1. 单一存储撑不住长期在线的 Agent:三个绕不过去的矛盾
一个 Agent 刚上线时跑得很好,聊十轮二十轮都很聪明。但连续跑上两三天、积累了几百轮对话之后,你会看到两种典型症状同时出现:一是它开始"忘事",用户上周明确说过不要推荐某类内容,这周照样推;二是它开始"变笨",上下文里塞满了无关的历史碎片,关键的指令被稀释到模型根本抓不住。更现实的问题是账单——每轮都把全量历史拼进 prompt,token 消耗呈线性甚至平方级上涨,成本曲线比功能曲线陡得多。
这就是AI Agent 分层记忆系统要解决的问题。它不是一个新模型,也不是一个新框架,而是一套工程上的存储编排方案:把 Agent 需要"记住"的东西按访问频率、结构化程度、生命周期切成几层,分别放进 Redis、PostgreSQL、ChromaDB 这三类特性完全不同的存储里,再用一条明确的读写链路把它们串起来。做得好,Agent 能在几个月的时间跨度上保持人格一致和事实准确;做得糙,你会得到三个各自为政的数据孤岛,互相打架,排查起来比单体数据库还痛苦。
这套方案适合谁?我个人的判断是:如果你的 Agent 只是单轮问答、无状态调用,完全不需要看这篇;但只要你的场景里出现了"跨会话""用户偏好""任务断点续跑""长期陪伴"中的任何一个,分层的必要性就出来了。下面这些内容是我在一个长期运行的对话型 Agent 上反复调过的实操记录,代码和参数都可以直接拿去改。
1.1 上下文窗口是稀缺资源,不是免费仓库
很多人第一反应是"窗口不是已经很大了吗,全塞进去不就行了"。这个想法在小规模验证阶段确实能跑通,但它忽略了三件事。
第一,注意力是有衰减的。同一段关键指令放在 20 万 token 的开头,和放在最近 2 千 token 里,模型遵循的概率差距非常明显。你把所有历史平铺进去,等于主动稀释了最重要的那部分信息。工程上我们常说"上下文预算",本质就是在做注意力分配,而不是容量分配。
第二,成本和延迟是线性绑定的。输入 token 翻倍,首 token 延迟基本同步上涨。对话类产品对首响极其敏感,用户等三秒和等八秒是完全不同的体验。哪怕你不在乎钱,延迟这一关也过不去。
第三,历史不等于记忆。原始对话里 80% 是寒暄、确认、重复表述,真正需要长期保留的可能只有几句偏好声明、几个关键事实、几个未完成的任务。把这 80% 的噪音永久保存并反复注入,是在给自己制造干扰。
所以分层的第一性原则不是"存不下",而是"该让什么进入上下文"。Redis 负责最近发生的事,保证短期连贯;PostgreSQL 负责确定的事实和状态,保证可审计、可查询;ChromaDB 负责语义相近的召回,保证"提一句就能想起来"。三者不是备份关系,是职责分工。
1.2 三种记忆的读写特征天差地别
如果把记忆当成一个整体,你会被迫用同一套策略对待所有数据,结果一定是顾此失彼。拆开看,它们的特征差异大到必须用不同技术栈。
| 维度 | 短期会话记忆 | 结构化长期记忆 | 语义联想记忆 |
|---|---|---|---|
| 读写比 | 写多读多,高频 | 写少读多 | 写少读中 |
| 单次数据量 | 小(单条消息) | 小到中(一行记录) | 中(一段文本块) |
| 延迟要求 | 毫秒级 | 十毫秒级可接受 | 几十到几百毫秒 |
| 生命周期 | 分钟到小时 | 月到永久 | 周到月 |
| 查询方式 | 按 key 精确取 | 按条件过滤 + 排序 | 按向量相似度 |
| 一致性要求 | 最终一致即可 | 强一致 | 最终一致 |
这张表是整套设计的出发点。Redis 的读写是内存级的,天然适合承接每轮对话的瞬时状态,但它不适合做复杂条件查询;PostgreSQL 有事务、有索引、有约束,适合存"用户明确表达过的事实",但它扛不住高频写入和 TTL 自动淘汰;ChromaDB 擅长拿一段文本找语义相近的片段,但它不会告诉你"这个用户的会员等级是什么"。
我见过最常见的错误是拿向量库当万能库用——把所有对话原样塞进 ChromaDB,需要什么就检索。短期看很省事,跑到几百个用户之后就会发现:要查"某用户上次说的预算上限"这种精确事实,向量检索给出的是一堆"意思差不多"的片段,你还得自己从里面挑,准确率完全不可控。精确的事就得用精确的存储。
1.3 分层的判定标准:延迟、结构化程度、生命周期
具体到一条数据该往哪层放,我总结了一个三步判断,实践中基本不会出错。
第一步问**"它需不需要被精确查询"**。如果答案是需要——比如用户 ID、订单号、任务状态、明确的偏好开关——直接进 PostgreSQL,不要犹豫。这类数据用向量检索是在给自己埋雷。
第二步问**"它在多久之后就没用了"**。如果是一轮对话的临时状态、工具调用的中间结果、当前正在执行的子任务栈,生命周期以分钟计,那就放 Redis 并设 TTL。这类数据的特点是量大、写频繁、过期即弃,放关系库会迅速把表撑爆。
第三步问**"它会不会以另一种说法被重新提起"**。如果一段内容的价值在于"用户换种说法时也能被想起来",比如过往讨论过的方案细节、用户描述过的背景故事,那它属于语义记忆,进 ChromaDB,并且必须带上足够的元数据用于过滤。
一个实用的反向检验:任何一条数据,如果你能明确写出它的查询语句,说明它属于结构化层;如果你只能描述"大概在什么情况下会用到",那它属于语义层。
还有一个容易被忽略的第四类:跨层引用。同一件事往往在三层都有投影——Redis 里有当前会话的原始消息,PostgreSQL 里有从这段消息里抽取的事实,ChromaDB 里有这段消息的向量。这三份数据必须靠一个稳定的主键串起来,否则后面排查数据不一致会非常痛苦。我的做法是给每条消息生成一个全局唯一的msg_id(时间戳 + 会话 ID + 序号),三层全部带上这个 ID,任何时候都能反查。
2. 三层存储的职责切分与数据结构定型
结构定型这一步值得多花时间。我前面吃过一次亏:一开始图快,Redis 里直接用裸字符串存 JSON,PostgreSQL 表结构随手定,向量库的元数据只放了个 user_id。跑到第三个月要加功能时,几乎每层都要做数据迁移。所以这一节把每层该存什么、字段怎么定讲清楚。
2.1 Redis:放"正在发生的事",TTL 是主角
Redis 在这一层承担的角色是工作内存,不是缓存。这个区别很关键:缓存是"数据库里有的东西我先放一份",工作内存是"这些数据只在这里有,过期就没了"。想清楚这一点,你就不会纠结"Redis 挂了数据怎么办"——丢了就丢了,因为它本来就不该长期存在。
具体存三类东西:
会话窗口。用 List 存最近 N 轮的消息,key 形如agent:session:{session_id}:window。只保留最近若干轮(我用的值是 20 轮,约等于 8k token),超出的部分不删,而是交给异步任务压缩后写入其他层,然后LTRIM掉。
工作状态。用 Hash 存当前任务进度,key 形如agent:session:{session_id}:state。字段包括current_step、pending_tool、retry_count、last_user_intent这类。Hash 的好处是单字段更新不用整体读写,HSET一个字段就够。
分布式锁与去重标记。用SET key value NX EX 30防止同一会话被并发处理,用SETNX做消息幂等去重。这两个是工程上真正救命的东西,尤其是接入了重试机制之后。
# 会话窗口写入 RPUSH agent:session:s_20240513_001:window '{"msg_id":"m_001","role":"user","content":"...","ts":1715600000}' LTRIM agent:session:s_20240513_001:window -20 -1 EXPIRE agent:session:s_20240513_001:window 7200 # 工作状态 HSET agent:session:s_20240513_001:state current_step 3 retry_count 0 # 并发保护 SET agent:lock:s_20240513_001 1 NX EX 30TTL 的设定要有讲究。会话窗口我给 2 小时,因为大多数对话在这一轮结束后两小时内还会继续;工作状态给 24 小时,因为任务可能跨天中断;锁给 30 秒,超过这个时间基本可以判定持有者已经挂了。这里有个坑:RPUSH和EXPIRE是两次调用,如果中间进程崩溃,key 就变成永久的了。稳妥做法是用 pipeline 把它们打包,或者干脆在每次写入时都刷新 TTL——宁可多刷新,也不要留下没有 TTL 的 key,那是内存泄漏的头号来源。
2.2 PostgreSQL 承担的是"可审计的事实"而不是缓存
PostgreSQL 这一层我定位为事实层。判据很简单:任何一条数据,如果用户第二天来问"我上次跟你说的是什么",你需要能一字不差地答上来,它就该在这里。
我实际用的表结构大致如下,字段不算多但都必要:
CREATE TABLE agent_memory_fact ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, session_id TEXT NOT NULL, msg_id TEXT NOT NULL, fact_type TEXT NOT NULL, -- preference / profile / task / constraint fact_key TEXT NOT NULL, -- 归一化后的键,如 budget_limit fact_value JSONB NOT NULL, confidence REAL DEFAULT 1.0, source TEXT, -- 来源:extract / user_stated / inferred valid_from TIMESTAMPTZ DEFAULT now(), valid_until TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_fact_user_key ON agent_memory_fact (user_id, fact_key) WHERE valid_until IS NULL; CREATE UNIQUE INDEX uq_fact_msg ON agent_memory_fact (msg_id, fact_key);几个设计点值得说明:
fact_key必须归一化。用户说"我预算不超过五千",抽取出来的 key 应该统一成budget_limit,value 存{"amount": 5000, "currency": "CNY"}。如果不做归一化,同一个含义会以十几种不同写法散落在表里,查询时根本聚不起来。
用valid_until做软失效,不要物理删除。用户改主意是常事,直接DELETE会让你丢掉"他曾经这么想过"这个信息,而这个信息在推荐、解释、冲突检测时都有用。查询时加WHERE valid_until IS NULL就只拿当前有效的。
confidence和source别省。从对话里自动抽取的事实,和用户明确陈述的事实,可信度完全不同。前者可能抽错,后者基本不会。有了这两个字段,你可以在注入上下文时按可信度排序,也可以在冲突时优先采信用户原话。
唯一索引(msg_id, fact_key)是为了幂等。同一条消息重复处理(重试、补偿任务)时,ON CONFLICT DO NOTHING就能兜住,不会产生重复事实。
2.3 ChromaDB 的向量库不是万能检索:切分粒度与元数据设计
向量层最容易做糙。很多人直接把整段对话作为一个 document 扔进去,然后抱怨检索效果差。问题出在切分。
我的经验是按"语义单元"切,不按固定长度切。一轮完整的问答、一个完整的方案讨论、一段用户自述的背景,都是自然的语义单元。如果单段超过 500 字,再按段落切;如果短于 50 字,和相邻的合并。固定 512 token 的机械切分会把一句话劈成两半,向量自然就歪了。
元数据的设计直接决定了检索质量。我实际用的字段:
collection.add( ids=[f"{msg_id}#{chunk_idx}"], documents=[chunk_text], embeddings=[vec], metadatas=[{ "user_id": user_id, "session_id": session_id, "msg_id": msg_id, "role": role, "chunk_idx": chunk_idx, "created_at": int(ts), "topic": topic_tag, # 粗粒度主题标签 "source_layer": "dialogue", }] )user_id是硬过滤条件,必须在查询时带上,否则会出现 A 用户检索到 B 用户记忆的严重问题。created_at用于时间衰减加权。topic是我加的一个粗分类标签(比如方案讨论、偏好声明、问题反馈),作用是缩小检索范围——当用户问"我们之前聊的那个方案",我可以先按 topic 过滤再走向量,召回精度提升很明显。
关于 embedding 模型的选择,我踩过的坑是:不同批次用不同模型生成向量,混在一个 collection 里,检索结果会莫名其妙。要么固定版本并在元数据里记录
model_version,要么换模型时重建整个 collection,没有第三条路。
3. 写入链路:一条用户消息怎么被拆到三个地方
写入链路的设计核心是别让用户在等。用户发一条消息,系统要做的事情包括:存会话窗口、抽取事实、生成向量、更新状态。这一串如果全同步做,首响延迟能翻三倍。所以整条链路的第一个决策就是同步异步的切分。
3.1 同步与异步的分界线画在哪
我的原则是:凡是"本轮回答不需要立刻用到"的写入,全部异步。
同步必做的只有两件:把用户消息RPUSH进 Redis 窗口,以及更新state里的轮次信息。这两个操作加起来不到 2 毫秒,对延迟无感知。
异步的部分包括:事实抽取(要调一次模型)、向量化(要调 embedding 接口)、摘要压缩(要调模型)、写 PostgreSQL。这些统统丢进队列,用独立 worker 消费。
def on_user_message(user_id: int, session_id: str, content: str) -> str: msg_id = gen_msg_id(session_id) r = get_redis() pipe = r.pipeline() pipe.rpush(f"agent:session:{session_id}:window", json.dumps({"msg_id": msg_id, "role": "user", "content": content})) pipe.ltrim(f"agent:session:{session_id}:window", -40, -1) pipe.expire(f"agent:session:{session_id}:window", 7200) pipe.execute() # 异步任务:抽取 + 向量化 + 摘要 enqueue("memory_ingest", { "msg_id": msg_id, "user_id": user_id, "session_id": session_id, "content": content }) return msg_id这里有个细节值得强调:异步任务必须带msg_id并且幂等。队列语义通常是"至少一次",重复消费一定会发生。worker 里先查uq_fact_msg唯一索引、向量层用ids覆盖写,就能保证重复执行不产生脏数据。我一开始没做这个,结果某次队列积压触发重试,同一个用户偏好被写进去五份,上下文里重复了五遍,模型直接开始自我复读。
3.2 摘要压缩的触发条件与结构化输出
摘要不是每轮都做,那样成本hold不住;也不是等窗口满了才做,那时重要信息已经被挤掉了。我用的触发条件是窗口内消息数达到阈值且对话出现自然停顿,具体是:窗口超过 12 条消息,且最近一条消息之后 30 秒无新消息。
压缩的提示词要让模型输出结构化 JSON,而不是一段自由文本。自由文本摘要在后面很难用——你没法按字段过滤,也没法做冲突检测。我用的结构是:
{ "facts": [ {"key": "budget_limit", "value": {"amount": 5000, "currency": "CNY"}, "type": "constraint", "confidence": 0.95, "source": "user_stated"} ], "summary": "用户希望在五千元预算内完成设备选型,重点关注续航和重量。", "open_questions": ["是否需要支持扩展存储"], "topic": "方案讨论" }关键是要求模型对每个 fact 标注confidence和source。用户明确说过的标user_stated,从上下文推断的标inferred。这个信息在注入上下文时的权重完全不同——inferred的事实我通常只作为参考,不写进 PostgreSQL,只留在向量层。
还有一个必须做的处理:新事实要和旧事实做冲突比对。同一fact_key如果已有有效记录且值不同,不能直接插入,而要把旧记录valid_until设为当前时间,再插入新记录。这个操作要在事务里做,否则并发情况下会出现两条同时有效的记录。
BEGIN; UPDATE agent_memory_fact SET valid_until = now() WHERE user_id = $1 AND fact_key = $2 AND valid_until IS NULL; INSERT INTO agent_memory_fact (user_id, session_id, msg_id, fact_type, fact_key, fact_value, confidence, source) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (msg_id, fact_key) DO NOTHING; COMMIT;3.3 向量化入库的批量与幂等
向量化最大的成本是接口调用。单条调用看起来快,但几百条消息攒下来,网络往返的累积延迟很可观。我的做法是攒批:worker 从队列里一次取 16 条,合并成一次 embedding 请求,再逐条写入 ChromaDB。
def ingest_worker(batch): texts = [item["content"] for item in batch] vectors = embed_batch(texts, batch_size=16) # 一次请求 for item, vec in zip(batch, vectors): chunks = split_semantic(item["content"]) for idx, chunk in enumerate(chunks): collection.upsert( ids=[f"{item['msg_id']}#{idx}"], documents=[chunk], embeddings=[vec], # 简化示例,实际需对每个 chunk 单独嵌入 metadatas=[{...}] )注意上面这段代码里的注释——如果一段内容被切成多个 chunk,每个 chunk 要单独算向量,不能复用同一段向量。这是初学者常犯的错,会让所有 chunk 检索出来高度相似。
upsert而不是add也是必须的。add遇到重复 ID 会报错,在重试场景下 worker 会一直失败重试形成死循环;upsert则是幂等覆盖,重复执行无害。
批处理大小也不是越大越好。我实测下来 16 到 32 之间比较稳,再大就容易触发接口的超时或限流,重试反而更慢。而且批太大时,单条失败会影响整批,粒度太粗不利于局部重试。
4. 读取链路:召回顺序决定了回答质量
写入是后台活,慢一点无所谓;读取在用户主链路上,每一毫秒都在体验里。这一节讲的是收到用户消息后,怎么在三层里把该拿的东西拿全、拿准、拿得不多不少。
4.1 先结构化后语义,还是并行召回
我试过两种方案。第一种是串行:先查 PostgreSQL 拿确定事实,再拿这些事实当过滤条件去向量库检索。第二种是并行:两个查询同时发出,最后融合。
串行的问题在于依赖关系强,一旦第一步慢了整体就慢,而且用结构化事实去过滤向量检索,会漏掉"相关的但不完全匹配"的历史。并行的问题是结果需要融合逻辑,复杂度上去了。
我现在用的是混合模式,具体分三步:
第一步,必须同步拿的——从 Redis 拿最近 10 轮原始对话,从 PostgreSQL 拿该用户当前有效的全部事实(按fact_key排序,通常十几到几十条)。这两步都是毫秒级,作为基础上下文。
第二步,并行触发向量检索,用用户当前消息做 query,带user_id过滤,返回 top 20。
第三步,融合。向量结果不直接塞进上下文,而是先做时间和相关度加权,取前 5 条,且必须排除已经在第一步里出现过的msg_id。
def build_context(user_id, session_id, query): recent = r.lrange(f"agent:session:{session_id}:window", -10, -1) facts = pg.query( "SELECT fact_key, fact_value, confidence FROM agent_memory_fact " "WHERE user_id=%s AND valid_until IS NULL ORDER BY confidence DESC", (user_id,) ) hits = collection.query( query_texts=[query], n_results=20, where={"user_id": user_id} ) scored = [] for doc, meta, dist in zip(*hits["documents"], hits["metadatas"], hits["distances"]): recency = decay(meta["created_at"]) # 时间衰减 score = (1 - dist) * 0.7 + recency * 0.3 scored.append((score, doc, meta["msg_id"])) seen = {json.loads(m)["msg_id"] for m in recent} picked = [d for _, d, mid in sorted(scored, reverse=True) if mid not in seen][:5] return assemble(recent, facts, picked)时间衰减函数我用的是简单的指数衰减,半衰期设 14 天。意思是两周前的记忆权重减半。这个参数没有标准答案,取决于你的场景——长期陪伴类可以放到 30 天,任务助理类缩到 3 天都合理。
4.2 去重、打分与截断
去重不只是按msg_id排重。实际跑起来你会发现两种情况:一是同一条消息被切成多个 chunk,检索出来好几段内容高度重叠;二是不同消息但内容相近,比如用户重复问了三遍同一个问题。
我的处理是两阶段去重。先按msg_id#chunk_idx取,同msg_id只保留分数最高的一个 chunk——因为同一段话的多个片段里,通常有一段信息最完整。然后再做一次相似度去重,用简单的字符级 Jaccard 相似度,超过 0.8 的只留一个。
打分公式里那个 0.7 和 0.3 的权重也需要调。相关度权重太高,会优先召回很久以前但字面最像的段落;时间权重太高,会丢失真正有价值的历史。我最后停在 0.7/0.3,是因为实测发现对齐"字面相似"更重要一些,但完全不带时间衰减又确实会引入太多陈旧信息。
截断要按总 token 预算而不是条数来。因为一条消息可能是 20 个字,也可能是一大段需求描述,按条数截会导致上下文长度剧烈波动。我的做法是给每层分配固定预算:
| 层 | 预算占比 | 约合 token | 说明 |
|---|---|---|---|
| 系统提示词 | 20% | 2000 | 固定,不可压缩 |
| 结构化事实 | 15% | 1500 | 按可信度排序截断 |
| 最近对话窗口 | 40% | 4000 | 从新到旧,超出即截 |
| 语义召回片段 | 20% | 2000 | 最多 5 条,每条不超 400 token |
| 用户当前输入 | 5% | 500 | 必须完整 |
这个比例不是拍脑袋定的。最近对话拿到最大份额,是因为短期连贯性对回答质量影响最直接;语义召回只用 20%,是因为它起的是"锦上添花"作用,塞多了反而干扰。这个配比在实际对话中我调过好几版,目前这版在"记得住"和"不跑题"之间平衡得比较好。
4.3 组装顺序对模型行为的影响
最后一个细节:上下文里的排列顺序会影响模型的注意力分布。我一开始按"系统提示 → 历史 → 事实 → 召回 → 当前问题"的顺序排,结果模型经常忽略结构化事实,因为它被夹在中间太深了。
后来改成:系统提示 → 结构化事实(以"已知用户信息"的小节呈现)→ 语义召回(以"相关历史片段"呈现)→ 最近对话 → 当前用户输入。把最确定的信息放前面、最相关的过程放后面,紧贴当前问题。这一改,事实遵循率肉眼可见地提升了。
还有一个技巧是给每段内容加轻量标签,比如:
## 已知用户信息 - 预算上限:5000 CNY(用户明确说明) - 偏好:重点关注续航和重量 ## 相关历史片段(仅供参考,可能不完整) [3天前] 用户提到通勤时间较长,希望轻便... ## 最近对话 ...这些标签本身不占多少 token,但会显著帮助模型区分"确定的事实"和"可能有偏差的回忆"。尤其是"仅供参考"这几个字,能明显降低模型把陈旧片段当既定事实来用的概率。
5. 一致性、过期与清理:分层系统最容易烂掉的地方
三层存储最大的隐患不是性能,是数据慢慢变得互相矛盾。这类问题不会报错,不会告警,只会让 Agent 的表现逐渐"不对劲",等你发现时已经积累了几个月的数据。这一节讲我遇到的具体形态和处理方式。
5.1 三种典型的不一致形态
第一种:事实改了但向量没改。用户先说预算 5000,三天后改成 8000。PostgreSQL 里旧记录被valid_until标记失效,新记录生效,这没问题。但向量库里还躺着三天前那段"预算 5000"的原始对话。当用户问"我预算多少",如果语义召回到了旧片段,模型就可能说出 5000。
对策是在注入前做交叉校验:语义召回的片段如果包含某个已知fact_key的旧值,且该 key 在当前事实表里已有新值,直接丢弃这个片段。实现上可以维护一个"已失效值"的集合,检索命中就过滤。
第二种:Redis 里的窗口和其他层不同步。比如 Redis 因为重启丢了一部分窗口,但 PostgreSQL 和向量库里的记录还在。这会导致 Agent 突然"记不起刚才聊了什么",但"记得上周的事",表现上很割裂。
对策是把 Redis 定位成纯加速层,而不是唯一来源。当检测到窗口为空但session_id存在时,从 PostgreSQL 回捞最近几条消息重建窗口。这个回捞逻辑平时不触发,但一旦触发就是救命。
第三种:摘要漂移。这个最隐蔽。多轮摘要级联下来,模型生成的摘要会逐渐偏离原意。第一轮的"用户希望便宜一点"经过三次压缩,可能变成"用户对价格敏感",再压一次变成"用户预算有限"。语义上似乎没错,但具体的数字和条件丢失了。
对策是摘要不级联——每次压缩都以原始消息为输入,不以旧摘要为输入。代价是每次要读取更多原始文本,但保真度显著提升。如果消息量太大必须级联,那就要求模型在摘要里保留所有数字、专有名词、否定条件,并且在提示词里明确列出这些约束。
5.2 TTL、归档与冷热分层
过期策略要分层设计,不能一刀切。
Redis 的 TTL 前面说过了,重点是每个 key 都要有。我加了一个巡检任务,每天扫一次SCAN出来的无 TTL key,发现就打日志告警。这个简单的检查帮我抓到过好几次漏设 TTL 的地方。
PostgreSQL 不做删除,做冷热分离。valid_until不为空的记录、以及created_at超过 180 天且从未被检索命中的记录,定期迁移到归档表。主表只保留热点数据,查询性能会稳定很多。迁移前记得先备份,我有一次迁移语句的WHERE条件写错,把有效数据也归档了,最后靠备份恢复。
向量库的处理是按时间清理 + 按重要性保留。普通对话的向量保留 90 天,被标记为topic=preference或topic=task的保留更久。清理不能直接删,先做一次"该用户近期是否还在活跃"的判断——不活跃用户的向量可以整体清掉,活跃用户的要保留。这个逻辑听着繁琐,但省下的存储成本很可观。
一个反直觉的经验:清理任务一定要有干跑模式。上线前先跑一遍只输出统计不执行删除的版本,看看会删掉多少、删掉哪些。我靠这个模式发现过一次过滤条件把整个用户的数据都圈进去的问题。
5.3 巡检指标与可观测性
分层系统不上监控等于裸奔。我关注的指标不多,但每个都对应一类具体故障:
| 指标 | 含义 | 异常阈值参考 |
|---|---|---|
| 三层数据条数比 | 同一 session 在三层的数据量比值 | 剧烈波动说明某层漏写 |
| 向量检索命中率 | 有结果返回的查询占比 | 长期低于 30% 说明切分或模型有问题 |
| 事实冲突次数 | 同一 key 不同值同时有效 | 任何非零值都需要排查 |
| Redis 无 TTL key 数 | 巡检扫描结果 | 大于 0 即告警 |
| 写入队列积压 | 待处理消息数 | 持续上涨说明 worker 处理不过来 |
有了这几个指标,绝大多数分层问题都能在用户抱怨之前发现。我最开始只盯着 QPS 和错误率,结果一次"事实冲突"的问题潜伏了两周才被发现,用户已经抱怨了好几轮答错信息。
6. 踩过的坑与调优记录
前面讲的是设计,这一节讲真实的翻车现场。这些内容在文档里基本找不到,都是被数据打脸之后总结出来的。
6.1 Redis 内存缓慢上涨
现象是内存曲线一路向上,重启就掉回去,然后继续涨。第一反应是 TTL 没设,查了一遍发现主要 key 都有。
真正的原因有两个。一是**LTRIM用错了范围**。我写的是LTRIM key 0 -20,本意是保留前 20 条,实际效果是保留除最后 20 条外的所有内容——方向反了。正确的写法是从尾部算:LTRIM key -20 -1。这个错误很典型,-20 -1和0 -20的语义差别需要仔细想清楚。
二是大 key 问题。有个会话的工作状态 Hash 里,我塞了一个tool_history字段,存了完整的工具调用返回结果,其中一次返回了几十 KB 的 JSON。这个 Hash 单 key 就占了大量内存。解决方式是工具返回结果只存摘要,完整内容落 PostgreSQL,Hash 里只放引用 ID。
修完之后内存曲线变平稳了。教训是:Redis 里不要存"大块数据",它的价值在速度不在容量。
6.2 向量检索的"看起来很像"
有一类检索错误特别迷惑人:召回结果字面上确实像,但完全不是用户要的。比如用户问"上次那个方案还有没有改进空间",召回了一堆含"方案"二字的片段,但都是其他话题里提到"方案"的地方。
根因是embedding 对高频词不敏感。查询和文档都大量出现"方案""问题""情况"这类词,余弦相似度会被这些词拉高,真正有区分度的信息被稀释了。
我做了三个调整。一是查询改写:把用户的口语化问题先过一遍,抽出关键实体和意图,比如"上次那个方案"改写成"设备选型方案 改进 续航 重量",再拿去检索。二是加 topic 元数据过滤,先缩小范围再算相似度。三是提高结果门槛,距离大于某个阈值的直接丢弃,宁可没有召回。
第三个调整最有效但也最有风险——阈值设太高会召回不到东西,设太低又会引入噪音。我的做法是先统计一批真实查询的距离分布,找出一个能过滤掉大约 40% 低质量结果的阈值。这个数字需要按你的数据分布重新测,我这里只提供一个方法。
6.3 摘要漂移与事实冲突
前面提过摘要级联的问题,这里补充一个更细的现象:模型在摘要时会"合理化"矛盾信息。
比如用户先说"预算五千",后来又说"其实我可以到八千"。模型摘要时倾向于输出"用户预算约五千到八千",听起来很合理,但实际上用户的最新意图是八千。这种"和稀泥"式摘要在后续对话里会持续误导。
我的处理是在抽取阶段强制要求区分"当前值"和"变更历史",并在提示词里明确禁止自己做折中。摘要结构里加一个字段:
{ "facts": [ {"key": "budget_limit", "value": {"amount": 8000}, "source": "user_stated", "supersedes": {"amount": 5000}, "confidence": 0.95} ] }supersedes字段明确记录"这条覆盖了旧值",写入时据此做失效标记。加了这一条之后,冲突检测变得可靠多了。
还有一个细节:用户说"其实"、"不过"、"改一下"这类转折词时,要重点标记为覆盖而非新增。这类词是意图变更的强信号,但在普通抽取里很容易被当成普通的连接词忽略掉。
整体跑下来,分层记忆这套架构的价值在长期运行的场景里非常明显——同等 token 预算下,Agent 的连贯性和事实准确率提升明显。但它不是装上就完事的组件,写入链路的幂等、读取链路的预算分配、三层之间的交叉校验,每一块都需要按自己的数据特点调。我个人的体会是,前两周调参的时间投入,会在后面几个月里十倍地省回来,尤其是在出问题排查的时候——结构清晰的数据,总比一团糊在一起的历史好查得多。