【笔记】Claude Code 的上下文加载与变更感知
1. 一句话心智模型
Claude Code 处理项目指令时同时维护两条通道:
- 上下文加载通道把 CLAUDE.md 等指令文件聚合成稳定前缀,并为会话复用缓存。
- 文件变更通道逐轮检查模型已经看过的文件,把外部修改作为新的 attachment 告诉模型。
flowchart TD
A[CLAUDE.md / rules / memory] --> B[getMemoryFiles]
B --> C[getClaudeMds]
C --> D[getUserContext<br/>memoized]
D --> E[会话前缀中的 system-reminder]
A --> F[readFileState<br/>保存内容与时间戳]
F --> G[getChangedFiles]
G --> H[edited_text_file attachment]
两条通道刻意不对称:修改文件后,Claude 可以通过 diff 感知变化,但已经缓存的完整指令前缀不会因此立即重建。
2. 先区分三个容易混淆的概念
2.1 指令文件不是 API system message
Claude Code 会把聚合后的指令内容包装进带 <system-reminder> 的元消息,放在用户消息序列前面。它具有指导模型行为的作用,但从实现结构看,不应简单描述成“修改了 API system prompt”。
当前 prependUserContext() 构造的形态大致是:
1
2
3
4
5
6
7
8
9
<system-reminder>
As you answer the user's questions, you can use the following context:
# claudeMd
...聚合后的指令内容...
# currentDate
Today's date is ...
</system-reminder>
准确说法是:CLAUDE.md 被装入会话的缓存前缀,而不是每轮都从磁盘读取后动态替换系统提示词。
2.2 加载指令与读取普通文件共用变更追踪
模型通过 Read 工具读取文件后,Claude Code 会在 readFileState 中记录内容快照和时间戳。启动时加载的指令文件也会被登记进去,因此它们能够参与后续外部修改检测。
readFileState 的职责不只是“防止未读先写”。它也是变更感知的观察集合:只有已经进入这个集合的文件,getChangedFiles() 才知道应该检查。
2.3 指令发现既有 eager,也有 lazy
会话启动时会发现并加载当前工作目录相关的全局、项目和本地指令。位于更深子目录中的 CLAUDE.md 则可以在访问相应路径时作为 nested memory 延迟加载。
这解释了为什么大型仓库不必把所有包级指令都塞进首轮上下文:
1
2
3
repo/CLAUDE.md 启动时与当前目录链路一起加载
repo/service-a/CLAUDE.md 访问 service-a 时按需注入
repo/service-b/CLAUDE.md 未访问时不必进入上下文
3. 静态通道:getUserContext 怎样构造稳定前缀
3.1 调用链
当前源码的核心链路是:
1
2
3
4
5
6
7
8
9
getUserContext()
├── getMemoryFiles()
│ ├── 发现不同作用域的 CLAUDE.md / rules / memory
│ ├── 解析 @include
│ └── 读取和规范化内容
├── filterInjectedMemoryFiles()
├── getClaudeMds()
│ └── 按来源标注后拼成 claudeMd 字符串
└── return { claudeMd, currentDate }
随后查询路径调用 prependUserContext(messages, userContext),把结果放到消息序列前端。
@include 会递归加载其他文件。当前实现使用已处理路径集合防止重复和循环,并设置最大深度;因此 import 是受边界约束的指令展开,不是无限递归的模板系统。
3.2 两层缓存
这里至少有两层需要同时理解:
getMemoryFiles()缓存发现和文件读取结果。getUserContext使用memoize缓存最终聚合结果。
只清内层不够:外层仍可能直接返回旧对象,根本不会再次调用 getMemoryFiles()。
flowchart LR
A[磁盘文件] --> B[getMemoryFiles cache]
B --> C[getUserContext memoize cache]
C --> D[conversation prefix]
这种缓存的目的不是单纯减少几次磁盘 I/O。稳定前缀还可以提高模型 API 的 prompt cache 命中率;如果每轮都因文件时间戳或无关变化重建前缀,后续整段对话都可能失去缓存复用。
3.3 缓存什么时候刷新
从当前源码看,主线程发生 compaction 时,runPostCompactCleanup() 会同时:
- 清理
getUserContext的 memoize cache。 - 调用
resetGetMemoryFilesCache('compact')清理内层 memory file cache。
/clear 的会话清理路径也会清除这些上下文缓存。下一次查询重新发现并读取指令文件。
这里要区分两个动作:
- 清缓存只让下一次调用有机会重新读盘。
- 真正重新注入发生在下一轮重新构造上下文时。
4. 动态通道:getChangedFiles 怎样发现外部编辑
4.1 readFileState 保存观察基线
每个已读取文件对应一份状态,核心信息包括:
1
2
3
4
5
file path
content snapshot
read timestamp
offset / limit
isPartialView
普通 Read、Edit、Write 以及指令注入都会维护这个集合。对于被截断或预处理过的指令文件,Claude Code 会尽量保存磁盘原始内容,并用 isPartialView 防止把不完整视图当成安全编辑基线。
4.2 逐轮检测流程
getChangedFiles(toolUseContext) 遍历 readFileState:
flowchart TD
A[遍历 readFileState] --> B{当前 mtime > 记录时间?}
B -->|否| C[跳过]
B -->|是| D{读取权限仍允许?}
D -->|否| C
D -->|是| E[重新调用 FileReadTool]
E --> F{内容类型}
F -->|文本| G[计算新旧内容 diff snippet]
F -->|图片| H[按 token budget 重新读取]
G --> I[edited_text_file attachment]
H --> J[edited_image_file attachment]
如果文件只是被 touch,内容 diff 为空,就不会产生 attachment。如果文件已经删除,会从观察集合移除;暂时性的权限错误或原子保存竞争则不会轻易驱逐记录,以便下一轮重试。
4.3 attachment 告诉模型发生了什么
文本文件变化后,Claude Code 注入的不是整份会话前缀,而是一个 edited_text_file attachment,其中包含文件名和新旧内容的差异片段。
因此模型能够知道:
- 哪个已经读过的文件被外部工具修改了。
- 修改集中在哪些片段。
- 继续编辑前需要基于新内容重新判断。
这套机制不仅服务 CLAUDE.md,也用于模型正在处理的普通源码。编辑器、格式化器或用户手工修改文件后,模型不应继续盲目使用旧快照。
5. CLAUDE.md 在会话中被修改时会发生什么
假设启动时加载了 repo/CLAUDE.md,随后用户在编辑器中修改它:
sequenceDiagram
participant Disk as CLAUDE.md
participant Static as getUserContext cache
participant Watch as getChangedFiles
participant Model
Disk->>Static: 会话启动时读取 V1
Static->>Model: 前缀注入完整 V1
Disk->>Disk: 外部编辑为 V2
Watch->>Disk: 下一轮比较 mtime 和内容
Watch->>Model: 注入 V1 → V2 的 diff snippet
Static->>Model: 仍复用缓存前缀 V1
此时模型看到两份信息:
- 前缀里仍然是 V1 的完整指令。
- 当前轮 attachment 告诉它文件已经变成 V2,并展示变化片段。
模型可以理解并遵循明确的变化,但这不等于 V2 已经取代缓存前缀。未出现在 diff 片段里的 V2 全文也不能被假设已经重新加载。
要完整重建指令上下文,需要触发会同时清理内外两层缓存的生命周期,例如主线程 compaction 或 /clear。若只需要继续当前任务,也可以显式 Read 新文件,让模型获得当前完整内容,但旧前缀在缓存刷新前仍然存在。
6. 为什么不监听文件并立即热重载
表面上最直接的方案是监听所有 CLAUDE.md,只要变化就重建上下文。但这会引入几个问题:
6.1 历史消息的语义无法被真正改写
旧指令已经参与前面多轮推理。把前缀替换成新版本,只能影响后续调用,无法让历史回答“从未见过旧指令”。因此所谓热重载本身就不是完全一致的时间旅行。
6.2 破坏稳定前缀和 prompt cache
前缀变化会改变后续请求的缓存键。为了一个可能与当前任务无关的文件修改,让长对话重新计算整个前缀,成本可能远高于收益。
6.3 文件变化不一定代表规则变化
格式化、换行调整、同步工具 touch、分支切换都可能改变 mtime。先用 diff attachment 暴露变化,再在明确生命周期重建完整上下文,是更保守的策略。
6.4 深层规则本来就是按需加载
仓库中可能有大量子目录指令。全量 watcher 不仅浪费资源,还会让尚未涉及的模块规则扰动当前会话。路径触发的 nested memory 更符合渐进式上下文加载。
7. 这套设计的边界
7.1 mtime 是检测入口,不是内容版本
当前检测先判断 mtime 是否更新。若文件系统时间精度、同步工具或异常写入让内容变化却没有更大的 mtime,检查可能看不到变化。需要高一致性时,不能把它当作文件监控协议。
7.2 readFileState 不是无限文件索引
它保存模型已读取或已注入文件的近期状态,不会扫描仓库里所有文件。一个从未进入观察集合的普通文件,即使外部修改,也不会自动产生 changed-file attachment。
7.3 diff attachment 不等于完整重读
差异片段适合提醒和冲突防护,但不能代替完整文件,尤其当新规则依赖未变化的上下文时。重要规则变更后,显式重读或刷新会话上下文更可靠。
7.4 不同执行路径可能裁剪上下文
主交互线程、子 Agent、SDK 调用和压缩后的会话可能采用不同的上下文裁剪策略。本文描述的是当前主交互路径的核心机制,不能据此断言所有子进程都会收到相同的 CLAUDE.md 全文。
7.5 源码行为可能随版本演进
getUserContext、getMemoryFiles、getChangedFiles 是当前 harness 源码中的实现锚点,不是公开 API。官方长期保证的是 CLAUDE.md 作为项目指令被加载;缓存层次、attachment 类型和刷新时机属于实现细节,升级后需要重新核验。
8. 实用操作判断
| 目标 | 推荐动作 | 原因 |
|---|---|---|
| 只想让 Claude 注意刚改的一小段 | 继续下一轮并确认 changed-file diff | 保留稳定前缀,成本低 |
| 需要 Claude 获得当前完整文件 | 显式 Read CLAUDE.md | 不依赖 diff 截取范围 |
| 规则发生根本变化,旧规则不应继续影响任务 | /clear 后重新开始 | 清除旧消息和上下文缓存 |
| 长会话自然压缩后继续 | 核对 compaction 后重新加载的指令 | 当前实现会刷新主线程 memory cache |
| 某子目录规则没有出现 | 先确认是否访问了该路径及规则作用域 | 深层规则可能按路径延迟加载 |
/clear 会丢弃当前对话上下文,不应为了无关小修改机械执行。是否刷新取决于规则变化会不会实质改变接下来的推理。
9. 复习索引
- 双通道:
getUserContext构造稳定前缀,getChangedFiles注入逐轮变更。 - 两层缓存:外层是 memoized user context,内层是 memory file 发现与读取缓存。
- 注入位置:CLAUDE.md 被包装为前置
<system-reminder>元消息,不要笼统称为 API system prompt。 - 观察基线:启动时指令文件和模型读过的普通文件都会进入
readFileState。 - 变化结果:外部修改产生 diff attachment,但不会立即替换旧的完整指令前缀。
- 刷新时机:当前主线程 compaction 和
/clear会清理相关缓存,下一轮重新加载。 - 渐进加载:当前目录相关指令 eager 加载,更深层 CLAUDE.md 可以随路径访问延迟注入。
- 边界:缓存和 attachment 是当前内部实现,不属于稳定公开 API。
10. 源码锚点与公开资料
src/context.ts:getUserContext()与getSystemContext()。src/utils/claudemd.ts:指令发现、include、聚合和缓存。src/utils/api.ts:prependUserContext()。src/utils/attachments.ts:getChangedFiles()与 nested memory 注入。src/screens/REPL.tsx:启动时将指令文件登记进readFileState。src/services/compact/postCompactCleanup.ts:压缩后的缓存清理。src/commands/clear/caches.ts:/clear的会话缓存清理。- Claude Code 官方文档:How Claude remembers your project