十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM请求调度:双队列与配额控制如何兼顾批量任务和交互延迟

LLM请求调度:双队列与配额控制如何兼顾批量任务和交互延迟 批量 LLM 任务和交互式流量抢资源是 LLM 应用上线后最先遇到的一类问题。离线打分、知识库向量化、数据清洗、定时摘要这些任务只要打开并发就会不断往模型服务里塞请求把 GPU 显存和排队位置全部占满。用户那边发来的实时问答、聊天、搜索请求被挤到队列末尾轻则多等几秒重则直接超时。这个用 TypeScript 写的调度器目标很直接让批量 LLM 任务不饿死交互式流量。我按它的设计思路重新实现了一遍用 mock 请求做了压测下面把问题定位、核心代码、验证方法和参数边界拆开讲。1. 批量任务为什么会挤掉交互请求先定位资源竞争点1.1 典型的 LLM 调用链路里瓶颈通常在排队而不是模型本身先说一个容易忽略的事实多数 LLM 服务无论你自己部署的还是走的云厂商接口真正让请求变慢的不一定是模型推理而是排队。你把 100 个批量任务一次性发进去服务端会用完并发配额剩下的请求全部进入等待。如果这个服务还同时接待用户在线请求在线请求也一样排队。这里的资源不只是 GPU 显存。请求进入服务端之后还要占用内存、连接池、队列数组、日志 IO。批量任务单个请求可能很小但数量多了之后光是在队列里等待的时间就足够把交互式请求拖垮。所以问题不是模型跑得慢而是任务进队列的顺序和数量没控制好。还要注意一个容易混淆的点批量任务和交互式任务在模型参数上通常有差异。很多团队给批量任务配置更低的采样温度、更短的输出长度甚至用 fp16 或 bf16 精度来节省显存。这些都是合理的优化但它们改变不了排队问题。哪怕批量任务的单次推理耗时只有交互任务的一半数量大到一定程度还是会把服务端的请求队列塞满。1.2 交互式流量和批量任务的本质差异交互式流量有明确的延迟预期。用户发一条消息心里默认是几秒钟内要见到返回。它通常也是突发性的白天工作时间多夜间少峰值和低谷差距大。批量任务正好相反。它对单次延迟不敏感一分钟跑完和十分钟跑完对业务结果没什么影响。但它数量大、持续时间长、可以容忍失败重试。数据清洗跑 10 万条中途断了重跑就行。这两类任务放在同一个服务里如果调度策略不做区分结果一定是批量任务占满所有并发交互式请求被饿死。因为批量任务可以源源不断地把队列填满交互式请求只有零星几个根本没有竞争机会。这里说的饿死不是完全执行不了而是交互请求的等待时间被拉长到不可接受的程度。1.3 为什么最大并发数调小一点不能解决问题很多人遇到这个问题第一反应是把总并发从 20 调到 5。这样交互式请求确实更容易拿到槽位但代价是批量任务的速度直线下降而且高峰期还是没有区分度。问题在于一个全局并发上限只能控制同时执行多少个任务不能控制优先执行谁。5 个并发全是批量任务的时候交互式请求依然要等。正确的做法不是一味压低并发而是把容量切成两块交互式任务独占一部分批量任务在剩下的额度里跑。这就是这个 TypeScript 调度器的核心思路。我实测下来的感受是全局并发数的调节更像是一个粗糙的应急开关适合在服务端报警时临时用一下不适合作为长期策略。真正的解决方案还是要在任务调度层把两类流量分开。2. 调度器的设计思路双队列加槽位预留2.1 交互任务永远优先拿槽位整个方案最核心的设计是双队列。交互式请求进交互队列批量任务进批量队列。调度器每次从队列里取任务时永远先处理交互队列交互队列空了或者并发达到上限才去看批量队列。这里要理解优先级和配额的区别。优先级只能保证交互任务排得靠前如果 20 个并发全被批量任务占着交互任务排得再靠前也只能等。所以调度器必须再加一层控制批量任务有独立的并发上限maxBatchConcurrency。也就是说不管批量任务有多少同时执行的批量任务数量不能超过这个值剩下的槽位永远留给交互请求。我建议把maxBatchConcurrency看成一条水管上的限流阀而不是总开关。这个阀门把批量任务的流量限制在一个可控范围内交互请求随时可以从旁边插进来。这样既保留了批量任务的执行能力又不会让交互请求在高峰期完全拿不到资源。2.2 不让批量任务完全饿死的机制只做交互优先批量任务有可能长时间得不到执行。尤其在高并发业务里交互请求不断进队列批量队列一直排在最前面却一直没机会被取走。这会导致知识库更新、离线计算这些任务无限期搁置。为了处理这个问题我在实现里加了一个简单的保护思路批量任务在队列里等待超过一定时间后可以提高自身的执行优先级或者让调度器为它预留一个最低槽位。具体做法可以做成配置项比如maxBatchWaitMs。这个参数不是必须的但如果你有持续性的批量任务需求建议加上。这里想强调一个边界不要让批量任务完全饿死也不要在高峰期强行保批量任务。如果你的业务里知识库更新必须每小时完成一次那就应该建立在低峰期批量任务能跑完的容量规划上而不是在高峰期和交互请求抢资源。2.3 和速率限制、负载均衡的关系这个调度器解决的是任务在应用层进入模型服务之前的排队顺序它不替代速率限制也不替代负载均衡。速率限制解决的是单位时间最多发多少个请求防止服务端拒绝。负载均衡解决的是多个模型实例之间怎么分配请求。这个调度器解决的是交互和批量两类任务怎么共享同一份并发额度。三者的关系是叠加的。你可以在调度器后面再接一个速率限制器也可以把调度器放在多个工作进程之前。对于大多数场景先把这个调度器跑通再按需要组合其他组件就够了。我在实际项目里的做法是调度器负责队列排序服务端 SDK 里自带的并发限制负责兜底网关层再设一层速率限制。三层各管一件事出了问题也容易定位。3. TypeScript 落地一个可以直接改的调度器实现3.1 环境准备代码我用 TypeScript 5.x 写运行环境是 Node.js 18 以上。不需要额外依赖核心调度器本身就是纯 TypeScript 的类。项目里只需要一个tsconfig.json目标设成ES2022模块用NodeNext或者CommonJS都可以。node -v npm -v如果你的机器还没装 TypeScript本地装一个就行npm install -D typescript tsxtsx用来直接运行.ts文件方便测试。后面验证调度器的时候我会用它跑压测脚本。正式项目里你可以用tsc编译后运行也可以直接用tsx跑看团队的构建习惯。3.2 核心代码LlmJobScheduler下面是调度器的主体实现。这个版本没有引入任何第三方依赖逻辑集中在submit和pump两个方法里。interface JobT { id: string; kind: interactive | batch; task: () PromiseT; resolve: (value: T) void; reject: (reason?: unknown) void; enqueueTime: number; } interface SchedulerOptions { maxConcurrency: number; maxBatchConcurrency: number; } export class LlmJobScheduler { private readonly maxConcurrency: number; private readonly maxBatchConcurrency: number; private active 0; private activeBatch 0; private interactiveQueue: Jobunknown[] []; private batchQueue: Jobunknown[] []; constructor(options: SchedulerOptions) { if (options.maxBatchConcurrency options.maxConcurrency) { throw new Error(maxBatchConcurrency must be maxConcurrency); } this.maxConcurrency options.maxConcurrency; this.maxBatchConcurrency options.maxBatchConcurrency; } submitT( kind: interactive | batch, id: string, task: () PromiseT ): PromiseT { return new PromiseT((resolve, reject) { const job: JobT { id, kind, task, resolve, reject, enqueueTime: Date.now() }; if (kind interactive) { this.interactiveQueue.push(job as Jobunknown); } else { this.batchQueue.push(job as Jobunknown); } this.pump(); }); } private pump(): void { // 交互式任务优先执行只要还有空余槽位就继续取 while ( this.interactiveQueue.length 0 this.active this.maxConcurrency ) { const job this.interactiveQueue.shift()!; this.runJob(job); } // 批量任务只能使用自己的配额并且不能超过总并发 while ( this.batchQueue.length 0 this.activeBatch this.maxBatchConcurrency this.active this.maxConcurrency ) { const job this.batchQueue.shift()!; this.runJob(job); } } private runJob(job: Jobunknown): void { this.active; if (job.kind batch) { this.activeBatch; } job.task() .then(job.resolve) .catch(job.reject) .finally(() { this.active--; if (job.kind batch) { this.activeBatch--; } this.pump(); }); } get pendingCount(): { interactive: number; batch: number } { return { interactive: this.interactiveQueue.length, batch: this.batchQueue.length }; } }这个代码的关键点有三个。第一active表示当前正在执行的任务总数所有任务共享这一个计数。第二activeBatch专门统计正在执行的批量任务数量它不能超过maxBatchConcurrency。第三pump在每次任务入队和任务结束时都会调用保证调度是实时的不需要定时器轮询。注意这个调度器控制的是应用层的排队顺序模型服务端自身的并发配额也要同步设置。两边不匹配时调度器只能保证发送顺序不能保证服务端内部不排队。3.3 关键参数说明下面这几个参数是接入自己业务前必须想清楚的参数含义设置建议maxConcurrency同时执行的所有任务总数包括交互和批量根据模型服务端并发配额和单机资源决定maxBatchConcurrency批量任务最多占用的并发数建议先设为maxConcurrency的一半以下任务超时时间单个 LLM 调用超过多久算失败交互任务可以设 30 到 60 秒批量任务可以更长批量任务重试次数批量任务失败后重试几次批量任务建议至少重试 1 到 2 次maxBatchConcurrency是这套方案里最值得花时间调的一个参数。它设得太小批量任务跑得慢设得太大交互式请求在高峰期会重新开始排队。我一般先按总并发的一半跑一轮再根据线上的交互延迟调整。4. 怎么验证它真的有效单任务、压测和指标4.1 先跑最小样例确认任务能正常执行和返回拿到任何调度器我都不建议直接接生产流量。先用一个最简单的样例验证任务能跑通、结果能正常返回、批量任务不会把交互队列堵死。下面这个脚本模拟了 3 个交互请求和 10 个批量请求每个任务都会在开始时打印标记。import { LlmJobScheduler } from ./llm-job-scheduler; const scheduler new LlmJobScheduler({ maxConcurrency: 4, maxBatchConcurrency: 2 }); function mockLlmCall(id: string, ms: number) { return () new Promisestring((resolve) { setTimeout(() resolve(done: ${id}), ms); }); } async function main() { for (let i 0; i 3; i) { scheduler.submit(interactive, chat-${i}, mockLlmCall(chat-${i}, 500)); } for (let i 0; i 10; i) { scheduler.submit(batch, batch-${i}, mockLlmCall(batch-${i}, 200)); } } main();跑完看两个结果第一是否所有任务都正常执行并返回第二交互任务是否比批量任务更早拿到执行机会。如果前 4 个并发槽位被批量任务占满交互任务的开始时间明显偏晚那说明maxBatchConcurrency没有生效先检查调度器的pump逻辑。建议先跑最小样例。不要一上来就接真实流量也不要一上来就把并发拉满。调度器的坑有时候不在功能上而在任务边界和异常处理上。4.2 压测时重点看 p95 延迟和批量吞吐单样例通过之后再做一轮简单的压力测试。压测不需要太复杂核心是模拟批量任务持续流入 交互请求随机涌入的场景然后对比两个指标。交互式请求的 p95 延迟也就是 95% 的请求在多少毫秒内返回。这个值直接代表用户体验。批量任务的吞吐量也就是单位时间完成了多少任务。这个值决定离线任务的效率。我建议记录三组数据完全没有批量任务时交互请求的延迟、打开批量任务后没加调度器的延迟、打开批量任务后加了调度器的延迟。有对比才有说服力。如果你的交互延迟从 3 秒变成了 15 秒调度器没起作用如果稳定在 5 秒以内说明配额和队列的策略是有效的。写压测脚本的时候注意不要让 mock 任务本身成为瓶颈。setTimeout模拟的耗时是假的真正要看的是调度逻辑对任务开始时间的影响。你可以给每个任务打上开始时间戳压测完直接看时间戳分布。4.3 接自己的 LLM 客户端时要注意什么把mockLlmCall换成真实的模型调用 SDK或者你们自己封装的大模型客户端时有几个点要处理。第一任务函数必须返回 Promise。如果你们公司的 SDK 是回调式的要先包一层 Promise。第二不要在任务函数里做太重的本地计算否则即使调度器控制好了并发CPU 被占满一样会影响交互任务。第三把超时和重试写在调度器的上层或任务函数内部调度器本身只负责排队不负责请求失败后的策略。还有一点容易被忽略批量任务很多时不要把几万条一次性全部submit进去。所有任务都会留在内存队列里量大了一样会占用大量内存。正确做法是分批提交同时观察pendingCount.batch超过阈值就暂停提交等队列消化一部分再继续。这里给出一个非常粗略的分批提交思路async function submitBatchInChunksT( scheduler: LlmJobScheduler, items: T[], taskFn: (item: T) Promiseunknown, chunkSize 100 ) { let index 0; while (index items.length) { while (scheduler.pendingCount.batch 200) { await new Promise((r) setTimeout(r, 1000)); } const chunk items.slice(index, index chunkSize); chunk.forEach((item, i) { scheduler.submit(batch, item-${index i}, () taskFn(item)); }); index chunkSize; } }这里的 200 和 1000 都是示例值具体要根据你的队列消费速度和任务平均耗时来定。核心思想是不要让调度器一次性背上全量任务让它在可控的负载下持续消费。5. 参数调优不同业务场景怎么配5.1 交互式请求量大时的配置方向如果你们的业务是客服机器人、实时搜索助手这类高交互场景优先保证交互请求的稳定延迟。此时maxBatchConcurrency要往小调例如总并发 10 时批量并发只给 2。代价是批量任务完成得慢但这是取舍问题交互体验是这里的首要目标。交互式请求本身也可能有突发峰值。调度器只保证了优先级没有限制交互并发。如果交互请求太多全部并发执行同样会把服务端打爆。所以高交互场景下还要考虑给交互任务也加一个上限或者配合速率限制一起用。另外不要把交互请求的超时时间设得太长。用户等待时间是有限的。调度器可以让请求进入队列但如果队列本身已经堆积了很多交互请求超时时间设得再长也救不了体验。这时候应该做的是降级或快速失败而不是继续往队列里塞。5.2 批量任务多但响应要求高的场景知识库向量化、数据清洗这类场景批量任务数量可能达到几十万条。这时不能把批量并发压得太低否则任务总耗时不可接受。一个更合理的做法是高峰期给批量任务一个较小的配额低峰期动态调大。动态调整不需要在调度器里加复杂逻辑只要把maxBatchConcurrency从类的私有字段改成可配置字段再暴露一个setMaxBatchConcurrency方法。外部按时间段或按实时延迟调整它就行。调度器每次pump的时候读取当前值改动立即生效。setMaxBatchConcurrency(value: number) { if (value this.maxConcurrency) { throw new Error(value must be maxConcurrency); } this.maxBatchConcurrency value; this.pump(); }这个方法的调用时机可以放在一个定时任务里比如每天早上 9 点把批量并发调低晚上 11 点调高。也可以用监控系统根据交互延迟自动调整但要设好上下限避免调度器频繁抖动。5.3 一个推荐的起始配置和观察周期如果你没有现成的容量数据可以从下面这组配置开始配置项建议值依据maxConcurrency服务端并发配额的一半到三分之二留出余量给网络抖动和突发流量maxBatchConcurrencymaxConcurrency的 40% 到 50%保证批量任务有吞吐也让交互请求有富余槽位观察周期至少跑 3 到 5 个工作日覆盖工作高峰和低峰跑完观察周期后重点看两个数据交互任务 p95 延迟有没有超过预期批量任务有没有在可接受时间内完成。两个指标互相矛盾时优先保交互因为批量任务可以错峰跑。这里有一个经验第一次跑的时候很多人会把maxConcurrency设得跟模型服务端配额一样大想着反正调度器会排优先级。但实际上应用层到服务端之间还有网络抖动、序列化时间和连接建立开销。建议留出 20% 到 30% 的余量避免应用层并发轻微波动时直接打满服务端。6. 常见问题与排查顺序6.1 交互流量还是慢先查哪里调度器上了之后交互请求还是慢不要急着改参数。按这个顺序排查先看交互任务实际开始执行的时间。如果执行时间很晚说明队列优先级有问题。再看active和activeBatch的数值。如果activeBatch长期等于maxBatchConcurrency说明批量并发配额给大了。接着看模型服务端。如果请求已经发出去了但返回依然慢说明瓶颈在模型推理、网络带宽或者服务端自身的队列。最后看任务函数内部。如果任务里除了调模型还做了别的事比如文件读写、向量检索这些耗时也会算进交互延迟。一个常见的误判是调度器控制好了应用层的并发但模型服务端仍然接收到了大量并发请求。原因可能是多个应用实例各自跑了一个调度器每个实例的并发加起来超过了服务端配额。这时候要做的是把配额改成共享的或者给服务端单独限流。多个应用实例部署时每个实例单独跑一个调度器总并发会翻倍必须在服务端或网关层做总配额。6.2 批量任务长时间不执行怎么调整如果批量队列一直有任务但activeBatch一直是 0说明调度器的pump逻辑没有给批量任务执行机会。常见原因有几种。第一种maxBatchConcurrency被设成了 0检查配置。第二种交互队列持续有请求调度器优先处理交互队列批量任务一直排不上。这种问题不是 bug而是策略选择。如果批量任务也很重要可以降低交互队列的占用或者在调度器里增加批量等待保护机制。第三种批量任务抛了异常但异常没有被正确处理导致finally里的计数没有执行。注意看任务函数里的异常捕获。我实际遇到过一种情况批量任务本身会调用一个已经被限流的内部接口任务一进来就报 429然后疯狂重试。重试没有通过调度器重新入队而是直接在任务函数内部循环导致activeBatch一直占着不放。最后排查下来问题不在调度器而在任务函数的重试策略写得太差。所以任务函数内部的重试要加退避重试次数要有限制不能把调度器的并发槽位当成无限资源来用。6.3 这个方案适合什么场景不适合什么场景这套双队列加配额方案的适用场景很明确实时交互和离线批量任务共用一条 LLM 调用链路需要做优先级区分。它适合做产品原型、中小型应用、以及内部工具的第一版调度方案。它不适合的场景要特别注意。如果你有多个模型服务、多个区域、多个团队共用一个资源池需要的是集中式任务调度系统和配额管理平台而不是单机内存队列。单机队列只对所在进程生效进程重启后队列里的任务会丢失。另外如果你的交互流量峰值远高于服务端容量调度器只能帮你排序不能帮你扩容。最后说一个我实际踩过的点这种调度器真正上线后最容易出的问题不是代码 bug而是监控缺失。只看到交互延迟正常看不到批量任务在队列里等了多少分钟就永远不知道配额是不是被压得太紧。建议把pendingCount、active、activeBatch、平均等待时间这几个指标接进日志或监控面板跑上几天再决定要不要动参数。调度器的价值不在于功能多花哨而在于让每一类任务都知道自己的位置。
返回列表