为什么要缓存 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
- 未命中 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) |
开箱即用: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
关键点三个:
litellm.cache = Cache(...):全局设置,整个进程所有completion()调用共享这把缓存。caching=True:在每次调用上显式声明"这一次参与缓存"。不加就不读也不写缓存——这是显式契约而非默认偷偷做。_hidden_params["cache_hit"]:返回体里能看到这次是不是命中,监控/日志必用。
生产主力: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 缓存的默认答案。原因:
- 毫秒级读写,对 LLM 秒级延迟来说基本零开销。
- 多实例共享——Router 起 N 个 worker,命中率累加而不是被拆分。
- 天生支持 TTL,过期自动淘汰。
- 你公司大概率已经有一个 Redis(session/队列)。直接复用。
归档缓存:S3 / GCS
S3 缓存的定位不是"热缓存",而是永久归档 + 审计。一次调用的请求和响应落到对象存储里,做这些事:
- 过去 3 个月所有用户提问重放一遍,评估新模型效果(离线 eval)。
- 合规留存,监管方随时能审请求日志。
- 冷启动新服务时,从历史 prompt 预热 Redis。
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"), )
双层缓存: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? | 备注 |
|---|---|---|
model | ✅ | gpt-4o 和 claude-sonnet 的结果当然不同 |
messages | ✅ | 逐字 hash,一个字符都不能差 |
temperature | ✅ | 0.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 天 )
还有两个精细开关:
no-cache: true(放 metadata 里):跳过读缓存,但仍写入。用于"刷新一次结果但后续能命中"。no-store: true:读缓存但不写入。用于临时调试、A/B 实验。
什么内容不该缓存
- 带当前时间 / 当前价格 / 当前库存的 prompt——会给用户看旧数据。
- 高温度的创意生成(写作 / 图像 prompt / 故事)——用户要多样性,结果一样就没意义了。
- 带工具调用的第一轮——tool_calls 返回给前端,但第二轮的工具结果往往不该缓存(工具每次结果不同)。
- 用户私有数据——上下文里有甲用户的合同,不能让乙用户命中同样 hash。
- 流式调试——开发阶段你在改 prompt,缓存命中会以为没生效。开发环境禁用缓存。
- 带随机种子之外的概率采样——temperature>0 + seed 未指定时,结果本就会漂移,缓存反而把漂移固化了。
流式响应的缓存
"流式"和"缓存"天生矛盾:缓存返回的是完整响应,而流式是逐字吐。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
- 阈值玄学。0.85–0.95 之间每 0.01 都可能从"神器"变"灾难"。先用 500 条历史 QA 灰度评估。
- 否定会翻车。"iPhone 支持无线充电" 和 "iPhone 不支持无线充电" 在 embedding 空间可能很近——但答案相反。
- 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")
仪表盘里做三件事:
- 命中率曲线按业务分桶——发现某一桶命中率跌了,通常是 prompt 被某处改了哪怕一个空格。
- 每小时省的钱——给老板看的。用
sum(cost_without_cache - cost_with_cache)。 - p50 / p99 延迟对比(命中 vs 未命中)——p99 差距越大,缓存给用户的体感收益越大。
常见坑位汇总
- prompt 里夹了
datetime.now():每次时间戳不同,100% miss。定期变的值要么去掉,要么四舍五入到小时。 - 系统提示被反复改:每改一次等于全盘失效,发版当天命中率清零。改完定一个 warmup 任务跑常见问题预热。
- 在 dev 忘了关:改完 prompt 测 A/B,结果全是缓存旧答,以为新 prompt 没生效——开发环境把
caching=False或直接不配 Cache。 - 用户隔离没做:甲的私有合同内容命中了乙的请求。含用户私有数据的上下文,要么不缓存,要么 key 里带 user_id。
- Redis 容量塞爆:没设
ttl,一年后 Redis 几百 G,eviction 直接打满 CPU。生产必须 TTL +maxmemory-policy allkeys-lru。 - 语义缓存阈值没评估直接上:"客服说退款要 7 天" 被匹配到 "退款可以退 100%" 的答案。上线前用 500 条历史数据评估。
- 流式+工具调用场景缓存了第一轮:第一轮返回 tool_calls 缓存下来了,但工具执行后的第二轮不该缓存——建议含
tools的请求手动caching=False。 - 多 worker in-memory 各跑各的:uvicorn 4 worker,每个进程一份 local cache,命中率被 4 整除还不自知。生产禁用 local,强制 Redis。
决策树:我该怎么配
| 场景 | 缓存类型 | TTL | 备注 |
|---|---|---|---|
| FAQ / 固定话术 | Redis exact | 24h+ | 命中率目标 >60% |
| 文档问答(私有) | Redis exact + user_id | 2h | key 带 user_id |
| 智能客服(自由问) | Redis semantic,阈值 0.92 | 6h | 灰度后再加大 |
| 创意写作 | 不缓存 / temp=0 时才缓存 | — | 缓存会毁掉随机性 |
| 实时信息(天气/股价) | 不缓存 | — | 或缓存 tool 结果 30s |
| 批处理 / 离线 | Redis + S3 双层 | 永久 S3 | 便于重算 |
| 本地脚本测试 | local | 1h | 省钱 |
本章小结
- 缓存是 LLM 应用的第一道省钱防线——命中率 40% 就能省下 40% 成本,同时把延迟拉到 10ms
- 两种流派:精确缓存(100% 安全,默认选)vs 语义缓存(客服类高价值但有坑)
- 后端三件套:local(开发)/ Redis(生产主力)/ S3(归档),Redis+S3 双层是常见架构
caching=True/False逐次控制、ttl精细过期、no-cache / no-store读写分离- cache key 由模型/messages/温度/tools/response_format 共同决定——私有数据要把 user_id 塞进 key
- 流式命中时 LiteLLM 自动仿真回放,前端无感
- 不该缓存的五类:含时间/价格/工具结果/创意生成/高温度随机
- 上线必须监控命中率、省钱金额、延迟分布,没有三件套就等于瞎猜