第 4 章 第二部分:核心引擎

Agent Loop:心跳与决策

QueryEngine 的核心循环机制——从用户输入到工具执行,消息处理流水线的完整剖析

Claude Code Agent Loop QueryEngine 工具编排

4.1 心脏在哪里

如果 Claude Code 是一个有机体,QueryEngine.ts 就是它的心脏。所有运行模式——CLI、MCP、SDK——最终都汇入这个类。它管理着一个看似简单但实际精密的循环:接收输入 → 构建提示 → 调用模型 → 解析响应 → 执行工具 → 继续或停止。

设计哲学:简单的循环,复杂的周边

arXiv 论文 2604.14228 对 Claude Code 架构的分析揭示了一个核心洞察:Agent Loop 本身是一个简单的 while 循环(调用模型 → 运行工具 → 重复),大部分代码和复杂度存在于这个循环周围的子系统中。

这不是贬低 Loop 的简单性——恰恰相反,好的架构把复杂度放在正确的位置。围绕简单 Loop 的五大子系统承担了真正的工程挑战:

子系统职责复杂度来源
权限系统决定工具能否执行六层规则来源 + ML 分类器,7 种权限模式
上下文管理控制送入模型的信息量五层压缩管线(见 4.6 节)
工具编排并发/串行执行工具动态分区 + 流式进度 + 最大并发控制
扩展性机制让外部代码接入MCP / 插件 / 技能 / Hooks 四种方式
子代理委派将任务分配给专用代理Worktree 隔离 + 独立上下文 + 结果汇总

理解这个”简单核心 + 复杂周边”的结构,是理解整本书后续章节组织的关键。子代理委派子系统在 v2.1.178 经历了一次重要的简化:移除了 TeamCreate/TeamDelete 工具,agent team 改为隐式创建——spawn teammate 时自动组队,团队会话退出时自动清理。这把”多 agent 协作”的编码抽象从”显式管理团队生命周期”压缩为”spawn 即组队,退出即清理”。

Dynamic Workflows 对此论断的根本性扩展:2026 年 5 月与 Opus 4.8 同日上线的 Dynamic Workflows 引入了第二层循环——脚本编排循环。此前 Claude 在对话上下文中逐步决策(每步中间结果都回填上下文),现在编排逻辑移入 JavaScript 脚本,中间结果存储在脚本变量中,对话上下文只保留最终结果。子Agent 在 acceptEdits 模式下自动审批文件修改。编排脚本的循环、分支、变量管理与主 Agent Loop 的 while 循环完全解耦。

2026-07-02 GA 后,三个核心原语正式公开:pipeline()(顺序流水线)、parallel()(并发扇出)、agent()(单 agent 任务)+ phase() 分阶段进度追踪。标杆案例为 Bun 从 Zig 到 Rust 的 96 万行代码移植(6 天,99.8% 测试通过率),验证了双循环架构在极端工程场景下的可行性。

这意味着”复杂周边”现在不仅包含五大子系统,还包含了一个平行的编排执行环境。关键设计差异:主循环是 Claude 在上下文中逐步决策,脚本循环是代码驱动的批量调度。

QueryEngine 的核心方法是 submitMessage,它是一个 AsyncGenerator——不是简单的 async 函数,而是一个可以持续产出消息的异步生成器:

// QueryEngine.ts 核心签名
class QueryEngine {
  async *submitMessage(
    prompt: string | ContentBlockParam[],
    options?: { uuid?: string; isMeta?: boolean },
  ): AsyncGenerator<SDKMessage, void, unknown> {
    // ... 数百行的循环逻辑
  }
}

使用 AsyncGenerator 而不是 Promise 是一个关键设计选择。它允许 QueryEngine 在处理过程中流式地向外部产出中间状态——每一个助手消息、每一个工具结果、每一个流事件都可以即时传递给 UI 层或 SDK 调用方,而不需要等整个对话轮次结束。

源码引用:QueryEngine 类定义于 QueryEngine.ts,是整个系统的会话引擎核心。

4.2 一次 submitMessage 的完整旅程

当用户输入一条消息(无论是在终端里敲了一行文字,还是通过 SDK 发送了一个 API 调用),submitMessage 开始执行。以下是它的完整流程:

阶段 1:准备上下文

用户输入
  ├── 清空本轮技能发现记录(discoveredSkillNames.clear())
  ├── 设置工作目录(setCwd)
  ├── 确定模型配置(parseUserSpecifiedModel / getMainLoopModel)
  ├── 确定 Thinking 配置(adaptive / disabled)
  ├── 构建 System Prompt
  │     ├── fetchSystemPromptParts() → 获取默认提示词 + 用户上下文 + 系统上下文
  │     ├── 加载 Memory Mechanics 提示词(SDK 模式 + KAIROS 路径覆盖时)
  │     └── asSystemPrompt() 组装最终 System Prompt
  └── 加载 Skills 和 Plugins(getSlashCommandToolSkills + loadAllPluginsCacheOnly)

这个准备阶段揭示了一个重要事实:System Prompt 不是静态文本,而是每次对话都动态构建的。fetchSystemPromptParts 会根据当前的工具集、模型类型、MCP 客户端配置、附加工作目录等参数,组装出一个定制化的系统提示词。我们在第 6 章会深入分析这个构建过程。

阶段 2:处理用户输入

const {
  messages: messagesFromUserInput,
  shouldQuery,        // 是否需要调用模型
  allowedTools,       // 允许的工具列表
  model: modelFromUserInput,  // 可能被 Slash 命令修改的模型
  resultText,         // 本地命令的直接结果
} = await processUserInput({
  input: prompt,
  mode: 'prompt',
  // ...
});

processUserInput 是一个关键的分流点。它负责:

  1. 解析 Slash 命令:如果用户输入的是 /commit 或 /model,在本地处理,不需要调用模型
  2. 构建消息:将用户输入转换为消息格式,附加必要的上下文(文件附件、图片等)
  3. 判断是否需要查询模型:shouldQuery 为 false 时,直接返回本地结果

如果 shouldQuery 为 false(比如用户只是执行了一个 /help 命令),submitMessage 会直接 yield 结果消息然后 return,不会进入模型调用阶段。

阶段 3:进入主循环

for await (const message of query({
  messages,
  systemPrompt,
  userContext,
  systemContext,
  canUseTool: wrappedCanUseTool,
  toolUseContext: processUserInputContext,
  fallbackModel,
  querySource: 'sdk',
  maxTurns,
  taskBudget,
})) {
  // 处理每一条从 query() 产出的消息
}

这里的 query() 函数是实际与 Anthropic API 交互的核心。它也是一个 AsyncGenerator,产出各种类型的消息:

消息类型含义处理方式
assistant模型的回复(文本或工具调用)推入消息历史 + yield 给外部
user工具执行结果(作为 user 消息回传给模型)推入消息历史 + 增加轮次计数
progress工具执行的中间进度推入消息历史 + yield
stream_eventAPI 流式事件(token 级别)更新用量统计
system系统消息(上下文压缩边界、API 错误重试)特殊处理
attachment附件(结构化输出、最大轮次达到)特殊处理
tombstone消息删除信号跳过

工具调用本身还有一个上表未列出的终态。v2.1.265(2026-09-08)起,上一进程在工具运行时死后恢复时,最后一个提示不再被重写,被中断的工具调用保留并标记为 interrupted:

Fixed resume after the previous process died while a tool was running: the last prompt is no longer rewritten, and the interrupted tool call is kept and marked interrupted

这条值得单独记,因为它修的是一个会误导模型的事实错误。在此之前,中断的 MCP 工具调用会向模型报告为 “completed with no output”(v2.1.246 修复)——模型会以为工具正常执行完毕但没输出,进而可能在错误的 premises 上继续推理。interrupted 这个显式状态让中断对模型是诚实可见的。

两条 2026-10 的补充,都指向”谁发起的工具调用”这件事。

  1. hooks 有自己的并发容量(v2.1.302):官方 changelog 逐字 “Changed prompt and agent hooks so that at most 12 of one event run at a time; the rest wait their turn”。同一事件的 prompt / agent hooks 最多同时跑 12 个,其余排队。hook 不再是零成本的旁路——它现在是一个有容量上限的调度对象,事件密集时会成为等待点。
  2. advisor 的工具调用进入可观测范围(v2.1.290):mod 的 turn.step hook 结果新增 serverToolUses——“the tool calls the API ran itself (the advisor)”, each with its id, name, input, start and end。本章的消息类型表里没有任何一类区分”谁发起的工具调用”,API 自己跑的(advisor)此前完全不可见。 同版本还加了 agentId 到 tool.check 事件,使 hook 能区分子代理与主会话的权限检查——同样是”谁的”这个维度。

阶段 4:预算控制与终止

主循环中有多个终止条件检查:

// 美元预算检查
if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
  yield { type: 'result', subtype: 'error_max_budget_usd', ... };
  return;
}

// 结构化输出重试限制
if (callsThisQuery >= maxRetries) {
  yield { type: 'result', subtype: 'error_max_structured_output_retries', ... };
  return;
}

还有一个隐含的终止条件:最大轮次。当 query() 内部检测到已达到 maxTurns 时,会通过 attachment 消息传递 max_turns_reached 信号。

⚡ 语义变更:maxTurns 对子代理不再是终态,而是可续跑的中间态(v2.1.246,2026-08-25)。

Improved subagent results: a subagent that stops at its maxTurns limit now returns its output marked as partial, with a hint to continue it via SendMessage, instead of appearing finished

达到 maxTurns 限制而停止的子代理,现在返回被标记为 partial 的输出,并提示用 SendMessage 继续,而不再显示为已完成。⚠️ 这条限定于子代理语境,官方 changelog 只在这个语境下描述,不要扩大到主循环。

编排含义很直接:SDK 调用方原先”收到 max_turns_reached 就放弃”的处理方式,现在会白白丢弃已完成的工作。正确做法是识别 partial 标记,用 SendMessage 续跑。

--max-turns 不是硬终止保证(v2.1.281)。 上面把 maxTurns 当成”到了就停”的确定性条件,官方 changelog 给出过一个反例:

Fixed a turn that could retry indefinitely, ignoring --max-turns, when the model alternated unparseable tool calls and output-limit truncation

失效模式是不可解析的工具调用 ↔ 输出被截断交替出现:每一轮都算作一次重试而不是一个新 turn,计数就被绕过。工程含义有两条:--max-turns 只能约束正常路径,不能作为对抗模型异常行为的唯一兜底;真正兜底需要外层超时或看门狗——

2026-10-17:兜底这件事官方已经给了实现,同时多了一类终止条件。

  1. 时间类终止条件(第四类)。后台 Bash / PowerShell 在时间上限后停止,停止时通知 Claude。v2.1.285 官方 changelog 逐字:“Changed background Bash and PowerShell commands to stop after a time limit (their timeout with run_in_background, default 30 min, max 2 h); Claude is notified when one is stopped”。⚠️ v2.1.288 起该时限只在无人值守会话生效(-p / Agent SDK / CI / cloud),终端 / 桌面 / VS Code 无限制——官方 changelog 逐字:“Changed the background command time limit to apply only in unattended sessions (-p, Agent SDK, CI, cloud); terminal, desktop app and VS Code sessions have no limit”。两个版本叠起来读:时限本身在 v2.1.285 落地,v2.1.288 收窄了它的适用范围。
  2. 重试预算的口径变了(v2.1.286):官方 changelog 逐字 “one limit now covers a whole model call, so with the default retry settings a failing call sends at most 14 requests”。本章 callsThisQuery >= maxRetries 的写法按”重试次数”计,新口径按”一次模型调用的请求总数”计,两者应并存。
  3. 外层看门狗已落地:CLAUDE_CODE_RETRY_WATCHDOG + CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS(v2.1.295,限制无人值守重试模式为 429/529 等待多久)与 CLAUDE_CODE_RETRY_WATCHDOG_MAX_USAGE_LIMIT_WAIT_MS(v2.1.301,遇重置时间过远的用量上限时结束请求而非继续等待)。上面那句”真正兜底需要外层超时或看门狗”因此要从建议改为官方已有实现,并把 v2.1.301 这条补进来——它防的是”等到天荒地老”这一失效模式。

这正好接上”循环要持续检查自己是否还活着”那条判据。

源码引用:主循环的完整实现参见 QueryEngine.ts submitMessage,预算控制逻辑在同文件中。

中断、超时与自动继续

前面四个终止条件都是”正常结束”的路径。真实的循环还需要在被外力打断时给出确定行为——4.7 处理的是 error_during_execution,但中断路径在 2026-08 至 09 之间发生了一组变化:

场景行为版本
非交互会话(-p、SDK、cloud)响应被服务器错误、连接丢失或停滞中途切断自动继续,而非以错误结束v2.1.246(2026-08-25)
子代理响应被计算机睡眠、连接断开或服务器错误中途切断自动继续,而非以不完整响应结束v2.1.257(2026-09-01)
API 从不开始响应曾静默 10+ 分钟;现约 3 分钟超时、重试一次,然后显示 API Error: No response from APIv2.1.243(2026-08-25)
上一进程在工具运行时死后恢复最后提示不再重写,中断的工具调用保留并标记为 interruptedv2.1.265(2026-09-08)

前两条的官方表述分别是:

Improved non-interactive sessions (-p, SDK, cloud sessions) to automatically continue a response cut off mid-stream by a server error, connection loss, or stall instead of ending with an error

Fixed subagents stopping when a response was cut off mid-stream by a computer sleep, dropped connection, or server error; they now automatically continue instead of ending with an incomplete response

这组改动的教学价值在于它延展了 4.9 第 3 条”预算控制是循环级别的”这一设计哲学:循环不仅要持续检查预算,还要持续检查”这一轮是否还活着”。3 分钟超时那一条尤其说明问题——10 分钟的静默不是”慢”,而是循环失去了对自身存活状态的判断,5 分钟级的静默在无人值守场景下足以让整个编排链路空转。

注意”自动继续”的适用边界:它挂在非交互会话与子代理上。交互式 REPL 里用户就在屏幕前,是否续跑应由人决定。

溢出 → 压缩不是一条单路径。 4.6 把压缩画成一条”超了就压、压完继续”的线性管线,官方在 v2.1.266–283 之间修掉了至少三条绕过压缩直接报错的分支:

分支症状版本
hook 驱动会话(如 active /goal)在 reactive compaction 之后再次溢出报 “Prompt is too long”,而不是再次压缩v2.1.274
auto-compaction 没有完整的 earlier exchange 可总结(多为 Agent SDK 的大 prompt 会话)会话永久卡在 “Prompt is too long”v2.1.269
压缩的 summarization 请求被拒旧版直接失败;现改为在 fallback model 上重试v2.1.282

三条的官方措辞分别是:

Fixed hook-driven sessions (such as an active /goal) ending with “Prompt is too long” instead of compacting when the context overflowed again after a reactive compaction

Fixed sessions getting permanently stuck on “Prompt is too long” when auto-compaction had no complete earlier exchange to summarize (mostly Agent SDK sessions with very large prompts)

Fixed compaction failing when the summarization request is refused; it now retries on a fallback model

把它们并排看的结论:压缩是”尽力而为”,不是”保证可用”。三条分支的共同点是压缩的某一环失败后没有降级出口,于是把失败向上抛成终态错误。上层编排若把”会压缩”当作容量假设,就会在这三条分支上一次性丢掉整段会话。

4.3 工具调用编排

当模型决定调用工具时,query() 内部启动工具编排流程。这个流程由 toolOrchestration.ts 中的 runTools 函数驱动:

模型返回工具调用请求
  └── runTools()
        ├── partitionToolCalls() → 分割为并发安全/非安全批次
        │     ├── 批次 1: [Grep, Glob, FileRead] → 并发安全,合为一批
        │     ├── 批次 2: [FileWrite]            → 非并发安全,独立一批
        │     └── 批次 3: [Grep, FileRead]       → 并发安全,合为一批
        │
        ├── 对每个并发安全批次 → runToolsConcurrently()(最大并发 10)
        └── 对每个非安全批次   → runToolsSerially()

分割策略的关键在于 isConcurrencySafe 属性:

function partitionToolCalls(
  toolUseMessages: ToolUseBlock[],
  toolUseContext: ToolUseContext,
): Batch[] {
  return toolUseMessages.reduce((acc: Batch[], toolUse) => {
    const tool = findToolByName(toolUseContext.options.tools, toolUse.name);
    const parsedInput = tool?.inputSchema.safeParse(toolUse.input);
    const isConcurrencySafe = parsedInput?.success
      ? Boolean(tool?.isConcurrencySafe(parsedInput.data))
      : false;

    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      // 与前一个并发安全工具合并为同一批
      acc[acc.length - 1]!.blocks.push(toolUse);
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] });
    }
    return acc;
  }, []);
}

源码引用:工具编排逻辑参见 toolOrchestration.ts,工具执行参见 toolExecution.ts。

为什么这个分割策略重要?考虑一个场景:模型同时请求 3 个 Grep 和 1 个 FileWrite。如果全部串行执行,3 个 Grep 要等彼此完成才能开始——但 Grep 是只读操作,完全可以并发。而 FileWrite 可能修改文件,必须等前面的 Grep 都完成后再执行,以避免读到不一致的状态。

最大并发数默认为 10,可通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 调整。

Dynamic Workflows 对工具编排的扩展:Dynamic Workflows 将工具编排的并发语义从单个循环内的 partitionToolCalls(最多 10 并发)扩展到多 Agent 间的并行工具调用(默认最多 16 个并发 Agent,每个 Agent 内部仍有自己的 partitionToolCalls)。

⚡ 16 不再是硬上限(v2.1.269)。 官方新增 CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS(取值 1–256),用于提高 Workflow 工具单次运行的并发 agent 上限,面向 inference-bound 的 fan-out:

Added CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS (1–256) to raise the Workflow tool’s per-run concurrent agent limit for inference-bound fan-outs

16 现在只对未设置该环境变量的场景成立。写作时不要再说”Workflow 最多 16 个并发 Agent”——第 8 章同口径表述已同步订正。

子Agent 在 acceptEdits 模式下运行,文件修改自动审批,绕过了主循环的 checkPermissionsAndCallTool 流程中的用户确认环节。编排脚本自身不能访问文件系统或 Shell,形成”脚本只调度、Agent 只执行”的职责分离。这些差异意味着 Dynamic Workflows 的权限模型需要在第 7 章中进一步展开。

4.4 权限检查:每次工具调用的门卫

在工具实际执行之前,有一道关键的门卫——checkPermissionsAndCallTool:

工具调用
  ├── Zod Schema 验证输入参数
  ├── validateInput() 验证业务规则
  ├── 执行 PreToolUse Hooks
  ├── 解析 Hook 权限决策
  ├── canUseTool() 权限确认
  │     ├── allow → 直接执行
  │     ├── deny  → 返回拒绝结果
  │     └── ask   → 询问用户 / AI 分类器决策
  ├── 执行工具
  └── 执行 PostToolUse Hooks

canUseTool 是一个被 submitMessage 包装过的函数——wrapper 会记录所有被拒绝的工具调用(permissionDenials),在最终结果中上报给 SDK 调用方。

4.5 会话持久化

QueryEngine 在循环中持续将消息写入会话存储(transcript),这确保了两个关键能力:

  1. 会话恢复:如果进程崩溃或用户重启,可以从上次的对话点继续(--resume)
  2. IDE 集成:桌面端可以实时看到对话进展

一个有趣的细节:用户消息在进入主循环之前就被写入 transcript。这是因为如果进程在 API 响应到来之前被杀死,transcript 里至少有用户消息,--resume 才能找到恢复点。

// 在进入 query() 循环之前就持久化用户消息
if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages);
  if (isBareMode()) {
    void transcriptPromise;  // 轻量模式:fire-and-forget
  } else {
    await transcriptPromise; // 正常模式:等待写入完成
  }
}

4.6 上下文压缩:五层级联管线

上下文管理是 Agent Loop 能够支持长时间运行的核心原因。它不是简单的”超了就压缩”,而是一个精心设计的五层级联管线——每一层解决不同类型的上下文压力,从源头预防到最终兜底,逐级递进。

⚠️ 2026-10-17 限定:五层管线的触发条件都是容量——“太多了”;v2.1.299 起多了一个不以溢出为前提的触发源。 见 4.6 末尾「第六个触发源」。

Layer 0:Tool Result Budget(源头控制)

在工具结果进入上下文之前,先在源头控制数据量。每个工具的返回结果有大小预算:当 FileRead 读取大文件、Grep 匹配大量结果时,超出预算的部分被截断。这一层的目标是防止单次工具调用就塞满上下文窗口。

2026-09 的两个版本给这一层补上了此前缺失的官方数字,也暴露出它的真实结构是一个三级漏斗,而不是”一个模糊的预算”:

阶段机制官方上限版本
内联接收命令与后台任务输出在落盘前可直接进入上下文的额度,由 bashOutputMaxChars / taskOutputMaxChars 控制最高 128K 字符v2.1.261(2026-09-04)
落盘保存超出内联额度的工具结果写入磁盘,只在上下文里留引用磁盘保存的工具结果新增 1 GB 上限v2.1.265(2026-09-08)
截断标注仍超限则截断对话内预览会说明文件何时被截断v2.1.265

原文:

Added bashOutputMaxChars and taskOutputMaxChars settings to raise how much command and background-task output Claude receives inline before it is saved to a file, up to 128K characters

Added a 1 GB cap on tool results saved to disk; the in-conversation preview says when a saved file was truncated

两个设置项的存在说明内联额度是可调的——默认额度偏保守,跑长命令的团队可以把它抬到 128K。而 1 GB 的落盘上限把”磁盘也不是无限”这件事显式化了:第三级截断不只是保护上下文,也在保护本地磁盘。

Layer 1:Micro Compact(局部摘要)

对单条消息内的冗长工具结果做局部摘要。如果一条工具结果包含大量重复模式或低信息密度内容,Micro Compact 会将其压缩为更精简的描述,同时保留关键信息。

Layer 2:Snip Compact(消息修剪)

整条删除最老的消息,保留最近 N 条。这是一种粗粒度但高效的策略——对于长会话中早期的探索性对话,完整保留的价值有限。

Layer 3:Reactive Compact(模型摘要)

当上下文窗口使用率达到阈值(约 80%)时,触发 Reactive Compact——用模型生成一段对话历史的摘要,替换掉原始的冗长消息。compact_boundary 消息标记了压缩边界:

⚡ 阈值更新:80% 是 200K 窗口时代的比例值,1M 窗口时代的触发点是约 967K。

v2.1.247(2026-08-26)把 Sonnet 5 的默认自动压缩窗口改为其完整 1M 上下文:

Changed Sonnet 5’s default auto-compact window to its full 1M context, so sessions on the 1M window now auto-compact at about 967K tokens instead of about 934K

v2.1.260(2026-09-03)进一步处理 Opus 与 Fable:

Improved auto-compact for 1M-context models: Opus and Fable sessions now compact shortly before the 1M-token limit, and recovery compaction on very large contexts no longer times out at 10 minutes

967K / 1M ≈ 96.7%,与旧的 80% 相差约 17 个百分点。窗口变大之后,相对触发点显著后移。 措辞要分开:967K 这个数官方只对 Sonnet 5 给了,Opus 与 Fable 的表述是”接近 1M 上限前”,不要写成统一阈值。

这个变化对前三层是加强而非削弱论证:Reactive Compact 被推迟,意味着 Layer 0–2 要承担更多削峰责任——它们原本只是”减少触发频率”,现在要顶住的是一段明显更长的未压缩区间。

2026-10-17:阈值不再是”一个会话一个数”,而是二维的。

  • 按模型维(v2.1.288):官方 changelog 逐字 “Changed /autocompact to save the auto-compact window per model, so each model keeps its own setting when you switch”。切换模型后各自保留自己的设置。
  • 按节点维(v2.1.296):autoCompactWindow 进入 subagent frontmatter 与 --agents 定义,“so a subagent can auto-compact earlier than the main conversation’s window”——子代理可以比主会话更早压缩。

两条合起来的含义有两点:967K 这个数的身份从”Sonnet 5 的阈值”降为”Sonnet 5 在某种配置下的默认值”;同一棵会话树里不同节点在不同的点压缩,因此后文”压缩进行中应作为全局状态向外广播”的建议更强了——需要广播的不只是”在压缩”,还有”哪个节点在按哪个窗口压缩”。

阈值建立在量之上,而量本身会出错(v2.1.273)。 上面两版阈值(80%、967K)都在回答”到多少触发”,却没有问”这个’多少’是谁数出来的”。官方修过一次计量口径缺陷:

Fixed the context meter and auto-compact counting advisor-tool turns at roughly twice their real context size, which made auto-compact fire at about half the real window

advisor-tool 轮次被按约两倍真实体积计入,结果是 auto-compact 在真实窗口约一半处就触发。这解释了实践中很常见的一类困惑:用户看着上下文明明只用了一半,压缩却已经发生了。

工程含义比这条 bug 本身更重要:上下文计量是一个会出错的组件,而阈值策略建立在它的输出之上——“auto-compact 触发了”不能反过来当作”上下文真的用完了”的判据——前者是计量结果,后者是事实。上层若用压缩事件去推断容量(比如据此判断要不要拆会话),就把一个可错信号当成了硬事实。

压缩本身也是一次模型调用,而它用什么 System Prompt 曾经是错的(v2.1.247,2026-08-26)。

上一段把 Reactive Compact 描述成”用模型生成一段摘要”,这里藏着一个容易被跳过的假设:这次生成用的是哪个 System Prompt?官方在 v2.1.247 修掉了一个真实缺陷:

Fixed /compact and “Summarize from here” in sessions started with --agent summarizing under the default system prompt instead of the conversation’s own

在带 --agent 启动的会话里,压缩会退回默认 System Prompt,而不是沿用该会话自己的那一份。也就是说,压缩不是”把对话喂回同一个模型”这么简单——它是一次独立的模型调用,同样有 System Prompt 选择问题,而且选错时不会报错,只会静默产出一份口径不一致的摘要。对 4.6 的意义是:五层管线里唯一”会写新内容”的那一层(Layer 3),其输出质量取决于一个直到 v2.1.247 才被修对的隐式入参。

if (message.subtype === 'compact_boundary' && message.compactMetadata) {
  // 释放压缩前的消息,让 GC 回收内存
  const mutableBoundaryIdx = this.mutableMessages.length - 1;
  if (mutableBoundaryIdx > 0) {
    this.mutableMessages.splice(0, mutableBoundaryIdx);
  }
}

需要注意的是,Reactive Compact 本身消耗 Token(需要调用模型生成摘要),这也是前面三层预防性压缩存在的意义——尽量减少触发 Reactive Compact 的频率。

压缩指南本身可以是被优化的对象(ACON,arXiv:2510.00615,ICML 2026):前面三条(Parallel Context Compaction、Slipstream、SelfCompact)优化的都是”怎么执行压缩”——谁来压、压的时候要不要阻塞、什么时候触发。ACON 动的是另一层:它把压缩指令(compression guideline)当作可优化变量,在自然语言空间里依据 agent 的失败分析迭代改写这份指南,再把优化好的压缩器蒸馏进小模型。在 AppWorld、OfficeBench 与 Multi-objective QA 上 peak token 降 26–54%,且任务成功率不低于现有压缩基线;蒸馏后的小模型保留教师约 95% 的表现,小模型侧提升 20–46%。

由此可以分出上下文压缩的两个可优化层次:压什么 / 按什么指令压(ACON)与怎么执行压(并行化、异步化、触发时机)。Claude Code 五层管线目前覆盖的是后者——Layer 0–2 的截断与修剪规则、Layer 3 的触发阈值、以及 v2.1.247 修对的压缩用哪个 System Prompt,都属于执行层。压缩指南作为一段可迭代的自然语言资产被显式优化,目前还是研究侧的做法。

并行压缩的可能:学术研究(arXiv:2605.23296)指出,当会话极长时,顺序压缩的阻塞等待可能成为瓶颈。Parallel Context Compaction 将会话分段并行压缩,在 8B-120B 多种模型上验证了 wall-time 加速。虽然 Claude Code 当前未采用此方案,但如果未来 Dynamic Workflows 的多 agent 场景导致主会话 Reactive Compact 触发频率显著升高,这是一个有工程参考价值的优化方向。

异步压缩与轨迹验证(Slipstream,Princeton,2026-05):比并行压缩更激进的方案——压缩时不阻塞 agent 执行,同时 fork 两个进程:压缩器生成候选摘要 + agent 在未压缩的完整上下文中继续执行 k 步,然后用 judge 对比两者的一致性(plan-level 和 statement-level)。这解决了”压缩器必须在知道 agent 下一步需要什么之前做决定”的根本矛盾。与 Claude Code 的同步阻塞式 Reactive Compact 形成工程对比——Slipstream 的 wall-time 优势在于压缩和 agent 执行重叠,但代价是需要额外的验证计算。⚠️ 目前无生产级 Agent 系统采用 Slipstream,实验验证仅限于 SWE-bench 规模。

模型自决策压缩(SelfCompact,arXiv:2606.23525):更进一步——不让系统决定”何时压缩”,而是给模型一个 compaction tool 和触发 rubric,让模型自主判断。在 7 个模型上测试发现:未提示模型不能可靠判断上下文何时”腐烂”,但 rubric 可闭合此认知 gap——这暗示 Layer 3 的固定 80% 阈值可能过于粗糙,语义驱动的压缩决策是可能的演进方向。目前尚无生产级 Agent 系统采用此方案。

Layer 4:Forced Compact(强制兜底)

当上下文接近硬限制时,不等模型生成摘要,强制执行压缩。这是最后防线,可能会丢失部分信息,但确保系统不会因为超出上下文窗口而崩溃。

⚠️ 工程教训:压缩不应在 mid-turn 触发。Cline v3.0.41(2026-07-15)修复了一个隐蔽的竞态 Bug:压缩调度器在”模型发出 tool call → 工具结果返回”的 mid-turn 间隙触发,导致正在执行的 tool call 的上下文被摘要替换——症状为重复编辑、遗忘命令、重新决策。修复方案:压缩仅在完整 turn 边界触发。Claude Code 的五层管线中的 Layer 3(Reactive Compact)在源码层面如何避免此问题值得深入验证——从已泄露的 v2.1.88 源码看,compact_boundary 消息可能起到了 turn 边界标记的作用,但具体防护机制仍需进一步逆向分析。

级联关系

五层之间的关系不是”选择其一”,而是逐级递进、同时生效的:

工具执行完毕
  └── Layer 0: 截断过大的工具结果
       └── Layer 1: 局部摘要冗长内容
            └── Layer 2: 修剪最老的消息
                 └── Layer 3: 模型生成摘要(200K 窗口 ~80%;1M 窗口 Sonnet 5 约 967K ≈ 96.7%)
                      └── Layer 4: 强制压缩(接近硬限制触发)

第六个触发源:空闲压缩(v2.1.299)

上图的每一层,触发条件都是容量——“太多了”。v2.1.299 加了一个不以溢出为前提的触发源。官方 changelog 逐字:

Added idle compaction in the terminal: a 200K+ token conversation left idle for about 54 minutes is compacted before its one-hour prompt cache expires, so your next message doesn’t reprocess it all

Added CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS to change idle compaction’s 200K-token minimum; "idleCompaction": false in settings.json turns it off. Both need v2.1.290 or later

v2.1.302 起 /config 有了 Idle compaction 行,压缩后的提示会告诉你怎么关掉它。

它压缩的不是”太多了”,而是”快要过期了”。 54 分钟这个数是赶在 1 小时 prompt cache 过期之前——压缩在这里的作用不是腾出上下文空间,而是让下一条消息不必重新处理整段会话,即续缓存而非腾空间。

🔴 这是本章最值得写的一句:五层管线的共同前提是”上下文压力”,空闲压缩的前提是”缓存生命周期”。同一个动作(压缩)第一次服务于两个不同的目的。 因此读到”压缩发生了”时,不能默认推断”上下文不够了”,也可能是”缓存要过期了”。这与上文「上下文计量是一个会出错的组件,‘auto-compact 触发了’不能反过来当作’上下文真的用完了’」构成同一条规则的两个反例。

⚠️ 两条待回源,写入时不推广:① 54 分钟是否随 promptCacheTtl 变化(官方只写在”1 小时 cache”语境下,本书按默认 1 小时 TTL 下的取值处理);② “Both need v2.1.290 or later” 是指两个开关从该版本起可用,还是指空闲压缩功能本身自该版本就在(v2.1.299 才公告)——两种读法对本书记述的影响不同。

第三个触发源:容量收缩(v2.1.301)

除了”内容增长”与”空闲”,还有第三类:上下文窗口本身变小了。官方 changelog 逐字:

Improved switching to a model with a smaller context window: a conversation too long for the new model is now summarized by the previous model first

这一条同时给出第二条”压缩器用哪个模型”的显式规则:模型切换场景下,压缩器 = 切换前的模型。它与 v2.1.247 那条(压缩应沿用会话自己的 System Prompt)并列,使本章”压缩是一次独立的模型调用、有自己的入参选择问题”的论断从一条证据变成两条。⚠️ v2.1.301 同版本还修了一个相关的坑:切换窗口更小的模型时,可能在你没有同意的情况下花掉一次”上一个模型做摘要”的用量——压缩不只是质量问题,也是计费问题。

正如论文所述:“no single compaction strategy addresses all types of context pressure”——没有单一压缩策略能应对所有类型的上下文压力。五层管线的设计让 Agent Loop 能够支持数小时的 SDK 会话而不会因为内存泄漏或上下文溢出而崩溃。

一个需要注意的前提:以上讨论都只在单会话内成立。 4.1 把上下文管理与子代理委派列为两个独立子系统,但一旦叠加子代理与会话恢复,压缩就不再是纯粹的上下文问题,而是一个分布式状态一致性问题。三条官方修复是证据:

Fixed Workflow tool subagents being restarted as stalled while a long context compaction was still in progress(v2.1.260)

Fixed resuming a session losing hook output and other context around parallel tool calls, which changed the resumed request(v2.1.261)

第一条是压缩拖慢了子代理的心跳,导致看门狗误判其 stalled 并重启;第二条是恢复时丢掉了 hook 输出与并行工具调用周围的上下文,恢复后发出的请求因此与中断前不一致——恢复不再是等价重放。

这给 4.1”复杂度存在于循环周围”提供了新的证据:五层管线的每一层在单会话里都有确定语义,但当压缩与子代理生命周期、与会话恢复共享同一份状态时,层与层之间的边界会被跨进程地穿透。设计长跑型多 Agent 系统时,“压缩进行中”应被视为一个需要向外广播的全局状态,而不是某个会话的内部细节。

学术研究视角:四种新范式与 Claude Code 五层管线的对比

Claude Code 的五层管线是”从轻到重、逐级递进”的生产级方案,但学术研究正在探索更灵活的自适应压缩范式:

范式核心思想与 Claude Code 五层管线的对比
TACO(arXiv:2604.19572)从真实交互轨迹中自进化压缩规则,区分”进度噪声”和”状态信号”Claude Code 的硬编码层级 vs TACO 的自学习规则——前者依赖固定阈值触发,后者让 Agent 学会判断哪些观察可以安全过滤
Context-ReAct / LongSeeker五个原子操作(Skip/Compress/Rollback/Snippet/Delete),Agent 自主选择Claude Code 的五层级联 vs Context-ReAct 的按需编排——前者系统自动逐级递进,后者 Agent 自主选择操作类型
Focus Agentstart_focus/complete_focus 自主上下文裁剪,维持持久 Knowledge 块Claude Code 的 Reactive Compact(被动触发)vs Focus Agent 的主动回退——前者只能向前修剪,后者可主动压缩回 Knowledge 基线
CWL(arXiv:2606.11213)typed episodes + 显式依赖图 + 确定性驱逐(无 LLM),89 任务 / 80M token 零退化Claude Code 的五层级联 vs CWL 的 agent 主动标注——前者系统自动逐级触发,后者 agent 主动声明依赖关系。CWL 的”驱逐不压缩”哲学(保留原文、按图驱逐完整片段)与 Claude Code 的摘要式压缩形成根本分歧

| SelfCompact(arXiv:2606.23525) | 模型自主决策压缩时机(compaction tool + rubric)——rubric 规定”子任务解决/轨迹收敛时压缩,推导途中/卡住时抑制” | Claude Code 的 Reactive Compact(固定 80% 阈值自动触发)vs SelfCompact 的模型自主语义驱动——后者在 6 基准 7 模型上 token 降低 30-70% 同时匹配固定间隔效果。SelfCompact 的 rubric 机制与 Claude Code 的硬编码层级的根本分歧在于:压缩决策应该是系统的还是 agent 的? |

| CompactionRL(arXiv:2607.05378) | 将上下文压缩融入 RL 训练循环——联合优化任务执行和 summary 生成 + 跨轨迹 GAE。GLM-4.5-Air SWE-bench +7.0pp(→66.8%)、Terminal-Bench +3.1pp | Claude Code 的五层管线 vs CompactionRL 的”压缩即训练”——前者是事后工程方案(压缩与 agent 能力训练分离),后者将压缩内化为模型能力。这是从”给 agent 配压缩器”到”训练 agent 学会压缩”的范式跃迁。已部署到 GLM-5.2(750B)RL pipeline |

| GenericAgent (GA)(arXiv:2604.17091) | “上下文信息密度最大化”单一原则——不追求压缩,而是全程只保留高信息密度内容。222K tokens = Claude Code 的 ~27.7% | Claude Code 的”五层压缩管线”(事后削峰)vs GenericAgent 的”事前信息密度优化”——GA 不是更好的压缩策略,而是更根本的上下文设计哲学:如果上下文本身就高密度,压缩需求自然降低。⚠️ GA 在 Lifelong AgentBench 上的优越性可能与实验设置高度相关,标题性对比(27.7%)需要标注上下文差异。|

| SJC(arXiv:2610.20927《Prune the Summary, Not the History: Source-Judged Compaction for Long-Horizon Agents》,2026-10-14) | 压缩摘要混入了”环境确立的事实”与”压缩器自己写的推理”,agent 事后无法区分;逐条打”是否有来源”分并删掉无源条目(判定器 Jev 是非生成式决策模型,输出校准概率而非文本) | 本章 v2.1.247 修的是”压缩的入参可能选错”,SJC 指出产物本身混入了非事实:一个广泛使用的结构化压缩器上 83% 的失败运行可追溯到某条执行了无环境输出支持的摘要条目。SJC 免训练、不改 agent/压缩器,每次压缩多一次判定请求,AppWorld/OfficeBench 两模型跨运行可靠性 +2.1%–8.9% 且不增 token。与 ACON 的关系:ACON 优化”按什么指令压”,SJC 优化”压完之后哪些条目可信” | | CONPACT / Side Constraints(arXiv:2608.11242《Lost in Context Compaction: When LLM Systems Drop Side Constraints》,v1 2026-07-31) | 用户跨轮约束(如”未经我确认不要删邮件”)在压缩中被静默丢弃 | Layer 2(按时间整条删)与 Layer 3(按内容摘要)都不认识”跨轮有效的约束”。收件箱实验:压缩后 30/100 会话未经批准删邮件,压缩前 0/100;三个场景(multi-turn chat / multi-turn tool use / long-horizon research)下受评压缩器平均只保留 17% 的注入约束。这是本章”压缩会丢信息”论述里第一个带基线的数字。作者的 SC extractor 不改压缩器与 LLM 即达 >90% 保留率 | | REMORY(arXiv:2610.11287《REMORY: Learning Residual Memory for Context Compaction》,2026-10-08) | 给摘要补一段有界的 soft memory tokens(序列维度上的残差连接) | 前八种范式都在回答”怎么压文本、压多少、谁来决定”,REMORY 回答”摘要之外再补一段非文本记忆”。SummHay 上以 5.2% 的输入位置逼近全上下文联合得分;Qwen3.8-27B 与 GLM-5.3-Flash 在长程基准上一致增益,两者在 BrowseComp 与 Terminal-Bench 2.1 上的重复工具输出与工具错误显著减少。与本章”Layer 3 生成一段文本摘要”的对照最尖锐:它补的不是文本 |

总结:Claude Code 的五层管线在生产环境中已被验证可靠,但学术研究指出更灵活的自适应压缩可能进一步提升长程任务的 token 效率和准确率。TACO 在 TerminalBench 上有实证,LongSeeker 和 Focus Agent 有定量结果,值得持续关注。

把上面三行与 ACON 合并看,上下文压缩现在有四个可优化层次:压什么 / 按什么指令压(ACON)、怎么执行压(并行化、异步化、触发时机)、压完之后哪些条目可信(SJC)、摘要之外补什么(REMORY)。Claude Code 五层管线目前覆盖的是第二层,第一 / 三 / 四层都还在研究侧。⚠️ 引用这三篇时的三条口径纪律:① SJC 的 83% 分母是失败运行数,不是全部运行数——写成”83% 的运行会失败”是错的;② CONPACT 的 17% 是三个场景的平均;③ REMORY 的 5.2% 是相对全上下文的输入位置数。三篇均未见同行评审或会议收录标注,不得写成”已被 X 会议接收”。

Context-Folding 的 KV-cache 回滚(Sun et al., FoldGRPO):这是目前唯一将压缩与推理引擎的 KV-cache 状态结合的方案——agent 调用 branch(description, prompt) 开启子上下文,调用 return(message) 同时回滚 KV-cache。主线程保持 ~8K token 以内而 agent 处理 100K+ token。与 CWL 的’驱逐不压缩’不同,FoldGRPO 的’回滚不压缩’是另一种激进策略——直接丢弃中间 token 而不尝试保留任何摘要。这对 Claude Code 五层管线中 Layer 2(Snip Compact)的粗粒度修剪策略是一个有趣的对照。

4.7 结果判定

主循环结束后,QueryEngine 需要判断结果是成功还是失败:

const result = messages.findLast(
  m => m.type === 'assistant' || m.type === 'user',
);

if (!isResultSuccessful(result, lastStopReason)) {
  yield { type: 'result', subtype: 'error_during_execution', ... };
  return;
}

isResultSuccessful 的判断标准:

  • 最后一条消息是 assistant 类型且包含文本内容
  • 或者最后一条消息是 user 类型且包含 tool_result(表示最后一步是工具执行)
  • 且 stop_reason 为 end_turn

如果不满足这些条件(比如 API 错误、意外中断),就报告为 error_during_execution。

4.8 QueryEngine 的状态管理

QueryEngine 维护的状态包括:

状态作用
mutableMessages活跃消息工作集(可被压缩、截断)
totalUsage累计 Token 用量
permissionDenials被拒绝的权限请求列表
readFileState文件读取缓存——上下文压缩的预防层(见下文)
discoveredSkillNames本轮发现的技能(每次 submitMessage 重置)
loadedNestedMemoryPaths已加载的嵌套记忆路径
abortController中断控制器

readFileState 的双重身份:它不仅是性能缓存(避免重复读取同一文件),更是上下文压缩管线的预防层。当模型请求读取已经读过的文件时,readFileState 返回缓存内容而不是再次将完整文件内容塞入上下文。在长会话中,同一个文件可能被多次引用——如果每次都将完整内容塞入消息,上下文窗口会迅速耗尽。readFileState 从源头避免了这种重复膨胀。

mutableMessages 与 transcript 的 CQRS 分离:QueryEngine 维护两套消息视图——mutableMessages 是活跃的工作集,可以被 4.6 节的压缩管线修剪和替换;transcript 是完整的追加式记录,不可变。这种 CQRS(命令查询职责分离)模式确保了压缩操作不会影响会话恢复的完整性——--resume 恢复时读取的是完整 transcript,而不是被压缩过的 mutableMessages。

特别注意 discoveredSkillNames 在每次 submitMessage 开始时被清空——这是轮次级别的状态,不能跨轮次累积,否则在长时间的 SDK 会话中会无限增长。

4.9 设计启示

QueryEngine 的设计有几个值得学习的点:

  1. AsyncGenerator 模式:让循环过程可以流式地向外部暴露状态,而不是攒到最后一次性返回。这对于需要实时展示进度的 UI 场景特别有价值。

  2. 消息类型的丰富性:不只是 user 和 assistant,还有 progress、attachment、tombstone、stream_event、system 等。每种类型有明确的语义和处理方式。

  3. 预算控制是循环级别的:不是在 API 调用前检查一次,而是在每次消息处理后持续检查。这避免了”一次工具调用花光所有预算但无法中止”的问题。

  4. 持久化先于执行:用户消息在进入主循环前就被持久化,这保证了即使在最坏情况下,会话也是可恢复的。