这一篇想聊一聊我最近在某个内部项目里落地的“agent-skills”技能层设计。起因很直接——某企业内部知识库助手Agent上线不到两个月,工具函数从十几个涨到四十多个,然后经常犯傻:该查库存的时候去查了售价,该调审批接口的时候调成了查审批状态的接口。排查到最后,问题几乎都出在同一个地方:我们把工具直接裸塞给了大模型。于是我们内部立了个代号“agent-skills”的小项目,核心目标是把“工具函数”升级成一套真正能被模型理解和调度的“技能体系”。
如果你也在做基于大模型的Agent应用,尤其是已经开始头疼工具越来越多、提示词越来越长、模型选择越来越飘,那这篇文章应该能帮上忙。下面我会完整拆解我们走过的弯路、最终的方案、以及踩坑后的取舍。
1. 直接裸调函数的三种“死法”:从似曾相识的翻车现场说起
先说为什么不能继续把工具函数直接丢给大模型。我们早期的架构非常简单:把每个工具的JSON结构和一段文字描述拼进System Prompt,然后让模型自己决定调用哪个。结果工具一多,问题立刻暴露,而且三种翻车方式都很有代表性。
1.1 提示词膨胀:越加功能,Agent越蠢
第一个问题是上下文膨胀。工具描述是从代码注释里抄的,每个工具平均200到300个字符,四十多个工具就将近一万个字符,再算上对话历史和系统指令,每次请求的输入轻松破万。
更麻烦的是,大模型对超长上下文的注意力是会被稀释的。实测下来,当工具列表超过二十个,模型对“用哪个工具”的决策准确度明显下降。它倒不是乱选,而是更倾向于选它最熟悉、排序最靠前的那几个——我们内部叫“注意力偏置”。越往后的工具,越容易被无视,哪怕那个工具才是正确的。
1.2 相似工具互相混淆:模型分不清“查库存”和“查售价”
第二个问题是相似工具的混淆。某个业务模块里,同时存在“查询商品库存”和“查询商品售价”两个接口,参数几乎一样,都接收商品ID和店铺ID,连返回字段结构都接近。人看着都像,模型更分不清。
有一回同事测试,问“这个商品还有货吗”,日志里显示模型调用了查售价的接口,拿到价格数字之后还一本正经地回答“查询到该商品当前售价为xxx元”。它甚至没有意识到回答和问题完全不匹配。根因就在于工具描述里的措辞太接近了,都属于“查询商品信息”,模型在解析用户意图时,根本没有足够的信息去区分。
1.3 返回结构五花八门:模型为了“圆场”开始编字段
第三个问题是返回结构不统一。早期很多工具是按后端习惯封装的,有的返回一个嵌套JSON对象,有的返回纯文本状态码,有的返回一个包含错误码的列表,甚至还有抛异常让前端自己处理的。
大模型面对这些不规范返回时,会出现一种很尴尬的行为——强行嫁接。它拿到了一个“查询成功,状态码200”的字符串,但用户问的是“具体还有多少货”,它就会编一个“库存充足”之类的内容。我们追踪过好几个幻觉案例,最后都追到了这个根因:不是模型满嘴跑火车,而是它拿到了一堆它不理解格式的返回值,为了凑出自然语言回答,只能靠猜。
这几个坑叠在一起,我们才开始意识到:工具函数是写给程序员看的签名,而Agent需要的是写给大模型看的“技能说明书”。
2. 技能注册表:把“会做某件事”固化为一套结构化清单
“agent-skills”设计的第一层,就是解决信息组织的问题。我们把所有工具统一收编进一张技能注册表(Skill Registry),每个技能在注册表里占一条记录,记录里不是代码签名,而是围绕“模型该怎么理解和使用它”展开的完整元数据。
2.1 技能标识与命名:不要给模型看缩写
第一件事是先定技能ID。早期工具命名用的是后端习惯,比如getStkBySkuIds这种,纯粹是给工程师看的。在技能层里,我们全部改成了三段式:域:动作:对象,同时要求技能ID本身就要像一句人话。
具体来说,一个查库存的技能,ID是inventory:query:stock,描述写“查询某个SKU在某个店铺的实时库存量”。我们强制规定:技能ID里不许出现缩写,不许出现只有内部人才懂的术语。原因很简单,最终读这个ID的是大模型,不是我们自己的代码。
2.2 入参出参的Schema:关键不是数据类型,是参数的“使用场景”
注册表里每个技能都要声明入参和出参的结构。我们直接采用的是公开的JSON Schema格式,但加了一个字段级要求:每个参数不光要有类型说明,还必须写清“该参数在什么场景下由用户话语中的哪种表达映射而来”。
举个例子,同样是“时间范围”参数:
- 之前:
"startTime": {"type": "string", "description": "开始时间"} - 现在:
"startTime": {"type": "string", "description": "用户提到“从什么时候开始”、“近一周”、“最近三天”等表达时,解析为对应ISO8601时间字符串,格式为YYYY-MM-DDTHH:mm:ssZ"}
这个改动很关键。模型不是看不懂类型定义,它是不确定“用户说的这句话到底该填到哪个字段”。你把参数和用户表达之间的映射关系写清楚,参数提取准确率会明显提升。
而且出参Schema我们也做了统一。所有技能执行后,都必须返回一个标准信封结构:
{ "status": "success", "code": 0, "data": { ... }, "error": null }信封外部字段固定,业务数据全放在data里。模型只需要判断status和code,然后直接拿data去组装回答。这彻底解决了之前“返回结构五花八门导致模型猜字段”的痛点。
2.3 适用场景与反例标注:让技能学会“不接单”
另一个我们后来才补上的字段是applicable_scenes和negative_examples。前者写的是“在用户的什么诉求下应该选中我”,后者写的是“看起来跟我相关但我并不负责的场景”。
以“查询库存”技能为例:
applicable_scenes: - 用户询问“还有货吗” - 用户询问“库存情况” - 用户询问“某SKU是否可售” negative_examples: - 用户询问“商品价格是多少” # 这是查售价技能的职责 - 用户询问“什么时候发货” # 这是查订单进度的职责给技能写反例,一开始我们觉得有些多余,但实测发现反例的价值甚至超过正面描述。它能显著压低模型误调用的概率,尤其是两个技能长得像的时候。
2.4 依赖与副作用声明:编排层必须知道的信息
注册表里还记录了两个对编排层非常重要的字段:readonly和depends_on。
readonly用来标识该技能是否只读。只读技能可以被并发执行、可以走缓存;写操作技能则必须串行、必须记审计日志。depends_on声明技能依赖的上游服务,这个在编排时很有用——两个技能如果依赖同一服务,我们可以做请求合并,减少一次外部调用。
到这一步,技能层的基础数据模型就成型了。我们当时把这些定义全部放在一个YAML文件集里,每个技能一个文件,统一由代码加载进注册表。后来接入新技能时,只需要新增一个YAML文件并注册,不需要改任何Agent主流程代码。
3. 从“匹配”到“确定”:让模型在几十个技能里选对的那一个
注册表解决了“信息怎么结构化”的问题,接着就要解决“模型怎么在几十个技能里选到正确项”的问题。我们把它拆成三段处理:召回、过滤、确认。
3.1 语义召回:别把检索交给逐字匹配
第一步是先做粗召回。我们给每个技能的description和applicable_scenes字段生成了向量,用户的当前问题也生成向量,然后做余弦相似度召回,取Top-K。这么做的好处是,用户问“这双鞋还有码吗”,可以直接命中inventory:query:stock技能,因为“还有码吗”这个问法和技能描述里的“还有货吗”在语义空间里足够接近。
召回之后,我们会对相似度做阈值过滤。低于某个阈值的候选直接不要。这一步很重要,能避免模型在完全没把握的情况下硬选一个技能。我们的阈值是通过采样历史日志标定出来的,初始值设在0.72左右,误召回太多就调高,召回率不够就调低,基本属于一个经验参数。
3.2 规则层过滤:在勇气可嘉的召回结果里泼冷水
召回只是第一步,召回的结果还得过一层规则过滤。这层规则考虑的主要是三类信息:
- 用户权限:当前登录人是否有权限调用该技能,没有就直接移除候选;
- 实体类型:候选技能里的参数是否匹配当前上下文中已经出现的实体,比如用户说的是“门店A”,技能只接受“仓编码”,不匹配就过滤掉;
- 会话状态:某些技能只能在特定流程阶段调用,比如“确认订单”只能在“购物车已选择”之后出现,规则会拦截不合时宜的候选。
规则层用的是最朴素的if-else,没什么高深之处,但它的价值在于省令牌。它可以把候选列表从动辄二十个压到三五个,接下来模型要做的选择就简单多了。
3.3 意图确认:让模型先说说“为什么选它”
最后一步,我们把筛选出的候选技能连同完整的Schema一起交给模型,让它输出一个结构化意图声明(Intent Declaration),里面包含三部分:选中的技能ID、选择理由、提取出的入参JSON。
这个步骤看起来多了一次推理消耗,但它带来的收益极大。要求模型填写“选择理由”后,模型被迫先做一步自我解释,这会显著降低胡猜的概率。如果模型提取入参时缺了必填项,我们还能在这个环节直接要求它补参数或者向用户追问,而不是等到执行时报错。
这里给一个我们内部实测前后的对比数据:在引入意图确认之前,技能误选择率约为21%;引入之后,降到了6%左右。代价只是每次请求多消耗一次模型调用,但这个成本完全值得。
3.4 兜底路径:没有合适技能时,不许硬编
最后一条规则是我们踩坑踩出来的:如果没有技能候选通过阈值和规则过滤,系统会走一条“UNKNOWN”路径——让模型直接告诉用户“我目前暂时无法处理这类请求”,而不是强行选一个相似技能。
一开始我们有顾虑,觉得这样会让Agent看起来“能力不足”。但真实用户反而更能接受诚实的回答,完全不接受的是明明没有权限或数据,模型却编造一个肯定答复。
4. 技能编排与执行:把多个技能串成一条能跑通的工作流
单技能选择解决了“某个步骤该调谁”,但真实用户问题往往是多步骤的。比如用户问“我想把购物车里那些缺货的商品换成有库存的型号”,这里面的动作至少有:查购物车、逐个查库存、查同品类可替代商品、改购物车商品。这就轮到技能编排层干活了。
4.1 统一数据信封:让技能间的输出能直接喂给下一个技能
多技能协作首先需要解决数据传递问题。我们用的方案是统一数据信封(Envelope),也就是上一节提到的{status, code, data, error}结构。任何技能的输出,编排层只关心data字段,并且要求data本身也是一个可序列化的JSON对象。
这样做的意义是,编排层可以用一套通用的管道(Pipeline)来处理数据流。技能A输出的data,经过一个声明式的映射规则,直接变成技能B的入参。映射规则写在编排配置里,例如:
pipeline: - skill_id: cart:query:items output_alias: cart_items - skill_id: inventory:query:batch_stock input_template: sku_ids: "{{cart_items.data.skus}}"这个设计让技能之间保持低耦合。每个技能都不知道自己是工作流里的第几步,它只负责把输入转换成输出。工作流本身是配置化的,改工作流不需要动技能代码。
4.2 顺序、条件与并行:编排器的核心循环
我们写了一个非常小的编排器核心循环,逻辑很简单:
def run_pipeline(pipeline, initial_ctx): ctx = initial_ctx for step in pipeline.steps: # 读取步骤依赖的输入 inputs = bind_inputs(step.input_template, ctx) # 执行技能 result = execute_skill(step.skill_id, inputs) # 把结果写入上下文,供后续步骤引用 ctx[step.output_alias] = result # 判断是否中断或跳转 if step.error_branch and result.status != "success": return run_pipeline(step.error_branch, ctx) return ctx这里只处理了顺序和简单条件分支。并行执行我们是在技能层做的,也就是上面提到的readonly技能可以并发发起。批量查库存这种操作会被拆成多个子任务并发执行,性能提升还是明显的。
4.3 执行守卫:超时、重试、幂等与审计
技能执行不是无限信任外部接口的。每个技能执行前,编排层都会统一套上几个守卫:
- 超时控制:默认单技能执行上限是15秒,超时直接标记失败;
- 重试策略:只读技能允许自动重试2次,写操作技能一律不自动重试,避免重复扣款或重复下单;
- 幂等键:写操作技能必须在入参里带上幂等标识,由幂等键标识来决定这次请求是否已经处理过;
- 审计日志:每个技能调用都记录调用方、入参、出参、耗时和错误信息,全部落到独立日志流。这既是为了排查,更是为了后续构建评测数据集。
守卫逻辑听起来很基础,但早期我们正因为“太信任工具”翻过车。有一次某个外部接口极端慢,导致Agent整体响应超时,而原因仅仅是其中一个工具没有设超时,拖垮了整个链路。
4.4 上下文瘦身:别把技能全文每次全量塞给模型
最后是上下文的管理。我们不会在每轮对话里都把所有技能定义加载进来,而是只把经过召回、过滤、确认后真正选中的技能描述注入到当轮请求中。
历史消息也不能直接无限拼接。我们的做法是先做摘要压缩,把之前的对话压缩成关键信息条目,比如“用户已选择门店A,已加入3件商品,正在确认是否换货”。压缩后的摘要再和当前意图声明、当前技能Schema拼接,这样每轮输入都能控制在一个稳定、可控的规模。
这一步对成本和响应延迟的影响非常大。改完上下文瘦身之后,单次请求的输入tok量平均下降了58%,响应速度也快了不少。
5. 验证与回归:技能层最容易被忽略的地基工程
很多团队把技能层做完就以为结束了,实际上必须配上评测和回归机制,否则技能改着改着就改乱了。我们把这块单独拎出来讲,因为它才是决定技能层长期可维护性的关键。
5.1 场景库构建:每个技能至少要配“正负样例”
我们为每个技能建立了一个评测场景文件,里面至少包含5条用例:3条正面样例(应该选我的)和2条负面样例(不应该选我的)。
这5条用例不是随便写的,正面样例要覆盖用户的不同说法习惯,负面样例要覆盖最容易混淆的关联场景。以查库存为例,正面样例有“还有货吗”“库存够不够”“显示一下剩余数量”,负面样例是“这个多少钱”“什么时候能发货”。
整个场景库合起来有好几百条样例,涵盖了所有技能。有了这个场景库,技能修改后可以直接跑一轮离线评测,不用每次都在真实业务环境里人工验证。
5.2 评测指标和执行流程
我们跑评测时看三个指标:技能命中率、参数完整率、流程完成率。
- 技能命中率:预期技能ID是否出现在模型最终选中的技能列表里,或被意图确认选中;
- 参数完整率:模型从用户提问中提取出的入参是否完整、类型是否正确;
- 流程完成率:涉及多步骤的用例,整条Pipeline是否跑通到最后一步并返回的结果里包含目标信息。
评测命令就是一个Python脚本,读取场景库,循环跑Agent的决策层,最后输出一张对比表。
改完技能描述后,我们会对全量场景库回归一遍,拿着对比表逐条看:哪些用例从“通过”变成了“失败”,以及失败原因是什么——是参数解析出了问题,还是技能选错了,还是技能间数据传递断了。
5.3 小心“过拟合”:别让模型背答案
跑了两个月场景库之后,我们发现一个新的风险:模型似乎记住了某些固定说法,而不再依靠语义理解。比如把“还有货吗”改成“货尚足否”,命中率就降下来了。
这说明场景库里的正面样例被模型当成了“标准答案”来背,而不是当作理解参照。这个问题的解法是定期改写测试用例,同一个语义隔段时间换一套说法,同时定期对技能描述本身做轻度改写,逼迫模型始终基于语义去做泛化,而不是基于局部词面顺序匹配。
5.4 从回归数据里反推技能描述的质量
还有一件很有用的事:把评测失败的用例,尤其是“误选”的用例,拿回来看技能描述。很多次我们发现,失败的原因不是模型不行,而是技能描述本身写得有歧义。
比如某个技能描述里用了“订单”这个词,但它的实际职责是“查询售后单”;用户问“我的订单到哪了”,它自然会被误召回。发现这类问题后,我们会修改技能描述或补充负面样例,然后回归验证。整个技能层就是在这样“跑评测-发现问题-改描述-再回归”的循环里慢慢变稳的。
6. 落地过程中的三个坑与我的取舍
最后聊聊我们在实施“agent-skills”过程中印象最深的三次“反向复盘”。这三个坑如果你正在设计类似的技能层,大概率也会撞上。
6.1 技能描述越写越像“产品说明书”,节奏不对
我们第一版技能描述,总觉得应该把功能边界写得越细越好,结果一条描述写成了三四行大长句,又是定义又是约束。实测效果并不好:模型确实很少再误调用了,但它开始变得过于保守——明明一个技能就能完成的问答,它非要反复跟用户确认一堆条件,然后才调用。
后来我们调整了写法:描述里只回答两个问题——“这个技能解决什么场景下的什么需求”和“关键入参从哪里来”。其余约束交给字段描述和规则层去管,不再堆在技能描述里。改成这种风格之后,模型表现得明显更果断,误选率没有回升。
6.2 为了“通用”把参数抽象过头,模型直接不会用了
过程中有人提过一个建议:能不能把所有查询类技能统一成一个“通用查询接口”,只传表名、字段和条件,这样以后加新报表都不用新增技能。
听起来很美,但真做了才发现是个大坑。模型根本不确定“表名”该填什么,“字段”该填什么,它只能在几个模糊选项里凭感觉猜,参数提取准确率断崖式下跌。最后我们放弃了这个“大一统”思路,回归到了“一把钥匙开一把锁”的独立技能设计。每个技能把参数名称、取值范围写得清清楚楚,模型才能稳定复用。
6.3 为了省Token删减描述,结果模型开始“编造技能”
有一段时间我们觉得技能描述太啰嗦,想省点Token,把很多带举例的说明删了,只留一句极简概述。结果模型又开始犯错,而且是新类型的错误——它开始凭空发明参数名,甚至擅自拼接两个技能的名字。
原因是信息不足时,模型不会冷静地说“我不知道”,它会硬编一个最接近想象的名字。这个行为在上下文里没有足够锚点时尤其明显。后来我们不再刻意压缩技能描述本身,而是压缩“注入数量”和“历史长度”,保证被选中的技能描述都完整且信息充足。
现在再让我回看“agent-skills”这个项目,我最大的体会是:技能层不是给程序员设计的API封装,而是给大模型设计的“工作界面”。它服务的对象是模型的理解力和决策力,因此一切设计判断都应该围绕着“模型看到这些信息时,会不会产生歧义,能不能做出正确选择”来展开。这个视角一旦转过来了,注册表怎么写、描述怎么措辞、评测怎么建,后续所有决策就都有了判断标准。
最后分享一个我们一直在用的小技巧:新接入一个技能时,先不急着接业务逻辑,而是把技能定义丢给模型做“盲测”——给它一批历史用户问题,看它能不能凭定义描述正确识别出什么时候该调用这个技能。如果盲测都过不了,说明技能定义有问题,做了业务逻辑再返工就晚了。