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 二次命中后的硬终止。这一章要回答:

  1. agentLoop / runAgentLoop / runLoop 三层为什么要分开;
  2. 一轮(turn)到底有哪几个阶段,prepareNextTurn 与 shouldStopAfterTurn 各自在哪切;
  3. 循环检测的结果是怎么变成「终止整轮」的;
  4. 事件流从内核 AgentEvent 一路到渠道 stream 的映射关系。

一、三层函数:谁负责返回流,谁负责返回消息

packages/agent-core/src/agent-loop.ts(1936 行)把主循环拆成三层,签名差异就是职责差异:

runAgentLoopContinue()(第 240 行)是恢复路径:它不追加新 prompt,但会先校验 context.messages.at(-1) 存在且不是 assistant,否则抛 TranscriptNotContinuableError——因为「最后一条是 assistant」意味着上一轮还没落地,继续跑会得到悬空的 tool call。

二、一轮的六个阶段

runLoop 的骨架是双层循环:外层 while (true)(第 351 行)在 getFollowUpMessages() 有货时才继续;内层 while (hasMoreToolCalls || pendingMessages.length > 0)(第 355 行)处理工具调用与插话。内层一轮的顺序是:

  1. tick 开始:非首轮时发 turn_start(第 361 行)并把 turnOpen 置回 true。
  2. 注入 pending / steering 消息(第 368 到 380 行):队列里若是 user 消息,先把 turnTainted 清掉,再逐条 message_start + message_end 并同时推进 currentContext 与 newMessages。
  3. 流式请求模型:streamAssistantResponse()(第 387 行,定义在第 530 行),结果 push 进 newMessages。
  4. 按 stopReason 分流(第 398 行):error 或 aborted 直接发 turn_end 后 agent_end 返回。
  5. 执行工具(仅当 stopReason === "toolUse" 且真有 toolCall,第 413 行):executeToolCalls()(第 633 行)返回一个 ExecutedToolCallBatch,字段是 messages / steeringMessages / terminate / terminateRun / intervention。注意注释明说「只有 completed 的 toolUse turn 才派发;length / stop 可能带着半截流式块」,所以第 408 行先做了一次 content.filter(toolCall)。
  6. 收尾决策:先发 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 个并发,把扇出限制在可审计的两层。收益是成本与取消路径都可控(不需要设计任意深的取消传播);代价是「分而治之再分而治之」的算法模式需要显式开嵌套并自行承担失控风险。

自测题

  1. agentLoop 的 .catch(pushLoopFailure) 与 runAgentLoop 直接 await 的差异,会体现在哪些事件序列上?如果调用方只用 runAgentLoop,循环内抛错时它还能拿到 agent_end 吗?
  2. runAgentLoopContinue 拒绝「最后一条是 assistant」的上下文。请构造一个会命中这个校验的真实场景,并说明为什么此时继续跑会产生悬空 tool call。
  3. critical 循环检测的终止条件是「二次命中」而不是「首次命中」。请说明 criticalToolLoopSeen 为什么必须跨轮累积在 toolLoopRecoveryState 上,而不能是本轮局部变量。
  4. TOOL_CALL_HISTORY_SIZE = 30 与 CRITICAL_THRESHOLD = 20 的差距意味着什么?如果历史窗口缩到 10,哪个检测器会最先失效?
  5. agentLoopContinue 产出的事件里没有 prompt 的 message_start / message_end,而 runAgentLoop 有。这个差异对「重放 transcript 得到相同事件序列」的测试意味着什么?你会怎么处理?

进入 keel 阅读