本文基于 paicli 项目的实践,拆解 Memory 系统五个环节的设计动机、实现细节和 trade-off。这是 Memory 系列的第二篇。
Memory 系统的本质
上一篇讲了 Memory 为什么存在。这一篇讲怎么实现。
先站到工程的角度想一下,Memory 系统要解决的核心问题是什么?本质上就一句话:在有限的上下文窗口里,给 LLM 更可靠、更相关的信息。 效果要好,同时要稳定、要省钱。
这句话拆开来看,每个诉求都对应一个设计环节:
- 「给 LLM 信息」→ 要先把信息存入系统
- 「有限上下文窗口」→ 不能无限堆,要有预算管理和压缩
- 「更可靠、更相关」→ 要检索出最匹配当前问题的记忆
- 「稳定」→ 关键信息要跨会话持久化
串起来就是一个完整的闭环:
用户说话 → 存入 → 存储(短期/长期)→ 压缩(超预算时)→ 检索(需要回忆时)→ 注入到 LLM prompt ↑ 预算管理(全程监控)五个环节,加上一个 MemoryManager 门面类统一编排。下面一个一个拆。
环节一:存入 — 一条记忆长什么样
Agent 运行时会产生多种类型的信息:用户说的话、Agent 的回复、工具调用的结果、压缩后的摘要、提取出的关键事实。如果这些信息都用同一种方式处理,后续压缩和检索就没办法区分优先级。
所以需要一个类型系统:
class MemoryType(Enum): CONVERSATION = "CONVERSATION" # 对话记忆(用户说的、Agent 回复的) FACT = "FACT" # 事实记忆(用户偏好、项目信息) SUMMARY = "SUMMARY" # 摘要记忆(压缩后的历史摘要) TOOL_RESULT = "TOOL_RESULT" # 工具执行结果为什么是这四种?因为它们在 Memory 系统中的命运完全不同:
| 类型 | 存在哪里 | 会被压缩吗 | 会被淘汰吗 |
|---|---|---|---|
| CONVERSATION | 短期记忆 | 会 | 会 |
| TOOL_RESULT | 短期记忆 | 会 | 会 |
| SUMMARY | 短期记忆 | 不会被再次压缩 | 会 |
| FACT | 长期记忆 | 不会 | 不会 |
一条信息进入 Memory 系统时,会被封装成 MemoryEntry,携带类型标签、内容、时间戳、token 数等信息。其中时间戳和 token 数是自动计算的 — 中文大约 1.5 个字符一个 token,英文大约 4 个字符一个 token。这个估算很粗,但对预算管理够用了。
还有一个细节:工具返回的结果可能很长(比如读一个大文件),如果完整存入会快速占满短期记忆。所以工具结果在存入时会被截断到 500 字符。这是一个实用主义的取舍 — 宁可丢失部分细节,也不能让一条消息把预算吃光。
环节二:存储 — 短期和长期的分治
存入之后,信息被路由到不同的存储组件。短期记忆和长期记忆的访问模式完全不同,必须分开设计。
短期记忆 — ConversationMemory
短期记忆负责维护当前对话的上下文。它的核心需求是:保序、能淘汰、够快。
数据结构选的是 OrderedDict。Python 3.7+ 的普通 dict 也保序了,但 OrderedDict 有一个独有方法:popitem(last=False),可以在 O(1) 时间内弹出最早的条目,实现 FIFO 淘汰。
淘汰的逻辑:
def store(self, entry: MemoryEntry) -> None: self._entries[entry.id] = entry self._current_tokens += entry.token_count while self._current_tokens > self._max_tokens and len(self._entries) > 1: self._evict_oldest()三个细节值得注意:
while不是if— 一条新消息可能很大,需要淘汰多条旧的才能腾出空间len > 1的保护 — 至少保留 1 条消息,即使它超出预算。否则记忆完全清空,Agent 彻底失忆- 被淘汰的不直接丢弃 — 而是追加到
_compressed列表,后续压缩时可以回溯
为什么选 FIFO 而不是 LRU?对话天然是时间有序的,早期的消息通常不如近期的相关。FIFO 实现简单(一行代码),覆盖 80% 的场景,剩下的 20% 靠压缩来兜底。如果用 LRU,每次访问都要移动位置,代码复杂度上去了,但对话场景下的收益很有限。
长期记忆 — LongTermMemory
长期记忆负责跨会话持久化关键事实。它的核心需求是:持久化、去重、可检索。
存储后端选的是 JSON 文件(~/.paicli/memory/long_term_memory.json)。为什么不用数据库?零依赖 — 不需要 Redis、SQLite、Milvus。可以直接打开文件看里面存了什么。对于学习项目来说,重点是理解 Memory 设计模式,不是存储引擎。生产环境当然会换数据库。
两个关键设计:
内容去重 — 存入时遍历所有已有条目,如果内容完全相同就跳过。防止同一个事实被存 10 次。但这是精确匹配,“我用 Python”和”语言:Python”是同一个事实却不会被去重。语义级去重需要向量相似度,成本更高。
每次写入都存盘 — 最简单但最低效的策略。如果一秒内调 10 次 store(),就写 10 次文件。改进方案是 dirty flag + 定时刷盘,但当前不需要这个优化。
短期 vs 长期对比
| 维度 | 短期记忆 | 长期记忆 |
|---|---|---|
| 数据结构 | OrderedDict(内存) | dict + JSON 文件(磁盘) |
| 生命周期 | 单次会话 | 跨会话持久化 |
| 淘汰策略 | FIFO 自动淘汰 | 不淘汰 |
| 去重 | 无(同 id 覆盖) | 内容精确匹配 |
| 存储内容 | 对话、工具结果、摘要 | 事实 |
两者的接口完全相同(都有 store、retrieve、search、clear 等),但内在实现完全不同。这就是 Protocol(协议)的好处 — 统一接口,各自实现。
环节三:压缩 — Map-Reduce 策略
短期记忆的 FIFO 淘汰是「暴力丢弃」— 被淘汰的消息直接没了。但旧消息里可能有重要信息。压缩是在淘汰之前,先用 LLM 把旧消息提炼成摘要,再替换原始消息。既节省空间,又保留关键信息。
什么时候触发
不是每次存消息都压缩,而是达到预算的 80% 时才触发。
为什么不是 100%?因为压缩需要调 LLM,需要时间。如果等到 100% 再压缩,在压缩完成之前新来的消息可能已经导致溢出了。留 20% 的缓冲区,大多数情况够压缩完成。
压缩流程 — 三步走
假设当前有 50 条消息:
Step 1: 分割 旧消息 = msg1~msg47(要压缩的) 近期消息 = msg48~msg50(保留最近 3 轮,不压缩)
Step 2: Map-Reduce 压缩旧消息 Map: [msg1~5]→摘要1, [msg6~10]→摘要2, ..., [msg41~47]→摘要9 Reduce: [摘要1, 摘要2, ..., 摘要9] → 最终摘要
Step 3: 重建记忆 清空短期记忆 存入: [最终摘要, msg48, msg49, msg50]为什么是 Map-Reduce 而不是一次性压缩?
如果把 47 条旧消息一次性丢给 LLM 说「帮我压缩」,有两个问题:第一,47 条消息本身就可能超出 LLM 的输出能力;第二,信息密度不均匀,一次性压缩容易丢重要信息。分而治之 — 先分组压缩(Map),再合并摘要(Reduce),每组只有 5 条消息,LLM 处理起来更精确。
如果旧消息不超过 5 条,就只有一个 chunk,跳过 Reduce 阶段,省一次 LLM 调用。
压缩的副产物:事实提取
压缩时旧消息即将丢失,但其中可能有值得长期保留的事实。所以在清空短期记忆之前,会先提取事实存入长期记忆:
# 先提取事实,再清空 — 顺序不能反facts = memory.extract_and_store_facts()memory.clear_short_term()事实提取是两阶段过滤:第一阶段让 LLM 从对话中提取候选事实,第二阶段用规则过滤掉临时任务(“帮我修改代码”)和推测性内容(“可能使用了 Redis”)。通过两道关卡的事实才会被存入长期记忆。
环节四:检索 — 找到最相关的记忆
存了那么多记忆,Agent 需要回忆的时候怎么找到最相关的?
分词 — 检索的基础
中文没有天然的词分隔符,需要特殊处理。这里用的是 n-gram 滑动窗口:2-gram 和 3-gram。
比如 tokenize("数据库配置") 会产生:{数据, 据库, 库配, 配置, 数据库, 据库配, 库配置}。
n-gram 的好处是零依赖、速度快,对于检索场景够用了 — 检索不需要精确的语义分词,只需要”query 中的片段能在 content 中找到”就行。
三级评分
检索时对每条记忆算一个相关性分数:
第 1 级:精确匹配 — 整个 query 是 content 的子串 → 1.0 分(最高)第 2 级:关键词匹配 — query 的 token 有多少命中 content → 0~1 分第 3 级:时间衰减 — 越新的记忆权重越高(24 小时内 1.0 → 0.5)最终分数 = 关键词匹配分 × 时间衰减系数。
长期记忆还有 1.2x 加权,因为长期记忆是经过筛选的,信息密度比短期记忆更高,同等相关性下更值得关注。
检索的局限
关键词匹配解决不了「用不同词表达同一个意思」的问题。用户说「数据库」,记忆里存的是「DB」— 匹配不到。解决这个需要向量检索(embedding + 相似度匹配),但需要额外的模型和存储。当前方案是零依赖的关键词匹配,够用但不够好。
两种检索模式
retrieve(query, limit)— 搜短期 + 长期,返回list[MemoryEntry],Agent 内部使用build_context_for_query(query, max_tokens)— 只搜长期,返回格式化文本,注入到 LLM 的 system prompt
为什么第二个只搜长期?因为短期记忆已经在 messages 列表里了,不需要额外注入。只有长期记忆(磁盘上的事实)需要在新会话时主动注入到 prompt 里。
环节五:预算管理 — Token 分配
一个容易搞错的事实:上下文窗口是 input + output 共享的。
这里可能会有一个误区 128K 的上下文窗口全部是给 input(你发的 messages)的,其实不是。LLM 的上下文窗口约束是 input tokens + output tokens ≤ context_window。你发的消息和 LLM 生成的回复共享这 128K 的总预算。如果你发了 126K 的 input,那模型最多只能生成 2K 的回复 — 回复稍微长一点就被截断了。
所以你不能把 input 塞满。必须给 output 预留空间。
基于这个事实,一次 LLM 调用的 128K 上下文窗口要这样分配:
┌─────────────────────────────────────────┐│ LLM 上下文窗口(128K) │├─────────────────────────────────────────┤│ 系统提示(约 500 tokens) │ ← 固定├─────────────────────────────────────────┤│ 工具定义(约 800 tokens) │ ← 固定├─────────────────────────────────────────┤│ 对话历史(约 57,600 tokens,占 45%) │ ← 动态,需要管理├─────────────────────────────────────────┤│ 回复预留(约 2000 tokens) │ ← 固定,给 LLM output 留空间└─────────────────────────────────────────┘为什么对话历史占 45% 而不是更多?按理说 128K 减去固定开销(500 + 800 + 2000)还有 124,700 tokens 可用,45% 只有 57,600,看起来很保守。这是因为实际场景中还有隐含的消耗 — Agent 回复可能带多个 tool_calls,每个 tool_call 的参数也要算 tokens;工具返回的结果可能很大。45% 是一个安全裕度,宁可压缩频繁一点,也不要冒回复被截断的风险。
压缩触发时,取 min(短期记忆预算, LLM 可用空间) 作为实际上限 — 确保不超过两个约束中较小的那个。达到上限的 80% 就触发压缩。
串一遍:一次完整的存入流程
五个环节讲完了,用 MemoryManager 串一遍 — 当用户说了一句话,数据怎么流转:
用户说 "帮我看看 main.py" │ ▼MemoryManager.add_user_message("帮我看看 main.py") │ ├─ 创建 MemoryEntry(类型:CONVERSATION,自动计算 token 数) │ ├─ 存入 ConversationMemory(OrderedDict) │ └─ 检查是否需要压缩(token_count >= budget × 80%?) │ ├─ 否 → 结束 │ └─ 是 → ContextCompressor.compress() │ ├─ 分割:旧消息 vs 近期消息 ├─ Map:分组调 LLM 生成摘要 ├─ Reduce:合并摘要 └─ 重建:清空旧记忆,注入摘要 + 保留近期Agent 需要回忆的时候:
用户问 "之前我们讨论的方案呢?" │ ▼MemoryManager.retrieve_relevant("讨论方案", limit=5) │ └─ MemoryRetriever.retrieve() │ ├─ 搜短期记忆(匹配对话历史和摘要) ├─ 搜长期记忆(匹配持久事实,1.2x 加权) └─ 按分数排序,返回 top-5这就是五个环节协同工作的完整流程。MemoryManager 是门面类,Agent 只需要调 add_user_message 和 retrieve_relevant,不需要知道内部有短期记忆、长期记忆、压缩器、检索器这些组件的存在。
每个环节都是 trade-off
回过头看,五个环节的每个设计决策都不是”唯一的正确答案”,而是在特定约束下的取舍:
| 环节 | 选择了 | 放弃了 | 原因 |
|---|---|---|---|
| 存入 | 类型标签 + 粗略 token 估算 | 无类型 + 精确 tokenizer | 精确需要外部依赖,粗略够用 |
| 短期存储 | FIFO 淘汰 | LRU 淘汰 | 对话天然时间有序,FIFO 够用且简单 |
| 长期存储 | JSON 文件 | 数据库 | 零依赖,学习项目够用 |
| 压缩 | Map-Reduce(分组 5 条) | 一次性压缩 | 分而治之更精确 |
| 事实提取 | LLM + 规则双重过滤 | 纯 LLM 或纯规则 | LLM 灵活但不可控,规则兜底 |
| 检索 | n-gram 关键词匹配 | 向量检索 | 零依赖,向量需要 embedding 模型 |
| 预算 | 45% 给对话,80% 触发压缩 | 更高比例 / 更高阈值 | 保守但稳定 |
没有银弹。每个选择都牺牲了一些东西来换取简单性或零依赖。