tool_calls 必须回传,少了会出大问题。 tool_calls 是对话协议的强约束部分,丢了模型会"失忆"或直接报错。
一句话回答
tool_calls是 assistant 上一轮"我决定要调这些工具"的承诺凭证 ,下一轮你必须把它原样回传 ,并紧跟着用role: tool消息把结果配对 回给模型。少传或漏配,模型会报错或行为异常。
必须回传的核心理由
1️⃣ tool 消息必须 通过 tool_call_id 配对到上一轮的 tool_calls
OpenAI 协议要求 role: tool 消息必须带 tool_call_id,这个 id 必须能在前一条 assistant 消息的 tool_calls 数组中找到:
python
# 上一轮 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 的来源,会报:
csharp
400: messages with role 'tool' must be a response to a preceding message with 'tool_calls'
(OpenAI 官方报错原文,几乎所有兼容服务都一样)
2️⃣ 模型靠它知道"自己上一轮干了啥"
模型本身是无状态 的,每轮都重新读完整 messages 推理。如果你删掉 tool_calls:
模型看到的对话变成:
vbnet
user: "看下当前目录有啥"
assistant: "" ← 啥都没说?
tool: "file1.txt file2.txt" ← 这哪冒出来的?
user: "再看下文件大小"
模型一脸懵:"我没调工具啊,怎么有 tool 结果?" 行为不可预测,可能:
- 重复调一次刚才的工具
- 忽略 tool 消息凭空乱编
- 直接报 schema 错误
3️⃣ 多 tool_calls 时必须全部配对回传
一次 assistant 可以并行调多个工具:
python
"tool_calls": [
{"id": "call_a", "function": {"name": "bash", "arguments": "{\"command\":\"ls\"}"}},
{"id": "call_b", "function": {"name": "bash", "arguments": "{\"command\":\"pwd\"}"}}
]
下一轮你必须对每一个回传 tool 消息:
python
{"role": "tool", "tool_call_id": "call_a", "content": "file1.txt..."},
{"role": "tool", "tool_call_id": "call_b", "content": "/Users/..."}
少一个模型会报:
csharp
400: 'tool_calls' must be followed by tool messages responding to each tool_call_id
简单例子
python
# 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,
})
这两步缺一不可,你都做了。
完整一轮长这样
python
[
# 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"}
]
对比:哪些字段要回传
| 字段 | 来源 | 要回传吗 | 为什么 |
|---|---|---|---|
role |
message | ✅ 必传 | 协议必填 |
content |
message | ✅ 必传 (可以是 "") |
协议必填 |
tool_calls |
message | ✅ 必传(如果有) | 配对锚点,丢了 400 |
reasoning_content |
message | ❌ 不要传 | 思考草稿,浪费 token |
thoughtSignature |
message | ❌ 不要传 | 厂商扩展,无意义 |
index |
choice 层 | ❌ | 不是 message 的一部分 |
finish_reason |
choice 层 | ❌ | 不是 message 的一部分 |
常见踩坑
❌ 坑 1:删 tool_calls 想"省 token"
python
# 错误:以为思考过程会污染上下文,把 tool_calls 也一起删
assistant_turn = {"role": "assistant", "content": msg["content"] or ""}
# 没传 tool_calls → 下一条 tool 消息找不到锚点 → 400
❌ 坑 2:tool 消息少传一条
python
# 模型一次返回 3 个 tool_calls,你只执行了前 2 个
for call in msg["tool_calls"][:2]: # ← 漏掉第 3 个
...
# 第 3 个 call 没有对应 tool 消息 → 400
正确做法:要么全部执行,要么对失败/跳过的也补一条 tool 消息:
python
{"role": "tool", "tool_call_id": "call_skipped", "content": "Error: skipped by user"}
❌ 坑 3:tool_call_id 写错
python
{"role": "tool", "tool_call_id": "call_xyz", ...} # ← id 拼错
# → 找不到对应的 tool_call → 400
永远用 call["id"] 取值,不要硬编码。
❌ 坑 4:tool 消息和 assistant 消息顺序颠倒
python
[
{"role": "tool", ...}, ← tool 不能在 assistant 之前
{"role": "assistant", "tool_calls": [...]}
]
正确顺序: assistant(tool_calls) → tool → tool → assistant → ...
简单记忆口诀
| 字段 | 口诀 |
|---|---|
tool_calls |
承诺:assistant 说"我要调这些工具",必须留着 |
tool_call_id |
凭证:tool 结果靠它"对账" |
reasoning_content |
草稿:用完就扔,别带回家 |
一句话总结
tool_calls必须原样回传 ,因为它是 tool 消息找回家的"户口本"。一旦丢失,模型会因为tool_call_id找不到上家而直接 400 报错 ,或者上下文断裂胡乱编造。你脚本已经正确处理了,无需改动。
reasoning_content 别传、tool_calls 必传 ------ 这就是构造 agent loop 消息历史的两条铁律。