第十二章: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_append、core_memory_replace、archival_memory_insert、archival_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 框架深度导读系列