《AI Agent 开发入门》8.3 记忆写入策略

检索解决「怎么取」,本节解决「怎么存」。如果什么都往记忆里塞,召回会被噪声淹没;塞得太少又会漏掉关键信息。本节给 minagent 加上写入策略:用 should_write 判断什么值得记(偏好、事实、结论 vs 寒暄),用规范化字符串比较做 dedup 去重,用 slot 加时间戳做冲突消解让新信息覆盖旧信息,并真跑一组写入用例。

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 记忆写入的三条判据

把整节的规则收成一张清单,实现任何写入策略时都可以拿来对照:

  1. 值不值得记——用 should_write 过滤:只记用户的稳定、个人、可复用的陈述,挡掉寒暄、过短内容与模型自述。
  2. 是否已经记过——用 dedup 去重:规范化字符串比较抹平标点与大小写的差异,同一件事只留一条。
  3. 是否与旧信息冲突——用 write 的槽位机制消解:同一槽位只保留最新一条,并防止乱序写入把新信息覆盖成旧的。

这三条对应写入流程的三个阶段:过滤 → 去重 → 消解。它们的顺序也不能反:先去重再去消解,可以少走一次槽位比对;先过滤再进前两者,则从源头减少了要处理的条目数。

8.3.7 真实模型下会怎样

本节的 should_write 是关键词规则,dedup 是字符串比较,write 是槽位覆盖——全部确定、可断言。真实系统里,这三步中的每一步都可能交给模型:

  • 值得记的判断可以用一次小模型调用替代关键词:「这条消息里有没有关于用户的长期信息?只回 yes/no」。它比关键词灵活,能识别「其实我更喜欢简短的回复」这种不带触发词的说法,代价是每次都要调用、且结果不再是确定性的。
  • 去重与消解可以用嵌入向量做语义比较:两条记忆的向量余弦相似度高于阈值就视为重复,冲突则看时间戳。这比 normalize 强得多,能抓到「回答请短一点」与「我偏好简洁示例」是同一件事。
  • 槽位归类也常交给模型或聚类,不必手写映射表。

但无论换哪种实现,三个阶段和三条判据不变。这正是本节把策略拆成三个纯函数的意义:真实化时,你只需替换 should_write / dedup / write 的内部实现,调用它们的代码一行都不用改。第 9 章会把去重与召回一起升级到向量方案。

记忆的读与写到这里都通了,但本章的存储始终是内存里的列表——进程一退,全没了。下一章 9.1 嵌入与向量存储:本地可离线方案 会引入本地嵌入与向量索引,把记忆升级成能按语义召回的长期存储。想回看检索是怎么按相关度挑条目的,见 8.2 记忆检索注入 。

阅读导航:上一节:8.2 记忆检索注入 · 下一节:9.1 嵌入与向量存储:本地可离线方案 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距