
前几天看到一张账单,一周下来 LLM 调用费用近 1.5 万美元——一个客服 Copilot,把同一套 FAQ 上下文反复塞进 prompt,一周之内同一段输入 token 被重复付费了约 41000 次。🤯
第一反应当然是去找 GPTCache 之类的中间件。但看了一眼它的依赖图,陷入了沉思……真的需要那么多轮子吗?
于是我认真想了想:100 行 Python 能搞定吗?
答案是:能。而且效果还不错。
先说结论:在典型的聊天式 Copilot、RAG 文档问答、内部工具等场景下,花半天写一个轻量缓存,一周之内就能回本,之后持续省钱。
动手之前,先搞清楚免费午餐有哪些。
OpenAI 的自动 Prompt Caching(提示词缓存):触发门槛 1024 tokens,每 128 tokens 为一个增量单位命中,对命中的输入 token 给你打五折——零代码改动。
Anthropic 的提示词缓存需要手动设置 cache breakpoints,属于 opt-in(主动开启)模式。
这两种方案都适合「前缀稳定」的场景:系统提示词很长、用户消息很短。但下面这些情况它俩都帮不上忙:
最后一条是关键。厂商的 Prompt Caching 只帮你省了输入 token 的费用,而一个真正的响应缓存,能把整个调用——包括输出 token——全部省掉。
先上代码(建议结合注释看):
from __future__ import annotations
import hashlib, json, time
from collections import OrderedDict
from dataclasses import dataclass
from threading import Lock
from typing import Callable, Optional
@dataclass(frozen=True)
class CacheKey:
prompt: str
system: str
model: str
temperature: float
max_tokens: int
def hash(self) -> str:
payload = json.dumps(
{"p": self.prompt, "s": self.system, "m": self.model,
"t": round(self.temperature, 4), "mt": self.max_tokens},
sort_keys=True, ensure_ascii=False
).encode("utf-8")
return hashlib.blake2b(payload, digest_size=16).hexdigest()
@dataclass
class CacheEntry:
response: str
embedding: Optional[list[float]]
created_at: float
hits: int = 0
class LLMCache:
def __init__(self, max_size: int = 5000, ttl_seconds: int = 3600):
self._store: OrderedDict[str, CacheEntry] = OrderedDict()
self._max_size = max_size
self._ttl = ttl_seconds
self._lock = Lock()
def _is_fresh(self, entry: CacheEntry) -> bool:
return (time.time() - entry.created_at) < self._ttl
def get_exact(self, key: CacheKey) -> Optional[str]:
h = key.hash()
with self._lock:
entry = self._store.get(h)
if entry and self._is_fresh(entry):
self._store.move_to_end(h) # LRU 更新
entry.hits += 1
return entry.response
if entry:
del self._store[h] # 过期了直接删
return None
def put(self, key: CacheKey, response: str,
embedding: Optional[list[float]] = None) -> None:
h = key.hash()
with self._lock:
self._store[h] = CacheEntry(response=response, embedding=embedding, created_at=time.time())
self._store.move_to_end(h)
while len(self._store) > self._max_size:
self._store.popitem(last=False) # 踢掉最老的
5 个值得关注的细节:
精确匹配层抓重复,语义兜底层抓近义问题——同一个问题换种说法,这是实际生产中收益最大的部分。
import math
def cosine(a, b):
dot = sum(x*y for x,y in zip(a,b))
na = math.sqrt(sum(x*x for x in a))
nb = math.sqrt(sum(y*y for y in b))
if na == 0 or nb == 0: return 0.0
return dot / (na * nb)
class SemanticLLMCache(LLMCache):
def __init__(self, embedder, similarity_threshold: float = 0.93, *kwargs):
super().__init__(*kwargs)
self._embedder = embedder
self._threshold = similarity_threshold
def get(self, key: CacheKey) -> Optional[str]:
hit = self.get_exact(key)
if hit is not None: return hit
query_emb = self._embedder(key.prompt)
best_score, best_resp = 0.0, None
with self._lock:
for entry in self._store.values():
if not self._is_fresh(entry) or entry.embedding is None: continue
score = cosine(query_emb, entry.embedding)
if score > best_score: best_score, best_resp = score, entry.response
if best_score >= self._threshold: return best_resp
return None
def put_with_embedding(self, key: CacheKey, response: str) -> None:
emb = self._embedder(key.prompt)
self.put(key, response, embedding=emb)
0.93 这个阈值不是拍脑袋的。低于 0.9 开始出现「怎么重置密码」和「怎么暂停账户」答非所问的情况;高于 0.96 语义相似命中极少,缓存形同虚设。大部分团队在自己数据上测完会落在 0.92~0.94 这个区间。
线性扫描没问题。5000 条、1536 维的 embedding 做余弦扫描,单核 5ms 以内。如果规模到了 50000 条以上再考虑换 FAISS 或 HNSW,不要提前优化。
按 GPT-4o 公开定价($2.50/M 输入,$10/M 输出)估算:一个客服 Copilot,每周 8000 次对话,平均每次 6 轮(约 48000 次调用),平均输入 1400 tokens,输出 280 tokens。单次约 $0.0063,不缓存每周约 $302。
加精确匹配层,命中率约 22%(重复问答、重试、刷新等),降到约 $236/周。
再加语义层(阈值 0.93),综合命中率到约 41%,降到约 $178/周。embedding 成本约 $4/周。净省约 $120/周——一周内回本,之后每周持续省。
确定性假设不成立时——如果你在用 temperature 0.9 追求创意输出,缓存是错误的工具。只缓存那些设计上是确定性的调用:分类、提取、结构化输出、FAQ 类查询。
底层知识变化时——如果缓存说「我们的退款窗口是 30 天」但你政策改成了 14 天,缓存会持续给出错误答案直到 TTL 过期。根据领域变化频率设 TTL:每周变化的支持内容设 1 小时,稳定产品文档设 24 小时,涉及用户状态的设 5 分钟。
还不理解监控的重要性——缓存之上你仍然需要 traces(调用链)、hit-rate 指标、缓存大小和淘汰率报警,以及看起来像省钱实际是正确性回归的故障模式。
整个接入代码一行:
response = cached_call(cache, key, lambda k: client.complete(...))
缓存实例放模块级别,embedder 是 embedding provider 的薄封装,call_llm 是你原来就在用的函数。如果你已经接了 GPTCache 或 LangChain 缓存而且跑得好,别动。如果每周账单四位数、在考虑换 vendor,先把这 100 行写了。
原文:The 100-Line LLM Cache That Pays For Itself in a Week