Skip to content

AI 应用的成本控制与优化

从一个月烧 $500 到 $50——手把手教你把 AI 应用的账单砍下来,同时不牺牲用户体验。


4. 缓存策略:相同的问题不要付两次钱

模型选对了,Prompt 瘦身了——接下来是最暴力的省钱手段:缓存。

想想看,一个知识库问答系统中,用户的问题重复率有多高?"怎么重置密码"、"发货时间是几天"、"怎么申请退款"——这些高频问题可能占了总查询量的 30-50%。每次都让 LLM 重新生成一遍回答,就是在为同一个答案反复付钱


4.1 为什么缓存是 AI 应用的第一道防线

缓存的经济学

缓存的投资回报率:

  假设:
    月查询量 = 10 万次
    单次查询成本 = $0.002(GPT-4o-mini + RAG)
    月总成本 = $200

  场景 1:无缓存
  ─────────────────────────────────────
    10 万次 × $0.002 = $200/月

  场景 2:缓存命中率 30%
  ─────────────────────────────────────
    7 万次 API 调用 × $0.002 = $140/月
    3 万次缓存命中 × $0 = $0
    Redis 成本 ≈ $5/月
    总计:$145/月 → 省 27%

  场景 3:缓存命中率 50%
  ─────────────────────────────────────
    5 万次 API 调用 × $0.002 = $100/月
    Redis 成本 ≈ $5/月
    总计:$105/月 → 省 47%

  场景 4:语义缓存命中率 60%
  ─────────────────────────────────────
    4 万次 API 调用 × $0.002 = $80/月
    Redis + 向量相似度 ≈ $10/月
    总计:$90/月 → 省 55%

  关键:缓存自身的成本(Redis)远低于 LLM API 成本
  → 即使命中率只有 20%,缓存也是值得的

缓存还能提升体验

缓存的双重收益:

  收益 1:省钱 💰
    缓存命中 → 不调用 API → 0 成本

  收益 2:快速响应 ⚡
    API 调用:800-2000ms
    缓存读取:1-5ms

  → 对用户来说,缓存命中的请求"秒回"
  → 省钱的同时还提升了用户体验
  → 这是极少数"鱼和熊掌兼得"的优化

4.2 精确缓存与语义缓存的实现

两种缓存策略,各有适用场景:

精确缓存 vs 语义缓存:

  精确缓存(Exact Match)
  ─────────────────────────────────────
  "怎么重置密码?" → 命中 ✅
  "如何重置密码?" → 未命中 ❌(差了一个字)
  "密码怎么重置?" → 未命中 ❌(语序不同)
  "忘记密码了"     → 未命中 ❌(表达不同)

  → 实现简单,100% 精确
  → 但命中率低(用户表达千变万化)
  → 典型命中率:10-20%


  语义缓存(Semantic Match)
  ─────────────────────────────────────
  "怎么重置密码?" → 命中 ✅
  "如何重置密码?" → 命中 ✅(语义相同)
  "密码怎么重置?" → 命中 ✅(语序不同但含义一样)
  "忘记密码了"     → 命中 ✅(意图相同)
  "我的密码不对"   → 可能命中 ⚠️(语义接近但不完全一样)

  → 实现稍复杂(需要 Embedding + 相似度比较)
  → 命中率高
  → 典型命中率:40-60%
  → 需要设置合理的相似度阈值

精确缓存实现

python
import hashlib
import json
import time

class ExactCache:
    """精确匹配缓存:问题完全一致才命中"""

    def __init__(self):
        self.cache = {}  # 生产环境用 Redis

    def _key(self, question: str, model: str) -> str:
        """生成缓存 key:问题 + 模型的 MD5"""
        raw = f"{question.strip().lower()}:{model}"
        return hashlib.md5(raw.encode()).hexdigest()

    def get(self, question: str, model: str = "gpt-4o-mini") -> dict | None:
        key = self._key(question, model)
        entry = self.cache.get(key)

        if entry and time.time() - entry["timestamp"] < entry["ttl"]:
            return entry["response"]
        return None

    def set(self, question: str, response: dict,
            model: str = "gpt-4o-mini", ttl: int = 3600):
        key = self._key(question, model)
        self.cache[key] = {
            "response": response,
            "timestamp": time.time(),
            "ttl": ttl
        }

# 使用
cache = ExactCache()
cached = cache.get("怎么重置密码?")
if cached:
    answer = cached  # 0 成本,1ms 响应
else:
    answer = call_llm("怎么重置密码?")  # 调 API
    cache.set("怎么重置密码?", answer, ttl=3600)

语义缓存实现

python
import numpy as np
from sentence_transformers import SentenceTransformer

class SemanticCache:
    """语义缓存:问题含义相似即命中"""

    def __init__(self, threshold: float = 0.92):
        self.model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
        self.threshold = threshold
        self.entries = []  # [(embedding, question, response, timestamp)]

    def get(self, question: str) -> dict | None:
        query_emb = self.model.encode([question], normalize_embeddings=True)[0]

        best_score = 0
        best_entry = None

        for emb, cached_q, response, ts in self.entries:
            score = np.dot(query_emb, emb)  # 余弦相似度
            if score > best_score:
                best_score = score
                best_entry = (cached_q, response)

        if best_score >= self.threshold:
            print(f"语义缓存命中!相似度 {best_score:.4f}")
            print(f"  原始问题:{best_entry[0]}")
            print(f"  当前问题:{question}")
            return best_entry[1]

        return None

    def set(self, question: str, response: dict):
        emb = self.model.encode([question], normalize_embeddings=True)[0]
        self.entries.append((emb, question, response, time.time()))

# 使用
sem_cache = SemanticCache(threshold=0.92)

# 第一次调用
answer = call_llm("怎么重置密码?")
sem_cache.set("怎么重置密码?", answer)

# 后续类似问题直接命中
result = sem_cache.get("如何重置我的密码")
# → 语义缓存命中!相似度 0.9651
# → 原始问题:怎么重置密码?

阈值选择:语义缓存的核心参数是相似度阈值。0.95 以上很保守(几乎完全一样才命中),0.88 以下很宽松(可能误命中不同问题)。推荐从 0.92 开始,根据实际误命中情况调整。

4.3 实战:用 Redis 构建 LLM 响应缓存

上面的实现用了 Python 字典——生产环境需要用 Redis,支持持久化、分布式访问和自动过期。

Redis 精确缓存(生产版)

python
import redis
import json
import hashlib
from openai import OpenAI

client = OpenAI()
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)

def cached_chat(
    question: str,
    system_prompt: str = "技术助手,简洁回答。",
    model: str = "gpt-4o-mini",
    ttl: int = 3600
) -> dict:
    """带 Redis 缓存的 LLM 调用"""

    # 1. 构建缓存 key
    cache_key = f"llm:{model}:" + hashlib.md5(
        f"{system_prompt}:{question}".encode()
    ).hexdigest()

    # 2. 查缓存
    cached = redis_client.get(cache_key)
    if cached:
        result = json.loads(cached)
        result["cached"] = True
        return result

    # 3. 未命中,调用 API
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": question}
        ],
        max_tokens=500
    )

    result = {
        "answer": response.choices[0].message.content,
        "model": model,
        "tokens": response.usage.total_tokens,
        "cached": False
    }

    # 4. 写入缓存
    redis_client.setex(cache_key, ttl, json.dumps(result, ensure_ascii=False))
    return result

# 使用
r1 = cached_chat("Python 的 GIL 是什么?")
print(r1["cached"])  # False(首次调用)

r2 = cached_chat("Python 的 GIL 是什么?")
print(r2["cached"])  # True(缓存命中,0 成本)

双层缓存:精确 + 语义

python
class DualLayerCache:
    """双层缓存:先查精确缓存,再查语义缓存"""

    def __init__(self, redis_client, embedding_model, threshold=0.92):
        self.redis = redis_client
        self.model = embedding_model
        self.threshold = threshold

    def get(self, question: str, llm_model: str) -> dict | None:
        # Layer 1:精确匹配(最快,1ms)
        exact_key = self._exact_key(question, llm_model)
        cached = self.redis.get(exact_key)
        if cached:
            return {**json.loads(cached), "cache_type": "exact"}

        # Layer 2:语义匹配(稍慢,5-10ms)
        return self._semantic_search(question, llm_model)

    def set(self, question: str, response: dict, llm_model: str, ttl=3600):
        # 同时写入两层
        exact_key = self._exact_key(question, llm_model)
        self.redis.setex(exact_key, ttl, json.dumps(response, ensure_ascii=False))

        # 存储 Embedding 用于语义匹配
        emb = self.model.encode([question], normalize_embeddings=True)[0]
        sem_key = f"sem:{llm_model}:{exact_key}"
        self.redis.setex(sem_key, ttl, json.dumps({
            "question": question,
            "embedding": emb.tolist(),
            "response": response
        }, ensure_ascii=False))

    def _exact_key(self, question: str, model: str) -> str:
        raw = f"{question.strip().lower()}:{model}"
        return f"llm:exact:{hashlib.md5(raw.encode()).hexdigest()}"

    def _semantic_search(self, question: str, llm_model: str) -> dict | None:
        query_emb = self.model.encode([question], normalize_embeddings=True)[0]

        # 遍历语义缓存(生产环境用向量数据库代替)
        best_score, best_response = 0, None
        for key in self.redis.scan_iter(f"sem:{llm_model}:*"):
            entry = json.loads(self.redis.get(key))
            cached_emb = np.array(entry["embedding"])
            score = float(np.dot(query_emb, cached_emb))
            if score > best_score:
                best_score = score
                best_response = entry["response"]

        if best_score >= self.threshold:
            return {**best_response, "cache_type": "semantic", "similarity": best_score}
        return None

缓存效果监控

python
class CacheMonitor:
    """缓存命中率监控"""

    def __init__(self, redis_client):
        self.redis = redis_client

    def record_hit(self, cache_type: str):
        self.redis.incr(f"cache:hits:{cache_type}")
        self.redis.incr("cache:hits:total")

    def record_miss(self):
        self.redis.incr("cache:misses")

    def get_stats(self) -> dict:
        hits = int(self.redis.get("cache:hits:total") or 0)
        misses = int(self.redis.get("cache:misses") or 0)
        total = hits + misses

        return {
            "total_requests": total,
            "cache_hits": hits,
            "cache_misses": misses,
            "hit_rate": f"{hits/total*100:.1f}%" if total > 0 else "N/A",
            "exact_hits": int(self.redis.get("cache:hits:exact") or 0),
            "semantic_hits": int(self.redis.get("cache:hits:semantic") or 0),
            "estimated_savings": f"${misses * 0.002:.2f} saved by cache"
        }

# 使用
monitor = CacheMonitor(redis_client)
stats = monitor.get_stats()
print(stats)
# {'hit_rate': '42.3%', 'estimated_savings': '$84.60 saved by cache'}

生产提示:语义缓存中用 scan_iter 遍历所有 key 的方式在缓存条目很多时性能差。生产环境推荐用向量数据库(如 Chroma/Milvus)存储缓存 Embedding,查询效率更高。

4.4 缓存失效策略与一致性保障

缓存最怕的事:用户看到过时的答案。知识库更新了,缓存还在返回旧回答——这比不缓存还糟糕。

三种失效策略

策略对比:

  1. TTL 过期(基础)
  ─────────────────────────────────────
    机制:设置固定过期时间,到期自动删除
    配置:redis.setex(key, ttl=3600, value)
    优点:简单,自动运行
    缺点:更新后有最长 TTL 的延迟

    推荐 TTL:
      FAQ / 常见问题 → 24 小时(变化少)
      知识库问答 → 1-4 小时(中等变化)
      实时数据问答 → 5-15 分钟(变化频繁)
      不要缓存 → 涉及个人数据的查询

  2. 主动清除(更新时触发)
  ─────────────────────────────────────
    机制:知识库更新时,主动清除相关缓存
    优点:数据立即一致
    缺点:需要在更新流程中加清除逻辑

  3. 版本号/标签(最精准)
  ─────────────────────────────────────
    机制:缓存 key 包含知识库版本号
    更新时版本号递增 → 旧缓存自动失效
    优点:零误删、零延迟
    缺点:实现稍复杂

主动清除实战

python
def on_knowledge_base_updated(updated_sources: list[str]):
    """知识库更新时,清除相关缓存"""

    # 方式 1:按来源清除
    for source in updated_sources:
        pattern = f"llm:*:{source}:*"
        keys = list(redis_client.scan_iter(pattern))
        if keys:
            redis_client.delete(*keys)
            print(f"清除 {len(keys)} 条缓存(来源:{source})")

    # 方式 2:简单粗暴——清除所有 LLM 缓存
    # 适合知识库整体更新的场景
    # keys = list(redis_client.scan_iter("llm:*"))
    # redis_client.delete(*keys)

def on_system_prompt_changed():
    """System Prompt 修改时,清除所有缓存"""
    keys = list(redis_client.scan_iter("llm:*"))
    if keys:
        redis_client.delete(*keys)
    print(f"System Prompt 变更,清除全部 {len(keys)} 条缓存")

缓存预热

python
def warm_up_cache(common_questions: list[str]):
    """预热缓存:提前对高频问题生成缓存"""
    for q in common_questions:
        cached = redis_client.get(f"llm:gpt-4o-mini:{hashlib.md5(q.encode()).hexdigest()}")
        if not cached:
            result = cached_chat(q, ttl=86400)  # 24 小时
            print(f"预热:{q[:30]}...")

# 从日志中提取 Top 100 高频问题
top_questions = [
    "怎么重置密码?",
    "发货时间是多久?",
    "怎么申请退款?",
    # ... 更多高频问题
]
warm_up_cache(top_questions)

缓存预热时机:可以在每天凌晨低峰期执行预热,也可以在知识库更新后立即预热高频问题。预热 100 个问题的成本约 $0.2,但可以保证白天高峰期用户"秒回"。


本章小结

知识点要点
缓存经济学命中率 30% 就能省 27%,50% 省 47%
双重收益省钱 + 秒回(API 800ms vs 缓存 1ms)
精确缓存MD5 哈希匹配,简单但命中率 10-20%
语义缓存Embedding 相似度匹配,命中率 40-60%
阈值选择推荐 0.92 起步,根据误命中率调整
双层缓存先精确 → 再语义,兼顾速度和命中率
Redis 方案setex 自动过期 + scan_iter 语义搜索
失效策略TTL 兜底 + 更新时主动清除 + 版本号
缓存预热低峰期预热 Top 100 问题,高峰期秒回

下一章预告:RAG 成本优化——检索层的省钱之道。我们会探讨 Embedding 计算成本、Context 窗口管理、分块策略对成本的影响,以及如何通过预过滤减少喂给 LLM 的无关内容。

坚持是一种品格