工单:「你们的 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.4 | 4.9 |
| 单任务平均 token | 31,200 | 26,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 分可能需要更长时间。