批量 LLM 流水线的盲点:离线 AI 的队列设计、检查点与成本分摊
大多数生产环境中的 AI 工程建议都假设你在构建聊天机器人。架构讨论集中在首字时间(TTFT)、流式部分响应以及亚秒级的延迟预算上。但越来越多的真实 LLM 工作负载与聊天界面毫无共同点。它们更像是每晚的数据扩充任务、每周的文档分类运行,以及每月对数百万条记录进行的合规性审查。
这些批处理流水线正是团队悄悄烧钱最多、因无声失败导致数据丢失最严重、以及积累技术债最多的地方——这正是因为来自实时服务的“延迟优先”思维模型不再适用,且尚未有人用更好的方案取而代之。
思维模型的不匹配
当受过实时 LLM 服务训练的工程师设计他们的第一个批处理流水线时,他们会诉诸熟悉的工具:相同的 API 客户端、相同的重试逻辑、相同的错误处理。结果就是一个按顺序调用 LLM API 的 for 循环,可能还点缀了一些 asyncio.gather 并发。处理 50 条数据时还没问题,但在 50,000 条数据面前就会分崩离析。
失败模式有着本质的不同。实时服务优化的是单个请求的长尾延迟。而批处理优化的是总吞吐量、单项成本,以及对可能运行数小时的任务中部分失败的优雅处理。这是两个不同的工程问题,有着不同的解决方案。
一个遇到频率限制的实时系统会通过指数退避进行重试,用户只需多等一秒。而一个在剩余 40,000 项时遇到频率限制的批处理系统,则需要在不丢失已完成的 10,000 项进度的前提下,重新调整其整个并发配置。一个遇到格式错误响应的实时系统会向用户返回错误。而一个遇到格式错误响应的批处理系统则需要隔离该单次失败,继续处理剩余项,并在数小时后人工检查结果时,以可操作的方式呈现该失败。
任务分片:第一个没人能做对的决定
批处理 LLM 流水线中最具影响力的架构决策是任务粒度——即你如何将工作划分为可以独立成功、失败、重试并报告状态的单元。
太粗: 单个任务处理所有 100,000 条记录。如果在第 73,000 条记录时失败,你就浪费了 73,000 次成功的调用,这些调用可能需要重新验证,而且你没有简便的方法来恢复。任何失败的影响范围都是整个流水线。
太细: 每条记录都是一个独立的任务。你现在需要管理 100,000 个队列条目,跟踪 100,000 个状态记录,编排开销超过了实际处理时间。你的监控仪表盘变得毫无用处,因为你盯着的是单个项目的失败,而不是任务级别的健康状况。
大多数 LLM 批处理工作负载的最佳平衡点是 100–500 个项目的批次,并在它们之间设置明确的检查点边界。每个批次都:
- 小到可以在重试时不会产生显著的成本浪费
- 大到足以分摊编排开销
- 可独立报告——你可以告诉利益相关者“第 1–47 批成功,第 48 批失败,第 49–200 批待处理”
提供商原生的批处理 API 改变了这种权衡。目前两家主要的 LLM 提供商都提供批处理端点,每个批次可接受 10,000–50,000 个请求,成本降低 50%,并提供 24 小时的 SLA。这些是专为离线用例构建的,并在内部处理并发、频率限制和部分失败隔离。如果你的工作负载可以容忍延迟窗口,提供商的批处理 API 应该是你的默认起点——它们消除了你原本需要自行构建的一整类基础设施。
检查点-恢复:你的流水线早就该采用的模式
一个构建良好的批处理流水线的定义性特征是,你可以在任何时刻终止它并恢复,而无需重新处理已完成的工作。这听起来显而易见。但几乎没有人能在第一次尝试时正确实现它。
幼稚的方法——在内存中跟踪已完成的 ID——在进程崩溃的那一刻就会失效。稍微不那么幼稚的方法——在处理完每一项后将已完成的 ID 写入数据库——会产生 I/O 瓶颈,使流水线的实际运行时间翻倍。
行之有效的模式:
- 在任务开始时将工作划分为有序的分块
- 原子化处理每个分块——分块 N 中的所有项目完成后,分块 N+1 才会开始
- 在每个分块后写入检查点文件,包含分块索引和任何累积的结果
- 启动时,读取检查点并跳至第一个未处理的分块
检查点写入本身需要是原子性的。写入临时文件并重命名——崩溃的写入要么留下旧的检查点,要么留下新的,绝不会是损坏的中间状态。对于大批次,每完成 25–50 项保存一次检查点,在 I/O 开销与潜在的重复工作之间取得平衡。
对于跨多个工作节点的分布式批处理,协调层至关重要。Redis 的原子操作(用于计数完成数的 INCR,用于主节点选举的 SETNX)可防止并行工作节点报告完成时的竞态条件。最后一个完成的工作节点触发结果聚合——原子性保证确保只有恰好一个工作节点执行此操作。
出错的代价是具体的。一个使用前沿模型的 100,000 条记录的流水线,价格约为每百万输入 token 500。如果在 90% 进度时崩溃且没有检查点,将浪费 2.50 和几分钟。
失败粒度:项、批次与作业
LLM 批处理流水线有三个截然不同的失败层级,将它们混为一谈是大多数运维痛苦的根源。
项级失败(Item-level failures) 是最常见的:特定的输入触发了内容策略拒绝、 产生了格式错误的输出或超时。这些失败应该被捕获、记录违规输入并进行隔离。批处理应继续运行。你的流水线应该报告 “5,000 个项中有 4,847 个成功,153 个失败”,而不是 “作业失败”。
批次级失败(Batch-level failures) 发生在整个分块失败时 —— 通常是由于频率限制(rate limiting)、供应商停机或身份验证过期。这些是暂时性且可重试的。你的流水线应该自动以退避算法(backoff)重试失败的批次,直到达到配置的限制,然后再将其标记为永久失败。
作业级失败(Job-level failures) 是系统性的:检查点存储(checkpoint store)无法访问、输出接收端(output sink)已满,或者输入数据与处理逻辑根本不兼容。这些需要人工干预,并应干净利落地停止流水线,保留所有已完成的工作。
死信队列(dead letter queue)模式在消息处理中同样适用于这里。在达到最大重试次数后仍然失败的项,应带着完整上下文写入单独的存储:原始输入、错误信息、尝试次数和时间戳。这是你的调试界面。如果没有它,你可能需要在凌晨 2 点搜索日志,试图弄清楚为什么昨晚的富化运行中有 3% 的结果为空。
我最常看到的反模式是将每个 LLM 调用都包裹在一个 try-catch 中,仅记录错误并继续。这看起来像是在处理错误,但实际上是在抑制错误。你处理了 100,000 条记录,其中 3,000 条默默失败了,直到下游消费者询问为什么缺失了 3% 的数据时,才有人注意到。
