Tool Calling 手动全流程
Tool Calling 手动全流程
第 02 章我们让模型「说话」:发消息、拿文本。第 05 章我们让模型「说人话」:约束输出格式、做 JSON 解析与校验。但无论是哪一章,模型都还只是一个聊天框——你问它什么,它回你一段文字。这一章要跨出关键的一步:让模型行动。它不再只回答「北京今天天气怎么样」,改为向你提出一个请求:请帮我调用 get_weather 这个工具,参数是 { city: '北京' }。你的代码执行完工具,把结果送回给模型,它再基于真实数据给出最终答复。这套「模型请求 → 你执行 → 结果回填 → 再请求」的循环,就是 Tool Calling(工具调用),所有 Agent 行动的起点。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出工具调用最本质的形态:模型不会真的执行任何东西,它只是在文本里声明「我想调用哪个工具、参数是什么」,执行永远发生在你的进程里;
- 手写一个工具的 schema 声明(
name/description/parameters),并解释为什么description写得越清楚,模型选得越准; - 解剖一次带
tools的请求与tool_calls响应:id/name/arguments(JSON 字符串)/finish_reason: 'tool_calls'; - 实现「解析 → 执行 → 回填 → 二次请求」的完整一轮:把
role: 'tool'消息按tool_call_id配对回填,让第二轮请求带上工具结果; - 处理同一轮的多个并行 tool_calls,并为每种畸形输出(arguments 解析失败、工具不存在、结果过长)设计不炸掉对话的兜底;
- 说出 Anthropic 与 OpenAI 在工具调用上的格式差异(
tool_useblock /tool_result/input_schema),并理解「协议形状不同,闭环逻辑相同」。
开场反例:没有工具,模型只是个聊天框
先看一个朴素的问题:
const messages = [{ role: 'user', content: '北京今天天气怎么样?' }];
// 把这段消息发给模型,你会得到什么?
你会得到一段语法通顺、内容全凭运气的回答。模型的全部知识来自训练数据,它既没有连过天气 API,也没有实时数据源——「北京今天 24°C 晴」和「-8°C 大雪」对它来说没有本质区别,都是它顺着语言习惯编出来的。这不是模型笨,而是它没有获取信息的通道。
再放大一点看:模型能做什么、不能做什么?
| 模型能 | 模型不能 |
|---|---|
| 根据上下文生成文本 | 发起 HTTP 请求(除非你替它发) |
| 记住对话里出现过的内容 | 读你文件系统里的文件 |
| 复述训练数据里的知识 | 执行 Python 代码、算今天的日期 |
| 理解「这个工具是查天气的」 | 自己运行这个工具 |
模型的能力边界是生成文本;而「行动」(查实时数据、读写文件、调用外部 API、执行计算)全都发生在模型之外的世界。你也许想过把数据塞进 prompt 里喂给它:
// 方案 A:把天气数据写进 system prompt
const system = '今日天气:北京 24°C 晴。用户可能问天气,照着数据回答。';
问题很明显:数据是静态的。用户问上海怎么办?明天问怎么办?每问一次就重新拼一次 prompt?而且这只是在「搬运数据」,根本没有「行动」,模型没有选择权,永远被动。要是数据源成百上千(天气、股票、订单、文件……),这套「预先塞好」的方案立刻破产。
Tool Calling 解决的就是这件事:把「行动」的选择权交给模型(它根据用户意图挑工具、填参数),把「执行权」牢牢留在你的进程里(真正跑代码的是你)。模型负责思考,你负责动手。
工具是什么
一个工具由两部分组成,缺一不可:
- schema 声明,写给模型看的「说明书」:这个工具叫什么、什么时候用、参数长什么样。模型只能看到这份说明书,看不到实现代码;
- 实现,写给我们自己用的真实函数。模型永远不会直接运行它。
// ① 实现:只有我们的代码能调用(模型看不到这段)
function getWeather({ city }) {
return `${city}: 24°C, 晴`; // 真实世界这里会去调天气 API
}
// ② schema 声明:随请求发给模型(模型只看到这个)
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: '查询指定城市的实时天气。',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名,例如 北京' },
},
required: ['city'],
},
},
},
];
这段结构正是第 05 章讲过的 JSON Schema:name 是工具名(模型用它来指代),parameters 描述参数的类型与约束。description 是模型做决策的唯一依据,写「查询指定城市的实时天气」和写「随便查点东西」,模型选对工具的概率天差地别。JSON Schema 的完整语法与校验器实现,第 05 章已经讲过,本章不再重复,只复用它的结论:schema 是给模型看的契约,模型按它填参数。
请求带 tools,模型返回 tool_calls
把 tools 数组挂到请求体顶层,其余与第 02 章完全一样:
const res = await fetch('https://api.example.com/v1/chat/completions', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: 'gpt-4o', messages, tools }),
});
区别在响应。模型判断「要回答这个问题,我需要外部数据」,于是不再返回文本,改为返回一个 tool_calls 数组:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_a1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"北京\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
逐字段拆开:
id:这次调用的唯一编号。后面的回填靠它配对,必须原样保存;function.name:模型选中的工具名;function.arguments:模型填的参数,注意它是一个 JSON 字符串("{\"city\":\"北京\"}"),不是对象。为什么是字符串?因为外层已经是 JSON,JSON 里没法再嵌套一段「活的 JSON 对象」,只能序列化成字符串再塞进去;finish_reason: 'tool_calls':模型明确告诉你:「我停下来说话,是因为我想调工具」。反过来说,finish_reason: 'stop'才意味着这是最终文本回答。
一个容易踩的直觉陷阱:content 是 null。模型这一轮没有回答任何问题,它说「给我工具,我才能回答」。你把 content: null 和 tool_calls 都放进消息数组,模型下一轮才能「接着上一句」继续。
解析与参数校验
拿到了 tool_calls,第一件事是把 arguments 从字符串解析成对象。第 05 章练过的 JSON.parse 在这里第一次实战:
function parseToolCalls(assistantMessage) {
return (assistantMessage.tool_calls ?? []).map((tc) => ({
id: tc.id,
name: tc.function.name,
arguments: safeParse(tc.function.arguments), // 字符串 -> 对象
}));
}
function safeParse(raw) {
try {
return JSON.parse(raw);
} catch {
return {}; // 畸形兜底,见下文
}
}
解析之后,严格的做法是按 parameters schema 校验参数(缺字段、类型错),这层校验器的实现第 05 章已经手写过,本章把「校验失败」与「解析失败」一样当作畸形输出兜底处理,先保证流程不断,再谈精度。
flowchart TD
A[请求 messages + tools] --> B{模型返回了 tool_calls?}
B -- 是 --> C[解析每个 tool_calls 的 arguments]
C --> D[执行工具(可并行)]
D --> E[回填 assistant + role:tool 消息]
E --> A
B -- 否 --> F[模型输出的就是最终文本]
F --> G[返回给用户]
执行工具
解析出 [{ id, name, arguments }] 后,执行是最朴素的「查表」:用一个注册表把工具名映射到实现函数,然后调用它。
const registry = {
get_weather: ({ city }) => `${city}: 24°C, 晴`,
};
async function execute(call) {
const fn = registry[call.name];
if (!fn) return `Error: unknown tool: ${call.name}`; // 兜底:工具不存在
try {
const result = await fn(call.arguments);
return typeof result === 'string' ? result : JSON.stringify(result);
} catch (err) {
return `Error: ${err.message}`; // 兜底:执行失败
}
}
两个值得记住的细节:
- 工具结果必须是字符串。 模型只能读文本。函数返回对象、数组、数字,都要序列化成字符串再回填;返回
undefined(比如「邮件已发送」这类动作型工具)就回填一句"success"之类的说明; - 模型永远碰不到你的进程。 「模型决定调用 get_weather」和「get_weather 真的执行了」之间隔着你的
execute。安全边界在这里。模型可以提出任何请求(包括危险的),最终放行与否由你的代码决定(这一步的权限设计在第 18 章展开)。
回填与二次请求:核心闭环
执行完之后,最关键的步骤:把结果送回给模型。回填的规则是:把三样东西追加进消息数组,然后原样发第二次请求:
- 模型的 assistant 消息(包含
tool_calls,它记录了自己刚才请求过哪些工具); - 每个调用一条
{ role: 'tool', tool_call_id, content }消息,tool_call_id与调用的id一一配对; - 你原来的消息(user 等)当然也在里面。
async function runToolRound(messages, { callLlm, tools }) {
const response = await callLlm(messages); // 第 1 次请求
const assistant = response.choices[0].message;
const calls = parseToolCalls(assistant);
if (calls.length === 0) return { messages, results: [] };
const results = await Promise.all(calls.map((c) => execute(c, tools)));
const toolMessages = calls.map((c, i) => ({
role: 'tool',
tool_call_id: c.id, // 与调用 id 配对
content: results[i], // 工具执行结果
}));
return {
messages: [...messages, assistant, ...toolMessages], // 第 2 次请求的输入
results,
};
}
整个闭环用一张时序图看清:
sequenceDiagram
participant U as 用户
participant A as Agent(我们的代码)
participant M as LLM
participant T as 工具实现
U->>A: 问题:北京天气怎么样?
A->>M: 请求 1:messages + tools 声明
M-->>A: 响应 1:assistant.tool_calls
A->>A: 解析 arguments(JSON 字符串)
A->>T: 执行 get_weather({city:'北京'})
T-->>A: 北京: 24°C, 晴
A->>M: 请求 2:原消息 + assistant + role:tool 结果
M-->>A: 响应 2:最终文本
A-->>U: 答案
为什么第二轮必须带上 assistant 消息和 tool 消息?因为模型是无状态的,它不记得上一轮发生了什么,只看得见你这次发给它的消息。第一轮它请求了 get_weather,如果你第二轮只发 user 消息 + 工具结果,模型会困惑「这个结果哪来的?我刚才要过什么?」。把 tool_calls 消息原样带回,它才能把结果和「自己刚才的请求」对上,然后综合出最终回答。这一步丢掉任何一环,第二轮模型就会答非所问——这是最常见的实现错误,本章练习会专门压这一点。
并行调用
模型可以在同一轮返回多个 tool_calls,比如用户问「北京和上海天气怎么样?」,模型一次请求两个 get_weather,参数分别是北京和上海。这毫不稀奇,因为这些调用之间相互独立:不需要任何一方的结果才能执行另一方。
处理方式也简单:Promise.all 并发执行,结果顺序按 tool_calls 数组的顺序排列,每个结果各配一条 role: 'tool' 消息(tool_call_id 各配各的):
const results = await Promise.all(calls.map((c) => execute(c, tools)));
// calls = [call_a1(北京), call_b2(上海)]
// results = ['北京: 24°C, 晴', '上海: 28°C, 多云']
// tool 消息:call_a1 -> 北京结果,call_b2 -> 上海结果
两个要点:
- 顺序由数组决定,配对由 id 决定。 并发执行完成后,必须按
tool_calls的原始顺序排结果、按id配tool_call_id,不能按「哪个先跑完」排,否则第二轮模型看到的结果与它请求的顺序对不上; - 串行执行永远可行,只是慢。 每个调用单独等上一个完成再执行,逻辑一样对。但当工具彼此独立时,并发能省掉几乎所有等待时间——真实 Agent 里「一次问 5 个城市」靠的就是这个。
畸形输出兜底
模型是概率系统,什么怪事都可能发生。工具调用环节的畸形输出主要有三类,处理原则是同一个:任何畸形都不应该炸掉对话,回填一条说明性的消息,让模型自己恢复。
① arguments 解析失败。 模型生成了不合法的 JSON(少个引号、多个逗号)。JSON.parse 会抛异常。兜底:返回 {}(见前文 safeParse),工具按缺参数处理,流程继续。也可以回填 Error: invalid arguments,让模型看到错误后自己修正再调一次。
② 工具不存在。 模型调用了你没声明的工具(名字拼错、幻觉出你根本没注册过的工具)。兜底:回填 Error: unknown tool: xxx。模型看到错误会自行纠正。这是 tool calling 最优雅的地方:错误不必抛到用户面前,模型读一遍错误文本,下一轮通常会换个工具或换个参数重试。
③ 结果过长。 工具返回了 10 万字的日志,直接回填会占满上下文窗口(token 成本 + 重要信息被淹没,上下文工程的完整讨论在第 09 章)。兜底:截断到一定长度再回填:
const MAX_RESULT = 2000;
const content = result.length > MAX_RESULT ? result.slice(0, MAX_RESULT) : result;
把这三类兜底做成默认行为,你的工具调用层才配叫「健壮」。判题练习里也专门有对应 stage。
厂商差异:Anthropic 的 tool_use
第 04 章说过:OpenAI 与 Anthropic 的协议「处处不同但逻辑相同」,工具也不例外。Anthropic 的 Messages API 里,工具调用长这样(细节以官方文档为准):
| 维度 | OpenAI(chat/completions) | Anthropic(Messages) |
|---|---|---|
| 工具声明 | tools: [{ type: 'function', function: { name, description, parameters } }] | tools: [{ name, description, input_schema }](参数 schema 直接叫 input_schema,没有 function 包装层) |
| 模型请求 | message.tool_calls[],function.arguments 是 JSON 字符串 | content 里的 tool_use block,input 已经是对象 |
| 结果回填 | 独立的 role: 'tool' 消息 + tool_call_id | 下一条 user 消息里的 tool_result block + tool_use_id |
| 停止原因 | finish_reason: 'tool_calls' | stop_reason: 'tool_use' |
对照着读:配对机制(id ↔ tool_call_id / tool_use_id)完全一样,载体不同(独立消息 vs 消息内的 block);参数是字符串 vs 对象,差别只在「要不要多一步 parse」。协议形状不同,闭环逻辑相同:拿到请求 → 解析 → 执行 → 按 id 配对回填 → 二次请求。这也正是第 04 章 Provider 抽象要翻译的那一层。如果你在写跨厂商的 Agent,把工具格式也收进 Provider 的翻译层,业务代码只面对一个统一形状。
常见坑
坑一:忘了 parse arguments,直接当对象用。 arguments 是字符串,call.arguments.city 是 undefined,工具静默拿不到参数。先 JSON.parse 再用。
坑二:tool_call_id 配错或漏配。 回填时把 call_a1 的结果挂到 call_b2 的 id 上,或者干脆不填 id,模型无法把结果与请求对上,轻则答非所问,重则报错。
坑三:回填时丢了 assistant 消息。 只回填 tool 消息、不回填带 tool_calls 的 assistant 消息,模型看不到「自己刚才请求过什么」。这是最常见的「第二轮突然失忆」的根因。
坑四:把工具结果当 user 文本塞回去。 有的实现图省事,把工具结果拼进 user 消息("工具返回:24°C")而不用 role: 'tool'。大多数模型能猜出意图,但配对信息(哪个结果对应哪个调用)会丢失,模型一多问几个就乱。协议给了 role: 'tool' 就用它。
坑五:不处理畸形输出。 arguments 解析失败直接 throw,模型一抽风整个对话崩溃。回填错误文本让模型恢复,成本低收益大。
坑六:并行执行后不按原顺序回填。 Promise.all 的结果数组本身保序,但如果你用「谁先完成谁先写」,第二轮模型看到的顺序就乱了。始终按 tool_calls 数组顺序配对。
小结
- 模型的边界是生成文本:它不会执行任何东西。工具调用 = 模型在文本里声明「我要用哪个工具、参数是什么」,执行永远在你的进程里,这就是「模型思考、你动手」的契约;
- 一个工具 = schema 声明(
name/description/parameters,JSON Schema,给模型看)+ 实现(给我们用);description决定模型选得准不准; - 带
tools的请求会得到tool_calls响应:id/name/arguments(JSON 字符串)/finish_reason: 'tool_calls';arguments要先 parse 再校验(校验器复用第 05 章的成果); - 核心闭环:解析 → 执行(结果必须是字符串)→ 回填(assistant 消息 + 每条调用配一条
role: 'tool'消息,按tool_call_id配对)→ 用扩展后的消息数组发第二次请求; - 并行调用:同一轮多个
tool_calls用Promise.all并发执行,顺序按数组、配对按 id; - 畸形兜底三件套:arguments 解析失败 →
{};工具不存在/执行失败 → 回填Error: ...让模型恢复;结果过长 → 截断。原则:任何畸形都不炸对话,回填说明性消息; - Anthropic 的
tool_use/tool_result/input_schema与 OpenAI 的tool_calls/role: 'tool'/parameters载体不同、配对逻辑相同,这是第 04 章 Provider 抽象要翻译的又一层。
到此为止,你手动走通了一次完整的工具调用。但注意:上面的 runToolRound 只走一轮,模型可能要调好几个工具、甚至多轮循环才能完成任务(先查天气、再定行程、再查交通……)。把这一轮塞进一个 while 循环、加上停止条件与死循环保护,就成了第 07 章的主角:最小 Agent Loop。
动手验证:打开本章练习,手动实现 parseToolCalls、executeToolCall 与 runToolRound,跑通「请求 → 解析 → 执行 → 回填」的完整一轮,并处理并行调用与畸形输出。
延伸阅读
- OpenAI: Function calling 官方指南:工具调用五步流程、函数定义与并行调用的权威说明。
- OpenAI: Chat Completions API 参考:
tools与tool_calls的完整字段定义。 - Anthropic: Tool use 六课教程:从第一个简单工具到完整工作流的系统讲解,含多工具聊天机器人。
- Anthropic: Tool use 官方文档:
input_schema、tool_useblock 与tool_result的规范。 - Microsoft: AI Agents for Beginners · 04-tool-use:Tool Use 设计模式综述,工具为什么让 Agent 能力破圈。
- CodeCrafters: Build your own Claude Code:判题式课程,其 stage 02–03 正是「声明工具 → 解析 tool_calls → 执行」的最小实现路径。