
最近在 Hacker News 上看到这样一个项目标题“Show HN: Keep batch LLM jobs from starving interactive traffic (TypeScript)”。第一眼容易被 TypeScript 吸引但真正值得关注的是它背后那个问题批量 LLM 任务正在悄悄吃掉在线交互请求的资源。如果你维护过 LLM 推理服务可能经历过这样的场景白天线上聊天应用一切正常一到后台跑评测集、批量生成标签、离线知识库抽取的时候接口延迟直接从 200ms 飙升到 8 秒甚至一堆请求超时。不是模型变笨了也不是网络抖动而是批处理任务占满了推理资源交互式流量被“饿死”了。这篇文章不打算去逐行解读那个仓库里的源码因为一个刚发布的 Show HN 项目往往还在快速迭代源码组织也不是重点。我更想和你一起把这个问题拆开批量任务和交互流量为什么会产生饥饿一个 TypeScript 调度器应该怎么设计如果换作你自己的服务要做到“交互流量优先、批处理低峰再跑”需要理解哪些关键点又会踩哪些坑我会用一个完整的最小示例带你在本地把这套调度逻辑跑起来。代码不依赖任何重量级框架核心思路可以平移到 Java、Go 或者 Python 项目里。1. 为什么“批处理饿死交互流量”在 LLM 时代成了高频问题1.1 过去默认的答案是“加资源”在传统 Web 服务里批量任务和在线流量争抢的是 CPU、数据库连接、带宽。架构师通常的做法是分开部署一个服务专门跑定时任务另一个服务处理用户请求中间用消息队列解耦。批处理跑得再慢也不至于把在线服务的线程池打满。但是进入 LLM 时代这个默认答案开始失效。LLM 推理不是简单的计算一次请求要加载模型权重要经过 prefill 阶段和 decode 阶段需要占用大量 GPU 显存。即便你用了 vLLM、TensorRT-LLM 这类推理框架也要面对一个硬约束同一块 GPU 上能同时运行的请求数量是有上限的而排队等待的请求会不断消耗调度器的时间。当你把批量评测任务和用户在线请求提交到同一个推理服务时它们共享的是同一批 GPU、同一个 KV cache 池、同一条请求队列。批处理任务往往数量巨大一个评测任务可能包含几千条 prompt而且提交得又快又密集。如果推理框架只是简单按提交顺序处理用户请求就会被淹没在这几千条 prompt 后面。1.2 饥饿问题的本质是调度缺失“饥饿”starvation是操作系统里的经典概念低优先级任务持续占用资源高优先级任务迟迟拿不到 CPU。在 LLM 推理服务里角色反过来了批处理任务量大、提交频繁在线交互请求虽然优先级高却被 FIFO 队列排在后面。很多团队一开始没有意识到这是个调度问题。他们以为加个并发控制就够了比如“最多同时跑 10 个请求”。但并发控制只限制同时执行的数量不决定执行顺序。如果 10 个并发名额全部被批处理任务占住在线请求照样要排队。真正需要的是在请求进入推理引擎之前做一层基于优先级的调度隔离。1.3 读者能从本文获得什么读完这篇文章你会完成三件事理解 batch 任务和 interactive 流量的核心差异以及 starvation 在 LLM 服务里的具体表现用 TypeScript 写一个最小可运行的调度器包含双队列、优先级选择、令牌桶三部分知道真实接入 LLM 推理服务时哪些地方需要替换、加固以免调度器本身变成新的瓶颈。2. 核心概念交互流量、批处理任务与饥饿2.1 交互式流量等待时间就是用户体验交互式流量指的是用户实时发起的请求比如网页聊天、智能客服、代码补全、RAG 问答。这类请求有三个明显特征延迟敏感用户期望 1 到 3 秒内看到回复超过 5 秒就会焦虑到达率波动大白天工作时段、活动推广期间流量可能瞬间翻倍不可重放用户已经在文本框里输入了内容不太可能因为超时自动重试。线上服务通常会给这类请求定 SLO比如 P95 延迟小于 2 秒。一旦批处理任务挤占资源最先被击穿的就是 P95 甚至 P50。2.2 批处理任务可以等但必须跑完批处理任务也有自己的特点量大一次评测可能提交上万条 prompt可容忍延迟跑 10 分钟还是一小时业务上通常都能接受适合连续批处理多条 prompt 可以拼成一个 batch降低开销业务价值同样重要模型评估、数据清洗、知识库构建、A/B 分析都依赖批处理结果。批处理任务不是不重要而是时效性要求不同。在资源有限的情况下它应该让位于在线请求但也不能被无限期饿死。2.3 Starvation 在推理队列中的具体表现可以用一个简化模型来理解饥饿假设推理引擎同时只能处理 1 个请求请求进来先排队。当前队列里已经有 100 个批处理任务此时来了一个用户请求它会被放在这 100 个任务后面。如果批处理任务的产生速度超过消费速度比如评测程序不断往队列里塞 prompt用户请求永远走不到队头。实际推理引擎虽然支持并发但原理相同高优先级请求被低优先级请求挡住了。KV cache 被批处理请求占满在线请求即使进入 prefill 阶段也可能因为显存不足而等待释放。2.4 两个队列与系统级优先级的区别常见的解决办法是把请求分成两个队列交互队列和批处理队列。调度器每轮优先消费交互队列只有交互队列为空时才消费批处理队列。这种方式实现简单在请求量不极端的情况下非常有效。但要注意它和 GPU 推理引擎内部的连续批处理调度不同。连续批处理解决的是“如何在同一个推理步骤中动态加入/退出请求”而应用层调度解决的是“请求应该被谁先消费”。两者可以共存外面做优先级分流里面做 continuous batching 提升吞吐。维度交互式流量批处理任务延迟要求秒级分钟级或小时级请求量相对稀疏、突发极大、持续可重试性低高典型应用聊天、代码补全、RAG评测、批量标注、离线抽取调度优先级高低保护手段优先队列、限流令牌桶、低峰执行3. 调度策略设计双队列、优先级与令牌桶3.1 如果只用 FIFO问题是不可控的最简单的实现是“谁先来谁先执行”。在 LLM 服务里这会直接导致本节开头描述的场景批处理任务把队列堵死。FIFO 在请求类型单一的场景里没问题但一旦混跑在线延迟就失去保障。3.2 方案一严格优先级队列严格优先级队列的思想很直接维护两个数组interactiveQueue和batchQueue。调度器每次循环时先检查交互队列有没有任务有就取交互任务没有再看批处理队列。// 伪代码思路 while (true) { if (interactiveQueue.length 0) { run(interactiveQueue.shift()); } else if (batchQueue.length 0) { run(batchQueue.shift()); } else { break; } }这个方案在交互流量持续不断时会有问题批处理任务永远得不到执行也就是批处理被“饿死”。所以只做优先级还不够还需要给批处理任务一个“保底带宽”。3.3 方案二令牌桶限速令牌桶是控制消费速率的经典工具。它允许批处理任务执行但限制执行速度。比如每秒只允许消费 2 个批处理任务其余时间资源都留给交互流量。令牌桶有两个参数容量capacity桶里最多能存多少个令牌决定突发能力速率rate每秒补充多少个令牌决定长期平均速度。每次调度批处理任务前必须先尝试从桶里取一个令牌。取不到就等下一次循环再试。3.4 把两个队列和令牌桶拼在一起最终设计是交互队列永远优先不加速率限制批处理队列只有在交互队列为空时才被考虑批处理任务执行前必须从令牌桶中成功取到令牌当批处理令牌不足时调度器短暂等待而不是忙等避免占用 CPU当批处理队列已经没有任务时调度结束。这个设计既保证了在线请求的低延迟又给了批处理任务一个最低吞吐保证。令牌桶的速度可以根据高峰低谷动态调整白天调到 1 个/秒夜间调到 10 个/秒。4. 环境准备与前置条件实操部分用 TypeScript Node.js 实现。我们不需要引入任何外部依赖全部用 Node 内置模块方便你复制到任何项目里跑通概念。4.1 安装 Node.js建议使用 Node.js 18 或更高的 LTS 版本。这篇文章不绑定具体小版本因为调度逻辑不依赖版本敏感 API。你可以在终端检查node -v如果还没有安装 Node.js去官网下载 LTS 版本即可。4.2 初始化 TypeScript 项目创建项目目录并初始化package.jsonmkdir llm-scheduler-demo cd llm-scheduler-demo npm init -y安装 TypeScript 和 tsx用于直接运行 TypeScript 文件npm install -D typescript tsx types/node生成 TypeScript 配置文件npx tsc --init --target es2020 --module commonjs --outDir dist --rootDir src --strict如果你觉得tsc --init逐项配置麻烦直接手工创建一个tsconfig.json也行{ compilerOptions: { target: ES2020, module: CommonJS, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*.ts] }4.3 说明示例不实际调用 LLM为了让调度器逻辑清晰可验证我会用一个setTimeout模拟 LLM 推理耗时。真正的工程实现里你只需要把runLLM函数替换成你所用推理服务的客户端调用例如访问 vLLM 的 OpenAI 兼容接口或者调用内部的 gRPC 推理服务。5. 核心流程拆解从请求入口到调度执行5.1 请求分类调度器首先要识别每个请求属于哪种类型。通常可以从 HTTP 路径区分/v1/chat/completions是交互请求/v1/batch/eval是批处理请求。在这里我们通过请求对象里的type字段区分。5.2 入队策略请求到达后根据类型放入不同数组交互请求interactiveQueue.push(req)批处理请求batchQueue.push(req)入队后调用pump()让调度器尝试消费。5.3 调度循环调度器内部维护一个running标志防止并发调用pump()导致任务重复消费。循环逻辑1. 如果交互队列非空取出第一个交互任务执行 2. 否则如果批处理队列非空尝试从令牌桶取令牌 3. 令牌成功取出第一个批处理任务执行 4. 令牌失败说明批处理任务需要限速等待 10ms 后重试 5. 两个队列都为空退出循环。5.4 执行任务执行任务就是调用推理接口。为了体现不同请求的耗时差异交互请求模拟 50ms批处理请求模拟 500ms。执行完成后记录延迟输出日志。5.5 为什么不是直接在 HTTP 回调里 sleep很多人会问既然交互流量优先为什么不在请求处理函数里用同步方式执行因为同步阻塞会占满 Node.js 的单线程事件循环后续请求根本无法进入队列。调度器必须把“读取队列”和“执行推理”分开。读取队列是同步的执行推理交给异步函数这样调度循环才能不断消费新请求。6. 完整示例与代码实现下面是完整的最小实现文件放在src目录下分为scheduler.ts和demo.ts。你只需要复制这两个文件。6.1 请求类型定义// src/scheduler.ts export type RequestType interactive | batch; export interface LLMRequest { id: string; type: RequestType; createdAt: number; payload: string; }6.2 令牌桶实现// src/scheduler.ts /** * 令牌桶用于控制批处理任务的消费速率。 * capacity: 桶的容量决定了突发能力 * rate: 每秒补充的令牌数 */ export class TokenBucket { private tokens: number; private lastRefill: number; constructor( private capacity: number, private rate: number ) { this.tokens capacity; this.lastRefill Date.now(); } tryTake(count 1): boolean { const now Date.now(); this.tokens Math.min( this.capacity, this.tokens ((now - this.lastRefill) / 1000) * this.rate ); this.lastRefill now; if (this.tokens count) { this.tokens - count; return true; } return false; } }6.3 调度器主体// src/scheduler.ts const sleep (ms: number) new Promisevoid(resolve setTimeout(resolve, ms)); export class LLMScheduler { private interactiveQueue: LLMRequest[] []; private batchQueue: LLMRequest[] []; private running false; constructor(private batchBucket: TokenBucket) {} submit(req: LLMRequest): void { if (req.type interactive) { this.interactiveQueue.push(req); } else { this.batchQueue.push(req); } void this.pump(); } private async pump(): Promisevoid { if (this.running) return; this.running true; try { while (this.interactiveQueue.length 0 || this.batchQueue.length 0) { // 1. 交互队列优先 if (this.interactiveQueue.length 0) { const req this.interactiveQueue.shift()!; await this.runLLM(req); continue; } // 2. 交互队列为空尝试批处理但受令牌桶限制 if (this.batchQueue.length 0 this.batchBucket.tryTake()) { const req this.batchQueue.shift()!; await this.runLLM(req); continue; } // 3. 批处理任务有但没有令牌等令牌补充 if (this.batchQueue.length 0) { await sleep(10); } } } finally { this.running false; } } private async runLLM(req: LLMRequest): Promisevoid { // 真实场景调用 vLLM / 内部推理服务 const costMs req.type interactive ? 50 : 500; await sleep(costMs); const latency Date.now() - req.createdAt; console.log( [${new Date().toISOString()}] ${req.type} ${req.id} 完成端到端延迟 ${latency}ms ); } }6.4 模拟流量启动脚本// src/demo.ts import { LLMScheduler, LLMRequest, TokenBucket } from ./scheduler; const randomId (prefix: string) ${prefix}-${Math.random().toString(36).slice(2, 8)}; // 批处理任务每秒最多消费 2 个允许 3 个突发 const scheduler new LLMScheduler(new TokenBucket(3, 2)); function submitRandomRequest() { const isInteractive Math.random() 0.7; const req: LLMRequest { id: randomId(isInteractive ? interactive : batch), type: isInteractive ? interactive : batch, createdAt: Date.now(), payload: simulated-request, }; scheduler.submit(req); } // 每 100ms 产生一个新请求模拟混合流量 const timer setInterval(submitRandomRequest, 100); // 15 秒后结束运行 setTimeout(() { clearInterval(timer); console.log(演示结束); process.exit(0); }, 15000);6.5 代码逻辑说明令牌桶放在调度器构造函数里传入让使用者可以更方便地调整批处理速率。调度器内部使用running标志防止重入因为submit可能在事件循环的不同阶段被调用多次。在pump()循环里交互任务不需要令牌直接执行批处理任务必须在拿到令牌后执行。如果令牌不足程序会sleep(10)等待下一次尝试。10ms 是人为设定的重试间隔它不影响最终结果只是避免 CPU 忙等。模拟耗时里交互任务固定 50ms批处理固定 500ms。这比真实 LLM 推理快但足够演示延迟差异。真实场景中你可以把runLLM改为一个异步 HTTP 调用并在结果返回后打印延迟。7. 运行结果与效果验证7.1 运行命令在项目根目录执行npx tsx src/demo.ts如果你不想用 tsx也可以先编译再运行npx tsc node dist/demo.js7.2 预期输出输出是一行行的完成日志示例[2025-03-01T10:15:23.102Z] interactive interactive-a1b2c3 完成端到端延迟 105ms [2025-03-01T10:15:23.198Z] batch batch-d4e5f6 完成端到端延迟 3500ms [2025-03-01T10:15:23.302Z] interactive interactive-f6a1b2 完成端到端延迟 102ms [2025-03-01T10:15:23.503Z] batch batch-c9d8e7 完成端到端延迟 2800ms观察重点交互请求的端到端延迟应该始终保持在 100ms 到 200ms 之间。这是 50ms 模拟推理时间加上排队时间。即便批处理任务积压很多交互延迟也不会无限变大。批处理请求的端到端延迟可能比较高比如 2500ms、3500ms。这是因为它们要等待令牌并且只有在交互队列为空时才被消费。7.3 如何判断调度是否生效如果在连续 15 秒内日志里的interactive请求延迟始终明显低于同时存在的batch请求延迟说明优先级隔离生效了。你还可以做一个对比实验把调度器改成只有单一 FIFO 队列再次运行 demo。你会发现交互请求有时会排到 500ms、1000ms 甚至更久因为前面积压了多个批处理请求。7.4 运行失败怎么查失败现象排查步骤提示tsx找不到执行npm install -D tsx确认安装在当前项目提示 TypeScript 类型错误检查tsconfig.json中strict是否太严格或先放宽为strict: false跑通输出不到 15 秒就退出检查最外层setTimeout是否被误删交互延迟仍然很高确认没有在submit内同步执行await runLLM(...)否则会阻塞队列8. 常见问题与排查思路8.1 在线请求延迟还是很高如果已经加了双队列交互延迟依旧上升通常不是调度逻辑的问题而是交互请求本身的并发度过高。调度器只能决定请求的消费顺序不能提高推理引擎的吞吐上限。你需要限制在线请求的并发数比如使用信号量控制同时执行的交互请求数量并给客户端返回 429 提示重试。8.2 批处理任务长时间得不到执行严格优先级会让批处理任务在连续高峰流量下饥饿。解决办法是给批处理任务留一个最小速率保证。最简单的做法是让令牌桶的速率保持不变并且在调度循环中即使交互队列非空也每隔一段时间允许一个批处理任务执行。实现方式可以是在循环里记录“上次批处理执行时间”如果距离上次执行超过 N 秒强制取一个批处理任务。这样既保证交互优先级又避免批处理完全饿死。8.3 令牌桶参数怎么调令牌桶的rate决定了每秒最多消费多少个批处理任务capacity决定了短时间突发量。一个参考起点是容量的值设为批处理任务“单批并发数”的两倍速率设为“期望每秒钟执行的批处理任务数”。比如你的批处理任务平均耗时 2 秒一次最多同时跑 4 个那容量可以是 8速率可以是 2。实际部署后再根据在线延迟和批处理吞吐监控调整。8.4 调度器本身成为瓶颈如果所有请求都经过调度器而调度器里有大量同步计算Node.js 的事件循环会被阻塞。避免在submit里做耗费 CPU 的 JSON 序列化、加密、压缩操作。如果请求量极大可以使用worker_threads将调度逻辑放到独立线程或者考虑用 Redis 加分布式锁做多实例调度。8.5 多个实例部署时公平性问题这个单机调度器不能解决多副本之间的公平问题。当服务扩到 3 个实例时所有请求会随机打散到不同实例每个实例有自己的队列和令牌桶。可能出现这种情况一个实例的交互流量很多另一个实例的批处理任务很多导致整体资源利用率不均。更稳妥的做法是引入一个统一的任务分发层让调度器消费一个公共队列例如 Redis Stream 或 Kafka配合分布式限流。本文的示例反映的是“单实例内部”的核心思路生产环境需要扩展。8.6 批处理任务失败重试会不会造成雪崩如果批处理任务失败后立刻重试且重试请求依然进入批处理队列那么在推理引擎故障期间批处理队列会越积越多。重试应该使用指数退避并设置最大重试次数同时如果批处理队列长度超过阈值应该暂时丢弃新任务等系统恢复后再重新提交。9. 最佳实践与工程建议9.1 为不同请求设置超时和队列上限交互请求和批处理请求都应该有过期时间。队列里的请求一旦超过 TTL就标记为过期调度器消费时直接丢弃避免无效推理。if (Date.now() - req.createdAt MAX_WAIT_MS) { console.warn(丢弃过期请求: ${req.id}); continue; }9.2 在线流量也要限流保护在线流量优先级高不代表可以无限进入队列。如果用户请求本身已经超过系统承载能力即使调度器把它们放在高优先级队列里延迟也会越来越高。要在接入层做速率限制或者对下游服务使用信号量控制最大并发。9.3 批处理任务支持暂停和恢复在流量高峰可以直接暂停从批处理队列取任务等到低峰期恢复。这个功能比令牌桶更粗粒度但实现简单、易操作。可以在运维页面上增加一个开关或者根据在线延迟指标自动暂停。9.4 监控三个核心指标调度器是否正常工作不能靠感觉要有指标交互请求的P50 / P95 / P99延迟批处理任务的吞吐量和平均排队时间令牌桶的耗尽次数和平均等待令牌时间。如果交互 P95 开始上涨优先看在线并发是否过高如果批处理排队时间持续增长且令牌桶耗尽次数为 0说明批处理被限制得太紧可以适当调高速率。9.5 与连续批处理框架的关系vLLM 这类推理框架本身有连续批处理也会对请求做某种形式的调度。本文的调度器是更上层的应用级调度。不要把两层调度混为一谈。推荐的做法是应用层调度器负责请求分类、优先级、令牌限流推理框架负责显存管理、prefill/decode 切换、连续批处理。两者共同作用才能既保证在线体验又不浪费 GPU 吞吐。9.6 用流量回放做回归测试上线之前把历史真实请求录制成文件然后在测试环境里重放。对比两种调度方案下的延迟分布。这样可以避免想当然的“我优化了”心态。关注批次任务积压时交互请求延迟是否仍然稳定。10. 总结与后续学习方向通过 TypeScript 实现一个最小调度器我们验证了一个关键结论LLM 服务中最危险的竞争不是模型质量而是资源调度。双队列解决的是优先级顺序令牌桶解决的是批处理速率两者组合能在大部分场景下避免批处理任务饿死交互流量。如果这个 Show HN 项目发布时只是单纯提供了 TypeScript 调度器那么它的价值正在于把系统工程中常见的优先级思路带进了 LLM 推理领域。你不需要把它当作一个只能照搬的工具完全可以借鉴它的抽象方式结合自己的推理框架和业务场景做二次设计。下一步我建议你做两件事一是把这个调度器接入你现有的推理服务。不需要急着做分布式先用单机模拟混合流量观察在线 P95 延迟和批处理吞吐确认参数是否合理。二是去研究连续批处理continuous batching的实现原理。你会发现应用层调度解决的是“该先跑谁”推理引擎层解决的是“怎么跑得更多”。理解这两层之后你对 LLM 服务性能的认识会再上一个台阶。批处理和交互流量的共存不会消失。只要 GPU 还是稀缺资源这个调度问题就值得你认真处理。希望这篇 TypeScript 示例能成为你动手改造的第一块跳板。