Prompt Caching 工程实践:把大模型调用成本砍掉 80%

从缓存命中率 7% 到 84%,这是某海外安全测试平台用三个月跑出来的真实数字。他们的 AI 安全扫描系统每次任务要走 26 个步骤、40 次工具调用,系统提示塞了 2500 行 YAML------超过 20K token。在没有认真对待缓存之前,每天的大模型账单是一条持续攀升的曲线。

优化之后,累计命中了 98 亿 token,成本降了 59%。

这篇文章不讲「Prompt Caching 是什么」------这个问题搜索五分钟能解决。我想讲的是:为什么你的缓存命中率停在 20% 左右,以及怎么把它推过 70%


一、KV Cache 的底层逻辑

在开始之前,先把「为什么前缀必须完全一样才能命中」这件事说清楚,否则后面所有的工程决策都会显得莫名其妙。

Transformer 在做 attention 计算时,会为每个 token 生成一对 key-value 张量。这个计算过程很重------它是大模型推理延迟和费用的大头。KV Cache 的核心思路是:如果下一次请求的 前缀部分 和上次完全相同,那就可以直接复用上次算好的 KV 张量,不用重算。

复用 = 省时间 + 省钱。

关键词是「完全相同」。不是语义相似,而是字节级别的精确匹配。一个多余的空格、一个不同的时间戳,全盘 miss。

这解释了为什么把时间戳塞进 system prompt 是最贵的习惯:每次请求的 prefix 都不一样,KV Cache 永远无法命中。


二、主流 Provider 的实现对比

国内大模型:DeepSeek/Qwen

以 DeepSeek-V3 为例,其 API 同样支持前缀缓存,命中时输入 token 价格可降低至约 10%(具体以官方定价为准)。Qwen-Max 等通义千问系列也有类似机制,通过 prompt 前缀重用实现成本节省。

使用方式与主流 API 规范一致------只要保证相同前缀的请求保持 prompt 结构稳定,缓存会自动生效。

以 DeepSeek-V3 调用为例:

python 复制代码
from openai import OpenAI

# DeepSeek API 兼容 OpenAI SDK
client = OpenAI(
    api_key="your-deepseek-api-key",
    base_url="https://api.deepseek.com/v1"
)

response = client.chat.completions.create(
    model="deepseek-chat",  # DeepSeek-V3
    messages=[
        {
            "role": "system",
            "content": "你是一个代码审查专家...(很长的系统提示,固定不变)"
        },
        {
            "role": "user", 
            "content": "审查这段代码..."
        }
    ]
)

# 查看缓存命中(部分 provider 返回 cache_tokens 字段)
print(response.usage)

经济模型分析(以 DeepSeek-V3 为例):

Token 类型 参考价格
标准输入 ~¥1.0/M
缓存命中输入 ~¥0.1/M(-90%)

Break-even 约 1.1-1.4 次:只要一段 prompt 会被重复使用,缓存即刻合算。

自动缓存模式(部分 Provider)

部分海外 LLM API 支持自动缓存(无需代码改动),对于兼容其 API 规范的国内网关同样适用:

python 复制代码
# 使用 API 网关统一入口(兼容多模型)
response = client.chat.completions.create(
    model="qwen/qwen-max",  # 通过网关路由到 Qwen
    messages=[...]
)

# 通过 usage 字段判断缓存效果
usage = response.usage
if hasattr(usage, 'prompt_tokens_details'):
    cached = usage.prompt_tokens_details.cached_tokens
    print(f"缓存命中 token: {cached}")

三、Prompt 结构即缓存架构

「把缓存命中率从 20% 提到 70%」这件事,90% 的工作是在 重新设计 prompt 的结构,而不是调 API 参数。

核心原则只有一句话:越静态的内容越靠前,越动态的内容越靠后

sql 复制代码
┌─────────────────────────────────────┐
│  System Prompt(所有请求共享)        │  ← 最稳定,最靠前,打缓存断点
├─────────────────────────────────────┤
│  工具定义(工具集固定时)              │  ← 次稳定,打缓存断点
├─────────────────────────────────────┤
│  检索文档 / 上下文资料                │  ← 对话内共享,可打断点
├─────────────────────────────────────┤
│  对话历史(随对话增长)               │  ← 动态增长
├─────────────────────────────────────┤
│  当前用户消息                        │  ← 每次不同,最靠后
└─────────────────────────────────────┘

五个最贵的习惯

实际项目里,以下五个模式是命中率杀手:

1. 在 system prompt 里注入时间戳

python 复制代码
# ❌ 每次请求前缀都不同,永远 miss
system = f"当前时间:{datetime.now()}。你是一个助手..."

# ✅ 时间信息放到 user message 里
system = "你是一个助手..."
user = f"[当前时间:{datetime.now()}]\n用户问题:{question}"

2. 在 system prompt 里注入用户 ID 或请求 ID

python 复制代码
# ❌ 每个用户的前缀都不同
system = f"用户ID: {user_id}。你是一个专属助手..."

# ✅ 用户信息后置
system = "你是一个专属助手..."
user = f"[用户: {user_name}]\n{question}"

3. 在 system prompt 开头放用户特定配置

python 复制代码
# ❌ 不同用户的配置不同,无法共享缓存
system = f"用户偏好:{user_preferences}\n\n# 核心系统提示..."

# ✅ 静态部分在前,用户配置在后
system_static = "# 核心系统提示..."  # 所有用户共享,可缓存
user_dynamic = f"用户偏好:{user_preferences}\n{question}"  # 动态部分后置

4. 随机化 few-shot examples 的顺序

每次随机排列 few-shot examples 会让每次请求的前缀都不同。固定顺序,按质量排序,然后保持不变。

5. 每次重新生成工具定义

如果工具集是固定的,把工具定义序列化一次存下来,直接复用同一个字符串。不要每次动态生成,因为字段顺序一旦不同,就会 miss。

RAG 系统的缓存设计

在 RAG 系统里,检索到的文档同样可以通过结构优化提升缓存命中率:

python 复制代码
def build_rag_messages(system_prompt, retrieved_docs, conversation_history, user_query):
    """RAG 场景下的缓存友好型 prompt 构建"""
    
    messages = []
    
    # 1. 静态系统提示(最稳定,最靠前)
    messages_system = system_prompt
    
    # 2. 检索文档(对话内复用)--- 拼接到 messages 开头
    doc_content = "\n\n".join([f"[文档 {i+1}]\n{doc}" for i, doc in enumerate(retrieved_docs)])
    
    # 3. 构建完整 messages
    if conversation_history:
        messages.extend(conversation_history)
    
    # 当前 user message:文档 + 对话历史 + 用户问题(动态部分最后)
    messages.append({
        "role": "user",
        "content": f"参考文档:\n{doc_content}\n\n问题:{user_query}"
    })
    
    return messages_system, messages

四、Agent 系统的三断点架构

这一节来自某海外安全测试平台的实战经验,也是把命中率从 <20% 推到 84% 的核心技巧。

多步骤 Agent 系统有个特殊问题:每一步都会重发完整的对话历史,而历史在不断增长。如果架构设计不当,增长的历史会污染缓存前缀,导致每次都 miss。

错误的初始架构

css 复制代码
[System Prompt(20K token)][Tool Definitions][对话历史 + 工作内存][用户消息]
                                               ↑
                              每步都在增长 = 整个前缀都在变化

这种结构下,对话历史夹在中间,每次都会让后面的内容前缀不同,系统提示的缓存也跟着失效。

三断点架构

python 复制代码
import json

def build_agent_messages_v2(system_prompt, tool_definitions, 
                             conversation_history, working_memory, user_message):
    """三断点架构:最大化缓存命中率"""
    
    # 断点1:静态系统提示(最稳定)
    # 注意:不同 provider 的 cache_control 语法可能不同,这里以兼容格式演示
    system = system_prompt  # 静态,保持不变
    
    # 断点3:工具定义(工具集不变时极稳定)
    # 确保工具定义序列化顺序一致(sort_keys=True)
    tool_defs_text = json.dumps(tool_definitions, ensure_ascii=False, sort_keys=True)
    
    messages = [
        {
            "role": "user",
            "content": f"<tools>\n{tool_defs_text}\n</tools>\n\n[对话开始]"
        },
        {"role": "assistant", "content": "好的,我准备好了。"}
    ]
    
    # 对话历史(稳定部分,构成断点2)
    if conversation_history:
        messages.extend(conversation_history)
    
    # 工作内存 + 当前用户消息(动态,放最后)
    current_content = ""
    if working_memory:
        current_content += f"<working_memory>\n{working_memory}\n</working_memory>\n\n"
    current_content += user_message
    
    messages.append({"role": "user", "content": current_content})
    
    return system, messages

关键的「Relocation Trick」:把工作内存(working memory)从 system prompt 的末尾移到 user message 的末尾

这看起来很小,但效果很显著。原因是:工作内存每步都在变化,如果放在 system prompt 尾部,会让整个 system prompt 的缓存在每步都失效。移到 user message 之后,system prompt 这 20K token 的缓存就能稳定地命中了。

实测效果:仅这一个改动就把命中率从 <20% 推到了 ~74%。


五、并发陷阱与冷启动问题

并发 Race Condition

当多个请求几乎同时发出、共享同一个 prefix 时,会出现竞态问题:

ini 复制代码
T=0ms: 请求A 发出(前缀100K token)→ 缓存 miss,开始写入
T=2ms: 请求B 发出(相同前缀)    → 缓存还在写,还是 miss
T=5ms: 请求C 发出(相同前缀)    → 命中!
T=8ms: 请求D 发出(相同前缀)    → 命中!

对于服务启动时的第一批请求,可以用预热请求来解决:

python 复制代码
async def warm_up_cache(client, system_prompt):
    """服务启动时预热缓存"""
    print("预热 Prompt Cache...")
    
    response = await client.chat.completions.create(
        model="deepseek/deepseek-chat",  # 或你使用的模型
        max_tokens=1,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": "ping"}
        ]
    )
    
    print(f"预热完成: {response.usage}")
    return True

冷启动监控

在监控系统里,要把「冷启动期」和「稳定期」的命中率分开统计,否则冷启动的 0% 会拉低整体数字,让你误判优化效果。


六、监控指标体系

缓存优化不是一次性工作,你需要持续监控来确认效果并发现退化。

python 复制代码
from dataclasses import dataclass

@dataclass
class CacheMetrics:
    cache_creation_tokens: int = 0
    cache_read_tokens: int = 0
    regular_input_tokens: int = 0
    output_tokens: int = 0
    request_count: int = 0
    
    def record(self, usage):
        """记录单次请求的 usage"""
        self.request_count += 1
        # 兼容不同 provider 的字段名
        self.cache_creation_tokens += getattr(usage, 'cache_creation_input_tokens', 0)
        self.cache_read_tokens += getattr(usage, 'cache_read_input_tokens', 0)
        # OpenAI 兼容格式
        if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
            self.cache_read_tokens += getattr(usage.prompt_tokens_details, 'cached_tokens', 0)
        self.regular_input_tokens += getattr(usage, 'input_tokens', 0) or getattr(usage, 'prompt_tokens', 0)
        self.output_tokens += getattr(usage, 'output_tokens', 0) or getattr(usage, 'completion_tokens', 0)
    
    @property
    def hit_rate(self) -> float:
        total = self.cache_creation_tokens + self.cache_read_tokens + self.regular_input_tokens
        return self.cache_read_tokens / total if total > 0 else 0
    
    def report(self):
        print(f"=== Prompt Cache 统计 ===")
        print(f"总请求数: {self.request_count}")
        print(f"命中率: {self.hit_rate:.1%}")
        print(f"  缓存写入: {self.cache_creation_tokens:,} tokens")
        print(f"  缓存命中: {self.cache_read_tokens:,} tokens")
        print(f"  普通输入: {self.regular_input_tokens:,} tokens")
        
        if self.request_count > 100 and self.hit_rate < 0.5:
            print(f"⚠️  命中率低于 50%,建议检查 prompt 结构")
        elif self.hit_rate >= 0.7:
            print(f"✅ 命中率健康(>70%)")

生产环境的目标基线:

  • 命中率 >70% --- 健康
  • 命中率 50%-70% --- 有优化空间
  • 命中率 <50% --- 需要检查 prompt 结构,很可能有动态变量污染

总结

三条核心原则:

  1. 静态内容前置:system prompt、工具定义、参考文档,越稳定的越靠前
  2. 动态内容后置:时间、用户 ID、工作内存,越易变的越靠后
  3. 持续监控命中率:把缓存命中率当成一个产品指标来对待

5 分钟自检表:

  • System prompt 里有没有时间戳或用户 ID?
  • 工具定义是不是每次动态生成(字段顺序不确定)?
  • System prompt 长度是否超过缓存下限(通常 1024 token)?
  • 有没有在监控缓存命中 token 数?
  • Agent 系统里工作内存放在哪里?在 prefix 中间还是末尾?

如果以上五条都检查通过,你的命中率应该能稳定在 60% 以上。要进一步到 80%+,就需要对 Agent 执行流程做更深的改造------三断点架构 + 动态内容外移。

相关推荐
养肥胖虎4 小时前
完整学习LLM(一):为什么我要系统学习大模型
大模型·llm·学习路线
扫地的小何尚5 小时前
掌握 Agentic AI 技术:AI Agent 定制方法全景与实践路径
大数据·人工智能·算法·ai·llm·agent·nvidia
冬奇Lab16 小时前
Agent 系列(一):Agent 是什么——不只是「会调工具的 LLM」
人工智能·llm·agent
冬奇Lab16 小时前
RAG 系列(二十四):代码 RAG——让 AI 理解你的代码库
人工智能·llm
创世宇图19 小时前
【AI入门知识点】LLM 原理是什么?为什么 ChatGPT 看起来像“会思考”?
人工智能·ai·llm·token
创世宇图1 天前
【AI入门知识点】Function Calling 是什么?为什么 AI 开始会“调用工具”了?
人工智能·ai·llm·functioncalling
BeforeEasy1 天前
关于大模型工具调用技术的总结
llm·agent·工具调用·function_call·tool_use
龙骑士baby1 天前
重建 AI 认知第 1 篇:基础认知——一张地图看懂 AI Landscape
深度学习·ai·大模型·llm·ai生态
龙侠九重天1 天前
Embedding 模型深度使用——语义搜索与聚类
人工智能·深度学习·数据挖掘·大模型·llm·embedding·聚类