记住什么是能力,忘掉什么才是设计。一个没有遗忘机制的记忆系统,会从资产迅速变成污染源——错误信息被反复召回、过时结论被当成当前事实、用户的一句玩笑被永久固化。这一章讲清一个记忆系统必须回答的六个问题。
编辑部注 · 本章的核心判据只有一句话:能被解释、能被更正、能被删除的记忆才是资产。
把记忆理解成「把历史存起来、需要时取出来」是最简单的理解,也是失败率最高的理解。因为记忆系统的真正难点不在「存」,而在四件事:什么时候该写、写的是什么级别的信息、冲突时信谁、以及怎么保证用户能把它删掉。
一、先分清三种「记忆」
很多人把 工作记忆(Working Memory)、情节记忆(Episodic Memory)、语义记忆(Semantic Memory) 三件不同的事都叫记忆,导致设计混乱。它们应该分开存、分开管。
| 类型 | 内容 | 存哪 | 存活期 |
|---|---|---|---|
| 工作记忆 | 当前任务的上下文、中间结果、待办 | 上下文 + 外部状态文件 | 任务结束即释放 |
| 情节记忆 | 发生过什么:任务历史、结论、失败原因 | 结构化日志 / 向量库 | 按需保留,可归档 |
| 语义记忆 | 长期稳定的事实与偏好:用户是谁、习惯什么口径 | 键值或知识表 | 长期,但必须可修改可删除 |
混淆的代价很具体:如果把「用户这次让我用简洁格式」写进语义记忆,下一次任何任务都会被这个偏好影响;如果把「本次任务的目标」写进语义记忆,三个月后它还会被召回。
二、写入时机:三个触发器
写入是记忆系统最容易失控的环节。最常见的事故是写入过多——把每一轮对话都写进去,结果记忆库充满噪音,召回(Memory Recall)质量急剧下降。真正决定「写不写」的是 写入触发器(Write Trigger):只有显式指令、任务固化、受控自动抽取三类事件才允许落库。
三、读取:怎么把记忆捡回来
前面三节都在讲「怎么把记忆写好、写对、写稳」,但记忆系统的另一半——读取——经常被漏掉。写进去只是第一步,真正决定效果的是「下一次对话时,能不能在正确的时候把正确的那条捡回来」。这一节补上另一半。
读取的难点不在「找得到」,而在「该不该放进去」。 记忆不是越多越好(这也是后面 5.5 误区要讲的)。每次对话开始时,你要从记忆库里挑出与当前话题相关的、且仍然有效的那批,塞进上下文——这个「挑」的动作就是读取策略,它有三个关键决策:
- 按什么召回:多数系统用「当前消息做 embedding,和记忆做相似度检索」——但这会漏掉「当前消息没提到、但上下文隐含需要」的记忆。更稳的做法是分层召回:先用硬规则(实体 id、topic 标签)精确命中,再用语义相似度补充,避免纯向量检索的「看着相关、其实无关」或「精确匹配却漏掉」。
- 按什么排序:召回一批后要排序。排序信号至少有三个——时效(越新的越优先)、置信度(fact 优先于 inference)、相关性(与当前意图的匹配度)。不能只按相似度排,否则一条半年前的「用户偏好红色」可能压过昨天刚说的「现在改要蓝色」。
- 按什么截断:记忆进上下文会占用预算,必须设上限(比如最多 N 条、或最多 M 字符),并在截断时优先保留高置信度事实、舍弃低置信度推断。截断不是「砍掉尾巴」,而是按优先级挑选。
| 决策 | 正确做法 | 常见错误 |
|---|---|---|
| 召回 | 硬规则(实体/topic)精确命中 + 语义相似度补充 | 只靠纯向量检索,漏召回或误召回 |
| 排序 | 时效 + 置信度 + 相关性,三信号加权 | 只按相似度排,旧的高相似记忆压过新的 |
| 截断 | 设上限,按「高置信事实优先」挑选 | 直接砍尾巴,把关键事实留在外面 |
读取与写入的闭环:读取时若发现「召回了一条已被软删除的旧版本」(superseded_at 非空),要自动跳过、只取 active 的那条——这就是为什么上一节的软删除不是可有可无的装饰,它直接服务于读取的正确性。同理,读取时若发现两条 active 记忆互相矛盾,就触发下一节的冲突消解流程,而不是静默选一条。
一句话:写入决定「有没有」,读取决定「用不用得对」。很多记忆系统效果差,不是没存,是读的时候全量塞、无脑排,把预算占满还盖掉了真正有用的那几条。
四、冲突消解:两条记忆打架怎么办
这是记忆系统最难的部分,也是面试里最容易问到的深层问题。它的正式名字是 冲突消解(Conflict Resolution),难点不在「判断」,而在「判断之后要不要假装知道答案」。
| 冲突类型 | 示例 | 处理原则 |
|---|---|---|
| 时序冲突 | 「A 部门负责这个指标」→ 半年后「改由 B 部门负责」 | 新事实取代旧事实,但旧事实要保留为历史版本并标记失效时间,而不是物理删除 |
| 来源冲突 | 用户口述「口径是 X」,系统文档写「口径是 Y」 | 系统文档优先作为事实;用户口述作为补充或标注为待确认 |
| 层级冲突 | 通用偏好「输出详细」vs 本次指令「要简洁」 | 越临时的越优先,本次指令覆盖长期偏好,且不修改长期偏好 |
| 真伪冲突 | 两条记忆语义相反且无时间差异 | 不选边:两条都标为「存在冲突」,在需要时向用户澄清 |
当两条记忆真正矛盾且无法用时序判断,绝大多数系统的做法是「取相似度高的那条」或「取新的那条」。这两种做法都在假装自己知道答案。
正确做法是:保留冲突,并在回答时显式暴露它——「关于这个口径,我这里有两条互相矛盾的信息(A:…… B:……),你希望以哪条为准?」 这样做有两个好处:用户得到了正确的服务,而且这次澄清会产出一条高置信度的新记忆,把冲突真正解决掉。
五、让记忆可解释、可更正、可删除
这三件事是记忆系统的底线,也是产品能不能被信任的前提。其中 软删除(Soft Delete) 保证「忘得掉」,记忆可解释性(Explainability) 保证「说得清」——二者合起来,用户才敢用。
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
@dataclass
class Memory:
key: str # 归属实体 + 主题,如 "user:42/pref/format"
content: str
tier: str # fact | preference | inference
confidence: float # 0-1,推断类必须低于 0.8
source: str # 谁说的 / 从哪来的
written_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
superseded_at: str | None = None # 被新版本取代的时间(软删除)
evidence: list[str] = field(default_factory=list) # 依据链
@property
def active(self) -> bool:
return self.superseded_at is None
def to_prompt_line(self) -> str:
"""回喂格式:内容 + 时间 + 层级 + 置信度,让模型自己能判断时效"""
tag = f"[{self.tier}·{self.confidence:.1f}]"
return (f"{tag} {self.content}\n"
f" 记录于 {self.written_at[:10]} 来源:{self.source}")
class MemoryStore:
def __init__(self):
self._items: dict[str, list[Memory]] = {} # key -> 版本链(保留历史)
def write(self, m: Memory) -> str:
"""
同 key 的旧版本不删除,只标记 superseded_at。
这保证了「可解释」——任何时候都能回答「为什么系统会这么认为」。
去重按内容指纹判断,而不是字符串相等:同一事实会有无数种表述,
直接比字符串几乎永远为假(见误区 5.3)。
"""
chain = self._items.setdefault(m.key, [])
fp = memory_fingerprint(m.content)
for old in chain:
if old.active and memory_fingerprint(old.content) == fp:
return "duplicate"
if old.active:
old.superseded_at = m.written_at # 软删除,保留证据
chain.append(m)
return "written"
def recall(self, keys: list[str], limit: int = 5) -> list[Memory]:
"""只返回当前有效版本,且带时间戳"""
out = []
for k in keys:
actives = [m for m in self._items.get(k, []) if m.active]
out.extend(actives)
return out[:limit]
def conflicts(self) -> list[tuple[Memory, Memory]]:
"""检测同 key 下无法用时序消解的并存冲突(理论上不该存在)"""
res = []
for chain in self._items.values():
actives = [m for m in chain if m.active]
if len(actives) > 1:
res.append((actives[0], actives[1]))
return res
def forget(self, key: str, hard: bool = False) -> int:
"""
删除能力是记忆系统的合规底线。
hard=True 用于用户行使「删除权」,彻底清除包括历史版本。
"""
if key not in self._items:
return 0
n = len(self._items[key])
if hard:
del self._items[key]
else:
for m in self._items[key]:
m.superseded_at = m.superseded_at or datetime.now().isoformat(timespec="seconds")
return n
def explain(self, key: str) -> str:
"""可解释性:把一条记忆的完整演变史打印出来"""
chain = self._items.get(key, [])
if not chain:
return f"{key}: 无记录"
lines = []
for m in chain:
state = "生效" if m.active else f"已于 {m.superseded_at[:10]} 失效"
lines.append(f" {m.written_at[:10]} {state} [{m.tier}] {m.content}(来源 {m.source})")
return f"{key} 的演变:\n" + "\n".join(lines)
def memory_fingerprint(text: str) -> str:
"""内容指纹,用于跨库去重"""
return hashlib.sha256(text.strip().encode()).hexdigest()[:16]- 用
explain()输出一条记忆的演变史,检查是否满足「任何时刻的结论都能追溯到来源与时间」; - 构造一个时序冲突(先写 A,再写 A'),验证
recall()只返回 A' 而历史仍可查; - 构造一个真伪冲突(同 key 两条 active),验证
conflicts()能检出,并设计一段话术在回答时暴露冲突。
六、常见误区与追问
6.1 误区:自动抽取最省事,开着就好
很多团队把「每轮自动判断有无值得记的」当成默认开启的能力。错在这会把一次性、临时的信息固化成长期记忆:用户随口说「这次简洁点」会被写成长期偏好,某次错误的推断会被当成事实。判据很硬:当记忆库里一次性信息的比例超过 30%,召回的信噪比会明显跌破可用线,模型开始「越跑越笨」。修法是给自动抽取设稳定性阈值(这条信息一个月后还成立吗?)加定期清理,且优先用显式指令触发写入。
6.2 误区:新旧事实冲突,直接覆盖旧的就行
直觉是「新的覆盖旧的,干掉旧的省事」。这是 时序冲突(Temporal Conflict) 的典型误处理:直接覆盖会丢掉可解释性——你再也无法回答「系统为什么曾经认为口径是 X」。正确做法是旧值保留为历史版本并标记 superseded_at(软删除),新值成为唯一 active 版本。代价只是多一点存储,换来的是任何时候都能把演变链打印出来。
6.3 误区:记忆去重靠字符串相等判断
实现上常写「如果内容等于已有记忆就跳过」。但同一事实会有无数种表述,字符串相等几乎永远为假,于是语义重复的记忆被反复写入,冲突与噪音随之而来。正确做法是用 内容指纹(Content Fingerprint)(对内容取哈希)做跨库去重,更新时改旧版本的 superseded_at 而不是新建一条。
6.4 误区:用户说删记忆,删掉当前值就够了
只删当前 active 值,历史版本仍在,既不满足合规删除权(如 GDPR 的被遗忘权),也会让过期结论在审计里重现。系统必须同时支持软删除(标记失效,保留证据)与硬删除(彻底清除含历史版本)。这两件事不矛盾:日常用软删除保可解释,用户行使权利时用硬删除彻底清除。
6.5 误区:记忆越多,Agent 越聪明
反直觉但真实:记忆是资产还是污染源,取决于信噪比。召回上限建议 5 条以内,且必须带写入时间,让模型自己判断时效性。过量召回会挤占上下文、把无关信息变成干扰,结果不是「更懂你」,而是「更常被旧信息带偏」。判据:统计一次召回里有几条真正被写进答案,命中率低于一半就先按实体与主题收窄召回键,而不是继续加大 top-k;记忆出问题几乎都是召得太宽,而不是记得太少。
七、自测
八、小结
| 问题 | 答案要点 |
|---|---|
| 记什么 | 分三层:工作记忆(任务级)/ 情节记忆(历史)/ 语义记忆(长期事实与偏好) |
| 什么时候写 | 显式指令 > 任务结束固化 > 自动抽取(最需谨慎) |
| 写入前校验 | 稳定性、来源可信、非重复、可归属,四条都要过 |
| 冲突怎么办 | 时序 / 来源 / 层级 / 真伪,四类用四套原则;真伪冲突不选边 |
| 怎么召回 | 上限 5 条以内,必须带时间与来源 |
| 底线 | 可解释、可更正、可删除(含硬删除) |
记忆的价值不在于记得多,而在于记得准、说得清、忘得掉。本刊编辑部
下一章处理一个常见的设计抉择:当任务复杂到一定程度,是该开更多 Agent,还是把上下文做得更好?
九、参考与延伸
本章的机制部分只讲到「够用」为止。想往下深挖,下面这几份材料按「先看图、再看代码、最后读博客」的顺序排好了。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看图(建立直觉)
- Anthropic · Building Effective Agents —— 把「记忆 / 检索 / 工具」当成 Agent 的状态组件来设计。重点看它怎么把记忆作为结构化状态而不是聊天历史,和本章的「分层存储」直接对应。
再看代码(动手实现)
- Datawhale · hugging-llm —— 中文社区对 LLM 应用工程的系统梳理。其中记忆与检索相关章节,可对照本章的「写入触发器 / 冲突消解」动手实现一遍。
最后读博客(对齐一手定义)
- Eugene Yan · Blog —— Eugene 的长期实践帖覆盖记忆、检索与个性化。他关于「把用户偏好当长期状态管理」的讨论,正是本章语义记忆要解决的问题。
- Simon Willison · LLM and AI notes —— Simon 大量记录「Agent 跑飞 / 工具误用」的真实案例。翻他的 agent 标签,能看到记忆与权限失控的真实事故。
◇ 面试官会怎么问
共 6 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
长期记忆你会存什么?什么时候写? 高频
写入时机是这道题的关键:只在信号足够强的时候写——用户显式纠正("以后别用表格")、用户显式声明("我在做招聘")、或者同一偏好被重复观察到多次。绝不能把每一次会话里的临时要求都写进去,否则记忆区会变成噪声区,注入后反而干扰判断。
配套要求:写入带时间戳与来源,支持用户查看与删除,并设置 TTL 或衰减机制。
追问链
- 怎么防止把一次性偏好写进去?
- 记忆检索是走向量还是规则?
记忆和新事实冲突了怎么办?
① 先分类——是"偏好变化"还是"事实变化"?偏好变化(从喜欢表格改成喜欢列表)通常是覆盖;事实变化(换团队了)也是覆盖,但要保留历史区间。
② 带时间与置信度——每条记忆都有生效时间与置信度,冲突时高置信度优先;同等置信度则新的优先,但把旧的标记为 superseded 而不是删除。
③ 不可消解就暴露给用户——如果两条记忆指向矛盾结论(用户说"汇报要简洁",但历史反馈显示他常要求补充细节),不要自己赌,应该在输出时给出选择或直接询问。
核心原则是:记忆系统要能解释自己为什么这么判断,否则出错时无法排查。
怎么证明记忆让效果变好了,而不是让输出变得飘忽?
做法是 A/B:同一批任务分别跑「注入记忆」与「不注入记忆」两条链路,对比任务完成率、人工介入次数、以及目标风格的一致率。要特别关注负向信号:注入记忆后,模型是否开始在不相关场景里生搬硬套旧偏好(过拟合);是否出现"忘了任务本身要求,只记得风格要求"(注意力争夺)。
工程上还有两个必要设计:记忆可关闭(某个任务可以临时不使用历史偏好)和记忆可追溯(输出里能看出用了哪条记忆)。没有这两样,记忆出问题时你会连原因都定位不到。
设计一个写入触发器系统,你怎么决定一条信息「该不该写」进记忆? 高频
其中自动抽取噪音最大,必须设稳定性阈值加定期清理,且优先用显式指令触发。
答出「稳定性阈值 + 定期清理 + 显式指令优先」说明真做过,而不是背过。
追问链
- 自动抽取噪音占比超过多少就该告警?
- 显式指令写入后要不要回显确认?
两条记忆语义相反、且无时间差(真伪冲突),系统该怎么处理?
用户澄清后会产出一条高置信度新记忆,把冲突真正解决。
选边就是在假装自己知道答案,比明确的「不确定」危害更大。
追问链
- 如果用户一直不回应怎么办?
- 怎么把这次澄清沉淀成高置信记忆?
记忆去重为什么不能靠原始字符串相等判断?那该怎么判断?
正确做法是先归一化再取指纹:把同一实体归一到同一个 key、值做规范化(单位、大小写、空白),再对规范化后的内容取哈希做跨库去重;更新时改旧版本的 superseded_at,而不是新建一条。
判据:去重键必须是「实体 + 属性 + 规范化值」的指纹,光对原文取哈希只能挡住一字不差的重复。
追问链
- 归一化漏掉一种表述会怎样?
- 更新时是覆盖还是标记失效?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。