前阵子我把公司内部一个散落着大量“伪工具”的智能体项目彻底重构了一遍,把所有能力收拢成一套真正可被调度的工具系统。这个过程中踩了不少坑,很多问题平时根本不会注意到,但在工具真正被模型高频调用时会集中爆发。所以我想开一个系列,围绕 Hermes agent 的 tools 体系,把落地应用这件事拆开讲清楚——从工具怎么定义、怎么注册,到模型怎么选工具、怎么执行、怎么处理错误、怎么做观测,每个环节都有值得深挖的细节。这篇算系列的开篇,先把整体方法论和几个最容易出问题的环节讲透。
如果你正在做智能体开发,或者你已经被各种 Agent 框架的“工具调用”功能折磨过,这篇文章应该能帮你省下不少试错时间。我不会讲太多概念层面的东西,重点放在“真实项目中这些模块到底该怎么设计和实现”,以及那些文档里不会写的坑。
1. 为什么智能体离不开工具系统,而不是单纯靠大模型硬答
1.1 语言模型的能力边界恰好是工具的起点
先把最基础的事情说透。大语言模型本质上是一个基于概率的文本生成系统,你给它一段输入,它输出一段最符合规律的文字。这意味着它在“知识回忆”“文本重写”“逻辑推理”这类任务上很强,但有一个天然的致命短板:它对自己回答的内容没有事实校验能力,也碰不到任何外部系统。
你问它“帮我查一下订单号 12345 的物流状态”,它编一个答案出来的概率很高。不是它故意骗你,而是它的训练数据里根本没有你的数据库。它唯一能做的是根据见过的类似文本,猜一个看起来合理的回复。这就是为什么把智能体直接接给业务系统用会出事——你既不敢让它瞎操作,又没法信任它带回来的数据。
工具系统解决的就是这个问题。工具是外部能力的“接口”,模型并不直接执行动作,它只负责做一件事:根据对话上下文,决定“现在应该调用哪个工具,并生成该工具的输入参数”。真正的查库、写文件、发请求,都是在工具执行阶段完成的,执行结果再作为新的上下文喂回给模型。于是模型从一个“什么都敢说”的生成器,变成了一个“只做决策”的调度器。
我自己在项目重构过程中的感受特别明显。重构前的系统,各种系统能力是通过很长的系统提示词“塞进”模型脑子里的,表面上看起来模型什么都会,但一旦涉及真实数据就频繁幻觉,而且上下文被塞得越来越长,推理越来越慢。重构后,模型只需要面对一个精简的工具清单,决策链路反而清晰了很多。
1.2 一个反面案例:把所有能力写进 Prompt 的苦日子
我原来负责的某内部客服工单系统,一开始做智能体的时候,就是因为嫌工具调用太麻烦,直接把系统操作全部描述写进了系统提示词。看起来挺聪明的做法——团队一共维护了十几个“能力说明”,每个说明都写得特别详细,包括“查询工单时,请使用 GET 请求访问 /api/ticket/{id},返回的 JSON 里包含 status 字段,请根据 status 字段判断工单状态”。
这套方案在 demo 阶段跑得还行,但真正放上线就崩了。主要有几个问题。
一是上下文膨胀。十几个能力说明加起来几千字,再加上用户问题的历史消息、临时夹带的业务数据,模型每轮要处理的 token 量巨大,响应延迟从 2 秒涨到了 8 秒。
二是参数解析不稳定。模型经常把请求路径拼接错,或者把查询参数写到 body 里,这种问题在真实调用中相当普遍。更糟糕的是,模型失败后还会“自作聪明”地根据错误信息瞎猜,比如试着把 URL 里的 id 从字符串改成数字再试一次,结果把不相关的订单查出来。
三是完全没法做权限控制。因为只要模型掌握了接口地址和参数规范,它在一次长对话里可以随意调用任何接口。你也许想过在提示词里写“禁止调用删除类接口”,但模型对这种指令的遵守程度,在实际压力场景下并不稳定。
后来我把这套系统彻底改成工具调用体系,所有能力收敛为标准化工具,模型不再直接接触接口实现细节,而是从工具名和描述中做选择。效果很直接:工单查询类任务的准确率从原先的 78% 左右提升到了 96% 左右,上下文 token 占用也降到了原来的三分之一。
1.3 Hermes agent 工具系统的整体形态
很多初学者对“工具系统”的理解比较模糊,以为就是在代码里写几个函数,然后传给模型就行。实际落地时,一个完整的工具系统至少包含下面几层。
- 工具注册中心:负责收集、校验、存储所有可用工具,并提供查询接口,供 Agent 执行引擎在每轮决策前拉取工具清单。
- 工具执行器:收到模型的工具调用指令后,解析参数、调用对应工具函数、处理返回值。它屏蔽了模型与底层实现之间的差异,也负责执行超时、重试这些策略。
- 结果处理器:工具执行产生的原始返回数据,不能直接一股脑喂回给模型。要先做截断、脱敏、结构化整理,让模型拿到的是高质量输入。
- 观测与追踪模块:记录每一轮的工具选择、参数生成、执行结果和错误信息,方便事后调试和回归。
另外还有一个很容易被忽略的模块——权限控制层。不同场景下,模型应该拥有不同级别的工具调用权限。比如只读查询的工具可以放心调用,但写操作工具可能需要二次确认。这块我后面会展开讲。
Hermes agent 的工具体系从一开始就按这个框架设计,这给后续的功能迭代省了非常多事。很多东西只有在真实流量下才会暴露问题,框架搭对了,后面填坑就只是时间问题。
2. 工具定义与注册:这一步偷懒,后面全是灾难
2.1 Tool Schema 设计的关键字段
工具注册中心的第一件事,是把“工具长什么样”用一套标准协议描述清楚。模型能不能正确使用工具,很大程度上取决于工具定义的描述质量。这不是玄学,是信息工程。
一个正规的工具 Schema 至少应该包含这些字段:
- 工具名称:必须是见名知意的短标识,例如 query_order_status,不要用那种内部内部代号。
- 工具描述:说明工具能做什么、不能做什么、适合什么场景。描述里可以放一些模型容易踩坑的注意事项。
- 参数定义:每个参数的名称、类型、是否必填、取值范围、说明。
- 返回值定义:返回的数据结构,以及可能出现的错误码说明。
- 执行属性:超时时间、是否允许并发、是否需要用户确认、访问权限级别。
我在项目里见过一个很典型的反例:工具描述写得太简略,比如“查询订单”,模型根本不知道该传什么参数。然后模型就会选择发挥它的想象力,把用户说的任何数字都当成订单 ID 传进来,结果自然是大量无效查询。
于是我给团队定了一条规矩:每个工具的描述必须包含“能力边界”。比如“查询订单状态:根据订单 ID 查询订单当前状态,仅支持查询近 90 天内的订单,不支持订单创建、修改操作,订单 ID 为系统生成的 13 位数字”。这样模型就不会去用这个工具做它不该做的事。
参数定义方面,除了常规的 JSON Schema 类型描述外,我还强烈建议给每个参数写一个“说明”字段,里面尽量写清楚这个参数从哪里获取。比如“用户说‘我的订单’时,请先调用 get_current_user 获取用户 ID,再传入本工具”。这能让模型在参数生成时显著少出错。
2.2 静态注册与动态注册怎么选
工具注册有两种常见模式:静态注册和动态注册。
静态注册就是系统启动时把所有工具一次性注册好,之后不再变化,模型每轮看到的工具清单都是固定的。它的好处是简单、稳定、容易调试,工具清单在启动时就能做完整校验。坏处是不灵活,如果工具数量非常多——几百上千个——每轮决策把所有工具描述都塞给模型,token 开销会变得非常大,模型的选择准确率也会下降。
动态注册是根据当前对话上下文,动态决定哪些工具进入模型的可见清单。比如用户聊的是“退款”主题,就从工具库里检索出与退款相关的工具,其他不相关工具就不出现在模型面前。这种方式的优点是省钱、准,缺点是复杂度高,需要维护索引和召回策略,而且召回失败会导致模型“看不到”该用的工具,最后给出一个很蠢的回答。
从我的实践经验来看,起步阶段建议先用静态注册,把所有工具都暴露给模型,把正确率做上来。当工具数超过 50 个之后,再考虑做动态注册或者说工具路由。工具路由本身也可以做成一个工具——让模型先选“工具包”,再在工具包内选具体工具,这种层次化设计在很多场景下都更好用。
提示:工具注册中心一定要做 Schema 校验。上线前写一个自动化测试,遍历所有工具定义,检查描述里是否有过长的字段、参数是否有重复、默认值是否合法。这些问题在运行时爆发,排查成本远高于启动时拦截。
2.3 从函数到可调用工具的完整流程
用一个实际例子说明工具注册的代码实现。假设我们有一个查询库存的工具,在 Hermes agent 体系里大致长这样。
@tool.register( name="query_inventory", description="查询指定商品SKU的当前库存数量。仅用于查询,不支持库存修改。适合用户询问'还有货吗''库存多少'等场景。", parameters={ "sku_id": { "type": "string", "required": True, "description": "商品的SKU编码,格式为字母+数字的组合,例如 SKU123456", }, "warehouse": { "type": "string", "required": False, "enum": ["华东仓", "华南仓", "华北仓"], "description": "仓库名称,不传则默认查询所有仓库", } }, timeout_seconds=10, require_confirm=False, access_level="read", ) def query_inventory(sku_id: str, warehouse: str = "all"): # 真实逻辑:查询库存服务 result = inventory_service.query(sku_id, warehouse) return { "sku_id": sku_id, "warehouse": warehouse, "total": result.total, "available": result.available, "reserved": result.reserved, }注意几个细节。tool 描述里写了“不支持库存修改”,这相当关键,因为模型在对话里如果发现用户有修改需求,就知道不能用这个工具,会去寻找其他工具,而不是拿只读工具硬凑。参数描述里给了 SKU 格式样例,模型生成参数时就有了参考锚点,实测参数错误率能下降一个量级。
注册完成后,每次 Agent 循环开始前,执行引擎会从注册中心拉取工具清单,经过必要的过滤(比如权限过滤、上下文路由过滤)后,把工具描述转成模型要求的协议格式,发送给模型。模型返回结构化的工具调用指令,执行器再根据指令里的工具名找到对应函数,调用并收集结果。
这一步整体来看不算难,难的是把工具定义写得足够好。我见过太多团队在工具描述上敷衍了事,然后花好几周时间调试模型为什么“不听话”,实际上根因就是工具描述太差。
3. Agent 循环里的工具调度:模型到底怎么决定调用什么
3.1 工具选择背后的原理,以及 Tool Choice 策略
当模型收到用户消息和工具清单时,它内部实际上是在做一个“下一段文本生成”的任务,只不过这段文本的格式被约束成了工具调用的结构。模型会综合理解对话历史、用户当前诉求、工具的名称与描述,然后输出一个 JSON,包含工具名和参数。
这里有一个很重要的概念叫 Tool Choice 策略,它控制模型是在“必须调用工具”“可以调用也可以不调用”“禁止调用”之间如何选择。不同的策略适合不同的场景。
- auto 模式:模型自己判断要不要调用工具,不调用就直接回答用户。
- required 模式:模型必须调用某一个工具。
- none 模式:禁止调用工具,直接回答。
我在实际使用中,最常用的是 auto 模式,但有一个坑:在 auto 模式下,模型偶尔会自作主张直接回答用户问题,而实际上这个问题必须调用工具才能回答。比如用户问“我的订单到哪了”,模型如果没抓住“订单”这个关键词,可能会凭常识说“请提供订单号”。这种“假聪明”情况在工具设计不合理时特别常见。
我的建议是,对特定场景可以强制 required。比如在客服工单场景里,只要用户表达出查单意图,就要求模型必须调用查单工具,即使工具执行后返回错误,也要把错误信息交给模型二次判断,而不是让模型直接猜。实操中可以用意图分类器先判断用户意图,再根据意图预置 Tool Choice 策略,会稳定很多。
3.2 标准 Agent Loop 的实现方式
工具调度的核心是 Agent 循环:模型决策→执行工具→观察结果→再次决策→直到得出最终答案。
def agent_loop(user_message: str, max_steps: int = 5): messages = [{"role": "user", "content": user_message}] for step in range(max_steps): response = llm.chat( messages=messages, tools=tool_registry.get_visible_tools(messages), tool_choice="auto", ) assistant_msg = response.message messages.append(assistant_msg) if not assistant_msg.tool_calls: # 模型不再调用工具,输出最终回复 return assistant_msg.content for tool_call in assistant_msg.tool_calls: # 执行工具,并把结果作为 tool 消息加入上下文 result = tool_executor.execute(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), }) return "已达最大步骤限制,请稍后再试。"这个循环本身不复杂,复杂的是步骤数限制、上下文长度管理和信息质量。我设置的 max_steps 往往是 5 到 8,太大会导致模型陷入“反复调用同一个失败工具”的死循环,太小则复杂任务根本完不成。真实项目里,还有一种情况是模型连续调用多个工具后就忘了最开始的任务目标,所以有经验的团队会在系统提示词里明确要求模型“每一步都要回顾当前任务主线,不要偏离”。
关于并行工具调用,现在很多模型支持在一次响应中返回多个 tool_call。这是个好东西,比如用户同时问“天气”和“机票价格”,模型可以并行调用两个工具。但要注意:同一批并行工具调用里,最好不要有依赖关系。比如先要拿到用户 ID 才能查订单,这两个工具就不能并行,必须等第一个结果出来再发起第二个调用。工程上可以在工具描述里写明依赖关系,或者在代码层面跟踪依赖图,实测下来前者对模型更直观。
3.3 超时、并发与重试:这些参数不是拍脑袋定的
工具调用的工程质量,往往体现在这些“小参数”上,它们直接决定了用户体验。
超时设置方面,我先说结论:普通查询类工具,超时 5 秒内;涉及外部第三方接口的,10 秒内,因为第三方接口经常有偶发慢请求。超时设置太短,工具频繁中断,模型拿不到结果就会开始胡编;太长,等待时间拖垮体验。我一般用“P95 延迟 × 2”作为基准,然后人工再取一个整数。
重试方面,并非所有工具都该重试。查询类工具、幂等操作可以重试,创建订单、转账这类有副作用的操作绝不能盲目重试,否则可能产生重复订单或重复扣款。重试次数我一般控制在 2 次以内,且采用指数退避,第一次等 500ms,第二次等 1.5s。超过重试次数就直接报错,不要把错误无限吞掉。
并发方面,模型如果发起了多个并行工具调用,执行器要控制并发上限。我见过一次事故:某个工具的接口原本只有 50 QPS 的容量,结果模型一次并行调了 20 个工具,直接把上游服务打挂了。之后我强制要求每个执行队列设置并发信号量,比如Semaphore(5),配合连接池复用,情况就稳多了。
经验:工具执行器里一定要设计“熔断”机制。当某个工具连续失败超过阈值(比如 5 次),自动开启熔断,不再接收新的调用,并直接返回“工具暂不可用”的错误信息。不然模型会在循环里反复调一个已经挂掉的工具,浪费 token 不说,还把错误上下文越堆越长。
4. 工具执行的可靠性与错误处理:脏数据绝不能回灌给模型
4.1 返回值不是数据库里有啥就回传啥
工具执行器最容易被忽视的一个环节,就是怎么处理工具返回的原始数据。很多初学者的做法是把查询结果直接转成 JSON 塞进消息里,然后让模型去读,结果模型满脑子都是这堆格式化数据,反而忘了用户原本问的是什么。
这里有三道处理流程可以做。
第一,字段裁剪。业务系统返回的数据通常有许多内部字段,这些字段对模型回答问题没有帮助,只会增加 token 消耗和干扰。执行器应该根据工具返回值定义,只保留对外可见的必要字段。
第二,超长截断。如果某个字段非常长,比如订单备注有几大段文字,全部回传给模型既费 token 又容易影响模型注意力。应该在返回值定义阶段就设定上限,例如“备注最多返回前 200 字,多余部分以省略号代替”。这个细节对长文档类工具尤其重要。
第三,脱敏处理。身份证号、手机号这类敏感信息,在回传给模型前必须打码。不是说你信不过模型,而是这些数据会被完整记录在你的会话日志里,一旦日志泄露就是事故。所以最佳实践是在执行器出口统一做脱敏拦截,而不是在每个工具里各自处理。
4.2 错误信息要“结构化、面向模型”,而不是甩一个堆栈
工具执行报错时,错误信息会作为 tool 消息返回给模型,模型会基于这段文字尝试下一步决策。如果错误信息写得很差,比如直接抛一个 Python 异常堆栈,模型大概率会懵掉,接下来就是各种乱试。
给模型看的错误信息应该做到:说明发生了什么、为什么发生、以及模型可以采取什么行动。
举个例子,一个库存查询工具抛错时,好的错误信息格式是:
{ "error": { "code": "SKU_NOT_FOUND", "message": "没有找到 SKU123456 对应的商品,请检查SKU编码是否正确,或者询问用户是否提供了正确的商品编号。", "suggestion": "你可以尝试让用户核对商品编号,也可以引导用户通过商品名称搜索相关商品。" } }模型看到这段内容后,大概率会顺着 suggestion 的路径走,而不是自己瞎想一个接口去调。所以工具出错处理不仅是一个工程问题,还是一个“给模型铺路”的问题。原则上:错误信息要告诉模型下一步做什么,而不是单纯告诉它做错了。
4.3 权限、沙箱与二次确认:让 Agent 不能乱来
工具系统接入真实业务后,权限控制就是一条不可触碰的红线。
我给工具分了四个权限级别:
- 只读级别:查询订单、查询库存、查询用户信息。这类工具模型可以自主调用。
- 操作级别:修改备注、更新状态、发送通知。这类工具需要用户在对话中明确表达出操作意图,并且系统会进行简单的参数校验。
- 高影响级别:创建订单、退款、删除数据。除了用户意图确认外,执行前必须弹出确认卡片让用户点击确认才能执行。
- 危险级别:批量删除、导出全量数据。这类工具默认不允许运行,需要管理员手动审批。
这里有朋友可能会问:模型真的会“越权调用”吗?我的回答是:模型当然不会故意越权,但它会“误解”。比如用户说“帮我把订单 123 取消掉”,模型可能直接调用一个具备写权限的取消订单工具。如果你设计的工具权限里没有拦截机制,这单就真的取消了。所以权限校验必须在执行器层面强制检查,一定不能指望模型自己守规矩。
执行器收到模型的工具调用指令后,会先对比该工具所需权限和当前会话的授权等级,不满足直接返回“权限不足,需要用户确认”。这个过程要在代码里做硬校验,不能是提示词层面的软约束。此外,涉及运行外部命令、读取任意文件这类高风险工具,尽量放到沙箱容器里执行,确保即使工具本身有 bug 也不会连累主服务。
5. 观测、调试与回归:工具落地不可跳过的三件套
5.1 全链路日志与调用追踪
工具系统一旦上线,调试是每个开发者的噩梦。模型调的哪个工具?传了什么参数?工具返回了什么?模型看到结果后怎么反应的?如果中间缺掉任何一环,出了问题根本定位不了。
所以从第一天起就要做好全链路日志,每个 Agent 循环都记录一条结构化日志,大致包括:会话 ID、步骤号、本次工具清单、模型原始响应、提取出的 tool_call 列表、执行器命中策略、工具执行返回、最终回复。
这些日志千万别只写在本地文件里,要接入集中式日志系统,方便按会话 ID 过滤。我遇到过一个问题:某用户反馈智能体答错了,但本地日志早被轮转覆盖了,查无可查。后来全部接入统一日志平台,配合 Trace ID 串联整个请求链路,复盘效率提升非常多。
5.2 离线回放:让模型犯错可以被复现
工具系统有一个很奇特的调试困境:模型是概率性的,同一段输入可能产生不同输出,所以“复现 bug”不像传统软件那样容易。这也是为什么我会特别强调离线回放。
做法不难:把线上真实请求的输入和工具调用记录全部保存下来,形成一个样本库。模型系统更新后,先在样本库上批量跑一遍,看看结果有没有变好或变坏。这个过程有点类似传统机器学习里的测试集评估。
回放时要注意:工具调用需要做 mock,不能让测试又真的去查询真实订单、真实库存。要把线上记录的工具执行结果缓存起来,回放时直接使用缓存结果,保证只测模型决策链路,不碰真实外部依赖。这个技巧在给模型切换版本、调整 prompt、修改工具描述时特别有用,能帮你快速判断改动到底有没有效果。
5.3 工具压测与并发稳定性
最后说压测,工具系统的瓶颈往往不是模型本身,而是下游外部服务。模型推理可以靠队列削峰,但如果工具系统疯狂调用外部 API,外部服务一挂,所有智能体功能都会跟着瘫痪。
我吃过一个具体的苦头:某次促销活动期间,智能体工具调用量激增,某个下游搜索服务响应越来越慢,最终拖垮了整个智能体链路。后来加了连接池限制和熔断器,给该工具单独设了下游调用上限,并且把“下游不可用”的错误信息做了兜底回复,让模型告诉用户“当前查询人数较多,请稍后再试”,而不是保持无响应。
压测方面建议做两类:一是单工具压测,摸清每个工具自己的容量上限;二是全链路压测,模拟真实对话过程里模型反复调用工具的场景。全链路压测最容易发现的问题包括:工具描述太长导致推理变慢、并行工具调用过多导致下游被打爆、上下文越来越长导致超时暴增。
经验:每次上线新工具,先灰度放量,控制工具在总流量里的曝光比例。比如先让 10% 的会话能看到新工具,确认没有异常再逐步放开。很多人直接全量接入,结果模型对某个工具描述的理解和预期不符,满屏都是乱调用。
6. 这个系列接下来要拆解的具体方向
开篇写了这么多,其实主要是把 Hermes agent 工具系统的整体框架和最容易踩坑的环节梳理了一遍。这个系列我在规划时,打算从这几个方向继续深入。
单个工具从痛点场景到稳定落地的全过程。比如拿“查订单状态”这个工具举例,从需求分析、Schema 编写、参数设计、错误处理到上线观测,完整走一遍。
多工具协作的编排问题。当一次用户请求需要连续调用三四个工具才能完成时,怎么设计工具之间的依赖关系,怎么让模型在不同工具之间平滑切换,怎么避免模型在长链路中迷失主线。
工具自动生成的实践。很多工具是从已有 API 和函数定义自动生成的,怎么让模型或代码生成器根据接口文档自动产生工具描述,以及自动生成后的描述质量问题怎么检测。
工具系统的性能优化。包括工具并行调度的并发模型、上下文压缩策略、大数量工具的召回与路由、以及成本控制方面的实操经验。
这些问题我都在具体项目中逐个踩过,后续系列会结合真实案例逐一展开。如果你正在做 Agent 或接口工具化改造,可以按这个系列的结构对照你的系统,很多设计可以拿过去直接改改就用。
最后说点个人体会:工具系统做得好不好,核心还是有耐心去抠细节。描述里多一句话,可能模型调用准确率就高一大截;错误信息多写一个建议,可能模型就少一次无效乱试。这些东西看似琐碎,实际才是工具落地成败的关键。后续我会在这个系列里持续分享,有具体问题也欢迎随时交流。