Skip to content

第 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 上的方法。

Memory 读写循环


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 有多不堪用?运行一下就知道:

python
# 无记忆版 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/line

buildMemoryPrompt() 在会话启动时读取 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,各负责一层,各自独立可运行:

python
# 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" 验证能看到 sessionsmessages 两张表。

python
# 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_messagesagent 每轮写入、启动时回放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 的核心操作

python
# 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]

验证:

python
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

python
# 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)

验证:

python
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 自己判断什么值得保存:

python
# 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:14user(用户画像)/ feedback(工作指导)/ project(项目事实)/ reference(外部资源指针)。本书直接使用这个分类,因为它经过真实生产验证。

扩展 4:LenaAgent 集成两层记忆

python
# 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() 后打印一次中间状态:

python
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 — 运行验证

下面把所有模块组合起来,运行跨会话记忆演示:

python
# 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()

安装依赖:

bash
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.pyprint(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 类型记忆(项目事实随时间失效),但保留 userfeedback 类型(用户偏好通常稳定)。

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("文件系统是无限外部记忆"铁律)

本章媒体资源

🎙️ 播客版 · 边读边听通勤可听

💡 建议:用播客版配合文字版一起学习,通勤/散步时收听效果极佳

构建通用 Agent Runtime,从这里开始