随笔
Claude Code 深度源码解读 04:Query Loop 是长任务恢复状态机
把 API retry、tool follow-up、missing tool_result 修补、compact 与 Stop Hook 放回同一状态机。
简化版入口:Query Loop / Recovery。
我的设问与回应
设问 1:长任务为什么不能只靠递归调用模型?
回应答案: 因为长任务需要跨 iteration 保存 messages、ToolUseContext、compact tracking、turn count、transition、恢复计数等状态。queryLoop 用显式 State 管理这些迁移。
证据与证明路径: 证据来自第 4 期对 State 字段、context shaping、follow-up 状态迁移和 terminal reason 的拆解。
还值得继续学习或反思: 要学习的是怎样把所有 continue reason 结构化,否则新增恢复路径时很容易遗漏状态。
设问 2:compact 失败是不是就直接任务失败?
回应答案: 不应该直接等同。Claude Code 区分 proactive auto compact、reactive compact、context collapse、max_output recovery,并给恢复机制设置防抖和失败熔断。
证据与证明路径: 证据来自 autoCompactIfNeeded、reactive recovery、MAX_OUTPUT_TOKENS_RECOVERY_LIMIT、consecutive compact failure breaker 等机制。
还值得继续学习或反思: compact 是恢复机制,但恢复机制本身也必须有停止条件。
源码调研主体
本组解读基于冻结源码快照进行教育、防御和架构研究。
本期主题:Harness 如何通过 query loop、follow-up、compaction、hook retry 与错误恢复,让长任务不轻易中断或跑飞。
1. 研究问题
本期围绕一个问题展开:
Claude Code Harness 为什么能在长任务中持续推进,并在工具调用、上下文溢出、输出截断、模型 fallback、用户中断和 Stop Hook 纠偏之间保持轨迹一致?
普通 coding agent 的脆弱点通常出现在“单次模型调用结束”之后:模型调用可能产生工具调用、工具结果需要再喂回模型、上下文可能接近窗口上限、API 可能重试或 fallback、用户可能中断、外部 Hook 可能要求补救。如果这些状态被当成简单递归或 ad hoc retry,容易出现重复工具结果、孤儿 tool_use、错误被提前暴露、Hook 死循环、上下文压缩后历史断裂等问题。
Claude Code 的主循环不是单纯的“问模型一次”。它维护一组跨 iteration 的状态,把每次 streaming 输出、工具结果、附件、compact 边界、恢复提示和停止条件归并为下一轮输入,直到满足明确的 terminal reason。
2. 对应总览节点
对应第 1 期总览图中的节点:
Query LoopModel StreamingTool Executiontool_resultContext CompactionStop HooksTranscript / Session StateTelemetry / Recovery Events
这部分机制参考前文「Query Loop / Recovery 机制图」。
3. 核心流程图
路线图给出的代表链路是:
streaming -> follow-up -> stop hook -> compact -> recovery源码中的实际关系不是严格线性。更准确地说,queryLoop 内部存在两个相互嵌套的循环:
- API attempt loop:处理模型 streaming、fallback model、API retry 抛错后的局部重试。
- Query iteration loop:处理工具 follow-up、context shaping、compact、Stop Hook blocking retry、token budget continuation 和 terminal reason。
这部分机制参考前文「Query Loop / Recovery 机制图」。
图例说明:图左上方的虚线框是独立图例区,不属于主流程。橙色框表示 API attempt loop,范围是一次模型请求内部的 streaming、fallback 和 API throw 处理;蓝色框表示 Query iteration loop,范围是跨模型请求的 context shaping、tool follow-up、compact、hook retry 和 terminal reason。关键点是:compact 通常在模型调用前进行;reactive compact 在 withheld 错误之后进行;Stop Hook 只在没有 tool_use 且最后一条不是 API error 的完成路径中评估。它们共同服务于 query loop,但不是每轮都必经。
4. 关键源码入口
| 职责 | 文件 |
|---|---|
| query 主循环、跨 iteration 状态、terminal reason | src/query.ts |
| query 依赖注入边界:模型调用、microcompact、autocompact、uuid | src/query/deps.ts |
| query 入口快照配置:streaming tool execution、summary、fast mode 等 | src/query/config.ts |
| Stop / SubagentStop / teammate 相关 hook 后处理 | src/query/stopHooks.ts |
| token budget continuation 判定 | src/query/tokenBudget.ts |
| streaming tool executor 与工具结果补齐 | src/services/tools/StreamingToolExecutor.ts |
| 非 streaming 工具 orchestration 兜底 | src/services/tools/toolOrchestration.ts |
| API retry、529/429、fallback model、persistent retry | src/services/api/withRetry.ts |
| proactive auto compact 阈值、失败熔断、session memory compact 优先级 | src/services/compact/autoCompact.ts |
| compact 结果重建为 query 可继续消费的消息 | src/services/compact/compact.ts |
| tool result size budget 与替换持久化 | src/utils/toolResultStorage.ts |
| transcript、content replacement 和 session 持久化支撑 | src/utils/sessionStorage.ts |
| SDK / headless query 生命周期封装 | src/QueryEngine.ts |
4.1 主线与场景特殊处理分类
| 机制/模块 | 分类 | 为什么这样归类 |
|---|---|---|
query() / queryLoop() | 主线机制 | 所有模型调用、工具 follow-up、停止条件和恢复路径都由该循环统一推进 |
State 跨 iteration 状态 | 主线机制 | 保存 messages、toolUseContext、compact tracking、max output recovery、turn count、transition 等恢复必需信息 |
needsFollowUp / toolUseBlocks | 主线机制 | 用模型流式输出中是否出现 tool_use 判断是否进入工具回灌循环;源码注释明确 stop_reason === tool_use 不可靠 |
StreamingToolExecutor | 主线机制 + 场景增强 | feature gate 开启时是主工具执行路径;关闭时回退到 runTools(),两者都必须产生匹配 tool_result |
runTools() | 主线机制兜底 | streaming executor 关闭时按并发安全性分批执行工具,保持工具结果和上下文更新 |
yieldMissingToolResultBlocks() | 主线机制 + 恢复支撑 | 在 fallback、throw、abort 等异常路径中为已输出的 tool_use 合成错误 tool_result,防止 API 轨迹断裂 |
getMessagesAfterCompactBoundary() | 主线机制 | 每次模型调用前只取 compact boundary 后的可发送历史,保证压缩后的对话继续有效 |
applyToolResultBudget() | 可观测/恢复支撑 | 控制聚合工具结果体积,必要时持久化 content replacement,避免工具输出挤爆上下文 |
microcompactMessages() | 场景增强 + 恢复支撑 | 每轮模型调用前可能进行轻量压缩;是否实质压缩取决于缓存/特性和消息形态 |
autoCompactIfNeeded() | 场景增强 + 恢复支撑 | 只在自动 compact 开启且 token 阈值满足时触发;失败有 consecutive failure 熔断 |
reactiveCompact | 场景增强 + 恢复支撑 | 只在相关 feature 编译/开启且 API 返回 prompt-too-long 或 media size 类 withheld 错误后尝试 |
contextCollapse | 场景增强 + 恢复支撑 | 只在 context collapse feature 开启时投影或 drain staged collapse,不是默认主线 |
handleStopHooks() | 安全/治理横切 + 恢复支撑 | 在正常完成路径评估 Stop Hook;blocking error 会作为 meta user message 回到 query loop 促使模型补救 |
executeStopFailureHooks() | 可观测/恢复支撑 | API error 完成时 fire-and-forget,避免把无效模型响应交给 Stop Hook 造成死循环 |
checkTokenBudget() | 场景增强 + 恢复支撑 | TOKEN_BUDGET feature 下根据预算进度注入 continuation nudge;对 subagent 禁用 |
maxTurns | 安全/治理横切 | 工具 follow-up 后检查 turn 数,防止无限工具循环 |
withRetry() | 主线支撑 + 场景增强 | API 调用层的 retry/fallback 支撑;persistent retry、fast mode、foreground 529 规则按场景触发 |
pendingMemoryPrefetch / skill prefetch | 场景增强 | 异步预取只在结算并可消费时注入附件,不是动态运行反馈必经路径 |
| tool use summary | 场景增强 + 可观测支撑 | 环境变量开启、非 subagent、工具批次完成后异步生成,用于 UI/可读性,不影响主循环成立 |
| telemetry / queryCheckpoint / transition reason | 可观测/恢复支撑 | 支撑定位恢复路径和性能,但不是模型语义输入的必需部分 |
| remote / SDK compaction status adapter | 场景增强 + 可观测支撑 | 面向 remote 或 SDK 的状态映射,不是本地 query loop 的必经语义链路 |
5. 机制拆解
5.1 query loop 用显式 State 替代隐式递归
src/query.ts 中 queryLoop() 初始化一个跨 iteration 的 State:
messages:下一次模型调用的历史基础。toolUseContext:工具、权限、文件状态、agent id、abort controller 等运行上下文。autoCompactTracking:compact 是否发生、compact 后 turn 计数、连续失败次数。maxOutputTokensRecoveryCount:输出截断恢复次数,避免无限续写。hasAttemptedReactiveCompact:reactive compact 单次防抖,避免 413 compact 死循环。maxOutputTokensOverride:输出上限升级 retry 的临时覆盖。pendingToolUseSummary:工具摘要异步结果,下一 iteration 再消费。stopHookActive:标记 Stop Hook blocking retry,避免同一机制反复无界触发。turnCount与transition:记录 query chain 进度和上一轮继续原因。
这使得每个 continue 都是一次有记录的状态迁移,而不是在多个 catch/finally 中散落地修改局部变量。
5.2 每轮模型调用前先做 context shaping
每次进入 while iteration 后,主循环先从当前历史中生成 messagesForQuery,并依次处理:
getMessagesAfterCompactBoundary():只发送 compact boundary 后的有效历史。applyToolResultBudget():对过大的工具结果做替换或持久化记录。snipCompactIfNeeded():在HISTORY_SNIPfeature 下做历史裁剪。microcompactMessages():轻量 compact。contextCollapse.applyCollapsesIfNeeded():在 context collapse feature 下投影 collapsed view。autoCompactIfNeeded():达到阈值时主动 compact。
这说明 compact 不是“模型回答后才补救”的单一动作。主线每轮都会检查上下文形态,但真正 compact 是条件触发。
5.3 streaming 期间即收集 tool_use,并可并行执行
模型 streaming 返回的每条 assistant message 都被加入 assistantMessages。主循环不依赖 stop_reason === 'tool_use',而是扫描 content block:
- 出现
tool_use时加入toolUseBlocks。 - 将
needsFollowUp设为 true。 - 如果 streaming executor 可用,立即
addTool(),并在 streaming 过程中不断 drain completed results。
这个设计减少“等模型完整结束后才开始工具”的空窗,同时保留后续 getRemainingResults(),确保所有工具调用都有结果。
5.4 fallback 与异常路径必须修补 tool_result 配对
模型 fallback、streaming fallback、异常 throw 和用户 abort 都可能发生在 assistant 已经输出 tool_use 之后。此时如果直接结束,下一次 API 调用会看到不完整的 tool trajectory。
yieldMissingToolResultBlocks() 是关键兜底:它为已收集的 assistant tool use block 生成 is_error: true 的 tool_result。在 fallback 路径中,旧的 assistant/tool result 会被 tombstone 或清空,streaming executor 也会 discard 并重建,避免旧 tool_use_id 的 orphan result 泄漏到新模型尝试中。
5.5 follow-up 不是递归调用,而是状态迁移
当 needsFollowUp 为 true,主循环执行工具后会:
- 收集工具结果到
toolResults。 - 处理 Hook 停止 continuation 的 attachment。
- 注入 queued command、memory、skill discovery 等附件。
- 刷新工具池,允许新连接 MCP server 在下一轮可见。
- 检查
maxTurns。 - 将
messagesForQuery + assistantMessages + toolResults写回state.messages。 - 设置
transition: { reason: 'next_turn' }并继续 while。
这里的 follow-up 本质是“把工具结果变成下一次模型输入”,不是独立的新用户回合。
5.6 Stop Hook 是完成路径上的纠偏器,不是所有错误路径的兜底
当本轮没有 tool_use,query loop 进入 completion path。Stop Hook 的位置很谨慎:
- 如果最后一条是 prompt-too-long / media size / max output tokens 等 withheld recoverable error,先走恢复路径。
- 如果最后一条是 API error,跳过 Stop Hook,改为
executeStopFailureHooks()。 - 只有模型产生了有效完成响应,才调用
handleStopHooks()。
Stop Hook 的结果有三类:
| 结果 | query loop 行为 |
|---|---|
preventContinuation | 直接返回 stop_hook_prevented |
blockingErrors | 将 blocking error 作为 meta user message 加入历史,设置 stopHookActive: true,继续 query loop |
| 无阻断 | 继续检查 token budget 或返回 completed |
这避免了一个常见死循环:API error 本身不是有效模型回答,如果再让 Stop Hook 注入 blocking message,会变成 error -> hook blocking -> retry -> error 的循环。
5.7 prompt-too-long 与 media error 使用 withheld recovery
源码在 streaming 阶段会暂时 withheld 某些可恢复错误,不立即 yield 给上层:
- prompt-too-long / 413 类错误。
- media size 类错误。
- max output tokens 错误。
completion path 再决定是否恢复:
- prompt-too-long 先尝试 context collapse drain。
- 若仍失败,尝试 reactive compact。
- media size 跳过 collapse,尝试 reactive compact 的 strip/retry。
- 如果恢复失败,才 yield withheld error 并终止。
这个策略的关键不是“隐藏错误”,而是避免 SDK/remote 调用方一看到中间错误就终止,而 query loop 实际还能自愈。
5.8 max_output_tokens 恢复分两层
max_output_tokens 触发后,query loop 先尝试更高 maxOutputTokensOverride 的同请求 retry。若升级后仍触顶,则最多注入 3 次 meta user message,要求模型直接续写、拆小剩余工作。
这与工具 follow-up 不同:它不是工具结果驱动,而是用 meta message 延续被截断的 assistant trajectory。MAX_OUTPUT_TOKENS_RECOVERY_LIMIT 防止无限续写。
5.9 API retry 层处理容量、认证、连接和 fallback
src/services/api/withRetry.ts 是 query loop 下方的 API 稳定性层:
- 默认最大重试次数为 10。
- 429 / 529 可根据 query source、fast mode、persistent retry 等策略重试。
- OAuth 401、token revoked、云厂商认证错误、stale keep-alive connection 会刷新 client 或禁用 keep-alive 后重试。
- 连续 529 达到阈值并存在 fallback model 时抛出
FallbackTriggeredError。 - persistent retry 会分块 sleep 并周期性 yield API retry system message,避免无人值守会话被宿主判定为空闲。
query loop 捕获 FallbackTriggeredError 后切换模型、清理旧 partial messages、修补缺失 tool result,然后重新进入 API attempt。
5.10 compact 也有失败熔断
autoCompactIfNeeded() 在达到阈值时先尝试 session memory compaction,再走常规 compactConversation()。如果 compact 失败,会把连续失败次数传回 query loop。达到 MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES 后停止继续尝试自动 compact,避免不可恢复的超长上下文在每轮都消耗 API。
这也是“稳定”的一部分:恢复机制本身必须有停止条件。
6. 对稳定解决问题能力的贡献
6.1 轨迹完整性
每个 tool_use 都必须有对应 tool_result,即使 fallback、abort 或 throw 发生在中途。这样模型 API 的对话结构保持合法,后续恢复不会建立在破损 transcript 上。
6.2 长任务可继续
工具 follow-up、Stop Hook blocking retry、token budget continuation、max output recovery 都通过同一个 State 迁移进入下一 iteration。长任务不是靠模型“一次说完”,而是由 Harness 管理多轮受控推进。
6.3 错误先分类,再决定是否暴露
可恢复错误会被暂时 withheld,先尝试 collapse、compact、输出上限升级或续写。不可恢复或恢复失败后才向上层暴露。这样减少了 SDK/remote 因中间错误提前终止的概率。
6.4 恢复机制有边界
maxTurns、MAX_OUTPUT_TOKENS_RECOVERY_LIMIT、reactive compact guard、autocompact consecutive failure breaker、Stop Hook API-error skip 都是防止恢复机制变成无限循环的边界。
6.5 场景增强不会污染主线
memory prefetch、skill discovery、tool summary、task summary、context collapse、reactive compact、persistent retry 等都按 feature、环境变量、query source 或 agent 类型触发。主循环能吸收这些增强,但不把它们写死为每次必经路径。
7. 风险、边界与待验证问题
| 风险/边界 | 影响 | 防御性解读 |
|---|---|---|
| query loop 继续条件很多 | 新增恢复路径时容易遗漏某个 state 字段,导致重复 compact、遗漏 tool result 或错误 transition | transition 和 QueryDeps 是向可测试状态机收敛的迹象,后续应继续收窄副作用边界 |
| Stop Hook blocking retry 能注入新 meta message | Hook 配置错误可能导致模型反复补救 | 源码已有 stopHookActive、API error skip 和 preventContinuation;第5期需进一步验证 Hook 层防重入语义 |
| reactive compact 文件在当前普通文件列表中不可见 | 该模块由 feature-gated require 引入,当前冻结快照的公开路径不一定包含其源码 | 本期只根据 query.ts 调用边界描述行为,不把内部策略写成已验证细节 |
| context collapse 与 compact 互斥/协作复杂 | 若阈值、drain、reactive compact 顺序变化,可能影响长会话保真度 | 本期按 query.ts 和 autoCompact.ts 当前顺序记录:collapse 优先投影/恢复,auto compact 在 collapse 开启时被抑制 |
| tool result budget 会替换内容 | 模型后续看到的是替换后的摘要/占位,可能影响细节回溯 | 这是稳定性与上下文预算的权衡;需要依赖 transcript/content replacement 支撑可审计恢复 |
| persistent retry 会长时间占用会话 | 无人值守任务可恢复,但也可能延迟失败暴露 | 它只在 feature 与环境变量启用、且 transient capacity error 下触发,并周期性 yield heartbeat |
| max output recovery 依赖模型服从 meta 指令 | 截断位置不一定自然,续写可能重复或遗漏 | 通过次数限制和直接续写指令降低风险,但不是语义完整性的绝对保证 |
待验证问题:
StreamingToolExecutor在兄弟工具失败、abort、discard 后的所有 synthetic result 是否都有测试覆盖。- Stop Hook blocking retry 与
stopHookActive的防重入是否能覆盖多个 Hook 同时阻断的情况。 applyToolResultBudget()的 content replacement 在/resume、AgentTool resume、session file 三类恢复路径中的一致性。- context collapse 与 reactive compact 同时开启时,真实 413 场景下的 transition 统计是否符合源码设计。
- remote / SDK consumer 对 withheld error、compact boundary、tombstone 的处理是否与本地 REPL transcript 完全一致。
8. 下一期衔接
第 4 期说明了 query loop 如何把“模型输出、工具结果、恢复动作和停止条件”组织成稳定推进的状态机。第 5 期应聚焦 Hooks 纠偏机制本身,特别是:
UserPromptSubmit如何在问题进入前修改、阻断或补充上下文。PreToolUse/PermissionRequest如何在工具执行前介入安全边界。Stop/SubagentStop如何在完成路径上要求补救或阻止继续。- Hook 输出如何被转换为 attachment、meta user message、permission decision 或 transcript-visible summary。
第 5 期需要继续保持本系列的分类规则:Hooks 是安全/治理横切机制,不应被写成每次 query iteration 的必经主线。
9. 延伸阅读:Status、双色 Loop 与 Codex 对比
本节补充三个容易混淆的问题:Status 的真实范围、API attempt loop 与 Query iteration loop 的关系,以及 Claude Code 的 compact 恢复机制是否能真正规避 Codex 常见的上下文过长 compact 失败。
9.1 Status 的全貌:它不是 query state,而是 SDK / remote 状态信号
源码里至少有三类“状态”容易被混在一起:
| 名称 | 所在层 | 代表字段 | 作用 |
|---|---|---|---|
query loop State | src/query.ts 内部 | messages / toolUseContext / transition / turnCount | 驱动下一次 query iteration,是主循环的内部状态 |
| terminal reason | src/query.ts 返回值 | completed / prompt_too_long / max_turns / aborted_tools 等 | 告诉外层 query 为什么结束 |
SDK Status | SDK / remote 输出层 | status: 'compacting' | null,可附带 permissionMode | 给外部消费者显示“正在 compact”或“compact 已结束”,不是完整任务状态机 |
SDKStatusSchema 当前只允许两个值:'compacting' 和 null。这说明 Status 的设计很窄:它不是“running / failed / completed / waiting”的全生命周期状态,而是一个专门服务外部 UI / remote session 的瞬时信号。完整运行结果仍由 result message、stop_reason、permission_denials、usage、errors 等字段表达。
关键源码关系:
compactConversation()在 PreCompact 前调用context.setSDKStatus?.('compacting')。compactConversation()在finally中调用context.setSDKStatus?.(null),无论 compact 成功还是失败都清理状态。print.ts把setSDKStatus(status)转为 SDK system message:{ type: 'system', subtype: 'status', status }。useRemoteSession.ts收到status='compacting'后设置isCompactingRef.current = true;收到status=null或compact_boundary后视为 compact 结束。sdkMessageAdapter.ts只把status='compacting'渲染成“Compacting conversation…”;status=null不渲染。
所以,Status 的设计意图是“让远端和 SDK 调用方知道 compact 临界段正在发生”,而不是让模型、工具或 Hook 根据它做推理。
9.2 状态变迁的事件转化图
这部分机制参考前文「Query Loop / Recovery 机制图」。
这张图体现了三层转换:
query.ts产生内部 message / stream event / terminal reason。QueryEngine把内部 message 转成 SDK message,同时维护 transcript、usage、lastStopReason和最终result。- remote UI 再把 SDK
status、compact_boundary、result等消息转成 REPL 可显示的 system / assistant / user 消息。
9.3 API attempt loop 与 Query iteration loop 的差异
| 对比项 | API attempt loop | Query iteration loop |
|---|---|---|
| 位置 | query.ts 中包住 deps.callModel() 的内层 while (attemptWithFallback) | query.ts 外层 while (true) |
| 生命周期 | 一次模型请求内部,可因 fallback 重试当前请求 | 一个 agentic turn 内跨多次模型请求推进 |
| 主要输入 | 当前 messagesForQuery、system prompt、tools、model options | State.messages、ToolUseContext、compact tracking、recovery counters |
| 主要输出 | assistant stream、API error、fallback trigger | 下一轮 state 或 terminal reason |
| 典型恢复 | 529 fallback、streaming fallback、missing tool_result repair、tombstone | tool follow-up、auto compact、reactive compact、Stop Hook blocking retry、token budget continuation、maxTurns |
| 边界 | 不负责长期任务推进 | 不直接处理底层 API backoff 细节 |
一个简化判断:如果问题是“这一次 API 请求是否要重试”,通常属于 API attempt loop;如果问题是“这次工具/compact/hook 之后任务是否还要继续”,通常属于 Query iteration loop。
9.4 与 Codex compact 失败的源码级机制差异
本节 Codex 对比基于本地源码快照,重点看 codex-rs/core/src/compact.rs、compact_remote.rs、tasks/compact.rs、session/turn.rs、session/handlers.rs 以及 tui/src/chatwidget/slash_dispatch.rs。Claude Code 侧重点看 src/commands/compact/compact.ts、src/services/compact/compact.ts、src/services/compact/autoCompact.ts、src/query.ts、src/QueryEngine.ts 和 src/remote/sdkMessageAdapter.ts。
9.4.1 Claude Code 用户主动 /compact
Claude Code 的手动 /compact 入口不是直接把用户文本发给模型,而是作为本地 slash command 执行。核心链路如下:
这部分机制参考前文「Query Loop / Recovery 机制图」。
关键源码点:
| 阶段 | 源码位置 | 机制 |
|---|---|---|
| slash command 解析 | src/utils/processUserInput/processSlashCommand.tsx::processSlashCommand() | 解析 /compact,调用 getMessagesForSlashCommand(),最终执行本地命令;local command 返回 shouldQuery=false,不会再把 /compact 当普通用户 prompt 发给主模型。 |
| 有效历史选择 | src/commands/compact/compact.ts | 先调用 getMessagesAfterCompactBoundary(messages),避免重复总结已经被 compact 边界裁掉的旧历史。 |
| session memory 优先 | src/commands/compact/compact.ts | 没有自定义摘要指令时,先尝试 trySessionMemoryCompaction();成功则清缓存、标记 post-compaction、抑制 compact warning,直接返回 type:'compact'。 |
| reactive-only 手动路径 | src/commands/compact/compact.ts::compactViaReactive() | 若开启 reactive-only,手动 /compact 仍先执行 manual PreCompact hook,设置 status='compacting',再调用 reactiveCompactOnPromptTooLong()。 |
| 传统摘要路径 | src/commands/compact/compact.ts → src/services/compact/compact.ts::compactConversation() | 先 microcompactMessages(),再 compactConversation(..., isAutoCompact=false);内部设置 status='compacting',运行 PreCompact hook,生成 summary request,并处理 prompt-too-long 重试。 |
| 消息替换 | src/utils/processUserInput/processSlashCommand.tsx | 对 result.type === 'compact' 调用 buildPostCompactMessages(),把 compact 结果、slash command 输入和可见 stdout 组装成新的 post-compact message 列表,并返回 shouldQuery:false。 |
| 事件输出 | src/QueryEngine.ts、src/remote/sdkMessageAdapter.ts | compact_boundary 被转换成 SDK system message;remote UI 再显示成 Conversation compacted。status='compacting' 被 remote UI 显示为 Compacting conversation…。 |
这条链路说明:Claude Code 的手动 /compact 与自动 compact 共用 compactConversation() 的核心摘要机制,但入口层会额外处理 session memory、reactive-only、microcompact、warning suppression 和 post-compact cleanup。
9.4.2 Codex 用户主动 /compact
Codex 的手动 /compact 入口链路是:
这部分机制参考前文「Query Loop / Recovery 机制图」。
源码上,tui/src/chatwidget/slash_dispatch.rs 对 SlashCommand::Compact 会先清 token usage、设置 task running,然后发送 app_event_tx.compact();tui/src/app_command.rs 将其映射为 AppCommand::Compact;core/src/session/handlers.rs 收到 Op::Compact 后创建默认 TurnContext 并 spawn_task(..., CompactTask)。真正的 compact 选择逻辑在 core/src/tasks/compact.rs:provider 支持 remote compact 时走 compact_remote 或 compact_remote_v2,否则合成 compact prompt 走本地 compact::run_compact_task()。
这意味着 Codex 的手动 compact 是一个独立 CompactTask,不是普通 user turn 内部的一次 message 修复。它会发送 TurnStarted,运行 pre/post compact hooks,并在成功后用 replace_compacted_history() 安装 replacement history。
9.4.3 系统级自动 compact:关键机制与事件传递
两套系统都有自动 compact,但触发时机、事件边界和失败传播不同。下图用颜色区分所属机制:
这部分机制参考前文「Query Loop / Recovery 机制图」。
Codex 自动 compact 不是只有一种触发点:
| 自动触发点 | Codex 源码位置 | 机制 |
|---|---|---|
| Pre-turn token threshold | core/src/session/turn.rs::run_pre_sampling_compact() | 进入 sampling 前调用 auto_compact_token_status(),若 token_limit_reached,用 CompactionReason::ContextLimit 和 CompactionPhase::PreTurn 发起 compact。 |
| Previous-model inline compact | core/src/session/turn.rs::maybe_run_previous_model_inline_compact() | 当 compaction compatibility hash 改变,或切换到更小 context window 的模型时,用上一个模型上下文先压缩。 |
| Mid-turn token threshold | core/src/session/turn.rs post-sampling 分支 | 一轮 sampling 后若 token_limit_reached && needs_follow_up,调用 run_auto_compact(..., InitialContextInjection::BeforeLastUserMessage, CompactionPhase::MidTurn)。 |
| Remote/local implementation choice | core/src/session/turn.rs::run_auto_compact() | 自动 compact 同样按 provider 能力选择 remote_v2、remote 或 local。 |
这里和 Claude Code 的共同点是:两者都有用户主动 compact,也都有自动 compact。关键差异不在“有没有 compact”,而在 compact 失败后是否仍被主任务循环当成一等状态迁移处理。
9.4.4 Codex compact 失败路径
Codex 对 compact 失败并非没有保护,但保护边界和 Claude Code 不同:
| 失败面 | Codex 源码行为 | 对任务恢复的含义 |
|---|---|---|
本地 compact 自身 ContextWindowExceeded | core/src/compact.rs::run_compact_task_inner_impl() 在 compact 请求过大时,如果 compact input 长度大于 1,会 history.remove_first_item() 后重试;若已经无法再删,则发送 error 并返回失败。 | 有“删旧历史再试”的局部修复,但修复失败后 compact turn 失败。 |
| remote compact 前历史过大 | core/src/compact_remote.rs::trim_function_call_history_to_fit_context_window() 会优先把 tool/function output 改写成截断消息,以便 remote compact endpoint 能接住请求。 | 这是 remote compact 前的输入瘦身,不等同于 compact endpoint 失败后的主循环恢复。 |
| remote pre-turn compact endpoint 返回 context window error | core/tests/suite/compact_remote.rs::snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() 明确断言:compact request 只有 1 次,post-compaction follow-up turn 不会发起,turn 停止并向用户暴露 context window error。 | 这是源码测试确认的“compact 失败即停止当前 turn”的路径。 |
| local pre-turn compact context window error | core/tests/suite/compact.rs::snapshot_request_shape_pre_turn_compaction_context_window_exceeded() 的快照说明为:pre-turn auto-compaction context-window failure,compact request 排除 incoming user message,turn errors。 | local 路径也有同类失败面。 |
普通 sampling 撞 ContextWindowExceeded | core/src/session/turn.rs::run_sampling_request_loop() 对 CodexErr::ContextWindowExceeded 设置 total tokens full 后直接返回错误。 | 该错误点没有再进入一个 reactive compact 分支。 |
因此,Codex 的设计是“compact task + pre/mid-turn 自动压缩 + remote/local 分流 + 局部重试/截断”,但源码中仍保留了明确的 compact failure terminal path。尤其 remote pre-turn compact 失败测试直接把“无 post-compaction follow-up request,turn stops after compaction failure”固化为当前行为。
9.4.5 与 Claude Code 的关键差异
| 维度 | Claude Code | Codex |
|---|---|---|
| 用户主动 compact | compactConversation(..., { isAutoCompact: false }) 设置 status='compacting',运行 PreCompact hook,失败时通知用户,最终清理 status。 | /compact 经 TUI SlashCommand::Compact 到 Op::Compact,再进入 CompactTask;按 provider 选择 remote/remote_v2/local compact。 |
| 自动 compact 位置 | queryLoop 每次迭代前通过 autoCompactIfNeeded() 主动治理上下文。 | pre-turn、previous-model、mid-turn 三类自动触发点,集中在 session/turn.rs。 |
| prompt-too-long 恢复 | queryLoop 可先 withheld error,再尝试 reactive compact;失败后才将错误暴露为 terminal reason。 | 普通 sampling 遇到 ContextWindowExceeded 时设置 token full 并返回错误,没有同点位 reactive compact。 |
| compact 失败计数 | autoCompactIfNeeded() 维护 consecutive failure,达到阈值后熔断自动 compact,避免重复消耗。 | compact 内部有 retry、删旧 history、截断 tool output,但未看到等价的 session-level consecutive auto-compact failure breaker。 |
| 成功后的边界 | QueryEngine 看到 compact boundary 后 flush transcript,并产出 compact_boundary SDK message。 | replace_compacted_history() 安装 replacement history,推进 auto_compact_window_id,重新计算 token usage。 |
| 失败后的继续性 | compact 失败被纳入 query-loop terminal/recovery 分类;一些错误先 withheld,再决定是否继续。 | compact 失败在多个测试中表现为当前 turn error,不发 post-compaction follow-up request。 |
| 用户输入并发 | compact 是 query loop 内状态,manual/auto 状态通过 stream/status 反映。 | TUI 测试显示 /compact 可排队;compact turn 运行时用户消息可进入队列,但不能改变正在运行的 compact turn。 |
| hooks | PreCompact hook 可阻止,finally 清理 status。 | remote/local compact 都运行 pre/post compact hooks;hook stop 会返回 TurnAborted。 |
9.5 判断:Claude Code 能否真正规避 Codex 类 compact 失败?
结论要分两层:
- 能显著降低一类 Codex 源码中已经存在的失败路径。
Codex 也有手动 /compact 和自动 compact,但其源码测试确认了 pre-turn compact 失败后不再发 post-compaction follow-up request,当前 turn 直接 error。Claude Code 把 proactive auto compact、reactive compact、withheld recovery、状态清理和 compact boundary 都放进 query loop / QueryEngine 的上下文治理链路里,因此更有机会把“爆窗/压缩失败”转成可分类、可记录、可恢复或可控终止的状态迁移。
- 不能保证运行时完全规避。
只要 compact 本身依赖模型调用、Hook、摘要质量、上下文窗口、媒体/附件处理和持久化写入,就仍可能失败。Claude Code 源码也承认这一点:compactConversation() 会 throw,autoCompactIfNeeded() 会记录 consecutive failures 并在多次失败后停止尝试。熔断不是“永不失败”,而是“失败后不要无限消耗 API,并尽量让主循环以明确 terminal reason 或后续可控路径结束”。
更准确的判断是:
Claude Code 的设计目标不是证明 compact 永不失败,而是把 compact 失败从“compact task 失败后 turn 停止”的路径,尽量降级为“query loop 内可分类、可熔断、可记录、可继续或可明确终止的事件”。这能规避 Codex 源码中已存在的一类 compact failure terminal path,但不能数学上保证所有长上下文任务都不会遇到 compact failure。
从本期主题看,这恰好体现了 Harness 的稳定性重点:不是单个恢复动作永远成功,而是每个恢复动作都有状态边界、事件输出和失败出口。
机制主线收束
站在架构师视角,我会把这组源码机制收束为三个问题:为什么需要它,什么时候必须引入它,以及真正要设计的是什么。
- why:Query Loop 解决的是长任务中模型输出、工具结果、恢复动作和停止条件如何保持同一轨迹。
- when:任务会跨多轮工具、可能遇到上下文溢出、API fallback、用户中断、Hook blocking 时,必须显式状态机化。
- what:分清 API attempt loop 与 Query iteration loop;修补 missing tool_result;recoverable error 先 withheld;恢复机制有上限。
Query Loop / Recovery 这组源码最值得带走的,不是某个函数或某个配置项,而是它如何把模型能力放进一组可验证、可拒绝、可恢复、可审计的工程边界里。读源码时,如果只记住名词,会很快散;如果抓住 why、when、what,就能把这组机制迁移到自己的 agent 架构判断中。