上一篇讲四种 Loop 的总览,这篇把 ReAct 拆开,讲清楚"为什么你的工具调用总是不生效"。
适合:已经能调通大模型 API、但一接工具就各种报错的同学。
一、为什么 ReAct 是最小可用的 Agent 循环
ReAct = Reasoning + Acting。一句话概括:
模型先想,再决定要不要调用工具;调完把结果给它看,它再继续想,直到能直接回答。
它是最小可用的 Agent Loop,因为只需要三样东西:一个模型、一个工具、一个循环。但"最小"不等于"简单"------真正写起来,90% 的 bug 都集中在三个细节里:消息顺序、参数分片、失败回填。
二、完整时序图
用户提问
│
▼
[1] 拼消息:System 人设 + 历史记忆 + 本轮问题
│
▼
[2] 调用模型(流式/非流式)
│
├── 没有 tool_calls ──▶ 文本即最终答案 → 存记忆 → 结束
│
└── 有 tool_calls
│
▼
[3] 回填 AssistantMessage(tool_calls) ← 关键:先把"模型想调工具"这件事记下来
│
▼
[4] 逐个执行工具,拿到结果(JSON 字符串)
│
▼
[5] 回填 ToolResponseMessage(按 tool_call id 对齐)
│
▼
[6] 下一轮:把完整消息列表再次发给模型(轮次 +1)
│
▼
超过轮次上限 → 追加"请直接给最终答案" → 强制收尾
这条链路里,任何一步顺序错、id 对不上、结果没回填,模型都会"看不懂"。
三、三个必须搞对的细节
3.1 消息顺序:ToolResponse 必须紧跟 Assistant(tool_calls)
模型看到的消息序列必须长这样:
System
User
Assistant(tool_calls=[{id:call_1, name:getWeather, arguments:{"city":"北京"}}])
Tool(tool_call_id=call_1, content={...})
Assistant(最终回答)
常见错误:把 ToolResponseMessage 放在下一轮开头、或者忘了先加 AssistantMessage(tool_calls)。
为什么必须这样?因为模型是靠 tool_call_id 把"我请求的调用"和"工具返回的结果"配对起来的。配对不上,它就会认为工具没执行,继续重复调用。
3.2 流式工具参数:arguments 是分片到达的
这是最容易踩的坑。流式响应里,一个工具调用的参数不是一次给全的:
chunk1: ToolCall(id=call_1, name=getWeather, arguments={"city":"北
chunk2: ToolCall(id=call_1, name=null, arguments":"京","unit":"c"})
如果每个 chunk 都直接执行,工具拿到的是半截 JSON,解析必然失败。
正确做法:按 id 合并,等流结束再执行。
java
// 伪代码:跨 chunk 累积工具调用
for (ToolCall incoming : chunk.getToolCalls()) {
boolean merged = false;
for (int i = 0; i < state.toolCalls.size(); i++) {
ToolCall existing = state.toolCalls.get(i);
if (existing.id().equals(incoming.id())) {
// 参数是"差分",追加到已有参数后面
String args = nullToEmpty(existing.arguments()) + nullToEmpty(incoming.arguments());
state.toolCalls.set(i, new ToolCall(
existing.id(), existing.type(), existing.name(), args));
merged = true;
break;
}
}
if (!merged) {
state.toolCalls.add(incoming); // 新工具调用
}
}
两个容易忽略的点:
ToolCall通常不可变(record) :不能existing.arguments += ...,必须构造新对象再set(i, newToolCall)。- 后续分片的
name常为 null:合并时保留首个分片里的 name。
合并完之后,最好再校验一次参数是不是合法 JSON------能解析再执行,不能解析就回填一条错误结果,别硬调。
3.3 失败回填:错误也要给模型一个"结果"
工具执行失败(超时、参数错、接口 500)时,很多人只打个日志就跳过。结果下一轮模型继续要求调用同一个工具,甚至直接报 400。
正确做法:失败也要构造一条同 id 的 ToolResponse,内容写明错误。
java
String result;
try {
result = findTool(tc.name()).call(tc.arguments());
} catch (Exception e) {
result = "{\"error\":\"" + e.getMessage() + "\"}";
}
messages.add(ToolResponseMessage.builder()
.responses(List.of(new ToolResponse(tc.id(), tc.name(), result)))
.build());
模型看到错误信息后,通常会自己换参数重试,或者换一种方式回答------这就是"自愈"。
四、终止条件:两个出口都要有
ReAct 必须有两条退出路径:
| 出口 | 条件 | 处理 |
|---|---|---|
| 正常完成 | 模型这次没有返回 tool_calls | 文本即最终答案,存记忆,结束 |
| 强制收尾 | 轮次达到上限 | 追加一条"已达最大轮次,禁止再调用工具,请直接回答"的消息,再调一次模型 |
没有第二个出口,遇到模型"犯轴"就会无限循环。轮次上限建议 3~10,按任务复杂度定。
五、完整伪代码(约 40 行)
java
public String run(String conversationId, String question) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(SYSTEM_PROMPT));
messages.addAll(loadHistory(conversationId)); // 历史记忆
messages.add(new UserMessage(question));
for (int round = 1; round <= MAX_ROUNDS; round++) {
ChatResponse resp = chatModel.call(new Prompt(messages));
if (!resp.hasToolCalls()) {
saveMemory(conversationId, question, resp.getText());
return resp.getText();
}
// 1) 先回填模型的工具调用意图
messages.add(AssistantMessage.builder()
.toolCalls(resp.getToolCalls()).build());
// 2) 再执行工具并回填结果
List<ToolResponse> responses = new ArrayList<>();
for (ToolCall tc : resp.getToolCalls()) {
String result;
try {
result = findTool(tc.name()).call(tc.arguments());
} catch (Exception e) {
result = "{\"error\":\"" + e.getMessage() + "\"}";
}
responses.add(new ToolResponse(tc.id(), tc.name(), result));
}
messages.add(ToolResponseMessage.builder().responses(responses).build());
}
// 强制收尾
messages.add(new UserMessage("已达到最大推理轮次,请基于现有信息直接回答,不要再调用工具。"));
return chatModel.call(new Prompt(messages)).getText();
}
六、踩坑清单(4 条)
- ToolResponse 没紧跟 Assistant(tool_calls) → 模型认为工具没执行,重复调用。
- 流式参数不合并 → 工具收到半截 JSON,解析异常。
- 失败只记日志不回填 → 模型不知道失败,下一轮可能 400 或死循环。
- 只有一个出口 → 没有"轮次上限 + 强制回答",遇到异常模型会无限循环。
七、什么时候 ReAct 不够用
ReAct 适合"单步或少量步骤"的任务:查资料、算数、调用一个接口。
但如果任务需要"先拆成 5 个子任务、再分别执行、最后汇总",ReAct 的"一次只走一步"就会很慢,而且容易漏任务。这时需要 Plan-Execute:先让模型生成任务清单,再按依赖分组并行执行,最后自评。
下一篇我们就拆 Plan-Execute:结构化计划怎么生成、order 分组并行为什么能提速、critique 自评如何决定"重来还是收尾"。
本文为个人学习实践总结,示例代码为通用伪代码。