最近我把手头几个长期维护的智能体做了一次大扫除,顺手把这套沉淀下来的能力抽成了一个叫agent-skills的技能库项目。它做的事情用一句话概括:把模型可能用到的各种能力封装成统一接口的技能,再用描述文本、参数校验和运行时隔离,把“模型什么时候该用、参数怎么传、结果如何返回”变得可预测。如果你正在做 Agent 应用,被各种工具函数和回调绕晕了,这篇文章应该能帮你少走不少弯路。我会从为什么需要技能库讲起,一步步拆技能注册、描述调教、组合编排和上线阶段踩过的坑,所有配置和排查路径都是可以直接照抄的那种。
整个项目没有用什么高深的技术,核心就是一套约定加一个不到两千行的调度器。但恰恰是这些约定,让我后来无论换什么模型、接什么任务,都能把自定义能力快速迁移过去。
1. 为什么会冒出 agent-skills 这个项目:Agent 开发的三个前置痛点
1.1 每个智能体都在重复造轮子
我最早是在维护几个长期运行的智能体时产生这个想法的。它们看起来各管一摊,但底层能力高度重叠:都要抓网页、都要读文件、都要跑临时脚本、都要把关键词自动转成结构化参数。最让人抓狂的是,每个智能体各自实现了一套“抓网页”的逻辑,一个用正则从头硬解析,一个套了现成的解析库,还有一个干脆依赖远程接口。某天用户反馈说同一篇资讯在两个智能体里看到不同字数,我排查了一个下午,最后发现不是模型问题,是两段抓取逻辑对正文截断的规则不一样。这种维护成本完全无意义,但又是很多项目里真实发生的。
1.2 技能、工具与任务:不是一回事
类似的经历多了以后,我开始琢磨统一底层能力。但第一版方案很容易犯一个错:把技能做成工具的换皮。我最初给技能库定义的“技能”就是“封装了 HTTP 请求的函数”,结果注册了三十多个技能,模型的选择准确率反而降了,因为技能和技能之间的边界太模糊。后来我重新划分三层:工具是最小的原子操作,比如发一个请求、读一个文件;技能是能独立完成用户某个意图的封装,比如“查询最新行业资讯”;任务是多个技能按业务规则串起来的目标,比如“每天早上生成一份简报”。只有把这三层拎清楚,才知道技能库里该放什么、不该放什么。
1.3 只做约定,不绑架框架
我当时也想过去找一个现成的智能体编排框架,但试了一圈发现,那些框架通常把模型接口、记忆管理、工具调度、流程编排绑得很死。我换模型和后端存储都得跟着框架走,重写成本不低。agent-skills的定位因此变成了一个极薄的技能层:不关心你用哪家模型,不关心任务怎么拆,只关心“模型要不要用这个技能、怎么用、用完结果怎么回来”。这个层只有三块:技能注册表、参数校验器、执行沙箱。外面无论接什么 Agent,传进来的都是统一的技能索引和调用结果格式。
1.4 一次让我下决心重构的现场
真正让我坐下来写agent-skills的,是一次联调事故。几个智能体同时接到同一个业务需求,因为各自用了不同的“读取配置”方式,分别拿到了不同环境下的配置,结果互相矛盾。我一开始以为某个智能体的 Prompt 出了问题,打开日志才发现底层接口版本都不一样。那一刻我就明白:如果不在能力层做收敛,再聪明的模型也救不了底层的混乱。所以技能库的第一版,我没有先写执行器,而是先写了注册表和一套约定文档,一个技能一个技能地把散落的工具函数收编进来。后面所有功能的稳定性,都建立在这次收敛之上。
2. 技能库的骨架:统一注册、Schema 校验与执行环境
2.1 一个技能的最小构成:describe、input_schema、execute
在agent-skills里,一个技能就是一个标准化的模块,它只需要暴露三个部分:describe返回一段面向模型的自然语言说明;input_schema是一份参数声明,描述每个参数的类型、取值范围和必填情况;execute是真正干活的函数。我把它写成下面这种很普通的结构:
# skill_example.py def describe(): return ("用于抓取指定网页的正文内容。" "当用户需要查看、保存或总结某个网页内容时使用。" "参数 url 必须是完整的 http/https 地址。" "如果用户只是想讨论链接本身,不需要获取正文,不要调用这个技能。") def input_schema(): return { "type": "object", "properties": { "url": { "type": "string", "pattern": "^https?://", "description": "完整网页地址,例如 https://example.com/article" }, "max_chars": { "type": "integer", "minimum": 100, "maximum": 5000, "default": 2000, "description": "返回正文的最大字符数" }, }, "required": ["url"], } def execute(params): # 实际的抓取逻辑,省略 return {"ok": True, "content": "这里是网页正文", "source_url": params["url"]}统一返回格式是我特别坚持的一点。不管技能内部做什么,最终都给一个包含ok、data或者error的字典。这样做有两个好处:一是后续编排其他技能时,结果格式完全一致,不用每个技能单独适配;二是模型从返回里提取信息时,路径非常固定,不太会出错。很多项目失败在“每个技能返回格式都不一样”,模型要在各种字段名之间猜来猜去,准确率自然上不去。
2.2 参数 Schema 的宽严权衡:枚举优先于正则
参数校验是技能库最容易翻车的地方。我之前认为,校验层越严格,模型越不容易传错参数。但实际跑下来发现,机器喜欢的严格不一定是模型喜欢的严格。比如枚举类型,模型看到region: ["cn", "us", "eu"]时,它能准确填us;如果你改成正则^(cn|us|eu)$,很多模型会把美国填成US,然后因为大小写校验失败。模型对于“格式限制”的理解其实很弱,对“明确选项”的理解强得多。
所以我在设计所有技能 Schema 时定了几条死规矩:能用枚举绝不用正则;数字参数尽量给范围和默认值;字符串参数尽量给示例值而不是空泛描述。校验失败时,错误信息必须返回给模型可理解的解决建议,比如“可选值只有这些,请重新选择”,而不是抛一个底层校验库的堆栈。这样模型下次调用的成功率会高很多,用户看到的是“一次成功的自动重试”,而不是“一个冷冰冰的报错”。
2.3 执行环境隔离:防止模型做出你不想负责的操作
技能执行环境这块,我见过很多 Agent 项目完全不在乎。起初agent-skills也直接在 Agent 主进程里跑技能,直到有一次某个“文件整理”技能在测试时创建了一堆临时文件,因为参数里传入了不存在的路径,它自动递归创建,差点把目录结构搞乱。从那时起,所有技能都必须在隔离进程里执行。
每个技能要声明自己的资源需求:允许访问哪些目录、是否允许网络请求、写操作是否需要确认。调度器在执行前根据声明构造一个临时环境,设置超时和内存上限。超时我一般设 15 秒,内存上限 128MB,超过就终止并把部分日志返回给模型。这样即便模型调用方式再离谱,影响范围也被限制在单次执行内。这个过程听起来只是工程细节,但恰恰是它决定了你晚上能不能安心睡觉。
3. 让模型真正用对技能:描述文本、命名与两级路由
3.1 技能描述不是文档,是模型的路标
我看了不少技能库,发现多数人把精力花在执行函数上,描述随手写几行。但真正决定模型用不用、怎么用这个技能的,恰恰是描述。在 LLM 自主调用场景里,一个技能对模型来说就是一段文本、一组参数定义、一个返回格式,没有别的。所以描述文本必须同时扮演两个角色:让模型知道“什么情况下选我”,同时知道“什么情况下别选我”。只写前者,误调用率会非常高。
我后来把每个技能模板固定成三段式:第一段说功能与适用场景;第二段说参数要点和输出形式;第三段写负面条件,也就是不该调用的场景。用这套模板重写后的技能,路由准确率提升非常明显。有一个技能原本描述是“查询数据库中的用户信息”,结果模型在统计数据时也会调它,返回一堆明细;改成“用于按精确条件查询单个用户资料,不负责统计;如需统计请调用 count_users”之后,误调次数直线下降。
3.2 命名与负面引导:如何降低误调用率
技能命名的影响远比想象中大。最开始有一个抓网页的技能叫fetch_webpage,模型经常把和网页相关的所有请求都归给它,包括要翻译网页、保存网页、对比两个网页。后来我改成fetch_webpage_content,并且在描述里显式写了“翻译网页请用 translate_text”,误调用率立刻降了一半。这里有个规律:技能名要具备动词加作用对象的语义,避免用太宽泛的词;描述里可以主动列举相似场景,防止模型按字面意思瞎猜。
另外,旧版本技能如果在库里,别和新版本同时暴露,两个相似技能会让模型随机选一个。我后来在注册表里加了一道相似度检测,语义上太接近的新技能直接不让注册,强制作者去复用现有技能。这样反而倒逼着大家把技能做得更通用,而不是不断堆砌新接口。
3.3 两级路由:规则先筛,模型兜底
技能多了以后,把所有技能索引一次性塞给模型,成本会越来越高,上下文也会被无关技能污染。二十个技能的描述加 Schema 可能超过三千个 token,再做一次意图选择,既慢又容易出现幻觉。agent-skills采用了两级路由:第一级是轻量规则,用关键词和正则先命中一部分确定性场景,比如用户提到“保存 pdf”就直接路由到 pdf 转存技能;没命中则进入第二级,把经过排序的技能索引交给模型,让模型选择。
规则层不需要覆盖所有场景,只要能把高频确定性意图吃下来,模型那层的负担就小很多。实测中,简单意图用规则命中的准确率能到九成以上;到了复杂意图,规则开始误伤,必须交给模型。这个比例会随着技能数量变化,但整体原则不变:能用规则锁定的就不浪费模型上下文。你也不用担心规则层维护成本,它通常只有几十行。
3.4 一个实测对比:改描述和必填项后误调用骤降
我拿一个真实技能做了个小实验:原始版本是“用于查询数据库中的用户信息”,参数里user_id是可选的;改后的版本把描述改成“用于按精确条件查询单个用户资料,不负责统计;如需统计请调用 count_users”,同时把user_id改成必填。连续跑一周,误调次数从每天二十次左右降到两三次。这个例子说明,参数 Schema 不只是校验工具,它还是给模型的行为约束:必填项越多,模型越不敢乱调;可选字段越多,模型越容易自由发挥。不想让模型碰的操作,直接在 Schema 层把它变成必填或干脆不暴露。
4. 组合技能的三种模式与我的最终取舍
4.1 顺序编排:简单但是一步出错全盘中断
技能库不能只有单个技能,还要能组合。我最先实现的是顺序编排,也就是把上一步的结果传给下一步,比如“抓取网页 -> 提取正文 -> 生成摘要”,几十行代码就能写完。但它特别脆弱,只要中间一步失败,后面全部停摆。更麻烦的是,一旦模型在中间步骤生成了不符合预期的参数,后续技能会把错误结果当成输入,整个链路越跑越偏。
我发现顺序编排只适合两类场景:一是步骤完全确定、任何一步失败都可以整体重试;二是人工审核节点,比如每个步骤前需要确认。自由组合场景里,纯顺序流程反而成了束缚。
4.2 状态机分支:可控与复杂度的权衡
为了弥补顺序编排的脆弱,我试过给技能之间加条件分支。具体做法是每个技能返回后,根据结果状态决定下一个技能,本质上是有限状态机。优点很明显:执行路径可控,失败也能落到明确的恢复节点。缺点是要维护一张越来越大的状态转移表,技能越多,作者就要考虑越多的组合路径。
我在agent-skills里只用在了两个地方:一个是涉及人工确认的授权流程,必须严格走审批节点;另一个是重试次数限制,比如网络请求失败后最多重试两次。除此之外的场景,状态机带来的复杂度远大于收益。你不需要为所有流程都画出完整的转移图,控制力只要覆盖关键路径就够了。
4.3 图编排:最灵活但最难复盘
后来我还接触过一种图编排的思路,把所有技能都看成节点,允许任意跳转。灵活性确实最高,模型可以在节点间自由选择路径,但我用一个四个技能的小图做了实验,模型跑出来的执行路径有十几种,日志复盘非常困难。一个问题出现后,你很难判断是模型的决策错、哪个技能的返回错,还是编排层选边选错。
对稳定性要求高的场景,这种不确定性完全不可接受。所以我没有把图编排作为默认方案,只在离线实验里保留了选项。三种方式的对比我整理成了下面这个表格:
| 编排方式 | 控制力 | 灵活性 | 调试成本 | 适用场景 |
|---|---|---|---|---|
| 顺序编排 | 高 | 低 | 低 | 步骤固定、可整体重试的流水线 |
| 状态机分支 | 中高 | 中 | 中高 | 需要审批、重试等强约束路径 |
| 图编排 | 低 | 高 | 高 | 探索型实验,不适合线上稳定场景 |
4.4 混合方案:把业务规则和模型自由度分开
最终线上使用的是一套混合方案:业务规则层写成固定脚本,技能选择层交给模型自由发挥。举个例子,生成行业报告这个任务,第一步搜资料、第二步生成报告是固定顺序,属于业务规则,不能乱跳;但搜哪几个来源、用什么技能做正文提取、是否去重,这些细节留给模型。
判断标准只有一条:这一步如果错了,用户能不能接受。用户不能接受就写成固定流程,用户能接受并且值得尝试就开放给模型。这套方案跑下来,稳定性比我预想的好很多。关键是作者要在设计阶段把“规则”和“自由空间”明确划开,否则模型会把你的固定流程也当成可选项。
5. 从测试到上线的五个坑,以及对应的排查链路
5.1 上下文爆炸:技能返回体过大导致模型失忆
上线后踩的第一个坑是上下文爆炸。某个抓取技能把整个网页正文原样返回,连续两个技能后上下文就满了,模型开始重复用户最早的需求,甚至答非所问。我当时的排查链路很长:先是看 Prompt,后来看模型参数,最后才想到打印每次技能调用返回体的规模。一看平均返回超过三千个 token,自然会把上下文冲垮。
修复也很简单:在execute层统一做结果精简,默认只返回摘要、关键字段和截断后的正文;要完整内容,模型可以再显式要求。记住一句话:技能返回值是给模型理解的快照,不是数据库记录,能少则少。现在每个技能默认返回都控制在几百个 token 以内。
5.2 参数幻觉:模型编造路径和日期
第二个坑是参数幻觉。某个技能需要传入文件路径,模型却在用户完全没有提到路径的情况下,自动填了一个“可能”存在的路径。第一次看到日志时我还有点不信,以为是数据问题,后来复现多次,发现模型确实会“脑补”参数。根因还是描述和 Schema 给了太多猜测空间。
我的修法:把所有这类参数改成可选,并在描述里明确写“仅当用户显式提供时填写;未提供则使用默认目录”。同时加了一层运行前检查,路径不存在就直接返回错误,并把可用目录列出来推荐给模型。改动后,这个技能的参数幻觉就很少出现了。
5.3 循环调用:技能互相调用形成递归
第三个坑是循环调用。事故发生在一次自主组合任务里,技能 A 在处理异常时调用技能 B,技能 B 在另一个分支又会调用 A,模型根据自己的判断在两处来回触发,最后形成深度递归。单测覆盖不到这个问题,因为单个技能内部并没有直接互相调用,是模型在运行时把它们串成了环。
排查思路是加调用链追踪:记录每个技能的parent_skill和request_id,设置最大嵌套深度,超过五层立即终止并输出调用链。修复之外,我顺手立了一条规矩:技能之间的依赖只能通过编排层发生,单个技能内部不要代码级别去调另一个技能,避免潜在环和无序耦合。
5.4 权限边界:内网访问与最小权限原则
第四个坑是权限边界。技能库上线初期,为了让技能更“有用”,我给某个数据检索技能开放了较多访问范围。结果一次测试中,模型根据用户输入解析后的参数,访问了一个本不该访问的内部路径。当时虽然没有造成实际损失,但让我彻底改了思路:技能权限不能按“功能需要”来配,要按“信任边界”来配。
默认所有技能只能访问最小必要资源,任何网络请求都要经过统一出口并记录日志;涉及写操作、删除操作的技能必须经过二次确认。运行时,沙箱层统一执行权限检查,技能代码本身并不能绕过这些限制。Agent 越自主,这个边界就要越清晰。
5.5 回归测试:技能描述改动引发全线业务漂移
最后一个坑是回归。技能描述算是程序逻辑的一部分,那它改动后,依赖它的所有业务都该重新验证。我吃过一次亏:改了一个通用 URL 清洗技能的名称,结果其他几个依赖它做意图路由的 Agent 全部跑偏,当天浪费了大半天排查。现在我对agent-skills的上线流程做了一条硬性规定:每个技能必须绑定一组意图测试用例,描述或 Schema 有任何改动都要重跑;准确率低于阈值就禁止发布。
另外,每次发布前我还会用一组自动生成的相近问题对技能索引做全量测试,防止改了 A 技能影响 B 技能的筛选。Agent 项目的稳定性,确实需要靠这些不起眼的流程堆出来。