这一切始于一个 cron 作业。每晚凌晨 2 点,一个脚本会被唤醒,拉取当天的记录,通过模型运行,将结果写入表中,然后继续休眠。这是解决该问题的最简单形态,而且在整整一年的时间里,它确实是最合适的形态。没有人去考虑它,因为没有人需要去考虑。
接着有人问结果能否在早上 8 点而不是中午准备好。然后有人问用户是否可以按需触发单条记录的运行。接着一位产品经理问是否可以让应用内的体验“感觉像是即时的”。每个请求都是合理的。每一次改动都很小。而且从始至终,没有人打开过一份名为“重新架构推理流水线”的文档,因为没有任何一次单一的改动让人觉得像是在重写。
18 个月后,你拥有了一个披着批处理作业外壳的延迟敏感型在线服务。它的 p99 无人衡量,队列无人清理,且存在一种失效模式:由于流水线被构建为重试整个批次,一条错误记录就会导致面向用户的请求停滞。这是 AI 系统中最常见的架构失效之一,而且它几乎从未作为一项决策出现,而是作为对一系列合理请求不断说“是”而产生的缓慢累积。
重写你架构的三个请求
架构漂移是指系统逐渐地、无意地偏离其初始设计的现象。危险之处在于,它是由局部合理的决策构成的。缩短 cron 间隔的工程师是在响应一个真实的抱怨。添加 HTTP 端点以触发单条记录运行的工程师是在疏通一个真实的功能。没有人做错任何事。但架构还是变了,因为单个决策会产生复合效应,而没有人去衡量这些决策对系统形态产生的总体影响。
看看这种下滑在实践中是如何发生的。请求一:“我们能早点拿到结果吗?”你将 cron 计划从每晚改为每小时。依然是批处理,没问题。请求二:“用户能为他们自己的记录触发它吗?”你将批处理函数封装在一个处理大小为 1 的批次的端点中。它在演示中运行良好。请求三:“它能感觉像是即时的吗?”现在你正盯着一个曾被调整为在 1 万条记录中摊销启动成本的函数,它在用户盯着旋转进度条时正带着一条记录被调用。
每一步都保留了之前的代码。这恰恰就是问题所在。批处理函数在每次调用时仍然将整个模型加载到内存中,因为这一成本过去分摊在 1 万行记录上。流水线在最后仍然通过一次事务写入结果,因为当消费者是下游报告时,部分写入并不重要。重试逻辑在发生任何故障时仍然会重新运行整个作业,因为对于每晚一次的批处理,在凌晨 2 点重试所有内容的成本为零。在线流量违反了所有这些假设,而这些假设是不可见的,因为它们从未被写下来。它们只是作为批处理作业成长起来的代码的自然形态。
批处理推理与在线推理优化的目标截然相反
你无法从一种模式平滑地滑向另一种模式的原因在于,它们并不是一个光谱上的两个点,而是针对存在直接冲突的目标进行优化的两种设计。
批处理推理优化的是单件成本。它允许速度慢。它可以等到积累了足够的工作量以高效利用 GPU,大批量运行所有内容,并将每一项固定成本——模型加载、连接建立、容器启动——分摊到数千条记录中。在 LLM 服务中,将请求分组可以将每个 token 的成本降低约 85%,而仅增加约 20% 的延迟。对于批处理作业来说,这种权衡无异于白捡。吞吐量是唯一重要的数字,重试是真的免费,因为没有人等待。
在线推理优化的是 p99 延迟。它不能等待积累工作,因为这项工作对应的是一位用户,而用户已经在等待了。它必须在以百毫秒计的预算内,通过网络完成检索上下文、运行模型、验证输出并返回结果。硬性指标不是平均值,而是长尾。延迟优先的设计会刻意让 GPU 在空闲期间处于低负载状态,以便突发流量不会冲垮 p99,并接受更高的单次请求成本作为可预测性的代价。
这些目标确实是冲突的。让批处理推理变得廉价的批处理技术,正是摧毁长尾延迟的元凶,因为交替处理分组请求的 prefill 和 decode 阶段,使得系统在结构上很难同时做到高速和高吞吐量。在批处理中免费的重试,在在线服务中意味着延迟预算翻倍。追求效率的举措与追求延迟的举措背道而驰。一个从未经过重新设计就从一种模式漂移到另一种模式的系统,现在正被要求通过一个仅为满足其中之一而构建的设计来同时满足这两个目标——而它将悄无声息地在那个它并非为此而生的目标上败下阵来。
批处理系统所做的假设与在线流量的冲突
有三个假设深深地植根于批处理管道中,以至于工程师们甚至不再将它们视为假设。然而,当实时流量到来时,每一个假设都会演变成生产事故。
故障隔离。批处理作业将整个批次视为工作单位。如果第 4,217 条记录的字段格式错误,整个作业就会失败,触发告警,然后由工程师修复数据,并在凌晨 2 点重新运行。这对于批处理来说是可以接受的。但在在线场景下,这是不可接受的,因为工作单位是单个用户的请求,一条“有毒”的输入绝不能搞垮属于其他人的请求。在线系统需要针对每个请求的故障隔离、超时机制和熔断器——这些都是批处理管道从未需要的,因为它的影响范围(blast radius)始终只是“今晚重试一次”。
背压(Backpressure)。批处理作业控制着自己的到达率。它决定何时开始、拉取多少数据,因此负载完全取决于作业的选择。而在线流量则随用户心意随时到来,并以系统未经授权的爆发形式出现。如果没有背压——即向上游发出信号以减速、丢弃负载或设置有界队列的能力——流量爆发就无法实现优雅降级,而是会直接崩溃。现代推理栈将背压视为头等大事,通过利用率和 SLO 余量来估算队列压力,并在系统倾覆前进行反压。发生偏移的批处理作业没有这种机制,它只有一个无界的内存队列,一直工作到某天早上突然崩溃。
幂等性与重试。批处理的重试逻辑是重新运行所有内容,这之所以安全,仅仅是因为批处理写入被设计为可重放的。在线重试是针对每个请求的且非常频繁,只有在重新执行一个工作单位产生相同结果时才安全。这需要确定性的幂等键和 upsert(更新或插入)操作,而不是盲目的追加(append)。如果一个管道在成长过程中习惯了追加行或在没有防护的情况下增加聚合值,那么在在线重试压力下,它会默默地产生重复计算。这种数据损坏并不明显,它只是一个略微错误的数字,直到几周后才被发现。
这些差距都不会产生清晰的错误。一个路由错误的批处理型系统会产生一种“无声”的失败:它表现为请求变慢而不是崩溃,表现为 p99 突增而不是停机页面,表现为略微错误的聚合结果而不是堆栈跟踪。这就是为什么这种偏移(drift)如此难以捕捉。系统一直在返回答案,它只是不再值得信赖,而你的仪表盘中没有任何指标是为此设计的。
你的批处理系统已超载的预兆
你通常无法直接看到偏移,因为你脑海中的架构是来自设计文档的,而不是在生产环境中运行的那个。但如果你仔细观察,症状是显而易见的。
- 有人添加了队列,但没有人为其设置边界。一个声称能“平滑负载”的内存列表其实是一个无界缓冲区,正等待着流量高峰将其变成内存溢出(OOM)导致的崩溃。
- 你有 p99 数值,但没有 p99 目标。延迟正在被绘制成图表,但没有人声明什么是可以接受的,这意味着没有人对尾部延迟负责。如果你无法说明 p99 应该是多少,那么在线延迟还不是一个目标——它只是一个意外。
- 单次请求成本正在爬升,且没人能说清原因。一个被逐条记录调用的批处理管道在为一个工作单位支付完整的固定成本。支撑原先设计的摊销成本优势已不复存在,而账单就是最好的证明。
- “重试一下就好”仍然是事故处理手册的内容。如果文档中对故障的响应是重新运行作业,那么即使现在另一端坐着的是用户,系统仍然是以批处理思维在运作。
- 同一个函数同时服务于两条路径。同一个代码路径既供夜间作业使用,也供实时接口使用。它无法同时针对吞吐量和延迟进行优化,因此它会偏向原作者最关心的那个指标——通常是吞吐量。
如果以上情况符合两条或更多,那么你拥有的并不是一个带有一些在线功能的批处理作业,而是一个从未按在线服务标准设计过的在线服务。这两者之间的差距,就是你下一次事故的潜伏地。
明确转型,然后有目的地重新架构
修复工作并不需要英雄主义。它更多是组织层面的:让偏移可见,并将一个意外形成的架构转变为一个深思熟虑的架构。
首先,大声地明确这一转型。在文档中写下“这现在是一个在线服务”这句话,因为这句话会开启一套完全不同的检查清单——p99 目标、负载测试、有界队列、单请求隔离、幂等键、优雅降级。只要大家还把它叫做“那个批处理作业”,这些项就不会得到关注,因为你用来定义一个系统的词语决定了你会问关于它的什么问题。
然后,分离路径,而不是过度负载单一路径。批处理路径和在线路径需要截然相反的优化,所以让它们成为共享接口下的两个不同实现。批处理端保留其大分组和低成本重试;在线端则获得热模型、有界队列、真正的超时机制和明确的 p99 目标。它们可以共享业务逻辑,但不应共享性能特性。
最后,要养成一种习惯,而不仅仅是做一次修复。一旦你停止观察,偏移就会立刻卷土重来,因为接下来的三个合理需求已经在路上了。最廉价的防御是在设计评审中反复提问:“这个变更是否让系统在批处理和在线之间发生了移动?如果是,这是我们的决定,还是顺其自然发生的?” 大多数破坏性的架构决策从来都不是被主动做出的,它们只是不断累积。那些能避开这个陷阱的团队,并不是从不发生偏移的团队——而是那些会安排时间审视系统全局形态,并在凌晨 2 点的定时任务(cron job)变成付费客户正盯着的问题之前,有目的地再次做出选择的团队。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部