模型跑完一批只要100毫秒,那API是不是也能100毫秒返回?这个问题一抛出来,白板上那些方框突然就不那么友好了。
因为模型的秒表是在等待结束之后才按下的,用户的秒表不是。打个比方:接驳车承诺车程十分钟,听着很美,但你完全可能在站台上耗掉四十分钟,司机只是在等更多人上车。车开得再快,也救不回那个下午。
这道Anthropic面试题问的是:设计一个LLM推理服务,每一批都耗时100毫秒。下面从单个请求开始推,而不是先堆一堆基础设施方框。推完之后你应该能说清楚三件事:一批什么时候发出、需要几个worker、以及从哪一刻起再接一个请求就变成了兑现不了的承诺。
先搞清楚秒表从哪一刻开始
选队列之前,先明确这100毫秒量的是什么。这里假设它覆盖模型对一批请求的完整执行,不是首token时间,也不是客户端整个往返。同时假设:单一模型版本、每批最多32个请求、每个worker一次只跑一批、返回完整响应而非流式,且在这个简化练习里固定计算时间对所有支持的批大小都成立。这些是设计假设,不是对Anthropic生产服务的描述。
给调用方一个临时的200毫秒端到端预算:大约20毫秒给入站网络和API处理,100毫秒给模型,20毫秒给结果返回。剩下60毫秒留给等待。
这60毫秒要同时覆盖两种等待:为了凑满一批而主动等,以及因为所有worker都忙而被动等。它们不是两笔可以各花一次的额度。这组数字只是示意性预算,证明不了p99目标——真实的网络抖动、服务开销和流量突发仍然需要实测。如果面试官的意思是端到端100毫秒而计算本身就占满100毫秒,那就该指出:这个预算没有给任何其他环节留余地。
允许半空的批次先走
等到32个槽位全满听起来很高效,直到流量变稀。一个孤零零的请求可能一直坐着,而GPU明明空着。更好的规则是同时看满载程度和等待时长。
把例子缩小:批上限设为4,允许最多10毫秒的主动攒批延迟。从空闲worker开始:
- 请求A在0毫秒到达,开始计等待时间。
- 请求B在7毫秒到达,沿用原来的时钟,不重新计时。
- 到10毫秒,把A和B一起发出去,哪怕还空着两个座位。
- 计算在110毫秒完成,A在这段服务里花了110毫秒,B花了103毫秒。
换一组到达方式:四个请求在0毫秒同时出现,立刻发满批,100毫秒完成。没必要为一个已经完成使命的计时器再等下去。
由此得到一条调度规则:当有兼容的worker空闲时,如果批次已满、最老请求已到攒批延迟上限、或者再等下去会威胁某个请求的截止时间,就立即派发;否则等下一个到达或相关计时器。计时器限制的是主动延迟,它变不出一个空闲worker。如果唯一的worker要忙到100毫秒,一个1毫秒到达的请求在开始计算前可以等上99毫秒,10毫秒的攒批计时器救不了它。
worker忙的时候要保留最老的到达时间戳。容量一恢复,立刻派发已经超时的半批,而不是重新起一个10毫秒计时器。这是个很小的实现细节,但对等待的人来说效果非常明显。NVIDIA的Triton批处理文档描述了针对无状态模型的可配置攒批延迟和队列策略,可以作为实现参考;这里的10毫秒是教学选择,不是通用建议。
先算容量,再谈加卡
在固定时间假设下,一个worker每秒完成10批。每批32个请求,理想吞吐就是每秒320个请求。
假设到达速率为每秒1000个请求,算术下限是4个worker:1000除以320向上取整。3个worker即使在理想情况下也只能完成每秒960个请求,持续每秒1000的到达会让队列不断变长。4个worker的理想容量是每秒1280个请求,负载约为容量的78%;5个是每秒1600,负载降到62.5%。
算术很漂亮,但别把最后一个数字当成延迟保证。它假设批次全满、零开销、无故障。如果平均批大小只有16,同样的固定时间模型下,一个持续忙碌的worker每秒只能处理160个请求。5个worker是待测量的候选配置,不是表格给出的标准答案。要跟踪实际批大小、队列中最老请求的年龄、接受与拒绝的流量、以及端到端延迟分位数,按你真实的到达模式留余量。
自动扩缩应该看排队工作和截止压力,而不只是GPU利用率。模型加载慢的时候要保留热容量;一个在调用方截止时间之后才到岗的worker,救不了那个请求。
把方框画出来,包括回程
第一版架构可以很小:客户端 → API前端 → 按模型版本划分的有界队列 → 调度器 → 模型worker。结果再经前端回到原来的调用方。
准入时校验输入大小和支持的设置、认证调用方,并附上请求ID、租户身份、到达时间、截止时间和回复目的地。前端在按请求ID查找的表里保存等待中的连接或future,调度器自己不需要持有HTTP连接。
返回结果时带上请求ID和尝试标识。一旦重试、取消或过滤会改变批次成员,批次位置就是脆弱的寻址契约。匹配到等待中的请求一次即可,丢弃过期的尝试或迟到的结果。
支持多个模型版本时,为兼容的版本保留各自的队列,并路由到已经加载好对应权重的worker。两个请求在队列里挨着,不代表它们的模型兼容。在共享容量前面加上租户配额或公平调度,免得一个热情的调用方占满整个候车室。
想清楚准入到底承诺了什么
对同步服务,可以明确允许前端崩溃导致连接失败、由客户端重试。如果已接受的工作必须扛过这次崩溃,就需要持久化的请求状态、结果存储,以及重连或轮询的契约。光有一个持久队列,恢复不了一个已经消失的连接。
队列要按条数和内存设上限,并让超过截止时间的请求过期。超出的工作用合适的过载响应拒绝,而不是默默接受一个不断变长的积压。用共享的重试预算和带抖动的退避,避免客户端在同一瞬间一起回来。
预估完成时间可以帮助准入判断,但它终究只是估计。如果一个请求明显无法在剩余预算内完成,就早点说。拖到截止时间才认输,既浪费调用方的时间,也浪费你的容量。
一个坏请求不该有无限次机会
顺利路径上的批处理很整齐,失败处理才是解释必须精确的地方。
- worker在批处理中途挂掉:找出未完成的请求,只重试那些仍在截止时间和尝试预算内的。工作能在worker之间迁移时,用租约或等价的归属跟踪,并给旧尝试加栅栏,防止迟到的响应把同一个请求完成两次。
- 某个请求反复搞崩批次:在调度前校验可预见的坏输入。对原因不明的失败,隔离可疑请求,或用严格的尝试预算拆分失败的组。永远不要无限重放同一个失败组。
- 调用方超时或断开:尽可能移除排队中的工作,并在执行引擎支持时传播取消。已经在跑的GPU工作未必立刻停下,但要阻止它最终产出的结果复活一个已过期的请求。
在失败原因不明时,推理可能执行两次。完成去重保护的是响应契约,它不会神奇地保证计算恰好一次。如果客户端期望重试能重放原始结果,就保留一份租户范围内的幂等记录,包含请求指纹和已保存的结果。重新生成的结果未必相同。
然后,把100毫秒的假设放掉
解完题目设定之后,才轮到放松假设。真实的生成请求提示长度和输出长度各不相同,光看请求数量无法描述它们消耗的工作和内存。
要考虑token和内存预算、兼容的模型设置,并把交互式工作和批量工作分开处理。一个短响应不该因为和长响应同时开始,就得等长响应跑完。采用连续批处理时,引擎可以在解码步骤之间调度活跃序列,并在其他序列结束时接纳新工作。vLLM在其服务特性中记录了连续批处理。这改变的是调度模型,它不会让任意生成都在100毫秒内完成。
做流式时,首token时间、token间隔和完整完成时间要分开度量。首token很快,后面照样可能跟一个慢得让人抓狂的回答。指标要和你承诺的体验绑在一起。
最后留一个值得做的练习:关掉解释,凭记忆重画系统。用一个worker、批上限4、10毫秒攒批延迟、100毫秒计算,手工推演三种情况——稀疏到达(A在0毫秒、B在7毫秒,解释为什么10毫秒发车、为什么两人等待时长不同);worker忙碌(四个请求0毫秒开始,E在1毫秒到达,没有其他worker时E最早100毫秒开始、200毫秒完成,解释攒批计时器为什么没有封顶它的总等待);截止时间到期(给E一个150毫秒的截止时间,即使worker一空就立刻派发也来不及,说清你在何时拒绝或让它过期,以及如何处理迟到的结果)。练习里忽略网络和前端开销,假设没有后续到达,目的是在加更多方框之前,让每一个时间戳都站得住脚。
这道题最妙的地方在于:模型快,并不能让我们跳过普通的工程活。总得有人决定谁等、等多久,以及当承诺兑现不了时会发生什么。
热门跟贴