大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
上个月帮一个客服 Agent 排查问题。用户前天说过一句话:"我搬到北京了,别按老地址派单。"这句被写进了记忆表,第二天派单,用的还是上海的老地址,用户直接投诉。我把记忆表翻出来,新记忆在,旧记忆也在,召回的时候,旧的那条排在了前面。Agent 不是没记住,是记住了、用错了。
很多人选 AI数据库,只问一句"能不能存向量"。存得下当然要,但真正决定 Agent 靠不靠谱的,是记忆写得对不对、取得准不准。关于 Agent 记忆,我早先写过一篇,讲的是记忆存哪,短期、长期、团队三层怎么分。今天这篇往下再挖一层,也只打两件事:写和取,这也是我踩坑最疼的地方。
一、AI数据库做记忆底座,写和取是两套逻辑
我一开始的想法很朴素:记忆嘛,写进去、查出来,就完了。真的做过一轮才发现,写和取是两套逻辑,目标还常常打架。写的时候要的是一条不漏、一条不重;取的时候要的是把最该想起的那条排到最前面。为了写得快就得异步,异步一开顺序就乱;为了取得全就得多存,存多了重复记忆又把有用的那条挤出去。
后来我把两条链路分开设计。写入链路只管一件事:把干净、有序、不重复的记忆落好。召回链路管另一件事:把该用的那条挑出来,排到最前。
二、写入这一端:AI数据库的记忆,异步一开丢、重、乱序全来了
记忆大多不能同步写。Agent 答完话,得先把结果还给用户,记忆在后台慢慢落,同步写会让用户干等两秒,这个上一篇说过。可异步的代价我当时没想清楚,它一口气带来三个麻烦。
一是丢,后台任务是异步的,进程重启、队列抖动都可能吃掉一条记忆,用户说了"我要退款",这条没落库,下次还得重问。二是重,Agent 一次任务可能连着触发几次写入,同一句话被拆成两三条存进去,这些重复记忆会占满召回的名额。三是乱序,用户先说在"上海",后说搬到"北京",两条都异步写,要是后发的先落库,"上海"反而成了最新的,Agent 就按旧地址干活了。
幂等:给每条记忆一个唯一键
重复最好治,给写入加一个唯一键,重复的直接冲突掉。键我这么定:agent_id、user_id、来源,再加一个内容哈希。同一件事、同一来源、哈希一致,就当成同一条。
-- 记忆表:用唯一键挡住重复写入
CREATE TABLE agent_memory (
id BIGINT PRIMARY KEY,
agent_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
topic VARCHAR(64) NOT NULL, -- 归一化主题,召回时用来去重
source VARCHAR(32) NOT NULL, -- 来源:对话/工单/文档
content_hash CHAR(64) NOT NULL, -- 内容指纹,用来做幂等
content TEXT NOT NULL,
confidence DECIMAL(3,2) DEFAULT 0.5,
valid_from TIMESTAMP NOT NULL, -- 这条记忆何时开始为真
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
embedding VECTOR(1536),
UNIQUE (agent_id, user_id, source, content_hash)
);
-- 幂等写入:重复的自动忽略,不报错
INSERT INTO agent_memory
(agent_id, user_id, topic, source, content_hash, content, valid_from, embedding)
VALUES
(:agent_id, :user_id, :topic, :source, :hash, :content, :valid_from, :embedding)
ON CONFLICT (agent_id, user_id, source, content_hash) DO NOTHING;
这里我搞错过一次。我第一版只拿 content 去重,结果同一个人先后两次说"要发票",措辞几乎一样,被当成重复合并了,那其实是两个订单。所以唯一键里得带上来源和业务标识,不能只认文本。
顺序:旧记忆不能盖住新记忆
乱序没法靠键解决,它是时间问题。我在表里存两个时间:一个 created_at,是我什么时候写进去的;一个 valid_from,是这条信息什么时候开始为真。
这俩不是一回事。用户昨天说"我在上海",今天说"我搬到北京了",北京这条的 valid_from 是今天,上海那条是昨天。哪怕上海晚一点落库,按 valid_from 一比,北京还是更新的。召回时同一个字段有冲突,就认 valid_from 最新的那条,异步的先后问题就这么绕开了。
三、召回这一端:AI数据库的记忆,只按相似度排序迟早出事
写干净了,只是及格。真正决定 Agent 靠不靠谱的,是召回。我见过不少记忆层,召回就一句"按相似度取前三条",这一句里藏着三个坑。
坑一,旧记忆压新记忆。老记忆和用户现在说的话语义上可能更接近,因为它属于同一个话题、反复出现过,相似度一算,旧的分反而高,被排到了前面。坑二,重复记忆挤名额,上一节那个重复,写的时候没挡住,召回时就会爆发,三条来自同一次对话的记忆内容差不多,把 top-3 占满了,真正有用的第四条进不来。坑三,过期记忆混进来,场景变了,老记忆还没失效,去年的活动规则、今年的问题,它照样被捞出来。
这三个坑,说到底是同一个错:把"相似"当成了"该用"。相似只是条件之一,不是全部条件。
修法:把治理条件塞进同一次查询
修法不复杂,召回的时候,除了相似度,再带上三样东西:时效、置信度、去重。时效是给记忆做时间衰减,越新的权重越高,我一般取 30 天半衰期;置信度是给记忆打分,来源可靠、被反复印证的分数高,低于阈值直接排除;去重是在召回里做,同一个主题只留最新那条。
这些条件如果能和向量检索放进同一条 SQL,最省事,不用先去向量库拿 top-K、再回业务库过滤。那种跨库写法,排序和过滤是分开的,前面那三个坑一个都躲不掉。
-- 召回:相似度 + 时效衰减 + 置信度 + 去重,一次完成
SELECT
m.content,
VECTOR_DISTANCE(m.embedding, :qvec, COSINE) AS dist,
-- 30 天半衰期的时间衰减系数
EXP(-LN(2) * EXTRACT(DAY FROM NOW() - m.valid_from) / 30) AS freshness
FROM agent_memory m
WHERE m.agent_id = :agent_id
AND m.user_id = :user_id
AND m.confidence >= 0.3
AND (m.expires_at IS NULL OR m.expires_at > NOW())
-- 同一个主题只留最新那条
AND m.valid_from = (
SELECT MAX(m2.valid_from)
FROM agent_memory m2
WHERE m2.agent_id = m.agent_id
AND m2.user_id = m.user_id
AND m2.topic = m.topic
)
-- dist 距离越小越像,freshness 越大越新;相除之后,越靠前越是"又像又新"
ORDER BY (dist / freshness) ASC
LIMIT 3;
关键是 ORDER BY。我不再只排相似度,而是排"距离除以时效"。旧记忆语义再像,被衰减一压也上不来;低置信度和重复的,在 WHERE 里就被挡掉了。
这段 SQL 有个前提:向量检索、标量过滤、主题去重,得在同一个引擎里跑,不然就得拆成两查一拼。金仓 KES 的向量是内核里的能力,不是外挂插件,向量和标量能放进同一条 SQL,相似度和时效一次就排出来了。距离函数的写法各家不同,照自己库的文档改。
这两种召回,差的不是算法,是 AI数据库肯不肯把治理条件放进同一条 SQL。
| 召回维度 | 只看相似度 | 相似度 + 治理 |
|---|---|---|
| 排序依据 | 语义距离 | 距离 ÷ 时效 |
| 旧记忆 | 可能排到前面 | 被时间衰减压低 |
| 重复记忆 | 挤占 top-K | 查询内按主题去重 |
| 过期记忆 | 混进结果 | WHERE 直接过滤 |
| 跨库代价 | 两次往返 + 应用层拼 | 一条 SQL 一次完成 |
四、再深一层:AI数据库的记忆有两个时间
上一节提了 valid_from,这事值得单独说,它是 AI数据库做记忆底座时最容易被忽略的一层,也最像数据库的老本行。记忆有两个时间:一个是我什么时候写进去的,叫写入时间;一个是这条信息什么时候开始成立,叫有效时间。很多人表里只存一个 created_at,两个就混在一起了。
混了会怎样。用户 3 月 1 日说"我在上海",这条 3 月 5 日才落库;3 月 3 日他又说"我搬到北京了",当天落库。只看 created_at,上海那条更"新",因为它落库晚,可事实上北京才是后来发生的事。把两个时间分开存,冲突就能按"事情何时发生"来消解,而不是按"我何时入库",带时点的版本处理,本来就是数据库的强项。
放到现实里更直观。金融对账按交易日,不按入库时刻;政务办件按受理时间,不按同步时间。这些和"记忆什么时候为真"其实是同一个问题。所以我挑 AI数据库时,会多看一句:这个库对时间和版本的支持,够不够细。
五、避坑清单:选 AI数据库做记忆底座,先记这四条
第一条,别只拿文本去重。我第一版只用 content 当唯一键,两次提到"要发票"被误合并,键要带上 agent_id、user_id、来源和业务标识。
第二条,时间字段存两个。只存 created_at,异步乱序一来旧信息会盖掉新信息,加上 valid_from,冲突按事实发生的时间判,这个坑我查了两个晚上才定位到。
第三条,召回别只排相似度。相似只是条件之一,时效衰减、置信度过滤、去重,尽量塞进一条查询,拆到应用层去拼,等于把坑留在了最外层。
第四条,别让记忆无限长大。过期记忆要清、重复记忆要并、低置信度的归档备查不参与召回,我这边每周清一次,数据涨得快就缩到每天,不然向量索引越建越大,召回只会越来越慢。
写在最后
复盘这个坑,我改了一个认知:我原先以为记忆层的难点在"存",其实难点在"取",存得下不算本事,取得准才算。写和取是 AI数据库当记忆底座的两条腿,写这一端挡住丢、重、乱序,取这一端把相似、时效、置信度、去重的顺序做对,两件事都尽量收进一个库、一条 SQL,就少一处出错的地方。
所以挑 AI数据库做记忆底座,我会问三件事:向量和业务数据能不能同库、同事务?记忆的写入能不能做到幂等?召回能不能把时效、置信度、去重塞进同一条 SQL?这三问过了,这套库才算真的接住了 Agent 的记忆。
这也是我选型时会看的一点。记忆要的分层存储、向量召回、标量过滤、时间版本,要是散在几个组件里拼,链路就长,故障面也大。还有一条我盯得更紧,向量和业务数据能不能在同一个事务里更新。记忆最怕写一半成功、一半失败,最后留一堆对不上的记录;金仓 KES 在这一层是把向量和标量放进同一个事务的,故障时不丢数据,金融、政务这类场景很吃这一点。对不对得上你的场景,还是拿真实数据跑一遍最实在。
你们给 Agent 搭记忆底座,卡在写,还是卡在取?评论区聊聊。很多人是不是正被重复记忆顶着,召回永远翻不到第四条。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋