去年下半年,我把大部分开发时间都投进了一个叫 agent-skills 的代码库。名字起得很简单,做的事情却不小:让大模型不再停留在“会聊天”,而是能安全、稳定地调用一堆真实业务技能完成任务。无论订单查询、退款计算还是排班调整,每个能力都被封装成独立的技能模块,智能体在回答问题时按需选择、调用并执行。这篇文章就是把我在这个项目里踩过的坑、沉淀下来的设计和实践心得完整梳理一遍。如果你正在做 AI 应用或智能体开发,或者正准备在业务系统上接入大模型能力,这篇内容应该能给你一套可以参照的落地路径。
1. 项目起源:给智能体“装手”之前,我踩过哪些坑
1.1 第一版教训:API 调用写对了,业务却总是答非所问
最早我们团队接了一个售后客服场景:用户问订单什么时候到、退款到没到账、发票怎么开。当时的做法非常朴素,把全部 API 说明塞进系统提示词,让模型自己判断该调哪个接口。结果上线第一周就出了不少问题。
最常见的翻车场景是参数混淆。比如用户说“我要查一下上个星期的退货单”,模型能识别出需要调用查询接口,却把order_id和return_id搞混,查出来一个不存在的记录,最后回复用户“未查询到相关信息”。更气人的是,这种错误不是偶发,同一个问题换几个说法就可能触发一次。原因在于提示词里的 API 描述只是“给模型看的一段文本”,它没有强约束,也没有反馈闭环。模型本质上是在靠着模糊语义猜测接口用法,猜错了它自己也不知道。
还有一类问题是模型过于自信地补全参数。用户只提供了手机号,模型却“贴心”地编造了一个订单号填进参数里,接口调用失败后又换一个编号继续试。这些错误在日志里看起来都很零散,但统计下来非常可观——我们当时粗算了一下,大约有 30% 的 API 调用错误属于参数错误或选错接口。
1.2 agent-skills 的定位:在模型和 API 之间加一层可治理的机制
踩了这些坑之后,我意识到问题不是模型能力不够,而是我们缺了一层“技能管理层”。这层东西要能做到三件事:
- 把每个 API 封装成一个有完整契约的技能,包括名称、功能描述、输入输出格式、出错时的标准化反馈,而不是把接口说明扔给模型去自由发挥。
- 在调用前后加校验和兜底逻辑,参数不齐就补、格式不对就拦、调用失败就换方案,避免模型在错误路径上一次又一次地空转。
- 让所有技能可测试、可灰度、可回滚,不能像改提示词那样靠“感觉”上线。
agent-skills 这个名字里的“skills”指的就是这一层能力单元。每个技能不是一个简单的函数封装,而是一个包含文档定义、输入 Schema、执行函数、测试用例和安全策略的四位一体模块。它解决的核心问题是:当模型和真实系统对话时,怎样保证这个过程可控、可观测、可纠错。
2. 技能模块的核心设计:从原生函数到可被模型理解的描述
2.1 一份技能清单应该包含哪些字段
我见过很多初版设计,技能就是一个函数名加一句注释,然后靠提示词把函数列表扔给模型。前期没问题,技能一多就乱了。我们后来把每个技能的数据结构固定成下面这样:
@dataclass class SkillSpec: name: str # 技能唯一标识,如 "query_order" description: str # 给模型看的用法说明 input_schema: dict # JSON Schema,描述每个参数 handler: Callable # 实际执行业务逻辑的函数 version: str = "1.0.0" # 技能版本 namespace: str = "common" # 所属领域/命名空间 tags: list[str] = [] # 检索标签 examples: list[dict] = [] # 典型调用示例 security: dict = {} # 权限、限流、审计配置这里最容易被忽略的是description的写法。很多人写“查询订单信息”,这种描述是给程序员看的,模型看了并不清楚“什么时候该用”。后来我们总结出一个写法原则:描述要写“在什么情况下用这个技能”,而不是“这个技能是什么”。对比一下:
- 差:查询订单信息。
- 好:当用户询问订单状态、物流进度、发货时间、预计送达时间等与订单履约进度相关的问题时使用。若用户提供订单号则直接传入 order_id,否则用手机号或用户 ID 查询。
这种描述把触发场景、参数来源和边界都讲清楚了,模型选技能的准确率明显提升。
examples字段也很重要。模型对抽象描述的理解不稳定,但给它一两个具体的输入输出例子,它就能很快对齐。我们的每个技能至少配两组示例,一组是标准调用,一组是边界情况(比如查不到记录时怎么返回)。
2.2 输入参数 Schema:决定模型会不会用错的菜谱
输入 Schema 是技能契约里最关键的一环。我们使用 JSON Schema 定义,因为主流模型厂商的工具调用格式基本都兼容它。一个典型的订单查询技能长这样:
{ "name": "query_order", "description": "当用户咨询订单状态、物流进度、发货/送达时间时使用", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,格式通常为 16 位数字。若用户只提供了手机号,该字段可以不传" }, "mobile": { "type": "string", "description": "用户下单手机号,用于通过手机号反查订单,格式为 11 位手机号" }, "page": { "type": "integer", "description": "页码,默认 1", "minimum": 1 } }, "required": [], "oneOf": [ {"required": ["order_id"]}, {"required": ["mobile"]} ] } }这里用oneOf约束“订单号和手机号至少传一个”,是一个很实用的技巧。模型有时候会贪多,两个参数都编一个出来,加上这种约束后它能明白“只需满足其中一条路径”。
写参数描述时,不要用缩写,不要把内部字段名直接暴露给模型。我们曾经有个技能叫getOrderByUID,参数叫u,模型永远猜不出u是什么。改名为query_order、参数改为user_id后,准确率一下子飙升。还有枚举值一定要列全,不列全模型就会开始幻觉出一些不存在的取值。
2.3 结果返回结构的标准化
技能执行后的返回结果同样需要统一。我们定了一套结构化返回格式:
{ "status": "success" | "failed" | "retryable", "data": { ... }, # 业务数据(成功时) "error_code": "ORDER_NOT_FOUND", # 错误码 "user_message": "没有查询到该订单,请确认订单号是否正确" # 可直接回复用户的话 }user_message是很多人会漏掉的设计。当技能内部已经知道“查不到”时,它可以直接生成一句用户能看懂的话。模型拿到这句话,就不需要再编理由了。retryable状态则告诉模型:这次失败是临时的,你可以换个参数再试,而不是鲁莽地不断重试。这套结构让下游的 Agent 行为可预测了很多。
3. 运行时调度与异常恢复:模型说错话时,系统怎么兜底
3.1 function calling 链路中常见的三类失败
技能定义好了,真正的挑战在运行时。模型调用工具这个过程,看起来是一个标准的 function calling,但实测中最常翻车的是三类:
第一类是参数幻觉。模型会生成一个看起来合理但实际不存在的参数值,比如用户说“查一下我上个月的订单”,模型直接编一个order_id: "202502001",但这个号码根本没存在过。第二类是参数缺漏。模型识别出要调用某个技能,却漏掉了必填参数,导致下游接口 400。第三类是死循环重试。技能抛错后,模型不换策略,而是用同样的参数再调一遍,连续失败四五次后才放弃。
这三类问题不是靠调提示词能根治的,必须在技能调用层加硬校验。
3.2 参数校验与自动修复
我们实现了一个前置校验器,核心逻辑是:模型生成工具调用参数后,先不直接执行,而是先跑一遍 JSON Schema 校验。如果校验失败,系统做两件事。
首先尝试自动修复:根据缺失参数的名称,从全局上下文里找候选值。比如模型漏了order_id,但对话历史里用户提到过“订单号 2025020112345678”,那我们就把它补进去。这种“上下文补参”的成功率不错,比让模型自己发现缺参再补要快得多。
如果无法自动修复,就把校验错误返回给模型,并附上明确的补充指引。我们的返回消息大概是:
调用技能 query_order 时缺少参数 mobile。用户最近一条消息中的手机号是 13800138000,是否使用该值?请重新生成完整的调用参数。这里有一个关键点:返回给模型的错误信息必须指出“缺了什么、应该怎么补”,而不是简单抛一个validation error。模型对模糊报错的处理能力很差。
3.3 三级降级链路:深度技能、轻量技能、固定话术
即使参数校验通过了,技能执行时仍可能失败,比如下游系统超时、数据源返回异常。我们设计了三级降级策略,用一张决策表来管理:
| 级别 | 触发条件 | 处理方式 | 示例 |
|---|---|---|---|
| L1 深度技能 | 主技能调用成功 | 返回结构化数据给模型 | 调订单中心查询订单详情 |
| L2 轻量技能 | 主技能失败但存在替代数据源 | 调用简化版接口,只返回关键字段 | 查缓存/只读库,仅返回状态和日志时间 |
| L3 固定兜底 | 连续失败或数据不一致 | 返回确定性话术,停止工具调用 | “受系统影响暂时无法查询,请稍后重试” |
降级不是无限尝试的代名词。我们在技能调用层加了一个熔断器:同一个技能在 30 秒内连续失败 3 次,就自动切换为 L3;在 5 分钟内错误率达到 50%,则直接停止该技能的自动调用,避免模型在故障系统上反复空转浪费时间和 token。
这套机制上线后效果立竿见影。之前那种“模型在同一接口上死磕五次”的情况基本消失了,平均单轮对话的工具调用次数从 2.7 次降到了 1.4 次,响应时间也快了很多。
4. 技能仓库的组织方式:权限、版本、冲突与冷热分层
4.1 命名空间划分:别把技能全部平铺在一起
技能数量到 30 个以上的时候,模型在选择技能时已经会出现“选择困难”。把所有技能平铺在一个大列表里,不仅增加模型的理解负担,还容易让它在语义相近的技能之间犹豫不决。
我们最终按业务域划分命名空间,每个命名空间是一个独立子模块:
agent-skills/ ├── order/ # 订单域 │ ├── query_order.py │ ├── cancel_order.py │ └── change_address.py ├── payment/ # 支付域 │ ├── query_refund.py │ └── apply_refund.py ├── aftersales/ # 售后域 │ ├── create_return.py │ └── track_return.py └── common/ # 通用域 ├── transfer_human.py └── faq_search.py这听起来像最简单的代码组织,但在 Agent 场景里有额外意义:模型在收到用户请求后,会先经过一层“意图路由”,预测它属于哪个命名空间,然后只把该命名空间下的技能列表注入提示词。这样上下文窗口里技能数量少、语义相近、模型选择压力小。
4.2 版本管理与灰度:改一个技能不拖垮整个对话
技能本质上和一段线上代码没有区别,改坏了会影响真实用户体验。最初我们很随意,直接改代码上线,结果某个同事把“查询订单”的技能改成了默认只返回最近一单,导致用户查历史订单时永远返回错误结果。好在那次只是测试环境。
之后我们建立了技能版本管理机制。每个技能可以同时存在多个版本,线上流量按比例分配:
| 版本 | 功能变化 | 灰度比例 | 回滚策略 |
|---|---|---|---|
| v1.0.0 | 基线版本 | 20% | 全量回退 |
| v1.1.0 | 新增物流轨迹字段 | 30% | 失败率超过 8% 自动回退 |
| v1.2.0 | 优化描述文案 | 50% | 参数解析错误率超过 5% 自动回退 |
灰度期间我们重点看两个指标:技能调用成功率和用户侧满意度。一旦发现某项指标下降,就把流量切回稳定版本,整个操作改配置即可,不需要重新发布代码。
4.3 冷热技能分离:技能太多时先检索再注入
当技能总数超过 60 个以后,光靠命名空间还是不够。十几个技能虽然不多,但如果每个都带长长的 JSON Schema,上下文窗口会被迅速吃掉。我们的做法是把技能分为冷热两档,热技能(最近 7 天调用次数高的)直接随提示词注入,冷技能则通过一个检索层按需加载。
检索层的实现不算复杂:把每个技能的name + description + tags + examples拼成一段文本,做 embedding,然后和用户当前问题计算相似度,召回 top-5 候选技能。同时保留一组规则映射(比如用户消息里包含“退货”就强制召回create_return)作为兜底。这套“向量召回 + 规则强制”双通道,保证了技能数量再大,单次对话真正暴露给模型的最多也就十来个。
5. 真实效果复盘:用数据说话
5.1 离线评测集:让每个技能改动有据可依
技能层面的改动如果没有评测集,回归成本非常高。我们做了一个固定的离线评测集,包含 100 条典型用户问题和 50 条边界问题,覆盖订单查询、退款申请、发票、地址修改等主要场景。每跑一次版本,都把这些问题重新过一遍,然后记录三个指标:
- 技能选择准确率:模型是否选中了正确的技能。
- 参数准确率:生成参数是否能被 Schema 校验通过。
- 任务完成率:最终是否给出了用户可以接受的答复。
下表是我们某一次从“纯提示词方案”切换到 agent-skills 框架后的对比数据,虽然不是业界标准,但能看出量级差异:
| 指标 | 纯提示词方案 | agent-skills 方案 |
|---|---|---|
| 技能选择准确率 | 71% | 92% |
| 参数准确率 | 64% | 89% |
| 任务完成率 | 58% | 84% |
| 平均工具调用次数/轮 | 2.7 | 1.4 |
| 单轮平均耗时 | 3.2s | 2.1s |
这里要说明,这套评测集的问题难度是固定的,数字只能反映我们业务场景的相对变化,不能外推成通用标准。但趋势是确定的:当模型不再“自由猜测接口”后,准确率的提升非常直接。
5.2 两个印象最深的调试案例
第一件是金额精度问题。某个退款相关技能返回浮点数金额,模型在回复用户时偶尔会写成 9.999999 这类值,虽然罕见,但发生在退款金额上会出大事。查了半天发现是技能内部把以分为单位的整数转成了 float 再返回,丢失了精度。修复很简单,全部统一成“以分为单位的整数”作为传输格式,再由前端做格式化。这个教训让我们规定:涉及金额的技能一律用整数分存储和传输,不允许出现浮点数。
第二件是技能误匹配问题。用户说“我不想用这个订单了,帮我取消”,我们的意图路由把“取消”匹配到了create_return(创建退货单),导致系统问用户要退货原因,用户很恼火。后来我们在两个技能的 description 里明确做了“区分提示”,并且在examples中加入相似问法的反例,这类误判率才降下来。给技能写清楚“和哪些技能容易混淆、边界在哪”,比单纯把描述写长更有用。
5.3 线上日志里的意外收获
接入 agent-skills 后,我们发现一个很意外的收益:排查问题变得容易多了。以前模型直接调用 API,出错后只有一行请求日志,很难定位是模型理解错了还是接口参数错了。现在每个技能调用都有完整的“意图→候选技能→选中技能→参数校验→执行→结果返回”链路日志,复盘一个问题只需要几分钟。这也是技能化设计带来的隐性价值——可观测性天然提升了。
6. 后续规划与给同行的一些建议
6.1 正在探索:让技能自己“长出来”
agent-skills 目前还是“人写技能、模型调用”的模式,我在想的是能不能让流程更加自动化。比如给定一个新的 API 文档,系统自动生成技能描述、输入 Schema 和基础测试用例,再通过离线评测来自动验证。这个方向我在一个小范围实验里跑通了:把某新接口的 OpenAPI 规范喂给一个专用生成流程,产出的技能描述质量已经接近人工写的 80%,后期只需要调整边界描述。
另一个方向是技能的评估自动化。每个技能升级后,自动用线上样本生成变体问题(改写法、换数字、加口语词),然后跑一遍评测,和旧版本对比。这套流程如果打通,技能迭代就能像 CI/CD 一样发布。
6.2 什么情况下别急着上技能框架
一个很常见的问题:是不是所有 Agent 项目都需要一套 agent-skills 这样重的技能框架?我的答案是看规模。如果整个应用只需要调用三五个核心接口,且这些接口非常稳定、参数简单,那你直接在提示词里写清楚也够用,强行上一套框架反而增加维护成本。
建议的触发条件是:一个智能体需要管理超过十个业务技能,或者接口参数经常变、出错影响大,或者你希望在多个项目里复用技能能力。满足任何一条,就值得拆出独立的技能层。另外还有一个心理上的判断标准——如果你已经出现过“这次改动不知道会不会影响上一个场景”的担忧,那说明技能之间已经出现耦合,该上框架了。
我在整理这些内容时,翻到了自己最初给 agent-skills 记录的 TODO,第一条写着“把技能定义做规范”。现在回头看,这一步确实是最关键的。如果你也正在搭自己的智能体,我建议你不要急着写调用代码,先花一个下午把技能接口、返回结构和测试样例定下来。这个习惯帮我省掉了无数次的返工和线上翻车。实际上,后面我们新同学上手项目时,首先看的也是技能定义文档,而不是业务代码——技能层已经成为了整个系统最稳定的锚点。