DeepAgents(十八):Agent 记忆实战
Agent 记忆实战
我给一个客服助手搭记忆库,选了本机已有的 Postgres 加 pgvector,灌了 5 万条真数据跑了一遍。结论是够用,但有一条 SQL 会让你一条都取不出来。
下面按 10 个步骤走一遍,从定表到最后接进 Agent,每一步都有实测数字。
第 1 步:先算要不要多引一个组件
记忆落库这事,常见的 3 个选法:继续用文件、上关系库、上专用向量库。
文件的问题在于量一上来就撑不住。专用向量库(Qdrant、Milvus 这一类)能力更强,代价是多一个组件:多一套连接、备份、监控、故障处理。
我的判断标准很简单:如果你已经有 Postgres,先把它用到底。 pgvector 是 1 个扩展,装上就有向量类型和索引,记忆这个量级(单用户几百到几千条)根本压不满它。
真要换专用向量库,通常是 2 种情况:向量总量到了千万级以上,或者「带复杂过滤的向量检索」是你最核心的查询。这 2 条都不是小团队早期该操心的。
下面全部在本机实跑,Postgres 16.15 加 pgvector 0.8.6,embedding 用 bge-small-zh-v1.5,512 维,跑在 CPU 上。
第 2 步:一张表 11 个字段
create extension if not exists vector;
create table memories (
id bigserial primary key,
user_id text not null,
kind text not null
default 'semantic',
content text not null,
emb vector(512) not null,
importance real not null
default 0.5,
source text,
active boolean not null
default true,
valid_until timestamptz,
created_at timestamptz not null
default now(),
last_hit_at timestamptz not null
default now()
);
create index memories_emb_idx
on memories using hnsw
(emb vector_cosine_ops);
create index memories_user_idx
on memories (user_id, active);
几个字段值得说一句。emb 直接跟行放在一起,不另开一张向量表,省掉一次 join。active 承担软失效,退场靠改这一列,不删行。
valid_until 给有明确时效的记忆用。last_hit_at 是遗忘任务的依据,每次召回后顺手更新。
kind 分语义和情景 2 类。这 2 类管理方式不同,放在一张表里可以,但召回时最好分开取。
第 3 步:写进去,3 步里有 2 步在库外
一轮对话结束后,写进去要走 3 步。
第 1 步,抽事实。 这步在模型侧做,用一个小模型把这一轮里的偏好、事实、纠正抽成短句。它不该在对话主链路上,快慢都不影响用户等待。
第 2 步,对账。 拿新事实去和老条目比,落成新增、更新、标失效或忽略。这一步是写入的真正核心,比抽事实难。
第 3 步,落库。 批量写时用 copy,不要一条一条 insert。5 万条实测:向量化 105 秒,摊到单条 2.1 毫秒;copy 入库 22 秒。
同样的数据用单条 insert 会慢一个量级。
灌历史记忆是 1 次性工作,2 分多钟可以接受。但这也说明一件事:如果记忆量会涨到百万级,向量化必须做成后台队列,不能放在请求链路里。
第 4 步:取出来,HNSW 把 102 毫秒压到 0.3 毫秒
同一句查询,全库 top-5,5 万条数据:
| 检索方式 | p50 | p95 |
|---|---|---|
| 无向量索引(全表扫加排序) | 102.2 ms | 223.4 ms |
| 有 HNSW 索引 | 0.3 ms | 0.5 ms |
建这个索引只花 4.6 秒。340 倍的差距,代价几乎是 0。
但这 0.3 毫秒不含 embedding。真正端到端还要把问题向量化,单条实测中位数 6.3 毫秒,p95 是 8.4 毫秒。所以一次记忆召回的端到端大约 7 毫秒。
批量灌数据时可以摊薄,128 条一批摊到单条 1.0 毫秒。
这 7 毫秒是加在每次请求上的。 要不要做记忆召回,得看这 7 毫秒值不值。
第 5 步:修掉过滤召回,这条 SQL 会返回 0 条
记忆系统的查询几乎都带 where user_id = ?,这是最自然的写法:
select id, content from memories
where user_id = %s and active
order by emb <=> %s::vector limit 5;
这条 SQL 在 5 万条的库上,返回 0 条。
数据是有的。user-07 名下有 1000 条 active 记忆,SQL 也没写错。执行计划显示它走了向量索引,扫了 40 个近邻,然后拿 user_id 一过滤,40 个全不是这个人,返回空。
原因很直白:HNSW 先按向量取近邻、再过滤。 全库 5 万条里,这个用户只占 2%,40 个近邻里凑巧命中他的概率很低。
把 ef_search 往上抬呢?40 还是 0 条;抬到 100 以上,有时 planner 干脆放弃索引改走全表,返回 5 条,有时还是一条都不给。能不能取到取决于 planner 那一刻怎么选,这不算解决方案。
2 种修法,都实测过:
| 修法 | 做法 | 结果 | p50 |
|---|---|---|---|
| 先过滤再排序 | 把该用户的候选集 materialize 出来再排序 | 5 条 | 2.2 ms |
| 抬阈值 | ef_search 提到 1000,逼 planner 走全表 |
5 条 | 2 ms 上下 |
第 1 种的写法:
with cand as materialized (
select id, content, emb
from memories
where user_id = %s and active
)
select id, content
from cand
order by emb <=> %s::vector
limit 5;
候选集是 1000 条,排一遍 2.2 毫秒,比 HNSW 的 0.3 毫秒慢,但它取得到东西。1000 条以内的暴力排序,在这个量级完全够用。
pgvector 0.8 引入了迭代扫描,官方说法是可以在过滤后继续扩大扫描范围。我在这个环境里试了 hnsw.iterative_scan 的 2 个取值,这条查询仍然是 0 条:升级到 0.8 不等于这个坑自动消失。
第 6 步:加一层排序,向量分单独用会选错
向量只回答「像不像」,答不了「哪条还算数」。
我在库里放了同一个人的 6 条编辑器偏好,跨越 200 天,语义上最贴问题的那条是旧的。问「我写代码默认用哪个编辑器」:
| 排序方式 | 第 1 位 | 最新那条(neovim)排第几 |
|---|---|---|
| 只按向量距离 | vim(200 天前)0.825 | 第 6,0.614 |
| 加重要性,不加时间 | vim 0.957 | 第 3,0.663 |
| 向量 × 时间(线性) | neovim 0.657 | 第 1 |
| 向量 × 时间(指数) | neovim 0.631 | 第 1 |
| 向量 × 时间(幂律) | neovim 0.462 | 第 1 |
只加重要性不够,因为旧那条的重要性更高,加了反而更稳。必须加时间。
最终分的算法就一行:
select id, content,
(1 - (emb <=> %s::vector))
* power(0.995,
extract(epoch from
(now() - last_hit_at))
/ 3600.0)
* (0.8 + importance
* 0.4) as score
from memories
where user_id = %s and active
order by score desc limit 5;
幂律那一行是 Generative Agents 用的衰减系数,指数那一行是感知类记忆常见的做法。我选幂律,因为它对「几天」和「半年」的区分度更大:3 天前的偏好乘 0.697,200 天前的乘到 0。
第 7 步:定遗忘任务,1 天 1 条 SQL
-- 到期下线
update memories set active = false
where valid_until is not null
and valid_until < now();
-- 每用户保留 400 条,其余按权重淘汰
with ranked as (
select id, row_number() over (
partition by user_id
order by importance desc,
last_hit_at desc
) rn
from memories where active
)
update memories m
set active = false
from ranked r
where m.id = r.id and r.rn > 400;
5 万条数据跑这 2 条,过期清掉 0 条,超容量清掉 30003 条,剩 2 万条 active。整条 SQL 不到 1 秒。
这段放定时任务里,每天凌晨跑 1 次就够。别放在请求链路上。
第 8 步:定整理任务,重复条目会占满 top-k
遗忘决定谁退场,整理负责把留下的收拾干净。第 1 个动作是去重合并:
with r as (
select id, row_number() over (
partition by user_id,
predicate,
object
order by created_at desc,
id desc) rn
from memories where active
)
update memories m set active = false
from r
where m.id = r.id and r.rn > 1;
5 万条规模上跑这条,标失效 47000 条,剩 3000 条,耗时 1.32 秒。后面再跑容量淘汰 0.04 秒、冷归档 0.03 秒,3 步合计 1.39 秒。
重复条目挤占的是 top-k 的名额,跟硬盘没关系。整理前问一个问题取 top-5,5 个名额只覆盖 3 件事;整理后能覆盖 5 件。
第 9 步:接进 DeepAgents,2 条路选 1 条
前 8 步建的是库,这一步才是把它交给 Agent 用。
DeepAgents 自带一条路:StoreBackend 把记忆当文件存进 store 表,memory=["/memories/preferences.md"] 在启动时把整个文件塞进 system prompt。库里有几千条记忆时,这条路走不通。
我走第 2 条,给 Agent 一个检索工具,让它自己决定什么时候查:
@tool
def recall(query: str) -> str:
"""按问题取回相关记忆。"""
rows = search(USER, query, k=5)
return "\n".join(rows) or "无"
agent = create_deep_agent(
model=MODEL,
tools=[recall],
system_prompt=PROMPT,
)
写记忆同样做成工具,让 Agent 在对账那一步调用。这样表结构、排序权重、遗忘规则都留在你手里。
官方 PostgresStore 还有个 TTL,我专门验了 3 轮。
| 配置 | 过期后 get | 过期后 search | 物理行 |
|---|---|---|---|
只写 ttl=1,不传配置 |
取得到 | 返回 | 还在 |
传 omit_expired: True |
取不到 | 不返回 | 还在 |
refresh_on_read 默认 True |
读一次就续期 | 同左 | 还在 |
结论是:官方 TTL 默认不生效。 得在构造时传 ttl={"omit_expired": True},再显式起后台清理任务,过期行才会真删。而且 refresh_on_read 默认开着,读一次就把过期时间往后推(实测推后 40 秒),等于滑动过期。
不知道这 3 个默认值,你会以为写了 ttl 就万事大吉。
第 10 步:上线前验的 5 件事
- 用真实数据量跑一次带
user_id的检索,确认返回条数不是 0。 - 确认执行计划真的走了向量索引,别看延迟数字猜。
- 量一次端到端延迟,把 embedding 算进去,别只算 SQL。
- 遗忘和整理任务先在只读副本上跑,看清楚会清掉多少条。
- 用 2 个连接同时改同一行,确认冲突能被察觉。
记忆库能不能上产线,不看它存得进去,看它在真实数据量下取得出来,取得出来之后排得对不对。