“Redis 已正式接入 AI 了”——这个标题最近在圈子里转得挺多。刚看到时我也愣了一下:Redis 不是做缓存的吗?跟 AI 能搭什么边?后来我把自己的 AI 会话项目里那一层数据逻辑完整梳理了一遍,才反应过来,Redis 早就已经是 AI 链路里绕不开的一环,只不过这次终于被明明白白摆在台面上说了。简单讲,Redis 接入 AI,不是让它帮你写 prompt,也不是让模型去读 Redis 命令,而是把 Redis 变成 AI 应用的高性能记忆层、缓存层和检索层。这篇文章来自我的落地经验,完整讲一遍接入过程中做了什么、怎么配参数、踩了哪些坑。
1. 为什么 Redis 会是 AI 应用的“最佳合伙人”
1.1 大模型应用真正的瓶颈,恰恰是记忆和状态
很多团队推 AI 应用,第一反应是“选模型”。模型确实重要,可一旦把应用推到线上,你会发现真正卡脖子的不是推理质量,而是怎么管理上下文、怎么省钱、怎么处理并发。
拿最常见的对话机器人举例。用户每说一句话,通常要把之前几轮对话一起发给模型,模型才能保持连贯。但这些历史放哪?放 MySQL 吧,单条消息写入几百 QPS 还能撑,一旦用户量和上下文长度上来,数据库先扛不住。放本地内存吧,服务一重启全没了,多个副本之间还不一致。更麻烦的是,很多用户问题其实是重复的,同一个问题换个说法进来,模型还要重新算一遍,token 费用一分不少。
这种场景下,Redis 几乎是唯一能同时解决“快、省、活”三个字的选择。快,指的是微秒级读写;省,指的是可以用缓存撞掉大量重复推理;活,指的是数据结构足够灵活,能存字符串、哈希、JSON、向量,还能做队列和锁。
1.2 Redis 的数据结构,长在了 AI 场景的痛点上
我经常跟人说,Redis 最被低估的不是性能,而是它那套数据结构刚好长在 AI 应用的痛点上。
会话历史是典型的“追加写入 + 按序读取”,用 Stream 或者 List 都很顺手;用户画像和会话元数据是结构化的,RedisJSON 可以直接存;知识库分片需要把文本切片转成向量,Redis 的向量检索模块正好能做相似度召回;多个用户同时触发模型调用,需要幂等和去重,Set 和分布式锁又能兜底。
这些需求要是分别引入 MySQL、Elasticsearch、向量数据库、消息队列,光基础设施就够喝一壶。Redis 把它们收敛到一个系统里,虽然不能每一项都做到专业数据库的极致,但胜在“够用、够快、够省事”。
1.3 所谓的“正式接入”,其实接的是这四件事
我理解“Redis 正式接入 AI”不是某一个版本突然多了一个 AI 按钮,而是整个生态已经把 AI 当作一等公民。落到实际项目里,一共就四件事:
| 能力 | 用的 Redis 能力 | 解决的问题 |
|---|---|---|
| 对话记忆 | Stream、JSON、TTL 过期 | 上下文存储、历史管理 |
| 语义缓存 | 向量检索 + Hash/JSON | 重复问题直接返回缓存答案,省 token 降延迟 |
| RAG 检索 | RediSearch 向量索引 | 知识库切片召回 |
| Agent 调度 | Lock、Stream、Set | 请求去重、异步排队、状态编排 |
后面所有实操,基本都在围绕这四件事展开。
2. 给 AI Agent 装上记忆:会话历史的存储方案
2.1 先想清楚:什么数据该进 Redis,什么不该进
接 AI 之前,先别急着把所有数据往 Redis 里塞。我习惯按“热数据”和“冷数据”区分。
热数据,指每次对话几乎都要读取的数据。比如当前会话最近 20 轮上下文、用户最近选中的知识库范围、正在排队等待模型响应的消息。这类数据必须毫秒级返回,适合放 Redis。
冷数据,指做分析、审计、长期存档的数据。比如用户几个月前的所有聊天记录、运营要统计的会话时长、模型调用费用报表。这类数据建议异步同步到 MySQL 或数仓,Redis 里只留一个最近窗口。
这样设计的核心原因只有一个:Redis 是内存数据库,昂贵且容量有限。它负责给 AI 应用“提速度”,不负责“装历史”。
2.2 用 RedisJSON 存会话上下文
我项目里最早用的是 String 类型,把所有消息拼成一个 JSON 字符串直接 SET。后面发现一个致命问题:要修改其中一条消息,得先把整个字符串取出来反序列化,改完再整个写回去,一旦消息多了,频繁大 KEY 读写特别难受。
后来换成 RedisJSON 模块,数据结构上彻底清爽。示例代码如下:
import redis import json r = redis.Redis(host="localhost", port=6379, decode_responses=True) session_key = "session:u_10001" # 初始化会话,messages 用数组存 r.json().set(session_key, "$", { "user_id": "u_10001", "mode": "rag", "messages": [] }) # 追加一轮对话 r.json().set(session_key, "$.messages", [ {"role": "user", "content": "Redis 的向量检索支持哪些索引?", "ts": 1710000001}, {"role": "assistant", "content": "支持 FLAT 和 HNSW 两种索引。", "ts": 1710000005} ]) # 只读取最近 10 条消息,避免把整个会话都拉出来 messages = r.json().get(session_key, "$.messages")[0][-10:]用 JSON 存的优势很明显:可以按路径更新某段字段,不用整存整取;可以只读最近几条再拼 prompt,省内存;配合 TTL 设置,会话超过 24 小时自动消失。
注意一个细节:r.json().set(session_key, "$", {...})里的"$"是 JSONPath 的根路径。刚开始用很容易漏掉,漏了会出现类型报错。
2.3 用 Stream 做消息管道,让 AI 响应不再阻塞
对话系统最难受的一个问题:模型推理要 2 到 5 秒,如果让用户请求一直阻塞等响应,连接池、线程、上游网关全都拖着。更合理的方式是把请求丢进队列,后端 worker 慢慢消费,响应好了再通过轮询或 WebSocket 推给用户。
Redis Stream 非常适合干这件事。它是 Redis 5.0 引入的持久化消息队列,支持消费者组,消息能被确认、能重放,比 List 做队列靠谱得多。
# 生产端:把用户问题写入队列 XADD chat:reqs * user_id u_10001 question "Redis集群怎么部署" session_id s_001 # 消费端:创建消费者组并读取新消息 XGROUP CREATE chat:reqs group1 $ MKSTREAM XREADGROUP GROUP group1 consumer1 COUNT 10 BLOCK 3000 STREAMS chat:reqs > # 处理完成 XACK chat:reqs group1 1710000001-0这里最重要的是>符号。XREADGROUP用>表示只读消费者组里未投递的新消息;如果写具体消息 ID,则是重新读取历史消息,常用于故障恢复。这个区别踩过坑的人应该都有印象。
2.4 序列化、过期策略和 context 裁剪
用 Redis 存对话,最常见的问题就是序列化。decode_responses=False时,Redis 返回的是 bytes,不是字符串;消息里有 datetime 对象,直接 json.dumps 会报错;用了 pickle 虽然能存,但跨语言基本没法读。
我的做法很简单:统一走 JSON,时间戳全部转成 int,所有进出 Redis 的数据先经过一个序列化函数。如果你用 FastAPI,可以直接用jsonable_encoder把 Pydantic 对象转成可 JSON 序列化的字典,再塞进 Redis。
过期策略我的建议是分两级。第一级是EXPIRE给整个 session key 设置 24 小时,用户不活跃自动清理。第二级是写入时间戳,拼接 prompt 时只取最近 N 条,防止上下文超出模型窗口。比如:
def build_prompt(session_key, max_turns=10): messages = r.json().get(session_key, "$.messages")[0] recent = messages[-max_turns * 2:] return [{"role": m["role"], "content": m["content"]} for m in recent]注意:Redis 的过期删除是惰性的,过期 key 不一定会立刻消失。如果担心内存被过期 key 长期占用,可以调active-expire-effort或者定期跑SCAN+UNLINK清理。
3. 用 Redis 做 RAG 的实时记忆与语义缓存
3.1 为什么选择 Redis 而不是单独部署一套向量数据库
现在市面上专门的向量数据库很多,Milvus、Qdrant、Pinecone 各有优势。但做 AI 应用落地时要考虑一个现实问题:数据链路越短越好。
RAG 场景里,知识库切片既要存向量,又要存原文,还要存标题、来源、权限等元数据。如果向量库只存向量,那原文还得放 MySQL,业务查一次要先搜向量,再回表查原文,链路长不说,还得处理两套数据一致性。Redis Stack 把向量搜索、JSON、Hash、索引放在同一个进程里,向量和高频元数据一起返回,我实测下来 P95 延迟只有纯向量库方案的 60% 左右。
如果你项目已经用了 Redis,再引入一个独立向量库,等于凭空多了一套要运维、要监控、要保证一致性的系统。“多一个中间件,就多一堆问题”这句话在 AI 项目里尤其适用。
3.2 准备索引:字段设计、向量参数和距离度量
用 Redis 做向量检索前,要先想清楚字段结构。我建议每一条知识切片至少包含三个字段:
{ "content": "Redis 8 原生支持向量检索,可以用于 RAG 场景。", "source": "docs/redis8.md", "embedding": [0.012, -0.034, ...] # 768 维向量 }这里的embedding由 Embedding 模型生成。常见选择是text-embedding-3-small,1536 维;如果用开源的bge-m3,大约是 1024 维;轻量场景用sentence-transformers/all-MiniLM-L6-v2,384 维。维度越高越准,但内存占用和计算成本也越高,我一般建议文档量在 100 万以下选 768 维左右。
创建索引时,可以用FT.CREATE:
FT.CREATE idx_docs ON HASH PREFIX 1 "doc:" SCHEMA content TEXT source TAG embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE解释一下VECTOR HNSW 6里的 6 是参数数量,后面跟着TYPE、DIM、DISTANCE_METRIC。HNSW 有两个常用参数要留神:M控制节点最多连接数,默认 16,数据量越大可以调到 32;EF_CONSTRUCTION控制建索引时的搜索范围,越大索引越精准,但建索引越慢。
如果你用 Python,就不用手写这些命令,官方有一个redisvl库,用起来更舒服:
from redisvl.index import SearchIndex from redisvl.schema import IndexSchema schema = IndexSchema.from_dict({ "index": {"name": "idx_docs", "prefix": "doc:"}, "fields": [ {"name": "content", "type": "text"}, {"name": "source", "type": "tag"}, {"name": "embedding", "type": "vector", "attrs": {"dtype": "FLOAT32", "dim": 768, "distance_metric": "cosine"}} ] }) index = SearchIndex(schema, redis_client=r) index.create()3.3 写入文档并执行 KNN 检索
索引创建好,接下来就是写入和查询。写入时注意,Hash 字段里的向量要转成 bytes 之后再做 HSET,不是直接放 Python list。redisvl 已经封装了这一层,所以优先用它的load方法:
doc = { "content": "Redis 8 原生支持向量检索,可以用于 RAG 场景。", "source": "docs/redis8.md", "embedding": embedding_model.encode("Redis 8 原生支持向量检索...") } index.load([doc])查询时同样直接传向量:
from redisvl.query import VectorQuery query_embedding = embedding_model.encode("Redis 的向量检索能力怎么样") query = VectorQuery( vector=query_embedding, vector_field_name="embedding", return_fields=["content", "source"], num_results=5, distance_threshold=0.8 ) results = index.query(query) for res in results: print(res["content"], res["distance"])这里distance_threshold是相似度阈值,COSINE 距离越小代表越相似。0.8 只是一个起点,实际要通过一组测试问题调。调低了会召回一堆不相关结果,调高了会导致该召回的内容为空,后面我会详细说。
3.4 语义缓存:让重复问题不再反复调模型
聊完 RAG,再聊一个被很多人忽略的高价值场景:语义缓存。
传统的 Redis 缓存是精确匹配,用户把“帮我写一封请假邮件”说成“帮我写封邮件请假”,两个 key 就不同,模型照样要重新算。语义缓存则先把用户问题转成向量,在向量索引里做相似度检索,如果找到距离足够近的历史问题,直接把当时模型返回的答案吐出来,省去一次模型调用。
这个方案对技术类产品帮助特别大。用户问“Redis 集群部署怎么配置”和“Redis 集群如何配置”其实是一个问题,后面那个如果语义缓存命中,延迟能从 3 秒降到 30 毫秒,费用直接归零。
实现思路不复杂:
def chat_with_semantic_cache(question): q_vec = embedding_model.encode(question) query = VectorQuery( vector=q_vec, vector_field_name="embedding", return_fields=["question", "answer", "distance"], num_results=1 ) result = index.query(query) if result and result[0]["distance"] < 0.05: # 距离足够小,直接命中 return result[0]["answer"] answer = call_llm(question) index.load([{ "question": question, "answer": answer, "embedding": q_vec }]) return answer注意阈值的选择。我用 COSINE 距离,对“同义改写”类问题,距离通常在 0.01 到 0.05 之间;对“字面相似但语义不同”的问题,距离可能到 0.2 以上。所以先拿一百条真实用户问题跑一遍,看命中分布,再决定阈值。
另外,语义缓存一定要做 TTL 清理。模型答案有时效性,今天告诉用户“最新版本是 8.0”,半年后可能就过时了。给每条缓存记录设置 7 天过期,是成本和质量之间的一个平衡点。
4. 完整接入实战:从 Docker 部署到核心代码
4.1 环境准备:用 Redis Stack 一步到位
如果你想完整跑通上面所有能力,不建议用普通redis:alpine镜像,因为里面没有 RediSearch、RedisJSON 这些模块。直接用 Redis Stack 最省事:
docker run -d \ --name redis-stack \ -p 6379:6379 \ -p 8001:8001 \ redis/redis-stack-server:latest8001端口是自带的 RedisInsight 可视化工具,能看到 key、执行命令、查看索引,排查问题非常方便。
启动后先验证模块有没有加载:
redis-cli MODULE LIST如果能看到search和ReJSON,说明环境没问题。
4.2 接入流程和代码骨架
我把整个 AI 会话服务代码拆成了三层。第一层是 API 层,负责接收请求和返回响应;第二层是服务层,负责调用模型;第三层是 Redis 层,负责记忆、缓存、向量检索。
from fastapi import FastAPI import redis import hashlib import json app = FastAPI() redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True) LOCK_PREFIX = "lock:prompt:" CACHE_PREFIX = "cache:semantic:" SESSION_PREFIX = "session:" def acquire_lock(key, ttl_ms=30000): token = hashlib.sha256(key.encode()).hexdigest() ok = redis_client.set(f"{LOCK_PREFIX}{token}", token, nx=True, px=ttl_ms) return token if ok else None def release_lock(key, token): # Lua 脚本保证原子性,防止误删别人的锁 lua = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ redis_client.eval(lua, 1, f"{LOCK_PREFIX}{hashlib.sha256(key.encode()).hexdigest()}", token) @app.post("/chat") async def chat(session_id: str, question: str): session_key = f"{SESSION_PREFIX}{session_id}" lock_key = f"prompt:{session_id}:{hashlib.md5(question.encode()).hexdigest()}" token = acquire_lock(lock_key) if not token: return {"answer": "请求处理中,请稍候"} try: # 1. 查语义缓存 cache_answer = semantic_cache_get(question) if cache_answer: return {"answer": cache_answer, "source": "cache"} # 2. 拼上下文 messages = get_recent_messages(session_key, max_turns=10) messages.append({"role": "user", "content": question}) # 3. 调用模型 answer = call_llm(messages) # 4. 写回会话与语义缓存 append_message(session_key, "user", question) append_message(session_key, "assistant", answer) semantic_cache_store(question, answer) return {"answer": answer, "source": "model"} finally: release_lock(lock_key, token)这个骨架基本照搬到我线上项目里。最关键的收益是:同一问题并发进来,只有一个会真的触发模型调用,剩下的要么走语义缓存,要么直接提示处理中,接口压力小了很多。
4.3 参数设置与资源规划
很多人在 Redis 上栽跟头,都是因为没提前算内存。上面代码里,每个会话存了最多 20 条消息,每条消息按 500 字节算,一个 session 大概 10KB。如果每天 10 万用户,每个用户平均 3 轮对话,内存占用大概是:
10 万 × 3 × 10KB × 2(JSON 冗余和索引开销) ≈ 6GB
这个量级在 Redis 里已经算不小了。所以必须设置maxmemory和淘汰策略:
redis-cli CONFIG SET maxmemory 4gb redis-cli CONFIG SET maxmemory-policy allkeys-lruallkeys-lru表示内存满了之后优先淘汰最久没访问的 key。对话数据本身低频访问会自然被淘汰,语义缓存也会跟着清理,不会出现 OOM。
如果业务要求重启后不丢数据,还要开 AOF 持久化:
redis-cli CONFIG SET appendonly yes redis-cli CONFIG SET appendfsync everysec但要注意,AOF 开启后写入性能会有轻微损耗,everysec是性能和可靠性之间的平衡点。对 AI 聊天场景来说,丢 1 秒数据基本可接受。
4.4 用分布式锁给 AI 请求“消抖”
为什么要给 AI 请求加锁?因为大模型推理是既慢又贵的外部调用。同一个用户连续点击“发送”三次,或者多个用户同时问同一个自己手头还没回答完的问题,直接并发打向模型,不仅浪费钱,还容易把模型限流打出来。
我用的是SET NX PX的方式,锁的 key 是 prompt 的哈希值,value 是一个随机 token。拿到锁的请求继续执行,没拿到的直接返回“正在处理中”。释放锁时一定用 Lua 脚本比对 token,否则一个请求的锁可能被另一个请求误删。具体代码就是上面骨架里的acquire_lock和release_lock。
锁的过期时间按模型接口超时时间设置。如果模型超时是 20 秒,锁 TTL 一般给 30 秒,留足余量。TTL 设太短,请求还没处理完锁就过期,其他线程就会涌进来;设太长,如果处理线程真的挂了,其他人要白等很久。
5. 常见问题与排查实录
5.1 语义缓存命中率低
现象是很多重复问题的语义缓存命中率不到 20%,看起来写了缓存却没什么用。
先别急着调阈值。我用 redisinsight 打开索引,把命中的样本距离逐条打出来看,发现检索返回的距离普遍在 0.1 到 0.2 之间,而阈值设的是 0.05,自然命中不了。另一种可能,是同一个 Embedding 模型在构建索引和查询时没有固定下来,比如索引里用的是 bge-m3,查询时换成了 OpenAI embedding,两个向量根本不在一个空间,距离永远很大。
解决办法:固定一个模型,不能混用;用一批真实问题做阈值分析,绘制距离分布,找到区分度最高的阈值;缓存 Key 里带有业务维度,比如不同知识库的答案不能互相命中。
5.2 序列化和类型混乱
我是踩过“用字符串存向量”的坑的。当时为了图省事,把 embedding 数组直接str()转成字符串再存,检索时 Redis 报Could not parse vector。这是因为 RediSearch 的 VECTOR 字段期望的是二进制向量,不是 JSON 数组或者字符串。
所有向量字段在用 redis-py 操作时,应该用numpy.float32转成 bytes:
import numpy as np def vector_to_bytes(vec): return np.array(vec, dtype=np.float32).tobytes()写入用HSET doc:1 embedding [bytes],查询时同样要转 bytes。用 redisvl 封装好的load/query可以完全避开这个坑,这也是我推荐它的原因。
5.3 锁误删和并发穿透
锁误删是分布式锁最经典的问题。A 请求拿到锁,执行时间超过锁 TTL,锁自动过期;B 请求拿到新锁,A 执行完之后执行 DEL,把 B 的锁删了。B 还没跑完,C 又进来了。典型事故现场。
解决办法有两个,我都做了:一是释放锁时用 Lua 校验 value,也就是上面代码里写的版本;二是锁的 TTL 设置为模型调用超时时间的 1.5 倍。如果模型超时 20 秒,锁 TTL 30 秒,基本不会出现执行时间超过 TTL 的情况。
还有一些场景会对同一个 prompt 重复请求,我建议在 Redis 里维护一个“最近已处理 prompt”的 Set,key 为 prompt 的哈希值,TTL 设置 60 秒。新请求进来先SISMEMBER,命中就直接提示重复提交,这样不用锁也能挡住绝大多数并发。
5.4 内存暴涨和 bigkey
内存暴涨通常不是一条消息引起的,而是某个 key 越积越大。比如把整个用户所有会话存进同一个 key,一个超级大用户能写几 MB。Redis 读写单 key 是有线程模型的,大 key 会让其他命令全部排队。
排查方法:
redis-cli --bigkeys跑一遍就能列出出最大的 key 分布。如果是会话 key,用DEBUG OBJECT session:xxx看序列化长度。一个 key 超过 10MB 必须拆分,要么按天拆,要么按会话拆。
平时写代码也要有意识地控制消息长度。给每个 session key 做LPUSH或JSON.ARRAPPEND后,用LTRIM或JSON.ARRPOP限制最大条数,比如最多存 50 条。这样不但节省内存,拼 prompt 时也不用再裁。
5.5 检索不准、召回乱序
RAG 检索不准,除了阈值问题,还有一个容易被忽略的坑:Redis 的FT.SEARCH默认按内部相关性排序,并不是严格按向量距离排序。如果你直接拿FT.SEARCH加SORTBY可能没启用向量距离排序。
正确做法是使用KNN查询:
FT.SEARCH idx_docs "*=>[KNN 5 @embedding $vec AS distance]" \ PARAMS 2 vec "\x00\x01..." \ SORTBY distance ASC \ RETURN 2 content distance \ DIALECT 3其中DIALECT 3是必须的,只有 dialect 3 才支持向量排序。如果你用 redis-py,最好直接走redisvl的VectorQuery,它会自动拼好这些参数。
另一个坑是向量维度不匹配。索引里设置 DIM 768,写入 1536 维向量,创建索引那步不会报错,但查询时会报维度不一致。遇到这类问题,优先用FT.INFO idx_docs查看 index 定义里的维度,再检查 embedding 模型的输出维度。
我在实际项目里把 Redis 接进 AI 链路后,最大的感触是:很多人把 Redis 当成“缓存数据库”,但它在 AI 场景里其实是“记忆中枢”。会话记忆、上下文、检索、缓存、防抖,都可以收拢到 Redis 里,让模型调用链路变得又短又稳。如果你现在正打算搞 AI 应用,但又不想一上来就铺一整套向量数据库和消息队列,不妨先从 Redis 入手。先跑通一个最小闭环:Docker 起来,RedisJSON 存会话,向量索引做 RAG,语义缓存挡重复请求。后面再按业务量决定要不要拆出更专业的中间件。