先说结论
大模型本身是无状态 的。它不是"记性差",而是根本没有记忆------每次请求对它来说都是第一次见面。
所以我们说的 Agent Memory,本质上只解决一个问题:让一个无状态的模型,在多轮、多实例、长时间跨度的对话里表现得像"记得"。
我的分层方案很简单:
markdown
本轮内存 messages → Redis(短期记忆) → PostgreSQL / pgvector(长期记忆)
一次请求内 当前会话 跨会话、用户画像
一句话记住:Redis 管"这次聊到哪了",PostgreSQL 管"这个人是谁"。
一、为什么短期记忆一定要用 Redis
先看短期记忆到底要存什么:用户上句说了什么、当前任务走到第几步、刚才那个工具返回了什么。它的特点是------读写极其频繁、只在会话期间有效、过期就该消失。
这三点几乎是为 Redis 量身定做的:
| 需求 | Redis 的对应能力 |
|---|---|
| 会话闲置后自动清理 | TTL,不用写任何定时任务 |
| 多实例共享同一份上下文 | 所有 Agent 实例连同一个 Redis |
| 高频读写、低延迟 | 内存数据库,亚毫秒级 |
| 服务重启不丢会话 | AOF / RDB 持久化 |
被忽略的一点:负载均衡会"杀死"本地记忆
如果你的会话记忆存在进程内存里,那一旦上了多实例,问题立刻出现:
css
http {
upstream backend {
server 192.168.1.10:8081;
server 192.168.1.11:8081;
}
}
用户第一句话打到 10 号机器,第二句话被轮询分到 11 号机器------上下文直接断片。这时候有人会想到用 IP 哈希:
- 轮询:请求挨个分给服务器
- 加权轮询:机器强的多接点活
- 最少连接:挑现在活最少的
- IP 哈希:同一个用户一直分到同一台
IP 哈希确实能"粘住"会话,但它是把状态绑死在机器上:那台机器一挂,用户的会话就永久丢了;扩容缩容时还会大面积重新分配。这是典型的用负载均衡策略去补架构漏洞。
正确的解法是把状态外置------会话数据放 Redis,任何一台机器都能接住任何一次请求。这也是我坚持短期记忆不落本地内存的原因。
二、Redis 七种数据结构,分别对应 Agent 的什么场景
很多人对 Redis 的印象停留在"缓存字符串"。实际上它的七种结构,在 Agent 场景里几乎各有各的位置。下面这段是 redis-test.mjs 里的速查代码,我按场景重新梳理了一遍。
1. String ------ 会话摘要、Token、计数器、分布式锁
csharp
await redis.set('name', 'lgl');
await redis.set('code', '6666', 'EX', 300); // 5 分钟有效的验证码
console.log(await redis.get('name'));
适用:验证码、Token、登录会话、计数器、配置项、对话摘要文本。
字符串的杀手锏是原子操作:
csharp
await redis.incr('counter:article:1024'); // 阅读数 +1
await redis.decr(...);
await redis.incrby('key', 5);
以及用一条命令实现分布式锁:
c
const lockKey = 'lock:order:key';
const lockResult = await redis.set(lockKey, 'locked', 'NX', 'EX', 10);
console.log('分布式锁:', lockResult ? '加锁成功' : '加锁失败');
SET key value NX EX 10 的含义是:只有 key 不存在时才写入,并设置 10 秒过期。写入和过期是原子的,不会出现"加了锁但没设过期"导致死锁。Agent 里做"同一用户同时只允许一个会话在跑"这类互斥,用它就够了。
2. Hash ------ 会话元信息
javascript
await redis.hset('user:1011', 'name', '李四', 'age', 28);
console.log(await redis.hgetall('user:1011'));
适用:用户信息、商品资料、购物车、结构化的对话上下文。相比把整个 JSON 塞进 String,Hash 能单独读改某个字段,不用整存整取。
常用命令:hset / hmset / hget / hgetall / hkeys / hvals / hincrby。
3. List ------ 聊天历史、任务队列
javascript
await redis.rpush('task:list', '任务1', '任务2');
await redis.rpush('task:list', '任务3');
console.log(await redis.lrange('task:list', 0, -1));
适用:消息队列、任务队列、操作日志、有序的聊天记录 。双向列表,左边 lpush、右边 rpush,lrange key 0 -1 取全部,llen 看长度。
4. Set ------ 去重、标签、权限
javascript
await redis.sadd('tag:set', 'redis', 'nset', 'node', 'nest');
console.log(await redis.smembers('tag:set'));
适用:数组去重、每日签到、IP 黑名单、共同好友、权限标签。sinter 交集、sunion 并集、sdiff 差集------做"用户有哪些能力"这类判断非常顺手。
5. ZSet ------ 排行榜
javascript
await redis.zadd('score:rank', 99, '小明', 95, '小红');
console.log(await redis.zrevrange('score:rank', 0, -1));
适用:各类排行榜、内容热度、用户积分、带权重的任务队列 。zscore 取分数、zrank 取排名。做 Agent 的"记忆重要性排序"时,ZSet 比 List 更合适------你可以按权重取 TopN。
6. Bitmap ------ 二进制状态
签到、活跃用户统计这类"是/否"的海量标记,一个 bit 表示一天,几百万用户也只占几百 KB。
7. Geo ------ 地理位置
vbnet
geoadd key 经度 纬度 名称
geodist key 名称1 名称2 km
适用:附近门店、附近的人、两地距离。做"附近有什么"的工具型 Agent 时,直接嵌进工具函数即可。
小结:String 存摘要、Hash 存画像、List 存历史、ZSet 排重要性。选错结构不会报错,但会让后面每一步都别扭。
三、正式落地:把短期记忆写进 Redis
3.1 先起环境
用 Docker Compose 起来,顺手把 RedisInsight 也带上,可视化看 key 会省很多时间:
yaml
services:
redis:
image: redis:7-alpine
container_name: agent_redis
restart: always
ports:
- "6380:6379" # 宿主机 6380,避免和本机已有 Redis 冲突
volumes:
- ./volumes/redis:/data
command: redis-server --appendonly yes # 开启 AOF
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
redisinsight:
image: redis/redisinsight:2.50
container_name: redis_insight
ports:
- "5540:5540"
注意 --appendonly yes。短期记忆虽然"短期",但用户会话不能因为容器重启就凭空消失,AOF 是底线。
对应 .env:
ini
REDIS_HOST=localhost
REDIS_PORT=6380
REDIS_DB=0
MEMORY_KEY_PREFIX=agent:short_memory
MEMORY_TTL_SECONDS=1800
3.2 Key 的设计:一行字符串决定后面所有事
javascript
messagesKey(sessionId) {
return `${this.keyPrefix}:${sessionId}:messages`;
}
agent:short_memory:demo_user_001:messages。这个命名规则看似随意,实际包含了三个工程决策:
- 前缀隔离:以后接长期记忆、缓存、限流,都靠前缀区分,不会互相踩
- sessionId 在中间:多租户天然隔离,也方便按 session 排查
:messages后缀 :同一个会话以后还能扩:summary、:meta,互不干扰
3.3 RedisMessageStore:四个方法就够了
kotlin
import {
mapChatMessagesToStoredMessages,
mapStoredMessagesToChatMessages,
} from '@langchain/core/messages';
class RedisMessageStore {
constructor({ redis, keyPrefix, ttlSeconds }) {
this.redis = redis;
this.keyPrefix = keyPrefix;
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 saveMessages(sessionId, messages) {
const payload = JSON.stringify(mapChatMessagesToStoredMessages(messages));
await this.redis.set(this.messagesKey(sessionId), payload, 'EX', this.ttlSeconds);
}
async clear(sessionId) {
await this.redis.del(this.messagesKey(sessionId));
}
async ttl(sessionId) {
return this.redis.ttl(this.messagesKey(sessionId));
}
}
这里有两个容易做错的地方:
第一,别自己手写 { role, content } 序列化。 LangChain 的消息不只是 role + content,还带着 tool_calls、tool_call_id、additional_kwargs。你自己拼一套,一旦接了工具调用,回读时就会丢信息、导致模型行为异常。mapChatMessagesToStoredMessages 就是官方提供的无损映射。
第二,整个 messages 数组存成一个 JSON 字符串,而不是用 List 逐条 push。
这看起来"不够 Redis"。但实际权衡是:
- 用 List 逐条存:省流量、能
lrange分页,但读出来的是一堆裸文本,结构信息全丢 - 用 String 存整体 JSON:一次
get拿到完整结构,写也是一次set,原子性天然成立
会话消息量通常在几十条这个量级,整体读写的成本可以忽略。结构保真比省那几百字节重要得多。 等真的到了需要分页的量级(几千条),再考虑 List + 分段,那时你已经该做长期记忆了。
3.4 接进 Agent 的调用闭环
javascript
async function invokeWithMemory(agent, store, sessionId, userText) {
// 1. 先从 Redis 捞出历史
const history = await store.loadMessages(sessionId);
// 2. 历史 + 本轮输入,一起交给 Agent
const result = await agent.invoke(
{ messages: [...history, new HumanMessage(userText)] },
{ recursionLimit: 30 }
);
// 3. 把最新完整 messages 写回 Redis
await store.saveMessages(sessionId, result.messages);
return result;
}
读 → 拼 → 调 → 写,四步。整个"记忆"就是这么来的------模型依然无状态,是我们在每次请求前后帮它把上下文搬来搬去。
注意第 3 步写回的是 result.messages(包含本轮 AI 回复的完整数组),不是只 append 用户那句。这样下次读到的历史才是完整的对话。
四、上下文会膨胀,所以必须做压缩
到这里有个隐患:消息只增不减。聊到第 50 轮,你每次都把 100 条消息整个塞给模型------token 超限、成本翻倍、首字延迟变长。
解决方案是摘要 + 截断 ,我用的是 summarizationMiddleware:
php
const agent = createAgent({
model,
tools: [],
systemPrompt: '你是会话助手,记住用户提到的关键事实,中文简短回答。若消息中有对话摘要,请据此对话。',
middleware: [
summarizationMiddleware({
model,
summaryPrompt,
trigger: { messages: 8 }, // 消息数到 8 条就触发压缩
keep: { messages: 4 }, // 压缩后保留最近 4 条原文
})
]
});
两个参数要理解清楚:
trigger: { messages: 8 }------ 单个对象里的多个字段是 AND 关系;写成数组则是 OR ,比如trigger: [{ tokens: 5000, messages: 3 }, { tokens: 3000, messages: 6 }]。keep: { messages: 4 }------ 压缩后保留最近 4 条原文,更早的内容被折叠成一段摘要。
框架默认值其实是 keep 20 条、trim 4000 tokens,对小模型或本地调试来说太宽松了,所以我把阈值调小,方便观察行为。
摘要 prompt 的质量,直接决定"记忆"有没有失真:
markdown
const summaryPrompt = `
你是对话摘要助手。请用中文总结以下对话,包含:
1. 讨论的主要话题
2. 用户提到的重要事实(姓名、偏好、日期等,务必保留原文信息)
3. 继续对话所需的关键上下文
保持简洁、不要编造、不要遗漏用户明确说过的信息。
待摘要的对话:
{messages}
摘要:`
最关键的一句是 "用户提到的重要事实,务必保留原文信息" 。默认摘要很容易把"我叫李四"抽象成"用户介绍了自己",那就等于记忆丢了。凡是姓名、偏好、日期、金额这类实体,必须让它原样保留。
金句:压缩的目标不是变短,而是"丢掉的都是可以丢的" 。
五、踩过的五个坑(都是真实代价)
这部分是我调试这段代码时实际遇到的,比功能本身更值得记。
坑 1:Number(undefined) ?? 6379 得到的是 NaN
ini
const REDIS_PORT = Number(process.env.REDIS_PORT) ?? 6379;
我原本以为这是"读环境变量,没配就用 6379"。实际上 Number(undefined) 是 NaN,而 ?? 只判断 null / undefined------NaN 会被原样放过。于是没配端口时,你传给 ioredis 的是 NaN。
正确写法是先取值再判空:Number(process.env.REDIS_PORT ?? 6379)。
??不是"兜底一切异常值",它只兜 null 和 undefined。
坑 2:baseURL 拼错一个字母,程序"卡住" 90 秒
OPENAI_BASE_URL 我把 dashscope 写成了 datascope。域名根本不存在,但程序不是立刻报错,而是:
- 请求发不出去
- SDK 默认
maxRetries: 2,带指数退避重试 - 我实测等了 93 秒 才抛出
Connection error
用户视角就是"输入完回车,什么都不发生"。而换成正确域名后,同样的调用 745ms 就返回了。
排查经验: "卡住"先看网络层,别急着怀疑业务逻辑。 顺手给模型加超时和重试上限:
php
new ChatOpenAI({
timeout: 30000,
maxRetries: 2,
// ...
})
坑 3:空 catch 把真相全吞了
csharp
} catch (err) {
} finally {
rl.close();
}
这是我最初写的。异常被完全吞掉,控制台干干净净一行不输出,直接静默退出。你永远看不到那个 Connection error。
哪怕只加一行 console.error(err),也能省掉我半小时。 空 catch 是调试阶段的头号杀手。
坑 4:TTL 永远不会"倒计时"
kotlin
await this.redis.set(key, payload, 'EX', this.ttlSeconds);
看起来没问题,但 SET ... EX 是每次覆盖过期时间。只要用户一直在说话,TTL 就永远是 1800。所以我打印出来的"记忆过期时间:1799 秒"永远是 1799 秒,跟"距离上次对话多久"毫无关系。
这就是滑动过期(sliding TTL) :活跃会话永不过期,闲置 30 分钟才清理。它适合"用户还在聊就别忘了",但如果你想要的是"会话创建后 2 小时强制失效",那就得改用绝对过期------比如把创建时间写进 Hash 的元信息,由业务层判断。
先想清楚你要哪种语义,再写那行 EX。
坑 5:ioredis 断线时,await 会一直挂着
ioredis 默认 enableOfflineQueue = true:Redis 断开时,命令不报错,而是排队等你重连 。于是 await store.loadMessages() 会安静地挂在那里,表现又是"卡住"。
如果要的是快速失败,就显式关掉它:
yaml
new Redis({
host: REDIS_HOST,
port: REDIS_PORT,
enableOfflineQueue: false, // 断线立即抛错,而不是排队
maxRetriesPerRequest: 3,
})
这两条坑连起来看就很有意思:Agent 的"卡住"通常有两个来源------上游模型连不上,下游存储不响应。都是异步在等,都不报错。
六、上生产前,我还会补这几件事
- Redis 客户端单例化 :每个请求
new Redis()会把连接数打爆,进程内复用一个实例即可。 - 序列化别存全量:工具调用返回的原始 JSON 可能几千字,存进记忆既费内存又污染上下文。落库前做截断,或只保留摘要字段。
- 别用
KEYS *:它是 O(N) 阻塞操作。要遍历用SCAN。 - 多租户前缀 :
agent:short_memory:{tenantId}:{sessionId}:messages,别让不同业务共用命名空间。 - 监控两件事 :Redis 内存使用量和慢查询日志,以及每个会话的
messages.length------如果它长期贴着 trigger 线,说明压缩阈值配得不合适。 - 明确"短期"到底多短:TTL 是产品决策,不是技术细节。客服场景可能是 2 小时,编程助手可能是 7 天。
七、收个尾
整个方案其实只有三句话:
- Redis 管当前会话:TTL 自动清理,多实例共享,亚毫秒读写
- 中间件管上下文长度:到阈值就摘要,最近的原文留住
- PostgreSQL 管跨会话:用户偏好、长期目标,交给 Mem0 这类方案做向量检索
模型永远无状态,记忆是我们一层层搭出来的。
金句:别指望模型"记住",要设计让它可以被提醒。
代码都在 redis-test 目录:数据结构速查看 redis-test.mjs,短期记忆完整实现看 agent-with-redis-memory.mjs,环境用 docker-compose.yaml 一键起。
下一篇准备写长期记忆:怎么把每轮消息落进 PostgreSQL,再用 pgvector 做语义召回------感兴趣可以先动手试试。