第 8 章:记忆与上下文——让 Agent 有昨天
[支柱:Memory]
Beat 1 — 路线图
Ch1→Ch2→Ch3→Ch4→Ch5→Ch6→Ch7→【Ch8←你在这里】→Ch9→Ch10→...
↑
记忆 = "agent 自己的昨天"本章从一个每次启动都失忆的 Lena v0.7 出发,经过两层记忆架构的搭建(短期 SQLite + 长期文件系统),到达一个能跨会话记住用户偏好的 Lena v0.8。途中会踩一个坑:Compaction 摘要不可信 — LLM 压缩历史时会悄悄篡改已做的决定,把否决过的方案写成"待评估"。
Lena 版本升级:v0.7(流式并发)→ v0.8(跨会话记忆 + save_memory 工具)。
本章 vs Ch 9 的明确分工:本章是 agent 自己的记忆——"我做过什么、用户是谁、用户喜欢什么"。Ch 9 是 agent 读外部知识库——"某份文档里说了什么"。这两个能力经常被混为一谈,但解法完全不同,本章专注前者。
🧠 聪明度增量(v0.7 → v0.8):Lena 第一次有了跨 session 的记忆——SQLite 短期记忆 + 文件系统长期记忆让她能记住"我做过什么、用户是谁",不再每次启动都失忆。这一章教读者把 agent 的自我历史记录能力长在自己 agent 上的方法。
Beat 2 — 动机
Karpathy 对 LLM 记忆的比喻切中要害:
"The LLM context window is RAM — anterograde amnesia means no memory consolidation across sessions; each restart is a clean slate." (LLM 的 context window 就是内存——顺行性遗忘症意味着跨 session 无法巩固记忆,每次重启都是白纸一张。)
你的笔记本电脑关机后,RAM 归零,但硬盘还在。没有本章要实现的 memory 层,Lena 每次启动就像一个失忆患者——知道所有常识,但忘了昨天跟你说过什么。
没有记忆的 agent 有多不堪用?运行一下就知道:
# 无记忆版 Lena(v0.7 状态)
session1 = LenaAgent()
session1.chat("我叫 Bob,我喜欢用 Python 写代码。")
# → "你好 Bob!我会记住你的偏好。"
# 重启进程,新 session
session2 = LenaAgent()
session2.chat("帮我写个 hello world。")
# → "请问您想用哪种编程语言?Python、JavaScript、还是其他?"第二次,Lena 完全不记得 Bob 是谁,也不记得他偏好 Python。用户说过的每一句话,在进程退出后就永远消失了。
这不是小问题。一个能"自主做任何事"的 agent,必须知道它在跟谁说话、这个人有什么偏好、上次讨论到了哪里。否则它只是个无状态的问答机器,每次对话都从零开始,每次都要用户重新介绍自己。
更隐蔽的问题在长会话里:当对话超过 128K tokens 时,框架会触发 autocompact,用 LLM 把历史压缩成摘要。LLM 生成的摘要会出错——把"用户明确否决了 LangChain 方案"写成"LangChain 方案待评估",然后 agent 在后续对话里再次推进这个方案,直到用户发现并叫停。
这是真实的生产事故模式,不是假想的风险。
解法分两层:会话内用 SQLite 保存完整消息历史(抗 compaction 数据丢失);跨会话用文件系统保存关键事实(抗进程重启失忆)。本章实现这两层。
Beat 3 — 理论铺垫
3.1 记忆的四个维度
在具体实现之前,先建立一个精确的分类框架。每一个维度对应一个设计决策。
时间维度:记忆分 会话内(in-session) 和 跨会话(cross-session) 两种。前者只活在当前进程的内存里;后者需要持久化到磁盘,能在下次启动时恢复。这是最根本的分层——没有跨会话持久化,agent 永远是一次性的。
精确性维度:记忆分 精确存储(verbatim) 和 摘要(summarized) 两种。SQLite 存的是原始消息,是 verbatim;autocompact 生成的是 LLM 摘要,是 summarized——可以失真。这个差异是 Beat 2 提到的"Compaction 摘要不可信"问题的根源:summarized 记忆经过 LLM 重写,会丢失细节甚至改变事实;verbatim 存储不经过模型,忠实保存每一条原始消息。
Convention:短期记忆 = 当前会话的消息历史,存 SQLite,verbatim,进程退出后持久化;长期记忆 = 跨会话的关键事实和用户偏好,存文件系统,verbatim,每条记忆一个 .md 文件。
访问方式维度:记忆分 顺序读取 和 按需检索 两种。短期记忆是顺序的(按时间戳加载全部历史);长期记忆是按需的(MEMORY.md 索引 → 读相关文件)。这个区别影响 context 占用量:顺序读全量,按需读索引页。随着会话轮次增加,短期记忆的顺序加载会撑大 context,这是 Ch 10 Context Engineering 要解决的问题。
写入时机维度:短期记忆是自动写入的(每轮对话结束追加);长期记忆是 agent 主动判断的("这句话值得记住"→ 调用 save_memory 工具)。让 agent 自己决定什么值得记是关键设计——如果所有对话都写入长期记忆,会产生大量噪声,用不相关的事实污染 system prompt。
短期记忆 长期记忆
(SQLite) (文件系统)
┌──────────┐ ┌──────────────┐
写入 → │ 自动追加 │ → │ agent 主动判断│
时机 │ 每轮结束 │ │ (save_memory) │
└──────────┘ └──────────────┘
┌──────────┐ ┌──────────────┐
访问 → │ 顺序全量 │ → │ 索引 + 按需 │
方式 │ 本会话历史│ │ MEMORY.md │
└──────────┘ └──────────────┘
┌──────────┐ ┌──────────────┐
精确 → │ verbatim │ → │ verbatim │
性 │ 原始消息 │ │ 不经 LLM 重写│
└──────────┘ └──────────────┘3.2 文件系统 vs 向量库
乍看 agent 的记忆问题,最直觉的答案是"用向量数据库"——把每条记忆向量化,检索时用余弦相似度找最相关的。
但实际上,这个答案在个人 agent 场景下是过度设计。原因在于:向量库解决的是语义相似性搜索问题("找和这个问题语义相关的文档"),而 agent 记忆需要的是精确检索("这个用户的所有偏好"、"上次讨论的结论")。
进一步看数量级:一个个人 agent 的长期记忆通常在 100-1000 条。对 1000 个 .md 文件做全量读取,Python 只需要约 50ms。没有任何理由为此引入向量嵌入模型、索引更新延迟、相似度阈值调参这些额外复杂度。
Manus 团队在 2025 年的 Context Engineering 实践中明确提出这条铁律:"文件系统是无限外部记忆。把重要上下文持久化到文件,而不是指望 context window 装得下。"(来源:Manus Context Engineering for AI Agents,2025)
Convention:agent 记忆 = 个人助手的已知事实 + 偏好 + 工作记录,文件系统足够;RAG = 外部知识库的语义检索,才需要向量库。(Ch 9 专讲 RAG,本章不重叠。)
3.3 CLAUDE.md-like 自动加载机制
Claude Code 有一个让很多人误解的特性:它"记得"你在 CLAUDE.md 里写的偏好。这不是模型的长期记忆能力——是每次会话启动时把文件内容注入 system prompt。模型本身是无状态的,"记住"是每轮调用时把记忆文件的内容放进上下文,让模型每次都能"读到"。
Claude Code 源码(source/src/memdir/memdir.ts:34)定义了:
ENTRYPOINT_NAME = 'MEMORY.md' // memdir.ts:34
MAX_ENTRYPOINT_LINES = 200 // memdir.ts:35
MAX_ENTRYPOINT_BYTES = 25_000 // ~125 chars/linebuildMemoryPrompt() 在会话启动时读取 MEMORY.md,截断到 200 行 / 25KB,拼入 system prompt。如果超限,追加警告行 > WARNING: MEMORY.md is N lines (limit: 200),告诉 agent 索引被截断了。
memoryScan.ts:22 中还限制了 MAX_MEMORY_FILES = 200,单个 project 最多追踪 200 个记忆文件,超出的按修改时间排序后截断。这是防止 agent 在大型 project 里产生无边界增长的记忆文件。
Lena v0.8 遵循同样原理:每次 chat() 调用,_build_system_prompt() 从文件系统读取长期记忆,格式化后注入 system prompt 开头和结尾(Recitation 双写)。用户感知到的是"Lena 记住了我",底层是每次调用都从磁盘读入。
这个机制有一个推论:文件系统记忆的更新立即生效,无需重启。你手动编辑 ~/.lena/projects/lena/MEMORY.md 或删除一个记忆文件,下一次 chat() 调用就会用新状态——这是向量数据库做不到的可检查性。
Beat 4 — 脚手架
下面构建最小的记忆骨架——两个 class,各负责一层,各自独立可运行:
# memory/store.py — 短期记忆(SQLite,零依赖)
import sqlite3
import json
from datetime import datetime
from pathlib import Path
class MemoryStore:
"""
SQLite short-term memory. Zero dependencies beyond stdlib.
Stores per-session message history, survives process restarts.
Separate from MemDir: store handles session history (sequential),
MemDir handles long-term facts (indexed, cross-session).
"""
def __init__(self, db_path: str = "~/.lena/memory.db"):
self.db_path = Path(db_path).expanduser()
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
# Fresh connection per call — simple, no connection pool needed at this scale
return sqlite3.connect(str(self.db_path))
def _init_db(self) -> None:
with self._conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created TEXT NOT NULL,
updated TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL, -- JSON-encoded
created TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id)
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_msg_session "
"ON messages(session_id, id)"
)预期输出:MemoryStore("~/.lena/memory.db") 应在 ~/.lena/ 下创建一个 memory.db 文件。用 sqlite3 ~/.lena/memory.db ".tables" 验证能看到 sessions 和 messages 两张表。
# memory/memdir.py — 长期记忆(文件系统,零依赖)
import yaml
import uuid
from datetime import datetime
from pathlib import Path
class MemDir:
"""
File-system long-term memory. One .md per memory, MEMORY.md as index.
Inspired by Claude Code's memdir/memdir.ts:34 (ENTRYPOINT_NAME = 'MEMORY.md').
Max 200 lines in MEMORY.md index (matching CC's MAX_ENTRYPOINT_LINES).
"""
ENTRYPOINT_NAME = "MEMORY.md"
MAX_ENTRYPOINT_LINES = 200 # matches memdir.ts:35
def __init__(self, project_slug: str = "lena"):
self.base = Path("~/.lena/projects").expanduser() / project_slug / "memory"
self.base.mkdir(parents=True, exist_ok=True)
self.index_path = self.base.parent / self.ENTRYPOINT_NAME预期输出:MemDir("lena") 应创建 ~/.lena/projects/lena/memory/ 目录,MEMORY.md 尚不存在(首次 save() 时才创建)。接下来我们在这个骨架上逐步增加能力。
Beat 5 — 渐进组装
| 扩展点 | 为何需要 | 如何加 |
|---|---|---|
MemoryStore.append / load_messages | agent 每轮写入、启动时回放 | INSERT + SELECT ORDER BY id |
MemDir.save() + YAML frontmatter | 每条记忆可独立读取、可编辑 | 写 ---\nfrontmatter\n---\n内容 |
MemDir.load_index() + format_for_prompt() | system prompt 注入 | 读 MEMORY.md + 拼格式化文本 |
save_memory 工具 | agent 自主判断什么值得记 | 工具调用 → memdir.save() |
扩展 1:MemoryStore 的核心操作
# memory/store.py(续)— 追加到 MemoryStore 类
def create_session(self, session_id: str) -> None:
now = datetime.utcnow().isoformat()
with self._conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO sessions VALUES (?, ?, ?)",
(session_id, now, now)
)
def append_message(self, session_id: str, role: str, content) -> None:
"""Append a message. Content can be str or list (tool_use blocks)."""
now = datetime.utcnow().isoformat()
with self._conn() as conn:
conn.execute(
"INSERT INTO messages (session_id, role, content, created) "
"VALUES (?, ?, ?, ?)",
(session_id, role, json.dumps(content, ensure_ascii=False), now)
)
conn.execute(
"UPDATE sessions SET updated=? WHERE id=?", (now, session_id)
)
def load_messages(self, session_id: str) -> list[dict]:
"""Load all messages for a session, ordered by insertion."""
with self._conn() as conn:
rows = conn.execute(
"SELECT role, content FROM messages "
"WHERE session_id=? ORDER BY id",
(session_id,)
).fetchall()
return [{"role": r, "content": json.loads(c)} for r, c in rows]验证:
store = MemoryStore()
store.create_session("test-001")
store.append_message("test-001", "user", "hello")
store.append_message("test-001", "assistant", "hi there")
msgs = store.load_messages("test-001")
print(len(msgs)) # → 2
print(msgs[0]) # → {'role': 'user', 'content': 'hello'}扩展 2:MemDir 的完整记忆 CRUD
# memory/memdir.py(续)— 追加到 MemDir 类
def save(
self,
content: str,
subject: str,
mem_type: str = "user", # user / feedback / project / reference
confidence: float = 0.9,
max_chars: int = 2000, # 内容截断保护,防 context 污染
) -> str:
"""Save a memory file. Returns mem_id."""
if len(content) > max_chars:
content = content[:max_chars] + "\n...[truncated]"
mem_id = (
f"mem_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_"
f"{uuid.uuid4().hex[:6]}"
)
frontmatter = {
"id": mem_id,
"type": mem_type,
"subject": subject,
"description": subject, # CC memdir uses description for manifest
"created": datetime.utcnow().isoformat(),
"confidence": confidence,
}
mem_file = self.base / f"{mem_id}.md"
mem_file.write_text(
f"---\n{yaml.dump(frontmatter, allow_unicode=True)}---\n\n{content}",
encoding="utf-8",
)
self._update_index(mem_id, subject, mem_type)
return mem_id
def _update_index(self, mem_id: str, subject: str, mem_type: str) -> None:
line = f"| `{mem_id}.md` | {mem_type} | {subject} |\n"
if not self.index_path.exists():
header = (
"# MEMORY.md — Long-term Memory Index\n\n"
"| 文件 | 类型 | 主题 |\n"
"|------|------|------|\n"
)
self.index_path.write_text(header + line, encoding="utf-8")
else:
# Enforce MAX_ENTRYPOINT_LINES (200) — same cap as CC memdir.ts
lines = self.index_path.read_text(encoding="utf-8").splitlines()
if len(lines) < self.MAX_ENTRYPOINT_LINES:
with open(self.index_path, "a", encoding="utf-8") as f:
f.write(line)
def load_all(self) -> list[dict]:
"""Load all memory files. Skips malformed files gracefully."""
memories = []
for md_file in sorted(self.base.glob("mem_*.md")):
text = md_file.read_text(encoding="utf-8")
try:
parts = text.split("---", 2)
fm = yaml.safe_load(parts[1])
memories.append({**fm, "content": parts[2].strip()})
except Exception:
continue # corrupt file — skip, don't crash
return memories
def format_for_prompt(self) -> str:
"""Render memories as a text block for system prompt injection."""
memories = self.load_all()
if not memories:
return ""
lines = ["## 已知信息(长期记忆)\n"]
for m in memories:
tag = f"[{m.get('type','?')}]"
subj = m.get("subject", "?")
body = m.get("content", "")
lines.append(f"- {tag} **{subj}**: {body}")
return "\n".join(lines)验证:
md = MemDir("lena")
mid = md.save("Bob 偏好 Python 写后端,拒绝 Node.js", subject="programming_language")
print(mid) # → mem_20260505_143022_a3f2b1
print(md.format_for_prompt())
# → ## 已知信息(长期记忆)
# - [user] **programming_language**: Bob 偏好 Python 写后端...扩展 3:save_memory 工具 — agent 自主写入长期记忆
这是让 Lena 真正"主动记住"的关键。工具让 LLM 自己判断什么值得保存:
# core/tools.py — 追加到工具列表
SAVE_MEMORY_TOOL = {
"name": "save_memory",
"description": (
"把重要信息保存到长期记忆,跨会话可用。"
"用于:用户表达了明确偏好、重要事实、需要跨会话记住的内容。"
"不要保存:代码片段、临时任务状态、当前会话的上下文。"
),
"input_schema": {
"type": "object",
"properties": {
"subject": {"type": "string", "description": "记忆主题,如 'programming_language'"},
"content": {"type": "string", "description": "要记住的内容,简洁明确"},
"mem_type": {
"type": "string",
"enum": ["user", "feedback", "project", "reference"],
"description": "类型:user=用户偏好, feedback=工作指导, project=项目事实, reference=外部资源指针",
},
},
"required": ["subject", "content"],
},
}Convention:记忆的四种类型来自 Claude Code memdir/memoryTypes.ts:14:user(用户画像)/ feedback(工作指导)/ project(项目事实)/ reference(外部资源指针)。本书直接使用这个分类,因为它经过真实生产验证。
扩展 4:LenaAgent 集成两层记忆
# core/agent.py
import uuid
from datetime import datetime
from memory.store import MemoryStore
from memory.memdir import MemDir
from core.llm import call_llm
from core.tools import SAVE_MEMORY_TOOL, execute_tool
class LenaAgent:
"""Lena v0.8 — cross-session memory via SQLite + file-system MemDir."""
def __init__(self, session_id: str | None = None):
self.store = MemoryStore()
self.memdir = MemDir(project_slug="lena")
self.session_id = session_id or f"sess_{uuid.uuid4().hex[:8]}"
self.store.create_session(self.session_id)
def _build_system_prompt(self) -> str:
base = "你是 Lena,一个通用 AI 助手。你能使用工具完成任务。"
# CLAUDE.md-like 注入:每次会话启动时从文件系统读取长期记忆
long_term = self.memdir.format_for_prompt()
if long_term:
# Recitation:把关键记忆写在末尾,对抗 lost-in-the-middle 漂移
return f"{base}\n\n{long_term}\n\n<!-- 记忆重申 -->\n{long_term}"
return base
def chat(self, user_input: str) -> str:
# 1. 加载会话历史(短期记忆)
messages = self.store.load_messages(self.session_id)
messages.append({"role": "user", "content": user_input})
# 2. LLM 调用(携带 save_memory 工具)
response = call_llm(
messages=messages,
system=self._build_system_prompt(),
tools=[SAVE_MEMORY_TOOL],
)
# 3. 处理工具调用(agent 可能决定保存记忆)
final_text = self._handle_tool_use(response, messages)
# 4. 持久化本轮对话
self.store.append_message(self.session_id, "user", user_input)
self.store.append_message(self.session_id, "assistant", final_text)
return final_text
def _handle_tool_use(self, response, messages: list) -> str:
"""If LLM called save_memory, execute it and get final response."""
if response.get("stop_reason") != "tool_use":
return response["content"][0]["text"]
tool_results = []
for block in response["content"]:
if block["type"] == "tool_use":
result = execute_tool(block["name"], block["input"], self.memdir)
tool_results.append({
"type": "tool_result",
"tool_use_id": block["id"],
"content": result,
})
# 追加工具结果,再次调用 LLM 拿到最终回复
messages.append({"role": "assistant", "content": response["content"]})
messages.append({"role": "user", "content": tool_results})
final = call_llm(messages=messages, system=self._build_system_prompt())
return final["content"][0]["text"]每次调用 chat() 后打印一次中间状态:
agent = LenaAgent()
resp = agent.chat("我叫 Bob,我写 Python。")
print("[memories saved]:", [f.name for f in agent.memdir.base.glob("mem_*.md")])
# → [memories saved]: ['mem_20260505_143022_a3f2b1.md', 'mem_20260505_143023_c9d8e7.md']Beat 5 补充:Recitation 双写技术
_build_system_prompt() 里有一个看起来奇怪的设计:记忆块被写了两次(开头一次,<!-- 记忆重申 --> 后再写一次)。
这来自 Manus 团队 2025 年的 Context Engineering 实践。原因是lost-in-the-middle 现象(Liu et al. 2023, "Lost in the Middle: How Language Models Use Long Contexts"——不需要读全文,只需要知道核心结论:LLM 对 context 两端的注意力显著高于中间,长 context 下中间信息被命中率低 20-30%)。
对 agent 记忆的影响:如果偏好只放在 system prompt 开头,经过 1000 tokens 的对话历史之后,模型生成时对"用户偏好 Python"的注意力会衰减。Manus 的解法是在 context 末尾重申关键指令。对 Lena,我们在 system prompt 末尾再写一次记忆块,让偏好在对话末端也有强烈信号。
system_prompt 结构(Recitation 双写):
┌─────────────────────────────────────────┐
│ 角色定义:你是 Lena... │ ← 开头
├─────────────────────────────────────────┤
│ 长期记忆(第一次) │ ← 开头读到
│ - [user] programming_language: Python │
│ - [user] user_name: Bob │
├─────────────────────────────────────────┤
│ <!-- 记忆重申 --> │ ← 末尾重申
│ 长期记忆(第二次) │ ← 生成前看到
│ - [user] programming_language: Python │
│ - [user] user_name: Bob │
└─────────────────────────────────────────┘代价:system prompt 增大约一倍。记忆量 < 500 tokens 时代价可接受(实测两条偏好 ≈ 50 tokens,双写 ≈ 100 tokens);记忆量很大时,考虑末尾只写一行摘要。
Beat 6 — 运行验证
下面把所有模块组合起来,运行跨会话记忆演示:
# main.py
import sys
from core.agent import LenaAgent
def main():
session_id = sys.argv[1] if len(sys.argv) > 1 else None
agent = LenaAgent(session_id=session_id)
print(f"[Session: {agent.session_id}]")
print(f"[Memories loaded: {len(agent.memdir.load_all())}]")
print()
while True:
try:
user = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nBye.")
break
if not user:
continue
reply = agent.chat(user)
print(f"Lena: {reply}\n")
if __name__ == "__main__":
main()安装依赖:
pip install anthropic pyyaml运行并观察跨会话记忆(以下是真实运行输出,使用 Claude Sonnet 4.6 via Bedrock):
$ python main.py
[Session: sess-live-001]
[Memories loaded: 0]
You: 我叫 Demo-User,我偏好用 Python 写所有代码。
Lena: 你好,Demo-User!😊 我已经记住了你的信息:
- 用户名:Demo-User
- 编程偏好:Python
以后我会记得用 Python 来为你编写代码。有什么我可以帮你的吗?
$ python main.py ← 新进程,新 session
[Session: sess-live-002]
[Memories loaded: 2] ← 读到了上次保存的记忆
- [user] programming_language: 用户偏好用 Python 写所有代码
- [user] username: 用户名叫 Demo-User
You: 帮我写个 hello world。
Lena: 根据你的偏好,以下是 Python 版本的 Hello World:
```python
print("Hello, World!")
```
运行方式:python hello_world.py
输出:Hello, World!对比无记忆版本(空 project slug,无任何历史记忆):
You: 帮我写个 hello world。
Lena: 你想用哪种编程语言来写 Hello World?比如 Python、JavaScript、
Java、C++ 等,请告诉我!差距是明确的:有记忆时直接给 Python,无记忆时反问用什么语言。这是 1 条 save_memory 工具调用 + 2 个 .md 文件带来的能力差距。
常见报错:
yaml.scanner.ScannerError:记忆文件 frontmatter 格式损坏。load_all()里的except Exception: continue已跳过,但建议手动删掉该.md文件。anthropic.APIError: 401:检查ANTHROPIC_API_KEY环境变量。[Memories loaded: 0]第二次仍为 0:检查~/.lena/projects/lena/memory/目录是否存在,以及第一次运行 Lena 是否真的调用了save_memory工具(在main.py加print(agent.memdir.load_all())debug)。
预期数字:首次运行保存 2 条记忆(user_name + programming_language),每条 .md 文件约 200 bytes,MEMORY.md 索引约 300 bytes,format_for_prompt() 返回约 150 字符。整个记忆系统的磁盘占用 < 10KB,system prompt 注入 < 500 tokens。
记忆解决了"失忆"问题。但还有一个隐患没有处理:随着对话变长,context window 的压力持续累积——50 轮对话后,messages 数组已经几千 tokens,还在增长。当 autocompact 触发,会话历史被 LLM 压缩成摘要,而这个摘要是 LLM 生成的——可以幻觉,可以改变已做的决定。
Compaction 后的正确姿势:重要决策在 compaction 发生前就写进 save_memory。如果 compaction 摘要把"已否决的方案 A"写成了"待评估方案 A",你有 MEMORY.md 里的 verbatim 记录可以覆盖它。Lena 的防御策略:每次 compaction 后,第一轮对话先 load_index() 对照 MEMORY.md,发现摘要与记忆文件冲突时以记忆文件为准。
如何让 Lena 在不失忆关键内容的前提下,优雅地压缩上下文,是下一章(Ch 9 → Ch 10 Context Engineering)的主题。
Beat 7 — Design Note × 2
Why Not Use a Vector Database for Agent Memory?
向量数据库(Chroma、Qdrant、pgvector)是个合理的第一直觉。它们的设计目标是:给定一个查询向量,找出语义最相近的 K 条记录。
这对 RAG(外部知识库检索)非常合适——当知识库有 10 万份文档,你需要语义相似度来找相关段落。但对 agent 自己的记忆,这个方案引入了三个不必要的成本:
成本一:嵌入依赖。每次写入记忆,需要调用嵌入模型(本地 or API),增加延迟和费用。每次读取记忆,需要把查询也向量化,再做相似度计算。
成本二:调参地狱。相似度阈值设多少?返回 Top-K 中 K 取几?阈值太高漏掉相关记忆,太低引入噪声。这些参数没有"正确答案",需要反复实验。
成本三:可检查性丢失。文件系统的记忆你可以用文本编辑器直接读写,向量数据库的记忆藏在索引里,出错时很难调试。
个人 agent 的长期记忆通常在 100-1000 条。对 1000 个文件全量读取,Python 约 50ms,远低于任何 LLM API 调用的延迟。文件系统遍历完全够用。
推荐方案:文件系统 + MEMORY.md 索引,100-1000 条记忆;向量库,10,000 条以上记忆 + 需要语义相似度的场景(这时已经是 Ch 9 RAG 的职责范围)。
Why Is This Chapter NOT About RAG?
本章和 Ch 9 处理的是相邻但截然不同的两个问题,必须明确区分。
本章(Ch 8):agent 自己的记忆。它回答的问题是"我做过什么"、"这个用户是谁"、"上次讨论的结论是什么"。写入者是 agent 自身(通过 save_memory 工具)。内容是关于 agent 和用户的事实。检索方式是全量加载或索引扫描。
Ch 9(RAG):读外部知识库。它回答的问题是"某份文档里说了什么"、"这个技术文档的规范是什么"。写入者是用户(上传文档)。内容是外部知识。检索方式是向量相似度搜索。
一个常见的误解是把两者混为一谈,用同一套向量数据库方案解决"agent 应该记住用户偏好"的问题。这会导致:agent 偶尔"记不住"明显应该记住的偏好(相似度阈值没命中),或者用一堆与当前任务无关的偏好塞满 context(Top-K 抓取过多)。
区分标准很简单:这条信息是 agent 经历产生的(记忆),还是用户提供的外部文档(知识库)?前者用本章的文件系统方案,后者用 Ch 9 的 pgvector 方案。
Lena 现在会记得自己做过什么。但如果用户问"昨天那篇关于 MCP 的论文怎么说"——记忆解决不了这个问题。她需要能查阅外部知识库。下一章,我们给 Lena 装上 RAG:向量检索 + 外部文档 + embedding 距离。她第一次学会"去读"而不只是"回想"。
Lena 在本章学会了"记住昨天"——跨会话 SQLite 短期历史加文件系统长期偏好,让她不再每次对话都失忆。
但记住了用户还不够。真实世界里有大量文档、代码库、报告,它们不可能全部塞进 context window——LLM 的记忆是有限的,而知识是无限的。当用户问"上周那份合同里的违约条款是什么",Lena 需要的不是更大的记忆,而是一套能把问题和文档精准对齐的检索机制。第 9 章,我们给 Lena 装上 RAG——向量检索让她能在海量文档里找到与当前问题最相关的那一段。
Challenges
C8.1 — 记忆过期策略:当前实现里,记忆文件永不过期。设计一个 expire_memories(days: int) 函数,删除 created 字段超过 N 天的 project 类型记忆(项目事实随时间失效),但保留 user 和 feedback 类型(用户偏好通常稳定)。
C8.2 — 记忆冲突检测:如果用户先说"我偏好 Python",后来说"我改用 Go 了",agent 会保存两条相互矛盾的 programming_language 记忆。修改 MemDir.save() 让它在写入前检查同 subject 的已有记忆,询问是覆盖还是追加。
C8.3 — Compaction 安全阀:在 LenaAgent.chat() 里加一个简单的 compaction 检测:当 store.load_messages(session_id) 返回超过 50 条消息时,自动调用 memdir.save() 把"当前会话的关键决定"提取并保存,然后提示用户 session 已接近 compaction 临界点。
参考
- Claude Code
source/src/memdir/memdir.ts:34-35(ENTRYPOINT_NAME / MAX_ENTRYPOINT_LINES) - Claude Code
source/src/memdir/memoryTypes.ts:14-21(四种记忆类型分类) - Claude Code
source/src/memdir/memoryScan.ts:22(MAX_MEMORY_FILES = 200) - nanoClaw
nanoclaw/memory/store.py(SQLite memory 原型) - Manus Context Engineering for AI Agents,2025("文件系统是无限外部记忆"铁律)
本章媒体资源
💡 建议:用播客版配合文字版一起学习,通勤/散步时收听效果极佳
