Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列中「记忆系统」命题的第 3 篇。前面我们拆解了跨会话持久记忆的存储模型和存取管线。但还有一个问题没回答:单次会话内怎么办?聊到 50 轮后上下文快满了,难道直接截断?

本文聚焦一件事:Claude Code 如何在单次会话中自动记笔记,并用这些笔记渐进式压缩臃肿的消息历史。

读完全文,你将能回答这几个问题:

  • Claude Code 每次聊完会自动做笔记吗? 会。Session Memory 是一个后台 Fork Agent,根据 token 增长 + 工具调用次数的双条件 AND 逻辑触发,把对话关键信息更新到 9 区段模板中。
  • 聊到 50 轮上下文满了怎么办? 不是简单截断——三阶段压缩(Microcompact → SM-compact → Full Compact)像内存管理一样分级回收,优先用最轻量的方式释放空间。
  • SM-compact 怎么用笔记替代消息历史? calculateMessagesToKeepIndex 计算保留的消息范围(满足 min 10K tokens + 5 条文本消息),丢弃的消息由会话记忆摘要替代。它还修复了 tool_use/tool_result 孤儿引用和 thinking block 丢失两个真实的 API 级 Bug。

「记忆系统」命题的另外两个主题已经独立成文:四类记忆与文件存储引擎——记忆怎么分类、MEMORY.md 索引怎么组织 topic 文件、路径决议怎么做到默认安全;注入与存取管线——记忆怎么进入 System Prompt、主路和旁路怎么分工保存、每轮怎么精选 5 条召回。感兴趣的朋友欢迎关注维元码簿,或订阅《Claude Code 源码 Deep Dive》专栏,持续追更。


本篇覆盖的源码范围

模块 核心文件 代码行 职责
会话记忆初始化 src/services/SessionMemory/sessionMemory.ts 496 模板初始化、shouldExtractMemory、setupSessionMemoryFile
会话记忆模板 src/services/SessionMemory/prompts.ts 325 DEFAULT_SESSION_MEMORY_TEMPLATE、更新 prompt
会话记忆阈值 src/services/SessionMemory/sessionMemoryUtils.ts 207 初始化/更新阈值、extraction 状态管理
SM 压缩核心 src/services/compact/sessionMemoryCompact.ts 631 trySessionMemoryCompaction、calculateMessagesToKeepIndex
微压缩 src/services/compact/microCompact.ts ~600 工具输出摘要替换、message 级 token 估算
全量压缩 src/services/compact/compact.ts ~1800 compactConversation、Fork Agent 摘要
自动压缩 src/services/compact/autoCompact.ts ~400 autoCompactIfNeeded、阈值检查
压缩后清理 src/services/compact/postCompactCleanup.ts ~130 runPostCompactCleanup、主/子 Agent 区分

总计约 4500 行核心代码。本篇聚焦其中与"会话记忆"和"压缩策略"最相关的部分。

为什么需要会话记忆

在深入代码之前,先理解设计动机。

跨会话持久记忆(Auto Memory)解决了"下次对话还记得"的问题。但单次会话内还有一个更紧迫的问题:上下文窗口是有限的。200K tokens 听着不小,但在复杂工程对话中——项目结构、文件内容、多轮交互——很快就满了。

Claude Code 的做法不是简单截断最旧的消息(那会丢失关键上下文),而是:先把对话中有价值的信息提取成结构化笔记,然后用笔记替代臃肿的原始消息。这就是 Session Memory + 压缩系统的价值。

在这里插入图片描述

会话记忆:Agent 接手会话时必须知道的那些事

看到“9 区段模板”的第一反应往往是“怎么凑出 9 个”。反着问可能更近本质:如果一个新 Agent 要接手这个会话往下做,它最少需要知道什么? DEFAULT_SESSION_MEMORY_TEMPLATEprompts.ts L11-41)的每个区段都在回答这个问题里的一个维度,9 个区段加起来恰好覆盖“任务 / 状态 / 导航 / 流程 / 警戒 / 知识 / 产出 / 时间线”这几块互不重叠的信息。不是凑出来的数字,是“接手所需的最小信息集”的自然分解。

哲学一:模板是 Schema,不是总结

固定 9 区段的第一个价值不是“清晰”,而是让 Fork Agent 的每次更新都有写入坐标。传统的“让模型写摘要”每次都得从零重新合成整篇;有了固定模板,Fork Agent 可以只做“往 Workflow 追加一行”这种增量更新。这就是上一篇讲的“索引 + 内容”理念在单次会话里的循环复刻——模板 = 索引,区段内容 = topic。

哲学二:每个区段对应一个“接手必问”的问题

区段 接手 Agent 必问 设计意图
Session Title 这是什么会话? 快速识别
Current State 现在进展到哪了? 压缩后最重要——直接决定下一步行动
Task specification 用户最初要什么? 回溯任务目标
Files and Functions 代码在哪里? 导航结构
Workflow 常用命令是什么? 重现工作流
Errors & Corrections 踩过什么坑? 不可重蹈
Codebase and System Documentation 系统怎么运作? 架构知识
Learnings 哪些方法验过有效/无效? 经验沉淀
Key results 用户要的精确输出是什么? 避免重算
Worklog 走到今天一共做了哪些事? 时间线

注意这里没有“对话历史”区段——因为对话本身是要被替代的,不是要被压缩保存的。这正是“会话记忆”区别于“对话摘要”的根本设计:摘要是“把说过的话变短”,记忆是“把说过的话凝结成状态”。

哲学三:斜体描述不是注释,是给 Fork Agent 的 Schema 约束

每个区段标题下面的斜体(例如 _A short and distinctive 5-10 word descriptive title for the session_)不是给人看的注释,而是Fork Agent 的写入规范prompts.ts L55-61 里的更新指令明确要求:只能更新斜体描述下方的内容,不能修改标题、不能删除斜体描述。这样一来,模板结构在每次更新后都保持稳定,Fork Agent 下一次更新时仍然认得每个区段的“写入坐标”。

容量限制:软上限 + 循环淘汰

每个区段约 2000 tokens 软上限(MAX_SECTION_LENGTH = 2000),整文件 12000 tokens 硬上限(MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000)。接近上限时,更新 prompt 会指示 Fork Agent “循环掉不那么重要的细节,保留最关键的信息”——又是一次“索引容量”的设计哲学复刻:稳定容量 + 优先级淘汰,让笔记永远保持在“可进 Prompt Cache”的体量。

在这里插入图片描述

后台提取:什么时候值得落笔

“多久提取一次”是典型的定时器思维,但会话记忆的触发逻辑不是按时间走的——因为 10 分钟里可能发生一次大规模重构,也可能只是用户看了两眼文档。真正的问题是“从上次落笔到现在,积累了多少值得记的东西”shouldExtractMemory 把这个问题拆成两条线索:量变(聊了多少 token)和形变(做了几次工具调用),以此判断“是不是攒够了可记的内容”。

哲学一:触发不走时间维度,走信息维度

“token 增长”衡量信息量,“工具调用次数”衡量工作强度——两者都低就说明“没发生什么大事”。这就是 shouldExtractMemorysessionMemory.ts L134-181)的双条件 AND:

初始化检查:
  token 总量 >= minimumMessageTokensToInit?
    Yes → 初始化,设置游标
    No  → 跳过(对话太短,不值得提取)

更新检查:
  token 增长 >= minimumTokensBetweenUpdate
    AND (工具调用次数 >= toolCallsBetweenUpdates
         OR 最后一轮没有工具调用)
    → 触发提取

哲学二:自然断点是最佳落笔时机

注意第二条 OR 里那个“最后一轮没有工具调用”——这是一条**“自然断点”规则**。一轮回复不含工具调用,通常意味着 Agent 完成了一个子任务、正在等用户给下一条指令。此时的对话状态最完整、最稳定——笔记在这里落笔,不会记录到一段“还没写完就被打断的逻辑”。它把 postSamplingHook 的高频触发和“低破坏性时机”匹配起来,实现了“每次 sampling 后都看一眼,但只在对的时候动手”。

哲学三:初始化门槛高于更新门槛

minimumMessageTokensToInit 明显高于 minimumTokensBetweenUpdate。这不是图方便——第一次提取的成本最高(要把整个会话的背景、任务、进展都写清楚),如果会话刚起步就触发,Fork Agent 只能在“对话还没定型”的时候输出一份粗糙笔记。把初始化门槛抬高,等信息足够成型再落第一笔,后续更新就轻松得多。

落地:postSamplingHook + Fork Agent

extractSessionMemory 通过 registerPostSamplingHook 注册——每次模型采样后都触发一次。看起来是高频钩子,但上述三条哲学落到 shouldExtractMemory 里的阈值检查后,绝大多数调用都是零成本的早期退出。只有“量变 + 形变 + 自然断点”都对上的那次,才会真的走到 Fork Agent 执行路径(sessionMemory.ts L272-375)——setupSessionMemoryFile 读记忆文件、buildSessionMemoryUpdatePrompt 构建带 cache-safe 参数的更新 prompt、runForkedAgent 并行更新各区段、markExtractionCompleted 标记完成。

三阶段压缩:渐进式上下文回收

有了会话记忆作为"笔记",压缩系统就能用笔记替代原始消息。压缩不是一刀切——是三阶段渐进式的,优先使用最轻量的方式。

阶段概览

     上下文用量
         │
   100%  ├─ API 报错
         │
    80%  ├─ SM-compact(会话记忆替代历史)
         │     成本:低(复用已有笔记)
         │
    60%  ├─ Microcompact(压缩工具输出)
         │     成本:几乎零(纯文本替换)
         │
     0%  └─────────────────────────► 对话轮次

类比:这就像 JVM 的内存管理——L1 缓存淘汰(快、小)→ GC 小循环(中、中)→ Full GC(慢、彻底)。

阶段 1:Microcompact(60% 阈值)

当上下文用量达到 60% 左右时触发。操作最简单——将旧的工具输出替换为摘要。例如,一个 50000 字符的 bash 输出只保留前 2000 字符 + 一个尾注(...truncated, showing first 2000 of 50000 chars)。

Microcompact 不涉及模型调用,不创建新消息——它纯在现有消息上做内容替换。成本几乎为零

阶段 2:SM-compact(80% 阈值)

当 Microcompact 不够用了,进入 SM-compact——这是我们本篇的重点。

入口:trySessionMemoryCompaction

trySessionMemoryCompaction(messages, agentId, autoCompactThreshold)sessionMemoryCompact.ts L514-630)的执行流程:

  1. 等待提取完成waitForSessionMemoryExtraction()——如果会话记忆正在后台提取中,等待它完成(15s 超时)
  2. 获取会话记忆getSessionMemoryContent()——读取当前会话记忆文件
  3. 计算保留范围calculateMessagesToKeepIndex()——决定哪些消息要保留
  4. 构建压缩结果createCompactionResultFromSessionMemory()——用会话记忆替代丢弃的消息

核心:calculateMessagesToKeepIndex

calculateMessagesToKeepIndex(messages, lastSummarizedIndex)sessionMemoryCompact.ts L324-397)是 SM-compact 的灵魂。它决定"哪些消息保留、哪些丢弃(用摘要替代)"。

输入

  • messages:完整的消息数组
  • lastSummarizedIndex:上一轮压缩到的位置

约束DEFAULT_SM_COMPACT_CONFIG L57-61):

参数 默认值 含义
minTokens 10,000 至少保留 10K tokens
minTextBlockMessages 5 至少保留 5 条有文本内容的消息
maxTokens 40,000 最多保留 40K tokens(硬上限)

算法(伪代码):

startIndex = lastSummarizedIndex + 1  // 从上次压缩位置开始
tokens = 统计 [startIndex, end] 范围内的 tokens
textMsgs = 统计 [startIndex, end] 范围内的文本消息数

// 如果已经超过最大上限,直接返回
if tokens >= maxTokens: return startIndex

// 如果已经满足最小约束,返回
if tokens >= minTokens AND textMsgs >= minTextBlockMessages: return startIndex

// 向后展开:往更早的消息走
for i = startIndex - 1 down to floor:
    tokens += 消息 i 的 tokens
    if 消息 i 有文本内容: textMsgs++
    startIndex = i
    if tokens >= maxTokens: break    // 撞硬上限
    if tokens >= minTokens AND textMsgs >= minTextBlockMessages: break  // 满足最小约束

return adjustIndexToPreserveAPIInvariants(messages, startIndex)

设计意图:压缩不是"删掉所有旧消息"——而是"保留一个最小的上下文窗口"。10K tokens 确保 Agent 保留足够的近期上下文来理解"现在发生了什么",5 条文本消息确保保留了用户和 Agent 之间最近的意图交流。40K 硬上限防止压缩后上下文仍然太大。

这些默认值可通过 GrowthBook 动态配置(tengu_sm_compact_config),允许团队在不发版的情况下调整压缩策略。

在这里插入图片描述

压缩结果的结构

createCompactionResultFromSessionMemory 构建的压缩结果包含:

  1. 边界标记compactBoundaryMessage):标记"从这里开始是摘要之前的旧消息"
  2. 摘要消息summaryMessages):将会话记忆内容包装为 user message,标记 isCompactSummary: true
  3. 保留消息messagesToKeep):calculateMessagesToKeepIndex 计算出的保留范围
  4. Hook 结果hookResults):重新执行 processSessionStartHooks,恢复 CLAUDE.md 等上下文

结果是:旧消息被替换为一条摘要消息(几千 tokens),加上保留的最近 10K-40K tokens。

阶段 3:Full Compact(兜底)

当 SM-compact 不可用时(会话记忆为空、Feature Gate 关闭、压缩后仍然超阈值),系统回退到 Full Compact——调用模型重写摘要。

compactConversationcompact.ts)的核心流程:

  1. 先用 Microcompact 预处理(减少 token)
  2. Fork Agent 读取对话历史 + 生成摘要 prompt
  3. 模型分析对话 → 输出 <analysis> + <summary>
  4. 摘要替代被丢弃的消息

Full Compact 成本最高(额外模型调用),但保底——即使会话记忆不可用,也能完成压缩。

API 不变量:streaming 时代的消息裁剪硬约束

上一节的 calculateMessagesToKeepIndex 算出 startIndex 之后,并不直接返回——它还要再过一道 adjustIndexToPreserveAPIInvariantssessionMemoryCompact.ts L232-314)。为什么算出来的下标还要再修?因为消息数组是 agent 的内部视图,但 Anthropic API 看到的是“逻辑对话”——你在内部数组上切出的 startIndex,未必切在“逻辑对话”的合法边界上。这一小节要讲的,不是“修了两个 bug”,而是一条对所有做 Agent 消息裁剪的人都适用的硬约束。

设计哲学:消息数组边界 ≠ 逻辑对话边界

Claude API 的 streaming 会把一次 assistant 响应按 content block 拆成多条流式事件——thinking 一条、tool_use A 一条、tool_use B 再一条——Claude Code 在内部消息数组里按这个粒度存。但对 API 而言,这三条属于同一次响应,裁剪时必须当一个整体看。

这带来两条Anthropic API 对输入消息的硬约束,任何做对话管理的 Agent 都要面对:

不变量 内容 streaming 下被破坏的方式
tool 配对 每个 tool_result 必须能在消息数组里找到对应 ID 的 tool_use 切点落在多个 tool_use 之间时,前面的 tool_use 被丢掉,后面 user 里对应的 tool_result 就成了“孤儿”
message.id 内聚 同一个 message.id 的所有 content blocks 必须一起保留 切点落在同 id 的 thinking block 和 tool_use block 之间时,normalizeMessagesForAPI 合并时找不到 thinking,整块丢失

违反任一条,Anthropic API 会直接 400。Sonnet 4.6 实测,SM-compact 有 ~2.79% 的失败率来自这两条被切坏——不是 bug 故事,是“不守约束”的代价。

adjustIndexToPreserveAPIInvariants 的两步回溯

这个函数做的事,其实就是把“算出来的 startIndex”回溯到最近的合法语义边界

  1. Step 1(保 tool 配对):收集 [startIndex, end] 范围内所有 tool_result 的 IDs → 到 startIndex 之前找匹配的 tool_use → 把它所在的消息也纳入保留。本质是把“孤儿 tool_result”连着它的 tool_use 一起拉回来
  2. Step 2(保 message.id 内聚):收集 [startIndex, end] 范围内所有 assistant message 的 message.id → 到 startIndex 之前找同 id 的消息 → 纳入保留。本质是把被切散的 content blocks 重新聚拢

注意这两步都是向更早的方向扩张保留范围——绝不会把已经保留的消息再踢出去。扩张的结果是:最终保留的消息数量可能略大于 minTokens 算出的理想值,但一定是 API 能接受的合法输入。

在这里插入图片描述

所有 Agent 消息裁剪的共同硬约束

只要你的 Agent 会对消息数组做下标级裁剪——不管是压缩、滑动窗口还是对话截断——这条结论都适用:

任何下标级的裁剪操作之后,必须额外跑一步“回溯到 API 合法边界”。 streaming 模式下,消息数组的物理边界和逻辑对话的语义边界不重合——不回溯就会在生产环境直接 400。

2.79% 的失败率不是设计的错,是没守这条约束的代价。Claude Code 把这条约束抽成独立函数(adjustIndexToPreserveAPIInvariants),让它可复用、可验证、可单测——这也是区分“能跑的原型”和“扛得住生产”的一道分水岭。

压缩后清理与恢复会话

runPostCompactCleanuppostCompactCleanup.ts)在压缩完成后执行清理。它有一个关键的主/子 Agent 区分

if isMainThreadCompact:
    重置 microcompact 状态
    重置 context-collapse 状态(如果启用)
    清除 memory file 缓存(getUserContext.cache)
    // 子 Agent 的压缩不重置主 Agent 的状态!

为什么要区分?因为 Agent Tool 创建的子 Agent 和主 Agent 共享模块级状态(同一个进程)。如果子 Agent 压缩时重置了状态,会破坏主 Agent 的正常运行。

另外,恢复会话的兼容性也值得关注。lastSummarizedMessageId 可能在恢复会话时缺失——这时回退到 messages.length - 1sessionMemoryCompact.ts L562-565),保证恢复的会话也能正常压缩。

本章小结

本篇拆解了会话记忆的自动提取和三阶段压缩策略:

  1. 会话记忆:9 区段模板 + Fork Agent 后台提取。斜体描述是给 Fork Agent 的指令,不是内容。各区段有 token 上限,超限时循环淘汰旧内容。

  2. 后台提取触发:双条件 AND 逻辑(token 增长 + 工具调用),两个条件都满足才触发;但自然断点(无工具调用的轮次)时可以只靠 token 阈值触发。初始化阈值高于更新阈值。

  3. 三阶段压缩:Microcompact(60%,纯文本替换,零成本)→ SM-compact(80%,会话记忆替代历史,低成本)→ Full Compact(兜底,模型重写摘要,高成本)。类比 JVM 的分级 GC。

  4. SM-compact 核心算法calculateMessagesToKeepIndex 三段约束(min 10K / 5 msgs / max 40K),向后展开直到满足最小约束或撞到硬上限。

  5. API 不变量保护adjustIndexToPreserveAPIInvariants 修复了 tool_use/tool_result 孤儿引用和 thinking block 丢失两个 streaming 导致的 Bug——2.79% 失败率归零。

  6. 压缩后清理runPostCompactCleanup 区分主/子 Agent——子 Agent 的压缩不重置主 Agent 状态,防止状态污染。


如果这篇文章对你有帮助,欢迎点赞收藏支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐