Redis 深度教程 · 06
Redis 作为 Agent 基础设施
前五章是经典 Redis——内存编码与单线程串行执行这两块基石已推导完毕。这一章把同两块基石搬到 AI Agent 场景:向量是新的数据形态(基石一),语义缓存查找和 LLM 限流是新的原子操作(基石二)。技术是新的,模型是旧的。基于 Redis 8.x / RedisVL ≈0.18.x / langgraph-redis ≈0.3.2,截至 2026-06。
本章你将建立的 schema
- Redis 在 Agent 系统里扮演的五个角色,以及哪个角色该外置专用组件
- 向量检索的两条路径(RediSearch HNSW/FLAT vs Redis 8 原生 Vector Set)及选型依据
- 语义缓存(semantic cache)的机制与
distance_threshold对命中率的影响 - 会话 buffer 与长期语义记忆的实现方式,以及 LangGraph checkpointer 的三个类
- 为什么 LLM 限流要把令牌桶逻辑包进 Lua 脚本,而非分步执行
- Streams / pub/sub / List 三种消息机制的选型矩阵
向量(embedding,嵌入向量)是一种新的数据形态——用基石一(精选内存编码)来存和检索它,链回第 01 章。语义缓存的相似度查找和令牌桶限流,都在单线程串行执行里天然原子,链回第 02 章。Agent 场景带来了新需求,但推导它行为的工具与前五章完全相同。
6.1向量检索:两条路径与选型
向量(embedding)是另一种内存编码形态——Redis 用 HNSW(分层可导航小世界图)或 FLAT 暴力索引存储高维浮点向量,以距离度量代替精确键匹配。
LLM 把文本映射成高维浮点向量(通常 768–3072 维),语义相似度转化为向量空间距离。经典 Redis 的精确键查找对向量无效,必须有专门的近似最近邻(ANN)索引。Redis 提供了两条路径:通过 RediSearch 模块创建向量索引,或(Redis 8 起)使用原生 Vector Set 类型。
路径一:RediSearch 向量索引
RediSearch 在 Hash 或 JSON 文档的字段上创建向量索引。FT.CREATE 时声明 VECTOR 字段,选择算法和距离度量:
# FLAT:暴力精确搜索,适合向量数 < 5 万的小集合
FT.CREATE idx:docs ON HASH PREFIX 1 doc: SCHEMA
content TEXT
embedding VECTOR FLAT 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
# HNSW:近似图索引,亿级向量仍可低延迟搜索
# M=16: 每节点连接数;EF_CONSTRUCTION=200: 构建时搜索宽度
FT.CREATE idx:docs_hnsw ON HASH PREFIX 1 doc: SCHEMA
content TEXT
embedding VECTOR HNSW 10 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE M 16 EF_CONSTRUCTION 200
# 向量相似度搜索:找最近 5 个,同时按 tag 预过滤
FT.SEARCH idx:docs_hnsw
"(@tag:{news})=>[KNN 5 @embedding $vec AS score]"
PARAMS 2 vec <binary_blob>
SORTBY score
DIALECT 2
FLAT 对每个查询向量做全量暴力比对,召回精确,时间复杂度 O(N×D)(N=向量数,D=维度)。在向量数不超过约 5 万时延迟可接受且实现简单。HNSW(Hierarchical Navigable Small World,分层可导航小世界图)是近似最近邻算法:构建一个多层图,查询时从顶层稀疏图逐层缩小搜索范围,查询复杂度约 O(log N)。参数 M(每节点出边数,默认 16)和 EF_RUNTIME(查询时搜索宽度,默认 10)控制精度与速度的权衡:EF 越大,召回越准,延迟越高。
距离度量三选一:COSINE(余弦距离,值域 0–2,值越小越相似,最常用);L2(欧氏距离,适合需要绝对量级的场景);IP(内积,适合已归一化向量,等价于 COSINE 但更快)。
路径二:Redis 8 原生 Vector Set(VADD / VSIM)
Redis 8.0(2025-05 GA)把向量类型内置进内核,不再依赖 RediSearch 模块。新类型基于 sorted set 的扩展,专用命令 VADD / VSIM / VCARD 等:
# 插入向量(默认 int8 量化;NOQUANT=全精度 float32;BIN=二值化)
VADD myvecs VALUES 1536 0.12 0.34 0.56 ... doc:42
VADD myvecs NOQUANT VALUES 1536 0.12 0.34 0.56 ... doc:43
# 相似度搜索:返回最近 5 个成员及距离
VSIM myvecs VALUES 1536 0.11 0.33 0.55 ... COUNT 5 WITHSCORES
# 查看集合大小
VCARD myvecs
Vector Set 默认以 int8 量化(每维 1 字节)存储向量,内存约为 float32 的 1/4,召回率略有损失(通常 <1%)。NOQUANT 保留 float32 全精度,BIN 二值化至每维 1 bit,内存极省但适用场景有限。HNSW 的 EF 参数(查询搜索宽度)默认 200,可通过 EF 选项在 VSIM 时覆盖。
VADD 是单线程串行插入(与所有写命令一致),在需要批量导入数百万向量的场景下速度慢。Redis 8.0–8.2 阶段的 Vector Set 尚不支持 RediSearch 的预过滤(KNN + 标签过滤组合)——这是 RediSearch 路径的核心优势之一。截至 2026-06,生产环境大规模向量检索仍优先考察 RediSearch 或专用向量库。
何时不该用 Redis 做向量检索
| 场景 | 推荐 | 理由 |
|---|---|---|
| 向量数 < 50 万、已有 Redis | Redis RediSearch HNSW | 零额外依赖,毫秒级延迟,预过滤能力强 |
| 向量数 < 10 万、精确召回 | Redis FLAT 或 pgvector | pgvector 在 PostgreSQL 里"够用",不引入新基础设施 |
| 重过滤(多属性联合)+ 大规模 | Qdrant | 专为混合过滤设计的 payload index,召回精度更可控 |
| 十亿级、托管免运维 | Pinecone / 云厂商向量服务 | Redis 单实例内存上限和单线程写入在十亿级向量下成为瓶颈 |
向量也是一种内存编码——FLAT 是全量浮点数组(精确),HNSW 图是按访问频率剪枝的多层索引(近似但省内存),int8 量化是把 float32 压缩到 1/4 体积的紧凑编码。Redis 用不同的编码结构服务不同精度/规模需求,与 Hash 在 listpack 和 hashtable 之间切换的逻辑完全一致。
6.2语义缓存:用向量相似度命中 LLM 响应
语义缓存(semantic cache)把用户 query 向量化,在历史 prompt 向量库里做相似度搜索,距离低于阈值时直接返回已缓存的 LLM 响应,跳过 LLM 调用。
同一个问题的不同表述(「Redis 怎么限流」vs「Redis 如何做速率控制」)在语义上等价,但精确字符串缓存无法命中。语义缓存把命中粒度从「完全相同」降低到「语义足够近」,在问答类、客服类场景中节省大量 LLM token 费用和延迟(LLM 调用通常百毫秒级,缓存命中通常个位数毫秒)。
RedisVL SemanticCache 机制
RedisVL(Redis Vector Library,Python 库,≈0.18.x,2026-04)的 SemanticCache 类封装了完整的语义缓存流程:
from redisvl.extensions.llmcache import SemanticCache
from redisvl.utils.vectorize import OpenAITextVectorizer
vectorizer = OpenAITextVectorizer(model="text-embedding-3-small")
cache = SemanticCache(
name="llm_cache",
vectorizer=vectorizer,
redis_url="redis://localhost:6379",
distance_threshold=0.10, # COSINE 距离,0.0–2.0,默认 0.1
)
# 查缓存(返回 None 时需调 LLM)
result = cache.check(prompt="Redis 怎么做限流")
if result:
answer = result[0]["response"]
else:
answer = call_llm("Redis 怎么做限流")
cache.store(
prompt="Redis 怎么做限流",
response=answer,
)
# 运行时调整阈值(无需重建索引)
cache.set_threshold(0.15)
distance_threshold 使用 COSINE 距离(值域 0–2,0 表示完全相同,2 表示完全相反)。默认 0.1 偏严,约等价于余弦相似度 ≥ 0.9,误命中极少但命中率低;实际工程中 0.15 是常见起点,在问答类任务里误命中率仍可控。高于 0.3 时同主题不同意图的 query 开始混淆,错误答案出现概率显著上升。
把 distance_threshold 从 0.1 调到 0.3,语义缓存的命中率和错误命中率各自如何变化?对客服 Agent 有什么实际风险?
展开思路与答案
命中率上升:原来距离在 0.1–0.3 之间、被认为"不够近"而放过的 query,现在都会命中缓存——命中率显著提升,LLM 调用减少,延迟下降。
错误命中率也上升:COSINE 距离 0.3 约等价于余弦相似度 0.85,同主题不同意图的问题(如「退款政策」vs「退货政策」)向量距离可能落在这个区间——缓存会把错误答案返回给用户。
对客服 Agent 的风险:用户问 A,返回的是语义相近但语义不等价的问题 B 的答案,导致客户投诉或错误操作。阈值调整必须在目标语料集上评估 precision@threshold,而非盲目上调。
Redis 官方推出的托管语义缓存服务 Redis LangCache,底层即 RedisVL SemanticCache 的托管版,截至 2026 年中仍处于 Preview 阶段,尚未 GA(General Availability)。生产环境使用需评估 SLA 与功能完整性。
语义缓存的「向量相似度查找」在 Redis 单线程里原子执行——没有其他命令能在「开始搜索」和「返回结果」之间插入。这保证了并发多个 Agent 同时查缓存时,不会出现中间态读到写了一半的向量索引。这正是「单线程串行执行使命令天然原子」在新场景下的直接体现。
6.3会话与记忆:buffer、checkpointer、语义召回
短期会话 buffer 把最近 N 轮对话存为 JSON 列表,长期记忆用向量检索按语义召回历史片段;LangGraph checkpointer 在二者之上再加一层完整的 Agent 状态快照。
LLM 无状态,每次调用独立。Agent 系统需要在调用之间保存上下文:短期是「这轮对话的最近几条消息」,长期是「跨会话的用户偏好 / 知识」,而 LangGraph 等框架还需要「整个 Agent 执行图的检查点」以支持断点续跑和分支回溯。Redis 的 JSON 存储、向量检索、有序数据结构恰好同时满足这三类需求。
短期会话 Buffer:langchain-redis
langchain-redis 的 RedisChatMessageHistory 把对话历史存为 Redis 中的 JSON 数组(依赖 RedisJSON 模块,Redis 8 已内置)。每条消息包含 role(human/ai/system)和 content,整个 session 以 session_id 为 key,支持通过 RediSearch 创建辅助索引以做跨 session 检索:
from langchain_redis import RedisChatMessageHistory
history = RedisChatMessageHistory(
session_id="user:42:session:001",
redis_url="redis://localhost:6379",
ttl=3600, # 会话 1 小时无活动后自动过期
)
history.add_user_message("Redis 的 HNSW 是什么")
history.add_ai_message("HNSW 是分层可导航小世界图...")
# 取最近 20 条消息传给 LLM
messages = history.messages[-20:]
LangGraph Redis Checkpointer
langgraph-checkpoint-redis(≈0.3.2,2026-04)为 LangGraph Agent 的状态图提供三个持久化类,依赖 RedisJSON + RediSearch(Redis 8 内置,Redis 7.x 需单独加载模块):
| 类 | 存储内容 | 适用场景 |
|---|---|---|
RedisSaver | 完整 thread 检查点历史(每步一个快照) | 需要回溯 / 分支重放 / 审计执行轨迹 |
ShallowRedisSaver | 只存最新检查点(覆盖旧快照) | 只需断点续跑、不需历史,内存占用小 |
RedisStore | KV + 向量的长期跨 thread 记忆 | 用户偏好、知识库,按语义检索跨会话记忆 |
from langgraph.graph import StateGraph
from langgraph_checkpoint_redis import RedisSaver
# 创建 checkpointer(同步版;异步版用 AsyncRedisSaver)
checkpointer = RedisSaver.from_conn_string("redis://localhost:6379")
checkpointer.setup() # 初始化 Redis 索引结构
# 编译 graph 时注入 checkpointer
graph = StateGraph(AgentState)
# ... add_node / add_edge ...
app = graph.compile(checkpointer=checkpointer)
# 执行时带 thread_id:同一 thread 自动从上次检查点恢复
config = {"configurable": {"thread_id": "session:42"}}
result = app.invoke({"messages": [("user", "继续上次的任务")]}, config)
RedisVL ≈0.18.x(2026-04),langgraph-redis ≈0.3.2(含 RedisSaver、ShallowRedisSaver、RedisStore)。两个库均依赖 RedisJSON + RediSearch 模块,Redis 8.0 起已内置,Redis 7.x 需通过 Redis Stack 或单独模块加载。版本号以 PyPI 发布为准,关键 API 变动参考各自 changelog。
6.4LLM 限流:令牌桶、滑动窗口与 Lua 原子性
LLM 网关对每个租户做速率控制(token bucket,令牌桶),必须把「查剩余令牌 → 判断 → 扣减」包进一段 Lua 脚本用 EVAL 执行,单线程串行保证整个判断-扣减操作原子完成。
LLM API 按 token 计费且有速率上限(如 OpenAI Tier 3:每分钟 150 万 token)。多实例网关(N 个 Pod 同时处理请求)如果各自用「先 GET 再 SET」两步操作控制速率,两个 Pod 可能同时读到「还有 100 个令牌」,各自决定放行,实际超发 200 个。把判断和扣减合并成一条原子脚本,Redis 单线程串行执行它,任意并发下都不会超发。
Lua 脚本通过 EVAL 在 Redis 服务端执行,整段脚本被当成一条命令处理:它在主线程里连续执行完毕才返回,期间其他命令排队等待。这是「单线程串行执行使命令天然原子」用于分布式限流的直接应用——与第 05 章分布式锁的 SET key value NX PX 原子命令同理,都是用基石二消灭竞态条件。
令牌桶 Lua 脚本思路
令牌桶(token bucket)维护两个字段:当前令牌数 tokens 和上次补充时间戳 last_refill。每次请求到来时,先按时间差补充令牌(补充速率 = capacity / window),再判断是否有足够令牌,有则扣减并放行,无则拒绝:
-- KEYS[1]: bucket key, e.g. "ratelimit:tenant:42"
-- ARGV[1]: capacity (max tokens, e.g. 1000)
-- ARGV[2]: refill_rate (tokens/second)
-- ARGV[3]: requested (tokens this request needs)
-- ARGV[4]: now (current unix timestamp in ms, from client)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local req = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
-- 按时间差补充令牌,不超过上限
local elapsed = math.max(0, now - last_refill) / 1000.0
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= req then
tokens = tokens - req
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
redis.call("PEXPIRE", key, 60000) -- 60s 无请求自动清理
return 1 -- 允许
else
redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
return 0 -- 拒绝
end
调用方用 EVAL <script> 1 ratelimit:tenant:42 1000 100 5 <now_ms> 执行。整段 Lua 在 Redis 主线程中连续执行,N 个网关 Pod 同时调用时,它们的请求在 Redis 侧串行处理,不存在超发。
脚本执行期间其他命令全部等待。令牌桶脚本只有几条 Redis 命令,执行时间在微秒级,无问题。但如果脚本里有循环(如遍历大集合),会阻塞整个实例——这是把 Lua 用于生产环境的强制约束:脚本必须 O(1) 或 O(小常数),绝不允许在脚本内做大量遍历。
除令牌桶外,滑动窗口(用 ZADD 存请求时间戳 + ZREMRANGEBYSCORE 清过期 + ZCARD 计数,同样包进 Lua)和 GCRA(Generic Cell Rate Algorithm,固定间隔令牌,实现比令牌桶更简单)也是常见方案。三者都依赖 Lua 原子性,选型取决于是否需要允许短时突发(令牌桶允许,GCRA 不允许)。
6.5消息总线:Streams、pub/sub、List 的选型
Streams 是持久化的、支持消费组确认的有序消息日志;pub/sub 是即发即弃的广播;List 是最简单的单消费者队列——三者不可互换,选型取决于是否需要持久重放与多消费者确认。
Agent 系统里有两类消息需求:一类是「任务分发」(需要可靠投递、任意时刻可重放、多 Agent 负载均衡消费),另一类是「状态广播」(多 Agent 订阅同一事件、不要求持久化、丢了就丢了)。Redis 为这两类需求提供了不同机制,混用会导致消息丢失或资源浪费。
Streams:Agent 任务队列首选
Redis Streams(XADD / XREADGROUP / XACK)是一个仅追加的消息日志,每条消息有自动生成的时间戳 ID。消费组(consumer group)机制允许多个消费者竞争消费同一个 stream(负载均衡),每条消息只被一个消费者取走;消费者调 XACK 确认后消息才从 PEL(Pending Entry List,待确认列表)移除。崩溃恢复时,PEL 里未 ack 的消息可通过 XAUTOCLAIM 转移给其他消费者重新处理:
# 创建 stream + 消费组(从头读)
XGROUP CREATE agent:tasks grp1 $ MKSTREAM
# 生产者推任务
XADD agent:tasks "*" task_type "summarize" payload "..." agent_hint "worker-1"
# 消费者拉取(最多 10 条,阻塞 2000ms)
XREADGROUP GROUP grp1 worker-1 COUNT 10 BLOCK 2000 STREAMS agent:tasks ">"
# 处理完后确认(消息从 PEL 移除)
XACK agent:tasks grp1 1718000000000-0
# 查看 PEL(超时未 ack 的消息,可用于故障恢复)
XPENDING agent:tasks grp1 - + 10
pub/sub:多 Agent 状态广播
Redis pub/sub(PUBLISH / SUBSCRIBE)是纯内存的发布订阅:消息发出后立即投递给所有在线订阅者,不落盘、不留副本。订阅者上线前发布的消息永久丢失。适合 Agent 系统里的状态事件广播(如「任务 X 已完成」通知多个 Observer Agent),不适合需要可靠投递的任务队列。
List:最简单的单消费者队列
LPUSH / BRPOP 组合实现阻塞队列:生产者 LPUSH 推消息,消费者 BRPOP 阻塞等待并弹出。无消费组、无 ack、无持久重放——消费者崩溃时正在处理的消息直接丢失。适合对丢失容忍的轻量单 Agent 场景,或开发调试用途。
一个 Agent 系统需要把「用户上传的文档摘要任务」分发给多个 Worker Agent 处理。Worker 偶尔崩溃,崩溃时正在处理的任务不能丢失。应该用 Streams 还是 pub/sub?核心理由是什么?
展开思路与答案
选 Streams + 消费组。核心理由有两点:
① 持久化 + 可靠投递:pub/sub 不持久,消息在发出瞬间没有在线订阅者就丢失;Worker 崩溃重启后,用 pub/sub 发出的任务已不存在。Streams 是仅追加日志,消息持久存在直到被显式删除。
② PEL + XACK 防丢失:消费组把每条消息放入 PEL,Worker 处理完调 XACK 才移除。Worker 崩溃时 PEL 里的未 ack 消息可用 XAUTOCLAIM 转移给其他 Worker 重试,实现 at-least-once 投递语义。pub/sub 和 List 都没有这个机制。
| 特性 | Streams | pub/sub | List BRPOP |
|---|---|---|---|
| 持久化 | 是(仅追加日志) | 否(即发即弃) | 是(key 存在即持久) |
| 历史重放 | 是(XRANGE) | 否 | 否(弹出即删除) |
| 多消费者 + ack | 消费组 + PEL + XACK | 广播,无 ack | 竞争消费,无 ack |
| 崩溃恢复 | XAUTOCLAIM 重新投递 | 丢失 | 丢失(弹出后) |
| 适用 Agent 场景 | 可靠任务队列 | 状态事件广播 | 开发/轻量单消费者 |
·自测
- Redis 8 原生 Vector Set 的默认量化方式是什么?
NOQUANT和BIN各放弃了什么来换取什么? SemanticCache的distance_threshold使用 COSINE 距离,值域 0–2。若设为 0.05,命中率和误命中率分别朝哪个方向变化?- 为什么 LLM 限流的「查余量 → 判断 → 扣减」必须用 Lua 脚本而非三条独立命令?从基石二推导。
- 设计判别:一个多 Agent 客服系统,需要向量检索(100 万篇知识库文档)、语义缓存、会话记忆、LLM 限流、任务队列。哪几个角色可以留在 Redis?如果向量数扩张到 10 亿,哪个组件该外置,外置到什么?
展开全部答案
1. 默认 int8 量化(每维 1 字节),内存约为 float32 的 1/4,召回率略降(通常 <1%)。NOQUANT 保留 float32 全精度——放弃内存节省,换取精确向量表示,适合维度不太高且召回精度敏感的场景。BIN 二值化至每维 1 bit——内存极省,但向量表达能力大幅下降,适合对精度要求极低的粗筛。
2. 阈值降至 0.05(更严):命中率下降(原来能命中的、距离在 0.05–0.1 之间的 query 不再命中);误命中率也下降(进一步减少语义近但意图不同的错误命中)。极低阈值下缓存几乎失效,省 token 的效益消失。
3. 三条独立命令(GET tokens → 客户端判断 → DECRBY tokens)之间,其他网关 Pod 的命令可以插入:两个 Pod 同时 GET 到 100 个令牌,各自判断「够用」,各自 DECRBY,实际扣了 200,超发。Lua 脚本用 EVAL 提交后,Redis 单线程把整段脚本连续执行完毕再处理下一条命令(基石二:单线程串行执行),两个 Pod 的脚本在 Redis 侧串行化,第二个看到的是第一个已扣减后的余量,不存在超发。
4. 可以留在 Redis 的角色:语义缓存(RedisVL SemanticCache)、会话记忆(RedisChatMessageHistory / RedisSaver)、LLM 限流(Lua 令牌桶)、任务队列(Streams + 消费组)——这四个角色的数据量和访问模式都在 Redis 单实例的能力范围内。向量检索 100 万文档目前仍可用 Redis RediSearch HNSW,是否外置取决于过滤复杂度和延迟要求。向量数扩张到 10 亿时,Redis 单实例内存上限和单线程写入成为瓶颈:应把向量检索外置到 Pinecone(托管) 或 Qdrant(自建,重过滤能力强),Redis 继续承担其余四个角色。
为客服 Agent 画出完整 Redis 用法并指出外置点
一个客服 Agent 系统具备以下能力:用户问题先查语义缓存,未命中后检索知识库(向量检索),结合最近 10 轮会话历史生成回答,对每个租户做 LLM token 限流,后台定期任务(更新知识库索引)通过任务队列分发给多个 Worker。
画出这个系统里 Redis 承担的每个角色(数据结构 / 命令),并回答:如果知识库文档量从 10 万增长到 5 亿,哪个组件应该外置,应该换成什么,理由是什么?
提示(不给全解)
角色梳理思路:对应本章五个角色逐一映射——语义缓存用哪个类;向量检索用哪种索引类型;会话历史用哪个 langchain-redis 类,TTL 怎么设;限流用 Lua 脚本绑定哪个 key(租户维度);后台任务队列用 Streams 还是 pub/sub(提示:需要 Worker 崩溃可重试)。
外置判断思路:5 亿文档 × 1536 维 × 4 字节/float ≈ 3TB 向量数据,超出 Redis 单实例实际可用内存上限。再考虑单线程 VADD 写入速度(每秒数万条)在批量导入时成为瓶颈。选型时对比 Pinecone(托管、serverless)和 Qdrant(自建、强过滤)的运维成本与过滤能力。