一条一星评价
8 月 6 号早上,应用商店来了条新评价,一星:
用了半个月,每次都要重新介绍一遍我的情况。上周让它记住的偏好,这周又忘了。同一家公司的人问同一个问题,答案还不一样,无语。
第二条倒是我们有意为之(不同角色权限不同),第一条是实打实的问题。
这篇记录我们做 Agent 记忆持久化的过程。存储、检索、隐私三块,其中隐私那部分是我们踩得最狠的。
先想清楚:记忆到底分几种
一开始我们所有"记忆"都往一个 chat_memory 表里塞,字段是 session_id + messages。这种设计天然不支持跨会话,因为它把记忆和会话绑死了。
重新分类,我们按"信息的变化频率和作用范围"分成四类:
| 类型 | 内容 | 作用范围 | 变化频率 | 例子 |
|---|---|---|---|---|
| 工作记忆 | 当前对话的上下文 | 单会话 | 每轮变 | 刚才说的订单号 |
| 情景记忆 | 过去发生的具体事件 | 跨会话 | 只增不改 | 7 月 12 日退过一笔款 |
| 语义记忆 | 从事件中提炼的稳定事实 | 跨会话 | 偶尔更新 | 用户偏好顺丰、对乳制品过敏 |
| 程序记忆 | 做事的方法和流程 | 租户/全局级 | 很少变 | 退款要先查订单再查流水 |
这个分类不是我发明的,认知心理学里就是这么分的,搬到 Agent 上很合适。关键是它直接决定了存储方案——四类信息的访问模式完全不同,不该放同一个地方。
存储方案:分三层,别存一处
-- 层一:工作记忆,Redis,TTL 4 小时,读写都热
-- key: mem:work:{sessionId} value: 压缩后的消息列表
-- 层二:情景记忆,PostgreSQL,按月分区,只追加
CREATE TABLE agent_episode (
id bigserial,
tenant_id bigint NOT NULL,
user_id bigint NOT NULL,
session_id varchar(64) NOT NULL,
happened_at timestamptz NOT NULL,
summary text NOT NULL, -- 一段话的摘要
entities jsonb NOT NULL, -- 涉及的实体,用于结构化过滤
embedding vector(1024),
importance smallint NOT NULL DEFAULT 5, -- 1~10,写入时由小模型打分
PRIMARY KEY (tenant_id, id)
) PARTITION BY RANGE (happened_at);
CREATE INDEX ON agent_episode USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON agent_episode (tenant_id, user_id, happened_at DESC);
-- 层三:语义记忆,PostgreSQL,可更新,带版本
CREATE TABLE agent_profile (
tenant_id bigint NOT NULL,
user_id bigint NOT NULL,
facet varchar(64) NOT NULL, -- preference / constraint / fact
key varchar(64) NOT NULL,
value text NOT NULL,
confidence real NOT NULL,
source_ep bigint, -- 来自哪个情景,可追溯
updated_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, user_id, facet, key)
);
程序记忆我们不存数据库,直接放配置中心和 Git 里的 prompt 仓库,走发布流程变更。
写入流程是异步的。会话结束后(或会话空闲 10 分钟),一个后台任务把本次对话抽成情景记忆 + 更新语义记忆:
@Async("memoryExecutor")
public CompletableFuture<Void> consolidate(String sessionId) {
List<Message> msgs = workMemory.load(sessionId);
if (msgs.size() < 4) return completedFuture(null); // 太短不值得记
// 1. 抽取事实,更新语义记忆
List<Fact> facts = extractor.extractFacts(msgs);
for (Fact f : facts) {
profileDao.upsert(f.tenantId(), f.userId(), f);
}
// 2. 生成情景摘要,只保留"发生了什么",丢弃中间推理
Episode ep = summarizer.toEpisode(msgs);
ep.setImportance(importanceScorer.score(ep));
episodeDao.insert(ep);
return completedFuture(null);
}
importance 这个字段一开始没有,后来加的。没有它,所有的历史都会被同等对待,导致 3 个月前一次无关紧要的闲聊和上周一次重要投诉权重一样。打分用小模型,1~10 分,我们的分布是:均值 4.2,8 分以上的占 6%。
检索策略:三层记忆怎么用
存储解决之后,检索才是难点。不是召回得越多越好——我们第一版把 top-10 情景记忆全塞进 prompt,结果 Agent 开始胡说八道,把三个月前的事情当成现在的。
最终的检索分三路并行,各有配额:
public MemoryBundle recall(String tenantId, String userId, String query) {
// A. 语义记忆:全量加载,它小且重要
List<Profile> profile = profileDao.loadAll(tenantId, userId,
f -> f.confidence() > 0.6); // 通常 5~15 条,约 300 token
// B. 情景记忆:向量检索 + 时间衰减 + 重要度加权
List<Episode> episodes = searchEpisodes(tenantId, userId, query, 5);
// C. 工作记忆:最近 N 轮,已在上下文中
return new MemoryBundle(profile, episodes);
}
private List<Episode> searchEpisodes(String t, String u, String q, int k) {
float[] qv = embedding.embed(q);
return jdbc.query("""
SELECT *, (1 - (embedding <=> ?::vector)) AS sim
FROM agent_episode
WHERE tenant_id = ? AND user_id = ?
AND happened_at > now() - interval '180 days'
ORDER BY (1 - (embedding <=> ?::vector))
* exp(-extract(epoch from (now() - happened_at)) / ?::float)
* (0.6 + 0.04 * importance)
LIMIT ?
""", qv, t, u, qv, DECAY_TAU, k);
}
那个 exp(-Δt / τ) 是指数衰减,DECAY_TAU 我们设成 21 天。含义是:21 天前的记忆权重降到 37%,42 天前降到 13%。这个参数调了三轮,从 7 天到 30 天都试过,21 天在我们的场景(客服、平均交互间隔 9 天)下评测分最高。
三段配额也有讲究:
- 语义记忆 300 token 上限,超了按置信度排序截断;
- 情景记忆最多 5 条、800 token,每条摘要限 160 token;
- 总量硬上限 1,200 token,超过就砍情景记忆。
这个上限是算出来的:我们统计过,记忆部分超过 1,500 token 之后,模型的指令遵循能力开始下降,工具调用的错误率从 2.1% 涨到 5.8%。
检索效果我们用一个离线集测的:构造 180 个"需要跨会话记忆才能答对"的问题,人工标注了应该召回哪几条记忆。
| 策略 | 召回准确率 | 答案正确率 | 平均注入 token |
|---|---|---|---|
| 纯向量 top-10 | 71% | 63% | 2,140 |
| 向量 + 重要度 | 76% | 69% | 2,090 |
| 向量 + 时间衰减 | 81% | 74% | 1,860 |
| 三者组合 + 1,200 token 上限 | 84% | 79% | 1,120 |
79% 离完美还很远,但比最初那个"每次都从零开始"的版本(正确率 41%)强太多了。
隐私与隔离:我们踩过的最疼的坑
这块我单独拿出来说,因为代价最惨。
8 月 12 日,也就是这篇写完的前两天,我们内部测试发现一个 bug:A 用户的 Agent 回答里出现了 B 用户的订单信息。
根因是一行代码的缓存 key 漏了 tenant_id:
// 错:key 里没有租户维度
String key = "recall:" + userId + ":" + hash(query);
// 对
String key = "recall:" + tenantId + ":" + userId + ":" + hash(query);
我们的 userId 在不同租户间是可能重复的(两套系统各自发号),所以这个 bug 只在特定组合下触发。测试环境没测出来,因为测试数据都是单一租户。
事后我们做了三件事,都值得做:
一、把所有涉及记忆的查询改成强制带租户条件。不再靠人记得写 WHERE tenant_id = ?,改用数据库的行级安全策略:
ALTER TABLE agent_episode ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON agent_episode
USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- 连接建立时设置,应用层改不了
SET app.tenant_id = '10086';
RLS 不能替代代码里的条件,但它是最后一道防线。我们做过一次演练:故意在某个查询里去掉 tenant_id 条件,RLS 拦住了,返回 0 行而不是全表。
二、加了一道输出侧的越权检测。召回的记忆里如果出现了不属于当前用户的实体标识符,直接丢弃:
public List<Episode> filterLeak(List<Episode> eps, String tenantId, String userId) {
Set<String> allowed = entityIndex.entitiesOf(tenantId, userId);
return eps.stream().filter(ep -> {
Set<String> mentioned = extractIds(ep.summary());
boolean leak = mentioned.stream().anyMatch(m -> !allowed.contains(m));
if (leak) {
metrics.counter("memory.leak_blocked").increment();
log.warn("blocked episode {} for user {}/{}", ep.id(), tenantId, userId);
}
return !leak;
}).toList();
}
上线后这个指标每天报 3~8 次,全是我们没想到的边界情况(比如用户换了手机号、订单被合并)。它不是万能的,但把风险从"可能发生"变成了"有数字盯着"。
三、记忆的删除要真删。用户要求删除记忆时,之前我们只是软删,向量还在库里、还能被召回。现在是同步删三处:Redis、PG 主表、向量索引。而且加了校验任务,每天扫一遍软删标记超过 24 小时还没物理删除的记录。
还有几个没想清楚的
这块我得诚实,记忆这块我们做得比别的都浅。
记忆冲突怎么办。用户 3 月说"我不吃辣",7 月说"最近开始能吃辣了"。我们现在的策略是新值覆盖旧值,但保留一条历史。问题是模型有时候会引用旧值,因为它在语义记忆里排得更靠前。加了时间戳提示("该信息更新于 2026-07-03")之后有改善,没根治。
记忆的自动遗忘。180 天硬截断太粗暴。有些信息("用户是公司 VIP")不该忘,有些("上周问过退货政策")一个月后就没用了。我们试过按访问频次做衰减,效果一般,目前还在观察。
多人共享一个 Agent 的场景。B 端客户里,一个"客服坐席"账号背后是 8 个人轮班用。这个时候记忆该归账号还是归人?我们现在归账号,导致坐席们抱怨"它记的是别人的偏好"。拆分方案在讨论,还没定。
整体数据
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 跨会话问题正确率 | 41% | 79% |
| 单次请求的记忆 token | 0 | 1,120 |
| 记忆检索 P99 | — | 64ms |
| 记忆写入延迟(异步) | — | P99 2.4s |
| 存储成本(82 万用户) | — | PG 340GB,¥2,800/月 |
| embedding 调用增量 | — | +6.2 万次/月,¥15/月 |
| 越权召回拦截 | 0(无检测) | 3~8 次/天 |
成本基本可以忽略,主要是 PG 的存储。写入侧的 embedding 调用只有情景记忆需要,而只有 23% 的会话会产生情景记忆(其余太短或没有实质内容)。
小结
回到开头那条一星评价。我们做完后给用户回了个消息,说明情况并送了张券。用户把评价改成了四星,附带一句"现在能记住了,但偶尔还是会串"。这个"偶尔会串"就是我们上面说的记忆冲突问题,还没解决。
技术上我最大的体会是:记忆的核心难点不是存,是"该忘什么"。存储方案三天就定下来了,检索的权重调了三周还在调。人的记忆之所以好用,不是因为记得多,是因为忘得对。
隐私这块,我的建议是别指望代码规范。tenant_id 这种东西,靠"写的时候记得带上"是一定会漏的,我们团队写了六年多租户代码还是漏了。要用机制保证:数据库行级安全、输出侧越权检测、定期的越权演练。三条里至少要有一条。
最后说一句,如果你也在做记忆,建议先把"跨会话正确率"这个指标建起来再动手。我们一开始凭感觉调,改了半个月不知道有没有变好,后来建了那 180 条的测试集,一周就调出效果了。没有度量的优化等于随机游走。