大模型的记忆系统怎么设计
大模型本身是无状态的,所谓的「记忆」全靠每次请求重新喂进去。但上下文窗口不是记忆系统,把所有历史都塞进去会带来成本、延迟和记忆漂移。这篇讲清工作记忆、短期记忆、长期记忆的分层设计,以及召回复核与时间衰减怎么做。
一、先破除一个幻觉:模型没有记忆
很多人第一次用大模型 API 时会惊讶地发现:「刚才不是聊过了吗,怎么它不记得了?」因为模型本身是无状态的——它不保存任何会话数据。「它记得上一轮说了什么」的真实原因是你自己把上一轮的问答又发了一遍。最朴素的多轮实现就是拼接:
发给模型的完整请求 =
系统提示词(角色、约束、输出要求)
+ 用户上一轮提问
+ 大模型上一轮回答
+ 用户这一轮提问
这条链路里没有任何"记忆模块",只有上下文拼接。所以大模型的「记忆」本质是「上下文工程」:要解决的不是"怎么让模型记住",而是在有限的 token 预算里,每一轮放什么、不放什么、怎么取。
为什么不能"全都塞进去"
把全部历史对话原样拼进去,是最直接的方案。上下文窗口确实越来越大,但这条路有三道硬墙:
| 问题 | 表现 |
|---|---|
| 成本 | 输入 token 按量计费,历史每翻一倍,单轮成本跟着翻倍;且是每一轮都重付一次 |
| 延迟 | 输入越长首 token 延迟越高,长上下文下可能翻几倍 |
| 效果反而变差 | 关键信息淹没在无关历史里,出现「中间迷失」——模型对长文本中部的召回明显更弱 |
第三点最反直觉:上下文变长不等于效果变好。一份有用的订单号,旁边围着二十轮寒暄,模型反而更容易忽略它。所以记忆系统的核心矛盾是:用户希望模型记住一切,但上下文窗口是一个必须精打细算的稀缺资源。
二、三层记忆架构
生产环境常见的做法是把记忆按「生命周期」和「访问频率」分成三层,各层用不同的存储和淘汰策略:
┌────────────────────────────────────────────────────────────┐
│ 工作记忆 (Working Memory) │
│ 载体:当前 Prompt 上下文 │
│ 内容:系统提示词 + 最近 N 轮对话 + 本轮召回的长期记忆片段 │
│ 策略:滑动窗口 + 滚动摘要,超出预算就压缩 │
│ 生命周期:单次请求 │
├────────────────────────────────────────────────────────────┤
│ 短期记忆 (Short-term Memory) │
│ 载体:Redis(带 TTL) │
│ 内容:本次会话的任务状态、槽位、已确认事实、中间结果 │
│ 策略:按 session_id 存取,会话超时自动失效 │
│ 生命周期:一次会话(分钟~小时) │
├────────────────────────────────────────────────────────────┤
│ 长期记忆 (Long-term Memory) │
│ 载体:MySQL 存元数据 + 向量库存 embedding │
│ 内容:用户偏好、稳定事实、历史结论(按 user_id 隔离) │
│ 策略:关键节点提炼入库,混合检索召回,时间衰减 │
│ 生命周期:跨会话(永久,除非被遗忘机制清理) │
└────────────────────────────────────────────────────────────┘
三层之间是漏斗关系:长期记忆在会话开始时被召回进短期记忆,后者在每轮请求时被裁剪进工作记忆;反过来,会话中产生的高价值信息只在关键节点向上固化。
工作记忆:滑动窗口 + 滚动摘要
工作记忆就是这一次请求真正发给模型的那段文本,管理策略通常组合使用:
| 策略 | 怎么做 | 优点 | 缺点 |
|---|---|---|---|
| 滑动窗口 | 只保留最近 N 轮原文 | 简单、无损、延迟可控 | 更早的信息彻底丢失 |
| 滚动摘要 | 超窗历史用 LLM 压成摘要,与最近几轮一起放 | 保留长期脉络 | 有损,且压缩多一次调用 |
| 组合 | 最近 N 轮保原文 + 更早的压成摘要 | 兼顾细节与脉络 | 需维护摘要版本 |
按轮数切还是按 token 数切,是另一个容易踩的坑。按轮数切在长短句混排时极不稳定——三轮闲聊可能只有 100 token,三轮技术讨论可能 3000 token,于是按 token 预算切更可靠:
def build_working_memory(sys_prompt, history, budget_tokens=6000):
"""按 token 预算装配工作记忆,保证不超窗"""
used = count_tokens(sys_prompt) # 系统提示词是硬约束,永远保留
kept = []
# 从最新往旧回填:最近的对话优先保留
for turn in reversed(history):
cost = count_tokens(turn)
if used + cost > budget_tokens:
break
kept.append(turn)
used += cost
kept.reverse()
# 被挤出去的部分不留原文,只挂一份缓存好的摘要
dropped = history[:len(history) - len(kept)]
if dropped:
kept.insert(0, {"role": "system",
"content": f"[更早对话摘要]{cached_summary_of(dropped)}"})
return [{"role": "system", "content": sys_prompt}] + kept
要点是:摘要要缓存,不要每次请求现算——每次都让 LLM 重压一遍历史,相当于给每轮又加一次模型往返;正确做法是随窗口推进增量更新。
短期记忆:会话状态放 Redis
短期记忆管的是"这件事办到哪一步了"。一个多步骤任务(比如售后处理)需要记住:用户意图是什么、订单号抽到没有、规则判定结果是什么、还差哪个信息。这些不是对话历史,而是结构化的任务状态;混在对话历史里让模型自己解析,既不可靠也不可查。
# 短期记忆:结构化任务状态,不是对话原文
KEY = f"agent:session:{session_id}"
state = {
"intent": "after_sales", # 意图识别结果
"slots": { # 已抽取的槽位
"order_no": "202605120001",
"issue_type": "price_protection"
},
"missing": ["price_evidence"], # 还缺什么
"rule_result": None, # 规则引擎判定结果
"turn_count": 3,
}
redis.setex(KEY, 1800, json.dumps(state)) # TTL 30 分钟,会话超时自动清理
选 Redis 的理由很直接:带 TTL 的自动过期。会话状态天生是易失数据,用户半小时不回来就该丢掉,不需要人工清理逻辑;换成数据库表,就要额外写跑批任务删过期记录。TTL 设多长是个权衡:太短,用户去查个订单号回来发现要重说一遍;太长,Redis 里堆积大量再也不会回来的会话,常见折中是每次交互都续期。
长期记忆:MySQL 元数据 + 向量库 embedding
长期记忆要跨会话存活,回答的是"这个用户是谁、他偏好什么、历史上发生过什么"。纯向量库方案在这里不够用,因为长期记忆的召回往往带结构化条件:比如"这个用户三个月内提过的售后问题"要时间范围过滤,"他确认过的收货地址"要精确匹配而非语义相似,只有"和当前话题相关的历史偏好"才轮到向量相似度。所以常见做法是双写:结构化字段(类别、创建时间、置信度、来源会话)进 MySQL,语义向量进向量库,两边用同一个记忆 ID 关联;检索时先结构化过滤,再向量相似度,最后由 LLM 复核。
召回链路通常是三段:
用户当前问题
↓
① 混合检索:向量相似度 topK + 结构化过滤(类别 / 时间 / 置信度)
↓
② 融合去重:同一件事的多条记忆合并,按时间与置信度排序
↓
③ LLM 二次筛选:把召回的记忆连同当前问题交给模型,判断"哪些真的相关"
↓
最终 3~5 条记忆片段 → 注入工作记忆
第 ③ 步的二次筛选是效果差异最大的地方。向量相似度是"语义接近",不等于"当前有用":用户当前在问退款,向量库可能召回一条语义接近但已办结的历史工单,塞进上下文只会误导模型。让模型拿着当前问题做一次相关性裁决,能显著降低无关记忆的干扰。
原则:召回宁少勿滥。注入 3 条精准记忆,比注入 20 条模糊记忆效果更好。
三、记忆的写入:合并、去重与衰减
记忆系统的难点不在读,而在写:什么该记、什么不该记、记了之后怎么维护,决定了这套系统半年后还能不能用。
只在关键节点固化
不是每轮对话都值得写入长期记忆——高频写入会带来存储膨胀和信噪比下降。常见的固化时机有三类:
| 时机 | 例子 |
|---|---|
| 用户明确表达偏好 | 「以后默认寄到这个收货地址」 |
| 任务成功闭环 | 一次售后处理完成,结论与依据固化 |
| 用户显式要求记住 | 「记住我的会员等级是钻石」 |
相反,闲聊、模型自己的推测、未确认的中间结论,都不应该写入长期记忆。尤其是模型推测——一旦把幻觉固化成"用户事实",后续每一轮都会被当成真的喂回去。
记忆合并:同一件事只留一条
同一件事被反复提到时,不应产生多条记忆,做法是写入前先做相似度检查:
def upsert_memory(user_id, new_mem, sim_threshold=0.92):
# 1. 在该用户自己的记忆里查最相似的一条
hits = vector_store.search(new_mem.embedding,
filter={"user_id": user_id}, top_k=1)
if hits and hits[0].score >= sim_threshold:
# 2. 命中 → 合并而非新增:内容取新的,置信度提升,更新时间刷新
store.update({
"id": hits[0].id,
"content": merge_text(hits[0].content, new_mem.content),
"confidence": min(1.0, hits[0].confidence + 0.1), # 反复确认 → 更可信
"updated_at": now(),
"hit_count": hits[0].hit_count + 1,
})
else:
# 3. 未命中 → 新增一条,初始置信度较低
store.insert(new_mem, confidence=0.6, created_at=now())
「被反复确认的记忆置信度更高」这一点很重要:它让记忆系统具备自我修正能力——用户三次都说"我偏好顺丰",这条记忆就比只说过一次的其他偏好更硬。
时间衰减:让旧记忆自然退场
记忆不能只增不减,除了用户显式删除,还需要一套衰减机制让长期不用的记忆自动降权:
召回优先级得分 = 相似度 × 时间衰减因子 × 置信度
时间衰减因子 = exp(-λ × 距今天数)
λ 越大衰减越快;λ = 0.01 时:60 天前 → 0.55,180 天前 → 0.17
| 记忆类型 | 建议衰减速度 | 理由 |
|---|---|---|
| 用户长期偏好(口味、尺码) | 慢,甚至不衰减 | 稳定事实 |
| 地址、联系方式 | 慢 | 除非用户改过,否则一直有效 |
| 近期兴趣、临时需求 | 快 | 有时效性,过期就是噪声 |
| 一次性任务结论 | 快 | 办完就该退场 |
关键原则:衰减影响的是"召回排序",不是"直接删除"——得分低的记忆自然排不进 topK,等价于被遗忘;但用户之后又提到相关话题时,它仍有机会被召回。先降权、后清理比直接删除安全得多。
四、三个绕不开的坑
1. 记忆污染
表现:一次错误的结论被写进长期记忆,之后每一轮都被当成事实喂回模型,错误被不断放大。典型来源包括模型推测出的"用户偏好"、用户口误但未被纠正的信息、把别的用户的数据写错归属。防治:写入设置信度门槛(低置信度先放短期记忆)、区分"用户说的"和"模型推的"(元数据记录来源类型,模型推测永远不写成用户事实)、保留原始出处以便追溯、提供删除与更正入口。
2. 记忆漂移
表现:滚动摘要反复压缩后,早期信息的原意被逐渐扭曲。第一轮说"预算 2000 左右",压缩五轮后变成"预算 2000 以内",再压几轮变成"要便宜的"。根因:摘要是有损压缩,压缩的又是上一版摘要,误差会累积。防治:关键事实(订单号、金额、时间)抽成结构化槽位单独存,不进摘要;摘要重建而非链式压缩,定期用原始对话重新生成。
一句话:自然语言摘要适合保留"脉络",不适合保留"事实"。事实要么进槽位,要么进长期记忆。
3. 上下文膨胀
表现:系统提示词 + 历史摘要 + 召回记忆 + 工具定义 + 检索片段层层叠加,还没开始回答就已经消耗几千 token。防治:
| 手段 | 做法 |
|---|---|
| 给每一部分设预算 | 历史摘要 ≤ 800、召回记忆 ≤ 600、检索片段 ≤ 2000,超了在内部裁剪 |
| 记忆按需召回 | 只在意图切换或需要用户偏好时才查长期记忆 |
| 工具定义按需注入 | 只挂当前场景相关的工具 |
| 复用缓存 | 系统提示词走 prompt caching,重复部分不重复计费 |
五、隔离与审计
记忆系统存的是用户隐私数据,这两件事不是可选项。
按用户隔离:所有记忆查询必须强制带上 user_id 过滤条件,且该条件由服务端从会话身份注入,不能由模型参数或前端传参决定。向量库的标量过滤能做这件事,但要确保它在所有查询路径上生效——漏一个入口就是一次数据泄露。
# 过滤条件由服务端注入,调用方无权覆盖
def recall_memories(session, query, top_k=5):
return vector_store.search(
query.embedding,
filter={"user_id": session.user_id}, # 强制注入
top_k=top_k,
)
审计日志:记忆的写入、读取、删除都要留痕,记录四要素——谁、什么时候、读了/写了哪条、来源是哪次会话。作用是:用户质疑"它怎么知道我这个信息"时能查到来源;出现记忆污染时能定位回滚;满足可查询、可导出、可删除的合规要求。
六、小结
- 大模型无状态,记忆靠每轮把历史重新拼进上下文;其本质是上下文预算分配
- 上下文不是越长越好:成本翻倍、延迟上升、关键信息被淹没
- 三层架构:工作记忆(当前 Prompt,滑动窗口 + 滚动摘要)、短期记忆(Redis 存任务状态,带 TTL)、长期记忆(MySQL 元数据 + 向量库 embedding)
- 工作记忆按 token 预算切而非按轮数切;滚动摘要要增量缓存,不要现算
- 召回用混合检索 + LLM 二次筛选,宁少勿滥;3 条精准胜过 20 条模糊
- 写入只在关键节点发生,且先做相似度合并;被反复确认的记忆置信度更高
- 时间衰减影响排序而非直接删除,先降权、后清理
- 三个坑:记忆污染(设置信度门槛)、记忆漂移(事实进槽位、摘要重建)、上下文膨胀(分块预算)
- 按用户隔离必须由服务端强制注入
user_id;记忆的读写删都要留审计日志
记忆系统最容易走偏的地方,是把它当成一个"存储"问题——拼命多存、多召回。真正决定效果的恰恰相反:敢不敢少放。好的记忆系统,衡量指标不是"记住了多少",而是"每一轮注入上下文的那几百个 token,是否恰好是这一轮真正需要的"。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。