我最早做agent项目的时候,把所有业务逻辑都塞在系统提示词里。今天加一个算税的功能,明天加一个查库存的指令,提示词越来越长,模型行为越来越不可控。后来才意识到,真正该做的是把能力的边界从模型本身挪出去,让agent像乐高拼图一样按需组合能力。这就是我做agent-skills这个项目的原因:给智能体配一套可注册、可编排、可观测的技能系统。本文就是这套系统的设计和实现记录,适合正在自建agent后端、或者想把工具调用从prompt里抽离出来的开发者参考。
1. agent技能系统要解决的三个核心问题
1.1 提示词膨胀后模型行为失控的困境
先说说我在没有技能系统之前遇到的实际场景。做一个客服agent,一开始只处理售后咨询,提示词大概一千多字,效果还可以。后来业务方要求加上订单查询、物流跟踪、退换货申请、优惠券核销,每一块都要在提示词里写清楚触发条件、调用接口、参数格式、返回处理规则。提示词膨胀到五千字左右之后,模型开始出现幻觉式的"伪调用"——它会在没有调用接口的情况下直接编造一个订单状态给用户。你问它为什么这么做,它认为自己只是在"模拟帮助用户",但这在生产环境是不可接受的。
技能系统的第一个目标就是把每个能力封装成有明确输入输出声明的单元,由模型根据用户意图动态选择调用哪个单元,而不是让所有能力都堆在提示词里互相干扰。这就像把一个大杂烩仓库改造成带货架和标签的超市:模型只需要看图上的分类指示牌,走到对应的货架取货,不必知道仓库里每一件货品放在哪里。
1.2 技能与普通工具调用的本质区别
很多朋友会问:这跟OpenAI的function calling、跟LangChain的tool不是一回事吗?区别在哪?我一开始也这么想过,但做完之后发现差异还挺明显。
普通的工具调用通常是一层扁平列表:模型根据用户输入,在可选工具列表里挑一个执行。技能的抽象层级更高,具备三个额外特征:第一,技能能自己声明适用场景,而不是靠名字推断;第二,技能可以组合出新的技能,形成层级关系;第三,技能内部可以有自己的状态和多步骤逻辑,不只是单次请求响应。换句话说,tool是"一次函数调用",skill是"一个完整的完成任务能力"。
以我的agent-skills项目为例,一个"处理退款"技能内部的执行流程包括:调用用户身份核验、调取订单状态、判断退款政策是否允许、计算退款金额、执行退款操作、给用户生成回执。对模型暴露的只是一个高层的"退款技能",但内部实际跑了六七个底层工具。这样模型的决策负担就小了很多,它不需要在一长串工具列表里精确匹配到第六个"计算退款金额接口"。
1.3 技能复用带来的边际成本递减效应
技能系统的第三个好处是复用性。我做客服方案这套技能,后来另一个项目做在线教育助教的时候,需要订单查询、退款处理、课程咨询,我发现可以直接复用之前的订单和退款技能,只需要新增一个"课程匹配"技能。新项目的agent开发周期从三周缩短到五天,因为大部分能力已经具备现成的技能封装,只需要修改配置里的技能描述,让它们贴合新业务的说法。
复用的边际成本递减效应是我坚持用技能体系的核心理由。没有技能系统之前,每个项目都从零写一遍工具调用、参数校验、错误处理,每个项目的行为逻辑还不太一致。有了技能系统之后,新业务只做增量配置,不推翻重来,这是一个质的改变。
2. 技能注册中心的schema设计与动态加载机制
2.1 技能声明的核心字段与JSON示例
agent-skills里最基础的数据结构是技能声明(SkillSpec)。这个schema我迭代过好几版,最终保留的字段如下展示:
name:技能的唯一标识,供agent内部编排使用,建议用kebab-case风格,例如order-refund。description:面向模型的自然语言描述,说明何时使用该技能,要写得像任务简报,而不是API文档。parameters:JSON Schema格式的参数定义,用于模型产出结构化调用参数。execute:实际执行逻辑的入口,可以是本地Python函数、远程HTTP调用或命令行脚本。capabilities:技能依赖的底层能力集合,用于依赖解析和冲突检测。state_schema:可选,技能在运行过程中持久化状态的格式声明,支持多轮会话内保持上下文。
一个典型技能声明的JSON大致如下:
{ "name": "order-refund", "description": "处理用户的退款请求。当用户明确要求退款、取消订单、追回已支付金额时使用。需要先核实用户身份,再检查订单状态和退款政策。", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "用户提供的订单号" }, "reason": { "type": "string", "description": "用户反馈的退款原因" } }, "required": ["order_id", "reason"] }, "capabilities": ["user-verification", "order-lookup", "refund-calculator", "payment-operation"], "execute": { "kind": "http", "endpoint": "http://internal-agent-svc/refund/run", "method": "POST" } }2.2 技能描述怎么写才能让模型准确选中
这个部分可能是我整个项目中投入产出比最高的一个点:技能描述的描述质量直接决定模型选技能的准确率。我一开始写得简短,比如"退款技能",结果用户问"我的钱怎么还回来?"模型完全不触发技能。后来把描述改成"当用户表达想要退回已支付的费用、取消订单或申请经济补偿时使用",召回率立刻提升了三成。
描述要避免三个常见错误:一是用名词堆砌而不是场景描述,二是描述过于泛化导致多个技能都匹配,三是只描述功能不写明触发条件。我建议每个技能的作者都在描述里回答两个问题:用户在什么时候会需要这个技能?用户通常会怎么说?把这两个答案写清楚,模型的选择准确率会有肉眼可见的提升。
另外我给技能加了"负向提示":在描述末尾注明"当用户仅咨询退款政策但不执行退款时,请勿调用本技能"。这个小技巧极大减少了误触发——咨询和办理是两回事,模型之前经常混淆。
2.3 依赖解析与技能冲突检测
技能不是孤立的。refund技能依赖user-verification、order-lookup这些底层能力。agent在启动时要建一张依赖图,边是技能之间的依赖关系。注册新技能时我会先跑一遍冲突检测:
need顺序:技能是否依赖当前尚未注册的能力?路径冲突:两个技能是否暴露出相同的名称但实现不同?参数冲突:两个技能是否对同名参数有不同格式要求?
这套检测逻辑不复杂,本质就是一个DAG拓扑排序加上命名空间检查。但少了它,后期维护的时候,随手改某个技能描述很可能会悄悄破坏另一个技能的调用链路。依赖检测在项目刚上线时可能用不上,等技能数量超过15个以后,它就是救命稻草。
我踩过的具体坑是:技能A和技能B都依赖一个payment-operation能力,后来技能B的开发者升级了这个能力,把回调地址换了,技能A的执行链路第二天就全部超时。因为是隐式依赖,日志里根本看不出是能力变化导致的失败,排查了很久。所以依赖关系一定要显式声明,并且每次注册或更新技能时都跑一遍依赖检查,把受影响技能列表打出来。
2.4 动态加载:开发环境热更新与生产环境锁版本
技能注册中心在agent-skills里还承担了动态加载的角色。开发环境下,我通过监听技能目录的变动来动态注册和卸载技能,也就是改完技能描述和逻辑后,不需要重启agent进程就能生效。这个热更新能力对调试体验的提升非常明显,尤其是在一遍遍微调技能描述来改善模型选择准确率的时候,改一行描述等五分钟重启是让人崩溃的。
生产环境则反过来:所有技能版本进入注册中心后自动生成快照,发布时锁定版本号,不允许热更新导致线上行为漂移。这里我设计了一个双轨机制:
- 开发环境使用watcher模式,技能目录变更后毫秒级重新加载;
- 生产环境使用registry模式,从不可变配置读取已发布的技能版本。
有一次我在生产环境误开了动态加载,导致运营同学改了个错别字一样的技能描述,全局agent决策行为立刻变化,用户端出现了好几天的异常回复。从那以后,生产锁版本成了铁律。
3. 技能编排器:从单技能调用到多技能协同
3.1 为什么模型不能直接调用一串技能
理想情况下,一个用户请求可以对应一次技能调用,但实际业务往往是一条长链路。用户说"我上周买的手机壳太大了,想退掉,然后重新买一个小一号的",这个请求拆解后需要执行退款技能、商品查询技能、重新下单技能,还是连续依赖的顺序执行。
如果让模型直接输出这三个技能的一次性调用列表,就存在一个关键问题:第二个技能的输入依赖第一个技能的输出。手机壳的新尺码不是用户直接给的,而是从退款订单详情里推断出来的。所以编排器需要具备"逐步计算"能力:让模型先在当前上下文中决定调用哪个技能,把结果放入上下文,再根据结果决定下一个动作。
这就是agent-skills编排器的核心思路:单步决策+循环执行。模型每轮只给一个决策,系统执行完技能后把结果追加到上下文,再让模型决定下一步。这和把整个流程塞给模型相比,决策准确率高了很多,因为每一步都基于真实返回结果,而不是模型臆想的中间状态。
3.2 顺序、条件分支与并行执行的实现
编排器内部支持三种执行模式:
- 顺序执行(sequence):上个技能输出作为下个技能输入,用于强依赖链路。
- 条件分支(condition):模型根据当前上下文判断是否继续执行/选择哪个分支。
- 并行执行(parallel):多个技能之间无依赖,可以同时发起,缩短总耗时。
顺序执行是最常见的。我在实现时维护了一个task_context对象,它是执行期间所有技能的共享内存。每个技能执行完成后,不仅可以返回给用户的消息,还可以往task_context里写入结构化数据,供后续技能读取。比如退款技能执行完,会把order_item_spec写入上下文,后续的商品查询技能直接读取这个值,不需要用户重新输入。
并行执行的收益在批量查询场景中很突出。用户问"我的订单什么时候到?退款到账了没有?优惠券什么时候过期?",这三个技能互不依赖,并行发起HTTP请求,总响应时间从三秒压到一秒。但并行执行也引入了复杂度:需要处理超时、部分失败、结果合并。我的策略是只要有一个技能失败就整体路由到兜底对话流程,不让模型在部分信息缺失的情况下继续瞎猜。
3.3 上下文窗口管理与中间结果截断策略
多技能协同执行虽然效果好,但会快速消耗上下文窗口。一个技能的结构化返回结果可能是一大段JSON,如果三个技能的返回都原样塞进上下文,再叠加系统提示词和历史记录,很容易撑爆窗口。现实中的token成本也会随之上升。
我采用的策略是三级截断:第一级,技能返回的结构化数据已经转换成摘要化的人类可读文本,丢掉了内部调试字段;第二级,对超过长度限制的结果做截断,并附上"字段列表+字段类型"摘要,这样模型虽然没有看到完整的值,但知道存在哪些字段;第三级,历史记录的高阶层压缩,把多轮之前的详细内容压成摘要,只保留关键实体和决策结论。
这里要特别注意:截断不能无差别截。用户刚提到的订单号、地点、金额这类关键实体必须保留,而那些用户自己已经看过并且不再依赖的纯展示内容,比如详细的商品说明,可以大胆压掉。我靠这个策略把一次长流程调用的平均token消耗压低了约35%,用户感知不受影响。
3.4 失败回退与重试策略的设计取舍
技能执行的失败处理是编排器设计里最考验细节的部分。一开始我的策略是失败就让模型解释:"抱歉,暂时无法完成您的请求"。但这在业务上完全不能接受。后来我做了三层回退:
第一层:技能执行器自动重试幂等操作,比如订单查询、物流状态查询,网络抖动造成的失败会通过重试恢复,重试间隔用指数退避,最多三次。
第二层:重试仍失败时,编排器把错误信息传入模型上下文,让模型判断是否可以换个技能完成任务。比如订单查询技能挂了,但用户可能只需要知道物流进度,物流查询技能还活着,模型应该路由过去。
第三层:模型判断无法替代时,才向用户输出降级话术,并且降级话术要包含用户可执行的替代动作,比如"您可以稍后在我的订单页查看物流更新"。
这套策略上线后,技能调用失败导致用户收到错误消息的比例从12%降到1.5%。但也要注意,幂等判断做不好会导致重复扣款这种严重问题。我的原则是:涉及资金、积分、库存变更的操作一律不做自动重试,失败直接交由人工或用户决策。
4. 实测踩坑记录:技能环路、描述二义性与状态污染
4.1 技能互相调用形成的死循环
这个坑让我印象很深。我设计了一个order-refund技能,它内部依赖user-verification来核验身份,而user-verification技能内部又依赖一个"查询用户是否存在"的底层call。看起来没什么问题。但后来我加了一个兜底技能fallback-human-agent,它被设计成在无法解决用户问题的时候触发。问题来了:fallback-human-agent的描述里写着"当用户需要人工客服处理问题时使用",而order-refund技能里的一段描述写着"当退款流程无法自动完成时,建议用户联系人工客服"。模型在一次复杂退款请求中,先触发了order-refund,发现参数不足,接着触发fallback-human-agent,而这个技能又想调回order-refund来处理退款诉求。两个技能互相呼唤了六轮,上下文被灌满无意义的中间结果,最后token耗尽才停下。
排查这场事故花了大半天。最后我做了两个防循环机制:一是全局执行深度限制,单个任务最多执行8次技能调用,达到上限强制走安全兜底;二是技能调用的去重表,同一个技能在连续三轮内不允许重复调用超过一次,除非用户明确补充了新的信息。这两个限制牺牲了极少部分合法场景的灵活性,但彻底杜绝了技能环路导致的失控。
4.2 技能描述歧义导致模型选错技能
描述二义性是另一个高频问题。我有两个技能:calendar-create用来创建日程事件,reminder-set用来设置提醒。第一次上线后实测,用户说"帮我记一下明天下午三点开会",两个技能的使用次数几乎五五开。用户看到的结果差异是:创建日程会真正写一个日历事件,而设置提醒只是弹一个通知。体验差别非常明显。
我的解决办法是对描述做场景化改写。calendar-create的描述改为:"当用户明确提到会议、日程、约会、事件安排,并且需要把该安排同步到日历时使用。一般会包含时间和参与人信息。"reminder-set的描述改为:"当用户想在未来某个时间点收到通知提醒,而不需要该时间点作为一个日历事件记录时使用。例如设置吃药提醒、还款提醒。"改写之后,测试集上的选择准确率从55%提到了91%。
还有一个补充技巧:我在注册中心加了一个"相似技能检测"工具,每次注册新技能时和已有技能描述两两计算embedding相似度,超过阈值就提示"该技能与已有技能描述相似,请确认是否存在语义重叠"。这个静态检查能帮人提前发现二义性问题,而不是等到线上才开始修。
4.3 技能内部状态在多轮会话间的偷偷污染
技能状态管理是个容易忽视的重灾区。我有一次做了个shopping-cart技能,用户往购物车里加商品,然后切走聊了别的,过了很久回来问"我现在购物车多少钱?",结果金额算出来完全不对。排查后发现,shopping-cart技能会在会话内保持一个状态变量,记录用户加的每件商品。但另一个discount-check技能在计算优惠时,会把优惠价覆盖到同一个状态字段上,污染了原始价格数据。
技能之间的状态隔离是必须做的。我的方案是给每个技能执行分配一个独立的作用域(scope),只允许技能访问自己声明过的状态字段,跨技能共享必须通过task_context的显式共享区,而且共享区只放纯数据,不能放可变引用。这个改动让状态类bug几乎绝迹,也让我对多技能协同的调试重新有了掌控感。
4.4 token有限下的长技能输出优化
长技能输出是最后提到的一个坑。有一些技能返回的结果非常膨胀,比如inventory-check会把仓库里几百个SKU的库存全部返回。模型根本不需要这么多数据,但返回结果全塞进上下文,既浪费token又干扰模型判断种子项。我调整了技能输出协议的约定:技能返回的结构化数据必须附带summary摘要字段,编排器优先使用摘要字段,而不是全文。
现在我在技能开发规范里强制要求:所有技能返回必须包含一个不超过200字的中文摘要,用于模型决策;完整数据走旁路存储,只有用户明确要求时才追加加载。这是一个性价比极高的优化,既延长了上下文的有效使用长度,又降低了模型被海量低质信息干扰的概率。
5. 技能可观测性与迭代闭环
5.1 每一次调用的trace应该记录哪些字段
无观测不优化。技能系统上线两周后,我发现最痛苦的不是写技能,而是不知道线上技能到底表现怎么样。于是我给编排器加了一层trace埋点,每条技能调用记录以下字段:
request_id和session_id:关联同一次任务的完整链路。user_intent:经过归因抽取后的用户原始意图摘要。selected_skill:模型本次选中的技能名。skill_confidence:如果是置信度阈值触发的技能选择,记录置信度。execution_time:技能从发起到返回的总耗时。parameters:模型生成的技能参数。result_summary:技能返回的摘要字段。error_reason:出错时的错误分类,如超时、参数错误、引用不存在。context_usage:执行前后上下文token占用变化。
这些数据落成JSON日志,进ClickHouse,最后在Grafana上做看板。有了trace之后,很多优化就有的放矢了。比如你发现某个技能的选择率极低但描述却不差,那就很可能是因为技能描述里的触发场景和实际用户话术不匹配,需要重新改写描述。
5.2 基于真实会话数据修正技能描述的迭代方法
我想分享一个实操技巧:不要靠感觉改技能描述,要拿历史会话数据做回归测试。我的做法是每天从真实日志里抽取覆盖各技能类型的100条代表性session,人工标注正确技能选择,形成一份"技能选择基准集"。每次修改技能描述或新增技能后,在基准集上跑一遍模型决策模拟,看选择准确率是否下降。
第一次这么跑,结果让我很意外。我以为优化得很好的描述,在基准集上准确率居然只有74%。问题在于我优化时只关注了典型话术,没有覆盖到那些含糊的、口语化的、带情绪的表达。比如用户说"这也太贵了吧",理想情况应该触发discount-check来向用户解释促销方案,但因为描述里没写"贵、价格、优惠"这类触发词,模型没有选中。后来我专门把描述写成包含常见表达变体的形式,准确率从74%提升到86%。
这个"基准集+回归测试"的闭环已经把技能描述当作一块正经的代码来对待了。每次改动都得过测试,这听起来繁琐,但长期下来省下的排查时间远大于写测试的时间。
5.3 新技能灰度上线与自动回滚机制
技能上线也需要稳妥的发布流程。我是按比例灰度做的:新技能先只在5%的会话里开放,观察48小时的trace数据,确认选择率、执行成功率、用户负面反馈率都达标后,再逐步提升到30%、70%、100%。
回滚机制做得比较轻量:每个技能在注册中心都有一个版本号,配置里指定当前生效版本。如果灰度期间发现某个版本触发异常行为,只需把配置里的版本号回退,滚动生效。因为技能描述是文本性的,回滚成本极低,不需要代码发布流程。
自动回滚的触发条件我设了三个硬指标:
| 指标 | 阈值 |
|---|---|
| 技能执行成功率 | < 85% |
| 技能选择后用户5分钟内负面反馈率 | > 20% |
| 单次技能调用的平均期望输出token偏差 | > 60% |
任何一个指标触发,编排器就会自动把该技能的流量切回前一个稳定版本,同时告警到开发者群。这套机制上线后,我再也没有被凌晨的电话吵醒过,技能版本的回滚基本在用户感知之前就完成了。
5.4 trace日志驱动的技能依赖关系图谱化
最后一个环节是把trace聚合起来,生成技能依赖关系图谱。因为生产环境的技能调用链往往比开发时设计的更复杂:有些技能执行时额外触发了编排器没有显式声明的内部依赖,这是通过模型的基于上下文的动态决策触发的。通过聚合trace中的selected_skill和next_skill关系,我能在线上数据里看到真实热度图和调用链路,发现哪些技能经常被连续调用,哪些技能是某些用户在特定任务中的必经节点。
这些信息对技能架构演进很有价值。比如我发现order-refund之后经常跟着product-recommend——用户退完款马上被推荐了替代品,这个高频路径在开发时根本没想到。由于聚合明显,我后来直接把两个技能组合成一个高阶技能refund-with-recommendation,既减少了模型单步决策的次数,又缩短了用户完成全流程的路径。这是数据驱动技能设计的一个范本案例,也是这套可观测体系最大的回报。
我个人在实际操作中的体会是:技能系统的价值不在于代码多花哨,而在于把agent的边界从模型本身挪到了一个可以结构化演进的位置。每次业务要加能力,我不用改提示词,不用碰模型参数,只需让一个领域专家按规范写出一份技能声明就能上线。这套模式在20个技能以内时看不出太大优势,但技能数量开始膨胀之后,它的架构红利就完全释放出来了。技能的拆分粒度、描述风格、状态边界,都是在一次次线上事故和trace回看中逐步迭代成形的,没有任何一行设计是拍脑袋想出来的。如果你的agent项目也开始遇到提示词膨胀、工具管理混乱、行为不可控的问题,不妨试试按这个思路把能力拆成技能,让模型只做决策,让技能负责执行。