现象
调试一个带大模型的脚本时,最磨人的不是写代码,是等。改一行提示词模板,重跑,前面十几个已经跑通的样本又要重新请求一遍,几十秒过去了,才轮到你真正想看的那一条。
批量任务里也一样。同一批数据跑两遍,输入完全没变,请求还是一条条重发。
这类重复请求没有任何新信息量,完全可以让它命中本地。下面写一层缓存,包在调用外面,输入相同就直接读磁盘,不碰网络。
环境准备
- Python 3.9 及以上
openai官方 SDK(pip install openai)- 一个 OpenAI 兼容协议的接入地址和调用凭证
有个前置条件:缓存键会把模型名算进去,所以同一份代码换模型时,接入地址和凭证最好保持不变,只改模型名。如果每换一个模型就得换一套地址和凭证,同一句输入在不同环境下会算出不同的键,缓存目录里会存下几份重复结果,命中率也就无从谈起了。jiekou.vip 近期上线了企业资源包,在同一入口下提供多个模型,接入前把这一项确认一下。
建目录:
bash
mkdir llm-cache && cd llm-cache
凭证走环境变量:
bash
# Windows PowerShell
$env:LLM_BASE_URL="https://你的接入地址/v1"
$env:LLM_API_KEY="你的凭证"
步骤一:先把缓存键定下来
键必须覆盖所有会影响输出的输入。漏掉任何一项,改了参数却读到旧结果,比不做缓存更麻烦。
新建 cache.py:
python
import hashlib
import json
def make_key(model, messages, **params):
payload = {
"model": model,
"messages": messages,
"params": {k: params[k] for k in sorted(params)},
}
blob = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
sort_keys=True 和对 params 排序是必要的:Python 字典的书写顺序会影响序列化结果,不排序的话同样的参数换个书写顺序就会算出两个不同的键,缓存永远打不中。
验证一下顺序无关:
python
if __name__ == "__main__":
m = [{"role": "user", "content": "hi"}]
a = make_key("gpt-4o-mini", m, temperature=0, top_p=1)
b = make_key("gpt-4o-mini", m, top_p=1, temperature=0)
print(a)
print(b)
print("same:", a == b)
输出:
9f2c4e7a1b8d3f05c6e9a2b7d4f18c30
9f2c4e7a1b8d3f05c6e9a2b7d4f18c30
same: True
步骤二:落盘读写
一个键一个 json 文件,最省事,也方便直接打开看内容。继续在 cache.py 里加:
python
from pathlib import Path
CACHE_DIR = Path(".llm_cache")
def load(key):
f = CACHE_DIR / f"{key}.json"
if not f.exists():
return None
return json.loads(f.read_text(encoding="utf-8"))["content"]
def save(key, content, meta):
CACHE_DIR.mkdir(exist_ok=True)
f = CACHE_DIR / f"{key}.json"
f.write_text(
json.dumps({"content": content, "meta": meta}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
把 meta 一起存进去(模型名、请求时间),排查问题时能看出这条结果是什么时候、哪个模型产生的,光有 content 不够。
步骤三:包一层带缓存的调用
新建 client.py:
python
import os
import time
from openai import OpenAI
import cache
class CachedClient:
def __init__(self):
self._api = OpenAI(
base_url=os.environ["LLM_BASE_URL"],
api_key=os.environ["LLM_API_KEY"],
)
self.hits = 0
self.misses = 0
def chat(self, model, messages, use_cache=True, **params):
key = cache.make_key(model, messages, **params)
if use_cache:
cached = cache.load(key)
if cached is not None:
self.hits += 1
return cached
resp = self._api.chat.completions.create(
model=model, messages=messages, **params
)
content = resp.choices[0].message.content
cache.save(key, content, {"model": model, "at": time.strftime("%F %T")})
self.misses += 1
return content
use_cache=False 这个开关要留着。有些场景本来就要每次重新生成,或者你想确认一下线上是不是真的还通着,得能绕过缓存。
步骤四:跑两遍看差别
新建 demo.py:
python
import time
from client import CachedClient
QUESTIONS = [
"用一句话说明什么是幂等",
"用一句话说明什么是背压",
"用一句话说明什么是长尾延迟",
]
c = CachedClient()
for round_no in (1, 2):
t0 = time.perf_counter()
for q in QUESTIONS:
c.chat("gpt-4o-mini", [{"role": "user", "content": q}], temperature=0)
print(f"第 {round_no} 轮 耗时 {time.perf_counter() - t0:.2f}s "
f"命中 {c.hits} 未命中 {c.misses}")
两轮输出:
第 1 轮 耗时 4.86s 命中 0 未命中 3
第 2 轮 耗时 0.01s 命中 3 未命中 3
第二轮三条全部命中磁盘,没有发出请求。缓存目录里能看到三个文件:
bash
ls .llm_cache
3a7f1c92d5e84b06a1f7c3e90d24b8f5.json
b41e07d3a9c25f8e60b3d17a4c9e2f08.json
e58c2a7091b4d63fa8c50e2b7d139f46.json
随便打开一个:
json
{
"content": "幂等指同一个操作执行一次和执行多次对系统状态的影响相同。",
"meta": {
"model": "gpt-4o-mini",
"at": "2026-08-12 11:04:23"
}
}
步骤五:两个容易踩的地方
temperature 不为 0 时要想清楚 。缓存会把随机性固定住,同一个输入永远拿到第一次那个结果。如果你要的就是多样输出,这类调用应该传 use_cache=False。
改了提示词模板但键没变 。如果模板是在 chat() 外面拼好再传进来的,模板变化会体现在 messages 里,键自然会变,没问题。但如果你把模板放在 chat() 内部拼装、只把变量传进来,那模板改了键却不变,就会读到旧结果。这种情况下把模板版本号也塞进键里:
python
def chat(self, model, messages, use_cache=True, key_extra=None, **params):
key = cache.make_key(model, messages, _extra=key_extra, **params)
调用时传 key_extra="promptv3",改模板就顺手改这个值,旧缓存自动失效。
清空缓存直接删目录:
bash
rm -r .llm_cache
小结
一层不到六十行的缓存,把调试期的重复等待砍掉了。关键就两点:键要把模型名、messages 和所有采样参数都算进去并排序,随机性场景要留一个绕过开关。
再往下可以加个过期时间,或者把落盘换成 sqlite 方便按条件批量清理。但对本地调试和批处理这两个主要场景,上面这个版本已经够用了。