Administrator
发布于 2026-08-14 / 2835 阅读
54

Agent 的记忆持久化与跨会话连续性

一条一星评价

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-1071%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%
单次请求的记忆 token01,120
记忆检索 P9964ms
记忆写入延迟(异步)P99 2.4s
存储成本(82 万用户)PG 340GB,¥2,800/月
embedding 调用增量+6.2 万次/月,¥15/月
越权召回拦截0(无检测)3~8 次/天

成本基本可以忽略,主要是 PG 的存储。写入侧的 embedding 调用只有情景记忆需要,而只有 23% 的会话会产生情景记忆(其余太短或没有实质内容)。

小结

回到开头那条一星评价。我们做完后给用户回了个消息,说明情况并送了张券。用户把评价改成了四星,附带一句"现在能记住了,但偶尔还是会串"。这个"偶尔会串"就是我们上面说的记忆冲突问题,还没解决。

技术上我最大的体会是:记忆的核心难点不是存,是"该忘什么"。存储方案三天就定下来了,检索的权重调了三周还在调。人的记忆之所以好用,不是因为记得多,是因为忘得对。

隐私这块,我的建议是别指望代码规范。tenant_id 这种东西,靠"写的时候记得带上"是一定会漏的,我们团队写了六年多租户代码还是漏了。要用机制保证:数据库行级安全、输出侧越权检测、定期的越权演练。三条里至少要有一条。

最后说一句,如果你也在做记忆,建议先把"跨会话正确率"这个指标建起来再动手。我们一开始凭感觉调,改了半个月不知道有没有变好,后来建了那 180 条的测试集,一周就调出效果了。没有度量的优化等于随机游走。

参考