KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 主循环与三重护栏:没有 maxTurns,只有超时与循环检测 — keel 龙骨
多数 agent 框架会给主循环配一个 maxTurns 兜底,OpenClaw 2026.8.1 没有。全仓搜 maxTurns / maxIterations / maxSteps,在 agent 内核里零命中——命中的只有 A2A ping-pong 的轮次上限(src/agents/tools/sessions-send-helpers.ts:118)与测试里的压力档位。
多数 agent 框架会给主循环配一个 maxTurns 兜底,OpenClaw 2026.8.1 没有。全仓搜 maxTurns / maxIterations / maxSteps,在 agent 内核里零命中——命中的只有 A2A ping-pong 的轮次上限(src/agents/tools/sessions-send-helpers.ts:118)与测试里的压力档位。
取而代之的是三重护栏:一是 48 小时的墙钟超时,二是声明式的循环检测(六个检测器 + warning/critical 两级),三是 critical 二次命中后的硬终止。这一章要回答:
agentLoop/runAgentLoop/runLoop三层为什么要分开;- 一轮(turn)到底有哪几个阶段,
prepareNextTurn与shouldStopAfterTurn各自在哪切; - 循环检测的结果是怎么变成「终止整轮」的;
- 事件流从内核
AgentEvent一路到渠道 stream 的映射关系。
一、三层函数:谁负责返回流,谁负责返回消息
packages/agent-core/src/agent-loop.ts(1936 行)把主循环拆成三层,签名差异就是职责差异:
agentLoop()(第 135 行)与agentLoopContinue():返回EventStream<AgentEvent, AgentMessage[]>,内部void runAgentLoop(...)并用.then(stream.end).catch(pushLoopFailure)挂接。面向「要流」的调用方。runAgentLoop()(第 213 行):接受一个AgentEventSink(第 52 行,(event: AgentEvent) => Promise<void> | void),自己先发agent_start/turn_start,把 prompt 逐条以message_start+message_end发出去,然后委托runLoop,返回newMessages。面向「要事件但不要流对象」的调用方。runLoop()(第 298 行):真正的while (true),不返回消息,只把新消息推进调用方给的数组。面向测试与需要精确控制事件顺序的场景。
runAgentLoopContinue()(第 240 行)是恢复路径:它不追加新 prompt,但会先校验 context.messages.at(-1) 存在且不是 assistant,否则抛 TranscriptNotContinuableError——因为「最后一条是 assistant」意味着上一轮还没落地,继续跑会得到悬空的 tool call。
二、一轮的六个阶段
runLoop 的骨架是双层循环:外层 while (true)(第 351 行)在 getFollowUpMessages() 有货时才继续;内层 while (hasMoreToolCalls || pendingMessages.length > 0)(第 355 行)处理工具调用与插话。内层一轮的顺序是:
- tick 开始:非首轮时发
turn_start(第 361 行)并把turnOpen置回 true。 - 注入 pending / steering 消息(第 368 到 380 行):队列里若是 user 消息,先把
turnTainted清掉,再逐条message_start+message_end并同时推进 currentContext 与 newMessages。 - 流式请求模型:
streamAssistantResponse()(第 387 行,定义在第 530 行),结果 push 进 newMessages。 - 按 stopReason 分流(第 398 行):
error或aborted直接发turn_end后agent_end返回。 - 执行工具(仅当
stopReason === "toolUse"且真有 toolCall,第 413 行):executeToolCalls()(第 633 行)返回一个ExecutedToolCallBatch,字段是messages/steeringMessages/terminate/terminateRun/intervention。注意注释明说「只有 completed 的 toolUse turn 才派发;length / stop 可能带着半截流式块」,所以第 408 行先做了一次content.filter(toolCall)。 - 收尾决策:先发
turn_end(第 437 行),然后依序判断terminateRun(第 442 到 461 行,硬终止)、config.prepareNextTurn()(第 469 行,可换模型与 thinkingLevel)、config.shouldStopAfterTurn()(第 493 行)、最后从getSteeringMessages取下一批 pending。
内层跑不动了,外层才问一次 config.getFollowUpMessages()(第 512 行);返回空数组就 break,随后发 agent_end(第 523 行)。
三、边界不是轮次,而是时间
真正的硬边界只有一个:DEFAULT_AGENT_TIMEOUT_SECONDS = 48 * 60 * 60(src/agents/timeout.ts:13,即 48 小时)。同文件里 NO_TIMEOUT_MS = MAX_TIMER_TIMEOUT_MS 是「不限时」哨兵,配置写 timeoutSeconds: 0 会走这个哨兵;注释补充说明即使在这个哨兵下,LLM idle watchdog 仍负责存活检测。
对比之下,OpenClaw 侧接线的部分(src/agents/embedded-agent-runner/run/ 下按阶段切分的 attempt-*.ts、src/agents/agent-command.ts(730 行)、src/agents/harness/builtin-openclaw.ts:createOpenClawAgentHarness(第 82 行))都不引入轮次上限——它们各自负责阶段编排、超时准备与恢复。唯一透传 max_turns 的地方是 CLI 后端契约(src/agents/cli-output-contracts.ts:29),那是外层 CLI 的产物字段,不是内核约束。
这个取舍的意图很明确:轮次上限会截断合法的长任务(例如长跑迁移、多文件重构),而循环检测能区分「卡住」与「在干活」。
四、三重护栏:阈值、检测器、硬终止
第一重是阈值常量。TOOL_LOOP_WARNING_THRESHOLD = 10(src/agents/tool-loop-thresholds.ts:1),并且被封装成 resolveToolLoopWarningThreshold()(第 3 行)。注释记录了一个刻意的设计收缩:「Numeric loop tuning was retired in #111382. Keep every admission path on the same built-in threshold so policy rewrites cannot drift from detection.」——即这个阈值不允许被策略覆盖,否则准入路径与检测路径会漂移。
第二重是检测器。src/agents/tool-loop-detection.ts 定义 LoopDetectorKind 为六个字面量(第 28 到 34 行):generic_repeat、argument_churn、unknown_tool_repeat、known_poll_no_progress、global_circuit_breaker、ping_pong。相关常量:TOOL_CALL_HISTORY_SIZE = 30(第 49 行)、UNKNOWN_TOOL_THRESHOLD = 10(第 50 行)、CRITICAL_THRESHOLD = 20(第 51 行)。检测结果分成 { stuck: false } 与 { stuck: true, level: "warning" | "critical", detector, count, message, ... } 两态(第 36 到 47 行),入口是 detectToolCallLoop()(第 510 行),配套记账是 recordToolCall()(第 680 行)与 recordToolCallOutcome()(第 710 行)。
第三重是硬终止。critical 命中的信息经 executeToolCalls 的返回值回到主循环:executedToolBatch.intervention 存在时把 toolLoopRecoveryState.criticalToolLoopSeen 置为 true(第 426 行),terminateRun 被置位后主循环在第 442 行接管——推入一条 content 为 TOOL_LOOP_RECOVERY_TERMINATED_MESSAGE 的失败消息,补一组 turn_start / message_start / message_end / turn_end,然后发 agent_end 返回。那条消息常量在 agent-loop.ts:72,文案是「OpenClaw stopped this run because tool-loop recovery encountered another critical loop. No blocked tool action was executed.」——注意 criticalToolLoopSeen 是跨轮累积在 toolLoopRecoveryState 上的(第 312 行初始化),所以「二次命中」才是终止条件。
五、事件流:内核合同与渠道映射
内核的事件合同是 AgentEvent(packages/agent-core/src/types.ts:600)这个字面量联合,共十种:
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
| { type: "message_start"; message: AgentMessage }
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
| { type: "tool_execution_start"; toolCallId; toolName; args; hideFromChannelProgress? }
| { type: "tool_execution_update"; toolCallId; toolName; args; partialResult; hideFromChannelProgress? }
| { type: "tool_execution_end"; toolCallId; toolName; result; isError; executionStarted?; errorKind? }
agentLoop 用它构造 EventStream:createAgentStream()(第 267 行)把 agent_end 同时设为终止条件与返回值提取器,流本身的实现来自 @openclaw/ai(第 54 行 EventStreamConstructor = LlmEventStream)。
OpenClaw 侧做两次转译。第一次是 src/agents/embedded-agent-subscribe.ts:subscribeEmbeddedAgentSession(第 51 行)把内核事件映射成带 stream 字段的渠道事件,已核对到的取值包含 "usage"(第 215 行)与 "thinking"(embedded-agent-subscribe.stream-rendering.ts:556),并直接透出 tool_execution_start / tool_execution_end(第 575、585、596 行)。第二次是 Gateway 广播:src/gateway/server-broadcast.ts:createGatewayBroadcaster(第 204 行)把事件推给订阅方,Gateway 侧的事件常量在 src/gateway/events.ts。
六、并发:per-session lane 与 transcript 写者校验
同一会话的两次运行不能真并行,否则 transcript 会交错。src/agents/embedded-agent-runner/run/lane-controller.ts 的 createEmbeddedRunLaneController()(第 29 行)接两个 lane 名字:globalLane(第 32 行)与 sessionLane(第 34 行),实际入队走 enqueueCommandInLane,并配一套 lane 级超时(resolveEmbeddedRunLaneTimeoutMs、withEmbeddedRunLaneTimeout,来自 ./lane-runtime.js)。per-session lane 保证同会话串行;可选 global lane 再给全局限流。
第二道是乐观写者校验。运行开始时记录 activeWriterRunId,写 transcript 时带上 expectedWriterRunId 校验(src/agents/cli-runner/types.ts:112 定义可选字段;src/agents/embedded-agent-runner/run/lane-controller.ts:162 在抢占时把 writerClaim.expectedWriterRunId 一并传下去)。效果是被抢占的旧 run 无法把陈旧数据提交进 transcript,只能拿到写失败。
七、子 agent:有限额,有预算
子 agent 实现在 src/agents/subagents/ 下,按 registry/(注册与生命周期)、announce/(结果播报)、completion/(完成判定与投递)、swarm/(扇出调度)四组目录组织。对模型暴露的工具定义在 src/agents/tool-catalog.ts 的 sessions 段:sessions_spawn(第 225 行)、agents_wait(第 233 行)、sessions_yield(第 241 行)、subagents(第 249 行)。
限额集中在 src/config/agent-limits.ts:DEFAULT_SUBAGENT_MAX_CONCURRENT = 8(第 24 行)、DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT = 5(第 26 行)、DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES = 60(第 28 行)、DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1(第 30 行)。最后一条的注释是「Keep depth-1 subagents as leaves unless config explicitly opts into nesting」——默认子 agent 是叶子节点,不允许再派子 agent,除非显式开嵌套。
八、harness 可插拔:内核不知道 OpenClaw 是什么
packages/agent-core 里没有一处引用渠道、配置或插件——它只知道 AgentMessage、AgentTool、AgentLoopConfig。把「OpenClaw 的业务语义」接进这个内核的,是 src/agents/harness/ 这一层。
默认实现是 src/agents/harness/builtin-openclaw.ts:createOpenClawAgentHarness()(第 82 行),配套的身份判定是 isBuiltInOpenClawAgentHarness()(第 127 行)。同目录还有 registry.ts(harness 注册表)、selection.ts(按条件挑 harness)、policy.ts(策略面)、types.ts(契约)。
AgentLoopConfig 上那几个回调——prepareNextTurn、shouldStopAfterTurn、beforeToolBatch、getSteeringMessages、getFollowUpMessages——就是这条 seam 的接口。内核负责「什么时候调用」,harness 负责「调用时做什么」:换模型、注入压缩、判定该不该停,全部下沉到 harness 侧。
于是 OpenClaw 侧的接线可以做到很厚也不污染内核:src/agents/agent-command.ts(730 行)负责命令层编排,src/agents/embedded-agent-runner/run/ 下按阶段切出一批 attempt-*.ts(例如 attempt-bootstrap-prepare.ts、attempt-system-prompt.ts、attempt-timeout-prepare.ts、attempt-exec-approval-continuation.ts、attempt-stop-reason-recovery.ts、attempt-sessions-yield.ts),每个文件只管一个阶段。
这个拆法有一个可观测的收益:内核测试(packages/agent-core/src/agent-loop.test.ts)可以不引入任何 OpenClaw 依赖就跑完整的循环语义,包括循环检测介入与终止路径。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 事件回调类型 | packages/agent-core/src/agent-loop.ts: AgentEventSink |
第 52 行;(event) => void | Promise<void> |
| 终止文案常量 | packages/agent-core/src/agent-loop.ts: TOOL_LOOP_RECOVERY_TERMINATED_MESSAGE |
第 72 行 |
| 流式外壳 | packages/agent-core/src/agent-loop.ts: agentLoop |
第 135 行;返回 EventStream |
| 事件型入口 | packages/agent-core/src/agent-loop.ts: runAgentLoop |
第 213 行;先发 agent_start/turn_start |
| 续跑入口 | packages/agent-core/src/agent-loop.ts: runAgentLoopContinue |
第 240 行;末条是 assistant 则抛错 |
| 主循环 | packages/agent-core/src/agent-loop.ts: runLoop |
第 298 行;外层 351、内层 355 |
| 插话注入 | packages/agent-core/src/agent-loop.ts: 第 368-380 行 |
user 消息会清 turnTainted |
| 工具执行分发 | packages/agent-core/src/agent-loop.ts: executeToolCalls |
第 633 行;批量前置第 645 行 |
| 硬终止分支 | packages/agent-core/src/agent-loop.ts: 第 442-461 行 |
依 terminateRun 推终止消息后 agent_end |
| 换模型 / 停轮钩子 | packages/agent-core/src/agent-loop.ts: prepareNextTurn / shouldStopAfterTurn |
第 469 / 493 行 |
| 事件联合类型 | packages/agent-core/src/types.ts: AgentEvent |
第 600 行;共 10 种,含 message_end / tool_execution_update |
| 事件流构造 | packages/agent-core/src/agent-loop.ts: createAgentStream |
第 267 行;agent_end 既是终止也是返回值 |
| 墙钟超时 | src/agents/timeout.ts: DEFAULT_AGENT_TIMEOUT_SECONDS |
第 13 行;486060 秒;0 走不限时哨兵 |
| 循环告警阈值 | src/agents/tool-loop-thresholds.ts: TOOL_LOOP_WARNING_THRESHOLD |
第 1 行;值 10,且不可被策略覆盖 |
| 循环检测器 | src/agents/tool-loop-detection.ts: LoopDetectorKind |
第 28-34 行;6 种,含 unknown_tool_repeat |
| 检测入口 | src/agents/tool-loop-detection.ts: detectToolCallLoop |
第 510 行;CRITICAL_THRESHOLD 20 在第 51 行 |
| 检测记账 | src/agents/tool-loop-detection.ts: recordToolCall / recordToolCallOutcome |
第 680 / 710 行 |
| CLI 后端 max_turns | src/agents/cli-output-contracts.ts: 第 29 行 |
仅是 CLI 产物字段,不是内核上限 |
| 渠道事件桥接 | src/agents/embedded-agent-subscribe.ts: subscribeEmbeddedAgentSession |
第 51 行;stream 取值含 usage / thinking |
| Gateway 广播 | src/gateway/server-broadcast.ts: createGatewayBroadcaster |
第 204 行 |
| lane 控制器 | src/agents/embedded-agent-runner/run/lane-controller.ts: createEmbeddedRunLaneController |
第 29 行;globalLane 32、sessionLane 34 |
| transcript 写者校验 | src/agents/embedded-agent-runner/run/lane-controller.ts: 第 162 行 |
writerClaim.expectedWriterRunId |
| 子 agent 限额 | src/config/agent-limits.ts: DEFAULT_SUBAGENT_* |
第 24/26/28/30 行;depth 默认 1 |
| 子 agent 工具 | src/agents/tool-catalog.ts: sessions_spawn |
第 225 行;agents_wait 233、sessions_yield 241、subagents 249 |
| harness 工厂 | src/agents/harness/builtin-openclaw.ts: createOpenClawAgentHarness |
第 82 行;身份判定第 127 行 |
| harness 注册与选择 | src/agents/harness/registry.ts |
与 selection.ts / policy.ts 同层 |
| 阶段化 attempt | src/agents/embedded-agent-runner/run/attempt-stop-reason-recovery.ts |
与 attempt-system-prompt / attempt-timeout-prepare 并列 |
关键取舍
不设 maxTurns,代价是必须把「卡住」做成可判定问题。
轮次上限是廉价但粗暴的兜底;去掉它意味着必须投入六个检测器、两级判级、跨轮累积的 criticalToolLoopSeen 以及一份 30 条的调用历史。收益是长任务不会被合法地腰斩,且终止时能给出「哪个检测器、重复了几次」的归因,而不是一句「超过最大轮次」。
阈值硬编码不可覆盖,代价是失去了按 session 调参的能力。resolveToolLoopWarningThreshold() 的注释直说这是为了「policy rewrites cannot drift from detection」——准入与检测必须用同一个数。代价是容忍度高的场景(大量重复 ls)无法放宽,只能靠 allow / deny 工具策略从源头减少重复。
三层函数而不是一个可配置入口,代价是调用方要做选择。
想要流就用 agentLoop,想要事件就传 sink 给 runAgentLoop,只关心消息数组就直接用 runLoop。收益是内核不必在同一个函数里既管 EventStream 生命周期又管消息累积;代价是同一条逻辑有三份签名要维护,且 pushLoopFailure 这类收尾逻辑只在第一层存在。
per-session lane 串行,代价是同会话吞吐被钉死为 1。
会话内的 transcript 是线性结构,并行写必然交错;lane 把「同会话一次跑一个」变成结构性保证,再用 expectedWriterRunId 兜住抢占。代价是用户在同一会话里连发两条消息时,第二条必须排队,体验上的「立刻响应」只能靠插话注入(steering)而不是并行执行来达成。
子 agent 默认深为 1(叶子),代价是无法自然表达递归分解。DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 1 加上每 agent 最多 5 个直系子代、全局最多 8 个并发,把扇出限制在可审计的两层。收益是成本与取消路径都可控(不需要设计任意深的取消传播);代价是「分而治之再分而治之」的算法模式需要显式开嵌套并自行承担失控风险。
自测题
agentLoop的.catch(pushLoopFailure)与runAgentLoop直接await的差异,会体现在哪些事件序列上?如果调用方只用runAgentLoop,循环内抛错时它还能拿到agent_end吗?runAgentLoopContinue拒绝「最后一条是 assistant」的上下文。请构造一个会命中这个校验的真实场景,并说明为什么此时继续跑会产生悬空 tool call。- critical 循环检测的终止条件是「二次命中」而不是「首次命中」。请说明
criticalToolLoopSeen为什么必须跨轮累积在toolLoopRecoveryState上,而不能是本轮局部变量。 TOOL_CALL_HISTORY_SIZE = 30与CRITICAL_THRESHOLD = 20的差距意味着什么?如果历史窗口缩到 10,哪个检测器会最先失效?agentLoopContinue产出的事件里没有 prompt 的message_start/message_end,而runAgentLoop有。这个差异对「重放 transcript 得到相同事件序列」的测试意味着什么?你会怎么处理?