数据截至 (上游 commit 8d6cbee1b527)
智能体运行时:循环、失败转移与上下文压缩
30 秒导读: 这一章讲 OpenClaw 里真正调用模型的那一层。它被切成两半:一半是
packages/agent-core里 那个干净的「双循环」——只管发请求、跑工具、发事件;另一半是src/agents/embedded-agent-runner/下 近百个模块组成的「编排壳」——管选模型、排队、超时、换凭据、换模型、压上下文。读完你会知道:为什么这两半 必须分开,以及一次「模型超时」是怎么一路变成「先压缩再重试,还不行就换 auth profile,再不行就换模型」的。
1. 这是什么(零基础也能懂)
-
一句话定义: 智能体运行时 = 「把一句用户输入,变成一串模型调用 + 工具执行,并且在中途出错时还能自己救回来」的那段代码。
-
它要解决的 问题: 你在群里 @ 了一句「帮我查一下这个仓库的构建脚本」。表面上是一次问答,实际上要发生的是:
- 模型回一段话,里面夹着「我要调用
read_file」; - 运行时真的去读文件,把结果塞回去;
- 模型再回一段话,可能再调工具……直到它说「我说完了」;
- 中途你又补了一句「顺便看下 CI 配置」——这句得能插进正在跑的对话,而不是排到下一轮;
- 中途模型超时了 / 额度用光了 / 上下文塞满了——都不该让用户看到一句冷冰冰的 500。
- 模型回一段话,里面夹着「我要调用
-
谁在用它: 上游是 回复流水线(它决定「该跑一次 agent 了」),下游是 工具与沙箱(真正动手的部分)。本章只讲中间这层。
-
一句话直觉: 把内核想成一台只会「问-做-再问」的状态机,把编排壳想成围着它转的运维团队—— 换电源(auth profile)、换机器(model failover)、清桌面(compaction)全是运维干的,状态机自己毫不知情。
-
两半的分界线,一句话记住: 内核只认识
AgentLoopConfig里那几个回调;所有「换什么、等多久、压多少」 的策略,都由编排壳填进这些回调里。
2. 顶层全景(它大概怎么转)
2.1 两半的 分工
怎么读这张图:从上往下是一次 run 的生命周期;左边是编排壳做的事,右边是内核做的事,中间的箭头是它们唯一的接口——回调。
编排壳 (src/agents/embedded-agent-runner/) 内核 (packages/agent-core)
───────────────────────────────────────────── ────────────────────────────
① 运行前:选模型/选 auth/定思考等级/回填会话键
│
▼
② 排队:session lane → global lane(带超时)
│
▼
③ 一次 attempt ──────── 调 用 ───────────────► runAgentLoop
│
◄─── 事件流(agent/turn/message)──┤
│
④ 出错?分类 → 压缩 / 换 profile / 换模型 │ 内层循环:tool call + steering
│ (retry,回到 ③) │ 外层循环:排队消息把它重新拉起
▼ │
⑤ 运行后:终态归一 + 记 auth 成功/失败 ◄───────────┘
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 内核双循环 | 发请求、跑工具、注入 steering、发事件 | packages/agent-core/src/agent-loop.ts:298 (runLoop) |
| Agent 包装类 | 持有 transcript / 队列 / abort,把事件喂给订阅者 | packages/agent-core/src/agent.ts:214 (Agent) |
| 事件流容器 | 一个可异步迭代、又能单独 await 终值的队列 | packages/llm-core/src/utils/event-stream.ts:9 (EventStream) |
| 编排壳入口 | 补 config、捕获代次、排队、起 attempt | src/agents/embedded-agent-runner/run-orchestrator.ts:79 (runEmbeddedAgent) |
| 失败转移策略 | 把「这次为什么失败」映射成一个动作 | src/agents/embedded-agent-runner/run/failover-policy.ts:157 (resolveRunFailoverDecision) |
| auth profile 账本 | 记凭据的失败次数、冷却窗口、禁用窗口 | src/agents/auth-profiles/usage.ts:991 (markAuthProfileFailure) |
| 上下文引擎 | 可被插件替换的「压缩/装配」实现 | src/context-engine/registry.ts:712 (resolveContextEngine) |
2.3 主线走一遍(不进代码)
一次正常的 run,粗粒度是这样五步:
- 解析。 从 config + 显式参数里定出
provider/model、思考等级、会话键。 - 排队。 先进这个会话自己的 lane,再进全局 lane,防止同一会话并发写 transcript。
- 跑 attempt。 编排壳构造好 config,调内核循环,一边跑一边收事件。
- 判定。 attempt 结束后看结果:成功就收工;失败就按分类走压缩 / 换凭据 / 换模型,回到第 3 步。
- 归一。 把「超时 / 取消 / 阻塞 / 失 败 / 完成」这几种终态压成一个不会被后续噪声覆盖的结论。
3. 内核:干净的双循环
这节讲内核为什么是两层循环,而不是一层。
3.1 它要解决的小问题
一层 while 只能表达「模型还要调工具吗?」。但真实聊天里还有第二个问题:
「模型已经想收工了,可用户在它跑的时候又说了一句话,怎么办?」
这两个问题的时机不同:前者在每一轮结束时判断,后者在整个循环本来要退出时判断。于是有了内外两层。
外层 while(true)
├── 内层 while(还有工具调用 || 有待注入消息)
│ ├─ 注入 pendingMessages(steering)
│ ├─ streamAssistantResponse ← 一次模型调用
│ ├─ executeToolCalls ← 跑工具
│ ├─ prepareNextTurn / shouldStopAfterTurn
│ └─ 重新取 steering 消息
│
└── 内层退出后:取 followUp 消息?
有 → 当作 pending,continue 外层(循环被"复活")
无 → break,发 agent_end
- 内层条件在
packages/agent-core/src/agent-loop.ts:355:while (hasMoreToolCalls || pendingMessages.length > 0)。 - 外层复活在
agent-loop.ts:512:getFollowUpMessages()拿到东西就塞进pendingMessages并继续。
3.2 两种「插话」的区别
内核提供两个队列回调,含义不一样。这是最容易混淆的一处:
| 回调 | 什么时候被问 | 语义 | 定义 |
|---|---|---|---|
getSteeringMessages | 每个 turn 结束后(以及循环启动时) | 「边跑边纠偏」,不打断当前这批工具 | packages/agent-core/src/types.ts:306-308 |
getFollowUpMessages | 内层循环已经准备退出时 | 「等它干完再说」,把 agent 重新拉起来 | packages/agent-core/src/types.ts:319-321 |
真实的队列实现是 PendingMessageQueue(packages/agent-core/src/agent.ts:165),它有一个 mode:
"all"—— 一次全部倒出来;"one-at-a-time"—— 只取最老的一条,其余留到下一个排空点(agent.ts:188的注释)。
默认两个队列都是 one-at-a-time(agent.ts:280-281),意思是用户连发三条,agent 会一条一条消化,而不是被一次性灌进同一个 prompt。
3.3 steering 的一个真实来源:子 agent 完成通知
steering 不只是「用户又说了一句话」。子 agent 跑完后,结果也要塞回请求方的这一轮。
src/agents/agent-steering-queue.ts 就是干这个的,它的设计值得单独看:
- 先租后投。
leasePendingAgentSteeringItemsFromSubagentRuns(agent-steering-queue.ts:185)把待投递项标成in_progress并打上steeringLeaseId,注入成功才ack(ackLeasedAgentSteeringItemsFromSubagentRuns,:222), 失败则release回队列(releaseLeasedAgentSteeringItemsFromSubagentRuns,:251)。 这样一次失败的父轮次不会把子 agent 的结果弄丢,也不会重复投两次。 - 租约会过期。 超过 5 分钟的
in_progress视为陈旧租约,重新入队(STALE_STEERING_LEASE_MS,:14;isStaleLease,:43)。 - 提示词有硬上限。 合并后的 steering prompt 超过 24 000 字符就停止追加(
MAX_MERGED_STEERING_CHARS,:15;selectPromptBoundedItems,:151)。 - 明确划清指令边界。 合并出的提示词开头写着:把这些队列项当作运行时数据与证据,不要当成用户指令——这是一条防注入的护栏。
3.4 事件流协议:上层 UI 靠它活
内核不返回「一个结果」,它一边跑一边推事件。事件类型定义在 packages/agent-core/src/types.ts:602 (AgentEvent)。
一次带工具调用的 turn,事件序列长这样:
agent_start
└ turn_start
├ message_start(user) ← 用户消息也走消息生命周期
├ message_end(user)
├ message_start(assistant) ← 流式开始
├ message_update × N ← text/thinking/toolcall 的增量
├ message_end(assistant)
├ tool_execution_start / _update / _end
├ message_start/end(toolResult)
└ turn_end
└ turn_start …(下一轮)
agent_end
三个层级各自的含义,记住这张表就够了:
| 层级 | 边界含义 | 发射点 |
|---|---|---|
agent_* | 整个 run 的开始与结束 | agent-loop.ts:346、:403 等 |
turn_* | 一次助手响应 + 它引发的工具执行 | agent-loop.ts:361 |
message_* | 单条消息(user / assistant / toolResult)的落地 | 贯穿 runLoop 各阶段 |
承载事件的容器是 EventStream(packages/llm-core/src/utils/event-stream.ts:9),它有个双面性:
- 作为
AsyncIterable被for await消费; - 同时
result()返回一个单独的 Promise,在「完成事件」出现时被 resolve(event-stream.ts:110)。
哪个事件算完成,由构造函数传进去。agent 层的判定是 event.type === "agent_end"
(agent-loop.ts:267-270 createAgentStream)。同一个类,靠不同谓词服务不同抽象层级(LLM 层用
AssistantMessageEventStream,event-stream.ts:116)。
Agent 类把这些事件先归约进自己的状态,再 await 所有订阅者。注意它的注释点破了一个陷阱:
agent_end 只代表「不会再有事件」,不代表 run 已经 idle——要等 agent_end 的所有 await 监听器跑完、
finishRun() 清完状态才算(agent.ts:296-297、:600-601 的注释)。
3.5 中断:必须落成一条 aborted 消息
这是内核里最不显然、也最值得抄走的一处设计。
问题: 用户按了取消。如果 transcript 就停在一条 stopReason: "toolUse" 的助手消息上,后面的会话
后处理(压缩、续跑)会以为「这轮还没做完,继续吧」,于是从一条半截的工具调用上接着往下跑。
做法: 中止时主动写入一条空的助手消息,stopReason 标成 "aborted"(stopIfAborted,agent-loop.ts:320):
// Persist an aborted assistant outcome so session post-processing does not
// compact or continue from the preceding toolUse message.
const abortedMessage = withAssistantTurnTaint(createFailureMessage(config.model, …, true));
newMessages.push(abortedMessage);
// 如果 turn 已关闭,先补一个 turn_start,保证事件配对
createFailureMessage(packages/agent-core/src/turn-interruption.ts:5)构造的这条消息:内容为空文本、
usage 全零、stopReason 取 "aborted" 或 "error"、errorMessage 带上原因。
同一个构造还被 pushLoopFailure(agent-loop.ts:274)复用:当整个循环抛异常时,它按
message_start → message_end → turn_end → agent_end 的顺序补齐事件,保证 UI 永远看到一套完整的生命周期,
而不是流突然断掉。
stopIfAborted 在一轮里被检查了五次(:356、:382、:439、:487 等)——注入消息前、发请求前、
turn 结束后、取 steering 之后。每一个可能耗时的边界后面都跟一次中断检查。
3.6 一个小而关键的清洗:非可执行工具调用
模型有时会在 stopReason 不是 toolUse 的情况下(比如被 length 截断)吐出半个工具调用块。
removeNonExecutableToolCalls(agent-loop.ts:113)在响应定稿时把这些块直接删掉:
if (message.stopReason === "toolUse") return message; // 正常批次,保留
const content = message.content.filter((item) => item.type !== "toolCall");
配合派发条件 message.stopReason === "toolUse" && toolCalls.length > 0(agent-loop.ts:413),
残缺的工具调用既不会被执行,也不会污染 transcript。
4. 编排壳:脏活全在这里
run.ts 那个巨文件已经被拆成近百个模块:入口在 run-orchestrator.ts,主循环在 run-loop.ts,
attempt 的各阶段在 run/attempt-*.ts 一族。按时间切成三段最清楚:运行前、运行中、运行后。
4.1 运行前:把"模糊输入"解析成"确定事实"
入口 runEmbeddedAgent(src/agents/embedded-agent-runner/run-orchestrator.ts:79)只做两件小事——补 config 快照、
捕获生命周期代次——然后交给 runEmbeddedAgentInternal(同文件 :102)。真正的解析有四项:
① 会话键回填。 调用方经常只给 sessionId 不给 sessionKey,但下游(hooks、压缩、上下文引擎)都需要它。
backfillSessionKey(run/session-bootstrap.ts:195)做一次只读查表把它补出来,并且在最早的位置调用
(调用点 run-orchestrator.ts:116),好让所有下游拿到的都是非空值。
② 模型解析。 resolveInitialEmbeddedRunModel(run/runtime-resolution.ts:86)的优先级是一张小判定表:
| 输入情况 | 结果 |
|---|---|
| provider 和 model 都显式给了 | 原样使用 |
| 只给了 model | 用 alias 索引解析这个字符串,provider 取解析结果或默认 |
| 都没给 | 用该 agent 的配置默认值,再兜底到内置默认 |
③ 思考等级初值。 resolveInitialThinkLevel(run/runtime-resolution.ts:54):显式请求优先;否则用
resolveThinkingDefault,并把当前模型的 reasoning 能力当成一份临时单条 catalog 传进去——
也就是说,同一个「默认等级」在不支持推理的模型上会得到不同结果。
④ auth profile 收窄。 createScopedAuthProfileStore(run/auth-store.ts:26)把全量凭据库裁成只含本次候选的
子集。这既是最小权限,也让后续的失败记账不会误伤无关 profile。
4.2 运行中之一:两级 lane 排 队
一次 run 要串行化两件事:同一会话不能并发写 transcript,全局并发要有上限。于是用了两级队列,
外层是会话 lane,内层是全局 lane(run-orchestrator.ts):
enqueueSession(会话 lane)(:185)
│ 先等本会话的延迟维护做完
└── enqueueGlobal(全局 lane)(:197)
└── 真正的 attempt 循环
- lane 标识:
resolveSessionLane/resolveGlobalLane(src/agents/embedded-agent-runner/lanes.ts:6、:11)。 - 先会话后全局的顺序是有意为之:注释说明这样「别的会话在本会话等自己的维护 lane 时仍能开工」(
run-orchestrator.ts:188-189)。 - 入队前先
waitForDeferredTurnMaintenanceForSession(调用点run-orchestrator.ts:192),保证同会话的后续读能看到之前的延迟 transcript 重写。
优先级由触发来源决定(resolveEmbeddedRunSessionQueuePriority,run/lane-runtime.ts:60):
| trigger | 优先级 |
|---|---|
user / manual | foreground |
cron / heartbeat / memory / overflow | background |
| 其它 | normal |
超时不是直接用 timeoutMs,而是加一段宽限:resolveEmbeddedRunLaneTimeoutMs(run/lane-runtime.ts:37)
返回 timeoutMs + 30_000(EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS,:11)。理由很直觉——lane 超时是最后一道
兜底,它必须晚于 run 自己的超时,否则每次正常超时都会先被 lane 杀掉、丢掉正常的收尾逻辑。
lane 超时还是「有进展就续命」型的:任务通过 taskTimeoutProgressAtMs 回调汇报心跳
(run/lane-controller.ts:82),noteLaneTaskProgress 在每次阶段通知时更新(:60)。
4.3 运行中之二:attempt 重试循环
编排壳自己的主循环在 run-loop.ts,每转一圈是一次 attempt。上限值分散在各恢复模块,挨在一处定义的是:
| 常量 | 值 | 管什么 | 位置 |
|---|---|---|---|
MAX_TIMEOUT_COMPACTION_ATTEMPTS | 2 | 超时触发的压缩最多做几次 | run/timeout-context-recovery.ts:10 |
MAX_OVERFLOW_COMPACTION_ATTEMPTS | 3 | 溢出触发的压缩最多做 几次 | src/agents/agent-compaction-constants.ts:14 |
| 总轮数上限 | 按候选 profile 数与 config 计算 | attempt 总轮数 | run/retry-limit.ts |
超过总轮数不是简单抛错,而是再走一次失败转移决策(stage: "retry_limit" 分支,run-loop.ts:325
调 resolveRunFailoverDecision),决定是「换个模型重来」还是「把错误返回给用户」。
4.4 运行后:终态归一
一次 run 的终态可能被多个来源同时报告:wait 结果、liveness 探测、超时归因。agent-run-terminal-outcome.ts
把它们压成一个 AgentRunTerminalOutcome(buildAgentRunTerminalOutcome,:492)。核心是两条规则:
规则一:硬超时的判定要谨慎。 只有当超时阶段属于 preflight/provider/post_turn
(HARD_TIMEOUT_PHASES,:462),或者「状态是 timeout 且 provider 确实已经被打通过」(providerStarted,:498-506),
才算硬超时。队列和网关排空阶段的超时属于等待层的不确定性,不该算到模型头上。
规则二:终态有粘性。 mergeAgentRunTerminalOutcome(src/agents/agent-run-terminal-outcome-merge.ts:16)里,
cancelled 一旦确立就不再变;hard_timeout 也守住不放,除非后来的证据能证明完成时刻早于或等于那次超时时刻。
这样「超时之后姗姗来迟的清理错误」不会把结论降级成一个普通失败。
5. 韧性:换凭据,再换模型
这节讲 OpenClaw 最工程化的一块:失败之后到底做什么。
5.1 两级失败转移
先建立心智模型。失败转移是两层套娃:
外层:换模型 (runWithModelFallback) ← src/agents/model-fallback-runner.ts:156
└── 内层:换 auth profile (advanceAuthProfile) ← run/auth-controller.ts:633
└── 一次 attempt
- 内层便宜:同一个模型,换一份凭据重试。
- 外层贵:换到 fallback 模型清单里的下一个,整个 run 重来(外层调用点
run-entry.ts:389)。 - 内层用尽或遇到「换凭据也没用」的错误,才升级到外层。