先交代一下背景。过去大半年我一直在做企业内部的知识库问答助手,最初它就是一个"能联网、能查库"的聊天机器人,但越往后越发现:真正让 Agent 变得可用的,不是接了多少个 API,而是你手上那套技能体系设计得有多干净。所谓的 agent-skills,不是给模型塞几十个接口,而是把能力拆成一个个带描述、带参数约束、带权限边界、带错误处理的行为单元。这篇文章把我自己在项目里从零搭建技能层的完整思路、踩过的坑和实测数据一起写出来,希望能帮到正在做类似架构的人。
1. 技能化:从"给Agent塞工具"到"给Agent立规矩"
1.1 一次失败的工具调用让我决定重做技能层
最早版本里,我们直接给 Agent 挂了 27 个函数,从查工单、读数据库、发通知到调用内部 API 全都有。结果线上跑了一个月,问题全暴露了:模型经常分不清"查询某个字段"和"导出全部字段"之间的差异,参数填错率高得离谱;更麻烦的是有些 API 是有副作用的,比如"发送通知"和"更新工单状态",模型在不该触发的时候触发了,用户直接被骚扰。那段时间我每天的工作就是看日志,靠手工在黑名单里加限制规则,越加越乱。
后来我意识到,问题不在模型的聪明程度,而在我们给模型提供的"能力描述"太粗糙。工具只有一个函数名和几行参数说明,模型只能靠猜。与其继续打补丁,不如把整个能力层推翻,做成一套真正意义上的"技能系统"。每个技能不再是一个孤零零的函数,而是一个完整的、自描述的、可编排的能力单元。
1.2 技能和工具到底差在哪
严格来说,工具(Tool)是技能的底层实现,技能是工具在 Agent 语境下的完整包装。我后来在团队内部定了一个标准:如果一样东西只有入参、出参、执行逻辑,它只能叫函数;只有当它同时具备名称、描述、参数约束、依赖声明、权限声明、超时策略、返回值规范、版本号时,它才配叫技能。
这个区分的价值在于:工具是给工程师调用的,技能是给模型"阅读和理解"的。代码里的函数名再清晰,对模型来说也只是字符串;但当它被包装成一段结构化的技能描述,模型就能像人看说明书一样,准确判断"这个场景该不该用你、用你传什么、用完你会给我什么"。我在实践中发现,同样的底层函数,从"裸工具"改造为"完整技能"之后,模型的动作选择准确率提升了非常明显,这个数据后面会详细讲。
1.3 给 Agent 立规矩的三个层次
技能化的本质是给 Agent 立规矩,这个规矩分三个层次:
- 能力边界:明确告诉 Agent 你"能做什么"。没注册的技能,模型在提示词里根本看不到,自然也不会调用,这比事后拦截靠谱得多。
- 行为方式:明确告诉 Agent 你"该怎么做"。技能描述里写清楚适用场景、不适用场景、典型用法,模型做决策时就不是掷骰子,而是有据可依。
- 后果控制:技能体声明了超时、重试、副作用权限,即使模型误判,执行层也会兜底,不会造成不可逆影响。
一个常见误区是,很多人觉得把技能描述写得越详细越好。其实不是,描述太多反而会稀释关键信息。我见过有的团队把技能描述写到 600 字,结果模型经常漏看关键的参数约束。正确的写法是像产品说明书——首句说清楚用途,中段给典型场景,末尾明确禁忌。下面这张表是我内部培训时常用的对比。
| 对比维度 | 裸工具(Tool) | 完整技能(Skill) |
|---|---|---|
| 核心内容 | 函数签名、参数列表 | 名称、描述、Schema、依赖、权限、超时等 |
| 面向对象 | 开发者 | 大模型 + 开发者 |
| 选择准确率 | 依赖参数命名巧合 | 依赖描述与场景匹配度 |
| 可治理性 | 基本不可控 | 权限、配额、审计都可控 |
| 可复用性 | 跨场景复用难 | 可组合、可编排、可共享 |
2. 技能注册协议:让模型一眼看懂的登记手册
2.1 注册清单里到底该放什么
我设计的技能注册清单,核心是下面这一组字段。每个字段都是经过线上问题反推之后保留的,缺一个都会在某个环节出问题。
{ "skill_id": "issue_label_helper", "version": "1.4.0", "name": "智能打标签助手", "description": "根据工单标题和正文的语义,为工单推荐3-5个分类标签。仅在需要对工单做文本分类时使用,不处理附件内容。", "input_schema": { "type": "object", "properties": { "title": { "type": "string", "description": "工单标题,不超过200字" }, "content": { "type": "string", "description": "工单正文,不超过8000字" } }, "required": ["title", "content"] }, "output_schema": { "type": "array", "items": {"type": "string"}, "description": "按置信度排序的标签列表,最多5个" }, "dependencies": ["auth_token_reader"], "timeout_ms": 3000, "permissions": ["read:ticket", "write:label"], "cost_hint": "low" }简单解释几个关键字段。description是给模型看的,重要性仅次于 skill_id,我单独在下一节讲。input_schema和output_schema是给模型看和给执行引擎校验用的,必须同时存在,很多团队只做输入校验,不约束输出,结果技能返回了模型看不懂的结构,还要模型"硬猜"。dependencies和permissions是给执行引擎做安全校验用的,避免一个技能悄悄读取它不该读的数据。cost_hint是我后来加的,告诉调度器这个技能是便宜的猜测还是昂贵的推理,方便做预算控制。
2.2 描述即路由:模型经常选错工具的真正原因
如果你发现模型总在错误的场景下调用某个工具,不要急着骂模型笨,先检查 description 写得怎么样。我有一次排查"模型该查知识库却去查工单系统"的问题,最后发现知识库工具的 description 里压根没写"用户提问的业务知识问题优先使用本工具",而工单工具的 description 里写了一句"可以同时查询与工单相关的知识",就这多出来的一句话导致模型几乎每次都误选。
现在我的团队对 description 有硬性要求,按这个模板写:
- 第一句:这个技能完成什么任务,务必包含明确的动词和名词。
- 第二句:在什么场景下优先使用。
- 第三句:什么场景下不要使用。
- 第四句:特殊注意事项(比如"仅处理文本,不解析附件"、"返回结果非实时,可能有 5 分钟延迟")。
举一个改前改后的例子:
改前:获取热点资讯数据。
改后:根据用户输入的主题词,从资讯平台获取最近 24 小时内的热点新闻列表,返回标题、来源和发布时间。当用户询问"最近有什么热点""今天发生了什么大事"时优先使用。如果用户需要的是财经行情类数据,不要使用本技能。
改完之后,同样的场景下,选错率直接下降了一大截。这说明模型不是不会选,而是你给的信息不足以让它做出正确的路由决策。
2.3 参数 Schema 的博弈:填错参数本质上是一场沟通失败
参数 Schema 是另一个重灾区。我见过很多人把属性名写得和内部数据库字段一样,比如tkt_cust_id,模型根本不理解这是什么,结果就是反复填错、反复校验失败、反复重试。
参数设计有一条原则:属性名和描述都站在"发起人的视角"写,而不是站在"数据库视角"写。比如查工单,参数customer_id的描述不要写"数据库外键 22",而要写"客户唯一编号,用户在个人中心可以看到,通常是一串 10 位数字"。模型看到这个描述就知道该填什么了。
另外,要到把required字段降到最少。每多加一个必填参数,模型填错的概率就高一分。非关键参数设为可选,并且提供默认值。我在技能执行引擎里做了一个自动补值模块,如果模型没有传某个可选参数,就从default字段取值,或者从上下文里自动抽取。这一步能把一次技能调用的失败率降低不少。
这里还要说一个容易忽略的点:返回值的结构也要让模型能看懂。很多技能返回一堆嵌套 JSON,字段名混乱,模型解析起来费劲,后续推理的输出质量就下降。我在技能执行引擎里加了一个result_summarizer,每次技能返回后,不是把原始 JSON 直接丢给模型,而是先经过一层摘要,只保留模型真正需要的关键信息,这样既能省 token,又能减少模型被无效信息带偏的概率。
3. 执行引擎与 ReAct 循环:一次技能调用的完整生命周期
3.1 模型、调度器、技能执行器三者如何协作
光有注册表还不够,技能要跑起来,必须有一个可靠的对齐循环。我们用的是经典的 ReAct 模式的改良版,但相比原始论文里的循环,我在中间加了一个专门的调度器和校验器,防止模型在 loop 里面来回折腾。
整个执行流程大致是这样的:
while steps < max_steps and not converged: prompt = build_prompt(state, skill_registry.list_all_descriptions()) decision = llm.complete(prompt) if decision.type == "call_skill": skill = skill_registry.get(decision.skill_id) if skill is None: state.add_observation("错误:所选技能不存在,请从列表中选择") continue payload, error = validate_arguments(decision.arguments, skill.input_schema) if error: state.add_observation(f"参数校验失败:{error},请修正后重试") continue if not check_permission(skill.permissions, context.actor): state.add_observation("错误:当前用户无权限调用该技能") break result = executor.run(skill, payload, trace_id=trace_id) state.add_observation(format_observation(skill, result)) elif decision.type == "final_answer": return decision.answer else: state.add_observation("错误:无法理解你的指令,请重新明确意图")这个循环里最关键的三个设计是:注册表驱动提示词、参数校验前置拦截、结果摘要回填。模型看到的技能列表不是写死在提示词里的,而是根据当前会话上下文做一次粗筛后的子集,避免几百个技能描述一次性全塞进去。参数校验在前置层就拦截错误,而不是让技能内部抛异常,这样模型收到的错误信息更清晰,下次重试时也更可能改正。
3.2 超时、重试与最隐蔽的副作用
技能执行一定会遇到超时。我一开始只设了全局 10 秒超时,后来发现问题大了:某个技能超时,Agent 会判定为"此路不通",转而尝试别的路径,但那个"超时"的技能其实已经在后台执行成功了,这就导致了重复操作和脏数据。
后来我在技能层做了一个双状态机制:每个技能执行时,状态分pending(已下发)、running(执行中)、succeeded(成功)、failed(失败)、timeout_unknown(超时但结果未知)。对于timeout_unknown,不允许引擎直接重试,除非技能自己声明了幂等(idempotent: true),否则必须先调用cancel接口或在业务上做好幂等校验,再决定是否重试。
我强烈建议每一个技能在注册时都要明确回答:这个技能的操作可不可以重复执行第二次?查询类技能天然幂等,发通知、改状态这类技能就不是。我在技能注册表里专门加了一个execution_side_effect字段,配合timeout_unknown状态,能让 Agent 在超时后做出更合理的决策,而不是傻傻地再调一次。
3.3 Token 预算:让 Agent 在断电之前回来
ReAct 循环一个容易被低估的问题是 token 失控。一次复杂的任务可能要跑十几轮,每轮都有技能描述、参数、结果摘要、中间推理,累加起来很容易把上下文撑爆。我们刚开始内测时,有用户反馈说"Agent 聊着聊着就开始胡说八道",查日志发现就是上下文太长,早期的关键信息被挤掉了。
解决思路是做一个三级预算:单轮调用预算、单技能结果摘要预算、整体会话预算。每轮的决策 prompt 只放当轮最必要的技能描述(控制在 1500 token 内),技能返回结果后立即压缩为摘要(控制在 500 token 内),整体会话超过阈值就提示用户开启新会话或对历史做摘要合并。
这一层做好之后,不仅模型吐字稳定了,响应速度也明显提升。因为上下文短了,每次推理的耗时也就短了,这对用户体验的影响是立竿见影的。
4. 技能编排:把单点能力串成业务闭环
4.1 编排层如何把流程变清晰
单技能解决的是"某个动作"的问题,但真实业务往往需要连续好几个动作。比如一个"新工单自动处置"的场景,要先识别工单分类,再搜索历史相似方案,再生成回复草稿,最后视严重程度决定要不要通知相关负责人。如果让 Agent 自由发挥,它可以靠循环把技能一个个调完,但问题是自由发挥不可控——可能中间某个环节漏掉了,可能顺序颠倒了,也可能在没必要的地方多调了技能。
所以我在技能层之上又加了一层轻量的编排层,用一个简单的流程图描述语言把技能的调用顺序、分支条件和数据依赖显式声明出来。
flow = SkillFlow( name="新工单自动处置", steps=[ Step("classify", skill="issue_classifier"), Step("search", skill="knowledge_search", if_="classify.severity == 'P1'"), Step("draft", skill="reply_generator", using=["classify", "search"]), Step("notify", skill="im_notifier", if_="classify.severity in ('P1', 'P2')"), ], )这种编排方式最大的好处是:技能依然是原子的、可复用的,但技能之间的连接关系由编排层控制,不用每次都在提示词里解释"先做什么再做什么"。Agent 只需要在编排层给定的大框架里做局部决策,比如确认某个 if 条件是否成立、选择某个步骤的输入参数,这大大降低了决策难度。
4.2 技能内部的技能复用
有时候,"一个大技能"内部天然是多个小技能的组合。比如"跨部门周报生成"这个技能,内部要先拉取 git 提交记录、再查项目管理系统里的任务进度、最后把两份数据合并渲染成 Markdown。如果把这个大技能做成一个无法拆分的大块头,它的复用性就差,而且一旦某个中间环节变化,整个技能都要改。
我的做法是:大技能作为"组合技能"存在,内部通过子技能调用(sub-skill invocation)串联。组合技能在注册时声明它依赖哪些子技能,执行引擎在调度到组合技能时,会自动把它的执行栈推入子技能执行栈,每个子技能内部依然有独立的超时和错误处理。
这有点像写程序时的函数封装:底层是原子技能,上层是组合技能,组合技能可以被更高的流程编排再次引用。层次清晰之后,每层都能独立测试、独立替换,不会出现"动一个参数就牵连全局"的尴尬。
4.3 上下文传递:每个技能只认自己该认的
技能编排里最容易出问题的,是上一技能的输出怎么变成下一技能的输入。一开始为了实现简单,我会把完整上下文传给所有技能,让模型自己去挑。结果经常发生这种情况:技能 A 输出里有一个"数量"字段,技能 B 需要的是"总量",模型看着差不多的语义就填了,实际业务上差之毫厘谬以千里。
后来我强制执行了一件事:每个技能只能从编排上下文里读取它依赖的那个键,不得读取全局上下文。编排层在生成下一个技能的参数时,先按照using声明把前序输出里对应的字段"翻译"成新的参数。这样技能之间是解耦的,上下文不会无限膨胀,而且每个技能的执行结果都更可预测。
在这个基础上,我还加了一层"字段来源追踪",记录每个技能的输入参数来自于哪个上游输出的哪个字段。调试时能看到完整的数据血缘,线上出问题排查起来非常省事,这也是我最舍不得砍掉的一个功能。
5. 实测数据与踩坑复盘:哪些设计让我睡不好觉
5.1 工具收敛后,效果反而提升了
写了这么多设计理念,再说说实际项目里的数据。我们把原来裸暴露给 Agent 的 27 个工具收敛成 14 个技能(其中 9 个原子技能、5 个组合技能),同时补全了描述和参数 Schema。在同样的 200 条真实工单测试集上,效果变化非常明显:
| 指标 | 改造前(裸工具) | 改造后(技能化) |
|---|---|---|
| 动作选择准确率(选对工具/技能) | 68.5% | 91.0% |
| 参数填写的首次校验通过率 | 42.0% | 76.5% |
| 单任务平均工具/技能调用次数 | 4.8 次 | 2.9 次 |
| 需要人工干预的比例 | 22.0% | 9.5% |
可以看出,收敛技能数量并没有让 Agent "变笨",反而因为每个技能都描述得足够清晰,模型一次就能做出正确选择,不需要反复试错。参数校验通过率提升更是立竿见影,这直接省掉了大量无效调用和 token 消耗。
5.2 五个最值钱的坑,每一个都踩过
第一个坑是"描述里用了不一致的动词"。比如同一个动作,在技能 A 的描述里叫"获取",在技能 B 里叫"获取到",模型对这两个词的理解权重是不一样的,可能导致在两个相似技能之间随机摇摆。后来我们引入了"同义动作归一化":所有技能描述里描述同一类动作的词必须统一,并且由代码自动检查。
第二个坑是"技能粒度太粗"。一开始有个"处理工单"技能,包含修改状态、分配负责人、追加备注、关闭工单四件事,参数 Schema 有十来个字段,模型根本填不全。后来拆成四个独立技能,每个技能的参数都降到 3 到 5 个,准确率立刻上来了。技能粒度以"一个动作只做一件事"为原则,宁多勿粗。
第三个坑是"技能描述里有绝对化用语"。比如描述里写"获取当前用户的所有信息",模型就真的以为能拿到所有字段,其实技能只返回了有限字段。后来我们改用工整的枚举说明,写清楚"返回字段包括 A、B、C,不包含 D"。
第四个坑是"重试导致的重复副作用"。这个前面提到过,超时后如果无脑重试,发通知、改状态这类技能就会执行两次。深入了解后我们规定所有非幂等技能必须配合业务侧的唯一请求 ID 做去重,否则不允许打开自动重试。
第五个坑是"技能评估只看成功不看过拟合"。我们最初的人工评估只统计"技能是否成功调用",后来发现有些案例虽然调用成功了,但 Agent 选的是次优技能,结果也是错的。从那时起,评估集从二元通过制改成了三级打分制:正确技能且正确参数、正确技能但错误参数、错误技能。这个调整让我们在后续优化中能更精准地定位问题在"路由"还是"填参"。
5.3 评估集:让每一次技能改进都可量化
技能系统的迭代非常依赖评估集。我整理了一套轻量但有效的评估流程,不必引入复杂的评测框架,只需要几个关键部分:
- 场景语料库:把线上的真实用户问题按业务场景分桶,每个桶至少 30 条,覆盖常见诉求、模糊表述、边界场景。
- 期望动作集:每条语料标注期望调用的技能链和关键参数,人工标定一次,之后每次改动都能自动比对。
- 回归机制:每改技能描述或参数 Schema,就全量跑一遍语料库,产出准确率变化报告。
- 线上抽检闭环:线上日志中随机抽取 10% 的会话做人工抽检,标注"Agent 是否选了正确技能,是否填了正确参数",每周汇总。
这套流程跑下来,最大的价值不是发现 bug,而是能说服团队里每一个人"这次的改动到底有没有用"。避免"我感觉好像变好了"这种无法验证的优化,一切靠数据说话。
6. 技能共享与多 Agent 协作:下一步我要做的事
6.1 技能仓库:让跨团队复用成为可能
技能系统成熟之后,我下一步想做的是技能仓库(skill registry hub)。同一个公司内部,不同业务线的 Agent 往往有很多相似技能,比如"获取用户信息""发送通知""查询订单状态"。如果每个团队各自实现一套,描述风格、参数 Schema、权限模型全都不一样,统一治理就无从谈起。
我目前正在验证一种组织方式:把技能仓库分成基础技能库(company-level)和业务技能库(team-level)。基础技能由平台团队统一维护,对所有 Agent 开放;业务技能由各业务团队自行维护,注册时声明可见范围。这样既保证了核心能力的统一描述,又允许业务侧灵活扩展。
技能一旦进入共享仓库,就要有明确的版本管理和下线机制。我的思路是用语义化版本号,每个技能发布后不可变,变更必须生成新版本并在描述里注明变更点。Agent 默认绑定主版本,explicitly 标注测试版本时才会用 beta channel,这样不会因为某个技能更新引发全线故障。
6.2 多 Agent 协作时的技能目录
另一个值得深入的方向是多 Agent 协作。过去我们是一个 Agent 干所有事,技能一多,提示词就会被撑爆。更好的做法是:多个专业 Agent 共享一个技能目录,每个 Agent 只加载与自己职责相关的技能子集。比如一个客服主 Agent,它不需要知道"导出财务报表"这个技能的存在;而一个数据分析 Agent,也不需要关心"修改工单紧急度"这类技能。
这种场景下,技能目录需要支持按标签检索和按 Agent 角色过滤。我在技能注册表里加了一个visible_to_roles字段,执行引擎在构建提示词时会根据当前 Agent 的 role 过滤技能列表。这样不仅上下文更精简,权限也更安全——每个 Agent 只能看到它该看到的技能,出问题的概率自然就小了。
关于多 Agent 之间的技能调用,我发现一个值得注意的细节:跨 Agent 的技能调用不应该直接暴露给模型自由路由,而是应该通过明确的协作协议(比如主 Agent 向副 Agent 发送"任务描述",副 Agent 自己决定用哪个技能完成),否则模型容易在两个 Agent 之间来回踢皮球。这个经验让我很受用,也推荐给准备做多 Agent 协作的团队。
最后分享一个我个人的小经验。如果你现在也在做 Agent 技能化,不用一上来就设计一个大而全的技能规范,这样反而容易陷入过度设计。我的建议是:先挑三个你最头疼的"裸工具",把它们完整包装成技能,跑通注册、调度、校验、摘要、评估这条链路,然后再扩展。技能系统是在真实问题的反复捶打中长出来的,不是一次画图能画出来的。踩过坑,才知道哪些字段是真的需要,哪些只是看起来很美好。