从 0 实现 ReAct:消息顺序、工具参数分片与失败回填

上一篇讲四种 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);   // 新工具调用
    }
}

两个容易忽略的点:

  1. ToolCall 通常不可变(record) :不能 existing.arguments += ...,必须构造新对象再 set(i, newToolCall)。
  2. 后续分片的 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 条)

  1. ToolResponse 没紧跟 Assistant(tool_calls) → 模型认为工具没执行,重复调用。
  2. 流式参数不合并 → 工具收到半截 JSON,解析异常。
  3. 失败只记日志不回填 → 模型不知道失败,下一轮可能 400 或死循环。
  4. 只有一个出口 → 没有"轮次上限 + 强制回答",遇到异常模型会无限循环。

七、什么时候 ReAct 不够用

ReAct 适合"单步或少量步骤"的任务:查资料、算数、调用一个接口。

但如果任务需要"先拆成 5 个子任务、再分别执行、最后汇总",ReAct 的"一次只走一步"就会很慢,而且容易漏任务。这时需要 Plan-Execute:先让模型生成任务清单,再按依赖分组并行执行,最后自评。

下一篇我们就拆 Plan-Execute:结构化计划怎么生成、order 分组并行为什么能提速、critique 自评如何决定"重来还是收尾"。


本文为个人学习实践总结,示例代码为通用伪代码。

相关推荐
一心同学1 小时前
Hermes架构拆解之Profiles
ai·架构·agent·profiles·hermes
泯泷11 小时前
单个 AI 再强,为什么也干不过一支 AI 团队?
人工智能·llm·agent
_砖家16 小时前
DeepSeek Harness 原来还是我们熟悉的 Webpack 和中间件模型
agent·ai编程·deepseek
泯泷19 小时前
同样是多 Agent,为什么有的稳,有的却天天翻车?
人工智能·llm·agent
阿里云云原生19 小时前
关于 DeepSeek 的 SuperAgent,以及我们的实践
云原生·agent
威哥爱编程20 小时前
模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来
python·agent·harmonyos
威哥爱编程20 小时前
模拟面试智能体开发实战 03:AI 面试官太客气?人设 Prompt 让它像个真考官
python·agent·harmonyos
七夜zippoe20 小时前
Agent 上线第一天就炸了:6 个生产事故的根因分析与修复记录
大数据·ai·agent·根因分析·修复记录
用户95179422510291 天前
最小循环:让 Agent 跑起来(I1)
python·agent