别再全量塞历史了:用 Redis 给 Agent 装上「短期记忆」

先说结论

大模型本身是无状态 的。它不是"记性差",而是根本没有记忆------每次请求对它来说都是第一次见面。

所以我们说的 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。这个命名规则看似随意,实际包含了三个工程决策:

  1. 前缀隔离:以后接长期记忆、缓存、限流,都靠前缀区分,不会互相踩
  2. sessionId 在中间:多租户天然隔离,也方便按 session 排查
  3. :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。域名根本不存在,但程序不是立刻报错,而是:

  1. 请求发不出去
  2. SDK 默认 maxRetries: 2,带指数退避重试
  3. 我实测等了 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 的"卡住"通常有两个来源------上游模型连不上,下游存储不响应。都是异步在等,都不报错。


六、上生产前,我还会补这几件事

  1. Redis 客户端单例化 :每个请求 new Redis() 会把连接数打爆,进程内复用一个实例即可。
  2. 序列化别存全量:工具调用返回的原始 JSON 可能几千字,存进记忆既费内存又污染上下文。落库前做截断,或只保留摘要字段。
  3. 别用 KEYS * :它是 O(N) 阻塞操作。要遍历用 SCAN。
  4. 多租户前缀 :agent:short_memory:{tenantId}:{sessionId}:messages,别让不同业务共用命名空间。
  5. 监控两件事 :Redis 内存使用量和慢查询日志,以及每个会话的 messages.length------如果它长期贴着 trigger 线,说明压缩阈值配得不合适。
  6. 明确"短期"到底多短:TTL 是产品决策,不是技术细节。客服场景可能是 2 小时,编程助手可能是 7 天。

七、收个尾

整个方案其实只有三句话:

  • Redis 管当前会话:TTL 自动清理,多实例共享,亚毫秒读写
  • 中间件管上下文长度:到阈值就摘要,最近的原文留住
  • PostgreSQL 管跨会话:用户偏好、长期目标,交给 Mem0 这类方案做向量检索

模型永远无状态,记忆是我们一层层搭出来的。

金句:别指望模型"记住",要设计让它可以被提醒。

代码都在 redis-test 目录:数据结构速查看 redis-test.mjs,短期记忆完整实现看 agent-with-redis-memory.mjs,环境用 docker-compose.yaml 一键起。

下一篇准备写长期记忆:怎么把每轮消息落进 PostgreSQL,再用 pgvector 做语义召回------感兴趣可以先动手试试。

相关推荐
初学AI的小高12 分钟前
Agent 工具太多怎么办:BM25 检索、依赖补全与按需加载实战
后端·agent
发现你走远了1 小时前
05-权限不是开关:工具调用如何在放行、拦截与追问之间做决定
人工智能·agent·claude
泯泷1 小时前
RL 训练真正难的不是算法:环境、奖励与样本效率
llm·agent
威哥爱编程4 小时前
模拟面试智能体开发实战 07:答完一题就点评一题,结构化输出给分建议,让每道题都变成复盘
python·agent·harmonyos
威哥爱编程4 小时前
模拟面试智能体开发实战 08:总评报告先过三道闸,防泄题防夸大防错建议,才敢发到候选人手上
python·agent·harmonyos
lucas_AI4 小时前
Gemini 4 Hydrogen:发布即全量,但 Flash 的低价时代结束了
agent
lucas_AI4 小时前
Termination Lag:你的 Coding Agent 早就修好了 Bug,只是它停不下来
agent·ai编程
Csvn15 小时前
每请求到底多少钱?把 Token 成本算成可观测指标(C01)
人工智能·aigc·agent
我不会起名字32216 小时前
会话缓存失效排查:区分 Redis 失效与节点广播
redis·后端·go·即时通讯·im·故障排查