做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每次都是一样的,完全可以缓存起来复用。
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%,那投入就非常值得了。