· Jeff · 教程 · 16 分钟阅读
自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次
Agent 的记忆越攒越多,新事实和旧事实开始打架。本文记录我给自己那套记忆系统做「冲突消解」的完整实现:为什么不能直接删、软失效 + 事件账本怎么设计、用什么判据判定两条记忆冲突。重点是一个真实的翻车——判据太激进,一条新记忆横扫了 50 条无关事实,误杀 19 条。更值得记的是:同一个 bug,我修了两次。
自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次
我给自己那套 Agent 框架写了个长期记忆插件,结构很简单:一个 facts 表,一条事实一行,带类别、标签、信任分。每次对话结束往里写点东西,下次开新会话时按需检索回来。
跑了几个月,问题来了:记忆开始自相矛盾。
同一个配置项,三月记的是「用 A 模型」,六月改成了 B,两条都在库里;同一条服务器信息,我改过一次端口,于是新旧两条并存,检索时随机命中一条,模型照着旧的那条去干活。
这篇记录我怎么给这套记忆系统加「冲突消解」,以及中间那次把自己坑得最惨的翻车。
一、先想清楚:为什么不能「直接删旧的」
最直觉的做法是:写新事实时,如果发现旧事实讲的是同一件事,就把旧的那行 DELETE 掉。
我一开始也是这么想的,但很快否掉了,有三个理由:
| 问题 | 直接删的后果 |
|---|---|
| 丢历史 | 「我什么时候改的主意」这个信息没了。排查问题时最有用的往往正是被删掉的那条旧值 |
| 无法回滚 | 判据一旦误判,删掉的东西就找不回来了。而判据一定会误判 |
| 不可审计 | 你没法回答「这条记忆为什么消失了」——这是记忆系统最不该有的状态 |
所以我的方案是软失效 + 事件账本。
二、实现:两行 schema 解决三件事
软失效:给事实加一个「失效时间」
facts 表加一列:
ALTER TABLE facts ADD COLUMN valid_to TIMESTAMP DEFAULT NULL;约定很简单:
valid_to IS NULL→ 这条事实当前有效valid_to有值 → 这条事实已被取代,但行还留在库里
配套的改动是所有检索路径统一加过滤条件:
SELECT ... FROM facts WHERE valid_to IS NULL这一步最容易漏。我当时检索入口有四五个(关键词检索、语义检索、按实体查、按类别列……),漏掉任何一个,被取代的旧事实就会从那条缝隙里漏回来——表现就是「我明明改过了,它还是用旧的」,而且极难复现,因为只有走那条检索路径时才出现。
教训:加了软失效字段,就要把
valid_to IS NULL当成检索的硬约束去逐个检查,别指望「主要路径加了就行」。
事件账本:谁取代了谁,单独记一笔
软失效只解决了「旧的不再被用」,但没解决「为什么不用了」。所以我加了一张账本表:
CREATE TABLE fact_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
old_fact_id INTEGER, -- 被取代的
new_fact_id INTEGER, -- 取代它的
category TEXT,
old_value TEXT, -- 快照:旧内容原文
new_value TEXT, -- 快照:新内容原文
reason TEXT DEFAULT 'superseded',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);注意 old_value / new_value 存的是内容快照而不是外键。这是刻意的:账本要能独立回答「当时发生了什么」,哪怕 facts 表里的行后来被清理了也不影响。
有了这张表,很多事就变得可能:
- 审计:
SELECT * FROM fact_events WHERE category='xxx'就是这条线的时间线 - 回滚:误判了?把
valid_to清空、账本行删掉,一条 SQL 恢复 - 自测:账本里的历史配对,天然就是一份标注好的测试集(后面第五节会用到,这是整个设计里我最满意的一点)
三、判据:什么算「两条记忆冲突」
这是整个功能的核心,也是我翻车的地方。
判据我用了双触发:
# 触发 1:相似度够高 → 判定为「同一件事的不同说法」
jaccard(new_tokens, old_tokens) >= 0.40
# 触发 2:新事实里明确带「纠错意图」→ 只要有一定重叠就算冲突
is_correction and len(new_tokens & old_tokens) >= N两条路的思路不同:
- 相似度路径管「我不知道自己在改,但内容明显重叠」——比如同一件事换了个说法写了两遍
- 纠错路径管「我明确知道自己改了」——新事实里出现「改为」「不对」「更正」「其实是」这类词,说明这是有意覆盖
扫描范围限定在同一 category 内,最多取 50 条候选。类别不同的事实基本不可能是同一件事,这个限定省了大量计算,也少了很多误判。
到这里逻辑看着挺干净。问题出在触发 2 的 N。
四、翻车:一条新记忆,横扫了 50 条无关事实
上线几天后我做例行审计,发现数字不对:
活跃事实:49 条
失效事实:30 条30 条失效里,我肉眼过了一遍,19 条是误杀。
被「取代」的有服务器防火墙配置、SSH 登录方式、磁盘容量……而取代它们的那条新事实,讲的是完全不相干的事(一条小说续写流水线的记录)。账本里那些配对的「旧内容」和「新内容」,主题毫不相干。
查根因,问题出在两个地方叠加:
根因 1:分词在中文上退化了
我的 token 切分用的是最朴素的方式——按空格切。
英文这么切没问题。但中文没有空格,一整段中文句子切出来就是极少数几个「token」,而中文技术文本里必然夹着 +、→、=、http、括号、单个汉字。于是这些垃圾碎片全成了「有效 token」。
根因 2:纠错路径只要求「共享 1 个 token」
elif is_correction and (new_tokens & old_tokens): # ← 任意 1 个共享 token 即判冲突两个根因一叠加,后果就是:任何含「改为 / 纠正 / 更新为」的新事实,几乎必然和同类别里所有事实共享至少 1 个 token(共享的可能只是 → 或者一个「的」字),于是它把同类别里最多 50 条全部标记为「已取代」。
中文写作里「改为」这类词出现频率极高——描述一次配置变更、复盘一次纠错、甚至写一条记录这个 bug 本身的记忆,都会命中。
修法
两处,都很小:
# 1. 丢弃纯标点 token 和单字符 token
_JUNK_TOKEN_RE = re.compile(r"^[\W_]+$", re.UNICODE)
def _token_set(self, text: str) -> set[str]:
toks = set()
for word in text.lower().split():
cleaned = word.strip(".,;:!?\"'()[]{}#@<>")
if not cleaned:
continue
if self._JUNK_TOKEN_RE.match(cleaned): # 纯标点
continue
if len(cleaned) < 2: # 单字符(的/在/为/单字母)
continue
toks.add(cleaned)
return toks
# 2. 纠错路径要求至少共享 2 个有效 token
_MIN_SHARED_TOKENS = 2
...
elif is_correction and len(new_tokens & old_tokens) >= self._MIN_SHARED_TOKENS:N 从 1 改成 2 看着像个微不足道的调参,实际是量级上的差别:共享 1 个 token 在中文里基本等于「随机命中」,共享 2 个有效 token 才勉强够得上「在讲同一件事」。
五、回归测试:拿自己的历史账本当标注集
改判据最怕的是「修了假阳性,引入了假阴性」——不误杀了,但真该取代的也不取代了,于是记忆库里新旧并存,比之前更糟。
我没有手工构造测试用例,而是直接用账本里的历史配对当标注集:账本里记着 30 对「谁取代了谁」,我人工过一遍,标出哪 19 对是误杀(应判「不冲突」)、哪 11 对是真取代(应判「冲突」),然后逐对断言。
FALSE_POSITIVES = [(61,4),(61,8),(61,29),...] # 19 对:必须判「不冲突」
TRUE_POSITIVES = [(74,73),(77,74),(78,77),...] # 11 对:必须判「冲突」
def conflicts(new_id, old_id):
nt, ot = token_set(content(new_id)), token_set(content(old_id))
if jaccard(nt, ot) >= 0.40:
return True
if is_correction(content(new_id)) and len(nt & ot) >= 2:
return True
return False
# 19 对全部应为 False,11 对全部应为 True结果:假阳性 0 / 19,真阳性 0 / 11 漏判。
这份测试集有个额外好处:它是随着系统运行自动增长的。每次真实的取代都会进账本,于是标注集越来越大,判据的任何调整都能立刻拿全量历史回归一遍。
如果你也在做类似的事,我强烈建议加这张账本表——它既是审计日志,也是免费的测试集。
六、改完代码不等于修完数据
这是我很长时间里没意识到的一点:代码修复不会自动修复已经被写坏的数据。
新代码上线了,但库里那 19 条早就被打了 valid_to,账本里也还留着 19 行「假取代」记录。不清掉,检索照样过滤它们。
所以修复是两步:
- 清
valid_to,让被误杀的事实重新生效 - 删掉账本里的假阳性行——账本不能继续声称一次从未发生过的取代
我把恢复脚本写成默认 dry-run,必须显式加 --execute 才动手:
execute = "--execute" in sys.argv
# 先打印「当前状态 / 将要恢复哪些 / 将要删除哪些账本行」
# 只有 --execute 时才 commit这条习惯救过我:脚本第一次跑时,我发现它把一条真取代(episodic 版本链)也列进了恢复名单——因为那条版本链在数据结构上和误杀长得一样。dry-run 让我在动手前拦下了它。
判据是人工定的:同一主题的版本迭代链 = 真取代(保留);跨主题的配对 = 假阳性(恢复)。
恢复后:活跃 49 → 70,失效 30 → 11(剩下 11 条全是真的版本链)。
七、最该记住的教训:同一个 bug 我修了两次
这是整件事里最值得写下来的部分。
翻 git 记录发现,这个假阳性 bug 在两个月前就修过一次。当时的修法是在判定前先剥掉内容里的元信息前缀(我习惯给事实加 [PREF:xxx] 这类前缀,前缀里的词会造成虚假重叠)。
而这次给记忆系统做「冲突消解」功能时,那段逻辑被重写了——重写版本更激进(从「相似度」扩展到「纠错词 + 任意重叠」),而两个月前那个修复,在重写时被丢掉了。
于是同一个坑,我踩了第二次。
更讽刺的一个细节:描述这个 bug 的那条记忆,本身也含「改为」这个词,于是它又顺手把另一条完全无关的规则误杀了——「修复假阳性的记录」成了新的假阳性来源。
从那以后我给自己定了两条:
- 修过的缺陷,必须把「根因 + 修法」写进文档或技能文件,而不是只留在代码注释里。代码会被重写,文档不会。
- 判据类的逻辑,回归测试必须覆盖历史所有误判案例。只测「新写的那几个用例」是不够的——重写时最容易丢的正是老用例。
八、小结
| 决策 | 选择 | 理由 |
|---|---|---|
| 冲突了怎么处理 | 软失效(valid_to)而非删除 | 保历史、可回滚、可审计 |
| 怎么知道谁取代了谁 | 事件账本表 + 内容快照 | 审计日志 + 免费测试集 |
| 判定判据 | 相似度 ≥0.40 或(纠错词 + ≥2 共享 token) | 双路径互补;单 token 在中文里等于随机 |
| 阈值调参 | 1 → 2 | 看似微小,实为量级差别 |
| 改完代码 | 还要修数据(清 valid_to + 删假账本行) | 代码修复不回溯历史 |
| 恢复脚本 | 默认 dry-run | 手工判据一定会误伤 |
| 缺陷记录 | 写进文档/技能,不只写代码注释 | 重写时注释会丢,文档不会 |
一句话版本:做记忆的冲突消解,难点不在「怎么取代」,而在「怎么判定该取代」——判定过松,系统会安静地吃掉自己的记忆;判定过紧,新旧并存比不取代更糟。
而比判据本身更重要的,是让这件事可审计、可回滚、可回归。我这次能发现 19 条误杀并全部找回,靠的就是账本;能确认修完没引入新问题,靠的就是那份历史标注集。
相关文章:
- AI Agent 定时任务为什么静默失败——同一类教训:没报错不等于成功,链路每一环都可能安静地返回空结果
- Hermes Agent 上下文长度配置被清掉的根因排查——另一种「配置层无声失效」,和本文的误判同属「系统按你以为的逻辑安静地做了别的事」
- 用 Docker 部署 Hermes Agent:从安装到多 Profile 管理——本文这套记忆系统的运行环境
☕ 如果这篇文章对你有帮助
欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~
打赏功能即将上线,先点个赞也是支持 ❤️