Agent 面试

记忆系统(Memory):从对话历史到记忆资产

记忆系统(Memory):从对话历史到记忆资产

对话历史是原始流水,记忆是经过提炼、去重、可检索、可撤销的长期资产。两者的区别不在「存了什么」,而在「写入前是否判定、写入后是否治理」。会存对话的 Agent 满地都是,会治理记忆的没几个——这就是这一期的分水岭。

前五期建立了「模型只提出决策、运行时负责执行」这条主线:

  • 第 01 期说明了 LLM 是概率生成模型,注意力是 O(n²)、KV Cache 是必备优化;
  • 第 02 期把 Agent 拆成 Model / Prompt / State / Memory / Tools / Guardrails,并区分了 State 与 Context;
  • 第 03 期讲清了 Tool Call 从意图到执行的完整链路;
  • 第 04 期讲清了工具能力如何被发现、接入和治理;
  • 第 05 期把上下文当预算来管,压缩掉的信息「去哪里」是其中一支——去记忆。

这一期接着第 05 期的尾巴往下走。上下文里被压缩、被淘汰的信息不会凭空消失,其中「关于用户的一般事实」需要被沉淀成可复用的资产。Memory 就是上下文的长期沉降层。

本篇回答两个问题:

#面试问题指向哪个工程面
Q1Agent 为什么需要 Memory?短期和长期怎么分?对话历史算记忆吗?记忆的定义边界与数据模型
Q2「我喜欢苹果」→ 三个月后「我最近不吃苹果了」,怎么处理?如何避免无限增长?写入判定、冲突消解与遗忘机制

两题是一条完整的生命周期:Q1 说明「什么才算记忆」,Q2 说明「写进来之后怎么治」。能答清 Q1 的边界,Q2 的治理才有对象;否则就容易把「把对话全存下来」当成了记忆系统。


一、对话历史算不算记忆

1.1 推荐回答

对话历史是载体,不是记忆本身。 它是原始流水——未经筛选、未去重、未结构化、无过期机制。记忆资产要满足四个条件:

记忆资产 = 提炼 + 去重 + 可检索 + 可撤销
              ↑      ↑       ↑        ↑
           从流水里  相同/    下次能   能改、能删、
           挑出一般  相近的  被召回   能标记过期
           化的事实  合并

一句对照:对话历史回答「刚才说了什么」,记忆回答「关于这个用户,什么是长期成立的」。

这也是 Q1 的胜负手。面试里如果答「就是把对话存起来,下次拼进 prompt」,基本就停在「会调框架」的层面了——因为那只是短期上下文的延长,不是记忆。

1.2 三类记忆

借用认知心理学的分类,但落到工程上是三种不同的数据形态与生命周期:

类型内容例子生命周期存储
情景记忆(Episodic)发生过什么「上周三排查过一个 502,是网关超时」中(可衰减)带时间戳的事件记录
语义记忆(Semantic)稳定的事实与偏好「用户偏好华为手机」「公司用 PG 不用 MySQL」长(除非被推翻)结构化字段 + 向量
程序记忆(Procedural)怎么做(技能/约定)「本项目的数据库迁移必须先跑 make gen」长规则/流程文档

再加一条时间维度的切分:

短期记忆(Short-term)= 当前任务的工作区  —— 随 session 结束而消失
长期记忆(Long-term)  = 跨会话的沉淀      —— 跨 session 存活,需要治理

关键区分不是「时长」,而是「是否跨会话存活 + 是否需要治理」。 一个任务跑两小时但不跨会话,仍是短期;一句偏好说完就跨会话留存,就是长期。

1.3 session 与 task 必须分开建模(N9)

这是最容易埋坑的地方。很多人把「任务状态」直接挂在 session(对话容器)上,结果:

用户开了个新会话问:「上次那个迁移任务做到哪了?」
  └─ 挂 session 上 → 新会话看不到旧任务 → 答「不知道」
  └─ 挂 task 上   → 任务状态独立于会话 → 可跨 session 恢复

正确做法:session = 对话容器,task = 任务实体。任务状态(进度、checkpoint、变量)挂在 task_id 上,session 只是「哪个对话在聊这个 task」的索引。这条在 08 期(状态管理)会展开,但它的建模决定在记忆这一期就该做对——因为记忆和任务状态是两类不同的数据。


二、写入判定:一条信息凭什么被记下来

2.1 推荐回答

不是所有信息都值得进记忆。写入前要过四问:

维度判据反例
新颖度与已有记忆重合度低吗?又一次说「我喜欢咖啡」,已有记录
稳定性长期有效,还是本次临时?「这次先用 SQLite,上线换 PG」
可复用性下次还用得上吗?「帮我算一下 3 加 5」
置信度用户明确说的,还是模型推断的?模型「推测」用户喜欢简洁回答

综合打分超过阈值才写。这个四问不是学术分类,而是给工程一个可实现的判定函数——第 9 节的代码就把它写成了加权函数。

2.2 稳定性和新颖度是两件事(代码实证)

这是四问里最反直觉、也最值得在面试里强调的一条。看配套代码跑出来的四个样本:

候选记忆新颖稳定复用置信综合写入
我不吃香菜,点外卖记得备注。1.01.00.51.00.875✅
我们约定:所有数据库迁移必须先跑 make gen。0.9641.00.851.00.954✅
这次先用 SQLite,上线再换 PG。0.9620.30.21.00.58❌
上周排查过一个 502,是网关超时导致的。1.01.00.50.50.775✅

注意第三行:它新颖度高达 0.962、置信度满分,却依然被拒——因为稳定性 0.3、可复用性 0.2 把它拖到 0.58,低于 0.62 的阈值。

这就证明了那条面试里最容易被追问的结论:「新颖」和「重要」是两件事。 一次性的技术选型决策很「新」,但它属于任务状态(第 08 期),不属于长期记忆。如果把它写进记忆库,三个月后模型还在以为「用户要用 SQLite」。

阈值定在 0.62(而不是 0.55)就是为它设的——0.55 时这条会以 0.58 擦边写入,长期累积就污染了记忆库。

2.3 常见的物化形式

写入判定通过后,记忆通常落到三种存储:

存储适合检索方式
结构化字段(PG/MySQL)明确字段的偏好、约定、配置SQL 精确查询
向量(pgvector / Milvus)自然语言描述的事实语义相似度召回
图谱(可选)实体关系(人—项目—技术栈)关系遍历

不要用「向量库包打天下」。 一句「用户偏好华为手机」用 SQL 查 preference='brand' 比向量检索更精确、更便宜、更好审计。向量只在「表述不确定、需要模糊匹配」时才是必要的。生产里常见的是结构化优先 + 向量辅助。


三、冲突消解:用户改口了怎么办

3.1 推荐回答

Q2 的前半句——「我喜欢苹果」→「我最近不吃苹果了」——考的是有没有冲突意识。正解不是「覆盖」也不是「都留着」,而是先判定关系,再选处理方式。五种关系:

关系判据处理
无关(none)不同维度、不同对象各存各的
重复(duplicate)语义等价复用计数 +1,不新增
取代(supersede)同维度、极性相反(用户明确改口)旧的标记 superseded,新的生效
共存(coexist)无直接冲突但相关两条都留
存疑(ask_user)同维度、可能冲突但拿不准降级为向用户确认

3.2 为什么「拿不准要问」而不是硬猜(代码实证)

配套代码对三段新陈述的处理:

已记录:用户喜欢吃苹果
  ├─ 新陈述「用户最近不吃苹果了」
  │    → supersede:同维度(吃)极性相反,旧的标记 superseded,保留历史不物理删除
  ├─ 新陈述「用户喜欢喝咖啡」
  │    → ask_user:同维度(喜欢、喝)却不构成重复(重合 40%)
  │                 细节可能冲突,降级为向用户确认  ← 拿不准时问,不要猜
  └─ 新陈述「用户偏好华为手机」
       → coexist:无直接冲突,两条共存

ask_user 这一条是工程上最容易做错的。 最常见的两个错误是「拿不准就覆盖」和「拿不准就都留下」:

  • 覆盖错了 → 用户偏好被写反,后续所有对话都受影响;
  • 都留下 → 两条矛盾记忆并存,模型每次召回都可能选错那条。

正确做法是降级为向用户提问。理由很硬:记忆写错比没写更糟——没写只是这次不知道,写错会持续污染后续全部交互。所以凡是「同维度、可能冲突、但又不能确定是改口」的,都应该问。

3.3 为什么旧记忆不物理删除

supersede 之后旧记忆标记为 superseded,不 DELETE。两个理由:

  1. 可审计:谁在什么时候覆盖了谁,能查——用户说「你记错了」时能溯源到原始证据;
  2. 留退路:新记忆也可能是错的。如果物理删了旧的,想恢复就得让用户重新说一遍。

这就是 04 期讲的「可撤销」落到记忆上的具体形态。

3.4 中文冲突判定的坑(诚实说明)

配套代码用词表规则判定语义冲突,这是个演示做法,不是生产做法。写它的过程中踩的坑值得记录:

  • 单字匹配误报严重:「用」会命中「用户」,「开」会命中「开票」——三个毫无关系的咨询记录被判成「同维度、需要问用户」;
  • 中文否定是「插入式」的:「吃」与「不吃」共享「吃」,靠子串包含判断极性完全失效。

规则最后收敛到「只用多字词做维度锚点 + 否定式优先」才稳定。但结论是明确的:生产系统必须用语义判定(embedding 相似度 + NLI 矛盾检测),规则版只适合做「明确矛盾」的快速拦截和兜底。

之所以保留它,是因为它让「判定错误 → 错误记忆 → 持续影响对话」这条链路可被观察——这正是这一期想让读者看到的东西。


四、防膨胀:记忆怎么不无限增长

4.1 推荐回答

记忆只增不减,三个后果:召回噪声变大、检索变慢、成本上升。四种治理手段:

手段做什么触发时机
TTL / 衰减情景记忆随时间降权、过期定时任务
重要度归档低分记忆转冷存储,不进热召回超阈值时
分层压缩明细 → 摘要(一条摘要替代多条细节)超上限时
聚类去重合并近似存量记忆离线批处理

4.2 分层压缩与聚类去重(代码实证)

分层压缩(超上限时把低优先级明细压成一条摘要):
  8 条低优先级明细 → 4 条明细 + 1 条摘要(压缩 4 条)

聚类去重(离线批处理,合并近似存量记忆):
  「用户喜欢喝美式咖啡」+「用户喜欢喝美式咖啡不加糖」
    → 保留置信度高的一条,复用计数累加,另一条标记 superseded

实测输出:

【④ 防膨胀:分层压缩】
  压缩前活跃记忆:8 条(全部低优先级明细)
  压缩后活跃记忆:5 条(4 条明细 + 1 条摘要)
  被压缩:4 条 → 合并为 m009

【⑤ 聚类去重】
  存量记忆:3 条 → 合并 1 组:[('m001', 'm002')]
  去重后活跃:2 条

两个动作的共同点:都不物理删除,而是把 status 改为 superseded。 理由同 3.3:可审计 + 留退路。

4.3 过期与用户可删除

除了「涨得太快」,还有一类必须处理:「这条已经不对了」。

  • 显式失效时间:带 TTL 的情景记忆到期自动转 expired;
  • 失效检测:新陈述与旧记忆矛盾 → 触发 supersede(3.1 的流程);
  • 用户可删除:合规要求(GDPR「被遗忘权」)。这不只是功能,是法定义务。

五、记忆与 RAG 的边界

5.1 推荐回答

这是 06 期最常被单独拎出来问的一道题(规划里的 N10)。六个维度的对照:

维度记忆(Memory)检索增强(RAG)
写入方系统自己写(从对话/行为中提炼)外部文档进(人上传/同步)
内容对象关于「你」的事实(偏好、约定、历史)关于「世界」的事实(知识、文档、代码)
更新方式持续增量、需冲突消解与过期整批替换、按版本重建索引
检索目标召回「用户画像」召回「支撑答案的证据」
出错后果个性错乱、越权、隐私问题答错事实、引用失效
存储选型结构化优先 + 向量辅助向量库为主 + 元数据过滤

5.2 一句话记法

记忆召回「关于你的事实」,RAG 召回「关于世界的事实」。

这句不是修辞,它直接决定工程决策:

「我的项目用哪个数据库?」        → 记忆(关于你)
「PostgreSQL 的 MVCC 怎么工作的?」 → RAG(关于世界)

如果搞混,会出现两个典型错误:把用户偏好当知识文档塞进 RAG(检索出来一半是别人的偏好);或把产品文档当记忆写进用户档案(用户 A 能看到用户 B 的配置)。


六、从机制到工程

6.1 写入不能全交给模型

让 LLM 自由决定「什么该记」,会导致:记忆爆炸(什么都记)、记忆漂移(记成模型自己推断的)、隐私泄漏(把不该记的记了)。工程上必须有代码侧的准入规则——四问打分是模型侧的启发式,但敏感信息过滤、用户级隔离、写入白名单必须由代码保证。

6.2 召回不能只靠向量相似度

高相似度 ≠ 高相关性。「用户喜欢喝咖啡」和「用户喜欢喝茶」向量很近,但如果当前问题是「帮我推荐咖啡豆」,只有前者该被召回。生产上召回要相似度 + 重要度 + 时间新鲜度 + 状态(只召回 active) 多路融合。

6.3 记忆的评估指标

至少记录:召回准确率(该召回的召回了没)、误召回率(召回的是不是相关)、陈旧率(有多少记忆已经不对了)、对任务成功率的增益(有记忆 vs 没记忆的 A/B)。最后一项才是记忆系统真正的 KPI——记忆不是「有就好」,它得让任务做得更好。


七、常见的错误认识

  1. 「把对话存下来就是记忆」 —— 那是短期上下文的延长,不是资产;
  2. 「记忆就是向量库」 —— 明确字段用 SQL 比向量更精确;
  3. 「什么都值得记」 —— 临时状态会污染长期记忆(见 2.2 的 0.58);
  4. 「冲突就覆盖」 —— 覆盖错了会持续影响后续对话,且丢失可审计性;
  5. 「冲突就都留着」 —— 矛盾记忆并存,召回时随机选错;
  6. 「记忆只增不减」 —— 噪声上升、检索变慢、成本失控;
  7. 「记忆可以直接改模型权重」 —— 不可撤销、难审计、无法鉴权、无法隔离,四个理由全灭;
  8. 「记忆和 RAG 是一回事」 —— 写入方、内容对象、更新方式全不同;
  9. 「任务状态挂在 session 上」 —— 新会话就丢失,必须挂 task(N9);
  10. 「拿不准就猜」 —— 记忆写错比没写更糟,该问就问。

八、概念速查

概念一句话
记忆资产提炼 + 去重 + 可检索 + 可撤销的长期信息
情景 / 语义 / 程序记忆发生过什么 / 稳定事实 / 怎么做
短期 vs 长期当前任务工作区 vs 跨会话沉淀(关键在是否跨会话 + 是否需治理)
写入四问新颖度 / 稳定性 / 可复用性 / 置信度
五种冲突关系无关 / 重复 / 取代 / 共存 / 存疑
supersede同维度极性相反,旧记忆标记而非删除
ask_user拿不准时降级为向用户提问
分层压缩明细 → 摘要,超上限时触发
聚类去重离线合并近似存量记忆
记忆 vs RAG关于你 vs 关于世界

九、动手实验

配套代码:

agent-developer-interview/agent-06-memory

实验用一个零依赖的记忆管理器,把写入判定、冲突消解、边界对照、压缩去重全部参数化,观察「同样一批信息、不同治理策略」下的记忆库状态。

9.1 核心代码节选(一):写入判定四问

W_NOVELTY, W_STABILITY, W_REUSABLE, W_CONFIDENCE = 0.25, 0.30, 0.25, 0.20
WRITE_THRESHOLD = 0.62

def score_memory(text, store):
    novelty     = 1.0 - max_similarity(text, store.all_texts())   # 越不重合越新
    stability   = 0.3 if any(h in text for h in _TRANSIENT_HINTS) else 1.0
    reusable    = 0.85 if any(h in text for h in _REUSABLE_HINTS) else 0.5
    confidence  = 1.0   # 用户明确说的;模型推断的给 0.5
    score = (W_NOVELTY * novelty + W_STABILITY * stability
             + W_REUSABLE * reusable + W_CONFIDENCE * confidence)
    return {"score": score, "write": score >= WRITE_THRESHOLD, ...}

关键在稳定性的权重最高(0.30)——这是把「临时状态」挡在门外的核心。真实输出(四个样本):

我不吃香菜,点外卖记得备注。          0.875  ✅
我们约定:所有数据库迁移必须先跑 make gen。 0.954  ✅
这次先用 SQLite,上线再换 PG。        0.58  ❌  ← 新颖 0.962 却被拒
上周排查过一个 502,是网关超时导致的。   0.775  ✅

9.2 核心代码节选(二):冲突消解五类

def resolve_conflict(new_text, store):
    for old in store.active():
        if _is_duplicate(new_text, old.text):
            return {"relation": "duplicate", "action": "reinforce", "target": old.mem_id}
        if _same_dimension(new_text, old.text) and _is_opposite(new_text, old.text):
            return {"relation": "supersede", "action": "mark_superseded", "target": old.mem_id}
        if _same_dimension(new_text, old.text) and not _is_duplicate(new_text, old.text):
            return {"relation": "ask_user", "action": "confirm_with_user"}
    return {"relation": "coexist", "action": "append"}

真实输出:

「用户最近不吃苹果了」 → supersede(同维度吃、极性相反)
「用户喜欢喝咖啡」     → ask_user(同维度但不重复,重合 40%)
「用户偏好华为手机」   → coexist(无直接冲突)

9.3 核心代码节选(三):分层压缩

def layered_compress(self, max_active=4):
    active = self.active()
    if len(active) <= max_active:
        return {"compressed": 0, "summary_id": None}
    low = sorted(active, key=lambda m: m.confidence + m.stability)[:len(active) - max_active]
    summary_text = "(历史记忆摘要)曾经记录过:" + ";".join(m.text[:18] for m in low)
    summary = MemoryItem(kind=MemoryKind.SEMANTIC, text=summary_text, ...)
    for m in low:
        m.status = Status.SUPERSEDED           # 不物理删除
    self.items.append(summary)
    return {"compressed": len(low), "summary_id": summary.mem_id}

真实输出:8 条低优先级明细 → 4 条明细 + 1 条摘要(压缩 4 条)。

9.4 脚本与结论对应表

段落验证的结论
① 写入判定四问新颖度高的临时状态仍被稳定性压下去
② 冲突消解五类supersede / ask_user / coexist 的分流依据
③ 记忆 vs RAG 边界「关于你 vs 关于世界」
④ 分层压缩明细 → 摘要,且不物理删除
⑤ 聚类去重近似存量记忆合并,保留高置信度那条

9.5 三个「改一改再跑」的练习

  1. 把 WRITE_THRESHOLD 从 0.62 降到 0.55,观察「这次先用 SQLite」这类临时状态以 0.58 擦边写入、开始污染长期记忆;
  2. 把 layered_compress(max_active=2),观察摘要吃掉了多少细节(提示:摘要里只剩 18 字截断);
  3. 把 dedup(threshold=0.5),观察过激的阈值会把多少不同记忆误合并。

十、延伸阅读

站内

  • 上下文工程与压缩:压缩掉的信息「去哪里」——一支就是去记忆,是本文的上游。
  • 第 02 期:什么是 AI Agent——State 与 Context 的区别、组件职责边界。
  • 第 08 期:Planning 与状态管理——session 与 task 的分离、状态外置与断点续跑。
  • 第 09 期:Harness——Memory 组件挂在运行时的哪一层、暴露什么接口。
  • 第 12 期:生产系统设计——架构里的 Memory 组件与数据层。
  • Agent 记忆专题:记忆金字塔、召回注入时机与分层压缩实践。

站外

  • Anthropic:Contextual Retrieval——为每个 chunk 补上下文再索引,缓解「碎片化检索」。
  • MemGPT / Letta:Memory OS——把上下文当虚拟内存分页管理的早期探索。
  • LangGraph / Mem0:记忆写入判定与冲突消解的开源实现参考。

十一、一句话总结

对话历史是载体,记忆是资产。分水岭不在「存了什么」,而在写入前是否判定(四问)、写入后是否治理(五类冲突、压缩去重、可过期可删除)。

而在所有治理规则里,最该记住的一条是:记忆写错比没写更糟。 没写只是这次不知道;写错会持续污染后续每一次对话。所以拿不准时该问就问(ask_user),旧记忆该标记就标记而不是删(supersede)——判断错了要能回头。

AI Agent面试记忆系统Memory

← 返回面试题