
前几天有人在群里甩了一个项目链接标题写的是“Keep batch LLM jobs from starving interactive traffic”。我第一反应是这不就是我前两天刚遇到的线上事故吗。当时服务里同时跑着两类任务一是后台批量总结文档二是线上群机器人实时问答。批量任务一旦多起来机器人就开始十秒、二十秒不回话。查监控真正的原因不是 GPU 不够而是任务调度的公平性出了问题。一句话总结就是批量 LLM 任务和交互式 LLM 请求共享同一批资源时不能靠“谁先到谁先跑”。必须让短任务有插队能力同时给长任务留一个可量化的最小执行额度。很多团队以为这是限流问题其实这是调度问题。解决起来也不复杂关键要理解两点第一任务必须拆成小调度单元第二队列不能是单一 FIFO。这个判断不是凭空来的。批量任务和交互任务在延迟敏感度上完全不同交互请求多等几百毫秒用户立刻能感知批量任务晚几分钟完成通常没有任何人发现。所以把两者放在同一个公平队列里本身就是一种“伪公平”。真正合理的设计是让批量任务学会让路。1. 先看懂问题批量任务为什么能把交互流量“饿死”1.1 看起来是资源不够其实是调度不公平先还原一个典型场景。你有一个基于大模型封装的内部能力层面向两类调用方交互任务来自 IM 机器人、客服助手、在线问答用户正在等结果。批量任务来自定时任务、离线数据分析、文档批量摘要等待时间不敏感。一开始流量小两类任务混在一个队列里没有问题。后来批量任务增加问题开始出现。最常见的监控表现是交互请求的平均耗时从 800ms 涨到 8s但 GPU 利用率并没有明显变化。再往下一看服务端设置的并发上限是 8而 8 个并发全被批量任务占着每个批量任务要跑三五分钟。后面的交互请求只能在队列里等。这就是典型的“饥饿”。它不是因为资源真的不够而是因为调度策略没有区分任务类型。FIFO 队列天然对短任务不友好一个长任务排队所有排在后面的短任务都要等。这跟操作系统调度里的“长任务导致交互式进程响应变慢”是同一个问题。Linux 调度器会主动提升交互式进程的优先级而很多 LLM 服务上线时根本没有做这一层。所以第一件事不是扩容而是先审视任务入口和队列模型。如果你只有一个全局并发池没有把任务按类型区分那么无论买多少卡只要批量任务永远存在交互流量就可能被饿死。1.2 真正的关键任务粒度决定调度可能性这里要泼一盆冷水如果批处理任务一旦开始就必须连续执行五分钟那任何调度器都很难救交互流量。因为无论你优先级怎么排正在执行的长任务已经占住了并发槽位交互请求只能等它结束。所以要解决“批量任务饿死交互流量”第一步往往不是改调度器而是改任务切分方式。一个可调度的批处理任务应该是这样的把一个 2000 页的文档批量摘要拆成 20 个 100 页的子任务。把 5000 条文本的批量分类拆成 50 个大小为 100 条的批请求。把一次长文本生成拆成多个可以独立提交的小段生成。拆完之后每个子任务的执行时间尽量控制在几百毫秒到几秒内。这样调度器就有了“让路”的机会一个批处理子任务结束后调度器可以先从交互队列里取任务让交互请求在下一个调度周期插队。如果做不到拆分批处理任务就是不可抢占的巨石。那么你再怎么优化队列也只能做到“新来的批处理任务排队”救不了已经被长任务占住的在线响应。标题里说“keep batch LLM jobs from starving interactive traffic”本质上不是拒绝批处理而是让批处理任务变成更小、更可让路的单元。2. 解决方案不是限流而是分层调度2.1 从入口到执行至少需要四个层次我建议把整个服务端拆成四层来看入口层识别请求类型。通过 URL 路径、请求头、任务来源等字段标记kind interactive | batch。队列层不再用单一共享队列。至少拆成interactiveQueue和batchQueue两个队列。每个任务记录入队时间用于老化判断和延迟观测。调度层负责决定下一个从哪个队列取任务。这是整个方案的核心。执行层真正发起 LLM 调用的 worker 池。每个 worker 一次执行一个任务执行完成后从调度层取下一个。很多项目卡在第三层。实现一个调度器并不是简单地把批处理优先级设为 0而是需要一套规则让交互任务在绝大多数情况下优先同时也不至于让批处理完全饿死。2.2 动态退让交互流量开始堆积时暂停批量派发调度器最常见的做法是“保护模式”。当检测到交互队列里有任务等待而且等待时间超过阈值比如 200ms调度器就进入保护模式。保护模式下调度器不再从批量队列里取新任务只消费交互队列。但这里有一个关键限制正在执行的批处理任务不会因为保护模式而中断。如果当前批处理子任务还剩 3 秒才能结束交互请求最多还是要等 3 秒。这也是为什么前面强调任务粒度。如果批处理子任务通常只有 200ms那么保护模式开启后最多再等一个子任务的时间交互请求就能被调度。保护模式什么时候关闭当交互队列清空或者交互任务最大等待时间重新降到阈值以下时可以恢复从批量队列派发任务。2.3 批处理也不能完全饿死最小配额和老化如果把批处理优先级做得无限低又会带来另一个问题交互流量只要一直有批处理任务就永远无法启动最终积压大量离线作业。所以调度策略里必须有一个“最小配额”的概念。例如允许批处理任务最多占用ceil(maxConcurrency * 0.2)个并发槽位。这个配额不是给批处理“插队”用的而是为了防止批处理长期得不到执行。另一种做法是引入“老化”。批处理任务每在队列里多等一段时间优先级就往上提一点。等待时间足够长以后即使有交互任务批处理也可以获得执行机会。这样既保护了交互流量也保证了批处理的最终完成。3. TypeScript 实现一个最小调度器3.1 核心数据结构下面这个实现不是一个生产级库而是一个演示调度思想的骨架。你可以把它理解为“带着注释的思路模板”具体落地时需要根据业务重写。先定义任务和数据队列type TaskKind interactive | batch; interface Task { id: string; kind: TaskKind; run: () Promiseunknown; enqueuedAt: number; } interface SchedulerOptions { maxConcurrency: number; interactiveTimeoutMs: number; batchMinRate: number; }这里有两个队列一个交互队列一个批量队列。调度器的核心状态包括running当前正在执行的任务数。runningBatch当前正在执行的批处理任务数。protecting是否处于保护模式。3.2 调度规则怎么定接下来是最重要的部分调度器如何从队列中选任务。一个比较合理的规则是如果交互队列非空优先取交互任务。如果保护模式开启且当前批处理任务数已经达到最小配额上限则不再取批处理任务。如果交互队列为空则可以正常取批处理任务。保护模式开启后等交互队列清空再恢复。把规则写成代码大概是这样class FairScheduler { private interactiveQueue: Task[] []; private batchQueue: Task[] []; private running 0; private runningBatch 0; private protecting false; private readonly options: SchedulerOptions; constructor(options: SchedulerOptions) { this.options options; } submit(task: Task): void { const queue task.kind interactive ? this.interactiveQueue : this.batchQueue; queue.push(task); if (task.kind interactive) { this.maybeEnableProtect(); } this.pump(); } private maybeEnableProtect(): void { const now Date.now(); const oldest this.interactiveQueue[0]; if (oldest now - oldest.enqueuedAt this.options.interactiveTimeoutMs) { this.protecting true; } } private pump(): void { while (this.running this.options.maxConcurrency) { const task this.pickNext(); if (!task) return; this.running; if (task.kind batch) this.runningBatch; const done () { this.running--; if (task.kind batch) this.runningBatch--; if (this.interactiveQueue.length 0) this.protecting false; this.pump(); }; task.run().then(done).catch(done); } } private pickNext(): Task | undefined { // 交互任务永远优先 if (this.interactiveQueue.length 0) { return this.interactiveQueue.shift(); } // 保护模式下批处理最多只能占用一部分并发 if (this.protecting) { const maxBatch Math.ceil( this.options.maxConcurrency * this.options.batchMinRate ); if (this.runningBatch maxBatch) return undefined; } return this.batchQueue.shift(); } }这段代码没有处理“任务入队后返回 Promise 给调用方”的问题也没有做超时取消和异常重试。在真实项目中submit应该返回一个新的 Promise并在任务真正完成时 resolve。否则调用方无法知道批量任务什么时候跑完。但核心思想已经体现出来了交互任务优先取保护模式下限制批处理并发。要让这个调度器真正有效批处理任务必须足够短否则protecting再早也要等当前任务结束。3.3 几个关键参数的落地建议参数建议不是固定值要根据下游能力和服务水平协议来调。我一般会这样起步参数初始建议说明maxConcurrency4~8不要超过下游模型 API 的并发上限也不要超过本机 worker 能承载的进程/线程数interactiveTimeoutMs200~500ms根据产品对交互延迟的要求设置越严格越容易被触发保护模式batchMinRate0.1~0.3防止批处理完全饿死但也不能太高否则保护模式形同虚设上线之后用真实流量观察交互请求的 p95 延迟和批处理任务积压情况再反过来调参。不要一上来就调到极端尤其不要把interactiveTimeoutMs设成 30ms否则批处理任务几乎永远没有机会执行。4. 落地最常踩的几个坑4.1 只调小并发数不等于保护交互流量很多人看到交互流量被批量任务挤占第一反应是把maxConcurrency从 8 改成 2。这会带来两个问题交互请求和批处理请求一起变慢资源利用率下降同时如果 2 个并发槽全被批处理任务占住交互流量照样饥饿。正确的做法是要做“按类型区分”的调度而不是简单把水位调低。全局并发上限只是安全底线不能让所有任务共享一个无差别的并发池。4.2 批处理任务不拆分抢占就是一句空话这一点再怎么强调都不为过。调度器能决定的只是“下一个谁执行”它改变不了“当前正在执行的长任务”。如果你的批处理任务是一个 10 分钟的 HTTP 长请求那么即使交互任务到达并且调度器不再从批量队列取任务交互请求也要等这 10 分钟。所以落地调度器之前先确认你的批处理任务是否可以拆成多个子任务。如果底层 SDK 不支持拆分可能需要在上层自己拆分一个文档拆成多个段落一个长文本拆成多个 chunk分开调用模型最后聚合结果。虽然增加了代码复杂度但这才是真正能“让路”的基础。4.3 没有指标调度器就是黑盒调度器本身应该暴露至少三组指标队列长度交互队列和批量队列各自还有多少任务。等待时间每个任务从入队到开始执行的耗时重点关注交互队列 p95。运行占比正在执行的并发槽里交互任务和批处理任务各占多少。没有这些指标你很难判断线上问题到底出在调度、执行还是下游依赖。通常我在接入调度器时会先把日志打到结构化日志里包含kind、enqueuedAt、startedAt、finishedAt四个字段这样后面排查会轻松很多。5. 交互流量仍然慢按这个顺序排查有时候调度器写了参数也调了交互请求还是慢。这时候不要急着改调度逻辑先按下面这个链路排查。5.1 第一层看排队时间确认问题在不在调度器在交互请求处理逻辑里至少埋两个时间点入队时间和开始执行时间。如果交互请求从入队到开始执行就花了 3 秒说明调度器没有及时把资源分给它。问题大概率在调度层。排查方向maxConcurrency是否被占满。当前运行的任务里批处理任务占了多少。保护模式是否生效交互请求触发保护后调度器有没有停止取批处理任务。批处理任务是否过长导致即使保护模式开启当前批处理任务也要很久才能结束。如果开始执行时间很快但执行本身耗时很长那问题就不在调度层而在模型调用层。5.2 第二层看执行时间确认是不是模型或下游变慢交互任务进入执行层后也可能很慢。比如下游模型 API 的网络延迟波动或者本地 GPU 正在处理一个很大的批处理请求导致实际推理排队。这一层要看单个交互请求的平均执行耗时是否正常。是否有多个并发请求同时到同一个上游接口触发了上游限流。本地推理环境里GPU 显存是否充足是否存在进程间争抢。调度器只能保证“轮到你了”不能保证“你执行得很快”。如果执行本身变慢要考虑的是基础设施隔离、模型性能和上游扩容。5.3 第三层看连接池和依赖限流别忽略最小细节有一次我排查了很久最后发现是 HTTP 连接池被批处理任务占满。调度器明明已经把交互任务派发出来了但交互请求到达下游前因为连接池没有空闲连接只能等。这个现象很像调度问题但本质是网络栈问题。所以排查时还要看HTTP client 的并发连接数是否足够。是否使用了同一个 API key上游账号的并发限制是多少。数据库连接池、外部存储读写是否也受到影响。这些问题很容易被误判成“调度器没写对”。先把日志里的阶段耗时拆分清楚再决定是调调度器还是调连接池。6. 这个方案适合谁不适合谁6.1 适合的典型场景如果你是在 TypeScript 项目里自己编排 LLM 调用并且有下面这些特征这个方案很有价值你维护的是一个业务编排层下面调用的是第三方模型 API 或内部推理服务。你同时有定时批处理任务和在线请求而且在线请求的延迟要严格控制。你不想为批处理和交互分别部署两套独立环境希望共享资源但互相隔离。你需要一个透明、可修改、可控的调度逻辑而不是依赖某个黑盒中间件。这种情况下一个简单的 TypeScript 调度器就能显著降低线上事故概率。6.2 不适合的典型场景如果你的底层推理服务已经是 vLLM、TensorRT-LLM 这类支持 continuous batching 的引擎它们内部已经做了连续的 token 级调度外层再套一个请求级调度器作用会变得有限。这种情况下你更多应该考虑的是“是否把批量请求一股脑塞进推理引擎”以及“租户之间的配额怎么划分”。如果批处理任务本身是不可拆分的单次大计算比如一次性读取整份数据并生成无法中断那么这套方案只能改善排队不能真正解决饥饿。你需要的是更重的任务拆分方案或者干脆单独分配资源池。还有一种情况是交互流量占比极小批处理任务吞吐才是核心目标。这时候你并不想为了保护交互流量让批处理频繁暂停完全可以通过独立资源池隔离来解决问题。6.3 更长期的优化方向把调度下沉到推理引擎业务层调度器解决的是“请求级别”的公平性。如果继续往下走你会发现推理引擎内部也在面临同样的问题一个长生成任务占住了 GPU 的连续批处理槽位其他短请求就得等。所以当前端调度器和后端推理引擎都支持连续性调度时才算真正把“让路”做到了底层。但对大多数团队来说第一步还是先把业务层调度做好。因为这一层最容易控制也最容易观测。等业务层稳定了再去研究推理框架的调度参数性价比会更高。回到最初那个问题。批量 LLM 任务饿死交互流量看起来像一个资源问题实际上是一个调度公平性问题。解决它的核心不是把资源池拆成两半也不是把批处理任务直接降权到零而是把批处理任务拆成足够小的调度单元让调度器在每一步之间优先把资源让给交互请求。用 TypeScript 写一个调度器并不难真正难的是任务粒度、参数调整、指标观测和边界判断。如果你正准备上线一个混合流量的 LLM 服务我建议从最小调度器开始先跑通队列和指标再逐步加上保护模式、最小配额和老化机制。不要等线上开始卡了再去临时加一个限流参数。让批量任务学会让路交互流量才会真正稳定。