☰
用技能库重构Agent工具调用:SKILL.md实战与准确率提升指南
2026/10/7 22:17:59 网站建设 项目流程

1. 先说我为什么从"挂工具"转成"搭技能库"

1.1 一次翻车现场:模型把天气工具用去查订单

大概在半年前,我第一次正儿八经做客服 Agent,当时的思路很直接:给大模型挂上十几个 API 工具,让它自己选着调。听起来没问题,实际一上线就崩。用户问"我的订单什么时候到",模型调了查天气的接口;用户问"你们退货政策是啥",模型调了订单查询接口,然后一本正经地编了一段退货政策出来。

我一开始也骂模型蠢,后来把调用日志翻出来一条条看,发现问题不在模型,在我。

当时的工具描述就一行字,比如"Get weather"、"Query order",参数也写得极其随意——字段叫什么、什么格式、必填不填,全靠模型猜。模型在一个充满歧义的"工具菜单"里做选择,选错其实是大概率事件,选对才是运气好。那一刻我意识到:工具调用这件事,真正缺的不是 API,是给每个能力写一份"说得清什么时候用、怎么用、什么时候别用"的说明书。

1.2 技能库的本质:把"能力列表"升级成"使用契约"

后来我参考了社区里 Agent Skills 的思路,把整个工具层重构了一遍,做成了一套叫 agent-skills 的体系。核心变化有三点。

第一,每个能力独立成一个技能目录,目录里有说明书(SKILL.md)和可执行脚本,不再是一堆散装的 JSON Schema 丢给模型。

第二,说明书里不只写"这技能是干嘛的",还明确写"什么时候用""什么时候绝不能用""具体执行步骤是什么"。这相当于和模型签了一份使用契约,把歧义摁死。

第三,所有技能统一注册、统一加载,路由时先生成结构化调用意图,再执行脚本。执行结果回传后,模型基于真实结果继续回答,而不是自己脑补。

这套重构上线之后,同一个场景下工具选择准确率从大概 71% 提到了 94%,最直观的变化是:模型不再拿查天气的去查订单了。

这篇文章,我想把整个 agent-skills 的搭建过程、设计思路和踩过的坑完整写出来。如果你正在做 Agent 开发,被工具调用不稳定、模型乱选工具、参数瞎传这些问题折磨过,这篇文章大概率能帮你少走两周弯路。

1.3 适合谁看,不适合谁看

先做个读者定位。这套方法适合下面几类人:

  • 正在用 OpenAI Function Calling、Claude Tool Use 或类似机制做 Agent 的开发者
  • 手头工具数量超过 5 个、已经开始出现模型选错工具的团队
  • 想给自己的 Agent 接入私有 API 或内部系统,但不知道该怎么组织这部分能力的人

不太适合的情况也有:如果你的 Agent 只调一个工具,或者所有调用都是写死在代码里的流程编排,那技能库这套东西暂时帮不到你。等你的 Agent 需要"自主决定调什么"的时候,再回来翻这篇不迟。

2. SKILL.md 怎么写,模型才不会误解你的意图

2.1 一个技能的完整骨架

一套技能,本质上就是一个目录加一份说明书。目录结构我用了最朴素的约定:

skills/ search_docs/ SKILL.md search_docs.py query_sql/ SKILL.md query_sql.py calc/ SKILL.md calc.py

每个技能目录下都有一份 SKILL.md,这是整个体系里最重要的文件。它的 YAML frontmatter 长这样:

--- name: search_docs description: 在本地知识库中检索资料,返回与问题最相关的文档片段 when_to_use: 用户询问产品使用、操作手册、公司制度、FAQ 等静态知识 when_not_to_use: 用户询问实时数据(天气、股价、新闻),或要求进行数学计算 version: 1.2.0 ---

frontmatter 下面才是正文,正文用枚举步骤把执行流程写清楚:

# 技能:本地文档检索 1. 从用户问题中提取 2-5 个关键词,去掉停用词 2. 调用 search_docs.py,传入 query 和 limit 参数 3. 如果结果为空,改写关键词后重试一次 4. 返回结果前,用一句话概括每个命中文档的核心结论

这个结构的好处是:既给模型读了"何时用"的语义描述,也给了它"怎么执行"的操作序列。模型读完后,不是瞎调工具,而是按你的步骤一步步走。

2.2 正向描述人人会写,负向约束才是分水岭

很多教程都会教你写 description,但 90% 的人写出来的 description 只有半句话,比如"用于文档搜索"。

这种写法有一个致命问题:模型对"文档"的理解太宽了。用户说"帮我看看这个 PDF 里写了啥",模型可能觉得这也是"文档搜索",于是把 search_docs 调起来。但实际上那个 PDF 是用户上传的,本地知识库里根本没有。

我后来在技能描述里加了一个之前完全没意识到重要性的字段——when_not_to_use。效果非常明显。

拿 search_docs 举例。最初版本只有"在本地知识库中检索文档",模型命中准确率只有七成。加了负向约束之后,错误调用率直接降了一半。因为负向约束给了模型一个"排除法"的依据:如果任务不属于这些场景,就不应该选它。

所以我的建议是:每个技能的 description 里,至少用一句话说明"什么时候不要用它"。这一句话,比你在正向描述里多写三行废话有用得多。

2.3 描述的关键词排布,决定匹配质量

还有一个容易被忽略的细节:技能的 description 不是给你看的,是给模型的语义匹配看的。模型做工具选择时,会把用户意图和技能描述做语义相似度计算,所以描述里的关键词排布,直接决定了匹配质量。

我踩过一个挺典型的坑。当时做了两个技能,一个是"query_order"查订单状态,一个是"query_sql"查数据库。前者的描述写的是"查询订单状态、物流进展、售后进度",后者的描述写的是"执行 SQL 查询数据库表格数据"。

表面看没什么问题,但实际上线后发现:用户说"帮我查一下数据库里有多少订单",模型经常会选 query_order,因为它看到了"订单"两个字。这些技能描述之间出现语义重叠时,模型就像站在岔路口,很容易选错。

后来我在 query_sql 的描述里加了"本技能直接读取业务数据库,用于数据分析与统计,不返回单个订单详情",在 query_order 的描述里加了"只查具体订单,不做聚合统计"。两个边界一划清楚,选择准确率马上就上来了。

写技能描述时的几条经验:

  • 描述里必须点出技能的"输入场景",而不是只说功能名字
  • 如果两个技能服务同一类用户请求,必须把差异点显式写进描述
  • 关键词要放在描述开头,因为截断或注意力衰减可能让后半句失效

3. 技能库的运行架构:注册、路由、执行

3.1 加载器:把目录变成模型能看的 tools 列表

光有说明书还不够,得让代码能把说明书读进来,转成模型能识别的工具 Schema。我写了个能力加载器,每次 Agent 启动时扫描整个 skills 目录:

from pathlib import Path from typing import Any SKILLS_ROOT = Path("./skills") def load_skills(root: Path) -> list[dict[str, Any]]: skills = [] for skill_dir in root.iterdir(): md_path = skill_dir / "SKILL.md" if not md_path.exists(): continue meta = parse_frontmatter(md_path) skills.append({ "name": meta["name"], "description": build_description(meta), "parameters": json.loads((skill_dir / "schema.json").read_text()), "runnable": skill_dir / f"{meta['name']}.py", "version": meta.get("version", "0.0.1"), }) return skills

关键在 build_description。我会把 frontmatter 里的 description、when_to_use、when_not_to_use 拼接成一个完整的描述文本。这样模型看到的 description 就不再是一句话,而是一段包含正反向约束的信息:

def build_description(meta: dict) -> str: desc = f"{meta['description']}。适用场景:{meta['when_to_use']}。" if meta.get("when_not_to_use"): desc += f"不适用场景:{meta['when_not_to_use']}。" return desc

每个技能的参数定义单独放在 schema.json 里,加载器会把它转成模型的 function 参数结构。这个做法的好处是:技能的新增和下线完全不需要改主程序代码,往 skills 目录里丢一个新文件夹就完事了。

3.2 两步路由:先选技能,再执行技能

这是 agent-skills 里最关键的一个设计。很多 Agent 的做法是直接把所有工具函数一股脑传给模型,让它在一个大列表里挑。工具少的时候没问题,工具一多,模型的选择质量就断崖式下跌。

我的做法是拆成两步。

第一步,让模型在技能列表里做"选择",这一步只输出技能名和参数,不执行任何代码。如果模型觉得所有技能都不合适,允许它不选——直接回复用户,而不是硬调一个不相关的技能。

第二步,根据模型给出的技能名,在注册表里定位到对应脚本,用子进程或独立容器执行,然后把真实结果回传给模型,由模型基于结果组织最终回答。

代码层面大概是这个意思:

# 第一步:模型选技能 response = model.call( messages=conversation, tools=[skill["tool_schema"] for skill in skills], ) if not response.tool_calls: return reply_without_tool(response) selected = response.tool_calls[0] skill_map = {s["name"]: s for s in skills} skill = skill_map.get(selected.name) if skill is None: return "抱歉,该操作当前不可用" # 第二步:执行技能脚本 result = subprocess.run( [sys.executable, skill["runnable"], *args_to_cli(selected.arguments)], capture_output=True, timeout=30, ) # 第三步:真实结果回传 final_reply = model.call([ *conversation, {"role": "tool", "content": result.stdout}, ])

为什么拆成两步比一次性调用稳定?因为"选哪个工具"和"用工具之后怎么回答"是两个不同难度的决策。前者是一个分类问题,后者是一个生成问题。混在一起,模型的注意力会被长上下文稀释;拆开之后,每一个环节的上下文都很短、很聚焦,出错的概率自然就降下来了。

3.3 技能执行的边界:权限、超时与无害化

技能一旦涉及真实操作,比如写文件、发消息、改数据,就必须考虑安全边界。这个部分不能偷懒,否则一个参数错误就可能造成不可逆的影响。

我的做法分三层:

第一层,参数白名单校验。模型传进来的参数不能直接信,必须用 schema 校验一遍,类型不对就强转,范围越界就拒绝。

第二层,超时和资源限制。所有技能统一用 subprocess 执行,设置 timeout,防止某个技能因为死循环或请求外部服务卡死不退。

第三层,操作类技能加"预执行确认"。凡是带副作用的技能,比如"发送邮件""修改数据库",在执行前多问模型一轮:你确定要执行吗?参数都准确吗?虽然多一次调用,但能拦掉大量幻觉参数导致的误操作。

4. 排查实录:模型连续三天选错技能的那次经历

4.1 现象:所有错误都集中在两个相似技能上

技能库上线第二周,我发现一个诡异的现象。日志里 search_docs 的调用次数高得离谱,但里面有一半的调用,用户实际问的是"算一下""统计一下"这类需求。也就是说,模型把"检索文档"的技能,用来干数学计算的活了。

最开始我怀疑是不是模型版本更新导致能力回退,但我把出错的调用日志单独拉出来看,发现所有错误调用都指向同一个输入模式:用户问题里含有"查一下""看看""帮我找找"这类模糊动词。

比如用户说"帮我查一下 3 月份销售额是多少",模型就把 search_docs 调起来了。而当时明明有一个 query_sql 技能,专门干这个事。

4.2 排查链路:从怀疑模型到定位到描述向量重叠

我做了三步排查。

第一步,检查上下文。把出错时喂给模型的完整 messages 序列调出来看,确认上下文没有截断,技能描述都被完整送进去了。排除上下文长度问题。

第二步,直接把同一个 prompt 在不同模型上测了一遍。在 GPT 和 Claude 上都复现了类似错误——这说明问题不在某一个模型,而在技能描述本身。

第三步,给技能描述做了个相似度计算。我用 embedding 把两个技能的 description 向量化,然后算余弦相似度。结果让我有点意外:search_docs 和 query_sql 的描述相似度高达 0.61,而它们本应该是两个完全不相干的能力。

为什么相似度这么高?因为两边都出现了"查询、数据、内容、文档"这些高频词。模型在做语义匹配时,会倾向于把用户问句映射到相似度最高的描述上,而用户问"查一下销售额",其语义重心其实更靠近"查询数据",于是模型就挑中了描述里同样有"查询、数据"的 search_docs。

4.3 修复:不只是改文案,还要改路由逻辑

定位到根因后,我做两处修改。

第一处,重写两个技能的描述。核心思路是让它们的语义空间尽量正交。search_docs 的描述里所有"数据"字样全部改成"文档片段""知识条目";query_sql 的描述里则强化了"结构化表格""聚合统计""销售/订单明细"等专属关键词。同时,两个技能的 when_not_to_use 里互相点名对方:

  • search_docs 的不适用场景加上:"不用于数值统计、表格分析"
  • query_sql 的不适用场景加上:"不用于检索自然语言文档或回答政策类问题"

第二处,路由逻辑升级。我不再让模型从一个平铺的技能列表里直接选,而是把技能按领域分组,先做一次粗粒度路由,再到组内做细粒度选择。这一步相当于给模型加了一个"先分类再匹配"的过程,选择准确率提升非常明显。

修复后我重新做了一轮回归测试:随机抽取 500 条历史对话,模拟真实用户请求。技能选择准确率从 71% 提升到了 94%,而且之前最严重的"用搜索技能做计算"的错误,在测试集中归零。

这次排查给我最大的教训是:当模型频繁选错工具时,第一反应不要怪模型,先去看技能描述之间的相似度。描述写得含糊,模型必然含糊。

5. 参数 Schema 和上下文预算:规模一上去,新坑就来了

5.1 Schema 写得太严格,模型反而不会填

技能数量变多之后,参数问题开始冒头。最开始我图省事,每个技能只有一个参数,全部用字符串类型。后来技能复杂了,开始出现必填字段、枚举值、嵌套对象,问题就来了。

一个典型的案例:search_docs 的 limit 参数,我一开始设成必填,范围限制在 1-20。结果模型经常漏填,一漏填整个技能调用就报错,Agent 就得重试。后来我给 limit 加了默认值 5,并且把必填改为非必填,漏填率一下就降下来了。

另一个坑是枚举值。我在 query_sql 技能里设了一个 table_name 参数,枚举了三个可查的表格。结果用户问了个不在枚举里的表,模型没有选择不去查,而是从枚举里硬挑了一个最接近的——然后返回了一堆错误数据。

我的解决办法是 schema 里加了 additionalProperties: false,禁止模型发明参数。另外,枚举值不是拦截所有不在范围内的请求,而是在参数校验不通过时,让 Agent 主动反问问用户,而不是闭眼硬试。

5.2 技能列表膨胀,上下文塞不下了

技能到 20 个的时候,新的问题出现了:所有技能描述拼起来,占了上下文一大半,模型开始出现"注意力稀释"——它能看到所有技能,但哪个都记不牢。

当时我在日志里看到一个典型错误:用户问"今天天气怎么样",模型在一堆技能列表里选中了一个人气最高的技能(因为描述最长、信息最多),而不是真正匹配天气的技能。这说明当技能超过一定数量后,模型的选择逻辑会退化成"选描述最显眼的"。

我试过压缩描述、删除冗余字段,都没用,因为根因是候选集太大。最终解法是引入技能分组路由:

SKILL_GROUPS = { "knowledge": ["search_docs", "faq_lookup", "wiki_query"], "data": ["query_sql", "export_csv", "chart_draw"], "external": ["weather", "geo_code", "translate"], "operation": ["send_email", "create_ticket", "update_order"], }

执行时先让模型判断用户请求属于哪个分组,再只把这个分组内的技能描述传给模型做选择。这样一来,模型每一步面对的候选集从 20 个降到了 4-5 个,选择准确率重新回到了 90% 以上。

关于技能数量,我最后得出的经验是:单个 Agent 实例里,平铺技能不要超过 12 个;超过就得分组,否则能力列表本身就是噪音。

5.3 模型幻觉参数的拦截:校验、重试、放弃三层兜底

模型生成参数时,偶尔会"幻觉"出一些根本不存在的字段,或者把数字类型传成字符串。这些问题如果不在入口拦住,会一路带进业务系统,轻则报错,重则脏数据。

我的技能执行入口做了三层兜底:

第一层,Schema 强校验。所有参数过 pydantic 模型,类型不对自动强转,字段缺失用默认值补齐,多余字段直接丢弃。

第二层,校验失败后自动重试一次。把校验错误信息回传给模型,让它重新生成参数。这一步能挽回大概六成左右的错误参数。

第三层,重试仍然失败,就放弃调用,明确告知用户"当前无法完成该操作",绝不带着错误的参数硬上。

这三层兜底跑了一个月之后,参数相关的错误率从 8% 降到了 2% 以下。别小看这 6 个百分点的提升,在真实业务里,这就意味着每天少几十次错误调用。

6. 技能库的长期运营:版本、度量和淘汰

6.1 技能必须做版本管理,不然线上问题你定位不了

技能库上线稳定之后,我踩了一个挺常见的坑:技能脚本更新了,但 Agent 框架加载的还是旧版本,线上出了问题,对着新代码排查了半天,最后发现跑的压根不是这个版本。

后来我做了三件事,彻底解决这个问题。

第一,每个技能目录全部纳入 git 管理,并且独立记录版本号,发布时跟着主程序一起做 tag。

第二,Agent 框架每次加载技能时,把技能版本号写入日志。这样哪次调用用了哪个版本的技能,全部可以回溯。

第三,灰度发布。技能脚本改了之后,不直接全量上线,而是先在一小部分会话里加载新版本,跑一天看错误率,没问题再全量切。

这套流程跑通之后,技能改起来放心多了。因为没有版本管理之前,一次改动上线,出问题你连"是哪个版本导致的"都说不清。

6.2 用几个指标判断技能有没有在认真工作

技能库运营一段时间后,我建立了一套简单的度量体系。不多,就三个核心指标。

第一个是调用次数。这个最直观,一个技能一周内被调用多少次,一眼就能看出它是热门能力还是摆设。连续一个月一次没被调用的技能,基本可以怀疑它存在的价值。

第二个是选择准确率。计算方式是:所有调用日志里,人工标记为"正确技能"的次数除以总调用次数。理想情况应该在 90% 以上。如果一个技能经常被选但经常选错,说明描述可能有问题,或者它和另一个技能语义重叠了。

第三个是修正率。模型第一次调用就完美执行的占比,这个指标衡量的是参数 Schema 写得好不好。如果某个技能修正率特别低,老是让模型重试参数,说明它的参数定义对模型不够友好,需要简化。

这三个指标我每周跑一次,表格拉出来自己看,作为技能库优化的依据。

6.3 该合并就合并,该下线就下线

技能库不是越大越好。我有一次为了覆盖更多场景,一股脑加了十几个新技能,结果其中一个技能上线 45 天,一次都没被调用过。不仅占上下文预算,还在语义匹配时对其他技能造成干扰。

后来我建立了一个比较简单的淘汰规则:技能连续 30 天零调用,或者和已有技能描述相似度超过 0.5,就进入评审。评审后要么合并、要么下线、要么重写描述。

另外,技能合并也要动脑筋。不是把两个脚本塞到一个技能里就行,而是要重新分析这两个能力的用户意图是否一致。比如"查天气"和"查空气质量"可以合并成一个"查天气与环境",但如果把"查天气"和"查订单"合并,那模型反而会晕。合并的判断标准是:用户会用一句类似的话来同时触发这两个能力,才值得合并。

技能库运营到现在,我的体感是:它更像一个产品,不是一份代码。技能的增删改查应该跟着真实用户需求走,而不是跟着"我能做什么"走。定期清理、度量、迭代,才能让技能库保持健康。

最后分享一个我个人的经验:如果你第一次接触 agent-skills 这套方法,不要一上来就铺二十个技能。先拿 5 个高频的、边界清晰的技能跑通全流程,把描述规范、路由机制和安全边界都调顺了,再慢慢往里加。技能库这个系统,规模小的时候怎么搭都行,规模一大,前面偷的懒都会变成后面踩的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询