跳转至

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 不依赖这个判断路由黏性,但依赖它计费。返回字段缺失会导致:

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 共存

router_settings:
  optional_pre_call_checks:
    - prompt_caching
    - 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 关键日志开关

export LITELLM_LOG=DEBUG

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 是否注册

curl -s "$PROXY/get/config/callbacks" \
  -H "Authorization: Bearer $ADMIN_KEY" | jq

期望在返回中看到 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}')
"

期望看到:

deployment:a1b2c3d4...:prompt_caching   TTL=287s   value={"model_id": "deploy-uuid-X"}

判定:

现象 原因
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_key01-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

router_settings:
  routing_strategy: simple-shuffle
  optional_pre_call_checks: ["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,改源码

# litellm/router_utils/prompt_caching_cache.py:190, 211
ttl=300   # ← 改这两处

注意:Anthropic ephemeral cache 上限是 5 分钟(或 1 小时 with ttl="1h"),LiteLLM 路由黏性 TTL 大于上游 cache TTL 没意义(cache 已过期,黏到同一 deployment 也得重建)。


10. 进一步阅读