Administrator
发布于 2025-08-29 / 7090 阅读
104

Agent 的记忆系统设计:短期、长期与 episodic

工单:「你们的 Agent 是不是失忆了」

六月份客服转过来一批工单,其中有条写得挺扎心:「你们的运维助手,我上周三让它排查过一次订单服务超时,昨天又超时了,它从零开始问我要服务名、要时间范围,像完全没见过我一样。是不是失忆了?」

我看了下那两个 session,确实是两套完全独立的对话。我们当时只做了会话内记忆(MessageWindowChatMemory,窗口 20 条),会话一结束 ChatMemory 就随对象一起被回收了。

更麻烦的是,即便在同一个会话里,20 条窗口也经常不够用。用户描述问题要用掉五六条,Agent 每次工具调用又产生两条,20 条窗口实际上只够跑六七步。

这篇记录我们后来两个月做的三层记忆系统,以及中间踩的坑。expert 级别的内容,我会把存储结构和参数都写出来。

先想清楚:Agent 到底需要记住什么

动手之前我们花了两天时间,把「记忆」这件事拆开。照搬心理学的分类有点重,我们按工程需要分了三层:

层次内容生命周期存储介质
工作记忆当前任务的完整上下文:系统提示、工具结果、近期对话单次任务内存 + Redis
情景记忆「发生过什么」:某次故障的现象、排查路径、结论数月,会衰减PostgreSQL + 向量
语义记忆「是什么」:服务拓扑、负责人、SOP、用户偏好长期,人工可修正PostgreSQL + 向量

区分后两层的标准很实用:情景记忆带时间和结果,语义记忆不带。「7 月 12 日订单服务因为连接池打满导致超时,扩容后恢复」是情景;「订单服务使用 HikariCP,最大连接数 50」是语义。前者会过期,后者会更新。

这个区分不是学术洁癖,它直接决定了召回和遗忘策略:情景记忆要按时间衰减,语义记忆要做版本覆盖。

工作记忆:窗口只是及格线

Spring AI 提供的 MessageWindowChatMemory 能解决「会话内不丢上下文」,但有两个硬伤:按条数截断而非 token 截断(一条工具返回可能顶十条对话),以及无法区分「必须保留」和「可以丢」。

我们自己做了一个带优先级的 token 预算型工作记忆:

public class BudgetedChatMemory implements ChatMemory {

    private static final int TOKEN_BUDGET = 24_000;   // 留 8k 给输出和工具定义
    private final Map<String, List<PrioritizedMessage>> sessions = new ConcurrentHashMap<>();

    public void add(String sessionId, Message msg) {
        sessions.computeIfAbsent(sessionId, k -> new ArrayList<>())
                .add(new PrioritizedMessage(msg, priorityOf(msg)));
    }

    int priorityOf(Message m) {
        if (m instanceof SystemMessage)      return 100;   // 永不淘汰
        if (isUserOriginalRequest(m))         return 90;    // 用户原始诉求
        if (m instanceof UserMessage)         return 70;
        if (isToolResult(m))                  return 30;    // 最先被压缩
        return 50;
    }

    public List<Message> get(String sessionId) {
        List<PrioritizedMessage> all = sessions.getOrDefault(sessionId, List.of());
        int used = all.stream().mapToInt(PrioritizedMessage::tokens).sum();
        if (used <= TOKEN_BUDGET) return strip(all);

        // 超预算:按优先级从低到高淘汰,淘汰前先压缩
        List<PrioritizedMessage> sorted = new ArrayList<>(all);
        sorted.sort(comparingInt(PrioritizedMessage::priority));
        int idx = 0;
        while (used > TOKEN_BUDGET && idx < sorted.size()) {
            PrioritizedMessage victim = sorted.get(idx++);
            used -= victim.tokens();
            used += compressedSizeOf(victim);     // 压缩后仍保留摘要
            victim.compress();
        }
        return strip(all);
    }
}

关键改动是淘汰不等于删除。工具返回结果被压缩后保留一句话摘要(「查询 order-service P99 延迟,结果为 840ms,异常」),虽然细节丢了,但模型知道「这件事做过了」。这解决了 Agent 最常见的毛病——重复执行同一个工具调用。

上线后同一任务的重复工具调用率从 14.7% 降到 3.2%。

情景记忆:把「做过的事」变成可检索的资产

这是解决开头那条工单的核心。思路是:任务结束时,把整个过程抽成一个结构化「情节」存起来,下次遇到相似问题时召回。

CREATE TABLE episodic_memory (
    id            BIGSERIAL PRIMARY KEY,
    user_id       VARCHAR(64)  NOT NULL,
    session_id    VARCHAR(64)  NOT NULL,
    occurred_at   TIMESTAMPTZ  NOT NULL,        -- 事件发生时间
    created_at    TIMESTAMPTZ  NOT NULL,        -- 记录写入时间
    trigger       TEXT         NOT NULL,        -- 触发现象:「订单服务 P99 超 800ms」
    entities      JSONB        NOT NULL,        -- 涉及实体:{"service":"order-service","host":"ord-03"}
    actions       JSONB        NOT NULL,        -- 采取的动作序列
    outcome       TEXT,                         -- 结果:「扩容至 6 副本后恢复」
    success       BOOLEAN,
    embedding     VECTOR(1024),
    access_count  INT          DEFAULT 0,
    last_accessed TIMESTAMPTZ
);
CREATE INDEX ON episodic_memory USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON episodic_memory (user_id, occurred_at DESC);

写入是在任务结束时异步做的,用一个专门的抽取提示词让小模型从完整对话里提取这几个字段:

String extractEpisode(List<Message> conversation) {
    return cheapModel.call("""
        从以下运维任务对话中提取一个「事件记忆」,输出 JSON:
        {
          "trigger": "触发这个任务的现象,一句话,含具体指标",
          "entities": {"service": "...", "host": "...", "component": "..."},
          "actions": [{"step": 1, "action": "做了什么", "result": "结果"}],
          "outcome": "最终结论",
          "success": true/false
        }
        要求:保留具体数值(延迟、QPS、副本数、错误码),不要概括成「性能问题」。
        ---
        %s
        """.formatted(format(conversation)));
}

「保留具体数值」这句要求是我们踩坑后加的。第一版抽取出来的 trigger 全是「服务出现性能问题」「数据库响应慢」这种,检索时没有任何区分度,embedding 余弦相似度全在 0.8 以上,等于没检索。

召回:相似度 + 时间衰减 + 实体匹配

纯向量检索在这个场景下不够用。「订单服务超时」和「支付服务超时」的 embedding 相似度能到 0.86,但对用户毫无价值。我们做了三路融合:

double score(Episode e, Query q) {
    double semantic   = cosine(e.embedding, q.embedding);
    double entityHit  = entityOverlap(e.entities, q.entities);   // 0.0 / 0.5 / 1.0
    double recency    = exp(-daysSince(e.occurred_at) / 45.0);   // 半衰期约 31 天
    double successBias = e.success ? 1.0 : 0.85;                 // 失败案例降权但不丢弃

    return 0.45 * semantic
         + 0.30 * entityHit
         + 0.20 * recency
         + 0.05 * (e.access_count > 0 ? 1.0 : 0.0)
         + (successBias - 1.0);
}

实体匹配权重给到 0.30 是调出来的。一开始只给 0.15,结果召回的经常是「语义很像但完全不相关服务」的情节,用户反馈「它记得的东西没用」。提到 0.30 之后,召回精确率从 41% 到 78%。

失败案例保留但降权,是因为失败的排查路径也是有价值的——至少告诉 Agent「这条路走不通」。我们统计过,成功案例和失败案例的召回比例大约是 7:3。

语义记忆:允许被覆盖,允许人工修正

语义记忆存的是「事实」,最大的问题不是存,是更新。服务换了个负责人、数据库连接池参数调过一次、某台机器下线了——这些变化如果不同步,Agent 就会用过时的知识回答问题,而且语气还很笃定。

我们的做法是所有语义记忆都带版本号和来源,冲突时按「来源可信度 + 时间」仲裁:

CREATE TABLE semantic_memory (
    id          BIGSERIAL PRIMARY KEY,
    subject     VARCHAR(128) NOT NULL,     -- "order-service"
    predicate   VARCHAR(128) NOT NULL,     -- "connection_pool_max"
    object      TEXT         NOT NULL,     -- "50"
    source      VARCHAR(32)  NOT NULL,     -- CMDB / USER_STATED / AGENT_INFERRED / DOC
    confidence  REAL         NOT NULL,
    valid_from  TIMESTAMPTZ  NOT NULL,
    valid_to    TIMESTAMPTZ,               -- NULL 表示当前有效
    embedding   VECTOR(1024)
);
CREATE UNIQUE INDEX ON semantic_memory (subject, predicate)
    WHERE valid_to IS NULL;               -- 同一主体同一属性,只允许一条现行记录

那个部分唯一索引是整个设计的锚点:同一事实只允许有一条现行版本。要更新就必须先关闭旧的,物理上杜绝了「两条互相矛盾的记忆同时存在」。

来源可信度排序是:USER_STATED > CMDB > DOC > AGENT_INFERRED。Agent 自己推断出来的事实置信度最低,用户可以一句话覆盖掉。我们还在 UI 上把 Agent 推断的事实标成浅黄色,鼠标悬停显示来源和推断时间。

这套机制上线后,用户主动修正过 237 条语义记忆,其中 41 条确实是错的(主要是服务负责人变更和已下线的机器)。这些是我们靠任何算法都发现不了的。

遗忘:不删数据,降权

我们从来不物理删除记忆,只做降权和归档。原因很简单:记忆系统的价值在长尾,今天看起来过时的东西,半年后排查历史问题时可能是唯一线索。

遗忘策略分三档:

条件处理占比
90 天未召回 且 success=false移出向量索引,仅保留结构化行(SQL 可查)34%
180 天未召回 且 未被引用转冷存(对象存储,Parquet),7 天内可恢复19%
用户明确要求遗忘 / 含敏感信息立即删除 embedding 和正文,保留审计记录<1%

把 embedding 从向量索引里摘掉是个省钱的技巧。我们的向量索引常驻内存,8.4 万条记忆占了 1.2 GB。归档掉 34% 之后降到 780 MB,检索 P99 也从 62ms 降到 41ms。需要的时候还能从结构化行重新算 embedding 恢复。

第三档必须做扎实。有次用户要求删除他误粘贴进去的一段数据库密码,我们不仅要删记忆,还要检查这段内容有没有被写进过其他记忆的摘要里。后来加了个「敏感片段指纹表」,写入记忆时先扫一遍。

效果与代价

上线两个月后对比:

指标改造前改造后
重复提问率(用户自评)38%9%
平均任务轮次7.44.9
单任务平均 token31,20026,800
单任务额外延迟0+380ms
记忆存储成本0¥340/月

延迟增加 380ms 主要花在两处:任务结束时的情节抽取(约 260ms,异步不阻塞用户)和任务开始时的记忆召回(约 120ms,同步阻塞)。

token 下降是因为记忆里已经有了背景,Agent 少问了很多问题。这个收益比我们预期的大。

几个踩过的坑

  • 别把完整对话存进向量库。我们第一版偷懒,直接把整个 conversation 拿去 embedding。结果检索出来的都是「你好」「好的,我来看看」这种噪音,而且长文本的 embedding 质量很差。必须结构化抽取后再存。
  • 召回的记忆要在 prompt 里标明时间和来源。否则模型会把三个月前的情节当成刚刚发生的事,说出「刚才我们已经重启过了」这种话。我们现在的格式是 [2025-06-18 你的同事处理过]
  • 记忆污染要防。有次 Agent 从一个错误的工具返回里推断出「订单服务部署在 AWS」,写进了语义记忆,之后三周都在用它回答问题。现在 AGENT_INFERRED 来源的事实默认置信度只有 0.6,且一周未被验证就自动失效。
  • 多用户记忆要隔离但有共享区。我们按 user_id 隔离个人偏好,但服务拓扑这类属于团队共享。一开始没分开,导致 A 同事的私有服务被 B 同事检索到,虽然没出事,但体验很怪。

小结

做这套系统最大的认知转变是:记忆不是存储问题,是检索和遗忘问题。存下来很容易,难的是在正确的时间、以正确的形式、把正确的那几条放进上下文里。

三层结构里,工作记忆决定 Agent 能不能把一件事做完,情景记忆决定它能不能从经验里学习,语义记忆决定它会不会说错话。很多团队只做第一层,所以用户觉得「它聊完就忘」;只做第二层不做第三层,则会遇到「它记了一堆细节但搞不清基本事实」。

回到开头那条工单,我们后来给那位用户回了信,解释了改造方案。他上个月的反馈是:「现在它至少记得我上次让它干什么了,虽然偶尔还是会搞混两个服务。」我觉得这个评价挺中肯——记忆系统做到 90 分不难,最后那 10 分可能需要更长时间。

参考