🧠 第三篇:核心机制

第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()
💡 为什么用 100ms 轮询而不是中断

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,完美展示了"循环退出条件"设计的微妙之处。

🐛 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;
}

注意 continuationRequestedmidTurnContinuation 是两个不同的标志:前者处理"模型话没说完",后者处理"模型要基于工具结果继续"。两个都会阻止循环从 inbox 抢占新消息,但触发原因和计数方式不同。

4.8 与外部世界的接口

ReactLoopAgent 通过构造器注入的 7 个依赖与外部协作,全部是 Domain 抽象:

依赖类型职责
sessionSessionLog事件追加(TurnStart/StepStart/UserMessage/AssistantChunk/ToolCall...)
inboxInbox消息输入箱,支持 NEXT_TURN / nextStep 两种目标
llmILlmRuntimePort流式生成端口,实现由 Infrastructure 提供
toolRegistry / toolExecutor工具域工具查找与统一执行(Hook + 守卫)
systemPromptAssemblerSystemPromptAssembler组装系统提示词(含工具 schema)
eventsAgentEventListener生命周期事件回调

另有两个按次设置的"流式接收器":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)持有两个有序列表,分别对应不同的"注入时机":

列表目标枚举语义典型场景
nextTurnInboxTarget.NEXT_TURN等一个完整 turn 的消息用户输入、followup 消息
nextStepInboxTarget.NEXT_STEP注入到下一步但不唤醒计划模式提示、上下文注入

关键操作 claim(target, turn) 的工作方式:

// Inbox.claim()(简化)
public List claim(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 List splice(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 的归一化坐标自己重建被移除的消息。如果反过来(先改内存再写事件),订阅者会看到一个"事件还没到但状态已经变了"的中间态。

💡 这是 Event Sourcing 的关键纪律

"持久化先于状态变更"看似多此一举,但它让"重建"和"重放"在因果上完全等价——任何时刻只要按 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章讲会话与事件溯源。