05 · 最佳实践与排障¶
本文给场景化建议、坑、调试方法。先读 01-mechanism.md 理解机制,否则下面的建议没办法判断是否适用。
1. 适用 / 不适用场景判断¶
1.1 强烈推荐启用¶
场景特征:
- 同一个
model_name下有 ≥2 个 deployment(多账户 AWS / 多区域 / 多中转站) - 各 deployment 背后的 prompt cache pool 是独立的(不是同一个 Anthropic key 的多个 LiteLLM 入口)
- 客户端会主动在 messages 里标
cache_control块 - 业务 prompt 普遍 >1024 tokens
- 你想最大化命中率(同前缀的请求落到同一 deployment)
→ 配置见 02-config-reference.md §7.2 生产推荐.
1.2 不必启用¶
场景特征:
- 单一 deployment(router 没得选)
- 所有 deployment 背后是同一个上游账户 / key(cache 全局共享,黏不黏都一样)
- 客户端从不主动标
cache_control(OpenAI 兼容场景常见) - prompt 普遍 <1024 tokens
→ 启了也是 no-op,但没有副作用,留着不影响。
1.3 启用反而有害¶
场景特征:
- 所有 deployment 中有一个根本不支持 prompt cache(中转站偷偷剥离
cache_control) - 这个不支持的 deployment 会被 LiteLLM 误绑定(因为只看 request 不看 response,详见 01-mechanism.md §5.2)
- 结果:流量集中到这个 deployment,cache 一直未命中,其它 deployment 闲置
→ 先确认所有 deployment 都真支持 prompt cache 再开。验证方法见 §5.
2. 中转站排雷¶
2.1 三件事必须验证(详细版)¶
2.1.1 中转站会不会把 cache_control 字段透传到上游?¶
抓包 / 看中转站日志,确认 LiteLLM → 中转站 → Anthropic 的请求 body 里 cache_control 还在。
如果中转站走严格 OpenAI schema validation(拒绝 unknown fields),cache_control 会被剥离。
取舍:
- 如果剥离 → 该中转站不支持 prompt cache,不要把它列为 prompt cache 候选
- 如果不剥离 → 进入下一步验证
2.1.2 中转站会不会返回 cache_creation_input_tokens / cache_read_input_tokens?¶
LiteLLM 不依赖这个判断路由黏性,但依赖它计费。返回字段缺失会导致:
- 路由黏性正常(看 request 即可)
- 但 cache 部分按全价计费(04-billing-and-cost.md §3.1.3)
2.1.3 几个中转站背后是不是同一个 Anthropic key?¶
如果你的 N 个中转站是同一个 organization 下的 key 或者中转站之间互相共享 key pool,它们的 prompt cache 是共享的。任何 deployment 命中都同样。这种情况下:
- 路由黏性没有节省成本(cache 已经全局共享)
- 但黏性可能造成负载不均
→ 这种场景不要开 prompt_caching 路由。
2.2 最小验证脚本¶
import os, litellm
os.environ["LITELLM_LOG"] = "DEBUG"
litellm.set_verbose = True
SYSTEM_PROMPT = "..." * 2000 # 确保 >1024 tokens
resp1 = litellm.completion(
model="openai/claude-sonnet-4-5",
api_base="https://relay-a.example.com/v1",
api_key=os.environ["RELAY_A_KEY"],
messages=[
{"role": "system", "content": [
{"type": "text", "text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}}
]},
{"role": "user", "content": "查询 A"}
],
)
print("Pass 1 usage:", resp1.usage)
# 期望: cache_creation_input_tokens > 0, cache_read_input_tokens == 0
# 失败模式:
# - 字段全部 None → 中转站剥离了 cache 字段
# - cache_creation_input_tokens == 0 → 中转站没把 cache_control 传到上游
import time
time.sleep(2)
resp2 = litellm.completion(
model="openai/claude-sonnet-4-5",
api_base="https://relay-a.example.com/v1",
api_key=os.environ["RELAY_A_KEY"],
messages=[
{"role": "system", "content": [
{"type": "text", "text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}}
]},
{"role": "user", "content": "查询 B"}
],
)
print("Pass 2 usage:", resp2.usage)
# 期望: cache_read_input_tokens > 0
# 失败 → cache 没命中
对每个中转站都跑一遍。只有两次结果都符合期望的中转站才适合纳入 prompt_caching 候选池。
3. 多 Pod 部署注意事项¶
3.1 Redis 必须配¶
01-mechanism.md §6.4 多 Pod 行为 已说明:不配 Redis 时映射只在单 Pod 进程内存里,重启 / 跨 Pod 全失效。
router_settings:
redis_host: os.environ/REDIS_HOST
redis_port: os.environ/REDIS_PORT
redis_password: os.environ/REDIS_PASSWORD
optional_pre_call_checks: ["prompt_caching"]
3.2 Redis 读写性能¶
每个请求会触发: - 路由前:1 次 Redis GET(读 cache_key) - 成功后:1 次 Redis SET with TTL(写映射)
→ QPS 高时 Redis 是瓶颈。DualCache 的内存层会吸收热点 cache_key(同一 Pod 反复命中同 key 时不打 Redis),缓解压力。
实测建议:观察 Redis 的 INFO commandstats 看 GET/SET 比例。如果 SET 远多于 GET,说明黏性命中率低,需要排查 §5。
3.3 不要混用多套 Redis¶
如果 router 配的 Redis 跟 litellm_settings.cache_params 配的 Redis 是不同实例,prompt_caching 和 response 缓存各自存各自的 → 这是合法但容易混淆。建议同一实例不同 db。
4. 跟其它机制叠加¶
4.1 跟 deployment_affinity 共存¶
执行顺序:列表顺序串行。第一个命中后立刻锁死,第二个拿到单元素列表 no-op。
建议顺序:prompt_caching 在前 —— 因为它有具体的"为什么必须黏到这个 deployment"的理由(cache 已经在那),而 deployment_affinity 是软偏好(同 user 尽量同 deployment)。
4.2 跟 cooldown 的关系¶
如果 PromptCachingDeploymentCheck 命中的 deployment 当前正在 cooldown:
_filter_cooldown_deployments在 prompt_caching filter 之前还是之后过滤?- 答:cooldown 是路由的另一关,发生在
_run_pre_call_checks调用之前。async_filter_deployments拿到的healthy_deployments已经是 cooldown 过滤后的列表。 - 所以:被 cooldown 的 deployment 不会出现在 healthy_deployments,prompt_caching 找不到匹配 → 返回原 healthy_deployments → 该次请求走 routing_strategy 兜底
- 下次 cooldown 解除后,新请求会重新建立映射
→ cooldown 期间黏性失效是合理设计,不算 bug。
4.3 跟 litellm_settings.cache: true 的关系¶
02-config-reference.md §4.1 已说明:
- 两个机制独立,可同时开
- 但 response 缓存命中时根本不打上游 → prompt cache 黏性形同虚设
- 一般场景下选其一即可:
- 响应稳定 / 容忍稍微过期 → 用 response 缓存
- 响应必须实时 / 但 prompt 前缀稳定 → 用 prompt cache 路由黏性
4.4 跟 routing_strategy 的关系¶
任何 routing_strategy 都兼容。prompt_caching 命中时优先级最高(强制路由),未命中时走 routing_strategy 兜底。
冷启动行为:
T0 (router 刚启动):
Pod A 收到第 1 个含 cache_control 的请求 X
routing_strategy=latency-based → 选 deployment_1
prompt cache: 无映射 → 不干预
请求成功 → async_log_success_event 写入: cache_key(X) → deployment_1
T0+0.1s:
Pod A 收到第 2 个相同前缀的请求
prompt cache: 命中 → 强制路由到 deployment_1 ✅
→ 第 1 个请求走 routing_strategy,之后才有黏性。这是必然的"冷启动"行为。
5. 调试与排障¶
5.1 没生效?分层定位¶
flowchart TD
Q[路由黏性没生效] --> S1{router 配了<br/>optional_pre_call_checks=<br/>prompt_caching ?}
S1 -- 否 --> F1[在 router_settings 加]
S1 -- 是 --> S2{messages 里有<br/>cache_control ?}
S2 -- 否 --> F2[客户端打 cache_control 标记]
S2 -- 是 --> S3{token >= 1024 ?}
S3 -- 否 --> F3[降低 MINIMUM_PROMPT_CACHE_TOKEN_COUNT<br/>或增大 prompt]
S3 -- 是 --> S4{Redis 有 deployment:*:<br/>prompt_caching keys?}
S4 -- 否 --> F4[第一次请求后才有<br/>等 1 次成功调用]
S4 -- 是 --> S5{后续请求路由到了<br/>同一个 deployment ?}
S5 -- 否 --> F5[cache_key 不一致<br/>看 extract_cacheable_prefix 截取]
S5 -- 是 --> OK[正常工作]
style OK fill:#dfd,stroke:#3a3
5.2 关键日志开关¶
PromptCachingDeploymentCheck.async_log_success_event 在 行 66-83 有 3 处 verbose_logger.debug —— 看 stdout 里有没有:
litellm.router_utils.pre_call_checks.prompt_caching_deployment_check: skipping adding model id to prompt caching cache, CALL TYPE IS NOT COMPLETION or ANTHROPIC MESSAGE
litellm.router_utils.pre_call_checks.prompt_caching_deployment_check: skipping adding model id to prompt caching cache, MESSAGES IS NOT A LIST
litellm.router_utils.pre_call_checks.prompt_caching_deployment_check: skipping adding model id to prompt caching cache, MODEL ID IS NONE
任一出现 → 写入跳过了,路由黏性永远建不起来。
5.3 HTTP-only 验证(无 kubectl / redis-cli 时)¶
如果你没有线上 shell / Redis CLI / 容器日志访问权限,只能通过 HTTP API 验证,按这个顺序:
a. 看 callback 是否注册¶
期望在返回中看到 PromptCachingDeploymentCheck 类名。看不到 → 配置没生效或没重启 proxy。
b. 看 router_settings 是否含字段¶
curl -s "$PROXY/router/settings" \
-H "Authorization: Bearer $ADMIN_KEY" \
| jq '.current_values | with_entries(select(.key | startswith("optional") or startswith("enable_pre")))'
⚠️ 注意:optional_pre_call_checks 不在 UI 白名单(02-config-reference.md §9),返回可能不含此字段即使后端实际生效。所以这一步只能确认"配进去了",不能确认"没配进去"。
c. 跑实际请求看 usage(最权威)¶
发两个相同 cache_control 前缀、不同 user message 的请求到同一 model_name:
# 第 1 次:期望 cache_creation_input_tokens > 0
curl -X POST "$PROXY/v1/chat/completions" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-6",
"messages": [
{"role": "system", "content": [{
"type": "text",
"text": "<重复 500 次的稳定文本,确保 >1024 tokens>",
"cache_control": {"type": "ephemeral"}
}]},
{"role": "user", "content": "查询 A"}
]
}' | jq '{usage: .usage, model: .model, model_id: ._response_id}'
sleep 2
# 第 2 次:期望 cache_read_input_tokens > 0,且 model_id 跟第 1 次一致
curl -X POST "$PROXY/v1/chat/completions" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{...同上但 user content 改为 "查询 B"...}' \
| jq '{usage: .usage, model: .model, model_id: ._response_id}'
判定:
| 第 1 次 | 第 2 次 | 含义 |
|---|---|---|
cache_creation_input_tokens > 0 |
cache_read_input_tokens > 0 |
✅ 上游 cache + 路由黏性都通 |
cache_creation_input_tokens > 0 |
cache_creation_input_tokens > 0(又写一次) |
⚠️ 上游 cache 通,但路由黏性失败(第 2 次落到不同 deployment 重新建 cache) |
| 全是 0 / null | 全是 0 / null | ❌ 上游 / 中转站没透传 cache_control,整套机制失败 |
d. 看 SpendLogs 或 S3 logs¶
如果你们配了 s3_callback_params(很多 prod 都配了),可以在 S3 看历史请求落到哪个 deployment:
# 用 LiteLLM /spend/logs 端点查
curl -s "$PROXY/spend/logs?api_key=$YOUR_API_KEY&start_date=2026-05-18" \
-H "Authorization: Bearer $ADMIN_KEY" \
| jq '.[] | {request_id, model, model_id, prompt_tokens, cache_hit_tokens: .request_tags}'
若同一 user 的连续请求 model_id 相同 → 黏性工作中。
5.4 直接看 Redis¶
.venv312/bin/python -c "
import os, redis
r = redis.Redis(host=os.environ['REDIS_HOST'], port=int(os.environ['REDIS_PORT']),
password=os.environ.get('REDIS_PASSWORD'))
keys = r.keys('deployment:*:prompt_caching')
print(f'total keys: {len(keys)}')
for k in keys[:10]:
val = r.get(k)
ttl = r.ttl(k)
print(f' {k.decode()[:60]}... TTL={ttl}s value={val.decode() if val else None}')
"
期望看到:
判定:
| 现象 | 原因 |
|---|---|
| 0 keys | 没请求过 / 全部失败 / call_type 不对 / 没 cache_control |
| 有 keys 但 TTL < 5s | 黏性即将过期;流量不够维持续期 |
| 有 keys 但 value 的 model_id 不在 model_list | deployment 已下线,但旧映射没清理(无影响,filter 找不到匹配会跳过) |
| 同一 cache_key value 频繁变 | 不该出现;说明 cache_key 计算不稳定(看 §5.6) |
5.5 手工算 cache_key 对比¶
from litellm.router_utils.prompt_caching_cache import PromptCachingCache
messages = [
{"role": "system", "content": [
{"type": "text", "text": "your system prompt",
"cache_control": {"type": "ephemeral"}}
]},
{"role": "user", "content": "随便填"},
]
key = PromptCachingCache.get_prompt_caching_cache_key(messages, tools=None)
print(key)
# 期望: deployment:<64hex>:prompt_caching
# None 说明 messages 没有 cache_control 块
把客户端实际发的 messages 喂进来,跟 Redis 里的 key 对比,确认是不是同一个 hash。
5.6 cache_key 不稳定的常见原因¶
| 原因 | 现象 | 修复 |
|---|---|---|
| 客户端每次插入新字段(如 timestamp)到 cache_control 之前的消息里 | cache_key 每次都变 | 把可变字段放在 cache_control 之后 |
| Pydantic 对象 dict() 输出包含 datetime 等非稳定字段 | 同上 | 序列化前先剥离 |
| 不同 LiteLLM 版本对 messages 的预处理不同 | 升级后命中率突降 | check git diff |
5.7 验证客户端真的带了 cache_control¶
LiteLLM 收到的 request body 经过若干预处理才到 PromptCachingCache。直接打印 router 收到的 messages:
# 临时在 router.py async_function_with_fallbacks 入口加:
print("DEBUG router messages:", json.dumps(messages, indent=2)[:2000])
确认 cache_control 字段没被中途某个 hook 剥离(比如某些 guardrail 会改 content)。
6. 客户端 SDK 改造¶
6.1 把 system block 标记 cache_control(最简单)¶
# OpenAI SDK 风格
client.chat.completions.create(
model="claude-sonnet-4.5",
messages=[
{"role": "system", "content": [
{"type": "text", "text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}}
]},
{"role": "user", "content": user_query}
],
)
适用条件:
- system 是稳定的(不每次拼 timestamp / 随机种子)
- system >1024 tokens
- LiteLLM 模型走 Anthropic / Bedrock / Vertex Anthropic 路径
6.2 多轮对话的渐进式缓存¶
# 每次对话追加新轮次时,把上一轮也标记为可缓存
def build_messages(history, new_question):
messages = [{"role": "system", "content": [...]}] # 不变的 system
for i, turn in enumerate(history):
is_last_old = (i == len(history) - 1)
messages.append({
"role": turn["role"],
"content": (
[{"type": "text", "text": turn["text"],
"cache_control": {"type": "ephemeral"}}]
if is_last_old else turn["text"]
),
})
messages.append({"role": "user", "content": new_question})
return messages
→ 第 N 轮对话时,前 N-1 轮全部可缓存。LiteLLM 路由黏性会保持当前 session 落在同一 deployment。
6.3 工具定义稳定时也标记¶
tools = [
{
"type": "function",
"function": {"name": "...", "description": "...", "parameters": {...}},
"cache_control": {"type": "ephemeral"}
}
]
⚠️ 注意 LiteLLM 路由黏性当前不把 tools 算进 cache_key(01-mechanism.md §2.3)。这种标记只对上游 prompt cache 起作用(让 Anthropic 缓存工具定义),不影响 LiteLLM 路由选择。
7. 与路由策略的成本/性能权衡¶
7.1 latency-based-routing + prompt_caching¶
router_settings:
routing_strategy: latency-based-routing
optional_pre_call_checks: ["prompt_caching"]
行为:
- 首次请求(cache 未命中)→ 走 latency-based-routing 选当前最快的 deployment
- 黏性建立后 → 锁定到这个 deployment,无视后续延迟变化
- TTL 300s 内即使该 deployment 突然变慢,仍坚持路由
- 该 deployment cooldown 时 → 跳过该次请求,下次 latency-based 重新选
权衡:cache 节省的钱 vs 不能动态切到更快 deployment 的延迟代价。通常 cache 节省压倒一切,因为 latency 差异远小于 cache 价差异。
7.2 simple-shuffle + prompt_caching¶
行为:
- 首次请求随机选一个 deployment
- 黏性建立后锁定
- 长期来看每个 deployment 各自负责若干"黏性热点",整体均衡
权衡:负载分布最均衡,但有可能首次请求选到一个 cold cache 的 deployment(理论上其它 deployment 可能 warm)。
7.3 不要用 usage-based-routing + prompt_caching¶
# ⚠️ 不推荐
router_settings:
routing_strategy: usage-based-routing
optional_pre_call_checks: ["prompt_caching"]
usage-based-routing 会基于历史 token 数选 deployment,黏性会让某个 deployment 持续接到高 token 量请求,跟 usage-based-routing 的初衷(均衡用量)冲突。
8. 决策清单¶
回答以下问题,得出你应不应该开 prompt_caching:
1. 同 model_name 下有 ≥2 个 deployment 吗?
是 → 继续 否 → 不开(无意义)
2. 这些 deployment 背后的 cache pool 是独立的吗?
是 → 继续 否 → 不开(黏到哪都一样)
3. 客户端会主动在 messages 里加 cache_control 吗?
是 → 继续 否 → 不开(or 改客户端,参 §6)
4. 业务 prompt 普遍 >1024 tokens 吗?
是 → 继续 否 → 降低 MINIMUM_PROMPT_CACHE_TOKEN_COUNT 试试
5. 多 Pod 部署吗?
是 → 必须配 Redis 否 → 内存模式也行
6. 想要的成本节省 > 黏性带来的负载不均吗?
是 → 开 否 → 评估清楚再开
→ 全部通过 → 配置见 02-config-reference.md §7.2
9. 常见误区¶
9.1 ❌ "我配了 prompt_caching 但没看到缓存命中"¶
可能:
- 客户端没传
cache_control(最常见,约 80%) - 上游中转站剥离了
cache_control - token 数不够 1024
- 第 1 次请求肯定 cache miss,要看第 2 次起
排查见 §5.1。
9.2 ❌ "我想让所有请求都走 prompt cache,不想让用户传 cache_control"¶
LiteLLM 不支持自动注入 cache_control。但你可以在客户端 / 网关层做。
方案 A:客户端 SDK 包装层自动注入
def patched_completion(messages, **kw):
if messages and messages[0]["role"] == "system":
sys = messages[0]
if isinstance(sys["content"], str):
sys["content"] = [
{"type": "text", "text": sys["content"],
"cache_control": {"type": "ephemeral"}}
]
return litellm.completion(messages=messages, **kw)
方案 B:在 LiteLLM proxy 前加一层 nginx/envoy filter 改写 body(不推荐,运维负担重)
9.3 ❌ "我配置改了立刻看 Redis 应该有 keys"¶
写入是请求成功后才发生。配置完不发请求,Redis 永远空。先发一个合规请求再看。
9.4 ❌ "我用 OpenAI 模型也想开"¶
OpenAI 模型的客户端通常不传 cache_control(OpenAI 不识别这个字段),所以 LiteLLM 路由黏性建立不起来。
但 OpenAI 自己的 prompt cache 是自动的,前缀相同就命中。对 OpenAI 模型而言,让 routing_strategy 倾向稳定(如 simple-shuffle 但带 session 黏性)比开 prompt_caching 更直接。
9.5 ❌ "TTL 太短了,能调大吗"¶
不能改 YAML,改源码:
但注意:Anthropic ephemeral cache 上限是 5 分钟(或 1 小时 with ttl="1h"),LiteLLM 路由黏性 TTL 大于上游 cache TTL 没意义(cache 已过期,黏到同一 deployment 也得重建)。
10. 进一步阅读¶
- 01-mechanism.md —— 机制全链路(如果还有疑问回去看)
- 02-config-reference.md —— 配置全字段
- 03-provider-matrix.md —— 各 provider 差异
- 04-billing-and-cost.md —— 计费与价格
- 上游 Anthropic 文档:https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
- 上游 LiteLLM 文档:https://docs.litellm.ai/docs/proxy/prompt_caching
- 跟 cooldown 的关系:../cooldown/README.md
- 跟 budget 的关系:../rate-limiting/05-skip-budget-checks-bug.md