对话历史是原始流水,记忆是经过提炼、去重、可检索、可撤销的长期资产。两者的区别不在「存了什么」,而在「写入前是否判定、写入后是否治理」。会存对话的 Agent 满地都是,会治理记忆的没几个——这就是这一期的分水岭。
前五期建立了「模型只提出决策、运行时负责执行」这条主线:
- 第 01 期说明了 LLM 是概率生成模型,注意力是 O(n²)、KV Cache 是必备优化;
- 第 02 期把 Agent 拆成 Model / Prompt / State / Memory / Tools / Guardrails,并区分了 State 与 Context;
- 第 03 期讲清了 Tool Call 从意图到执行的完整链路;
- 第 04 期讲清了工具能力如何被发现、接入和治理;
- 第 05 期把上下文当预算来管,压缩掉的信息「去哪里」是其中一支——去记忆。
这一期接着第 05 期的尾巴往下走。上下文里被压缩、被淘汰的信息不会凭空消失,其中「关于用户的一般事实」需要被沉淀成可复用的资产。Memory 就是上下文的长期沉降层。
本篇回答两个问题:
| # | 面试问题 | 指向哪个工程面 |
|---|---|---|
| Q1 | Agent 为什么需要 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.0 | 1.0 | 0.5 | 1.0 | 0.875 | ✅ |
| 我们约定:所有数据库迁移必须先跑 make gen。 | 0.964 | 1.0 | 0.85 | 1.0 | 0.954 | ✅ |
| 这次先用 SQLite,上线再换 PG。 | 0.962 | 0.3 | 0.2 | 1.0 | 0.58 | ❌ |
| 上周排查过一个 502,是网关超时导致的。 | 1.0 | 1.0 | 0.5 | 0.5 | 0.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。两个理由:
- 可审计:谁在什么时候覆盖了谁,能查——用户说「你记错了」时能溯源到原始证据;
- 留退路:新记忆也可能是错的。如果物理删了旧的,想恢复就得让用户重新说一遍。
这就是 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——记忆不是「有就好」,它得让任务做得更好。
七、常见的错误认识
- 「把对话存下来就是记忆」 —— 那是短期上下文的延长,不是资产;
- 「记忆就是向量库」 —— 明确字段用 SQL 比向量更精确;
- 「什么都值得记」 —— 临时状态会污染长期记忆(见 2.2 的 0.58);
- 「冲突就覆盖」 —— 覆盖错了会持续影响后续对话,且丢失可审计性;
- 「冲突就都留着」 —— 矛盾记忆并存,召回时随机选错;
- 「记忆只增不减」 —— 噪声上升、检索变慢、成本失控;
- 「记忆可以直接改模型权重」 —— 不可撤销、难审计、无法鉴权、无法隔离,四个理由全灭;
- 「记忆和 RAG 是一回事」 —— 写入方、内容对象、更新方式全不同;
- 「任务状态挂在 session 上」 —— 新会话就丢失,必须挂 task(N9);
- 「拿不准就猜」 —— 记忆写错比没写更糟,该问就问。
八、概念速查
| 概念 | 一句话 |
|---|---|
| 记忆资产 | 提炼 + 去重 + 可检索 + 可撤销的长期信息 |
| 情景 / 语义 / 程序记忆 | 发生过什么 / 稳定事实 / 怎么做 |
| 短期 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 三个「改一改再跑」的练习
- 把
WRITE_THRESHOLD从 0.62 降到 0.55,观察「这次先用 SQLite」这类临时状态以 0.58 擦边写入、开始污染长期记忆; - 把
layered_compress(max_active=2),观察摘要吃掉了多少细节(提示:摘要里只剩 18 字截断); - 把
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)——判断错了要能回头。
