LLM(大语言模型)本身是无状态的。每次调用模型,本质上都是把一段 Prompt 和对话消息发给模型,让它根据当前输入生成结果。模型不会天然记住用户上一轮说过什么,也不会自动维护用户画像、禁忌、偏好、任务进度。
Agent 想要像一个长期可用的助手,就必须在模型外部搭建记忆系统。
记忆系统要解决两类问题:
| 记忆类型 | 解决的问题 | 生命周期 | 典型数据 |
|---|---|---|---|
| 短期记忆 | 当前会话怎么保持连贯,正在做的任务进展到哪一步 | 一次会话内,通常分钟到小时级 | 最近几轮对话、当前任务状态、临时约束 |
| 长期记忆 | 跨会话记住稳定事实和历史经验 | 天、月甚至更长 | 用户姓名、过敏史、偏好、历史方案、长期标签 |
只用“滑动窗口 + 向量数据库”很容易做出 Demo,但离生产系统还差很远。核心原因有两个:
- 滑动窗口会丢掉早期但重要的信息。
- 向量检索只能找语义相似内容,不能保证事实精确。
例如用户一开始说“我对花生严重过敏”,中间连续聊了几十轮需求细节,再问“你还记得我的饮食禁忌吗”。如果只保留最近几轮对话,这个关键信息很可能已经被截断。
再例如用户保存过手机号、生日、过敏史这类强事实数据,用向量库去召回并不可靠。向量检索适合找“意思接近”的内容,不适合保证“号码、日期、禁忌”这类字段 100% 精确。
生产级 Agent 记忆架构的核心思路不是把所有记忆都塞进一个向量库,而是把不同类型的数据放到不同存储里,再按优先级组装给模型。
flowchart TB
A[用户请求] --> B[短期记忆]
A --> C[长期记忆]
B --> B1[最近对话流]
B --> B2[Session 状态机]
C --> C1[强事实结构化数据]
C --> C2[半结构化历史文本]
C --> C3[非结构化语义片段]
C --> C4[实体关系]
B1 --> D[Prompt 组装]
B2 --> D
C1 --> D
C2 --> D
C3 --> D
C4 --> D
D --> E[LLM 推理]
E --> F[返回结果]
短期记忆不能只靠滑动窗口
最简单的短期记忆实现,是把历史对话放进 messages 数组:
[
{"role": "user", "content": "我叫林冲,对花生过敏"},
{"role": "assistant", "content": "好的,我会记住你的花生过敏信息"},
{"role": "user", "content": "帮我整理一个产品需求文档"}
]
问题在于模型上下文窗口是有限的。对话越来越长时,很多系统会用 FIFO(First In First Out,先进先出)策略删除最早的消息,只保留最近 N 轮。
这种做法只能保证“最近聊过什么”,不能保证“重要信息还在”。
短期记忆更合理的设计,是在 Redis 里拆成两层:
| 层级 | 存储内容 | 作用 | 是否直接进入 Prompt |
|---|---|---|---|
| 最近对话流 | 最近 5 到 10 轮原始对话 | 保持语气、指代、上下文连贯 | 放入 messages |
| Session 状态机 | 当前会话里的关键事实、约束、任务状态 | 防止核心信息被截断 | 放入 System Prompt 或高优先级上下文 |
Redis 最近对话流
最近对话流只负责上下文连贯,不承担长期事实记忆。
可以用 Redis List 保存:
key: agent:sess:{session_id}:messages
type: list
ttl: 30min / 2h / 1day,按业务决定
max_length: 10 到 20 条消息
写入时控制长度:
def append_message(redis, session_id, role, content):
key = f"agent:sess:{session_id}:messages"
message = {
"role": role,
"content": content,
"ts": int(time.time())
}
redis.rpush(key, json.dumps(message, ensure_ascii=False))
redis.ltrim(key, -10, -1)
redis.expire(key, 3600)
这层数据可以直接进入 messages,让模型知道最近发生了什么。
Session 状态机
Session 状态机保存“不能因为窗口截断而丢失”的信息,例如:
- 用户当前身份
- 当前任务目标
- 已确认约束
- 明确禁忌
- 待办事项
- 本轮会话里刚发生的状态变更
一个状态对象可以长这样:
{
"session_id": "s_10001",
"version": 7,
"user_profile": {
"name": "林冲",
"role": "产品经理"
},
"constraints": [
{
"type": "allergy",
"value": "peanut",
"confidence": 1.0,
"source": "user_explicit",
"updated_at": "2026-06-07T10:20:00+08:00"
}
],
"task_state": {
"current_goal": "整理产品需求文档",
"stage": "collecting_requirements"
}
}
这部分内容不要混在普通历史对话里,而要放到更高优先级的位置,例如 System Prompt:
你正在与用户进行同一个会话。必须遵守以下会话状态:
用户信息:
- 姓名:林冲
- 职业:产品经理
当前约束:
- 用户对花生严重过敏,涉及饮食推荐时必须排除花生及含花生成分的食品。
当前任务:
- 用户正在整理产品需求文档,当前阶段是需求收集。
这样即使最近对话流里已经看不到第一轮消息,模型仍然能拿到关键状态。
短期记忆的关键点是:最近对话负责连贯,Session State 负责核心状态。两者不能互相替代。
flowchart LR
U[用户输入] --> P[轻量抽取器]
P --> S[(Redis Session State)]
U --> M[(Redis 最近对话流)]
S --> B[Prompt Builder]
M --> B
B --> L[LLM]
L --> R[回复用户]
R --> M
长期记忆不能只靠向量数据库
RAG(Retrieval-Augmented Generation,检索增强生成)常见做法是:把历史内容切块,生成 Embedding(向量化表示),写入向量数据库;用户再次提问时,用当前问题做向量检索,把相似片段塞进 Prompt。
这套机制适合召回语义相关内容,但不适合保存强事实。
原因很简单:向量检索的目标是相似,不是精确。
例如:
- “我手机号是 138 开头”与“他手机号是 139 开头”在语义空间里可能很接近。
- “我不能吃海鲜”与“我喜欢海鲜自助”都围绕海鲜这个主题,可能被召回到一起。
- “我去年去过东京”与“我下周要去东京”语义相关,但时间状态完全不同。
如果这些信息直接影响模型决策,纯向量召回就有风险。
生产系统应该按数据性质拆存储。
| 记忆类型 | 推荐存储 | 适合保存什么 | 召回方式 | 优先级 |
|---|---|---|---|---|
| 强事实结构化数据 | MySQL / PostgreSQL / MongoDB | 姓名、手机号、生日、过敏史、明确禁忌、用户标签 | 主键、索引、条件查询 | 最高 |
| 半结构化长文本 | Elasticsearch | 历史总结、需求文档、方案记录、关键词强相关内容 | BM25、关键词、过滤条件 | 高 |
| 非结构化语义片段 | Milvus / Qdrant / pgvector | 情绪片段、表达风格、模糊偏好、经验片段 | 向量相似度检索 | 低 |
| 实体关系 | Neo4j / NebulaGraph | 家庭成员、同行人、组织关系、复杂关联 | 图遍历 | 按业务决定 |
向量库不是不能用,而是不能放在最高优先级。它适合作为补充记忆,用来扩展上下文,而不是覆盖结构化事实。
长期记忆的异构存储设计
一个可落地的长期记忆系统,通常至少包含三类表或索引。
强事实表
强事实数据需要可校验、可更新、可覆盖,适合用关系型数据库或文档数据库保存。
CREATE TABLE user_memory_fact (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
fact_type VARCHAR(64) NOT NULL,
fact_key VARCHAR(128) NOT NULL,
fact_value JSON NOT NULL,
confidence DECIMAL(5,4) NOT NULL DEFAULT 1.0,
source VARCHAR(64) NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_user_fact (user_id, fact_type, fact_key)
);
示例数据:
| user_id | fact_type | fact_key | fact_value |
|---|---|---|---|
| 10001 | profile | name | "林冲" |
| 10001 | allergy | peanut | {"level": "severe"} |
| 10001 | travel | destination | {"city": "Tokyo", "time": "next_week"} |
这种数据必须通过确定性查询读取,不能交给向量库猜。
半结构化文本索引
历史对话总结、需求文档、过往方案这类内容不一定有固定字段,但经常需要关键词命中,适合放进 Elasticsearch。
{
"user_id": 10001,
"memory_type": "requirement_summary",
"title": "智能客服需求讨论记录",
"content": "用户希望客服机器人支持工单查询、退款进度跟踪和多轮补充信息收集。",
"tags": ["客服", "工单", "退款"],
"updated_at": "2026-06-07T11:00:00+08:00"
}
Elasticsearch 的 BM25 排序对关键词检索很友好。用户问“上次说的退款工单流程是什么”,关键词命中往往比单纯向量召回更稳定。
向量记忆
向量库适合存放不容易结构化、但对生成有帮助的内容。
{
"user_id": 10001,
"memory_type": "style_preference",
"content": "用户喜欢简洁、偏工程化的回答,不喜欢长篇背景铺垫。",
"embedding": [0.013, -0.028, 0.102],
"metadata": {
"source": "dialogue_summary",
"created_at": "2026-06-07T11:30:00+08:00"
}
}
检索向量记忆时必须加标量过滤,例如先限定 user_id、memory_type、时间范围,再做向量相似度搜索:
results = vector_store.search(
embedding=query_embedding,
filter={
"user_id": 10001,
"memory_type": {"$in": ["style_preference", "experience"]},
"created_at": {"$gte": "2026-01-01"}
},
top_k=5
)
不加过滤直接全库向量搜索,会显著增加误召回概率。
同步读链路:先组装记忆,再调用模型
用户请求进入 Agent 后端时,不应该直接把输入丢给模型。更合理的顺序是先读取和整理记忆,再构造 Prompt。
主链路需要控制 RT(Response Time,响应时间),所以读操作要尽量并发完成。
sequenceDiagram
participant C as 客户端
participant A as Agent API
participant R as Redis
participant DB as MySQL/MongoDB
participant ES as Elasticsearch
participant V as 向量数据库
participant L as LLM
C->>A: 发送用户输入
A->>R: 读取最近对话和 Session State
A->>DB: 查询强事实记忆
A->>ES: 关键词检索历史文本
A->>V: 向量检索语义片段
R-->>A: 返回短期记忆
DB-->>A: 返回结构化事实
ES-->>A: 返回文本结果
V-->>A: 返回语义结果
A->>A: 合并、去重、排序、截断
A->>L: 发送 Prompt 和 messages
L-->>A: 返回生成结果
A-->>C: 返回回复
Prompt 组装时要明确优先级:
优先级从高到低:
1. 本轮用户输入中明确表达的状态变更
2. Session State 中的当前会话状态
3. MySQL / MongoDB 中的强事实长期记忆
4. Elasticsearch 中召回的历史文本
5. 向量库召回的语义补充片段
如果低优先级信息与高优先级信息冲突,必须以高优先级信息为准。
一个简化版组装逻辑可以这样写:
async def build_agent_context(user_id, session_id, user_input):
# 本轮输入先做轻量抽取,避免“刚说完就忘”
current_delta = extract_session_delta(user_input)
redis_task = get_session_memory(session_id)
facts_task = get_user_facts(user_id)
es_task = search_es_memories(user_id, user_input)
vector_task = search_vector_memories(user_id, user_input)
session_memory, user_facts, es_hits, vector_hits = await asyncio.gather(
redis_task,
facts_task,
es_task,
vector_task
)
session_state = merge_state(
old_state=session_memory["state"],
delta=current_delta
)
retrieved_memories = rerank_and_cut(
es_hits=es_hits,
vector_hits=vector_hits,
max_tokens=1200
)
system_prompt = render_system_prompt(
session_state=session_state,
user_facts=user_facts,
retrieved_memories=retrieved_memories
)
messages = session_memory["recent_messages"]
return system_prompt, messages
这里有一个很重要的细节:本轮输入里的状态变更,要在调用模型前进入 Session State。
例如用户说“我改主意了,下周去东京,不吃海鲜”,本轮回复就应该立刻遵守“东京”和“不吃海鲜”。长期存储可以异步更新,但当前会话状态不能等消息队列慢慢消费。
异步写链路:用事件驱动更新长期记忆
记忆写入不适合全部放在主链路里。每轮对话都同步调用大模型总结、写数据库、写向量库,会让接口延迟和算力成本同时上升。
更常见的做法是:主链路完成回复后,把对话事件投递到 MQ(Message Queue,消息队列),由后台消费者异步分析和落盘。
flowchart LR
A[主链路生成回复] --> B[投递记忆事件到 MQ]
B --> C[记忆消费者]
C --> D[轻量分类器]
D -->|无记忆价值| X[丢弃]
D -->|强事实变更| E[更新 MySQL/MongoDB]
D -->|历史文本| F[写入 Elasticsearch]
D -->|语义片段| G[生成 Embedding 写向量库]
D -->|关系变更| H[写入图数据库]
E --> I[ACK]
F --> I
G --> I
H --> I
事件结构可以设计成这样:
{
"event_id": "evt_20260607_0001",
"user_id": 10001,
"session_id": "s_abc",
"user_input": "我改主意了,下周去东京,不吃海鲜",
"assistant_output": "好的,我会按东京行程并避开海鲜来规划。",
"created_at": "2026-06-07T12:00:00+08:00"
}
消费者拿到事件后,不要直接做大模型总结。更省成本的方式是先做轻量分类:
| 分类结果 | 处理方式 |
|---|---|
| 普通闲聊 | 不落长期记忆 |
| 临时上下文 | 只更新 Redis Session State |
| 强事实新增或变更 | 写结构化数据库 |
| 长文本经验 | 总结后写 Elasticsearch |
| 模糊偏好 | 生成 Embedding 后写向量库 |
| 实体关系 | 写图数据库 |
例如分类器识别到“下周去东京,不吃海鲜”包含状态变更,可以输出标准指令:
{
"actions": [
{
"type": "upsert_fact",
"fact_type": "travel",
"fact_key": "destination",
"fact_value": {
"city": "Tokyo",
"time": "next_week"
}
},
{
"type": "upsert_fact",
"fact_type": "diet_constraint",
"fact_key": "seafood",
"fact_value": {
"avoid": true,
"source": "user_explicit"
}
}
]
}
后台再根据 type 路由到对应存储。这样既减少无效写入,也避免每轮对话都触发昂贵的大模型总结。
一致性兜底:异步更新不能让 Agent 读到旧状态
异步写链路一定会遇到一致性问题。MQ 积压、消费者失败、数据库更新冲突,都可能导致长期记忆还没更新,而用户已经发起下一轮请求。
解决办法不是放弃异步,而是给不同重要级别的数据设置兜底策略。
| 问题 | 兜底方案 | 适用场景 |
|---|---|---|
| 长期记忆尚未更新 | Session State 优先于长期记忆 | 同一会话内刚发生的状态变更 |
| MQ 消费延迟 | 写 Redis 临时事实缓存,设置短 TTL | 几分钟内必须生效的新状态 |
| 零容错数据不能延迟 | 主链路同步写强事实库 | 手机号、安全禁忌、严重过敏史 |
| 并发更新互相覆盖 | 版本号 + 乐观锁 | 多设备、多会话同时修改记忆 |
| 消费失败 | 重试队列 + 死信队列 + 补偿任务 | 后台落盘失败或超时 |
Session State 永远比旧长期记忆优先
同一会话里,用户刚说过的新状态必须优先使用。
例如长期记忆里保存的是:
{
"destination": "Shanghai"
}
用户本轮说:
我改主意了,下周去东京。
哪怕 MySQL 还没更新,Session State 也应该变成:
{
"destination": "Tokyo",
"source": "current_session",
"updated_at": "2026-06-07T12:00:00+08:00"
}
Prompt 组装时必须以 Session State 为准。
Redis 临时事实缓存
对需要短时间内立即生效、但又不想阻塞主链路的状态,可以写一份临时缓存:
key: agent:user:{user_id}:pending_facts
ttl: 5min
读取长期记忆时,合并顺序为:
Redis pending_facts > Session State > MySQL facts > ES / Vector
当 MQ 消费成功并写入数据库后,消费者再清理这份临时缓存。
关键数据同步写
不是所有记忆都应该异步。
像严重过敏史、手机号、安全限制、支付相关偏好这类零容错数据,应该同步写入强事实库,写成功后再返回或再继续后续流程。这样会增加一点延迟,但可以避免更严重的错误。
乐观锁防止旧数据覆盖新数据
强事实表里需要版本号:
UPDATE user_memory_fact
SET fact_value = :new_value,
version = version + 1,
updated_at = NOW()
WHERE user_id = :user_id
AND fact_type = :fact_type
AND fact_key = :fact_key
AND version = :old_version;
如果更新行数为 0,说明数据已经被其他请求改过。消费者要重新读取最新数据,再决定是否重试或丢弃旧事件。
进阶优化:关系、过滤、离线画像和生命周期
基础架构稳定后,还可以继续补几类能力。
图数据库保存实体关系
关系型数据库适合保存单个用户的事实,但多实体关系会变复杂。
例如:
用户 A 的母亲是 B。
B 对海鲜过敏。
A 下周要和 B 一起去东京。
当用户 A 让 Agent 推荐餐厅时,系统要知道同行人 B 也有饮食禁忌。这类关系可以放到图数据库里:
graph LR
A[用户A] -- 母亲 --> B[用户B]
A -- 同行 --> T[东京旅行]
B -- 过敏 --> S[海鲜]
图数据库更适合做关系遍历,再把结果变成约束写入 Prompt。
标量过滤再做向量检索
向量检索必须限制范围。推荐顺序是:
按 user_id 过滤
按 memory_type 过滤
按时间范围过滤
按业务标签过滤
再做向量相似度检索
这样可以避免把其他用户、其他场景、过期记忆召回进来。
实时链路和离线链路分工
实时链路负责明确表达的新事实,例如“我不吃海鲜”“我的目的地改成东京”。
离线链路适合从大量历史行为里挖掘隐性偏好,例如用户长期更喜欢简洁回答、经常选择预算型酒店、常在工作日晚上咨询某类问题。
两条链路不要混在一起:
| 链路 | 数据来源 | 更新频率 | 适合内容 |
|---|---|---|---|
| 实时链路 | 当前对话事件 | 秒级到分钟级 | 明确事实、当前状态 |
| 离线链路 | 历史行为、对话日志 | 小时级到天级 | 隐性偏好、统计画像 |
记忆生命周期分级
不是所有记忆都应该永久保存。
| 记忆类型 | 建议生命周期 |
|---|---|
| 当前会话状态 | 会话结束后过期 |
| 临时计划 | 任务完成后过期 |
| 用户明确事实 | 长期保存,允许用户修改 |
| 模糊偏好 | 定期衰减或重新计算 |
| 低价值闲聊 | 不保存 |
长期记忆越多,召回成本和冲突概率越高。记忆系统不只要会“记住”,也要会“遗忘”。
落地检查清单
Agent 记忆系统可以按这份清单设计:
| 模块 | 关键设计 |
|---|---|
| 短期记忆 | Redis 保存最近对话流和 Session State,两层分离 |
| 长期强事实 | MySQL / MongoDB 保存,确定性查询,最高优先级 |
| 历史文本 | Elasticsearch 保存,用关键词和 BM25 召回 |
| 语义记忆 | 向量数据库保存,只做补充,必须加标量过滤 |
| 关系记忆 | 图数据库保存多实体关系 |
| 读链路 | 多路并发召回,合并去重,按优先级组装 Prompt |
| 写链路 | MQ 异步更新,轻量分类后按类型落盘 |
| 成本控制 | 避免每轮大模型总结,只在状态变更时触发记忆更新 |
| 一致性 | Session State 优先、Redis 临时缓存、关键数据同步写、版本号乐观锁 |
| 生命周期 | 按记忆类型设置 TTL、衰减和删除策略 |
一句话概括:Agent 记忆不是一个向量库问题,而是一个后端状态管理问题。
LLM 负责推理和生成,Redis、数据库、搜索引擎、向量库、消息队列负责记忆的读取、更新和一致性控制。把模型当成计算节点,而不是存储节点,Agent 才能在真实业务里稳定工作。