CC 源码解读 #2:记忆系统为什么不用向量数据库?

58 阅读8分钟

CC 源码解读系列


CC 源码系列 #2 | 上一篇《CC 为什么不用 RAG》发出后,评论区有不少人追问:那记忆系统呢?难道也不用向量?这篇带你扒开 Claude Code 记忆系统的源码,看看它到底怎么做的——读完你大概会觉得"还真是这样"。


从 RAG 帖说起

上一篇聊了 CC 为什么不用 RAG,没想到反响不错。很多人留言说:"那记忆系统怎么实现的?Embedding?向量检索?"

答案依然出乎意料:都不用。

CC 的记忆系统用的是一套极其朴素的文件 + 两阶段加载方案。没有向量数据库,没有 Embedding 计算,没有相似度检索。但它工作得相当好,背后的工程逻辑值得认真看一遍。


架构:两层结构,索引 + 主题文件

CC 记忆系统的核心架构是两层:

第一层:MEMORY.md(索引)

每次对话都会被注入 system prompt,必须存在。它不存正文,只存索引——告诉模型"有哪些记忆文件,每个文件大概讲什么"。

第二层:主题文件(按需加载)

实际内容分散在多个小文件里,不是全量加载,而是由 Sonnet 做 side query 决定加载哪几个。

这个设计解决的核心矛盾是:记忆要全,但 context window 是有限的。

索引常驻,正文按需——避免把 context window 撑爆。


目录结构:前缀即含义

记忆文件放在 ~/.claude/projects/<project-hash>/memory/ 下,文件命名有严格的前缀约定:

前缀含义示例
MEMORY.md索引,必须存在
user_*.md用户偏好(语言、风格、习惯)user_profile.md
feedback_*.md用户反馈和修正记录feedback_reply_style.md
project_*.md项目背景、架构信息project_ccstats.md
reference_*.md技术参考资料reference_api.md

Claude 被明确禁止在 MEMORY.md 里写正文内容——索引和内容必须分离。这条规则看似简单,但是决定整个系统能否规模化的关键约束。


两阶段加载流程

这是整个系统最关键的机制。

阶段一:MEMORY.md 直接注入 system prompt(必须加载)

每次对话开始,MEMORY.md 无条件进 system prompt。这是同步操作,不做筛选。

阶段二:Sonnet side query 分析意图,决定加载哪些主题文件

这步才是真正的"智能"部分。CC 会用 Sonnet 做一次独立的 side query,分析当前对话的上下文,从候选记忆文件里挑出最相关的,最多 5 个,注入到当前上下文。

几个细节值得注意:

  • alreadySurfaced 过滤:本轮已出现过的文件不重复加载
  • 最多 5 个是硬上限,不是软限制——超出直接截断
  • 如果当前话题和任何记忆文件都不相关,side query 可以返回空列表,一个文件都不加载

这个机制本质上是用语言模型本身做检索,替代了向量数据库的相似度计算。


Sonnet Side Query 工程细节

这个 side query 的实现细节值得细看:

用 claude-sonnet 做选择,不是 haiku 也不是 opus

精度和成本的折中——haiku 准确率不够,opus 太贵,sonnet 刚好。

max_tokens: 256,强制控制输出长度

side query 的任务只是返回一个文件名列表,不需要长输出。256 token 足够,也防止模型"话多"。

JSON Schema 约束输出格式

返回值必须是结构化的 JSON,不允许自由发挥。CC 在这里用了强制 structured output,拿到的是可以直接解析的文件名数组。

一次 side query 的成本

大约几厘钱(人民币量级)。对于一个每天可能跑几十次会话的工具来说,这个成本是可接受的。

没有记忆时怎么处理

telemetry 上报区分了两种状态:-1(没找到记忆)和 没选择(找到但不相关)。前者是系统没有记忆文件,后者是有记忆但判断当前对话不需要。


记忆时效性:超过 1 天会被加过期警告

这个设计让我印象很深。

CC 会给超过 1 天未更新的记忆文件悄悄加一条警告,类似:

<system-reminder>这条记忆超过 1 天未更新,请注意时效性</system-reminder>

为什么要这样做?

原因很反直觉:有出处反而让过期信息听起来更权威。

如果记忆文件里写"用户偏好用 Python",但实际上这条信息是三个月前记录的,模型会因为"有明确记录"而更倾向于相信它,反而比没有记忆时更容易出错。

加过期警告,是在告诉模型"这条信息可能已经过时,保持怀疑"。

最佳实践:核心记忆要定期 touch,保持记忆新鲜度。


三道硬上限

CC 对记忆系统设了三道超过就直接截断的硬上限:

上限一:MEMORY.md 最多 200 行 / 25KB

这是索引文件的上限。注意顺序:先检查行数,再检查字节数。索引超过这个规模,说明设计出了问题——应该拆分主题文件,而不是在索引里堆内容。

上限二:记忆文件总数最多 200 个

整个记忆目录不能超过 200 个文件。这个上限的背后有实测数据支撑:p100 场景下,197KB 的记忆数据可以在 200 行以内表达——说明 200 个文件对于绝大多数用户场景已经足够。

上限三:frontmatter 只读前 30 行

每个主题文件的 frontmatter(文件头的元信息)最多读 30 行。这是 side query 做文件选择的依据,30 行足够描述文件内容,超出部分不会被读取。设计理念是:单次扫描复杂度等价于 syscall,要控制在常数级别。

这三道上限的共同特点是:超出就截断,没有警告,没有软着陆。 理解这一点,设计记忆结构时才不会踩坑。


6 条使用规则(从限制倒推)

理解了上面的机制,使用规则就是自然推论出来的:

  1. MEMORY.md 只写索引,不写正文 — 索引和内容混用会让 side query 无法正常工作
  2. 一个主题一个文件,不要堆在一起 — 粒度太粗,side query 精度下降;粒度太细,文件数量爆炸
  3. 索引描述要准确(决定 side query 是否命中) — MEMORY.md 里对每个文件的描述,直接决定 Sonnet 能不能选到它
  4. frontmatter 关键信息放前 30 行 — 超出的部分不会被 side query 看到
  5. 总数控制在 200 以内 — 超限的文件会被忽略,静默失败
  6. 核心记忆每隔几天 touch 一下,避免过期警告 — 特别是用户偏好、项目关键决策这类长期有效的信息

为什么不用向量数据库?

这是最核心的问题。聊完整个系统,答案其实已经呼之欲出了:

1. 规模不需要

Claude Code 的记忆文件上限是 200 个。在这个规模下,用 Sonnet 做语义选择,成本比搭建和维护向量数据库低得多。

2. 语言模型本身就是更好的检索器

向量相似度检索的问题是:它度量的是语义距离,但"这段对话需要哪条记忆"其实是一个推理问题,不是相似度问题。用 Sonnet 做 side query,能利用上下文做推理,而不是机械地算向量距离。

3. 部署复杂度

向量数据库需要 Embedding 服务、索引维护、版本管理。对于一个本地工具来说,这些依赖都是包袱。文件系统 + JSON Schema + 一次 Sonnet 调用,没有额外依赖。

4. 可解释性

文件名列表是人类可读的。你可以直接看 MEMORY.md,知道 CC 在当前会话里加载了什么记忆。向量检索的结果不具备这种透明度。


一句话总结

MEMORY.md 是索引,主题文件按需加载,Sonnet 帮你选。超过 1 天加过期警告,超限直接截断,没有软着陆。MEMORY.md 当目录用,主题文件小而专,side query 才能精准命中。


延伸:用 cc-statistics 观察记忆 side query 的成本

如果你在用 cc-statistics 统计 Claude Code 的 token 消耗,可以观察一下记忆 side query 在总 token 里的占比。

这套机制的实际工程成本比多数人想象的低——每次 side query 256 token 上限,一次几厘钱,在高频使用场景下依然可控。

cc-statistics 支持按 session 拆分 token 消耗,可以看到每次会话的 input/output token 分布,side query 的成本会体现在 input token 里。


下一篇预告

CC 源码系列 #3:Agent Teams——CC 没用 AutoGen 也没用 CrewAI,自己造了一套团队调度系统

9000+ 行代码,完整的多 Agent 协作框架,下一篇扒开看。