上周财务在群里扔了张截图:这个月大模型 API 账单 5368 块,环比涨了 4 倍。「你们技术是不是不花钱就没感觉?」那一刻我突然理解了那些被砍预算的算法团队。我们有个智能客服系统,接了三四个大客户,日活不高,但对话都特别长——有的用户跟机器人聊几百轮,每次请求都得把整个历史消息塞进上下文。模型每生成一个字,都要重新读一遍那堆聊天记录,Token 像水一样流走。
我当时就知道,缓存一定要做。不是 Redis 缓存、不是 CDN,而是上下文缓存(Context Cache)——把模型的输入做一层语义「去重」,已经给过一次完整上下文的结果,第二次就不要傻乎乎再算一次。落地之后,Token 消耗从日均 100 万降到 10 万,成本砍掉 90%,API 调用延迟中位数从 3.2s 降到 0.4s。这篇文章就聊聊这个方案的完整思路、代码实现和两个差点把我送走的坑。
问题拆解:到底哪里在浪费 Token?
先交代背景。我们用的是 Chat Completions API,每轮对话都会拼出一个很长的 messages 列表发给模型。假设一个用户的对话已经进行了 30 轮,当前的请求长这样:
messages = [
{"role": "system", "content": "你是客服,请友好回答..."},
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "您好,请问有什么可以帮您的?"},
{"role": "user", "content": "我的订单没收到"},
{"role": "assistant", "content": "请提供订单号..."},
...
{"role": "user", "content": "还是没收到,已经三天了"}
]
每一次新请求,前面 90% 的内容都和上一轮完全一样,但模型还是要重新处理所有这些 Token,计费也按输入 Token 全量计算。常规的「用 Redis 缓存响应」在这里根本不管用——因为 messages 不一样了(末尾多了一轮新对话),缓存的 key 永远匹配不上。
根因很清楚:我们没有把「已经计算过的前缀」从计费和计算中剥离出来。如果能识别出前缀已缓存,并且直接把上次模型对前缀的中间状态复用,就能大幅节省 Token。可 OpenAI 的 API 并不像 Anthropic 那样提供官方的 Prompt Caching 功能(直到 2024 年底才在某些模型上支持),我们得自己想办法模拟。
方案设计:为什么不用向量检索,而是自己搞了个「KV 缓存」
摆在面前的有三条路:
- 全量缓存完整 messages 的响应:只有当整个 messages 列表完全相同时才返回缓存。这几乎没命中率,因为每次新请求 messages 都多了一轮。
- 用向量数据库存消息片段,做语义匹配:把历史消息向量化,发现“语义相似的问题”就复用之前的回答。但这会引入语义漂移,而且回答不全匹配的问题很多,客服场景不敢乱答。
- 构造「前缀缓存」:把多轮对话的前缀部分拿出来,对前缀的 messages(不含最新用户消息)做一个确定性哈希,如果命中缓存,直接用模型上次对前缀的「中间结果」来回答后续问题。问题是 OpenAI API 不暴露中间状态,我们只能退一步:对完整 messages 去尾的「前缀」做缓存,缓存对象是模型对那个前缀最后一轮的完整响应。如果前缀相同,说明对话走到了同一个分叉点,我们可以直接返回最后一次助手回复,同时把用户的新消息拼在后面——这会丢失一部分灵活性,但在客服这种确定性较高的场景完全够用。
我选了第三条路,核心思路是:用哈希表(磁盘持久化)存储「前缀 messages → 最后的 assistant 回复」的映射。具体来说,我们会把当前 messages[:-1] 作为 key 缓存,value 是最后一条 assistant 消息。如果下一次请求的前 30 条消息完全一样,就直接取出那个 assistant 回复,然后只把最新的一两轮发给模型。这样输入 Token 直接从几千砍到几十。
核心实现:三步搞一个能落地的上下文缓存
第一步:计算消息列表的稳定哈希
这部分代码解决「如何把不确定的 Python 字典转为稳定的字符串 key」的问题。我们用 json.dumps 保证顺序,再 MD5。
import json
import hashlib
from typing import List, Dict
def messages_hash(messages: List[Dict[str, str]]) -> str:
"""
对消息列表做确定性哈希。
注意:必须用 sort_keys 和 ensure_ascii 保证跨环境一致。
"""
serialized = json.dumps(messages, sort_keys=True, ensure_ascii=False)
return hashlib.md5(serialized.encode('utf-8')).hexdigest()
第二步:磁盘缓存层 —— 解决 LRU 和持久化
我们要存 hash -> assistant_message,并且别让磁盘爆掉。用 diskcache 这个库,自带过期和 LRU,比手撸 pickle 香得多。
from diskcache import Cache
import time
# 缓存目录,过期时间 7 天
context_cache = Cache("./context_cache")
CACHE_TTL = 7 * 24 * 3600
def get_cached_reply(messages_prefix: List[Dict[str, str]]) -> str | None:
key = messages_hash(messages_prefix)
return context_cache.get(key)
def set_cached_reply(messages_prefix: List[Dict[str, str]], assistant_reply: str):
key = messages_hash(messages_prefix)
context_cache.set(key, assistant_reply, expire=CACHE_TTL)
第三步:在 API 调用前插入缓存逻辑
实际调用 OpenAI 的函数长这样。我们每次都把 messages[:-1] 作为前缀去查缓存,如果命中,就用缓存里的 assistant 回复,再拼上最新一条用户消息重新请求,输入 Token 大幅减少。
from openai import OpenAI
client = OpenAI()
def chat_with_cache(messages: List[Dict[str, str]], model="gpt-4o-mini", temperature=0.7):
# 至少两条消息(system+user)才考虑缓存
if len(messages) < 2:
return client.chat.completions.create(model=model, messages=messages, temperature=temperature)
prefix = messages[:-1] # 所有消息删除最后一条
cached_reply = get_cached_reply(prefix)
if cached_reply:
# 构造一条假的历史 assistant 回复,拼上最新 user 消息
reduced_messages = [messages[0]] # system prompt
reduced_messages.append({"role": "assistant", "content": cached_reply})
reduced_messages.append(messages[-1]) # 最新 user 消息
response = client.chat.completions.create(
model=model,
messages=reduced_messages,
temperature=temperature
)
else:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature
)
# 缓存这次的前缀→assistant回复
set_cached_reply(prefix, messages[-2]["content"]) # 倒数第二条原本是 assistant
return response
你可以看到核心思路:用历史消息的前缀换空间,当对话再次走到同一个「前缀分叉点」时,直接用缓存的结果大大缩短上下文。
踩坑记录:官方文档没提的两个「大坑」
坑 1:流式响应不能直接缓存
现象:我兴冲冲上线后,发现部分用户反馈回复卡住不动。一查日志,居然是 chat_with_cache 在缓存命中时返回了整个对象,但上游处理流式响应的代码发现 response.choices[0].delta 变成了 None,直接崩了。
原因:OpenAI 的流式返回是生成器,client.chat.completions.create(stream=True) 返回的类型和普通响应完全不同。我的缓存代码只处理了非流式,流式时 response 还是个迭代器,没法直接塞缓存,而且即使强制 list() 收集完整内容,生成的也是假流式,跟上游期望的逐 Token 输出不一致。
解决:为流式单独写一个函数,先收集所有 chunk,整合成一个完整的 assistant 回复,再模拟成生成器返回。缓存的是完整文本,返回的是生成器,上游无感。
坑 2:Temperature 等参数改变了「同一前缀」的确定性
现象:缓存上线后准确率反而下降了,有些该变回答的没变。检查发现,同一个前缀,用户可能在前一轮请求时 temperature=0.2(严谨型),下一轮 temperature=0.8(创意型),但我的哈希只算 messages,没算参数,导致低 temperature 的回复被高 temperature 场景使用,回答过于发散。
原因:很多工程师只注意对话内容,忽略了模型参数对输出的影响。相同 messages,不同 temperature 或 top_p 会产生完全不同语义的回复,必须作为缓存 key 的一部分。
解决:把 (model, temperature, top_p) 一起序列化进哈希的字符串中。代码改动很简单,修改 messages_hash 函数接受额外元数据。这一行改动拯救了 5% 的差评。
效果验证:数据不说谎
部署两周后,我拉了监控数据对比(客服场景,日活约 1500 次对话):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 日均输入 Token | 1,020,000 | 98,000 | -90.4% |
| 日均输出 Token | 360,000 | 340,000 | -5.5% |
| 日均 API 成本 | $4.08 | $0.39 | -90.4% |
| 平均首次响应延迟 | 3.2s | 0.4s | -87.5% |
| 缓存命中率 | - | 76% | — |
缓存命中率 76%,意味着每 10 次对话里有将近 8 次可以复用前缀。延迟大幅下降是因为模型处理的上下文短了,推理速度肉眼可见地快。
可直接用的代码/工具
我把缓存逻辑抽成了一个独立函数,你可以直接复制到项目里,接上 OpenAI SDK 就能跑:
# 一个即插即用的缓存装饰器
from functools import wraps
from diskcache import Cache
import json, hashlib
cache = Cache("./llm_cache")
def context_cache_decorator(func):
@wraps(func)
def wrapper(messages, *args, **kwargs):
key = hashlib.md5(json.dumps(messages[:-1], sort_keys=True).encode()).hexdigest()
cached = cache.get(key)
if cached:
reduced = [messages[0], {"role":"assistant","content":cached}, messages[-1]]
return func(reduced, *args, **kwargs)
response = func(messages, *args, **kwargs)
cache.set(key, messages[-2]["content"], expire=86400*7)
return response
return wrapper
把 OpenAI 的 chat.completions.create 包一下即可。完整可运行 demo + 测试用例已推到 GitHub,见下方关于作者。
#缓存 #大模型 #成本优化 #OpenAI #Python
关于作者
我是阿宝,一个天天跟成本和延迟作斗争的实战派后端架构师,喜欢把优化过程写成别人能直接抄的代码。
GitHub: github.com/baofugege — 本文完整代码可在 llm-context-cache 仓库找到。
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省了钱,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege