Agent 工程体系 · 第 90/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 限流与容量:并发、Token 速率、队列、GPU 配额和过载

Agent 的容量问题,不能简化为“接口每秒能处理多少请求”。一个 Agent 请求通常会经历多次模型调用、工具调用、重试、分支、人工确认和状态持久化。用户看到的是一次任务,系统实际消耗的是一组随时间变化的资源:

R=(C,Tin,Tout,G,M,I/O,W)R = (C, T_{in}, T_{out}, G, M, I/O, W)

其中:

  • CC:并发占用,包括正在运行的工作流、模型请求和工具调用;
  • TinT_{in}:输入 Token 速率;
  • ToutT_{out}:输出 Token 速率;
  • GG:GPU 计算或显存资源;
  • MM:内存、连接池、文件句柄等容量;
  • I/OI/O:数据库、搜索、HTTP、MCP 等外部依赖;
  • WW:工作线程、进程、协程和任务槽位。

限流控制进入系统的速率或数量,容量描述系统在目标服务等级下可以稳定承载的工作量,队列保存暂时不能立即执行的工作,过载则是到达需求持续或瞬时超过某个关键资源的服务能力。

这几个概念必须同时考虑。只限制 HTTP 请求数,无法防止 Token 速率超限;只限制 Token 速率,无法防止工具连接池耗尽;只增加 GPU,无法解决外部 API 的低吞吐;只增加队列,则可能把“立即失败”变成“几十分钟后超时”。


一、先把 Agent 请求还原成资源消耗

1. Agent 请求不是一次模型调用

设一次 Agent 工作流包含:

  1. 初始模型调用;
  2. 工具调用;
  3. 根据工具结果再次调用模型;
  4. 可能的重试;
  5. 可能的子 Agent 或 handoff;
  6. 最终生成。

如果第 ii 次模型调用消耗输入 Token IiI_i、输出 Token OiO_i,工具调用耗时为 DjD_j,则一次工作流的总模型消耗是:

Irun=i=1NIiI_{run} = \sum_{i=1}^{N} I_i

Orun=i=1NOiO_{run} = \sum_{i=1}^{N} O_i

Drun=iDmodel,i+jDtool,j+DqueueD_{run} = \sum_i D_{model,i} + \sum_j D_{tool,j} + D_{queue}

这里的 NN 不是固定常数。一个看似简单的请求,可能因为工具失败、模型判断不确定或重试而产生更多步骤。

因此,Agent 容量规划的基本单位不应只是“请求数”,而应至少记录:

  • 每个工作流的步骤数;
  • 每个步骤的输入、输出和总 Token;
  • 工具调用次数及耗时;
  • 重试次数;
  • 并行分支数;
  • 是否发生 handoff;
  • 是否进入降级路径;
  • 最终成功、超时、取消还是资源拒绝。

2. 工作流并发、模型并发和工具并发不是一回事

假设系统同时运行 100 个 Agent 工作流,但每个工作流只有一个模型步骤正在执行,模型并发可能接近 100。

如果每个工作流同时启动 4 个搜索工具调用,则:

Ctool100×4=400C_{tool} \approx 100 \times 4 = 400

此时工作流并发仍然是 100,但搜索服务、HTTP 连接池和下游 QPS 已经承受 400 个并行任务。

反过来,若一个工作流先调用模型,再长时间等待工具,那么工作流并发可能是 1000,而模型并发只有几十。此时限制模型并发并不能控制会话状态、内存、连接和队列占用。

实际系统通常需要至少三层并发限制:

工作流并发
 ├── 模型调用并发
 ├── 工具调用并发
 ├── 外部服务并发
 └── 单租户 / 单会话并发

每一层都可能成为独立瓶颈。


二、并发:数量约束,不是吞吐量

1. 并发的定义

并发数表示某一时刻处于“占用资源但尚未完成”状态的任务数量。它不是单位时间内完成的任务数。

常见的并发指标包括:

  • active_workflows:正在运行的 Agent 工作流;
  • active_model_calls:正在等待模型返回的请求;
  • active_tool_calls:正在执行的工具调用;
  • inflight_tokens:已提交但尚未完成的 Token 工作量;
  • active_gpu_sequences:推理服务中正在处理的序列数。

“每秒处理 10 个请求”是吞吐量;“同时有 50 个请求运行”是并发数。两者通过平均完成时间联系起来,但不能互相替代。

2. Little 定律:从延迟和吞吐推算并发

稳定系统满足 Little 定律:

L=λWL = \lambda W

其中:

  • LL:系统中的平均任务数,也就是平均并发数;
  • λ\lambda:平均到达或完成速率;
  • WW:任务从进入到完成的平均时间。

例如:

  • 稳定吞吐为 2 个工作流/秒;
  • 平均工作流耗时为 8 秒。

则平均并发为:

L=2×8=16L = 2 \times 8 = 16

这只是平均值,不是安全上限。如果 P95 耗时是 30 秒,且系统需要覆盖突发流量,实际并发上限不能只按 16 设置。

3. 并发上限的作用

设置工作流并发上限 CmaxC_{max} 后,系统可以在入口处做出三种选择:

  1. 有空闲槽位:立即运行;
  2. 没有空闲槽位但队列未满:进入队列;
  3. 槽位和队列都满:拒绝、降级或异步化。

一个简单的状态机如下:

stateDiagram-v2
    [*] --> Accepted: 请求到达
    Accepted --> Running: 获得工作流槽位
    Accepted --> Queued: 槽位已满且队列未满
    Accepted --> Rejected: 队列已满或预算不足
    Queued --> Running: 调度器分配资源
    Running --> Completed: 成功完成
    Running --> Failed: 不可恢复错误
    Running --> Cancelled: 超时或客户端取消
    Running --> Queued: 可重试任务重新排队

关键点是:排队状态本身也会消耗容量。排队任务需要占用消息队列、数据库记录、内存元数据、租户配额和任务租约。


三、Token 速率:模型容量的真正约束

1. Token 速率是什么

Token 速率描述单位时间内消耗或生成的 Token 数量。至少需要区分:

  • 输入 Token 速率,通常包括历史消息、系统提示、工具定义和检索上下文;
  • 输出 Token 速率;
  • 总 Token 速率;
  • 实时生成速率,即生成阶段的 tokens/s;
  • 供应商或项目级 Token-per-minute 限制;
  • 本地推理服务的实际吞吐。

设时间窗口为 Δt\Delta t,期间完成的请求集合为 SS,则总 Token 速率可以估算为:

Rtoken=rS(Ir+Or)ΔtR_{token} = \frac{ \sum_{r \in S}(I_r + O_r) }{ \Delta t }

但对 Agent 来说,这个公式还不够。因为一个正在运行的长请求可能已经占用大量 KV Cache 和 GPU 序列槽位,却尚未贡献到“完成请求”的统计中。因此容量系统应同时记录:

  • 已完成 Token;
  • 已发出但未完成的预估 Token;
  • 当前上下文长度;
  • 预留的最大输出长度;
  • 重试造成的额外 Token。

2. 输入 Token 常被低估

一个 Agent 的输入不只是用户问题:

Istep=Isystem+Ihistory+Itools+Iretrieval+Iprevious_results+IuserI_{step} = I_{system} + I_{history} + I_{tools} + I_{retrieval} + I_{previous\_results} + I_{user}

例如,一次包含 20 轮历史消息、30 个工具定义、两段检索结果的调用,用户输入可能只有 50 个 Token,但实际输入可能达到数万 Token。

如果工作流平均有 NN 个模型步骤,则单请求 Token 需求近似为:

Trun=i=1N(Ii+Oi)T_{run} = \sum_{i=1}^{N}(I_i + O_i)

当每一步都携带不断增长的历史和工具结果时,IiI_i 可能随步骤递增,使总 Token 消耗显著高于“初始上下文长度 × 步骤数”的粗略估计。

3. Token 预算必须在调度前预留

假设模型项目的安全 Token 预算为 QTQ_T tokens/min,当前窗口已经消耗 UTU_T,一个新任务预计需要 ETE_T,则只有在:

UT+ETQTU_T + E_T \leq Q_T

时才应立即准入。

这里的 ETE_T 不应只取平均值。更可靠的做法是按任务类型使用分位数估计:

ET=P95(Trunclass)E_T = P_{95}(T_{run} \mid class)

例如:

任务类型 平均 Token P95 Token 预估值
简单问答 2,000 4,000 4,000
检索问答 8,000 18,000 18,000
多工具分析 20,000 60,000 60,000

如果所有任务都按平均值准入,长尾任务会在高峰期把 Token 配额耗尽,导致后续任务集中收到限流错误。


四、限流算法:控制到达速率和资源占用

1. 固定窗口的边界问题

固定窗口限流例如“每分钟最多 60 个请求”:

10:00:00—10:00:59:最多 60 个
10:01:00—10:01:59:最多 60 个

如果 10:00:59 集中进入 60 个请求,10:01:00 又集中进入 60 个请求,实际可能在两秒内收到 120 个请求。

固定窗口实现简单,但不适合对模型 Token 和昂贵工具做精细控制。

2. 令牌桶:允许有限突发

令牌桶有两个参数:

  • rr:令牌生成速率;
  • BB:桶容量,也就是最大突发额度。

每个请求消耗一个或多个令牌。对于 Token 限流,可以让请求按照预估 Token 数量消耗令牌:

from dataclasses import dataclass
import time


@dataclass
class TokenBucket:
    rate_per_sec: float
    capacity: float
    tokens: float
    last_ts: float

    @classmethod
    def create(cls, rate_per_minute: int, burst: int):
        now = time.monotonic()
        return cls(
            rate_per_sec=rate_per_minute / 60.0,
            capacity=float(burst),
            tokens=float(burst),
            last_ts=now,
        )

    def try_consume(self, cost: int) -> tuple[bool, float]:
        now = time.monotonic()
        elapsed = max(0.0, now - self.last_ts)
        self.tokens = min(
            self.capacity,
            self.tokens + elapsed * self.rate_per_sec,
        )
        self.last_ts = now

        if self.tokens >= cost:
            self.tokens -= cost
            return True, 0.0

        missing = cost - self.tokens
        wait_seconds = missing / self.rate_per_sec
        return False, wait_seconds


bucket = TokenBucket.create(
    rate_per_minute=60_000,
    burst=12_000,
)

accepted, retry_after = bucket.try_consume(cost=8_000)
print(accepted, round(retry_after, 2))

这个示例的含义是:

  • 平均允许 60,000 Token/min;
  • 最多允许一次性消耗 12,000 Token;
  • 一个预估需要 8,000 Token 的任务,会消耗 8,000 个令牌;
  • 令牌不足时不应忙等,而应返回预计等待时间、进入有界队列或执行降级。

风险在于:如果每个请求一开始都按最大输出 Token 预留,容量会被过度保守;如果只按历史平均值预留,又会在长尾任务上失控。常见实现是“准入时按 P95 预留,完成后按实际值结算”。

3. 并发限流和 Token 限流必须同时存在

一个模型调用只有在同时满足以下条件时才能执行:

Cmodel<Cmodel,maxC_{model} < C_{model,max}

Twindow+ETQTT_{window} + E_T \leq Q_T

GrequiredGavailableG_{required} \leq G_{available}

Ddeadline>DestimatedD_{deadline} > D_{estimated}

其中最后一个条件表示任务剩余截止时间必须足以完成预计工作。否则,即使当前有并发槽位,也不应启动一个必然超时的任务。


五、队列:把瞬时过载变成可管理的等待

1. 队列解决什么问题

队列解决的是到达速率的瞬时波动,不是永久容量不足。

设:

  • 到达速率为 λ\lambda
  • 服务速率为 μ\mu

当:

λ<μ\lambda < \mu

系统可以在平均意义上排空队列。

当:

λμ\lambda \geq \mu

队列会持续增长,除非拒绝、取消、降级或增加服务能力。

因此,“队列很大所以系统有容量”是错误的。无限队列只是把过载延后暴露为:

  • 等待时间无限增长;
  • 任务过期后仍被执行;
  • 用户重复提交;
  • 重试风暴;
  • 消息堆积和存储成本增加。

2. 有界队列必须有等待预算

设任务截止时间为 DD,估计运行时间为 SS,当前预计等待时间为 WqW_q,则准入条件至少应满足:

Wq+SDW_q + S \leq D

例如:

  • 用户请求总超时 30 秒;
  • 当前队列预计等待 18 秒;
  • Agent 执行 P95 为 20 秒。

则:

18+20=38>3018 + 20 = 38 > 30

这个任务不应继续排队。继续接受只会制造一个稍后必然失败的任务。

队列应保存以下信息:

{
  "task_id": "task_123",
  "tenant_id": "tenant_a",
  "priority": 20,
  "estimated_tokens": 18000,
  "deadline_at": "2026-09-01T10:00:30Z",
  "retry_count": 0,
  "idempotency_key": "tenant_a:request_456",
  "state": "queued"
}

其中 estimated_tokensdeadline_at 是调度所必需的,不是仅用于监控的附加字段。

3. FIFO 不等于公平

先进先出队列容易实现,但会产生队头阻塞:

  • 前面的任务需要执行 10 次模型调用;
  • 后面的任务只需要一次简单回答;
  • 后面的短任务必须等待前面的长任务完成。

更重要的是,单租户大量提交任务时,FIFO 会让一个租户占满整个系统。

实际调度通常需要组合:

  • 租户级配额;
  • 优先级;
  • 每租户并发上限;
  • 加权公平队列;
  • 老化机制;
  • 截止时间优先;
  • 任务类型隔离。

一种简单的公平策略是为每个租户维护虚拟完成时间:

Fi=max(V,Fi,previous)+costiweightiF_i = \max(V, F_{i,previous}) + \frac{cost_i}{weight_i}

其中:

  • FiF_i:租户 ii 当前任务的虚拟完成时间;
  • VV:系统虚拟时间;
  • costicost_i:任务成本,可以是预计 Token、GPU 秒或执行时间;
  • weightiweight_i:租户权重。

调度器优先选择 FiF_i 最小的任务。这样,一个 Token 需求很大的任务会自然承担更高的调度成本,而不是只因为“先到”就长期占用资源。


六、GPU 配额:不是显卡数量,而是可调度的推理资源

1. GPU 配额与 GPU 容量的区别

GPU 配额是平台允许某个租户、项目或服务使用的上限,例如:

  • 最多使用多少张 GPU;
  • 最多创建多少个推理副本;
  • 最多使用多少 GPU 时间;
  • 最大显存或 MIG 分片;
  • 最大并行序列数;
  • 最大 batch size;
  • 最大并发模型请求数。

GPU 容量是实际能达到的服务能力,例如:

  • 每秒生成多少 Token;
  • 在给定上下文长度下支持多少并发序列;
  • P95 TTFT 是否仍满足目标;
  • 工具编排和调度开销是否可接受。

拥有 8 张 GPU,不代表可以线性支持 8 倍吞吐。原因包括:

  • 模型并行通信;
  • 显存被 KV Cache 占用;
  • 长上下文降低可容纳序列数;
  • batch 过小导致利用率低;
  • batch 过大导致 TTFT 上升;
  • CPU、网络或存储成为瓶颈;
  • 不同请求的输入输出长度分布不同。

2. GPU 推理的两个阶段

自托管 LLM 推理通常可以分成:

  • Prefill:处理输入上下文,影响 TTFT;
  • Decode:逐步生成输出,影响持续生成吞吐。

长输入任务可能主要消耗 Prefill 计算和显存;长输出任务则长期占用 Decode 槽位。

因此只看平均 tokens/s 不够。应分别测量:

TTFT=tfirst_tokentrequest_acceptedTTFT = t_{first\_token} - t_{request\_accepted}

TPOT=tlast_tokentfirst_tokenOTPOT = \frac{t_{last\_token} - t_{first\_token}}{O}

其中:

  • TTFT 是首 Token 延迟;
  • TPOT 是平均每输出 Token 的时间;
  • OO 是输出 Token 数。

一个系统可能总吞吐很高,但 TTFT 已经超过交互式请求的 SLA;也可能 TTFT 很低,但长输出任务使 GPU 长时间被占用。

3. 用 GPU 秒做成本和容量单位

对不同长度的 Agent 任务,可以估算 GPU 时间:

Grun=Gprefill(I)+Gdecode(O)+GoverheadG_{run} = G_{prefill}(I) + G_{decode}(O) + G_{overhead}

如果一张 GPU 在目标 SLA 下可提供 gg 个有效 GPU 秒/秒,则 kk 张 GPU 的理论容量近似是:

μG=kg\mu_G = k \cdot g

但有效容量还要乘以安全利用率 η\eta

μsafe=kgη\mu_{safe} = k \cdot g \cdot \eta

η\eta 不是“让 GPU 利用率保持 70%”这样的固定经验数字,而是通过压测确定的工作点。超过该点后,队列、TTFT 或 OOM 可能非线性恶化。

4. 长上下文会降低并发容量

KV Cache 大致随以下因素增长:

MKVC×L×H×BM_{KV} \propto C \times L \times H \times B

其中:

  • CC:当前并发序列数;
  • LL:上下文长度;
  • HH:与模型层数、注意力头数和数据类型相关的常数;
  • BB:每个序列的批处理或缓存开销。

这意味着将上下文长度从 8k 提升到 32k,不只是让单请求变慢,也可能显著降低同一 GPU 能容纳的并发序列数。

常见错误是按照“GPU 显存 ÷ 模型权重大小”计算容量,却忽略 KV Cache、激活、运行时缓冲区和通信缓存。


七、容量规划:先找瓶颈,再计算上限

1. 多资源容量取最小值

对于同一类 Agent 任务,系统安全吞吐量可以近似写成:

μsafe=min(μworkflow,μmodel_concurrency,μtoken,μgpu,μtool,μdb,μnetwork)\mu_{safe} = \min( \mu_{workflow}, \mu_{model\_concurrency}, \mu_{token}, \mu_{gpu}, \mu_{tool}, \mu_{db}, \mu_{network} )

每个 μ\mu 都应该是在目标 SLA 下的安全服务速率,而不是理论峰值。

如果:

  • 工作流调度器最多 20 个任务/秒;
  • 模型 Token 配额支持 12 个任务/秒;
  • GPU 在 P95 TTFT 目标下支持 10 个任务/秒;
  • 搜索工具支持 15 个任务/秒;

则系统容量是:

μsafe=min(20,12,10,15)=10\mu_{safe} = \min(20, 12, 10, 15) = 10

增加调度器线程不会提高整体容量,因为 GPU 已经是瓶颈。

2. 完整算例:多步骤 Agent

假设一个客服 Agent 的 P95 工作流特征如下:

  • 每个请求平均 3 次模型调用;
  • 总输入 Token:12,000;
  • 总输出 Token:3,000;
  • 平均工具调用:2 次;
  • 工作流 P95 耗时:18 秒;
  • 目标吞吐:每秒 5 个请求;
  • 模型项目 Token 配额:每分钟 2,000,000 Token;
  • 模型调用并发上限:40;
  • 工具服务安全吞吐:每秒 12 次;
  • 单个工作流最多允许 4 个并行工具调用。

第一步:计算 Token 速率

每个工作流需要:

Trun=12,000+3,000=15,000T_{run} = 12,000 + 3,000 = 15,000

目标吞吐为 5 请求/秒,因此需要:

RT=5×15,000=75,000 Token/sR_T = 5 \times 15,000 = 75,000 \text{ Token/s}

换算为每分钟:

75,000×60=4,500,000 Token/min75,000 \times 60 = 4,500,000 \text{ Token/min}

超过项目配额 2,000,000 Token/min,因此仅凭该模型项目无法支持每秒 5 个请求。

Token 约束下的最大吞吐是:

μT=2,000,00060×15,0002.22 请求/s\mu_T = \frac{2,000,000}{60 \times 15,000} \approx 2.22 \text{ 请求/s}

第二步:计算模型并发容量

按 P95 工作流耗时粗略估算:

Crequired=λW=5×18=90C_{required} = \lambda W = 5 \times 18 = 90

但系统只有 40 个模型调用并发槽位。若每个工作流平均有 3 次模型调用,模型调用的平均占用不能简单等同于整个工作流耗时,还需要测量模型调用实际耗时。

假设模型调用总耗时占工作流的 40%,则:

Wmodel=18×0.4=7.2 sW_{model} = 18 \times 0.4 = 7.2\text{ s}

需要的模型调用并发为:

Cmodel,required=5×7.2=36C_{model,required}=5 \times 7.2 = 36

因此模型并发上限 40 足够接近目标,但余量很小。一旦重试或长尾增加,模型并发会成为瓶颈。

第三步:计算工具容量

每请求 2 次工具调用,目标吞吐 5 请求/秒:

Rtool=5×2=10 calls/sR_{tool}=5 \times 2=10\text{ calls/s}

工具服务安全吞吐为 12 calls/s,理论上满足目标,但只有 20% 余量。

第四步:得出系统容量

μsafe=min(μT=2.22,μmodel5.56,μtool=6)=2.22 请求/s\mu_{safe} = \min( \mu_T=2.22, \mu_{model}\approx5.56, \mu_{tool}=6 ) =2.22\text{ 请求/s}

系统瓶颈是 Token 配额,而不是 GPU、模型并发或工具服务。

第五步:计算队列增长

如果实际到达速率为 5 请求/秒,服务速率只有 2.22 请求/秒,则每秒净增加:

52.22=2.78 个请求5 - 2.22 = 2.78\text{ 个请求}

10 分钟后,理论堆积约为:

2.78×600=1,668 个请求2.78 \times 600 = 1,668\text{ 个请求}

这还没有计入任务过期、用户取消和重试。如果队列不设上限,系统最终会同时出现高延迟、Token 配额耗尽和失败重试。

可行的修复方向包括:

  • 将目标吞吐降至 Token 配额允许的范围;
  • 将简单任务路由到更低成本模型;
  • 缩短历史和工具定义;
  • 减少无必要的模型步骤;
  • 对长任务改为异步;
  • 为不同租户分配独立 Token 预算;
  • 增加模型项目配额,但不能假设配额增加后 GPU 也自动足够。

八、调度器必须处理“预估成本”和“实际成本”

1. 任务准入

一个任务进入运行态前,应经过准入检查:

from dataclasses import dataclass
from time import monotonic


@dataclass
class TaskEstimate:
    workflow_slots: int
    model_calls: int
    estimated_tokens: int
    estimated_seconds: float
    priority: int
    deadline_seconds: float


@dataclass
class Capacity:
    workflow_used: int
    workflow_limit: int
    model_used: int
    model_limit: int
    token_available: int


def admit(task: TaskEstimate, cap: Capacity) -> tuple[bool, str]:
    if cap.workflow_used + task.workflow_slots > cap.workflow_limit:
        return False, "workflow_concurrency"

    if cap.model_used + task.model_calls > cap.model_limit:
        return False, "model_concurrency"

    if task.estimated_tokens > cap.token_available:
        return False, "token_rate"

    if task.estimated_seconds > task.deadline_seconds:
        return False, "deadline"

    return True, "accepted"


task = TaskEstimate(
    workflow_slots=1,
    model_calls=3,
    estimated_tokens=18_000,
    estimated_seconds=12,
    priority=10,
    deadline_seconds=30,
)

capacity = Capacity(
    workflow_used=7,
    workflow_limit=8,
    model_used=20,
    model_limit=24,
    token_available=50_000,
)

print(admit(task, capacity))
# (True, 'accepted')

这个示例只演示准入逻辑,生产实现还需要原子地扣减配额。若先检查、后扣减,中间存在并发竞争:

任务 A 检查:剩余 20,000 Token,需求 15,000,允许
任务 B 检查:仍看到剩余 20,000 Token,需求 15,000,允许
任务 A 扣减
任务 B 扣减
实际超出配额 10,000 Token

因此 Redis、数据库事务或集中式调度器需要使用原子操作,不能依赖应用进程内的普通变量。

2. 完成后的结算

预估值和实际值必须分开记录:

准入时:
  reserve_tokens = P95 预估值

执行中:
  actual_tokens = 已产生的真实 Token
  actual_steps = 已完成的步骤
  actual_tool_calls = 工具调用数

结束时:
  释放 reserve_tokens - actual_tokens
  记录超估 / 低估比例

如果一个任务预估 20,000 Token,实际只消耗 8,000 Token,则应释放未使用的预算;如果实际消耗 35,000 Token,则需要:

  • 截断后续步骤;
  • 降低输出上限;
  • 进入成本预算保护;
  • 记录估算模型失准;
  • 防止无限追加 Token。

九、过载:从入口到故障的完整路径

过载不是单一错误,而是资源耗尽沿调用链传播的过程。

flowchart LR
    A[请求突增] --> B[入口限流]
    B -->|准入| C[工作流队列]
    B -->|拒绝/降级| R[429或降级响应]
    C --> D[调度器]
    D --> E[模型并发池]
    D --> F[工具并发池]
    E --> G[Token配额]
    E --> H[GPU推理]
    F --> I[外部服务]
    G -->|不足| X[等待/降级/拒绝]
    H -->|显存或排队| Y[TTFT升高/OOM]
    I -->|超时| Z[重试]
    Z --> C
    Y --> Z

1. 过载的常见阶段

阶段一:队列增长

表现:

  • 活跃任务数接近上限;
  • 队列长度持续增长;
  • CPU 和 GPU 可能仍不高;
  • P50 延迟开始上升。

原因可能是:

  • Token 配额耗尽;
  • 外部工具变慢;
  • 下游 API 限流;
  • 工作流步骤变多;
  • 调度器没有及时释放资源。

阶段二:等待时间超过截止时间

表现:

  • 任务还没有开始执行就已经超时;
  • 用户重复提交;
  • 客户端重试;
  • 同一任务出现多个执行副本。

这说明队列没有把 deadline 纳入调度。对交互式任务而言,过期任务应在出队时丢弃,而不是继续消耗模型和工具资源。

阶段三:重试风暴

如果每个失败请求都立即重试 kk 次,到达系统的实际工作量可能从 λ\lambda 放大为:

λeffective=λ(1+p+p2++pk)\lambda_{effective} = \lambda(1 + p + p^2 + \dots + p^k)

其中 pp 是一次尝试失败后再次重试的概率。

pp 较高时,重试并非恢复手段,而是进一步增加过载。重试必须具备:

  • 指数退避;
  • 随机抖动;
  • 最大次数;
  • 只对幂等错误重试;
  • 截止时间检查;
  • 租户级重试预算;
  • 对 429、超时、5xx 分别处理。

阶段四:级联耗尽

模型变慢会导致工作流占用时间变长;工作流变长会导致并发槽位不释放;并发槽位耗尽后队列增长;队列增长后客户端重试;重试又进一步消耗模型 Token 和 GPU。

这是典型的正反馈:

WC=λWQueueWW \uparrow \Rightarrow C=\lambda W \uparrow \Rightarrow Queue \uparrow \Rightarrow W \uparrow

此时单纯“把并发上限调大”通常会使系统更快进入 GPU OOM、连接池耗尽或下游雪崩。


十、过载保护:拒绝、降级和取消必须有层次

1. 拒绝

适合以下情况:

  • 队列已满;
  • 预计等待时间超过 deadline;
  • 租户配额耗尽;
  • Token 预算不足;
  • GPU 或模型资源不可用;
  • 请求缺少幂等键,无法安全排队。

同步 API 应返回可区分的错误,例如:

{
  "error": {
    "code": "capacity_exceeded",
    "reason": "token_rate",
    "retryable": true,
    "retry_after_seconds": 8
  }
}

不要把所有资源问题都返回成普通 500。500 表示服务内部错误,而容量拒绝是系统有意识的保护行为。

2. 降级

降级不是简单地“换一个模型”。应明确降级对工作流语义的影响:

原路径 降级路径
多轮规划 单轮结构化回答
复杂检索 仅使用缓存
多工具并行 只调用关键工具
长输出 限制输出长度
高级模型 更低成本模型
实时执行 转为异步任务

降级路径也必须独立限流,否则所有请求在主路径失败后涌入备用模型,形成第二次过载。

3. 取消

取消是释放容量的重要机制。客户端断开连接并不一定自动停止后端 Agent。后端必须将取消信号传播到:

  • 工作流运行器;
  • 当前模型请求;
  • 工具调用;
  • 子 Agent;
  • 等待中的重试;
  • 队列租约。

如果只取消前端响应而不取消后端执行,用户虽然看到了超时,GPU 和工具仍然继续工作。


十一、观测:必须能把容量问题定位到步骤

平均延迟无法说明 Agent 为什么变慢。至少需要同时观察:

1. 容量指标

workflow_active
workflow_queue_depth
workflow_queue_wait_seconds
model_inflight
tool_inflight{tool}
token_reserved
token_actual
token_estimation_error
gpu_memory_used
gpu_active_sequences
gpu_prefill_utilization
gpu_decode_utilization

2. 延迟指标

request_latency
queue_wait
model_ttft
model_generation_time
tool_latency{tool}
step_latency{step_type}
retry_delay

3. 结果指标

completed
rejected
deadline_exceeded
cancelled
model_rate_limited
tool_timeout
gpu_oom
budget_exceeded
degraded

监控必须按以下维度切分:

  • 租户;
  • Agent 类型;
  • 模型;
  • 任务等级;
  • 工具;
  • 输入长度区间;
  • 输出长度区间;
  • 是否重试;
  • 是否降级;
  • 区域和实例。

4. 用 Trace 还原一次工作流

Agent 容量问题经常需要回答:

  • 哪一步占用了最长时间?
  • 是排队慢,还是模型慢?
  • Token 是哪次调用增长的?
  • 哪个工具触发了重试?
  • 是否发生 handoff?
  • 取消后是否仍有后台任务运行?

OpenAI Agents SDK 的 tracing 将一次工作流表示为 trace,并在其中记录模型生成、工具调用、handoff、guardrail 和自定义事件;span 具有开始和结束时间,并通过父子关系形成层级。(openai.github.io)

在长时间运行的 Worker 中,默认 trace 可能异步导出,因此任务结束后不一定立即出现在仪表盘;如果需要在任务边界保证当前缓冲数据已导出,可以在 trace 上下文退出后调用 flush_traces()。该调用会等待当前缓冲的 trace 和 span 导出完成,因此不应在 trace 尚未结束时调用。(openai.github.io)

一个概念性的埋点结构如下:

from agents import Runner, trace, custom_span, flush_traces

async def execute_task(agent, prompt, task_id):
    try:
        with trace(
            "customer_agent",
            metadata={"task_id": task_id},
        ):
            with custom_span(
                "capacity_admission",
                data={"estimated_tokens": 18000},
            ):
                pass  # 在这里执行原子准入和资源预留

            result = await Runner.run(agent, prompt)
            return result
    finally:
        flush_traces()

这里的 capacity_admission 不是模型调用,而是为了让“排队、准入和资源预留”出现在同一条工作流证据链中。实际使用时,应根据 SDK 版本确认自定义 span 的参数形式,并避免将用户隐私、完整提示词或敏感工具参数直接写入 trace。

官方 Agent Evals 文档建议:调试阶段先从单条 trace 观察工作流级问题,再将代表性样本转成可重复运行的数据集和评测;trace 中可以定位模型调用、工具调用、guardrail 和 handoff 等行为。(developers.openai.com)

容量变更也应纳入评测。例如,增加并行工具调用后,不只评估答案正确率,还要评估:

  • 平均工具调用数是否增加;
  • P95 Token 是否增加;
  • P95 TTFT 是否恶化;
  • 超时率是否上升;
  • 降级率是否上升;
  • 每个成功任务成本是否变化。

十二、常见误解与反例

误解一:CPU 不高,所以系统还有容量

反例:模型请求都在等待远端 API,CPU 很低,但 Token 配额已耗尽,队列持续增长。CPU 利用率只能说明 CPU 资源,不说明模型、网络或外部服务容量。

误解二:GPU 利用率 100% 就代表吞吐最高

反例:GPU 利用率达到 100% 时,长上下文请求使 TTFT 从 1 秒升至 15 秒。虽然硬件利用率更高,但交互式 SLA 已经失败。容量目标必须同时约束吞吐、TTFT、P95 延迟和错误率。

误解三:提高并发上限就能提高吞吐

反例:GPU 实际只能稳定处理 32 个序列,却把应用并发从 32 调到 128。结果是更多请求进入 GPU 等待区,TTFT 增长,KV Cache 增加,最终 OOM 或超时。并发上限应该围绕目标工作点设置,而不是追求更大的数字。

误解四:队列越长,系统越稳定

反例:用户请求的截止时间是 30 秒,队列等待已经 60 秒。此时继续排队只会浪费存储、调度和重试资源。队列必须有长度上限和时间上限。

误解五:一次用户请求只算一个 Token 预算

反例:Agent 进行 4 次模型调用,每次都携带历史、工具定义和前一步结果。用户输入 1,000 Token,不代表工作流只消耗 1,000 Token。预算应按整个运行周期累计。

误解六:429 一律重试

反例:Token 配额在当前窗口内已经用尽,所有客户端立即重试,只会增加失败请求。只有在有明确恢复时间、剩余 deadline 足够且重试不会破坏幂等性的情况下,429 才适合退避重试。


十三、生产诊断路径

遇到“Agent 变慢或大量失败”时,按资源路径排查,而不是先修改一个超时时间。

第一步:确认是排队还是执行变慢

比较:

Latencytotal=QueueWait+ExecutionLatency_{total} = QueueWait + Execution

如果 QueueWait 占比高,问题在准入、调度、配额或服务速率;如果 Execution 占比高,再继续拆分模型和工具。

第二步:确认是否是 Token 速率瓶颈

检查:

  • 实际 Token 速率;
  • 预留 Token 速率;
  • 输入 Token 是否突然增长;
  • 输出是否出现长尾;
  • 重试是否重复消耗 Token;
  • 某个租户是否占满预算。

若只有 Token 使用接近上限而 GPU 空闲,增加 GPU 没有帮助。

第三步:确认是否是模型并发或 GPU 瓶颈

检查:

  • 模型 inflight;
  • active sequences;
  • TTFT;
  • Prefill 和 Decode;
  • KV Cache;
  • 显存峰值;
  • OOM 和批处理退化。

若 GPU 已进入非线性延迟区,应降低并发或输入长度,而不是继续增加队列。

第四步:确认是否是工具或外部依赖瓶颈

按工具名称查看:

  • 调用速率;
  • P50/P95/P99 延迟;
  • 超时;
  • 限流响应;
  • 重试次数;
  • 连接池使用率;
  • 单请求并行度。

如果所有 Agent 都在等待同一个搜索工具,问题可能不在模型层。

第五步:确认过载是否被重试放大

计算:

RetryAmplification=AttemptsOriginalRequestsRetryAmplification = \frac{Attempts}{OriginalRequests}

如果这个值在故障期间快速增长,优先降低重试上限、增加退避和启用熔断,而不是增加 worker 数量。


十四、把容量策略固化为服务契约

一个可运营的 Agent 服务,应明确以下契约:

单租户最大工作流并发:N
全局最大工作流并发:M
模型调用最大并发:K
单分钟 Token 预算:Q
单任务最大步骤数:S
单任务最大工具调用数:T
队列最大长度:L
最大排队时间:W
最大运行时间:D
最大重试次数:R
超载行为:拒绝 / 降级 / 异步
取消传播:是 / 否
幂等策略:键、租约、去重窗口

这些参数之间必须相互一致。例如:

  • 最大运行时间 30 秒;
  • 最大排队时间 20 秒;
  • P95 执行时间 18 秒。

这三个配置显然不一致,因为排队和执行的总和已经超过请求总预算。容量配置不是独立开关,而是一个约束系统。

可以把单任务准入写成:

Ctenant+1Ctenant,maxCglobal+1Cglobal,maxTreserved+ETQTGreserved+EGQGWq+ESDSteps+EstepsS\begin{aligned} & C_{tenant} + 1 \le C_{tenant,max} \\ & C_{global} + 1 \le C_{global,max} \\ & T_{reserved} + E_T \le Q_T \\ & G_{reserved} + E_G \le Q_G \\ & W_q + E_S \le D \\ & Steps + E_{steps} \le S \end{aligned}

只有所有条件同时成立,任务才进入运行态。否则,调度器必须明确选择拒绝、等待、降级或异步化,而不是无条件启动。


Agent 的稳定容量不是某个单一数字,而是一个由工作流结构、Token 速率、并发槽位、队列等待、GPU 工作点、工具依赖和截止时间共同决定的可行区域。

当到达速率低于最小瓶颈服务速率时,队列可以吸收有限突发;当到达速率超过该速率时,系统必须通过拒绝、降级、取消或扩容改变负载。真正可靠的限流系统,不是让更多请求“进入”,而是在资源耗尽之前,准确判断哪些任务值得执行、哪些任务应等待、哪些任务已经不应再消耗系统容量。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。