截止时间、预算与排队
前置为前两章。一次请求可能花 100ms 排队、800ms 调模型、400ms 执行工具,再重试一次。单独给每个步骤 2 秒,并不能保证总任务在 2 秒内完成。统一的截止时间比层层重置超时更符合用户合同。
预算分配必须在调用之前发生
先以单调时钟计算剩余时间,再判断是否足以发起下一步。成本同样先保留上限,完成后按实际用量结算;并发调用必须原子占用预算,不能让两个请求都看到“还有余额”后各花一遍。以下整数预算以虚拟单位计费,不代表任何平台价格。
class Budget:
def __init__(self, remaining):
self.remaining = remaining
self.reserved = {}
def reserve(self, call_id, maximum):
if maximum <= 0 or call_id in self.reserved:
raise ValueError('invalid reservation')
if maximum > self.remaining:
raise RuntimeError('budget exhausted')
self.remaining -= maximum
self.reserved[call_id] = maximum
def settle(self, call_id, actual):
maximum = self.reserved[call_id]
if not 0 <= actual <= maximum:
raise ValueError('invalid usage')
del self.reserved[call_id]
self.remaining += maximum - actual
b = Budget(100)
b.reserve('a', 70)
try:
b.reserve('b', 50)
except RuntimeError:
pass
else:
raise AssertionError('overcommitted')
b.settle('a', 40)
assert b.remaining == 60
b.reserve('b', 50)
assert b.remaining == 10
print('reserved before execution')保存为 budget.py,输出 reserved before execution。第一次预留后剩30,不能再发50的调用;结算实际40后退回30,才允许下一次。该对象只演示单进程顺序调度,多个 worker 需要数据库条件更新。供应商超时但用量未知时保留占额并进入对账,不应按零成本结算。实际价格含输入、输出、缓存、工具与存储等独立维度,不能只乘输出 token。
重试必须满足两个条件
错误可恢复,且重新执行不会造成未经控制的重复效果。限流、暂时服务不可用可按截止时间有限重试;参数错误要修输入,拒答要按业务规则转人工或结束。读取 Retry-After 后仍需检查任务剩余时间,并加抖动避免所有客户端同时重试。降级模型只有在能力和数据边界满足任务合同后才可选,不把不支持图像的便宜模型送进图像任务。
容量为什么取决于尾部
平均到达率为每秒5个任务,平均服务时间2秒时,稳定系统平均在途约10个。这里使用的是长期均值关系,不是并发上限保证。遇到服务时间变为10秒的尾部,队列会积压。限制并发同时限制排队长度,队列满时明确拒绝或返回稍后查询,不能无限占内存。
用模型调用、检索和工具各自的耗时拆解尾延迟。若工具限额每秒2次,增加模型 worker 不会提高整条链的吞吐,反而可能把压力推向工具端。缓存只有在权限和版本身份正确时才参与优化,先看第一章的隔离条件。
排队和重试共用同一个时钟
保存 deadline_queue.py,Python 3.11+运行。注入时钟让实验不用真实等待;队列容量限制等待任务数,出队会跳过已过期项。
from collections import deque
class Clock:
def __init__(self):self.now=0.
def advance(self,seconds):self.now+=seconds
class Queue:
def __init__(self,capacity,clock):
if capacity<1:raise ValueError('capacity')
self.capacity=capacity;self.clock=clock;self.waiting=deque()
def put(self,key,timeout):
if timeout<=0:raise ValueError('timeout')
if len(self.waiting)>=self.capacity:raise RuntimeError('queue full')
self.waiting.append((key,self.clock.now+timeout))
def take(self):
while self.waiting:
key,deadline=self.waiting.popleft()
if deadline>self.clock.now:return key,deadline
return None
def next_timeout(deadline,clock,retry_after=0.,minimum_call=.2,cap=1.):
if retry_after<0:raise ValueError('retry delay')
remaining=deadline-clock.now
if remaining<retry_after+minimum_call:return None
return min(cap,remaining-retry_after)
clock=Clock();queue=Queue(1,clock)
queue.put('a',2.)
try:queue.put('b',2.)
except RuntimeError:pass
else:raise AssertionError('queue overflow')
clock.advance(1.2)
key,deadline=queue.take()
assert next_timeout(deadline,clock,retry_after=1.) is None
assert abs(next_timeout(deadline,clock)-.8)<1e-9
queue.put('expired',.3);clock.advance(.4)
assert queue.take() is None
print('queue waiting consumes total deadline')任务a的总预算2秒,排队已经花掉1.2秒,所以剩余0.8秒。收到1秒Retry-After时应停止;不需要重试时,传给底层的超时最多0.8秒,而不能重新给2秒。next_timeout返回的是等待之后可用的单次调用预算;真实执行应在等待后重新读取单调时钟,调度暂停也会消耗时间。本例minimum_call是产品选择的最小调用窗口,不是供应商保证。
已过期任务被移除,但调用者还需记录timeout终态通知用户。此小队列不是持久消息系统,重启恢复、多worker原子认领、租户公平与最大在途请求数应另行实现;不能把等待队列容量当并发数。满队列可拒绝入队并让客户端稍后查询,不能在后台无限积压。
练习
用 reserve('x', 20) 后调用 settle('x', 25),验证抛 ValueError 后预留仍存在且剩余不变;再以实际10结算并检查余额。解释为什么未知用量不能直接退回20。把初始余额设为30,最终余额应是20。
追加时钟练习:任务预算3秒,排队1秒,Retry-After为1.5秒,最小调用时间0.6秒,是否还能重试?再把最小调用时间改成0.4秒,预测底层超时。