批量生成不能简单地把所有请求交给
Promise.all,因为应用会在瞬间把压力传递给模型服务、连接池和自身内存。本文用一个可直接运行的 Node.js 调度器,把并发预算、有界队列、超时取消与租户公平性放进同一条请求链路。
Promise.all 为什么会放大故障
假设一个内容平台允许用户一次生成 100 条商品描述。最直接的实现通常是:
const results = await Promise.all(
products.map(product => generateDescription(product))
);
Promise.all 负责聚合结果,但不负责限制任务启动速度。只要 generateDescription 在调用时立即发出网络请求,这 100 个任务就会几乎同时进入模型 SDK、HTTP 连接池或上游网关。
在多租户服务中,问题会进一步放大:一个租户提交大批量任务,可能占满全部连接;其他租户的少量交互请求只能排在后面。上游开始返回 429 后,若客户端立即重试,又会形成新的流量尖峰。
| 问题 | 直接影响 | 常见连锁反应 |
|---|---|---|
| 无并发上限 | 同时请求数快速增长 | 连接耗尽、429、超时 |
| 无队列上限 | 待处理 Promise 持续堆积 | 内存上涨、GC 压力增加 |
| 不区分模型 | 慢模型占用全部执行槽 | 快模型请求也被拖慢 |
| 不区分租户 | 大批量租户长期占队 | 小请求出现高尾延迟 |
| 只设置网络超时 | 请求在队列中无限等待 | 用户已离开,任务仍然执行 |
关键点不是彻底消除排队,而是让排队发生在可观察、可拒绝、可取消的应用层,而不是失控地转移到连接池和上游服务中。
把容量拆成四个控制面
一个实用的 LLM 请求调度器至少需要四类约束。
第一是分模型并发预算。不同模型的延迟、成本和上游配额可能不同,例如普通生成模型允许 3 个并发,推理模型只允许 1 个。预算应按实际部署、账号或区域继续细分,而不是假设所有模型共享同一容量。
第二是有界等待队列。当执行槽已满时,请求可以短暂等待;达到队列上限后必须快速失败。对 HTTP 服务,通常可以返回 503 并附带 Retry-After,也可以在明确表示租户配额不足时返回 429。具体状态码要与网关和客户端重试策略保持一致。
第三是覆盖排队时间的总超时。如果只在真正调用模型时启动超时,一个已经等待 20 秒的请求还会继续消耗上游资源。更合理的做法是在进入调度器时启动计时,并通过 AbortSignal 把取消传递给实际请求。
第四是租户公平调度。简单 FIFO 会让先提交 1000 个任务的租户挡住后来的交互请求。轮询租户队列可以让每次调度机会在活跃租户之间流转。它不能抢占已经运行的任务,但能避免单个租户长期垄断后续执行槽。
可运行的 Node.js 调度器
下面的示例只使用 Node.js 内置 API,可保存为 scheduler.mjs,然后使用 Node.js 18 或更高版本运行 node scheduler.mjs。示例中的 fakeLlm 模拟可取消的模型调用;接入真实 SDK 时,应把同一个 signal 传给该 SDK 支持的取消参数。
import { setTimeout as sleep } from 'node:timers/promises';
class BusyError extends Error {
constructor(message = 'queue is full') {
super(message);
this.name = 'BusyError';
}
}
class FairScheduler {
constructor({ modelLimits, maxQueue, worker }) {
this.limits = new Map(Object.entries(modelLimits));
this.running = new Map(
[...this.limits.keys()].map(model => [model, 0])
);
this.maxQueue = maxQueue;
this.worker = worker;
this.queues = new Map();
this.tenantRing = [];
this.activeTenants = new Set();
this.queued = 0;
}
run(job, { timeoutMs = 5000 } = {}) {
if (!this.limits.has(job.model)) {
return Promise.reject(new Error(`unknown model: ${job.model}`));
}
if (this.queued >= this.maxQueue) {
return Promise.reject(new BusyError());
}
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new Error('request deadline exceeded'));
}, timeoutMs);
const promise = new Promise((resolve, reject) => {
const item = {
job,
signal: controller.signal,
resolve,
reject,
state: 'queued',
onAbort: null
};
let queue = this.queues.get(job.tenantId);
if (!queue) {
queue = [];
this.queues.set(job.tenantId, queue);
}
queue.push(item);
this.queued += 1;
if (!this.activeTenants.has(job.tenantId)) {
this.activeTenants.add(job.tenantId);
this.tenantRing.push(job.tenantId);
}
item.onAbort = () => {
if (item.state !== 'queued') return;
const index = queue.indexOf(item);
if (index !== -1) {
queue.splice(index, 1);
this.queued -= 1;
}
item.state = 'done';
reject(item.signal.reason ?? new Error('aborted'));
this.pump();
};
item.signal.addEventListener('abort', item.onAbort, { once: true });
this.pump();
});
return promise.finally(() => clearTimeout(timer));
}
nextRunnable() {
const turns = this.tenantRing.length;
for (let i = 0; i < turns; i += 1) {
const tenantId = this.tenantRing.shift();
this.activeTenants.delete(tenantId);
const queue = this.queues.get(tenantId) ?? [];
const index = queue.findIndex(item => {
const model = item.job.model;
return this.running.get(model) < this.limits.get(model);
});
let item = null;
if (index !== -1) {
item = queue.splice(index, 1)[0];
this.queued -= 1;
}
if (queue.length > 0) {
this.tenantRing.push(tenantId);
this.activeTenants.add(tenantId);
} else {
this.queues.delete(tenantId);
}
if (item) return item;
}
return null;
}
pump() {
while (true) {
const item = this.nextRunnable();
if (!item) return;
const model = item.job.model;
item.state = 'running';
this.running.set(model, this.running.get(model) + 1);
Promise.resolve()
.then(() => this.worker(item.job, item.signal))
.then(item.resolve, item.reject)
.finally(() => {
item.state = 'done';
item.signal.removeEventListener('abort', item.onAbort);
this.running.set(model, this.running.get(model) - 1);
this.pump();
});
}
}
}
async function fakeLlm(job, signal) {
const latency = 300 + Math.floor(Math.random() * 700);
await sleep(latency, undefined, { signal });
return `${job.model}: ${job.prompt}`;
}
const scheduler = new FairScheduler({
modelLimits: {
chat: 3,
reasoning: 1
},
maxQueue: 8,
worker: fakeLlm
});
const tasks = [];
for (let i = 0; i < 6; i += 1) {
for (const tenantId of ['tenant-a', 'tenant-b', 'tenant-c']) {
tasks.push(
scheduler.run(
{
tenantId,
model: i % 3 === 0 ? 'reasoning' : 'chat',
prompt: `task-${i}`
},
{ timeoutMs: 1800 }
)
);
}
}
const results = await Promise.allSettled(tasks);
for (const result of results) {
if (result.status === 'fulfilled') {
console.log('OK ', result.value);
} else {
console.log('ERR', result.reason.name, result.reason.message);
}
}
这里仍然使用了 Promise.allSettled,但它只负责等待结果;真正的启动节奏由调度器控制。队列满时,新任务立即得到 BusyError。超时从进入调度器开始计算:任务尚在队列时会被移除,已经运行时则由 AbortSignal 中断可取消的工作。
调度器会在租户之间轮询,并在某个模型没有容量时寻找该租户队列中其他可运行模型,因此它提供的是公平性优先的弱顺序,而不是严格 FIFO。如果业务要求同一会话严格有序,应单独为会话增加串行约束。
接入生产服务时还要补什么
调度器解决的是进程内负载控制,不等于完整的分布式配额系统。多实例部署时,每个进程都持有一份预算:如果 10 个实例各允许 3 个并发,上游实际可能看到 30 个并发。可以根据实例数静态切分预算,或使用集中式令牌服务;不要把进程内计数误认为全局上限。
HTTP 层还应把客户端断开连接转换为取消信号。需要注意,取消本地等待不一定代表上游已经停止计费,最终行为取决于模型提供方是否接受并及时处理取消。因此,超时既是资源保护手段,也是调用方停止等待的边界,不能承诺一定撤销远端计算。
建议至少记录以下指标:
- 按模型统计运行中任务数、队列长度和执行耗时;
- 按租户统计接收、完成、超时与队列拒绝数量;
- 分开记录排队耗时和上游耗时,避免把两者混成一个延迟;
- 记录 429、网络错误和取消,但不要在日志中写入完整提示词;
- 为队列使用率设置告警,持续接近上限通常意味着容量不足或上游变慢。
重试也必须重新进入调度器,并使用指数退避和随机抖动。若在 worker 内无限重试,一个任务会长期占用执行槽;更稳妥的方式是限制尝试次数,同时确保总请求截止时间覆盖全部重试。
预算值不要凭感觉一次定死。可以从上游明确配额、单请求资源消耗和可接受排队时间出发设置保守初值,再根据真实指标逐步调整。队列越长并不代表吞吐越高,它通常只会容纳更多已经来不及完成的请求。
总结
把 LLM 原型改造成负载可控的服务,核心不是替换 Promise.all,而是明确每个请求何时可以执行、最多等待多久,以及系统过载时如何失败。
- 用分模型并发预算隔离不同延迟和配额的模型;
- 用有界队列限制内存占用,并在过载时快速拒绝;
- 让超时覆盖排队与执行阶段,并向下游传递取消信号;
- 用租户轮询减少大批量任务对其他租户的阻塞;
- 分别观测排队、执行、拒绝和取消,再据此调整容量。
这些机制不会增加上游模型的真实吞吐,但能阻止流量尖峰被无条件放大,让服务在容量不足时以可预期、可恢复的方式退化。
评论