前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯9899
  • 利用Athena和CUDOS实现Amazon Bedrock成本归属可视化
  • Claude Code 自动模式 93% 批准率下的安全设计
  • 从脏文档提取结构化数据的完整管道
  • 选什么协议传 AI 响应:WebSocket/SSE/轮询的真实决策框架
  • 用 AllenAI Open Instruct 构建 RLVR/GRPO 后训练流水线
  • 研究证实:LLM 输出可反推原始 prompt,精度近完美
  • 自托管vs API的真正盈亏平衡点
  • 向团队提供Open Model的工程指南
  • Self-Consistency:采样多次取多数票的推理术
  • 缓存贵的、重算便宜的:LLM输出缓存策略
  • Agent工具调用的安全防护要点
  • 搜索延迟拆解:毫秒级预算分配实战
  • 推理模型延迟预算分析
  • 推理 Effort 档位实际控制什么
  • 推理模型性价比曲线:如何算清每分正确率成本
  • 为什么推理能力在数学好过代码好过文本
  • 通过所有测试的代码仍可能让AI代理崩溃
  • Cursor 修 IDOR 易漏同文件其他路由
  • 可信数据是AI Agent规模化的关键瓶颈
  • Anthropic为Agent引入梦境推理能力引热议
  • Python 中缓存 LLM 响应:细节决定成败
  • Harness Engineering:打造 AI 编程助手的工作环境
  • 加向量数据库前,先用 NumPy 搞定 Embedding
  • 自实现令牌桶限流:比等 429 更优
  • 长时 AI 任务用后台任务队列:状态机比框架重要
  • asyncio 并发调用 40 个 LLM:代码少但坑多
  • AI 项目中 API 密钥的泄密风险与防护层级
  • 电话Agent工程实践:SIP/WebRTC音频路径全解析
  • pgvector索引调优:HNSW与IVFFlat成本精确分析
  • 多租户应用按客户计费:成本与可计费用量必须分开
  • PDF 解析难点深度剖析:字符间距阈值是万恶之源
  • Hugging Face开源OlmoEarth文本嵌入
  • 阿里开源 Qwen3.8:2.4T MoE、激活 95B、256K 上下文
  • MiniMax H3:单个 Transformer 替代视频生成完整流水线
  • DeepSeek V4 Pro 正式版发布:多项测试接近 Fable 5 水平
  • AI编程助手Lovable完成新一轮4亿美元融资,估值达133亿美元
  • MiniMax Music 3.0:开放权重生产级音乐生成模型
  • Microsoft 发布 MindTopo:VLMs 空间推理能力新基准
  • Grok 4.6 发布:剑指 GPT-5.6 Sol,主打长时间 Agent 任务
  • Grok 4.6 中文详解:训练数据、Agent 能力边界与定价
  • 市场份额报告:Google Gemini 份额从 12% 跌至 1.9%
  • 用Embeddings+Reranking+LLM构建可靠的内容分类流水线
  • 推理冷启动从10分钟降至秒级:容器镜像瘦身实战
  • Anthropic研究揭示:Claude Code旧版权限提示97%被机械通过
  • DeepSeek-V4-Pro-0813 悄然上线,支持思考与非思考模式
  • AI 生图 Prompt 审核实战:Node.js 调用 Chat JSON Schema 方案
  • 企业AI分析的隐藏陷阱:语义漂移问题深度剖析
  • AI 正在消除软件工程中层:代码看不懂、没人负责的团队困境
  • Qwen3.8-2.4T-A95B 模型发布
  • TraceMotive:本地优先的AI代理执行追踪调试工具
  • AI编程工具正在离开IDE:终端原生Agent工作流崛起
  • 已加载 51 / 9899
8.0
热点
AI SCORE
编程提效2026-08-13 00:50

Python 中缓存 LLM 响应:细节决定成败

dev.to · AI#LLM#缓存#Python
Editor brief · 编辑速览

介绍 LLM 缓存的实现陷阱,指出 cache key 必须包含所有影响答案的变量:模型 ID、采样参数、工具定义、模板版本、租户信息等。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

缓存失效从不是崩溃

缓存失效从不是崩溃——它是你修改了一个 prompt,部署上线,然后在 TTL 到期之前一直用旧 prompt 生成答案。

什么应该进入 key

规则很明确:所有可能改变答案的东西都要进入 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 是正确的第一选择

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 串联起来

串联起来只需要一个 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

问题是:**一个错误的答案可以接受多久?**不是"答案保持正确的时间",而是"服务一个过期答案多久之后会有人在意"。这个重新表述能立刻给出答案,而"这个数据有多易变"只会引发争论。

有两个机制比具体数字更重要。第一,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

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
Anthropic为Agent引入梦境推理能力引热议
下一篇
Harness Engineering:打造 AI 编程助手的工作环境