大家都知道,这两年做大模型应用,怎么都绕不开“智能体”这三个字。我最近在重构一个多智能体协作项目,折腾了一圈,最后发现整个系统的地基不在模型选型,也不在Prompt技巧,而在一个特别容易被低估的模块——Agent Skills。说白了,就是怎么把智能体的一套套能力抽象成可复用、可插拔、可独立测试的技能包。如果你也在做Agent应用,或者正准备把一堆散装工具调用收敛成体系,这篇文章的内容应该能帮你在买过的坑上少踩几次。我尽量少讲空话,多放能直接用的东西。
为什么这个点值得单独拿出来写?因为很多项目一开始都是“万能Agent”的思路:一个超长系统提示词,把所有工具说明、调用规则全部塞进去,指望模型自己理会。前期Demo确实跑得通,但一旦技能数量超过十个、场景超过三个,Prompt就会失控。要么上下文爆掉,要么工具选择越来越不准,要么工程师根本不敢动那个五千字的系统提示。我前后试过好几种方案,最后落地的这套“技能化”设计,核心就一句话:把每个能力做成一个独立技能包,由路由层决定什么时候用哪个,而不是把所有东西都焊在Agent脑子里。
1. 为什么要做技能化改造:从“一股脑提示词”到“可复用技能包”
1.1 单体Prompt的痛点和失控现场
先还原一个典型崩溃场景。我早期的一个项目里,Agent需要做的事情包括查天气、算日期、调数据库、发邮件、写周报摘要。当时的实现方式,是在系统提示词里把五个工具的长描述、参数说明、使用示例全部写进去,每个工具写了两百多字。跑起来之后问题接踵而至:
第一个问题是token被工具描述大量吞噬。五个工具还算好,等工具数量涨到十几个,光工具描述就占了系统提示词将近两千token,留给真正任务描述的份额被严重挤压。模型在长上下文下对中段位置的注意力本来就弱,工具描述放在中间,经常出现该调用的工具没看到、不该调用的瞎调。
第二个问题是修改一个工具要碰全局。有一次我只是改了一个字段的枚举值,结果动了整段提示词,其他工具的描述因为重排被连带调整,当天线上出现了选错技能的回归问题。这种牵一发动全身的维护方式,在技能少时还能忍,技能一多就是要命。
第三个问题最隐蔽,模型会把相似的技能描述搞混。工具的英文名和功能关键词如果太接近,比如“get_user_info”和“get_user_profile”,模型在路有时就随机挑一个,返回的数据结构对不上,下游直接报错。
1.2 Skill是什么:不是Function Calling,也不是Plugin
要先做一个概念厘清,很多人把Agent Skill跟Function Calling或者Plugin搞混,然后依葫芦画瓢,结果做出来的东西既不是技能,也不是工具,两头不靠。
Function Calling本质上是一种模型交互协议,它让模型学会输出结构化的函数调用指令,但每个函数之间是平的,没有语义边界,也没有可组合性。Plugin则是更粗粒度的应用扩展,往往包含完整的前后端逻辑,体积大、耦合重,不适合作为Agent内部的能力单元。
我理解的Skill,是介于两者之间的一种“能力积木”。一个完整的Skill包,至少要包含四部分:能力描述(什么时候用、解决什么问题)、入参定义(结构化参数约束)、执行逻辑(实际干活的核心代码或API调用)、测试用例(验证技能行为和边界)。这跟传统软件工程里的模块化是一个逻辑,但比模块化多了一层面向LLM的语义描述。也就是说,Skill不仅要能跑,还要能被模型准确判断“何时该用它”。
我用一个生活化类比帮助团队理解:如果Agent是一个厨师,Function Calling是砧板上的菜刀、锅铲,Plugin是一整个整体厨房,而Skill则是“一个会做番茄炒蛋的副厨”。他会判断客人点的是不是这道菜,知道需要哪些原料,能独立端出成品,出了问题还能单独考核他。
1.3 技能化的三层收益
把整个系统切分为技能包之后,肉眼可见的收益有三个方面。
可独立测试是第一层。以前改一段Prompt,只能靠端到端跑一遍业务通不通,没法局部验证。技能化之后,每个Skill拥有自己的测试集,我改完“计算差旅报销总额”这个技能,就把它的二十条测试用例跑一遍,绿了再更新到Agent,回归成本降了一个数量级。
按需加载是第二层。系统根据用户任务关键词,先从技能注册表里召回相关的三五个技能,再把它们的描述注入上下文。这样Agent脑子里的负担从全量技能变成了当次任务需要的一小撮,错误率和token消耗同时下降。我拿真实场景测过,上下文节省了约40%,路由准确率从72%提升到了94%。
协作边界清晰是第三层。多Agent系统最烦互相踩脚,技能化之后每个技能有明确的“Owner”,谁负责哪个能力写得清清楚楚,技能改动通过版本管理流转,冲突大幅减少。这个收益前期不明显,等到团队从一个人变成几个人同时提交代码时,你就知道有多重要了。
2. 技能定义与目录结构:把能力写进Manifest
2.1 一个技能包的最小结构
实操层面,一个标准化技能包建议长这样:
skills/ resolve_dispute/ # 技能名:kebab-case,动词开头 SKILL.md # 能力描述、使用场景、触发条件 schema.json # 入参JSON Schema runner.py # 执行逻辑(或调用外部API) tests/ cases.json # 典型输入输出对 edge_cases.json # 边界与异常用例 version.txt # 语义化版本,如 1.3.0这个结构不复杂,但每一块都有讲究。SKILL.md是给LLM看的,所以它的写法跟普通开发文档完全不同。schema.json是给校验器看的,必须严格。runner.py是真正干活的,要稳定、要快。tests则是用来兜底的,没有测试的技能包我不允许合入主分支。
技能名有一个很容易犯的错:用名词短语而不是动词短语。比如“customer_refund”就不如“process_refund”清楚。因为模型在生成调用时,动作性名词的意图指向性更强,可以降低误召回。这个细节我是在一个命名混乱的技能库里吃了亏才总结出来的,当时同一批技能里既有“refund_handler”又有“process_refund”,模型被拐到沟里很多次。
2.2 描述词怎么写才不影响路由准确率
SKILL.md的灵魂是描述部分。描述写得好不好,直接决定模型能不能在关键时刻想起它。我总结了几个核心原则。
第一,必须有“该做什么,不该做什么”。很多描述只写正面能力,不写边界,结果模型把不太合适的任务硬套给技能。比如“处理退款”这种描述太宽,模型遇到买家咨询退款流程时也可能调用它,其实这时候该走FAQ技能。后来我改成:“处理退款:当用户明确提出要退款、已购买需取消并返款时使用。仅处理退款执行本身,不处理退款政策咨询、不处理退货物流。” 加了这半句边界拦截,误调用率降了一半以上。
第二,要有触发关键词和反例。SKILL.md里我习惯专门写一块“Triggers”,列出典型的触发短句,再写一块“Do NOT use when”,列出混淆场景。这等于是在给模型做短路拦截。
第三,优先级说明。有些技能边界天然重叠,比如“查物流”和“查订单详情”。描述里最好写一句“如果用户同时需要物流和商品信息,优先使用查订单详情,由该技能内部分步调用”。这能避免两个技能被同时选中,产生冗余调用。
实操中,描述的长度控制在150到300字之间比较合适。太短说明不了边界,太长占用token又稀释注意力。写完描述后,我会做一个小测试:把描述丢给另一个模型,让它判断几个模糊case该不该用这个技能,准确率低于九成就继续改措辞。
2.3 入参定义:JSON Schema的克制与细节
schema.json是执行成功率的另一个主要因素。这不是理论问题,我见过太多因为Schema设计不合理导致的线上故障。
首要原则是“够用就好”。新手最喜欢加各种非必填字段,例如给一个查询技能加上分页参数、排序方式、时区选项,铺开十几个字段。表面上看灵活性很高,实际上模型在补齐这些非必填字段时很容易编造值,尤其是那些没给出的字段,模型会凭空生成一个“合理”的默认值,导致真实逻辑判断错乱。我的建议是:只在业务真正需要时增加字段,否则全部砍掉。
第二个原则是描述必须可理解。JSON Schema里每个字段的description不是写给人看的注释,而是写给模型看的引导。字段描述里要带上取值范围或常见样例,比如:
{ "type": "object", "properties": { "dispute_reason": { "type": "string", "enum": ["quality", "delivery", "service"], "description": "争议原因分类,用户未明确时可选service" }, "order_id": { "type": "string", "description": "订单编号,格式为17位字母数字混合" } }, "required": ["order_id"], "additionalProperties": false }上面这个Schema里,“用户未明确时可选service”这句话就属于面向模型的默认值引导。它可以有效避免模型在缺参时随意编一个不在枚举里的值。实测加了这类提示后,Schema校验失败次数减少了大概六成。
第三个原则是不要滥用anyOf和oneOf。复杂联合类型在理论上很强,但模型输出时很容易挑错分支,导致校验不通过。如果业务条件太多,宁可拆成两个技能,让路由层去分流,也不要在一个技能里搞“变体”式入参。
2.4 示例与测试用例:技能质量的压舱石
技能包里tests/cases.json的价值,在我重构的初期被严重低估。那时候忙着写代码,测试用例随便塞了几个正常输入就完事。后来在一次大版本升级中改了一个技能的执行逻辑,结果线上暴露了一个隐藏了几个月的参数边界问题。从那以后,我把测试集当作技能包的一半工作量来对待。
cases.json里我会放三类用例:
- 正常用例:覆盖技能设计时预期的主要场景,数量不少于十条;
- 边界用例:参数边界、空输入、极大极小值、枚举边缘;
- 异常用例:缺参、类型错误、外部服务超时等情况下,技能必须走兜底逻辑而不是抛出未经处理的异常。
这里有一个特别容易忽略的点:测试用例里必须记录“期望输出”,而不是只记录“输入”。因为技能包的价值在于可回归,没有期望输出,后续改代码时没法判断行为是否发生变化。期望输出也不一定要求字节级一致,一些场景只要求结构正确,一些场景要求数值偏差在阈值内,这些都写清楚,回归时自动判定。
另外,我建议把每一条测试用例的输入输出对也喂给模型做“脱敏练习”。什么意思呢?就是拿这些真实输入输出去验证LLM是否理解了技能的触发条件。如果模型在一个明显该触发的输入下没有选这个技能,说明SKILL.md的描述还有问题。这一步相当于拿测试集反向校准描述,效果比人工一遍遍念描述好得多。
3. 技能编排与工具调度:让Agent真正“会用”技能
3.1 路由选择:LLM选技能,还是规则选技能
技能包定义好之后,核心问题就变成了:Agent在任务处理时,到底怎么决定调用哪个技能?纯靠LLM拍脑袋非常危险,纯靠规则又僵硬。我在项目中用的是混合路由,按任务复杂性分层。
第一层是规则预筛。如果用户请求里出现了明确的高置信度关键词,例如“退款”“退货”,直接命中对应技能,不走模型。这个层我用的是正则加一个小的关键词映射表,成本低、速度快,还能拦截一大半常规请求。
第二层是嵌入召回。把用户输入转成语义向量,同时把所有SKILL.md的描述也转成语义向量,做相似度Top-K召回。通常取前五个,这一步会把技能空间从几十个缩小到五六个。关键是嵌入模型要跟Agent主模型分开,我的经验是嵌入模型不需要很强,通用句向量模型就够了,但描述文本要干净,不能有太多噪声。
第三层才是LLM精排。把预筛和召回的结果连同各自的SKILL.md全文塞给模型,让模型做最终判断。这里有个细节,不能只给技能名和一句话摘要,必须给完整描述,否则模型还是会凭名字猜。
这套混合路由上线后,最明显的变化是令牌消耗大幅下降、响应更快。因为LLM每次只需要在五个候选项里做选择,而不是在几十个技能里“大海捞针”。
3.2 上下文注入与技能热插拔
技能化带来的另一个实际操作重点是怎么管理上下文。早期我把所有技能的描述常驻在系统提示词里,后来改成按需加载后,发现每次请求都要组装一套上下文,如果不做缓存,接口耗时反而会增加。
我的方案是把技能包的“元信息”常驻在一个轻量级索引里,包括技能名、入口摘要、版本号、召回向量。完整的SKILL.md只在被召回后注入。这样大部分请求走的是“索引层 + 召回层”,重上下文只在确定使用某个技能时才出现。
还有一个容易被忽视的问题:技能上下文的排列顺序会影响模型判断。我不止一次发现,同样的候选项,排列不同,模型选择结果就差很多。后来定了一个固定策略:按“预筛命中 > 向量相似度 > 技能字典序”三级排序。这样同样输入每次都会得到同样的候选顺序,问题排查时可复现性一下子高了很多。
热插拔则是技能编排的自然延伸。我把每个技能包做成了独立的可加载单元,用一个registry维护“已启用”和“已下线”的状态,改技能的某个字段,只需要在registry里更新元信息,不需要重启Agent主服务。这个模式对快速迭代很重要,因为技能调整的频率远比想象中高。
3.3 动态技能组合:从串联到并联
单技能能解决前半段问题,但真实业务场景里,用户的一次请求经常涉及好几个能力。比如“查一下我前几天的订单物流,然后给我生成一个周报摘要”这个请求,至少要拆成“查历史订单”“查物流信息”“生成周报摘要”三个技能,而且有明显的先后依赖。
我踩过的坑是让Agent在一个循环里反复自主决定下一步调用谁。这种纯LLM驱动的方式,在多步任务里很容易迷路:模型可能在某一步产生了幻觉,把不存在的参数传给下一个技能,导致下游开始连锁报错。
后来我改成“规划器 + 执行器”分离的结构。规划器先根据用户需求,把任务拆成一个有向图:哪些技能可以并行,哪些技能必须等前置结果。并行部分,我设计了一个简易的序列执行框架,技能之间只通过参数结果传递数据,不共享任何内部状态。这个改动带来的直接收益是,多步骤任务的失败率显著降低,原因在于每一步的输入输出都有明确的校验,模型没有机会在一个长上下文里“自由发挥”从而跑偏。
组合策略里还有一个实用技巧:把“编排逻辑”本身也抽成一个技能。比如“生成周报”是一个上层技能,它内部会调用“查询数据”“汇总格式”“发送邮件”三个子技能。这种元技能的粒度比原子技能大,但描述写得足够清晰时,模型能更好地理解任务目标,而不是陷入一堆细碎工具里。
3.4 调用隔离与权限边界
技能化之后,如果不限制技能之间的访问边界,系统会变得危险。比如一个“文本格式化”技能理论上不应该有权限调用“删除数据库记录”技能。但在一个全等Agent模型里,模型如果同时看到这两种能力,存在误调用的可能性,必须从机制上挡掉。
我在技能编排层加了一个依赖白名单:每个技能包里可以声明“depends_on”,列出它能调用的其他技能。执行器在运行时校验依赖关系,不在白名单里的跨技能调用直接拒绝,事件记录到审计日志。这个机制成本低,却让系统在能力和安全之间找到了一个很好的平衡点。
白名单的另一层作用是防止死循环。技能之间如果存在双向依赖,比如A可以调B、B也能调A,一旦模型陷入循环调用,会把上下文撑爆。我在manifest里加了一个禁止环检测,构建技能图时如果发现环就拒绝加载,并报出涉及的技能名。从模型策略上,我也给执行器设了调用深度上限,满了就强制结束,返回部分结果和一个“任务未完全执行”的标记。这对用户透明度很重要,不要让用户以为任务完成了,实际上半路被掐断了。
4. 错误处理与调试实录:花钱买来的避坑清单
4.1 高频错误:Schema验证失败和工具返回值被截断
技能包上线后,我整理过一份线上错误日志,出现频率最高的是两类问题。
第一类是Schema验证失败。用户输入经过模型转成结构化参数时,经常会发生值不在枚举里、类型不匹配、缺少必填字段等情况。这类问题的根源往往不在模型本身,而在Schema设计。比如我把某个字段的类型设置成integer,但模型的输出却给了字符串"123",其实能自动转换,早期我却在验证层直接报错。后面我加了一个宽松转换层:数字字符串转数字、大小写归一化、空白字符去除。做了这个宽松处理之后,验证失败率直线下降。宽松转换不能滥用,只用于明显可无损归一化的情况,否则会掩盖模型输出错误。
第二类是技能返回结果过大被上下文截断。某个查询技能一次返回了几千行数据,导致后续模型处理时上下文溢出。这个问题的解法是在技能内部做结果压缩:能聚合的先聚合,不能聚合的截断到前N条,并附加summary字段说明总条数和截断情况。这样模型拿到的是有结构的摘要,而不是一整块原始数据。现在我的所有技能返回值都约定一个上限,默认50条,超出部分只给summary。
4.2 调试三板斧:日志、回放、最小复现
技能系统的问题复现比普通后端服务难,因为同样的用户输入,模型输出可能有随机性。调试了很久之后,我总结出一个相对有效的三板斧方法。
第一板斧是日志,全链路日志是标配。每个技能调用前后都输出一行结构化日志:请求ID、命中技能、候选列表、模型返回参数、实际执行结果、耗时、token消耗。这里有一条经验:候选列表一定要记,很多问题都是“模型本不该选它却选了”,不记候选列表就无法还原这个决策过程。
第二板斧是回放。日志有了之后,回放就是重放同一条请求的日志链路,观察每个步骤的输入输出。我写了一个小工具,可以把一条用户请求的整个日志链路以时间线和调用树的方式渲染出来。排查一次故障的时间,从小时级压缩到了十几分钟。
第三板斧是最小复现。光看日志还不够,遇到诡异问题我会把日志里的某一步抽出来,做最小化的复现验证。比如这一路参数“为什么会生成”:直接把上一步的输出保存下来,重放给模型,用同一个技能包做单测,看能否复现。能复现就继续缩小范围,不能复现则考虑接入随机性问题,调低温度再加跑几次。这个流程本质上就是传统测试里的“最小化还原bug”,但很多人做Agent开发时居然不走这一步,总是靠端到端各种试。
4.3 让Agent“自我修复”的尝试与取舍
对错误处理,我还尝试过在Agent内部加一个“反思”机制:如果技能调用失败,让模型自己看错误信息,调整参数后重试一次。这听起来很智能,但实测下来,效果要打一个大的折扣。
模型的自省能力比想象中弱得多。给它报“参数X不在枚举值内”,它十次有八次会改写成相近但不一定正确的值,甚至编一个不在枚举里的新值。第二次失败的概率非常高。白白浪费了时间和token。
后面我改成“规则为重试兜底”的策略:模型参数过不了验证时,先做归一化,再匹配历史成功参数。如果归一化后仍失败,再允许模型带着错误信息重试一次。并且有一个硬限位,同一技能同一请求最多重试两次,超过之后直接降级到人工接口返回“暂时无法处理”。这个策略上线后,整体任务成功率反而上升了,因为减少了无意义的反复试错、避免让用户等太久,等着等着就流失了。
4.4 一个典型的从故障到修复的完整链路
用一个实例把上面的方法串起来。某天线上监控发现,“生成对账单”这个技能的成功率突然从95%掉到68%。
我先看日志,错误清一色是“缺少customer_level参数”。但customer_level在设计时就标为非必填,怎么可能大量缺?于是我用回放工具查了一条失败样本,发现模型在生成参数时根本没看到schema里的非必填说明,而是根据SKILL.md里那句“针对不同等级用户生成不同模板”,自己脑补了一个必填参数。
再去看SKILL.md,果然描述里有一句“该技能针对不同用户等级输出差异化的对账模板”,导致模型以为必须知道等级。修复方案很简单:一是把SKILL.md里的措辞改成“系统自动获取用户等级,无需模型传入”;二是schema里给customer_level加了一个默认值equal。修复后重跑测试集全部通过,并把回归案例加进了测试套件。
这个过程不复杂,但能体现一条关键的排查原则:很多Agent错误的根因不在执行器,而在技能包的语义描述与模型交互的错位——描述里的一个词,可能被模型当成硬性条件。所以排查时,除了看代码逻辑,一定要回头审视SKILL.md和schema是不是在误导模型。
5. 横向经验:技能库治理与团队协作
5.1 版本管理与命名规范
技能数量超过几十个之后,治理会变成一个问题。第一个要做的是版本管理。我目前采用的是语义化版本规则:主版本号在技能行为不兼容时递增,次版本号在新增可选项时递增,补丁号在修复不影响行为的错误时递增。任何技能包升级后都要在registry里登记变更。
这里有一个容易被忽略的坑:skill的版本变化会直接影响历史对话的复现。同一条用户请求,在技能1.0版和1.2版下走出来的参数与结果可能不一样。所以我在日志里始终记录技能的版本号,排查历史问题时能准确定位是哪个版本的技能引发了行为差异。没有版本号的技能日志,和没有酒标的酒一样,根本没法追溯。
命名规范也同样重要,统一kebab-case、动词开头、禁止缩写。比如“查天气”用lookup_weather,不要用weatherInfo。缩写和大小写混乱是技能召回的大敌,因为嵌入模型会把它们当不同语义处理。
5.2 技能复用与冲突解决
做多Agent系统时,技能复用是提高效率的关键,但直接复用会产生冲突。我遇到过一个经典的冲突:A团队负责的“获取订单详情”技能,返回的字段结构是订单维度的,B团队在另一个Agent里调用它,却是为了获取订单里的商品维度数据,于是B团队就复制了一份改名为“获取订单商品”,造成了同一语义的技能出现了两个版本,后续维护成本急剧上升。
我的做法是在技能注册表里增加“所属领域”和“复用声明”。每个技能必须声明它是通用能力还是垂直领域能力。通用能力如果有,原则上不允许重复建包,只能需求扩展时升级原有技能;垂直领域能力可以独立,但要显示声明与其他技能的差异点。复用规则就一个:要复用先看registry,能扩展不新建,能继承不复制。
冲突解决则需要一个可操作的决策流程。两个技能同时声称适配同一场景时,不看谁先来,而是看测试集覆盖度。谁的测试集更能覆盖业务主路径,谁就作为主技能,另一方降级为备选。这个机制没有主观色彩,吵不起来。
5.3 评估与回归:没有评测的技能库是空中楼阁
我把最后这段留给评估。很多Agent项目的技能库里装了一堆技能,却没有任何评测机制。这等于盖了楼不做承重测试,迟早要塌一部分。
我的做法是维护一个全量评估集,里面收集了真实业务场景的种子问题,加上人工构造的干扰项、边界项,总共几百条。每一次技能包版本更新或路由策略改动,都要跑一遍评估集,对比关键指标。什么指标重要呢?技能路由准确率、参数校验通过率、任务完整执行率、平均token消耗、平均耗时。前三个是质量指标,后两个是成本与体验指标。
评估跑完不是结束,还要看失败case在哪个环节崩掉。失败分布要按“路由错了”“参数错了”“执行器错了”“模型输出错了”四个类别打标签,这样集中火力修复瓶颈。我做过一次统计,当时失败里“路由错了”占了四成,于是重点调整了SKILL.md描述的边界和触发关键词,第二次评估失败率立刻下降了十几个百分点。
回归评估还有一个附加价值:它可以倒逼技能包描述规范、Schema规范、测试用例规范这些基础建设不断完善。没有评测这个反馈回路,很多规范执行不下去,只能靠人肉苦口婆心。
我个人做完这一整套改造的体会是:Agent应用能不能稳定落地,看的不是模型有多聪明,而是工程化的能力边界划得有多清爽。技能包只是把人人都懂的一些模块化思想搬到了LLM应用里,但真正复杂的地方在于,要在“面向人写代码”和“面向模型写语义描述”之间同时做对。踩过几次坑之后,我现在每动手写新技能前,都会先想清楚:描述会不会误导模型、Schema是不是克制、测试集有没有覆盖边界、版本会不会破坏兼容。就这套动作,治好了我过去一半的线上焦虑。最后再分享一个小技巧:技能描述写完后,别急着上线,先拿三个“有点像但又不完全是”的任务去测试它,让模型判断要不要触发这个技能——能答对,才说明描述是真的清楚。