Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
II 版 · Harness 工程 第 05 章 Remember and Forget
II · Harness 工程 05 / 22 进阶

Memory 记忆系统

Remember and Forget
预计阅读 30 分钟
难度 进阶
关键词 长期记忆 · 写入策略
本机状态 未读

记住什么是能力,忘掉什么才是设计。一个没有遗忘机制的记忆系统,会从资产迅速变成污染源——错误信息被反复召回、过时结论被当成当前事实、用户的一句玩笑被永久固化。这一章讲清一个记忆系统必须回答的六个问题。

编辑部注 · 本章的核心判据只有一句话:能被解释、能被更正、能被删除的记忆才是资产。

把记忆理解成「把历史存起来、需要时取出来」是最简单的理解,也是失败率最高的理解。因为记忆系统的真正难点不在「存」,而在四件事:什么时候该写、写的是什么级别的信息、冲突时信谁、以及怎么保证用户能把它删掉。

一、先分清三种「记忆」

很多人把三件不同的事都叫记忆,导致设计混乱。它们应该分开存、分开管。

类型内容存哪存活期
工作记忆当前任务的上下文、中间结果、待办上下文 + 外部状态文件任务结束即释放
情节记忆发生过什么:任务历史、结论、失败原因结构化日志 / 向量库按需保留,可归档
语义记忆长期稳定的事实与偏好:用户是谁、习惯什么口径键值或知识表长期,但必须可修改可删除

混淆的代价很具体:如果把「用户这次让我用简洁格式」写进语义记忆,下一次任何任务都会被这个偏好影响;如果把「本次任务的目标」写进语义记忆,三个月后它还会被召回。

二、写入时机:三个触发器

写入是记忆系统最容易失控的环节。最常见的事故是写入过多——把每一轮对话都写进去,结果记忆库充满噪音,召回质量急剧下降。

什么时候该写:三个触发器 ① 显式指令 用户明确说「记住……」 「以后都用这个口径」 可靠性:最高 频率:低 策略:写入后回显确认 ② 任务结束固化 任务完成后固化「结论」而非 「过程」:目标、结论、依据 可靠性:高 频率:中(每任务一次) 策略:写成结构化条目 ③ 自动抽取 ⚠ 最需谨慎 每轮自动判断「有无值得记的」 可靠性:低 频率:高 → 噪音主要来源 策略:必须设阈值 + 定期清理 写入前必须过的四道校验(跳过任何一道都会留下长期污染) ① 稳定性:这条信息一个月后还成立吗?只是一次性偏好 → 不写。 ② 来源可信:是用户在陈述事实,还是模型自己的推断?推断必须标为推断。 ③ 非重复:与已有记忆语义重复 → 更新旧的,而不是新建一条(否则冲突)。 ④ 可归属:必须关联到具体实体(用户 / 项目 / 部门),不能是悬空陈述。 召回侧:只选必要的那几条 召回过多的记忆会挤占上下文、并把无关信息变成干扰。上限建议 5 条以内,且必须带「写入时间」。 带时间的原因:让模型自己判断这条是不是已经过时——你无法替它判断,但你可以把判断依据交给它。 兜底:任何召回的记忆,都允许用户看到、更正、删除。
图 1 写入侧四个校验 + 召回侧三条纪律。注意触发器 ③ 的标注:自动抽取是记忆系统噪音的主要来源,绝大多数「记忆让效果变差」的案例都出在这里——不是记忆没用,而是自动抽取把一次性信息固化了。

三、冲突消解:两条记忆打架怎么办

这是记忆系统最难的部分,也是面试里最容易问到的深层问题。

冲突类型示例处理原则
时序冲突「A 部门负责这个指标」→ 半年后「改由 B 部门负责」新事实取代旧事实,但旧事实要保留为历史版本并标记失效时间,而不是物理删除
来源冲突用户口述「口径是 X」,系统文档写「口径是 Y」系统文档优先作为事实;用户口述作为补充或标注为待确认
层级冲突通用偏好「输出详细」vs 本次指令「要简洁」越临时的越优先,本次指令覆盖长期偏好,且不修改长期偏好
真伪冲突两条记忆语义相反且无时间差异不选边:两条都标为「存在冲突」,在需要时向用户澄清
第四行是很多系统的致命缺陷

当两条记忆真正矛盾且无法用时序判断,绝大多数系统的做法是「取相似度高的那条」或「取新的那条」。这两种做法都在**假装自己知道答案**。

正确做法是:保留冲突,并在回答时显式暴露它——「关于这个口径,我这里有两条互相矛盾的信息(A:…… B:……),你希望以哪条为准?」 这样做有两个好处:用户得到了正确的服务,而且这次澄清会产出一条高置信度的新记忆,把冲突真正解决掉。

四、让记忆可解释、可更正、可删除

这三件事是记忆系统的底线,也是产品能不能被信任的前提。

memory_store.pypython
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。
        这保证了「可解释」——任何时候都能回答「为什么系统会这么认为」。
        """
        chain = self._items.setdefault(m.key, [])
        for old in chain:
            if old.active and old.content == m.content:
                return "duplicate"
            if old.active and old.content != m.content:
                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() 能检出,并设计一段话术在回答时暴露冲突。

五、自测

本章自测第 3 题区分度最高
Q1为什么「每轮对话都自动抽取记忆」是危险的?
C。成本上升只是次要问题。真正的伤害是信噪比恶化:「这次要简洁」被写成长期偏好,「这个数据好像不太对」被写成事实,最终召回的内容大量是噪音。修法是设稳定性阈值 + 定期清理 + 优先用显式指令触发写入。
Q2记忆冲突中,「越临时的越优先」适用于哪种情况?
B。不同冲突用不同原则:D 用时序(新的取代旧的),C 用来源权威性(系统文档优先),A 则不选边、暴露冲突。B 是层级冲突——本次指令覆盖长期偏好,但必须注意不能因此修改长期偏好,否则一次临时要求会永久污染用户画像。
Q3为什么记忆要「软删除」(标记失效)而不是直接物理删除?
D。可解释性是记忆系统的核心资产:当用户质疑「你为什么觉得我们口径是 X」时,能拿出「2025-03-12 由 A 提供,2025-09-01 被 B 的新口径取代」这条链,信任才建立得起来。同时要记住用户要求删除时必须能真正硬删除——这两件事不矛盾,是两套不同场景。

六、小结

问题答案要点
记什么分三层:工作记忆(任务级)/ 情节记忆(历史)/ 语义记忆(长期事实与偏好)
什么时候写显式指令 > 任务结束固化 > 自动抽取(最需谨慎)
写入前校验稳定性、来源可信、非重复、可归属,四条都要过
冲突怎么办时序 / 来源 / 层级 / 真伪,四类用四套原则;真伪冲突不选边
怎么召回上限 5 条以内,必须带时间与来源
底线可解释、可更正、可删除(含硬删除)

记忆的价值不在于记得多,而在于记得准、说得清、忘得掉。本刊编辑部

下一章处理一个常见的设计抉择:当任务复杂到一定程度,是该开更多 Agent,还是把上下文做得更好?

面试官会怎么问

共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照

长期记忆你会存什么?什么时候写? 高频
存的内容分三类:偏好记忆(用户的表达风格、格式要求、反感什么)、事实记忆(用户的身份、所在团队、长期目标等稳定信息)、场景记忆(某类任务上一次是怎么做成的、踩过什么坑)。
写入时机是这道题的关键:只在信号足够强的时候写——用户显式纠正("以后别用表格")、用户显式声明("我在做招聘")、或者同一偏好被重复观察到多次。绝不能把每一次会话里的临时要求都写进去,否则记忆区会变成噪声区,注入后反而干扰判断。
配套要求:写入带时间戳与来源,支持用户查看与删除,并设置 TTL 或衰减机制。

追问链

  1. 怎么防止把一次性偏好写进去?
  2. 记忆检索是走向量还是规则?
记忆和新事实冲突了怎么办?
不能简单地"新的覆盖旧的",因为新的未必更对。我的处理是分层消解
① 先分类——是"偏好变化"还是"事实变化"?偏好变化(从喜欢表格改成喜欢列表)通常是覆盖;事实变化(换团队了)也是覆盖,但要保留历史区间。
② 带时间与置信度——每条记忆都有生效时间与置信度,冲突时高置信度优先;同等置信度则新的优先,但把旧的标记为 superseded 而不是删除。
③ 不可消解就暴露给用户——如果两条记忆指向矛盾结论(用户说"汇报要简洁",但历史反馈显示他常要求补充细节),不要自己赌,应该在输出时给出选择或直接询问。
核心原则是:记忆系统要能解释自己为什么这么判断,否则出错时无法排查。
怎么证明记忆让效果变好了,而不是让输出变得飘忽?
把记忆当成一个可评测的改动,而不是默认有益的机制。
做法是 A/B:同一批任务分别跑「注入记忆」与「不注入记忆」两条链路,对比任务完成率、人工介入次数、以及目标风格的一致率。要特别关注负向信号:注入记忆后,模型是否开始在不相关场景里生搬硬套旧偏好(过拟合);是否出现"忘了任务本身要求,只记得风格要求"(注意力争夺)。
工程上还有两个必要设计:记忆可关闭(某个任务可以临时不使用历史偏好)和记忆可追溯(输出里能看出用了哪条记忆)。没有这两样,记忆出问题时你会连原因都定位不到。
加分点:把"记忆可能有害"当成前提来设计,是这道题最容易被忽略的深度。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes