一个 ReActAgent 到底做了什么?从 call() 开始拆 AgentScope Java

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

上篇我们刚把一个最容易拧巴的点掰开。

AgentScope Java 不是模型 SDK 包装层。

它更像一个 Agent Runtime。

那接下来最自然的问题就是。

这个 Runtime,到底是怎么跑起来的?

很多人第一次用 AgentScope Java,入口都差不多。

ReActAgent agent =
        ReActAgent.builder()
                .name("Assistant")
                .sysPrompt("You are a helpful AI assistant. Be friendly and concise.")
                .model(
                        DashScopeChatModel.builder()
                                .apiKey(apiKey)
                                .modelName("qwen-plus")
                                .stream(true)
                                .enableThinking(true)
                                .formatter(new DashScopeChatFormatter())
                                .defaultOptions(
                                        GenerateOptions.builder()
                                                .thinkingBudget(1024)
                                                .build())
                                .build())
                .memory(new InMemoryMemory())
                .toolkit(new Toolkit())
                .build();

这段不是我编的。

它基本就是 agentscope-examples/documentation/quickstart 里的 BasicChatExample

然后业务侧真正看到的,通常就是这一句。

Msg response = agent.call(userMsg).block();

表面上很像一次普通方法调用。

但如果你真的去翻实现,会发现这句代码背后不是一跳。

而是一整条执行链。

CallableAgent.call(Msg)
  ↓
AgentBase.call(List<Msg>)
  ↓
ReActAgent.doCall(List<Msg>)
  ↓
executeIteration(0)
  ↓
reasoning(...)
  ↓
acting(...)
  ↓
下一轮 reasoning(...) 或直接结束

这篇我们就顺着这条链,一步一步拆。

先跑一个最小 ReActAgent

先说一个很容易被忽略的事实。

哪怕是最简单的 BasicChatExample,这个 Agent 也已经不是一次裸模型调用了。

因为它至少同时带了四个东西。

组件

在最小示例里的实际配置

作用

Model

DashScopeChatModel

负责真正的大模型推理

Memory

new InMemoryMemory()

保存对话上下文

Toolkit

new Toolkit()

提供工具 schema 和工具执行入口

sysPrompt

builder 里传入的系统提示词

作为每轮推理前的系统消息

注意这里的 Toolkit

即便你现在还没注册任何工具,这个组件也已经在场了。

这意味着 ReActAgent 的最小运行骨架,一开始就是按 ReAct 运行时来搭的,不是后面需要工具时再外挂一个扩展点。

也就是说,最简单的聊天例子,和后面复杂的工具调用例子,底层走的是同一套骨架。

差别主要在于,这次调用最后会不会真的进入 acting 阶段。

01-minimal-skeleton.png

call() 不是普通方法调用

先看第一跳。

agent.call(userMsg) 这个姿势,真正落到的是 CallableAgent 里的默认方法。

CallableAgent.call(Msg) 做的事很朴素,它会把单条消息包成 List<Msg>,再交给核心接口。

default Mono<Msg> call(Msg msg) {
    return call(msg == null ? List.of() : List.of(msg));
}

这一步看起来没什么。

但从这儿开始,AgentScope Java 已经明确了一个态度。

Agent 的核心输入,不是一个字符串,也不是一段 prompt。

而是一组 Msg

这也是为什么后面工具调用、工具结果、系统消息、结构化输出,最终都能收敛到同一条执行链里。

第二跳才真正开始有 Runtime 的味道。

CallableAgent.call(List<Msg>) 的实现不在接口里,而是在 AgentBase

AgentBase.call(List<Msg>) 的主干可以压成下面这个样子。

return Mono.using(
        this::acquireExecution,
        resource -> {
            beforeAgentExecution(msgs);
            return TracerRegistry.get()
                    .callAgent(
                            this,
                            msgs,
                            () ->
                                    notifyPreCall(msgs)
                                            .flatMap(this::doCall)
                                            .flatMap(this::notifyPostCall)
                                            .onErrorResume(createErrorHandler(msgs.toArray(new Msg[0]))));
        },
        this::releaseExecution,
        true);

这段代码里有几个点特别关键。

第一,它不是直接 doCall(msgs)

它先 acquireExecution,结束后 releaseExecution

也就是说,每次调用都先进入一个执行占用区间。

这和普通 Service 方法直接开始执行业务逻辑,不是一个事情。

第二,它在真正调用子类逻辑前后,插了完整的生命周期。

beforeAgentExecution
  ↓
PreCallEvent
  ↓
doCall
  ↓
PostCallEvent
  ↓
ErrorHandler / releaseExecution

第三,它把 tracing 也包进去了。

所以从 AgentBase 开始,一次 agent.call() 就已经不只是一次函数调用,而是一次带执行边界、Hook、错误恢复和可观测性的 Agent 运行。

还有一个经常被忽视的源码细节。

AgentBase 的类注释写得很直接,同一个 Agent 实例不是为并发执行设计的,不应该被多个线程同时 call()stream()

这句话不是提醒你小心一点。

它其实是在告诉你,Agent 运行天然带状态。

后面你看到 Memory、当前系统消息、工具结果回填,就能理解这件事为什么不是文档洁癖。

02-call-wrapper.png

第一次模型推理发生在哪里

真正进入 ReAct 逻辑,是 ReActAgent.doCall(List<Msg>)

它的开头先判断一个很实用的问题。

当前 Memory 里,有没有还没完成的工具调用。

protected Mono<Msg> doCall(List<Msg> msgs) {
    Set<String> pendingIds = getPendingToolUseIds();
if (pendingIds.isEmpty()) {
    addToMemory(msgs);
    return executeIteration(0);
}

if (msgs == null || msgs.isEmpty()) {
return acting(0);
}

// 有 pending tool 且用户传回了 ToolResultBlock,走恢复逻辑
}

正常第一次调用,通常都是 pendingIds.isEmpty()

所以真实路径是。

用户输入
↓
addToMemory(msgs)
↓
executeIteration(0)

这个顺序很重要。

用户输入不是直接送模型。

它先写进 Memory

然后 Agent 再从 Memory 里拿上下文,进入第 0 轮迭代。

所以即便你只问一句话,Runtime 看到的也不是单次 request。

它看到的是当前这次运行之前和之后的状态衔接。

接下来 executeIteration(0) 会进入 reasoning(0, false)

真正的第一次模型推理,就发生在这里。

reasoning(...) 的骨架大概是这样的。

return checkInterruptedAsync()
.then(notifyPreReasoningEvent(memory.getMessages()))
.flatMapMany(event -> {
GenerateOptions options = ...;
List<Msg> modelInput =
prependSystemMsg(event.getInputMessages(), event.getSystemMessage());
return model.stream(modelInput, toolkit.getToolSchemas(), options)
.concatMap(chunk -> checkInterruptedAsync().thenReturn(chunk));
})
.doOnNext(chunk -> context.processChunk(chunk))
.then(Mono.defer(() -> Mono.justOrEmpty(context.buildFinalMessage())))
.flatMap(this::notifyPostReasoning)
.flatMap(event -> { ... });

这段比很多介绍文章都更接近真相。

先说第一件事。

notifyPreReasoningEvent(...) 吃进去的不是刚刚那条原始用户输入,而是 memory.getMessages()

也就是说,推理阶段拿到的是整个当前上下文。

再说第二件事。

系统消息不是一开始就常驻写进 Memory。

ReActAgent 里有个 seedSystemMsg(),它会把 builder 里传入的 sysPrompt 构造成一条 MsgRole.SYSTEM 的消息。

但这条系统消息真正塞进模型输入,是在每次 model.stream(...) 之前,通过 prependSystemMsg(...) 临时拼进去的。

这个实现非常像工程师会做的事。

既要保证每轮模型推理都能拿到系统提示。

又不想把系统消息污染进对话 Memory。

第三件事更关键。

模型调用不是返回一个完整字符串。

Model.stream(...) 返回的是 Flux<ChatResponse>

ReActAgent 这边会用 ReasoningContext.processChunk(chunk) 一块一块地累计。

所以 Runtime 看到的是流式推理过程,不只是最后答案。

这也是为什么 HookExample 里可以监听 ReasoningChunkEvent,把推理阶段流出来的内容一边接一边打印。

03-first-reasoning.png

如果你想真的看见这条链,不要自己手写日志。

直接看 HookExample 反而最实在。

它监控的事件顺序基本就是这样。

PreCallEvent
↓
ReasoningChunkEvent ...
↓
PreActingEvent(如果模型决定调用工具)
↓
ActingChunkEvent ...
↓
PostActingEvent
↓
PostCallEvent

这也是为什么我一直觉得,AgentScope Java 的 call() 更像一次可观测的状态机运行,而不是一个同步拿结果的方法。

工具调用为什么会进入第二轮推理

这块是第 2 篇最应该讲透的地方。

很多人知道 Agent 会调工具。

但不知道工具结果为什么还要再回模型一轮。

源码里的答案其实很直接。

reasoning(...) 结束之后,ReActAgent 会检查这轮推理产出的消息是不是已经可以结束。

判断逻辑就在 isFinished(msg)

private boolean isFinished(Msg msg) {
if (msg == null) {
return true;
}

List&lt;ToolUseBlock&gt; toolCalls = msg.getContentBlocks(ToolUseBlock.class);
return toolCalls.isEmpty();
}

也就是说,判断标准很朴素。

如果这条 assistant 消息里没有 ToolUseBlock,那就结束。

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

哪怕这个工具名其实不存在,也不会在这里提前结束。

它会继续进入 acting,让后面的工具执行器把错误结果组织出来,再交回模型看。

这点特别有工程味。

因为 Runtime 不会替你抢答。

它会尽量把错误也组织成 Agent 能继续消费的上下文。

进入 acting 之后,事情会变成这样。

第 0 轮 reasoning
↓
assistant 消息里出现 ToolUseBlock
↓
acting(0)
↓
Toolkit 执行工具
↓
生成 ToolResult 消息并写回 Memory
↓
executeIteration(1)
↓
第 1 轮 reasoning
↓
模型基于工具结果给出最终回答

这就是为什么工具调用不是一个分叉动作。

它其实是 ReAct 循环里的中间步骤。

再看 acting(int iter) 的主干,你会更清楚。

private Mono<Msg> acting(int iter) {
List<ToolUseBlock> pendingToolCalls = extractPendingToolCalls();
if (pendingToolCalls.isEmpty()) {
return executeIteration(iter + 1);
}

toolkit.setInternalChunkCallback(
(toolUse, chunk) -&gt; notifyActingChunk(toolUse, chunk).subscribe());

return notifyPreActingHooks(pendingToolCalls)
.flatMap(this::executeToolCalls)
.flatMap(results -&gt; {
// 成功结果写回 memory
// pending/suspended 结果返回给用户
// 正常情况继续 executeIteration(iter + 1)
});

}

这里最值得盯住的是三件事。

第一,acting 执行的是 pending tool calls,不是盲目重放所有历史工具。

第二,工具执行也有自己的 chunk 回调,所以 HookExample 才能收到 ActingChunkEvent

第三,工具结果不是简单返回给业务侧。

正常成功结果会被包装成 Tool Result Message,再写回 Memory。

源码里 notifyPostActingHook(...) 最后会 memory.addMessage(e.getToolResultMsg())

这意味着,第 1 轮 reasoning 看到的输入,不只是用户原问题。

而是。

用户问题

  • 第 0 轮 assistant 的工具调用意图

  • 第 0 轮工具执行结果

直到这时候,模型才真正具备了回答业务问题的上下文。

所以你可以把 ReActAgent 理解成一个会自己补全中间步骤的对话循环。

不是模型想完一次就结束。

而是模型先决定要不要做事,做完了再回来继续想。

04-tool-second-round.png

ReActAgent 的核心不是回答,而是循环

如果只看外层 API,ReActAgent 给人的感觉很像一个带记忆的聊天对象。

但你把源码链路走完,会发现它真正的核心其实是这句话。

reasoning -> acting -> reasoning -> acting -> ... -> finish

甚至连异常和边界情况,也都是按这个循环来处理的。

比如工具执行被挂起。

ToolResultBlock 里明确支持 suspended 状态。

当工具抛出 ToolSuspendException 时,框架会把它转成 suspended result。

这时候 ReActAgent 不会假装任务完成,而是返回一条 GenerateReason.TOOL_SUSPENDED 的消息,让调用方接手外部执行。

再比如最大迭代次数到了。

reasoning(...) 一开始就会检查 iter >= maxIters,一旦触顶,就进入 summarizing()

如果还有没完成的工具调用,它还会先补一批错误 ToolResult,再让模型总结当前局面,最后把返回消息标成 GenerateReason.MAX_ITERATIONS

这说明 maxIters 不是简单的循环保护。

它其实也是 Runtime 的退出策略。

还有一个很实用的点。

最终返回给业务方的,不只是文本。

Msg 本身就带 getGenerateReason()

默认是 MODEL_STOP,但也可能是这些值。

GenerateReason

说明

MODEL_STOP

正常完成

TOOL_SUSPENDED

有外部工具等待你接手

REASONING_STOP_REQUESTED

推理阶段被 Hook 停下

ACTING_STOP_REQUESTED

工具阶段被 Hook 停下

MAX_ITERATIONS

达到最大迭代次数后结束

INTERRUPTED

运行被中断

05-loop-and-exits.png

这也是为什么业务代码不能只写。

System.out.println(response.getTextContent());

这当然能打印答案。

但它拿不到这次 Agent 运行到底是正常结束,还是卡在外部工具,还是被 HITL 拦下来了。

而这些信息,在真正的企业项目里往往比一段文本答案更重要。

把整条链压成一句话

如果你想把这一篇文章的核心收成一句话,我觉得最贴近源码的一句是。

ReActAgent.call() 不是从用户问题直接到模型答案,而是把消息写进 Memory,再通过 reasoning -> acting -> reasoning 的迭代循环,驱动模型、工具和状态一起完成一次 Agent 运行。

你也可以把它记成下面这个表。

阶段

关键方法

实际发生的事

入口收口

CallableAgent.call(Msg)

单条消息包装成 List<Msg>

执行包裹

AgentBase.call(List<Msg>)

获取执行权、触发 PreCallEvent、调用 doCall、触发 PostCallEvent

写入上下文

ReActAgent.doCall(...)

处理 pending tool,正常情况下先把用户消息写进 Memory

第一次推理

reasoning(0, false)

触发 PreReasoningEvent,拼系统消息,调用 model.stream(...)

判断是否结束

isFinished(msg)

没有 ToolUseBlock 就结束,有就进入 acting

工具执行

acting(iter)

Toolkit.callTools(...) 执行工具,结果经过 Hook 后写回 Memory

下一轮推理

executeIteration(iter + 1)

模型基于工具结果继续推理

边界退出

summarizing() / suspended / interrupt

处理 MAX_ITERATIONSTOOL_SUSPENDED、中断等状态

所以从第 2 篇开始,你应该已经能把 ReActAgent 和普通聊天 SDK 分开看了。

普通聊天更像。

request -> model -> response

ReActAgent 更像。

message -> memory -> reasoning -> tool -> memory -> reasoning -> final msg

它给你的不是一次回答。

而是一套最小可运行闭环。

也正因为如此,后面我们继续往下看 MsgToolUseBlockToolResultBlock,才会真正有抓手。

因为如果你不先理解这条运行链,后面看到消息模型,大概率还是会把它当成一堆为了兼容模型厂商而多出来的对象。

其实不是。

那些结构,都是为了喂这条循环。

下一篇我们就接着拆这个点。

为什么在 AgentScope Java 里,Msg 不是一个 String。