记忆系统(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 就是上下文的长期沉降层。

本篇回答两个问题:

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

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


一、对话历史算不算记忆

1.1 推荐回答

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

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

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

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

1.2 三类记忆

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

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

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

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

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

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

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

text 复制代码
用户开了个新会话问:「上次那个迁移任务做到哪了?」
  └─ 挂 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 为什么「拿不准要问」而不是硬猜(代码实证)

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

text 复制代码
已记录:用户喜欢吃苹果
  ├─ 新陈述「用户最近不吃苹果了」
  │    → 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 分层压缩与聚类去重(代码实证)

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

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

实测输出:

text 复制代码
【④ 防膨胀:分层压缩】
  压缩前活跃记忆: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 召回「关于世界的事实」。

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

text 复制代码
「我的项目用哪个数据库?」        → 记忆(关于你)
「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 核心代码节选(一):写入判定四问

python 复制代码
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)------这是把「临时状态」挡在门外的核心。真实输出(四个样本):

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

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

python 复制代码
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"}

真实输出:

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

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

python 复制代码
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),观察过激的阈值会把多少不同记忆误合并。

十、一句话总结

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

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

相关推荐
GEO实战经验分享1 小时前
王涛认为被AI引用不等于被吸收:GEO跨平台度量框架解读
人工智能·chatgpt
喜欢睡觉1 小时前
DeepAgents 项目拆解:中间件机制、虚拟文件系统与权限模型
人工智能
知几蜗牛1 小时前
GPT-6.1 Sol迁移指南:从token单价转向每任务成本门禁
人工智能
知几蜗牛1 小时前
WSL Containers GA:本地AI容器的生命周期、网络与治理验收
人工智能
FPGA信号处理1 小时前
《随机信号分析与处理》第1章 随机变量基础:习题解答
人工智能·机器学习·概率论
知几蜗牛1 小时前
MongoDB Atlas Agent Engine之后,如何用版本门禁防止陈旧写入
人工智能
爱喝雪碧的可乐1 小时前
CSDN|爆火哑巴AI Jev模型深度实战|技术博客
人工智能·大模型·jev
知几蜗牛1 小时前
从ProvenanceGuard理解多源RAG的claim-to-source验证
人工智能
RoboWizard1 小时前
2026年企业级NVMe SSD推荐哪些品牌?
大数据·人工智能