直接说结论:Agent Skills 这个概念,正在把“做大模型应用”这件事从“炼丹”变成“搭积木”。我最近把几个线上项目的架构都往这个方向重构了一遍,最直观的感受是:以前调一个复杂 Agent 的行为,要靠改 System Prompt、调温度参数、甚至偷偷塞 few-shot 样例,改一次提心吊胆一次;现在把能力拆成一个个独立的 Skill,模型自己会根据任务去“挑选”技能来用,不仅可控性上来了,调试效率也高了一个量级。
这篇文章不打算讲太多理论,我尽量用做项目时踩过的坑、验证过的方法论,把 Agent Skills 从入门到精通的完整路径串一遍。不管你是刚听说这个概念想试试水,还是已经在项目里用了但总觉得“调用不稳定”,这篇文章应该都能给你一些参考。
1. Agent Skills 到底是什么:先搞清楚它解决什么问题
1.1 从“Prompt塞满”到“技能外挂”的转变
如果你做过稍微复杂点的 Agent 应用,大概率经历过这种场景:为了让模型学会调用某个内部接口,你把接口文档、参数说明、调用示例全写进 System Prompt 里。刚开始还挺好使,但随着接口越来越多,Prompt 越来越长,模型开始“选择性失明”——你说的话它记住了前半段,忘了后半段;你强调“必须调用工具”,它偏要凭记忆瞎编一个结果。
Agent Skills 的思路完全不一样。它不要求模型“记住”技能怎么用,而是把技能本身变成一个个独立的模块,每个模块自带完整的“使用说明书”和“执行代码”。模型在收到用户需求后,先看一遍当前环境里有哪些技能可用、每个技能是干什么的,然后像人在工具箱里挑扳手一样,选中合适的技能并传入参数,技能执行完把结果返回来,模型再基于结果继续推理。
这个转变解决了两个根本问题:
- 上下文碎片化:技能描述和实现细节不再挤占主上下文窗口,模型只需要知道“有这个技能、什么时候该用、参数怎么填”,具体逻辑在技能内部完成。
- 能力边界清晰:每个技能独立负责一块业务,改一个技能不影响其他技能,也不影响主模型的推理逻辑。这就像把一堆乱七八糟的电线分门别类扎好,哪根出问题直接换哪根,不用把整台机器拆了。
1.2 Skill 和日常说的插件、工具到底差在哪
很多人会把 Agent Skills 和“工具调用(Function Calling)”或者“插件(Plugin)”混在一起,它们确实有血缘关系,但本质上是不一样的东西。
工具调用是“单个函数”,你告诉模型有这样一个函数,模型自己决定调不调。它的特点是粒度小、逻辑简单,比如“计算两个日期差几天”“把文本翻译成英文”。
插件是“一组能力的集合”,通常是一整套接口的封装,比如一个支付插件包含创建订单、查询订单、发起退款等多个接口。
Agent Skill 则更偏向“一个完整的问题解决单元”。它不仅有执行代码,还包含:
- 技能本身的用途说明(什么时候该用,什么时候不该用)
- 参数的详细定义(类型、范围、单位、约束)
- 执行结果的返回格式与语义说明
- 可选的内部子流程(一个技能内部可以编排多个工具调用)
我用一个生活化的类比来解释:工具调用是一颗螺丝,插件是一盒螺丝套装,Agent Skill 是一台“拧螺丝机器人”——你告诉它“把A板和B板固定住”,它会自己判断用哪颗螺丝、用多大扭矩、拧几圈,然后把“已固定”的结果告诉你。
这个区别对架构设计的影响很大。工具调用时代,业务逻辑散落在模型和函数之间,你很难在“模型乱调用”时快速定位问题。而 Skills 时代,每个技能都是黑盒,输入输出都做了约定,边界清楚,出了问题只需要看是哪个技能执行失败,排查范围瞬间缩小。
2. 为什么“技能描述”比“技能代码”更影响成败
2.1 技能描述的本质是一份“给模型看的说明书”
我把大量精力花在写技能描述上之后才发现,真正决定一个技能好不好用的,往往不是代码写得有多优雅,而是描述写得有多清楚。
模型判断“该不该用这个技能”,靠的完全是技能描述里的文字。如果描述写得含糊,模型就会在边缘场景下反复犹豫——该用它的时候不用,不该用它的时候乱用。
我自己总结了一套写技能描述的公式,核心就三块:
- 触发条件:明确写出“当用户请求涉及什么内容时,必须使用此技能”
- 禁止条件:明确写出“当用户只是闲聊、不涉及实际执行时,不要调用此技能”
- 参数说明:明确写出每个参数的业务含义和取值规则
举一个我在项目中实际用过的例子。假设要做一个“查快递”的技能,低级版本的描述是这样写的:
查询快递物流信息。
模型看了这段描述,遇到“我的包裹到哪了”这种问题时大概率会调用,但如果用户说“我想知道快递一般多久能到”这种咨询类问题,模型也会去调用,然后返回一个不合适的结果。
我的改进版本是这样写的:
当用户提供快递单号或明确要求查询物流轨迹时使用此技能。如果用户只是在询问快递时效、费用或寄件规则等常识性问题,严禁调用此技能。查询时必填参数:tracking_number(快递单号,字符串类型,支持多家快递公司单号);可选参数:phone(收件人手机号后四位,用于部分快递公司身份校验,查询结果为“已签收”状态时强烈建议补充)。
加了“严禁”这个词之后,误调用率肉眼可见下降了。模型对否定性描述其实非常敏感,你用肯定句它不一定记住,但用否定句它往往记得很牢。
2.2 参数 Schema 怎么设计才不会被传错
技能描述解决的是“用不用”的问题,参数 Schema 解决的是“传得对不对”的问题。
我发现很多人在设计参数时有一个坏习惯:只在描述里用文字说明参数含义,Schema 里却写得很随意。比如描述里写着“phone 参数是手机号后四位”,Schema 里却没有 maxLength 约束,结果模型传了一个 11 位的完整手机号,技能内部一校验就崩了。
参数 Schema 设计有五个铁律,我建议直接照抄:
- 每个业务参数都必须有 description,description 里必须写明业务含义、单位、取值范围
- 字符串参数必须标注长度限制,能用 enum 约束的就用 enum 约束
- 数字参数必须标注最小值和最大值,避免模型传负数或天文数字
- 必填参数与可选参数要分开,对可选参数要明确写出“什么情况下才需要传”
- 日期、时间类参数要规定格式,是
YYYY-MM-DD还是时间戳,必须写死
举个例子,我之前做一个“航班查询”技能,参数 schema 里有个 depart_date,我第一次写的时候只写了"type": "string",模型经常传各种格式。后来我改成:
{ "depart_date": { "type": "string", "description": "出发日期,格式必须为 YYYY-MM-DD,例如 2025-03-15。只接受当天及未来 365 天内的日期,不接受过去日期。", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" } }加了正则之后,模型传参的格式错误率基本降到了零。你别觉得这是小概率事件,实际跑起来就知道,模型传参的“野路子”比你想象的多得多。
3. 核心执行链路拆解:从意图识别到结果返回
3.1 一次技能调用的完整生命周期
很多初学者有一个误解,觉得 Agent 调用技能就是“模型说调用就调用”,这一步到那一步之间没有中间环节。实际上,一次完整的技能调用链路比大部分人想的要长,我拆开来给你看:
- 意图识别阶段:用户输入消息后,模型先结合当前对话上下文,判断用户诉求是否匹配某个技能。这一步其实发生在模型内部,但它会综合技能的描述、参数说明、历史调用记录来决定。如果模型判断“这个请求应该用技能A”,它会生成一个结构化的调用请求,包含技能名和参数。
- 参数解析与校验阶段:平台或应用框架收到模型的调用请求后,先根据技能声明的参数 Schema 做一次校验。校验不通过有两种处理方式,一种是直接报错让模型重新生成,另一种是自动纠正(比如日期格式不对就尝试转换)。我建议第一版先做严格校验,宁可调用失败也不让脏数据进入业务逻辑。
- 技能执行阶段:技能本体开始运行。它可能是调用一个外部 HTTP 接口,可能是执行一段本地 Python 代码,也可能是编排多个内部子工具。执行过程中如果出错,需要把错误信息结构化地返回给模型,让模型知道发生了什么、能不能自己修正。
- 结果返回与模型整合阶段:技能执行完返回结果,模型读取结果,结合用户原问题,生成最终的自然语言回复。这一阶段常见的问题是“模型忽略技能返回结果,强行按自己的理解回答”,尤其当技能返回的结果是 JSON 格式、没有附带自然语言总结时,模型经常“揣着明白装糊涂”。
我单独拎出最后一阶段说一下,因为这是最多人踩坑的地方。技能返回结果,最好是“结构化数据 + 一句话人类可读总结”的组合。比如查询天气的技能,返回:
{ "success": true, "data": { "city": "上海", "temperature": 23, "condition": "多云" } }模型虽然能看懂 JSON,但它怎么组织语言、是否提取关键信息,是完全不受控的。所以我习惯在返回值里额外加一个summary字段:
{ "success": true, "data": { "city": "上海", "temperature": 23, "condition": "多云" }, "summary": "上海今天多云,气温23摄氏度。" }模型看到 summary 之后,直接基于这句话组织回复,准确性高很多。这个技巧屡试不爽。
3.2 长任务和异步处理的两种落地思路
技能执行并不总是秒回。调用外部 API 要等网络,生成图片要等算力,跑一段批量数据处理可能要几十秒。如果你的 Agent 平台不支持异步,用户就会看到模型“卡住”或者直接报超时。
处理长任务,业界目前主要就两条路:
- 同步超时降级:给技能调用设置一个较长的超时时间,比如 60 秒。如果超时,返回一个“任务仍在处理中”的状态,让模型告诉用户“稍后我再告诉你结果”。这种方式适合能在短时间内完成的任务,比如查数据库、调常规 API。
- 异步任务机制:技能执行后立刻返回一个 task_id,真正的处理在后台进行。Agent 通过轮询或回调通知的方式获取最终结果。这种方式适合耗时特别长的任务,比如批量图片处理、大文件解析。
我刚才做的一个“简历解析”技能就用了异步方案。简历上传后需要 20~40 秒才能完成解析,如果让用户干等,体验极差。我的做法是:技能先返回“已受理,正在处理中,任务编号 XXX”,同时把任务状态写入存储;主对话流程结束前提示用户“稍等片刻,我会主动告诉你解析结果”。后端解析完成后,通过回调把结果推回对话系统,系统主动发一条新消息给用户。
这里有一个坑要特别提醒:异步任务对“状态管理”的要求高很多。Agent 本身是“无状态”的,你必须在技能内部自己管理任务状态,至少包含待处理、处理中、成功、失败四个状态。千万别把状态放在模型上下文里,模型一旦忘记,整个任务就断了。
4. 从零搭一个“简历解析技能”的完整实操
4.1 前期准备:技能边界与输入输出约定
理论讲了半天,下面我拿一个完整案例,带你把一个真实技能从零到一搭建起来。我选“简历解析”这个场景,因为它足够典型:输入是“上传一份简历文件”,输出是“结构化的人才信息”,中间涉及文件解析、规则清洗、信息抽取多个环节,能比较好地展示技能的完整链路。
第一步是定义技能边界。一句话概括这个技能做什么:接收用户上传的简历文件(PDF、Word 或纯文本),解析出候选人姓名、联系方式、工作经历、教育经历、技能标签五类核心信息,以 JSON 格式返回。
同时要明确技能不做什么:不做岗位匹配、不做候选人评分、不做简历优劣判断——这些属于上层业务逻辑,如果混进技能内部,技能就失去复用价值了。
输入和输出的约定很重要,我在写代码之前先列了一张表:
| 输入项 | 类型 | 约束说明 |
|---|---|---|
| file_path | string | 文件路径,支持 pdf/docx/txt,文件大小不超过 10MB |
| file_name | string | 原始文件名,仅用于日志和错误提示 |
| parse_mode | string(可选) | strict(严格模式,必填项缺失则报错)/ normal(宽松模式,尽量解析) |
输出结构:
{ "candidate_name": "张三", "phone": "138****1234", "email": "zhangsan@example.com", "work_experience": [ { "company": "某科技有限公司", "position": "高级工程师", "start_date": "2020-06", "end_date": "2023-08", "description": "负责XX系统的架构设计与开发" } ], "education": [ { "school": "某大学", "degree": "本科", "major": "计算机科学与技术", "graduation_date": "2020-06" } ], "skills": ["Python", "Java", "MySQL"] }这一步看似繁琐,但它决定了后续所有代码写起来有多顺畅。输出结构清晰了,技能内部实现和模型的语言组织都会轻松很多。
4.2 技能代码与描述文件的具体实现
我用的技术栈是 Python,技能代码本身就是一个标准的 Python 函数入口,核心逻辑大概长这样:
import json import re def run_resume_parser(file_path: str, file_name: str, parse_mode: str = "normal") -> dict: result = { "success": False, "data": None, "summary": "", "error": None, } try: # 1. 文件类型判断与内容提取 if file_name.lower().endswith(".pdf"): text = extract_text_from_pdf(file_path) elif file_name.lower().endswith((".docx", ".doc")): text = extract_text_from_docx(file_path) elif file_name.lower().endswith(".txt"): with open(file_path, "r", encoding="utf-8") as f: text = f.read() else: raise ValueError(f"不支持的文件类型: {file_name}") # 2. 基础信息抽取(这里用正则+规则,也可以换成大模型抽取) candidate_name = extract_name(text) phone = extract_phone(text) email = extract_email(text) if parse_mode == "strict": if not candidate_name or not phone: raise ValueError("严格模式下必须解析出姓名和手机号") work_experience = extract_work_experience(text) education = extract_education(text) skills = extract_skills(text) result["success"] = True result["data"] = { "candidate_name": candidate_name, "phone": phone, "email": email, "work_experience": work_experience, "education": education, "skills": skills, } result["summary"] = f"简历解析完成,共识别到 {len(work_experience)} 段工作经历、{len(education)} 段教育经历、{len(skills)} 项技能。" except Exception as e: result["error"] = str(e) result["summary"] = f"简历解析失败:{str(e)}" return result这里的抽取规则部分我故意省略了细节,因为真实场景中你可能想用大模型来抽取而非正则,这取决于你的成本和精度要求。我的经验是:能确定性完成的用规则,语义理解类的用模型,混合方案效果最好。
技能描述文件同样重要。我给这个技能写的描述关键词是这样的:
当用户上传简历文件或要求解析简历内容时,必须使用此技能。技能接收 PDF、Word 或纯文本格式的简历文件,解析并返回候选人的基本信息、工作经历、教育背景和技能列表。如果用户只是询问简历怎么写、面试怎么准备等建议类问题,严禁调用此技能。注意:file_path 必须是有效文件路径,parse_mode 为可选参数,默认 normal。
4.3 联调与命中率验证的几条标准
技能开发完成后,联调阶段最容易掉以轻心。很多人本地测几个用例觉得没问题,一上线就翻车,原因是测试用例覆盖的场景太窄。
我给了自己一套联调验证清单:
- 显性触发测试:用户直接说“帮我把这份简历解析一下”,必须触发技能
- 隐性触发测试:用户说“这个候选人背景怎么样”,如果对话中只给了文件,也应该触发技能
- 边界拒绝测试:用户说“简历一般怎么写比较好”,绝对不允许触发技能
- 参数完整测试:只给了文件路径没给文件名,技能要能正确处理或友好报错
- 异常文件测试:上传一个损坏的 PDF,技能要返回可读的错误信息,而不是直接崩溃或返回空白
命中率的衡量指标,我建议看两个数:召回率(该触发时是否触发了)和精确率(不该触发时是否误触发)。理想状态是两者都接近 100%,但实际调参时你会发现它们是跷跷板。如果你只能取舍一个,我倾向于保召回率——技能被误触发,用户顶多觉得有点突兀;技能该触发却没触发,用户会觉得这产品是个废物。
5. 常见问题清单与排查技巧实录
5.1 模型死活不调用技能?先检查这两件事
遇到“模型就是不调用技能”的情况,90% 的问题出在以下两个地方:
第一,技能描述的触发条件写得不够明确。我见过很多人写“该技能可用于查询天气”,这种作为式描述模型理解起来非常费劲。正确写法是“当用户询问某地当前天气或未来天气时,必须使用此技能”。把触发场景具体化、命令化,命中率会高很多。
第二,模型被主提示词“带偏”了。如果你的 System Prompt 里写了“你是一个知识渊博的助手,可以根据自己的知识回答用户问题”,模型就会倾向于直接回答而不调用工具。你需要明确告诉模型:当用户的需求超出你的知识范围,或者环境中有可用的技能时,必须先考虑调用技能,而不是直接回答。这句话放在 System Prompt 和技能描述里都有效。
还有一个容易被忽略的点:模型可能会“连续多次不调用技能”后突然开始调用。很多 Agent 框架有重试机制,但重试只是重复发送同一个请求,没有改变模型的决策输入,所以重试多少次都没用。正确的做法是在重试时把技能描述再强调一遍,比如在重试的 prompt 里追加一句“请重点检查是否需要调用简历解析技能”。
5.2 参数传错、乱传?多半是 Schema 写得不够死
我在前面已经讲过 Schema 设计的基本规则,这里补充一个排查技巧。当你发现模型经常传错参数时,先不要急着骂模型,去把技能的调用日志翻出来看看——模型到底传了什么、为什么传成这样。
举个例子,我之前遇到一个“发送邮件”技能,模型总是把收件人地址误传成收件人姓名。后来我翻日志发现,问题出在我的描述里写了“必填参数:recipient(收件人)”,模型没理解“收件人”指的是邮箱地址,它认为填名字也行得通。我把描述改成“recipient(收件人邮箱地址,仅接受标准邮箱格式,例如 someone@example.com,不接受中文姓名)”,问题立刻消失。
参数校验环节还有个建议:不要只靠模型自觉,技能内部也要做二次防御。我的做法是写一个通用校验函数,在技能入口统一校验一遍参数,不合法直接返回错误,让模型自己修正后重新发起调用。即使平台侧已经做了一层校验,内部二次防御仍然值得,因为平台校验和实际业务校验经常不完全一致。
5.3 回调超时、状态丢失?不要让技能“有记忆”
异步任务最怕的就是状态丢失。我之前踩过一个坑:任务在后台处理了 80%,用户等不及了,刷新了一下页面,整个任务状态就没了。用户再问“解析好了吗”,Agent 一脸茫然地说“我没有找到您的任务”。
这个问题的根源在于我把任务状态存在了对话上下文的临时变量里,而对话上下文根本不是可靠存储。后来我改成把任务状态写入独立的存储服务,比如 Redis 或者数据库,key 是 task_id,value 是完整的任务状态 JSON。Agent 后续查询时,直接从存储里读,不再依赖模型“记得”这件事。
异步任务的另一个坑是回调结果返回给“谁”的问题。如果你的 Agent 应用是大模型直连,没有会话管理服务,回调结果没法主动推给用户。这个时候你需要一个轮询机制,让 Agent 定期查询任务状态,完成后再向用户汇报。轮询间隔我建议 5~10 秒一次,太频繁浪费资源,太稀疏用户体验差。
5.4 技能数量膨胀后怎么治理
技能这东西,做着做着就容易失控。我见过一个项目,刚上线时 8 个技能,三个月后膨胀到 40 多个,模型每次决策都要在一堆技能里翻找,调用准确率反而下降不少。
一个有效的治理手段是“技能分组(Skill Group)”。按业务域把技能分门别类,比如“人力域:简历解析技能、候选人查询技能”、“天气域:当前天气查询技能、未来天气预报技能”。模型先找组,再在组内找技能,决策路径短了很多。
另一个更硬核的治理思路是“技能下线审计”。每个技能上线时记一个“激活率”指标——它被模型调用的次数除以它被系统携带的次数。如果一个月激活率低于阈值,就说明这个技能是个“摆设”,要么是描述写得有问题,要么是根本没必要存在。该删就删,该合就合。
我做过一次大清理,把 35 个技能合并成 18 个,合并后整体命中率反而高了 7 个百分点。核心原因就是模型决策负担变轻了,虚假的“选项太多”反而让模型选择困难。
最后再分享一个小技巧
前面讲的都是方法和框架,最后说一个我在实际项目中反复受益的小习惯:给每一个技能准备一套“标准测试集”,每次修改技能代码或描述之后,用同一套测试问题跑一遍回归。看起来多花时间,但效果立竿见影——你不会在改完一个技能之后,隔三差五被用户反馈“这个技能以前能用,现在怎么抽风了”打得措手不及。
Agent Skills 这套玩法的精髓,说到底就是“把不可控的模型行为,变成可控的模块行为”。模型依然是那个会猜、会编、会犯迷糊的模型,但你可以通过精心设计的技能边界、描述和参数约束,把它不可控的部分圈在一个个盒子里。盒子外面,是稳定的业务逻辑;盒子里面,是模型自由发挥的空间。谁把盒子设计得好,谁就能在应用层真正驾驭大模型。