ARTICLE DETAIL

资讯详情

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

用TypeScript实现LLM流量调度:交互优先与并发槽位保留

用TypeScript实现LLM流量调度:交互优先与并发槽位保留 批量任务和在线交互请求共用一套 LLM 推理资源时最容易出现的隐患就是“批量作业把并发槽位占满线上交互请求排队超时”。这个问题在真实部署中并不少见尤其当离线任务批量摘要、批量向量化、批量对话生成提交量大、单次执行时间长时交互式流量的延迟会快速恶化。本文就用 TypeScript 实现一个轻量级的 LLM 流量调度器核心思路是“交互任务优先 为交互流量保留并发槽位”并给出可直接运行的示例和对比数据。本文适合以下几类读者正在做 LLM 服务网关或 Agent 服务编排的开发者已经遇到批量任务和交互任务互相抢资源的后端工程师以及想用 TypeScript 实现一个简单调度器、理解队列与并发控制原理的学习者。读完本文你可以掌握一套能防止批量任务饿死交互流量的最小可运行方案并知道如何在此基础上继续扩展。1. 背景批量 LLM 作业与交互式流量的资源之争1.1 两种 LLM 流量的特点一个稍微复杂的 LLM 服务系统通常会同时收到两类请求交互式流量Interactive Traffic比如聊天机器人中的实时对话、在线文档生成、RAG 问答中的即时检索生成。这类请求由用户直接触发对响应时间非常敏感通常要求 P95 延迟在几百毫秒到几秒之间。一旦超时用户体验会明显下降甚至直接放弃服务。批量作业Batch Jobs比如把一批历史对话做总结、为一批文档生成向量、跑一次全量数据清洗。这类任务提交后不要求立即返回允许在后台运行几分钟甚至几小时但吞吐量大且经常占用大量计算资源。两者如果共享同一组 GPU 或同一个推理服务就会产生资源竞争。批量任务通常用“多线程并发 持续轮询”的方式提交很容易把服务的并发槽位比如最大同时处理请求数全部占满。交互请求到达后不得不排在批量任务后面等待一个槽位释放。批量任务执行越久交互请求等待就越久最终表现为“交互流量被批量任务饿死了”。1.2 饥饿Starvation在 LLM 场景下的具体表现“饥饿”这个词来自操作系统进程调度意思是某个优先级低的任务一直得不到执行资源。放在 LLM 服务场景里饥饿对象变成了交互式流量。下面几个现象都可能是饥饿导致的线上聊天接口的响应时间从几百毫秒飙到几十秒甚至大量 504 超时。服务整体 GPU 利用率很高但交互接口的吞吐率很低。日志里能看到批量推理任务的开始时间和完成时间都很正常但交互请求大量堆积在网关队列中。扩容后现象很快缓解但批量任务一多同样的问题再次出现。1.3 核心问题不是“资源不够”而是“调度不公平”很多团队遇到这个问题第一反应是加 GPU。但加完资源后如果批量任务还是可以无限制地把并发槽位占满交互流量依然会间歇性饥饿。真正要解决的是如何让不同类型的任务在共享资源池中按照公平或优先级的策略排队和执行。常见的方案有三类优先级队列交互任务进入高优先级队列批量任务进入低优先级队列调度器优先消费高优先级队列。并发配额给批量任务设置一个最大并发数例如总并发 8批量任务最多占用 5其余并发槽位预留给交互任务。权重公平调度按照一定的权重比例分配并发资源例如交互流量权重 0.7批量任务权重 0.3。2. 环境准备与项目初始化我们先创建一个 TypeScript 项目来实现调度器。示例环境为 Node.js 18 和 TypeScript 5.x版本可以根据你的实际项目调整本文重点演示调度思路。2.1 创建项目打开终端执行下面的命令mkdir llm-scheduler cd llm-scheduler npm init -y npm install typescript ts-node types/node --save-dev2.2 初始化 TypeScript 配置用npx tsc --init生成一个基础的tsconfig.json然后可以根据需要修改为如下配置{ compilerOptions: { target: ES2022, module: CommonJS, moduleResolution: Node, rootDir: ./src, outDir: ./dist, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*] }2.3 项目结构本文示例项目结构如下llm-scheduler/ ├── src/ │ ├── scheduler.ts │ └── demo.ts ├── package.json └── tsconfig.jsonscheduler.ts存放调度器核心实现demo.ts是演示脚本用来模拟批量任务与交互任务混合提交的场景。3. 核心调度原理拆解3.1 为什么一个简单的并发限制不够最简单的方案是给整个服务设置一个maxConcurrency所有任务先进先出FIFO执行。这种做法只能控制总体并发不能保证交互任务的优先级。如果批量任务先到交互任务后到那么交互任务必须排到所有先到的批量任务之后。所以还需要一个条件不同类型的任务不能使用同一个队列或者不能使用同一种调度策略。3.2 交互优先 保留槽位我们选择一种实现成本低、效果直观的方案维护两个任务队列interactiveQueue和batchQueue。调度器在有空闲并发槽位时优先从interactiveQueue取任务。从batchQueue取任务之前需要检查一个额外的条件当前空闲槽位是否大于保留给交互任务的槽位数reservedInteractiveSlots。为什么要保留槽位假如没有预留槽位当交互请求到达时所有槽位都被批量任务占满那么交互请求必须等待某个批量任务完成才能被调度这个等待时间可能很长。如果始终保留至少 1 个空闲槽位那么交互请求到达后可以立刻进入执行阶段。当然预留槽位会牺牲一部分资源利用率因为即使没有交互任务那些保留槽位也不会用于批量任务。这是“保护交互流量”和“提升批量吞吐”之间的一个 trade-off生产环境里可以让这个值动态可调。3.3 调度器的基本数据模型每个任务需要记录以下信息任务 ID方便日志追踪。任务类型kindinteractive或batch。执行函数run返回一个 Promise。入队时间enqueueTime用于计算等待时间。resolve和reject用于把最终结果返回给调用方。调度器需要维护两个任务队列。当前运行中的任务总数。最大并发数。保留给交互任务的槽位数。4. 完整实战用 TypeScript 实现 LLM 流量调度器4.1 定义任务类型在src/scheduler.ts中先定义类型和接口// 文件路径src/scheduler.ts export type TaskKind interactive | batch; interface TaskEntry { id: string; kind: TaskKind; run: () Promiseunknown; resolve: (value: unknown) void; reject: (reason?: unknown) void; enqueueTime: number; startTime?: number; }这里用Promiseunknown是为了保持通用性具体业务可以自行传入类型更具体的执行函数。4.2 实现调度器核心类创建LlmScheduler类负责任务提交、调度和状态统计// 文件路径src/scheduler.ts export class LlmScheduler { private interactiveQueue: TaskEntry[] []; private batchQueue: TaskEntry[] []; private runningCount 0; private completedTasks: { id: string; kind: TaskKind; waitMs: number; durationMs: number }[] []; private taskId 0; private readonly maxConcurrency: number; private readonly reservedInteractiveSlots: number; constructor(maxConcurrency: number, reservedInteractiveSlots 1) { if (maxConcurrency 0) { throw new Error(maxConcurrency 必须大于 0); } if ( reservedInteractiveSlots 0 || reservedInteractiveSlots maxConcurrency ) { throw new Error(reservedInteractiveSlots 必须在 0 和 maxConcurrency 之间); } this.maxConcurrency maxConcurrency; this.reservedInteractiveSlots reservedInteractiveSlots; } submit(kind: TaskKind, run: () Promiseunknown): Promiseunknown { return new Promise((resolve, reject) { const task: TaskEntry { id: ${kind}_${this.taskId}, kind, run, resolve, reject, enqueueTime: Date.now(), }; if (kind interactive) { this.interactiveQueue.push(task); } else { this.batchQueue.push(task); } this.scheduleNext(); }); } private canStartBatch(): boolean { const available this.maxConcurrency - this.runningCount; // 启动一个 batch 之后剩余空闲槽位仍然要大于保留给交互任务的槽位 return available this.reservedInteractiveSlots; } private pickNext(): TaskEntry | undefined { // 交互任务永远优先 if (this.interactiveQueue.length 0) { return this.interactiveQueue.shift(); } // 没有交互任务时才能调度批量任务但要满足保留槽位的条件 if (this.batchQueue.length 0 this.canStartBatch()) { return this.batchQueue.shift(); } return undefined; } private scheduleNext(): void { while (this.runningCount this.maxConcurrency) { const task this.pickNext(); if (!task) break; this.startTask(task); } } private startTask(task: TaskEntry): void { this.runningCount; task.startTime Date.now(); const waitMs task.startTime - task.enqueueTime; console.log( [${new Date().toISOString()}] start ${task.id} (wait${waitMs}ms, running${this.runningCount}) ); const runStart Date.now(); task.run() .then((result) { const durationMs Date.now() - runStart; this.completedTasks.push({ id: task.id, kind:
返回列表