Chapter 08

缓存策略:省钱的第一道防线

LLM 调用贵且慢。同一个 prompt 问十次没必要花十次钱。LiteLLM 的 Cache 一行配置就能把重复请求拦住,但"什么能缓存、什么不能、key 由什么决定",决定了你省的是钱还是踩的是坑。

为什么要缓存 LLM 调用

先算一笔账。假设你做一个文档问答产品,每天 10 万次请求,平均输入 2000 token、输出 500 token,用 GPT-4o:

无缓存
  • Input:10w × 2000 × $2.5/M = $500/天
  • Output:10w × 500 × $10/M = $500/天
  • 平均延迟:2.5s
  • 月成本:$30,000
加缓存(命中率 40%)
  • 未命中 6w 次:$600
  • 命中 4w 次:Redis 几乎 $0
  • 命中时延迟:10ms
  • 月成本:$18,000,省 40%

缓存对 LLM 的价值比传统 Web 更大——一次调用几秒、几分钱;命中一次就省几分钱,还顺便救了用户的等待时间。有 LLM 应用不做缓存,基本等于把钱烧掉。

精确缓存 vs 语义缓存

缓存的核心是"给请求一个 key,再从 key 反查 response"。LLM 缓存有两种流派:

类型key 决定方式命中条件适合场景开销
精确缓存(exact) hash(messages + model + temperature + tools + …) 输入完全一致 系统提示固定、FAQ、重复校验、批处理 极低(hash 一下)
语义缓存(semantic) embedding(user query) + 相似度阈值 语义足够相近 客服、知识问答、搜索 每次请求要跑 embedding(便宜,约 $0.02/M)
先默认精确缓存。语义缓存听起来香,但踩坑概率高得多:相似度阈值难调,不同问题被当成同一个、把错答案缓存下来,都是常见事故。精确缓存虽然命中率看上去低,但100% 安全——hash 一致的请求结果一定应该一致。只有系统提示里显式要求"自然语言问答"的场景,再考虑上语义缓存。

开箱即用:in-memory Cache

最简单的一行配置:

import litellm
from litellm import completion
from litellm.caching.caching import Cache

litellm.cache = Cache(type="local")   # 进程内内存 dict, 秒级上手

def ask(q):
    return completion(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": q}],
        caching=True,          # 明确开启本次请求参与缓存
    )

r1 = ask("地球到月球多远?")    # 真调用, ~1.2s
r2 = ask("地球到月球多远?")    # 命中缓存, <1ms

print(r2._hidden_params["cache_hit"])   # True

关键点三个:

in-memory 不适合生产。只要多进程/多容器就各自一份,命中率急剧下降;进程重启缓存清空。它只适合开发调试和单机脚本。生产必须 Redis。

生产主力:Redis 缓存

litellm.cache = Cache(
    type="redis",
    host=os.getenv("REDIS_HOST"),
    port=6379,
    password=os.getenv("REDIS_PASSWORD"),
    ttl=3600,                     # 默认 TTL 1 小时
    namespace="prod:llm",          # 加前缀, 多业务共享 Redis 不串
    redis_flush_size=100,          # 批量写入, 降 Redis QPS
)

Redis 是 LLM 缓存的默认答案。原因:

归档缓存:S3 / GCS

S3 缓存的定位不是"热缓存",而是永久归档 + 审计。一次调用的请求和响应落到对象存储里,做这些事:

litellm.cache = Cache(
    type="s3",
    s3_bucket_name="my-llm-cache",
    s3_region_name="us-east-1",
    s3_aws_access_key_id=os.getenv("AWS_KEY"),
    s3_aws_secret_access_key=os.getenv("AWS_SECRET"),
)
S3 不要单独作为热缓存。它每次 GET 都要走 HTTPS、几十到上百毫秒,还按请求数收费。正确的姿势是双层缓存:Redis 热 + S3 冷。

双层缓存:Dual Cache

"热数据走 Redis,冷数据走 S3"——这是大量生产系统的默认架构。LiteLLM 支持直接配两层:

litellm.cache = Cache(
    type="redis-semantic",     # 或 "dual-cache" (版本相关, 看文档)
    host="...",
    ttl=1800,                  # Redis 热层 30 分钟

    # 冷层落 S3
    s3_bucket_name="my-llm-archive",
    s3_region_name="us-east-1",
)

读取顺序:Redis → S3 → 真调 LLM → 同步写 Redis + 异步写 S3。对调用方完全透明,命中率能比纯 Redis 高 5–15 个百分点。

cache key 的决定因素

缓存正确性的 80% 问题,全都是 key 算错了。LiteLLM 默认把所有影响 LLM 输出的参数都进 key:

参数影响 key?备注
modelgpt-4o 和 claude-sonnet 的结果当然不同
messages逐字 hash,一个字符都不能差
temperature0.0 和 0.7 是两把缓存
top_p / seed / max_tokens同上
tools / tool_choice工具定义变了行为就变
response_format要 JSON 和要自由文本是两个世界
user(OpenAI 审计字段)业务层用,与内容无关,自动过滤
metadata默认不影响,除非自己配
caching 本身开关字段,自动过滤

如果你有跨用户不应共享的场景——比如私有数据问答——要把用户 ID 塞进 key:

completion(
    model="gpt-4o",
    messages=msgs,
    caching=True,
    metadata={"cache_key": f"user-{user_id}-{hash(prompt)}"},
)

或者更简单——在 messages 里拼用户身份,让 hash 天然区分。

per-call 控制 caching

全局开了缓存,但某些请求不能走缓存——比如"当前时间"、"最新新闻"、"生成一首新诗"。用 caching=False 单点关闭:

def ask_news(q):
    return completion(
        model="gpt-4o",
        messages=[{"role": "user", "content": q}],
        caching=False,     # 这次不读不写缓存
    )

def ask_faq(q):
    return completion(
        model="gpt-4o",
        messages=[{"role": "user", "content": q}],
        caching=True,      # FAQ 场景, 积极缓存
        ttl=86400,           # 本次结果缓存 1 天
    )

还有两个精细开关:

什么内容不该缓存

血的教训清单:

流式响应的缓存

"流式"和"缓存"天生矛盾:缓存返回的是完整响应,而流式是逐字吐。LiteLLM 的解法是仿真回放——命中缓存时把完整 response 切成 chunk 逐个 yield,前端感知不到差异。

litellm.cache = Cache(type="redis", host="...")

def chat_stream(q):
    return completion(
        model="gpt-4o",
        messages=[{"role": "user", "content": q}],
        stream=True,
        caching=True,
    )

# 第一次: 真流 2.5s
for chunk in chat_stream("Hello"):
    print(chunk.choices[0].delta.content, end="")

# 第二次: 命中缓存, 瞬间 "流"完
for chunk in chat_stream("Hello"):
    print(chunk.choices[0].delta.content, end="")

用户体验的影响:命中缓存时几乎瞬间出完答案,可能比真调还快。如果你的产品依赖"逐字出字"的节奏感,命中缓存时加一个 5–10ms 的间隔模拟一下即可。

Router 里开缓存

Router 的 completion 同样支持 caching=True。更常见的配置方式是在 Router 级别打开:

router = Router(
    model_list=model_list,
    routing_strategy="usage-based-routing-v2",
    redis_host=os.getenv("REDIS_HOST"),
    redis_port=6379,
    cache_responses=True,     # ← 一行开启全局缓存
    cache_kwargs={"ttl": 1800},
)

resp = router.completion(
    model="chat",
    messages=msgs,
    caching=True,
)

优势:Router 的限流计数 + 缓存共用同一个 Redis,运维极简。

语义缓存(选读)

如果业务是"客服问答、FAQ、知识库检索",用户问法千差万别但答案该相同,可以考虑语义缓存:

litellm.cache = Cache(
    type="redis-semantic",
    host=os.getenv("REDIS_HOST"),
    similarity_threshold=0.9,           # 0.0–1.0, 越接近 1 越严
    redis_semantic_cache_embedding_model="text-embedding-3-small",
    ttl=3600,
)

r1 = completion(model="gpt-4o",
              messages=[{"role":"user","content":"退款要几天?"}],
              caching=True)

r2 = completion(model="gpt-4o",
              messages=[{"role":"user","content":"退钱要等多久?"}],
              caching=True)   # 相似度 > 0.9, 命中 r1
语义缓存的三个坑,上线前必须知道:
  1. 阈值玄学。0.85–0.95 之间每 0.01 都可能从"神器"变"灾难"。先用 500 条历史 QA 灰度评估。
  2. 否定会翻车。"iPhone 支持无线充电" 和 "iPhone 不支持无线充电" 在 embedding 空间可能很近——但答案相反。
  3. embedding 要钱要时间。每次请求多一次 API 调用,如果缓存命中率不到 20%,反而更贵。先确认命中率再上。

观察缓存效果

上线以后至少要盯三个指标:

resp = completion(model="gpt-4o", messages=msgs, caching=True)

hit = resp._hidden_params.get("cache_hit", False)
cost = resp._hidden_params.get("response_cost", 0)
latency = resp._response_ms

logger.info(f"hit={hit} cost=${cost:.4f} lat={latency}ms")

仪表盘里做三件事:

常见坑位汇总

  1. prompt 里夹了 datetime.now():每次时间戳不同,100% miss。定期变的值要么去掉,要么四舍五入到小时。
  2. 系统提示被反复改:每改一次等于全盘失效,发版当天命中率清零。改完定一个 warmup 任务跑常见问题预热。
  3. 在 dev 忘了关:改完 prompt 测 A/B,结果全是缓存旧答,以为新 prompt 没生效——开发环境把 caching=False 或直接不配 Cache。
  4. 用户隔离没做:甲的私有合同内容命中了乙的请求。含用户私有数据的上下文,要么不缓存,要么 key 里带 user_id
  5. Redis 容量塞爆:没设 ttl,一年后 Redis 几百 G,eviction 直接打满 CPU。生产必须 TTL + maxmemory-policy allkeys-lru
  6. 语义缓存阈值没评估直接上:"客服说退款要 7 天" 被匹配到 "退款可以退 100%" 的答案。上线前用 500 条历史数据评估
  7. 流式+工具调用场景缓存了第一轮:第一轮返回 tool_calls 缓存下来了,但工具执行后的第二轮不该缓存——建议含 tools 的请求手动 caching=False
  8. 多 worker in-memory 各跑各的:uvicorn 4 worker,每个进程一份 local cache,命中率被 4 整除还不自知。生产禁用 local,强制 Redis。

决策树:我该怎么配

场景缓存类型TTL备注
FAQ / 固定话术Redis exact24h+命中率目标 >60%
文档问答(私有)Redis exact + user_id2hkey 带 user_id
智能客服(自由问)Redis semantic,阈值 0.926h灰度后再加大
创意写作不缓存 / temp=0 时才缓存缓存会毁掉随机性
实时信息(天气/股价)不缓存或缓存 tool 结果 30s
批处理 / 离线Redis + S3 双层永久 S3便于重算
本地脚本测试local1h省钱

本章小结