《AI Agent 工程实战》6.1 Redis 缓存:检索结果与嵌入缓存命中率

本节给 ResearchCopilot 的检索结果与嵌入向量加一层 Redis 缓存:缓存 key 带上 workspace_id 以对齐 4.2 的租户隔离,真连本机 Redis 跑出「首次未命中回源、二次命中」的耗时对比与 70% 命中率,并给出检索、嵌入、会话三类 TTL 策略表,以及缓存穿透、击穿、雪崩在 Agent 场景下的具体形态与对策。

6.1 Redis 缓存:检索结果与嵌入缓存命中率

第 5 章把会话、消息、运行、记忆都落进了 Postgres,查询也调优了,可服务一旦真跑起来,你会发现钱和时间仍花在重复的计算上:同一个用户反复问「Redis 的 TTL 怎么设」,Agent 每次都重新嵌入问题、重新跑一遍多跳检索、重新拼上下文。检索和嵌入都是纯函数——输入相同、输出必相同,却没有任何一层记住过结果。这一节就把这层「记住」补上。

本节把 ResearchCopilot 推进到 researchcopilot/cache.py:用 Redis 给检索结果与嵌入向量加一层缓存,key 带上 workspace_id 与查询指纹,真连本机 Redis 跑出命中率与耗时对比,并定下 TTL 与三大缓存故障的对策。

6.1.1 Agent 服务的两种重复

缓存不是「有就好」,得先看清 Agent 服务里到底在重复什么。有两类重复,量级和性质完全不同:

重复类型例子相同输入?适合缓存?
完全重复同一 workspace 反复问同一个问题是,逐字节相同强适合,key 直接命中
近义重复换个说法问同一个问题否,字面不同弱适合,交给 6.2 的语义缓存

本节只解决第一类——字面完全相同的重复。别小看它:企业用户往往把同一个问题粘给多个会话,Agent 的 search 工具在固定语料上反复被同样的关键词打,后台 digest 每天用同样的模板跑同样的检索。这些请求的字面输入一模一样,却每次都在重算。

Agent 服务里最值得缓存的三样东西是:检索结果(search 工具的输出,一次多跳检索可能要几百毫秒)、嵌入向量(把问题或文档变成向量,最贵的一步)、会话摘要(长对话压缩后的中间产物)。三者的读多写少程度递减,缓存价值也递减,本节重点做前两个。

把 ResearchCopilot 处理一次提问的路径摊开,重复点就清楚了。一次 run 的骨架是:嵌入问题 → 向量检索 → 重排 → 可能再检索一轮 → 拼上下文 → 模型生成。其中前四步不含任何模型调用,是纯计算,而且对同一 (workspace_id, query) 的输出完全确定;只有最后一步「模型生成」是不确定的(有采样、有温度)。于是结论很明确:把确定的那几段缓存起来,把不确定的那一段留给模型。这一刀切下去,缓存层要管的就只是检索和嵌入,逻辑干净、失效简单——这正是本节只做这两样的原因。

6.1.2 缓存 key 必须带 workspace_id

这是本节最容易被漏、后果最严重的一点:缓存 key 里必须带 workspace_id。4.2 花了整节讲租户隔离——记忆表按 workspace_id 分区、查询强制带租户谓词——可一旦在缓存层忘了它,隔离就从前门挡住了、后门又漏了。

设想一个没有租户维度的 key:ret:<query_hash>:3。租户 A 问「我们 Q3 的营收是多少」,命中并缓存;租户 B 问一模一样的字符串,直接拿到 A 的检索结果——这不是性能问题,是跨租户数据泄漏。所以 key 的形态必须是:

ret:<workspace_id>:<sha256(query)[:16]>:<top_k>
emb:<workspace_id>:<sha256(text)[:16]>

三个组成部分各有职责:workspace_id 保证租户隔离,sha256 指纹保证同 query 稳定映射到同一个 key(且长度可控),top_k 保证不同召回条数不会被串味——top_k=3 和 top_k=10 的结果集不同,必须分开存。

用 sha256 而不是把原始 query 塞进 key,还有一个现实原因:Redis key 是有长度和字符限制的,中文 query 直接进 key 又长又难读,而且一旦 query 里带了 : 就会把 key 结构冲乱。指纹化之后,key 永远是定长的十六进制串。

6.1.3 起 Redis 与缓存层实现

本机需要一个 Redis。书稿专用容器映射在非默认端口 16380(6379 已被占用),等价命令:

docker run -d --name ab-redis -p 16380:6379 redis:7-alpine

连接串是 redis://localhost:16380/0,服务端实测 Redis 7.4.11,Python 客户端 redis 8.1.0。若连不上,先 docker ps | grep ab-redis 确认容器在跑。没起容器时,本节代码可以把 redis.Redis 换成 fakeredis 或直接用一个 dict 降级,但那样就没有 TTL 与原子性的真实验证了。

缓存层的核心是「查缓存 → 未命中则回源 → 写回缓存」三段式:

from __future__ import annotations
import hashlib, json, time
from dataclasses import dataclass
import redis

R = redis.from_url("redis://localhost:16380/0", decode_responses=True)

def qhash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]

@dataclass
class CachedRetriever:
    r: redis.Redis
    ttl_retrieval: int = 300          # 检索结果 5 分钟
    ttl_embedding: int = 86400        # 嵌入 24 小时
    ret_hits: int = 0
    ret_misses: int = 0
    emb_hits: int = 0
    emb_misses: int = 0

    def embed(self, workspace_id: str, text: str) -> list[float]:
        key = f"emb:{workspace_id}:{qhash(text)}"
        raw = self.r.get(key)
        if raw is not None:
            self.emb_hits += 1
            return json.loads(raw)
        self.emb_misses += 1
        vec = self._embed_source(text)          # 昂贵的回源
        self.r.set(key, json.dumps(vec), ex=self.ttl_embedding)
        return vec

    def retrieve(self, workspace_id: str, query: str, top_k: int = 3):
        key = f"ret:{workspace_id}:{qhash(query)}:{top_k}"
        raw = self.r.get(key)
        if raw is not None:
            self.ret_hits += 1
            return json.loads(raw), "hit"
        self.ret_misses += 1
        result = self._retrieve_source(workspace_id, query, top_k)
        self.r.set(key, json.dumps(result), ex=self.ttl_retrieval)
        return result, "miss"

    @staticmethod
    def _embed_source(text: str) -> list[float]:
        time.sleep(0.08)                       # 模拟一次真实嵌入调用
        return [b / 255.0 for b in hashlib.md5(text.encode()).digest()]

    @staticmethod
    def _retrieve_source(workspace_id: str, query: str, top_k: int):
        time.sleep(0.03)                       # 模拟向量检索 + 重排
        return [{"doc": f"{workspace_id}-doc-{i}", "score": round(0.9 - 0.1 * i, 2)}
                for i in range(top_k)]

三个设计点值得展开。第一,回源函数是「慢」的模拟——_embed_source 睡 80ms、_retrieve_source 睡 30ms,代表真实世界里一次嵌入 API 调用和一次向量检索的耗时;缓存的价值就是把这几十上百毫秒压成一次网络往返。第二,写缓存用 ex= 而不是永久 key——所有缓存都必须有 TTL,下一节细讲。第三,命中计数器内建在类里,这样命中率是一个可观测指标,而不是拍脑袋的感觉。

6.1.4 真跑:未命中 → 回源 → 命中

先看单次调用的对比——同一 workspace、同一 query 连跑两次:

cache = CachedRetriever(R)
WS = "ws-acme"
t0 = time.perf_counter()
res1, st1 = cache.retrieve(WS, "Redis 缓存 TTL 怎么设", 3)
t1 = time.perf_counter()
res2, st2 = cache.retrieve(WS, "Redis 缓存 TTL 怎么设", 3)
t2 = time.perf_counter()
print(f"retrieve #1: {st1:4s}  {(t1 - t0) * 1000:6.1f} ms  -> {res1[0]['doc']}")
print(f"retrieve #2: {st2:4s}  {(t2 - t1) * 1000:6.1f} ms  -> {res2[0]['doc']}")

e0 = time.perf_counter(); cache.embed(WS, "Redis 缓存 TTL 怎么设"); e1 = time.perf_counter()
cache.embed(WS, "Redis 缓存 TTL 怎么设"); e2 = time.perf_counter()
print(f"embed    #1: miss  {(e1 - e0) * 1000:6.1f} ms")
print(f"embed    #2: hit   {(e2 - e1) * 1000:6.1f} ms")

实测输出(Python 3.14.6,redis 8.1.0,服务端 7.4.11):

retrieve #1: miss    51.8 ms  -> ws-acme-doc-0
retrieve #2: hit      4.6 ms  -> ws-acme-doc-0
embed    #1: miss    95.0 ms
embed    #2: hit      4.3 ms

差距一目了然:第一次 51.8ms(回源检索 + 一次 Redis 写),第二次 4.6ms(一次 Redis 读),快了约 11 倍。嵌入更夸张:95.0ms → 4.3ms,约 22 倍。这正是缓存层的意义——把「昂贵的计算」换成「一次内存查表」。

再跑一批有真实感的请求流:8 次请求里只有 3 个不同的问题,看聚合命中率:

BATCH = [
    "Redis 缓存 TTL 怎么设", "Redis 缓存 TTL 怎么设", "Postgres 慢查询怎么优化",
    "Redis 缓存 TTL 怎么设", "Postgres 慢查询怎么优化", "怎么给 Agent 加限流",
    "怎么给 Agent 加限流", "Postgres 慢查询怎么优化",
]
t = time.perf_counter()
for q in BATCH:
    cache.retrieve(WS, q, 3)
elapsed = time.perf_counter() - t
total = cache.ret_hits + cache.ret_misses
print(f"批量 {len(BATCH)} 次请求: 命中 {cache.ret_hits} / 未命中 {cache.ret_misses}"
      f"  命中率 {cache.ret_hits / total:.0%}  总耗时 {elapsed * 1000:.0f} ms")
etotal = cache.emb_hits + cache.emb_misses
print(f"嵌入: 命中 {cache.emb_hits} / 未命中 {cache.emb_misses}  命中率 {cache.emb_hits / etotal:.0%}")
_, st3 = cache.retrieve("ws-other", "Redis 缓存 TTL 怎么设", 3)
print(f"跨 workspace 同 query: {st3}(key 带 workspace_id,故不命中)")
for k in sorted(R.keys("*")):
    print(f"  {k}  ttl={R.ttl(k)}s")

实测输出:

批量 8 次请求: 命中 7 / 未命中 3  命中率 70%  总耗时 154 ms
嵌入: 命中 1 / 未命中 1  命中率 50%
跨 workspace 同 query: miss(key 带 workspace_id,故不命中)
  emb:ws-acme:eaa7c783f8ad98a6  ttl=86400s
  ret:ws-acme:c162db3574b5ca61:3  ttl=300s
  ret:ws-acme:d7f7cc5fd20cd386:3  ttl=300s
  ret:ws-acme:eaa7c783f8ad98a6:3  ttl=300s
  ret:ws-other:eaa7c783f8ad98a6:3  ttl=300s

注意这里的命中数是 7,但总请求是 10(前两次单测 + 批量的 8 次),因为计数器是跨批次累积的。批量里 3 个不同问题各首跑一次(3 miss)、其余 5 次命中(5 hit),加上前面单测的 1 miss + 1 hit,合计 7 hit / 3 miss = 70%。这个数字说明:只要问题有重复,缓存就能吃掉大部分回源。

最后一行是租户隔离的验证:ws-other 用完全相同的 query 请求,结果 miss。因为 key 里带了 workspace_id,ret:ws-acme:... 和 ret:ws-other:... 是两个不同的 key,谁也命中不了谁。翻到 KEYS * 的输出也能看到两条 eaa7c783f8ad98a6 并排存在、各自带自己的 TTL——指纹相同、前缀不同,隔离生效。

6.1.5 缓存粒度:缓存零件,不缓存整机

新手最容易犯的错,是直接把整个 run 的结果当缓存对象:run:<workspace>:<query_hash> -> 最终答案。这看起来最省事——一次命中直接返回报告,连模型都不用调——但它踩了三个坑。

第一,粒度太粗,命中率骤降。整机结果的 key 必须包含全部输入:query、检索到的语料版本、工具返回、甚至模型名与温度。任何一个变了,key 就不同,缓存就 miss。而零件缓存(比如嵌入)的 key 只依赖一个变量(文本),稳定得多,命中率高得多。

第二,答案不该被缓存。模型生成是不确定的,同一问题两次回答可能措辞不同;更重要的是,缓存一个旧答案会让 Agent 的「思考」完全冻结,用户改了后续追问、语料更新了,它还在吐旧报告。缓存中间产物(检索、嵌入)没有这个问题——它们是确定的事实,不是生成的判断。

第三,越粗的缓存越难失效。语料更新时,你希望失效的是「检索结果」这一层;可如果只缓存了整机结果,你就只能整份作废,连带着没变的嵌入也一起丢了。分层的零件缓存让失效可以精准到最小单位。

缓存粒度key 依赖命中率失效难度建议
整机 run 结果全部输入低高,只能整份作废不推荐
检索结果workspace + query + top_k中低,语料更新时删对应 key推荐
嵌入向量workspace + text高极低,几乎不用失效强烈推荐

一句话:缓存零件,不缓存整机。零件是确定的事实、依赖少、命中高、失效准;整机是生成的判断、依赖多、命中低、失效糙。

6.1.6 TTL 策略表

缓存不能永久,TTL 就是「这份数据多久后算过期」的承诺。三类缓存的时效性诉求完全不同:

缓存对象建议 TTL依据过期后果
嵌入向量24 小时文本→向量是纯函数,几乎不变无,重算即可
检索结果5 分钟语料可能更新、索引可能重建可能返回略旧的召回集
会话摘要30 分钟对话在推进,摘要随时变旧上下文不一致

三行的规律是:越「纯函数」的越可以长 TTL,越「随状态变」的越要短。嵌入是纯粹的 text -> vector,同一文本永远同一个向量,24 小时(甚至更久)都安全;检索结果依赖语料和索引,语料一更新旧结果就错了,所以只能给分钟级;会话摘要是对话状态的快照,对话每多一轮它就该重算,TTL 最短。

TTL 还有两个工程细节。其一,TTL 要带抖动——如果所有 key 都在同一时刻批量写入、又设同一个 TTL,它们会同时过期,形成「雪崩」,所以真实系统会给 TTL 加 ±10% 的随机偏移。其二,TTL 是兜底不是精度——别指望「TTL 一到数据就准」,主动失效(语料更新时显式删 key)才是保证新鲜度的正路,TTL 只负责清理那些没人管、已经烂掉的条目。

6.1.7 穿透、击穿、雪崩在 Agent 场景的形态

教科书上缓存三大故障是抽象概念,落到 Agent 服务里各有具体长相:

故障通用定义Agent 场景的具体形态对策
穿透查不存在的数据,次次打到后端用户问语料里根本没有的问题,key 永远 miss缓存空结果(短 TTL),或布隆过滤器挡
击穿某个热 key 过期瞬间,大量请求同时回源热门问题缓存刚过期,一波请求全去跑嵌入单飞(single-flight):只放一个请求回源,其余等结果
雪崩大量 key 同时过期,回源洪峰压垮后端早高峰批量写入的缓存同时到期TTL 加随机抖动,分批预热

穿透在 Agent 场景特别常见:用户问的很多问题语料里就是没有答案,检索回空,可如果空结果不缓存,每次问都白跑一遍昂贵的检索。对策是把「空」也缓存起来(给一个更短的 TTL,比如 30 秒),这样同一个无效问题短时间内只回源一次。

击穿的典型是「热门问题刚好过期」:一个被高频问的问题,缓存 TTL 到点,此刻恰好有几个并发请求同时发现 miss,一起冲向嵌入服务——这就是缓存最不该造成的负载放大。对策是单飞锁:miss 时不是每个请求都回源,而是抢一把锁,抢到的那个回源并写缓存,其余请求短暂等待后重读缓存。Redis 的 SET key value NX 天然适合做这把锁:

def get_or_load(self, key: str, loader, ttl: int):
    raw = self.r.get(key)
    if raw is not None:
        return json.loads(raw), "hit"
    lock = f"{key}:lock"
    # SET NX 抢锁,锁自身也带 TTL 防死锁
    if self.r.set(lock, "1", nx=True, ex=5):
        try:
            value = loader()                      # 只有一个请求回源
            self.r.set(key, json.dumps(value), ex=ttl)
            return value, "miss"
        finally:
            self.r.delete(lock)
    # 没抢到锁:短暂等待后重读,仍 miss 则自己回源兜底
    time.sleep(0.05)
    raw = self.r.get(key)
    if raw is not None:
        return json.loads(raw), "hit-after-wait"
    return loader(), "miss-fallback"

单飞的本质是把并发的回源压成一次:锁只保护「回源」这一小段,写缓存后立刻释放,其余请求要么等到结果、要么在兜底路径上自己回源。注意兜底那行——锁不能成为单点故障,如果持锁的请求崩了,等待的请求必须能自己顶上,否则一次崩溃会拖垮整个热门查询。

雪崩的诱因在 Agent 里往往是「预热点」:服务启动时批量预热了一堆热门 query 的缓存,都设了同样的 TTL,于是它们会在同一秒集体过期。对策是给 TTL 加抖动,让过期时间摊开;再配合分批预热,避免洪峰。

一句话记牢:穿透是「查了不存在的东西」,击穿是「一个热点突然失效」,雪崩是「一片缓存同时失效」——三者的对策分别是「缓存空值」「单飞锁」「TTL 抖动」。

真实环境会怎样:本节的回源是 time.sleep 模拟的,真实耗时由嵌入 API 的网络往返和向量库的检索规模决定,可能是几百毫秒到几秒,缓存收益更大。真实系统里还有三个本机演示没覆盖的点:一是缓存要落指标(命中率、回源耗时、key 数量)进 13 章的看板,否则命中率掉了没人知道;二是写缓存要考虑一致性,语料更新时要主动失效对应的检索 key,否则用户会拿到旧结果;三是Redis 通常不是单点,生产上是哨兵或集群,key 的分布与故障转移都要考虑。本节刻意只做「检索 + 嵌入」两个最纯的缓存,正是因为它们的失效模型最简单——把最简的做对,再去碰有状态的会话缓存。

阅读导航:上一节:5.3 查询优化:EXPLAIN 定位慢查询并加索引 · 下一节:6.2 语义缓存:相似问题复用与错配检测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距