跳到主要内容

批量 LLM 流水线的盲点:离线 AI 的队列设计、检查点与成本分摊

· 阅读需 14 分钟
Tian Pan
Software Engineer

大多数生产环境中的 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 瓶颈,使流水线的实际运行时间翻倍。

行之有效的模式:

  1. 在任务开始时将工作划分为有序的分块
  2. 原子化处理每个分块——分块 N 中的所有项目完成后,分块 N+1 才会开始
  3. 在每个分块后写入检查点文件,包含分块索引和任何累积的结果
  4. 启动时,读取检查点并跳至第一个未处理的分块

检查点写入本身需要是原子性的。写入临时文件并重命名——崩溃的写入要么留下旧的检查点,要么留下新的,绝不会是损坏的中间状态。对于大批次,每完成 25–50 项保存一次检查点,在 I/O 开销与潜在的重复工作之间取得平衡。

对于跨多个工作节点的分布式批处理,协调层至关重要。Redis 的原子操作(用于计数完成数的 INCR,用于主节点选举的 SETNX)可防止并行工作节点报告完成时的竞态条件。最后一个完成的工作节点触发结果聚合——原子性保证确保只有恰好一个工作节点执行此操作。

出错的代价是具体的。一个使用前沿模型的 100,000 条记录的流水线,价格约为每百万输入 token 10,每条记录处理500tokensAPI花费约为10,每条记录处理 500 tokens,API 花费约为 500。如果在 90% 进度时崩溃且没有检查点,将浪费 450和数小时的实际运行时间。有了检查点,你只会损失一个分块的工作量——可能也就450 和数小时的实际运行时间。有了检查点,你只会损失一个分块的工作量——可能也就 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% 的数据时,才有人注意到。

成本归因:规模化下的隐形成本问题

实时 LLM 成本相对容易归因 —— 每个请求都映射到一个用户操作,你可以按客户或功能标记成本。批处理成本则更难,因为触发业务事件与 LLM 支出之间的关系是间接且延迟的。

一个夜间摘要流水线处理来自 12 个不同团队的文档。每月的账单是 14,000 美元。谁来买单?如果没有每批次的成本归因,答案就是 “平台团队的预算”,这意味着没有人有动力去优化他们的输入。

批处理流水线所需的仪器化监控:

  • 每批次元数据: 记录批次 ID、触发事件、项数量、使用的模型、总输入 token 数、总输出 token 数、实际时长(wall-clock duration)和完成状态
  • 每项 token 跟踪: 记录每个项的输入和输出 token 数,而不只是每批次的 —— 这能揭示哪些项的成本高得不成比例
  • 每消费者成本汇总: 如果多个团队触发批处理作业,请将成本归因于请求团队,以防止公地悲剧

这些数据还揭示了令人惊讶的优化机会。团队经常发现 10% 的输入项占用了 60% 的 token 支出 —— 通常是因为少数输入异常长,可以在进入 LLM 之前进行过滤或截断。

批处理工作负载的模型选择矩阵

实时服务限制了你的模型选择:你需要足够快的模型来满足延迟 SLA。批处理完全消除了这一限制,但团队很少利用这种自由。

对于批处理工作负载,决策矩阵发生了变化:

  • 分类任务(情感、类别分配、二元决策):使用满足准确性阈值的最小模型。对于大多数分类任务,比前沿模型低两个级别的模型在准确性差距 2–3% 的范围内,而每 token 成本要低 10–20 倍。
  • 提取任务(实体识别、结构化数据提取):中端模型处理得很好,但要仔细测试 —— 当你处理数百万条记录且下游系统无差别地消费输出时,提取错误会产生复合影响。
  • 生成任务(摘要、描述、分析):这些任务从前沿模型中获益最多,但 50% 折扣的批处理定价通常使得前沿模型在每项成本上比标准定价的小型模型更便宜。
  • 多步推理: 考虑是否可以分解为一个过滤 70% 项的廉价分类步骤,然后对剩余 30% 进行昂贵的推理步骤。这种流水线方法通常可以将批处理成本降低 50–70%。

批处理语境还解锁了实时服务难以企及的提示词缓存(prompt caching)优势。当你的系统提示词在批处理中的 10,000 个项中完全相同时,缓存命中率接近 100%,从而将有效输入 token 成本降低高达 90%。

LLM 批处理流水线的队列设计

用于批处理 LLM 处理的队列架构在一些关键方面与传统消息队列不同:

背压(Backpressure)是成本驱动的,而非延迟驱动的。 在实时系统中,背压防止超时。在批处理系统中,背压防止成本超支。你的队列消费者应该遵循每分钟 token 预算,而不只是每秒请求数限制。如果并发没有上限,一批长文档可以在几分钟内消耗掉你整个小时的 token 预算。

可见性窗口需要重新思考。 标准消息队列的可见性超时假设处理时间在分钟以下。LLM 调用通常需要 10–60 秒。如果你的可见性超时是 30 秒,而 LLM 调用需要 45 秒,消息将再次变得可见并被处理两次 —— 导致重复工作使成本翻倍。将可见性超时设置为预期 p99 处理时间的至少 3 倍。

幂等性是不容商榷的。 在重试、重复投递和可见性超时竞争之间,你的项将被处理不止一次。写入输出存储的每次操作都需要一个幂等键 —— 通常是作业 ID 和项 ID 的组合 —— 以便重复处理时执行覆盖而不是追加。

存储对 Payload 至关重要。 早期的尝试通常使用队列本身来携带大型 Payload(正在处理的完整文档、完整的提示词)。这很快就会崩溃。一个包含 500 个项且每个提示词为 2KB 的批次就有 1MB 的队列 Payload,这超过了大多数消息代理的限制。将 Payload 存储在对象存储(S3, GCS)中,并在队列中传递引用。

批处理任务中“完成”的定义

批处理 LLM 流水线的完成条件比“处理完所有项目”更为复杂。你需要明确定义完成标准:

  • 成功阈值: 如果 95% 的项目完成,任务算成功吗?99% 呢?还是必须 100%?大多数批处理工作负载应该容忍部分失败,而不是阻塞下游消费者。
  • 质量门禁: 在宣告成功之前,流水线是否应该对输出运行验证步骤?对于分类任务,随机抽样质量检查可以在模型退化传播到下游系统之前及时发现问题。
  • 下游通知: 无论是 Webhook、数据库标志位还是事件推送——具体的机制并不重要,重要的是必须有一个。如果没有明确的完成信号,下游消费者就只能依靠轮询或时间预测(如“任务通常在凌晨 4 点完成”),而这两者都很容易失效。
  • 清理工作: 临时文件、中间结果和检查点状态应在成功完成时清理,但在失败时保留以便调试。

完成报告应该是一项核心产物:总项目数、成功数、失败数、重试次数、实际耗时、总成本、单项目成本,以及指向失败项目存储库的链接。这是你的值班(On-call)团队赖以生存的运维界面。

回归经典数据工程

关于批处理 LLM 流水线最深刻的见解是,它们实际上是向经典数据工程的回归。面对的问题依然是任务调度、失败恢复、成本核算和数据质量——这些都是数据工程社区在几十年前就已经通过 Airflow、Prefect 和 Temporal 等工具解决的问题。

错误的做法是将批处理 AI 视为需要全新架构的特殊情况。事实并非如此。它需要的严谨性与数据团队应用在 ETL 流水线上的严谨性完全一致,只是增加了一些 LLM 特有的考量:作为资源限制的 Token 预算、作为质量变量的非确定性输出,以及作为吞吐量上限的模型特定频率限制(Rate limits)。

那些尽早意识到这一点,并基于成熟的编排框架(而非自定义脚本)构建批处理 LLM 流水线的团队,交付速度更快,故障更少,且在调试生产事故上花费的时间也大幅减少。批处理流水线并不华丽,但它是真正产生商业价值的地方。把它做对,就是将纯粹的工程纪律应用到了正确的问题上。

References:Let's stay in touch and Follow me for more thoughts and updates