前阵子一直在折腾agent-skills这个话题也就是给智能体设计一套可复用、可编排、可治理的技能体系。不少朋友一听到“智能体技能”就下意识觉得这是提示词工程稍微多写点描述、给它挂几个工具就算完了。真正扔进业务场景里跑起来才发现技能设计直接决定了智能体的上限模型再聪明技能接口一团糟照样给你演一出“会聊天但不会干活”。这篇把我在技能接口设计、编排链路的实际落地、以及后续治理维护上的经验整理出来给同样在搞智能体技能的人做个参考。1. 从“会聊天”到“会干活”智能体技能到底指什么1.1 技能的本质是一个闭环我理解中的智能体技能核心是一个闭环大模型根据用户意图和现有信息生成对这个技能的调用参数外部系统实际执行动作再把执行结果返回给模型模型继续推理下一步。这个闭环里真正干活的不是模型而是技能背后的业务能力——查订单、发短信、改配置、计算价格全都是真实世界的操作。模型负责的是“决定怎么调用”以及“理解调用结果”。所以技能并不等于一段函数它至少应该包含三样东西能力描述让模型知道这个技能是干什么的、什么时候该用、什么时候不该用。参数协议模型需要传哪些字段进来每个字段的取值范围和约束是什么。结果协议技能执行完返回什么样的结构化数据成功长什么样失败又长什么样。这三部分缺一个都不行。能力描述写得含糊模型就会在不需要的时候乱调参数协议不清模型生成的参数十有八九解析不了结果协议不稳定模型拿到字段后根本不知道该往哪走。打个比方这就好比给一个新人安排工作。你不能只告诉他“你去处理一下订单”而是得说清楚订单在哪里查、按什么条件查、查不到怎么办、查到了之后是什么格式给你。智能体技能就是这个“新人工作说明书”只不过读者变成了模型。1.2 技能和提示词、插件有什么边界很多人会把技能和提示词混在一起实际上两者的定位差别很大。提示词改变的是模型的输出风格和思考方式但它不能直接触发外部动作。技能则是把“模型想做什么”和“系统能做什么”连在一起的那根管道。你可以用提示词告诉模型“说话礼貌一点”但你不能靠提示词让系统真的去扣一笔款那必须有技能。至于插件更偏重的是外部系统适配层负责把外部系统的接口包装进来。技能则站在模型侧关注的是“模型如何理解和使用这个能力”。一个插件通常会暴露成一个或多个技能而一个技能内部也可以调用多个插件接口。换句话说插件是底层设施技能是模型视角的入口。这块我在设计技能目录的时候特别有感触。当时给某电商业务方的客服智能体做技能规划业务方一口气列了三十几个能力查订单、查物流、改地址、申请售后、发优惠券、转人工、查询会员等级、猜用户喜好……真去梳理的时候发现里面至少三分之一是可以合并的还有几个能力根本不该暴露给模型。因为模型选技能靠的是描述理解技能数量越多选择出错的可能性越大。宁可少而精也不要多而乱。2. 技能设计的第一原则把不确定性挡在函数边界之外2.1 大模型生成的参数不能直接信做技能设计我会先立一个原则**任何从模型侧进入技能的数据默认都是不可信的。**这不是对模型有偏见而是模型生成参数这件事本身就是概率性的哪怕今天你测试一百次都正确明天换一个用户问法、换一种上下文它就可能给你生成一个错的枚举值、漏掉一个必填字段、甚至编一个根本不存在的参数名。所以技能入口必须做参数规范化。我通常会把参数处理拆成几步第一步是做 schema 校验定义好每个字段的类型、是否必填、枚举范围。缺了什么、多了什么、类型不对都要在这里被拦截。第二步是枚举映射模型经常返回自然语言而不是标准码比如技能要求传channelonline模型可能传一个渠道线上那就需要做一层归一化。第三步是兜底解析针对常见格式问题做一些修复比如 JSON 里多了一个逗号、字符串带了空格、日期格式不对这些都可以用规则修掉不用把脏数据往业务系统里传。用一段简化代码来展示这个边界长什么样def normalize_skill_params(raw: str, schema: dict) - dict: # 1. 尝试解析成 JSON 对象 try: obj repair_json(raw) except JsonRepairError: raise SkillParamError(参数不是合法JSON) # 2. 字段过滤与补全 allowed set(schema.keys()) obj {k: v for k, v in obj.items() if k in allowed} # 3. 类型与枚举归一 for field, rule in schema.items(): if field not in obj: if rule.get(required): raise SkillParamError(f缺少字段 {field}) continue obj[field] coerce_by_rule(obj[field], rule) return obj这个边界最大的好处是技能内部只需要处理“正常的、干净的对象”其余精神污染全部在外面解决。业务系统那边的同学也不用整天因为“模型传了一个奇怪参数”来找你调试成本会急剧下降。2.2 出口必须结构化自由文本是灾难技能返回给模型的内容同样要设计。很多初版技能图省事直接把下游接口的原始响应或者一段拼接好的字符串丢给模型结果模型读起来困难后续决策几乎全靠猜。我比较推荐的做法是固定一层结构化返回对外只暴露必要字段。比如查物流下游系统返回一大堆 JSON里面有内部节点代码、底层仓库编码、各种时间戳。技能真正需要给模型的是“当前节点名称”“签收状态”“预计送达时间”这三四个字段那我就只往返回体里放这几个字段。字段名也尽量稳定不要今天叫status、明天叫state否则模型学起来成本太高。返回结构我习惯统一成下面这个样子{ result: success, data: { }, error_code: , user_message: }result只允许两个值success和failed。data放结构化数据必须是纯 JSON不能往里塞大段自然语言。error_code给系统看user_message给用户看。当结果是failed时user_message一定要写成用户能看懂的话——“您的账号没有权限查看该订单”而不是“ERR_PermissionDenied”。模型如果要把结果转述给用户它可以直接把user_message当作底稿这样既准确又自然。顺便提一句很多人会纠结要不要让技能返回“给用户的最终话术”。我的建议是不要让技能输出最终话术技能只负责提供事实怎么组织语言是模型的事。否则每次技能改个措辞都要重发模型模型的任务也从“理解结果”被污染成了“背话术”反而更容易出幻觉。2.3 失败也要成为可理解的状态技能调不通怎么办这个问题我在前期设计时常常忽略等到联调的时候才发现代价很大。技能的失败不应该表现为“抛个异常就完了”。模型拿不到任何结构化反馈就只能自己瞎编告诉用户“系统正在升级”而真实情况可能是上游超时。要让失败变成一种可被模型理解和决策的状态我在技能里通常要处理四类失败参数类失败必填字段缺失、枚举值非法。这属于模型没理解它应该自己修正参数再调一次。权限类失败当前用户没有权限或者登录态失效。这属于流程问题模型应该引导用户登录或转人工。上游业务失败下游明确返回失败比如库存不足、订单已取消。这属于正常业务状态模型应该把结果告诉用户并提供备选方案。系统类失败超时、熔断、服务异常。这属于不确定状态模型不应该反复重试而应该告诉用户“暂时无法获取请稍后再试”或直接转入人工。每一类失败在error_code上要有清晰的语义区分。我在真实项目里遇到过最无语的情况是技能超时了模型以为是自己参数不对重新生成参数再调结果又超时白白浪费十几秒。这就是失败分类没做好导致的连锁问题。把“不可重试”的失败明明白白标出来模型就不会在一条死路上死磕。3. 技能编排从单步调用到多步协作3.1 五种常见的编排模式单个技能再完善也解决不了复杂的用户问题。大多数真实任务需要多个技能配合起来才能完成。我梳理一下自己项目中常见的编排模式基本就是下面几类模式场景示例串行后一步依赖前一步的返回结果先查库存再查询价格最后生成报价单并行多个技能互相不依赖同时发起同时查机票和目的地天气条件分支根据当前上下文决定走哪个技能路径根据用户是否是会员走不同定价逻辑循环同一个技能反复调用直到满足条件分页翻完所有订单直到没有下一页父场景先确认意图再决定后续技能组先判断用户想退还是想换再进入对应流程实际项目里不会只用某一种而是一个大的任务往往由多个模式嵌套而成。模型先识别意图进入一个节点节点内部再做串行或并行调用最后汇总。我最初犯的错误是把编排逻辑写得太重坚持要让模型在一个大循环里自己决定“下一步做什么”结果模型经常陷入死循环反复调同一个技能。后来我把解决思路调整为模型只在“确认大方向”时做选择具体顺序由编排引擎用确定性规则控制。这个改动让整个系统的稳定性和可解释性都上了一个台阶。3.2 上下文传递中间产物不要全部塞给模型多步骤编排里最容易被忽视的是中间产物管理。三个技能串起来每一步都会产生一个结果如果全部塞进对话上下文不仅占用大模型上下文窗口还会引入大量噪声导致它在后续步骤里抓不住重点。我的做法是使用工作区workspace来管理中间产物。每个编排任务都有一个独立工作区里面用变量保存中间结果比如workspace[order_no]、workspace[inventory]。技能需要什么参数直接从工作区取而不是让模型每次都从对话历史里回忆。这相当于给智能体配了一个“草稿纸”。步骤结束之后是否把结果回写到对话历史由编排节点显式决定。通常只有“用户可见的关键信息”和“下一步需要的简短摘要”会回到上下文其余过程性数据全部留在工作区里。顺便说个细节工作区里的变量要区分“任务级”和“用户级”。任务级变量是当前这次对话的一次性数据用户级变量是跨会话的用户画像和偏好。两者混在一个命名空间里很容易出现 A 用户的数据串到 B 用户身上的事故。这个我做权限审计的时候检查过多次属于高危雷区。3.3 提前终止与人工介入的闸门编排不是只能一路走到黑必须有闸门。第一个闸门是意图置信度。模型判断用户想退款如果置信度很低、还同时给了好几种可能那就不要直接进入退款流程而是先回问一句“您是要办理退款吗”这是把决策权拉回来减小误操作面。第二个闸门是敏感操作确认。扣款、发送短信、删除数据这类高风险技能在执行前必须显式让用户确认确认后再调用技能。第三个闸门是转人工。同一个技能在编排中连续失败两次或者用户情绪识别为负面就应该直接转人工而不是让模型继续折腾。这里我想强调一下编排引擎要有一个全局的步数上限和耗时上限。真跑起来你才会发现模型有时候会在两个技能之间反复横跳。没有步数上限它会无限循环浪费额度不说用户体验也彻底崩掉。我在某项目中设置的是单次任务最多 6 步技能调用超过了就强制收敛要么转人工要么把当前进度整理成小结让用户确认是否继续。这个参数可以根据场景调但一定不能没有。4. 我在实际项目中踩过的几个技能落地坑4.1 技能返回体过大把模型上下文撑爆了做第一个版本的时候我天真地觉得返回数据越全越好模型拿着全量数据判断起来更准。于是查订单这个技能一次性把用户近三个月的 20 条订单全返回了字段还特别多包括收货地址、支付流水号、商品详情的长文本描述。结果模型一口气读了几千 token反而忽略了真正重要的“订单当前状态”用户问“我的快递到哪了”它答非所问。后来我把返回结构改成分层摘要默认返回订单列表的精简摘要只包含订单号、时间、状态、金额这几个最核心字段模型如果想看某一个订单的完整详情就再调用一个get_order_detail技能。这个改动之后上下文占用直接降了 70%模型抓取关键信息的能力明显提升。这个教训我总结成一句话**技能的返回体是给模型看的不是给审计日志用的。**完整数据要存储、要记录那是另外一条链路的事不要都堆到模型面前。4.2 技能描述写得像模型就炸了技能描述看着是小事实际上直接决定模型能不能选对。当时我手上有两个技能一个叫query_weather一个叫query_air_quality。前者描述里写了“查询天气情况”后者写了“查询空气质量”。听起来没问题对吧但真跑起来模型经常在空气质量场景调用天气技能或者在天气场景把两个都调一遍。排查下来发现两个描述里都提到了“天气”这个词而且没有把使用场景、不适用场景写清楚。模型理解名词的时候是靠语义相似度的两个技能语义太接近它就分不清楚边界。解决方案有两个思路。一个是为描述写正反例比如在query_air_quality描述里明确写“当用户问PM2.5、AQI、雾霾时使用不要用于普通晴雨温度查询”。另一个是考虑合并技能如果两个能力都来自同一个上游接口干脆合成一个query_environment技能通过不同参数区分天气和空气质量。我后来的经验是技能列表里语义重叠度超过 60% 的就要警惕能合并先合并合并不了就靠反例描述强行拉开距离。4.3 技能不是纯函数重试会闯祸在设计初期我一直默认技能是无副作用的调两次和调一次效果一样。直到某次模拟用户充值用户在支付页面点了两次“确认付款”模型判断第一次调用超时了又发起重试结果同一笔扣款发生了两次。这个事故让我意识到技能必须显式处理幂等性。具体做法是在高风险技能内部加幂等键机制。调用方传入一个唯一的trace_id服务端在首次执行时把trace_id和处理结果一并持久化后续如果同一个trace_id再来直接返回第一次的结果不重复执行。这套机制对支付、发券、下单这类技能特别关键。另外超时后的重试策略也不能只靠模型自己拍脑袋最好在技能层面对“超时任务是否允许重试”做出明确标记不允许重试的时候模型就只能走人工确认流程。4.4 并发和权限是最后才想到的洞技能上线初期是单用户、单会话测试一切都好。一旦上了生产的并发环境问题就来了同一个用户同时在两个会话里操作两个会话各自调用了修改类技能先后校验都通过结果数据互相覆盖。这就引出一个协作模型设计的问题技能调用时必须携带完整的调用上下文而不是只带业务参数。我在技能接口里加了caller结构里面至少包含用户唯一标识、当前会话标识、权限级别、来源渠道。技能内部所有涉及身份的操作都从caller里面取身份绝不信任大模型生成的user_id参数——不然模型把别人的 ID 传进来看订单怎么办。权限校验也必须下沉到技能内部不能只在编排层做一次。因为多步编排中某个技能可能会被其他技能动态调用权限边界稍微漏一条就会出现越权。并发这一块我的经验是凡“读改写”型操作都要加条件更新Redis 里可以用乐观锁版本号数据库里可以用select ... for update实在不行就在技能里串行化处理。宁可牺牲一点吞吐也不要做脏数据。5. 技能的治理与维护注册表、评测和灰度5.1 技能注册表技能一旦超过二十个就失控不要觉得技能只有几个就不用治理。等你这边做了天气技能、那边做订单技能业务方再塞几个进来技能数很快会超过二十个。这个时候没有一份稳定、可查的注册表整个系统的技能就变成了一堆没人说得清的暗接口。我维护的技能注册表大概包含这些字段技能唯一 ID、显示名、当前版本、能力描述、输入参数 schema、输出结构版本、所属负责团队、下游依赖系统、幂等策略、风险等级、上线日期、状态线上/灰度/下线。这里面最容易被忽略的是“风险等级”。我一般把只有读操作、无敏感信息的技能标记为低风险涉及修改、可见他人隐私的标记为中风险涉及资金、账号变更、大规模群发的一律标为高风险。高风险技能的变更必须走人工审核不允许模型通过自描述的方式动态调整。5.2 评测集改技能描述之前先想好怎么证明没改坏很多团队做技能是一锤子买卖上线了能跑就行后续改个描述、加个参数改完就上也不验证。这个习惯是很危险的。模型对技能的调用准确率会随着描述改动剧烈波动可能你觉得只是改了个措辞结果把原来正确的调用路径全部带偏了。我的习惯是每个技能都配套一个评测集里面存放大量“用户请求文本 应该选中的技能 期望参数”的测试用例。改技能描述之前先拿评测集回归一遍。为了锁住行为评测集里还专门放一些“不该调这个技能”的负例。比如给query_air_quality放一条“今天适合穿什么衣服”期望结果就是“不调用任何技能直接反问或推荐冷知识”。有这套评测集在改动的时候才敢上线。评测集也要定期扩充每次线上出现误调用的案例不要只修一个 case而是要把相似的案例沉淀进评测集。这样把日积月累的 bad case 变成资产而不是永远在救火。5.3 灰度放量与日志走查缺一不可技能上线之后的运维比开发更磨人。我现在做的流程是新版本技能先在内部用户环境跑几天重点看调用成功率、平均耗时、异常率没有明显问题再用白名单方式放给一小部分真实用户最后才全量。每个阶段如果出现异常直接切回上一个稳定版本。日志走查是我认为最值得投入的一个环节。每一条技能调用我都会记录下模型当时的输入上下文片段、它选择的技能 ID、生成的参数、技能实际执行结果、模型对结果的下一步判断。这五样东西记录下来就能完整复盘一次决策到底是怎么发生的。很多问题不日志根本找不出来比如模型选了技能但参数全错、或者明明结果失败了模型还给用户说成功。没有日志这类 bug 就像幽灵一样。最后说一个我后来才养成的习惯技能描述和参数协议一定当成代码来管要进版本库、要被 review、要留变更记录。不是拍脑袋改完了就上而是像对待对外 API 一样严谨。毕竟对外 API 的调用方是人类开发者而技能这个 API 的调用方是一套在概率世界里摸索的大模型它一旦被带偏纠错的成本可比改代码高多了。