· Jeff · 教程  · 16 分钟阅读

自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次

Agent 的记忆越攒越多,新事实和旧事实开始打架。本文记录我给自己那套记忆系统做「冲突消解」的完整实现:为什么不能直接删、软失效 + 事件账本怎么设计、用什么判据判定两条记忆冲突。重点是一个真实的翻车——判据太激进,一条新记忆横扫了 50 条无关事实,误杀 19 条。更值得记的是:同一个 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 行「假取代」记录。不清掉,检索照样过滤它们。

所以修复是两步:

  1. valid_to,让被误杀的事实重新生效
  2. 删掉账本里的假阳性行——账本不能继续声称一次从未发生过的取代

我把恢复脚本写成默认 dry-run,必须显式加 --execute 才动手:

execute = "--execute" in sys.argv
# 先打印「当前状态 / 将要恢复哪些 / 将要删除哪些账本行」
# 只有 --execute 时才 commit

这条习惯救过我:脚本第一次跑时,我发现它把一条真取代(episodic 版本链)也列进了恢复名单——因为那条版本链在数据结构上和误杀长得一样。dry-run 让我在动手前拦下了它。

判据是人工定的:同一主题的版本迭代链 = 真取代(保留);跨主题的配对 = 假阳性(恢复)

恢复后:活跃 49 → 70,失效 30 → 11(剩下 11 条全是真的版本链)。

七、最该记住的教训:同一个 bug 我修了两次

这是整件事里最值得写下来的部分。

翻 git 记录发现,这个假阳性 bug 在两个月前就修过一次。当时的修法是在判定前先剥掉内容里的元信息前缀(我习惯给事实加 [PREF:xxx] 这类前缀,前缀里的词会造成虚假重叠)。

而这次给记忆系统做「冲突消解」功能时,那段逻辑被重写了——重写版本更激进(从「相似度」扩展到「纠错词 + 任意重叠」),而两个月前那个修复,在重写时被丢掉了

于是同一个坑,我踩了第二次。

更讽刺的一个细节:描述这个 bug 的那条记忆,本身也含「改为」这个词,于是它又顺手把另一条完全无关的规则误杀了——「修复假阳性的记录」成了新的假阳性来源。

从那以后我给自己定了两条:

  1. 修过的缺陷,必须把「根因 + 修法」写进文档或技能文件,而不是只留在代码注释里。代码会被重写,文档不会。
  2. 判据类的逻辑,回归测试必须覆盖历史所有误判案例。只测「新写的那几个用例」是不够的——重写时最容易丢的正是老用例。

八、小结

决策选择理由
冲突了怎么处理软失效valid_to)而非删除保历史、可回滚、可审计
怎么知道谁取代了谁事件账本表 + 内容快照审计日志 + 免费测试集
判定判据相似度 ≥0.40 (纠错词 + ≥2 共享 token)双路径互补;单 token 在中文里等于随机
阈值调参1 → 2看似微小,实为量级差别
改完代码还要修数据(清 valid_to + 删假账本行)代码修复不回溯历史
恢复脚本默认 dry-run手工判据一定会误伤
缺陷记录写进文档/技能,不只写代码注释重写时注释会丢,文档不会

一句话版本:做记忆的冲突消解,难点不在「怎么取代」,而在「怎么判定该取代」——判定过松,系统会安静地吃掉自己的记忆;判定过紧,新旧并存比不取代更糟。

而比判据本身更重要的,是让这件事可审计、可回滚、可回归。我这次能发现 19 条误杀并全部找回,靠的就是账本;能确认修完没引入新问题,靠的就是那份历史标注集。

相关文章:

☕ 如果这篇文章对你有帮助

欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~

打赏功能即将上线,先点个赞也是支持 ❤️

返回博客

相关文章

查看全部 »
进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

我自建的一个统计面板曾经挂了整整 16 天我才发现:进程从没退出、端口一直在 LISTEN、CPU 占用是零,看上去「健康得不得了」,可所有新连接都拿不到响应,公网一律 504。根因是单线程 HTTPServer 没有 socket 超时,被一条半开连接永久阻塞;更值得记的是第二层——进程监督器只认「进程存活」,这种假活对它完全不可见。这次我给它上了四层加固,也第一次想明白:为什么「重启脚本」这种修复,会被下一次部署悄悄冲掉。

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

定时任务连续几天不出活,日志里却看不出错——这是跑 LLM 自动化最常见的一种失败。本文是我排查一个每日定时任务的完整记录,两个真实根因:推理型模型的 token 预算被思维链烧穿(实测 4096 下 2/6 空回复、8192 下 0/6),以及框架的「模型漂移保护」fail-closed 静默跳过任务。附复现方法、修法和一份模型横向压测表。

Google Search Console 从验证到收录:新站接入实操与踩坑清单

Google Search Console 从验证到收录:新站接入实操与踩坑清单

新站被搜索引擎冷落,第一步不是狂发外链,而是把 Google Search Console 接上——它决定你能不能看见「爬虫到底来没来、收录卡在哪」。这篇是完整实操:网域还是网址前缀、四种验证方式怎么选、DNS TXT 验证的准确姿势、验证后必做的三件事、多久有数据,以及我踩过的六个坑,附 GSC / Bing / 百度三平台对照。