先设定一个会贯穿全文的任务。用户在 Telegram 里说:“检查项目的失败测试,修好后把结果发回这里。”这句话表面上只有十几个字,系统内部却必须完成一串不能颠倒的判断:消息是不是允许启动一个 Agent 回合;它应该落进哪个会话;同一会话是否已经有任务在跑;模型能看见什么;工具能做什么;最后的文字和附件又由谁交回 Telegram。

如果把这些步骤粗暴地压成“收消息 → 调模型 → 发回答”,我们会立刻解释不了三个故障。第一,两个渠道里的同一个人为什么有时共享上下文、有时完全隔离;第二,模型明明已经输出文字,为什么调用方仍然等不到一个完成信号;第三,工具真的改了文件但最终回复发送失败时,系统究竟应该重跑、续跑,还是只补发结果。

所以第一篇暂时不深入某一种渠道、某一种工具或某一种恢复算法。我们只做一件事:沿着一次真实的渠道消息,确认每一步由谁处理、写入哪份状态,以及什么信号表示整轮结束。后面七篇再逐层展开。

阅读契约。读完以后,你应该能不看图复述:一条渠道消息为什么先变成规范化上下文;sessionKey 为什么比模型名更早决定行为;同一轮里模型与工具怎样来回接力;assistant、tool、lifecycle 三条事件流为什么不能互相替代;以及 ReplyPayload 为什么只是出站载荷,而不是整个运行状态。

证据边界。本文固定在 OpenClaw 提交 e6b42643e7aae229d115477596b33c36c38f7101。函数名、类型和顺序来自该快照;产品层行为同时参照官方的 Agent Loop、Messages 与 Session 文档。源码能证明调用和数据边界,但不能自动证明所有部署中的延迟、可靠性或安全效果。

一、消息怎样获得会话与执行顺序

1.1 先认清“一轮”包含哪些步骤

在我们的例子里,用户只发了一条消息,但 Agent 可能经历四次模型交互:先决定读取测试日志,看到日志后搜索源码,修改文件后执行测试,最后把测试结果整理成回答。因此 turn 不是一次模型请求。它是一段从入站消息被接纳,到运行进入可观察终态,再到结果被出站策略处理的生命周期。

这一区分很重要。把模型请求当成 turn,会把工具执行误认为“模型外的一段附加逻辑”;而在 Agent runtime 里,工具结果恰恰是下一次模型判断的输入。只有最后不再产生工具调用、等待交互或可恢复分支时,这一轮才可能进入终态。

对象负责什么不负责什么
消息表达一次来自渠道的事件与文本、媒体、发送者等事实。不天然决定会话,也不保证能启动 Agent。
模型请求给某个 provider/model 一份当前视图,并获得 assistant/tool-use 输出。不等于整个回合,也不直接拥有渠道投递。
Agent turn串起接纳、会话、队列、模型、工具、事件、终态与出站处理。不要求只有一次模型调用,也不保证只有文字结果。
会话为一串相关 turn 提供持久身份、并发边界和历史归属。不是正在运行的那一个 Promise,也不是最终回复。

1.2 渠道内核先把原始事件变成统一输入

Telegram、Discord、Slack 或 WebChat 的原始事件各不相同。OpenClaw 没让下游运行时理解每家 SDK 的原始对象,而是把渠道差异收在插件与 adapter 边界。共享入口 runChannelTurn 先调用 adapter 的 ingest,得到一份规范化输入,再判断事件类型是否允许启动 Agent turn。

这意味着“收到事件”和“运行 Agent”不是同义词。已被渠道自己处理的事件、观察事件、被 admission 规则丢弃的事件,都可以在模型运行之前结束。源码甚至为被 drop 的消息保留了受策略控制的 history 记录路径:这提示我们,接纳结果本身也是运行事实,不能只用“有没有回复”推断发生了什么。

const input = await params.adapter.ingest(params.raw);
const eventClass = (await params.adapter.classify?.(input)) ?? DEFAULT_EVENT_CLASS;

if (!eventClass.canStartAgentTurn) {
  return { admission: { kind: "handled", reason: `event:${eventClass.kind}` }, dispatched: false };
}

这段摘录不是完整实现,却显示了一条可迁移规则:各家 provider 的原始事件先由 adapter 转成 OpenClaw 的统一输入,再由共享入口决定是否启动 Agent。否则每个渠道都要重复实现去重、审计、历史与错误处理,修复其中一个入口时很容易漏掉其他入口。

1.3 路由先产生 sessionKey,再选择模型

消息通过接纳后,系统需要回答“它属于哪段会话”。OpenClaw 的路由结果不只是一个 agent 名称,还会影响 sessionKey。这个 key 把渠道、账号、peer、thread、binding 和 agent 配置折叠成一个稳定地址。两条消息只要得到不同的 key,就会写入不同历史;即使它们稍后恰好使用同一个模型,也不能据此合并。

模型可以在运行中回退,runtime plugin 也可以重新解析 provider/model;但队列、持久存储和回复操作必须更早拿到稳定的会话地址。runReplyAgent 的参数同时携带 sessionEntry、sessionStore、sessionKey、queueKey 与 resolved queue;一次运行显然不能只靠模型名称定位。

一个实用判断。遇到“同一个用户为什么失忆”时,先比较两次消息的 route 与 sessionKey,再看模型和 prompt。许多看似上下文丢失的问题,其实在进入模型之前就已经被路由成两个会话。

1.4 队列保护 transcript 的先后顺序

假设用户连续发送两句:“先修测试”,紧接着又说“不要改生成文件”。如果两轮同时读取旧 transcript、分别写入新消息,最终历史就可能把第二条约束放到错误位置,甚至让两个工具调用交错修改同一个 workspace。OpenClaw 因此同时维护 session lane 与 global lane。

在 runEmbeddedAgentInternal 中,lane controller 暴露 enqueueSession 与 enqueueGlobal。代码先进入会话队列,等待该会话之前的 deferred transcript maintenance,再进入全局并发门。顺序表达了两个不同约束:

  • session lane 保护同一会话里的读写顺序,让后一轮看到前一轮已经提交的维护与 transcript;
  • global lane 控制整个进程可以同时占用多少昂贵运行资源,但不应该让一个拥堵会话长期锁住所有其他会话。

因此队列策略会改变产品语义,而不只是吞吐。steer、follow-up、interrupt 和 collect 的差别,本质上是在回答新消息应该进入当前运行、排在它后面,还是终止它。第三篇会专门拆这组策略;这里先记住:队列决定谁有权成为同一 transcript 的下一位作者。

二、请求怎样进入模型—工具循环

2.1 dispatchReplyFromConfig 把分发拆成可终止阶段

渠道确定路由与会话地址后,请求进入 dispatchReplyFromConfig。从名字看,它像一个简单的配置分发器;实际内层连续经过 gather、delivery、operation context、operation、route、execution、finalize 与 audit。

// 阶段顺序示意;省略各阶段的 complete 分支与运行作用域
const gathered = await gatherDispatchRequest(params, messageAuditTerminal, allowActiveQueueResolution);
if (gathered.status === "complete") return gathered.result;

const delivery = await prepareDispatchDelivery(gathered.state);
const context = await prepareDispatchOperationContext(delivery.state);
const operation = await prepareDispatchOperation(context.state);
const route = await chooseDispatchRoute(operation.state);
const execution = await prepareDispatchExecution(route.state);
const executed = await executeDispatch(execution.state);
return (await finalizeDispatchAndAudit(executed.state)).result;

反复出现的 status === "complete" 很值得注意。它允许命令处理、策略拒绝、无需模型的回复或其他短路路径在自己的阶段结束,而不用伪装成一次 embedded agent 成功。异常路径还会根据重放安全性 commit 或 release inbound dedupe claim,并记录 dispatch、processed 与 idle 状态。于是“失败后能否重试”不再由一个宽泛的 catch 决定,而取决于此前是否可能产生副作用。

2.2 runReplyAgent 连接渠道进度与 Agent 运行

dispatchReplyFromConfig 最终把需要模型执行的分支交给 runReplyAgent。这一层既知道渠道侧的 typing、block streaming、reply threading 和 partial reply,也知道 Agent 侧的 session、queue、model 与 tool progress。它向内启动运行,向外把运行事件转换成渠道可展示的进度和结果。

它还会处理 provider 重复投递。源码在真正调用模型之前读取 session entry,并用 source turn id 检查持久的来源认领;重复来源会清理 typing 并直接返回。认领说明已有运行接管了消息,并不等于运行已经完成;只有运行不再处于 running 等条件成立时,代码才尝试退休终态认领。这不是唯一的去重关口:分发阶段已在插件 hook 之前过滤持久重复来源,这里在模型接纳边界重新读取并复查,避免重复执行工具。

我们的“修复失败测试”到这里才真正获得执行资格。前面的步骤没有浪费,它们先确定了谁拥有这次运行、它排在哪里、结果该投递到哪里,以及重复消息是否能安全重放。

2.3 runEmbeddedAgentInternal 执行模型—工具循环

runEmbeddedAgent 先补齐配置、运行生命周期 generation 与插件 generation,再进入 runEmbeddedAgentInternal。内层统一 session target 身份、补全缺省 sessionKey、检查运行接纳,随后进入 session/global lanes。不能再把这个函数理解成仅用于剥离 host 字段的插件包装器。

这个入口也不再保证模型执行始终发生在同一个进程。通过接纳与并发检查后,符合条件的请求可以先分派到 CLI backend;其余请求再解析 workspace、模型候选与 harness runtime。下文的模型—工具循环聚焦内置 harness:真正开始 attempt 前,它仍要装配 bootstrap files、system prompt、skills、工具集合和 transcript。第四到第六篇会分别拆这些材料与策略。

循环的核心可以用一个不绑定具体 SDK 的伪代码概括:

while (!terminal) {
  const assistant = await model.respond(runtimeView);
  emit("assistant", assistant);

  if (!assistant.toolCalls.length) break;
  for (const call of assistant.toolCalls) {
    emit("tool", { phase: "start", call });
    const result = await governedToolRunner.execute(call);
    emit("tool", { phase: "end", call, result });
    runtimeView.append(result);
  }
}

这段循环的职责很清楚:模型只提出结构化 tool call;工具 runner 才执行真实动作;结果重新进入本轮消息,让模型决定下一步。这段伪代码省略了实际工具调度的并行策略、重试和中断处理,并不声明工具总是串行执行。图中的模型—工具双向箭头因此只位于 runEmbeddedAgentInternal 的内置运行范围内,不会从渠道层或 ReplyPayload 直接伸到工具。

三、运行怎样被观察、保存与恢复

3.1 三条事件流分别报告内容、工具和终态

OpenClaw 延后终态路径:assistant 与 tool 用于观察;attempt finishing 后由外层处理回退与收尾,再发布 end/error 供 agent.wait 观察
聚焦延后终态的路径。上方进度只用于观察;下方最终状态进入运行记录,等待调用也可以先因超时返回。

模型—工具循环运行时,外部不能等到最后才知道发生了什么。OpenClaw 的 subscription handler 把事件分成 assistant、tool 与 lifecycle 三条流。它们有时在同一毫秒附近出现,却回答不同问题。

事件流表达的事实典型消费者为什么不能当终态
assistant模型正在形成哪些文本、推理可见块或消息内容。流式 UI、partial reply、transcript 组装。一段文字之后仍可能继续调用工具。
tool哪项工具调用开始、更新或结束,以及结果怎样。进度展示、审计、媒体与副作用跟踪。一个工具结束不代表后续没有模型或其他工具。
lifecyclerun 的 start、attempt 的 finishing,以及最终 end/error 等生命周期状态。agent.wait、运行注册表、恢复与终态投递。start 与 finishing 仍非终态;最终 end/error 才提供终止证据。

handleAgentStart 发出 stream: "lifecycle" 与 phase: "start"。handleAgentEnd 则不能只看最后一条 assistant 是否有字:它还要检查确定性副作用、message tool 投递、已接受的 session spawn、cron 创建、未完成 tool-use turn 和 replay validity,最后把运行归类为 working、blocked、abandoned 等状态。

图中聚焦延后终态的路径。attempt 结束时先发 finishing,外层保留错误与终态字段,等回退及本轮收尾完成后才发 end 或 error。终态协调器不会把 finishing 当成完成。否则第一个失败候选刚结束,调用者就可能误以为整个 run 已失败,而下一个候选还在执行。

agent.wait读取 Gateway 的运行记录,还可能返回排队中的 pending 或等待超时。等待超时不证明后台运行已经停止;最终执行状态也不自动证明渠道投递成功,调用者应同时检查返回的 terminalDelivery、terminalReceipt 和 terminalReply。内容可见、执行结束和结果送达是三件事。

3.2 四份数据分别服务入站、存储、模型和出站

OpenClaw 一轮中的四份数据:FinalizedMsgContext 保存入站事实,SessionEntry 与 transcript 保存持久会话,本轮模型视图临时组装,ReplyPayload 描述出站内容

走到这里,最容易出现的误解是把所有东西都叫“上下文”。实际上,一轮至少使用四份不同的数据:FinalizedMsgContext 保存渠道规范化后的入站事实;SessionEntry 与 transcript 写入持久 session 存储;embedded agent 为本次模型调用组装临时消息;ReplyPayload 只描述渠道无关的出站内容。

ReplyPayload 可以携带 text、fallbackText、media、attachments、presentation 与 delivery 偏好。它没有承担整个 session 的职责,也不应该成为恢复运行的唯一证据。相反,SessionEntry 包含 sessionId、updatedAt、restart recovery state 和 plugin extensions 等持久信息。

把这几份数据拆开,会得到一个很实用的排障顺序:

  1. 消息内容不对,先查渠道 normalize 后的 FinalizedMsgContext;
  2. 历史串错或并发错序,查 sessionKey、session lane 与持久 session/transcript;
  3. 模型没看到某条规则,查本轮 runtime view 的装配与 compaction;
  4. 模型已经答对但渠道没发出,查 ReplyPayload 到 delivery adapter 的映射与审计。

这四类问题长得很像,修复位置却不同。如果试图在 prompt 里修路由,或靠修改 transcript 解决渠道投递,系统只会变得更难解释。

3.3 失败后先判断副作用,再决定是否重试

现在回到“修复测试”的任务。假设模型已经调用工具修改文件,随后 provider 超时。简单重跑可能再次修改同一文件;简单宣布失败又会让已经发生的副作用无人认领。OpenClaw 在多个层次记录 replay validity、tool delivery evidence、source claim、lifecycle generation 与 terminal state,目的都是判断:这次运行是否还能安全地从原输入重新执行。

几个提前终止路径尤其值得区分:

  • ingest/drop/handled:Agent 根本没有启动,不应该伪造模型失败;
  • command 或策略短路:dispatch 阶段已经得到完整结果,embedded agent 不参与;
  • 模型或工具错误:需要结合是否已有可见输出和副作用判断 failover、abandon 或补充错误结果;
  • 中断中的 tool-use turn:即使之前已有 assistant 文本,也不能把未完成工具链标成正常 end;
  • 重复恢复来源:持久记录表明来源已被运行接纳或已有终态记录,因此跳过重复执行;只有满足终态清理条件才退休 claim。

所以重试前必须回答:持久记录能否证明这次工具没有执行,或者重复执行仍然安全?进程内 Promise 是否 rejected,无法单独回答这个问题。

四、从主线进入后续章节

4.1 接下来七篇为什么按这个顺序

OpenClaw 源码阅读八篇顺序,从消息主线开始,再到 Gateway、路由与会话、上下文与记忆、能力装配、安全边界、多 Agent、常驻与恢复

第一篇故意把很多名字只点到为止,因为它的任务是建立坐标系。后续七篇沿着状态和执行职责的交接处依次展开:

  1. Gateway:为什么它是控制平面,而不只是 WebSocket server;RPC、事件与客户端怎样共享协议。
  2. 路由与会话:binding、peer、thread、sessionKey、队列与 transcript 怎样决定“这句话属于谁”。
  3. 上下文与记忆:workspace、bootstrap、system prompt、session history、compaction 与 memory 如何分层。
  4. 能力装配:tools、skills、plugins、hooks 在哪一层进入 runtime,哪些条件会改变本轮可用工具。
  5. 安全边界:tool policy、sandbox、elevated、approval 与渠道身份如何共同约束副作用。
  6. 多 Agent:session tools、subagent 与 ACP 怎样创建新的子会话,又怎样把结果交回父会话。
  7. 常驻与恢复:heartbeat、cron、durable task 与 restart recovery 如何把一次 turn 延伸到进程之外。

这个顺序从“一次如何完成”走向“长期如何不乱”。如果直接从 cron 或多 Agent 开始,很容易把新 session 当成普通函数调用;如果没有先认清 tool 与 lifecycle 事件,又很难判断跨重启恢复究竟应该恢复哪一层状态。

4.2 五条可以迁移到其他 Agent runtime 的规则

  1. 先规范化,再决定是否启动。渠道插件处理 provider 差异,共享入口决定消息能否进入 Agent 生命周期。
  2. 先确定会话地址,再选择执行资源。sessionKey 与队列顺序通常比 provider/model 更早决定正确性。
  3. 模型提出动作,runtime 执行动作。tool call、tool side effect 与 tool result 必须保留清晰边界。
  4. 内容事件不等于终态事件。assistant、tool、lifecycle 应分别服务展示、执行观测与 run completion。
  5. 重试安全由副作用证据决定。一旦动作可能发生,就需要持久 claim、投递证据和可恢复终态,而不是无条件重跑。

有了这五条规则,我们就能进入下一篇:把镜头从一条 turn 拉远,看看 Gateway 怎样接入客户端、管理配置和节点,并把运行事件交给远程界面观察和控制。

参考源码与文档