你的推荐系统在高峰期同时有 80 个请求问同一个问题,却打出了 80 次 LLM 调用。语义缓存没命中,去重逻辑没覆盖,账单翻了 8 倍。这篇文章讲的就是:在请求真正落到 LLM 之前,把那 80 次压成 1 次。
先把概念理清楚
做 LLM 优化的团队经常混用三个词:去重(deduplication) 、缓存(caching) 、请求合并(coalescing)。它们解决的是不同层面的问题:
| 模式 | 作用时机 | 命中条件 | 典型实现 |
|---|---|---|---|
| 语义缓存 | 请求到达前 | 向量相似度 ≥ 阈值 | Redis + embedding |
| 精确去重 | 请求到达前 | prompt hash 完全匹配 | Redis SET NX |
| 请求合并 | 请求进行中 | 相同 key 的并发请求 | single-flight / promise dedup |
关键区别在于时机:缓存和去重都是在新请求到来时检查历史结果,而请求合并关注的是「当前有没有相同的请求正在飞行中」。如果有,新请求不发起新的 LLM 调用,而是「搭便车」等待那个正在进行的请求完成,然后共享同一份结果。
这个模式在 CDN 领域叫 request coalescing ,在 Go 社区叫 singleflight ,在 Promise 领域叫 promise dedup 。叫法不同,本质一样:同一时刻只有一个 in-flight 操作。
真实场景:为什么缓存不够
先看一个典型的 LLM 应用架构:
css
用户请求 → 语义缓存检查 → [命中] → 直接返回
→ [未命中] → LLM API 调用 → 缓存结果 → 返回
这个架构在低并发下没有问题。但在高峰期,假设 100ms 内来了 50 个相同的请求:
ini
T=0ms 请求A 进入 → 缓存未命中 → 开始调用 LLM(需要 2000ms)
T=10ms 请求B 进入 → 缓存未命中(A还没回来呢)→ 开始调用 LLM
T=20ms 请求C 进入 → 缓存未命中 → 开始调用 LLM
...
T=100ms 请求50进入 → 缓存未命中 → 开始调用 LLM
结果:50 次相同的 LLM 调用同时飞出去,账单乘以 50 倍。等第一个请求在 2000ms 后回来,后面的请求才能命中缓存------但这时候,另外 49 个调用已经全部发出去了。
这就是经典的 **cache stampede(缓存雪崩)**在 LLM 场景下的变体。语义缓存解决不了它,因为 LLM 调用本身就是缓慢的异步操作。
Single-Flight 原理
Single-flight 的核心数据结构就是一个 in-flight 请求的 Map:
typescript
// 核心数据结构
type InFlightMap = Map<string, Promise<LLMResponse>>;
class LLMSingleFlight {
private inFlight: InFlightMap = new Map();
async call(key: string, fn: () => Promise<LLMResponse>): Promise<LLMResponse> {
// 如果已有相同 key 的请求在飞行中,直接返回同一个 Promise
if (this.inFlight.has(key)) {
return this.inFlight.get(key)!;
}
// 否则发起新请求,并注册到 in-flight map
const promise = fn().finally(() => {
// 请求完成(成功或失败)后,从 map 中移除
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
}
这 20 行代码做到了:相同 key 的并发请求只触发一次实际调用,所有等待者共享同一个 Promise。
对于 LLM 调用,key 通常是 prompt 的 hash:
typescript
import { createHash } from 'crypto';
function promptKey(prompt: string, model: string, params: object): string {
const content = JSON.stringify({ prompt, model, params });
return createHash('sha256').update(content).digest('hex').slice(0, 16);
}
完整实现:生产可用的 LLM Request Coalescer
下面是一个生产可用的实现,包含超时、错误隔离和可观测性:
typescript
import { createHash } from 'crypto';
import { EventEmitter } from 'events';
interface LLMRequest {
prompt: string;
model: string;
temperature?: number;
maxTokens?: number;
}
interface LLMResponse {
content: string;
usage: { promptTokens: number; completionTokens: number };
model: string;
}
interface CoalescerMetrics {
hits: number; // 命中 in-flight 的请求数
misses: number; // 发起新 LLM 调用的请求数
errors: number; // 失败的调用数
savings: number; // 节省的 LLM 调用次数(hits - 0 = hits)
}
class LLMRequestCoalescer extends EventEmitter {
private inFlight = new Map<string, Promise<LLMResponse>>();
private metrics: CoalescerMetrics = { hits: 0, misses: 0, errors: 0, savings: 0 };
private readonly timeout: number;
constructor(options: { timeoutMs?: number } = {}) {
super();
this.timeout = options.timeoutMs ?? 30_000;
}
private buildKey(req: LLMRequest): string {
const { prompt, model, temperature = 0, maxTokens = 2048 } = req;
// 只对决定性参数(temperature=0 时输出确定)做合并
// temperature > 0 时每次结果不同,不适合合并
if (temperature > 0) {
// 不合并随机性请求:每次都生成唯一 key
return `rand-${Date.now()}-${Math.random()}`;
}
return createHash('sha256')
.update(JSON.stringify({ prompt, model, maxTokens }))
.digest('hex')
.slice(0, 16);
}
async call(
req: LLMRequest,
fn: (req: LLMRequest) => Promise<LLMResponse>
): Promise<LLMResponse> {
const key = this.buildKey(req);
if (this.inFlight.has(key)) {
this.metrics.hits++;
this.metrics.savings++;
this.emit('coalesced', { key, totalHits: this.metrics.hits });
return this.inFlight.get(key)!;
}
this.metrics.misses++;
// 包装超时
const callWithTimeout = (): Promise<LLMResponse> => {
return Promise.race([
fn(req),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeout}ms`)), this.timeout)
),
]);
};
const promise = callWithTimeout()
.catch((err) => {
this.metrics.errors++;
this.emit('error', { key, error: err });
throw err;
})
.finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
getMetrics(): CoalescerMetrics {
return { ...this.metrics };
}
getInflightCount(): number {
return this.inFlight.size;
}
}
// 使用示例
const coalescer = new LLMRequestCoalescer({ timeoutMs: 30_000 });
async function callLLM(req: LLMRequest): Promise<LLMResponse> {
return coalescer.call(req, async (r) => {
// 实际的 LLM API 调用
const response = await fetch('https://api.example.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.API_KEY}` },
body: JSON.stringify({
model: r.model,
messages: [{ role: 'user', content: r.prompt }],
temperature: r.temperature ?? 0,
max_tokens: r.maxTokens ?? 2048,
}),
});
const data = await response.json();
return {
content: data.choices[0].message.content,
usage: data.usage,
model: data.model,
};
});
}
流式响应的合并:真正的难点
上面的实现对于非流式响应(一次性返回完整结果)足够用了。但现代 LLM 应用大多使用 流式响应(SSE/streaming),这让 coalescing 复杂了一个数量级。
问题在于:流式响应不是一个 Promise<string>,而是一个 AsyncIterator<string>。你没法简单地把同一个 AsyncIterator 给多个消费者。
方案一:Buffer + Fan-out(推荐)
把 SSE 流缓冲起来,然后广播给所有等待者:
typescript
class StreamCoalescer {
private inFlight = new Map<
string,
{
chunks: string[];
done: boolean;
error?: Error;
listeners: Array<(chunk: string | null, error?: Error) => void>;
}
>();
async *coalescedStream(
key: string,
fn: () => AsyncGenerator<string>
): AsyncGenerator<string> {
if (this.inFlight.has(key)) {
// 搭便车:从当前 buffer 开始回放,然后监听新 chunk
yield* this.joinStream(key);
return;
}
// 发起新的流式请求
const entry = {
chunks: [] as string[],
done: false,
error: undefined as Error | undefined,
listeners: [] as Array<(chunk: string | null, error?: Error) => void>,
};
this.inFlight.set(key, entry);
// 后台消费源流,同时广播给所有监听者
(async () => {
try {
for await (const chunk of fn()) {
entry.chunks.push(chunk);
entry.listeners.forEach((l) => l(chunk));
}
entry.done = true;
entry.listeners.forEach((l) => l(null)); // null 表示结束
} catch (err) {
entry.error = err as Error;
entry.listeners.forEach((l) => l(null, entry.error));
} finally {
// 流完成后延迟清理,给后来者读 buffer 的机会
setTimeout(() => this.inFlight.delete(key), 100);
}
})();
// 主请求也作为一个监听者
yield* this.joinStream(key);
}
private async *joinStream(key: string): AsyncGenerator<string> {
const entry = this.inFlight.get(key);
if (!entry) return;
// 1. 先回放已有 buffer
for (const chunk of entry.chunks) {
yield chunk;
}
// 如果流已经完成,直接返回
if (entry.done) return;
if (entry.error) throw entry.error;
// 2. 注册监听器,等待新 chunk
const queue: string[] = [];
let resolve: (() => void) | null = null;
let streamDone = false;
let streamError: Error | undefined;
const listener = (chunk: string | null, error?: Error) => {
if (error) {
streamError = error;
} else if (chunk === null) {
streamDone = true;
} else {
queue.push(chunk);
}
resolve?.();
};
entry.listeners.push(listener);
try {
while (true) {
// 消费队列中的 chunk
while (queue.length > 0) {
yield queue.shift()!;
}
if (streamDone) break;
if (streamError) throw streamError;
// 等待下一个 chunk
await new Promise<void>((r) => {
resolve = r;
});
resolve = null;
}
} finally {
const idx = entry.listeners.indexOf(listener);
if (idx !== -1) entry.listeners.splice(idx, 1);
}
}
}
方案二:ReadableStream + Tee(浏览器/Edge Runtime 适用)
对于 Web 环境,可以用 ReadableStream.tee() 来分叉流:
typescript
// 每次需要一个新消费者时 tee 一份
function teeStream(stream: ReadableStream): [ReadableStream, ReadableStream] {
return stream.tee();
}
// 但注意:tee 会在内存中 buffer 两份数据,不适合无限流或大数据量
这个方案简洁,但内存开销是 Buffer Fan-out 的两倍,且只能 tee 一次(要给 N 个消费者需要多次 tee,树形结构)。生产上超过 3 个消费者时建议换回 Fan-out 方案。
生产踩坑:5 个必须知道的陷阱
陷阱 1:temperature > 0 时不能合并
这是最容易犯的错误。当 temperature=0 时,相同 prompt 产生相同输出,合并完全合理。但 temperature=0.7 时,每次调用本该产生不同的创意结果------如果合并了,N 个用户会拿到完全相同的「随机」内容,用户体验崩掉。
typescript
// 正确做法:temperature > 0 时跳过合并
function shouldCoalesce(req: LLMRequest): boolean {
return (req.temperature ?? 0) === 0;
}
陷阱 2:失败的请求不能缓存,但已合并的请求必须全部收到错误
当 LLM 调用失败时,你的 single-flight 实现应该:
- 立即从 in-flight map 删除 key(让下一个请求可以重试)
- 把错误传播给所有等待者(不能只给第一个请求报错,其他 Promise 不能永远悬挂)
上面的实现用 .catch + .finally 组合处理了这个问题:finally 保证删除,catch 保证错误传播。
typescript
// 错误实现(危险!):只在成功时删除
const promise = fn().then(result => {
this.inFlight.delete(key); // 失败时这行不执行!
return result;
});
// 正确实现:无论成功失败都删除
const promise = fn().finally(() => {
this.inFlight.delete(key);
});
陷阱 3:分布式环境下 in-flight map 失效
上面所有实现都是进程内的,如果你的应用有多个 Pod/Worker,不同 Pod 上的 in-flight map 互不可见,请求合并退化为零。
解决方案:用 Redis + Lua 实现分布式 single-flight:
typescript
const ACQUIRE_LOCK = `
local key = KEYS[1]
local ttl = ARGV[1]
local existing = redis.call('GET', key)
if existing then
return {0, existing} -- 0 = 已有在飞请求,返回 waitKey
end
redis.call('SET', key, ARGV[2], 'PX', ttl)
return {1, ''} -- 1 = 当前进程获得执行权
`;
async function distributedSingleFlight(
redis: Redis,
key: string,
fn: () => Promise<string>,
ttlMs: number = 30_000
): Promise<string> {
const lockKey = `sf:lock:${key}`;
const resultKey = `sf:result:${key}`;
const waitKey = `sf:wait:${key}`;
const [acquired] = await redis.eval(ACQUIRE_LOCK, [lockKey], [ttlMs, waitKey]) as [number, string];
if (!acquired) {
// 等待其他进程完成,通过 pub/sub 通知
return new Promise((resolve, reject) => {
redis.subscribe(waitKey, (message) => {
const { result, error } = JSON.parse(message);
if (error) reject(new Error(error));
else resolve(result);
});
});
}
try {
const result = await fn();
// 存结果并通知等待者
await redis.set(resultKey, result, 'PX', ttlMs);
await redis.publish(waitKey, JSON.stringify({ result }));
return result;
} catch (err) {
await redis.publish(waitKey, JSON.stringify({ error: (err as Error).message }));
throw err;
} finally {
await redis.del(lockKey);
}
}
陷阱 4:超时与 Key 泄漏
如果 LLM 调用本身没有超时机制,一个卡死的请求会让 in-flight map 中的 key 永远存在,后续所有相同 key 的请求都在等一个永远不会完成的 Promise。
必须给 single-flight 加超时保护:
typescript
// 强制超时:LLM 调用超过 30s 自动失败并清理 key
const promise = Promise.race([
fn(req),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 30_000)
),
]).finally(() => this.inFlight.delete(key));
陷阱 5:合并窗口期设计
对于批量处理场景(不要求实时),可以引入合并窗口期(coalescing window):等待 N ms 后把这段时间内的相同请求批量合并:
typescript
class WindowedCoalescer {
private pending = new Map<string, {
requests: Array<{ resolve: (v: string) => void; reject: (e: Error) => void }>;
timer: NodeJS.Timeout;
}>();
call(key: string, prompt: string, delay: number = 50): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.pending.has(key)) {
const timer = setTimeout(async () => {
const entry = this.pending.get(key)!;
this.pending.delete(key);
try {
const result = await callActualLLM(prompt);
entry.requests.forEach(({ resolve }) => resolve(result));
} catch (err) {
entry.requests.forEach(({ reject }) => reject(err as Error));
}
}, delay);
this.pending.set(key, { requests: [{ resolve, reject }], timer });
} else {
this.pending.get(key)!.requests.push({ resolve, reject });
}
});
}
}
这个模式适合非实时的推荐/分析场景,50ms 的合并窗口在高并发下可以合并 10-20 个请求。
与其他优化的组合策略
单独使用 coalescing 效果有限,生产中通常与其他策略叠加:
csharp
请求进入
↓
[1] 精确 hash 缓存检查(Redis GET,<1ms)
↓ 未命中
[2] 语义缓存检查(embedding 相似度,~10ms)
↓ 未命中
[3] in-flight single-flight 检查(内存,<0.1ms)
↓ 无相同飞行中请求
[4] 实际 LLM API 调用(500ms - 5000ms)
↓ 返回
[5] 同时写入缓存 + 通知 single-flight 等待者
这个三层防护的效果:
- 第一层(精确缓存):命中率 20-40%,处理日常重复请求
- 第二层(语义缓存):再捞 15-25%,处理语义相似请求
- 第三层(coalescing):把高峰期的并发爆炸从 10x-50x 压到 1x
可观测性:你必须量的指标
添加了 coalescing 之后,要监控这些指标才能知道它在生效:
typescript
// Prometheus 指标示例
const coalescingMetrics = {
// 核心指标
coalescedTotal: counter('llm_coalescing_coalesced_total', 'Total requests coalesced'),
missTotal: counter('llm_coalescing_miss_total', 'Total requests that triggered new LLM calls'),
inflightGauge: gauge('llm_coalescing_inflight', 'Current in-flight LLM calls'),
// 派生指标(在 Grafana 里算)
// coalescingRatio = coalescedTotal / (coalescedTotal + missTotal)
// costSavingRatio ≈ coalescedTotal / (coalescedTotal + missTotal)
// 异常指标
errorTotal: counter('llm_coalescing_error_total', 'LLM call errors'),
timeoutTotal: counter('llm_coalescing_timeout_total', 'LLM call timeouts'),
};
// 添加到 coalescer
coalescer.on('coalesced', ({ key }) => {
coalescingMetrics.coalescedTotal.inc({ key_prefix: key.slice(0, 4) });
});
正常运行时,合并率(coalescing ratio)在高峰期应该在 40%-80% 之间。如果合并率始终接近 0,说明你的请求模式不适合合并(每次 prompt 都不同),或者 key 生成策略有问题。
实战数据:一个真实案例
某内容推荐服务的场景:
- 服务:文章摘要生成,同一篇文章 ID 对应固定的 summarize prompt
- 流量:高峰期 500 QPS,其中约 60% 是重复文章
- 问题:热门文章发布后的 5 分钟内,同一篇文章触发 200-300 次 LLM 调用
引入 coalescing 前后对比:
| 指标 | Before | After |
|---|---|---|
| 高峰期 LLM QPS | 500 | 80-120 |
| 单文章最大并发 LLM 调用 | 300 | 1 |
| P99 响应延迟 | 4200ms | 2100ms |
| 月 LLM 费用 | $8,400 | $2,100 |
延迟下降的原因:高峰期的并发 LLM 调用使服务商侧出现排队,合并后队列压力减轻,响应速度自然上升。
适合与不适合的场景
适合 coalescing 的:
- temperature=0 的确定性调用
- 文档摘要、内容分类、实体抽取等批量处理
- 推荐理由生成(同一商品 ID → 同一 prompt)
- 代码审查、文档生成(同一文件内容 → 同一 prompt)
不适合 coalescing 的:
- temperature > 0 的创意生成
- 包含时间戳、随机 seed 的 prompt
- 用户个性化内容(每个用户 context 不同)
- 需要每次独立审计日志的合规场景
小结
Request coalescing 是语义缓存和精确去重之间的空档区------它解决的是「相同请求同时发出但缓存还没预热」的并发爆炸问题。实现成本极低(核心逻辑 20 行),但在高并发重复请求场景下能把 LLM 费用直接打下去 60%-80%。
核心要记住的三点:
- temperature > 0 时跳过合并,否则创意类应用结果会变质
- 失败必须清理 key,否则一次失败会让所有等待者永久挂起
- 分布式部署下需要 Redis + pub/sub,进程内 Map 在多 Pod 场景下是摆设
生产中把这三层叠起来------精确缓存 → 语义缓存 → in-flight coalescing------才是 LLM 调用成本优化的完整防线。