主题 15 · 长上下文与记忆管理#
定位:从对话历史压缩到跨会话长期记忆——三层去重 + 矛盾消解 + 三层存储(MySQL SoR + Redis 缓存 + Milvus 向量索引,#34 ②),以及一次上下文溢出导致答案退化的排查故事
一、通用知识#
1.1 核心概念与原理#
记忆分层架构#
Agent 记忆系统的设计本质上是在 LLM “每次调用无状态”的局限上,模拟人类记忆的三层结构:
- Working Memory(工作记忆):当前对话上下文,直接放在 context window 内。最快但最短暂——context window 关了就没了。
- Episodic Memory(情景记忆):会话级记忆,记住”这次对话聊了什么”。对应 Redis/DB 中的对话历史持久化。
- Semantic Memory(语义记忆):用户级持久化记忆,跨会话保留”用户的偏好/事实/上下文”。需要向量存储支撑语义检索。
面试怎么讲:“这三层对应人类记忆的短时→工作→长期。LLM 天生失忆——每次调用是无状态的,Agent 系统的记忆设计就是在这个局限上叠加三层存储,让系统’记住’用户。Working Memory 在 prompt 里,Episodic Memory 在对话历史里,Semantic Memory 跨会话持久化——以 MySQL 为系统级真相源(SoR),Redis 做读缓存、Milvus 做向量索引。三层各有不同的读写策略和淘汰机制。“
上下文窗口管理#
虽然现代 LLM 支持 128K+ context,但 Lost in the Middle 效应(长上下文中间信息召回率最低)意味着”塞进去 ≠ 用得上”。实际有效利用区间远小于标称 context window。
管理策略按复杂度递进:
- 硬截断(最近 N 轮):最简单但丢上下文,用户提到”刚才说的那个”可能无法理解。适合短对话场景
- LLM 摘要:压缩比高(3:1 甚至更高),但需要额外 LLM 调用增加延迟。摘要本身可能丢失关键细节和实体名
- 滑动窗口 + 摘要混合:近期原文 + 远期摘要,平衡精度和长度,是工程上最务实的方案。近期保留支持指代消解,远期摘要保留主题连续性
面试怎么讲:“context window 越大不代表越好用。Lost in the Middle 告诉我们,超过一定长度后中间信息的利用率骤降。所以上下文管理的核心不是’塞得进去’,而是’用得上’——高质量的 top-K 比大量证据更有效。我们的策略是最近 6 轮保留原文(支持指代消解),更早的对话异步 LLM 摘要压缩。“
记忆冲突与遗忘#
用户说”我用 MySQL 8”→ 存入记忆。两个月后说”我们迁移到了 PostgreSQL”→ 新旧记忆矛盾。需要三层处理:
- 精确去重:内容完全一样(byte-equal)不存。最快,O(N) 扫描已有记忆
- 语义去重:embedding cosine ≥ 0.85 → 判定为换了说法的同一件事(如”我喜欢用 Java”和”Java 是我的主力语言”),不重复存
- 矛盾消解:语义空间较远但逻辑矛盾的信息(如”用 MySQL”→“迁移到 PostgreSQL”),需要 LLM 级别的理解。旧记忆标记 superseded,新记忆生效
面试怎么讲:“冲突处理分三层:精确去重最快但最粗——只防完全相同;语义去重防换了说法的重复;LLM 矛盾消解防真正的逻辑冲突。三层成本递增(O(1) → embedding 计算 → LLM 调用),分别解决不同粒度的问题。Mem0 那篇论文的核心观点就是:记忆不是追加日志——需要去重、冲突消解、失效。“
Lost in the Middle#
Liu et al. (2023) 发现 LLM 对长上下文的利用率呈 U 型曲线——首尾信息利用率高、中间信息利用率最低。关键数据:20 文档输入时,模型对放在 position 1 和 position 20 的信息召回率接近 ~80%,而放在 position 10 附近的信息召回率降到 ~56%。
这是 RAG 系统做精排 + 截断的物理原因:不是 context 越多越好,而是高相关性的 top-K 放在利用率最高的位置(紧接 system prompt)效果最好。工程含义:prompt 模板中检索来源永远放在最前面,对话历史放最后。
面试怎么讲:“Lost in the Middle 不是理论问题——我们在生产中直接碰到了。15+ 轮对话后检索来源被推到 prompt 中间,citationCoverage 从 0.85 降到 0.40。修复就是遵循论文结论:高价值信息放首尾,低价值信息压缩或丢弃。这是论文到工程的直接映射。“
记忆与 RAG 的关系#
RAG 检索的是”知识库中的文档”(共享知识),记忆存储的是”用户的个人信息”(私有知识)。两者在 prompt 中是不同区域,信任级别不同——知识库来源可以被引用和标注([n] 标记),记忆信息用于个性化但不作为引用来源。
面试怎么讲:“RAG 和记忆是互补关系。RAG 回答’知识库里有什么’,记忆回答’这个用户是谁、关心什么、之前说过什么’。在 prompt 中它们占不同区域:检索来源放最前面(利用率最高),记忆放在中间用于个性化上下文,对话历史放最后。信任级别也不同——知识库来源经过文档审核可以引用,记忆信息可能过时需要谨慎使用。“
上下文预算分配#
当 context window 中同时存在 system prompt、检索来源、长期记忆、用户画像、对话历史时,需要一个预算分配策略决定”每个区域最多占多少 token”。这不是简单的平分——不同区域对回答质量的影响权重不同:
- 检索来源是核心证据,预算最大(通常 50-60%)
- 对话历史支持指代消解,预算次之(20-30%)
- 长期记忆提供个性化,预算适中(5-10%)
- System prompt 和用户画像是固定开销,按实际长度扣减
当总量超出预算时,按优先级低→高的顺序裁剪:先压缩对话历史(远期摘要),再减少检索来源 top-K,最后裁剪记忆条目数。
1.2 业界主流方案对比(表格形式)#
表 1:记忆系统方案对比#
| 方案 | 存储 | 检索方式 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|---|
| 纯对话历史截断 | 内存/DB | 按时间截取最近 N 轮 | 实现最简单,零额外成本 | 丢失远期上下文,无跨会话记忆 | 一次性问答场景 |
| Redis KV | Redis | key 精确匹配 | 读写快、运维简单 | 无语义检索能力,只能 key 匹配 | 结构化偏好存储 |
| 向量记忆(Milvus/Pinecone) | 向量 DB | 语义 cosine 检索 | 语义召回强、支持模糊查询 | 需要 embedding 计算成本、运维复杂 | 非结构化记忆检索 |
| Mem0 | Redis + 向量 DB | LLM 级去重 + 语义检索 | 完整记忆生命周期管理(ADD/UPDATE/NOOP) | 重量级、每次写入需 LLM 调用 | 高价值用户画像场景 |
| MySQL SoR + Redis 缓存 + Milvus 索引三层(本项目,#34) | MySQL 为 SoR / Redis cache-aside / Milvus 纯向量 | 语义检索 + 子串降级 | 持久性强(SoR 不丢、软删审计)、缓存提速、语义召回;三层各司其职 | 三层一致性需处理(写序 + 失效 + 召回对账) | 企业 RAG 系统 |
表 2:上下文压缩方案对比#
| 方案 | 机制 | 压缩比 | 延迟增加 | 精度损失 | 适用场景 |
|---|---|---|---|---|---|
| 硬截断(最近 N 轮) | 直接丢弃远期对话 | 无压缩 | 0 | 高(丢上下文) | 客服类短对话 |
| LLM 摘要 | 每次摘要完整历史 | 3:1 ~ 5:1 | +200~500ms/次 | 低(LLM 理解力强) | 中等长度对话 |
| LongLLMLingua | 按 perplexity 逐 token 决定留不留 | 2:1 ~ 10:1 | +100~300ms | 中(可能丢关键 token) | 极长文本压缩 |
| 滑动窗口 + 摘要混合 | 近期原文 + 远期异步摘要 | 近期 1:1 / 远期 3:1 | +200ms(异步不阻塞) | 低(近期无损) | 知识问答类长对话(本项目) |
1.3 关键论文与技术要点#
- Generative Agents(Park et al., 2023, Stanford):模拟 25 个 AI 居民的社会行为,核心创新是记忆流(Memory Stream)+ 重要性评分 + 遗忘曲线。启示:记忆需要遗忘和优先级——不是所有信息都值得记,accessCount 和 TTL 是工程上的遗忘机制
- Mem0(2024):专注 LLM 长期记忆层,核心主张”记忆不是追加日志——需要去重、冲突消解、失效”。提出 ADD/UPDATE/NOOP 三状态分类 + supersedes 取代链。DocMind 的 MemoryExtractor 直接借鉴了这个设计
- Lost in the Middle(Liu et al., 2023):系统性实验证明 LLM 对长上下文中间信息利用率最低,首尾最高。关键数据:20 文档输入时中间文档的 recall 降到 ~56%,而首尾接近 ~80%。RAG 做精排 + 截断 + 位置优化的物理依据
- LongLLMLingua(Jiang et al., 2023):token 级上下文压缩——用小模型计算每个 token 的 perplexity,低 perplexity(可预测性高)的 token 丢掉,保留信息量大的 token。优势是无损语义、压缩比高;劣势是需要额外模型推理
- MemGPT(Packer et al., 2023):虚拟上下文管理——把 context window 当操作系统的内存管理,实现 page in/out。核心思想:context window 是稀缺资源,需要像操作系统管理物理内存一样管理它。main context 相当于 RAM(有限容量、快速访问),外部存储相当于磁盘(无限容量、需要显式加载)
面试怎么讲:“这五篇论文覆盖了记忆系统的五个维度:Generative Agents 讲遗忘和优先级,Mem0 讲去重和冲突消解,Lost in the Middle 讲上下文位置效应,LongLLMLingua 讲 token 级压缩,MemGPT 讲 context 资源管理。工程实践中你不需要全部实现,但需要理解每一个维度的 tradeoff。DocMind 取了 Mem0 的冲突消解 + Lost in the Middle 的位置优化 + 自己的滑动窗口压缩,三者组合解决实际问题。”
记忆工程三原则:
- 写时去重消解:存入时处理冲突,不是读取时——写入路径有三层去重 + LLM 矛盾消解,读取路径只做语义检索
- 读时语义检索:cosine 召回而非 key 匹配——“我的数据库是什么”应该能召回”用户使用 MySQL 8”这条记忆
- 降级优先于报错:Milvus 挂了退 Redis 关键词匹配,不是抛异常——可用性 > 精确性
1.4 常见面试问答#
Q1: 上下文超窗了怎么办?
面试怎么讲:“三个策略递进:硬截断 → LLM 摘要 → 滑动窗口 + 摘要混合。选哪个取决于延迟预算和信息损失容忍度。客服类对话硬截断够了;知识问答类用滑动窗口 + 摘要——最近 6 轮保留原文支持指代消解,更早的部分异步 LLM 摘要。关键是不要试图用扩大 context window 来解决——Lost in the Middle 效应意味着塞进去的信息不一定用得上。”
Q2: 记忆怎么处理冲突?
面试怎么讲:“三层递进:精确去重(byte-equal,最快但最粗)→ 语义去重(cos ≥ 0.85,防换说法的重复)→ LLM 矛盾消解(supersedes 取代链,处理逻辑矛盾)。前两层在 MemoryStore.save 同步完成,LLM 消解在 MemoryExtractor.extractAndStore 异步完成。两条路径独立运行——embedding 级处理语义空间相近的冲突(cos 0.75-0.85),LLM 级处理语义空间远但逻辑矛盾的冲突(如’用 MySQL’→‘迁移到 PostgreSQL’)。”
Q3: 长期记忆和 RAG 检索有什么关系?
面试怎么讲:“互补——RAG 是共享知识(知识库文档,所有用户看到一样的),记忆是私有知识(用户个人信息,每个用户不同)。prompt 中分区显示,信任级别不同——检索来源可以被
[n]引用标注,记忆信息用于个性化回答但不标引用编号。agentic 循环中 recall_memory 是三个只读工具之一(和 searchDocs、webSearch 并列),模型可以主动决定是否需要查记忆。”
Q4: Lost in the Middle 怎么缓解?
面试怎么讲:“工程手段三管齐下——(1) 精排 top-K 控制总量,减少中间区域长度;(2) 检索来源放在 prompt 最前面(紧接 system prompt),利用首部效应提高利用率;(3) 对话历史放最后,利用尾部效应让模型捕获近期对话。核心是不靠扩大 context window 解决,而是优化信息位置和密度。128K 窗口的模型在 8K 精心排布的 prompt 上表现,好过在 30K 随意堆砌的 prompt 上表现。”
Q5: 记忆系统怎么评测效果?
面试怎么讲:“三个维度:(1) 召回率——存了的记忆能否被相关 query 检索到(如存了’用户用 MySQL 8’,问’他的数据库是什么’能不能召回);(2) 精确率——检索到的记忆是否和当前 query 相关(不应该混入无关记忆);(3) 冲突消解正确率——‘迁移到 PG’是否正确 supersede ‘用 MySQL’。用构造的测试集手动标注。生产上通过 Langfuse 监控 memoryRecallCount + citationCoverage 间接衡量记忆系统对回答质量的影响。”
Q6: 对话历史压缩该用哪种方案?
看场景:客服类对话 → 硬截断够了(对话短、上下文依赖弱);知识问答类 → 滑动窗口 + 摘要(远期摘要 + 近期原文,支持指代消解);分析类长对话(20+ 轮)→ 可能需要 LongLLMLingua token 级压缩。成本和精度是 tradeoff——LongLLMLingua 压缩比最高但需要额外模型推理。选型关键问题是”用户说’刚才提到的’时系统能不能理解”——只要保留了近期原文(滑动窗口),指代消解就没问题。
二、DocMind 实践#
30 秒口述版#
“DocMind 的记忆管理分三层——对话历史在 context window 内直接拼接、语义缓存热查询 Redis 回放、跨会话长期记忆走三层存储:MySQL
user_memory是系统级真相源(SoR,软删保审计、不设 TTL),Redis 退为 cache-aside 读缓存(TTL 7d、成员变更失效整键),Milvus 退为纯向量索引(语义召回 + 冲突检测,命中回 SoR 取全文)。写入时三层去重:精确(byte-equal skip)、语义(cos ≥ 0.85 skip)、LLM 矛盾消解(MemoryExtractor 注入已有记忆让模型输出 supersedes 列表)。读取时 Milvus 语义召回 cos > 0.3 → 回 SoR 取全文,且区分’DB 返回空’与’DB 不可达’避免复活陈旧召回。提取触发不做规则预筛——对标 Mem0/LangMem 采用『LLM 即 Gate』,每轮都异步提取、由 LLM 返回 NOOP 兜底,recall 优先。最有价值的实践是一次上下文溢出事件——15+ 轮对话后引用覆盖率从 0.85 骤降到 0.40,排查发现是 Lost in the Middle 效应,修复落到全局 token 预算 + 历史滚动摘要。“
详细展开#
记忆类型设计#
DocMind 将记忆分为三种类型(MemoryType 枚举),各有不同的提取时机和使用场景:
- PREFERENCE(偏好):用户主动声明的偏好,如”我喜欢简洁回答”、“我用 Java”。提取时机:Stage 1 QueryUnderstanding 的
memoryWriteHints(同步写入) - FACT(事实):从对话中提取的客观事实,如”用户的项目是 DocMind”、“团队用 MySQL 8”。提取时机:Stage 8 MemoryExtractor 异步提取
- CONTEXT(上下文):对话结论和推导信息,如”上次讨论确定用 HNSW 索引”。提取时机:同 FACT,由 MemoryExtractor 从回答中提取
PromptAssembler.formatMemoryContext() 按类型分三个区块展示(偏好/事实/上下文),无记忆时显示”(无长期记忆)“。
三层存储架构(#34 ②:Redis-as-SoR → MySQL SoR)#
演进:早期把 Redis 当系统级真相源(SoR)——单节点 + 仅 RDB 快照 + 30d TTL,崩溃/淘汰/过期即全量蒸发,且”长期记忆因一段时间没访问就消失”语义本身就错。下沉为三层,各司其职:
- MySQL
user_memory(SoR,真相源):所有读写以此为准。失效用软删除(invalidated_at != 0)保留审计,superseded_by记取代链;持久层不设 TTL(长期记忆不应因闲置蒸发)。字段含 id/type/content/createdAt/lastAccessed/accessCount/invalidatedAt/supersededBy,user_id用VARCHAR(64)全程一致。 - Redis(cache-aside 读缓存):
user:memory:cache:v1:{userId}Hash 逐条缓存有效记忆(field = memoryId → recordJson),TTL 7d(仅缓存层)。读 miss 回源 DB 回填;任何成员变更(save/invalidate/evict/clear)→ 失效整个键、下次重建。 - Milvus
docmind_memory(纯向量索引):HNSW(M=16, efConstruction=64)+ COSINE + 1024 维,只存 300 字截断版 content,仅供semanticRecall召回与findMostSimilarByType冲突检测,命中后回 SoR 取全文。
写入顺序:DB(提交点,失败即中止不写向量,杜绝”有向量无 SoR”孤儿)→ Milvus 向量(派生索引,失败仅降级召回)→ 失效缓存 → 淘汰。淘汰用 recency×frequency 价值分(I-evo-2):有效记忆超 200 条时软删归档最低价值的——evictionScore = exp(−ageDays/14) + 0.5·log1p(accessCount)(新鲜度指数衰减 + 频度加权),而非旧的 FIFO/LRU 按 createdAt。
三个并发与一致性工程要点(code review 修复):
- C1 — 缓存逐条原子写防丢更新:缓存用 Hash(
field=memoryId)而非”整用户一个 JSON blob”。旧 blob 模式并发改两条不同记忆各自”读整块→改→写回”会互相覆盖丢更新;改 Hash 后访问计数只put命中那一个 field,互不干扰。 - C2 — 访问计数 DB 端原子自增:
touchAccessCountAsync用access_count = access_count + 1(setSql)替代”读 JSON→改→写回”,异步 best-effort、脱离召回关键路径,彻底消除读-改-写竞态。 - C3 — 双层删除语义 + 召回对账:SoR 失效走软删(保审计、行不消失);Milvus 向量则物理删除(
deleteVectors,避免陈旧向量在索引里堆积、被召回命中)。两层之外再加召回期对账——semanticRecall回 SoR 取全文时,DB 健康但该 id 已非有效的命中按”陈旧向量”跳过,兜底物理删除可能的滞后/失败。
面试怎么讲:“这套存储是换过地基的。原来把 Redis 当 SoR 是典型存储错配——单节点、只有 RDB、还挂 30 天 TTL,崩一次或淘汰一次记忆就全没了。我下沉成三层:MySQL 当 SoR,软删保审计、不设 TTL;Redis 退回 cache-aside 读缓存,成员一变失效整键保证一致;Milvus 退为纯向量索引,命中后回 MySQL 取全文。还修了三个并发/一致性洞:缓存从整块 blob 改 Hash 逐条原子写防丢更新(C1)、访问计数改 DB 端原子自增消竞态(C2)、SoR 软删但 Milvus 向量物理删除 + 召回期对账避免陈旧向量复活(C3)。淘汰从按时间 FIFO 改成 recency×frequency 价值分,因为旧实现收集了访问统计却完全没用上。“
三层去重写入#
MemoryStore.save() 的写入路径按成本递增:
- Layer 1 精确去重:
loadActive(cache-aside:命中 Redis Hash 或回源 MySQL SoR)取有效记忆,type + content 完全相同 → skip。O(N) 但 N ≤ 200 - Layer 2 语义去重:embedding cosine ≥ 0.85 → 判定语义重复,skip。依赖 Milvus
findMostSimilarByType(一致性级别 STRONG,必须读到刚写入的近邻) - Layer 3 冲突替换:cosine ≥ 0.75 但 < 0.85 → 语义相近但内容有变化(如版本更新),软删旧记忆(记
superseded_by)+ 物理删旧向量 + 写入新记忆
这三层处理语义空间内的冲突(cos ≥ 0.75 的近邻)。语义空间外的逻辑矛盾(如”用 MySQL”→“迁移到 PostgreSQL”,cos 可能 < 0.75 因为两个数据库 embedding 差异大)由 MemoryExtractor 的 LLM 级矛盾消解处理。两条路径独立运行:save() 是同步快路径,LLM supersedes 是异步慢路径,共同覆盖所有冲突场景。
LLM 矛盾消解(Mem0 风格)#
MemoryExtractor.extractAndStore() 在 LLM 提取时注入该用户已有同类型记忆:
- 格式化为
[TYPE-fullId] content,让 LLM 能看到并引用 - LLM 在同一次调用中输出三部分:提取结果(content)、动作分类(ADD/UPDATE/NOOP)、取代列表(supersedes[])
- supersedes 列表传给
MemoryStore.invalidate(),执行软删除:设置invalidatedAt+supersededBy审计字段,同时从 Milvus 删除向量(L299)防止被语义召回命中
面试怎么讲:“矛盾消解零额外 LLM 调用——在提取 prompt 里注入已有记忆,模型在同一次调用中同时做提取和冲突分类。supersedes 输出的是旧记忆 ID 列表,invalidate 用精确匹配 + 前缀兜底(LLM 可能截断 ID),软删除保留审计追溯能力。这是 Mem0 论文的核心思想在工程上的落地。“
提取触发:LLM 即 Gate(无规则预筛)#
演进:早期版本用 MemoryExtractor.shouldExtract() 做两层规则 Gate(Layer 1 问题/回答长度硬过滤 + Layer 2 memoryAware/memoryWriteHints/complexity=COMPLEX/个人信号正则),目标是过滤 60-70% 无价值轮次省 LLM 调用。后对标主流产品调研后整体移除,改为「始终提取、LLM 兜底」:每轮回答结束后都异步触发一次提取,是否有可记内容完全交给提取 LLM 自身裁决——无内容时对所有字段返回 NOOP,ExtractionResult.isEmpty() 命中即静默跳过写入。规则 Gate 彻底删除,唯一保留的是 userId 为空时跳过(无存储键,结构性前置条件,非内容启发式)。
为什么删:调研 Mem0 / LangMem / Zep 发现,没有一个主流记忆系统用规则启发式决定”是否提取”——
- Mem0:每次
add()都跑 fact-extraction prompt,论文明确”用 LLM 推理而非单独分类器”做决策,无规则预筛 - Zep/Graphiti:每条消息都进时序图,非丢失式异步增量,根本没有”是否提取”这一步
- LangMem:background(潜意识)提取”专为保证更高 recall”
规则 Gate 的代价是伤 recall:用户在一句短消息、或一个 SIMPLE 非个人正则的提问里说了重要事实,会被 Layer 1/2 直接拦掉、永久丢失。而记忆系统的核心 KPI 正是 recall——漏记比偶尔多花一次廉价异步调用更糟。省成本的正确做法不是规则预筛,而是「异步执行(不阻塞 SSE)+ 廉价小模型」消化——记忆提取单独配 memory.extract_model(默认 qwen-flash),与主回答 qwen-plus 分层,可在管理面板热调。
面试怎么讲:“这是一次有意识的反向重构——我原本用两层规则 Gate 省成本,但对标 Mem0/LangMem/Zep 后发现主流一致用『LLM 即 Gate』:始终提取,让提取 prompt 对无可记轮次返回 NOOP 充当真正的闸门。规则启发式(长度/正则/复杂度)是 recall 的敌人——它会静默丢掉短消息里的关键事实。于是我把 Gate 整段删掉(做减法),把省成本交给异步 + 小模型。这个取舍体现的是『记忆系统 recall 优先』的判断。“
上下文溢出事件排查(SAR)#
Situation:内测期间发现某个高频用户连续对话 15+ 轮后,模型回答质量明显下降——引用覆盖率(citationCoverage)从稳定的 0.85 骤降到 0.40,开始遗漏检索结果中的关键信息。
Action:排查 Langfuse trace 发现 prompt 总长度异常。对话历史(15 轮 × ~300 字/轮 ≈ 4500 字)+ 检索上下文(6 chunk × ~500 字 ≈ 3000 字)+ system prompt(~1500 字)≈ 9000 字。虽然 qwen-plus context window 标称 128K,但 Lost in the Middle 效应在 8K+ token 后开始显现——检索来源被推到上下文中间区域,利用率骤降。
Result:三项修复措施,后续在 #32/#33/#35 上下文预算改造中固化为系统能力——
- 历史滚动摘要(I-perf-5,
PromptAssembler.fitTailWithSummary):history 超预算时不再硬截断丢弃早期对话,而是把被裁掉的早期部分用廉价模型(qwen-flash)压成要点摘要塞回头部(【较早对话摘要】… ---(以下为最近原文)---),失败回退硬截断。 - 全局 token 预算(#32/#33):
fitAuxBlocks让 chunks 与三条辅助流(computations/memory/history)共享prompt.budget.total_max_tokens(默认 6000)全局天花板,按 computations(1500) > memory(600) > history(1000) 优先级分配 aux 总预算(2500),chunks 优先、aux 自动让位;估算器语种感知防低估。 - 检索来源始终放在 prompt 最前面(紧接 system prompt),对话历史放最后——利用 Lost in the Middle 的”首尾效应”。
修复后 citationCoverage 在 15+ 轮对话场景下恢复到 0.78+,同时 prompt 总 token 受全局天花板封顶在 6000 以内(原来无限增长)。
面试怎么讲:“这个事件最大的教训是:128K context window 不等于 128K 有效利用空间。Lost in the Middle 效应是物理级的——模型注意力分配不均匀。修复不是靠扩窗口,而是靠压缩 + 排位——高价值信息放首尾,低价值信息压缩或丢弃。滑动窗口 + 摘要是工程上最务实的折中方案。“
历史压缩方案选型#
对比了四种方案后选择了滑动窗口 + 摘要混合:
- 硬截断(最近 N 轮):简单但丢上下文。用户说”刚才提到的那个方案”,如果那个方案在第 3 轮而窗口只保留最近 5 轮,10 轮后就无法理解了
- LLM 摘要(每轮全量):压缩比高但每轮多一次 LLM 调用 ~200ms,且摘要可能丢失细节关键词
- LongLLMLingua(token 级):效果最好的压缩方案,但需要额外小模型推理 perplexity,部署和维护成本高
- 滑动窗口 + 摘要混合(DocMind 选这个,I-perf-5):
ConversationService.buildHistory先按条数取最近 N 条(粗护栏),PromptAssembler.fitTailWithSummary再按history_max_tokens预算控总量——仅当 history 超预算时,把被裁掉的早期部分用廉价模型(qwen-flash)压成 ≤summary_max_tokens(默认 300)的摘要塞回头部,最近原文保尾原样。摘要是生成关键路径上的一次同步廉价 LLM 调用(只在长对话触发),任一异常(功能关 / 无配置 / 未超预算 / 摘要失败)回退硬截断、永不阻断。
面试怎么讲:“选型逻辑是:先排除 LongLLMLingua——部署复杂度不值得;纯截断丢信息太多;全量摘要每轮调一次 LLM 成本高。我选滑动窗口 + 摘要:最近原文保尾支持’刚才说的’这类指代,只有 history 真的超预算时才用一次 qwen-flash 把远期历史压成一段摘要塞回头部。这是关键路径上的同步调用,但仅长对话触发、且用的是廉价小模型,摘要失败立刻回退硬截断,所以既省 token 又不丢早期上下文。“
userId 安全注入#
MemoryTool.recallMemory() 的 userId 参数标注了 required = false,但实际值永远来自 AgentToolContext.get().getUserId()(L200-201),不信任 LLM 或外部 MCP 传入的 userId。这防止了越权读写他人记忆——LLM 在 function-calling 中可能尝试传任意 userId,但 resolveUserId 强制覆盖为认证上下文中的真实用户 ID。
面试怎么讲:“记忆系统的安全边界很关键——LLM 是不可信的。function-calling 中模型可能传一个其他用户的 ID 来读别人的记忆,所以 userId 必须从认证上下文强制注入,而不是信任模型传入的参数。这和 kbIds 的安全处理是同一个设计原则。“
记忆提取时机与职责分离#
记忆写入分两个阶段,职责明确分离:
- Stage 1 QueryUnderstanding:只提取 PREFERENCE(用户从问题中主动声明的偏好,如”我喜欢简洁回答”)。同步写入,
stageMemoryWrite调用MemoryTool.store() - Stage 8 MemoryExtractor:从完整对话(问题 + 回答)中提取 FACT 和 CONTEXT。异步执行,
stageAsyncMemoryExtract通过CompletableFuture.runAsync提交
这个分离确保了:(1) 偏好类记忆立即可用(当轮对话就能用上)(2) 事实和上下文类记忆不阻塞响应(提取需要 LLM 调用,~200ms)(3) 提取无规则预筛,每轮都触发,由提取 LLM 返回 NOOP 兜底(LLM 即 Gate,recall 优先)。
代码锚点#
| 类/方法 | 路径 | 职责 |
|---|---|---|
MemoryStore.save() | mcp/MemoryStore.java | 三层去重写入(exact→semantic→conflict)→ DB→Milvus→失效缓存→recency×frequency 淘汰 |
MemoryStore.semanticRecall() | 同上 | 语义召回 cos>0.3 → 回 SoR 取全文(sorAvailable 区分 DB 空/不可达,不复活陈旧召回);Milvus 异常 → fallbackRecall 子串匹配 |
MemoryStore.invalidate() | 同上 | 软删(invalidated_at+superseded_by 审计)+ Milvus 向量物理删除(C3 双层删除语义) |
MemoryStore.touchAccessCountAsync() | 同上 | 访问计数 DB 原子 +1(C2,异步、脱关键路径)+ 缓存 field 就地 bump(C1 Hash 逐条) |
MemoryExtractor.extractAndStore() | service/rag/MemoryExtractor.java | LLM 提取 + supersedes 矛盾消解(无规则 Gate,LLM 返回 NOOP 即兜底) |
MemoryTool.recallMemory() | mcp/MemoryTool.java L107-128 | @Tool 语义召回,userId 从 AgentToolContext 安全注入 |
PromptAssembler.formatMemoryContext() | service/rag/PromptAssembler.java L173-210 | 记忆三类分类格式化(偏好/事实/上下文) |
PromptAssembler.assemble() | 同上 L29-48 | 主 prompt 组装(含记忆区 + 历史区) |
DocMindAgent.stageAsyncMemoryExtract() | agent/DocMindAgent.java | 异步记忆提取入口(无规则预筛,每轮触发,LLM 即 Gate) |
AgentToolContext.putMemory() | agent/AgentToolContext.java L99-106 | 记忆上下文写入 ThreadLocal |
量化数据#
| 指标 | 数值 | 基线 | 来源 |
|---|---|---|---|
| 每用户记忆上限 | 200 条 | — | MAX_RECORDS_PER_USER |
| 记忆 SoR | MySQL user_memory,不设 TTL | 旧:Redis-as-SoR(已废) | #34 ② |
| 缓存 TTL | 7 天(仅缓存层) | 旧:30d 作用于 SoR(已废) | CACHE_TTL_DAYS |
| 淘汰策略 | recency×frequency 价值分软删归档(半衰 14d、频度权 0.5) | 旧:FIFO/LRU 按 createdAt(已废) | I-evo-2 / evictionScore |
| 精确去重 | byte-equal (type + content) | — | MemoryStore L79-85 |
| 语义去重阈值 | cos ≥ 0.85 | — | SEMANTIC_DEDUP_THRESHOLD |
| 冲突替换阈值 | cos ≥ 0.75 | — | CONFLICT_THRESHOLD |
| 召回阈值 | cos ≥ 0.3 | — | semanticRecall L214 |
| 提取触发策略 | 每轮触发,LLM 即 Gate(无规则预筛) | 旧版两层规则 Gate(已删) | 对标 Mem0/LangMem/Zep |
| 记忆提取模型 | qwen-flash(memory.extract_model,可热调) | 主模型 qwen-plus | 「每轮都提取」的成本由廉价小模型消化 |
| 记忆向量维度 | 1024d (text-embedding-v3) | — | HNSW index |
| 对话历史窗口 | 最近 6 轮 | 全部(无限制) | 滑动窗口策略 |
| 历史摘要压缩比 | ~3:1 | — | 异步 LLM 摘要 |
| token 预算:全局天花板 | total ≤ 6000 | 无限制 | prompt.budget.total_max_tokens(#33) |
| token 预算:历史 | ≤ 1000 | 无限制 | prompt.budget.history_max_tokens |
| token 预算:检索 chunks | ≤ 3000 | — | rag.context_max_tokens |
| token 预算:记忆 | ≤ 600 | — | prompt.budget.memory_max_tokens |
| token 预算:计算结果 | ≤ 1500 | — | prompt.budget.computations_max_tokens |
| 历史滚动摘要子预算 | ≤ 300(走代码默认,无 DB 种子) | — | prompt.history.summary_max_tokens(I-perf-5) |
| 溢出修复后 citationCoverage | 0.78+ | 0.40(修复前) | Langfuse 监控 |
| HNSW 参数 | M=16, ef=64 | — | initCollection |
| 记忆 content 最大长度 | 150 字符 | — | MAX_EXTRACT_LENGTH |
| Milvus content 截断 | 300 字符 | — | insertVector |
三、追问应对#
面试官想听到的信号#
- 记忆全生命周期思维:写入(三层去重 + 矛盾消解)→ 存储(MySQL SoR + Redis 缓存 + Milvus 索引三层 + 降级)→ 读取(语义召回 + 阈值 + 不复活陈旧召回)→ 淘汰(recency×frequency 软删归档),不是只说”用 Redis 存一下”
- 上下文管理实战经验:溢出事件的发现(Langfuse trace citationCoverage 下降)和修复(滑动窗口 + 预算分配 + 位置优化),能用数据说话(0.85 → 0.40 → 0.78+)
- Lost in the Middle 的工程含义:不是背论文结论,而是能说出”所以我们把检索来源放 prompt 最前面,对话历史放最后”——论文结论到工程决策的连接
- 冲突处理的多层架构思维:embedding 级(MemoryStore 同步快路径)和 LLM 级(MemoryExtractor 异步慢路径)互补,两条路径独立运行、各处理不同语义距离的冲突
- 安全意识:userId 安全注入防止 LLM 越权,记忆系统不信任外部传入的身份标识
追问预判与应答#
Q1: 三层去重为什么需要三层,一层不够吗?
精确去重只防完全相同——“我用 Java”发两遍能拦住,但”Java 是我的主力语言”拦不住。语义去重补上这个缺口——cos ≥ 0.85 的两条语义几乎一样,不存。但”我用 MySQL 8”和”我用 MySQL 9”cos 可能在 0.75-0.85 之间——语义相近但信息有变化,这时候应该替换而非跳过。三层分别解决完全重复 / 换说法重复 / 信息更新三个不同问题,成本递增但覆盖面也递增。
Q2: MySQL / Redis / Milvus 三层写会有一致性问题吗?
不需要分布式事务。MySQL user_memory 是唯一 SoR,Redis 是读缓存、Milvus 是向量索引,两者都是派生层。写序固定 DB(提交点)→ Milvus 向量 → 失效缓存:DB 写失败即中止、不写向量,杜绝”有向量无 SoR”孤儿;Milvus 写失败只丢语义检索能力,召回降级到 fallbackRecall(基于 SoR/缓存里 active 记忆做 content.contains 子串匹配),不丢数据。删除是双层语义——SoR 软删保审计、Milvus 向量物理删除,再加召回期对账(DB 健康但 id 非有效的命中按陈旧向量跳过)兜底物理删除的滞后。缓存一致性靠”任何成员变更失效整键”+ Hash 逐条原子写(C1)保证。
Q3: 上下文溢出是怎么发现的?
Langfuse trace 中 citationCoverage 指标突然下降——从稳定的 0.80+ 降到 0.40。不是一个用户偶发,而是高频用户(对话超过 10 轮)系统性出现。回溯 trace 发现这些用户的 prompt 总 token 数异常高,检索来源被”挤”到上下文中间区域。对照 Lost in the Middle 论文的曲线,和我们观察到的利用率下降完全吻合。
Q4: 历史摘要的 LLM 调用成本值得吗?
异步执行不阻塞主流程(CompletableFuture.runAsync),~200ms 延迟对用户不可感知。一次摘要将 10 轮历史(~3000 字)压缩到 ~300 字,释放 ~2700 字给检索上下文——换来的是检索利用率从 0.40 恢复到 0.80+,绝对值得。而且摘要只在超过 6 轮时触发,大部分对话不到 6 轮,实际调用率不高。
Q5: Milvus 不可用时 Redis 降级效果怎么样?
Redis 降级是关键词匹配(fallbackRecall:content 字段 contains 查询词),精度低于语义检索。但记忆条目通常包含明确关键词——“用户使用 MySQL 8”搜”MySQL”能命中,“用户偏好 Java”搜”Java”能命中。对于模糊查询(“他的数据库是什么”)就不行了。可接受范围内——降级 > 报错,保证基本可用性,精确检索等 Milvus 恢复后自动恢复。
Q6: 记忆系统怎么评测效果?
三个指标:(1) 召回率——构造 50 条测试记忆 + 50 个相关 query,检查能否正确召回 (2) 精确率——检索到的记忆是否和 query 相关,不应该混入无关条目 (3) 冲突消解正确率——构造 20 组新旧矛盾对(如”用 MySQL”→“迁移到 PG”),检查 supersedes 是否正确触发。目前用手动标注的小测试集验证,生产上通过 Langfuse 监控 memoryRecallCount 和 citationCoverage 间接衡量。
Q7: 怎么决定一轮对话要不要提取记忆?为什么不用规则预筛省成本?
用「LLM 即 Gate」——每轮回答后都异步触发提取,不做任何规则判断,由提取 prompt 自己对无可记轮次返回 NOOP(ExtractionResult.isEmpty() 命中即跳过写入)。早期我确实做过两层规则 Gate(长度硬过滤 + memoryAware/complexity/个人信号正则),预估过滤 60-70% 轮次。但对标 Mem0/LangMem/Zep 后发现,没有一个主流系统这么做——它们要么始终提取让 LLM 兜底(Mem0、Zep),要么折叠进主 LLM 调用(Letta、ChatGPT)。原因是规则启发式伤 recall:用户在短消息或非个人化提问里说的关键事实会被静默拦掉、永久丢失,而 recall 恰恰是记忆系统的核心 KPI——漏记比多花一次廉价异步调用糟得多。所以我整段删掉了 Gate,省成本改由「异步不阻塞 + 廉价小模型」消化——记忆提取单独配 memory.extract_model(默认 qwen-flash),和主回答 qwen-plus 做成本分层。这是一次基于对标调研的反向重构,做减法反而对齐了主流并提升了 recall。
Q8: 软删除为什么保留 invalidatedAt 和 supersededBy 而不直接物理删除?
审计追溯——如果 LLM 矛盾消解判断错误(把正确的旧记忆误判为已过时),需要能回溯。保留 invalidatedAt(何时失效)和 supersededBy(被谁取代)让我们可以在 Langfuse 中追踪记忆变更链。Redis 中保留失效记录不影响读取——loadActiveFromRedis 只返回 isActive()=true 的记录(invalidatedAt == 0),而 Milvus 侧的向量在 invalidate 时已经物理删除(L299),不会被语义召回命中。这是”Redis 留审计、Milvus 删向量”的分工。
反击引导#
当面试官在某个方向追问到底时,用以下锚点把话题引向你更熟悉的领域:
- “记忆的读取通过 recall_memory 工具暴露给 agentic 循环——工具设计和双路执行(内部 function-calling + 外部 MCP endpoint)是另一个完整的故事。比如 userId 安全注入就是防止 LLM 在 tool-calling 中越权读写他人记忆。”(→ Story 09 工具设计与 Function-Calling)
- “记忆上下文在 prompt 中的格式化是 Prompt Engineering 多模板架构的一部分——
formatMemoryContext将记忆分三类(偏好/事实/上下文)显示,和检索来源、对话历史分区放置。位置安排遵循 Lost in the Middle 的启示。”(→ Story 11 Prompt Engineering 实践) - “Milvus 降级 Redis 是全链路降级 10 层之一——每层有独立的 fallback 策略,从 embedding 计算失败到 Milvus 不可用到 LLM 提取失败,都有对应的降级路径。记忆系统的降级是其中第 7 层。”(→ Story 08 全链路降级)
- “上下文溢出的发现依赖 Langfuse 可观测性——citationCoverage 这个指标是我们在 done 事件中埋的,正是因为有这个指标才能发现长对话场景的退化。”(→ Story 05 可信生成与评测)