ARTICLE · 985266
OpenClaw 事件驱动与 ReAct 闭环:一条消息的完整数据流追踪
OpenClaw 的 agent 运行时不是"一次推理",而是"两层循环":外层是 attempt 重试环,内层才是真正的 ReAct 工具环。本文从一条用户消息出发,追踪它从渠道接入、会话串行、工具闭环到结果返回落盘的完整数据流。
一、引子:把"一条消息的一生"追到底
前面两篇我们建立了 OpenClaw 的整体认知:单进程三合一(消息面/控制面/agent 执行核心)加上五层边界划分。但"一个进程到底装了什么"是静态的骨架,真正让系统活起来的,是一条消息从进来到出去所走过的路径。
本篇是顶层架构第 03 篇,也是这个系列最关键的一篇:我会沿着一条用户消息,逐段追踪它如何——
被渠道接入并归一化成统一 turn 输入; 经会话串行 + 全局限流排队; 进入 ReAct 工具闭环反复"推理 → 调工具 → 回填 → 再推理"; 最终以事件驱动的方式被塑形成回复、投递回渠道、落盘存档。
重点:读完本文,你会理解 OpenClaw agent 运行时的"两层级循环"模型——这是理解整个系统如何"活"起来的那把钥匙。

二、接入段:turn 内核,一条标准化的同步流水线
1. 真正的入口不是"消息到达",而是 turn 内核
很多人以为渠道插件收到消息就直接进 agent 了,其实不然。OpenClaw 把所有渠道的原始事件先归一化成统一的 turn 输入,交给一个叫 turn 内核的东西去跑标准管线。
以 Telegram 为例,插件收到消息后调用:
这个 runChannelInboundEvent 在 SDK 层(src/plugin-sdk/channel-inbound.ts:162)只是一个透传,真正的实现在 turn 内核:
runChannelTurn 是一条同步流水线:ingest → classify → preflight → resolveTurn → dispatch → finalize。也就是说,无论消息来自 Telegram、微信还是飞书,接入后都走同一条标准化路径——这正是"渠道可插拔"的根基:渠道只负责把平台事件翻译成 turn 输入,剩下的交给内核。
2. 派发链:从消息到"该交给哪个 agent"
turn 内核 dispatch 阶段会做两件事:记录入站会话 + 解析该交给哪个 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,而是受理——它校验、去重、然后立刻返回一个"已受理"的凭证:
源码注释把意图说得很直白:
"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):
这不是"模型一轮一轮推理"的循环,而是 attempt 重试环。它的职责是:失败重试、超时恢复、上下文溢出恢复、模型切换。真正一轮一轮"推理 → 调工具 → 回填 → 再推理"的内层循环,在 runEmbeddedAttempt 里(attempt.ts:854,6116 行的大文件)。
这个区分是全文最重要的正名:看到 while(true) 别以为是一次 ReAct 推理循环,它只是"这次 attempt 挂了就再来一次"。
2. 内层 ReAct 工具环:拐点是"结果回填"
内层循环每轮做三件事:组装 prompt → 模型流式推理 → 若有 tool_calls 就执行工具并把结果回填。关键接线点是 installContextEngineLoopHook:
它替换了 agent.transformContext,使得每次工具结果写入历史后,上下文引擎重新组装上下文,驱动下一轮推理。这就是 ReAct 闭环的"拐点"——没有这一步,模型看到的就是旧的、没有工具结果的上下文,也就无法继续推理。
工具结果还有明确的上下文预算(同文件顶部常量):
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 | ||
| tool | ||
| assistant |
三种流统一走 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 层做终态串行,依次执行:
flushDeferredAssistantEvents(冲刷延后的助手事件) flushDeferredBlockReplies(冲刷延后的块回复) flushBlockReplyBuffer({final:true})(终态冲缓冲) flushPendingMediaAndChannel(冲刷待发媒体与渠道) 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 只是内存广播,别被名字骗了。
六、总结:一条消息的完整生命周期
把四段串起来,就是一条用户消息的完整数据流:

回顾本文四个核心洞察:
- 两层级循环
——外层 attempt 重试环(run.ts:1984)≠ 内层 ReAct 工具环;工具环的拐点是 installContextEngineLoopHook替换agent.transformContext; - 受理/执行分离 + 双 lane
——换来幂等与限流,是"事件驱动"的骨架; - 三流桥 + 串行分发
——lifecycle/tool/assistant 三流经 scheduleEvent保序不重入,统一emitAgentEvent广播; - NO_REPLY 的精确语义
——多种形态全覆盖 + 兜底替换,宁可多说一句也不误吞实质答案。
下一篇,我们将继续深入 agent 内核的某个具体环节。如果你对某一段(比如 ReAct 工具环的守卫细节,或三流桥的具体 handler)想展开看,欢迎留言告诉我。
今天的分享就到这里,如果您觉得有用的话,记得关注哦,关注后才能第一时间看到清风君的每周分享。
关注"数字化与AI之知与行"

<===> END <===>
