聊多模态长期记忆,很多时候大家的第一反应都是“怎么把东西找回来”。图像找相似、视频捞片段、语音转文本之后再检索,流程看起来很完整,但真把系统扔到业务里用一个月,你会发现最尴尬的问题根本不是“找不到”,而是“算不出来”。用户想知道的不只是“哪段监控记录了那位顾客”,而是“这位顾客过去三个月是不是越来越倾向晚上来”;不只是“员工培训后有没有犯错视频”,而是“培训后一周内的操作失误率到底降没降”。这种问题,传统检索式记忆给不出答案。
我去年做门店运营分析 Agent 时被这个问题卡了很久,后来把记忆层整体重写,项目代号叫 adamm(Adaptive Multi-Modal Decision & Memory Module)。这套东西的核心认知只有一句话:多模态记忆不只要“找得到”,还要“算得出来”。这篇就把 adamm 的架构思路、关键模块和我踩过的坑完整梳理一遍,给正在做多模态 Agent、多模态记忆系统的朋友一个可直接参考的落地框架。
1. 多模态记忆的尴尬:检索能力很强,分析能力为零
1.1 传统“检索式记忆”只能给你素材,不能给你答案
大部分多模态记忆系统做的是这么一件事:把视频、音频、图片、文本全部过一遍编码器,得到 embedding,存进向量库,查询时用 query 的 embedding 去 Top-K 召回。这种范式在“找相似”场景下非常好用,比如“有没有和这张商品图风格类似的陈列照片”——召回结果直接就是答案。
可一旦问题变成分析型,这套方式就失效了。用户问“过去三个月进店高峰期有没有变化”,情感上我们希望系统直接给出“从晚上7点变成晚上8点,整体后移约1小时,建议关注夜宵档”。但向量检索返回的是 50 段高相似度的视频切片,每段里都有顾客进店的画面,却没有一段能代表“高峰期的整体变化”。检索把“证据”和“答案”混为一谈,默认只要材料够了,答案会自动浮现,但实际并不会。
这个问题在纯文本 RAG 里相对容易掩盖,因为文本本身有概括性,检索到几篇文档还能拼凑观点。多模态数据不一样:一段视频包含大量瞬时信息,截取哪一帧、截多长时间、和哪条音频对齐,都会影响语义。检索模块如果只做相似度匹配,根本没有能力对一堆异构片段做时间维度的聚合或者跨模态交叉验证。“找得到”是记忆的仓储能力,“算得出来”才是记忆的分析能力,两者缺一不可。
1.2 长期记忆的真正价值不是存档,而是建模
长期记忆的价值曲线会随着时间推移发生变化。系统刚上线时,每次检索都能命中最近几天的数据,用户觉得“还挺聪明”;等数据积累到几个月甚至一年,用户开始期待系统能回答“照这个趋势发展下去,下个月库存该怎么备”“哪个环节的异常频率明显升高”。这些问题的共同点是:它们依赖时间轴上的统计规律、事件之间的共现关系、以及多模态信号互相印证后的结论。
长期记忆如果只是“存了很多东西”,那就和网盘没有本质区别。真正的长期记忆应该是一个不断更新的世界模型:它要能把零散的多模态事件压缩成可查询的知识,然后在需要时对这些知识进行聚合、比较、趋势推断,最后输出可执行的分析结果。这也决定了记忆系统不能只有一个向量索引,它还需要结构化属性层、事件模型、分析算子层,以及一套把自然语言查询翻译成分析计划的能力。adamm 就是按这个思路设计的。
2. 先把记忆分清楚:情景、语义、程序性三层架构
2.1 三层记忆到底各管什么
设计 adamm 时,我参考了认知科学里常见的记忆分类,把系统记忆拆成三层:情景记忆、语义记忆、程序性记忆。
- 情景记忆(Episodic Memory)保存具体的多模态事件:某年某月某日某分,某位顾客在哪个货架前停留了多少秒,收银台当时排队几个人,环境声音里有没有促销广播。事件是带时间戳、带原始数据引用的“一次发生”,强调时间、地点、人物、事件。
- 语义记忆(Semantic Memory)保存从事件中抽象出来的概念知识:比如“雨天客单价显著高于晴天”“周末下午 4 点后进店人数开始上升”“高毛利商品在试吃活动后销量提升约 18%”。语义记忆不带“某一次”的标签,而是跨事件归纳后的结论。
- 程序性记忆(Procedural Memory)保存可复用的分析方法与操作流程:比如“遇到销售趋势类问题,应该先按周聚合再做差分”“检测到客流量异常时,要同步检查天气、促销活动、周边路况事件”。程序性记忆让系统知道“怎么算”。
三层并不孤立。情景记忆是原材料,语义记忆是加工产物,程序性记忆是加工方法。传统多模态记忆系统往往只做了第一层,甚至第一层都只做了一半。adamm 把三层都显式建模,目的就是让“找得到”和“算得出来”各有归处:检索主要落在情景记忆和语义记忆上,而计算引擎依赖程序性记忆中的算子和编排规则。
2.2 统一多模态事件格式(MEV)是整个系统的基础
三层记忆要想协同工作,底层数据结构必须先统一。我在 adamm 里定义了多模态事件格式(Multi-modal Event Vector,MEV),所有输入数据在写入前都会被转成这种格式。一个典型事件长这样:
{ "event_id": "evt_10234", "timestamp": "2025-11-03T14:32:00Z", "modality": ["video", "audio", "text"], "embedding": { "video": [0.12, -0.04, 0.37], "audio": [0.55, 0.21, -0.09], "text": [0.47, -0.33, 0.08] }, "entities": ["customer_123", "store_042", "cashier_07"], "relations": [ {"head": "customer_123", "type": "browses", "tail": "product_88"} ], "stats": { "person_count": 3, "speech_emotion": "neutral", "weather": "rain" }, "raw_ref": [ "s3://store042/20251103/14_32.mp4", "s3://store042/20251103/14_32.wav" ] }用“事件”而不是“片段”作为原子单位,是因为分析算子需要一个可对齐的最小语义单元。视频里截出来的 10 秒片段如果不绑定音频、时间戳、实体关系,就无法回答“这个人在做什么”“当时环境如何”。MEV 把多模态信号绑定到同一个事件 ID 下,后续做共现分析、时间聚合、跨模态交叉验证时,能直接用 event_id 做 Join,不用再纠结不同模态之间的对齐问题。
2.3 写入与合并:怎么防止长期记忆变成垃圾场
记忆系统最怕的是“垃圾进、垃圾出”。多模态数据量大,如果每个原始片段都无脑存进去,系统很快就会变得又慢又臃肿。adamm 的写入流程分四步:
- 多模态编码:视频抽帧后过 CLIP/ViT 编码,音频切段后过 Whisper 编码,文本直接过文本编码器。各模态 embedding 都归一化到同一维度。
- 事件切分:按场景切换点和滑动窗口把连续流切成候选事件。视频画面发生明显变化、音频静音/人声切换、文本语义转折,都会触发新事件。
- 质量筛选与去重:对候选事件做质量打分,纯噪声音频、黑屏视频、无实体关联的文本会被降权或丢弃。相似度超过阈值的事件自动合并,保留信息量最丰富的一份,并把冗余 raw_ref 追加到原事件的引用列表里。
- 增量聚合:标记为同类的高频事件定期触发语义记忆更新,比如“连续三个工作日 14:00 到 15:00 客流量都高于均值”,这条会被写入语义记忆层。
这套流程跑稳之后,情景记忆不会无限膨胀,语义记忆又能持续沉淀,分析引擎才有干净的数据可用。
3. “找得到”层的正确姿势:双通道索引加混合召回
3.1 为什么单靠向量检索不够
很多实现会直接拿 embedding 建向量索引,查询时算余弦相似度。向量检索擅长处理“语义相似”,但分析型查询通常带着很强的结构化约束:“过去三个月”“周六下午”“下雨天”“客单价”。这些约束如果用纯向量表达,模型会对“周六”“下雨”“客单价”这些概念做隐式编码,但没法精确保证结果百分之百落在指定时间范围和天气条件下。结果就是检索返回了一些“语义上感觉对”但时间戳不对的事件,分析算子一聚合,数据就污染了。
adamm 的做法是双通道索引:一路向量通道,负责语义相似度召回;另一路结构化属性通道,负责对时间、地点、实体类型、情感、天气、事件类型等可枚举字段做精确过滤。查询进入系统后,先解析出结构化约束,用属性通道把候选集合大幅缩小,再在缩小后的集合里做向量相似度排序。
打分融合用一个简单公式:
score = alpha * cosine_sim(query_embedding, event_embedding) + beta * attr_match_score(event, constraints)alpha 和 beta 可以在标注数据上用简单的网格搜索确定。我在早期项目里直接固定 alpha=0.6、beta=0.4,效果已经不错。如果后续要更精细,可以把两者拼成一个特征向量,训练一个轻量级排序模型,但没必要一上来就上复杂模型。
3.2 双通道检索的日常效果对比
| 检索方式 | 适合场景 | 典型问题 |
|---|---|---|
| 纯向量检索 | 找同款风格图片、相似语音片段 | 无法精确限定时间和实体,聚合时数据噪声大 |
| 纯结构化查询 | “查 11 月 3 日 14:00 到 15:00 的门店视频” | 没有语义泛化能力,换个说法就查不到 |
| 双通道混合检索 | 分析型问题:某时间段内相似行为的趋势 | 需要维护属性索引,写入链路更复杂 |
双通道混合召回并不会显著增加在线延迟,因为属性过滤在向量检索之前完成,向量检索只需要在过滤后的子集上搜索。真正需要注意的是属性字段的维护成本,写入时如果没把 entities、stats 抽取准确,属性通道再快也没用。
3.3 保留原始模态数据,永远不要只留 embedding
有一个设计我踩过坑:为了省存储,早期版本只保存了事件 embedding 和断句文本,原始视频和音频直接扔掉。结果分析引擎给出“该顾客在货架前停留时间异常长”的结论后,用户要求查看原始视频取证,系统拿不出来。这个教训很直接:可执行分析必须可解释,而可解释的最后一道防线是原始证据。
所以 adamm 的 MEV 里强制保留 raw_ref,原始媒体要么放对象存储,要么存本地文件路径,只把路径写进事件。冷数据可以压缩后存低成本存储,但不能删。这样检索和分析产出的每个结论都能回溯到具体时间点的原始多模态数据。
4. “算得出来”层的核心:把查询翻译成可执行分析计划
4.1 用户问题先转成分析意图,而不是直接去搜
用户不会按照 API 文档提问,他们说的是自然语言。adamm 接到问题后,第一步不是检索,而是让一个大语言模型做意图解析,把问题转换成结构化的分析计划。比如用户问:“下雨天和晴天相比,哪种天气客单价更高?”
解析结果大致是这样的:
{ "intent": "compare", "target_metric": "avg_order_value", "group_by": ["weather"], "filters": { "event_type": "purchase", "time_range": "all" }, "required_evidence": ["purchase_event", "weather_label"] }解析过程我用了约束解码,不让 LLM 自由发挥字段名,而是给一段预先定义好的 JSON Schema,只允许模型在 schema 里选择。这样能保证后续算子从记忆库里取数据时不会出现字段对不上。意图类型也就固定十几种:trend、compare、anomaly、periodicity、co_occurrence、breakdown、top_k、correlation 等。
4.2 记忆分析算子库:每种问题都对应一个可复用算子
分析计划确定后,真正干活的是算子库。算子不是简单的 Python 函数,每个算子都绑定了输入 schema、输出 schema、可解释模板和置信度评估方法。我在 adamm 里初期实现了这些算子:
- trend:对目标指标按时间聚合后做线性回归或移动平均,输出变化方向和幅度。
- compare:对对照组和实验组做均值比较,并计算差异显著性。
- anomaly:用统计阈值或轻量级隔离森林检测事件指标中的异常点。
- periodicity:用自相关或快速傅里叶变换识别事件周期。
- co_occurrence:统计两个实体或两类事件同时出现的频次和提升度。
- breakdown:按指定维度(时段、门店、商品类目)拆解指标,找出贡献最大的部分。
算子执行时可以直接消费 MEV 中的 stats 字段和实体关系。比如 trend 算子会先把所有符合过滤条件的 purchase 事件按天聚合,再在聚合序列上做平滑和差分,最终得到“整体上升/下降,每周环比变化百分之多少”这个结论。
4.3 从分析计划到结果的执行流程
整个计算链路用 Python 描述大概是这样的:
def execute_analysis(query, memory): plan = parse_intent(query) operator = resolve_operator(plan.intent) events = memory.retrieve( filters=plan.filters, semantic=plan.semantic_query, top_k=plan.top_k ) if not enough_evidence(events, plan): return build_insufficient_payload(plan, missing_reason) result = operator.compute(events, group_by=plan.group_by, target=plan.target_metric) analysis_payload = build_analysis_payload( summary=result.summary, evidence=result.evidence_event_ids, confidence=result.confidence, action=plan_to_action(plan, result) ) return analysis_payload这里面有个容易忽略的地方:检索出来的事件集合不一定够用来计算。比如用户问“最近三个月每天客单价趋势”,但系统里只有两个月的 purchase 事件。如果强行算 trend,出来的结论会误导人。所以执行算子前一定要做样本量检查,我设了一个最小样本数阈值,低于阈值就返回“当前数据量不足以支撑该分析”,并说明还需要哪些数据。宁可让系统承认算不出来,也不能编一个看似精准的结论。
4.4 可执行分析的输出不能只是一段话
“可执行”三个字决定了输出结构。adamm 的每个分析结果都包含三部分:人类可读的摘要、结构化证据列表、可执行动作。
{ "summary": "下雨天客单价平均为 128 元,晴天为 96 元,雨天高出约 33%。", "evidence": [ {"event_id": "evt_10234", "role": "purchase", "weight": 0.82}, {"event_id": "evt_10289", "role": "weather_label", "weight": 0.9} ], "confidence": 0.87, "action": { "type": "recommendation", "params": { "target": "雨天时段", "suggestion": "安排高毛利商品试吃,提高客单价" } } }结构化动作可以直接喂给下游业务系统。比如检测到连续三天晚间客流量异常上升,系统可以自动生成一条“建议增加晚班人员”的工单。这才是把长期记忆从“能查”变成“能用”的关键一步。
5. 一个完整落地案例:门店运营多模态记忆系统
5.1 数据接入:摄像头图像、语音工单、POS 流水
为了说清楚这套设计怎么跑通,我拿门店运营场景举例。接入的数据主要有四类:入口和货架摄像头视频流、店内麦克风采集的环境音与员工语音、客服语音工单、POS 系统的交易流水。摄像头图像经过检测模型得到“顾客出现/停留/拿取商品/走向收银台”等行为事件;语音经过转写和情绪识别得到“顾客询问/员工引导/情绪异常”等事件;POS 流水直接提供每一笔交易的金额、商品、时间。
所有事件统一封装成 MEV 写入情景记忆层。天气数据作为环境标签在写入时关联到门店和时段,不单独做成事件,但会写进事件的 stats 字段。这样后续按天气分组时,直接过滤 stats.weather 即可。
5.2 用户提问:“下雨天和晴天比,哪种天气客单价更高?”
这个查询会走一遍完整的“解析-检索-计算-动作”链路。
意图解析器把它转成 compare 分析计划,target_metric 是 avg_order_value,group_by 是 weather。检索模块先在属性通道里过滤出 event_type=purchase 的事件,再通过时间戳关联到天气标签,得到两张事件集合:雨天 purchase 事件和晴天 purchase 事件。
compare 算子分别对两批事件计算客单价均值,同时统计样本数量。如果雨天的有效 purchase 事件少于 100 笔,系统会降低结论置信度。当样本量足够时,它输出“雨天客单价更高,高出约 33%”,并附上参与计算的事件 ID 列表。
这里我可以用伪 SQL 展现算子实际拿到数据后的聚合逻辑:
SELECT weather, SUM(order_amount) / COUNT(DISTINCT bill_id) AS avg_order_value FROM memory_events WHERE event_type = 'purchase' AND weather IS NOT NULL GROUP BY weather;算子并不直接操作底层数据库,而是通过记忆访问层拿到过滤好的 MEV 集合,再在内存里做聚合。上面的 SQL 只是展示“算子内部实际在算什么”,帮助理解。
5.3 分析结果如何变成门店动作
得到“雨天客单价更高”的结论后,动作层给出建议:雨天客流通常较少,但客单价反而高,说明雨天进店的顾客购买意向更明确。这种情况下可以安排高毛利商品试吃,或在雨天推送“雨天专属套餐”,进一步拉高客单价。建议不是拍脑袋,而是基于 co_occurrence 算子发现雨天事件常与“顾客咨询高毛利商品”事件同时出现。
这个案例的价值在于,用户没有要求“查一下雨天发生了哪些事件”,他问的是“哪种天气客单价更高”。传统检索系统只能给他一堆雨天视频和晴天视频,adamm 则能给出一个结论、一手证据、一条可执行建议。
6. 工程实现里的坑,以及把效果调稳的几个关键点
6.1 显存只有 16G 时,多模态编码怎么做
多模态模型往往被当成显存杀手,但实际落地并不需要把所有模态都塞进同一个超大模型。adamm 的多模态编码我在代码复现时彻底拆开:视频帧用 CLIP ViT-B/16 编码,音频用 Whisper-small 转写和向量化,文本直接用 SentenceTransformer,这几个开源小模型并在一起,16G 显存完全跑得动。
拆开之后还有一个好处:每个模态可以异步处理。视频流抽帧是瓶颈,我就先 1fps 抽帧,检测到画面变化明显(比如货架前人群聚集)再插帧补充细节;每个事件最多保留 8 帧原始画面,超过的部分只留 embedding 和粗粒度描述文本。音频切段限制在 30 秒以内,Whisper 转写时不生成时间戳对齐到字级别,而是对齐到事件级,大大减少计算量。
如果想进一步榨干显存,可以把编码器转成 ONNX 或 TensorRT,用半精度跑。实测在同一条视频流上,TensorRT 优化后编码吞吐能提升 40% 以上,显存占用下降一半。关键原则是:不要让多模态大模型做长期在线推理。离线把视频切好、抽好特征、写好离线描述,在线只做轻量级检索和计算,这样 16G 卡也能撑起一个小型门店群的多模态记忆系统。
6.2 长期记忆膨胀与数据冷热分层
只要业务连续跑,记忆数据就会一直涨。我见过最夸张的情况是落地三个月后,事件总量翻了几十倍,向量检索延迟直线上升。解决方案不是无脑扩机器,而是做数据冷热分层。
- 热数据(最近 30 天):保留完整 embedding、完整 raw_ref、完整结构化字段,检索实时走向量库。
- 温数据(30 天到 180 天):embedding 降维保留,raw_ref 保留原始码率但转存低频存储。
- 冷数据(180 天以上):只保留语义记忆层自动生成的摘要事件,原始情景记忆压缩成按月维度的统计摘要。如果后续需要回溯具体案例,再从原始存储异步拉取。
这套分层做下来,向量库的热数据容量能控制在可接受范围内,长期分析仍然可以跨全量记忆执行,只是冷数据部分走语义记忆摘要而不是逐条事件。这样既保住了“找得到”,也保住了“算得出来”的响应速度。
6.3 防止“算出来”的内容是幻觉
分析型输出比检索型输出更容易产生幻觉,因为统计结论是加工过的,用户不会一眼看出它是不是真的由事件支撑。adamm 从三个层面控制这个问题。
第一,所有结论必须携带证据事件 ID。没有证据链的 summary 不会被输出。第二,置信度计算引入样本量惩罚。一个简单的置信度公式是:
confidence = evidence_count / (evidence_count + decay_base)decay_base 设为 50 左右,样本不足 50 个事件时置信度很难超过 0.5,系统会标记为低置信度结果。第三,算子明确拒绝“外推”。趋势算子可以给出“过去 90 天周环比增长 5%”,但不能自动预测“下个月还会增长 5%”。预测属于另一类任务,需要额外的前置条件和更严格的验证集。
踩过一次很深的坑:门店系统上线第三周,遇到连续暴雨天气,顾客很少,只有 30 笔雨天交易。系统照样输出“雨天客单价高 40%”的结论,运营同事差点按照这个建议改了雨天促销策略。后来检查证据,发现雨天样本只有 30 笔,晴天样本有 3000 多笔,均值差异根本不显著。从那以后,minsample 阈值和置信度惩罚成了强制项,宁可结论弱一点,也不能给出夸张的误导。
7. 如果让我从零复刻一套,最小可行版本怎么搭
7.1 组件选型和最简架构
不一定要自研所有模块。如果要快速验证 adamm 的思路,我会选一套现成组件搭最小可行版本:SQLite 存储事件结构化字段和 raw_ref,Milvus 或 FAISS 做向量索引,CLIP 做图像编码,Whisper-small 做音频转写,一个 chat 模型做意图解析和摘要生成,再加一层几十行代码的分析算子库。这套组合在单台 16G 显存机器上就能跑通。
最简架构可以按这个顺序搭:先确定 MEV 数据结构和写入链路,把真实业务数据灌进 SQLite;再给事件建向量索引,验证双通道检索能准确找回分析需要的候选集合;然后实现两三个核心算子,比如 trend 和 compare,跑通一个完整查询;最后接上动作输出。整个流程大概两周能出第一个可用版本。
7.2 迭代路线:先做好一件事,再扩展算子
很多团队一上来就追求“全模态、全算子”,这是大忌。我建议第一版只做一件事:让用户能用自然语言问“某个指标在某个维度下的变化趋势”。把这条链路做通,做稳,把证据回溯和置信度做扎实,再去加异常检测、周期性分析、共现分析。每新增一个算子,都要重新检查它对底层事件数据的要求,最好提前预留一个“算子-证据需求”的映射表,新增算子时照着表核对需要采集哪些新字段。
程序性记忆层的价值在这一步体现得最明显。新增算子不是写一个孤立函数,而是往程序性记忆里注册一套“方法”。下次遇到同类问题,系统能自动关联到这个方法,而不是每次都从零解析。
7.3 我回头看这套系统的三点经验
多模态记忆本身不是目的,可执行分析才是。判断一个记忆系统好不好,不该只看检索准召率,而要看它能不能回答三类问题:发生了什么、为什么会发生、下一步该做什么。如果系统只能回答“在哪”,那它本质上还是一个带索引的数据库。
另一个经验是:分析引擎的边界一定要清晰。遇到样本不足、字段缺失、模态不完整,系统应该大大方方说“算不了”,并告诉用户缺什么数据。这种“拒绝能力”反而能建立信任。用户宁可听到一次拒绝,也不愿意被一个看起来精确无比的数字骗五次。
最后想说,adamm 这套设计并不高深,但它提醒我一件事:长期记忆系统做到最后,拼的不是模型参数有多大,而是对业务问题的拆解能力、对证据链的执着,以及把结论变成行动的执行力。如果你也在做类似的东西,建议先把自己的记忆数据结构画清楚,再谈用什么模型、什么向量库。数据结构稳了,后面一切都会顺很多。