前端调用大模型 API,我在请求层用这三层缓存把成本打下来了

前端调用大模型 API,最常见的一笔糊涂账就是:同一个 prompt,用户刷新一下页面就再请求一次,钱就这么烧掉了。我现在的做法是在请求层叠三层缓存——内存缓存、sessionStorage 持久化缓存、请求去重队列——把单次会话的重复请求归零,跨页面恢复直接读缓存,并发相同请求只发一次网络调用。三层各司其职,命中率达 80% 以上的场景不在少数。

下面按缓存粒度从细到粗,把每一层的设计决策、代码实现和踩过的坑都说清楚。

第一层:内存缓存(Map + LRU),解决组件重渲染和页面内重复调用

这一层直接解决的问题是:同一个组件在 React 重渲染时发起完全相同的请求,或者用户短时间内多次点击触发同一段逻辑。内存缓存的本质是一个带淘汰策略的 Map,我用的是 LRU(Least Recently Used),因为大模型请求的调用频率分布极不均匀——少数几个 prompt 会被反复调用,大部分只用一次。

具体实现上,我没有引入第三方库,直接用 Map 配合 WeakMap 做键值管理,淘汰逻辑自己写。核心代码如下:

class LRUCache<T> {
  private cache: Map<string, { value: T; timestamp: number }>;
  private maxSize: number;
  private ttl: number; // 毫秒

  constructor(maxSize = 50, ttl = 10 * 60 * 1000) {
    this.cache = new Map();
    this.maxSize = maxSize;
    this.ttl = ttl;
  }

  get(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    // 检查 TTL 过期
    if (Date.now() - entry.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    // LRU 刷新:删除再插入,把当前条目移到末尾
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.value;
  }

  set(key: string, value: T): void {
    // 淘汰最旧的条目
    if (this.cache.size >= this.maxSize) {
      const oldestKey = this.cache.keys().next().value;
      if (oldestKey) this.cache.delete(oldestKey);
    }

    this.cache.set(key, { value, timestamp: Date.now() });
  }
}

缓存键的设计是关键。不能直接用 prompt 字符串,因为同一个 prompt 可能带不同的 temperaturemax_tokens 等参数,生成的响应完全不同。我用的键生成策略是:

function generateCacheKey(params: {
  model: string;
  messages: { role: string; content: string }[];
  temperature?: number;
  max_tokens?: number;
}): string {
  const payload = JSON.stringify({
    model: params.model,
    messages: params.messages,
    temperature: params.temperature ?? 0.7,
    max_tokens: params.max_tokens ?? 1024,
  });
  return simpleHash(payload); // 用 SHA-256 的前 16 位,碰撞概率可忽略
}

这里用 SHA-256 取前 16 位而不是完整的 hash,是因为缓存键本身也会占用内存,而且 16 位 hex 在 50 条缓存上限下碰撞概率低于 10^-8,完全够用。如果你用 Node.js 环境,直接调 crypto.createHash('sha256').update(payload).digest('hex').slice(0, 16);浏览器端我用的是一个轻量的纯 JS 实现,体积不到 1KB。

这一层缓存放在请求函数的入口,优先级最高。调用时先查内存缓存,命中直接返回,没命中再往下走。

第二层:sessionStorage 持久化缓存,跨页面恢复时避免重复请求

内存缓存有一个致命缺陷:用户刷新页面或在新标签页打开同个应用,缓存全丢。但大模型请求的特点是,同一个用户在同一个 session 内,高频调用的 prompt 有很强的重复性——比如用户反复让模型总结同一段长文本,每次只微调一句话。

sessionStorage 正好匹配这个场景:同源同标签页共享,关闭标签页后自动清除,不需要手动管理过期。我把这一层放在内存缓存 miss 之后、真正发起网络请求之前。

实现时需要注意两点。第一,sessionStorage 只能存字符串,序列化和反序列化有性能开销,所以不适合存流式响应(streaming response)的中间状态,只存完整的响应结果。第二,sessionStorage 有 5MB 的限制,大模型的单次响应通常只有几 KB 到几十 KB,存几百条绰绰有余,但为了安全,我在写入前做容量检查。

function getSessionCache(key: string): string | null {
  try {
    const entry = sessionStorage.getItem(`llm_cache_${key}`);
    if (!entry) return null;

    const parsed = JSON.parse(entry);
    // 检查是否过期(sessionStorage 本身不过期,手动加时间戳)
    if (Date.now() - parsed.timestamp > 30 * 60 * 1000) {
      sessionStorage.removeItem(`llm_cache_${key}`);
      return null;
    }
    return parsed.value;
  } catch {
    return null;
  }
}

function setSessionCache(key: string, value: string): void {
  try {
    // 容量检查:超过 4MB 时清理最旧的 20% 条目
    const currentSize = new Blob(Object.values(sessionStorage)).size;
    if (currentSize > 4 * 1024 * 1024) {
      pruneSessionCache();
    }

    sessionStorage.setItem(
      `llm_cache_${key}`,
      JSON.stringify({ value, timestamp: Date.now() })
    );
  } catch {
    // quota exceeded 时直接清掉旧缓存
    pruneSessionCache(0.5);
  }
}

这里有个细节:sessionStorage 的过期时间我设了 30 分钟,比内存缓存的 10 分钟长。原因是跨页面恢复的场景中,用户可能在页面 A 发起请求,切到页面 B 操作一段时间再回来,10 分钟太短容易误过期。30 分钟是经验值,你可以根据自己应用的 session 长度调整。

第三层:请求去重队列,并发相同请求只发一次网络调用

前两层解决的是时间维度上的重复调用,但还有一种更隐蔽的浪费:同一时刻,多个组件或多次快速操作触发了完全相同的请求。比如一个仪表盘页面,三个图表组件同时加载,都需要调用模型分析同一份数据。如果不做处理,三个请求同时发出去,API 费用直接乘 3。

这一层的做法是在请求层维护一个 in-flight 请求的 Map,键和缓存的键相同。当新请求进来时,先检查是否已经有相同的请求正在飞行中,如果有,直接返回那个请求的 Promise,等它 resolve 后一起拿到结果。

const inFlightRequests = new Map<string, Promise<string>>();

async function fetchWithDedup(
  cacheKey: string,
  requestFn: () => Promise<string>
): Promise<string> {
  // 检查是否有相同的请求正在进行
  const existing = inFlightRequests.get(cacheKey);
  if (existing) {
    return existing;
  }

  // 创建新请求并注册
  const promise = requestFn()
    .then((result) => {
      // 请求完成后从队列中移除
      inFlightRequests.delete(cacheKey);
      // 同时写入前两层缓存
      memoryCache.set(cacheKey, result);
      setSessionCache(cacheKey, result);
      return result;
    })
    .catch((error) => {
      inFlightRequests.delete(cacheKey);
      throw error;
    });

  inFlightRequests.set(cacheKey, promise);
  return promise;
}

这里有一个容易被忽略的问题:如果请求失败,留在队列里的 Promise 会导致后续相同请求也直接拿到失败的 Promise。所以 catch 块里必须 delete,并且重新 throw error,让调用方正常处理错误,同时让后续请求可以重新发起。

三层合起来,完整的请求流程是这样:

async function callLLM(params: LLMParams): Promise<string> {
  const cacheKey = generateCacheKey(params);

  // 第一层:内存缓存
  const memResult = memoryCache.get(cacheKey);
  if (memResult) return memResult;

  // 第二层:sessionStorage 缓存
  const sessionResult = getSessionCache(cacheKey);
  if (sessionResult) {
    memoryCache.set(cacheKey, sessionResult); // 回填内存缓存
    return sessionResult;
  }

  // 第三层:请求去重 + 网络调用
  return fetchWithDedup(cacheKey, () => actualAPIRequest(params));
}

实际效果和几个边界场景

我在一个内部工具上跑了两周的数据,日均 API 调用约 1200 次。三层缓存整体命中率在 78% 左右,其中内存缓存贡献了约 55%,sessionStorage 贡献了约 15%,请求去重贡献了约 8%。按 GPT-4o 的定价算,一个月省了大约 120 美元。这个数字不大,但考虑到这个工具的用户才十几个人,规模化之后节省会更明显。

有几个边界场景值得单独说。

流式响应的缓存处理。 如果用的是 streaming(stream: true),响应是一块一块回来的,不能直接缓存整个字符串。我的做法是:在请求去重层对 streaming 请求做特殊处理,把多个监听者注册到同一个流上,每个 chunk 同时推给所有监听者。流结束后,把拼接好的完整响应写入前两层缓存。代码略复杂,核心是用一个 Set<callback> 维护所有监听者。

缓存失效策略。 用户改了 prompt 中的一个字,缓存键就变了,这是符合预期的。但有一个场景是:用户反复微调 system prompt,每次都生成新的缓存条目。这本身不是问题,因为 LRU 会自动淘汰旧条目。但如果你的应用频繁改 system prompt,可以把 system prompt 的 hash 放在缓存键里,或者单独维护一个 system prompt 的版本号。

跨标签页的缓存同步。 sessionStorage 天然隔离不同标签页,这意味着用户在标签页 A 发起的请求,标签页 B 不会自动拿到缓存。如果需要跨标签页共享,可以用 localStorage 替代 sessionStorage,但要注意 localStorage 没有自动清除机制,需要手动管理过期。或者用 BroadcastChannel API 在标签页间同步内存缓存,实现起来不复杂,但一般场景下没必要。

常见问题

缓存键用 SHA-256 前 16 位,真的不会碰撞吗?

在缓存上限 50 条、日均 1200 次调用的场景下,碰撞概率约为 1200 × 50 / 16^16,这个数字小于 10^-8,实际使用中几乎不可能遇到。如果你实在不放心,用完整 SHA-256 字符串做键就行,多占几十字节内存而已。

sessionStorage 的 5MB 限制会不会不够用?

单条大模型响应通常 2-20KB,5MB 能存 250-2500 条。加上我们设的 30 分钟过期和容量检查,实际占用很少超过 1MB。如果你返回的是 base64 图片或多模态内容,响应体积可能到几百 KB,这时可以把缓存上限调小,或者只缓存纯文本响应。

请求去重层在请求失败后会怎样?

失败的 Promise 会从 in-flight 队列中移除,后续相同请求会重新发起网络调用。这避免了「一个请求失败导致所有后续请求都拿到错误」的问题。但要注意,如果你的 API 因为限流返回 429,短时间内大量重试可能加重限流。建议在 actualAPIRequest 里单独处理重试逻辑和退避策略,不要依赖缓存层解决这个问题。

streaming 模式下三层缓存还能工作吗?

能,但需要额外处理。请求去重层要把多个监听者注册到同一个流上,每个 chunk 广播给所有监听者。流结束后拼接完整响应写入前两层缓存。Streaming 响应不适合直接存 sessionStorage,因为序列化开销大,只存最终拼接结果即可。