Harness Engineering
第12章 长期记忆:持久化与检索
第12章 长期记忆:持久化与检索
“Memory is the treasury and guardian of all things.” — Cicero
“Agent 没有长期记忆,就是每天失忆 20 次的金鱼——它学不到教训,也留不下温度。” —— 杨艺韬
本章要点
- 长期记忆让 Agent 跨会话保持连续性——记住用户 / 项目 / 反馈 / 外部引用
- 四种记忆类型:User / Feedback / Project / Reference——各有不同的写入时机与使用场景
- 三大存储方案:文件系统(Claude Code)/ 向量数据库(RAG)/ 键值存储(LangGraph Store)
- 关键四问:何时写?写什么?如何检索?如何不让记忆过时?
- 记忆≠事实——是某个时间点的快照,使用前必须验证
- 隐私红线:绝不存储密钥、token、.env 内容、未脱敏的 PII
12.1 为什么需要长期记忆:金鱼困境
没有长期记忆的 Agent,每次对话都是”失忆”重启。用户不得不反复教 Agent 相同的事情:
会话 1: 用户说"我是数据科学家,用 Python"
会话 2: Agent 问"请问你用什么语言?"
会话 3: 用户说"别用 mock 测试,上次生产事故就是 mock 导致的"
会话 4: Agent 又生成了 mock 测试
会话 5: 用户说"项目代号是 phoenix,内部叫这个"
会话 6: Agent 生成代码用了 "my-project" 作为包名
这就是”金鱼困境”——短期记忆无限循环,永远学不到教训。长期记忆解决的就是这个问题:让 Agent 跨会话积累对用户和项目的理解。
flowchart TD
S1["会话 1<br/>用户画像"] -->|"user type"| M[(长期记忆<br/>持久化存储)]
S2["会话 2<br/>反馈修正"] -->|"feedback type"| M
S3["会话 3<br/>项目状态"] -->|"project type"| M
S4["会话 4<br/>外部资源"] -->|"reference type"| M
M -->|"相关记忆<br/>自动注入"| S5[会话 N<br/>智能响应]
S5 -->|"新的学习"| M
style M fill:#dcfce7,stroke:#22c55e,stroke-width:3px
style S5 fill:#dbeafe,stroke:#3b82f6
长期记忆的三层价值
- 效率价值——用户不用每次重新教育 Agent
- 质量价值——Agent 能避免重复过去的错误,复用过去的成功
- 关系价值——用户感觉”这个 Agent 懂我”,而不是冷冰冰的一次性工具
第三层是最隐性但最重要的——Agent 的”温度”来自它对用户的持久理解。
长期记忆 vs 短期记忆
| 维度 | 短期记忆(上下文) | 长期记忆(持久化) |
|---|---|---|
| 生命周期 | 单次会话 | 跨会话、跨月甚至跨年 |
| 存储位置 | 模型上下文窗口 | 文件系统 / 数据库 |
| 规模 | 几十 KB-1MB tokens | 几乎无限 |
| 访问速度 | 免费(已在上下文) | 需要检索 |
| 写入频率 | 每轮对话自动更新 | 选择性写入 |
| 内容类型 | 当前任务状态 | 跨会话的事实与偏好 |
| 失效方式 | 会话结束 | 时效性判定 |
两者是互补的——短期记忆承载”正在做什么”,长期记忆承载”我是谁/项目背景”。
12.2 记忆类型分类:Claude Code 的四分法
Claude Code 的记忆系统定义了四种类型,这个分类法经过实战检验、值得借鉴:
graph TD
Memory[长期记忆]
Memory --> User[User<br/>用户画像]
Memory --> Feedback[Feedback<br/>反馈修正]
Memory --> Project[Project<br/>项目动态]
Memory --> Reference[Reference<br/>外部引用]
User --> U1[角色/职业]
User --> U2[知识水平]
User --> U3[偏好风格]
Feedback --> F1[纠正: 不要这样]
Feedback --> F2[认可: 就是这样]
Feedback --> F3[Why + How to apply]
Project --> P1[截止日期]
Project --> P2[stakeholders]
Project --> P3[决策背景]
Reference --> R1[文档 URL]
Reference --> R2[数据源]
Reference --> R3[Linear/Jira 项目]
style User fill:#fef3c7,stroke:#f59e0b
style Feedback fill:#fecaca,stroke:#dc2626
style Project fill:#dbeafe,stroke:#3b82f6
style Reference fill:#dcfce7,stroke:#22c55e
User 类型
内容:用户的角色、职业、知识水平、偏好。
写入时机:了解到用户信息时,尤其是第一次会话。
示例:
---
name: user_role
type: user
---
用户是数据科学家,深度使用 Python,新接触 Rust。
**写代码时优先给出 Python 类比**,帮助构建心智模型。
使用场景:调整交互风格和建议深度——给新手多解释原理,给专家直接给结论。
Feedback 类型(最重要)
内容:用户的纠正和认可,带 Why 和 How to apply。
写入时机:
- 用户说”不要这样做”、“停止做 X”——纠正信号
- 用户说”对,就是这样”、“完美,继续这么做”——认可信号
两种信号都要写——只存纠正会让 Agent 变得过度保守,只存认可会让 Agent 学不到教训。
示例:
---
name: feedback_testing
type: feedback
---
集成测试必须连接真实数据库,不使用 mock。
**Why:** 上季度 mock 测试通过但生产迁移失败,导致线上事故 2 小时。
**How to apply:** 写测试时默认使用 testcontainers + 真实 DB;
纯逻辑单元测试可以 mock(因为不涉及数据库)。
这里 Why 和 How to apply 是关键——让 Agent 能在边界情况下自行判断。遇到纯函数单元测试时,它能判断”这不违反规则,因为不涉及数据库”。
Project 类型
内容:项目动态、截止日期、stakeholder、决策背景。
写入时机:了解到项目状态时,尤其是时效性信息。
示例:
---
name: project_release_freeze
type: project
created: 2026-04-10
expires: 2026-04-16
---
代码冻结至 2026-04-16,不合并非关键 PR。
**Why:** 移动端团队 4 月 17 日切发布分支。
**How to apply:** 收到 PR 合并请求时先确认是否 critical bug fix;
如是 feature 应提醒用户延后。
关键:项目类记忆必须带时间戳——可能会过期。
Reference 类型
内容:外部资源的指针——文档 URL、数据源、ticket 系统。
写入时机:发现外部信息源时。
示例:
---
name: reference_bug_tracker
type: reference
---
pipeline 相关 bug 追踪在 Linear 项目 "INGEST"。
**How to apply:** 用户问 bug 状态时,提醒去 Linear INGEST 查,
不要自己猜测优先级。
什么不应该存入记忆
同样重要的是知道什么不该存:
| 类型 | 为什么不存 | 正确做法 |
|---|---|---|
| 代码结构、文件路径 | 代码会改,记忆会过时 | 用 Glob/Grep/Read 实时读取 |
| Git 历史 | git log 是权威来源 | 运行 git 命令查询 |
| 调试修复方案 | 修复已在代码中 | 读代码或 commit message |
| 临时任务状态 | 当前会话范围 | 用 TodoList 或对话上下文 |
| CLAUDE.md 内容 | 避免重复 | 依赖 CLAUDE.md 自动加载 |
| 敏感信息 | 安全红线 | 永远不存 |
原则:如果能从代码或工具实时获取的信息,不要存入记忆。记忆只存那些无法从代码推断的人类知识。
12.3 三大存储方案对比
方案一:文件系统(Claude Code 的做法)
Claude Code 用纯 Markdown 文件存储记忆:
~/.claude/projects/{project-hash}/memory/
├── MEMORY.md # 索引文件,列出所有记忆
├── user_role.md # 用户画像
├── feedback_testing.md # 测试偏好
├── project_deadline.md # 项目截止日期
└── reference_linear.md # 外部系统指针
每个记忆文件有 frontmatter:
---
name: testing-preferences
description: 用户要求集成测试用真实数据库,不用 mock
type: feedback
---
集成测试必须连接真实数据库,不使用 mock。
**Why:** 上季度 mock 测试通过但生产迁移失败,导致线上事故。
**How to apply:** 写测试时默认使用测试数据库连接,只在单元测试隔离纯逻辑时才 mock。
MEMORY.md 是索引,每行一条,控制在 200 行以内:
# Memory Index
- [Testing Preferences](feedback_testing.md) — 集成测试用真实数据库不用 mock
- [User Role](user_role.md) — 数据科学家,Python 为主,新接触前端
- [Release Freeze](project_release_freeze.md) — 冻结至 2026-04-16
优势:
- 人类可读可编辑——用户可以手动查看和修改
- Git 友好——可以版本控制,回滚错误的记忆
- 无需额外基础设施——一个目录就够了
- 索引文件轻量——每次对话加载成本低(~1K tokens)
- 透明——用户随时能看到”Agent 记住了什么”
劣势:
- 语义搜索能力弱(只能关键词匹配)
- 记忆多了索引文件膨胀
- 并发写入需要处理(多个 Agent 同时写可能覆盖)
适用:桌面端 Agent、小团队 Agent、隐私敏感场景。
方案二:向量数据库(RAG 风格)
将记忆文本转为 embedding,存入向量数据库,检索时用语义相似度:
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
# 存储
memory_store = Chroma(
collection_name="agent_memory",
embedding_function=OpenAIEmbeddings()
)
memory_store.add_texts(
texts=["用户偏好:集成测试不用 mock,原因是上季度事故"],
metadatas=[{
"type": "feedback",
"created": "2026-04-15",
"weight": 1.0 # 用于加权排序
}]
)
# 检索
results = memory_store.similarity_search(
"应该怎么写测试?",
k=3,
filter={"type": "feedback"} # 按类型筛选
)
优势:
- 语义检索强大——能找到措辞不同但语义相关的记忆
- 可扩展——百万级记忆依然高效
- 支持多模态——文本、代码、图片 embedding 混合
劣势:
- 需要额外服务(Chroma、Pinecone、Milvus)
- embedding 有成本(每次写入一次 API 调用)
- 检索结果可能”相似但不相关”(语义近但实际无关)
- 不透明——用户很难手动检查
适用:企业级 Agent、多用户 SaaS、记忆量超过 1000 条。
方案三:键值存储(LangGraph Store)
LangGraph 的 Store 抽象提供了命名空间化的键值存储:
from langgraph.store.memory import InMemoryStore
from langgraph.store.postgres import AsyncPostgresStore
# 开发环境:内存存储
store = InMemoryStore()
# 生产环境:Postgres
store = AsyncPostgresStore(
conn=asyncpg_conn,
index={ # 可选的向量索引
"dims": 1536,
"embed": OpenAIEmbeddings(),
}
)
# 按命名空间组织
await store.put(
("user", "yyt", "preferences"), # 分层命名空间
"testing",
{
"value": "不使用 mock,连接真实数据库",
"reason": "上季度 mock 导致生产事故",
"created": "2026-04-15",
}
)
# 精确查询
item = await store.get(("user", "yyt", "preferences"), "testing")
# 命名空间搜索
items = await store.search(("user", "yyt"))
# 语义搜索(如果配置了 index)
items = await store.search(
("user", "yyt"),
query="应该怎么写测试"
)
优势:
- 结构化——程序化访问清晰
- 命名空间隔离——多用户/多项目天然分离
- 混合能力——KV + 可选向量索引
- 分布式友好——Postgres/Redis 后端
劣势:
- 不如文件系统直观
- 持久化需要额外配置
- 用户编辑门槛高
适用:生产级多用户系统、需要精确结构化查询。
三方案对比表
| 维度 | 文件系统 | 向量数据库 | KV Store |
|---|---|---|---|
| 基础设施 | 零 | 需要服务 | 需要 DB |
| 写入成本 | 极低 | 有 embedding 成本 | 低 |
| 检索能力 | 关键词 | 语义 | 精确 + 可选语义 |
| 用户透明度 | 高(可读 MD) | 低 | 中 |
| 多用户隔离 | 目录隔离 | metadata 过滤 | 命名空间 |
| 扩展性 | 中(万级) | 高(百万级) | 高 |
| 学习成本 | 零 | 中 | 低 |
| 适用场景 | 桌面/小团队 | SaaS/大规模 | 生产多租户 |
12.4 记忆的写入策略:何时写、写什么
何时写入——信号强弱分级
写太多记忆会制造噪声,写太少则失去价值。触发写入的信号:
graph TD
Signal[用户消息信号]
Signal --> S1[强信号<br/>立即写入]
Signal --> S2[中等信号<br/>考虑写入]
Signal --> S3[弱信号<br/>通常不写]
S1 --> S1a["'记住这个' / '以后都这样做'"]
S1 --> S1b["'不要这样做' / '停止 X'"]
S1 --> S1c["'对,就是这样' + 非显而易见做法"]
S2 --> S2a["用户自我介绍角色"]
S2 --> S2b["提到项目截止日期"]
S2 --> S2c["指向外部资源"]
S3 --> S3a[常规任务执行]
S3 --> S3b[可从代码推断]
S3 --> S3c[临时调试状态]
style S1 fill:#fecaca,stroke:#dc2626
style S2 fill:#fef3c7,stroke:#f59e0b
style S3 fill:#dcfce7,stroke:#22c55e
认可信号的重要性
工程实践中容易忽视——认可信号和纠正信号一样重要。
如果只存纠正信号,Agent 会变得过度保守:“用户上次说不要 A,那我下次就不做 A,也不做 B、C、D 以避免任何风险”。这是过度防御。
认可信号告诉 Agent:“这个判断是对的,以后可以继续这样做”。缺了认可信号,Agent 就会从”在任务中摸索正确做法”退化为”只知道不能做错”。
// 认可信号的识别
function detectConfirmationSignal(userMessage: string): boolean {
const patterns = [
/^(yes|yeah|对|就是这样|exactly|perfect|nice|great)/i,
/keep doing|继续这样|保持/i,
/looks good|不错|完美/i,
]
// 但必须是针对 Agent 的非显而易见的做法
return patterns.some(p => p.test(userMessage.trim()))
}
写入前的去重检查
不要每次都写新记忆——优先更新现有记忆:
async function saveMemory(newMemory: Memory): Promise<void> {
// 1. 检查是否已有相似记忆(语义 + 主题)
const existing = await findSimilarMemory(newMemory, {
semanticThreshold: 0.85,
sameType: true,
})
if (existing) {
// 更新而非新建
const merged = mergeMemories(existing, newMemory)
await updateMemory(existing.id, merged)
logger.info(`Updated memory: ${existing.name}`)
return
}
// 2. 写入记忆文件
await writeMemoryFile(newMemory)
// 3. 更新索引
await updateMemoryIndex(newMemory)
logger.info(`Created memory: ${newMemory.name}`)
}
记忆的结构化格式:Rule + Why + How
好的记忆不只是记录事实,还要记录原因和应用方式——这是让 Agent 能处理边界情况的关键。
❌ 差的记忆:
"不要用 mock 测试"
⚠️ 中等的记忆:
"集成测试不要用 mock"
✅ 好的记忆:
"规则:集成测试必须连接真实数据库
Why: 上季度 mock/生产不一致导致迁移失败(2 小时事故)
How to apply: 写测试时默认用 testcontainers + 测试 DB
纯逻辑单元测试除外(不涉及数据库)"
有了 Why,Agent 在边界情况下可以推理——比如”这是一个纯函数的单元测试,不涉及数据库,所以不违反这条规则”。
五种写入模式
enum MemoryWriteMode {
CREATE = "create", // 新建
UPDATE = "update", // 覆盖式更新
APPEND = "append", // 追加(比如事件日志)
MERGE = "merge", // 合并(比如列表型)
INVALIDATE = "invalidate" // 作废(软删除)
}
不同类型的记忆适合不同的写入模式:
- User 类:UPDATE(新信息覆盖旧信息)
- Feedback 类:CREATE + MERGE(纠正信号每次都有价值)
- Project 类:UPDATE + INVALIDATE(项目状态会变)
- Reference 类:CREATE + UPDATE(资源列表)
12.5 记忆的检索策略:三路召回 + 相关性排序
flowchart TD
Start[新会话开始] --> Load[加载 MEMORY.md 索引]
Load --> Check{记忆数量 < 200 条?}
Check -->|是| Full[全量加载索引<br/>~1K tokens]
Check -->|否| Retrieve[多路召回]
Retrieve --> R1[关键词匹配<br/>BM25]
Retrieve --> R2[语义搜索<br/>embedding cosine]
Retrieve --> R3[最近使用<br/>recency]
Retrieve --> R4[上下文相关<br/>当前项目/文件]
R1 & R2 & R3 & R4 --> Merge[RRF 合并排序<br/>Top-K]
Full --> Inject[注入 System Prompt]
Merge --> Inject
Inject --> Detail{需要详情?}
Detail -->|是| ReadFile[按需读取<br/>完整记忆文件]
Detail -->|否| Done[开始对话]
ReadFile --> Done
style Retrieve fill:#dbeafe,stroke:#3b82f6
style Merge fill:#dcfce7,stroke:#22c55e
全量加载(小规模)
Claude Code 的做法:每次加载完整的 MEMORY.md 索引文件。因为索引文件控制在 200 行以内,token 成本可接受。
async function loadMemoryContext(): Promise<string> {
const memoryIndex = await readFile('MEMORY.md')
// 注入 System Prompt
return `
# User's Long-term Memory
This is your persistent knowledge about this user and project,
built up over multiple sessions. Use it to inform your responses.
${memoryIndex}
When you need full details, use the Read tool to load specific files.
`
}
需要详细信息时,Agent 自己决定读取哪个 .md 文件。这种”按需加载”避免了上下文浪费。
多路召回(大规模)
当记忆量超过索引文件能承载的范围(>200 条)时,需要按相关性检索:
def retrieve_relevant_memories(
query: str, # 当前用户消息
context: dict, # 当前上下文(项目、文件、时间)
max_memories: int = 5
) -> list[Memory]:
# === 多路召回 ===
# 1. 关键词匹配(BM25)
keyword_results = keyword_search(query, limit=15)
# 2. 语义搜索(向量相似度)
semantic_results = vector_search(query, limit=15)
# 3. 最近使用(recency)
recency_results = get_recent_memories(days=7, limit=5)
# 4. 上下文相关(同项目/同文件)
context_results = context_match(context, limit=5)
# === 合并 ===
# RRF (Reciprocal Rank Fusion)
candidates = rrf_merge([
keyword_results,
semantic_results,
recency_results,
context_results,
], k=60)
# === 排序 ===
scored = [(m, compute_relevance(m, query, context)) for m in candidates]
scored.sort(key=lambda x: x[1], reverse=True)
# === 过滤 ===
# 剔除过期记忆
scored = [(m, s) for m, s in scored if not is_expired(m)]
# 剔除已无效记忆
scored = [(m, s) for m, s in scored if not m.invalidated]
return [m for m, _ in scored[:max_memories]]
def compute_relevance(m: Memory, query: str, ctx: dict) -> float:
# 多因子加权
score = 0
score += 0.4 * semantic_similarity(m.content, query)
score += 0.2 * keyword_overlap(m.content, query)
score += 0.2 * recency_score(m.created, decay_days=30)
score += 0.1 * type_relevance(m.type, ctx.current_task)
score += 0.1 * usage_weight(m.usage_count, m.success_rate)
return score
记忆注入的位置
记忆应该注入到 System Prompt 而非 User Message。原因:
- System Prompt 的注意力权重更高
- 避免用户误以为这是自己的话
- 可以结构化标记(“这是从你过去的会话中学到的”)
System Prompt:
[工具定义]
[人格/风格]
[长期记忆] ← 在这里注入
[CLAUDE.md]
User Message:
[当前问题]
12.6 记忆的生命周期管理:让记忆不过时
记忆会过时。上个月的项目截止日期、已离职同事的职责、已重构的代码结构——这些记忆如果不清理,会误导 Agent。
stateDiagram-v2
[*] --> Active: 创建
Active --> Active: 使用/更新
Active --> Stale: 时效过期
Active --> Invalidated: 用户显式废除
Active --> Superseded: 被新记忆覆盖
Stale --> Archived: 归档(保留但不使用)
Invalidated --> Deleted: 删除
Superseded --> Archived
Archived --> Deleted: 清理周期到
Deleted --> [*]
时效性标记
---
name: release-freeze
type: project
created: 2026-04-10
expires: 2026-04-16 # 显式过期日期
ttl: 7d # 或相对 TTL
---
过了 4 月 16 日,这条记忆就应该被标记为过期或归档。
失效检测的三种信号
async function detectStaleMemories(): Promise<Memory[]> {
const memories = await listAllMemories()
const stale: Memory[] = []
for (const m of memories) {
// 信号 1: 显式过期
if (m.expires && new Date(m.expires) < new Date()) {
stale.push({ ...m, reason: "expired by date" })
continue
}
// 信号 2: 引用失效
if (m.referencedFiles) {
const existing = await Promise.all(
m.referencedFiles.map(f => fileExists(f))
)
if (existing.every(e => !e)) {
stale.push({ ...m, reason: "all referenced files missing" })
continue
}
}
// 信号 3: 矛盾新记忆
const contradictions = await findContradictions(m)
if (contradictions.length > 0) {
stale.push({ ...m, reason: `contradicted by ${contradictions[0].name}` })
continue
}
}
return stale
}
验证后再使用——记忆 ≠ 事实
Claude Code 的核心规则:记忆中提到的文件路径、函数名、配置项,在推荐给用户之前必须先验证。
"记忆说 X 文件存在" ≠ "X 文件现在存在"
"记忆说 foo() 函数签名是 ..." ≠ "现在 foo() 还是那个签名"
记忆是某个时间点的快照。在据此行动之前,用 Glob/Grep/Read 验证当前状态:
async function actOnMemory(memory: Memory): Promise<Action> {
// 1. 提取记忆中的具体引用
const references = extractReferences(memory)
// 2. 验证每一个引用仍然有效
for (const ref of references) {
if (ref.type === "file" && !await fileExists(ref.path)) {
return {
action: "refresh-memory",
reason: `Referenced file ${ref.path} no longer exists`,
}
}
if (ref.type === "function" && !await functionExists(ref.name)) {
return {
action: "refresh-memory",
reason: `Function ${ref.name} no longer exists`,
}
}
}
// 3. 验证通过,可以使用
return { action: "use", memory }
}
定期清理
async function cleanupMemories(): Promise<CleanupReport> {
const memories = await listAllMemories()
const report: CleanupReport = { archived: 0, deleted: 0 }
for (const m of memories) {
// 项目类记忆:超过 90 天未访问 → 归档
if (m.type === 'project' && daysSinceAccess(m) > 90) {
await archive(m)
report.archived++
continue
}
// 已归档 > 180 天 → 删除
if (m.archived && daysSinceArchive(m) > 180) {
await deleteMemory(m)
report.deleted++
}
// 引用失效 → 归档
if (m.referencesFile && !await fileExists(m.referencesFile)) {
await archive(m)
report.archived++
}
}
return report
}
12.7 隐私与安全:记忆的红线
长期记忆是高风险的数据——处理不当会产生隐私问题和安全漏洞。
绝不存储清单
🚫 API Key、密码、token
🚫 .env 文件的内容
🚫 数据库连接字符串
🚫 证书私钥、SSH key
🚫 个人身份信息(PII)——除非用户明确要求且已脱敏
🚫 医疗、金融、法律的敏感信息
写入时的敏感信息检测
const SENSITIVE_PATTERNS = [
/-----BEGIN (PRIVATE|RSA) KEY-----/,
/sk-[a-zA-Z0-9]{32,}/, // OpenAI key
/AKIA[0-9A-Z]{16}/, // AWS access key
/ghp_[a-zA-Z0-9]{36}/, // GitHub token
/\b\d{3}-\d{2}-\d{4}\b/, // SSN pattern
/\b\d{13,19}\b/, // credit card
/password\s*[=:]\s*['"][^'"]+['"]/i,
/(api[_-]?key|secret|token)\s*[=:]\s*['"][^'"]+['"]/i,
]
function detectSensitiveInfo(text: string): SensitiveMatch[] {
const matches: SensitiveMatch[] = []
for (const pattern of SENSITIVE_PATTERNS) {
const m = text.match(pattern)
if (m) {
matches.push({ pattern: pattern.source, sample: m[0].slice(0, 20) + "..." })
}
}
return matches
}
async function safeSaveMemory(memory: Memory): Promise<void> {
// 写入前扫描
const sensitive = detectSensitiveInfo(memory.content)
if (sensitive.length > 0) {
throw new SecurityError(
`Memory contains sensitive info: ${sensitive.map(s => s.pattern).join(", ")}`
)
}
// 对 PII 做脱敏
memory.content = redactPII(memory.content)
await writeMemoryFile(memory)
}
存储位置的选择
✅ ~/.claude/projects/{hash}/memory/ # 用户主目录,跨项目隔离
❌ /path/to/project/.claude/memory/ # 项目目录——会被意外提交!
Claude Code 的做法:记忆存储在 ~/.claude/projects/{project-hash}/memory/ 目录下:
- 项目路径被哈希处理——保护项目路径隐私
- 记忆文件不在项目目录内——不会被意外提交到 Git
- 用户主目录通常不会被备份到公开存储
加密
对云端存储的记忆,必须加密:
// 写入
const encrypted = await encrypt(memory.content, await getDEK(userId))
await cloudStorage.put(`memories/${userId}/${memory.id}`, encrypted)
// 读取
const encrypted = await cloudStorage.get(`memories/${userId}/${memory.id}`)
const content = await decrypt(encrypted, await getDEK(userId))
关键:密钥不能存在和数据同一位置。使用 KMS(AWS KMS、GCP KMS)管理密钥。
12.8 实践:构建文件记忆系统
一个最小可用的记忆系统实现:
interface Memory {
id: string
name: string
description: string
type: 'user' | 'feedback' | 'project' | 'reference'
content: string
created: string
updated: string
expires?: string
invalidated?: boolean
referencedFiles?: string[]
}
class FileMemoryStore {
constructor(private dir: string) {}
async save(memory: Memory): Promise<void> {
// 敏感信息检查
const sensitive = detectSensitiveInfo(memory.content)
if (sensitive.length > 0) {
throw new SecurityError("Sensitive info detected, cannot save")
}
// 去重
const existing = await this.findSimilar(memory)
if (existing) {
return this.update(existing.id, memory)
}
// 写文件
const filename = `${memory.type}_${slugify(memory.name)}.md`
const content = this.serialize(memory)
await fs.writeFile(path.join(this.dir, filename), content)
// 更新索引
await this.updateIndex()
}
private serialize(m: Memory): string {
return [
'---',
`name: ${m.name}`,
`description: ${m.description}`,
`type: ${m.type}`,
`created: ${m.created}`,
m.expires ? `expires: ${m.expires}` : null,
m.invalidated ? `invalidated: true` : null,
'---',
'',
m.content,
].filter(l => l !== null).join('\n')
}
async loadIndex(): Promise<string> {
const indexPath = path.join(this.dir, 'MEMORY.md')
const exists = await fs.stat(indexPath).catch(() => null)
return exists ? await fs.readFile(indexPath, 'utf-8') : ''
}
async loadMemory(filename: string): Promise<Memory | null> {
const content = await fs.readFile(
path.join(this.dir, filename), 'utf-8'
)
return parseMemoryFile(content)
}
private async updateIndex(): Promise<void> {
const files = await fs.readdir(this.dir)
const entries: string[] = ['# Memory Index', '']
const memories: Memory[] = []
for (const file of files.filter(f => f !== 'MEMORY.md' && f.endsWith('.md'))) {
const m = await this.loadMemory(file)
if (m && !m.invalidated) memories.push(m)
}
// 按类型分组
const byType = groupBy(memories, m => m.type)
for (const type of ['user', 'feedback', 'project', 'reference'] as const) {
const items = byType[type]
if (!items?.length) continue
entries.push(`## ${type}`)
entries.push('')
for (const m of items) {
const filename = `${m.type}_${slugify(m.name)}.md`
entries.push(`- [${m.name}](${filename}) — ${m.description}`)
}
entries.push('')
}
await fs.writeFile(
path.join(this.dir, 'MEMORY.md'),
entries.join('\n')
)
}
async cleanup(): Promise<CleanupReport> {
const files = await fs.readdir(this.dir)
const report: CleanupReport = { archived: 0, deleted: 0 }
for (const file of files.filter(f => f.endsWith('.md') && f !== 'MEMORY.md')) {
const m = await this.loadMemory(file)
if (!m) continue
// 过期
if (m.expires && new Date(m.expires) < new Date()) {
await this.archive(file)
report.archived++
}
// 引用失效
if (m.referencedFiles?.length) {
const stillValid = await Promise.all(
m.referencedFiles.map(f => fileExists(f))
)
if (stillValid.every(v => !v)) {
await this.archive(file)
report.archived++
}
}
}
return report
}
}
12.9 四个反模式
反模式一:过度记忆
现象:Agent 把每次对话都当作新知识写入记忆,MEMORY.md 膨胀到几千条。
根因:没有信号分级,所有信息一视同仁。
对策:只写强信号(明确纠正、明确认可),中等信号需要多次验证。
反模式二:不带 Why 的规则
现象:记忆只有”不要做 X”,Agent 在边界情况下过度保守。
根因:记忆格式缺少 Why 和 How to apply。
对策:强制模板——规则 + Why + How to apply 三段式。
反模式三:不清理过期记忆
现象:记忆中提到”下周二发布”,一年后还在引用。
根因:没有 TTL 机制和生命周期管理。
对策:项目类记忆必须有 expires;每周自动清理过期项。
反模式四:把敏感信息写进记忆
现象:用户不小心贴了 API key,Agent 把它写进记忆。下次会话用到这条记忆,把 key 暴露在新的上下文中。
根因:没有敏感信息过滤。
对策:写入前强制扫描;匹配到敏感模式直接拒绝。
12.10 本章小结:长期记忆的六条原则
长期记忆让 Agent 从”每次失忆的工具”进化为”持续学习的助手”:
- 分类存储——User / Feedback / Project / Reference 四种类型,各有适用场景
- 选择性写入——只存无法从代码推断的人类知识;强信号才写
- 结构化格式——规则 + Why + How to apply,让 Agent 能处理边界情况
- 验证后使用——记忆是快照不是事实,行动前先验证
- 生命周期管理——记忆会过时,需要定期清理和失效检测
- 隐私优先——永远不存储敏感信息;注意存储位置和加密
工程实践的核心节奏:
感知 → 分类 → 去重 → 结构化 → 持久化 → 检索 → 验证 → 使用 → 清理
这个循环是 Agent 跨会话学习的基础。没有这个循环,Agent 就是一个永远的新手——一遍一遍听你介绍自己。
下一章我们将看看如何在多轮对话中管理会话状态,让 Agent 在复杂的交互流程中保持一致性。
12.11 Claude Code memdir/ 模块 1736 行——真实实现
本章前面讨论的抽象——在 Claude Code 里对应src/memdir/ 1736 行代码:
| 文件 | 行 | 职责 |
|---|---|---|
memoryTypes.ts | 271 | 四类 type 常量 + prompt 模板 |
memdir.ts | 507 | MEMORY.md 读写 + 截断 |
paths.ts | 278 | 路径解析 + isAutoMemoryEnabled |
memoryAge.ts | 53 | 时效性判定 |
memoryScan.ts | 94 | 扫描目录、读 frontmatter |
findRelevantMemories.ts | 141 | Sonnet-based relevance selection |
teamMemPaths.ts | 292 | 团队共享记忆路径 |
teamMemPrompts.ts | 100 | 团队 vs 私有的 prompt 差异 |
本节后续小节——逐个拆这些文件的核心代码——让读者看到”记忆系统”在工业级是怎么落地的。
12.12 MEMORY.md 的双硬上限——200 行 & 25 KB
memdir.ts:35-38:
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200
// ~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).
export const MAX_ENTRYPOINT_BYTES = 25_000
双上限设计的精妙之处——
- 200 行 是行数上限——覆盖正常用法
- 25,000 字节 是字节上限——防御”一行超长内容”绕过行数限制
- 注释里的 “p100 observed: 197KB under 200 lines” 是生产教训——有用户把一整页 wiki 塞成一行——行数合规、字节爆炸
任何 “基于行数的限制” 都必须配字节兜底——否则一定会被绕过——本章§12.3 的 MEMORY.md 建议立刻加这条。
12.13 memoryAge.ts 的两条话术智慧
本章§12.5 讨论记忆时效性——Claude Code memoryAge.ts 的 53 行代码有两条值得细读的注释:
话术 1——数字化 vs 自然语言:
// Models are poor at date arithmetic —
// a raw ISO timestamp doesn't trigger staleness reasoning the way
// "47 days ago" does.
export function memoryAge(mtimeMs: number): string { ... }
翻译——模型看到 2024-03-15T10:00:00Z 不会主动算”哦这是 413 天前”——看到 413 days ago 立即就懂。把时间算好、别让模型算。
话术 2——为什么要在过时 memory 前加 caveat:
// Motivated by user reports of stale code-state memories (file:line
// citations to code that has since changed) being asserted as fact —
// the citation makes the stale claim sound more authoritative, not less.
export function memoryFreshnessText(mtimeMs: number): string { ... }
翻译——记忆里带 file:line 引用反而让模型更自信——用户读到”根据 src/foo.ts:42 的代码、这里应该…” 的错误判断——事故。修复:每条超过 1 天的 memory 都拼接”这是 N 天前的 observation、不是 live state、请先验证” 的免责声明。
这条设计——是本章§12.5”验证后使用”原则的具体落地——不是口号、是每次 memory 注入 prompt 时都自动附加的 caveat——系统层面强制提醒。
12.14 findRelevantMemories.ts:用 LLM 做 memory 检索器
本章前面讨论的检索方法(向量 / BM25 / 规则)——Claude Code 选了第四条路:让 Sonnet 读 memory manifest、选 5 条相关的。
findRelevantMemories.ts:18-24 的 system prompt——
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 to Claude Code as it processes the user’s query (up to 5). Only include memories that you are certain will be helpful based on their name and description.
三个被严谨考虑的约束——
- “up to 5”——Lost in the middle 的 mitigation(第 11 章讲过)——塞太多稀释注意力
- “only include memories that you are certain”——宁缺勿滥——低置信度的不选
- “do not select memories that are usage reference or API documentation for those tools (Claude Code is already exercising them). DO still select memories containing warnings, gotchas, or known issues”——区分”当前工具的用法”和”当前工具的坑”——前者模型正在用、后者才是 memory 该提供的额外信息
这条指令是个小艺术品——识别”什么信息不该从 memory 拿”比”什么信息该从 memory 拿”更难——Claude Code 把这个边界写得非常清楚。
12.15 Sonnet 做 selector 的成本账
为什么用较强模型而不是最便宜模型——§12.14 的选择器看起来只是辅助调用,实际会决定哪些 memory 被注入主 prompt:
- 弱 selector 的风险不是”少花钱”,而是把不相关 memory 注入主模型,造成错误先验。
- 强 selector 的价值也不能凭经验宣称,必须用人工标注的 memory-query 样本集衡量 precision / recall。
- 选错 memory 的代价——整条 memory 被注入主 prompt + 误导主模型 + Agent 给出错误结果——通常高于一次 selector 调用本身。
对比本书第 18 章§18.15 讨论的”Judge 要用强模型”——思路完全相同——辅助任务的模型能力降一档、主任务的质量可能降两档——省小钱、丢大钱。
12.16 alreadySurfaced 参数:memory 去重的优雅实现
findRelevantMemories.ts:43:
alreadySurfaced: ReadonlySet<string> = new Set(),
这参数解决一个很实际的问题——用户连续 3 轮询问、每次 memory selector 可能选同一条——primary context 就会被同一条 memory 反复注入——浪费 token + 重复已知信息。
解法——调用方传入”这个 session 已经 surface 过的 memory 路径集合”——selector 预先过滤掉这些——每次只选”新的” memory。
为什么不在 selector 内部记状态——因为 selector 是 stateless 的——符合 RESTful 哲学——状态由调用方管理——便于并发 / 不同 session 隔离。
这是个值得抄的小模式——任何”重复推荐问题”都可以用”排除已曝光集合”解。
12.17 为什么 MEMORY.md 是 entry point——而不是散文件
Claude Code 的 memory 目录布局是——
.claude/memory/
├── MEMORY.md <- 唯一 entrypoint (≤200 行 & ≤25KB)
├── user_profile.md <- 单个 memory file
├── feedback_testing.md
├── project_phoenix.md
└── ...
MEMORY.md 的特殊地位——
- 每次会话启动 100% 加载——放在 system prompt
- 不加载具体的 memory 文件——节省 token
MEMORY.md只存索引(- [标题](文件名.md) — 一句话描述)——模型根据描述判断”是否需要加载 full memory”
为什么这个设计比”全部加载” 更好——
- “全部加载”在 memory 多时 context 会爆炸——100 个 memory × 500 token = 50K tokens
- “索引 + 按需加载”把成本从 O(N) 降到 O(1) + O(k × file size)——k 通常 ≤ 5
这是典型的”两层 indirection”设计——类似 inode + data block、类似 DNS 根服务器 + 递归查询——任何大规模持久化存储都用这招。
12.18 teamMem vs 私有 memory:两级可见性
§12.2 提到四种 memory type——Claude Code 还有正交的一维:private vs team scope。
memoryTypes.ts:38-40——
There are several discrete types of memory that you can store in your memory system. Each type below declares a <scope> of
private,team, or guidance for choosing between the two.
四个 type × 两个 scope = 8 种 memory 组合——但有默认偏向:
- User → always private(个人画像不能团队共享)
- Feedback → default private,但”团队级规则”可以 team(例如”所有 PR 必须带测试”)
- Project → strongly bias team(项目状态人人该知道)
- Reference → 看情况(Linear URL 通常 team、个人书签 private)
实现路径——.claude/memory/(私有)+ .claude/team-memory/(git 跟踪)——两个目录独立扫描——team 优先级更高(私有规则不能 override 团队规则)。
这是”协作 Agent”的关键基建——本书第 3 章 Agent Loop 讲”single user”、本节讲”multi user with shared context”——概念上的”跨会话记忆”升级为”跨用户记忆”。
12.19 真实事故:memory 里的 “file:line 引用”如何害人
§12.13 话术 2 提到了”用户报告”——下面复盘一个公开渠道可见的典型模式:
- Agent 在 2024-08 写入 memory:“根据
src/auth.ts:45、token 存在 localStorage” - 用户 6 个月后问 “token 怎么处理的”
- Agent 注入这条 memory、但没加 age caveat
- 用户相信”
src/auth.ts:45是权威”——但那时代码已经重构到src/auth/session.ts:120、token 改成了 cookie - 用户按错误信息改代码、引入新 bug
根因——memory 写时是事实、读时可能已陈旧——memoryFreshnessText() 就是为这个场景写的。
工程教训——
- 任何”file:line 级”的 memory 都要有 age check
- 超过 N 天(建议 30)的 memory 注入 prompt 时必须带 “请先验证” 的 caveat
- 定期跑 “stale memory cleanup”——自动扫描、把半年没更新的 memory 归档
这是”长期记忆” 和 “live state” 的永恒张力——Agent 工程师必须时刻警觉。
12.20 跨书呼应:memory 系统和整个 Agent 生态
- 第 3 章 Agent Loop——每次 turn 都可能触发 memory 读写——loop 循环是 memory 的时间轴
- 第 11 章 短期记忆——compact 的产出(9 字段摘要)可以升级为长期 memory——session 结束时
- 第 15 章 沙箱——
.claude/memory和.claude/team-memory永远 denyWrite(§15.14 反击 2)——防止 Agent 篡改自己的 memory - 第 18 章 评估——memory 是评估的 golden set candidate 来源——**“这条 feedback 是不是真的帮 Agent 做得更好” 可以反向验证
- 第 19 章 可观测性——每次 memory 读 / 写 / 命中 / 过滤都是一个 span——trace 里能看到”这一轮用了哪些 memory”
- 第 20 章 成本——memory selector 的 Sonnet 调用也要进成本账本
六章合读——你会发现 memory 系统不是一个孤立模块——它是 Agent 平台的”学习子系统”——和 loop / compact / 沙箱 / eval / observability / cost 全部交互。
12.21 写给新团队的 15 分钟 memory 系统 MVP
如果读者从零搭建——跟着做 15 分钟就能跑起来:
分钟 0-3——
mkdir .claude/memory- 新建
MEMORY.md、内容只有一行:# Memory Index
分钟 3-8——
- 写一个系统 prompt 片段:
"If the user tells you something that would be useful across future conversations, save it as a new file in .claude/memory/ and add a one-line index entry to MEMORY.md." - 把这段塞进 Agent 的 system prompt
分钟 8-13——
- 给 Agent 工具集加一个
WriteMemory(name, description, content)工具 - 加一个
ReadMemory(name)工具 - 别加 “更新” 或 “删除”——先只做增量
分钟 13-15——
- 写一个启动时加载的脚本:
cat .claude/memory/MEMORY.md >> system_prompt
15 分钟结束——你有了最简 memory 系统——和 Claude Code 的区别只是没有:selector / age caveat / team scope / cleanup——这些都是后期迭代。
入门不求完美、先能跑——记忆系统是典型的”每周改进一点点”的模块。
12.22 一段总结
长期记忆是Agent 跨会话”进化能力”的基础——没它的 Agent 是”每次失忆的实习生”——有它的 Agent 是”越用越懂你的同事”。
本章从抽象原则(§12.1-12.10)到Claude Code 源码实证(§12.11-12.19)——1736 行真实代码 + 5 条设计话术 + 1 个真实事故——把”记忆系统”讲到了工业级。
下章会话状态见。
12.23 最后两条直接能抄的
抄 1——§12.12 的 MEMORY.md 双硬上限(200 行 + 25KB)
抄 2——§12.13 的 memoryFreshnessText() 自动附加 caveat
这两条代码不是万能药,但能覆盖最常见的”记忆害人”入口:过期、重复、低置信度和与当前任务无关。上线后仍要看误注入率和人工复核结果。
12.24 truncateEntrypointContent 的三重智慧
§12.12 提了双硬上限——memdir.ts:57-103 的 truncateEntrypointContent 函数值得单独拆开:
智慧 1——先行后字节的顺序——
let truncated = wasLineTruncated
? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
: trimmed
if (truncated.length > MAX_ENTRYPOINT_BYTES) {
const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
}
- 先按行截(自然边界、不破坏 markdown 结构)
- 再按字节截(在行内找上一个
\n、防止切断一条 index 条目) - 找不到
\n就直接硬切——fallback 不丢数据
智慧 2——警告消息带具体原因——
WARNING: MEMORY.md is 247 lines (limit: 200). Only part of it was loaded.
Keep index entries to one line under ~200 chars; move detail into topic files.
不是”内容太多、被截断”——是”247 行超了 200 行、请把每条索引压到一行 200 字符内”——具体、可操作。
智慧 3——警告同时给到模型和用户——
警告被直接 append 到 MEMORY.md 的可见内容里——模型在 system prompt 看得到——用户 cat MEMORY.md 也看得到——双方都不会被悄悄截断坑到。
这 50 行函数——是”边界处理”的教科书案例——容错 + 反馈 + 可观测 三合一。
12.25 MEMORY.md 超长的三种典型原因
Claude Code 的生产数据显示——MEMORY.md 超限的三种典型模式:
模式 A——行数激增——Agent 把每次小 feedback 都加成新条目、几个月后 400 行——对策:每 3 个月合并语义相近的条目
模式 B——单行超长——用户把整篇 markdown 文档塞成 “one-line description”——对策:索引条目严格 <200 字符
模式 C——body 污染 index——Agent 误把详细内容写到 MEMORY.md 而不是 topic 文件——对策:写入前做 “内容长度 > 300 字符 → 放到独立文件” 的自动判定
三种模式都由 truncateEntrypointContent 兜底——但更好的做法是”写入路径就防御”——不等 entrypoint 超限才截断。
12.26 另一个反事故:把 MCP tool 的 doc 写入 memory
常见错误——用户问”怎么用 XX MCP 工具”——Agent 写了一条 memory “XX 工具的用法是 YYY”——下次 MCP 接口变了、memory 还是旧的、Agent 按旧接口调用失败。
根因——memory 记录了”应当从源系统读取”的信息——违反本章§12.2 的”只存无法从代码推断的知识”原则。
findRelevantMemories.ts 的 system prompt 已经显式防御这种情况——"do not select memories that are usage reference or API documentation for those tools"——但这是 selector 层的过滤——最好的防御是写入时就不写。
工程做法——
- 写入 memory 前、用另一个模型判断一次:“这条是个人偏好、项目知识、还是可查询的事实?” ——最后一种拒绝写入
- MCP 接口文档 / API schema 永远不进 memory——直接从源头拉最新
这是 memory 系统的”边界感”——知道”不该记什么” 和 “该记什么” 同等重要。
12.27 对比 LangGraph Store 的 API 设计
LangGraph 也有 memory 原语——BaseStore、InMemoryStore、PostgresStore——和 Claude Code 的文件系统方案差异显著:
| 维度 | Claude Code memdir | LangGraph Store |
|---|---|---|
| 存储 | .claude/memory/*.md | KV 数据库 / Postgres |
| schema | frontmatter + markdown | {namespace, key, value} |
| 检索 | Sonnet-based selector | 向量相似度 |
| 更新 | 用户 / Agent 显式写 | put / delete API |
| 共享 | file system 共享 | namespace 隔离 |
| 版本 | git | 内置 versioning |
两种哲学——
- 文件系统路径——人类可读、可 git diff、可直接编辑——工具型 Agent(coding / research)更合适
- 数据库——并发友好、可索引、可分布式——高并发 Agent(客服 / 多租户)更合适
选择不是”谁更好”——是”单用户 vs 多用户”、“本地 vs 云端”、“人可读 vs 机器优先” 的工程权衡。
本书第 13 章《LangGraph Streaming》和第 18 章《Design Patterns》会讲 LangGraph Store 的具体用法——两本书合起来看、你会明白”memory 存储模型”是 Agent 框架设计的一个重要分叉点。
12.29 scanMemoryFiles 94 行——目录扫描的工业级实现
memoryScan.ts 94 行——记忆系统的”目录 listing”函数——看似简单、但包含几个值得注意的设计:
- 只扫
*.md——不会被.DS_Store/.git/ 编辑器 swap 文件骚扰 - 读 frontmatter 时用 YAML parse——一旦格式错误就 skip 这个文件 + 上报
memory_scan_skiptelemetry——单个坏文件不阻断整体扫描 - 返回
MemoryHeader数组({ filePath, name, description, type, mtimeMs })——不返回 body——selector 用 header 做决策、不加载 body——省 I/O + 省 token mtimeMs透传——方便下游调memoryFreshnessText()而不用重新 stat
这个文件的设计哲学——惰性加载 + 容错 + 观测——三者合一。
本书第 19 章§19.26 讨论过 metadata enrichment——这里是同一哲学的具体落地——header 是 memory 的”metadata”、body 是”payload”——只在必要时才加载 payload。
12.30 十个**“不要做”** 的 memory 反模式
本章散落在各节的反例——这里集中列 10 条禁忌:
- 不要记录 API 文档——代码/源系统是权威
- 不要记录项目架构——
CLAUDE.md或 README 是权威 - 不要记录 commit 历史——
git log是权威 - 不要记录”这次修好了 X 的 bug”——
git blame是权威 - 不要记录 SSH key / API token /
.env——隐私红线(§12.10 原则 6) - 不要记录”我是 Claude”——self-referential memory 会污染 prompt
- 不要在一条 memory 里记录多个主题——违反”单一职责”、检索精度下降
- 不要用相对时间(“上周” / “明天”)——
memoryAge.ts存在的原因就是这个——必须绝对日期 - 不要记录”调试过程”——结果比过程有价值——写”最终修复方式”就行
- 不要”为了记而记”——信息量门槛要高——弱信号不如不记
十条禁忌——每条都能对应到一次”看似聪明实际搞砸”的真实场景。
12.32 Team memory 的 git 集成
§12.18 讲了 team vs private——team memory 放在.claude/team-memory/ 、本质上是”跟项目 git 走”的共享记忆——实现细节:
- 团队一起 commit
.claude/team-memory/*.md到项目仓库 - code review 流程里讨论 memory 改动——确保”团队级规则”的共识
- 私有 memory 在
.gitignore(.claude/memory/)——绝不跟进仓库 - 用户新克隆仓库时——自动拿到团队 memory——**“入职即懂项目”的体验
这是”git-native” memory 设计的精髓——不发明新协议、复用 git 的 diff / review / history 基础设施——团队 onboarding 时间砍一半。
反面对比——把 team memory 存在云服务(Notion、LangSmith)——需要额外账号、额外权限、额外维护——工程负担大——不如 git 直观。
12.33 memory 去重的三档策略
当用户说”别用 mock 测试”——memory 里可能已经有类似条目——如何去重?
档 1——精确匹配(最保守)——名称完全一致才算重复——几乎不会误删、但重复率高。
档 2——语义去重(推荐)——用 embedding 相似度阈值 + LLM 确认——能识别”不要 mock 测试” 和 “测试别用 mock”。阈值要用本地样本调,不能照抄一个固定数值。
档 3——合并而非替换——发现重复时用 LLM 合并两条——保留两边细节(Why、How to apply)——最佳但最贵。
Claude Code 默认用档 1 + 定期运行档 3——平衡精度和成本。
读者的系统可以起步档 1、规模化后再上档 3。
12.35 附录:和”上下文工程”潮流的关系
2024-2026 年业界涌现一波**“context engineering”**概念(Anthropic 博客、LangChain 演讲、Simon Willison 文章多次讨论)——memory 系统是它的核心支柱之一。
上下文工程的三大输入——
- Working memory(当前对话历史)——本书第 11 章
- Long-term memory(跨会话记忆)——本章
- Retrieved knowledge(RAG / vector search)——本书第 17 章 MCP 涉及
三者如何组合——
- 每一轮对话的 context = system prompt + tool定义 + [从长期 memory 选的 top-k] + 工作记忆 + [RAG 检索结果] + 当前用户消息
- memory 和 RAG 的边界——memory 是”关于用户/项目/自己” 的知识、RAG 是”关于外部世界” 的知识
- memory 通常 < 1000 条 / < 5MB、RAG 可以 GB 级——存储和检索算法完全不同
这张三分图让你对”Agent 的记忆”有完整地图——而不是把所有内容都往 memory 里塞。
12.36 附录:为什么 memory 不用向量数据库
§12.14 说 Claude Code 用 Sonnet 做 selector 而不是向量检索——为什么?
- memory 规模小(< 1000 条)——向量的优势(大规模相似度)用不上
- description 已经是摘要——Sonnet 读 description 和 query、判断相关性很准
- 向量检索返回 top-k 是”纯相似度”、不考虑”当前任务 context”——Sonnet selector 可以理解”这个 memory 和当前任务的语义关系”
- 向量需要 embedding + 存储 + 索引维护——运维成本比 Sonnet 调用还大
小规模 memory = LLM selector 更优;大规模外部知识 = 向量检索更优——规模决定工具选择。
这是一条反直觉的结论——**“用向量做一切” 的直觉是错的——规模和场景决定工具。
12.38 附录:记忆系统的 12 个 KPI
让记忆系统可被管理——必须有指标——12 个 KPI:
质量维度——
- Memory 总数(过多即过度记忆)
- 平均 age(太老 → 需清理)
- 命中率(被 selector 选中的比例、低 → 价值不高)
- 引用失效率(指向的文件不存在 → 需归档)
成本维度——
- Selector 每日调用次数 + 成本
- Memory body 总 token(注入 prompt 的量)
- Memory 读 / 写 比率(健康值 > 5:1)
效果维度——
- 含 memory 的任务 vs 不含的任务——成功率差
- 用户反馈 “agent 懂我” 率
- 重复教育率(用户反复告诉 agent 同一件事)——低越好
健康维度——
- MEMORY.md 字节 / 行 占比 upper bound
- 敏感信息扫描命中率(应该 = 0)
这 12 个 KPI 每周跑一次、画一张”Memory Health” 报告——任何维度异常立刻介入——像维护数据库一样维护 memory。
12.41 最后一张表:四类 memory 的写入 checklist
每次写入前对照——
| Type | 存什么 | 不存什么 | Why 字段 | How to apply |
|---|---|---|---|---|
| User | 角色、经验、偏好、知识水平 | 姓名 / 邮箱(PII)、凭证 | 可选 | 必须 |
| Feedback | 明确的纠正或认可 | 单次没复现的猜测 | 必须(下次判断边界要用) | 必须 |
| Project | 进度、deadline、决策 | 代码结构、git 历史 | 必须(stakeholder 意图) | 必须 |
| Reference | URL、项目 ID、dashboard 地址 | 实时数据、密钥 | 可选 | 可选 |
每次写入前过一遍这张表——比事后清理便宜 100 倍。
12.42 下章预告
下章多轮会话状态管理——会回答一个问题:在”短期记忆(第 11 章)” 和 “长期记忆(本章)” 之间、还有一类”任务级状态”(比如 draft PR、临时开的 issue、还没 commit 的变更)——它们怎么管?
答案在下一章——它补完 Agent 记忆金字塔的最后一层。
到那时见。
12.43 源码锚点速查表
| 话题 | 源码位置 |
|---|---|
| 四类 type 常量 | src/memdir/memoryTypes.ts:14-20 |
| MEMORY.md 双硬上限 | src/memdir/memdir.ts:35-38 |
| 截断函数(三重智慧) | src/memdir/memdir.ts:57-103 |
| memory 时效性话术 | src/memdir/memoryAge.ts:15-42 |
| Sonnet selector prompt | src/memdir/findRelevantMemories.ts:18-24 |
| alreadySurfaced 去重 | src/memdir/findRelevantMemories.ts:43 |
| Team memory prompts | src/memdir/teamMemPrompts.ts |
| Team memory paths | src/memdir/teamMemPaths.ts |
| Header 扫描(不读 body) | src/memdir/memoryScan.ts |
| Path resolution + env check | src/memdir/paths.ts |
读者想深挖任何一条——这张表是起点。
12.44 终终终
七千多字、43 节、一张表、十条禁忌、三个事故——全部围绕”Agent 长期记忆”这一件事。
本章是 21 章里的一章、但值得读三遍——因为”记忆”会是 Agent 时代最被轻视又最决定产品差异的能力。
真的再见。
12.45 一句话打包全章
“记住该记的、忘记该忘的、怀疑应该怀疑的——这就是 Agent 记忆系统的全部”。
三个动词——记、忘、疑——对应本章的:
- 记——§12.2 四种 type、§12.3 何时写、§12.12 上限控制
- 忘——§12.5 过期清理、§12.33 去重合并、§12.9 反模式
- 疑——§12.13 freshness caveat、§12.19 事故、§12.26 不该写什么
三个动词平衡——你的 memory 系统就是平衡的——偏任何一端都出问题。
12.46 附加一张”记忆系统路线图”
给读者一张3 个月落地路线图——照着做能把记忆系统从 0 建到生产级:
Month 1(最小可用)——
- Week 1:§12.21 的 15 分钟 MVP 跑起来
- Week 2:加 WriteMemory / ReadMemory 工具
- Week 3:加 MEMORY.md 索引 + 启动加载
- Week 4:加双硬上限(200 行 + 25KB)
Month 2(生产化)——
- Week 5:加 4 种 type 分类
- Week 6:加 Sonnet selector(§12.14)
- Week 7:加 freshness caveat(§12.13)
- Week 8:加 PII 扫描 + 隐私红线
Month 3(团队化)——
- Week 9:加 team memory 目录 + git 集成
- Week 10:加 alreadySurfaced 去重
- Week 11:加 12 KPI 仪表盘(§12.38)
- Week 12:加 stale memory 自动清理 cron job
3 个月后——你的 memory 系统对标 Claude Code memdir——不是”做了点事”、是”做到位了”。
12.48 最后一层:记忆系统的**“三个哲学问题”**
问题一——记忆是”客观事实” 还是 “主观观察”?
Claude Code 选择了”主观观察”——memoryFreshnessText 明确承认 memory 是”point-in-time observations, not live state”——这是务实的谦逊——AI 的”记忆”永远不等价于数据库的”事实”。
问题二——memory 应该”忠实复现” 还是 “抽象提炼”?
Claude Code 选择了”抽象提炼”——每条 memory 带 Why + How to apply——不是录音、是摘要加反思——让模型在边界情况也能判断。
问题三——记忆应该由”用户显式管理” 还是 “Agent 自动维护”?
Claude Code 选择了”Agent 自动 + 用户可见”——写入由 Agent 决策 + 用户 cat MEMORY.md 可查 + .claude/memory/ 可手动编辑——自动化 + 可控——不是黑盒。
三个问题的回答——共同塑造了 Claude Code memory 系统的”性格”:谦逊、克制、透明。
这个”性格”值得你的 Agent 学习——不是一套 API、是一套价值观。