文章

【笔记】Claude Code 的上下文加载与变更感知

【笔记】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 两层缓存

这里至少有两层需要同时理解:

  1. getMemoryFiles() 缓存发现和文件读取结果。
  2. 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 源码行为可能随版本演进

getUserContextgetMemoryFilesgetChangedFiles 是当前 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.tsgetUserContext()getSystemContext()
  • src/utils/claudemd.ts:指令发现、include、聚合和缓存。
  • src/utils/api.tsprependUserContext()
  • src/utils/attachments.tsgetChangedFiles() 与 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
本文由作者按照 CC BY 4.0 进行授权