深入理解 LLM 函数调用:模型不会”决定”用工具

一句话:函数调用不是模型内部”觉醒”的能力,而是推理层在 prompt 里塞了一套工具描述后,模型遵循协议输出的结构化 token。本质上是 prompt engineering 的延伸——只不过发生在 API 层而非用户输入层。


模型不会”觉得该用工具了”

关于函数调用最大的误解,就是以为模型内部有个”我该调工具了”的判断机制。没有。模型就是一个 autoregressive token 生成器——给定前文,预测下一个 token。仅此而已。

函数调用是推理层启用的能力,需要两个显式条件同时满足:

  1. 请求里必须带 tools 参数——一个函数定义数组,每个函数包含名称、自然语言描述和 JSON Schema 参数规格。
  2. tool_choice 参数必须允许调用。默认值是 "auto"——模型在它觉得合适时可能返回工具调用。你也可以设为 "none"(绝不下手)、"required"(必须下手),或锁定到某个具体函数。

tool_choice"auto",模型做的事是把每个函数的描述跟用户请求做匹配。请求超出了模型知识边界(实时数据、私有数据库、精确计算),且恰好有匹配的工具——模型就输出工具调用,不输出文本。没匹配上,就正常吐文本。

函数描述是模型判断何时调用的唯一信号。写个模糊的”获取数据”既会导致误触发,也会漏调用。写成”查询指定城市的当前天气。返回温度、湿度和天气状况字符串”——准确率能上去一大截。说白了,工具描述就是 prompt engineering,只是位置从用户消息挪到了 API 参数里。


content 和 tool_calls 永远不共存

这个设计让很多第一次接触的人愣住:在同一个 assistant 响应里,contenttool_calls 是互斥的。模型要么返回文本,要么返回工具调用——绝不同时返回。

情况 content tool_calls
模型直接回答 有值 null
模型需要工具 null 调用数组

ChatGPT 看起来能”边搜边说”,那是一种编排层制造的流畅假象。底层是多次往返:模型返回工具调用(没文本),你的系统执行它,把结果喂回去,模型再生成自然语言回复。你看到的连贯对话,是多次独立 API 调用拼接出来的。

这个互斥特性有实际的工程影响:如果你在流式传输响应,没法一边等工具结果一边先渲染部分文本——要么有内容能展示,要么有工具要执行,同一个响应对象里永远不存在两者并存。


messages[] 是唯一的真相来源

函数调用不是有状态的。发给模型的每一次请求,都必须在 messages 数组里带全整段对话历史。这个数组是模型唯一能看到的东西,它随每一轮对话不断膨胀。

一个完整的工具调用流转长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
第1步——用户提问
messages: [user: "北京天气怎么样?"]
↓ 发给模型

第2步——模型判断需要工具
messages: [user, assistant(tool_call: get_weather("北京"))]
↓ 你执行 get_weather,拿到 { temp: 22, condition: "晴" }

第3步——追加工具结果
messages: [user, assistant(tool_call), tool(result: "22°C,晴")]
↓ 完整数组发回模型

第4步——模型生成最终答案
messages: [user, assistant(tool_call), tool(result), assistant(text: "今天北京晴,22°C")]

每一步都是对 messages[] 的一次追加。模型在两次 API 调用之间没有记忆——messages 数组就是记忆。如果你在最后一轮漏了 tool 结果消息,模型就会幻觉,因为它根本没看见那些数据。同理,你不能在中途删消息:模型需要看到 assistant(tool_call) 才能理解”刚才有过工具调用”,也需要看到 tool(result) 才能知道”返回了什么”。


工程落地:并行、错误、循环、流式

并行工具调用

模型可以在单个响应里同时返回多个 tool_calls——比如一次性查北京和上海的天气。这些调用彼此独立,你的系统应该并发执行它们,再把各自的结果以独立的 tool 消息全部追加进去,然后发回模型。串行执行等于平白无故把延迟翻倍。

错误处理

当工具执行失败,别把异常直接抛给用户。把错误当成一条 tool 消息追加到数组里,发回模型。模型通常会道歉、解释问题、或尝试替代方案。错误变成了对话的一部分,而不是走到了死胡同。

工具调用循环

这是最棘手的边缘情况。模型可能调用一个工具,拿到结果,然后基于结果再调用另一个工具——在给出最终答案之前串联多次调用。你的编排层必须能处理循环:只要模型还在返回 tool_calls 就继续迭代,同时设一个硬上限防死循环。每次迭代都在 messages[] 末尾追加内容,重新请求模型。

流式增量组装

接收流式响应(SSE)时,工具调用以碎片化的增量(delta)到达。一个 get_weather("北京") 可能拆成五个 SSE 事件传过来。你需要按 index 缓冲(模型可能交叉发送多个工具调用),累积 idfunction.namefunction.arguments 的碎片,直到 tool_calls 流发出完成信号。只有到那一刻你才能解析出完整的 JSON 参数并执行。


核心要点

  • function calling 是外部触发的,不是内部决定的——tools 参数和 tool_choice 设定控制着是否及何时调用;模型只是按协议输出。
  • contenttool_calls 互斥——单个响应绝不同时包含两者;多轮编排制造了文本与工具同步的假象。
  • messages[] 是唯一真相来源——每次 API 调用都带全对话历史,遗漏任何中间消息都会破坏模型上下文。
  • 并行工具调用必须并发执行——它们定义上就互相独立,串行是浪费延迟。
  • 错误也是消息——工具执行失败作为 tool 消息喂回对话,让模型优雅恢复。
  • 流式工具调用需要小心的增量组装——按索引缓冲,累积碎片,完整后才解析。