如果你也在做大模型 Agent,一定见过这种场景:系统提示词越写越长,里面塞满各种工具的使用说明、参数格式、注意事项,模型还是经常把日期传错、把 A 工具和 B 工具搞混、出了问题根本没法定位。我们内部这个代号叫 agent-skills 的项目,就是为了解决这一整类问题。它不是一个重框架,不搞复杂的 Agent 状态机,只做一件事:把技能从提示词里拆出来,用一套轻量的注册、发现、执行、编排机制,让 Agent 知道自己有哪些技能、什么时候该用、怎么用才不翻车。这篇文章是我在落地这个项目几个版本后的完整复盘,包含技能文件设计、路由命中、安全边界、失败恢复和组合编排的实操细节,适合正在做 Agent 应用、工具调用层、自动化工作流的开发者参考。
1. 为什么我决定把"技能"从Prompt里拆出来
1.1 硬编码技能的三种死法
早期版本里,我们把所有工具说明直接写进 system prompt,用"如果用户问销量,就调用 query_sales;如果问库存,就调用 query_stock"这种伪代码描述。刚开始只有四五个工具,效果还行;等工具涨到三十个以上,问题集中爆发。
第一是 prompt 爆炸。每个技能描述平均 150 字,30 个就是 4500 字,加上历史消息直接逼近上下文窗口,模型注意力被严重稀释,连基础指令都开始不稳定。第二是复用困难。A 业务写死的技能,B 业务要复用只能复制粘贴,改一个字段就要动全局提示词,回归测试成本极高。第三是没法灰度。技能逻辑升级只能整体替换 prompt,出问题只能整体回滚,没法对单个技能做小流量验证。
这三条里最要命的是第一条。上下文一长,模型开始"选择性失明",明明工具列表里有 query_stock,它偏用 query_sales 去查库存,参数还对不上。我们当时花了大量时间调 prompt 措辞,结果只是按下葫芦浮起瓢。
1.2 agent-skills不是工作流引擎,只是一层"技能适配层"
开始设计 agent-skills 时,我们明确不想做成工作流引擎。工作流引擎关心的是节点、状态流转、人工审批;agent-skills 关心的是:模型该如何发现并调用一个可执行技能。它更像是模型和业务代码之间的一层适配层,管好四件事:技能注册表、语义路由、执行沙箱、编排器。
注册表统一管理技能描述;语义路由决定面对当前任务时调哪个技能;执行沙箱负责权限隔离、超时、重试和留痕;编排器负责组合技能之间的数据传递。业务逻辑仍然留在技能内部,agent-skills 不碰核心算法。
另外还有一个现实原因:不同模型平台自带的函数调用机制,在参数解析方式、返回格式上有差异,如果直接把技能塞给各家平台的 function calling,后面换模型时到处都要改。agent-skills 定义了一套偏中立的技能描述和调用协议,底层适配层可以分别接不同模型,上层技能基本不动。
很多团队一上来就上重框架,状态机、图数据库、可视化编排全安排上,结果模型生成的流程和图定义对不上,排错排到怀疑人生。我们的经验是:先从这四件事做起,撑不住再加复杂度,大多数场景其实不需要重框架。
1.3 开工前立的三条铁律
在写第一行代码前,我们定了三条原则,后面所有设计都围绕它们展开。
第一,技能必须自描述。每个技能除了实现代码,还要带一份结构化说明文件,包含做什么、什么时候用、输入输出格式、依赖谁。自描述是路由和复用的基础,没有它,模型只能靠猜。第二,模型只能看到技能的"使用说明",看不到实现细节。这样做一方面减少 token,另一方面避免模型被内部实现带偏,比如看到实现里有缓存就自作主张跳过调用。第三,任何技能调用都要可观测、可回滚。入参、出参、耗时、花费、错误信息全部留痕,技能版本支持一键切换。没有可观测性的技能库,上线就是埋雷。
这三条看起来简单,但后面几次大坑都跟没遵守它们有关。下面我会逐个展开。
2. 技能文件结构:一个技能到底长什么样
2.1 manifest字段设计,少一个字段后面都难受
技能清单(manifest)是 agent-skills 的元数据核心。每个技能目录下至少有一个 manifest.json,统一描述“这个技能是什么、什么时候用、怎么调用”。我们最终在用的字段大概是这样:
- id:全局唯一标识,用于路由和执行定位
- name:给模型看的中文短名
- description:一句话说明核心功能,走路由时的首选信息
- when_to_use:明确适用和不适用的边界条件,防止模型误调用
- input_schema / output_schema:用 JSON Schema 描述参数结构
- permission:技能需要的权限级别,比如只读、可写、不可信
- timeout:单次执行超时
- retry_policy:重试策略
- dependencies:组合技能依赖的其他技能
- version:当前生效版本
- owner:维护人,便于告警和评审
早期我们漏了 owner 和 when_to_use,结果技能出问题找不到负责人,描述也与实际行为有偏差。后来加了 when_to_use,模型的误调用率明显下降。原因很简单:模型在调用前多了一道“边界检查”信号,知道什么情况下不该用,比单纯知道怎么用更重要。
2.2 为什么输入校验必须用JSON Schema
很多人习惯在技能函数里写 if not isinstance(arg, str) 这种判断,但这只覆盖了类型。模型生成的参数经常是“看起来合法但语义不对”,比如 month 传成“2025年3月”而不是“2025-03”。如果不清洗,业务代码直接背着脏数据跑,结果错了还不知是哪一环出的问题。
我们用 JSON Schema 做两层校验:第一层在路由和解析之后立即检查类型、格式、枚举值、必填项;第二层在真正执行业务逻辑前做一次幂等校验。校验不通过时不会直接报错,而是把校验失败原因作为“修正信号”返回给模型,让模型重新生成参数。这个“校验—反馈—重试”的回路,实际效果比让模型自己思考参数合理得多。
这里有个关键点:校验失败信息要写得像“给模型的提示”,而不是“给开发者的异常日志”。比如写成“start_date 需要是 YYYY-MM-DD 格式,你传入的是 2025年3月1日”,模型很容易理解并修正;写成“ValueError at line 3”,模型基本没法用。
2.3 一个真实技能示例:销售趋势查询与摘要
拿内部销售看板助手的一个技能举例,结构是:
skills/query_sales_trend/ manifest.json execute.pymanifest.json 核心内容如下:
{ "id": "query_sales_trend", "version": "2", "name": "查询销售趋势", "description": "查询指定时间范围的销售趋势,返回销售额、订单量、客单价序列", "when_to_use": "当用户询问销售额、订单量、增长趋势、日环比、周同比时使用;需要先确认日期范围;不要用它查询库存或客户明细", "input_schema": { "type": "object", "properties": { "start_date": {"type": "string", "format": "date"}, "end_date": {"type": "string", "format": "date"}, "granularity": {"type": "string", "enum": ["daily", "weekly", "monthly"]} }, "required": ["start_date", "end_date"] }, "permission": "read_only", "timeout": 5 }execute.py 内部就是查数仓、算指标、返回 DataFrame 转 JSON。这些逻辑原本藏在 prompt 描述里,现在被隔离成一个可独立测试的函数。写到这里想强调一点:技能边界要小,一个技能只做一件事。我们早期做过一个“查询经营数据”技能,扛了 30 个参数,最后路由经常命中它但用不对参数,后来拆成 8 个小技能,问题少了一半。
2.4 原子技能、组合技能、系统技能的分类逻辑
agent-skills 把技能分为三类:
- 原子技能:不可再拆,直接落地到单个函数,比如查库存、发消息、调外部 API。
- 组合技能:内部声明依赖哪些原子技能,并定义好数据传递顺序,比如“生成竞品分析报告”依赖“搜索资讯”“抓取页面”“调用摘要模型”。
- 系统技能:agent-skills 内置的技能管理能力,比如列出技能、查看技能状态、开关某个技能。系统技能对外开放后,可以让 Agent 自己诊断,但要配只读权限。
分类不是为了炫设计,而是为了权限和重试粒度。原子技能失败可以单独重试;组合技能失败要整链路回滚;系统技能失败只影响诊断,不能拖累主流程。有了这个分层,上线后的告警定位成本低了很多。
3. 技能注册、发现与路由:Agent怎么知道该用哪个技能
3.1 启动时全量扫描 vs 运行时动态注册
agent-skills 第一版用的是启动时扫描技能目录,根据 manifest 生成技能清单和函数索引。好处是简单、可预测;坏处是新增技能要重启服务。后来加了运行时动态注册接口,允许在管理后台“上线”一个新技能,系统会校验 manifest 合法性再放进注册表。
两种方式不是替代关系,而是分层:核心技能走启动扫描,保证基础能力永远在;试验技能走动态注册,方便快速验证。动态注册有个坑:如果新技能的 description 跟已有技能重叠,路由会出现“摇摆”,时而选 A 时而选 B。解决办法是注册时做一次描述相似度检查,重叠度超过阈值就拒绝上线,强制改名。这个机制被我们内部叫“技能名查重”。
3.2 语义描述怎么写,命中率天差地别
路由环节本质是让模型从技能清单中选出最合适的一个,所以技能的 description 必须站在“用户问题”的视角去写,而不是站在“实现功能”的视角。同一个技能,两种写法对比:
- 差:“query_sales_trend 函数,参数 start_date, end_date, granularity,通过 HTTP 接口查询 mysql 数据库”
- 好:“当用户需要查看任意日期范围内的销售额、订单量、客单价,或分析日环比、周同比、月度变化趋势时使用”
前者把读者当成了开发者,后者把读者当成了“正在处理用户问题的模型”。这个差异在实测中非常明显。同样的 100 个真实用户问题,只改描述,技能命中率从 62% 提到 91%。措辞里的动词和场景名词是关键,像“查看”“分析”“对比”“趋势”这类词要尽量覆盖用户会用的表达。
3.3 路由器先按概率排序再按约束过滤
agent-skills 的路由分两步。第一步,把用户问题和技能清单一起交给模型,让模型输出“每个技能的使用概率+理由”。第二步,按概率从高到低排序,再用硬约束过滤。硬约束包括:技能是否启用、权限是否够、依赖是否满足、输入是否可解析为合法参数。过滤后取 Top1 作为调用目标;如果 Top1 概率低于 0.5,或者校验返回错误两次,则进入澄清流程,直接问用户而不是硬调。
有人问为什么不直接让模型输出技能 ID。实测下来,让模型输出概率分布比输出唯一 ID 要稳,因为即使唯一 ID 选错了,概率分布里的第二名可以作为降级选项;而且理由文本可以写入审计日志,事后排查时能看到模型为什么选了它。
3.4 一组实测数据:描述改写前后命中率对比
下面是我们内部测试集的一组数据,100 个来自真实用户的问题均匀覆盖 8 个技能。改动只有两个:重写 description 和补 when_to_use。
| 指标 | 改前 | 改后 |
|---|---|---|
| 技能 Top1 命中率 | 62% | 91% |
| 平均重试次数 | 3.2 | 1.4 |
| 路由逻辑失败率 | 18% | 6% |
| 每任务路由 token | 约 1200 | 约 900 |
重试次数下降说明,参数校验错误少了;token 下降是因为减少了对错误技能的无效尝试。最关键的是,模型不再出现“用销售技能查库存”这种离谱错误。路由这层看似简单,但它决定了后续所有环节能不能省心。
4. 技能执行的安全边界与失败恢复
4.1 可信度分级:不是所有技能都该直接执行
agent-skills 给每个技能打上可信度等级:受信任、低信任、不可信。受信任技能一般是内部只读查询,入参经过严格校验后可以直接执行;低信任技能比如发邮件、改写数据库,需要二次人工确认或走审批接口;不可信技能比如解析外部网页、执行用户上传的表达式,必须在沙箱里跑,限制资源、网络、文件系统。
这个分级直接影响执行引擎的调度策略。我们用了一个简单的策略表:只读技能放行;有副作用的技能默认“干跑模式”,先不落库,返回预览结果让用户确认;无法验证来源的技能直接拒绝。分级还决定了超时和重试参数:只读技能超时 5 秒重试 1 次,写操作超时 10 秒不重试,避免重复提交。
4.2 超时、重试与熔断配置
每个技能可以在 manifest 里单独写 timeout 和 retry_policy,不写则套用全局默认。全局默认大概是:只读 5 秒、重试 1 次;写操作 10 秒、重试 0 次;外部 API 8 秒、重试 2 次、间隔递增。
熔断是另一个容易被忽略的层。同一技能连续失败超过 5 次,熔断器打开,随后 5 分钟内不再调用它,而是直接走降级逻辑。我们遇到过一个外部天气 API 间歇性故障,本来只是上游不稳,结果因为一直重试,硬是把 Agent 主任务拖到超时。上了熔断之后,技能失败会在 2 秒内被发现并切到降级方案,用户基本无感。
4.3 全链路留痕:入参、出参、耗时、Token开销
执行层会在技能调用前后各埋一个点,记录调用链 ID、技能 ID、版本、入参、出参摘要、状态码、耗时、Token 消耗、重试次数。数据写到独立的审计表,不跟业务表混在一起。
这些痕迹有三个用途:一是排障,用户说“刚才结果不对”,我们能定位到具体路径;二是质量监控,定期统计各技能的失败率、平均延迟、成本占比,识别需要重构的技能;三是模型行为分析,比如查看哪些描述让模型误入歧途。有一次我们发现某个技能的调用量突增,一查是描述里多了“如果用户没说明时间范围,默认最近 30 天”,模型开始频繁代填参数,后来把这句话删掉,调用量恢复正常。
4.4 技能失败时的降级链
一个技能失败不是终点,agent-skills 支持在 manifest 里声明 fallback。降级链可以是同功能的不同实现,比如查询销售数据失败后降级到只查前一天全量汇总;也可以是替代表达方式,比如实时行情失败就返回缓存快照并标注时间。
设计降级链最重要的是控制长度,最多三级。超过三级,链路本身的判断成本已经超过“直接告诉用户暂时不可用”的成本。我们还规定降级分支也要留痕,并在最终回复里主动标注“这是降级数据”。用户对数据时效是有预期的,不标注反而更伤信任。
5. 技能编排:从单技能到多技能协作
5.1 组合技能通过依赖声明而不是代码胶水
组合技能不写“先调 A 再调 B”的硬编码。我们在 manifest 的 dependencies 里声明需要哪些子技能,以及它们之间的数据流约束。agent-skills 的编排器根据依赖关系生成一张简单的执行顺序图。顺序规则只有两条:被依赖者先执行;无依赖关系的技能按注册顺序执行。
这样设计的好处是,替换子技能实现时不需要改编排代码。比如“生成竞品报告”依赖“搜索资讯”和“抓取页面”,后来把“抓取页面”从自研爬虫换成第三方解析接口,只需要替换技能目录里的实现,报告能力保持不变,上游技能完全无感。
5.2 数据在技能间传递的统一信封格式
组合技能执行时,前一个技能的输出会作为后一个技能的输入。为了避免技能之间因为字段命名不同而互相等待,agent-skills 规定所有技能输出必须包一层标准信封:
{ "ok": true, "data": { ... }, "meta": { "data_date": "2025-04-01", "source": "query_sales_trend", "elapsed_ms": 230 } }meta 里放数据日期、来源、耗时;data 才是业务数据。信封不是形式主义。一次排查中,我们发现两个技能使用的日期字段,一个叫 date,一个叫 day,模型在中间做了一次“理解性转换”,结果把日期格式弄错。改成统一信封后,技能之间传递的是结构化字段,不再依赖模型转述。某个技能只要声明消费字段,就能从信封里取,缺字段直接报错而不是猜测。
5.3 分支逻辑放哪一层?我最后放在Agent编排层
组合技能内部要不要写 if 判断?我们的经验是:不要。分支逻辑应放在 Agent 编排层,由模型根据用户意图决定走哪条技能链;组合技能只负责固定的顺序流水线。原因是模型适合做“要不要做 A”的决策,不适合做“HBase 里查不到就走 ES”这种技术容错。
实际项目里,我们把技能链拆成“主链 + 旁路”:主链是固定顺序的组合技能,旁路是可选的增强技能。模型拿到用户请求后先规划主链,再判断是否追加旁路。比如“竞品分析”主链是搜索 + 抓取 + 摘要,旁路可以是“查内部定价”或“拉取库存数据”,视问题而定。这样既保留了模型决策的灵活性,又不像完全自由编排那样不可控。
5.4 案例:自动生成绩效异动分析报告
拿一个内部场景串一遍。用户说“帮我看下上个季度哪些商品销量异动比较大”。agent-skills 的路由先匹配到“绩效异动分析”组合技能。主链依次执行:
- 查询销售趋势(原子技能),得到日/月粒度指标;
- 计算环比与异常点(原子技能),输出异动商品 ID 列表和幅度;
- 查询商品主数据(原子技能),把 ID 映射为名称和类目;
- 调用大模型摘要生成(原子技能),把这几天变化写成人话。
每个技能的输入输出都是信封,前一步的输出直接作为下一步的部分输入,Agent 编排层只需在步骤间补充用户提供的限定条件。整个过程 20 秒左右,大部分耗时在查询和摘要生成,编排器本身的调度开销不到 1%。这在我们原来硬编码 prompt 的方案里几乎不可能实现——那时候所有环节都靠模型“自由发挥”,结果中间坏一步就只能从头再来。
6. 复盘:最值得记住的五个教训
6.1 技能不能写得太"聪明"
第一版技能设计时,我们试图让技能内部自动纠错:识别到日期格式不对就自动改,识别到用户问的是库存就自动跳到查询库存技能。结果就是黑盒套黑盒,出了问题完全不知道是模型决策错还是技能内部自作主张。后来统一原则:技能只做“按输入执行并返回结果”,所有判断交给模型或 Agent 编排层。笨技能反而好排查。
6.2 校验约束不是越多越好
JSON Schema 加得太细,同样有问题。开始我们要求时间格式必须精确到日期且必须落在合法业务周期内,结果模型生成的参数频繁校验失败,重试两次还失败就直接拒绝,用户问题明明很简单却办不成事。后来把必填项减到真正影响正确性的字段,其余用宽松类型加自动归一化。校验的目的是挡住脏数据,不是给模型设路障。
6.3 技能更新要支持热切换与灰度
技能代码更新时,最怕“删旧上新”一刀切。我们现在每个技能目录保留最近两个版本,manifest 里用 version 字段标识当前生效版本。管理后台可以给某个技能指定灰度组,比如先切 10% 流量到新版本,观察平均耗时和错误率,稳定后再放到 100%。一次因为新版本查询逻辑少处理了周末数据,灰度阶段立刻发现异常,回滚只用了十几秒,没有影响主流程。
6.4 接入前后的一组对比数据
我们以一个内部经营分析助手为样板做了接入前后对比:任务覆盖查数、对比、异动解释、报告生成四类。接入 agent-skills 之前,任务整体成功率约 70%,平均每任务调用轮数偏高;接入后,整体成功率提升到 94%,单轮调用占比明显提高,失败任务主要集中在外部 API 超时和权限确认环节。
成功率提升的主要来源不是执行更快,而是“选错技能”的情况几乎消失了。说明把技能说明结构化、让模型先选后调,比在提示词里堆功能描述更符合模型的推理习惯。
6.5 后续扩展方向
这个项目目前还在迭代,后续有几个方向在推进:一个是把技能描述 embedding 化,用向量检索做预筛后再交给模型精排,降低长技能列表对上下文的压力;另一个是支持技能间的并行执行,因为组合技能里有多个无依赖的子技能,现阶段还是串行,往后可以显著缩短整体耗时;还计划做一个技能质量评分面板,自动归因失败技能并给出描述修改建议。
如果正在做 Agent 技能化的朋友,建议从最小闭环开始:先拆三五个技能,跑通注册、路由、执行、留痕,再一步步加组合和灰度。别上来就追求大而全的编排框架,技能层最先要解决的永远是“模型别选错、选了能跑对、跑错能查到”这三件事。