无状态模型的记忆幻觉:深度拆解 Claude Code 的五层记忆架构

从源码层面深度拆解 Claude Code 2.1.88 的五层记忆架构:指令记忆、索引记忆、按需唤醒、后台萃取、上下文压缩,分析每一层的设计张力与断裂点。

一个根本性的矛盾

每次你打开 Claude Code 开始新对话,模型对你一无所知。它不知道你是写了十年 Go 的后端工程师还是刚入门的大学生,不知道你上次让它"别再每次结尾都总结一遍",不知道你的项目下周四有 merge freeze。

但用户的期望恰恰相反——他们希望 AI 助手"记得"。记得自己的偏好,记得上次的教训,记得项目的上下文。

这就是 Claude Code 记忆系统要解决的根本矛盾:让一个无状态的模型表现得像有状态的。

大多数人对这个问题的直觉是"存个数据库,下次查出来塞进 context"。这个直觉对了一半——Claude Code 确实把记忆存在文件里、塞进 context。但它错在把"记忆"当成了一个单一问题。Claude Code 的源码揭示了一个更精细的认知:**记忆不是一个东西,而是一个频谱。**从"始终在场的背景知识"到"需要被提醒才想起来的细节",不同类型的记忆需要完全不同的注入策略、生命周期管理和 token 预算。

读完 Claude Code 2.1.88 的源码,我发现它实际上构建了五个层次的记忆,每一层解决不同的问题,承担不同的 trade-off。这不是一篇"看看代码怎么写的"文章——我想讲的是:为什么一个记忆系统需要五层,每一层的设计张力是什么,以及这些选择在什么条件下会崩溃。

五层记忆的抽象模型

在看任何代码之前,让我们先建立一个思维模型。

想象你是一个每天早上醒来都失忆的人(这就是 LLM 的处境)。你需要一套系统来恢复"自己"。这套系统至少需要解决五个层次的问题:

┌─────────────────────────────────────────────────────────┐
│  Layer 5: 上下文压缩 (Compaction)                        │
│  "对话太长时,哪些记忆值得保留?"                            │
├─────────────────────────────────────────────────────────┤
│  Layer 4: 后台萃取 (Background Extraction)               │
│  "对话中产生了什么新的值得记住的东西?"                       │
├─────────────────────────────────────────────────────────┤
│  Layer 3: 按需唤醒 (Relevance-based Recall)              │
│  "这个问题需要回忆起哪些旧记忆?"                            │
├─────────────────────────────────────────────────────────┤
│  Layer 2: 索引记忆 (MEMORY.md Index)                     │
│  "我有哪些记忆可用?全局目录。"                              │
├─────────────────────────────────────────────────────────┤
│  Layer 1: 指令记忆 (CLAUDE.md Hierarchy)                 │
│  "我是谁?我应该怎么行事?始终在场的身份。"                    │
└─────────────────────────────────────────────────────────┘

这五层从下到上,注入时机越来越晚,生命周期越来越短,但灵活性越来越高。

类比人类记忆:Layer 1 像程序性记忆——你不需要"想起"怎么骑自行车,它就是你的一部分。Layer 2 像你桌上的便签索引——你扫一眼就知道自己记过什么。Layer 3 像情景记忆的检索——有人提到"东京",你自动想起上次去东京的经历。Layer 4 像睡眠中的记忆固化——白天的经历在后台被整理成长期记忆。Layer 5 像遗忘曲线——不是所有东西都能永远记住,系统性地决定什么该留、什么该丢。

这个模型的关键 insight:每一层都有自己的 token 预算、触发条件和失败模式。把它们混在一起管理是不可能的——这就是为什么它需要五层。

Layer 1:指令记忆——始终在场的身份

问题空间

第一层要解决的问题看起来最简单:把一些固定的指令塞进 system prompt。但一旦你允许用户、团队、企业、项目各自定义指令,就产生了一个经典的配置管理问题——多源指令的合并与优先级。

谁的话算数?当企业策略说"所有代码必须通过安全审查"而本地开发者说"跳过这个项目的安全检查"时,系统该听谁的?

优先级反转:后加载 = 高优先级

Claude Code 的回答违反了大多数人的直觉。在传统配置系统中(CSS、环境变量、YAML 合并),通常是"更具体的覆盖更一般的"——全局 < 项目 < 本地。但 Claude Code 不是覆盖,而是拼接:所有层级的 CLAUDE.md 全部拼接进 system prompt,利用语言模型的一个特性——模型对 prompt 中靠后出现的内容赋予更高的注意力权重。

加载顺序(claudemd.ts:826-933)从低优先级到高优先级:

企业托管 (/etc/claude-code/CLAUDE.md)
  → 用户全局 (~/.claude/CLAUDE.md)
    → 项目级 (./CLAUDE.md, ./.claude/CLAUDE.md, ./.claude/rules/*.md)
      → 本地私有 (./CLAUDE.local.md)
        → 自动记忆 (MEMORY.md + topic files)

源码中的注释一语道破(claudemd.ts:9):

"Files are loaded in reverse order of priority, i.e. the latest files are highest priority with the model paying more attention to them."

这个设计选择的精妙之处在于:它不需要任何合并逻辑。没有"如果冲突则 X 覆盖 Y"的规则引擎,没有 deep merge,没有冲突检测。所有指令全部保留,只是在 prompt 中的位置不同。冲突解决被委托给了模型本身——而 LLM 确实擅长处理"前面说了 A,后面说了 B"这种情况,它会倾向于后者。

这个设计牺牲了什么? 确定性。你无法精确预测两条矛盾指令的最终行为——模型可能大部分时候听后者,但不是 100%。对于需要强制执行的企业策略,这是一个隐患。

条件规则:按需加载的指令

但"始终在场"太贵了。如果你有 50 条针对不同文件类型的 lint 规则,全部塞进 system prompt 是浪费。

Claude Code 的解决方案是 .claude/rules/*.md 的 frontmatter paths: 字段(claudemd.ts:255-279)。一条规则可以声明自己只在特定文件匹配时激活:

---
paths: "src/**/*.tsx, app/**/*.tsx"
---
所有 React 组件必须使用函数式写法,禁止 class component。

规则被分为两类:无条件的(没有 pathspaths: **)在启动时立即加载;有条件的在模型读取/编辑匹配路径的文件时才注入,通过 ignore() 库做 glob 匹配(claudemd.ts:1354-1397)。

这是一个懒加载模式——用"什么时候注入"替代"要不要注入"的判断,把 token 成本从 O(all rules) 降到 O(relevant rules)。但代价是增加了运行时复杂度:每次文件操作都要扫描条件规则库,触发 InstructionsLoaded hook 用于审计。

@include 的递归深度

指令文件还支持 @ 引用其他文件(claudemd.ts:451-535),最大递归深度 5 层(MAX_INCLUDE_DEPTH),带环检测(processedPaths Set),且只允许白名单文本扩展名。

这本质上是在 Markdown 文件里实现了一个简陋的 #include 预处理器。它让团队可以共享指令片段,但 5 层的硬限制暴露了一个工程判断:递归引用带来的灵活性不值得无限深度的复杂度。 大多数实际用例 2-3 层就够了,5 层是安全余量。

所以 Layer 1 的本质是:把分布式配置管理问题,用"全部拼接 + 位置即优先级"简化为线性序列,代价是放弃确定性的冲突解决。

Layer 2:索引记忆——200 行的全局目录

为什么索引和内容必须分离

如果 Layer 1 是"你是谁",Layer 2 是"你记得什么"的目录。

MEMORY.md 是一个纯文本索引文件,每行一条指向具体记忆文件的链接。它始终被注入 system prompt,让模型知道"我有哪些记忆可用"。但它只是索引,不包含记忆内容本身。

为什么不直接把所有记忆塞进一个大文件?因为 system prompt 的 token 是最贵的——它出现在每一轮对话中,被 prompt cache 缓存。一个包含所有记忆内容的大文件会:

  1. 吃掉大量 context window 预算
  2. 频繁变更导致 prompt cache 失效
  3. 每次修改一条记忆都要重写整个文件

分离的设计让 MEMORY.md 保持小而稳定(利于缓存),具体内容在 Layer 3 按需注入。

双重截断:防御性的上限管理

MEMORY.md 的截断机制(memdir.ts:57-103)是一个有趣的防御性设计。它有两道关卡:

export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000  // ~125 chars/line at 200 lines

先按行截断(200 行),再按字节截断(25KB)。为什么需要两道?源码注释解释了(memdir.ts:36-38):

"~125 chars/line at 200 lines. At p97 today; catches long-line indexes that slip past the line cap (p100 observed: 197KB under 200 lines)."

生产环境的真实数据驱动了这个设计——有用户在 200 行以内写出了 197KB 的索引(每行接近 1000 字符)。行数限制在这种情况下完全失效,所以加了字节兜底。

截断时先切行、再切字节,且字节截断在最后一个换行符处切割(不切断行中间)。超限后追加的 WARNING 还会精确报告是哪个限制触发的,让用户知道该优化什么。

这是一个典型的"被线上数据教育过"的设计——你不会在第一版就想到需要双重截断。

KAIROS:日记模式的替代路径

Claude Code 还有一个实验性的替代方案——KAIROS 模式(memdir.ts:327-370),为 assistant 长期会话设计。在这个模式下,新记忆不再写入 topic files + 更新 MEMORY.md,而是追加到按日期命名的日志文件:

<memoryDir>/logs/YYYY/MM/YYYY-MM-DD.md

每条记忆是一个带时间戳的 bullet point,append-only。一个独立的夜间 /dream 技能负责把日志蒸馏成 MEMORY.md + topic files。

这个设计暴露了一个更深层的 trade-off:写入时的组织成本 vs 读取时的检索成本。 标准模式要求每次保存记忆都做分类和索引更新(写入成本高),但检索快。KAIROS 模式写入几乎零成本(追加日志),但把组织工作延迟到异步批处理。

对于长期会话(模型持续运行数小时甚至数天),写入成本的累积远大于偶尔的检索,所以 KAIROS 选择了"先记后整理"。这和人类的笔记习惯一模一样——你不会在会议中途停下来整理笔记分类。

Layer 3:按需唤醒——用小模型做记忆检索

为什么不用向量搜索

这是整个记忆系统最反直觉的设计决策。

当你面对"从一堆记忆中找到和当前问题相关的"这个需求时,第一直觉是:向量数据库 + embedding 相似度搜索。这是 RAG 系统的标准答案。但 Claude Code 选择了一个完全不同的路径——用 Sonnet(一个小型语言模型)读取记忆文件的标题和描述,然后判断哪些和当前问题相关。

这个选择的 prompt(findRelevantMemories.ts:18-24)值得完整引用:

"You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. Return a list of filenames for the memories that will clearly be useful... Only include memories that you are certain will be helpful based on their name and description."

为什么不用向量搜索?我从源码中推断出几个原因:

第一,规模不对。 向量搜索的优势在万级以上的文档中。Claude Code 的记忆上限是 200 个文件(MAX_MEMORY_FILES),每个文件只读前 30 行 frontmatter。这个规模下,一次 Sonnet 调用(256 max_tokens)的成本和延迟可能比维护一个 embedding 索引更低。

第二,语义理解 > 语义相似。 向量搜索找的是"表面语义相似"的内容。但记忆检索需要的是"概念关联"——"我下周要去东京" 和 "帮我推荐东京的餐厅" 语义完全不同,但需要调用同一条记忆。Sonnet 能理解这种间接关联,embedding 余弦相似度做不到。

第三,无基础设施依赖。 Claude Code 是一个命令行工具,用户 npm install 就能用。引入向量数据库意味着额外的安装步骤、后台进程、存储管理。用 Sonnet 做选择只需要一次 API 调用。

代价是什么? 延迟和成本。每一轮对话都要多一次 Sonnet API 调用。而且 Sonnet 的判断质量完全取决于 frontmatter 中 description 字段的质量——如果用户写了一个模糊的描述,Sonnet 也无能为力。

三重预算:token 经济学的精密平衡

Layer 3 的注入受到三重预算约束:

Per-file:   4KB / 200 lines        (MAX_MEMORY_BYTES / MAX_MEMORY_LINES)
Per-turn:   5 files                 (Sonnet 最多选 5 条)
Per-session: 60KB cumulative        (RELEVANT_MEMORIES_CONFIG.MAX_SESSION_BYTES)

这三个数字各自解决一个问题:per-file 防止单个记忆吃掉太多 context;per-turn 防止信息过载(每轮对话注入太多记忆反而让模型分心);per-session 防止长会话中记忆注入的累积膨胀。

60KB 的 session 预算特别值得注意。粗估 4 chars/token,60KB ≈ 15K tokens。在 200K 的 context window 中,这意味着整个会话的记忆注入不能超过 context 的约 7.5%。这是一个"看不见的上限"——大多数用户不会感知到它,但它决定了记忆系统在长会话中的有效射程。

非阻塞 prefetch:异步的时序博弈

记忆选择不阻塞主对话流。每轮用户输入时,系统异步启动 Sonnet 选择(startRelevantMemoryPrefetch),然后主模型开始处理。当主模型完成工具调用后,检查 prefetch 是否完成——完成了就注入,没完成就跳过。

用户输入 → [启动 prefetch] → 主模型思考 → 工具调用 → [检查 prefetch] → 注入或跳过

这个设计的核心假设是:Sonnet 的选择速度通常快于主模型的第一次工具调用。 如果这个假设成立,记忆注入是"免费的"(藏在工具执行的延迟里)。如果不成立,记忆就被跳过——不完美,但不会拖慢用户体验。

这是一个"宁可少记起不可多等待"的设计哲学。

工具感知的记忆过滤

还有一个精妙的细节:如果模型最近在使用某个 MCP 工具(比如 mcp__X__spawn),Sonnet 会跳过该工具的参考文档类记忆,但保留关于该工具的 warnings 和 gotchas(findRelevantMemories.ts:87-95)。

逻辑很清楚:如果你正在用一个工具,你大概不需要它的入门教程;但你确实需要知道它的已知陷阱。这种区分体现了对"什么时候记忆有价值"的深入思考——不是有关联就有用,而是"此刻是否能改变行为"才有用。

Layer 4:后台萃取——Forked Agent 的记忆固化

问题:谁来决定什么值得记住?

前三层解决的是"如何存储和检索记忆",Layer 4 解决一个更根本的问题:如何从对话中自动提取值得记忆的信息。

这不是一个简单问题。对话是碎片化的、充满指代和省略的。一段 "把上次那个方案改一下" 的交流,离开上下文就是噪声。系统需要判断:这段对话里有什么值得长期记住的?

Claude Code 的解决方案是一个后台运行的 forked agent——主对话完成后,一个独立的 agent 进程接过完整的对话历史,专门负责提取记忆。

Prompt Cache 共享:用工具权限而非工具列表做隔离

这个 forked agent 的架构决策中最精妙的是 prompt cache 的共享机制。

API 的 prompt cache key 由 system prompt + tools list + messages prefix 组成。如果提取 agent 使用不同的 tools list,它就无法复用主对话的 cache——这意味着每次提取都要重新发送整个对话历史。

Claude Code 的解决方案(forkedAgent.ts:177-179):提取 agent 使用和主 agent 完全相同的 tools list,但通过 canUseTool 权限函数限制实际可用的工具。

主 Agent:  tools = [Read, Write, Edit, Bash, Grep, Glob, Agent, MCP...]
           permissions = 全部可用

提取 Agent: tools = [Read, Write, Edit, Bash, Grep, Glob, Agent, MCP...]  ← 相同!
           permissions = {
             Read/Grep/Glob: ✓ (无限制)
             Write/Edit: ✓ (仅 memory 目录)
             Bash: ✓ (仅只读命令)
             Agent/MCP/其他: ✗
           }

工具列表相同 → cache key 相同 → cache 命中 → 提取 agent 几乎不消耗额外的 input tokens。

这是用"权限控制"替代"接口隔离"来保护 cache 的设计。 在 token 经济学主导一切的 LLM 应用中,cache 命中率就是成本和延迟的命脉。为了保护 cache,宁可让权限层更复杂。

互斥锁:主 Agent 优先

一个微妙的竞争条件:如果主 agent 在对话中已经直接保存了记忆(比如用户说"记住这个"),后台提取 agent 不应该重复提取。

extractMemories.ts:345-360 用一个简单的策略解决了这个问题:检查自上次提取以来,主 agent 是否在对话历史中写入了 auto-memory 路径的文件。如果写了,跳过提取并推进游标。

if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
  // 主 agent 已经处理了,跳过
  lastMemoryMessageUuid = lastMessage.uuid
  return
}

主 agent 永远优先。 因为主 agent 有完整的 system prompt 中的记忆保存指令,它的判断比提取 agent 更充分。提取 agent 只是一个兜底——当主 agent 忙于其他任务没有主动保存时才介入。

"信任对话,不验证代码"

提取 agent 的 prompt 中有一条严格的约束(prompts.ts:41):

"You MUST only use content from the last ~N messages to update your persistent memories. Do not waste any turns attempting to investigate or verify that content further — no grepping source files, no reading code to confirm a pattern exists, no git commands."

这条规则的设计意图是:提取 agent 不应该自己做研究。 它的工作是把对话中已经出现的信息结构化为记忆,不是去验证这些信息是否正确。

为什么?因为提取 agent 有 5-turn 的硬上限(maxTurns: 5),而且它的 token 消耗直接影响 API 成本。如果允许它去读源码验证,一个"用户提到项目用了 Redis"的简单记忆可能变成 5 轮的代码探索。

高效的提取策略被明确教给了 agent:turn 1 并行读取所有需要更新的记忆文件,turn 2 并行写入所有更新。 两轮搞定,最高效率。

节流与合并:防止过度提取

提取不是每轮都跑。turnsSinceLastExtraction 计数器配合 GrowthBook feature flag(tengu_bramble_lintel,默认 1)控制提取频率。如果设置为 3,意味着每 3 轮对话才提取一次。

更重要的是合并机制:如果一次提取正在进行时下一轮对话结束了,新的上下文被暂存到 pendingContext。当前提取完成后,会用暂存的上下文做一次 trailing run。这避免了两种极端——不会因为上一次没跑完就丢弃新的提取机会,也不会同时跑两个提取 agent 互相干扰。

所以 Layer 4 的本质是:一个受限的、缓存友好的、互斥的后台 agent,职责单一——把对话中的非结构化信息固化为结构化记忆,且绝不越权去验证内容。

Layer 5:上下文压缩——遗忘的艺术

当 context window 装不下时

所有前面的层都在往 context 里"加"东西。Layer 5 解决相反的问题:当 context 接近上限时,如何在压缩中保全记忆。

Auto-compact 在 token 数超过 effectiveWindow - 13,000AUTOCOMPACT_BUFFER_TOKENS)时触发。它调用模型对对话历史做摘要,然后用摘要替换原始消息。但这个过程不能丢失关键信息。

压缩后的记忆恢复

compact.ts:1415-1464 实现了一个精心设计的恢复策略:压缩后,自动重新注入最近访问的最多 5 个文件(50K token 预算)。这些是模型最可能在后续对话中需要的文件。

但有一个刻意的遗漏——技能(skills)不会在压缩后重新注入。代码注释解释了原因:这样做每次 compact 能节省 4-10K tokens。模型仍然可以通过 SkillTool 重新调用技能,而且已调用的技能会通过 invoked_skills attachment 保留其名称。

这是一个"节省 vs 方便"的显式 trade-off:能通过工具重新获取的信息不值得占用 system prompt 的 cache 空间。 但文件内容不同——重新读取文件需要一次工具调用的往返,这在 compact 后的"重新定向"阶段成本太高,所以主动恢复。

Circuit Breaker:防止 compact 风暴

如果 compact 本身失败了怎么办?autoCompact.ts 有一个 3 次重试的 circuit breaker。连续 3 次 auto-compact 失败后,系统停止自动重试,避免"compact 失败 → 立刻触发 → 再次失败"的死循环。

这个设计承认了一个现实:压缩是一个可能失败的操作(模型生成的摘要可能太长、API 可能超时),而且失败的条件往往是持续性的。 不如停下来让用户手动 /compact

设计断裂点:什么时候这套系统会失败

断裂点一:Sonnet 选择的语义盲区

Layer 3 的 Sonnet 选择完全依赖 frontmatter 的 description 字段。如果一条记忆的描述是 "user prefers dark mode",而用户问的是 "帮我配置 VS Code 的主题"——Sonnet 可能不会把这两者关联起来。

记忆系统的召回率上限,不在检索机制,在 description 的质量。 这个质量又完全取决于写入时的 prompt——无论是用户手写还是提取 agent 自动生成。

断裂点二:200 文件的天花板

MAX_MEMORY_FILES = 200MAX_ENTRYPOINT_LINES = 200。这意味着记忆系统的容量天花板是 200 条独立记忆。

对于个人使用,200 可能足够。但对于长期维护一个大型项目的团队,200 条记忆可能在几个月内就会耗尽。此时用户必须手动清理旧记忆——系统没有自动的"遗忘"机制(除了用户可以删除文件)。

一个记忆系统如果没有遗忘机制,终将被自身的积累淹没。 这可能是当前设计最大的长期隐患。

断裂点三:提取 Agent 的 Prompt 质量天花板

后台提取 agent 的判断质量——什么值得记、怎么分类、怎么去重——完全取决于它收到的 prompt。这个 prompt 是硬编码的(prompts.ts),不能被用户定制。

如果某个项目有独特的记忆需求(比如一个安全项目需要记住每次渗透测试的结果,但提取 agent 的 prompt 可能把这归类为"可从代码推导的信息"而跳过),用户无法调整提取策略。

提取 prompt 的通用性和特定项目需求之间的张力,是记忆质量的根本约束。

断裂点四:Prompt Cache 的脆弱性

Layer 4 精心设计的 cache 共享依赖一个假设:系统 prompt + tools list 在主 agent 和提取 agent 之间完全一致。如果任何中间件修改了其中一个(比如一个 hook 动态添加了工具),cache 就会 miss,提取成本翻倍。

源码中的警告(forkedAgent.ts:96-103)甚至提到改变 max_output_tokens 都会破坏 cache,因为 thinking config 也是 cache key 的一部分。

这是"性能优化"变成"架构约束"的典型案例——一个本该透明的缓存层,反过来限制了功能的演化空间。

面试深度追问

Q1:Claude Code 的记忆系统用了向量数据库吗?

表面回答:没有,它用文件系统存储记忆。

源码级回答:不仅没用向量数据库,它故意选择了 LLM-as-retriever 的方案——用 Sonnet 读取最多 200 个文件的 frontmatter(文件名 + description),通过 structured output(JSON schema)返回最多 5 个相关文件名。选择这个方案而非向量搜索的原因是:200 文件的规模不足以justify embedding 基础设施的维护成本;Sonnet 能理解概念关联而非仅语义相似;且 Claude Code 作为 CLI 工具不能依赖外部服务。

追问链: → 那 Sonnet 的选择延迟如何处理? → 非阻塞 prefetch,藏在主模型工具调用延迟中。如果 prefetch 没完成就跳过。 → 如果连续多轮都跳过了呢? → 60KB session 预算会在几轮后耗尽,之后整个 recall 层静默关闭。但这反而是一个止损——长会话中后期,上下文中已经积累了足够的信息,新记忆注入的边际价值递减。

Q2:后台提取 agent 和主 agent 怎么共享 prompt cache?

表面回答:提取 agent 是主 agent 的 fork,共享对话历史。

源码级回答:共享的关键不是对话历史,而是 cache key 的构成。API prompt cache key = system prompt + tools list + messages prefix。提取 agent 使用和主 agent 完全相同的 tools list(不是它实际能用的子集),通过 canUseTool 权限函数在运行时限制实际可用工具。这样 tools list 相同 → cache key prefix 相同 → cache 命中。提取 agent 的实际 prompt 作为新的 user message 追加在 messages 末尾,不影响 prefix matching。

追问链: → 如果修改了提取 agent 的 thinking config 呢? → 源码警告:thinking config 是 cache key 的一部分。修改 max_output_tokens 会同时修改 budget_tokens(via clamping in claude.ts),导致 cache miss。所以提取 agent 不设置自定义的 maxOutputTokens。 → 这个约束对未来功能演化有什么影响? → 任何想让 forked agent 有不同能力的需求(不同模型、不同 token 限制、不同工具集)都会破坏 cache 共享,成本翻倍。这迫使所有 forked agent 保持与主 agent 的"表面同构"。

Q3:CLAUDE.md 的优先级冲突怎么解决?

表面回答:后加载的优先级更高。

源码级回答:所有层级全部拼接进 system prompt,没有任何合并或覆盖逻辑。优先级利用的是 LLM 对 prompt 尾部内容的注意力权重更高这个特性。加载顺序从低到高:企业托管 → 用户全局 → 项目 → 本地 → 自动记忆。这意味着冲突解决是非确定性的——模型"倾向于"听后者,但不是 100%。对于企业合规场景,这是一个已知的局限。

追问链: → 那条件规则呢?它们什么时候注入? → 不在启动时。条件规则在模型首次读取/编辑匹配 glob 的文件时才注入。使用 ignore() 库做 glob 匹配,patterns 相对于 .claude/ 的父目录解析。这意味着条件规则可以"迟到"——模型可能已经处理了几个文件后才收到本该早就加载的规则。

工程启示:可迁移的模式

1. "位置即优先级"适合 LLM 配置合并

当你有多层配置需要合并,且最终消费者是一个 LLM 时,不要写合并逻辑。拼接,让位置决定优先级。这比确定性的覆盖规则更简单、更灵活,代价是放弃精确控制——但大多数场景不需要精确控制。

适用场景:多租户 AI 系统的指令合并、多角色 prompt 的组装。 不适用:需要确定性行为的安全策略(如访问控制规则)。

2. LLM-as-Retriever 在小规模下击败向量搜索

当你的文档集不超过几百条,且每条文档有高质量的摘要时,用一个小模型做 retrieval 比维护 embedding 索引更划算。它理解概念关联(向量搜索只做语义相似),无基础设施依赖,且天然支持复杂的过滤逻辑("最近用过的工具的参考文档不选,但 warnings 要选"——这种规则在向量搜索中需要额外的过滤管道)。

适用场景:个人知识库、项目配置检索、少量文档的 FAQ 匹配。 不适用:万级以上文档、需要亚秒延迟的实时检索。

3. Cache Key 保护可以反过来塑造架构

当 prompt cache 的命中率是关键成本指标时,"不破坏 cache key"会变成一个隐性的架构约束。Claude Code 让提取 agent 共享主 agent 的 tools list 就是一个例子。在设计 LLM 应用架构时,尽早确定 cache key 的边界,避免后期为了 cache 不得不做反直觉的妥协。

4. 记忆系统需要遗忘机制

任何只有写入没有过期的记忆系统,终将被自身淹没。这不是"未来可能"——Claude Code 的 200 文件上限说明设计者已经意识到了这个问题,但用硬上限而非软过期来对付。更好的方案可能是:基于访问频率的衰减、基于时间的自动降级、或定期让 LLM 审查并合并冗余记忆。


Claude Code 的记忆系统不是一个功能——它是一整套关于"如何让无状态系统表现得有状态"的工程哲学。五层架构不是过度设计,而是对"记忆"这个概念的精确分解:什么该始终在场,什么该按需唤醒,什么该自动提取,什么该在压力下丢弃。每一层的 trade-off 都清晰可辨,每一层的断裂点也诚实地暴露在源码中。这种在 LLM 约束下做工程设计的思路,值得每一个构建 AI agent 的人研究。