一、Redis 是什么
Redis 是一个高性能的键值型内存数据库,数据默认放在内存里,读写延迟通常在亚毫秒级,是后端最常用的缓存与中间件之一。
它有三个区别于普通数据库的特点:
- 内存存储------没有磁盘寻道,单次读写微秒级;
- 数据结构丰富------不只是字符串,还有 Hash、List、Set、ZSet 等;
- 单线程执行命令 ------每条命令天然原子,不用加锁就能保证
INCR、SADD的正确性。
因为它快、能设过期、能被多个服务实例共享,所以特别适合高频读写、且生命周期有限的数据。
二、为什么 Redis 适合做 Agent 短期记忆
大模型本身是无状态的,跨请求不会记得你说过什么。想让它"记住",必须由外部存储来保存上下文。常见分层是:
本轮内存 message → Redis(短期记忆) → PostgreSQL / 向量库(长期记忆)
短期记忆和长期记忆的分工:
| 短期记忆 | 长期记忆 | |
|---|---|---|
| 解决什么 | 当前会话说了什么、任务进行到哪一步 | 跨会话的偏好、长期目标、值得沉淀的历史信息 |
| 存储 | Redis | PostgreSQL + 向量检索(如 Mem0) |
| 关键需求 | 快、可过期、多实例共享 | 可检索、可语义匹配、长期保留 |
Redis 的四项特性正好对上短期记忆的需求:
- TTL(Time To Live)------会话闲置一段时间自动清理,不用自己写定时任务;
- 多实例共享------所有 Agent 实例连同一个 Redis,负载均衡打到哪台机器都能读到同一份上下文;
- 丰富的数据结构------String 存摘要,Hash 存会话元信息,List 直接存对话列表;
- 持久化------AOF/RDB 保证重启后数据不丢。
还有一个绕不开的问题:上下文窗口有限。不可能每次把完整聊天记录塞给模型,否则 token 超限、成本上升、延迟变长。所以工程上要配合上下文截断、摘要压缩和相关记忆检索,只把"当前真正需要的那部分"组装进 Prompt。
三、连接 Redis
1. 用 Docker 起一个
yaml
services:
redis:
image: redis:7-alpine
container_name: agent_redis
command: redis-server --appendonly yes
ports:
- "6380:6379" # 宿主机 6380 → 容器 6379
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- common-network
networks:
common-network:
name: common-network
注意端口的方向:容器内永远是 6379,6380 只是宿主机映射出来的门牌号 。宿主机程序连 localhost:6380,容器之间互连则用 redis:6379。
2. 用 ioredis 连接(Node.js)
js
import Redis from 'ioredis';
const redis = new Redis({
host: 'localhost',
port: 6380,
db: 0,
});
redis.on('connect', () => console.log('链接成功'));
redis.on('error', (err) => console.log('链接失败', err));
async function runRedisDemo() {
await redis.set('name', '文强');
await redis.set('code', '666666', 'EX', 300); // 5 分钟后过期
console.log('String name:', await redis.get('name'));
await redis.hset('user:1011', 'name', '李四', 'age', 28);
console.log('Hash user:', await redis.hgetall('user:1011'));
await redis.quit(); // 关键:主动断开,否则进程不退出
}
runRedisDemo();
这三个点最容易踩:
- 漏写
redis.quit():ioredis 会保持长连接,Node 认为还有待处理事件,脚本跑完也不退出,看起来像"卡死"。 - 多写一个
password:只有服务端配了requirepass才需要。没配却传 password,会报ERR AUTH called without any password configured。 - 用
npm run跑文件 :npm run只能跟package.json里 scripts 定义的名字,直接跑脚本要用node src/redis-test.mjs。
四、五种核心数据类型
Redis 的 key 只是名字,类型绑定在 key 上 ,第一次写入时确定,之后不能改;用错命令会报 WRONGTYPE。类型决定了底层结构和命令复杂度,所以选型本质是"看你怎么读"。
1. String ------ 一个变量
最简单的类型,一个 key 对应一个值(文本或数字,二进制安全)。
场景:验证码、token、登录会话、计数器、配置项、文章阅读数、文本类短期记忆(摘要)。
redis
SET code:13800138000 666666 EX 300 -- 验证码,5 分钟过期
GET code:13800138000
INCR counter:article:1024 -- 阅读数 +1,原子
DECR counter:article:1024
INCRBY counter:article:1024 10 -- 按步长增加
SET lock:order:2001 "locked" NX EX 10 -- 简易分布式锁
ioredis:
js
await redis.set('code:13800138000', '666666', 'EX', 300);
await redis.get('code:13800138000');
await redis.incr('counter:article:1024');
要点 :EX 单位是秒(PX 是毫秒);NX 表示"不存在才写",配合 EX 就是最常用的锁写法。
2. Hash ------ 一个对象
字段 → 值的映射,相当于一个 Python dict。字段少时会用紧凑编码,比拆成多个 String key 省内存。
场景:用户信息、商品资料、购物车、会话元信息、结构化的对话上下文。
redis
HSET user:1011 name 李四 age 28 -- 一次写多个字段
HMSET user:1011 name 李四 age 28 -- 老写法,效果同上
HGETALL user:1011 -- 取全部字段和值
HKEYS user:1011 -- 只取字段名
HVALS user:1011 -- 只取值
HINCRBY user:1011 age 1 -- 某个字段自增
ioredis:
js
await redis.hset('user:1011', 'name', '李四', 'age', 28);
const user = await redis.hgetall('user:1011'); // { name: '李四', age: '28' }
console.log(user.name);
要点 :hgetall 返回的值全是字符串 ,age 拿到的是 '28' 而不是 28,需要自己转换。
3. List ------ 一个队列 / 栈
双向链表,有序、允许重复,两端插入极快。
场景:消息队列、任务队列、操作日志、聊天历史、按时间追加的记录。
redis
LPUSH chat:1001 "你好" -- 从左边插入(新的在前)
RPUSH chat:1001 "在吗" -- 从右边插入(新的在后)
LRANGE chat:1001 0 -1 -- 取全部,-1 表示最后一个
LRANGE chat:1001 0 9 -- 取最近 10 条
LPOP chat:1001 -- 左边弹出
RPOP chat:1001 -- 右边弹出
LLEN chat:1001 -- 长度
要点 :LRANGE key 0 9 是最常用的"取最近 N 条",前提是写入时用 LPUSH(新消息在头部)。顺序搞反了会拿到最旧的 N 条。
4. Set ------ 一个去重集合
无序、自动去重,支持交集、并集、差集运算,且都在服务端完成。
场景:数组去重、每日签到、IP 黑名单、共同好友、权限标签。
redis
SADD tags:post:1 "python" "redis" -- 添加(重复的会被忽略)
SMEMBERS tags:post:1 -- 取全部成员
SISMEMBER tags:post:1 "redis" -- 判断是否在集合里,O(1)
SINTER tag:a tag:b -- 交集
SDIFF tag:a tag:b -- 差集
SUNION tag:a tag:b -- 并集
ioredis:
js
await redis.sadd('tags:post:1', 'python', 'redis');
const members = await redis.smembers('tags:post:1');
const isMember = await redis.sismember('tags:post:1', 'redis'); // 0 或 1
要点 :SISMEMBER 返回 1/0 而不是 true/false。
5. ZSet ------ 带分数的有序集合
每个成员绑定一个 score,按 score 自动排序。这是 Redis 里唯一"能排序又能快速范围查询"的结构。
场景:各类排行榜、内容热度排序、用户积分排名、按时间排序的记录、延迟队列。
redis
ZADD rank:score 100 "张三" 80 "李四" -- 写入成员和分数
ZRANGE rank:score 0 -1 -- 按分数从小到大取
ZREVRANGE rank:score 0 9 WITHSCORES -- 取前 10 名(分数从高到低)
ZSCORE rank:score "张三" -- 查某个成员的分数
ZREVRANK rank:score "张三" -- 查排名(从 0 开始)
要点 :ZREVRANGE 配合 WITHSCORES 是排行榜的标准写法;把时间戳当 score,就变成"按时间窗口查询"的索引。
6. 另外三种(了解即可)
| 类型 | 用途 | 核心命令 |
|---|---|---|
| Bitmap | 签到打卡、活跃统计,一位一个状态 | SETBIT / GETBIT / BITCOUNT |
| HyperLogLog | 用 12KB 估算上亿 UV,有误差 | PFADD / PFCOUNT |
| GEO | 附近门店、附近用户、两点距离 | GEOADD key 经度 纬度 名称 / GEODIST key A B km |
GEO 的坐标顺序是先经度后纬度,写反了位置会跑到南极。
五、选型速查
| 你的需求 | 选它 | 原因 |
|---|---|---|
| 存一个整体值(JSON、token、计数) | String | 整块读写最直接 |
| 要单独改某个字段 | Hash | 不用读全量再写回 |
| 按顺序追加、取最近 N 条 | List | 两端插入 O(1) |
| 去重、判断存在、集合运算 | Set | 自动去重 + O(1) 查询 |
| 要排序、要排名、按分数范围查 | ZSet | 唯一有序结构 |
| 按位记录状态(签到) | Bitmap | 极省内存 |
| 统计 UV / 附近的人 | HyperLogLog / GEO | 专用算法 |
一句话判断标准:先想"我要怎么读",再选结构,而不是先想"数据长什么样"。
六、Agent 短期记忆的 Key 设计
把上面的结构组合起来,一套可直接落地的命名方案:
text
memory:session:{sid}:messages → List 对话历史,LPUSH + LRANGE 0 19
memory:session:{sid}:meta → Hash user_id、created_at、token 数、当前任务步
memory:session:{sid}:summary → String 压缩后的会话摘要
memory:article:{id}:views → String INCR 阅读数
memory:tags → Set 去重标签
memory:rank:hot → ZSet 热度排行
配套的过期策略:
js
// 写入消息时同步续期,闲置 30 分钟自动清理
await redis.lpush(`memory:session:${sid}:messages`, JSON.stringify(msg));
await redis.expire(`memory:session:${sid}:messages`, 1800);
三个工程建议:
- key 带冒号分层 ,方便
SCAN前缀遍历,也方便 RedisInsight 里按模式筛选; - 每个 key 都设 TTL,短期记忆的价值随时间衰减,不清理会无限膨胀;
- 别用
KEYS *,它会阻塞整个单线程服务,线上用SCAN增量遍历。
七、结语
Redis 的价值不在"快"这一个字,而在把数据结构做成了服务端原语:你在客户端只需发一条命令,排序、去重、取交集、按分数截取全部在服务端完成,只传指令不传数据。
放到 Agent 场景里,它承担的角色很明确------管当前会话的内存:TTL 负责遗忘,数据结构负责组织,多实例共享负责横向扩展。跨会话的长期记忆交给 PostgreSQL + 向量检索,两者拼接成 Prompt,才是完整的记忆系统。