前端调用大模型 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 可能带不同的 temperature、max_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,因为序列化开销大,只存最终拼接结果即可。