背景与痛点
多轮对话是 Chatbot 的“灵魂”,但上下文管理却是“体力活”。早期我把对话历史全塞进进程内存,结果:
- 用户量一上来,内存像吹气球,4 核 8 G 的机器 3 000 并发就 OOM
- 检索靠暴力遍历,平均响应 600 ms,体验堪比 2 G 时代
- 多实例部署时,WebSocket 粘到 A 节点,下一条消息却落到 B,上下文瞬间“失忆”
痛定思痛,必须把状态外置,让无实例可横向扩展,同时把毫秒级延迟压到两位数。
技术选型对比
| 维度 | Redis | MongoDB | 向量数据库 |
|---|---|---|---|
| 延迟 | 内存级,P99 < 5 ms | 磁盘+索引,P99 20-40 ms | 依赖 ANN 算法,P99 10-30 ms |
| 并发 | 单线程事件循环,10 w QPS 轻松 | 需分片,QPS 随片数线性 | 与向量维度正相关,高维会掉 |
| 数据结构 | 哈希、ZSET、Stream 原生支持 | 文档嵌套,需二次索引 | 只存向量+ID,对话原文需外挂 |
| 容量 | 受内存限制,>32 G 成本陡增 | 磁盘友好,TB 级 | 同左,但需 GPU 加速才划算 |
| 运维 | 主从+哨兵即可 | 分片+副本集,复杂度高 | 新增 IVF/PQ 调参,门槛最高 |
结论:
- 纯对话缓存 → Redis,速度就是生产力
- 冷数据归档 → MongoDB,省内存
- 语义召回 → 向量库做外挂检索,不放在主链路上
下文聚焦 Redis,把“热缓存”做到极致。
核心实现
数据模型
1 条对话 = 1 个 Hash + 1 个 ZSET 成员
- Hash Key:
chat:{uid}:ctxturn:{seq}→ 本轮 JSON(含 role、content、ts)last→ 最新 seq,用于原子递增
- ZSET Key:
chat:{uid}:idx- Member =
{seq},Score = 时间戳
作用:按时间范围批量拉取,O(logN+M)
- Member =
过期策略
- 每写 Hash 时同步
EXPIRE 3600(1 h 滑动窗口) - 兜底:Redis 4.0 以上开启
lazyfree避免 del 阻塞
Python 代码(aioredis 2.x)
import asyncio, json, time, uuid from aioredis import Redis class ContextManager: def __init__(self, redis: Redis, ttl: int = 3600): self.r = redis self.ttl = ttl async def add_turn(self, uid: str, role: str, text: str): """原子写入一轮对话,返回自增序号""" key_h = f"chat:{uid}:ctx" key_z = f"chat:{uid}:idx" seq = await self.r.hincrby(key_h, "last", 1) ts = int(time.time()) payload = {"role": role, "content": text, "ts": ts} pipe = self.r.pipeline() pipe.hset(key_h, f"turn:{seq}", json.dumps(payload)) pipe.zadd(key_z, {str(seq): ts}) pipe.expire(key_h, self.ttl) pipe.expire(key_z, self.ttl) await pipe.execute() return seq async def get_window(self, uid: str, limit: int = 10): """拉取最近 limit 轮,按时间正序""" key_h = f"chat:{uid}:ctx" key_z = f"chat:{uid}:idx" # 1. 从 ZSET 倒序取 limit 个 seq seq_list = await self.r.zrevrange(key_z, 0, limit - 1) if not seq_list: return [] # 2. 批量取 Hash fields = [f"turn:{s.decode()}" for s in reversed(seq_list)] items = await self.r.hmget(key_h, *fields) return [json.loads(i) for i in items if i] async def rollback(self, uid: str, n: int = 1): """撤回 n 轮,用于“说错了”场景""" key_h = f"chat:{uid}:ctx" key_z = f"chat:{uid}:idx" seq_list = await self.r.zrevrange(key_z, 0, n - 1) if not seq_list: return 0 pipe = self.r.pipeline() for seq in seq_list: pipe.hdel(key_h, f"turn:{seq.decode()}") pipe.zrem(key_z, seq) await pipe.execute() return len(seq_list)异常处理:
- 所有
await包在try/except里捕获ConnectionError,降级返回空列表,不让单点故障穿透到业务层 - 写入失败重试 2 次,仍失败则抛自定义
CtxFullError,上层转文字提示“记忆已满,请 /clear”
性能优化
- 异步 I/O:全链路基于
asyncio,QPS 从 6 k 提到 3 w - 批处理:一次性
hmget50 条,减少 RTT 往返 - Pipeline:上面代码已用,单次往返完成 4 条命令,延迟 1 → 0.25 ms
- 索引优化:ZSET 只存 seq+ts,不存原文,内存降 40 %
- 本地缓存:对 1 s 内重复读取加
lru_cache,命中率 60 %,进一步压掉 30 % Redis 负载
基准(单机 4 核 16 G,Redis 7.0 容器限制 2 G):
- 10 并发线程,各 100 轮对话,平均写入 1.2 ms,读取 0.8 ms
- CPU 占用 38 %,内存 480 MB,网络 IO 12 MB/s
- 同比 MongoDB 方案,延迟下降 70 %,CPU 降 25 %
避坑指南
- 缓存雪崩:给 TTL 加随机 jitter(±300 s),避免同一时刻大面积失效
- 热 key 倾斜:高频用户 seq 增长快,ZSET 长度 >5 k 时,定期
ZREMRANGEBYRANK只保留最近 2 k - 上下文丢失:WebSocket 断线重连时带
last_seq参数,后端对比本地last,缺哪段补哪段 - 大 Value:单轮 >8 k 字符(语音转写常出现)启用压缩
zlib,再落盘,节省 60 % 内存 - 版本升级:Redis 升级先做
redis-check-aof,避免旧 RDB 与新版本不兼容导致启动失败
总结与思考
把上下文做成“热缓存 + 冷归档 + 语义召回”三级梯队后,多轮对话的吞吐和延迟都进入舒适区。但故事没完:
- 上下文压缩:把 50 轮摘要成 3 句,再喂给 LLM,可减少 80 % token,同时降低延迟
- 个性化向量:用用户历史微调小模型,生成专属向量,语义检索时 Top-1 命中率从 82 % → 93 %
- 边缘计算:在离用户最近的 CDN 节点跑轻量 Redis,写入回源,读取本地,全球延迟 <50 ms
如果你也想亲手搭一个能听、会想、会说的 AI 伙伴,不妨从实战营开始——从0打造个人豆包实时通话AI 把 ASR→LLM→TTS 整条链路拆成 7 个可运行模块,配好环境变量就能跑通。我跟着敲了一遍,本地 30 分钟就把“语音进、语音出”跑通,比自己零散查文档省太多时间。小白也能顺利体验,建议先把 Redis 缓存思路用上,再替换自己的对话逻辑,一套代码两种收获。