博客.
博客.
(联系方式)

保持联系

如果你想联系我,无论是讨论合作机会、分享想法,还是只是打个招呼,都可以通过以下方式找到我。

(最后更新)

版权所有 / 2026

(快捷键)C
返回学习路线
(02 · Agent 开发)2026年8月

工具调用

Function Calling 不是给模型接 API 那么简单——工具描述本身就是 prompt 的一部分。从协议细节、Schema 手册写法到 Hermes 专用模型与幂等设计,这篇把工具调用讲透。

工具调用

先破除一个幻觉:模型什么都没「调用」

很多人第一次用 Function Calling 时都有个误会:以为模型真的会去执行函数。不会的。模型从头到尾只干一件事——输出 token。Function Calling 的本质是:模型输出一段结构化的 JSON(叫 tool_call),告诉外面的程序「我想调用这个工具,参数是这些」,真正执行的是 Harness(Agent 外面那层脚手架,在「Agent 核心概念」一章讲过)。

完整的链路长这样:

工具调用主循环:Harness 发送 messages 和工具 Schema,模型只输出 tool_call 做决策,代码真正执行工具并把结果回填,循环直到模型直接回答

一个最小的 TypeScript 循环:

while (true) {
  const resp = await llm.chat({ messages, tools });
  if (!resp.tool_calls?.length) return resp.content; // 模型直接回答,结束

  for (const call of resp.tool_calls) {
    const result = await runTool(call.name, call.arguments); // 真正干活的是这里
    messages.push({ role: "tool", tool_call_id: call.id, content: result });
  }
}

理解「模型只负责决策,代码负责执行」这一点至关重要——错误处理、超时、权限、幂等,全是你的代码的责任,模型一概不管。把这句刻在脑子里,后面所有工程问题都好理解了。


协议细节:tool_call 到底长什么样

Function Calling 各家厂商细节略有差异,但消息结构大同小异。以 OpenAI 风格为例,模型决定调用工具时,它产生的 assistant 消息长这样:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    { "id": "call_01", "type": "function",
      "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } },
    { "id": "call_02", "type": "function",
      "function": { "name": "get_weather", "arguments": "{\"city\": \"上海\"}" } }
  ]
}

三个容易踩坑的细节:

1. arguments 是字符串,不是对象。 它是被 JSON 序列化过一次的参数,你的代码要 JSON.parse 之后才能用。忘了 parse 直接把字符串传给下游,是新手第一大坑。模型是逐 token 生成这段 JSON 的,流式场景下你会先收到半个 JSON——必须等它生成完再 parse。

2. 并行调用是「一条消息、多个 tool_call」。 上面例子里模型一次性要求查北京和上海两个城市的天气。规则是:你必须把每个 tool_call 的结果都回填成一条对应的 tool 消息(用 tool_call_id 一一对应),全部补齐之后才能再次调用模型。漏回填任何一个,协议层直接报错。执行顺序无所谓、可以并发,但回填一条都不能少。

3. strict 模式把「大概率正确」变成「协议保证」。 普通模式下,模型输出符合 Schema 只是「训练出来的习惯」,极端情况下它会给你多一个字段、少一个引号。OpenAI 的 Structured Outputs 和 Anthropic 的 strict tool use 都提供了 strict: true 选项:底层用约束解码(constrained decoding)——生成每个 token 时直接把不符合 Schema 的候选屏蔽掉,从机制上保证输出 100% 合法。代价是 Schema 只能用 JSON Schema 的一个子集(比如所有字段都要列进 required、必须 additionalProperties: false),以及首次请求有一次编译开销。生产环境的写操作工具,能开 strict 就开。

4. 调不调工具,本身也是可以控制的。 tool_choice 参数能强制模型本轮的行为:auto 让模型自己判断(默认)、required 强制必须调一个、指定某个工具名强制就调它、none 则完全禁用。两个典型用法:一是「必须先调工具再说话」的场景(比如客服机器人不允许凭空回答订单问题),直接 required 兜底,比在 prompt 里写「请务必调用工具」可靠得多;二是把模型当结构化抽取器用——定义一个永远不希望真执行的「伪工具」,强制模型调用它,你就拿到了一份严格符合 Schema 的 JSON,这是很多信息抽取管线的标准玩法。


Schema 是 prompt,不是配置文件

这是本章最重要的一句话:工具的 name、description、参数描述,都会被拼进模型的上下文里。模型是这些文字的唯一读者。

所以写 Schema 的心态,应该是给一位聪明但第一天上班的新同事写工具使用手册。这位同事能力极强,但对你们公司的系统一无所知——他不知道金额单位是分还是元,不知道城市参数能不能传省份,不知道这个接口一天只能调几次。

对比一下两种写法:

// ❌ 写给编译器看的
{ name: "order", description: "订单", parameters: { id: "string" } }

// ✅ 写给新同事看的
{
  name: "get_order_detail",
  description: "根据订单号查询订单详情(状态、金额、物流)。当用户问到「我的订单怎么样了」时使用。不要用于创建或修改订单。",
  parameters: {
    type: "object",
    properties: {
      order_id: {
        type: "string",
        description: "18 位数字订单号,以「JD」开头。如果用户只给了物流单号,请先调用 express_to_order 转换。"
      }
    },
    required: ["order_id"]
  }
}

四条手册编写原则:

  1. 命名用动词开头,说清楚动作和对象:get_weathercancel_order,而不是 weatherorder2。模型选工具时首先看到的就是名字,名字本身就是最强的提示。
  2. 描述要写「什么时候用、什么时候别用」。反面约束往往比正面描述更能防止误调,尤其当两个工具功能相近时——「查订单」和「查物流」并排出现时,不在描述里划清界限,模型一定混用。
  3. 参数写清边界和单位:枚举值用 enum 锁死,金额注明「单位:分,整数」,日期注明格式 YYYY-MM-DD。模型不会去猜,猜错的代价是你来付。
  4. 描述文字直接影响调用准确率,而且比你想象的敏感。 同一个工具,描述从「查询天气」改成「查询指定城市的实时天气与气温,用户提到冷、热、下雨、出门穿什么时使用」,触发率会明显变化。这不是玄学:模型做工具选择的全部依据就是这些文字。改描述 = 改 prompt,要用评估集回归验证,而不是改完凭感觉上线。

再看一个嵌套参数的正反例。模型的 JSON 生成能力随嵌套深度快速衰减,能扁平就不要嵌套:

// ❌ 三层嵌套,模型经常填错层级、漏字段
{
  name: "create_ticket",
  parameters: {
    type: "object",
    properties: {
      meta: { type: "object", properties: {
        assignee: { type: "object", properties: {
          name: { type: "string" }, dept: { type: "string" } } }
      } },
      content: { type: "string" }
    }
  }
}

// ✅ 拍平 + enum 收敛自由文本
{
  name: "create_ticket",
  parameters: {
    type: "object",
    properties: {
      assignee_name: { type: "string", description: "负责人的花名,不是真名" },
      assignee_dept: { type: "string", enum: ["研发", "测试", "产品", "运营"] },
      content: { type: "string", description: "工单正文,纯文本,不支持 markdown" }
    },
    required: ["assignee_name", "content"]
  }
}

enum 是被低估的利器:任何你能穷举的取值(城市列表、状态机状态、业务类型),写进 enum 就从「模型的自由发挥」变成了「有限集合里的选择题」,错误率断崖式下降。

写完 Schema 后的自测方法很简单:把 Schema 拿掉代码上下文,给你团队的新同事看,问他能不能正确使用这个工具。他看不懂的地方,模型一样会错。


多工具编排:数量、筛选与上下文

真实场景里工具不止一个,编排上要注意三件事。

并行与串行。 现在的主流模型支持一次输出多个 tool_call。相互独立的调用(同时查三个城市的天气)可以并行执行再一起回填;但有依赖关系的(先搜订单号、再查订单详情)必须串行——别自作聪明地并行,第二个调用拿不到第一个的结果。判断标准很朴素:后一个调用的参数里有没有前一个调用的结果。

工具数量与准确率是负相关的,这是有实证支撑的。 BFCL(Berkeley Function-Calling Leaderboard)的评测类目设计本身就说明了问题:单工具(simple)场景下主流模型准确率普遍很高,一旦进入「从多个候选工具里选」(multiple)和「一次选对多个并填对参数」(parallel multiple),准确率就明显掉档。工程经验值是:单次给模型的工具控制在 5-15 个以内比较稳,超过几十个后选择错误率会肉眼可见地上升——想象给新同事一本 500 页的手册,他也会懵。

那真实系统几百个工具怎么办?动态工具筛选:不把全部工具塞进上下文,而是先选后调。三种常见方案:

  • Embedding 检索:把所有工具的描述向量化,用户请求来了先检索 top-k 相关工具再喂给模型(ToolLLM 就是这么做的)
  • Router 模型:用一个便宜的小模型先做一轮工具路由,主模型只看到被选中的几个
  • 分层暴露:先给模型一个 search_tools 元工具,让它自己按需查工具手册(Anthropic 的 tool_search 就是这个思路)

动态工具筛选:几百个工具不直接塞进上下文,经 Embedding 检索、Router 模型或分层暴露先选后调,只把 top-k 工具的 Schema 喂给模型

一个最小的 embedding 筛选实现:

// 启动时离线算好:每个工具描述的 embedding
const toolVecs = await embed(tools.map(t => t.name + ":" + t.description));

async function pickTools(query: string, k = 5) {
  const q = await embed([query]);
  return tools
    .map((t, i) => ({ t, score: cosine(q[0], toolVecs[i]) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, k)
    .map(x => x.t); // 只把这 k 个工具的 Schema 发给模型
}

工具结果要裁剪。 工具返回的内容会原样进入上下文,一个返回 10 万 token 日志的 read_logs 能直接把上下文窗口撑爆。Harness 层要对结果做截断、摘要,只留模型决策所需的部分。裁剪策略本身也值得写进工具描述:「最多返回前 50 条」。反过来,返回太少也有代价——模型拿不到关键信息时会反复调用同一个工具试探,Loop 步数反而变多。合理的默认是:返回「够模型做下一步决策」的最小集合,并在结果里附上「共 328 条,已返回前 50 条」这样的元信息,让模型知道还有余量可以翻页。


函数调用专用微调模型:Gorilla、Hermes 与 BFCL

GPT、Claude 这些通用模型开箱就能 Function Calling,但如果你要私有化部署开源模型,会发现普通开源模型的工具调用很不稳定:不遵守 Schema、该调不调、参数乱编。于是出现了一类专门为工具调用微调的模型

Gorilla(Berkeley,2023)是这个方向的开山之作。它的思路有两个亮点:一是用 self-instruct 方式构建了 APIBench——从 HuggingFace、TorchHub、TensorFlowHub 的 API 文档自动生成的上万条「指令 → API 调用」训练数据;二是 retriever-aware training:训练时就把检索到的 API 文档片段拼进上下文,让模型学会「看着文档调 API」,这样 API 更新时只需更新检索库,不用重训模型。这个「训练时见过检索」的细节至关重要——直接把文档拼给一个没这么训过的模型,效果会差很多。

NousResearch 的 Hermes 系列(Hermes 2 Pro、Hermes 3 等)把这条路走得更工程化:在 Llama-3-8B 这类开源底座上,混入大量函数调用样本(模型学会用 <tool_call> 标签包裹结构化输出)和 JSON mode 数据,同时保留通用对话数据防止能力遗忘。Hermes 2 Pro 一个 8B 模型,在函数调用专项上的准确率一度接近 GPT-4 的水平,是很多私有化 Agent 的性价比之选。它的训练思路对自研很有参考价值:函数调用能力不是靠海量数据堆出来的,而是靠高质量、格式严格一致、覆盖各种边界情况(无参数、多参数、拒绝调用)的样本喂出来的

BFCL(Berkeley Function-Calling Leaderboard)则是这个领域的「裁判」。它来自 Gorilla 团队,评测不靠人工打分,而是把模型输出的函数调用做 AST(抽象语法树)级别的比对:函数名对不对、参数值和类型对不对,一是一二是二。类目覆盖单调用、多工具选择、并行调用,还有一个特别重要的 irrelevance(无关性检测):用户的问题根本不需要工具时,模型能不能忍住不调——乱调工具和不会调工具一样糟糕。v3 之后又加入了多轮交互场景。选型时别只看总分,要按你的场景形态看对应类目。

什么时候值得用专用模型:

  • 私有化部署,数据不能出内网
  • 高频固定工具集的调用,成本敏感,小模型够用
  • 需要本地做确定性强的 Schema 遵循

反过来,如果你的 Agent 工具集经常变、任务开放性强,通用大模型的泛化能力仍然更稳。选型前先去 BFCL 榜单看一眼最新数据,这个领域排名变化很快。


错误处理:一张决策树

工具调用一定会失败,关键是失败后怎么办。别再用一个 try/catch 包所有了,按这张决策树分四类处理:

工具执行失败的决策树:参数校验错可自纠、暂时性故障可重试、业务失败原样返回、权限安全错误绝不重试立即上抛

参数错误(模型的锅)。 校验失败时把错误原因写成人话回填,模型下一轮大概率会自我纠正:

// ❌ 模型收到这个只会再错一次
return { error: "invalid params" };

// ✅ 模型能据此修正
return { error: "order_id 必须是 18 位数字,收到的是「张三」。请向用户确认订单号。" };

暂时性故障(外部世界的锅)。 由 Harness 重试,指数退避,带上限:

async function callWithRetry(fn: () => Promise<string>, max = 3) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i + 1 >= max || !isRetryable(e)) throw e; // 403/404 不重试
      await sleep(2 ** i * 1000); // 1s → 2s → 4s
    }
  }
}

重试必须设上限,否则一个挂掉的服务能让你的 Agent 空转烧钱。超过上限后如实告诉模型「服务暂时不可用」,让它决定换工具还是告知用户。注意 isRetryable 这一行:429 和 503 值得重试,403 和 404 重试一万次也是同样的答案。 对反复失败的服务还应该加熔断:连续失败 N 次后直接短路一段时间,别再放请求过去。

业务失败(不是错误,是事实)。 「库存不足」「订单已取消」要原样返回,千万别吞。模型需要这个信息做下一步决策,吞掉错误等于对模型撒谎。

一个反直觉的经验总结:错误信息也是 prompt。写给模型看的错误描述,质量要求和 Schema 描述一样高。


幂等:为重试和重复调用而生

有了重试机制,新的问题来了:如果 create_order 第一次其实成功了,只是响应超时,Harness 重试一次——用户被创建了两个订单,扣了两次款。

而且不只是重试。模型本身也可能重复调用同一个工具(上下文里结果回填不及时、或者多轮对话里用户重复确认时)。所以所有写操作工具都必须幂等:同样的请求来两次,效果和来一次一样。

经典做法是给写工具加幂等键:

async function create_order(args: {
  idempotency_key: string; // 由 Harness 生成,同一个逻辑调用多次重试时保持不变
  sku: string;
  count: number;
}) {
  const existing = await db.orders.findByKey(args.idempotency_key);
  if (existing) return existing; // 重复调用,直接返回首次结果
  return db.orders.create(args);
}

注意这里重试和幂等是一对配合关系,不是一回事:重试是「我再发一次请求」的动作,幂等是让「再发一次」变得安全的属性。只有重试没有幂等,就是把故障放大器。幂等键的生成也有讲究:由 Harness 在同一次逻辑调用的所有重试间保持不变,而不是每次重试新生成一个——那就失去意义了。有些业务有天然的幂等键(订单号、用户+日期),优先用天然的。

读操作天然幂等,不用管。写操作的幂等设计是工具开发者(你)的责任,指望模型「别重复调用」是靠不住的。


争议现场:细粒度 vs 粗粒度工具

工具该切多碎,社区里是有真争议的,两派都有道理:

细粒度派(Unix 哲学):给模型 read_filesearch_coderun_test 这样的小工具,模型自由组合。好处是灵活、可控、可观测——每一步都在台面上,Agent 遇到没预见的状况能自己绕路。Claude Code 的 Bash / Read / Edit / Grep 就是这一派的代表作。代价是 Loop 步数多、token 消耗大,模型在长链条里更容易迷路。

粗粒度派(工具内聚派):一个 run_sql_and_chart 内部完成查库、清洗、画图三步确定性逻辑,模型只调一次。好处是链路边界内全部是可测试的普通代码,正确性不靠模型的临场发挥,步数少、便宜、快。代价是灵活性差——工具内部的分支是写死的,遇到边界情况模型没有绕路的空间。

我的立场和上一章的粒度原则一致:粒度对齐「人类工程师的一次原子操作」,再按任务可调性微调。任务路径越开放(调试陌生 bug、探索陌生代码库),越要细粒度给模型留决策空间;任务路径越确定(固定的报表生成),越要粗粒度把确定性收进代码。一个系统里两派完全可以共存:粗粒度工具兜住高频确定性流程,细粒度工具留给长尾。


常见误区

  • 把 Schema 当配置文件写完就忘:它的读者是模型,写完后要用真实 bad case 反复打磨描述文字
  • 改描述不做回归:描述就是 prompt,改动可能修好一个 bad case、搞坏三个好 case
  • 错误信息返回 {"error": "fail"}:模型拿不到有效反馈,只会盲目重试同样的错误
  • 403/404 也重试:先分清错误类型再决定动作,永久性错误重试纯属浪费
  • 参数嵌套三层以上:能扁平就扁平,能 enum 就 enum
  • 写操作不做幂等:上线第一天可能没事,重试机制触发那天就是资损事故
  • 一次塞几十个工具:选择准确率暴跌,先做动态筛选

术语表

名词定义一句话直觉常见混淆
Function Calling模型按预定义 Schema 输出结构化调用请求的能力模型开口说「帮我调这个函数」与 MCP 混淆:MCP 是工具接入的标准协议,Function Calling 是模型的输出格式,一个是「插座标准」,一个是「说话方式」
tool_call模型输出的一次工具调用请求(名称 + 参数 JSON)一张填好的「申请单」与「函数执行」混淆:tool_call 只是请求,执行发生在 Harness
JSON Schema描述 JSON 结构的规范,工具参数用它声明参数的合同条款与 OpenAPI 混淆:OpenAPI 描述整个 HTTP 接口,JSON Schema 只描述数据形状
Tool UseAnthropic 对 Function Calling 的叫法同一枚硬币的另一面以为两家协议通用:消息结构(tool_calls vs tool_use block)并不一样
并行工具调用模型一条消息里发出多个无依赖的 tool_call一次点完一桌菜误以为可以并行有依赖的调用:后者的参数依赖前者结果时必须串行
strict 模式用约束解码保证输出 100% 符合 Schema 的模式给模型戴上模具写字与「运行时校验」混淆:校验是事后发现错,strict 是事前不可能错
Harness模型外面执行 Loop、工具、重试的那层程序模型是大脑,Harness 是身体和手脚与 Agent 混淆:Agent 是整体系统,Harness 特指其运行时脚手架
GorillaBerkeley 的工具调用专用微调模型(2023),配 APIBench 数据集第一个「看着文档学调 API」的模型与 Hermes 混淆:Gorilla 是学术开山作,Hermes 是工程化延续
HermesNousResearch 的开源模型系列,函数调用是其微调重点开源界的函数调用课代表以为专用模型全面更强:它强在 Schema 遵循,开放推理仍输大模型
BFCLBerkeley 函数调用榜单,AST 级别自动评测工具调用能力的「 standardized 考试」以为总分高就适合自己:要按 simple / multiple / 多轮等类目分开看
重试失败后按退避策略再次发起请求「再试一次」的动作与幂等混淆:重试是动作,幂等是让重试安全的属性,两者必须配套
幂等同一请求执行多次与执行一次效果相同按十次电梯按钮也只来一次误以为「不报错」就是幂等:重复创建成功但不报错恰恰最危险
幂等键标识同一次逻辑操作、跨重试保持不变的唯一键请求的「身份证号」每次重试重新生成:等于没有幂等键
熔断连续失败后短路请求、暂停调用一段时间的保护机制保险丝烧了先断电与重试混淆:重试是「再试」,熔断是「别试了」,方向相反

参考材料


小结

记住四件事:模型只输出 tool_call,执行是你的代码;Schema 是写给模型这位「新同事」的使用手册,描述文字直接决定调用准确率;重试和幂等是工具可靠性的两条腿,缺一不可;工具数量要用动态筛选克制在模型能看清的范围内。 下一章我们会聊这些工具调用是如何在更大的 Agent 工作流里被编排起来的。