夜雨聆风学习资料网

ARTICLE · 985266

OpenClaw 事件驱动与 ReAct 闭环:一条消息的完整数据流追踪

OpenClaw 事件驱动与 ReAct 闭环:一条消息的完整数据流追踪

OpenClaw 的 agent 运行时不是"一次推理",而是"两层循环":外层是 attempt 重试环,内层才是真正的 ReAct 工具环。本文从一条用户消息出发,追踪它从渠道接入、会话串行、工具闭环到结果返回落盘的完整数据流。


一、引子:把"一条消息的一生"追到底

前面两篇我们建立了 OpenClaw 的整体认知:单进程三合一(消息面/控制面/agent 执行核心)加上五层边界划分。但"一个进程到底装了什么"是静态的骨架,真正让系统活起来的,是一条消息从进来到出去所走过的路径。

本篇是顶层架构第 03 篇,也是这个系列最关键的一篇:我会沿着一条用户消息,逐段追踪它如何——

  1. 被渠道接入并归一化成统一 turn 输入;
  2. 经会话串行 + 全局限流排队;
  3. 进入 ReAct 工具闭环反复"推理 → 调工具 → 回填 → 再推理";
  4. 最终以事件驱动的方式被塑形成回复、投递回渠道、落盘存档。

重点:读完本文,你会理解 OpenClaw agent 运行时的"两层级循环"模型——这是理解整个系统如何"活"起来的那把钥匙。


二、接入段:turn 内核,一条标准化的同步流水线

1. 真正的入口不是"消息到达",而是 turn 内核

很多人以为渠道插件收到消息就直接进 agent 了,其实不然。OpenClaw 把所有渠道的原始事件先归一化成统一的 turn 输入,交给一个叫 turn 内核的东西去跑标准管线。

以 Telegram 为例,插件收到消息后调用:

//extensions/telegram/src/bot-message-dispatch.ts:2201runChannelInboundEvent({ 
channel: "telegram", adapter: { ingest, resolveTurn, ... } })

这个 runChannelInboundEvent 在 SDK 层(src/plugin-sdk/channel-inbound.ts:162)只是一个透传,真正的实现在 turn 内核:

// src/channels/turn/kernel.ts:849export const runChannelInboundEvent = runChannelTurn;   // :678 定义

runChannelTurn 是一条同步流水线:ingest → classify → preflight → resolveTurn → dispatch → finalize。也就是说,无论消息来自 Telegram、微信还是飞书,接入后都走同一条标准化路径——这正是"渠道可插拔"的根基:渠道只负责把平台事件翻译成 turn 输入,剩下的交给内核。

2. 派发链:从消息到"该交给哪个 agent"

turn 内核 dispatch 阶段会做两件事:记录入站会话 + 解析该交给哪个 agent。这条派发链层层封装,值得顺着读一遍:

dispatch.ts:587 dispatchInboundMessageWithBufferedDispatcher  → :528 dispatchInboundMessage  → dispatch-from-config.ts:1215 dispatchReplyFromConfig  → get-reply.ts:240 getReplyFromConfig  → agent-scope.ts:323 resolveSessionAgentId   ← 最终确定 agent

同时会话解析把 (channel/account/thread/from/to) 归一成 session-key 并记录入站会话(conversation-resolution.ts:420 + session.ts:31 recordInboundSession)。这一步的意义是归属:这条消息属于哪个会话、哪个 agent,在这里定死。

3. agent.run:只"受理",不"执行"

定位到 agent 后,接下来调用 agent.run。这里有一个极易误解的设计:agent.run 不是去执行 agent,而是受理——它校验、去重、然后立刻返回一个"已受理"的凭证:

// src/gateway/server-methods/agent.ts:3178-3187{  runId: ...,  sessionKey: ...,  status: "accepted" as const,  acceptedAt: Date.now(),}

源码注释把意图说得很直白:

"Give the accepted frame one event-loop turn to flush before the runner starts potentially heavy synchronous prompt/context setup... so immediate agent.wait calls can reach the gateway before the pre-turn runner monopolizes the loop." — agent.ts:3202-3205 "Store an in-flight ack so retries do not spawn a second run." — agent.ts:3195

也就是说,受理与执行分离,是刻意为之:先让"已受理"这个信号尽快返回,真正的重活在请求处理器之外异步排队。同时用 in-flight ack 做幂等防重跑——重试不会拉起第二个 run。

4. 双 lane:同会话串行 + 跨会话限流

受理之后,任务被排进两条 lane:

  • session lane
    (同会话串行):resolveSessionLane 把 session-key 变成 session:xxx(lanes.ts:6),保证同一个会话的消息严格按序处理,不会乱序;
  • global lane
    (跨会话限流):resolveGlobalLane(lanes.ts:11)控制全局并发水位,并兜底"会话失败挂起"的场景(run.ts:675)。

lane 的解析在 runEmbeddedAgent 里完成(run.ts:669-670),真正入队走 enqueueCommandInLane(command-queue.ts:475,run.ts:778/:794)。

重点:agent.run 受理/执行分离 + 双 lane 串行化,是"事件驱动"的骨架。由此换来两样东西——幂等(不重跑)与限流(不乱序、不失控)。


三、Agent 循环:两层级循环,一层重试、一层推理

1. 外层 while(true):是 attempt 重试,不是逐轮推理

进入 runEmbeddedAgent 后,最容易被误读的是 run.ts:1984 那个 while (true):

// src/agents/embedded-agent-runner/run.ts:1984while (true) {  // ... 尝试跑一次 attempt}

这不是"模型一轮一轮推理"的循环,而是 attempt 重试环。它的职责是:失败重试、超时恢复、上下文溢出恢复、模型切换。真正一轮一轮"推理 → 调工具 → 回填 → 再推理"的内层循环,在 runEmbeddedAttempt 里(attempt.ts:854,6116 行的大文件)。

这个区分是全文最重要的正名:看到 while(true) 别以为是一次 ReAct 推理循环,它只是"这次 attempt 挂了就再来一次"。

runEmbeddedAgent(run.ts:624) 
→ runEmbeddedAgentInternal(:644)→ 外层 while(true)(run.ts:1984)  
← attempt 重试环(失败/超时/溢出/换模型)→ runEmbeddedAttemptWithBackend(backend.ts)→ runAgentHarnessAttempt(harness/selection.ts:309)→ runAgentHarnessLifecycleAttempt(lifecycle.ts:217)→ runEmbeddedAttempt(attempt.ts:854)             ├─ context-engine bootstrap + 工具构造计划             ├─ 逐轮推理(attempt.ts:5682 runLlmOutput)  ← 真正的 ReAct 工具环             │   ├─ 模型请求工具 → 守卫/hook/审批             │   ├─ 真正执行 tool.execute             │   └─ 结果回填上下文 → 下一轮推理             └─ 直到 stop / 达上限 → 返回 attempt 结果

2. 内层 ReAct 工具环:拐点是"结果回填"

内层循环每轮做三件事:组装 prompt → 模型流式推理 → 若有 tool_calls 就执行工具并把结果回填。关键接线点是 installContextEngineLoopHook:

// src/agents/embedded-agent-runner/tool-result-context-guard.ts:326export function installContextEngineLoopHook(params: { ... })

它替换了 agent.transformContext,使得每次工具结果写入历史后,上下文引擎重新组装上下文,驱动下一轮推理。这就是 ReAct 闭环的"拐点"——没有这一步,模型看到的就是旧的、没有工具结果的上下文,也就无法继续推理。

工具结果还有明确的上下文预算(同文件顶部常量):

const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5;   // 单条工具结果最多占 50%const PREEMPTIVE_OVERFLOW_RATIO = 0.9;          // 预判溢出阈值 90%const TOOL_RESULT_ESTIMATE_TO_TEXT_RATIO = 4;   // 字符/Token 折算 4:1

3. 工具调用前的守卫与审批

模型请求调用某工具时,会经过 wrapToolWithBeforeToolCallHook(agent-tools.before-tool-call.ts:1706)→ runBeforeToolCallHook(:1387),依次做:

  • tool-loop 检测
    (tool-loop-detection.ts):防止模型卡在同一个工具上死循环;
  • 受信工具策略
    (agent-tools.policy.ts):哪些工具可信、可用;
  • 审批
    (resolveBeforeToolCallApprovalOutcome :1184):需要人工确认的场景;
  • before_tool_call 插件 hook
    :可改参数、可 veto。

这里有个很"韧"的短路设计:当没有核心策略、没有受信策略、也没有 hook 时,runBeforeToolCallHook 直接返回 { blocked: false, params }(:1481-1482),零额外开销。而 veto 不是"报错打断",而是以 blocked tool result 的形式回给模型——让模型自己决定下一步,而不是直接崩掉。

4. 并行工具:顺序保真 + 成本护栏

当模型一次请求多个并行工具时,OpenClaw 保证顺序保真:

"Reserve slots synchronously at tool execution entry, before async before_tool_call hooks run, so parallel client-tool batches preserve assistant source order even when later hooks finish first." — attempt.ts:2407-2410

即先同步预留 clientToolCallSlots 槽位,再异步跑 hooks(attempt.ts:2407-2411),这样即使后面的 hook 先完成,并行批次也能保持助手发出的源顺序。

成本护栏方面,有空闲超时 circuit breaker(run.ts:2418-2422,#76293)、MAX_RUN_LOOP_ITERATIONS 重试上限、审批超时约束等,防止付费模型调用失控。


四、返回段:事件驱动的流式塑形

1. 三流桥:subscribeEmbeddedAgentSession

run 内部会产生海量会话事件(message_start/update/end、tool_execution_、agent_start/end、compaction_)。这些事件不能直接丢给渠道,需要被翻译成统一的事件流。这个翻译器就是 subscribeEmbeddedAgentSession(embedded-agent-subscribe.ts:162,1489 行)。

它内部用 scheduleEvent 把事件压入 pendingEventChain串行化——保证 message/tool/lifecycle 三类事件不乱序、不重入;只有 tool_execution_end 可以 { detach: true } 异步流动。随后事件被分发到三个 handler 文件:

流
负责内容
关键位置
lifecycle
run 从 start 走向 end/error
handlers.lifecycle.ts(phase start:48 / terminal:170)
tool
工具生命周期与结果
handlers.tools.ts(start:1049 / update:1191 / result:1489)
assistant
可见文本 + 推理增量
handlers.messages.ts(buildAssistantStreamData)

三种流统一走 emitAgentEvent({ stream, data })(agent-events.ts:115)——这是全局统一广播。

重点:subscribeEmbeddedAgentSession 是 run 内部事件与渠道面向之间的"三流桥":tool 流报告工具干了什么,assistant 流传可见文本与推理增量,lifecycle 流标记 run 的生死。

2. 回复塑形:delta 缓冲 + 分块 flush

assistant 流的文本不是一次性到位的,而是流式增量。这里有个坑:不同 provider 行为不一致——有的在 text_end 重发全文,有的半路才知道某段是 commentary。所以 delta 要按"单调追加"而不是"整体替换"来缓冲。

缓冲由 EmbeddedBlockChunker 完成(state 里的 deltaBuffer/blockBuffer/blockState),flush 触发点有:

  • handleMessageUpdate
    (streamItemChanged)
  • handleMessageEnd
    (text_end 走 emitSplitResultAsBlockReply,message_end 走 flushBlockReplyBuffer + onBlockReplyFlush({reason:"message_end"}))
  • handleAgentEnd
    (terminal)

最终经 onBlockReply / onBlockReplyFlush 投递渠道(subscribe.ts:313 emitBlockReplySafely,给 BlockReplyPayload 打 assistantMessageIndex 后调用)。

3. NO_REPLY:静默 token 的精确定义

OpenClaw 用 SILENT_REPLY_TOKEN = "NO_REPLY"(tokens.ts:7)表示"这轮不回复"。但它不是简单"等于 NO_REPLY 就吞",而是精确识别多种形态:

  • isSilentReplyText
    :识别重复 token、空白、边缘标点包裹;
  • isSilentReplyPayloadText
    :token-only / JSON 动作信封 {action:"NO_REPLY"} / 推理前缀三形态;
  • isSilentReplyPrefixText
    (:290):流式前缀分片,防吞自然语言 "No…";
  • 专门防吞"以 NO_REPLY 结尾的实质答案"(tokens.ts:58, #19537)。

更关键的是静默兜底:resolveSilentReplyFallbackText(messages.ts)当文本恰为 NO_REPLY 时,用最近一次消息工具已发文本(messagingToolSentTexts.at(-1))填充——避免"该说的其实已经通过工具发了,却还要硬憋一句 NO_REPLY"。

重点:静默回复 token 不只是"等于 NO_REPLY 就吞"——它识别重复 token、标点包裹、JSON 动作信封和推理前缀,同时确保"有价值的答案后面带个 token"不会被误伤。

4. 终态串行与安全净化

run 结束时,lifecycle 层做终态串行,依次执行:

  1. flushDeferredAssistantEvents
    (冲刷延后的助手事件)
  2. flushDeferredBlockReplies
    (冲刷延后的块回复)
  3. flushBlockReplyBuffer({final:true})
    (终态冲缓冲)
  4. flushPendingMediaAndChannel
    (冲刷待发媒体与渠道)
  5. emitLifecycleTerminal()
    (发射终态)

其中 onBeforeTerminalDelivery 可返回 {suppressTerminalDelivery} 抑制终发(比如工作态已被"消息工具已发答案"替代)。

安全方面,工具错误会被净化——capLiveExecResult、summarizeToolValidationError 避免把 secrets 泄进 lifecycle(tools.ts)。


五、转录落盘:纠正一个常见误区

最后,这条消息的"一生"以落盘收尾。一个常见误区是:转录文件是在 runner 的 transcript-.ts 里写的。实际不是*。落盘发生在:

  • src/sessions/user-turn-transcript.ts:463appendUserTurnTranscriptMessage
    (+:573 appendFileTarget)
  • src/agents/session-tool-result-guard.ts:667appendMessageAndCacheTranscriptSeq

而 src/sessions/transcript-events.ts:59 的 emitSessionTranscriptUpdate 只是内存监听广播(非磁盘写)。 归档则在 gateway 层:src/gateway/session-transcript-files.fs.ts:121resolveSessionTranscriptCandidates / :375archiveSessionTranscripts。

重点:转录的"写盘"在用户会话转录模块,不在 runner;transcript-events 只是内存广播,别被名字骗了。


六、总结:一条消息的完整生命周期

把四段串起来,就是一条用户消息的完整数据流:

用户消息 → 渠道 inbound → telegram:2201 runChannelInboundEvent  → turn 内核(kernel.ts:849=:678): ingest→classify→preflight→resolveTurn→dispatch      → recordInboundSession + 派发链 → resolveSessionAgentId  → agent.run 受理(return {runId, acceptedAt})  [agent.ts:3178-3187]    → session lane + global lane 排队 → runEmbeddedAgent      → 外层 attempt 重试环(run.ts:1984)        → runEmbeddedAttempt(attempt.ts:854)           → ReAct 工具环(attempt.ts:5682)              → 守卫/hook/审批 → tool.execute → 结果回填 → 再推理           → 模型 stop / 达上限      → subscribeEmbeddedAgentSession(subscribe.ts:162)         → scheduleEvent 串行分发 → 三流 handler(lifecycle/tools/messages)         → emitAgentEvent 统一事件流         → 回复塑形(NO_REPLY 过滤+兜底 / 工具摘要 / 错误净化)         → delta 缓冲 + blockChunker 分块 → onBlockReply → 渠道出口      → transcript 落盘 + gateway 归档

回顾本文四个核心洞察:

  1. 两层级循环
    ——外层 attempt 重试环(run.ts:1984)≠ 内层 ReAct 工具环;工具环的拐点是 installContextEngineLoopHook 替换 agent.transformContext;
  2. 受理/执行分离 + 双 lane
    ——换来幂等与限流,是"事件驱动"的骨架;
  3. 三流桥 + 串行分发
    ——lifecycle/tool/assistant 三流经 scheduleEvent 保序不重入,统一 emitAgentEvent 广播;
  4. NO_REPLY 的精确语义
    ——多种形态全覆盖 + 兜底替换,宁可多说一句也不误吞实质答案。

下一篇,我们将继续深入 agent 内核的某个具体环节。如果你对某一段(比如 ReAct 工具环的守卫细节,或三流桥的具体 handler)想展开看,欢迎留言告诉我。


今天的分享就到这里,如果您觉得有用的话,记得关注哦,关注后才能第一时间看到清风君的每周分享。

关注"数字化与AI之知与行"

<===> END <===>

相关学习资料