3444 字
17 分钟
Memory 怎么设计 — 五个环节的实现拆解

本文基于 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()

三个细节值得注意:

  1. while 不是 if — 一条新消息可能很大,需要淘汰多条旧的才能腾出空间
  2. len > 1 的保护 — 至少保留 1 条消息,即使它超出预算。否则记忆完全清空,Agent 彻底失忆
  3. 被淘汰的不直接丢弃 — 而是追加到 _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 覆盖)内容精确匹配
存储内容对话、工具结果、摘要事实

两者的接口完全相同(都有 storeretrievesearchclear 等),但内在实现完全不同。这就是 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_messageretrieve_relevant,不需要知道内部有短期记忆、长期记忆、压缩器、检索器这些组件的存在。

每个环节都是 trade-off#

回过头看,五个环节的每个设计决策都不是”唯一的正确答案”,而是在特定约束下的取舍:

环节选择了放弃了原因
存入类型标签 + 粗略 token 估算无类型 + 精确 tokenizer精确需要外部依赖,粗略够用
短期存储FIFO 淘汰LRU 淘汰对话天然时间有序,FIFO 够用且简单
长期存储JSON 文件数据库零依赖,学习项目够用
压缩Map-Reduce(分组 5 条)一次性压缩分而治之更精确
事实提取LLM + 规则双重过滤纯 LLM 或纯规则LLM 灵活但不可控,规则兜底
检索n-gram 关键词匹配向量检索零依赖,向量需要 embedding 模型
预算45% 给对话,80% 触发压缩更高比例 / 更高阈值保守但稳定

没有银弹。每个选择都牺牲了一些东西来换取简单性或零依赖。

Memory 怎么设计 — 五个环节的实现拆解
https://jiqingjiang.github.io/posts/tech/agent/memory怎么设计/
作者
erode
发布于
2026-05-19
许可协议
CC BY-NC-SA 4.0