介绍 LLM 缓存的实现陷阱,指出 cache key 必须包含所有影响答案的变量:模型 ID、采样参数、工具定义、模板版本、租户信息等。
缓存失效从不是崩溃——它是你修改了一个 prompt,部署上线,然后在 TTL 到期之前一直用旧 prompt 生成答案。
规则很明确:所有可能改变答案的东西都要进入 key。你漏掉的任何东西,都会成为一种"没人想到会影响缓存"的变更,却仍然返回旧结果的途径。
最简洁的做法是对即将发送的请求体做 hash,因为按照定义请求体包含了 provider 会处理的所有参数。然后再加上模板版本和 tenant id,这两个不在请求体里。
# cache_key.py
import hashlib
import json
from typing import Any
TEMPLATE_VERSION = "2026-08-04.a" # bump this when you edit a prompt template
def cache_key(payload: dict[str, Any], *, tenant: str | None = None) -> str:
"""A stable hash of everything that changes the answer."""
material = {
"payload": payload,
"template": TEMPLATE_VERSION,
"tenant": tenant,
}
encoded = json.dumps(
material,
sort_keys=True, # dict order must not change the key
separators=(",", ":"), # no incidental whitespace
ensure_ascii=False,
default=str, # datetimes and Decimals do not crash the hash
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
sort_keys=True 是为了防止两个逻辑上相同的请求因为字典构建顺序不同而 hash 出不同结果——这种 bug 表现为命中率为零,但任何地方都不会报错。用 sha256 而不是 Python 内置的 hash(),是因为字符串的 hash() 在每次进程重启时都会加盐改变,这会让持久化缓存失效,而且需要一小时才能注意到。
key 本身不是秘密,但 key 背后的材料可能是。如果 prompt 里包含个人数据,记住缓存存储的是 prompt 和响应,继承了和其他存储一样的保留和删除义务——LLM 日志中的 PII 同样适用于此。
SQLite 是正确的第一选择:一个文件,不需要服务器,进程重启后依然存活,而且你想查看缓存实际内容时可以直接查询。等需要多个进程共享缓存,或者想让 TTL 自动强制执行时,再迁移到 Redis。
# cache.py
import json
import sqlite3
import time
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS llm_cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
model TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL,
hits INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS llm_cache_expiry ON llm_cache (expires_at);
"""
class ResponseCache:
def __init__(self, path: str = "llm_cache.db"):
self.conn = sqlite3.connect(path, isolation_level=None)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.executescript(SCHEMA)
def get(self, key: str) -> Any | None:
row = self.conn.execute(
"SELECT value, expires_at FROM llm_cache WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
if row["expires_at"] is not None and row["expires_at"] < time.time():
self.conn.execute("DELETE FROM llm_cache WHERE key = ?", (key,))
return None
self.conn.execute(
"UPDATE llm_cache SET hits = hits + 1 WHERE key = ?", (key,)
)
return json.loads(row["value"])
def set(self, key: str, value: Any, model: str, ttl: float | None) -> None:
now = time.time()
self.conn.execute(
"INSERT OR REPLACE INTO llm_cache"
" (key, value, model, created_at, expires_at, hits)"
" VALUES (?, ?, ?, ?, ?, 0)",
(key, json.dumps(value), model, now,
now + ttl if ttl is not None else None),
)
def stats(self) -> dict:
row = self.conn.execute(
"SELECT count(*) AS entries, sum(hits) AS hits,"
" sum(CASE WHEN hits = 0 THEN 1 ELSE 0 END) AS never_used"
" FROM llm_cache"
).fetchone()
return dict(row)
串联起来只需要一个 wrapper:
def cached_call(cache: ResponseCache, client, payload: dict,
*, ttl: float | None = 86400.0, tenant: str | None = None) -> dict:
key = cache_key(payload, tenant=tenant)
hit = cache.get(key)
if hit is not None:
return hit
response = client.post("/chat/completions", json=payload)
response.raise_for_status()
body = response.json()
cache.set(key, body, payload["model"], ttl)
return body
缓存整个响应体,而不只是文本。usage 对象才是让节省可量化的东西——一次命中就是省下了你没付费的 token,如果不存储计数,你就无法说出省了多少。
stats() 查询值得每周跑一次。大量 never_used 条目意味着你在缓存从不重复的请求,这是白白浪费的磁盘和复杂性;解决办法是把缓存缩小到只针对那些确实会重复的调用。
问题是:**一个错误的答案可以接受多久?**不是"答案保持正确的时间",而是"服务一个过期答案多久之后会有人在意"。这个重新表述能立刻给出答案,而"这个数据有多易变"只会引发争论。
有两个机制比具体数字更重要。第一,TTL 是一个上限,不是执行计划:模板编辑应该立即失效,这就是 TEMPLATE_VERSION 存在的意义。第二,如果很多条目同时写入,它们也会同时过期,结果是大量请求一瞬间砸向你的 provider——用 jittered TTL 来分散写入,ttl * random.uniform(0.9, 1.1),和其他任何缓存的做法完全一样。
当重复率足够高,能够抵消其复杂性成本时,缓存才值得构建。这个重复率在写上面任何代码之前就可以测量。你只需要一个数字:你的请求中有多少是之前请求的精确重复。
先只做 hash 不做缓存。在调用日志里加上 cache_key(payload)——在每次 model 调用的日志里记录 prompt_sha 字段就是这个用途——其他什么都不用改。真实流量跑一天通常就够了。
数一下重复次数。用 SELECT count(*) - count(DISTINCT prompt_sha) FROM calls 对那天的日志跑一下,得出的就是完美缓存能消除的调用次数。
算一下价格。把所有 hash 之前见过的调用的已记录成本加总。这就是节省,用你被收费的货币来衡量,不需要任何建模假设。
检查分布,不只是总数。如果节省集中在一个 hash 上——比如健康检查、cron 任务里的固定 prompt——更好的做法是直接不发那个调用。如果节省分散在成千上万个 hash 上,才是缓存应该处理的场景。
-- 一个完美缓存在一天日志上能节省多少
SELECT
count(*) AS calls,
count(DISTINCT prompt_sha) AS distinct_prompts,
100.0 * (1 - count(DISTINCT prompt_sha)::float / count(*)) AS repeat_pct,
sum(cost_micros) / 1e6 AS spent,
(sum(cost_micros) - sum(first_cost)) / 1e6 AS saveable
FROM (
SELECT prompt_sha, cost_micros,
first_value(cost_micros) OVER (PARTITION BY prompt_sha
ORDER BY started_at) AS first_cost
FROM calls WHERE status = 'ok'
);
有两个阈值值得明确说出来。重复率低于约 5% 时,缓存就是一个 prompt 仓库、一个保留义务、一个随时可能发生的失效 bug,换来的收益却微乎其微——先规范化 prompt 再测一次再动手构建。高于 20% 时,这是最便宜的优化手段之一,而且命中时的延迟收益通常比省下的钱更有价值。
缓存运行起来之后,持续关注 stats() 里的 hits 和 never_used。命中率下降又没有解释,几乎总是意味着进入 key 的某个东西每次请求都不一样——系统 prompt 里的时间戳、随机排序的检索文档列表、会话 id。这是发送端的一个 bug,而缓存是唯一能发现它的地方。
任何以高 temperature 采样以获得多样性的内容。如果用户点了"重新生成",返回缓存的答案是 bug,看起来就像按钮坏了。在这种路径上在 key 里加一个 nonce,或者直接跳过缓存。
流式响应,以流的形式。你可以把组装好的文本缓存起来然后回放,但一个毫秒级到达的"流"是完全不同的用户体验。缓存文本;再决定要不要伪造打字效果。
错误。429 或 503 不是答案。把它们缓存起来会把一个临时故障变成持续 TTL 时长的故障。
任何没有把 tenant 纳入 key 的东西。 最糟糕的缓存 bug 是一个客户收到另一个客户的答案。如果 key 不包含 tenant,存储层面必须是每个 tenant 一个缓存。
上面的缓存只在字节完全相同时才命中。对于自由文本问题,命中率接近零,因为"how do I reset my password"和"password reset?"会 hash 出完全不同的结果。
有两条路,按风险递增排序。在 hash 前做规范化——小写、合并空白、去掉尾部标点——这很便宜、安全,而且效果出乎意料的好。或者把问题转成 embedding,把相似度超过阈值的最邻近结果当作命中,这就是语义缓存,以正确性换命中率:在阈值宽松到有用的程度时,有些问题会得到另一个问题的答案。用一批带标签的真实查询对来设置阈值,而不是凭直觉。
最后注意,响应缓存和 prompt 缓存不是一回事,后者是 provider 端对重新处理共享前缀的折扣。两者可以组合:你的缓存完全省掉了调用,它们的缓存让你做的调用更便宜。
Logging Every Model Call
Your First LLM Call in Python
Rate Limiting Yourself Before They Do