Tool Calling 手动全流程

Tool Calling 手动全流程

第 02 章我们让模型「说话」:发消息、拿文本。第 05 章我们让模型「说人话」:约束输出格式、做 JSON 解析与校验。但无论是哪一章,模型都还只是一个聊天框——你问它什么,它回你一段文字。这一章要跨出关键的一步:让模型行动。它不再只回答「北京今天天气怎么样」,改为向你提出一个请求:请帮我调用 get_weather 这个工具,参数是 { city: '北京' }。你的代码执行完工具,把结果送回给模型,它再基于真实数据给出最终答复。这套「模型请求 → 你执行 → 结果回填 → 再请求」的循环,就是 Tool Calling(工具调用),所有 Agent 行动的起点。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:没有工具,模型只是个聊天框

先看一个朴素的问题:

const messages = [{ role: 'user', content: '北京今天天气怎么样?' }];
// 把这段消息发给模型,你会得到什么?

你会得到一段语法通顺、内容全凭运气的回答。模型的全部知识来自训练数据,它既没有连过天气 API,也没有实时数据源——「北京今天 24°C 晴」和「-8°C 大雪」对它来说没有本质区别,都是它顺着语言习惯出来的。这不是模型笨,而是它没有获取信息的通道

再放大一点看:模型能做什么、不能做什么?

模型能模型不能
根据上下文生成文本发起 HTTP 请求(除非你替它发)
记住对话里出现过的内容读你文件系统里的文件
复述训练数据里的知识执行 Python 代码、算今天的日期
理解「这个工具是查天气的」自己运行这个工具

模型的能力边界是生成文本;而「行动」(查实时数据、读写文件、调用外部 API、执行计算)全都发生在模型之外的世界。你也许想过把数据塞进 prompt 里喂给它:

// 方案 A:把天气数据写进 system prompt
const system = '今日天气:北京 24°C 晴。用户可能问天气,照着数据回答。';

问题很明显:数据是静态的。用户问上海怎么办?明天问怎么办?每问一次就重新拼一次 prompt?而且这只是在「搬运数据」,根本没有「行动」,模型没有选择权,永远被动。要是数据源成百上千(天气、股票、订单、文件……),这套「预先塞好」的方案立刻破产。

Tool Calling 解决的就是这件事:把「行动」的选择权交给模型(它根据用户意图挑工具、填参数),把「执行权」牢牢留在你的进程里(真正跑代码的是你)。模型负责思考,你负责动手。

工具是什么

一个工具由两部分组成,缺一不可:

  1. schema 声明,写给模型看的「说明书」:这个工具叫什么、什么时候用、参数长什么样。模型只能看到这份说明书,看不到实现代码;
  2. 实现,写给我们自己用的真实函数。模型永远不会直接运行它。
// ① 实现:只有我们的代码能调用(模型看不到这段)
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 Schemaname 是工具名(模型用它来指代),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"
    }
  ]
}

逐字段拆开:

一个容易踩的直觉陷阱:contentnull。模型这一轮没有回答任何问题,它说「给我工具,我才能回答」。你把 content: nulltool_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}`;                      // 兜底:执行失败
  }
}

两个值得记住的细节:

回填与二次请求:核心闭环

执行完之后,最关键的步骤:把结果送回给模型。回填的规则是:把三样东西追加进消息数组,然后原样发第二次请求

  1. 模型的 assistant 消息(包含 tool_calls,它记录了自己刚才请求过哪些工具);
  2. 每个调用一条 { role: 'tool', tool_call_id, content } 消息,tool_call_id 与调用的 id 一一配对
  3. 你原来的消息(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 -> 上海结果

两个要点:

畸形输出兜底

模型是概率系统,什么怪事都可能发生。工具调用环节的畸形输出主要有三类,处理原则是同一个:任何畸形都不应该炸掉对话,回填一条说明性的消息,让模型自己恢复

① 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.argumentsJSON 字符串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'

对照着读:配对机制(idtool_call_id / tool_use_id)完全一样,载体不同(独立消息 vs 消息内的 block);参数是字符串 vs 对象,差别只在「要不要多一步 parse」。协议形状不同,闭环逻辑相同:拿到请求 → 解析 → 执行 → 按 id 配对回填 → 二次请求。这也正是第 04 章 Provider 抽象要翻译的那一层。如果你在写跨厂商的 Agent,把工具格式也收进 Provider 的翻译层,业务代码只面对一个统一形状。

常见坑

坑一:忘了 parse arguments,直接当对象用。 arguments 是字符串,call.arguments.cityundefined,工具静默拿不到参数。先 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 数组顺序配对。

小结

到此为止,你手动走通了一次完整的工具调用。但注意:上面的 runToolRound 只走一轮,模型可能要调好几个工具、甚至多轮循环才能完成任务(先查天气、再定行程、再查交通……)。把这一轮塞进一个 while 循环、加上停止条件与死循环保护,就成了第 07 章的主角:最小 Agent Loop

动手验证:打开本章练习,手动实现 parseToolCallsexecuteToolCallrunToolRound,跑通「请求 → 解析 → 执行 → 回填」的完整一轮,并处理并行调用与畸形输出。

延伸阅读

完成阅读,去做练习 →