第4章 ReactLoopAgent:ReAct 循环引擎
整个系统的心脏——一个 769 行的类如何驱动"思考-行动-观察"
4.1 本章导读
前面两章建立的是"静态地图":模块怎么分、领域怎么划。本章我们进入"动态核心"——domain/agent/service/ReactLoopAgent.java,这是整个系统最重要、也最精巧的一个类。你将看到:ReAct 循环如何用 turn/step 两级结构实现、取消如何做到协作式不丢数据、max_tokens 截断如何自动续写,以及一个曾经让"工具结果永远送不到模型眼里"的真实 Bug 是怎么修掉的。
4.2 ReAct 模式回顾
ReAct(Reason + Act)是最经典的 Agent 范式:模型不只是生成文本,而是在"推理 → 调用工具 → 观察结果 → 再推理"的循环中逼近目标。
听起来简单,但工程化时有一堆硬问题:循环什么时候停?模型输出被 token 截断怎么办?用户中途取消怎么处理?多个消息同时进来怎么排队?这些正是 ReactLoopAgent 要解决的。
4.3 三级驱动结构:kick → turn → step
ReactLoopAgent 的驱动分为三层,从外到内是:kick(驱动器)→ turn(轮)→ step(步)。
对应源码的三个方法:
// ReactLoopAgent.java · 三级驱动(简化注释版)
private CompletableFuture<Void> kick(Phase.Running running) { // 246行:驱动器
return CompletableFuture.runAsync(() -> {
while (true) {
if (running.abort().get()) break; // 被取消则停
if (!inbox.hasPending()) break; // 没消息则停
if (!turn(running)) break; // 执行一轮
}
// finally 里:若期间又来了新消息,递归 kick 继续处理
}, Executors.newCachedThreadPool());
}
private boolean turn(Phase.Running running) { // 282行:一轮
session.append(SessionEventFactory.turnStart());
final long MAX_STEPS_PER_TURN = 50; // 安全上限
final int MAX_TOKEN_CONTINUATIONS = 4; // 续写上限
boolean midTurnContinuation = false; // 关键标志(见4.6)
while (true) {
// ... 检查步数上限、取消、抢占 inbox 消息
TurnEndReason stepResult = step(running, turn, step); // 执行一步
if (stepResult == null) { midTurnContinuation = true; continue; } // 工具续步
// ... 根据 Completed/MaxTokens/Blocked/Aborted/Error 决定 turn 结局
}
}
private TurnEndReason step(Phase.Running running, long turn, long step) { // 404行:一步
// 1. 组装系统提示词 2. 构建请求 3. 流式调用 LLM
// 4. BlockAssembler 组装响应 5. 有工具调用则执行并回填
}4.4 Phase 状态机与协作式取消
Agent 的运行状态用 sealed 类型 Phase 表达:Idle(空闲)/ Running(运行中,携带 abort 标志)/ Maintenance(维护,如压缩上下文)。
取消的设计非常关键——不能粗暴中断线程(那样可能写到一半的会话事件丢失),而是协作式:
// 取消:只设置标志位,不断线程
public synchronized void cancel(AgentCancelCause cause, boolean keepInbox) {
if (phase instanceof Phase.Running r) {
r.abort().set(true); // AtomicBoolean,运行中的循环自己检查发现
}
}
// step 内等待流式结束时的检查(485行)
while (!done.await(100, TimeUnit.MILLISECONDS)) {
if (running.abort().get()) { aborted.set(true); break; } // 100ms 轮询
}
// 流式订阅 onNext 里也检查(442行):一旦发现 abort 立即 subscription.cancel()LLM 流式调用可能在任何时刻收到取消。协作式取消保证:当前已收到的 chunk 全部写入 SessionLog、工具执行要么完整完成要么干净放弃、TurnEnd 事件一定落库。这样取消后的会话仍然是可回放的完整事件流,而不是一堆残片。
4.5 流式组装的细节:Flow + CountDownLatch + BlockAssembler
step 的核心是订阅 LLM 的流式响应。这里用了 JDK 9 的响应式流 Flow.Publisher(Domain 层因此不依赖任何 HTTP 客户端库):
StreamChunk 是 sealed 接口,有 TextDelta(文本增量)、Retry(重试提示,会转成人类可读的"模型请求失败,正在重试(1/3)"推给前端)等子类型。BlockAssembler 负责把零散的 chunk 组装成完整的 ContentBlock 列表(文本块 + 工具调用块)。
4.6 真实 Bug 复盘:midTurnContinuation 标志的诞生
这是本项目修复过的最有教学价值的一个 Bug,完美展示了"循环退出条件"设计的微妙之处。
让 Agent 执行"列出目录文件并介绍"——它确实调用了 shell_execute,工具也执行成功了,但对话就此中断,模型从不基于工具结果给出最终回答。工具结果仿佛石沉大海。
根因:turn 循环的每个 step 开头都要检查 inbox 是否有新消息(inbox.claim(...)),如果 inbox 为空就 break 退出 turn。这个逻辑对"第一步"是对的(没有用户消息就不该开始),但工具调用完成后的后续 step 也走了同样的检查——此时 inbox 本来就是空的(用户没发新消息),于是循环在工具结果回填后直接退出,模型永远没机会看到工具结果。
// 修复:引入 midTurnContinuation 标志
boolean midTurnContinuation = false;
while (true) {
if (!midTurnContinuation) {
claimed = inbox.claim(InboxTarget.NEXT_TURN, (int) turn);
if (claimed.isEmpty() && !inbox.hasPending() && !continuationRequested) break;
// ...
}
TurnEndReason stepResult = step(running, turn, step);
if (stepResult == null) { // null = 工具调用已完成,模型应继续推理
midTurnContinuation = true; // 后续 step 跳过 inbox 检查
continue;
}
}修复的本质:区分"turn 的第一步"和"turn 内的续步"。第一步必须有用户消息才启动;续步(工具调用后)不需要用户消息,直接继续让模型推理。
4.7 max_tokens 截断的受控续写
模型输出有 token 上限,超长回复会被截断(finish_reason = max_tokens)。ReactLoopAgent 的处理是自动续写,但有上限:
final int MAX_TOKEN_CONTINUATIONS = 4;
// ...
} else if (stepResult instanceof TurnEndReason.MaxTokens) {
if (++tokenContinuations > MAX_TOKEN_CONTINUATIONS) {
session.append(SessionEventFactory.turnEnd(new TurnEndReason.MaxTokens()));
return true; // 续写 4 次还不够,放弃,turn 结束
}
continuationRequested = true; // 标记:下一步是续写,不要从 inbox 取新消息
continue;
}注意 continuationRequested 与 midTurnContinuation 是两个不同的标志:前者处理"模型话没说完",后者处理"模型要基于工具结果继续"。两个都会阻止循环从 inbox 抢占新消息,但触发原因和计数方式不同。
4.8 与外部世界的接口
ReactLoopAgent 通过构造器注入的 7 个依赖与外部协作,全部是 Domain 抽象:
| 依赖 | 类型 | 职责 |
|---|---|---|
session | SessionLog | 事件追加(TurnStart/StepStart/UserMessage/AssistantChunk/ToolCall...) |
inbox | Inbox | 消息输入箱,支持 NEXT_TURN / nextStep 两种目标 |
llm | ILlmRuntimePort | 流式生成端口,实现由 Infrastructure 提供 |
toolRegistry / toolExecutor | 工具域 | 工具查找与统一执行(Hook + 守卫) |
systemPromptAssembler | SystemPromptAssembler | 组装系统提示词(含工具 schema) |
events | AgentEventListener | 生命周期事件回调 |
另有两个按次设置的"流式接收器":streamDeltaSink(文本增量,推给 SSE chunk)和 streamToolCallSink(工具调用,推给 SSE step_break)——它们让同一个 Agent 引擎既能服务 SSE 流式请求,也能服务阻塞式请求,还能被工作流复用。
4.9 课程补充:Inbox 双队列与请求构建细节
本章主链路(kick → turn → step)只展现了 Agent 引擎的"骨架"。下面四个细节决定了它在真实工作负载下能否不出错、不丢消息、不浪费 token——它们都在源码里,但主章节没展开,是课程里覆盖不全的部分。
4.9.1 Inbox 的双队列语义
Inbox(📦 domain/agent/service/Inbox.java)持有两个有序列表,分别对应不同的"注入时机":
| 列表 | 目标枚举 | 语义 | 典型场景 |
|---|---|---|---|
nextTurn | InboxTarget.NEXT_TURN | 等一个完整 turn 的消息 | 用户输入、followup 消息 |
nextStep | InboxTarget.NEXT_STEP | 注入到下一步但不唤醒 | 计划模式提示、上下文注入 |
关键操作 claim(target, turn) 的工作方式:
// Inbox.claim()(简化) public Listclaim(InboxTarget target, int turn) { List claimed = new ArrayList<>(mutate(NEXT_STEP, 0, nextStep.size(), List.of(), false)); if (target == InboxTarget.NEXT_TURN) { claimed.addAll(mutate(NEXT_TURN, 0, 1, List.of(), false)); // 抢占 1 条 } for (Message m : claimed) notifications.claimed(m, turn); // 发布 claimed 通知 return claimed; }
注意几个微妙之处:
- nextStep 每次都被清空(只要消费就全清);nextTurn 每次只取 1 条。
- claim 不写 insert 事件,只写"删除"事件——这是因为用户消息早已 append 时落地过
AgentInboxSpliced事件,再写一遍就是冗余。 - uniqueness 校验:跨两个列表的全集做
HashSet去重,重复 id 直接抛IllegalStateException——防止同一消息被排到两次。
4.9.2 "持久化先于状态变更"的因果序
Inbox 每次变更(append/prepend/replace/clear)的内部流程是先写事件,再改内存:
// Inbox.mutate()(简化) public Listsplice(target, start, deleteCount, inserted) { var event = SessionEventFactory.inboxSpliced(...); // 1. 构造事件 session.append(event); // 2. 先追加到 WAL // 3. 再改内存列表 inbox.subList(start, start + actualDeleteCount).clear(); for (int i = inserted.size() - 1; i >= 0; i--) inbox.add(start, inserted.get(i)); // 4. 发通知 if (discardRemoved) removed.forEach(notifications::discarded); inserted.forEach(notifications::inserted); }
这种顺序保证了一件关键事:任何同步观察 session 事件的订阅者,看到的都是"尚未变更"的旧状态,可以凭 InboxSplicedPayload 的归一化坐标自己重建被移除的消息。如果反过来(先改内存再写事件),订阅者会看到一个"事件还没到但状态已经变了"的中间态。
"持久化先于状态变更"看似多此一举,但它让"重建"和"重放"在因果上完全等价——任何时刻只要按 seq 重放所有事件,结果与实时运行一致。这正是第5章"事件溯源"在 Inbox 上的具体落地。
4.9.3 buildRequest:上下文截断公式
step() 每次发请求前都要从 session.deriveMessages() 重建历史,再按 token 预算裁剪。完整逻辑(ReactLoopAgent.buildRequest(),第 611 行起):
private static final int CONTEXT_WINDOW_FALLBACK = 128_000; // 对应 yml context-window
private GenerateOptions buildRequest(PromptAssembly assembly, long turn, long step) {
List history = session.deriveMessages();
if (continuationRequested) { // 续写场景:注入"接着写"指令
history.add(Message.createUser(
List.of(new TextBlock(CONTINUATION_PROMPT)),
new PluginSource("react-loop")));
}
int maxContext = CONTEXT_WINDOW_FALLBACK;
int systemTokens = estimateTokens(assembly.system());
int budget = maxContext - systemTokens - options.maxTokens() - 1024;
if (budget < 4096) budget = 4096; // 保底
history = truncateToBudget(history, budget);
return new GenerateOptions(...);
} 预算公式:
budget = context_window(128k)
- system_tokens // 系统提示词占的 token
- max_tokens // 本次回复的预留 token
- 1024 // 安全余量
(最低 4096)truncateToBudget() 从历史尾部向前贪心装回,保留最近消息的完整语义;如果发现裁掉太多(少于 6 条),改为"只保留最近 6 条"——绝对不丢到 0。
token 估算很粗:(int) Math.ceil(text.length() / 4.0),对中文实际是 1.5~2 token/字,对代码标识符约 0.25 token/字符,所以预算偏保守(多裁一些)。在 MEMORY.md 的"已知隐患"里也标记了这一点。
4.9.4 temperature 可配置:踩过的 400 坑
代码里有一段很不起眼的常量:
// 读系统属性 / 环境变量;null = 不发送 temperature
private static final Double SAMPLING_TEMPERATURE = parseTemperature(
System.getProperty("harness.agent.temperature",
System.getenv("HARNESS_AGENT_TEMPERATURE")));
private static Double parseTemperature(String raw) {
if (raw == null || raw.isBlank()) return null; // 关键:null → 不发送
try { return Double.parseDouble(raw.trim()); }
catch (NumberFormatException e) { return null; }
}这背后有个真实踩过的坑:本项目默认模型 gpt-5.5 走的上游网关只接受 temperature=1,发任何其他值(如代码里曾经硬编码的 0.3)都会被网关 400 拒掉。于是代码改成"可配置 + 默认不发"——上游网关自己会用默认值(一般是 1),既兼容只接受 1 的网关,也兼容接受自定义温度的模型。
配置方式有两种(顺序优先级):
# 方式 1:环境变量 export HARNESS_AGENT_TEMPERATURE=1 # 方式 2:JVM 启动参数 java -Dharness.agent.temperature=1 -jar app.jar # 不设 = 不发送 temperature 字段,让上游自己决定
"可配置 + 默认留空"是一个常被忽视的工程原则:上游规则会变,今天只接受 1、明天可能支持 0~2;与其把参数写死导致每次模型变更都要改代码+重打包,不如让出配置权,让上游规则自己消化。temperature、context-window、request-timeout 都是这个思路。
4.10 课程补充:意图识别与工具选择纠偏
ReAct 循环的上游是意图链(第6章展开策略树细节),本节讲两个 case 层节点的工程细节——它们直接决定"模型第一步调什么工具",也各自埋着一个本项目真实修过的 Bug。
4.10.1 AgentIntentNode:纯正则意图分类
AgentIntentNode.classify()(📦 cases/agent/node/AgentIntentNode.java)是纯正则规则,没有 LLM 参与,按优先级短路返回四类意图:
| 优先级 | 条件 | 意图 | 下游效应 |
|---|---|---|---|
| 0 | 空消息 | CLARIFICATION | 向用户发起澄清 |
| 1 | 问候语全文匹配(^(你好|hi|thanks...)$) | CHAT | 直接闲聊回复 |
| 2 | 文件路径 / 目录路径 / 代码围栏 / (代码关键词 AND 任务动词) | TASK_EXECUTION | 注入"先调工具"指令 |
| 3 | 闲聊短语(你是谁/谢谢/bye...) | CHAT | 闲聊回复 |
| 4 | 代码关键词(bug/api/线程/正则...) | CODE_QUESTION | 讲解优先 |
| 5 | 解释词 AND 任务动词 | CODE_QUESTION | 讲解优先 |
| 6 | 任务动词(帮我/请/执行/修复/构建...) | TASK_EXECUTION | 注入"先调工具"指令 |
| 7 | 文件系统关键词 AND 问号结尾 | TASK_EXECUTION | 注入"先调工具"指令 |
| 兜底 | 以上都不命中 | CHAT | 闲聊回复 |
规则顺序就是语义优先级:先收窄"必然是任务"的强信号(路径/代码围栏),再排除闲聊,再按弱信号组合判断。TASK_EXECUTION 消息会被 AgentDispatchNode.prepareMessageText() 改写,注入"请先使用可用工具完成下面的任务,不要在未使用工具前直接给出结论"——防止模型跳过工具直接"想象"答案。
4.10.2 真实 Bug 复盘:列目录场景的工具选择纠偏
AgentDispatchNode.buildDirectoryInstruction()(📦 cases/agent/node/AgentDispatchNode.java:115)对"目录里有什么"类问题注入固定指令。这个函数曾经硬编码"必须调用 fs_search"——但 fs_search 是 glob 文件搜索(pattern="*" 会递归匹配所有文件),用来"列一级目录"会产生几千行的递归清单,模型拿到的是噪声不是答案。
"search"这个名字让人以为它是目录列表器,实际是递归 glob。修复方式:指令改为推荐 shell_execute 执行 ls -la <dir> 或 find <dir> -maxdepth 1 -type d——语义精确(-maxdepth 1 限定一级),且系统提示词同步强化。给模型注入工具建议时,工具语义必须与任务形状严格对齐,名字相似性不算数。
// AgentDispatchNode.buildDirectoryInstruction()(当前版本,:115 起)
private static String buildDirectoryInstruction(String messageText) {
if (messageText == null || !DIRECTORY_QUESTION.matcher(messageText).find()) return null;
Matcher matcher = ABSOLUTE_PATH.matcher(messageText);
if (!matcher.find()) return null;
String directory = matcher.group(1);
return "你需要列出这个目录的一级子目录和文件。推荐用 `shell_execute` 执行 `ls -la \""
+ directory + "\"` 获取完整列表,\n"
+ "或者用 `find \"" + directory + "\" -maxdepth 1 -type d` 只列目录。\n"
+ "拿到真实结果后,用 Markdown 列表展示所有一级项目,不要遗漏、不要模糊化。\n\n"
+ "用户原始请求:" + messageText;
}指令里有两个工程细节值得注意:ABSOLUTE_PATH 正则在空白和常见中英文标点处截断(避免把后续说明文字吞进路径);指令尾部附用户原始请求——注入指令替换了原文,不带回原文模型就丢失了用户的完整语境。
4.11 小结与下一章预告
本章要点:三级驱动 kick→turn→step;单 turn ≤ 50 步、续写 ≤ 4 次是安全护栏;取消用 AtomicBoolean + 100ms 轮询实现协作式中断保证事件完整;midTurnContinuation 标志修复了"工具结果送不到模型"的经典 Bug;Flow.Publisher 让 Domain 不依赖 HTTP 客户端。
下一章:本章反复出现的 session.append(...) 到底做了什么?为什么说"事件日志就是真相之源"?取消后如何回放完整会话?第5章讲会话与事件溯源。