☰
从工具平铺到技能库:Agent工具调用准确率提升的工程实践
2026/10/14 9:01:46 网站建设 项目流程

接了个内部的小项目,名字叫 agent-skills,说白了就是给智能体搭一套“技能系统”。一开始我以为这不过是把几个工具函数整理一下、配个 Prompt 就完事,结果真做起来才发现,工具堆得越多,模型越容易犯迷糊。后来我花了两周时间,把整套工具的调用方式从“平铺式函数列表”改成了“显式技能库”,带过来的提升非常直接:触发准确率从 67% 左右涨到了 83%,参数填错率也降了一大截。这篇文章把我踩过的坑、试过的方案、以及最终的落地方案完整拆出来,适合正在做 Agent 应用、或者被“工具一多就乱选”问题折磨的开发者参考。

1. 为什么 Agent 需要一套显式的技能体系,而不是越多越好的工具函数

1.1 工具一多,模型就开始“乱选”

我最初的做法很朴素:把需要的功能直接写成函数,注册给模型当工具调用。当时接了十几个工具,包括网络搜索、数据库查询、内部文档检索、定时任务、消息推送等等。开发的时候觉得功能越全越好,可真到测试阶段,翻调用日志的时候差点没崩掉。

一个典型的例子:用户说“帮我统计一下上个月每个渠道的线索量,然后生成周报”。模型第一轮确实调用了数据查询工具,但参数把“上个月”传成了“本月初至今”,时间范围直接错;第二轮想生成周报,却把消息推送工具当成文档生成工具用了。类似的错误,在 10 个工具以内还不算频繁,一旦超过 15 个,问题就成几何级数增长。

我统计过两轮实测数据:平铺工具的情况下,模型对工具的选择准确率只有 67%,参数完全填对的概率更惨,只有 53%。也就是说,十次调用里差不多有三次会选错工具,两次会把关键参数写错。这种稳定性的 Agent 根本没法交给真实场景使用,这也是我下决心重构的直接原因。

1.2 技能是“工具 + 使用方法 + 边界”的封装

工具描述和技能描述的区别,其实很像“工具箱里的一把扳手”和“一辆车的完整维修手册”的区别。扳手本身只是一件硬件,但“如何根据螺栓规格选扳手、什么场景不能用它、拧的时候扭矩多少、拧不动怎么办”,这些信息散落在别处,并没有和扳手绑定。

AI 的调用链路也一样。工具函数只是给模型提供了一个“动作入口”,它不知道为什么要用这个动作、什么时候适合、遇到什么情况应该放弃。技能则是一个完整的封装单元,它把“工具的用途、触发条件、输入输出约束、执行策略、失败兜底路径”绑定在一起,模型看到的不再是一个孤零零的函数,而是一份“怎么用 + 什么时候用 + 什么时候不用”的完整说明书。

我重构之后的核心概念很简单:一个技能单元通常包含五个部分:技能描述、参数 Schema、执行体、前置条件、失败兜底。描述负责让模型判断“该不该调用”,Schema 负责让模型“按规矩传参”,执行体负责“把事做成”,前置条件和兜底负责“在异常局面里稳住”。

1.3 显式技能化带来的三个核心收益

第一是决策准确率提升。技能描述写得好的前提下,模型不需要在一大堆平铺函数里猜语义,而是先通过路由缩小范围,再在少量技能里做精确匹配。实测对比非常明显,同样一套能力,技能化之后选择准确率提高了 16 个百分点以上。

第二是可观测性。技能体系天然有一套元数据:技能 ID、版本号、调用记录、参数入参、结果状态。出了问题可以直接定位到具体技能、具体参数、具体失败模式,不像平铺工具时代,只能翻模型吐出来的原始 JSON,看半天也看不出它为什么选错。

第三是协作边界清晰。有了技能库,产品经理、算法工程师和后端工程师可以围绕同一个清单讨论。后端只管提供执行体,算法只管优化“技能描述-模型触发”链路,产品只管定技能边界。三方的沟通从“不可言说的大模型行为”变成了“可审查可变更的技能版本”。

我把这个思路做成了一张对比表,方便直观看到差距:

维度平铺工具调用技能库驱动
工具选择准确率67%83%
参数正确率53%74%
平均决策耗时1.1s0.7s
新工具接入成本低,但维护难高一点,但可复用
错误定位难度高低

2. 技能的核心结构:拆解、描述与边界划定

2.1 一个技能的最小编成

我给自己定下了七要素模型,每个技能上线前必须把这七项写完整:

  • 技能名称:短、无歧义,建议用“动词 + 宾语”的结构,比如“查询线索数据”“生成周报模板”。
  • 技能描述:给模型读的说明书,必须写清楚触发场景、执行动作、输入输出、禁用情况。
  • 参数 Schema:JSON Schema,做结构化和校验。
  • 执行体:真正做事的函数或服务入口。
  • 前置条件:执行前需要满足的状态,比如“数据源已连接”“用户已授权”。
  • 兜底策略:失败后的备选路径,比如降级查缓存、返回局部结果、让模型换一个邻近技能。
  • 版本与负责人:技能会随需求迭代,没有版本管理的技能库很快会乱成一锅粥。

这七项里面,描述和参数 Schema 是模型侧的“界面”,执行体和兜底策略是系统侧的“内核”。模型能不能用对技能,主要看前两项;技能能不能稳定扛住线上流量,主要看后两项。

2.2 搞懂任务拆解,才能定义技能边界

技能边界这件事,我是反复调了好几次才找到感觉。一开始我把“会议助手”整个做成一个大技能,描述写了很长,参数也有十几项,结果模型面对“把昨天的录音整理成纪要”这种请求时经常绕过主逻辑,反而去调别的工具。后来我把它拆成了五个原子技能:

  • 录音文件转写:音频文件进来,转出带时间戳的文字。
  • 发言人分离:按音色特征区分说话人,把转写结果标注成不同角色。
  • 会议内容摘要:针对转写文本生成结构化摘要。
  • 行动项提取:从文本中提取待办事项、负责人与截止时间。
  • 归档执行:把整理结果写入指定文档库。

拆完以后每个技能的描述都非常好写,因为职责单一,触发条件清晰。模型面对“昨天产品讨论会的录音整理成纪要”时,会依次触发“转写”和“摘要”,再通过编排层串联,准确率远比一个大技能高得多。

拆解时我常用的判断标准是:一句话能不能说清这个技能“什么时候调用、输入什么、输出什么”。说不清就继续拆;如果说得太清且太窄,比如“打开某个固定页面并点击某按钮”,也可以考虑并入上一个相关技能。

2.3 技能描述怎么写给模型才有效

描述是人和 LLM 之间的真正接口,这块值得多花时间反复锤炼。我一开始以为描述越长越详细越好,结果模型被一堆前置条件和边界规则绕晕了,反而频繁误触发。后来总结出一套适合大多数场景的写法:

先说触发条件,明确什么情况下该用。再说执行内容,用动词短语描述动作。然后给参数说明,包括必填项和可选项。最后写反例,说明什么情况下禁用,这样模型更容易避开误用。

以“查询线索数据”为例,初始版本写的是“查询线索数据,支持各种筛选条件”,模型基本看不太懂。优化后这样写:

这个技能用于从 CRM 数据库中查询销售线索列表。触发条件:用户正在询问线索数量、线索状态、来源渠道、跟进阶段等统计信息。执行内容:接收查询条件,返回结构化线索列表。输入参数:时间范围、渠道、状态等。禁用场景:用户询问的是客户联系方式或互动记录,此时应调用其他技能。

看起来只是多了几句话,但效果差异巨大。同一个技能,从“执行目的不明确”变成了“能明确匹配请求意图”,触发准确率从 61% 提升到 84%。

2.4 原子技能和工作流的边界

技能不是越大越好,也不是越小越好。原子技能负责“做一件事”,工作流负责“按正确顺序编排多件事”。两者必须分开,混在一起就成了“大泥球”。

我在项目里设计了两个层级:技能层和编排层。技能层只保证“给定参数,完成单一动作”,比如“查天气”“查会议空闲时间”“发送邮件”。编排层负责“理解用户意图,决定调用技能的顺序和条件”。这样做的好处是,如果某个步骤失败,编排层可以动态选择替代技能,而不是硬着头皮跑完一个写死的流程。

我举一个实际例子:用户说“如果明天上午不下雨,就约会议,并把会议邀请邮件发给我”。这需要三个技能并行/串联:查天气、查空闲档期、发邮件。如果天气查询失败,编排层会尝试用天气服务的备选接口,而不会把整条链路卡死。如果没有编排层,模型只能硬编一个步骤序列,任何一步出错都会导致整个任务中断。

当时我刚接项目的时候完全没有概念,先去找了项目的老代码看了看。老实说老代码能跑,但扩展性很差。后来我干脆用两天时间,把技能描述和调用链路全部重写了一遍。整个过程里我沉淀下来一套很顺手的流程:先从用户请求日志里找出高频动词和对象,再给每个高频组合设计技能,最后把技能描述放到一个独立的目录里做版本管理。每个描述我都会反复测试,看它跟哪些请求容易混淆,再针对性加一句反例,把混淆边界切干净。

3. 技能注册、发现与调用的工程实现

3.1 注册表负责统一接入与规范校验

技能库的底盘是一个注册表。所有技能在上线之前都要先注册,注册表会做规范性检查:描述是否非空、参数 Schema 是否合法、执行体是否能正常响应、锚点是否冲突。注册成功之后,每个技能会得到一个唯一 ID 和版本号,后续调用都靠这个 ID 定位。

我用一个简单的 Python 类做注册基类,大概是这样的思路:

class Skill: def __init__(self, meta, description, schema, handler, fallback=None): self.meta = meta self.description = description self.schema = schema self.handler = handler self.fallback = fallback class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill): if skill.meta["name"] in self._skills: raise ValueError(f"duplicate skill: {skill.meta['name']}") self._skills[skill.meta["name"]] = skill def get(self, name): return self._skills.get(name) def list(self): return list(self._skills.values())

所有技能元信息都存在注册表里面。模型侧的调度上下文、路由层的分类索引、运维侧的监控面板,都是从这份注册表派生的。一旦新增或修改技能,只需要变更注册表配置,不需要改编排主流程。

3.2 模型侧的“技能发现”怎么做

把技能注册好之后,接下来要解决的就是“模型怎么知道有哪些技能”。我尝试过三种方案,各有取舍:

方案 A:把全部技能描述写进 system prompt。适合技能数量少于 15 个的场景,实现简单,模型对每条描述都能看到。缺点是数量一多,Prompt 会膨胀,决策效率下降,准确率也受影响。

方案 B:两级路由。先让一个轻量模型快速做意图粗分类,把技能分成几组,然后再把对应分组的技能描述注入主模型。适合技能数量在几十个等级的场景,token 消耗可控,准确率也比较稳。

方案 C:向量检索。把用户请求和技能描述都向量化,用检索引擎召回最相关的技能子集。适合技能数量上百、意图非常分散的场景。缺点是延迟多一跳,并且要求技能描述本身足够规范,否则检索质量会飘。

我最终采用 B 为主、C 兜底的混合方案。具体过程:用户请求先经过一个轻量路由模型,路由模型只负责从技能组的列表中选一个组;然后系统把该组下的技能描述注入主模型;如果主模型在组内找不到合适的技能,会触发一次向量检索,从全部技能中召回相似度最高的几个。

这个架构的核心原因是:路由模型开销极低,输出结果稳定;分组之间天然隔离,跨组的误导概率小;就算偶尔组内找不到,向量检索还能兜住极端情况。实现上不需要特殊基础设施,我用的都是通用组件,对现有系统改动也很小,两三天就能落地。

3.3 调用参数校验、超时与重试

技能参数不是模型说什么就收什么。我已经踩过太多次“参数类型对但语义完全不对”的坑了。所以必须加一层校验和转换逻辑:

第一步是结构校验,用 JSON Schema 检查参数是否完整、类型是否正确。 第二步是语义转换,比如把用户说的“本周”“上月”转换成具体日期范围,把模糊的自然语言映射成枚举值。 第三步是执行前快照,记录入参和调用时间,方便事后排查。 第四步是超时控制,单个技能默认超时不超过 5 秒。 第五步是重试策略,超时才重试,业务错误不重试,重试次数默认 2 次。

重试策略我总结了一个表格:

失败类型处理方式示例
超时重试 2 次,间隔递增第三方搜索服务响应慢
参数校验失败尝试自动修复一次,失败即报错日期格式不合法,尝试转换
业务错误不重试,走兜底技能用户没有权限查询该数据
依赖服务 5xx缓存降级或切换备用接口数据服务 502

3.4 技能运行时的沙箱与安全

技能执行体不一定都是内部函数,有些需要执行外部代码、访问文件或网络接口。我强烈建议给技能做一层权限控制,否则任何技能都能访问任何数据,等于给自己埋了一堆雷。

我在实践中引入的规则很简单:技能执行体运行在受限环境中,默认无网络出口;需要网络访问的技能单独声明权限;需要写入文件的技能必须指定允许路径;涉及用户数据导出的操作,需要额外审批。这些规则不影响正常调用,但能挡住绝大多数“模型幻觉带来的误调用”风险。

这里的核心思路是:技能本身没有“可以做什么”的权利,准确的表述是“技能以一套最小权限原则去执行任务”。日常聊天类技能不需要读文件,不需要访问网络,就不要再开额外的口子;需要数据查询的技能,只赋予只读权限。整体来说,权限设计得越紧,出安全问题的可能性越低。

4. 技能数量与描述长度对效果的影响,以及我的优化方案

4.1 技能数量不是线性增长

我做过一组“技能数量 vs 决策准确率”的对比实验,数据发出来给大家参考:

技能数量触发准确率参数正确率平均决策耗时
590%82%0.4s
1286%78%0.6s
2083%74%0.7s
3575%63%1.0s

可以看到,技能数量超过 20 之后,模型在“判断该用哪个”这件事上压力明显加大。这并不意外,因为模型面对的描述越多,相似技能之间的边界就越容易模糊。解决方式不是“减少技能”,而是“优化描述 + 分组路由 + 压缩注入长度”。

4.2 给技能描述“瘦身”

一个常见的误解是“描述写得越长越精确”。实际测试告诉我,把复杂约束全部写进去,模型反而会忽略关键信息。我的做法是:都把关键触发条件放在前半段,把反例和边界条件放在后半段;非必要的前置条件不要写;把复杂的校验逻辑放到执行体里,而不是让模型去理解。

我举个例子,“告警分析技能”最初版本描述长达 400 字,列出了十几种告警类型的处理规则。模型测试时经常漏掉关键规则。后来我把描述压缩到 120 字,只保留触发条件、核心动作和三个高频反例,把详细规则挪到执行体内部做判断,效果反而提升了差不多 9 个百分点。

这背后的逻辑是:模型侧只负责“决策该不该调用”,执行侧负责“调用后如何处理”。不要把决策层和执行层的逻辑混在一起写给模型看。

4.3 执行体的幂等与缓存

技能执行体的设计也必须跟上。很多技能内部会调用第三方接口或数据库,同一个技能可能在一次会话里被多次激活,执行体设计得不好会导致重复计算、重复请求,甚至产生脏数据。

我从实践中总结了一条原则:所有读操作类技能执行体必须是幂等的,并加上结果缓存。比如“查询线索统计”这种技能,同一个查询条件在五分钟内无论调用多少次,结果都从缓存里返回。查询类接口延迟从 700ms 降到了 80ms 左右,对会话体验的提升非常直接。

写操作类技能则反过来,必须强制加识别唯一请求 ID,防止模型因为重试导致重复下单、重复发送。这个点不加监督,线上很容易出事。

4.4 自己遇到的两个高频坑

第一个坑是参数校验过严。早期我对参数 Schema 要求很严格,时间范围必须精确到毫秒,用户说“昨天”必须转换成具体日期时间戳。结果模型频繁被判为“参数非法”,技能调用失败率升高。后来我把策略改成“宽松校验 + 执行体容错”:结构正确就放行,执行体内部做语义修正。失败率一下就降下来了。

第二个坑是技能描述里写了太多“不要做什么”。理论上反例越多边界越清晰,实际测试发现反例超过三条后,模型更容易混淆。现在我的每条描述里最多保留两到三个最典型的反例,其他边界规则全部挪到执行体做校验,效果稳定。

5. 一个可复现的实例:从技能库到会议协作助手

5.1 技能清单设计

我拿会议协作助手来完整演示一遍。这个场景适合做技能体系入门,因为边界清楚、流程可测、反馈比较快。我设计的技能清单如下:

技能名称触发条件输入参数输出
transcribe_audio用户提供录音文件链接file_url, language带时间戳文本
separate_speaker需要区分说话人角色transcript_id分角色文本
generate_summary需要会议内容摘要transcript_id, focus_points结构化摘要
extract_action_items需要待办事项清单transcript_id行动项列表
archive_doc需要把结果保存到文档库content, folder文档地址
query_schedule需要查询空闲时间段date, duration空闲时段列表
send_notification需要发送结果给用户message, channel发送状态

这七个技能里,前五个是会议链路的主体,后两个是辅助能力。技能之间各自独立,可以自由编排。

5.2 用户请求链路演示

模拟一个请求:“帮我把昨天的产品讨论会录音整理成纪要,发给项目组的企微群。”

第一步:路由层把请求分类到“会议纪要”组。第二步:主模型看到该组的四个技能(转写、分角色、摘要、归档),决策先调用转写技能。第三步:转写成功后,模型继续调用摘要技能,生成结构化纪要。第四步:模型调用归档技能保存,最后调用通知技能,把纪要推到群聊。

这里有一个细节值得注意:模型并不会一开始就“规划”完所有步骤,而是一步一步根据当前结果决策。所以每个技能都被设计成“给定当前上下文可以独立完成一步”,而不是依赖上一个技能的某种固定返回值。

5.3 最小实现核心框架

核心代码我做了简化,但结构是完整的。技能执行体用一个注册函数绑定,路由部分直接把技能描述放到 prompt 里让模型选择。

registry = SkillRegistry() registry.register(Skill( meta={"name": "transcribe_audio", "version": "1.0.0"}, description="用于将录音文件转换为带时间戳的文本。触发条件:用户提供音频文件或录音链接。", schema={"type": "object", "properties": {"file_url": {"type": "string"}}}, handler=transcribe_handler, )) registry.register(Skill( meta={"name": "generate_summary", "version": "1.0.0"}, description="根据转写文本生成结构化会议摘要。必须在转写完成后调用。", schema={"type": "object", "properties": {"transcript_id": {"type": "string"}}}, handler=summary_handler, ))

路由调用的核心逻辑也非常直接。把所有技能描述拼成一段文本作为系统上下文,用户请求进入时,模型从中选出要调用的技能名称和参数,系统从注册表取出执行体执行并返回结果。看起来简单,但整套可用性其实是靠前几节里的描述设计、校验、容错和缓存撑起来的。

5.4 关于这个架构的最终坦白

把技能体系做完之后,我对 Agent 开发的感受有了不少变化。以前总觉得“模型能力那么强,你给它工具它就会用”,实际操作过才明白:模型确实强,但在没有良好结构的前提下,它的“强”会被模糊的工具描述消耗掉。技能体系的本质不是限制模型,而是给模型铺一条更清晰的路。

我特别喜欢把技能库当成“团队共享词汇表”来用。后端同事维护执行体,算法同事维护描述,产品同事定义边界,每个新需求进来先讨论“是新增一个技能,还是组合现有技能”,这比讨论“给模型加什么提示词”要科学得多。

最后再分享一个小经验:技能库上线之后,一定要持续跟踪调用日志,记录哪些技能经常被误触发,哪些技能七天都没人调用。长期无人调用的技能优先考虑下线或合并,频繁误触发的技能说明描述边界不够清晰。我现在的做法是每周过一遍调用统计,用数据反向修正描述。技能系统不是一次性搭建,它是靠持续运维慢慢长稳的。

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

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

立即咨询