数据截至 (上游 commit db80b6a7c591)
文本核心:三阶段语义无损压缩管线
30 秒导读: SimpleMem 的文本这一支,做的是一件事——把源源不断的原始对话,变成一批能被检索、能被独立读懂的记忆条目。它不存原文,而是让 LLM 把每段对话重写成"消解了代词、把'明天'换成绝对日期"的自包含事实句(
MemoryEntry),写库时顺手去重,查询时再按意图召回。本章讲透这条主线的三个阶段,并端到端追一次add_dialogue → ... → ask的真实调用。
本章是 SimpleMem 文本支的主干。检索的细节(意图规划、三视图召回、反思)在 混合检索;底层存储(LanceDB、FTS、向量表)也在 混合检索 那条边界之外——本章只在"写进 VectorStore.add_entries""从 HybridRetriever.retrieve 取出"这两个接缝处点到即止,不重复它们的内容。总览见 index。
1. 这是什么(零基础也能懂)
一句话定义: SimpleMem 是给 LLM agent 用的"长期记忆",文本支负责把对话压缩成语义无损的记忆单元再存起来。
它解决什么问题。 假设你有个聊天 agent,用户跟它聊了几百轮。你不可能 每次都把全部历史塞进上下文窗口——又贵又超长。传统做法要么截断(丢信息),要么原样存数据库再全文检索(存的是"他明天来"这种一离开上下文就看不懂的碎片)。SimpleMem 的答案是:存之前先由 LLM 把每句话改写成一个能脱离上下文独立成立的事实。
"无损压缩"是什么意思。 这里的"压缩"不是把文本变短,而是去掉对上下文的依赖:
| 原始对话(有上下文依赖) | 记忆条目(自包含) |
|---|---|
| Alice: "Bob,明天下午 2 点老地方见" | "Alice 于 2025-11-15T14:30 建议在星巴克于 2025-11-16T14:00 与 Bob 会面讨论新产品" |
| Bob: "行,我准备材料" | "Bob 同意参会,并承诺准备相关材料" |
代词("他/它")、相对时间("明天")、省略的主语,全部被消解成绝对、显式的表达。这样每条记忆单独拎出来都读得懂,检索时也不会歧义。
用起来什么样。 一个最小真实用法(来自 examples/quickstart.py:13-19):
mem.add_dialogue("Alice", "Let's meet at 2pm tomorrow for the project review", "2025-11-15T14:30:00")
mem.add_dialogue("Bob", "Sure, I'll prepare the quarterly report by then", "2025-11-15T14:31:00")
mem.add_dialogue("Alice", "Great, also bring the client feedback from last week", "2025-11-15T14:32:00")
mem.finalize() # 冲刷缓冲、把不满一窗的对话也处理掉
answer = mem.ask("When is the meeting and what should Bob prepare?")
喂对话进去、finalize() 收尾、ask() 提问——中间的切分、抽取、去重、召回、生成全部自动完成。
一句话直觉。 把它想成一个"会记笔记的秘书":你在旁边随口聊,它不抄原话,而是把每件事整理成一条日后单独看也不会误会的备忘,并且随手把重复的划掉。
2. 顶层全景(它大概怎么转)
SimpleMem 的文本支对应论文的三个阶段,代码里写在 SimpleMemSystem 的类注释里(simplemem/text/system.py:20-25):
| 阶段 | 论文 | 干什么 | 入口 |
|---|---|---|---|
| ① 语义结构化压缩 | §3.1 | 滑窗切对话 → LLM 抽取成 MemoryEntry → 写库 | add_dialogue() |
| ② 在线语义合成 | §3.2 | 写入时借上一窗上下文去重、保证信息全覆盖 | (在 ① 内部完成) |
| ③ 意图感知检索规划 | §3.3 | 提问 → 三视图召回 → 生成答案 | ask() |
注意阶段 ② 不是一个独立步骤,而是嵌在阶段 ①的写入过程里——去重靠的是抽取时把"上一窗生成的条目"喂给 LLM 当参考。
部件职责
| 部件 | 干什么 | 文件 |
|---|---|---|
SimpleMemSystem | 门面:组装三大模块,暴露 add_dialogue/finalize/ask | simplemem/text/system.py:17 |
MemoryBuilder | 缓冲对话、滑窗切分、调 LLM 抽取、去重、写库 | simplemem/core/memory_builder.py:21 |
MemoryEntry / Dialogue | 记忆单元 / 原始对话的数据模型 | simplemem/core/models/memory_entry.py:13、:70 |
VectorStore | 三视图索引落库(本章边界外) | simplemem/core/database/vector_store.py:19 |
HybridRetriever | 意图规划 + 三视图召回(见第 2 章) | simplemem/core/hybrid_retriever.py |
AnswerGenerator | 从召回的 context 合成简洁答案 | simplemem/core/answer_generator.py:13 |
主线走一遍(不进代码)
┌──────────────── 写入侧(阶段 ① + ②)────────────────┐
add_dialogue ──▶ dialogue_buffer ──▶ 满一窗?──▶ process_window
(逐条进缓冲) (List[Dialogue]) │ │
否 │ 取窗口 + 上一窗条目(去重上下文)
│ ▼
finalize ──▶ _generate_memory_entries
(冲刷余量) │ LLM 抽取 → List[MemoryEntry]
▼
VectorStore.add_entries
(embedding + 三视图落库)
└───────────────────────────────────────────────────┘
┌──────────────── 查询侧(阶段 ③)──────────────────┐
ask(question) ──▶ HybridRetriever.retrieve ──▶ AnswerGenerator.generate_answer ──▶ 答案
(意图规划 + 三视图召回) (从 context 合成简洁答复)
└───────────────────────────────────────────────────┘
怎么读:上半是写入,一条对话从缓冲到落库;下半是查询,一个问题从召回到答案。两条路径通过 VectorStore 这个共享存储相接——写入侧往里存,查询侧从里取。
3. 核心原理(逐个机制,由浅入深)
3.1 数据模型:MemoryEntry 的三层视图
先看存的是什么,后面的一切都围绕它转。
MemoryEntry(simplemem/core/models/memory_entry.py:13)是一个 Pydantic 模型,论文把它的索引写成 I(m_k) = {s_k, l_k, r_k}——同一条记忆,三种索引视角:
| 视图 | 论文符号 | 字段 | 服务于哪种检索 |
|---|---|---|---|
| 语义层 Semantic | s_k | lossless_restatement(自包含事实句) | 稠密向量相似度(概念相近) |
| 词汇层 Lexical | l_k | keywords(核心关键词列表) | BM25 式精确关键词匹配 |
| 符号层 Symbolic | r_k | timestamp / location / persons / entities / topic | 元数据过滤(时间、人、地点等硬约束) |
关键在于 lossless_restatement(:22-25),它的字段描述直接写明约束:"Self-contained fact with Φ_coref (no pronouns) and Φ_time (absolute timestamps)"——没有代词、时间全是绝对值。这就是"语义无损"落到数据结构上的样子。
为什么要三层?因为一个问题可能需要不同的召回方式:"他们几点见面"靠语义,"关于 product XYZ 的事"靠关键词,"11 月 15 日发生了什么"靠符号层的时间过滤。一条记忆同时挂在三种索引上,查询侧才能三路并召(细节见 第 2 章)。
Dialogue(:70-81)则是原始对话的模型,只有 dialogue_id / speaker / content / timestamp 四个字段,__str__ 会渲染成 [时间] speaker: content 喂给抽取 prompt。它是"输入",MemoryEntry 是"产出"。
3.2 阶段①:滑窗切分 + 隐式语义密度门控 Φ_gate
要解决的小问题: 对话是流式来的,不能一条一条单独抽取(会丢跨句上下文,比如"行,我准备材料"里的"材料"指什么),也不能攒到最后一次性抽(太长、超 token)。所以要滑窗。
思路: 攒够一窗对话再送去抽取。窗口大小 WINDOW_SIZE 默认 40(simplemem/core/settings.py:26),窗口间保留 OVERLAP_SIZE(默认 2)条重叠,给下一窗留连续性上下文。
MemoryBuilder.__init__ 里把重叠换算成步长(memory_builder.py:42-45):
self.overlap_size = getattr(config, 'OVERLAP_SIZE', 0)
# step_size 是窗口每次前进多远;overlap 保留末尾 overlap_size 条给下一窗当上下文
self.step_size = max(1, self.window_size - self.overlap_size)
add_dialogue 把对话塞进 dialogue_buffer,一旦攒够一窗就触发 process_window(:58-66)。process_window 取头部一窗、但缓冲只前移 step_size(而非整窗),重叠因此自然产生(:141-142):
window = self.dialogue_buffer[:self.window_size] # 取整窗抽取
self.dialogue_buffer = self.dialogue_buffer[self.step_size:] # 只前移 step_size,尾部 overlap 留给下一窗
"隐式语义密度门控 Φ_gate" 是什么。 论文里 Φ_gate(W) → {m_k} 意为"过滤掉低信息密度的窗口/内容,只留有价值的记忆"。这里要诚实:代码里没有一个显式的打分/阈值函数去过滤窗口。门控是隐式的——它委托给了 LLM:抽取 prompt 要求"提取所有有价值的信息",低密度的寒暄自然不会被 LLM 抽成条目(_generate_memory_entries 的注释 :170-173 明确把自己标为 Φ_gate 的实现)。所以 Φ_gate 的"门"开在 prompt 语义里,不是一段可 grep 的阈值判断 (inferred:代码中无显式密度阈值,门控体现为 LLM 抽取行为)。
3.3 强制消解:Φ_coref(去代词)与 Φ_time(绝对时间)
要解决的小问题: 前面说记忆要"自包含"。可 LLM 默认会照抄"他明天来"。怎么强制它写成"Bob 于 2025-11-16 来"?
答案全在 prompt 里。 _build_extraction_prompt(memory_builder.py:229)把约束写死成硬要求(:246-256):
- Force Disambiguation:"Absolutely PROHIBIT using pronouns (he, she, it, they, this, that) and relative time (yesterday, today, last week, tomorrow)"——这就是 Φ_coref + Φ_time。
- Lossless Information:每条
lossless_restatement必须是完整、独立、可理解的句子。 - Complete Coverage:生成足够多的条目,确保对话里所有信息都被覆盖(这条同时服务阶段 ②的"信息全覆盖")。
prompt 里还塞了一个具体 few-shot 示例(:276-303),把"明天下午 2 点"演示成 2025-11-16T14:00:00,让模型照着做。抽取时 temperature=0.1(:213)压低随机性,追求稳定结构化输出。
原理演示(示意,非源码):
# 演示 Φ_coref / Φ_time 做的转换:把依赖上下文的表达变成绝对表达
raw = "[2025-11-15T14:30] Alice: Bob, 明天下午 2 点老地方见"
# LLM 按 prompt 约束改写为自包含事实:
entry = {
"lossless_restatement": "Alice 于 2025-11-15T14:30 建议在星巴克于 2025-11-16T14:00 与 Bob 会面",
"timestamp": "2025-11-16T14:00:00", # "明天下午2点" → 绝对时间(Φ_time)
"persons": ["Alice", "Bob"], # "Bob"、隐含的"Alice" 都显式化(Φ_coref)
"keywords": ["Alice", "Bob", "meeting"],
}
# 重点看:输出里再无"明天""老地方"这类离开上下文就失义的词
抽完后 _parse_llm_response(:308)把 LLM 返回的 JSON 数组逐个转成 MemoryEntry 对象(:325-333),缺字段用默认值兜底(keywords 空列表、timestamp None 等)。
3.4 阶段②:在线语义合成(写入即去重)
要解决的小问题: 滑窗有重叠(默认 2 条),相邻两窗会看到重复对话;同一件事也可能在对话里被反复提起。若不管,库里全是重复记忆。
思路——不是写后清理,而是写前避免。 抽取当前窗口时,把上一窗刚生成的条目当参考一起喂给 LLM,让它"看到已经记过什么,别再重复记"。
_generate_memory_entries 里构造这段 context(memory_builder.py:180-184):
context = ""
if self.previous_entries:
context = "\n[Previous Window Memory Entries (for reference to avoid duplication)]\n"
for entry in self.previous_entries[:3]: # 只放前 3 条,控制 prompt 长度
context += f"- {entry.lossless_restatement}\n"
这段 context 会拼进 prompt 的 {context} 占位(:241)。每处理完一窗,process_window 就把这窗的产出存进 self.previous_entries 作下一窗的上下文(:152):
self.previous_entries = entries # 存为下一窗的去重上下文
这就是论文说的 §3.2"Online Semantic Synthesis / intra-session consolidation":去重发生在会话内、写入的当下,而不是事后跑一个合并任务。注意它是启发式的——只喂前 3 条、且靠 LLM 自觉,不是精确的相似度去重;跨窗很远的重复它未必能防住 (inferred)。
3.5 阶段③入口:意图感知检索(本章只到接缝)
写入侧讲完,查询侧从 ask 起步(system.py:146-170):
def ask(self, question: str) -> str:
# 阶段 3:意图感知检索规划
contexts = self.hybrid_retriever.retrieve(question) # 三视图召回
answer = self.answer_generator.generate_answer(question, contexts) # 从 context 合成答案
return answer
retrieve(hybrid_retriever.py:58)内部会做意图规划、多查询、三视图并召、反思——这些是 第 2 章 的内容,本章不展开。
召回回来的是一批 MemoryEntry,交给 AnswerGenerator.generate_answer(answer_generator.py:22)。它做三件事:空则直接返回 "No relevant information found"(:33-34);把每条记忆的各视图字段格式化成可读 context(_format_contexts,:85);再用一个要求"先推理、后给极简短语答案、日期统一 'DD Month YYYY'"的 prompt(_build_answer_prompt,:113-153)让 LLM 抽出最终答案,解析 JSON 取 answer 字段(:70-72)。整个生成也有 3 次重试兜底(:56-83)。
至此,一条对话从进入缓冲到变成答案的完整闭环:
add_dialogue → dialogue_buffer → process_window → _generate_memory_entries
→ (LLM 抽取 + Φ_coref/Φ_time + 去重上下文) → VectorStore.add_entries
─────────────────────────────────────────────────────────────
ask → HybridRetriever.retrieve → AnswerGenerator.generate_answer → 答案
4. 深入实现:并行建库与它的坑
单条 add_dialogue 是顺序处理的,但批量灌历史数据时(比如导入几百轮对话),顺序调 LLM 太慢。于是有并行建库这一支,也是本章最容易踩坑的地方。
4.1 何时走并行
add_dialogues(memory_builder.py:68)按批量大小选路(:72-83):
if self.enable_parallel_processing and len(dialogues) > self.window_size * 2:
self.add_dialogues_parallel(dialogues) # 大批量:并行
else:
for dialogue in dialogues: # 小批量:顺序,攒够整窗就 process
self.add_dialogue(dialogue, auto_process=False)
if auto_process:
while len(self.dialogue_buffer) >= self.window_size:
self.process_window()
门槛是"批量 > 两倍窗口大小"才并行,小批量走顺序省得开线程。
4.2 并行的切窗与落库
add_dialogues_parallel(:85)先把整批塞进缓冲,再按 step_size 切成一组窗口(:99-109),不满一窗的尾巴单独作一个小批,然后清空缓冲、把所有窗口丢给线程池:
pos = 0
while pos + self.window_size <= len(self.dialogue_buffer):
window = self.dialogue_buffer[pos:pos + self.window_size]
windows_to_process.append(window)
pos += self.step_size # 按 step_size 前进,保留 overlap
remaining = self.dialogue_buffer[pos:] # 剩余不满一窗的,单独成批
if remaining:
windows_to_process.append(remaining)
self.dialogue_buffer = [] # 全部交出去,清空缓冲
_process_windows_parallel(:338)用 ThreadPoolExecutor(默认 MAX_PARALLEL_WORKERS,settings 里是 16——settings.py:34)并发跑每个窗口的抽取,as_completed 收集结果,最后一次性批量落库(:363-367)。
4.3 三个坑
坑一:并行模式下去重上下文失真。 顺序模式里 previous_entries 是"严格的上一窗"。但并行模式所有 worker 共享同一份 previous_entries(:388,worker 里的 context 构造),各窗看到的是同样的旧上下文,而不是各自真正的前一窗——注 释也承认这点(:386"shared across all workers")。跑完后 previous_entries 被更新成"最后 10 条"(:371),语义已不同于顺序模式的"上一窗全部"。结论:并行更快,但去重不如顺序精确。
坑二:异常回退无法天真地重叠重放。 并行途中若抛异常,except 分支要回退到顺序处理(:118-130)。难点在于缓冲可能已在 :109 被清空,重叠窗口也没法简单"再堆回去"。代码用 pre_existing 快照 + 判断缓冲是否为空来还原状态:
if not self.dialogue_buffer: # 缓冲已被清空(异常发生在清空之后)
self.dialogue_buffer = pre_existing + list(dialogues) # 还原:原有的 + 本次要处理的
# process_window() 用 step_size,重叠在这里被正确处理
while len(self.dialogue_buffer) >= self.window_size:
self.process_window()
注意它只在缓冲为空时才还原(:126);若异常发生在清空之前,缓冲里已是 pre_existing + dialogues,原样保留即可。这是为了避 免"双倍塞入"。
坑三:finalize() 与并行的关系。 finalize()(system.py:139-144)只调 process_remaining(),顺序处理缓冲里剩下的对话。在并行路径里,余量在 :106-108 已作为 remaining 批一起处理掉了,所以并行模式下 finalize() 通常无事可做——它是顺序模式的安全网,把最后不满一窗的对话冲刷掉(process_remaining,memory_builder.py:157-168)。忘了调 finalize(),顺序模式下尾部不满一窗的对话就永远留在缓冲里、进不了库。
5. 巧妙之处(可借鉴的技术)
- "压缩 = 去上下文依赖"而非"变短"。 把每条记忆重写成自包含事实句(
lossless_restatement),从根上避免了"检索到一条离开原文就看不懂的碎片"。锚点:memory_entry.py:22-25。 - 去重前置而非后清理。 把上一窗产出喂给下一窗当参考,让 LLM 在生成时就避开重复,省掉一个昂贵的事后相似度合并阶段。锚点:
memory_builder.py:180-184、:152。 - 约束写进 prompt 而非后处理。 Φ_coref / Φ_time 不是用正则去清洗代词和相对时间,而是把"禁止代词、时间必须绝对"作为硬要求写进抽取 prompt,并配 few-shot 示例。锚点:
memory_builder.py:246-303。 - 一条记忆三视图。 同一条
MemoryEntry同时挂语义/词汇/符号三种索引,让查 询侧能按问题类型选择召回通道。锚点:memory_entry.py:17、:22-53。 - 步长即重叠。 用
step_size = window_size - overlap_size一个减法就实现了滑窗重叠,取窗取整窗、前移只走 step,重叠自然产生。锚点:memory_builder.py:45、:141-142。
6. 边界与局限(诚实)
- Φ_gate 没有显式阈值。 "隐式语义密度门控"完全委托给 LLM 的抽取判断,代码里没有可调的密度阈值或过滤函数——低价值内容能否被滤掉,取决于模型和 prompt。
- 去重是启发式、非精确。 只喂前 3 条
previous_entries(顺序)/ 最后 10 条(并行)当参考,靠 LLM 自觉。跨越多窗的远距离重复、或 LLM 判断失误,都可能漏去重。 - 并行牺牲去重精度。 见 §4.3 坑一:worker 共享同一份旧上下文,去重效果弱于顺序模式。追求记忆干净时应关掉并行。
- 强依赖 LLM 输出合规。 抽取和答案生成都要求 LLM 返回合法 JSON,虽有 3 次重试(
memory_builder.py:202-227、answer_generator.py:55-83),但连续失败会返回空条目 / "Failed to generate answer"——这一窗的信息就丢了。 - 忘调
finalize()会漏尾部对话。 顺序模式下不满一窗且未 finalize 的对话永远进不了库(§4.3 坑三)。
7. 横向对比
同 shelf 的其它记忆/上下文方案,与 SimpleMem 文本支的取舍差异:
- 对"原始 vs 改写"的取舍: SimpleMem 存的是 LLM 改写后的自包含事实,不存原文;许多向量记忆方案直接切原文块存,靠检索时的上下文拼接补义。SimpleMem 用"写入时多花一次 LLM 抽取"换"检索时无歧义"。
- 对"去重时机"的取舍: SimpleMem 写入即去重(§3.4);另有方案(如本库 EvolveMem 支)把整合放到后台演化阶段做。前者省事后合并、但精度弱;后者更彻底、但更重。
同库其它章:总览见 index;查询侧见 混合检索;自进化检索见 EvolveMem;多模态与跨会话见 Omni 与 Cross。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 系统门面 / 三阶段编排 | simplemem/text/system.py:17 | SimpleMemSystem |
| 加单条对话 / 生成 dialogue_id | simplemem/text/system.py:112 | add_dialogue |
| 收尾冲刷缓冲 | simplemem/text/system.py:139 | finalize → process_remaining |
| 提问入口(阶段③) | simplemem/text/system.py:146 | ask |
| 记忆构建器 | simplemem/core/memory_builder.py:21 | MemoryBuilder |
| 滑窗步长/重叠 | simplemem/core/memory_builder.py:45 | step_size / overlap_size |
| 处理一窗(切窗+抽取+存) | simplemem/core/memory_builder.py:132 | process_window |
| LLM 抽取(Φ_gate) | simplemem/core/memory_builder.py:170 | _generate_memory_entries |
| 抽取 prompt(Φ_coref/Φ_time/去重) | simplemem/core/memory_builder.py:229 | _build_extraction_prompt |
| 并行建库入口 | simplemem/core/memory_builder.py:85 | add_dialogues_parallel |
| 并行切窗+线程池落库 | simplemem/core/memory_builder.py:338 | _process_windows_parallel |
| 记忆单元(三视图) | simplemem/core/models/memory_entry.py:13 | MemoryEntry |
| 原始对话模型 | simplemem/core/models/memory_entry.py:70 | Dialogue |
| 答案合成 | simplemem/core/answer_generator.py:22 | generate_answer |
| 写库接缝(→ 第 2 章) | simplemem/core/database/vector_store.py:55 | add_entries |
| 召回接缝(→ 第 2 章) | simplemem/core/hybrid_retriever.py:58 | retrieve |
| 默认配置(窗口/topK/并行数) | simplemem/core/settings.py:12 | _DEFAULTS |