数据截至 (上游 commit 85532420387c)
MemReader:从原始对话/文档到结构化记忆
30 秒导读: MemReader 是 MemOS 的"入库前处理厂"。你给它一堆原始对话或文档,它把这些 长文本切成窗口、逐窗喂给 LLM 抽取成一条条"自洽的记忆陈述",每条都带上溯源(这句话 出自哪次对话/哪个文件)和向量(用于日后语义检索),最后交给下一章入图组织。 本章只讲抽取——不含入图、去重与检索。
1. 这是什么(零基础也能懂)
一句话定义: MemReader 是一条抽取管线(extraction pipeline)——输入是"人类看得懂的原始 素材" (聊天记录、PDF、纯文本),输出是"机器能入库的结构化记忆项"。
它解决什么问题。 原始对话又长又碎:一段 50 轮的聊天里,真正值得"记住"的可能只有三五件事。 你不能把整段对话原样塞进记忆库——那样既检索不动,也充满噪声。MemReader 干的就是提纯: 把长对话浓缩成若干条"独立成立、无需上下文也能读懂"的记忆陈述。
给谁用。 它不是给终端用户直接调的,而是被上层的 MOSCore / MemScheduler 在"往记忆库写东西" 之前调用(见 MOSCore 内核、MemScheduler)。
一条记忆长什么样。 抽取的产物是 TextualMemoryItem(见
src/memos/memories/textual/item.py:299)。用最小示例感受一下输入到输出:
# 示意,非源码:一次典型调用
reader.get_memory(
scene_data=[[ # 一个"场景" = 一段对话
{"role": "user", "content": "我下周三要交项目报告"},
{"role": "assistant", "content": "建议你每天留 2 小时专注写"},
]],
type="chat",
info={"user_id": "u1", "session_id": "s1"}, # 两个字段必填
mode="fine", # fine=调 LLM 精抽;fast=不调 LLM 走捷径
)
# 产出(简化):一条带溯源+向量的记忆
# TextualMemoryItem(
# memory="用户计划下周三提交项目报告,助手建议每天安排 2 小时专注写作。",
# metadata=... memory_type="LongTermMemory", tags=["报告","时间管理"],
# sources=[{type:"chat", role:"user", content:"我下周三要交项目报告"}, ...],
# embedding=[0.01, -0.3, ...]) # 向量,供日后检索
一句话直觉/类比: 把 MemReader 当成做读书笔记的人——它读完一整段原始材料,不是逐字抄, 而是提炼出几条"要点卡片",每张卡片背面还贴着"这条出自原文哪里"的便签(溯源)。
本节到此不碰底层。记住三件事:输入 raw、输出
TextualMemoryItem、每条都带溯源+向量。
2. 顶层全景(它大概怎么转)
主线一句话: get_memory 是唯一入口 → 先把千奇百怪的输入规整成标准格式 →
按 chat / doc 分派 → 切窗 → LLM 抽取 + 构建节点 → (可选)质量过滤 → 返回一批记忆。
怎么读下面这张图: 从上到下是一条数据的旅程,左侧是阶段名,右侧是负责的真实符号。
输入 scene_data (对话 / 文档 / 纯文本)
│
▼
┌──────────────────────────────┐
│ ① 入口与校验 │ get_memory() simple_struct.py:467
│ info 必带 user_id/session │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ② 归一化(反向兼容) │ coerce_scene_data() read_multi_modal/utils.py:207
│ 杂输入 → list[MessagesType]│ (文档路径→解析成文本;对话→注入 chat_time)
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ③ 场景整理 + 分派 │ _read_memory() simple_struct.py:647
│ chat → _process_chat_data │ get_scene_data_info() simple_struct.py:772
│ doc → _process_doc_data │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ④ 切窗(重叠滑窗) │ _iter_chat_windows() simple_struct.py:303
│ 按 token 上限切,窗间重叠 │ chunker.chunk() (doc 走 chunker)
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ⑤ LLM 抽取 + 构建节点 │ _get_llm_response() simple_struct.py:268
│ 一窗 → 若干条记忆项 │ _build_node/_make_memory_item
└──────────────┬──── ───────────┘
▼
┌──────────────────────────────┐
│ ⑥ 质量控制(可选/异步) │ filter_hallucination_in_memories() :591
│ 去幻觉 / 改写 │ rewrite_memories() :522
└──────────────┬───────────────┘
▼
list[list[TextualMemoryItem]] → 交给第 4 章入图
各部件一句话职责:
| 阶段 | 干什么 | 符号 · 文件 |
|---|---|---|
| 抽象基类 | 定义 MemReader 接口(get_memory / fine_transfer 等) | BaseMemReader · base.py:13 |
| 工厂 | 按 backend 名造出具体 reader(单例) | MemReaderFactory.from_config · factory.py:17 |
| 主实现 | 最常用的"对话+文档"抽取器 | SimpleStructMemReader · simple_struct.py:167 |
| 归一化 | 把 legacy/新格式统一成 MessagesType | coerce_scene_data · read_multi_modal/utils.py:207 |
| 语言判定 | 判中文/英文, 选对应 prompt 模板 | detect_lang · read_multi_modal/utils.py:334 |
| 容错解析 | 从 LLM 脏输出里抠出 JSON | parse_json_result · utils.py:38 |
三个具体 reader 的分工(都由工厂 backend_to_class 映射,factory.py:20):
| backend | 类 | 擅长 |
|---|---|---|
simple_struct | SimpleStructMemReader | 纯文本对话 + 文档(主力) |
strategy_struct | StrategyStructMemReader | 继承 simple,换更灵活的切窗策略与 prompt |
multimodal_struct | MultiModalStructMemReader | 图片/文件/工具轨迹等多模态消息 |
3. 核心原理(逐个机制,由浅入深)
3.1 入口与工厂:一切从 get_memory 开始
要解决的小问题: 上层不想关心"你内部是 simple 还是 multimodal",只想丢进素材、拿回记忆。
思路: 用工厂 + 统一接口解耦。BaseMemReader(base.py:13)是 抽象契约,规定每个 reader
必须实现 get_memory 和 fine_transfer_simple_mem;MemReaderFactory(factory.py:17)按配置里的
backend 字段挑一个具体类实例化,并用 @singleton_factory() 保证同配置复用同一实例(避免重复
加载 LLM/embedder 这些重资源)。
主流程的前几步是"守门"。 get_memory 先做三件校验,再把活交给 _read_memory:
# 示意,非源码:get_memory 的守门逻辑(对照 simple_struct.py:467)
if not scene_data: # 空输入直接拒
raise ValueError("scene_data is empty")
required = {"user_id", "session_id"} # info 必须带这两个字段
if required - set(info.keys()):
raise ValueError("missing required fields")
standard = coerce_scene_data(scene_data, type) # 归一化后再往下走
return self._read_memory(standard, type, info, mode, ...)
真实实现: 校验与分派见 get_memory src/memos/mem_reader/simple_struct.py:479-532——注意它
先归一化后处理,把"兼容各种历史输入格式"的脏活收敛在一个地方(下一节) 。
关键细节: mode 有两档。fine 会真的调 LLM 抽取;fast 走捷径——直接把整窗文本当成一条
记忆、不调 LLM(见 3.4)。type 字段虽仍在,但源码注释标了 (Deprecated),未来靠内容自识别。
3.2 归一化:把千奇百怪的输入捏成一种形状
要解决的小问题: 历史上 scene_data 有好几种长相——对话是 list[list[dict]]、文档是
list[str](可能是文件路径、URL、也可能就是纯文本)。下游不想为每种写一套。
思路: 在 coerce_scene_data(read_multi_modal/utils.py:207)里一次性归一成
list[MessagesType],后面所有阶段只面对这一种形状。
doc 分支的自动判别很实用(utils.py:271-333):对每个字符串猜它是什么——
一个字符串 s
├─ os.path.exists(s)? → 本地文件:用 markitdown 解析成文本 → {"type":"file", file:{filename, file_data}}
├─ 像 URL / 有扩展名 / 带路径分隔符? → 远端文件:原样留作 file 部件
└─ 都不是 → 纯文本:{"type":"text", "text": s}
chat 分支做一件小事:补时间戳。 若某组消息里没人带 chat_time,就用当前时间按
"%I:%M %p on %d %B, %Y" 格式(如 03:00 PM on 17 July, 2026)注入每条消息
(utils.py:256-264)。这个时间后面会进溯源,也会影响记忆里的"时间表述"。
语言判定顺带在这层: detect_lang(utils.py:339)先剥掉 role 前缀、时间戳、URL、id
这些噪声,再统计中文字符占比 >0.3 判 zh,否则 en——决定了后面用中文还是英文 prompt。
3.3 场景整理与切窗:为什么要"重叠切窗"
要解决的小问题: LLM 有上下文上限,一段超长对话不能整段塞。得切;但硬切会切断语义—— 一条信息横跨切口两侧就抽不出来了。
思路:两段式切分。 先 get_scene_data_info(simple_struct.py:784)做粗切:按消息条数
切成 ≤10 条一组、组间留 2 条重叠的场景(simple_struct.py:857-866);同时过滤掉非法消息
(只保留 role ∈ {user, assistant, system}、content 为字符串的项)。再在每组内用
_iter_chat_windows(simple_struct.py:315)做细切——按 token 上限滑窗。
为什么用 token 而不是条数细切: 一条消息可能很长也可能一个字,按条数切窗口大小不可控;
按 token 才能把每窗喂给 LLM 的量卡在预算内(默认 chat_window_max_tokens=1024,
simple_struct.py:207)。
重叠是关键。 当累计 token 超限时先 yield 当前窗,然后从队头弹出旧行、直到剩余 ≤ overlap
(默认 200 token),把这点"尾巴"留给下一窗当"开头",保证跨切口的信息不丢:
# 示意,非源码:重叠滑窗的核心(对照 _iter_chat_windows simple_struct.py:322-329)
if count_tokens(cur_text + line) > max_tokens and cur_text:
yield {"text": "".join(buf), "sources": sources.copy(), ...} # 先吐出这一窗
while buf and count_tokens("".join(buf)) > overlap: # 只保留最后 ~overlap token
buf.pop(0); sources.pop(0) # 其余弹掉
# 无论是否切窗,当前行都追加进 buf,并同步记一条 source
buf.append(line)
sources.append({"type":"chat", "index": idx, "role": role, "content": content, ...})
注意 sources 与 text 同步生长: 每追加一行文本,就追加一条溯源记录。这样一窗抽出的记忆,
天然知道自己源于哪几条原始消息——这是 3.5 "带溯源产出"的物质基础。
doc 的切窗不走这套。 文档在 _process_doc_data(simple_struct.py:871)里交给
chunker.chunk()(见 3.6),按字符/句子/markdown 结构切成 Chunk,每块再单独抽取。
3.4 LLM 抽取与节点构建:一窗文本 → 若干记忆项
要解决的小问题: 拿到一窗纯文本,怎么变成"几条要点 + 每条的类型/标签/标题"?
思路: 交给 LLM,用结构化 prompt 要求它输出固定 JSON,再解析成节点。
fine 模式(精抽) 的核心是 _get_llm_response(simple_struct.py:274):按语言选模板、把窗口
文本填进 ${conversation} 占位符、调 LLM、解析 JSON。LLM 被要求吐出这种形状(prompt 定义见
templates/mem_reader_prompts.py:26):
{
"memory list": [
{"key": "项目会议", "memory_type": "LongTermMemory",
"value": "在 2025-06-25 下午 3 点,Tom 与团队开会讨论新项目……",
"tags": ["项目", "时间表", "会议"]}
],
"summary": "一段 120–200 字、从用户视角的整体总结……"
}
_process_chat_data fine 分支(simple_struct.py:404-429)遍历 memory list,每条 value 用
_make_memory_item 变成一个 TextualMemoryItem,summary 则塞进节点的 background 字段做 背景。
两个构建器,职责微差:
| 构建器 | 用在哪 | 特点 |
|---|---|---|
_make_memory_item · simple_struct.py:220 | chat 抽取 | 实例方法;key 缺省时用 derive_key 兜底(取首句前 80 字);need_embed 可关 |
_build_node · simple_struct.py:105 | doc 抽取 | 模块级函数,便于丢进线程池并发;内含 generate→parse→build 三段各自 try/except |
容错解析是重点。 LLM 常吐出带 ```json 围栏、或半截的 JSON。parse_json_result
(utils.py:38)层层兜底:先抠代码块 → 找第一个 { → json.loads;失败就截到最后一个 }/]
再试 → 再不行就按括号计数补齐缺失的 }/] → 还失败就转义反斜杠重试。目标是"尽量抠出点东西",
抠不出返回 {}。
_safe_generate / _safe_parse(simple_struct.py:258 / :265) 是更薄的一层安全网:把
"调 LLM"和"解析"各自包一层 try,任一步炸了就返回 None,让上游走兜底路径——
_get_llm_response 在解析为空时,会退回"整窗当一条 UserMemory"的最小结果(simple_struct.py:294-313)。
⚠ 曾有一个真实的坑(键名不一致),新版已修复。 旧版兜底结果用的键是 "memory_list"(下划线),
而下游遍历读的是 "memory list"(空格,simple_struct.py:409、:446)——一旦走解析失败的兜底分支,
fine 模式这层就会因键名对不上而拿到空列表、丢掉那条兜底记忆(bug #1355:静默降级成
/product/add 返回 200 但零记忆写入)。新版兜底已改用带空格的 "memory list",并在源码注释里言明
「该键必须带空格,拼错会静默丢数据」。
fast 模式(捷径) 在 _process_chat_data 的 mode=="fast" 分支(simple_struct.py:374-403):
完全不调 LLM,直接把每个窗口的整段文本当成一条记忆,tags=["mode:fast"],并用线程池
(8 worker)并发 embed。快、省钱,但不做提炼——适合"先囫囵存下、日后再精炼"的场景。
3.5 抽取产出的是"带溯源 + embedding"的记忆项
这是本章最该记住的一点。 抽取不只产出"文字要点",而是每条都配齐两样元数据,才交给下一章入图:
- 溯源
sources:一串SourceMessage(item.py:16),记下这条记忆源自哪次对话/哪个文件—— chat 存{type,role,chat_time,content,index},doc 存{type:"doc", doc_path}。用于日后审计、 回溯、去重。溯源在切窗时就随文本同步攒好了(见 3.3)。 - 向量
embedding:_make_memory_item里对value调embedder.embed([value])[0](simple_struct.py:247),供第 5 章检索做语义召回。
其余元数据由 TreeNodeTextualMemoryMetadata(item.py:175)承载:memory_type 是个受限枚举
(LongTermMemory / UserMemory / SkillMemory / PreferenceMemory 等十种,item.py:178)、
status="activated"、confidence=0.99、background(那段 summary)等。
3.6 质量控制:改写与去幻觉
要解决的小问题: LLM 抽取会编造(说了对话里没有的事)或表述含糊(代词指代不清、缺主语)。 入库前得清一遍。
两道工序,都用 general_llm(非微调模型,simple_struct.py:182):
| 工序 | 符号 · 行 | 干什么 |
|---|---|---|
| 改写 | rewrite_memories · simple_struct.py:534 | 让 LLM 判断每条是否 need_rewrite,是则用 rewritten 文本替换,补全主语/消歧 |
| 去幻觉 | filter_hallucination_in_memories · simple_struct.py:603 | 让 LLM 对每条给 keep 判决,keep=false 的直接丢弃 |
两者都把"原始消息"和"抽出的记忆"一起塞进 prompt,让 LLM 对照原文逐条判(用 mem_idx 索引对齐),
再用 parse_rewritten_response / parse_keep_filter_response(utils.py:80 / :124)解析出
{idx: {...}}。解析失败或没判决 → 保守保留原记忆,不误删。
去幻觉默认不开,靠环境变量触发。 _read_memory 末尾仅当 SIMPLE_STRUCT_ADD_FILTER=="true"
才跑 filter_hallucination_in_memories(simple_struct.py:709)——说明它是可选的重活,常态由
异步链路(MemScheduler)承担而非同步阻塞抽取。
fine_transfer_simple_mem(simple_struct.py:749):二次精炼。 它吃的是已经存在的
TextualMemoryItem(比如 fast 模式先囫囵存下的),再调 LLM 精抽一遍(_process_transfer_chat_data,
simple_struct.py:431),复用同一套 _get_llm_response。这实现了"先快后精"的两阶段策略:
先 fast 抢时效,空闲时再 transfer 提质。
4. 多模态与专用读者(分工一览)
主力是 SimpleStructMemReader;另有两类专用读者与三个抽取子模块,各管一摊:
| 读者 / 子模块 | 入口符号 · 文件 | 负责 |
|---|---|---|
| StrategyStruct | StrategyStructMemReader · strategy_struct.py:34 | 继承 Simple,换切窗策略:支持按 content_length 或按 chunk_session/overlap 切,配不同 prompt |
| MultiModal | MultiModalStructMemReader · multi_modal_struct.py:34 | 处理图片/文件/工具轨迹等;有 image/tool/file 各类 parser,并做多模态记忆拼接与合并 |
| 多模态 parser 群 | read_multi_modal/ · __init__.py:16 | 每种消息一个 parser(user/assistant/system/tool/image/text/file),各支持 fast/fine 两档 |
| 偏好抽取 | process_preference_fine · read_pref_memory/process_preference_memory.py:235 | 从 QA 抽显式/隐式偏好,产出 PreferenceMemory |
| 技能抽取 | process_skill_memory_fine · read_skill_memory/process_skill_memory.py:985 | 从任务轨迹抽可复用技能,产出 SkillMemory,可上传 OSS |
MultiModal 的差异点: 它重写 get_scene_data_info 为"原样返回不切"(multi_modal_struct.py:1257),
把切分/拼接的复杂度移到 _process_multi_modal_data 内部——因为多模态消息(一张图 + 一段文字 +
一次工具调用)不能简单按 token 硬切。
上游依赖(一句话职责):
| 依赖 | 目录 | 职责 |
|---|---|---|
| chunkers | src/memos/chunkers/ | 把长文档文本切成带 token 计数的 Chunk(sentence/character/markdown/simple 四种策略) |
| parsers | src/memos/parsers/ | 用 markitdown 把 PDF/docx/pptx 等文件解析成纯文本 |
| embedders | src/memos/embedders/ | 把记忆 value 文本编码成向量(ark / ollama / sentence_transformer / universal_api 后端) |
5. 巧妙之处(可借鉴的技术)
- 溯源与文本同步生长。 切窗时每追加一行文本就同步追加一条 source(
simple_struct.py:343-353), 记忆天生自带"我出自哪几条原始消息",无需事后回溯对齐。 - 重叠滑窗按 token 弹队头。 yield 后只保留 ≤overlap token 的尾巴当下一窗开头
(
simple_struct.py:337-340),用极小重叠成本换"跨切口信息不丢"。 - 层层兜底的 JSON 解析。
parse_json_result(utils.py:38)对半截 JSON 补括号、对坏转义重试, 把"LLM 输出不规范"这个老大难收敛成一个健壮函数。 - 先快后精两阶段。 fast 不调 LLM 抢时效,
fine_transfer_simple_mem(simple_struct.py:749) 日后再精炼,把"实时性"和"质量"解耦。 - 保守的质量门。 改写/去幻觉在解析失败或无判决时默认保留(
simple_struct.py:637、:582), 宁可留噪声也不误删真信息。
6. 边界与局限(诚实)
- SimpleStruct 只吃纯文本对话。
get_scene_data_info明确丢弃str场景与非 {user/assistant/system} 角色的消息(simple_struct.py:799-835),多模态得换MultiModalStructMemReader。 - 兜底路径有键名 bug。 解析失败时 fine 模式因
memory_list(下划线)vsmemory list(空格) 键名 不一致而丢掉兜底记忆(见 3.4 的⚠),属静默降级。 - fast 模式不提炼。 整窗直接当一条记忆,噪声高,只适合先囫囵存、后 transfer。
- 去幻觉默认关闭。 需
SIMPLE_STRUCT_ADD_FILTER=true才在同步链路生效(simple_struct.py:709); 常态下抽取产物未经幻觉过滤,清洗责任落在下游/异步链路。 - 强依赖 LLM 守规矩。 抽取质量、JSON 结构全押在 LLM 上;
derive_key等兜底只能补key,补不了value语义。
7. 横向对比
同为 MemOS 内部环节,MemReader 是"写入前"的一段,与其它章各司其职:
- 它的产物直接喂给 04 图谱式明文记忆(上)——那里才做入图、去重、冲突。
- 记忆的类型与容器(三类记忆 + MemCube)定义见 01;本章产出的
memory_type枚举正来自那套体系。 - 被谁调度着调用,见 06 MemScheduler:异步摄取常在后台批量跑抽取。
跨库看,"raw → 结构化记忆"的抽取环节是记忆型 agent 的通用工序;MemReader 的特色是把溯源做进 切窗、并用受限枚举 + 图节点元数据为下游图组织提前铺路。
8. 代码地图(导航索引)
用符号名 grep 比行号抗漂移。所有引用 as-of sourceCommit。
| 主题 | 文件路径 | 符号 |
|---|---|---|
| 抽象接口 | src/memos/mem_reader/base.py | BaseMemReader |
| 工厂(单例) | src/memos/mem_reader/factory.py | MemReaderFactory.from_config · backend_to_class |
| 主实现 | src/memos/mem_reader/simple_struct.py | SimpleStructMemReader |
| 入口 + 守门 | src/memos/mem_reader/simple_struct.py | get_memory |
| 分派 | src/memos/mem_reader/simple_struct.py | _read_memory |
| 粗切(≤10 条/2 重叠) | src/memos/mem_reader/simple_struct.py | get_scene_data_info |
| 细切(token 滑窗) | src/memos/mem_reader/simple_struct.py | _iter_chat_windows |
| chat 处理(fast/fine) | src/memos/mem_reader/simple_struct.py | _process_chat_data |
| doc 处理 | src/memos/mem_reader/simple_struct.py | _process_doc_data |
| LLM 抽取 | src/memos/mem_reader/simple_struct.py | _get_llm_response |
| 节点构建 | src/memos/mem_reader/simple_struct.py | _make_memory_item · _build_node |
| 安全生成/解析 | src/memos/mem_reader/simple_struct.py | _safe_generate · _safe_parse |
| 改写 | src/memos/mem_reader/simple_struct.py | rewrite_memories |
| 去幻觉 | src/memos/mem_reader/simple_struct.py | filter_hallucination_in_memories |
| 二次精炼 | src/memos/mem_reader/simple_struct.py | fine_transfer_simple_mem |
| 输入归一化 | src/memos/mem_reader/read_multi_modal/utils.py | coerce_scene_data |
| 语言判定 | src/memos/mem_reader/read_multi_modal/utils.py | detect_lang |
| JSON 容错解析 | src/memos/mem_reader/utils.py | parse_json_result |
| 记忆项 / 溯源 / 元数据 | src/memos/memories/textual/item.py | TextualMemoryItem · SourceMessage · TreeNodeTextualMemoryMetadata |
| 策略切窗读者 | src/memos/mem_reader/strategy_struct.py | StrategyStructMemReader |
| 多模态读者 | src/memos/mem_reader/multi_modal_struct.py | MultiModalStructMemReader |
| 偏好抽取 | src/memos/mem_reader/read_pref_memory/process_preference_memory.py | process_preference_fine |
| 技能抽取 | src/memos/mem_reader/read_skill_memory/process_skill_memory.py | process_skill_memory_fine |