Agent 记忆系统

Agent 记忆系统完整教学(面试导向)


一、为什么记忆系统是 Agent 面试必考题

面试官问"你们的 Agent 怎么记住东西",本质是考察三个能力:

|------|-----------------|----------------------|
| 考察点 | 潜台词 | Java 对应 |
| 架构设计 | 你分得清短期/长期/工作记忆吗 | 你分得清 L1/L2/L3 缓存吗 |
| 工程落地 | 你能选对存储方案吗 | 你能选 Redis/MySQL/ES 吗 |
| 边界意识 | 你知道记忆系统的坑在哪吗 | 你知道缓存穿透/雪崩的坑吗 |


二、记忆分类学:从认知科学到 Agent 工程

2.1 认知科学分类

复制代码
人类记忆
├── 感觉记忆(毫秒级)→ Agent 中 ≈ 当前轮次的原始输入
├── 短期记忆(秒~分钟,容量 7±2)→ Agent 中 ≈ 滑动窗口内的对话
├── 长期记忆
│   ├── 陈述性记忆("是什么")
│   │   ├── 语义记忆(事实/知识)→ Agent 中 ≈ 向量库/RAG
│   │   └── 情景记忆(个人经历)→ Agent 中 ≈ 用户历史交互日志
│   └── 程序性记忆("怎么做")→ Agent 中 ≈ Prompt/SOP/技能定义

2.2 Agent 工程映射(这才是面试要说的)

|-------------|---------------------|------------|-------------|----------|-------|
| 认知层 | Agent 实现 | 存储介质 | 容量上限 | 检索方式 | 生命周期 |
| 工作记忆 | 当前 messages 列表 | GPU/内存 | 128K tokens | 全量送 LLM | 单轮请求 |
| 短期记忆 | 滑动窗口 + Checkpoint | PostgreSQL | 最近 N 轮 | 时间排序 | 单会话 |
| 长期记忆-语义 | 向量库 + RAG | pgvector | 百万级文档 | 语义检索 | 跨会话永久 |
| 长期记忆-情景 | 用户事件日志 | PostgreSQL | 取决于归档策略 | 结构化查询 | 按策略归档 |
| 程序记忆 | System Prompt + SOP | 配置文件/DB | KB 级 | LLM 每次读取 | 版本化管理 |

2.3 Java 后端直接类比

复制代码
工作记忆 = JVM 栈帧里的局部变量          → 方法调用结束就没了
短期记忆 = Redis Session / HttpSession    → 单次会话有效,有过期时间
语义记忆 = Elasticsearch 全文索引         → 跨会话持久化,语义搜索
情景记忆 = MySQL 用户行为表               → 结构化存储,可审计
程序记忆 = application.yml 配置           → 启动加载,热更新

三、记忆架构的两种流派

3.1 显式记忆(Explicit Memory)

Agent 主动调用记忆工具,自己决定存什么、什么时候查。

复制代码
用户: "我上次说的竞品分析报告呢?"
  ↓
Agent 思考: 用户提到了"上次"→ 需要查记忆
  ↓
Agent 调用: memory_search(query="竞品分析报告", time_range="past_7_days")
  ↓
返回: 记忆片段 [{content: "...", timestamp: "2026-06-10", session_id: "sess_001"}]
  ↓
Agent 整合到回答中

代表框架:LangChain ConversationBufferMemory → 显式调用

3.2 隐式记忆(Implicit Memory)

框架/引擎在幕后自动注入,Agent 无感知。

复制代码
用户: "我上次说的竞品分析报告呢?"
  ↓
框架自动检索相关记忆 → 注入到 System Prompt 前缀
  ↓
Agent 看到的上下文已经包含了记忆内容,正常回答

代表框架:Mem0、Letta(MemGPT)→ 自动注入

3.3 选型决策

|------------|-----------|----------------------------|
| 场景 | 推荐 | 理由 |
| 简单对话机器人 | 隐式记忆 | 开发快,无需设计工具 |
| 复杂多步骤任务 | 显式记忆 | Agent 需要判断"什么时候该查记忆" |
| 多 Agent 协作 | 显式 + 隐式混合 | 各 Agent 显式存,Supervisor 隐式读 |
| 面试常考 | 两者对比 + 混合 | 展现架构判断力 |


四、短期记忆:问题远比"保留最近 N 条"复杂

L3.2 讲了三层架构的"第一层:滑动窗口",这里深化到短期记忆的生产级设计

核心矛盾

LLM 的上下文窗口是有限的(128K tokens),但对话可以无限增长。短期记忆要解决的本质问题是:在有限预算内,保留最有价值的信息

三个维度,不是只靠轮数

只说"保留最近 5 轮"是面试扣分回答。实际上短期记忆的驱逐策略有三个变量在同时起作用:

① 轮数上限:最粗粒度的控制,超过 N 轮的消息进入"待压缩"队列,而不是直接丢弃。

② Token 预算:这才是真正的硬上限。一条消息可能 50 tokens,也可能 5000 tokens(贴了一大段代码)。只看轮数不看 token 消耗,10 轮轻量对话和 10 轮代码审查占用的上下文天差地别。TokenBudget 才是真正的闸门------累计 token 超限时,从最早的非 system 消息开始裁剪。

③ 信息密度优先级:不是所有消息都平等。驱逐时的保留优先级:

复制代码
System Prompt(灵魂,绝不删)
  > Tool 调用结果(包含事实数据)
  > 用户明确指令
  > 分析推理过程
  > 闲聊寒暄

这意味着一条 2000 token 的数据库查询结果,可能比 5 轮闲聊更有保留价值。

Token 预算管理的实现

python 复制代码
class TokenBudget:
    """滑动窗口的 Token 预算管理器"""
    
    def __init__(self, max_tokens: int = 8000):
        self.max_tokens = max_tokens
        self.messages: List[dict] = []
    
    def add(self, msg: dict) -> None:
        """添加消息,超出预算自动淘汰"""
        estimated = self._estimate_tokens(msg)
        self.messages.append(msg)
        
        total = sum(self._estimate_tokens(m) for m in self.messages)
        while total > self.max_tokens and len(self.messages) > 2:
            # 永远保留 system prompt(索引0) + 最新一条
            removed = self.messages.pop(1)  # 从最早的非 system 消息开始删
            total -= self._estimate_tokens(removed)
    
    def _estimate_tokens(self, msg: dict) -> int:
        """粗略估算:中文 ~1.5 字/token,英文 ~0.75 词/token"""
        return int(len(msg.get("content", "")) * 0.5)

Java 类比 :就像 Guava Cache 的 size-based eviction------maximumWeight(8000) + 自定义 weigher + expireAfterAccess,三项驱逐策略同时生效。

Checkpoint 的本质:存在哪、怎么恢复

Checkpoint 解决的不是"存什么",而是存储介质和恢复机制的工程选择。

LangGraph 的三级进化:

|---------------|------------|-----------|------|
| 方案 | 介质 | 并发能力 | 适用场景 |
| MemorySaver | 字典存内存 | 无(进程重启全丢) | 开发调试 |
| SQLiteSaver | 本地文件 | 无(单连接锁) | 单机部署 |
| PostgresSaver | PostgreSQL | 多用户并行读写 | 生产环境 |

面试关键点:为什么用 PostgreSQL 而不是 Redis 存 Checkpoint?

Redis 是缓存,PostgreSQL 是真相源。Checkpoint 存的是 Agent 的完整状态快照------丢了就丢了整段对话,这不是缓存能承担的责任。PostgreSQL 的 ACID 事务保证一个用户的多次对话不会互相覆盖,而且和业务表、向量表在同一个实例------运维只需要管一个数据库。


五、摘要记忆:压缩的艺术,不是"让 LLM 总结一下"

L3.2 讲了三层架构的"第二层:LLM 摘要压缩",这里深化到压缩策略的选择与权衡

三种策略的本质区别

LLM 摘要压缩有三种做法,区别在于每次压缩时,LLM 看到的是什么东西

|----------|-------------------|--------------|-------|-----------|
| 策略 | LLM 看到什么 | 复杂度 | 信息质量 | 适用 |
| 递增摘要 | 已有摘要 + 新增几轮 | O(1) | 有累积偏差 | 长对话(生产主流) |
| 全量摘要 | 全部原始历史 | O(n),随对话线性增长 | 最准确 | 短对话 |
| 分层摘要 | 递增为主 + 每 10 轮全量合并 | O(1) 摊销 | 可控 | 生产推荐 |

递增摘要为什么是生产主流?因为成本恒定。不管对话 100 轮还是 10000 轮,每次压缩只处理新增内容------200 input tokens + 100 output tokens,每轮成本约 $0.0002。全量摘要到 100 轮时每次要处理几万字,既不经济也不实时。

递增摘要会"漂移"------面试拉开差距的知识点

这不是幻觉,是累积信息衰减。假设每轮摘要 LLM 丢失 2% 的细节:

复制代码
第 10 轮摘要:保留原始信息的 98%  # 看起来还行
第 20 轮摘要:保留 98% × 98% = 96%
第 50 轮摘要:保留 98%^5 = 90%  # 已经开始丢东西
第 100 轮摘要:保留 98%^10 = 82%  # 近 1/5 的信息蒸发了

每轮 LLM 都觉得"我这个摘要挺好的",但 50 轮后回头看,很多早期关键信息已经被平滑掉了。

解决方案:分层策略里的定期全量合并------每 10 轮让 LLM 重新审视原始对话(不只是已有摘要),把跑偏的信息拉回来。这和 Kafka 的 log compaction 一个道理:每天做增量压缩,每周做一次全量压缩。

压缩的提示词比压缩的代码更重要

同一个对话,提示词写"请总结"和写:

"保留关键决策、用户偏好变化、待办事项;丢弃闲聊和已完成的查询"

产出的摘要质量差一个量级。摘要压缩的工程难点不在调用 LLM,在定义"什么值得保留"的标准。这个标准要根据业务场景定制------客服 Agent 可能侧重"用户情绪变化",分析 Agent 可能侧重"数据来源和结论"。

实现骨架

python 复制代码
class IncrementalSummarizer:
    """递增摘要 + 定期全量合并"""
    
    def __init__(self, llm, full_merge_interval: int = 10):
        self.llm = llm
        self.current_summary: str = ""
        self.turn_count: int = 0
        self.full_merge_interval = full_merge_interval
    
    async def compress(self, old_summary: str, new_turns: list[dict]) -> str:
        self.turn_count += 1
        new_text = self._format_turns(new_turns)
        
        if self.turn_count % self.full_merge_interval == 0:
            # 每 N 轮全量合并:抑制累积漂移
            prompt = f"""整合摘要+新对话为完整摘要。
保留:关键决策、用户偏好、待办事项、重要事实。
旧摘要:{old_summary}\n新对话:{new_text}"""
        else:
            # 正常递增:只压缩新内容
            prompt = f"""将新内容补充到已有摘要。
保留:新增决策、偏好变化。如果无新信息,返回原摘要。
已有摘要:{old_summary}\n新对话:{new_text}"""
        
        response = await self.llm.ainvoke(prompt)
        self.current_summary = response.content
        return self.current_summary

六、长期记忆:不止是 ORDER BY embedding <=>

L3.2 讲了三层架构的"第三层:向量数据库",L4-A 落地了 pgvector。这里深化到检索是一个排序问题,不只是相似度问题

检索不是一步完成的------五道工序

很多人的理解是:用户问问题 → 向量化 → 找最相似的 Top-K → 返回。这在 Demo 里够用,生产级差得远。完整的检索链:

复制代码
① Query 重写 → ② 混合检索 → ③ RRF 融合 → ④ 元数据过滤 → ⑤ Reranker 精排

① Query 重写 :用户说"上次那个报告",向量检索完全不知道"那个报告"是什么。所以检索前用 LLM 把模糊指代展开为 2-3 个具体搜索词,多角度捞。这是召回率放大器

② 混合检索:向量检索擅长语义但怕专有名词------"DeepSeek-V3"和"deepseek v3"在向量空间可能离得不近。BM25 关键词检索擅长精确匹配但不懂同义词。两条路同时走,各取所长。

③ RRF 融合:向量返回的分是 0.85,BM25 返回的分是 21.3,两个体系没法直接比。Reciprocal Rank Fusion 不管原始分数,只管排名位置------"你在向量结果里排第 3,在 BM25 结果里排第 7,综合排名 = 1/3 + 1/7 = 0.48。"

④ 元数据过滤:用户问"上周的竞品报告",你不需要检索三年前的数据。时间范围、内容来源、文档类型------这些在检索后过滤,比在 SQL 里限定更灵活。

⑤ Reranker 精排:这是整个流水线里最关键的一步。粗排 k=60 是为了高召回(宁可多捞),Reranker 是交叉编码器(Cross-Encoder),把 query 和每个候选文档拼接送模型做逐对相关性计算------比向量余弦相似度准一个量级。精排到 top_k=10 为精度。这就是推荐系统验证了几十年的两阶段范式(粗排→精排),到 Agent 长期记忆里照搬过来。

复制代码
粗排(向量余弦): "这家公司怎么样" vs "该公司盈利增长20%" → 0.72
精排(Cross-Encoder): 同样的 pair → 0.91
                                           ↑ 数值不同但更重要的是排序变了

时间衰减:旧信息权重应该更低

用户三个月前说"我在学 Java",上周说"我现在主要用 Python"------两个都是事实,但时效性不同。

用指数衰减给旧记忆降权:设置半衰期 30 天,每过 30 天权重减半。60 天前的记忆权重只有新记忆的 25%。检索时用 原始相似度 × 时间衰减系数 作为最终排序分。

这和 Elasticsearch 的 function_score + 高斯衰减一模一样------你不需要删除旧记忆,只需要让它"沉下去"。

python 复制代码
def time_decay_score(base_score: float, created_at: datetime, 
                     half_life_days: int = 30) -> float:
    """时间衰减:半衰期 30 天"""
    days_passed = (datetime.now(timezone.utc) - created_at).days
    decay = math.exp(-math.log(2) * days_passed / half_life_days)
    return base_score * decay

重要性评分:不是所有记忆都平等

"我决定用 PostgreSQL 替代 MySQL"(决策)vs "今天天气不错"(闲聊)------向量相似度看不出区别,但业务价值天差地别。

python 复制代码
class MemoryImportance:
    """多因子重要性评分"""
    
    @staticmethod
    def score(memory: dict) -> float:
        # 内容分类权重
        type_weight = {"decision": 0.9, "preference": 0.85, 
                       "fact": 0.7, "question": 0.3, "chitchat": 0.1}
        mem_type = classify_memory_type(memory["content"])
        
        # 时间新鲜度
        recency = time_decay_score(1.0, memory["created_at"])
        
        # 访问频率:被反复检索的记忆加分("高频真相")
        access_boost = min(memory.get("access_count", 0) / 10, 1.0)
        
        return type_weight[mem_type] * 0.5 + recency * 0.3 + access_boost * 0.2

最终检索分数 = 语义相似度 × 重要性 × 时间新鲜度,三个维度综合排序。只用相似度会把一条"今天天气不错"排在"我们决定换数据库"前面------因为前者的向量可能恰好更接近用户的查询。

三层记忆的协作才是关键

这三层不是各管各的。实际运行时:

复制代码
用户: "上次那个竞品分析结论再展开说说"

1. 短期记忆里找不到"上次那个"的指代
   → 触发长期记忆检索

2. 长期记忆检索到摘要:
   "2026-06-10 竞品分析结论:竞品A在定价策略上有显著优势"
   → 注入当前短期记忆

3. LLM 看到完整上下文后自然回答

4. 回答结束后,触发摘要压缩:
   "用户追问了定价策略细节" → 补充到已有摘要

三层记忆在流程里分别扮演:

  • 短期记忆:当前推理的桌面工作区
  • 摘要记忆:跨轮次的信息压缩缓存
  • 长期记忆:跨会话的知识库

每一层都在自己的时机被读写,缺一层系统就残废------没有短期记忆就没有多轮对话,没有摘要短期就会爆,没有长期记忆用户每次都得从头说一遍。


七、主流记忆框架横评

7.1 框架对比表

|----------------------|---------------------------------|-----------------|----------------|-------------|
| 框架 | 原理 | 存储 | 优劣 | 适合谁 |
| Mem0 | 自动记忆抽取 + 三层记忆 | 向量DB + DB | ✅ 开箱即用 ❌ 黑盒难调优 | 快速原型 |
| Letta (MemGPT) | OS 虚拟内存类比 + 自主管理 | SQLite + 向量 | ✅ 理念高级 ❌ 侵入性强 | 研究/复杂 Agent |
| Zep | 对话即记忆,时序 + 向量一体 | PostgreSQL/Neon | ✅ 企业级 ❌ 托管服务贵 | 生产系统 |
| LangChain Memory | 工具集,需手写逻辑 | 任意 | ✅ 灵活 ❌ 低层,需自己拼 | 自定义需求 |
| 自建(我们的方案) | PostgresSaver + pgvector + 滑动窗口 | PostgreSQL | ✅ 完全可控 ❌ 开发量大 | 深度定制 + 面试 |

7.2 面试回答模板

:"你们用了哪个记忆框架?为什么?"

:"我们评估了 Mem0 / Letta / Zep / LangChain Memory 四种方案。

Mem0 开箱即用但黑盒问题排障困难;Letta 理念好但侵入性太强,改造现有 ReAct 循环成本高;Zep 企业级但托管服务有数据出境风险。

最终选择自建方案 :PostgreSQL Checkpoint(短期)+ pgvector(长期语义)+ 滑动窗口(工作记忆),三合一跑在同一个 PostgreSQL 实例上,运维成本 = 零额外组件。

从架构上看,这就是简化版的 Mem0 ------ 我们保留了三层记忆的顶层设计,但把存储统一到 PostgreSQL 以降低运维复杂度。"


八、记忆检索策略:什么时候查、查什么

核心问题:检索不是免费的

每次记忆检索都有代价------延迟增加 100-300ms、token 消耗、LLM 注意力分散。如果用户说了句"你好"你也去检索记忆,那就和每次查数据库之前都扫描全表一样蠢。

所以检索策略的本质是:用最小的检索成本,把最相关的记忆在正确的时机送到 LLM 面前。

四种触发方式:从"每次都查"到"混合触发"

① 每轮都查 :最简单也最浪费。相当于每次请求都打一次 SELECT * FROM memories ORDER BY similarity。Demo 阶段可以这么干,上生产后检索延迟会成为瓶颈------1000 个并发用户每人每轮都触发向量检索,pgvector 直接冒烟。

② 关键词触发:纯规则,零延迟。识别到"上次""之前""还记得"这类词才触发检索。问题是用户可能用更隐晦的方式表达------"那个竞品报告有更新吗"没有关键词但显然需要查记忆。关键词覆盖了约 60-70% 的真实需求。

③ LLM 自主判断 :给 Agent 一个 search_memory 工具,让它自己决定什么时候调用。最灵活但也最危险------LLM 可能"忘记"调(漏检),也可能每轮都调(滥用)。需要 Prompt 里明确指导。

④ 混合触发(生产推荐):关键词快速预判覆盖大头 + LLM 工具调用兜底剩余场景。这和接口鉴权一个道理------网关规则先过滤明显请求,RBAC 再细粒度控制。

|----------|-----------------|-------|-------|---------|
| 触发方式 | 延迟 | 召回率 | 精确率 | 生产适用 |
| 每轮都查 | 高 (100-300ms/轮) | ~95% | ~20% | ❌ 浪费 |
| 关键词触发 | 零(规则判断) | ~65% | ~80% | 🔶 一层不够 |
| LLM 自主判断 | 取决于 LLM | ~90% | ~60% | 🔶 不稳定 |
| 混合触发 | 低(规则 80% 零延迟) | ~92% | ~75% | ✅ 生产推荐 |

python 复制代码
class MemoryTrigger:
    """混合触发:规则先筛 + LLM 兜底"""
    
    MEMORY_KEYWORDS = {"上次", "之前", "还记得", "以前", "历史", "回顾", "那个"}
    
    @staticmethod
    def should_search(user_input: str) -> bool:
        """第一阶段:关键词预判,零延迟覆盖 ~65% 场景"""
        return any(kw in user_input for kw in MemoryTrigger.MEMORY_KEYWORDS)
    
    # 第二阶段:如果关键词没命中,Agent 的 System Prompt 里
    # 告知它拥有 search_memory 工具,遇到"模糊指代""需背景信息"时可主动调用
    # 混合触发 = 高速路径(规则) + 灵活路径(LLM)

检索精度 vs 召回:面试官最爱挖坑的取舍题

面试官问"检索 60 条但只用了 10 条,是不是浪费?"------他在考察你是否理解推荐系统的两阶段范式。

核心逻辑:永远不要用一次检索同时追求高精度和高召回,物理上做不到。高召回需要宽网口(k 大),高精度需要窄网口(k 小)。拆成两步:

复制代码
粗排 (Recall): k=60, 余弦相似度, 快到毫秒级, 目标"别漏"
精排 (Precision): k=10, Cross-Encoder Reranker, 慢但准, 目标"别错"

直接用 k=5 检索:速度快,但能捞回的东西只有你看到的那些。相关但不相似的内容(同义词、换个说法的同一个问题)会被余弦相似度误杀。

用一个具体例子感受:

复制代码
用户查: "DeepSeek 的最新定价"
向量检索 k=5: 返回了 5 条包含"DeepSeek 价格"的文档
向量检索 k=60: 返回了"API 计费模型 2026""token 单价调整 0.28→0.14"这些
                    余弦相似度不高但内容极度相关的文档

k=60 时能捞到的额外 55 条里,经过 Reranker 精排,会有 5-8 条真正有用------这些是 k=5 永远看不到的。多花的检索成本(~50ms)换来的是回答质量的显著提升。


九、记忆更新与冲突解决

为什么冲突是必然发生的

记忆系统和数据库不一样------数据库里一行记录只有一个真相,但记忆里的"真相"会随时间变化。用户换工作了、技术栈变了、连博客地址都可能搬家。如果不处理冲突,Agent 就会用旧记忆回答新问题:你明明已经转 Python 了,它还推荐你学 Spring Boot 进阶。

冲突解决的本质是:在不丢失历史的前提下,让旧信息不再干扰新判断。

三种冲突场景,策略各不相同

场景 1:事实更新(直接覆盖)

旧记忆:"花月的博客地址是 blog.csdn.net"

新对话:"我的博客搬家了,新地址是 huayue.dev"

这种冲突的处理最直接------标记旧为 deprecated,写入新的。因为旧地址对当前决策零价值,留着只会让检索系统把用户导向死链接。但注意:是标记 deprecated 而非物理删除,万一需要追溯历史(比如排查某次回答为什么引用了旧博客),数据还在。

场景 2:偏好变化(保留历史 + 更新标签)

旧记忆:"花月偏好 Java 后端开发"

新对话:"最近决定主要用 Python 做 Agent"

和事实更新不同,偏好变化需要保留历史。为什么?因为三个月后用户可能又说"Java 那边有个项目要接"------你不保留历史偏好,就不知道用户其实有 Java 背景。处理方式是:旧偏好标记为"历史偏好",时间戳保留;新偏好设为"当前偏好",权重拉满。检索时当前偏好排前面,历史偏好作为补充上下文。

场景 3:矛盾信息(都保留 + 标记冲突)

旧记忆:"产品 A 的月活是 100 万"(来源:用户自述,时间:3 月)

新对话:"我看到产品 A 的月活最新数据是 80 万"(来源:第三方报告,时间:6 月)

两个都是"事实",但互相矛盾。这种情况下不要替用户做判断------你不知道哪个是真哪个是假。正确的做法:两条都保留,标记为 conflicting,Agent 在检索到冲突记忆时应该主动告诉用户"根据你 3 月提供的数据,月活是 100 万;但 6 月的第三方报告显示是 80 万"------让用户自己判断信哪个。

三层冲突解决策略:从自动到人工

复制代码
策略 1: 时间胜出    → 自动化    → 覆盖 60% 冲突(事实更新)
策略 2: 来源权威性  → 半自动    → 覆盖 25% 冲突(官方数据 > 用户推断)
策略 3: 保留+标记   → 人工兜底  → 覆盖 15% 冲突(矛盾信息,Agent 自行判断)

Java 类比:策略 1 就像数据库的乐观锁(版本号比对,新版本覆盖旧版本),策略 2 就像消息队列的优先级队列(来源权威性决定消费顺序),策略 3 就像 Git 的 merge conflict------系统不替你决定,标记出来让你手动解决。

python 复制代码
class MemoryConflictResolver:
    """记忆冲突解决器:三层递进策略"""
    
    async def resolve(self, existing: dict, incoming: dict) -> dict:
        # 策略1:时间胜出(最新优先)------ 适用于事实更新
        if incoming["timestamp"] > existing["timestamp"]:
            existing["status"] = "deprecated"  # 不删,只标记
            incoming["status"] = "active"
            incoming["replaces"] = existing["id"]  # 可追溯
            return incoming
        
        # 策略2:来源权威性 ------ 官方 > 用户 > 推断 > 未知
        source_authority = {"official": 10, "user_stated": 8, "inferred": 3, "unknown": 1}
        if source_authority.get(incoming["source"], 0) > source_authority.get(existing["source"], 0):
            existing["status"] = "deprecated"
            incoming["status"] = "active"
            return incoming
        
        # 策略3:保留两者 + 冲突标记 ------ 让 Agent 自行判断
        existing["status"] = "conflicting"
        incoming["status"] = "conflicting"
        # Agent 检索到冲突记忆时,主动向用户展示两个版本
        return incoming

面试加分点:为什么不能直接物理删除

"冲突了直接把旧的删了不就行了?"

不行。三个理由:

  1. 审计追溯:如果 Agent 用旧记忆给出了错误回答,你需要在日志里找到那条记忆来复盘
  2. 偏好回退:用户今天说用 Python,下周可能又说 Java 有个项目要接------历史偏好是上下文
  3. 训练数据:冲突记忆本身是宝贵的训练数据------如果你后续要微调冲突检测模型,这些就是标注样本

十、记忆遗忘策略:为什么需要"忘记"

记忆膨胀:沉默的系统杀手

大多数记忆系统的问题不是"记不住"而是"忘不掉"。假设一个用户每天产生 50 条记忆,一年后向量库里有 18,000 条记录。检索延迟从 50ms 涨到 500ms,向量索引膨胀到几个 GB,最致命的是------18,000 条里至少 30% 是过时的、错误的、或永远用不到的噪音。

这是一个不容易被注意到的恶化曲线:

复制代码
上线第 1 个月:检索 50ms,命中率 75%,存储 500MB
上线第 3 个月:检索 180ms,命中率 58%,存储 1.8GB
上线第 6 个月:检索 450ms,命中率 42%,存储 4.2GB  ← 用户开始抱怨"回答不准确"

根因不是算法变差了,是记忆里的噪音把有效信号淹没了。遗忘策略解决的就是这个问题。

遗忘 ≠ 删除:三种策略的哲学差异

① 时间衰减(软遗忘):不删除,只降权。半衰期 30 天意味着 60 天前的记忆在排序里的影响力只有新记忆的 25%。适用场景是"可能还有用但优先级很低"的信息------比如用户三个月前说喜欢某个 UI 风格,现在可能不喜欢了,但万一喜欢呢?降权而不是删除,让它在排序里沉下去但不消失。

② 访问频率淘汰(冷热分离):和 CPU 缓存淘汰一个道理------90 天没人碰的数据,大概率以后也不会有人碰了。但这里的"淘汰"不是物理删除,是归档到冷存储(比如 S3/对象存储)。归档数据检索慢 10 倍但成本只有热数据的 1/10。对于"可能偶尔被问到"的历史信息,这是性价比最高的方案。

③ 显式删除(硬删除) :用户说"忘了这个",或者隐私合规要求(GDPR 的"被遗忘权"),必须物理删除。这里最难的是级联删除------用户的偏好是从某段对话里提取出来的,那段对话可能和其他记忆有关联。删一条可能意味着要把一整棵记忆依赖树都标记为"来源已失效"。

什么样的记忆该被遗忘?一个判断框架

复制代码
是否包含用户 PII(个人身份信息)? → 有合规要求,到期必须删
最后一次被检索是什么时候? → >90 天 → 归档冷存储
是否被标记为 deprecated? → >30 天 → 物理删除
是否与当前活跃话题相关? → 否 + 低重要性分 → 降权但不删
时间衰减分是否 < 0.1? → 是 → 归档(保留但不出现在常规检索里)

JVM GC 类比------面试时一句顶十句

Java 后端出身,这个类比天然是你的武器:

复制代码
Young Gen (Eden/S0/S1)  → 短期记忆(滑动窗口)
   高频回收,大部分对象(消息)用一次就扔
   
Old Gen (Tenured)       → 摘要记忆 + 活跃长期记忆
   经过 N 轮 GC 还活着的对象,说明有价值,移到老年代
   类比:被反复检索的记忆说明是"高频真相",提升重要性分

Full GC                 → 全量记忆归档
   谨慎触发(STW 影响大),一般凌晨跑
   类比:定时任务凌晨做全量归档,不影响在线检索

-XX:MaxTenuringThreshold → 时间衰减半衰期
   经过多少次 GC 才晋升老年代?30 天半衰期 = 阈值设 30

class MemoryGarbageCollector:
    """记忆 GC:分代回收策略"""
    
    async def collect(self):
        # Young Gen 回收:时间衰减到阈值以下 → 归档
        await self.archive("WHERE time_decay_score < 0.1")
        
        # Old Gen 回收:90 天未被访问 → 归档冷存储
        await self.archive("WHERE last_accessed < NOW() - INTERVAL '90 days'")
        
        # Full GC:deprecated 超过 30 天 → 物理删除(不归档)
        await self.delete(
            "WHERE status = 'deprecated' AND updated_at < NOW() - INTERVAL '30 days'"
        )
        
        # 级联:来源已删除的记忆 → 标记为 orphaned
        await self.mark_orphaned("WHERE source_memory_id IN (SELECT id FROM deleted)")

面试加分:遗忘策略的生产考量

  • 不要同步删:检索请求的响应时间里同步删除记忆 = 自杀。删除走异步队列,不影响检索路径。
  • 先归档再删除:物理删除前一定先归档到冷存储。万一删错了,还有后悔药。
  • 监控遗忘速率:如果每天遗忘量突然翻了 10 倍,大概率是 bug 在误杀有效记忆------告警阈值设为"遗忘量 > 前 7 天日均值 × 3"。
  • 用户可控的遗忘 :给用户"清除我的记忆"按钮(合规要求),但后端实现是标记 deleted_by_user=true 而非物理删除------为可能的"误操作恢复"留 30 天窗口。

十一、记忆与推理的平衡

这不是"存 vs 算"的二元对立

面试最容易掉进去的坑:把记忆和推理对立起来------要么全记住(堆存储),要么全靠 LLM 现场算(堆算力)。正确答案永远在中间的灰度地带:记忆是随时可取的廉价缓存,推理是缓存 miss 时的昂贵回退。

就像一个 CPU:L1 缓存(短期记忆)命中 → 1ns 取到,缓存 miss → 走 DRAM(LLM 推理)→ 100ns 算出来。你不会因为 CPU 能算就把缓存拆了,也不会因为缓存足够就不需要 CPU。

决策框架:什么时候该查、什么时候该算

|----------------|-----------------|------------|----------------|
| 问题类型 | 记忆(查) | 推理(算) | 策略 |
| "我上次说的竞品报告" | 命中率 90%,50ms | LLM 猜不准 | 必查(不查没法回答) |
| "分析一下竞品的定价策略" | 补充数据,降低推理成本 | LLM 本身就能分析 | 查为辅,算为主 |
| "你好 / 谢谢 / 好的" | 命中率 2%,100ms 白花 | 立刻能回 | 不查(延迟换零价值) |
| "总结今天的对话" | 摘要记忆直接给 | LLM 重读全文太慢 | 优先查摘要 |

关键判断标准不是"有没有相关信息",而是检索成本 vs 推理收益。检索一次 ~100ms + ~500 tokens 上下文窗口消耗。如果检索回来的信息和 LLM 自己就能推断出来的差不多------那这次检索就是纯浪费。

面试中的致命错误

❌ "我们的 Agent 每次都先查记忆再回答"

面试官听到这句话的潜台词:你的系统每轮对话至少多 100ms 延迟,1000 个并发用户就多 100 次 pgvector 扫描,其中 60% 的检索结果 LLM 根本没用到------你在给数据库加压,给用户加等待时间,但没有给回答质量加分。

❌ "Agent 把所有信息都记下来,推理交给 LLM"

这和"我把所有代码都写在一个 main 函数里,逻辑交给 CPU 去跑"一样------能跑,但跑不远。LLM 的推理能力随上下文窗口增长而衰减(Lost in the Middle 效应),上下文越臃肿,LLM 越容易忽略关键信息。记忆系统存在的意义就是帮 LLM 做信息预筛选,而不是把所有东西都堆给它。

三级决策引擎:从快到慢的分层路由

复制代码
用户输入
  │
  ├─→ [快速响应] "你好/谢谢/再见" → skip(0ms, 0 token)
  │    规则匹配,直接跳过记忆检索
  │
  ├─→ [事实查询] "上次/之前/还记得/什么时候" → quick_search(100ms, ~500 tokens)
  │    轻量向量检索 k=20, 不用 Reranker
  │
  ├─→ [深度分析] "分析/对比/总结/回顾" → full_search(300ms, ~2000 tokens)
  │    完整流水线:Query重写→混合检索→RRF→Reranker
  │
  └─→ [不确定] → llm_decide(LLM 自行判断是否调用 search_memory 工具)
       兜底路径

Java 类比 :这就是 Spring 的 @Cacheable 注解策略------简单查询走缓存(@Cacheable),复杂计算才进 Service 层(LLM 推理),缓存 miss 时降级到全量计算但这次结果写回缓存供下次用。

python 复制代码
class MemoryDecisionEngine:
    """记忆调用决策引擎:三级分层路由"""
    
    QUICK_RESPONSE = {"你好", "谢谢", "好的", "再见", "知道了", "OK"}
    FACT_CHECK = {"上次", "之前", "还记得", "数据", "是多少", "什么时候"}
    DEEP_ANALYSIS = {"分析", "对比", "总结", "回顾", "梳理", "评估"}
    
    def decide(self, user_input: str) -> str:
        # 第一级:快速响应 → 零成本
        if any(t in user_input for t in self.QUICK_RESPONSE):
            return "skip"
        # 第二级:深度分析 → 全量检索
        if any(t in user_input for t in self.DEEP_ANALYSIS):
            return "full_search"
        # 第三级:事实查询 → 轻量检索
        if any(t in user_input for t in self.FACT_CHECK):
            return "quick_search"
        # 兜底:LLM 自主判断
        return "llm_decide"

十二、生产落地的七个关键点

这七个点不是"建议",是从大量翻车案例里总结出来的血泪教训。每一条背后都有一个生产事故。

12.1 存储统一:少一个组件就少一个故障点

架构上最大的诱惑是"每个问题找一个最好的专用工具"------Redis 做缓存、Elasticsearch 做全文检索、PostgreSQL 做业务数据、pgvector/Weaviate/Pinecone 做向量检索。看起来很漂亮,但上线三个月后你会发现:

  • Redis 的内存满了要扩容
  • ES 的索引出了问题要重建
  • pgvector 的版本和 PostgreSQL 不兼容要回滚
  • 四个组件之间的数据一致性永远对不齐

生产铁律:能用同一个数据库解决的问题,绝不多引入一个组件。PostgreSQL 一体三用(关系表 + pgvector + Checkpoint),运维只需要管一个实例的备份、监控、故障恢复。这不是技术限制,是运维成本的理性选择。

复制代码
✅ PostgreSQL 一体三用:
   1. 关系表(业务数据)
   2. pgvector(向量检索)
   3. Checkpoint(Agent 状态)

❌ 避免:
   架 Redis 做短期 + Elasticsearch 做向量 + PostgreSQL 做业务 = 运维噩梦
   每多一个组件,故障概率乘积,不是你加出来的

12.2 检索去重:用户贴同一篇文章三次,你就写三条记忆?

同一个用户在三个不同对话里分享了同一个 URL,如果不做去重,向量库里会有三条几乎相同的记忆。检索时三条同时返回,占用了 Top-K 里宝贵的三个位置------用户真正想找的那条记忆被挤到了第 6 名。

去重逻辑的核心不是"内容完全相同"(太严格),而是"来源相同 + 语义相似"。用 source_url 做分区,同一来源只保留向量相似度最高的那条。相似度阈值设 0.3------低于这个说明内容已经差异很大了,可以都保留。

sql 复制代码
-- 同一来源的相似内容只保留一份
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY source_url 
    ORDER BY embedding <=> $1
  ) AS rank
  FROM chunk_embeddings
  WHERE embedding <=> $1 < 0.3  -- 低于 0.3 的差异大到可以算"不一样的内容"
)
SELECT * FROM ranked WHERE rank = 1;

12.3 检索缓存:同一个 query 5 秒内来两次,只查一次

用户说"那个报告呢"→ Agent 查了一次记忆 → 返回了 10 条结果。5 秒后用户说"不对,我要的是上周那个"------但前半句"那个报告"的查询结果其实没变,不需要重新检索。

用 LRU 缓存(maxsize=128),key 是 query 的哈希,过期时间 5 分钟。覆盖的场景是:用户连续追问同一话题,每次追问都衍生出轻微变化的 query,但核心检索词没变。

注意:缓存的是检索结果,不是 LLM 的回答。检索结果是数据库操作,适合缓存;LLM 回答是推理结果,不应该缓存(同一份记忆证据,用户问的方向不同,回答应该不同)。

python 复制代码
@lru_cache(maxsize=128)
async def cached_memory_search(query_hash: str):
    """同一个 query 的检索结果缓存 5 分钟"""
    return await actual_search(query)

12.4 记忆分片:三个维度切,检索时只扫热区

不要把所有用户的记忆扔进同一个向量空间------检索时你扫的是全量表。三个维度的分片策略:

按用户分 (最基础):每个 user_id 的向量在独立命名空间。检索时加 WHERE user_id = $1,pgvector 的 IVFFlat 索引可以在用户维度大幅缩小扫描范围。

按时间分(冷热分离):热数据(30 天内)存高性能 SSD 表空间,温数据(30-90 天)存普通存储,冷数据(90 天+)归档到对象存储。检索时优先扫热区,热区命中率通常超过 80%。

按类型分(权重差异化):决策类("我们决定用 PostgreSQL")和偏好类("我喜欢深色模式")比事实陈述和闲聊有价值得多。检索时对不同类型加不同权重,而不是让相似度算法自行判断重要性。

12.5 监控指标:四个指标,缺一个就是盲飞

没有监控的记忆系统 = 没有仪表盘的汽车。你永远不知道什么时候会撞墙。

|------------------|-----------------|----------|----------|--------------------------------|
| 指标 | 为什么重要 | 健康阈值 | 告警阈值 | 告警后做什么 |
| 检索延迟 P99 | 直接决定用户体验 | < 200ms | > 500ms | 检查 pgvector 索引是否失效、是否该做 VACUUM |
| 记忆命中率 | 衡量记忆系统是否存在 | > 60% | < 30% | 检索策略可能有问题,或记忆都是噪音 |
| 总量增长率 | 提前发现异常写入 | < 10%/天 | > 50%/天 | 大概率是 bug 导致重复写入,紧急排查 |
| LLM 摘要延迟 P95 | 摘要压缩是后台任务,但不能太慢 | < 3s | > 5s | 摘要 prompt 可能太长,或 LLM 服务端拥塞 |

为什么命中率 < 30% 要告警? 因为这意味着 70% 的检索都是白做的------延迟花了、数据库负载加了,但返回的结果 LLM 根本没用到。这要么是你的检索策略有问题(搜的东西和用户问的不匹配),要么是记忆库被低价值内容污染了。

12.6 降级策略:记忆系统挂了,Agent 不能跟着挂

这是面试最能体现工程成熟度的地方。任何外部依赖都可能挂,记忆系统也不例外。关键是:降级后 Agent 依然能给出一个有用的回答,只是不如查了记忆那么精准。

复制代码
记忆检索超时(> 500ms) 
  → 放弃检索,凭当前上下文回答
  → 回答末尾不暴露"我查不到记忆"(用户不需要知道内部故障)
  → 但要在日志里记录降级事件,用于事后分析

pgvector 挂了 / 索引损坏
  → 降级为 "只用 BM25 关键词检索"(tsvector 走的 PostgreSQL 原生全文索引)
  → 如果 BM25 也挂了 → 进一步降级为 "纯上下文回答"

LLM 摘要调用失败(API 限流 / 超时)
  → 不做摘要压缩,滑动窗口直接用硬截断
  → 下次对话时少了摘要上下文,但至少对话能继续

Java 类比:这就是 Hystrix/Sentinel 的熔断降级------外部依赖不可用时,fallback 到一个更简单但能跑的逻辑,而不是把异常直接甩给用户。

12.7 成本控制:记忆系统每月花多少钱,你要能算出来

面试官问"你们记忆系统的成本是多少",如果回答"没用多少"或"不太清楚",直接暴露了你没做过成本核算。

精确估算方法(以 DeepSeek API 为例):

复制代码
每次递增摘要:200 input tokens + 100 output tokens
  = 200 × $0.00000014 + 100 × $0.00000028
  = $0.000028 + $0.000028 = $0.000056/次

每次向量检索:k=60, embedding 维度 1024
  = pgvector 本地计算,零 API 成本

每次 Reranker:10 个候选 × (query + doc) 拼接
  ≈ 3000 tokens input × $0.00000014 = $0.00042/次

日均成本估算(1000 活跃用户,每人日均 20 轮对话):
  摘要压缩:20,000 次 × $0.000056 = $1.12
  Reranker:(假设 30% 对话触发深度检索)6,000 次 × $0.00042 = $2.52
  Embedding:10,000 次写入 × $0.00001 = $0.10
  日均总计 ≈ $3.74,月均 ≈ $112

省钱的三个关键决策:① 递增摘要而非全量(O(1) 不是 O(n))② 向量粗排用本地 pgvector(免费)只把 Reranker 送 LLM ③ 快速响应不触发检索(跳过 ~30% 的无意义检索)。这三个决策加起来,能把记忆系统成本压到不做任何优化的 1/5。


十三、Java 后端全面类比总结

|-------------------------|--------------------------|-----------------------|
| Java 概念 | Agent 记忆对应 | 说明 |
| Guava LoadingCache | 滑动窗口 + TokenBudget | 容量驱逐 + 权重计算 |
| Redis Session | PostgresSaver Checkpoint | 会话状态持久化 |
| Elasticsearch | pgvector 向量检索 | 语义搜索 + 全文检索 |
| Kafka log compaction | LLM 递增摘要 + 定期全量合并 | 压缩 + 去重 |
| JVM GC 分代回收 | 记忆 GC 归档策略 | Young/Old Gen → 热/冷数据 |
| 缓存穿透/雪崩 | 记忆检索降级 + 缓存 | 故障时优雅降级 |
| 数据库读写分离 | 记忆读多写少优化 | 检索比写入频繁 100:1 |
| Spring Cache @Cacheable | 检索缓存 lru_cache | 相同 query 不重复检索 |
| application.yml | System Prompt / SOP | 程序记忆 |
| MySQL binlog | 情景记忆(用户事件日志) | 可审计、可回溯 |


十四、面试题自测

题 1:简述 Agent 记忆系统的三层架构。

标准回答

工作记忆(当前 messages)→ 短期记忆(滑动窗口 + PostgreSQL Checkpoint,最近 N 轮)→ 长期记忆(pgvector 语义检索 + 关系表情景记忆)。三层各司其职:工作记忆送 LLM,短期记忆跨轮持久化,长期记忆跨会话。


题 2:短期记忆为什么要用 Checkpoint 而不是直接把 messages 存 Redis?

标准回答

Redis 存 JSON 序列化的 messages 可以,但需要额外处理:① 版本迁移(messages 结构变了怎么兼容)、② 并发安全(多轮同时写入)、③ 与 LangGraph 集成(原生支持 Checkpoint,自己接 Redis 要写适配层)。

PostgreSQL Checkpoint 的好处:ACID 保证一致性 + JSONB 灵活查询 + 与向量表同一数据库。


题 3:向量检索只靠相似度排序够吗?

标准回答

不够。生产级需要五道工序:Query 重写(多角度检索)→ 混合检索(向量 + BM25)→ RRF 融合 → 元数据过滤(时间/来源/类型)→ Reranker 精排。

单独相似度排序的问题:① 旧信息和新信息一样权重(需要时间衰减)、② 长文档和短文档不公平(需要长度归一化)、③ 闲聊和重要决策一样权重(需要重要性评分)。


题 4:Mem0 / Letta / Zep / 自建怎么选?

标准回答

|-------------------------------------------------------|---------------|----------|------------|
| 选 Mem0 | 选 Letta | 选 Zep | 选自建 |
| 快速验证想法 | 研究自主记忆管理 | 有预算要 SLA | 深度定制需求 |
| 不需要调优记忆策略 | OS 式内存管理理念吸引你 | 不想管运维 | 记忆和数据要同一DB |
| 我们选自建是因为:统一存储(PostgreSQL 三合一)降低运维成本,而且面试时能讲清每一层的设计决策。 | | | |


题 5:记忆冲突怎么处理?

标准回答

三层策略:① 时间胜出(新信息覆盖旧信息)→ ② 来源权威性(用户明说的 > LLM 推断的)→ ③ 都保留 + 冲突标记(让下游自行判断)。

不会直接物理删除旧记忆------保留完整历史用于审计和回溯,只标记 status=deprecated。


题 6:记忆系统的成本怎么控制?

标准回答

三个方向:① 摘要用递增而非全量(O(1) 而非 O(n))、② 检索先向量粗排再 Reranker 精排(减少 LLM 调用次数)、③ 定期 GC 归档冷数据(控制向量库规模)。

以每日 1000 轮对话估算,记忆系统日均成本 < $5(DeepSeek API)。


题 7:你说的记忆系统和我理解的"用 Redis 存一下对话历史"有什么区别?

标准回答

区别就像"用 HashMap 存一下数据"和"设计一个数据库"的区别。

Redis 存对话历史只是短期记忆的一种最简单实现。完整的记忆系统还需要:① 摘要压缩(不能无限存)、② 语义检索(不是简单时间排序)、③ 冲突解决(新旧信息矛盾)、④ 遗忘策略(不能只增不减)、⑤ 降级方案(存储故障时 Agent 还能跑)。

Redis 可以是一个底层存储,但不是"记忆系统"。


十五、自检清单(25 项,能讲清即过关)

  • 能画三层记忆架构图并解释每层存储/容量/检索方式
  • 能讲清 TokenBudget 的容量驱逐策略
  • 能对比递增摘要 vs 全量摘要 vs 分层摘要
  • 能解释"递增摘要漂移"及解决方案
  • 能写出时间衰减公式并解释半衰期含义
  • 能讲清混合检索的五道工序(Query重写→混合→RRF→过滤→Reranker)
  • 能对比 Mem0 / Letta / Zep / LangChain Memory / 自建
  • 能解释为什么 PostgreSQL 一体存储优于多组件
  • 能讲清三种检索触发方式及混合触发实现
  • 能解释检索精度 vs 召回的两阶段设计(粗排→精排)
  • 能讲清记忆冲突的三种场景和三层解决策略
  • 能解释时间衰减 / 访问频率淘汰 / 显式删除的区别
  • 能类比 JVM GC 解释记忆归档策略
  • 能讲清记忆 vs 推理的决策原则
  • 能说出记忆系统的四个监控指标和阈值
  • 能讲清两种降级策略(存储故障 / LLM 摘要失败)
  • 能估算记忆系统日均成本
  • 能讲清为什么不用 Redis 直接存对话历史
  • 能用 Java 缓存体系类比 Agent 记忆系统
  • 能讲清短期记忆中 Checkpoint 优于 Redis 的理由
  • 能解释检索缓存的作用和实现
  • 能讲清记忆分片的三个维度
  • 能解释重要性评分的多因子公式
  • 能讲清记忆膨胀的危害和应对
  • 能回答"你们 Agent 的记忆系统是怎么设计的"(3 分钟内完整表述)