数据截至 (上游 commit db80b6a7c591)
Omni 多模态与 Cross 跨会话记忆
30 秒导读: 前三章讲的是 SimpleMem 的文本核心(压缩 → 检索 → 自进化)。本章讲两支外延: Omni-SimpleMem 把同一套"压缩优先"哲学从纯文本扩到文本 / 图像 / 音频 / 视频四模态; Cross 则换一个维度——把"一次对话记得住"升级成"跨会话记得住",从 Claude 这类客户端 收集会话、双存储落库、合并去噪、下次开场自动注入。两支各占本章一半,偏结构导览,细节留给 你按章末代码地图下钻。
本章不重复其它章:文本三阶段压缩见 01-text-pipeline,意图规划 + 三视图召回见 02-hybrid-retrieval,AutoResearch 自进化见 03-evolvemem, 总览见 index。
第一部分 · Omni-SimpleMem:多模态记忆
1. 这是什么(零基础也能懂)
- 一句话定义: Omni-SimpleMem 是 SimpleMem 的多模态版——你可以往它里塞文字、图片、音频、视频, 它统一压缩成"记忆原子",查询时按 token 预算把最相关的取出来。
- 解决什么问题: 纯文本记忆只能记"说过的话"。但 agent 越来越要处理截图、录音、录像。 直接把每一帧图、每一秒音频都存进去,既贵又全是冗余(连续 100 帧监控画面里 99 帧一模一样)。 Omni 要解决的是:在多模态洪流里,只留下真正有信息量的那部分,并且查得快、查得省。
- 一句话直觉: 把它当一个会自动跳过重复画面的智能录像机——场景没变就不记,变了才记一笔。
统一抽象:MAU。 四种模态进来后不各存各的,而是都被包装成同一种单元 MAU
(MultimodalAtomicUnit,多模态记忆原子)。文本、图、音、视在系统里"平权",只是 modality_type
字段不同(simplemem/multimodal/core/mau.py:17 class ModalityType、:119 class MultimodalAtomicUnit)。
最小使用示例(来自 simplemem/router.py:20-21 的文档示例):
# 示意,非源码
from simplemem import create
mem = create(mode="omni", data_dir="./my_memory")
mem.add_text("User loves hiking.", tags=["session_id:D1"])
mem.add_image("photo.jpg") # 图像:熵触发决定要不要存
result = mem.query("What did I see?") # 按 token 预算取回摘要,必要时展开
create(mode="omni") 背后是 simplemem/router.py:233 _init_omni——它建一个默认
OmniMemoryConfig,再实例化 OmniMemoryOrchestrator(:240 register(mode="omni", ...))。
2. 顶层全景(它大概怎么转)
一句话: 一个中央编排器 OmniMemoryOrchestrator 把三件事串起来——摄入(各模态处理器 + 熵触发)、
存储(MAU 库 + 混合向量库 + 事件 + 知识图谱)、检索(金字塔预览 + 混合召回 + 图谱增强)。
OmniMemoryOrchestrator (orchestrator.py:74)
├── 编排 processors + routing + parametric
│
add_text/image/ │
audio/video ─────┤
▼
┌──────── 摄入 ────────┐ ┌──────── 存储 ────────┐ ┌────── 检索 ──────┐
│ text/image/audio/ │ │ MAUStore │ │ PyramidRetriever │
│ video Processor │──▶│ HybridVectorStore │◀──│ (预览→展开) │
│ + 熵触发(triggers) │ │ (FAISS text+visual) │ │ + BM25Store │
│ 过滤冗余 │ │ BM25Store(关键词) │ │ + GraphRetriever │
└──────────────────────┘ │ KnowledgeGraph(图谱) │ │ + ParametricStore│
│ ParametricStore(蒸馏)│ └────────┬─────────┘
└──────────────────────┘ │
▼
query() 返回摘要
(+ 展开 / 图谱 / 参数化答案)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
OmniMemoryOrchestrator | 中央编排:摄入/存储/检索的总调度 | simplemem/multimodal/orchestrator.py:74 |
| 各模态 Processor | 把原始 text/image/audio/video 变成 MAU | simplemem/multimodal/processors/* |
| 熵触发 triggers | 摄入前的轻量过滤,判"值不值得存" | simplemem/multimodal/triggers/* |
PyramidRetriever | 金字塔检索:先摘要预览,再按需展开 | simplemem/multimodal/retrieval/pyramid_retriever.py:73 |
BM25Store | 关键词召回,补 FAISS 的语义盲区 | simplemem/multimodal/retrieval/bm25_store.py |
KnowledgeGraph + GraphRetriever | 跨模态实体图谱 + 多跳推理 | simplemem/multimodal/knowledge/* |
MemoryRouter | 在 episodic / semantic / hybrid 路径间做策略路由 | simplemem/multimodal/routing/router.py:30 |
ParametricMemoryStore + MemoryDistiller | 把重要记忆蒸馏/固化进参数 | simplemem/multimodal/parametric/* |
主线走一遍(高层):
add_image(...)→ImageProcessor.process→ 内部熵触发判要不要存 → 命中则产出 MAU (orchestrator.py:284add_image)。_store_mau把这枚 MAU 写进 MAU 库、向量库、事件、并抽实体入图谱 (orchestrator.py:525_store_mau)。query(...)→ 金字塔预览拿摘要 → BM25 补关键词 → 图谱补实体 → 参数化补高置信答案 (orchestrator.py:700query)。
重要:这套架构不是手工设计的。 README 明说:Omni 的架构是被一个自主研究流水线 (AutoResearch)"发现"出来的——它跑了约 50 组实验、自己诊断失败模式、提出架构改动、 甚至修数据管线 bug,全程内环无人(
README.md:309)。所以下面这三条原则更像是"被搜出来的 结论",而非拍脑袋的设计。AutoResearch 机制本身见 03-evolvemem。
3. 三条核心原则
Omni 把"压缩优先"落成三条原则(README.md:309)。下 面逐条讲。
3.1 选择性摄入(按模态熵驱动过滤)
要解决的小问题: 多模态数据里冗余极高——静止画面、静音音频、连续相似帧。全存=浪费。
思路: 在昂贵处理之前放一个轻量"闸门",先算这份数据的信息熵/变化量,不够就直接扔。 每种模态用自己的闸门:
- 视觉:用 CLIP/SigLIP 之类轻量编码器算与上一帧的余弦相似度,太像就丢
(
triggers/visual_trigger.py:25VisualEntropyTrigger,高阈值 0.9 判静止、低阈值 0.7 判显著变化)。 - 音频:用 VAD(Voice Activity Detection,语音活动检测) + 能量阈值,判有没有说话/有没有显著声音
(
triggers/audio_trigger.py:19AudioEntropyTrigger)。
统一契约: 所有触发器都继承 BaseTrigger,吐一个三态决策 ACCEPT / REJECT / UNCERTAIN
(triggers/base.py:11 TriggerDecision、:19 TriggerResult)。should_process() 只在 ACCEPT 时为真:
# 真实源码 triggers/base.py:34-36
def should_process(self) -> bool:
"""Check if this data should be processed."""
return self.decision == TriggerDecision.ACCEPT
这行是整条"选择性摄入"的开关:闸门说 REJECT,后面的 VLM/ASR 就一步都不跑。
一句话直觉: 把连续的视觉/音频流,压成稀疏的事件流—— "This converts continuous visual streams into sparse event streams"(
triggers/visual_trigger.py:36)。
3.2 渐进式检索(金字塔 + 混合召回)
要解决的小问题: 多模态内容(尤其原始图/视频)展开起来极耗 token。查询时不该一上来就把原图塞进上下文。
思路——像文件管理器: 先给缩略图(摘要),你点了才加载原图(raw)。PyramidRetriever
把检索分成层级,由便宜到贵(retrieval/pyramid_retriever.py:30 RetrievalLevel):
| 层级 | 内容 | 成本 |
|---|---|---|
SUMMARY | 只文本摘要 | 最便宜(默认预览) |
METADATA | 摘要 + 元数据 | 低 |
DETAILS | 完整 MAU 明细(不含原始内容) | 中 |
EVIDENCE | 含 raw 原始数据(原图/音频) | 最贵,仅按需 |
两阶段: retrieve_preview 先向量搜出一批 SUMMARY;真要细节才 expand 到更高层
(pyramid_retriever.py:114 retrieve_preview、:223 expand)。当调用方给了 token 预算,
retrieve_with_budget 会在预算内自动决定展开几条、展开到哪层——文本估 100 token/条、
非文本估 500 token/条,能塞下才展(pyramid_retriever.py:338 retrieve_with_budget、
逐条估价见 :366)。这就是任务里说的"金字塔 token 预算扩展"。
混合召回:FAISS + BM25。 语义向量(FAISS)擅长"意思相近",但会漏具体实体(书名、人名)。
所以 query() 在 FAISS 结果后追加 BM25 关键词命中,而且刻意不重排——保留 FAISS 的语义顺序,
BM25 只作补充上下文(orchestrator.py:761-791,注释 Do NOT re-sort 见 :764)。
# 真实源码 orchestrator.py:774-777(节选)
# Set-union: add BM25-only results after FAISS results (preserve FAISS order)
existing_ids = {item["id"] for item in result.items}
for mau_id, bm25_score in bm25_results:
if mau_id not in existing_ids:
...
这段是"混合"的落点:并集去重,FAISS 在前、BM25 补尾。
3.3 知识图谱增强(跨模态多跳)
要解决的小问题: 有些问题靠"相似度"答不了,得靠关系——"在咖啡店附近发生了什么?"要先找到 "咖啡店"这个实体,再 顺着关系图往外跳。
思路: 每存一枚 MAU,就抽出其中的实体和关系,喂进一张知识图谱。这张图跨模态——
文本里的"Alice"和图片里认出的"Alice"能链到同一个实体节点,provenance 记回源 MAU
(knowledge/knowledge_graph.py:145 KnowledgeGraph,类注释明说"Supports multimodal entity linking
(text + visual)""Enables cross-modal knowledge reasoning",:156-159)。
多跳遍历: 图谱支持"从某实体出发,N 跳内的邻居"(knowledge_graph.py:377 get_neighbors,
max_hops 参数,BFS 逐跳扩展见 :392)。检索侧 GraphRetriever.retrieve 默认 max_hops=2,
reason_multi_hop 可到 3 跳(knowledge/graph_retriever.py:78 retrieve、:263 reason_multi_hop)。
接进主流程: 摄入时 _store_mau 调 entity_extractor.extract(mau) 抽实体/关系再入图
(orchestrator.py:559-565);查询时 query() 调 graph_retriever.retrieve(...) 把图谱实体挂到结果上
(orchestrator.py:797-802)。
4. 编排器怎么把它们串起来
一句话: OmniMemoryOrchestrator.__init__ 是一张"装配清单"——它一次性 new 出所有子系统,
add_* 走摄入路径、query 走检索路径,中间共享同一套存储。
摄入端(add_* → _store_mau): 四个 add_text/image/audio/video 长得几乎一样——
都调对应 processor,result.success and result.mau 为真才 _store_mau
(orchestrator.py:252/284/316/348)。_store_mau 是扇出写入的单点:MAU 库、向量库
(按模态分 text/visual 分支,orchestrator.py:544-552)、事件管理器、知识图谱、consolidator
一次全更新(orchestrator.py:525)。
检索端(query): 顺序叠加四类证据(orchestrator.py:700):
- 金字塔预览 / 预算检索(
:744-759) - BM25 关键词并集(
:766-791) - 参数化高置信答案:
parametric_store.recall,置信度 > 0.8 才采纳(:793-795) - 图谱实体:
graph_retriever.retrieve(..., max_entities=5)(:797-802)
4.1 策略路由(routing)
要解决的小问题: 同一个查询,有时该走 episodic(逐条事件回忆)、有时该走 semantic (蒸馏后的语义记忆)、有时两者都要。谁来判?
思路: MemoryRouter 用一组分布特征做启发式判断——基尼系数
(gini_coefficient,衡量得分分布"尖不尖")、top1 分、top1 与 top2 的 gap
(routing/features.py:8 gini_coefficient、:51 compute_route_features)。规则大意
(routing/router.py:76 _heuristic_route):
- episodic 的 top1 明显更高(超
episodic_margin)→ 走BASELINE_EPISODIC(:83)。 - semantic 分布"又尖又高又拉得开"(gini/top1/gap 三阈值全过)才认为"语义好答";否则退回 episodic(
:92-104)。 - 两者接近 →
HYBRID两路都用(:106-113);semantic 明显更好 →SEMANTIC_ONLY(:115)。
安全阀: BenchmarkSafeGuard 能强制回退到基线 episodic 路径,保证评测可复现
(routing/policy.py:15 BenchmarkSafeGuard)。当前 query() 里 router/guard 已初始化但尚未接管选路——
代码注释明说完整路由集成"deferred to when SemanticStore has content",此刻查询仍走既有 episodic 路径以保证零行为变化
(orchestrator.py:730-735)。这是个诚实的"半成品接线"信号,读代码时别被 _memory_router 的存在误导。
4.2 记忆蒸馏 / 固化(parametric)
要解决的小问题: 反复被问到的高价值记忆,每次都去检索太浪费;能不能"背下来"?
思路——三件套:
MemoryConsolidator:给每枚记忆算重要性(含访问频率),追踪哪些值得固化 (parametric/consolidator.py:141MemoryConsolidator、:62compute_importance)。MemoryDistiller:把重要的 episodic 记忆蒸成 QA 对,攒够样本后用 LoRA 微调把知识写进模型参数 ——"Compresses episodic memories into parametric form",基 座 Qwen2.5-7B、增量 SFT (parametric/memory_distiller.py:91MemoryDistiller、:213should_distill、:221distill)。ParametricMemoryStore:推理侧免检索地直接召回蒸馏后的答案,并估置信度 (parametric/parametric_store.py:43ParametricMemoryStore、:80recall、:148_estimate_confidence)。
接线: 编排器构造时就 new 好 parametric_store 和 consolidator(orchestrator.py:157-158);
query() 里 recall 只在置信度 > 0.8 时才把答案并入结果(orchestrator.py:793-795)——
宁可不用,也不给低置信的"背诵答案"。
第二部分 · Cross:跨会话记忆
5. 这是什么(换个维度)
- 一句话定义: Cross 是一个跨会话记忆扩展——让 agent 记住上一次对话里发生了什么, 下次开场自动带上相关上下文。
- 和 Omni 的区别: Omni 解决"多模态",Cross 解决"跨时间"。Omni 是"记得住图片声音", Cross 是"记得住昨天那次对话的决定和踩过的坑"。
- 给谁用: 接 Claude Desktop / Cursor 这类 MCP 客户端,或任何 agent 框架——它在每次会话的
start/message/tool/stop/end 生命周期上挂钩,悄悄采集、落库,下次开场注入
(
cross/__init__.py:2-17模块 docstring 与 quick start)。
最小使用示例(来自 cross/__init__.py:10-17):
# 示意,非源码
from cross.orchestrator import create_orchestrator
orchestrator = create_orchestrator(project="my-project")
result = await orchestrator.start_session(
content_session_id="session-1",
user_prompt="Continue working on the API",
)
print(result["context"]) # 上一次会话里注入进来的跨会话上下文
6. 顶层全景(数据怎么流)
一句话: 采集事件 → 双存储落库 → 会话结束时提炼 → 定期合并去噪 → 下次开场按预算注入。
Claude/Cursor 等客户端
│ 生命周期钩子 (hooks.py: SessionHooks)
▼
start ─▶ message/tool ─▶ stop ─▶ end
│ │ │
│ │ │ finalize:提炼观察+摘要
▼ ▼ ▼
┌─────────────────────────────────────┐
│ CrossMemOrchestrator (orchestrator) │ ← 主入口,装配所有部件
│ ├── SessionManager (生命周期/提炼) │
│ ├── EventCollector (采集+脱敏) │
│ └── ContextInjector (开场注入) │
└───────────────┬─────────────────────┘
▼
┌────────── 双存储 ──────────┐
│ SQLiteStorage (结构化: │
│ 会话/事件/观察/摘要/链接) │
│ CrossSessionVectorStore │
│ (LanceDB:语义向量) │
└─────────────┬───────────────┘
▼
ConsolidationWorker (衰减/合并/剪枝)
▲
对外:api_http (REST) / api_mcp (MCP tools)
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
CrossMemOrchestrator | 主入口:装配存储/会话/注入/钩子 | cross/orchestrator.py:51 |
SessionManager | 会话生命周期 + 结束时提炼(可跑 SimpleMem 3 阶段管线) | cross/session_manager.py:166 |
EventCollector / ObservationExtractor | 采集消息/工具事件,提炼成观察 | cross/collectors.py:180 / :503 |
RedactionFilter | 脱敏(密钥、敏感文件等) | cross/collectors.py:111 |
SessionHooks / DefaultHooks | 生命周期钩子接口 + 默认实现 | cross/hooks.py:41 / :148 |
ContextInjector | 开场按预算装配上下文 bundle | cross/context_injector.py:118 |
ConsolidationWorker | 定期衰减/合并/剪枝 | cross/consolidation.py:110 |
SQLiteStorage | 结构化存储(6 张表) | cross/storage_sqlite.py:29 |
CrossSessionVectorStore | LanceDB 语义向量存储 | cross/storage_lancedb.py:79 |
MCPToolRegistry / REST app | 对外暴露 MCP 工具 / HTTP API | cross/api_mcp.py:31 / cross/api_http.py |