章节导航 第 4 章 / 共 23 章
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 是一个关键的分流点。它负责:
- 解析 Slash 命令:如果用户输入的是
/commit或/model,在本地处理,不需要调用模型 - 构建消息:将用户输入转换为消息格式,附加必要的上下文(文件附件、图片等)
- 判断是否需要查询模型:
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_event | API 流式事件(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 的补充,都指向”谁发起的工具调用”这件事。
- 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 不再是零成本的旁路——它现在是一个有容量上限的调度对象,事件密集时会成为等待点。
- advisor 的工具调用进入可观测范围(v2.1.290):mod 的
turn.stephook 结果新增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
maxTurnslimit now returns its output marked as partial, with a hint to continue it viaSendMessage, 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:兜底这件事官方已经给了实现,同时多了一类终止条件。
- 时间类终止条件(第四类)。后台 Bash / PowerShell 在时间上限后停止,停止时通知 Claude。v2.1.285 官方 changelog 逐字:“Changed background Bash and PowerShell commands to stop after a time limit (their
timeoutwithrun_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 收窄了它的适用范围。 - 重试预算的口径变了(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的写法按”重试次数”计,新口径按”一次模型调用的请求总数”计,两者应并存。 - 外层看门狗已落地:
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 API | v2.1.243(2026-08-25) |
| 上一进程在工具运行时死后恢复 | 最后提示不再重写,中断的工具调用保留并标记为 interrupted | v2.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-outs16 现在只对未设置该环境变量的场景成立。写作时不要再说”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),这确保了两个关键能力:
- 会话恢复:如果进程崩溃或用户重启,可以从上次的对话点继续(
--resume) - 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
bashOutputMaxCharsandtaskOutputMaxCharssettings 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
/autocompactto 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
/compactand “Summarize from here” in sessions started with--agentsummarizing 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_TOKENSto change idle compaction’s 200K-token minimum;"idleCompaction": falsein 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 Agent | start_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 的设计有几个值得学习的点:
-
AsyncGenerator 模式:让循环过程可以流式地向外部暴露状态,而不是攒到最后一次性返回。这对于需要实时展示进度的 UI 场景特别有价值。
-
消息类型的丰富性:不只是
user和assistant,还有progress、attachment、tombstone、stream_event、system等。每种类型有明确的语义和处理方式。 -
预算控制是循环级别的:不是在 API 调用前检查一次,而是在每次消息处理后持续检查。这避免了”一次工具调用花光所有预算但无法中止”的问题。
-
持久化先于执行:用户消息在进入主循环前就被持久化,这保证了即使在最坏情况下,会话也是可恢复的。