做AI客服的都知道一个痛点:每次对话都要把system prompt和历史消息全部重新发给LLM,前10轮对话的token消耗是逐轮递增的。一个客服Agent每天处理10万次对话,光重复计算system prompt就烧掉了30%的费用。

后来接入了Prompt Caching,把system prompt的KV Cache缓存下来复用,API成本直接降了40%。这篇文章把实现细节整理出来。

KV Cache原理

LLM推理时,每个token都会生成一组Key和Value向量。传统做法是每次请求都从头算一遍所有token的KV。但如果你的请求里有一段固定的前缀(比如system prompt),这段前缀的KV每次都是一样的,完全可以缓存起来复用。

flowchart TD A[请求到达] --> B{前缀已缓存?} B -->|是| C[加载缓存KV] B -->|否| D[计算前缀KV] D --> E[缓存KV] E --> F[继续推理] C --> F

Anthropic Prompt Caching

Anthropic的Prompt Caching是最容易接入的方案。只需要在API调用时加上cache_control标记:

from anthropic import Anthropic

client = Anthropic()

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    system=[
        {
            "type": "text",
            "text": "你是一个专业的客服助手。以下是公司的产品信息和FAQ...\n\n" + long_system_prompt,
            "cache_control": {"type": "ephemeral"}
        }
    ],
    messages=[
        {"role": "user", "content": "怎么退款?"}
    ]
)

缓存的有效期是5分钟,每次请求都会自动续期。如果5分钟内没有请求,缓存就会失效。

缓存写入有额外费用(比普通输入贵25%),但读取只收普通输入价格。所以只要同一段内容被用到2次以上就赚了。实际测试中,一个客服场景的缓存命中率达到了85%。

OpenAI Prefix Caching

OpenAI的方案更底层,是自动的prefix caching。如果多个请求共享相同的前缀,vLLM会自动复用KV Cache。

但你需要在部署时配置:

# vLLM启动时开启prefix caching
# vllm serve Qwen/Qwen2.5-7B-Instruct \
#   --enable-prefix-caching \
#   --gpu-memory-utilization 0.9

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1")

# 所有请求共享同一个system prompt
response = client.chat.completions.create(
    model="Qwen/Qwen2.5-7B-Instruct",
    messages=[
        {"role": "system", "content": long_system_prompt},  # 这段会被缓存
        {"role": "user", "content": user_message}
    ]
)

关键点是system prompt必须完全一致才能命中缓存。哪怕改了一个字,KV Cache就全部失效。

自建缓存层

如果用的是不支持原生Prompt Caching的模型,可以自己搭一层缓存。思路是把system prompt的KV Cache序列化后存到Redis里:

import torch
import redis
import pickle

class KVCacheManager:
    def __init__(self, redis_url="redis://localhost:6379"):
        self.redis = redis.from_url(redis_url)
        self.ttl = 300  # 5分钟

    def save(self, key: str, kv_cache):
        """把KV Cache序列化到Redis"""
        data = pickle.dumps(kv_cache)
        self.redis.setex(f"kv:{key}", self.ttl, data)

    def load(self, key: str):
        """从Redis加载KV Cache"""
        data = self.redis.get(f"kv:{key}")
        if data:
            return pickle.loads(data)
        return None

    def make_key(self, prefix_text: str) -> str:
        """用hash生成缓存key"""
        import hashlib
        return hashlib.md5(prefix_text.encode()).hexdigest()

这个方案的挑战是KV Cache数据量很大。一个7B模型的32层Transformer,每层的KV Cache大约2MB,总共64MB左右。如果并发高的话,Redis的内存和网络带宽都是瓶颈。

优化方案是只缓存前N层的KV Cache,后面几层重新计算。实测缓存前24层(共32层),精度损失在1%以内,但缓存大小减少了25%。

实际效果

在客服场景跑了30天的数据:

场景                无缓存      有缓存      节省
system prompt       1200 token  1200 token  -
首次计算成本        ¥0.18/千次  ¥0.23/千次  -
后续读取成本        -           ¥0.03/千次  83%
综合成本            ¥0.18/千次  ¥0.06/千次  67%
响应延迟            2.1s        1.4s        33%

延迟降低是因为省去了system prompt的计算时间。1200个token的system prompt,在7B模型上大约需要0.7秒。

踩坑记录

缓存失效太频繁

一开始把TTL设成了60秒,结果很多用户间隔超过1分钟后发消息就miss了。改成300秒后命中率明显提升,但Redis内存占用也上来了。最终用了分层策略:热用户的缓存保留300秒,冷用户的60秒就回收。

多轮对话的前缀不一致

客服场景有个多轮对话的问题:每轮对话都会追加历史消息,导致前缀一直在变。解决方案是把system prompt和历史消息分开处理,只缓存system prompt部分:

def build_messages(system_prompt, history, new_message):
    return [
        {"role": "system", "content": system_prompt},  # 这部分可以缓存
        *history,  # 历史消息每次不同,不缓存
        {"role": "user", "content": new_message}
    ]

跨模型不通用

不同模型的KV Cache格式不同,不能混用。从Qwen换到LLaMA,缓存全部作废。所以模型升级的时候要做好缓存清理。

写在最后

Prompt Caching是目前LLM推理优化里投入产出比最高的方案之一。Anthropic的API方案接入最简单,自建方案灵活但运维成本高。

建议先从API方案开始,观察缓存命中率。如果命中率低于30%,说明你的场景不适合缓存(比如每个请求的前缀都不同);如果高于60%,那投入就非常值得了。