做AI Agent最容易被忽视的,往往不是模型本身的能力,而是那些看起来不复杂、实际却决定成败的细节。我第二次做Agent项目时踩了个大跟头——把二十多个函数一股脑塞给大模型,结果系统Prompt膨胀到四千多token,模型在无关对话里乱调技能,参数格式各种臆造,一度让我怀疑是不是该把方案推倒重来。后来我停下来,把所有能力重新梳理成一套独立技能层,局面才开始转好。如果你也在做类似的事,或者正准备给自己的智能体加技能体系,这篇文章会把我的完整设计思路、踩坑过程和修复方案都写出来,可以直接照着落地。
1. 技能层,是Agent从Demo到可用的分水岭
1.1 我从一次"一地鸡毛"的集成里得到的教训
当时我在负责一个内部知识库答疑助手的改造,业务方希望它能查文档、拉工单、翻项目进度,还要能读邮件。第一版图省事,我把所有外部接口的函数名和参数说明拼进system prompt里,让模型自己选。Demo演示时一切正常,因为演示用的几条问题都是精心挑好的,背后对应的函数也就那么三四个。等放到测试环境跑了一周,问题全暴露了。
首先是模型开始"幻觉调用"。比如用户问"这个需求什么时候上线",模型居然调用了查询天气的接口,因为它的描述里写了"用于查询与时间相关的信息",这个描述太宽泛了。其次是函数互相抢活儿,查项目和查工单两个接口功能有重叠,模型每次选哪个全看心情,数据来源不稳定。最头疼的是,每次加一个新接口都要重新编排prompt,测试同学被迫把整条链路回归一遍,一周能改三次。
后来我找了一个做后端的老同事聊,他点了一句话:你这不是模型问题,是把路由层的活硬塞给提示词。这句点醒了我。大模型确实能做一定程度的函数选择,但它需要的不是一份函数清单,而是一套能被它理解和约束的技能说明,同时还需要后端的校验和降级兜底。于是我决定单独抽一层"技能层",这也是项目名agent-skills的由来。
1.2 技能层的定位与边界
很多刚接触Agent的同学会混淆几个概念:工具、函数、技能、插件。我现在的理解是这样的:
| 概念 | 作用 | 例子 |
|---|---|---|
| 函数 | 最底层的一段可执行代码 | 查询订单状态的SQL封装 |
| 工具 | 对函数做一层外部接口暴露 | HTTP接口,接收参数返回JSON |
| 技能 | 给工具加上"给模型看的使用说明书" | 包含名称、描述、参数约束、触发条件 |
| 插件 | 一组技能的集合,可按需加载 | 客户服务插件包含查订单、退款、开发票三个技能 |
技能层不负责实现业务逻辑,它的核心职责只有三件事:让模型知道系统里有什么能力,知道什么场景下该用哪个能力,知道怎么用才算用对。这三件事听起来简单,但每件都要写得很讲究。技能层还承担着安全边界的作用——不该暴露给模型的内网接口,绝不能出现在任何技能描述里,这是铁律。
我当时给自己定了一个原则:如果某个功能不能写清楚"什么时候用、什么时候不用、用错了有什么后果",那它就不配做成技能,宁可在产品上砍掉。正是这条原则,帮我挡掉了不少后来会成为坑的需求。
1.3 技能层带来的三个可见变化
重构之后,最直观的变化是system prompt的涵洞。原来四千多token的函数清单缩成了技能层的索引,每个技能单独管理,模型推理时只需要看到当前会话相关的技能集合。系统Prompt瘦身到一千token以内,响应速度肉眼可见地变快。
第二个变化是排错路径终于清晰了。以前模型乱调函数,你根本分不清是描述问题还是模型抽风。有了技能层之后,每次调用都会被记录下来:调用了哪个技能、传了什么参数、成功还是失败。排查时先看是不是模型选错了技能,再看是不是参数格式不合法,最后才是后端逻辑的问题。三层分开,定位效率高了很多。
第三个变化是回归测试终于能自动化了。每个技能都对应一组回归用例,改动技能描述后只需要跑对应的用例集,不用再全链路手点。我在下一节会详述技能描述该怎么写,因为这是整个技能层里最花心思、也最容易被做坏的部分。
2. 先把技能描述写成模型能读懂的样子
2.1 一套可用性较高的技能定义格式
我给每个技能定义了统一的字段结构,用JSON表示,存到独立目录里。每个技能一个文件,后面再做注册表聚合。一个完整的技能文件看起来像这样:
{ "id": "query_order", "name": "查询订单详情", "version": "1.2.0", "description": "根据订单号查询订单的当前状态、金额、物流信息。当用户询问'我的订单到哪了''这个订单什么状态'时使用。如果用户只提供订单号,也可以直接查询。此技能只读,不会修改任何订单数据。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "用户提供的订单号,格式通常为13位数字,如202501010001。" }, "include_logistics": { "type": "boolean", "description": "是否返回物流轨迹,默认false。", "default": false } }, "required": ["order_id"] }, "examples": [ { "user": "帮我看看订单202501010001到了没", "api_call": "query_order(order_id='202501010001', include_logistics=true)" } ], "triggers": ["订单状态", "物流", "快递", "到哪了"], "not_use_when": [ "用户想修改订单或退货,应引导到售后技能", "用户询问的是虚拟商品兑换码,但不查询具体物流" ] }这里最关键的不是字段有多少,而是description和not_use_when。我见过很多团队把description写成给开发看的接口注释,什么"根据ID获取订单信息",模型看到这种描述根本不知道什么时候该调用,结果就是乱调或者不调。
2.2 写好description的四个原则
第一个原则是写给模型看,不是写给同事看。模型和人不一样,它不会主动推理"这个函数大概是干嘛的",它只能根据你的文字描述去匹配用户意图。描述必须讲清楚触发条件。比如"当用户询问订单状态、物流进度时使用",这比"查询订单"四个字有用十倍。
第二个原则是必须写负面条件。也就是not_use_when这个字段,我后来把它合并进了description的末尾,因为有些模型不会去看独立字段。效果就是让模型在模糊场景下敢于不调用技能,避免幻觉调用。比如"此技能只读,不会修改任何订单数据"这句话,看似多余,实际上能拦掉很多模型自作主张的越权操作。
第三个原则是参数描述要给格式示例。光写"订单号"是不够的,要写"通常为13位数字,例如202501010001"。模型对具体数字的敏感度远高于抽象描述,给了示例之后,参数填错率能下降一大截。这是我实测得到的结论。
第四个原则是每个技能都要配一两个示例。示例的作用类似于少样本提示,模型在看到完整示范之后,能更好地理解参数之间的逻辑关系。尤其是包含布尔参数、可选参数的时候,示例几乎是必须的。
2.3 参数Schema的几个设计要点
参数这块我踩过很多坑,最后沉淀下来几条比较稳的做法。
尽量用枚举值取代自由文本。比如订单状态,与其让模型填"已完成""完成""completed"这种变体,不如直接枚举:pending, paid, shipped, completed, cancelled。模型在面对明确选项时的表现,远远好过让它自由发挥。
必填参数不要太多。每个额外必填参数都会增加模型调用失败的概率。能用默认值解决的就给默认值,能通过自动推断解决的就别让模型猜。比如查询订单,用户只发了一个"我订单到哪了"但没有订单号,模型既不能编订单号,也不该直接拒绝——这时候可以让技能支持"查询最近一笔订单"的降级逻辑,用user_id来替代order_id查询。
不要完全信任模型传来的参数类型。我在后端统一加了一层schema校验,不合法就直接返回明确错误信息,让模型根据错误自行修正。这个会在第四章展开细说。
2.4 技能描述的长度控制与索引策略
技能描述写得太长,会导致推理时token消耗大、模型注意力分散。写得太短,又会导致语义匹配不准。我尝试下来,description控制在80到140个字之间效果比较平衡,算上参数和not_use_when,一个技能整体在250到350个字左右。超过这个范围,就要考虑拆技能了。
当技能数量超过15个,全部塞进上下文里会让模型混淆度显著上升。这时候我做了两层设计:第一层是技能索引,用一句话概括每个技能的功能,放在系统Prompt里;第二层是完整技能描述,只加载"本轮对话可能涉及"的那几个。如何判断"可能涉及"?我用了一个轻量级的关键词分类器,把用户输入和技能triggers做一次粗匹配,命中才加载完整描述。这个做法让我在技能数量继续扩张时没有重新陷入混乱。
3. 技能编排:当多个能力撞在一起时的路由逻辑
3.1 技能越来越多之后,误选率开始抬头
技能层单独跑通之后,我一度以为问题解决了。但没过多久,新的麻烦来了:技能多了以后,模型开始"抢答"。典型场景是用户说"最近有什么需要我处理的任务吗",结果项目进度查询、工单列表、待办事项三个技能都有可能被调用,而且文档里描述都说得通。模型选哪个完全是运气,这直接导致同一个问题三次回答内容来源不一致,用户直接质疑数据对不上。
这就是技能编排问题。技能层不能只是被动地让模型挑,而要在设计上主动帮模型降低选择难度。我后来总结了三板斧:技能分组、冲突消解、拒绝策略。
3.2 用技能组实现意图隔离
我先对所有技能按业务域做了分组,比如"订单域""客服域""知识库域""日历域"。然后对每个组增加了加载条件,只有用户的问题命中了该组的触发词,这组技能才会被完整加载进上下文。
举个例子,用户说"帮我把这周二的会议安排一下",系统先判断命中"日历域",于是加载日历域的技能全集,包括创建会议、查询日程、取消会议等;同时知识库域和订单域的技能只保留一行索引摘要。这相当于给模型做了一个前置过滤。分组之后,模型在五个技能里做选择,比在二十个技能里做选择靠谱太多。
分组条件的设定也有讲究。不能太宽泛,否则一个组里堆十几项,等于没分;也不能太窄,否则意图稍微偏一点就落到空组。我当时定的标准是:一个技能组最多容纳六到八个技能,超过就继续拆子组。每个组的触发词尽量使用用户自然会说的口语词,而不是内部术语。
3.3 冲突消解:当两个技能都能满足时怎么办
即便分了组,同组内仍然会有功能相近的技能。最典型的是"查询项目进度"和"查询项目详情"——用户说"看看项目X现在咋样了",你很难说该走哪个。我处理这种冲突的办法是三个层级。
第一层是描述互斥。把两个技能的not_use_when写得互相排斥,比如项目进度技能写明"本项目详情技能已包含基础信息,但若要获取甘特图时间线请使用进度技能",这种显式说明对模型的效果很好。第二层是优先级标注。在技能定义里加一个priority字段,当模型判断两者都可以时,优先返回高优先级技能。第三层是融合技能。如果两个技能经常被同时命中,干脆把它们合并成一个复合技能,返回结果时一次拿全。我实测下来融合技能的体验最好,因为数据口径不会再打架。
还有一个细节:有些技能之间是串联关系。比如先调用"查订单"得到order_id,再调用"修改订单备注"就需要带上这个id。我会在技能的description里显式写出"依赖查询订单技能的返回结果,请先调用query_order获取订单号"。这种前置依赖说明,比在代码里写死更灵活,也让模型对调用顺序有了概念。
3.4 拒绝也是一种正确输出
很多做Agent的人容易忽略一件事:技能不是越多越好,也不是每次都必须要调用一个。我在设计技能层时单独规定了一种"无匹配"的响应模式。当模型判断所有技能都无法满足当前用户问题时,它应该诚实地返回拒绝信息,而不是硬选一个最接近的技能。
这个拒绝逻辑既要在描述里强调"如果以上技能均不适用,直接说明无法处理,不要编造",也要在后端兜底。后端在收到模型返回结果时,如果发现调用名称不在技能注册表里,或者参数校验不通过,应该返回一个统一错误,并提示模型重新描述用户需求。这套机制跑通后,模型开始学会说"我没有你想要的权限"或者"这个问题需要转给人工客服",这比它硬着头皮调用一个错技能然后返回一堆胡话要体面得多。
4. 实测中的典型翻车场景与修复过程
4.1 翻车案例一:模型把字符串参数写成了嵌套JSON
技能层刚上线那阵,错误日志里出现频率最高的一类问题是参数格式错乱。比如查询订单技能要求order_id是字符串,模型实际传的却是:
{ "order_id": { "value": "202501010001", "source": "user_input" } }我在日志里看到时整个人是懵的,模型怎么会把一个简单字符串包装成对象?后来复盘发现,是因为我在参数描述里写了"订单号来自用户输入",模型就"贴心"地把来源信息也塞了进去。这类问题的修复不能靠改prompt,因为模型会不停发明新的包装方式。我的做法是在所有技能后端统一套一层参数净化器,用JSON Schema做严格校验,不合法就直接按技能定义里的类型转换一次,转换不了就返回错误要求重试。在校验器里我还特意关了coerce_types,避免模型传来的"123"被悄悄转成123后又引发别的问题。
修复之后我把这类错误按周统计,发现从第一周的一百二十二次降到了第八周的三次。这个结果很有说服力——靠提示词不如靠硬校验。
4.2 翻车案例二:无关闲聊触发了业务操作
有一次测试同学打开调试台,对着机器人发了一句"今天天气怎么样",结果系统居然调用了"创建工单"技能。虽然最终因为参数校验不通过没有真的创建成功,但这个惊险一幕让我意识到,光是说要"使用条件明确"还不够,必须加上强约束的语言。
我给创建类技能统一加了一句硬性说明:"此技能会修改真实业务数据,必须用户明确表达创建、提交、下单等操作意图时才可调用,仅在用户询问'能不能''怎么办'时不得调用。"同时在后端做了操作确认机制:凡是写操作技能的调用,必须带上一个confirmation字段,值为true才会真正执行。模型发现需要主动确认时,通常会先反问用户"是否确认创建",这就把一次危险调用变成了一个安全的会话节点。
4.3 翻车案例三:技能列表太长,后面的技能被模型完全遗忘
有段时间我供着三十多个技能,每个都写了详细描述,系统Prompt里塞得满满当当。结果日志显示,排在列表末尾的五六个技能一次都没有被成功调用过。这不是模型歧视,是注意力被长列表稀释了,更准确的说是排序在前面的技能拦截了大部分语义匹配。
我当时的解决思路是放弃"全量常驻",改成上文提到的索引加按需加载。技能索引要足够短,一句话就够。然后我把触发判定从模型判断改成了规则判定加上模型判断的混合模式:先用规则粗筛出候选技能,通常三到五个,再让模型在这几个候选者里做选择。这么做之后,长尾技能的调用次数从零变成了每周十几次,因为只要有用户问到,规则层就能把它们捞出来。
4.4 修复效果的量化对比
改造做完之后,我做了一次连续六周的对比统计,这里列几个关键数字供大家参考:
| 指标 | 改造前均值 | 改造后均值 | 变化 |
|---|---|---|---|
| 技能调用成功率 | 67% | 92% | 提升 |
| 无关会话误调用率 | 14% | 2% | 下降 |
| 平均单次推理耗时 | 1.8秒 | 1.2秒 | 下降 |
| 因参数错误引发的后端报错 | 每周40次 | 每周9次 | 下降 |
| 新增技能所需联调时间 | 2天 | 0.5天 | 下降 |
数字不会骗人。最让我意外的是最后一行,新增技能联调时间的大幅缩短——因为技能定义规范化之后,测试用例可以直接从样例里生成,后端校验器也可以自动识别新技能,团队协作的摩擦一下子就小了。
4.5 日志与回归测试是技能层的生命线
整个修复过程里,我最庆幸的是从一开始就保留了完整的调用日志。日志记录的字段包括会话ID、用户输入摘要、召回技能列表、最终调用技能、原始参数、净化后参数、后端返回码、模型重试次数。有了这些数据,我才能够定位上面那些翻车场景,不然全靠肉眼去复现对话,效率会低很多。
后来我把这组日志做成了每周一次的自动分析任务,重点盯三个指标:误调用率、空结果率、参数修正率。前两个指标上升说明技能描述出了问题,第三个指标上升说明模型在频繁生成不合法参数,需要加强示例或参数校验。这相当于给技能层装了一个仪表盘,所有改动都能被观测到,而不是拍脑袋调完就说"应该好了"。
5. 后续演进:技能复用与共享的实践经验
5.1 从单Agent到多Agent,技能描述的一致性危机
项目跑顺之后,团队开始把agent-skills的能力复用到其他几个Agent上,比如客服机器人、内部工单助手、项目周报自动生成器。这时候冒出了一个新的问题:同一个业务能力在不同Agent里的技能描述被复制了好几份,改了一个忘了另一个,行为不一致。
比如"查询用户订单"这个技能,在客服机器人里的描述写的是"当用户询问订单状态时使用",在工单助手里写的却是"当需要获取用户订单信息以填写上下文时使用"。结果工单助手时不时把用户订单状态当作工单处理结果输出,数据串味了。
我意识到,技能需要有一个单一事实来源,而不是每个Agent各存一份。
5.2 技能注册表与版本管理思路
我搭了一个简单的技能注册中心,只做三件事:存技能定义、管技能版本、提供检索接口。所有Agent启动时从注册中心拉取自己需要的技能列表,不再在各自代码库里维护JSON文件。
版本管理上我采用语义化版本,技能定义的变更规则是:修复描述、调整参数默认值属于Minor更新,不影响调用方;修改参数结构、删除技能字段属于Major更新,必须同步更新所有调用方Agent。每个技能变更后,注册中心会自动跑一遍该技能自己的回归用例,通过才允许发布新版本。
这里有一个容易被忽略的细节:技能的description变更看起来只是文字改动,实际影响面特别大。因为模型的行为完全由描述牵引,可能只是把触发词从"查询"改成"查看",就会让模型在某个会话里的选择行为发生变化。所以我把description的变更也归为Major级别对待,必须走完整回归。曾有一位同学吐槽我小题大做,直到他改了一个词导致客服机器人间歇性抽取错误订单号之后,就再也没质疑过了。
5.3 多Agent协作时的技能边界设计
多Agent之间还会出现互相调用技能的场景。比如工单助手需要了解订单信息时,我一开始直接让它调用工单域的"查询订单"技能,后来发现会造成重复查询和权限过宽。更合理的做法是让上层Agent只暴露"获取订单摘要"这个精简技能,内部再自行调用完整技能。这有点像接口的瘦身设计——对外暴露的,永远是经过裁剪的、符合最小权限原则的能力。
我在技能定义里加了visible_to这个字段,标记某个技能可以被哪些Agent看到。不可见的技能,Agent在推理时完全不知道存在,自然也就不会去调用。这个隔离方案在权限管理上帮了大忙,尤其是有两个Agent对同一条业务数据不同口径时,直接让它们各看各的视图即可。
5.4 日常运营技能的迭代节奏
技能不是写完就完事的,它会随着业务变化持续迭代。我现在维护技能的节奏大概是这样的:每周看一次调用日志和误调用日志,每两周和业务方碰一次新需求,每月做一次技能清理。清理的标准是近一个月调用次数为零,且没有合理的保留理由,就直接下线。因为不用的技能放在注册表里,只会增加检索噪音和误调用的概率。
还有一个比较微妙的地方:同一个用户问题,在不同上下文中应该触发不同技能。比如"这个单子能改吗",如果是客服场景应该走售后技能,如果是销售场景应该走改价审批技能。我现在是通过"场景标签"来实现的,Agent启动时带上自己的场景标签,技能描述里也会声明自己适用的场景。这种场景级的隔离,比单纯靠技能描述硬扛要稳得多。
这套agent-skills体系从最初被我嫌弃的"prompt胖卷",到如今成了团队里所有Agent的地基,回头看最有价值的不是某个具体写法,而是把"模型能力"和"业务能力"之间的连接做成了可维护的工程结构。如果你也在做Agent,建议从给自己的技能写一份合格的使用说明书开始,再一步一步把注册、编排、回归都补上。这条路不复杂,但每一步都得走扎实。