tool_calls 需要回传给大模型吗?

11 阅读4分钟

tool_calls 必须回传,少了会出大问题。 tool_calls 是对话协议的强约束部分,丢了模型会"失忆"或直接报错。

一句话回答

tool_callsassistant 上一轮"我决定要调这些工具"的承诺凭证,下一轮你必须把它原样回传,并紧跟着用 role: tool 消息把结果配对回给模型。少传或漏配,模型会报错或行为异常

必须回传的核心理由

1️⃣ tool 消息必须通过 tool_call_id 配对到上一轮的 tool_calls

OpenAI 协议要求 role: tool 消息必须带 tool_call_id,这个 id 必须能在前一条 assistant 消息的 tool_calls 数组中找到

# 上一轮 assistant 必须保留 tool_calls
{"role": "assistant", "content": "", "tool_calls": [
    {"id": "call_xxx", "function": {"name": "bash", "arguments": "..."}}
]}

# 紧接着的 tool 消息必须引用这个 id
{"role": "tool", "tool_call_id": "call_xxx", "content": "ls 输出..."}

如果丢了 tool_calls,模型/服务端找不到 call_xxx 的来源,会报:

400: messages with role 'tool' must be a response to a preceding message with 'tool_calls'

(OpenAI 官方报错原文,几乎所有兼容服务都一样)

2️⃣ 模型靠它知道"自己上一轮干了啥"

模型本身是无状态的,每轮都重新读完整 messages 推理。如果你删掉 tool_calls

模型看到的对话变成:

user:      "看下当前目录有啥"
assistant: ""                          ← 啥都没说?
tool:      "file1.txt file2.txt"       ← 这哪冒出来的?
user:      "再看下文件大小"

模型一脸懵:"我没调工具啊,怎么有 tool 结果?" 行为不可预测,可能:

  • 重复调一次刚才的工具
  • 忽略 tool 消息凭空乱编
  • 直接报 schema 错误

3️⃣ 多 tool_calls 时必须全部配对回传

一次 assistant 可以并行调多个工具:

"tool_calls": [
    {"id": "call_a", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}},
    {"id": "call_b", "function": {"name": "bash", "arguments": "{\"command\":\"pwd\"}"}}
]

下一轮你必须对每一个回传 tool 消息:

{"role": "tool", "tool_call_id": "call_a", "content": "file1.txt..."},
{"role": "tool", "tool_call_id": "call_b", "content": "/Users/..."}

少一个模型会报:

400: 'tool_calls' must be followed by tool messages responding to each tool_call_id

简单例子

# 1) 保留 tool_calls 到 assistant 消息
assistant_turn = {"role": "assistant", "content": msg.get("content") or ""}
if msg.get("tool_calls"):
    assistant_turn["tool_calls"] = msg["tool_calls"]   # ← 关键
messages.append(assistant_turn)

# 2) 对每个 tool_call 都回传一条 tool 消息(用 id 配对)
for call in msg["tool_calls"]:
    ...
    messages.append({
        "role": "tool",
        "tool_call_id": call["id"],   # ← 关键
        "content": output,
    })

这两步缺一不可,你都做了。

完整一轮长这样

[
  # 1. 用户提问
  {"role": "user", "content": "看下当前目录有啥"},

  # 2. 模型决定调工具(这条必须保留 tool_calls)
  {"role": "assistant",
   "content": "",
   "tool_calls": [
     {"id": "call_a", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}}
   ]},

  # 3. 你执行后回传结果(必须带 tool_call_id 配对)
  {"role": "tool",
   "tool_call_id": "call_a",          ← 必须等于上面的 "call_a"
   "content": "file1.txt\nfile2.txt"},

  # 4. 模型基于工具结果给出最终回答
  {"role": "assistant", "content": "目录里有 file1.txt 和 file2.txt"}
]

对比:哪些字段要回传

字段来源要回传吗为什么
rolemessage必传协议必填
contentmessage必传(可以是 ""协议必填
tool_callsmessage必传(如果有)配对锚点,丢了 400
reasoning_contentmessage❌ 不要传思考草稿,浪费 token
thoughtSignaturemessage❌ 不要传厂商扩展,无意义
indexchoice 层不是 message 的一部分
finish_reasonchoice 层不是 message 的一部分

常见踩坑

❌ 坑 1:删 tool_calls 想"省 token"

# 错误:以为思考过程会污染上下文,把 tool_calls 也一起删
assistant_turn = {"role": "assistant", "content": msg["content"] or ""}
# 没传 tool_calls → 下一条 tool 消息找不到锚点 → 400

❌ 坑 2:tool 消息少传一条

# 模型一次返回 3 个 tool_calls,你只执行了前 2 个
for call in msg["tool_calls"][:2]:    # ← 漏掉第 3 个
    ...
# 第 3 个 call 没有对应 tool 消息 → 400

正确做法:要么全部执行,要么对失败/跳过的也补一条 tool 消息

{"role": "tool", "tool_call_id": "call_skipped", "content": "Error: skipped by user"}

❌ 坑 3:tool_call_id 写错

{"role": "tool", "tool_call_id": "call_xyz", ...}   # ← id 拼错
# → 找不到对应的 tool_call → 400

永远用 call["id"] 取值,不要硬编码

❌ 坑 4:tool 消息和 assistant 消息顺序颠倒

[
  {"role": "tool", ...},      ← tool 不能在 assistant 之前
  {"role": "assistant", "tool_calls": [...]}
]

正确顺序:assistant(tool_calls)tooltoolassistant → ...

简单记忆口诀

字段口诀
tool_calls承诺:assistant 说"我要调这些工具",必须留着
tool_call_id凭证:tool 结果靠它"对账"
reasoning_content草稿:用完就扔,别带回家

一句话总结

tool_calls 必须原样回传,因为它是 tool 消息找回家的"户口本"。一旦丢失,模型会因为 tool_call_id 找不到上家而直接 400 报错,或者上下文断裂胡乱编造。你脚本已经正确处理了,无需改动

reasoning_content 别传、tool_calls 必传 —— 这就是构造 agent loop 消息历史的两条铁律。