犀牛鸟 2026 研究笔记 estelledc.github.io

第十二章:Memory 设计对比与演进

本章目标:理解 Agent Memory 的四种分类,对比 tRPC-Agent-Go、LangGraph、CrewAI、Agno 以及商业 Agent 的记忆系统设计,掌握读写路径的设计取舍。


12.1 Agent 为什么需要记忆?

先想一个日常场景:你去一家新餐厅吃饭。第一次去,服务员问你”有什么忌口吗?”你说”我不吃香菜”。饭菜上来,没问题。第二次去(同一个服务员),他又问”有什么忌口吗?”你有点烦,但还是说了。第三次去,他还问。你决定换一家餐厅。

一个好的服务员会记住你的偏好。他不需要每次都问,因为他记住了。LLM 默认就是那个”每次都问”的服务员——每次对话都是一张白纸。Agent Memory 系统就是让 LLM 从”金鱼记忆”变成”有经验的服务员”的关键基础设施。


12.2 Agent Memory 的分类学

12.2.1 工作记忆(Working Memory)

当前 session/context 内的短期信息。在一次对话中 LLM 能”看到”的所有内容——system prompt、对话历史、工具调用结果——就是它的工作记忆。

人类类比:你正在做一道数学题时,脑子里同时保持着”被除数是 144”、”除数是 12”这些临时信息。做完这道题,这些信息就释放了。

技术实现就是 LLM 的 context window。容量有限,session 内有效,session 结束就消失。不需要额外存储基础设施——LLM 自带。最重要的是:它是所有其他类型记忆的”入口”——其他记忆最终都要注入到工作记忆中才能被 LLM 感知。

12.2.2 情节记忆(Episodic Memory)

完整的对话历史,按时间顺序记录每一轮交互的原始内容。人类类比:你记得”上周四下午和李明讨论了 bug #1234,他说是线程安全问题,我们决定加锁”。

技术实现是把每轮对话消息原样存储到数据库或文件中。不做提炼,就是原始记录。为什么要保留原始记录?因为提炼总会丢失信息。保留完整情节,就像保留案件的完整卷宗——以后需要回溯时有据可查。

12.2.3 语义记忆(Semantic Memory / Facts)

从对话中提炼出的知识点、事实和偏好。不再绑定具体的时间和上下文,而是抽象化的信息。人类类比:你不记得什么时候学的”水在 100 度沸腾”,但你确定知道这个事实。

技术实现是从对话历史中提取 key-value 形式的知识,如”用户偏好:中文回答”、”项目语言:Go”。通常用 LLM 做提取,然后存入向量数据库或结构化存储。

情节记忆 vs 语义记忆的关系:

情节记忆(原始对话):
  用户: "别用 MySQL 了,咱们换 PostgreSQL 吧,因为需要 JSONB 支持"
  助手: "好的,我来改配置..."

  ↓ 提炼

语义记忆(提炼后的知识):
  - 数据库选择: PostgreSQL(原因:需要 JSONB 支持)

12.2.4 程序记忆(Procedural Memory)

从过去经验中学到的策略、模式和操作流程。不是”知道什么”,而是”知道怎么做”。人类类比:骑自行车——你说不清楚怎么保持平衡,但身体记住了。

这是最不成熟的一类 Agent Memory。目前的实现方式通常是记录成功的任务执行策略,下次遇到类似任务时作为参考。为什么最不成熟?因为策略的抽象和泛化远比知识的存取困难。


12.3 tRPC-Agent-Go 的 Memory 设计

tRPC-Agent-Go 的 Memory 设计围绕两个核心概念:Fact(语义记忆)和 Episode(情节记忆),外加 9 种 Session 后端来持久化工作记忆。

12.3.1 Fact Memory

Fact 是从对话中提取的结构化知识点:

type Fact struct {
    ID         string    `json:"id"`
    Content    string    `json:"content"`
    Source     string    `json:"source"`
    Category   string    `json:"category"`
    CreatedAt  time.Time `json:"created_at"`
    UsageCount int       `json:"usage_count"`
}

典型存储方式是向量数据库(如 Milvus),支持语义相似度检索。写入时机通常在对话结束时,用独立的 LLM 调用来提取。

注意 UsageCount 字段——这不只是一个统计指标,它直接影响记忆的存活。每次 Fact 被检索到并注入 context 时,UsageCount 加 1。如果一条 Fact 长期不被使用(没有被任何对话检索命中),它的优先级会逐渐降低,最终可能被遗忘策略清理掉。这个设计模拟了人类记忆的”越用越记得牢”现象——心理学上叫做”检索练习效应”(Retrieval Practice Effect)。

Fact 提取的 Prompt 设计也值得关注。一个好的提取 prompt 需要做三件事:识别什么是值得记住的(过滤噪音)、用规范格式表达(便于存储和检索)、标注类别(便于分类查询)。

// Fact 提取 prompt 的典型设计
const factExtractionPrompt = `请从以下对话中提取值得长期记住的信息。
只提取以下类型的信息:
1. 用户偏好(语言、风格、工具选择)
2. 项目决策(技术栈、架构选择、规范约定)
3. 关键事实(项目结构、团队信息、环境配置)

不要提取:临时性信息、具体的代码片段、一次性的问题

每条信息用一行表示,格式为:[类别] 内容

对话内容:
%s`

这个 prompt 的关键在于负面指令——”不要提取”的部分比”要提取”的部分更重要。如果不加限制,LLM 会把对话中的每一句话都当作”值得记住的信息”,导致 Facts 膨胀到不可用。

12.3.2 Episode Memory

Episode 是完整的对话记录,按 session 组织:

type Episode struct {
    ID        string    `json:"id"`
    SessionID string    `json:"session_id"`
    Messages  []Message `json:"messages"`
    Summary   string    `json:"summary"`
    CreatedAt time.Time `json:"created_at"`
}

12.3.3 Memory 接口

type Memory interface {
    StoreFact(ctx context.Context, fact *Fact) error
    QueryFacts(ctx context.Context, query string, limit int) ([]*Fact, error)
    UpdateFact(ctx context.Context, fact *Fact) error
    DeleteFact(ctx context.Context, id string) error
    StoreEpisode(ctx context.Context, episode *Episode) error
    QueryEpisodes(ctx context.Context, query string, limit int) ([]*Episode, error)
    Close() error
}

接口设计清晰——读写分开,Fact 和 Episode 分开。使用者不需要关心底层存储是 Redis 还是 PostgreSQL。

12.3.4 九种 Session 后端

支持 Redis、PostgreSQL、MySQL、MongoDB、SQLite、DynamoDB、Firestore、Memory(内存)、Custom(自定义)。不同部署环境有不同的基础设施约束,所以需要多种后端。

// 不同后端的选型参考
// Redis —— 适合需要高吞吐、低延迟的在线 Agent(如客服机器人)
session := memory.NewRedisSession(redis.Options{Addr: "localhost:6379"})

// SQLite —— 适合单机部署、个人项目、CLI 工具
session := memory.NewSQLiteSession("./agent_memory.db")

// PostgreSQL —— 适合生产环境,支持 JSONB 索引加速检索
session := memory.NewPostgreSQLSession(pgxConfig)

// Memory —— 适合开发测试,重启就没了
session := memory.NewMemorySession()

选型的核心考量是部署环境已有什么基础设施。大厂内部通常有现成的 Redis/MySQL 集群,直接接入最方便。小团队用 SQLite 最轻量。云原生环境用 DynamoDB (AWS) 或 Firestore (GCP)。Custom 接口允许你对接任何自定义存储——比如公司内部的 KV 存储服务。


12.4 LangGraph 的 Memory 设计

LangGraph 走了一条很不同的路——不关心”提炼知识”,而是关心完整的状态快照。

12.4.1 Checkpoint:完整状态快照

LangGraph 的核心记忆机制是 Checkpoint——把 Agent 执行图的完整状态序列化保存:

from langgraph.checkpoint.memory import MemorySaver

graph = graph_builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "user-123"}}
result = graph.invoke({"messages": [HumanMessage("你好")]}, config)

# 下次调用,同一个 thread_id 恢复之前的状态
result2 = graph.invoke({"messages": [HumanMessage("接着聊")]}, config)

Checkpoint 存储的不只是对话历史,而是图中所有 Channel 的完整值。这就像游戏的”存档”——连背包物品、NPC 对话进度、地图探索状态都记住了。

具体来说,如果你的 State 定义包含 messages、user_profile、task_status、intermediate_results 等多个字段,Checkpoint 会把它们全部序列化保存。这意味着你可以从任意一个时间点恢复 Agent 的完整状态——不只是”上次聊到哪了”,而是”上次处理到第几步、中间结果是什么、用户是什么身份”。

# LangGraph 的 State 定义——Checkpoint 会保存所有字段
from typing import TypedDict, Annotated
from langgraph.graph import add_messages

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]  # 对话历史
    user_profile: dict          # 用户画像
    task_status: str            # 当前任务状态
    intermediate_results: list  # 中间处理结果
    retry_count: int            # 重试计数

Checkpoint 的一个重要特性是版本化——它不只保存最新状态,还保存历史版本。你可以回滚到任意一个 Checkpoint,这在调试复杂 Agent 行为时非常有用。

12.4.2 无内建的跨 Session 语义记忆

LangGraph 没有内建的语义记忆。Checkpoint 解决的是”同一个 thread 内的状态持久化”。跨 session 的知识需要用外部的 LangGraph Store(BaseStore):

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
store.put(("user", "user-123"), "preferences", {"language": "zh"})

def my_node(state, config, store):
    user_id = config["configurable"]["user_id"]
    prefs = store.get(("user", user_id), "preferences")

但这只是简单的 key-value 存储,没有语义检索能力。

12.4.3 设计哲学

LangGraph 的 Memory 哲学是:关心”上次到哪了”,不太关心”从过去学到了什么”。这和它作为”状态图引擎”的定位一致——核心价值在于精确控制执行流。把复杂的记忆管理留给开发者,而不是内建一个可能不合用的方案。


12.5 CrewAI 的 Memory 设计

CrewAI 把记忆作为 Crew 级别的能力,一个开关打开就有。

12.5.1 memory=True

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, writing_task],
    memory=True,
)

12.5.2 三种记忆层

短期记忆:当前 Crew 执行范围内的信息。前面 Agent 的输出作为后面 Agent 的上下文。

长期记忆:跨 Crew 执行的持久化信息,默认使用向量数据库。多次运行同一个 Crew 时,之前的经验通过相似度检索注入。

实体记忆:记录对话中出现的实体及其关系。这是 CrewAI 比较独特的设计——不只记住”信息”,还记住”实体间的关系”。

12.5.3 Agent Knowledge Sources

CrewAI 还有 knowledge_sources 机制——RAG 式知识注入:

from crewai.knowledge.source.pdf_knowledge_source import PDFKnowledgeSource

agent = Agent(
    role="API 专家",
    knowledge_sources=[PDFKnowledgeSource(file_paths=["api_ref.pdf"])],
)

12.5.4 权衡

优点:上手简单、三层分离清晰、自带实体识别。缺点:灵活性有限——不能细粒度控制什么该记、记忆何时过期、用什么存储后端。类比:CrewAI 的记忆像智能手机相册(自动分类、一键开启),tRPC-Agent-Go 更像专业相机的 RAW 文件管理(什么都要自己设置,但灵活度极高)。


12.6 Agno 的 LearningMachine

Agno 在记忆设计上有一个有趣的特性:让 Agent 自动从成功经验中学习。

from agno.agent import Agent
from agno.memory.db.sqlite import SqliteMemoryDb

agent = Agent(
    name="coding-assistant",
    learning=True,
    learning_db=SqliteMemoryDb(table_name="learning", db_file="learning.db"),
)

开启 learning=True 后,Agent 成功完成任务时会自动把策略存入学习数据库,下次遇到类似任务时检索作为参考。这是程序记忆的实现——记住”怎么做”而非”知道什么”。

学习的核心流程:

任务执行完成
  │
  ├─→ 判断:这次执行成功吗?
  │     ├─ 失败 → 不学习(避免记住坏经验)
  │     └─ 成功 → 继续
  │
  ├─→ 提取:LLM 自我反思,提取关键步骤和决策
  │
  ├─→ 存储:策略向量化,存入学习数据库
  │
  └─→ 下次相似任务时,检索并注入作为参考

日常类比:一个厨师学做新菜。第一次做红烧肉,他尝试了大火快炖——失败了,肉很硬。第二次改用小火慢炖——成功了,入口即化。好的学习系统会记住”小火慢炖 2 小时”这个成功策略,下次做红烧肉时自动参考。但如果厨师误以为大火快炖也成功了(幻觉),他就会记住一个错误策略,下次还是做出硬邦邦的肉。

最大的问题正是“成功”的判断标准。没有 Ralph Loop 这样的验证机制时,Agent 怎么知道自己”成功了”?如果 Agent 幻觉性地认为自己成功了(第十一章讨论的问题),那它学到的就是”错误的经验”——这比没有记忆更危险,因为它不仅犯错,还会”坚信”这个错误做法是对的。

另一个问题是策略的泛化性。一次成功的代码生成策略(”先写测试再写实现”)可能在大多数场景都适用,但在探索性原型开发中反而是负担。简单的相似度检索无法判断策略的适用边界——它只知道”这个任务和上次那个任务很像”,但不知道”像在哪里、不像在哪里”。

12.6.5 专用 Memory 框架对比

前面几节讨论的 tRPC-Agent-Go、LangGraph、CrewAI、Agno 都是通用 Agent 框架,Memory 只是其中一个模块。但业界还有一类项目是专门为 Agent Memory 设计的独立框架——它们不负责 Agent 的推理和工具调用,只专注于”记忆”这件事。你可以把它们理解为 Agent 世界的”外挂硬盘”或”第二大脑”。

日常类比:通用 Agent 框架像一台笔记本电脑——CPU、内存、硬盘、显卡全都有,但每个部分都不是最强的。专用 Memory 框架则像外接的 NAS(网络存储)——它不能运行程序,但在”存数据、找数据”这件事上比笔记本自带硬盘强得多。你可以把 NAS 接到任何电脑上用。

下面对比三个最有代表性的专用 Memory 框架:Mem0、Letta(前 MemGPT)和 Graphiti。

Mem0:统一 Memory 层

Mem0 的定位是”给任何 AI 应用加上记忆层”。它提供了极简的 5 个核心 API——add、get、search、update、delete——几乎和操作数据库一样简单。

from mem0 import Memory

m = Memory()

# 添加记忆——Mem0 自动从自然语言中提取结构化信息
result = m.add(
    "我在美团做后端开发,主要用 Go 语言,团队用 tRPC 框架",
    user_id="jason",
    metadata={"source": "onboarding"}
)
# Mem0 自动提取出:
# - 公司:美团
# - 角色:后端开发
# - 语言:Go
# - 框架:tRPC

# 搜索记忆——语义检索,不需要精确关键词
results = m.search("这个用户用什么技术栈?", user_id="jason")

# 获取某个用户的所有记忆
all_memories = m.get_all(user_id="jason")

# 更新——Mem0 会自动处理冲突(新信息覆盖旧信息)
m.add("我们最近从 MySQL 迁移到了 PostgreSQL", user_id="jason")
# 自动把之前的"数据库:MySQL"更新为"数据库:PostgreSQL"

Mem0 的核心设计思想是自动提取 + 自动去重 + 自动更新。开发者不需要自己写 Fact 提取 prompt(12.3.1 节讨论过的),Mem0 内部用 LLM 完成这些工作。当新信息和旧信息冲突时(比如数据库从 MySQL 换成了 PostgreSQL),Mem0 会自动检测冲突并更新旧记忆——这就是前面 12.9.3 节提到的”智能遗忘”的一种实现。

Mem0 的存储层同时使用向量数据库 + 图数据库。向量数据库负责语义检索(”找到和这个问题最相关的记忆”),图数据库负责关系推理(”张三负责的项目用了什么技术”)。这种双引擎设计让它既能处理模糊查询(”那个数据库的事”),也能处理关系查询(”和 Redis 相关的所有配置”)。

和 Agent 框架集成时,Mem0 作为外部服务嵌入到 Agent 的读写路径中:

# 将 Mem0 集成到任意 Agent 框架
class AgentWithMem0:
    def __init__(self):
        self.memory = Memory()
        self.llm = ChatOpenAI()

    def chat(self, user_id: str, message: str) -> str:
        # 读路径:检索相关记忆
        memories = self.memory.search(message, user_id=user_id)
        memory_ctx = "\n".join([m["memory"] for m in memories])

        # 注入到 prompt
        response = self.llm.invoke(
            f"用户已知信息:\n{memory_ctx}\n\n用户消息:{message}"
        )

        # 写路径:对话后自动存储
        self.memory.add(
            f"用户:{message}\n助手:{response}",
            user_id=user_id
        )
        return response

Mem0 的优势是上手简单且与框架无关——不管你用 LangGraph、CrewAI 还是自己写的 Agent,都可以接入。劣势是它的”自动提取”是一个黑盒——你很难控制”什么该记、什么不该记”的边界。对于需要精细控制的场景(比如合规要求不能记住用户的某些信息),这种全自动策略可能带来麻烦。

Letta(前 MemGPT):操作系统式 Memory 管理

Letta 的前身是 2023 年那篇著名的 MemGPT 论文。它的核心思想来自操作系统的虚拟内存机制——既然 LLM 的 context window 有限(就像物理内存有限),那就像操作系统管理内存一样来管理 context。

日常类比:想象你在一张很小的书桌上写论文。桌面(context window)只能同时放 3 本参考书,但你的书架(外部存储)上有 100 本。你需要一个策略来决定”什么时候把书架上的书拿到桌面上”和”桌面上的书什么时候放回书架”。Letta 就是这个”图书管理员”。

Letta 把 Memory 分为两层:

┌─────────────────────────────────┐
│  main_context(主上下文)        │  ← 受限窗口,Agent 能直接"看到"的
│  - system prompt                │
│  - 核心记忆(用户画像/偏好)     │
│  - 最近几条对话                  │
│  容量限制:context window 大小   │
├─────────────────────────────────┤
│  archival_memory(归档存储)      │  ← 无限容量,Agent 需要主动"翻阅"
│  - 所有历史对话                  │
│  - 所有提取的知识                │
│  - 外部文档                     │
│  容量限制:无(磁盘/数据库)      │
└─────────────────────────────────┘

最关键的设计是:Agent 自己决定何时读写记忆。Letta 给 Agent 提供了特殊的”记忆工具”——core_memory_appendcore_memory_replacearchival_memory_insertarchival_memory_search。Agent 在对话过程中可以主动调用这些工具来管理自己的记忆,就像人会主动决定”这个信息很重要,我要记下来”。

# Letta Agent 的内部工具调用示例(概念性代码)

# "用户说他叫张三,这很重要,我要记到核心记忆里"
core_memory_replace(
    section="user_info",
    old_content="姓名:未知",
    new_content="姓名:张三"
)

# "这段对话包含项目架构讨论,以后可能用到"
archival_memory_insert(
    content="2025-01-15:讨论了微服务拆分方案,决定按业务域拆分..."
)

# "用户问到了之前讨论过的数据库选型,我需要翻阅历史"
results = archival_memory_search(query="数据库选型讨论", count=5)

这种”Agent 自主管理记忆”的设计和其他框架形成了鲜明对比。在 tRPC-Agent-Go 或 CrewAI 中,记忆的读写是由框架自动完成的。在 Letta 中,Agent 自己就是记忆的管理者——它可以主动决定”现在我需要回忆一下上周的讨论”或者”这个信息很重要,我要存起来”。

这带来了两个效果:一方面,Agent 可以做出比自动化策略更”聪明”的记忆决策(因为它理解上下文的语义)。另一方面,Agent 可能会”忘记去记忆”——如果 LLM 没有判断出某条信息的重要性,它就不会调用记忆工具,这条信息就会随着 context 截断而丢失。

Letta 适合需要长时间运行的有状态 Agent——比如个人助手、游戏 NPC、虚拟伴侣。弱点是系统复杂度高,且记忆质量严重依赖 LLM 的”判断力”。

Graphiti:时序知识图谱

Graphiti 解决的是一个前面反复提到但没有很好解决的问题:记忆的时效性。在扁平的 Facts 列表中,”数据库用 MySQL”和”数据库用 PostgreSQL”可能同时存在,检索时无法判断哪个是最新的。Graphiti 的方案是给每条知识加上时间戳,构建带时序的知识图谱。

日常类比:传统的 Facts 列表像一本无日期的笔记本——你翻到一页写着”小明的女朋友是小红”,但不知道这是今天的信息还是三年前的。Graphiti 像一本日记——”2024-03-15:小明和小红开始交往”、”2025-01-10:小明和小红分手了”。有了时间线,你就知道最新状态是什么。

# Graphiti 的核心概念:带时间戳的三元组(概念性代码)
# 每条知识都是 (实体A, 关系, 实体B, 时间范围)

graph.add_episode(
    name="数据库选型讨论",
    episode_body="团队决定从 MySQL 迁移到 PostgreSQL",
    source_description="团队会议纪要",
    reference_time=datetime(2025, 3, 15)
)
# Graphiti 自动提取三元组:
# (项目, 使用数据库, MySQL, valid_from=2024-01, valid_to=2025-03-15)
# (项目, 使用数据库, PostgreSQL, valid_from=2025-03-15, valid_to=None)

# 查询时,Graphiti 自动返回当前有效的知识
results = graph.search("项目用什么数据库?")
# 返回:PostgreSQL(因为 MySQL 的 valid_to 已过期)

Graphiti 使用 Neo4j 作为底层图数据库,配合 LLM 做三元组提取。它的独特价值在于解决了三个问题。

第一是记忆过时:当新信息和旧信息冲突时,自动标记旧信息的失效时间,而不是简单删除。保留历史记录的好处是支持”回溯”——”上个月我们用的什么数据库来着?”这种问题只有保留了时间线才能回答。

第二是关系推理:知识图谱天然支持多跳推理。”张三负责的项目使用的数据库的版本是多少?”需要沿着 张三→项目→数据库→版本 的关系链查找,扁平 Facts 做不到。

第三是增量更新:每次新对话只需要添加新的 episode,Graphiti 自动完成三元组提取、冲突检测和时间戳更新。不需要像 Claude Code 那样定期全量整理。

Graphiti 的劣势是基础设施要求高——需要运行 Neo4j 实例,这比 SQLite 或 Markdown 文件重得多。而且知识图谱的质量完全取决于 LLM 的三元组提取能力——如果提取错了关系或遗漏了实体,图谱就会有”洞”或”错路”。

三个框架的对比总结

维度 Mem0 Letta Graphiti
核心思想 统一记忆 API OS 式内存管理 时序知识图谱
记忆管理者 框架自动 Agent 自主 框架自动
存储后端 向量库 + 图库 PostgreSQL Neo4j
冲突处理 自动检测更新 Agent 手动 replace 时间戳失效
时序感知
接入成本 低(5 个 API) 高(需改造 Agent) 中(需 Neo4j)
适合场景 通用,快速集成 长对话,有状态 Agent 知识密集,关系复杂

选择建议:如果你只是想给现有 Agent 快速加上记忆能力,选 Mem0——API 最简单、集成最快。如果你要做长时间运行的个人助手类产品,选 Letta——它的 OS 式设计最适合管理大量累积的历史信息。如果你的应用场景涉及复杂的实体关系且信息会频繁更新(比如企业知识管理、CRM),选 Graphiti——时序知识图谱是处理”信息过时”问题的最佳方案。


12.7 商业 Agent 的 Memory 设计

12.7.1 Claude Code:三文件两阶段异步 Pipeline

Claude Code 的记忆系统是目前商业 Agent 中最精巧的设计之一。

三个文件:CLAUDE.md(项目级永久知识)、raw_memories.md(原始记忆条目)、MEMORY.md(整理后的记忆)。

两阶段 Pipeline:

阶段 1(实时):用户说了重要的事 → 追加到 raw_memories.md(快速写入)
阶段 2(异步):定期把 raw_memories.md 整理到 MEMORY.md(LLM 去重合并)

为什么分两阶段?实时提炼太贵——每次用户说一句话就调用 LLM 来”提炼知识”,会增加延迟和成本。先速记不求工整,会后再整理成正式纪要。

注入方式是在对话开始时作为 developer instruction 全量注入 system prompt——不是向量检索,所以体量必须控制在合理范围内(通常几百到几千字)。

Claude Code 记忆的精妙之处在于它让记忆成为了版本可控的文本。CLAUDE.md 就是一个普通的 Markdown 文件,可以用 Git 管理变更历史。这意味着:你可以看到记忆是什么时候被加入的、被谁修改过、修改了什么。如果 Agent “记错了”什么,你可以 git diff 看到具体哪里出了问题,然后手动修正。

这和向量数据库中的嵌入向量形成了鲜明对比——你几乎不可能”看到”向量数据库里存了什么(一堆浮点数),更不可能手动修改。Claude Code 的方案牺牲了检索灵活性(全量注入 vs 语义检索),换来了完全的可审计性和可编辑性

# CLAUDE.md 的典型内容
## 项目信息
- 语言: Go 1.22
- 框架: tRPC
- 数据库: PostgreSQL (需要 JSONB 支持)

## 编码规范
- 错误处理: 必须用 fmt.Errorf 包装,包含上下文
- 测试: 表驱动测试,覆盖率 > 80%
- 命名: 接口用动词,结构体用名词

## 用户偏好
- 偏好中文交流
- 喜欢简洁的代码,不喜欢过度抽象

12.7.2 Codex:AGENTS.md + Session Persistence

OpenAI 的 Codex 方案更简洁。AGENTS.md 是项目级指令文件,Session Persistence 维护 session 内的对话状态,跨 session 的记忆主要依赖 AGENTS.md 这个显式文件。

12.7.3 两种哲学的对比

Claude Code 代表自动记忆哲学——Agent 自己观察、提炼、记住。优点是体验好(感觉 Agent “越来越懂你”),缺点是可能记错东西、记了不该记的。

Codex 代表显式记忆哲学——用户控制什么该记。优点是确定性高(记忆内容完全可控),缺点是需要用户手动维护。

这两种哲学在软件工程中也有对应——”约定优于配置”(Convention over Configuration)vs “显式优于隐式”(Explicit is better than Implicit)。没有绝对的好坏,取决于场景和用户群体。

一个有趣的观察:随着 Agent 越来越成熟,两种方案似乎在融合。Claude Code 虽然有自动记忆,但用户也可以手动编辑 CLAUDE.md 来覆盖自动提取的内容。Codex 虽然强调显式管理,但越来越多的用户开始用脚本自动生成和更新 AGENTS.md。最终的最佳实践可能是:自动提取作为默认行为,用户手动覆盖作为最终权威——类似于 CSS 中”继承 + 覆盖”的模式。


12.8 读路径对比

“读路径”是指:当 Agent 需要回忆过去的信息时,它是怎么把记忆找出来、注入到当前上下文中的?

12.8.1 tRPC-Agent-Go:QueryFacts 注入 System Prompt

func (a *Agent) prepareContext(ctx context.Context, userMessage string) {
    // 根据用户消息检索相关 Facts
    facts, _ := a.memory.QueryFacts(ctx, userMessage, 10)
    
    // 构建记忆注入段
    var memorySection strings.Builder
    memorySection.WriteString("## 关于用户的已知信息\n")
    for _, fact := range facts {
        memorySection.WriteString(fmt.Sprintf("- %s\n", fact.Content))
    }
    
    // 注入到 system prompt
    a.systemPrompt = basePrompt + "\n" + memorySection.String()
}

核心机制是语义检索:用用户的当前消息作为 query,从 Facts 库中找语义最相关的条目注入。优点是只注入相关的记忆(节省 context),缺点是检索质量依赖向量化模型的质量。

一个常见的陷阱是检索不命中——用户说”数据库的事”,但你的 Fact 写的是”PostgreSQL JSONB 配置参数”,向量化模型可能认为两者相似度不够高。解决方案通常是在存储 Fact 时同时生成多个”检索锚点”(anchor text),比如给”PostgreSQL JSONB 配置参数”额外关联”数据库”、”数据存储”等更泛化的关键词。

另一个设计要点是 limit 参数——检索多少条 Facts 注入。太少可能遗漏重要信息,太多会占用宝贵的 context window。通常的做法是设一个默认值(如 10),然后根据用户消息的复杂度动态调整。

12.8.2 LangGraph:Checkpoint 恢复全部 Channel 状态

# LangGraph 的读路径极其简单——恢复 Checkpoint
config = {"configurable": {"thread_id": "user-123"}}
# invoke 时自动从 checkpointer 恢复之前的完整状态
result = graph.invoke({"messages": [new_message]}, config)

LangGraph 不做”检索”——它直接恢复上次保存的全部状态。这意味着:如果你的 State 中有 1000 条消息,全部恢复。简单粗暴,但对长对话可能导致 context 过长。

LangGraph 通常配合消息裁剪策略使用:

from langgraph.prebuilt import create_react_agent

# 只保留最近 20 条消息
agent = create_react_agent(
    model, tools,
    state_modifier=lambda state: state["messages"][-20:]
)

12.8.3 CrewAI:向量相似度检索注入 Context

CrewAI 的长期记忆使用向量数据库存储,读取时通过相似度检索注入。和 tRPC-Agent-Go 的 QueryFacts 类似,但封装得更深——开发者不需要显式调用检索 API,CrewAI 框架自动在每次 Agent 执行前做检索和注入。

# CrewAI 内部的读路径(简化)
def _prepare_agent_context(self, agent, task):
    if self.memory:
        # 自动检索相关的长期记忆
        relevant_memories = self.long_term_memory.search(
            query=task.description,
            limit=5,
        )
        # 注入到 agent 的上下文
        agent.context += format_memories(relevant_memories)

12.8.4 Claude Code:Developer Instruction 注入

Claude Code 的读路径最”朴素”——直接把 CLAUDE.md 和 MEMORY.md 的全文注入到 system prompt 的 developer instruction 部分。不做向量检索,不做语义匹配,就是全量注入。

为什么能用这么”笨”的方式?因为 Claude Code 在写路径上做了很好的信息压缩——MEMORY.md 经过整理后通常只有几百到几千字,全量注入的成本可以接受。而且全量注入的好处是零检索延迟零检索遗漏——你不会因为检索算法的问题漏掉重要的记忆。

12.8.5 读路径对比表

框架 检索方式 注入位置 是否选择性注入 延迟
tRPC-Agent-Go 语义检索(向量相似度) System Prompt 是(只注入相关的) 中(需检索)
LangGraph 全量恢复(Checkpoint) State Channel 否(全部恢复) 低(直接读)
CrewAI 语义检索(向量相似度) Agent Context 是(自动检索) 中(需检索)
Claude Code 全量注入(直接读文件) Developer Instruction 否(全量注入) 低(直接读)

12.9 写路径对比

“写路径”是指:Agent 在什么时候、以什么方式把信息存入记忆?

12.9.1 什么时候写?

每轮写入:每次 LLM 调用后立即存储。实时性最好,但写入频率高、成本高。适合需要即时记忆的场景(如客服对话中的用户情绪跟踪)。

Session 结束时写入:对话结束后统一提取和存储。减少写入频率,但如果 session 中途崩溃就丢失了。tRPC-Agent-Go 的 Episode 存储通常在 session 结束时执行。

异步 Consolidation:Claude Code 的方式——先快速追加原始记录,后台异步整理。兼顾了实时性和成本。

每轮写入          ←─ 实时性高,成本高
Session 结束写入  ←─ 平衡
异步 Consolidation ←─ 实时性低,成本最优

12.9.2 写什么?

只写原始消息:LangGraph 的 Checkpoint 方式。保留全部信息,不做提炼。优点是不丢失信息,缺点是数据量大、读取时需要处理大量原始数据。

只写提炼知识:只存储从对话中提取的 Facts。数据量小、读取效率高,但提炼过程可能丢失重要细节。

两者都写:tRPC-Agent-Go(Fact + Episode)和 Claude Code(raw_memories + MEMORY.md)的方式。兼顾了信息完整性和检索效率,但存储成本更高、系统复杂度也更高。

// tRPC-Agent-Go 的双写策略
func (a *Agent) onSessionEnd(ctx context.Context) {
    // 写 Episode(原始记录)
    a.memory.StoreEpisode(ctx, &Episode{
        SessionID: a.sessionID,
        Messages:  a.messages,
    })

    // 写 Facts(提炼知识)
    facts := a.extractFacts(ctx, a.messages)
    for _, fact := range facts {
        a.memory.StoreFact(ctx, fact)
    }
}

12.9.3 遗忘机制

记忆不能只增不减——无限膨胀的记忆会导致检索效率下降、注入 token 成本上升、甚至注入了过时的错误信息。

基于使用频率的遗忘(tRPC-Agent-Go):Fact 有 usage_count 字段,每次被检索到并使用时计数加 1。长时间不被使用的 Facts 可以被清理。还有 max_unused_days 参数——超过 N 天没被使用的 Fact 自动过期。

// 遗忘策略
type ForgetPolicy struct {
    MaxUnusedDays int  // 超过多少天未使用就过期
    MinUsageCount int  // 使用次数低于阈值的优先清理
    MaxFacts      int  // Facts 总量上限
}

func (m *MemoryManager) Cleanup(ctx context.Context, policy ForgetPolicy) error {
    // 删除过期的 Facts
    cutoff := time.Now().AddDate(0, 0, -policy.MaxUnusedDays)
    return m.store.DeleteFactsBefore(ctx, cutoff, policy.MinUsageCount)
}

手动删除(Claude Code / Codex):用户可以直接编辑 CLAUDE.md / MEMORY.md 来删除不再需要的记忆。简单直接,但需要用户主动维护。

基于容量的 LRU 淘汰:当记忆条目超过上限时,淘汰最久没有使用的条目。类似操作系统的页面置换算法。

目前大多数框架的遗忘机制都比较原始。 理想的遗忘应该是”智能的”——自动识别哪些记忆已经过时(比如”项目用 MySQL”在用户决定换 PostgreSQL 后应该自动失效),但这需要理解记忆之间的语义关系,目前还是开放问题。

12.9.4 写路径对比表

框架 写入时机 写入内容 遗忘机制
tRPC-Agent-Go Session 结束 Fact + Episode usage_count / max_unused_days
LangGraph 每步执行后 完整 State 快照 手动清理 / Checkpoint 过期
CrewAI 自动(框架管理) 短期 + 长期 + 实体 向量库内建
Claude Code 实时 + 异步 raw_memories → MEMORY.md 手动编辑
Agno 任务成功后 策略摘要 手动管理

12.10 深度对比:五大框架的 Memory 哲学

退一步看全局,五个框架的 Memory 设计反映了五种不同的哲学立场:

12.10.1 tRPC-Agent-Go:数据库思维

tRPC-Agent-Go 把记忆当作数据管理问题来解决。9 种后端、CRUD 接口、usage_count 计数——这些都是数据库工程师熟悉的概念。它的优势在于灵活性和可控性——你可以精确控制每一条记忆的生命周期。

适合场景:企业级 Agent 应用,需要精细的数据管理和审计。

12.10.2 LangGraph:状态机思维

LangGraph 把记忆当作状态持久化问题来解决。Checkpoint 就是状态快照。它不区分”什么值得记”和”什么不值得记”——全部保存、全部恢复。

适合场景:需要精确状态恢复的复杂工作流(如多步审批流程、可中断的长任务)。

12.10.3 CrewAI:即插即用思维

CrewAI 把记忆当作功能开关来处理。memory=True 一键开启。它假设大多数用户不需要(也不想)理解记忆的底层实现。

适合场景:快速原型、非技术用户构建 Agent、简单到中等复杂度的应用。

12.10.4 Claude Code:文件系统思维

Claude Code 用 Markdown 文件管理记忆——人类可读、可编辑、版本可控(可以用 Git 管理 CLAUDE.md 的变更历史)。这反映了一种”记忆应该是透明的”设计理念。

适合场景:开发者工具、需要人机协同维护知识库的场景。

12.10.5 Agno:学习系统思维

Agno 的 LearningMachine 把记忆当作自我改进的基础设施。它不只是”存信息”,而是”学经验”。这是最有野心的设计,但也是最难做对的。

适合场景:重复性任务(每次执行类似但不完全相同的工作)、需要 Agent 逐步改进的场景。

12.10.6 Memory 系统工程挑战

前面讨论了各种框架的设计哲学和 API,但从”Demo 能跑”到”生产环境稳定运行”,中间隔着一系列工程挑战。这些挑战在学术论文和框架文档中很少提及,但在真实项目中往往是决定成败的关键。

日常类比:设计一个 Memory 系统就像开一家图书馆。选好书架类型(存储后端)、制定借阅规则(读写路径)只是第一步。真正的难题是:开馆第一天书架是空的怎么办?两本书的信息互相矛盾怎么处理?怎么保证张三借的书不会被李四看到?读者投诉”我明明还过这本书,系统说我没还”怎么查?老馆搬新馆的书怎么搬?怎么知道图书馆运营得好不好?

冷启动问题

Agent 第一次运行时,Memory 是空的——它对用户一无所知。这个阶段的体验通常很差:Agent 问了一堆本应”记住”的问题,用户觉得它”很笨”。

解决方案有几种。显式 onboarding:第一次对话时主动向用户收集关键信息(姓名、角色、偏好),像新员工入职的 HR 面谈。Claude Code 的 CLAUDE.md 其实就是一种手动 onboarding——用户第一次使用时手动写好项目信息。从外部系统导入:如果用户在其他系统(CRM、HR 系统、用户画像平台)已有数据,可以直接导入作为初始 Memory。Mem0 的 metadata 参数支持标注数据来源,方便区分”导入的”和”对话中学到的”。渐进式丰富:接受冷启动期间体验不佳的事实,但通过 UI 设计降低用户的期望——比如在第一次对话时提示”我还在了解你,接下来几次对话我会越来越了解你的偏好”。

// 冷启动检测 + 渐进式 onboarding
func (a *Agent) handleColdStart(ctx context.Context, userID string) {
    facts, _ := a.memory.QueryFacts(ctx, "", 100)
    if len(facts) < 3 {
        // Memory 几乎为空,触发 onboarding
        a.systemPrompt += `\n## 注意
这是与该用户的早期对话,你对他了解不多。
请在自然对话中了解以下信息(不要像问卷一样连续发问):
- 用户的角色和技术背景
- 常用的编程语言和框架
- 沟通偏好(语言、详细程度)`
    }
}

记忆冲突

当两条记忆的内容互相矛盾时怎么办?比如 Fact A 说”用户偏好 MySQL”,Fact B 说”用户偏好 PostgreSQL”。这种冲突可能因为用户改变了偏好(时序冲突),也可能因为 LLM 提取错了(提取错误),还可能因为同一用户的不同 session 产生了矛盾信息(多源冲突)。

时序优先策略:最新的信息覆盖旧的。这是最常见的做法(Mem0 和 Graphiti 都采用),但前提是你能可靠地判断哪条更新——如果两条 Fact 是同一分钟内写入的呢?

置信度评分:给每条 Fact 一个置信度分数。用户明确说”我们用 PostgreSQL”的置信度高于 LLM 从上下文推断出的”可能用 MySQL”。检索时优先返回置信度高的。

冲突标记 + 人工仲裁:检测到冲突时不自动解决,而是标记出来等用户确认。适合高风险场景(如医疗、金融),错误记忆的代价很高。

// 记忆冲突检测(概念性代码)
type ConflictDetector struct {
    memory Memory
}

func (d *ConflictDetector) CheckConflict(ctx context.Context, newFact *Fact) (*Fact, bool) {
    // 检索同类别的现有 Facts
    existing, _ := d.memory.QueryFacts(ctx, newFact.Content, 5)
    for _, old := range existing {
        if old.Category == newFact.Category && d.isContradiction(old, newFact) {
            return old, true // 发现冲突,返回冲突的旧 Fact
        }
    }
    return nil, false
}

func (d *ConflictDetector) isContradiction(a, b *Fact) bool {
    // 用 LLM 判断两条 Fact 是否矛盾
    // "用户偏好 MySQL" vs "用户偏好 PostgreSQL" → 矛盾
    // "用户偏好 Go" vs "用户也会写 Python" → 不矛盾
    prompt := fmt.Sprintf(
        "以下两条信息是否矛盾?\nA: %s\nB: %s\n只回答是或否",
        a.Content, b.Content)
    result := callLLM(prompt)
    return strings.Contains(result, "是")
}

多租户隔离

在 SaaS 场景下,多个用户共享同一个 Agent 服务。用户 A 的记忆绝对不能泄露给用户 B。这听起来简单(按 user_id 隔离就行),但实际有很多陷阱。

向量数据库的隔离:大多数向量数据库(如 Milvus、Pinecone)支持 namespace 或 partition 隔离,但有些操作(如全量扫描、索引重建)可能跨分区。需要确认你用的向量数据库的隔离级别是否满足合规要求。

LLM 的隐性泄露:如果你用同一个 LLM 实例为多个用户提取 Facts,LLM 的 context 中可能残留上一个用户的信息——虽然 API 调用之间理论上是无状态的,但实现 bug(如连接复用、缓存)可能导致泄露。

日志和监控的隔离:即使 Memory 本身隔离了,如果日志系统记录了所有用户的对话明文,泄露风险就转移到了日志系统。需要对日志做脱敏处理。

可解释性

当 Agent 基于记忆做出决策时,用户可能想知道”你为什么这么说?”。如果记忆存在向量数据库中(一堆浮点数),Agent 无法解释”我是因为第 47 号 Fact 而给出这个建议的”。

Claude Code 的 Markdown 方案在可解释性上有天然优势——记忆就是人类可读的文本,用户可以直接查看、理解和修改。tRPC-Agent-Go 的 Fact 也有一定的可解释性,因为 Facts 是结构化的文本,可以作为引用来源附在回答后面。

更好的做法是在回答中显式引用记忆来源:

用户:帮我配置数据库连接
Agent:根据我之前了解到的信息 [来源: Fact #23, 2025-03-15],
你的项目使用 PostgreSQL 并且需要 JSONB 支持。
以下是推荐的连接配置...

记忆迁移

当 Agent 框架升级、存储后端更换、或者从一个 Agent 产品迁移到另一个时,现有的记忆怎么办?这个问题在框架文档中几乎不讨论,但在真实场景中很常见。

核心挑战是格式不兼容。tRPC-Agent-Go 的 Fact 结构和 CrewAI 的长期记忆格式完全不同。从向量数据库迁移到另一个向量数据库,嵌入向量可能不兼容(因为用的嵌入模型不同)——这意味着你不能简单地复制数据,而需要重新用新模型生成嵌入。

建议的最佳实践是:始终保留一份人类可读的纯文本导出。不管底层用什么存储,定期把记忆导出为 Markdown 或 JSON 格式。迁移时可以从这份纯文本重新导入到新系统。Claude Code 的设计天然满足这一点——CLAUDE.md 本身就是纯文本。

评估指标

怎么衡量一个 Memory 系统的好坏?这个问题出乎意料地难。

检索准确率(Recall@K):给定一个查询,前 K 条检索结果中有多少是真正相关的?这衡量的是”能不能找到对的记忆”。

注入价值比(Injected Value Ratio):注入的记忆中,有多少真正影响了 Agent 的回答质量?如果注入了 10 条 Facts 但 Agent 只用到了 1 条,说明注入效率低。

记忆新鲜度(Freshness Score):记忆库中过时信息的比例。如果 30% 的 Facts 都已经过时了,说明遗忘机制不够好。

端到端用户满意度:最终衡量的是用户体验——”Agent 真的记住了我说过的东西吗?”。可以通过 A/B 测试(有记忆 vs 无记忆)来衡量记忆系统的整体价值。

目前没有统一的 Memory benchmark。各框架的评估方式各不相同,很难做跨框架的公平比较。这也是为什么在选择 Memory 方案时,更应该关注”是否满足你的具体需求”,而不是”哪个框架的 benchmark 分数高”。


12.11 Memory 设计的关键取舍

设计 Agent Memory 系统时,你会遇到以下几个核心取舍:

12.11.1 信息完整性 vs Token 效率

保留越多信息,context 占用越大。全量注入(Claude Code 式)简单可靠但占 token;选择性注入(tRPC-Agent-Go 式)省 token 但可能漏掉重要信息。

一个实用的折中:分层注入。核心偏好(高频使用的 Facts)全量注入 system prompt,长尾知识(低频使用的详细信息)按需检索。

System Prompt 注入(必定看到):
  - 用户偏好:中文、简洁风格
  - 项目信息:Go 1.22、tRPC 框架
  
按需检索(相关时才注入):
  - PostgreSQL 的 JSONB 配置细节
  - 上次讨论的 CI/CD 配置方案
  - 三周前的 bug 排查记录

12.11.2 自动提炼 vs 显式管理

自动提炼(LLM 自己提取 Facts)省人力但可能出错;显式管理(用户手动写 CLAUDE.md)准确但需要用户参与。

最佳实践是自动提炼 + 人类审核的混合模式:Agent 自动提取候选 Facts,定期让用户确认”这些信息对吗?要保留吗?”

12.11.3 实时性 vs 成本

每轮都调 LLM 提取 Facts 实时性最好但成本高;异步批量处理成本低但有延迟。Claude Code 的两阶段 pipeline 是目前最优雅的平衡方案。

12.11.4 隐私与安全

记忆系统存储了用户的交互历史和偏好——这些都是敏感数据。需要考虑:记忆存在哪里?谁能访问?用户能否要求”忘掉所有关于我的信息”?合规要求(GDPR 等)如何满足?

这个问题在学术框架中几乎不讨论,但在商业产品中是不可回避的。Claude Code 把记忆存在本地文件(用户完全控制),是一种简洁的隐私方案。


12.12 Memory 的演进趋势

12.12.1 从”被动存储”到”主动学习”

早期的 Memory 设计是被动的——用户说什么存什么。Agno 的 LearningMachine 代表了主动学习的趋势——Agent 从自己的经验中提炼可复用的策略。

未来的方向可能是:Agent 不仅记住”用户说了什么”,还能推断出”用户没说但暗示了什么”。比如,用户连续三次在讨论 Go 代码时使用 errgroup 库,Agent 推断出”用户熟悉并偏好 errgroup 做并发控制”——这个推断并没有被显式表达过。

12.12.2 从”单 Agent 记忆”到”多 Agent 共享记忆”

当前大多数 Memory 设计是单 Agent 的——一个 Agent 的记忆只有它自己能用。但在多 Agent 系统中(如 tRPC-Agent-Go 的 Team),Agent 之间可能需要共享记忆。

比如:研究员 Agent 发现”用户要的数据在 S3 桶里,格式是 Parquet”,这个信息应该自动共享给数据处理 Agent,而不是让数据处理 Agent 自己去重新发现。

CrewAI 的短期记忆在一定程度上实现了这个——同一个 Crew 内的 Agent 可以共享当前执行的信息。但跨 Crew、跨 session 的共享还很原始。

12.12.3 从”文本记忆”到”结构化知识图谱”

目前的 Facts 存储基本是”一条一条的文本”。更高级的方式是构建知识图谱——不只记住单独的事实,还记住事实之间的关系。

当前(扁平 Facts):
  - 张三是项目 Alpha 的负责人
  - 项目 Alpha 使用 Go 语言
  - Go 1.22 新增了 range over func

未来(知识图谱):
  张三 →[负责]→ 项目Alpha →[使用]→ Go →[版本]→ 1.22 →[特性]→ range over func

知识图谱能支持更复杂的推理——比如回答”张三的项目有什么新特性可以用?”需要沿着关系链推导,扁平的 Facts 列表做不到。

CrewAI 的实体记忆是这个方向的早期尝试。

12.12.4 从”无限记忆”到”智能遗忘”

人类的遗忘不是缺陷——是特性。忘掉不重要的细节,才能把注意力集中在重要的事情上。Agent 也需要”智能遗忘”——不只是”超时删除”,而是理解”这条信息已经被新的信息取代了”。

比如,”数据库用 MySQL”在用户决定换 PostgreSQL 后应该自动被标记为”已过时”并降低检索优先级。但目前的使用频率方式很难做到这种语义级别的遗忘。

12.12.5 Memory 与 RAG 的边界

在 Agent 系统中,有两个子系统都在做”把外部知识注入 LLM context”这件事——Memory 和 RAG(Retrieval-Augmented Generation)。它们看起来很像,但解决的是不同的问题。

日常类比:Memory 像你自己的记忆——你亲身经历过的事、别人告诉你的偏好、你做过的决策。RAG 像你手边的参考书——你不需要记住书里的每个细节,需要时翻一下就行。你不会把《新华字典》的内容”记住”,但你会记住”查字典时先查拼音再查页码”这个策略。

Memory 管理的是”交互产生的知识”——用户偏好、对话历史、过去的决策和经验。这些信息是动态的,随着每次对话不断积累和更新。核心特征是个性化——不同用户的 Memory 内容完全不同。

RAG 管理的是”预先存在的文档”——API 文档、产品手册、公司知识库、代码仓库。这些信息在 Agent 运行之前就已经存在了,且对所有用户相同(或按权限区分)。核心特征是静态性(相对于 Memory 而言变化频率低得多)。

什么该存到 Memory,什么该放到 RAG?

存到 Memory(交互产生的、个性化的):
  - "用户偏好用 PostgreSQL"
  - "上次讨论决定用微服务架构"
  - "用户是 Go 高级开发者,不需要解释基础语法"
  - "这个用户喜欢简洁的代码风格"

放到 RAG(预先存在的、共享的):
  - tRPC 框架的完整 API 文档
  - 公司的编码规范手册
  - PostgreSQL 的配置参考指南
  - 项目的 README 和架构文档

但现实中,边界并不总是清晰。有些信息同时具备两种特征。比如”项目的架构决策记录(ADR)”——它是文档(RAG),但也是团队在交互过程中产生的决策(Memory)。再比如”用户自己写的笔记”——它是预先存在的文档,但高度个性化。

融合趋势:越来越多的系统开始把 Memory 和 RAG 统一到一个检索层中。Mem0 的做法就是同时支持”用户记忆”和”知识库文档”的检索,用统一的 search API 返回两类结果。CrewAI 的 knowledge_sources 也在模糊这条边界——Agent 可以同时拥有记忆(memory=True)和知识来源(knowledge_sources)。

实践中的决策框架:

问自己:这条信息是因为和用户交互才产生的吗?
├─ 是 → Memory(用 Fact/Episode 存储)
└─ 否 →
    这条信息需要频繁全量更新吗?
    ├─ 是(如实时 API 文档)→ RAG(每次构建时重新索引)
    └─ 否 →
        这条信息超过 context window 能容纳的大小吗?
        ├─ 是 → RAG(需要分块检索)
        └─ 否 → 直接写进 system prompt 就行(如 CLAUDE.md)

一个常见的反模式是把 RAG 的文档当 Memory 存——比如把整个 API 文档的内容提取成 Facts 存到 Memory 中。这会导致 Memory 膨胀(几万条 Facts),检索效率下降,且文档更新时需要重新提取所有 Facts。正确的做法是让 RAG 管文档检索,Memory 只存交互产生的知识——两套系统各司其职,通过统一的注入层组合到 LLM 的 context 中。


12.13 为你的 Agent 选择 Memory 方案

如果你正在设计一个 Agent 系统,以下是选择 Memory 方案的决策树:

你的 Agent 需要跨 session 记忆吗?
├─ 不需要 → LangGraph Checkpoint 就够了
└─ 需要 →
    你需要精细控制记忆的存取吗?
    ├─ 需要 → tRPC-Agent-Go 的 Fact/Episode + 自选后端
    └─ 不需要 →
        你的用户是开发者吗?
        ├─ 是 → Claude Code 式的 Markdown 文件(透明可编辑)
        └─ 否 →
            你需要快速上手吗?
            ├─ 是 → CrewAI 的 memory=True
            └─ 否 → 自己设计,参考以上方案

没有”最好”的 Memory 方案——只有最适合你的场景的方案。关键是理解每种方案的取舍,然后根据你的具体需求做选择。


12.14 实战:为 tRPC-Agent-Go 实现 Memory 系统

前面十几节讲了各种 Memory 框架的设计哲学和 API,但”知道”和”会做”之间隔着一个实战。这一节用一个完整的小项目来走一遍:从需求分析到架构设计到核心代码,为 tRPC-Agent-Go 实现一个最小但完整的 Memory 系统。

14.1 需求分析

我们的目标是实现一个满足以下要求的 Memory 系统:

功能需求:支持 Fact(语义记忆)的存储和检索;支持 Episode(情节记忆)的存储;对话结束时自动提取 Facts;下次对话时自动注入相关记忆;支持基于使用频率的遗忘。

非功能需求:使用 SQLite 作为后端(最低部署成本);不依赖外部向量数据库(用简单的关键词匹配代替,生产环境再升级);代码可读性优先,方便学习和修改。

明确不做:不做多用户隔离(单用户场景);不做向量语义检索(用关键词匹配简化);不做异步 consolidation(同步写入,简化流程)。

14.2 架构设计

┌──────────────────────────────────────────────┐
│                  ReAct Agent                  │
│                                              │
│  ┌────────────┐    ┌────────────────────┐    │
│  │ prepareCtx │───→│ System Prompt      │    │
│  │ (读路径)   │    │ + 注入的 Facts     │    │
│  └────────────┘    └────────────────────┘    │
│                                              │
│  ┌────────────┐    ┌────────────────────┐    │
│  │ onSessionEnd│───→│ Fact 提取 + 存储   │    │
│  │ (写路径)    │    │ Episode 存储       │    │
│  └────────────┘    └────────────────────┘    │
│                                              │
├──────────────────────────────────────────────┤
│              MemoryStore (接口)               │
│  StoreFact / QueryFacts / StoreEpisode       │
│  IncrementUsage / Cleanup                    │
├──────────────────────────────────────────────┤
│              SQLite 后端                      │
│  facts 表 / episodes 表                      │
└──────────────────────────────────────────────┘

14.3 Go 核心代码

package memory

import (
    "context"
    "database/sql"
    "fmt"
    "strings"
    "time"

    _ "github.com/mattn/go-sqlite3"
)

// Fact 表示一条语义记忆
type Fact struct {
    ID         string
    Content    string
    Category   string
    CreatedAt  time.Time
    UsageCount int
    LastUsedAt time.Time
}

// Episode 表示一段完整的对话记录
type Episode struct {
    ID        string
    Messages  string // JSON 序列化的消息列表
    Summary   string
    CreatedAt time.Time
}

// MemoryStore 定义 Memory 系统的核心接口
type MemoryStore interface {
    StoreFact(ctx context.Context, fact *Fact) error
    QueryFacts(ctx context.Context, query string, limit int) ([]*Fact, error)
    IncrementUsage(ctx context.Context, factID string) error
    StoreEpisode(ctx context.Context, ep *Episode) error
    Cleanup(ctx context.Context, maxUnusedDays int) (int, error)
    Close() error
}

// SQLiteStore 是基于 SQLite 的 MemoryStore 实现
type SQLiteStore struct {
    db *sql.DB
}

// NewSQLiteStore 创建并初始化一个 SQLite 存储
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        return nil, fmt.Errorf("打开数据库失败: %w", err)
    }

    // 建表——facts 和 episodes
    schema := `
    CREATE TABLE IF NOT EXISTS facts (
        id TEXT PRIMARY KEY,
        content TEXT NOT NULL,
        category TEXT DEFAULT '',
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        usage_count INTEGER DEFAULT 0,
        last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );
    CREATE TABLE IF NOT EXISTS episodes (
        id TEXT PRIMARY KEY,
        messages TEXT NOT NULL,
        summary TEXT DEFAULT '',
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );`
    if _, err := db.Exec(schema); err != nil {
        return nil, fmt.Errorf("建表失败: %w", err)
    }
    return &SQLiteStore{db: db}, nil
}

// StoreFact 存储一条新的 Fact
func (s *SQLiteStore) StoreFact(ctx context.Context, f *Fact) error {
    _, err := s.db.ExecContext(ctx,
        `INSERT OR REPLACE INTO facts
         (id, content, category, created_at, usage_count, last_used_at)
         VALUES (?, ?, ?, ?, ?, ?)`,
        f.ID, f.Content, f.Category,
        f.CreatedAt, f.UsageCount, f.LastUsedAt,
    )
    return err
}

// QueryFacts 用关键词匹配检索相关 Facts
// 注意:这是简化版,生产环境应该用向量数据库做语义检索
func (s *SQLiteStore) QueryFacts(
    ctx context.Context, query string, limit int,
) ([]*Fact, error) {
    keywords := strings.Fields(query)
    if len(keywords) == 0 {
        return nil, nil
    }

    // 构建 WHERE 子句:任一关键词匹配即可
    conditions := make([]string, len(keywords))
    args := make([]interface{}, len(keywords))
    for i, kw := range keywords {
        conditions[i] = "content LIKE ?"
        args[i] = "%" + kw + "%"
    }
    where := strings.Join(conditions, " OR ")
    sqlStr := fmt.Sprintf(
        `SELECT id, content, category, created_at, usage_count, last_used_at
         FROM facts WHERE %s
         ORDER BY usage_count DESC LIMIT ?`, where)
    args = append(args, limit)

    rows, err := s.db.QueryContext(ctx, sqlStr, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var facts []*Fact
    for rows.Next() {
        f := &Fact{}
        if err := rows.Scan(&f.ID, &f.Content, &f.Category,
            &f.CreatedAt, &f.UsageCount, &f.LastUsedAt); err != nil {
            return nil, err
        }
        facts = append(facts, f)
    }
    return facts, nil
}

// IncrementUsage 增加 Fact 的使用计数
func (s *SQLiteStore) IncrementUsage(ctx context.Context, id string) error {
    _, err := s.db.ExecContext(ctx,
        `UPDATE facts SET usage_count = usage_count + 1,
         last_used_at = CURRENT_TIMESTAMP WHERE id = ?`, id)
    return err
}

// Cleanup 清理超过 maxUnusedDays 未使用的 Facts
func (s *SQLiteStore) Cleanup(ctx context.Context, days int) (int, error) {
    cutoff := time.Now().AddDate(0, 0, -days)
    result, err := s.db.ExecContext(ctx,
        `DELETE FROM facts WHERE last_used_at < ?
         AND usage_count < 3`, cutoff)
    if err != nil {
        return 0, err
    }
    n, _ := result.RowsAffected()
    return int(n), nil
}

// StoreEpisode 存储一段完整对话
func (s *SQLiteStore) StoreEpisode(ctx context.Context, ep *Episode) error {
    _, err := s.db.ExecContext(ctx,
        `INSERT INTO episodes (id, messages, summary, created_at)
         VALUES (?, ?, ?, ?)`,
        ep.ID, ep.Messages, ep.Summary, ep.CreatedAt)
    return err
}

// Close 关闭数据库连接
func (s *SQLiteStore) Close() error { return s.db.Close() }

14.4 集成到 ReAct Agent

// 读路径:在每次对话前注入相关记忆
func (a *Agent) PrepareContext(ctx context.Context, userMsg string) string {
    facts, err := a.memory.QueryFacts(ctx, userMsg, 10)
    if err != nil || len(facts) == 0 {
        return a.basePrompt
    }

    var sb strings.Builder
    sb.WriteString(a.basePrompt)
    sb.WriteString("\n\n## 关于用户的已知信息\n")
    for _, f := range facts {
        sb.WriteString(fmt.Sprintf("- [%s] %s\n", f.Category, f.Content))
        // 每次注入都增加使用计数
        a.memory.IncrementUsage(ctx, f.ID)
    }
    return sb.String()
}

// 写路径:对话结束时提取并存储 Facts
func (a *Agent) OnSessionEnd(ctx context.Context, messages []Message) {
    // 1. 存储完整对话(Episode)
    msgJSON, _ := json.Marshal(messages)
    a.memory.StoreEpisode(ctx, &Episode{
        ID:        uuid.New().String(),
        Messages:  string(msgJSON),
        CreatedAt: time.Now(),
    })

    // 2. 用 LLM 提取 Facts
    facts := a.extractFacts(ctx, messages)
    for _, f := range facts {
        a.memory.StoreFact(ctx, f)
    }

    // 3. 定期清理过期记忆(每 10 次对话清理一次)
    a.sessionCount++
    if a.sessionCount%10 == 0 {
        deleted, _ := a.memory.Cleanup(ctx, 30)
        log.Printf("清理了 %d 条过期记忆", deleted)
    }
}

14.5 测试验证

func TestMemoryStore(t *testing.T) {
    store, err := NewSQLiteStore(":memory:")
    if err != nil {
        t.Fatal(err)
    }
    defer store.Close()

    ctx := context.Background()

    // 测试 1:存储和检索 Fact
    fact := &Fact{
        ID: "f1", Content: "用户偏好 PostgreSQL 数据库",
        Category: "偏好", CreatedAt: time.Now(),
        LastUsedAt: time.Now(),
    }
    if err := store.StoreFact(ctx, fact); err != nil {
        t.Fatalf("StoreFact 失败: %v", err)
    }
    results, err := store.QueryFacts(ctx, "数据库", 5)
    if err != nil || len(results) != 1 {
        t.Fatalf("期望 1 条结果,得到 %d", len(results))
    }
    if results[0].Content != "用户偏好 PostgreSQL 数据库" {
        t.Fatal("检索到的 Fact 内容不匹配")
    }

    // 测试 2:使用计数递增
    store.IncrementUsage(ctx, "f1")
    results, _ = store.QueryFacts(ctx, "数据库", 5)
    if results[0].UsageCount != 1 {
        t.Fatalf("UsageCount 期望 1,得到 %d", results[0].UsageCount)
    }

    // 测试 3:遗忘机制
    oldFact := &Fact{
        ID: "f2", Content: "旧的配置信息",
        Category: "配置",
        CreatedAt:  time.Now().AddDate(0, 0, -60),
        LastUsedAt: time.Now().AddDate(0, 0, -60),
    }
    store.StoreFact(ctx, oldFact)
    deleted, _ := store.Cleanup(ctx, 30)
    if deleted != 1 {
        t.Fatalf("Cleanup 期望删除 1 条,实际删除 %d", deleted)
    }
}

这个实战项目虽然简单(约 150 行核心代码),但覆盖了 Memory 系统的所有核心要素:存储接口定义、读路径(检索 + 注入)、写路径(提取 + 存储)、遗忘机制(基于时间和使用频率)。在生产环境中,你需要做的升级主要是:把关键词匹配换成向量语义检索(接入 Milvus 或 Qdrant),加上冲突检测逻辑(12.10.6 节讨论过的),以及加上多用户隔离。但核心架构和接口设计不需要大改。


思考题

读完本章后,请尝试回答以下问题。不急着看答案——先用自己的理解想一想,想不通再往后读或来问我。

Q1(分类判断):以下信息分别属于哪种记忆类型(工作记忆/情节记忆/语义记忆/程序记忆)?(a) “用户上次问了怎么配置 Nginx 反向代理” (b) “处理 CSV 文件时应该先检查编码再解析” (c) “用户偏好使用 Vim 而非 VSCode” (d) “当前正在解析用户上传的 JSON 文件,已经处理了 3/10 个字段”

Q2(设计取舍):假设你在做一个客服 Agent,每天处理 10000 个对话。你会选择 tRPC-Agent-Go 的 Fact/Episode 方案还是 Claude Code 的 Markdown 文件方案?为什么?考虑存储成本、检索效率和运维复杂度三个维度。

Q3(遗忘策略):设计一个”智能遗忘”机制——当用户说”我们不用 MySQL 了,换 PostgreSQL”时,自动把之前记录的”数据库选择:MySQL”标记为过时。你会怎么实现?提示:考虑 Fact 之间的”取代关系”如何表达和检测。

Q4(隐私问题):一个用户先和你的 Agent 讨论了公司的机密项目细节,然后说”忘掉刚才的对话”。你的 Memory 系统能真正做到”忘掉”吗?考虑向量数据库中的嵌入向量、LLM 的训练数据、日志系统中的记录——哪些地方可能残留信息?

Q5(架构思考):如果把 LangGraph 的 Checkpoint(精确状态恢复)和 tRPC-Agent-Go 的 Fact Memory(语义知识检索)结合起来,设计一个”两层记忆”系统——Checkpoint 保证短期的精确状态恢复,Fact 提供长期的跨 session 知识——你觉得两层之间的接口应该怎么设计?特别是:什么时候从 Checkpoint 中提取 Facts?两层的数据格式如何统一?


上一章:第十一章 Ralph Loop 与可验证 Agent 执行

下一章:第十三章 Plugin 与 Tool 系统设计深度对比

返回目录:Agent 框架深度导读系列