8.3 记忆写入策略
前两节把记忆的「读」做完了:8.1 让近期对话留得住,8.2 让相关条目召得回。但还有一半没碰——这些记忆条目最初从哪来? 如果没有一套写入规则,只有两个极端:要么把所有消息都塞进去,检索时被寒暄和废话淹没;要么凭感觉偶尔记一条,关键信息悄悄漏掉。写和读是一体两面,读得再准,源数据是垃圾也白搭。
本节把 minagent 推进到「会挑着记」:用
should_write(message)判断一条消息值不值得入记忆,用规范化字符串比较做dedup去重,用slot加时间戳做冲突消解。三条规则都写成可断言的小函数,真跑一组写入用例。
8.3.1 什么值得记
记忆的容量和召回质量都是有限的,所以写入的第一原则是宁缺毋滥。判断一条消息值不值得记,看三件事:
| 判据 | 说明 | 例子 |
|---|---|---|
| 是否稳定 | 短期会变的信息记了很快作废 | 「今天有点冷」不值得记 |
| 是否个人 | 关于用户的偏好、事实、约束才需要跨会话 | 「我偏好简洁示例」值得记 |
| 是否可复用 | 未来某轮可能用得上的结论才值得留 | 「项目用 Python」值得记 |
反过来,有一类内容几乎永远不该写进长期记忆:寒暄与过程性对话。「你好」「谢谢」「哈哈」「在吗」这些消息占据大量轮次,却不含任何可复用信息;把它们的近义句反复写进记忆,只会稀释检索。这就是为什么写入策略要先做一次「值不值得」的过滤。
8.3.2 should_write:过滤寒暄与噪声
should_write 接收一条 Message,返回布尔。规则不复杂,但要把边界划清楚:
# minagent/write_policy.py
from __future__ import annotations
from .types import Message
GREETINGS = {"你好", "您好", "hi", "hello", "谢谢", "多谢", "哈哈",
"在吗", "早上好", "晚上好", "ok", "好的"}
WRITE_MARKERS = ("我喜欢", "我偏好", "我习惯", "我倾向", "我平时",
"请不要", "记住", "以后都", "我的", "我在做", "我负责")
def should_write(message: Message) -> bool:
text = message.content.strip()
if message.role != "user" or not text:
return False
if text.lower() in GREETINGS:
return False
if len(text) <= 3:
return False
return any(mark in text for mark in WRITE_MARKERS)
四条规则逐条看:
- 只记用户说的(
role != "user")。模型自己的回复里可能混着幻觉和客套,作为「事实」写进记忆风险很大;用户的陈述才是可信的一手信息。 - 空内容直接否。工具消息、占位消息可能没有正文,写进去没有意义。
- 寒暄白名单(
GREETINGS)与过短内容(len <= 3)一起挡掉噪声。用白名单而非「包含寒暄词就否」,是因为「我偏好简洁,谢谢」这种句子里含有意义的偏好,不该因为末尾一个「谢谢」被整条丢弃。 - 触发词(
WRITE_MARKERS)是这套规则的核心。偏好类消息几乎都会带「我喜欢 / 我偏好 / 我习惯」,约束类会带「请不要」,项目背景会带「我在做 / 我负责」。命中任一即写入。
这里的关键取舍是:规则宽松会写入垃圾,规则严格会漏掉信息,宁可偏严。 漏掉一条偏好,用户下一轮补一句就好;写进一堆垃圾,却会长期污染检索。所以本节用的是「触发词白名单」这种偏保守的策略。
8.3.3 dedup:规范化去重
同一个偏好,用户可能换着说法说好几遍:「我偏好简洁的代码示例」「我偏好简洁的代码示例。」「 我偏好简洁的代码示例 」。如果不去重,检索时同一件事会被召回三次,既浪费 token 又误导模型。
最朴素也最有效的去重是规范化后比字符串:把大小写、空白、标点统一抹掉,再比是否相同:
# minagent/write_policy.py(续)
import re
_PUNCT = re.compile(r"[\s,。!?、,.!?;;::\"'()()【】\[\]]+")
def normalize(text: str) -> str:
return _PUNCT.sub("", text.lower())
def dedup(memories: list[str]) -> list[str]:
seen: dict[str, str] = {}
for m in memories:
key = normalize(m)
if key and key not in seen:
seen[key] = m
return list(seen.values())
normalize 只做两件事:转小写、去标点空白。它把上面三个变体压成同一个键 我偏好简洁的代码示例,于是 dedup 只保留第一次出现的那条原文。用 dict 而不是列表遍历,是为了让「是否见过」的查找是 O(1);字典的值存原始文本,保证返回的是用户真正写下的那句话,而不是被规范化后的版本。
规范化去重只能抓字面重复,抓不到「同义但不同字」的情况——「我偏好简洁示例」和「回答请短一点」说的是同一件事,normalize 之后仍是两个不同的键。要处理同义,得回到 8.2 的 terms 做近似比较(用 Jaccard 相似度),或者干脆交给嵌入模型判断语义距离。本章先用规范化这一档,它覆盖了绝大多数重复场景,且完全确定。
8.3.4 冲突消解:新信息覆盖旧信息
比重复更难的是冲突:用户上周说「我用 Python」,这周说「我改用 TypeScript 了」。两条都该被记住吗?如果都留,检索会召回两条互相矛盾的信息,模型无所适从。正确做法是新信息覆盖旧信息。
覆盖的前提是能识别「这两条说的是同一件事」。最直接的办法是给每条记忆一个槽位(slot)——一个表示「这条记忆属于哪个主题」的标签,比如 语言、编辑器、回答风格。同一槽位里只保留最新的一条:
# minagent/write_policy.py(续)
from dataclasses import dataclass
@dataclass
class MemoryItem:
text: str
slot: str
ts: int = 0
def write(store: list[MemoryItem], item: MemoryItem) -> str:
for i, old in enumerate(store):
if old.slot == item.slot:
if normalize(old.text) == normalize(item.text):
return f"重复,忽略 slot={item.slot}"
if item.ts > old.ts:
store[i] = item
return f"覆盖 slot={item.slot}: {old.text} -> {item.text}"
return f"忽略旧信息 slot={item.slot}: {item.text}"
store.append(item)
return f"新增 slot={item.slot}: {item.text}"
write 里其实叠了三种判断,顺序不能乱:
| 情况 | 判断 | 动作 |
|---|---|---|
| 槽位相同、文本等价 | normalize(old) == normalize(new) | 视为重复,忽略新条目 |
| 槽位相同、新条目更新 | item.ts > old.ts | 覆盖,新条目替换旧条目 |
| 槽位相同、新条目更旧 | 否则 | 忽略,防止乱序写入把新信息盖掉 |
| 槽位不存在 | 遍历完未命中 | 追加为新条目 |
「重复」判断必须放在「覆盖」之前:否则一条文本相同、时间戳更新的重发消息会被当成「覆盖」写一遍,虽不致命却产生无意义的日志。而「更旧则忽略」这一条是防乱序的——真实系统里消息可能并发写入、重放或补录,不能假设它们按时间顺序到达,用时间戳做闸门比用到达顺序可靠。
槽位从哪来?最省事的做法是写死一张「关键词 → 槽位」的映射表(语言 槽命中「Python / Go / TypeScript」等词),真实系统则常用一次轻量的模型调用或嵌入聚类来自动归类。本章先用映射表,它够确定、够可测。
8.3.5 跑一遍:一组写入用例
把三段逻辑串起来跑一组用例。先看 should_write 对七条消息的判断:
# demo_write.py
cases = [
Message(role="user", content="你好"),
Message(role="user", content="谢谢"),
Message(role="user", content="嗯"),
Message(role="user", content="我喜欢用 Python 写 Agent"),
Message(role="user", content="我偏好简洁的代码示例"),
Message(role="assistant", content="用户喜欢 Python"),
Message(role="user", content="今天天气不错"),
]
for c in cases:
print(f"{should_write(c)!s:<5} | {c.role:<9} | {c.content}")
实测输出(Python 3.14.6):
False | user | 你好
False | user | 谢谢
False | user | 嗯
True | user | 我喜欢用 Python 写 Agent
True | user | 我偏好简洁的代码示例
False | assistant | 用户喜欢 Python
False | user | 今天天气不错
逐条对照规则:寒暄「你好」「谢谢」被白名单挡掉;「嗯」因为太短被挡;两条偏好消息命中触发词通过;「用户喜欢 Python」虽含「喜欢」但是 assistant 说的,被角色规则挡掉;「今天天气不错」没有触发词,也不写。七条里恰好只有两条通过,正是我们想要的「宁缺毋滥」。
再跑去重,五个变体压成两条:
raw = [
"用户偏好简洁的代码示例",
"用户偏好简洁的代码示例。",
" 用户偏好简洁的代码示例 ",
"用户在做本地知识库问答",
"用户在做本地知识库问答!",
]
print("去重前:", len(raw), "去重后:", len(dedup(raw)))
去重前: 5 去重后: 2
- 用户偏好简洁的代码示例
- 用户在做本地知识库问答
三条「代码示例」的变体(无标点、句号、首尾空格)被 normalize 归一后只剩一条,两条「知识库问答」变体同理。注意保留下来的都是第一次出现的原始文本,标点形态和用户第一次写下的一致。
最后是冲突消解,模拟「偏好被反复陈述、最后改变」的过程:
store: list[MemoryItem] = []
print(write(store, MemoryItem("用户偏好用 Python", slot="语言", ts=1)))
print(write(store, MemoryItem("用户偏好用 Python", slot="语言", ts=2)))
print(write(store, MemoryItem("用户改用 TypeScript 了", slot="语言", ts=3)))
print(write(store, MemoryItem("用户偏好用 Python", slot="语言", ts=0)))
print("最终 store:", [(m.slot, m.text) for m in store])
新增 slot=语言: 用户偏好用 Python
重复,忽略 slot=语言
覆盖 slot=语言: 用户偏好用 Python -> 用户改用 TypeScript 了
忽略旧信息 slot=语言: 用户偏好用 Python
最终 store: [('语言', '用户改用 TypeScript 了')]
四次写入走了三条不同分支:第一次新增,第二次因文本相同被判重复,第三次因时间戳更新触发覆盖,第四次是乱序到达的旧消息(ts=0 比现存的小),被「忽略旧信息」挡下。最终 store 里 语言 槽只有一条——用户改用 TypeScript 了。这就是冲突消解的完整闭环:无论写入顺序如何,同一槽位永远只留最新的那条。
8.3.6 记忆写入的三条判据
把整节的规则收成一张清单,实现任何写入策略时都可以拿来对照:
- 值不值得记——用
should_write过滤:只记用户的稳定、个人、可复用的陈述,挡掉寒暄、过短内容与模型自述。 - 是否已经记过——用
dedup去重:规范化字符串比较抹平标点与大小写的差异,同一件事只留一条。 - 是否与旧信息冲突——用
write的槽位机制消解:同一槽位只保留最新一条,并防止乱序写入把新信息覆盖成旧的。
这三条对应写入流程的三个阶段:过滤 → 去重 → 消解。它们的顺序也不能反:先去重再去消解,可以少走一次槽位比对;先过滤再进前两者,则从源头减少了要处理的条目数。
8.3.7 真实模型下会怎样
本节的 should_write 是关键词规则,dedup 是字符串比较,write 是槽位覆盖——全部确定、可断言。真实系统里,这三步中的每一步都可能交给模型:
- 值得记的判断可以用一次小模型调用替代关键词:「这条消息里有没有关于用户的长期信息?只回 yes/no」。它比关键词灵活,能识别「其实我更喜欢简短的回复」这种不带触发词的说法,代价是每次都要调用、且结果不再是确定性的。
- 去重与消解可以用嵌入向量做语义比较:两条记忆的向量余弦相似度高于阈值就视为重复,冲突则看时间戳。这比
normalize强得多,能抓到「回答请短一点」与「我偏好简洁示例」是同一件事。 - 槽位归类也常交给模型或聚类,不必手写映射表。
但无论换哪种实现,三个阶段和三条判据不变。这正是本节把策略拆成三个纯函数的意义:真实化时,你只需替换 should_write / dedup / write 的内部实现,调用它们的代码一行都不用改。第 9 章会把去重与召回一起升级到向量方案。
记忆的读与写到这里都通了,但本章的存储始终是内存里的列表——进程一退,全没了。下一章 9.1 嵌入与向量存储:本地可离线方案 会引入本地嵌入与向量索引,把记忆升级成能按语义召回的长期存储。想回看检索是怎么按相关度挑条目的,见 8.2 记忆检索注入 。
阅读导航:上一节:8.2 记忆检索注入 · 下一节:9.1 嵌入与向量存储:本地可离线方案 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。