一、要解决的问题
大模型本身是无状态的:每次请求都是全新的,它不记得上一轮你说过什么。而业务往往要求它"记住刚才的话、接着往下聊"。此外还有两个绕不过去的现实:
- 上下文窗口有限。把全部历史每轮都塞进 Prompt,token 成本线性膨胀,延迟也越来越高。
- 服务是多实例的。Nginx 把请求轮流分给多台机器,如果记忆存在进程内存里,用户下一句话被分到另一台机器就"失忆"了。
所以需要一层外部、共享、能自动过期的存储。Redis 正好三样都满足。
二、为什么是 Redis
| 需求 | Redis 的能力 |
|---|---|
| 高频读写、低延迟 | 数据在内存,亚毫秒级 |
| 会话过期自动清理 | TTL / EX,不用自己写定时任务 |
| 多实例共享 | 所有 Agent 服务连同一个 Redis |
| 结构灵活 | String/Hash/List/Set/ZSet 按场景选 |
| 可持久化 | AOF/RDB,重启不丢 |
对应到记忆场景,就一句话:Redis 管当前会话(短期),postgres/向量库管跨会话(长期),把真正需要的信息组装进 Prompt 交给模型。
三、Redis 实例:从建连到销毁
很多人写 Agent 记忆时直接到处 redis.get(),结果连接数爆炸、程序退不出去。正确做法是全程序共用一个客户端实例。
3.1 创建实例
js
import Redis from 'ioredis'
const redis = new Redis({
host: 'localhost',
port: 6380, // 注意:容器 6379 映射到宿主机 6380
db: 0, // 选 0 号库
})
三个要点:
new Redis()创建即连接,不是 lazy 的。这行执行完,连接就已经在建立了。- 构造函数里能配的远不止这些:
password、maxRetriesPerRequest、retryStrategy、enableReadyCheck。没配requirepass就不要传password,否则会报ERR AUTH called without any password configured。 db是逻辑库 ,0~15。同一个 Redis 里不同 db 数据隔离,SELECT切库。生产上建议用 key 前缀做隔离,而不是靠 db。
3.2 事件监听
js
redis.on('connect', () => console.log('Redis 连接成功'))
redis.on('error', (err) => console.log('Redis 连接失败', err))
error必须监听 。ioredis 在连接失败时会 emiterror,Node 的 EventEmitter 对未监听的error事件会直接抛异常终止进程。connect只代表 TCP 通了;真正可用还可以监听ready。- 断线重连是 ioredis 内置的,你不用手动写重试逻辑。
3.3 命令调用:都是 Promise
js
await redis.set('agent:short_memory:demo_user_001:messages', payload, 'EX', 1800)
const raw = await redis.get('agent:short_memory:demo_user_001:messages')
const ttl = await redis.ttl('agent:short_memory:demo_user_001:messages')
await redis.del('agent:short_memory:demo_user_001:messages')
几个容易踩的点:
- 参数是位置参数,不是对象 。
set(key, value, 'EX', 1800)这四个参数就是 Redis 原生命令SET key value EX 1800。别写成{ ex: 1800 }。 EX单位是秒 ,PX是毫秒,NX是"不存在才写",XX是"存在才写"。ttl的返回值:-1表示 key 存在但没设过期,-2表示 key 不存在。- 同一个客户端上的命令会排队串行执行,不需要你自己加锁。
3.4 优雅退出
js
await redis.quit() // 发 QUIT 命令,等已排队的命令执行完再断
// redis.disconnect() // 立刻断,不等
不调用就会导致 Node 进程不退出------因为连接是活跃句柄。这是新手最常遇到的"脚本跑完了但终端卡住"的原因。
补充:
SCAN代替KEYS *。KEYS会阻塞整个 Redis,生产环境禁用;排查用redis-cli --scan --pattern 'agent:short_memory:*'。
四、Store 实例:把 Redis 命令封装成"会话仓储"
直接在各处写 redis.get 会让 key 规则散落一地,改前缀要改十几处。所以抽一个类,只负责消息的读写,不掺业务逻辑。
js
class RedisMessageStore {
constructor({ redis, keyPrefix, ttlSeconds }) {
this.redis = redis // 复用外部连接实例
this.keyPrefix = keyPrefix // 如 'agent:short_memory:'
this.ttlSeconds = ttlSeconds
}
messagesKey(sessionId) {
return `${this.keyPrefix}${sessionId}:messages`
}
async loadMessages(sessionId) {
const raw = await this.redis.get(this.messagesKey(sessionId))
if (!raw) return []
return mapStoredMessagesToChatMessages(JSON.parse(raw))
}
async saveMessage(sessionId, messages) {
const payload = JSON.stringify(mapChatMessagesToStoredMessages(messages))
await this.redis.set(this.messagesKey(sessionId), payload, 'EX', this.ttlSeconds)
}
async ttl(sessionId) {
return this.redis.ttl(this.messagesKey(sessionId))
}
}
const store = new RedisMessageStore({
redis, // 上面创建的那个客户端
keyPrefix: 'agent:short_memory:',
ttlSeconds: 1800,
})
三个方法对应三条 Redis 命令:
| 方法 | 命令 | 语义 |
|---|---|---|
loadMessages |
GET |
key 不存在返回 null → 转成空数组 |
saveMessage |
SET ... EX |
覆盖写,同时续期 1800 秒 |
ttl |
TTL |
看还剩多少秒 |
注意 SET 是整值覆盖 ,不是追加。所以每轮必须把"历史 + 新消息 + 模型回复"的完整数组写回去,漏一条就丢上下文。
为什么用 String 而不是 List? 因为 LangChain 的消息需要整体序列化/反序列化,一个 JSON 数组最直观;用 List 就得 LPUSH/LRANGE 管理下标,还要处理裁剪,反而更复杂。数据量小的会话记忆,String 是最优解。
五、Agent 实例与模型实例
js
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_API_BASE_URL }, // 指向 DeepSeek
temperature: 0,
})
const agent = createAgent({
model,
tools: [],
systemPrompt: '你是会话助手,记住用户提到的事实......',
middleware: [
summarizationMiddleware({
model,
summaryPrompt,
trigger: { messages: 8 },
keep: { messages: 4 },
})
]
})
ChatOpenAI走的是 OpenAI 兼容协议 ,只要把baseURL指向https://api.deepseek.com/v1就能用 DeepSeek。baseURL大小写敏感,写成baseurl会静默失效。createAgent返回的 agent 本身不存状态 。它每次invoke只处理你传进去的messages,处理完就忘。记忆完全靠外部的 Redis 手动 load/save------这既是负担,也是自由度(换存储只改 Store)。
六、一轮对话的完整闭环
js
async function invokeWithMemory(agent, store, sessionId, userText) {
const history = await store.loadMessages(sessionId) // 1. 读
const result = await agent.invoke(
{ messages: [...history, new HumanMessage(userText)] }, // 2. 拼
{ recursionLimit: 30 }
)
await store.saveMessage(sessionId, result.messages) // 3. 写
const ttl = await store.ttl(sessionId) // 4. 查
console.log('过期时间:', ttl)
return result
}
四步各有细节:
- 读 :
GET拿到的是字符串,JSON.parse后还要过mapStoredMessagesToChatMessages才能变回带方法的BaseMessage实例。 - 拼 :历史在前,新的
HumanMessage在后。顺序错了模型会理解成"用户先说了新话"。 - 写 :存的是
result.messages,不是你传进去的数组。因为模型回复也在里面,摘要中间件压缩后的结果也在里面。 - 查 :
TTL每次写都会重置回 1800,这就是"活跃会话永不过期,闲置半小时自动清理"。
七、两处格式转换:为什么不能直接存对象
这是最容易糊涂的地方,记住两个边界:
javascript
ChatOpenAI / Agent ──(mapChatMessagesToStoredMessages)──► 纯数据对象
│
JSON.stringify
▼
Redis 字符串
│
JSON.parse
▼
ChatOpenAI / Agent ◄──(mapStoredMessagesToChatMessages)── 纯数据对象
- 为什么要转 :
HumanMessage是类实例 ,原型链上挂着_getType()、concat()、toDict()等方法。JSON.stringify只序列化可枚举的自有属性 ,方法全部丢失。直接从 Redis 读回来JSON.parse得到的只是{ content, role }这样的普通对象,LangChain 内部调用message._getType()就会报 "not a function"。 - 映射函数干什么 :把实例压平成
{ type: 'human', data: {...} }这种纯数据;反向再把纯数据new回对应的HumanMessage/AIMessage/SystemMessage/ToolMessage。 - 名字是复数 :
mapChatMessagesToStoredMessages(messages → messages),单数形式在 v1 里不存在,写错直接undefined is not a function。
所以 Redis 里存的就是一段 JSON 字符串 ,type 字段只能是 human / ai / system / tool 之一,整体必须是一个数组。手动往 Redis 塞测试数据时格式错了,读回来就会炸。
八、摘要中间件:上下文超长怎么办
Redis 存的全量历史会越来越长,最终还是顶到窗口。summarizationMiddleware 就是自动压缩器:
trigger: { messages: 8 }------ 消息数攒到 8 条触发(键名必须是复数messages,写成message会直接报Invalid summarization middleware options)。keep: { messages: 4 }------ 压缩时保留最近 4 条原文 ,前面的用 LLM 按summaryPrompt压成一段摘要。summaryPrompt里的{message}是占位符,中间件会把待压缩的对话塞进去。
压缩后的结果会作为一条摘要消息混在 result.messages 里,所以第 3 步写回 Redis 时,存进去的已经是"摘要 + 最近 4 条"的精简版。下一轮再读出来,模型看到的就是这段摘要,从而间接记住了更早的事。
这就是"短期记忆 + 自动压缩"的组合价值:Redis 负责不丢,中间件负责不爆。
九、Key 设计与 TTL 策略
推荐的 key 结构:
vbnet
agent:short_memory:{sessionId}:messages → String,JSON 消息数组
agent:short_memory:{sessionId}:meta → Hash,用户信息、创建时间、轮次
agent:long_memory:{userId} → 长期记忆(实际走向量库)
设计原则:
- 前缀分层 :
业务:存储类型:维度:id。这样--scan --pattern 'agent:short_memory:*'能一次捞全某个业务的 key。 - sessionId 必须唯一且可预测(通常是 userId + 会话 id),否则多用户串话。
- TTL 按业务定:客服会话 30 分钟,编程助手可能几小时。宁可短一点,靠长期记忆兜底,也不要无限堆积。
- 多用途就别塞一个 key:元信息用 Hash,历史用 String,各管各的,读取时也省得解析整个大 JSON。
- 不要用 db 做隔离。Redis Cluster 只支持 db 0,靠 db 分环境的代码后面迁移会痛。
十、验证与排查
bash
# 宿主机连(映射到 6380)
redis-cli -p 6380 -n 0 --scan --pattern 'agent:short_memory:*'
# 容器内连(容器里是 6379,别搞混)
docker exec -it agent_redis redis-cli -n 0
> type agent:short_memory:demo_user_001:messages # string
> get agent:short_memory:demo_user_001:messages # 看 JSON
> ttl agent:short_memory:demo_user_001:messages # 剩余秒数
排查清单:
| 现象 | 常见原因 |
|---|---|
WRONGTYPE |
拿 String 的命令操作 Hash/List 等 key |
TTL 返回 -2 |
key 已过期或从没写成功 |
读回来报 _getType is not a function |
少了 mapStoredMessagesToChatMessages |
| 摘要不生效 | trigger 写成了 message(单数) |
| 脚本跑完不退 | 忘了 redis.quit() |
| 数据莫名其妙没了 | 检查是不是设了很短的 EX,或写了 FLUSHDB |
十一、小结
四句话概括:
- Agent 无状态,记忆必须外置 ------ Redis 管会话,向量库管长期。
- Redis 客户端全局单例 ,
new即连接、监听error、退出前quit。 - Store 把
GET/SET EX/TTL封成领域方法,key 分层加唯一 sessionId。 - 消息对象和 JSON 字符串之间必须经过两个 map 函数,否则方法丢失。
再加一条工程经验:历史不会无限增长,一定要有压缩策略 ------trigger + keep 这套参数,就是让 Redis 里的数据始终维持在窗口可承受的范围。