ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis 接入 AI 的落地实践:会话记忆、语义缓存与向量检索

Redis 接入 AI 的落地实践:会话记忆、语义缓存与向量检索 “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(hostlocalhost, port6379, decode_responsesTrue) 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_responsesFalse时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_turns10): 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或者定期跑SCANUNLINK清理。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-small1536 维如果用开源的bge-m3大约是 1024 维轻量场景用sentence-transformers/all-MiniLM-L6-v2384 维。维度越高越准但内存占用和计算成本也越高我一般建议文档量在 100 万以下选 768 维左右。创建索引时可以用FT.CREATEFT.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数据量越大可以调到 32EF_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_clientr) 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( vectorquery_embedding, vector_field_nameembedding, return_fields[content, source], num_results5, distance_threshold0.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( vectorq_vec, vector_field_nameembedding, return_fields[question, answer, distance], num_results1 ) 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(hostlocalhost, port6379, decode_responsesTrue) LOCK_PREFIX lock:prompt: CACHE_PREFIX cache:semantic: SESSION_PREFIX session: def acquire_lock(key, ttl_ms30000): token hashlib.sha256(key.encode()).hexdigest() ok redis_client.set(f{LOCK_PREFIX}{token}, token, nxTrue, pxttl_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 fprompt:{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_turns10) 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 × 2JSON 冗余和索引开销 ≈ 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转成 bytesimport numpy as np def vector_to_bytes(vec): return np.array(vec, dtypenp.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”的 Setkey 为 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语义缓存挡重复请求。后面再按业务量决定要不要拆出更专业的中间件。
返回列表