LLM 请求合并工程实践:用 Single-Flight 把并发重复调用从 N 次砍成 1 次

你的推荐系统在高峰期同时有 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 实现应该:

  1. 立即从 in-flight map 删除 key(让下一个请求可以重试)
  2. 把错误传播给所有等待者(不能只给第一个请求报错,其他 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%。

核心要记住的三点:

  1. temperature > 0 时跳过合并,否则创意类应用结果会变质
  2. 失败必须清理 key,否则一次失败会让所有等待者永久挂起
  3. 分布式部署下需要 Redis + pub/sub,进程内 Map 在多 Pod 场景下是摆设

生产中把这三层叠起来------精确缓存 → 语义缓存 → in-flight coalescing------才是 LLM 调用成本优化的完整防线。

相关推荐
程序员爱钓鱼1 小时前
Go 编程实战:数组 Array——固定长度的数据集合
后端·面试·go
程序员黑豆7 小时前
Java包装类:基本类型与对象的桥梁
java·前端·ai编程
桦说编程9 小时前
并发编程中的等待-通知模式:从 wait/notify 到 Guava Monitor
后端
pqpo10 小时前
Agent Team 实践(一): 如何构建跨 Harness 的统一 Runtime
agent·ai编程
Flynt10 小时前
花一下午把Qwen3.8-27B跑在本地,最坑的不是显存
开源·llm·llama
用户9385156350711 小时前
工厂模式与 Nest.js 核心思想 —— 从蜜雪冰城到企业级架构
后端·设计模式·nestjs
用户9385156350711 小时前
实战 Todo CRUD —— 从路由到异常,手写一个完整模块
后端·typescript·nestjs
XLYcmy11 小时前
京东 算法实习一面 下+手撕
c++·python·llm·概率论·数据处理·训练·codebert
GetcharZp11 小时前
Qdrant 向量数据库 Golang 实战指南:从零构建高性能检索应用
后端