第 46 章:有限窗口的智慧

个人公众号

源码验证日期:2026-05-15,基于 commit 0d81bb6

你在第 44 章看过工具系统的执行管线,在第 45 章读过权限模型的分层设计。但有一个维度我们从未触及:对话不是无限的。

不管你的模型多聪明,200K token 就是 200K token。超了就是超了,API 会返回 prompt_too_long 错误。这是一个物理限制,不是可以优化的东西。上下文压缩的本质,是用信息损失换取空间。你不可能把一段五万 token 的对话无损地塞进两千 token 的摘要里。

问题在于:消失的是什么?消失的东西重要吗?我们怎么知道?

这一章讨论上下文窗口管理的架构选择。


本章路线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
graph LR
CH41["第41章<br/>为什么是TypeScript"] --> CH42["第42章<br/>为什么是React/Ink"]
CH42["第42章<br/>为什么是React/Ink"] --> CH43["第43章<br/>为什么用Zod"]
CH43["第43章<br/>为什么用Zod"] --> CH44["第44章<br/>工具系统的演进"]
CH44["第44章<br/>工具系统的演进"] --> CH45["第45章<br/>安全与便利"]
CH45["第45章<br/>安全与便利"] --> CH46["第46章<br/>有限窗口"]
CH46["第46章<br/>有限窗口"] --> CH47["第47章<br/>大AsyncGenerator"]
CH47["第47章<br/>大AsyncGenerator"] --> CH48["第48章<br/>Agent架构"]
CH48["第48章<br/>Agent架构"] --> CH49["第49章<br/>开放协议"]
CH49["第49章<br/>开放协议"] --> CH50["第50章<br/>性能的故事"]
CH50["第50章<br/>性能的故事"] --> CH51["第51章<br/>纵深防御"]
CH51["第51章<br/>纵深防御"] --> CH52["第52章<br/>稳定、历史与未来"]

style CH41 fill:#e0e0e0,stroke:#999
style CH42 fill:#e0e0e0,stroke:#999
style CH43 fill:#e0e0e0,stroke:#999
style CH44 fill:#e0e0e0,stroke:#999
style CH45 fill:#e0e0e0,stroke:#999
style CH46 fill:#4a90d9,stroke:#2c5f8a,color:#fff,stroke-width:3px
style CH47 fill:#e0e0e0,stroke:#999
style CH48 fill:#e0e0e0,stroke:#999
style CH49 fill:#e0e0e0,stroke:#999
style CH50 fill:#e0e0e0,stroke:#999
style CH51 fill:#e0e0e0,stroke:#999
style CH52 fill:#e0e0e0,stroke:#999

现状:三层压缩的防御体系

Claude Code 的上下文管理不是一道防线,而是三道。每一层的代价和效果不同,系统从最廉价的开始,逐步升级。

第一层:Microcompact——静默的清道夫

microcompactMessages 是最早行动的机制。它不做摘要,不做总结,只是悄悄地把旧的工具结果清掉。

时间触发的 microcompact 利用了这个洞察:如果距离上一次 assistant 消息已经超过了缓存过期时间(通常 5 分钟),那么服务端的缓存已经冷了。既然要重写前缀,不如先把冗余的工具结果清掉,缩小重写范围。

1
export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'

一行替换,零 API 成本,可能省掉几千 token 的重写。

cachedMicrocompact 更进一步——它使用 API 的缓存编辑能力,直接在服务端标记删除旧的工具结果,而不需要修改客户端的消息内容。缓存前缀不变,这是最优解,但依赖于 API 特定的功能。

第二层:Session Memory Compact——结构化的记忆

当 autocompact 触发时,系统先尝试 trySessionMemoryCompaction。这不是把对话喂给 AI 做摘要,而是把结构化的 session memory(会话记忆)当作压缩手段——如果 session memory 已经记录了关键信息,就直接裁剪消息。

1
2
3
4
5
export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = {
minTokens: 10_000, // 压缩后最少保留的 token 数
minTextBlockMessages: 5, // 最少保留的消息条数
maxTokens: 40_000, // 压缩后的硬上限
}

为什么是 10,000 和 40,000?太小了模型丢失太多上下文,太大了压缩没意义。这两个数字是工程直觉加上大量实验数据的结果。

第三层:Full Compact——AI 做摘要

如果 session memory 不可用或不足以释放足够空间,系统才启动完整的 AI 摘要压缩。这是代价最高但也最灵活的方式。

压缩提示词要求 AI 输出九个部分:主要请求与意图、关键技术概念、文件与代码段、错误与修复、问题解决、所有用户消息、待办任务、当前工作、可选的下一步。这不是随便写写,而是一份精心设计的模板。

prompt.ts 里的 NO_TOOLS_PREAMBLE 反复强调”不要调用工具,只输出文本”:

1
2
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
Tool calls will be REJECTED and will waste your only turn

这段话之所以这么强调,是因为在某些模型上有 2.79% 的发生率——模型有时会在压缩请求中尝试调用工具。maxTurns 设为 1,工具调用被拒绝后,模型没有第二次机会。

触发时机的设计

AUTOCOMPACT_BUFFER_TOKENS 设为 13,000,意味着系统在离窗口边界还有 13K token 的时候就开始压缩。这个缓冲区给系统留了足够的时间,不会在最后一刻手忙脚乱。

autoCompact.ts 里的熔断器(circuit breaker)也是同样的哲学:连续失败 3 次就停止尝试,不再浪费 API 调用。注释里说,在引入熔断器之前,有些会话连续尝试 50+ 次压缩,每次都失败,每天浪费约 25 万次 API 调用。


当时还有什么选择

选择一:不压缩,直接截断

最简单的方案。上下文满了,把最老的消息删掉,只保留最近的。不需要 AI,不需要摘要,零额外 API 成本。

问题显而易见:截断是无脑的。如果用户在第一轮说”帮我重构认证模块”,然后进行了五十轮对话,截断会把最关键的指令删掉。

选择二:滑动窗口 + 向量检索

把旧对话存进向量数据库,每轮开始时用当前输入检索相关的历史片段。RAG(检索增强生成)就是这个思路。

问题在于延迟和精度。每次检索需要额外的网络调用或本地计算,而且向量检索不是精确的。在编程助手的场景里,你需要的往往是精确的文件名、具体的错误消息、逐字的用户指令,而不是”语义相近”的模糊匹配。

选择三:只有 AI 摘要,没有分层

把所有上下文管理工作都交给全量压缩。简单直接。

问题是全量压缩太贵。每次都要一次 API 调用,而 API 调用是按 token 计费的。如果你有 100K token 的对话需要压缩,压缩调用本身可能消耗 100K+ 的输入 token。


为什么选了三层

成本递增,频率递减

单靠 AI 摘要,每次都要消耗一次完整的 API 调用,成本高、延迟大。Microcompact 是免费的——它只是本地文本替换。Session memory compact 也是几乎免费的——它利用已有的结构化数据。三层设计让系统在大多数情况下用廉价方案解决问题,只在必要时才动用昂贵的 AI 摘要。

编程助手需要精确信息

“大概记得”在聊天机器人里可以接受,在编程助手里不行。模型需要知道确切的文件名、准确的函数签名、具体的错误栈。这排除了截断方案,也对摘要质量提出了极高要求。

缓存与压缩的冲突

压缩会破坏 prompt cache。这是一对矛盾:缓存要求请求前缀保持不变,而压缩的本质是替换前缀。

streamCompactSummary 使用 runForkedAgent 来执行压缩,允许压缩请求共享主对话的缓存前缀。实验数据证实了这个选择:禁用缓存共享会导致 98% 的缓存未命中,增加约 0.76% 的全舰队缓存创建成本。

代码里还有一个精巧的设计:partialCompactConversation'up_to' 方向尝试只压缩后缀,保留前缀不变。但部分压缩带来了新的复杂性——用户的对话不是线性叙事,前后之间有复杂的引用关系。


如果重新设计

分层摘要:减缓信息衰减

每次压缩都是不可逆的。一个改进方向是分层摘要:第一次压缩生成详细摘要,后续压缩基于前一次摘要生成,但保留一个”关键事实”的精简列表,穿越所有压缩层次。

用更小的模型做摘要

摘要需要的不是创造力,是准确性和完整性。一个小模型可能更适合这种结构化的信息提取任务,而且成本更低、延迟更小。

预压缩:主动而非被动

当前的压缩是被动的——上下文满了才触发。一个更激进的设计是主动压缩:在上下文达到 50-60% 时就开始做轻量级压缩。这类似于垃圾回收里的”增量收集”vs”全量收集”——增量收集每次做一点,暂停更短。


试试看

练习一:观察压缩触发

shouldAutoCompact 函数的入口加日志,观察压缩在什么条件下被触发。追踪 AUTOCOMPACT_BUFFER_TOKENS 的计算。

练习二:追踪 Microcompact

microcompactMessages 函数里加日志,观察哪些工具结果被清理了,清理后节省了多少 token。

练习三:阅读压缩提示词

找到 prompt.ts 里的压缩提示词模板,仔细阅读它要求 AI 输出的九个部分。思考:如果你来设计这个模板,你会要求 AI 保留什么?


检查点

  • 三层压缩:Microcompact(免费、静默)-> Session Memory Compact(廉价、结构化)-> Full Compact(昂贵、灵活)
  • Microcompact 的洞察:缓存冷了才清理,零 API 成本
  • 熔断器:连续失败 3 次停止,防止无限重试
  • 压缩提示词:九个部分的结构化摘要模板
  • 缓存与压缩的冲突runForkedAgent 共享缓存前缀,避免 98% cache miss
  • 13K token 缓冲区:在窗口边界之前提前触发压缩

导航

上一章:第 45 章:安全与便利的平衡

下一章:第 47 章:为什么 query.ts 是大 AsyncGenerator