ReAct 不神秘:我用 Java 手写了一个极简 Agent 循环

AgentScope Java 原理与企业级智能体实战41 次阅读31 分钟

前面几篇我们一直在拆 AgentScope Java 的运行结构。

第 1 篇讲的是,Agent 不是一次模型 API 调用。

第 2 篇讲的是,agent.call(...) 背后不是一跳,而是一整条执行链。

第 3 篇讲的是,Msg 不是 String,因为 Agent 运行里要保存角色、内容块、工具调用、工具结果这些结构化信息。

第 4 篇讲的是,Formatter 把统一的 MsgToolSchemaGenerateOptions 翻译成不同模型厂商能理解的请求格式。

讲到这里,很多人脑子里其实还差最后一块拼图。

那就是。

ReActAgent 到底在循环什么。

不是概念上的 ReAct。

不是论文里的 Reasoning and Acting。

而是落到 Java 代码里,一个 Agent 为什么会先问模型,再执行工具,再把工具结果塞回上下文,然后继续问模型。

这篇我想先把它拆到最朴素。

我们先假装没有 AgentScope Java。

就用 Java 手写一个极简版。

Lucas 把 ReAct 翻译成一个 while 循环

先把 ReAct 翻译成一个 while

如果只看心智模型,一个 ReAct Agent 大概可以写成这样。

class MiniReActLoop {
private final Model model;
private final Toolkit toolkit;
private final List<Msg>; context = new ArrayList<>();
private final int maxIters = 10;

Msg call(Msg userMsg) {
    context.add(userMsg);

    for (int iter = 0; iter &lt; maxIters; iter++) {
        Msg assistantMsg = model.stream(context, toolkit.getToolSchemas(), options)
                .collectFinalMessage();

        context.add(assistantMsg);

        List<ToolUseBlock> toolCalls =
                assistantMsg.getContentBlocks(ToolUseBlock.class);

        if (toolCalls.isEmpty()) {
            return assistantMsg;
        }

        for (ToolUseBlock toolCall : toolCalls) {
            ToolResultBlock result = toolkit.callTool(toolCall).block();
            Msg toolMsg = ToolResultMessageBuilder.buildToolResultMsg(
                    result,
                    toolCall,
                    &quot;MiniAgent&quot;);
            context.add(toolMsg);
        }
    }

    return summarize(context);
}
}

这段不是 AgentScope Java 的真实源码。

但它是理解 ReActAgent 最好用的骨架。

你会发现,这个循环里真正重要的东西只有四个。

环节

极简版在做什么

AgentScope Java 里的真实对应

上下文

把用户消息、助手消息、工具结果都放进同一个列表

AgentState.contextMutable()

推理

带着上下文和工具 schema 调模型

reasoning(...) + model.stream(...)

判断

看模型回复里有没有工具调用

isFinished(...) 检查 ToolUseBlock

行动

执行工具,把结果变成工具消息塞回上下文

acting(...) + Toolkit.callTools(...)

这就是 ReActAgent 的核心。

不是模型调用。

是一个上下文不断被追加、模型不断重新观察上下文的循环。

这块特别容易被误解。

很多人会把它想成这样。

用户问题 -> 模型回答 -> 程序解析回答 -> 程序调用工具 -> 程序拼最终答案

但 ReAct 更像这样。

用户问题
  ↓
模型看到上下文,决定要不要调用工具
  ↓
程序执行工具
  ↓
工具结果作为新消息写回上下文
  ↓
模型再次看到上下文,继续决定下一步

关键区别在最后两行。

工具结果不是 Java 代码偷偷拿来拼字符串。

工具结果会变成 MsgRole.TOOL 的消息,重新交给模型。

模型不是只负责第一次判断。

模型每一轮都在读新的上下文。

所以 ReAct 的「Acting」不是独立流程,它是给下一轮「Reasoning」准备观察材料。

真实源码里不是 while,而是递归式响应链

你如果真的去看 ReActAgent,不会看到一个特别直白的 while

AgentScope Java 是响应式实现,用的是 Reactor 的 MonoFlux

所以它把循环拆成了几个方法互相调用。

真实入口大概是这样。

AgentBase.call(...)
  ↓
ReActAgent.doCall(...)
  ↓
doCallInner(...)
  ↓
coreAgent()
  ↓
executeIteration(0)
  ↓
reasoning(iter, false)
  ↓
acting(iter)
  ↓
executeIteration(iter + 1)

也就是说。

极简版里那句。

for (int iter = 0; iter < maxIters; iter++) {
    ...
}

在真实实现里被拆成了。

private Mono<Msg> coreAgent() {
    return executeIteration(0);
}
private Mono<Msg> executeIteration(int iter) {
return reasoning(iter, false);
}

然后 reasoning(...) 根据模型结果决定。

如果完成,就返回。

如果没完成,就进入 acting(iter)

acting(...) 执行完工具后,再走。

return executeIteration(iter + 1);

这就是那个 while。

只是它被写成了响应式链路。

Lucas 修理 ReActAgent 的响应式递归链

这么写不是为了炫技。

因为真实 Agent 运行时有流式输出、工具流式输出、中断、Hook、Middleware、权限确认、外部工具挂起、Session 保存。

这些东西如果硬塞在一个同步 while 里,代码很快会变成一坨。

AgentScope Java 把它拆成 reasoning 和 acting 两个阶段,本质上是为了把「模型推理」和「工具执行」的边界切清楚。

第一步,用户消息先进入上下文

在极简版里,我们写的是。

context.add(userMsg);

真实的 ReActAgent.doCallInner(...) 里也是这个意思。

当没有未完成的工具调用时,它会走。

addToContext(msgs);
return coreAgent();

只不过这里的上下文不是旧版文章里常见的 memory.getMessages()

这点要注意。

AgentScope Java 2.0 里,旧的 MemoryInMemoryMemory 还在,但它们已经标了 @Deprecated

Memory 的注释说得很直白,conversation context 现在放在 AgentState.getContext() 里。

所以第 5 篇如果还把真实实现讲成「读 Memory,然后写 Memory」,就不准确了。

更准确的说法是。

ReActAgent 把输入消息追加到 AgentState 的 context 里。

AgentState 里有两个方法很关键。

public List<Msg> getContext()
public List<Msg> contextMutable()

前者返回防御性只读拷贝。

后者返回真实可变列表。

ReActAgent 在运行时追加用户消息、助手消息、工具结果消息,用的就是 state.contextMutable()

这件事很重要。

因为 ReAct 循环不是围绕某个单独变量转。

它围绕一条不断增长的上下文时间线转。

Lucas 把 Msg 逐条归档进 AgentState.context

第二步,reasoning 把上下文交给模型

进入 reasoning(...) 后,框架会先检查一件事。

if (!ignoreMaxIters && iter >= maxIters) {
    return summarizing();
}

也就是最大轮数。

这个 maxIters 不是模型最大 token。

它限制的是 ReAct 的推理-行动轮次。

比如模型第一轮决定调用工具,这是第 0 轮 reasoning。

工具执行完,模型第二轮继续推理,这是下一轮。

如果模型一直要工具、一直没有给出最终回答,循环就不能无限跑。

所以 maxIters 是 Agent Runtime 的刹车。

接下来,reasoning(...) 会准备三样东西。

List<Msg> modelInput = prependSystemMsg(event.getInputMessages(), event.getSystemMessage());
List<ToolSchema> tools = toolkit.getToolSchemas();
GenerateOptions options = ...

这三样正好对应前几篇讲过的东西。

Msg 是上下文。

ToolSchema 是工具说明。

GenerateOptions 是本轮生成参数。

然后它进入。

reasoningStream(context, modelInput, tools, options)

再往里就是。

mci.model().stream(mci.messages(), mci.tools(), mci.options())

也就是说,模型看到的不是孤零零的一句用户输入。

模型看到的是。

system message
历史用户消息
历史助手消息
历史工具结果消息
当前工具 schema
当前生成选项

这就是为什么前面几篇要先讲 MsgFormatter

没有结构化消息,工具调用和工具结果塞不进上下文。

没有 Formatter,不同模型厂商也吃不下同一套上下文和工具 schema。

ReAct 循环看起来简单,但它其实踩在前面这些结构之上。

第三步,完成条件不是 hasFinalAnswer,而是没有 ToolUseBlock

很多人手写伪代码时会写。

if (response.hasFinalAnswer()) {
    return response;
}

这个写法适合教学,但它不是 AgentScope Java 的真实判断方式。

真实实现里是 isFinished(...)

它做的事情非常朴素。

private boolean isFinished(Msg msg) {
    if (msg == null) {
        return true;
    }
List<ToolUseBlock> toolCalls = msg.getContentBlocks(ToolUseBlock.class);
return toolCalls.isEmpty();
}

没有工具调用,就认为这一轮完成。

有工具调用,就继续进入 acting。

Lucas 检查助手消息里有没有 ToolUseBlock

这里有一个很工程化的细节。

源码注释里专门写了,即使工具不存在,也不要在这里直接结束。

因为只要模型返回了 ToolUseBlock,就应该进入 acting。

如果工具不存在,ToolExecutor 会返回「Tool not found」这样的错误结果,再让模型在下一轮看到这个错误。

这很符合 Agent 的运行方式。

程序不应该偷偷替模型做结论。

程序应该把运行事实写回上下文,让模型继续基于事实推理。

这也是 ReAct 和普通 Java 业务流程最不一样的地方。

传统 Java 代码遇到错误,通常是直接抛异常、返回错误码、或者走 fallback。

Agent 运行里,很多错误会变成上下文的一部分。

模型下一轮会读到它。

然后决定是换个工具、修正参数,还是直接告诉用户失败原因。

第四步,acting 只执行还没拿到结果的工具

如果 reasoning 产出的助手消息里有 ToolUseBlock,就会进入 acting(iter)

第一句是。

List<ToolUseBlock> pendingToolCalls = extractPendingToolCalls();

注意,是 pending。

不是把最近的所有工具调用都重新执行一遍。

extractPendingToolCalls() 会看最近的 assistant message 里的工具调用,再检查上下文里有没有对应 id 的 ToolResultBlock

已经有结果的,不再执行。

没结果的,才执行。

这解决的是恢复问题。

比如工具执行到一半中断了。

比如 HITL 权限确认让 Agent 暂停了。

比如用户下一次调用时手动补了部分工具结果。

框架都不能傻乎乎地把所有工具从头跑一遍。

它必须根据 tool call id 判断哪些工具还欠结果。

而这个 id 就在 ToolUseBlock 里。

ToolUseBlock 不是只有工具名。

它有。

字段

作用

id

标识这一次工具调用,用来和结果对齐

name

工具名称,用来从 Toolkit 里找工具

input

模型给出的参数

content

流式工具调用时的原始内容

metadata

模型厂商相关元数据

state

权限确认等场景里的工具调用状态

所以工具调用不是一句文本。

它是一张待执行工单。

Lucas 执行 pending 工单并把结果回填成工具消息

acting(...) 拿到这些工单后,会走 Toolkit.callTools(...)

再往下是 ToolExecutor.executeAll(...)

这里真实框架开始补齐极简 while 里没有的工程细节。

它会做工具查找。

工具不存在,返回错误结果。

它会做工具组激活检查。

工具不在当前可用组里,返回未授权工具调用。

它会做参数校验。

模型给错参数,不直接炸进程,而是生成错误结果。

它会处理外部工具。

如果工具是 SchemaOnlyTool 或者 @Tool(externalTool=true),框架不会本地执行,而是返回 TOOL_SUSPENDED

它还会处理顺序执行、并发执行、超时、重试、调度线程池。

所以极简版里的一句。

toolkit.callTool(toolCall)

真实实现里背后是一整套执行基础设施。

这就是框架和 Demo 的区别。

第五步,工具结果必须变成 Msg 写回上下文

工具执行完以后,不能只拿到一个 Java 返回值就结束。

AgentScope Java 会把工具结果包装成 ToolResultBlock

然后通过 ToolResultMessageBuilder 变成一条工具消息。

核心代码很简单。

ToolResultBlock resultWithIdAndName =
        result.withIdAndName(originalCall.getId(), originalCall.getName());
return Msg.builder()
.name(agentName)
.role(MsgRole.TOOL)
.content(resultWithIdAndName)
.build();

这个细节非常关键。

工具结果必须带回原始工具调用的 idname

否则下一轮模型只会看到一段孤立文本。

它不知道这个结果对应哪一次工具调用。

也不知道该把它接在哪个 tool call 后面。

在 OpenAI、DashScope、Ollama、Gemini 这些模型协议里,工具调用和工具结果都需要对齐。

所以 ToolResultBlock 的结构里也有。

字段

作用

id

对应原来的 tool call id

name

对应工具名

output

工具输出内容块

metadata

额外执行信息,比如 suspended 标记

state

工具结果状态,比如 success、error、denied、running

工具结果写回上下文后,acting(...) 会继续。

return executeIteration(iter + 1);

于是下一轮 reasoning 又开始了。

模型再次看到完整上下文。

这一次,上下文里多了工具返回结果。

模型就可以继续回答,或者继续调用别的工具。

这就是 ReAct。

所以真正的循环长什么样

如果把真实源码压回一个更接近 Java 后端开发者的版本,我会写成这样。

Msg call(List<Msg> input) {
    state.contextMutable().addAll(input);
int iter = 0;

while (iter; maxIters) {
    Msg assistantMsg = reasoning(
            prependSystemMsg(state.contextMutable(), systemMsg),
            toolkit.getToolSchemas(),
            buildGenerateOptions());

    state.contextMutable().add(assistantMsg);

    if (assistantMsg.getContentBlocks(ToolUseBlock.class).isEmpty()) {
        return assistantMsg;
    }

    List<ToolUseBlock> pendingToolCalls = extractPendingToolCalls();

    List<ToolResultBlock> results =
            toolkit.callTools(
                    pendingToolCalls,
                    toolExecutionConfig,
                    this,
                    buildMergedRuntimeContext())
                    .block();

    for (int i = 0; i < pendingToolCalls.size(); i++) {
        Msg toolMsg = ToolResultMessageBuilder.buildToolResultMsg(
                results.get(i),
                pendingToolCalls.get(i),
                getName());
        state.contextMutable().add(toolMsg);
    }

    iter++;
}

return summarizing();
}

还是那句话。

这不是源码。

但它是源码的心智模型。

你拿着这个模型再去看 ReActAgent,就不会被 MonoFlux、Hook、Middleware、Event 这些东西绕晕。

因为你知道它们都挂在同一个主干上。

reasoning
  ↓
如果没有工具调用,结束
  ↓
如果有工具调用,acting
  ↓
工具结果写回上下文
  ↓
下一轮 reasoning

为什么 AgentScope Java 不直接暴露这个 while

说到这里,可能会有一个很自然的问题。

既然核心就是这个循环,为什么框架不把它写得更直白一点。

像上面那样,一个 while 不就完了吗。

原因也很现实。

因为企业项目里真正麻烦的,往往不是这个循环本身。

而是循环旁边那些东西。

比如流式输出。

模型一边吐文本,一边吐 thinking,一边吐工具调用片段,框架要把这些 chunk 累积成最终 Msg

所以 ReActAgentReasoningContext

比如工具执行也可能是流式的。

工具一边执行,一边通过 ToolEmitter 往外吐 chunk,框架要发出 ToolResultTextDeltaEventToolResultDataDeltaEvent

比如用户可能要求中断。

AgentBase.call(...) 会通过 acquireExecution 保证同一个 Agent 实例不被并发调用,还会处理 interrupt。

比如工具调用可能需要人确认。

权限系统会把工具调用标成 ASKING,返回 GenerateReason.PERMISSION_ASKING,下一次调用再带确认结果继续。

比如工具可能不是本地执行。

外部工具会触发 TOOL_SUSPENDED,让调用方拿着 ToolUseBlock 去外部系统跑,再把 ToolResultBlock 补回来。

比如循环次数到了。

框架不是直接扔异常,而是进入 summarizing(),必要时还会给未完成工具补错误结果,再返回 GenerateReason.MAX_ITERATIONS

这些东西,极简 while 都没有。

Lucas 维护核心循环旁边的工程能力

但企业项目里迟早会遇到。

所以我觉得看 ReActAgent 最好的方式,不是上来就盯着全部源码细节。

而是先在脑子里放一个极简 while。

然后再把真实实现里的每个工程能力挂回去。

用 ToolCallingExample 对一下

仓库里的 ToolCallingExample 正好能对上这个循环。

它先创建一个 Toolkit

Toolkit toolkit = new Toolkit();
toolkit.registerTool(new SimpleTools());

SimpleTools 里有三个 @Tool 方法。

get_current_time

calculate

search

然后构建 ReActAgent

ReActAgent agent =
        ReActAgent.builder()
                .name("ToolAgent")
                .sysPrompt("You are a helpful assistant with access to tools. ...")
                .model(...)
                .toolkit(toolkit)
                .build();

用户问一句需要算数或查时间的问题时,大概会发生什么。

第一轮 reasoning,模型看到用户问题,也看到这三个工具的 schema。

如果它决定调用 calculate,回复里就会出现 ToolUseBlock

isFinished(...) 看到有 ToolUseBlock,不会返回最终答案。

它进入 acting。

acting 通过 Toolkit 找到 calculate 对应的 Java 方法,执行它。

执行结果被包装成 ToolResultBlock

ToolResultMessageBuilder 再把它包装成 MsgRole.TOOL 的消息,写回上下文。

下一轮 reasoning,模型看到「用户问题 + 自己刚才的工具调用 + 工具返回结果」。

这时候它才生成最终自然语言回答。

这条链路如果用传统 Java 方法调用来类比,大概像这样。

Controller 不是直接调 Service 得到最终结果。
Controller 把问题交给 Agent。
Agent 让模型决定要不要调 Service。
Service 的结果再回到 Agent 上下文。
模型读完结果后,再组织最终回答。

对 Java 后端来说,这个心智切换很重要。

工具不是代码主动调用的依赖。

工具是模型可选择的行动空间。

而 ReAct 循环,就是把这个行动空间变成可执行流程的运行时机制。

几个很容易踩的误区

第一个误区,是以为 ReActAgent 每次只调一次模型。

不是。

只要模型返回工具调用,框架就会执行工具,然后继续下一轮模型调用。

第二个误区,是以为工具结果由 Java 代码直接拼进最终回答。

也不是。

工具结果会作为 MsgRole.TOOL 消息进入上下文,让模型下一轮消费。

第三个误区,是以为完成条件是模型显式说「final answer」。

在 AgentScope Java 的 ReActAgent 里,更直接的判断是有没有 ToolUseBlock

没有工具调用,就结束。

有工具调用,就 acting。

第四个误区,是把 maxIters 当成模型参数。

它不是。

它是 Agent 循环层面的最大推理-行动轮数。

第五个误区,是还按 1.0 心智把上下文叫成 Memory

现在真实运行态已经是 AgentState.context

Memory 还在,但主要是兼容旧代码。

这几个点如果不拆清楚,后面看工具系统、权限、人机协同、Session 恢复都会很容易拧巴。

因为那些能力不是独立挂件。

它们全都插在这个循环里。

本文结论

这一篇我们没有急着讲更多工具细节。

只是把 ReActAgent 的核心运行模型压到了一个最小 Java 循环里。

你可以先记住这几句话。

结论

说明

ReActAgent 的核心是循环

reasoning -> acting -> reasoning

上下文是循环的中心

用户消息、助手消息、工具结果都会进入 AgentState.context

完成条件很朴素

当前助手消息没有 ToolUseBlock,就可以返回

工具结果必须回填

ToolResultBlock 会被包装成 MsgRole.TOOL 消息

工程复杂度在循环边上

流式、Hook、Middleware、权限、外部工具、恢复、总结都围绕这条循环展开

所以,ReAct 不神秘。

它不是一个黑盒魔法。

它更像一个被工程化包起来的 Java 循环。

只不过这个循环里,下一步不是由程序员提前写死。

而是由模型在每一轮运行时,根据当前上下文决定。

这就是 Agent 跟传统后端流程真正不一样的地方。

下一篇开始,我们就顺着这个循环往下拆最关键的一块。

Tool。

因为只有理解工具到底怎么暴露给模型、怎么被调用、怎么把结果回填,Java 开发者才算真正摸到 AgentScope Java 的工程价值。

继续阅读

基于全文检索与主题相似度