之前一段时间自己实现了一个终端 Agent能帮助用户进行代码的编写和项目分析。当时为了更好地了解上下文管理和压缩机制又去看了一遍 Claude Code 泄漏的源码受益颇丰啊。一开始发现当时的 Claude Code 的源码里对于上下文压缩机制采用的策略很多很乱这就显得它的设计很复杂让人摸不着头脑。但是我完整看下来发现并不然因为这些实验性的功能大多都很激进真正用的时候估计只是用来作数据的统计作内部测试使用所以我们在学习这个项目的时候应该盯住它默认打开的功能呢先把数据链路搞清楚然后再看实验性的功能。这样会发现好懂很多贴一下当时的记录。其中还有一些自己的自问自答搞懂项目的第一步是说服自己哈哈哈一、记忆的管理分成三层CLAUDE.md指令层由用户/团队管理明确规则、约束、工作流、架构说明、编码规范。Auto-memory长期记忆层这是 Claude Code 自己跨会话积累的记忆目录通常是~/.claude/projects/sanitized-git-root/memory/里面有MEMORY.md索引和若干主题文件。会话历史 JSONLClaude Code 的主会话历史会持久化到本地 JSONL~/.claude/projects/project/session-id.jsonl1CLAUDE.md指令层CLAUDE.md指令层的本质是一组普通 Markdown 文件由用户/项目/组织管理Claude Code 负责发现、解析、缓存、按需加载并把它们作为上下文注入模型。一共有四类路径定义在config.ts:1779Managed: managed-path/CLAUDE.md User: ~/.claude/CLAUDE.md Project: repo-or-dir/CLAUDE.md Project: repo-or-dir/.claude/CLAUDE.md Project: repo-or-dir/.claude/rules/*.md Local: repo-or-dir/CLAUDE.local.md加载顺序是 Managed - User - Project - Local并且“越后加载优先级越高”。见claudemd.ts:1。这些文件是拼接到上下文里不是互相覆盖。更靠近当前目录的文件更晚出现因此更容易影响模型。.claude/rules/*.md是CLAUDE.md层的规则拆分机制为了避免 CLAUDE.md 变得越来越臃肿frontmatter中没有 paths 的规则启动时直接加载。frontmatter中有 paths 的规则只在 Claude 处理匹配文件时生效而不是每次启动加载。CLAUDE.local.md是本地私有的项目指令文件与 CLAUDE.md 的区别在于后者是项目共享指令通常提交到仓库给所有协作者共用前者是个人在这个项目里的私有指令通常不提交用来放自己的偏好/本机环境/临时工作流。比如- 我本机跑测试用pnpm test -- --runInBand- 我本地数据库端口是 154322Auto-memory 长期记忆层Auto-memory 是一个项目级、Markdown 格式、由 Claude 自己维护的长期笔记库。它默认开启会让 Claude 跨会话积累学习内容每个项目有自己的~/.claude/projects/project/memory/目录里面有MEMORY.md索引和若干主题文件。~/.claude/projects/sanitized-git-root/memory/├──MEMORY.md ├── user_role.md ├── feedback_testing.md ├── api-conventions.md └──...MEMORY.md是记忆索引只应该放短索引行不是正文。具体内容放到独立主题文件里。-[Testing feedback](feedback_testing.md)- user prefers real DB tests每个具体 memory 主题文件是普通 Markdown但带frontmatter包括filename、description、type。类型定义在memoryTypes.ts:14frontmatter 示例在memoryTypes.ts:261。type一共有四种user用户是谁、角色、偏好、背景。feedback用户纠正过 Claude 的工作方式或明确确认过某种方式有效。project不可从代码本身推导出的项目背景、目标、约束、事故原因、截止日期。reference外部系统位置比如 Github 地址、Internal Wiki 地址、Grafana dashboard。源码还明确规定不该存什么代码结构、文件路径、git 历史、临时任务状态、已经在CLAUDE.md里的内容。因为这些应该通过读代码、git、文档获取而不是污染长期记忆。见memoryTypes.ts:180。如何写入?有两条写入路径第一条是主模型直接写。系统 prompt 告诉 Claude如果用户说“记住 X”马上写入 auto-memory如果用户说“忘记 X”找到并删除对应记忆。入口在memdir.ts:419。第二条是后台抽取器补漏。每个完整回合结束后会异步触发一个后台记忆抽取任务它 fork 当前对话复制一份当前对话上下文让一个后台 subagent 分析最近消息判断是否有值得写入auto-memory的长期保存的信息。实现在extractMemories.ts:296。如果主模型本轮已经写了 memory 文件后台抽取器会跳过避免重复保存。见extractMemories.ts:121。如何读取和召回可以拆成两套机制开看第一层启动/每轮上下文里读MEMORY.md索引。这一步在claudemd.ts:979Auto-memory 目录里有很多主题文件但 Claude Code 不会一上来把所有主题文件都塞进上下文。它默认只读入口索引然后把它包装后存到上下文。实现见claudemd.ts:1153。Contents of /.../memory/MEMORY.md (users auto-memory, persists across conversations): - [Testing feedback](feedback_testing.md) - user prefers real database tests - [Release context](project_release.md) - migration deadline context所以MEMORY.md的作用是让 Claude 知道有哪些长期记忆主题存在但不直接加载所有正文。第二层真正召回主题文件。当 Claude 看到MEMORY.md索引后如果觉得某条记忆可能相关它有两种方式拿正文。模型自己主动读系统 prompt 明确告诉它当记忆看起来相关或者用户要求“回忆/记住/检查之前的内容”时必须访问 memory如果 memory 提到文件、函数、flag使用前要验证当前状态。规则在memoryTypes.ts:216。自动召回自动预取相关主题文件。这个更有意思。在 query loop 开始时Claude Code 会启动一个异步 memory prefetch 任务。它不会阻塞主模型回答而是在主模型工作时并行跑。实现见attachments.ts:2335。主模型先正常回答/调用工具工具执行完成准备进入下一轮模型调用前如果 memory prefetch 此时已经完成把相关长期记忆插入到消息流下一轮模型调用能看到这些记忆。对应源码位置是query.ts:1592用户发来问题 ↓ startRelevantMemoryPrefetch() ↓ 扫描 auto-memory 目录下的主题 .md 文件(排除 MEMORY.md最多扫描 200 个主题文件按 mtime 新到旧排序) ↓ 只读取每个文件前 30 行 frontmatter ↓ 得到 filename / description / type / mtime ↓ 用一个 sideQuery 让 Sonnet 选最多 5 个相关文件 ↓ 读取这些文件的前 200 行或 4KB ↓ 作为 relevant_memories attachment 注入后续模型上下文选择器 prompt 要求最多选 5 个只有“明确有用”才选不确定就不要选。注意点召回没有使用向量检索。这一点很关键从源码看它不是 embedding 检索不是 vector DB而是文件系统扫描frontmatter摘要然后使用小模型来判断相关性、读取被选中的 Markdown 文件。所以 Claude Code 的 memory recall 是一个显式 Markdown 索引 LLM reranker 的设计。去重和预算控制源码里还做了几层克制防止 memory 乱入太多已经检索过的 memory不再重复选。见findRelevantMemories.ts:35。如果模型本轮已经用Read/Write/Edit看过某个 memory 文件就不再自动注入。见attachments.ts:2507。整个 session 自动注入 memory 超过 60KB 后停止预取。见attachments.ts:279。因此Auto-memory 的“读取和召回”不是简单地“每轮加载所有记忆”而是一个两段式系统先加载轻量索引再按当前问题选择性召回正文。这也是它能长期积累而不把上下文撑爆的关键设计。如何管理长期记忆的质量源码主要靠 prompt 规则和后台维护保存前先查已有记忆优先更新而不是新建重复文件。按主题组织不按时间流水账组织。如果记忆提到文件、函数、flag使用前要验证当前代码状态。发现错误或过期记忆要更新或删除。memory prompt 明确告诉 Claude记忆只是“写入时为真的 claim”不是当前事实。如果记忆里提到文件、函数、flagClaude 在基于它给建议前会进行验证。比如提到文件路径时检查文件是否还存在、提到函数或 flag 时用 grep 搜代码库。如果发现文件不存在或逻辑已改则判断 memory 过期需要更新或删除对应 memory 文件。对应源码在memoryTypes.ts:240。如果用户说“不是这样了我们已经不用那个流程了” 或者“忘掉之前关于测试环境的记忆”系统 prompt 要求 Claude 找到相关 memory 并更新/删除。见memdir.ts:241。后台抽取器也会分析最近消息。如果用户纠正了 Claude 的记忆或偏好它可能在回合结束后补写/修正 memory见extractMemories.ts:329。关键点Claude Code 不是靠“时间到了就自动过期”而是靠偏差检测记忆内容 vs 当前文件 / 当前外部资源 / 用户最新纠正一旦冲突就认为 memory 可能过期。如果用户说忽略 memory就要当MEMORY.md为空不引用也不暗中使用。此外还有 autoDream 周期性整理机制在满足时间和会话数量阈值后后台 fork 一个 consolidation agent阅读 memory、近期 transcript、日志把冗余、过期、零散内容整理进更好的 topic 文件并更新 MEMORY.md。如果发现旧 memory 和当前代码矛盾就修正或删除。见autoDream.ts:1和consolidationPrompt.ts:15。3会话历史 JOSNL会话历史 JSONL 是 Claude Code 的“本地事件日志”它把当前 session 中的消息、工具调用结果、摘要、标题、tag、文件快照等都按行追加到本地明文文件里。运行时恢复会话时再从这个 JSONL 重建当前可继续的消息链。当发生/compact或auto-compact时Claude Code 通常是追加新的 compact boundary 和 summary message而不是把旧的历史消息从 JSONL 里改写删除。但是发给 LLM 时不会再全量使用 JSONL 里的旧历史。query 侧会从最后一个 compact boundary 开始切片只取 compact 后的摘要和保留尾部见messages.ts:4643。所以可以这样理解磁盘 JSONL保留历史流水账 compact 边界 compact 摘要模型 prompt只看 compact 边界之后的“压缩视图”JSONL 里的存储类型可以分成这几大类TranscriptMessage 真正对话记录、会话摘要与命名类、文件历史与贡献快照Claude 修改文件前的快照用于 checkpoint/回滚、Claude 对文件内容贡献量的统计快照、工具结果与上下文压缩辅助。每条 transcript message 都有parentUuid。恢复时不是简单把 JSONL 全部按行拼回数组读取 JSONL 构建Mapuuid, message、找最新 leaf message、从 leaf 沿parentUuid反向走到根、reverse 得到当前会话链。大工具输出不会总是完整塞在 JSONL 里。如果 tool result 太大会把完整内容写到~/.claude/projects/project/session-id/tool-results/tool_use_id.txt|json。JSONL 里的tool_resultcontent 会变成一个persisted-output引用包含文件路径和 preview。源码见toolResultStorage.ts:272。默认保留期是 30 天配置项是cleanupPeriodDays。清理范围包括projects/project/session.jsonl、session 目录下的tool-results/、空 session/project 目录。JSONL 不是 messages[]而是 append-only 的 session event log。真正恢复会话时Claude Code 会从这些 entry 里挑出 transcript messages用 parentUuid 重建当前对话链再把 metadata、summary、file snapshot、content replacement 等挂回会话状态。4Session Memory 会话记忆SessionMemory 是当前会话内的滚动摘要存储在~/.claude/projects/project/session-id/session-memory/summary.md主要服务于/compact或auto-compact后的连续性。SessionMemory 是 Claude Code 为当前 session 维护的一个summary.md文件。它由后台 forked agent 周期性更新记录当前任务状态、关键文件、错误修正、工作流和结果当自动压缩发生或用户/compact时Claude Code 可以直接拿这份摘要作为 compact summary而不是再临时调用一次普通压缩总结。后台如何更新 SessionMemory?默认触发阈值是第一次初始化上下文达到 10000 tokens后续更新距离上次提取增长 5000 tokens工具调用阈值3 次 tool calls满足条件后它会创建或读取当前会话的summary.md然后用一个隔离的 forked agent 去更新这个 Markdown 文件。当自动压缩发生或用户/compact时SessionMemory 的内容会被包装成类似“上一段会话因为上下文耗尽而继续下面是摘要”的 user summary message同时会告诉模型 recent messages 仍然原样保留。所以 compact 后的上下文是SessionMemory 摘要 最近一段原始对话 CLAUDE.md / hook 注入的上下文。二、记忆的压缩Claude Code 中关于记忆的压缩有很多种方式包括History snip、Microcompact、Context collapse 等手段但是其中一部分不是公开文档里的主线机制更像内部/实验型上下文管理默认不开启所以这里我们只看会正常使用的。在向 LLM API 发送推理请求时Claude Code 内部执行了下面的几个处理步骤1工具执行阶段单个工具结果超过阈值时完整内容写到tool-results/文件只把路径和前 2KB 预览放进上下文。文件路径类似project-dir/session-id/tool-results/tool_use_id.txt/json字符串写.txt数组 text blocks 写.json。文件名用tool_use_id所以同一次工具调用有稳定路径。比如Bash/Grep/WebFetch等工具有自己的maxResultSizeChars超过阈值会直接保存到tool-results/上下文里只放persisted-output预览。阈值算法在toolResultStorage.ts:45Bash: 30_000 chars Grep: 20_000 chars 多数工具声明 100_000但会被系统上限夹到 50_000 chars Read: Infinity不走这个落盘策略LLM 不再看到完整结果而是看到persisted-output Output too large (...). Full output saved to: /absolute/path/tool-results/xxx.txt Preview (first 2 KB): ... /persisted-outputpreview 是这个工具结果自己的开头部分默认PREVIEW_SIZE_BYTES 2000实现上按字符串 slice并尽量在换行处截断。为什么工具执行结果需要落盘落盘之后 LLM 如何感知呢再调一次工具查找这样不还是放到了上下文里吗落盘的原因在于如果某一轮并行工具结果合计巨大直接塞入上下文会让后续每轮都背着这坨历史跑。落盘替换后后续轮次只携带稳定的小预览文本完整内容仍在本地必要时再查。落盘后LLM 实际看到的是一个很短的替代文本它能感知三件事这个工具输出太大完整内容没有放进上下文。完整内容保存在哪个绝对路径。有一小段 preview 可供初步判断。如果 LLM 需要完整内容它可以再调用 Read 去读那个路径。这确实会把读取出来的内容重新放进上下文但区别在于不是自动全量放回而是按需读取。Read 通常可以带范围/限制模型可以只读相关片段。把“必然全量进入上下文”变成“需要时再局部取用”。但是LLM既然已经 function call 这个工具了肯定是就有用的啊为什么还要等需要时再局部取用不一定。LLM 调工具时通常只知道“我要看某类信息”但不知道结果会有多大、里面哪些部分真的有用。比如它调用Bash: npm test它需要的是“测试是否通过、失败点在哪里”不是 900KB 的完整日志。工具执行前它不知道日志会不会很短。执行后如果输出爆了模型看到 preview 后通常已经能判断下一步这时它只需要再读相关片段。再比如 Grep: 搜索某个符号模型想知道“哪里用了这个符号”但 grep 可能命中 3 行也可能命中 3000 行。3000 行全部进入上下文会挤掉更重要的对话历史、文件内容、用户目标。落盘后模型先看 preview再决定是缩小 grep 条件、打开某几个文件还是读取完整结果的一段。所以“调用了工具”只能说明这个信息源可能有用不等于工具返回的每一个 token 都值得长期留在上下文里。还有个实践上的原因工具结果一旦进了对话历史后续每轮都会被重新带上直到被压缩策略清掉。一个超大日志如果完整留下就会变成“历史包袱”。落盘的意义是把它从每轮都携带大结果变成每轮只携带 2KB preview 文件路径需要细节时再取局部。2请求前在内存消息数组 Message 中从最近的 compact boundary 开始截断历史找到最后一次 compact boundary 边界只保留边界之后的活动消息。老历史不会直接进入 API 请求只通过 summary 留下。3请求前Auto compact如果上下文仍接近上限会走 autoCompact相关代码在autoCompact.ts (line 241)。Auto compact 是 Claude Code 默认主线里的“上下文快满时自动摘要”策略。它本身不是每轮都执行而是在每次发 LLM API 前检查一次如果当前上下文估算已经逼近模型窗口就先压缩再用压缩后的消息继续本次请求。触发条件token 超过 auto compact 阈值。阈值不是完整 context window而是有效窗口 - 13_000 tokens其中有效窗口 模型上下文窗口 - 预留摘要输出 token 数。预留摘要输出 token 数 最多按 20_000 预留因为 compact 摘要本身也需要输出空间。在 Anthropic 观测到的压缩摘要输出长度分布里99.99% 的压缩摘要输出都不超过 17,387 tokens向上取整预留 20,000 tokens。13_000 源码没有写统计来源它是一个经验安全余量。作用是让 Claude Code 不要等上下文真的顶满才压缩因为压缩前后还有这些不确定开销token 估算误差系统提示、工具 schema、MCP 指令、用户上下文也会进入请求当前这一轮可能还有工具调用和模型输出。如果太晚压缩连“生成压缩摘要的请求”本身都可能 prompt too long。如果现在发现目前从内存 message 拿出的上下文没有到达 auto compact 的压缩阈值所以没有启用auto 压缩但是等接下来把系统提示、工具 schema、MCP 指令都加进来发现超过了预留的13000 tokens了怎么办如果后加的系统提示、工具 schema、MCP 指令等超过了这 13K buffer主动 autocompact 可能确实没来得及触发。那么结果是 API 直接返回 prompt too long然后进行 reactive compact如果 reactive compact 也失败显示错误。如果现在发现目前从内存 message 拿出的上下文没有到达 auto compact 的压缩阈值所以没有启用auto 压缩接下来把系统提示、工具 schema、MCP 指令都加进来也没有超过了预留的 13000 tokens但是等模型输出的时候加上模型输出的 token 数就超过了 13000 tokens这该怎么办模型的输出也算到最大上下文窗口里面吧如果加上输出超过了这个窗口会有什么影响。当前项目里有两类兜底1请求前的 max_tokens 保护。某些旧 provider 发 API 请求时会带 max_tokens 指定模型本次回答最多生成多少 token。如果“输入 允许输出上限”一开始就超了可能还没生成就被 API 拒绝。2有些新 API 不一定用“请求前 400 拒绝”的方式处理而可能允许请求开始然后在生成时用stop_reason model_context_window_exceeded 告诉客户端“生成被上下文窗口截断了”。以 Qwen 举例qwen3.5 以后的版本都使用 max_completion_tokens其他模型用 max_tokens。为什么这里 API 已经判断过了为什么还会出现输出过程中撞到上下文窗口的情况因为模型生成时服务端掌握最终渲染后的 prompt、thinking、工具循环、缓存处理、上下文管理策略等真实状态。即使客户端传了max_tokens真实可用输出空间仍然可能少于max_tokens。为什么客户端统计的 token 数有误差客户端很多时候不是精确 tokenizer 计数API response 里的输入输出 usage token 数是真实的但是对于新增的 messages 比如工具的执行结果、新的 MCP 和指令在发送 API 请求前都是需要估算的。新增部分不是用服务端同款 tokenizer 精确算而是走估算逻辑。压缩方式full compact compact.ts:387先让模型生成摘要然后把请求消息替换成compact boundary compact summary message 重新注入最近读取的附件 / 已调用的skills/MCP / hooks 等如果 full compact 在“生成 summary 的请求”阶段就 prompt too long 怎么办初始尝试 1 次 最多 3 次 retry。把 retry 最早的一批对话轮次从“要总结的输入”里删掉然后重新请求摘要。full compact 成功后发现还是超过 auto 阈值会有重试和兜底吗full/auto compact 成功后Claude Code 会组装 post-compact messages然后继续 query loop。即使估算发现 post-compact messages 仍然超过 auto 阈值也不会立刻再压缩一遍。如果接下来 API 真的返回 prompt too long / 413才进入后面的救火链→ reactive compact → retry 原请求 → 还失败就提示错误 prompt_too_long。4请求前拼入 userContext / system prompt真正调用模型前会把 userContext prepend 到消息前面system prompt、工具定义、MCP 信息、skills 等也会被组装进请求。注意这些不一定都在 messages 数组里但会进入最终 token 计算。5请求后如果第一次 API 调用后仍返回 prompt-too-long 响应Claude Code 还有“反应式恢复”路径会扣留这个错误、进行 reactive compact然后重试。reactive compact 会把较早的一段历史总结掉但保留最近一段原始消息让当前回合、最近工具调用、最后几轮对话尽量不被摘要损失掉。compact boundary summary preserved recent suffix attachments/hooks如果 compact 后 API 仍返回 prompt too long会有重试和兜底吗同一轮最多成功压缩并 retry 原请求 1 次。如果 retry 后 API 还是 prompt too long就不会继续 reactive compact 循环而是把错误返回给用户。一些默认不启用的实验性策略了解即可applyToolResultBudget先压缩过大的工具结果检查同一个 API user message 里的tool_result总量。如果太大会把完整结果持久化到本地文件只在上下文里留下路径和短预览。这个逻辑在toolResultStorage.ts:739。persisted-output Output too large (...). Full output saved to: /absolute/path/... Preview (first 2 KB): ... /persisted-output每个被落盘的tool_result各自保留前 2000 字符左右的 preview尽量在换行处截断。见toolResultStorage.ts:187。Claude Code 内部可能有多个连续 user messages比如并行工具结果分多条进入状态。但发 API 前会把连续 user messages 合并。所以预算检查也按“最终 API 会看到的一组 user message”来算而不是按本地数组里的单条 message 算。所以这和“单个工具结果太大”的落盘不是同一层。单个工具结果如果超过工具自己的阈值也会在生成 tool_result 时落盘这里解决的是“每个结果单看不算离谱但一批并行工具结果加起来太大”的问题。Microcompacttime-based microcompact把较旧的、可压缩工具结果内容替换成类似[Old tool result content cleared]但保留消息结构。