“agent-skills”这个标题乍一看很简短,但真正动手维护过 Agent 项目的人会明白,它背后其实是一整套关于“如何让大模型稳定地调用外部能力”的工程问题。我在过去几个月的实践中,把一个小仓库从最初只有几个 prompt 脚本,逐步迭代成了一套带注册、校验、调用、反馈闭环的技能库系统。这篇文章不打算讲理论,就把我在这个过程中的设计取舍、踩坑记录和最终落地方案整理出来。
1. 从“一堆 prompt”到“一套完整技能库”:agent-skills 到底在解决什么问题
先说一个很现实的场景:你写了一个 Agent,最初它只会聊天。后来你给它接上了搜索、计算器、数据库查询、邮件发送这些工具,每次都是把工具描述塞进 system prompt,让模型自己决定什么时候调用。这个方案在小规模时挺好用,但一旦工具数量超过十个、二十个,问题就来了——模型经常选错工具、漏掉参数、甚至因为描述太含糊而反复尝试同一个动作。
我最初接触 agent-skills 这个方向,就是被这个问题逼的。当时我们一个模拟项目里需要让 Agent 同时处理客服问答、订单查询、售后工单创建等十几个任务。每个人写工具函数的方式都不一样,有的返回 JSON,有的返回纯文本,有的直接打印日志。结果 Agent 的行为非常不稳定,同样的用户问题,今天能正确调用订单接口,明天就跑去查库存了。
后来我们逐渐意识到,缺的不是单个工具,而是一套统一的“技能封装层”。所谓技能(skill),就是让 Agent 能够理解、调用、并从中获得反馈的最小任务单元。它比普通函数多一点东西:要有给模型看的描述、有入参出参的 schema、有校验逻辑、有错误恢复机制。agent-skills 这个名字听起来像是一个普通仓库名,但它的核心思想是:把 Agent 的能力组织方式从“临时拼装”升级成“标准化资产管理”。
技能和插件、工具函数的关系也值得说清楚。插件通常指一套完整的扩展包,可能包含 UI、存储、网络等能力;工具函数是最底层的代码实现;技能则是介于两者之间的抽象层。技能可以封装一个或多个工具函数,并且附带了“模型应该如何理解和使用这个能力”的说明。打个比方,工具函数是零件,技能是装配好的模块,Agent 是工人,它只需要看模块上的说明书就能决定用哪个、怎么用。
这套抽象带来的直接收益有三个。第一,技能可以被复用,一个项目里调试好的技能,换个场景稍微改一改描述就能迁移;第二,技能可以被测试,每次改动都能用固定的用例集去回归;第三,技能可以被观测,调用记录、成功失败率、耗时都能统一采集。这些都是散装工具做不到的。
1.1 一个最小可运行的技能样例
先给一个最简化的技能定义,方便建立直观印象。技能通常包含两部分:一份 YAML 或 JSON 的描述文件,加上一个可执行脚本。
name: order_query description: 根据用户提供的订单号查询订单状态、物流信息和商品清单。当用户询问“我的订单到哪里了”“订单发货了吗”时使用。 version: 1.2.0 author: agent-platform inputs: order_id: type: string description: 订单号,通常是长度 16-20 位的数字或字母组合 required: true output: type: object schema: status: string logistics: array items: array allowed_calls: - database:orders - http:logistics_api timeout: 15s对应的执行脚本只需要遵循一个约定:从 stdin 读取 JSON 入参,处理完向 stdout 输出 JSON 结果,错误时写 stderr 并返回非零退出码。
#!/usr/bin/env python3 import json, sys def main(): payload = json.load(sys.stdin) order_id = payload["order_id"] # 这里内部查询数据库和物流 API result = query_order(order_id) print(json.dumps(result, ensure_ascii=False)) if __name__ == "__main__": main()可能有人会问,为什么不直接让模型调用 Python 函数?原因在于隔离。Agent 进程和技能进程分离之后,技能崩溃不会拖垮主进程,技能升级可以热切换,而且还能对技能做权限限制——比如某个技能只能访问只读数据库,不能执行写操作。这些在单体函数调用里做起来很麻烦,在技能框架里是默认能力。
2. 目录结构与元信息约定:我在第一版里踩过的坑
技能库的设计第一关就是目录结构。我先讲一下自己第一版有多天真:当时的做法是把所有技能脚本平铺在一个skills/目录下,每个技能一个文件夹,里面放run.py和README.md。刚开始只有五六个技能时确实很清爽,但等技能数量到了二十多个,问题集中爆发。
第一个问题是命名冲突。两个技能一个叫report_generator,一个叫report_builder,模型在选择时根本分不清谁是谁。第二个问题是归属混乱,有些技能明显属于数据分析领域,有些属于客户服务领域,但目录上完全看不出来。第三个问题是依赖管理,每个脚本用自己的requirements.txt,但整个仓库没有一个统一的环境声明,导致换一台机器跑,一半技能直接 import 失败。
后来我参考了不少开源仓库的布局方式,结合自己的需求,最终固定成下面这套结构:
agent-skills/ ├── manifest.yaml ├── skills/ │ ├── order/ │ │ ├── order_query/ │ │ │ ├── skill.yaml │ │ │ ├── run.py │ │ │ ├── tests/ │ │ │ │ ├── cases.json │ │ │ │ └── expected_outputs.json │ │ │ └── dependencies.txt │ │ └── order_cancel/ │ │ ├── skill.yaml │ │ ├── run.py │ │ └── ... │ ├── customer/ │ │ ├── complaint_create/ │ │ └── satisfaction_survey/ │ └── data_analysis/ │ ├── trend_report/ │ └── anomaly_detect/ ├── runners/ │ ├── docker/ │ └── local/ ├── registry/ │ ├── index.json │ └── validator.py └── scripts/ ├── build_index.py └── run_tests.py这套结构里有几个关键决定。第一,技能按业务领域分组,而不是按技术类型分组。因为模型在理解任务时会先判断“用户要做什么”,再决定“用什么工具”,业务领域分组和模型的任务分类天然对得上。第二,每个技能拥有独立的测试用例目录,这一点极其重要。技能是给模型用的,模型的调用方式千奇百怪,没有固定用例做回归,任何一次描述修改都可能引入隐性问题。第三,最外层有manifest.yaml统一声明整个技能库的版本、默认运行时、全局依赖,避免散落管理导致的环境分裂。
2.1 技能元信息字段的设计逻辑
技能描述文件是模型理解技能的唯一入口,所以字段设计直接决定了模型会不会用、用得好不好。我从多次试错中总结出这几个必填字段。
name是技能的唯一标识,建议采用“领域_动作”格式,比如order_query、ticket_create、data_trend,防止出现两个语义模糊的名字。description是最核心的字段,它决定了模型何时选择这个技能。描述里要写清楚三件事:这个技能做什么、什么情况下用、什么情况下不要用。很多人只写前两件,忽略负面触发条件,结果模型在用户说“我要退货”时调用了订单查询技能。
inputs字段要严格定义参数的 type、required、description,最好还要有示例值。有经验的调用方会在描述里写明常见格式,比如“订单号通常是 16 位数字,如果用户只提供了 8 位,需要先让用户补充完整才能调用”。这一条看似是给参数说明,实际是在引导模型做好前置澄清,能大幅减少因为参数缺失导致的调用失败。
allowed_calls是我后来加的字段,用来声明这个技能内部会访问哪些资源。它有两个作用:一是安全审计时可以快速看到每个技能的权限范围;二是在部署时可以作为生成防火墙规则和数据库账号权限的依据。虽然现在很多项目还没做到这个粒度,但我强烈建议从一开始就预留这个字段,否则后续要做权限收敛时,需要逐个翻代码,成本极高。
timeout不能省略。模型对耗时不敏感,但用户对耗时非常敏感。给每个技能设定合理的超时时间,并且在超时后给出统一的错误提示,可以让 Agent 在技能卡死时迅速回退到其他路径,而不是一直等下去。
2.2 版本与依赖锁定的教训
版本管理这一块,我踩过两次比较疼的坑。第一次是因为技能升级没有做版本区分,模型同时看到了新旧两个版本的描述,导致行为飘忽不定。第二次是因为依赖没锁死,技能运行所在的容器重建后,某个底层库从 1.x 升到了 2.x,一个技能在本地测试全绿,部署到线上直接报错。
现在我的做法是:每个技能的skill.yaml里除了版本号,还要写明兼容的最低技能框架版本;同时dependencies.txt里的每个依赖都固定到精确版本,不允许使用>=或*。在 CI 里加一道校验,凡是依赖不锁精确版本的技能,一律拒绝合并。这个规则执行起来有阻力,因为开发总想省事,但你会发现,技能库维护到后期,最大的成本是回归测试和排查环境差异,而不是写那几行==。
3. 注册-调用-反馈闭环:让 Agent 真正学会“使用”技能
有了目录和描述文件,下一步就是把技能变成 Agent 可以动态发现和调用的服务。如果只是把描述拼进 system prompt,那还不能叫技能库,只能叫 prompt 模板。技能库的第三个核心是“动态注册-调用-反馈”的闭环。
3.1 注册与发现机制
在系统启动时,技能库扫描器会读取所有skill.yaml,解析出每个技能的 name、description、input schema、output schema,然后写入一个统一的索引文件registry/index.json。这个索引文件就是 Agent 的“技能列表视图”。
这里有个重要的点:索引文件不应该在每次请求时实时生成,那样太慢,而应该在启动时生成一次,并随技能版本变更而重新构建。我们用一个很简单的脚本scripts/build_index.py来维护,它还会做几件额外的事:检查技能描述文件是否合法、检查是否有重复的 name、检查必填字段是否齐全。相当于在注册阶段就把脏数据拦下来,而不是让模型在运行时面对一个残缺的技能列表。
import yaml, json, glob skills = [] for path in glob.glob("skills/**/skill.yaml", recursive=True): with open(path, "r", encoding="utf-8") as f: skill = yaml.safe_load(f) # 合法性校验:字段缺失直接抛错 assert skill.get("name"), f"{path} missing name" assert skill.get("description"), f"{path} missing description" skills.append(skill) with open("registry/index.json", "w", encoding="utf-8") as f: json.dump(skills, f, ensure_ascii=False, indent=2)这个索引文件在每次 Agent 启动时会被加载,作为上下文的一部分。如果技能数量较多,还可以把 description 用某种 embedding 预先向量化,在运行时只筛选和用户问题相关的 Top K 个技能描述。不过我这个项目目前还在用“按领域先粗筛 + 完整描述全给”的方案,技能数在 30 个左右时效果尚可。
3.2 调用协议:输入、输出与退出码
技能的调用协议一定要简单且严格。我们的统一规则是:外部调用方把参数 JSON 通过 stdin 传给技能进程,技能处理完后通过 stdout 返回 JSON,错误信息走 stderr,退出码 0 表示成功,非 0 表示失败。
这条规则的好处是语言无关。不管技能是用 Python 写的,还是 Node.js,甚至是 shell 脚本,只要遵守 stdin/stdout 约定,就能被同一个调度器管理。调度器负责设置超时、捕获输出、记录日志、统计耗时,技能本身不关心这些横切逻辑。
这里有一个容易被忽视的细节:技能返回的 JSON 结构必须是固定的外层框架。比如统一用{"success": true, "data": {...}}或{"success": false, "error": {"code": "...", "message": "..."}}。不要允许技能自由发挥外层结构,否则 Agent 需要去猜测返回结果里到底哪个字段才是真正的内容,非常容易出错。我一开始没强制这个规范,导致有的技能返回数组,有的返回对象,无谓地消耗了很多上下文和调试时间。
3.3 反馈与错误恢复:不要把技能当黑盒
技能调用会失败,这是常态,关键是失败之后的反馈路径要设计好。当技能返回失败时,调度器不应该直接把原始错误信息扔给 Agent,而应该先把错误归类。
常见错误类型我分成三类:参数错误、业务规则错误、系统异常。参数错误通常是模型给的参数值不符合 schema,比如订单号短了、日期格式错了,这类错误可以通过解析校验信息直接反馈给模型,让它补充或修正参数后重试。业务规则错误是参数没问题但业务上不允许,比如订单已取消、商品已下架,这类错误不应该让模型盲目重试,而应该让它基于错误内容直接回应用户。系统异常是代码 bug、网络超时、依赖崩溃,这类错误要把脱敏后的堆栈信息记录下来,同时对模型输出一个标准提示“系统暂时无法完成此操作”。
为什么要把错误分类?因为原始的报错信息往往有敏感内容(数据库连接串、文件路径、内部 IP),直接传给模型再生成回复给用户,存在泄露风险;另外原始报错信息过于技术化,模型理解后会生成一些让用户困惑的回答。分类之后再反馈,既安全又清晰。
3.4 技能选择与记忆:让 Agent 学会取舍
最后是技能选择这一环。很多模型的 system prompt 里塞了几十个技能描述后,注意力会被稀释,主要表现为:该用 A 技能时选了 B 技能,或者干脆不调用任何技能,直接用已有知识胡编答案。
我尝试过几种缓解办法,其中最有效的是“技能选择前置过滤”。在把技能描述送入大模型之前,先用一个轻量级的意图分类器对用户问题做粗筛,只把与该意图相关的技能描述展示给模型。这个意图分类器不需要很重,甚至可以用规则+关键词完成,因为它的目标是缩小候选集,而不是做最终决策。粗筛之后,候选技能通常从 30 个降到 5 个以内,模型的调用准确率会明显提升。
另外,技能库里应该支持让 Agent 记录“什么场景下用了什么技能、结果如何”。这个记录不一定要长期保存,但至少在单轮多轮会话里要有。比如用户先问“我的订单到哪里了”,Agent 调用了order_query;用户接着问“那这个订单还能改地址吗”,模型看到上文已经查到了订单号,就应该直接使用这个订单号调用order_modify的预检查接口,而不是反问用户要订单号。这种上下文衔接能力,依赖技能调用历史的回传,必须有意识地在交互协议里设计,否则模型就会变成“每次都要用户重复一遍的笨蛋助手”。
4. 可靠性工程:测试、评估、灰度上线
技能库要能长期演进,不能只靠“在本地跑了一次好像没问题”来保证质量。这一章分享我在测试、评估和上线环节的具体做法。
4.1 离线测试:用固定用例守住行为边界
每个技能在tests/目录下都有一套离线用例,格式是输入 JSON 和期望输出 JSON 的配对。测试脚本会遍历所有用例,执行技能进程,比对输出结构、关键字段和退出码。
[ { "input": {"order_id": "20240601001"}, "expected": { "success": true, "data": { "status": "shipped", "logistics": ["已发货", "运输中"], "items": ["手机", "保护壳"] } } }, { "input": {"order_id": "123"}, "expected": { "success": false, "error": {"code": "INVALID_ORDER_ID", "message": "订单号长度不足"} } } ]用例的设计要多考虑模型的调用习惯。我发现模型调用技能时经常不按常理出牌,比如日期参数给定成“2024年6月1日”,而不是标准化的2024-06-01;再比如明明 schema 里标了order_id是 string,模型还是会传出 int。所以在离线测试里要专门加入“输入格式容错”的用例,让技能在收到非标准输入时也能给出可处理的反馈,而不是直接抛异常。
4.2 在线评估:模拟用户任务而不是模拟用户对话
离线测试之外,我还会跑一组“在线评估”任务。这组任务不在单元测试里做,因为需要真实的大模型参与。做法是:准备 30 个模拟用户任务,比如“用户想查询订单 20240601001 的物流信息”“用户想取消订单 20240601002”“用户想知道最近 7 天的销量趋势”,每个任务都有期望的技能调用序列和最终回复要点。
跑评估时,Agent 使用当前技能库的索引,完整地执行任务,然后自动检查三件事:第一,是否在合适的时机调用了正确的技能;第二,技能调用的入参是否完整合理;第三,最终生成的回复是否包含期望的信息点。
这个评估体系的价值在于,它能捕捉到“技能描述写得不好”导致的隐性失败。比如某个技能在离线测试里逻辑全对,但描述写得含糊,模型在真实任务里根本不调用它,离线测试完全发现不了,在线评估一跑就露馅。
4.3 灰度与回滚:技能变更也能走发布流程
技能库也是一个软件系统,所以也要有灰度发布和回滚机制。我们的做法比较朴素:索引文件打版本 tag,旧版本索引保留备份。发布新技能或修改描述后,先在线上环境让 10% 的流量使用新索引,其余 90% 用旧索引。对比两组流量的技能调用成功率、任务完成率和人工反馈,确认没问题再全量切换。
回滚其实更容易被忽略。有一次某个技能的新版本把所有订单查询都引到了一个新的只读副本,结果那个副本的数据延迟了 10 分钟,用户看到的订单状态明显不准。好在我们保留了旧索引,一条命令就切换回去了。所以这里给的建议很直接:对技能库做任何改动,都要能在一分钟内恢复原状,这是底线。
4.4 技能规模增长带来的性能与维护问题
技能数量超过 30 个之后,每次构建索引、加载描述、跑全量回归的时间都会明显上升。我在实践里做了三件事来缓解。
一是给技能描述做“两级展示”。索引文件里只保留一句话摘要,详细信息在模型决定使用某个技能时再按 name 拉取完整描述。这样 system prompt 不会因为技能过多而膨胀。二是给技能分域构建索引,按领域分别生成customer.json、order.json、data_analysis.json,运行时只加载和用户问题最相关的那个域。三是全量回归测试改成“改动技能 + 关联技能”的范围回归,而不是每次都跑全部用例,节省时间的同时还能更快获得反馈。
维护方面,技能库一定要定期做“废弃清理”。有些技能当时为某个临时需求创建,需求结束后还留在索引里,模型时不时错误地调用它,产生奇怪的回答。给每个技能加一个deprecated标记,并在描述里明确写“该技能已废弃,不要在任何场景使用”,比直接删除要安全,因为有时候旧会话还依赖这个技能名。
5. 从稳定到异常:五个值得警惕的失败模式
技能库跑起来之后,真正的挑战是各种边界情况。我这里列出几个在实践里反复出现的问题,以及对应的规避方案,你可以直接把这几条当成自检清单来用。
5.1 描述越具体,模型反而越不敢调用
这可能有点反直觉。我一开始以为描述写得越详细,模型越容易理解。后来发现不少技能因为描述里写了“仅当……且……才使用”,模型在模糊场景下就会犹豫,干脆不调用了。
问题的根源在于模型对否定条件往往过于保守。好的描述策略是:先写正面的使用场景,把最典型的用户问法放在最前面,负面触发条件放在后面,并且用“优先使用”这样的词来明确鼓励模型。比如:
description: 当用户询问订单状态、物流信息、收货时间时优先使用。可接收 16-20 位订单号。如果用户没有提供订单号,应该先向用户索要;如果用户只是在咨询退货政策,则不要使用本技能。把“索要订单号”写进描述而不是靠模型临场发挥,效果会好很多。
5.2 技能循环调用
另一个经典问题:模型调用 A 技能失败,根据错误码自动修改参数重试,重试再失败,再重试,形成循环。如果技能里有副作用(比如创建工单、发送邮件),循环会造成严重后果。
我建议做两层防护。第一层是调度器硬性限制:同一技能在同一轮任务中最多执行 3 次,超过次数直接返回“多次尝试后仍然失败,建议人工介入”。第二层是在技能描述里写明某些操作的“单次性”——比如“本技能一旦调用就会创建真实的工单,请务必确认所有参数准确后再调用”。模型读到这种提示后,会在入参不完整时主动向用户确认,而不是蒙着头调用。
5.3 上下文被技能返回结果塞爆
技能返回大量数据时,Agent 的上下文窗口会被快速消耗。比如查询一个商品的详情,数据库里可能包含几十个字段、几百条日志,把全部内容返回给模型,既浪费又容易混淆模型判断。
解决办法是在技能内部做字段裁剪。技能输出给模型的内容,和技能实际存储的内容,可以有各自的 schema。比如查询订单时,给模型展示的字段只有 status、logistics_summary、item_names、estimate_delivery_time;而完整日志保存在本地存储,用户如果追问“为什么物流卡住了”,再触发一个order_logistics_detail技能去拉详情。
给每个技能的 output schema 设置max_tokens上限也是一个有效手段。如果输出超过上限,截断后添加“已省略部分内容”的提示,模型通常能理解还有更多数据可查。
5.4 多技能并行调用的冲突
有些场景需要同时调用多个技能,比如用户问“对比这周和上周的销量,并且告诉我哪些商品需要补货”,模型可能会同时触发trend_report和inventory_check。这两个技能如果内部都写同一个临时文件、或者并发更新同一个缓存,就可能互相踩数据。
做并行执行前一定要确认技能有“只读优先”的设计原则。默认情况下,一个技能除非明确声明会写外部状态,否则应当保持纯计算和只读访问。对于有写操作的技能,要给调度器声明写锁,让这类技能在串行队列里执行,避免和其他技能并发。
5.5 外部 API 的响应结构变化
技能对接的外部 API 一旦响应结构变化,技能会静默失败,或返回一些奇怪的字段。比如外部物流接口原来返回tracking_list,某天改了字段名traces,技能代码没同步更新,模型拿到的结果就是一个空数组。
应对方案是给技能内部的 API 响应加一层“结构校验”,校验不通过时返回一个明确的UPSTREAM_CHANGED错误码,而不是把解析失败后的空数据当成正常输出。这个错误码一旦出现,监控系统就要报警,提醒维护者及时排查上游接口变更。很多技能问题都是这样静默发生的,等用户发现时已经产生了一堆错误回复。
6. 技能库的部署形态与运行环境
最后聊一下技能库实际跑起来的环境选项。这里不追求大而全,只说我用过的、并且觉得靠谱的基础配置。
6.1 运行沙箱:本地进程还是容器
最简单的方案是让技能在 Agent 同一进程内作为子进程运行。好处是延迟低,调度逻辑简单;坏处是技能代码里的隐患可能影响 Agent 主进程所在机器的稳定性。稍微安全一点的方案是容器化:每个技能或每个技能组运行在一个独立的容器里,通过标准 I/O 传递数据。
我的实践是,把技能分为两个安全等级。只读且无外部副作用的技能(知识库检索、数据分析、日志查询)可以在本地子进程运行,延迟低。有写操作或需要访问敏感系统的技能(订单取消、邮件发送、支付操作)必须容器化运行,并且赋予最小化的网络权限。
6.2 目录挂载与依赖管理
技能容器或者本地子进程最好工作在一个固定的工作目录里。对于有依赖的技能,启动时通过依赖清单构建镜像或虚拟环境。我的做法是写了一个极简的runners/local/run_skill.py,它负责从 manifest 读取依赖信息,自动把当前目录切到技能目录,再调用对应的 run 脚本。
这里有一个实操建议:在技能开发阶段,不要直接修改共享环境的依赖,而是为每个技能建一个虚拟环境。否则 A 技能升级了某个库,B 技能可能被牵连挂掉,排查非常痛苦。虚拟环境方案虽然占用磁盘多一点,但隔离性好,值得付出这一点代价。
6.3 日志与可观测性
技能库的可观测性比普通后端服务更难做,因为需要把模型决策和技能调用关联起来。我的日志方案包含三级:第一级是调度日志,记录每次技能调用的时间、入参摘要、出参摘要、耗时、错误码;第二级是模型决策日志,记录模型选择了哪个技能、为什么选择(如果模型能输出思考过程的话);第三级是业务结果日志,记录用户对最终回复的反馈,比如“有帮助/没有帮助”。
串联三者的关键是 request id。一场会话的所有技能调用和模型决策都带上同一个 request id,之后任何一次异常回查,都能从日志里还原完整的链路。这对分析“为什么这个用户问题被错误地路由到了订单取消技能”非常有帮助。
6.4 技能库的后续演进方向
目前这个 agent-skills 项目对我来说已经从“给 Agent 加能力”进化成了一个“Agent 外设能力的运维平台”。后面我计划再做两件事:一是把技能描述和元信息也纳入版本差异对比,这样每次改描述后能清楚地看到变了什么;二是尝试让技能库有简单的自学习能力,比如记录哪些技能被高频调用但在线上评估中表现较差,自动生成待排查清单。
如果你也在维护类似的 agent-skills 仓库,我的建议是不要一上来就追求高深的设计,先把目录、注册、调用、测试这四件事做扎实。等到一切稳定了,你会发现技能库本身比 Agent 的 prompt 更值得花时间打磨——毕竟它承载的就是 Agent 真正可以去行动的那部分能力。