跳到主要内容

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:16
主实现最常用的"对话+文档"抽取器SimpleStructMemReader · simple_struct.py:167
归一化把 legacy/新格式统一成 MessagesTypecoerce_scene_data · read_multi_modal/utils.py:207
语言判定判中文/英文,选对应 prompt 模板detect_lang · read_multi_modal/utils.py:334
容错解析从 LLM 脏输出里抠出 JSONparse_json_result · utils.py:38

三个具体 reader 的分工(都由工厂 backend_to_class 映射,factory.py:19):

backend擅长
simple_structSimpleStructMemReader纯文本对话 + 文档(主力)
strategy_structStrategyStructMemReader继承 simple,换更灵活的切窗策略与 prompt
multimodal_structMultiModalStructMemReader图片/文件/工具轨迹等多模态消息

3. 核心原理(逐个机制,由浅入深)

3.1 入口与工厂:一切从 get_memory 开始

要解决的小问题: 上层不想关心"你内部是 simple 还是 multimodal",只想丢进素材、拿回记忆。

思路:工厂 + 统一接口解耦。BaseMemReader(base.py:13)是抽象契约,规定每个 reader 必须实现 get_memoryfine_transfer_simple_mem;MemReaderFactory(factory.py:16)按配置里的 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:467-520——注意它 先归一化后处理,把"兼容各种历史输入格式"的脏活收敛在一个地方(下一节)。

关键细节: 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:266-328):对每个字符串猜它是什么——

一个字符串 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:251-259)。这个时间后面会进溯源,也会影响记忆里的"时间表述"。

语言判定顺带在这层: detect_lang(utils.py:334)先剥掉 role 前缀、时间戳、URL、id 这些噪声,再统计中文字符占比 >0.3 判 zh,否则 en——决定了后面用中文还是英文 prompt。

3.3 场景整理与切窗:为什么要"重叠切窗"

要解决的小问题: LLM 有上下文上限,一段超长对话不能整段塞。得切;但硬切会切断语义—— 一条信息横跨切口两侧就抽不出来了。

思路:两段式切分。get_scene_data_info(simple_struct.py:772)做粗切:按消息条数 切成 ≤10 条一组、组间留 2 条重叠的场景(simple_struct.py:845-854);同时过滤掉非法消息 (只保留 role ∈ {user, assistant, system}、content 为字符串的项)。再在每组内用 _iter_chat_windows(simple_struct.py:303)做细切——按 token 上限滑窗。

为什么用 token 而不是条数细切: 一条消息可能很长也可能一个字,按条数切窗口大小不可控; 按 token 才能把每窗喂给 LLM 的量卡在预算内(默认 chat_window_max_tokens=1024, simple_struct.py:201)。

重叠是关键。 当累计 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, ...})

注意 sourcestext 同步生长: 每追加一行文本,就追加一条溯源记录。这样一窗抽出的记忆, 天然知道自己源于哪几条原始消息——这是 3.5 "带溯源产出"的物质基础。

doc 的切窗不走这套。 文档在 _process_doc_data(simple_struct.py:859)里交给 chunker.chunk()(见 3.6),按字符/句子/markdown 结构切成 Chunk,每块再单独抽取。

3.4 LLM 抽取与节点构建:一窗文本 → 若干记忆项

要解决的小问题: 拿到一窗纯文本,怎么变成"几条要点 + 每条的类型/标签/标题"?

思路: 交给 LLM,用结构化 prompt 要求它输出固定 JSON,再解析成节点。

fine 模式(精抽) 的核心是 _get_llm_response(simple_struct.py:268):按语言选模板、把窗口 文本填进 ${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:392-417)遍历 memory list,每条 value_make_memory_item 变成一个 TextualMemoryItem,summary 则塞进节点的 background 字段做背景。

两个构建器,职责微差:

构建器用在哪特点
_make_memory_item · simple_struct.py:214chat 抽取实例方法;key 缺省时用 derive_key 兜底(取首句前 80 字);need_embed 可关
_build_node · simple_struct.py:105doc 抽取模块级函数,便于丢进线程池并发;内含 generate→parse→build 三段各自 try/except

容错解析是重点。 LLM 常吐出带 ```json 围栏、或半截的 JSON。parse_json_result (utils.py:38)层层兜底:先抠代码块 → 找第一个 {json.loads;失败就截到最后一个 }/] 再试 → 再不行就按括号计数补齐缺失的 }/] → 还失败就转义反斜杠重试。目标是"尽量抠出点东西", 抠不出返回 {}

_safe_generate / _safe_parse(simple_struct.py:252 / :259) 是更薄的一层安全网:把 "调 LLM"和"解析"各自包一层 try,任一步炸了就返回 None,让上游走兜底路径—— _get_llm_response 在解析为空时,会退回"整窗当一条 UserMemory"的最小结果(simple_struct.py:288-300)。

⚠ 一个真实的坑(键名不一致)。 兜底结果用的键是 "memory_list"(下划线,simple_struct.py:290), 但下游遍历读的是 "memory list"(空格,simple_struct.py:397:434)。正常 LLM 路径吐的是带空格的 "memory list" 所以没事;可一旦走解析失败的兜底分支,fine 模式这层就会因键名对不上而拿到空 列表、丢掉那条兜底记忆。属于"静默降级"的暗坑。

fast 模式(捷径)_process_chat_datamode=="fast" 分支(simple_struct.py:362-391): 完全不调 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 里对 valueembedder.embed([value])[0] (simple_struct.py:241),供第 5 章检索做语义召回。

其余元数据由 TreeNodeTextualMemoryMetadata(item.py:175)承载:memory_type 是个受限枚举 (LongTermMemory / UserMemory / SkillMemory / PreferenceMemory 等十种,item.py:178)、 status="activated"confidence=0.99background(那段 summary)等。

3.6 质量控制:改写与去幻觉

要解决的小问题: LLM 抽取会编造(说了对话里没有的事)或表述含糊(代词指代不清、缺主语)。 入库前得清一遍。

两道工序,都用 general_llm(非微调模型,simple_struct.py:182):

工序符号 · 行干什么
改写rewrite_memories · simple_struct.py:522让 LLM 判断每条是否 need_rewrite,是则用 rewritten 文本替换,补全主语/消歧
去幻觉filter_hallucination_in_memories · simple_struct.py:591让 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:697)——说明它是可选的重活,常态由 异步链路(MemScheduler)承担而非同步阻塞抽取。

fine_transfer_simple_mem(simple_struct.py:737):二次精炼。 它吃的是已经存在的 TextualMemoryItem(比如 fast 模式先囫囵存下的),再调 LLM 精抽一遍(_process_transfer_chat_data, simple_struct.py:419),复用同一套 _get_llm_response。这实现了"先快后精"的两阶段策略: 先 fast 抢时效,空闲时再 transfer 提质。


4. 多模态与专用读者(分工一览)

主力是 SimpleStructMemReader;另有两类专用读者与三个抽取子模块,各管一摊:

读者 / 子模块入口符号 · 文件负责
StrategyStructStrategyStructMemReader · strategy_struct.py:38继承 Simple,换切窗策略:支持按 content_length 或按 chunk_session/overlap 切,配不同 prompt
MultiModalMultiModalStructMemReader · 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:214从 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:1249), 把切分/拼接的复杂度移到 _process_multi_modal_data 内部——因为多模态消息(一张图 + 一段文字 + 一次工具调用)不能简单按 token 硬切。

上游依赖(一句话职责):

依赖目录职责
chunkerssrc/memos/chunkers/把长文档文本切成带 token 计数的 Chunk(sentence/character/markdown/simple 四种策略)
parserssrc/memos/parsers/用 markitdown 把 PDF/docx/pptx 等文件解析成纯文本
embedderssrc/memos/embedders/把记忆 value 文本编码成向量(ark / ollama / sentence_transformer / universal_api 后端)

5. 巧妙之处(可借鉴的技术)

  • 溯源与文本同步生长。 切窗时每追加一行文本就同步追加一条 source(simple_struct.py:331-341), 记忆天生自带"我出自哪几条原始消息",无需事后回溯对齐。
  • 重叠滑窗按 token 弹队头。 yield 后只保留 ≤overlap token 的尾巴当下一窗开头 (simple_struct.py:325-328),用极小重叠成本换"跨切口信息不丢"。
  • 层层兜底的 JSON 解析。 parse_json_result(utils.py:38)对半截 JSON 补括号、对坏转义重试, 把"LLM 输出不规范"这个老大难收敛成一个健壮函数。
  • 先快后精两阶段。 fast 不调 LLM 抢时效,fine_transfer_simple_mem(simple_struct.py:737) 日后再精炼,把"实时性"和"质量"解耦。
  • 保守的质量门。 改写/去幻觉在解析失败或无判决时默认保留(simple_struct.py:625:582), 宁可留噪声也不误删真信息。

6. 边界与局限(诚实)

  • SimpleStruct 只吃纯文本对话。 get_scene_data_info 明确丢弃 str 场景与非 {user/assistant/system} 角色的消息(simple_struct.py:787-823),多模态得换 MultiModalStructMemReader
  • 兜底路径有键名 bug。 解析失败时 fine 模式因 memory_list(下划线)vs memory list(空格) 键名不一致而丢掉兜底记忆(见 3.4 的⚠),属静默降级。
  • fast 模式不提炼。 整窗直接当一条记忆,噪声高,只适合先囫囵存、后 transfer。
  • 去幻觉默认关闭。SIMPLE_STRUCT_ADD_FILTER=true 才在同步链路生效(simple_struct.py:697); 常态下抽取产物未经幻觉过滤,清洗责任落在下游/异步链路。
  • 强依赖 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.pyBaseMemReader
工厂(单例)src/memos/mem_reader/factory.pyMemReaderFactory.from_config · backend_to_class
主实现src/memos/mem_reader/simple_struct.pySimpleStructMemReader
入口 + 守门src/memos/mem_reader/simple_struct.pyget_memory
分派src/memos/mem_reader/simple_struct.py_read_memory
粗切(≤10 条/2 重叠)src/memos/mem_reader/simple_struct.pyget_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.pyrewrite_memories
去幻觉src/memos/mem_reader/simple_struct.pyfilter_hallucination_in_memories
二次精炼src/memos/mem_reader/simple_struct.pyfine_transfer_simple_mem
输入归一化src/memos/mem_reader/read_multi_modal/utils.pycoerce_scene_data
语言判定src/memos/mem_reader/read_multi_modal/utils.pydetect_lang
JSON 容错解析src/memos/mem_reader/utils.pyparse_json_result
记忆项 / 溯源 / 元数据src/memos/memories/textual/item.pyTextualMemoryItem · SourceMessage · TreeNodeTextualMemoryMetadata
策略切窗读者src/memos/mem_reader/strategy_struct.pyStrategyStructMemReader
多模态读者src/memos/mem_reader/multi_modal_struct.pyMultiModalStructMemReader
偏好抽取src/memos/mem_reader/read_pref_memory/process_preference_memory.pyprocess_preference_fine
技能抽取src/memos/mem_reader/read_skill_memory/process_skill_memory.pyprocess_skill_memory_fine