厂商不给 rate limit 文档, 我把它逼了出来: 阿里云 Token Plan 的三堵墙; 对跑多 agent 很重要
-
背景
前两天 qwen3.8-max 发布,想试试, 顺带发现阿里的 Token Plan 里正好也有我长期在用的
deepseek-v4-flash, 就去翻了三档套餐(Lite / Standard / Pro)的额度和参数。结果卡在一个含糊的地方: 并发那一栏只写了「支持 Agent 并发 Lite 1–2、Standard 3–4、Pro 6–8」,却没说这个数字到底是什么意思。是 RPS?是同时在飞的请求数?还是别的什么口径? 搜了一圈, 没有一个靠谱的答案。
而并发恰恰是日常折腾里(尤其多agent时)最先撞上的那堵墙 —— 额度是慢慢烧掉的, 并发是当场就拒的。所以在买大包之前得先把它弄明白:先买一个 Lite 回来,把它测穿。 测完一想,反正都干了,不如让 AI 顺手总结一篇发出来, 给大伙乐呵乐呵。
结论是三个数字,和一条比数字更重要的发现:同一个 HTTP 429,两种完全不同的报错,指向两堵完全不同的墙 —— 其中一条会把你骗去查额度面板,而额度根本没事。
我注册不了国服,测的是新加坡(International)的 Lite 档; 国服的限制应该一致(而且还便宜一点点),但我没有验证过。
题外话:另外一点有意义的是, 这次测试是
Opus 5做 plan、deepseek-v4-flash做 worker 配合完成的, 目测工作量三七开 —— 结果我完全满意。deepseek-v4-flash-0731出来之后,我的主观感受是:个别场景能追平 Opus 5,日常足以平替 Sonnet 5,对 Haiku 4.5 则是完爆。所以最近我一直在日常使用里刻意观察它真正干活时的表现,而不是看榜单。现实里还有另一条线。很多公司出于合规不允许用国产模型;但最近陆续听身边朋友说,他们公司「烧不起 Claude 了」,开始限额,有的甚至在逐步放开对国产模型的限制 —— 理由很直接:省 token 开销。
我自己的分工是:正经干活还是只用 Claude,日常折腾和自己的东西(尤其是常驻的 agent)早就大部分切到 v4-flash 了。 而现在的感觉是,「正经活」也可以分一部分出去了。
TL;DR
测试对象:阿里云 Token Plan 个人版 Lite 档 1-2 Agent 并发, OpenAI 兼容端点, 模型
deepseek-v4-flash-0731。限制 实测阈值 报错 并发数(同时在飞的请求) 12 个 429·API-Key Requests rate limit exceeded·type: limit_requests请求速率(QPS) 没测到上限 — token 桶容量 约 1,219,000 token 429·Allocated quota exceeded, please increase your quota limit5h / 7d credits 预算 Lite:700 / 5h · 2500 / 7 天 与上一行 429相同(官方 FAQ)
️ 第三行是「桶容量」,不是 TPM。 它的定义是排空后一口气能推进去的 token 总量 —— 存量上限,不是速率。实测撞墙发生在 32 秒内,所以它回答的是「一口气能推多少」,不是「每分钟能推多少」。稳态速率这套测试没测(见 §10 末尾和 §13)。其他两档 (Standard, Pro) 可以类推. 知道了并发限制后, 具体 token 需求多少 大家根据自己的使用情况决定什么档位合适. 粗糙估算, 套餐如果合理打满, 用量应该是 单买token 的 3-6x ( 具体情况要看自己的使用场景, in, out, cache hit 的比例等等)
三个反直觉的点:
- 官方标称 Lite「支持 1–2 个并发 agent」,实测 HTTP 层能同时跑 12 个。
1-2那个数字不是闸。 **Allocated quota exceeded不等于额度用光。** 触发它的那一刻,控制台显示 5 小时窗口只用了 27.37%。- 它也不是「关门一分钟」。 被拦之后 1 秒内发一个小请求, 直接 200。
1. 为什么要测
包月 LLM 套餐的宣传页通常只给两类数字:每月多少额度、支持几个「并发 agent」。前者是账单口径,后者是营销口径 —— 两者都不是你写代码时需要的那个数。
我要知道的是:同时发多少个请求会被拒?一分钟能推多少 token?被拒之后多久恢复?这些数字决定了并发编排怎么写、重试策略怎么定、以及最终那个问题 —— 要不要升档。
官方文档没有。响应头里也没有(见 §4)。那就只能撞。
2.
️ 先说一个错误做法最自然的想法是:开 N 个 agent 会话,看第几个报错。
这个探针是坏的,四个理由:
问题 后果 agent 会话要加载 system prompt + tool definitions + 历史,单次输入 40–80k token 撞的可能是 token 桶容量,不是并发数上限 —— 但报错长得一样 本地同时起 N 个 agent 进程 VM客户端 CPU 先饱和,你测的瓶颈是自己的机器 (我的 hermes agents 都是跑在 PVE VM上的) 几乎所有 SDK 和 agent 框架自带重试 + 退避 429 被吞掉重试掉,「第几个失败」的观测直接失真 每次会话有真实工具调用和长输出 慢、贵、不可重复 一条报错对应多个真因时,先用一个绕开被测系统的最小实验把变量砍掉。 所以全程用裸 HTTP 请求当探针,agent 框架完全不参与。
3. 实验设计
四个互相独立的实验,分别对准一堵墙:
实验 怎么做 判据 并发梯度 同时发 N 个,N 逐级上升 成功数卡在固定值 → 在飞数上限 串行连发 一个接一个,全程只有 1 个在飞 这里也失败 → 撞的是速率不是并发 满并发多波 打满并发,波次之间不停歇 中途开始挂 → 存在每分钟请求数窗口 大 payload 满载 每请求几万 token,数到被拦为止 累计 token 数 = 桶容量 三个设计细节,每一个都直接决定数据是否可信:
① 发射抖动必须是毫秒级(0–300ms)。
单请求耗时以秒计,所以 0–300ms 的抖动下 N 个请求仍然全部同时在飞,并发梯度成立;同时避开「N 个 TCP 连接在同一毫秒握手」这种人造的 thundering herd。窗口一旦开到秒级,请求就不重叠了,测出来的会变成 QPS 而不是并发。②
max_tokens不能设太小。
设成 16 的话请求几百毫秒就结束,N 个根本重叠不上,并发上限永远测不出来。要让单请求持续数秒,槽位才被真实占住。这里用 600,配合推理模型实测每个请求 4–6 秒。③ prompt 必须每个都不同,而且 nonce 要放在最前面。
用 16 条互不相同的真实技术问题,而不是say OK:PROMPTS = [ "Proxmox 里 qm set --onboot 1 和 cloud-init 的 runcmd 谁先执行?顺序为什么重要?", "systemd user service 在用户没登录时靠什么保持运行?loginctl enable-linger 改了什么?", "OpenAI 兼容 API 里 reasoning_effort 和 extra_body.thinking 语义上有什么区别?", "LLM 推理里 prefix cache 的命中条件是什么?为什么 JSON key 顺序变了就 miss?", # … 共 16 条 ]理由是隐式缓存:阿里对
messages做前缀匹配,命中的 token 单价只有输入的 20%,而且响应快得多。内容相同的请求会互相命中缓存,延迟和 token 计数全部失真。 我在 §8 真的踩了这个坑。发射函数长这样:
def one(cfg, rung, idx, prompt, max_tokens, jitter_ms, t0): time.sleep(random.uniform(0, jitter_ms) / 1000.0) # 毫秒级抖动 launch = (time.time() - t0) * 1000 # 记下实际发射偏移 body = json.dumps({"model": cfg.model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}).encode() ...发射偏移要记进结果,事后才能验证「这 N 个请求确实重叠过」,而不是靠假设。
4. 阶段 0:先找 header,别急着撞
最便宜的一步 —— 打一发,把完整响应头 dump 出来。有
x-ratelimit-limit-*的话,整套压测都可以省掉。$ python3 probe1.py http 200 latency_ms 3754 --- headers --- content-type: application/json; charset=utf-8 grpc-encoding: identity grpc-accept-encoding: gzip x-envoy-upstream-service-time: 2784 date: Tue, 04 Aug 2026 18:01:41 GMT server: istio-envoy x-request-id: d95a4a3e-b197-4e55-9caf-59e49d86034f req-cost-time: 2784 req-arrive-time: 1785866499350 resp-start-time: 1785866502134 vary: Accept-Encoding connection: close transfer-encoding: chunked --- usage --- {"prompt_tokens": 33, "total_tokens": 334, "completion_tokens": 301, "prompt_tokens_details": {"cached_tokens": 0}, "completion_tokens_details": {"reasoning_tokens": 300}}一个
x-ratelimit-*都没有,retry-after也没有。 只能靠撞。顺带一个观察:
max_tokens=300全被 reasoning 吃光了(reasoning_tokens: 300),content是空的。给推理模型设max_tokens时要把思考预算算进去。5. 阶段 1:并发梯度
N = 2 → 4 → 6 → 8 → 12 → 16,每梯之间静置 10 秒。
$ python3 conc.py 1 === 阶段 1:并发梯度(max_tokens=600, jitter 0-300ms) === [P1-n2] n=2 ok=2 fail=0 wall=5898ms lat_min=5483 lat_max=5656 [P1-n4] n=4 ok=4 fail=0 wall=5567ms lat_min=4808 lat_max=5547 [P1-n6] n=6 ok=6 fail=0 wall=6008ms lat_min=4924 lat_max=5875 [P1-n8] n=8 ok=8 fail=0 wall=6087ms lat_min=5036 lat_max=6000 [P1-n12] n=12 ok=12 fail=0 wall=6237ms lat_min=4228 lat_max=5962 [P1-n16] n=16 ok=12 fail=4 wall=6223ms lat_min=4835 lat_max=6210 ✗ idx=6 http=429 retry_after='' err={"error":{"message":"API-Key Requests rate limit exceeded, please try again later.","type":"limit_requests"... ✗ idx=9 http=429 ... ✗ idx=11 http=429 ... ✗ idx=13 http=429 ...注意
wall一列:每梯的总耗时都在 6 秒左右,和单请求延迟(5–6 秒)基本相等 —— 证明这 N 个请求确实是并行的,不是排队跑完的。N=12 全过,N=16 挂了 4 个。边界在 12 和 16 之间。
6. 阶段 1b:定位边界 + 确认可复现
一次失败可能只是服务端抖动,所以把 13/14/15/16 逐个测,16 测两遍,最后加一个 20。
[P1b-n13] n=13 ok=12 fail=1 wall=6655ms lat_min=5097 lat_max=6437 [P1b-n14] n=14 ok=12 fail=2 wall=6072ms lat_min=4726 lat_max=5884 [P1b-n15] n=15 ok=12 fail=3 wall=5858ms lat_min=4856 lat_max=5766 [P1b-n16] n=16 ok=12 fail=4 wall=7392ms lat_min=4707 lat_max=7227 [P1b-n16] n=16 ok=12 fail=4 wall=6213ms lat_min=4717 lat_max=5958 [P1b-n20] n=20 ok=12 fail=8 wall=6018ms lat_min=4743 lat_max=5885(每行下面还有对应数量的 429 明细,文案完全一致,这里省略。)
六次独立测量,
ok全部恰好等于 12。 不管发 13 个还是 20 个,永远放行 12 个,多出来的当场拒绝 —— 没有排队,没有等待,立即失败。这是全文最干净的一个数字。
7. 阶段 2:排除 QPS
并发数卡在 12,但这到底是「同时在飞的数量」还是「单位时间的请求数」?两者现象一样。
实验:串行连发 20 个,全程只有 1 个在飞。
$ python3 conc.py 2 === 阶段 2:串行 QPS(20 连发, 间隔 50-200ms, 全程 1 个在飞) === #00 http=200 lat=1473ms #01 http=200 lat=1714ms #02 http=200 lat=1773ms ... #18 http=200 lat=1852ms #19 http=200 lat=1703ms [P2] ok=20/20 wall=36410ms20/20 全过。但串行速率只有 0.55 req/s,不够快,不足以排除「每分钟请求数」这种窗口限制。
再来一个:5 波 × 12 并发,波次之间完全不停。
[P2b-wave0] n=12 ok=12 fail=0 wall=4482ms lat_min=2925 lat_max=4348 [P2b-wave1] n=12 ok=12 fail=0 wall=5125ms lat_min=2500 lat_max=5096 [P2b-wave2] n=12 ok=12 fail=0 wall=4053ms lat_min=2801 lat_max=3830 [P2b-wave3] n=12 ok=12 fail=0 wall=4553ms lat_min=2567 lat_max=4345 [P2b-wave4] n=12 ok=12 fail=0 wall=3946ms lat_min=2795 lat_max=3646 总计 60 个请求 / 22s,ok=6060/60 全过,持续 2.7 req/s 无一失败。
结论:限的是同时在飞的数量,不是每秒发多少个。 只要不超过 12 个并发,请求速率随便打。
8. 阶段 3:大 payload 撞出第二种 429 —— 以及一次真实的数据污染
小请求测出来的边界不能直接套到生产上:真实 agent 的单请求是几万 token,比探针大三个数量级。很可能出现「16 个小请求都没事,2 个真实请求就撞墙」。
于是把 prompt padding 到约 25k token,再跑一遍梯度:
padding 字符数: 47628 [P3-n2] n=2 ok=2 fail=0 wall=2883ms lat_min=2706 lat_max=2763 [P3-n4] n=4 ok=4 fail=0 wall=3228ms lat_min=2628 lat_max=3142 [P3-n8] n=8 ok=8 fail=0 wall=4165ms lat_min=2401 lat_max=3964 [P3-n12] n=12 ok=12 fail=0 wall=7450ms lat_min=1783 lat_max=7285 单请求 prompt_tokens: 25661 | 缓存命中: {0, 25600}
️ 最后一行暴露了一个错误。cached_tokens出现了 25600 —— 说明有请求命中了隐式缓存。原因是我的 nonce 每梯只生成一次,同一梯内 12 个请求共用同一段 padding,前缀完全相同,从第二个请求开始就命中缓存了。这批数据作废:命中缓存的请求走的是不同的计费路径和不同的延迟,拿它们衡量吞吐是错的。
修法:nonce 必须每请求独立生成,而且放在 prompt 的最前面 —— 阿里的前缀匹配从 token 0 开始算,nonce 放中间是没用的。
prompt = f"[{uuid.uuid4().hex}] {pad}{PROMPTS[i % len(PROMPTS)]}" # ^^^^^^^^^^^^^^^^^^^^ 每请求独立,且必须在最前面9. 阶段 3b:重跑,撞到完全不同的一堵墙
[P3b-n12] n=12 ok=12 fail=0 wall=4485ms 输入token合计=375000 缓存命中={0} [P3b-n16] n=16 ok=15 fail=1 wall=9101ms 输入token合计=468728 缓存命中={0} ✗ idx=1 http=429 {"error":{"message":"API-Key Requests rate limit exceeded, ... [P3b-sustain0] n=12 ok=12 fail=0 wall=8425ms 输入token合计=374998 缓存命中={0} [P3b-sustain1] n=12 ok=0 fail=12 wall=1645ms 输入token合计=0 缓存命中=set() ✗ idx=0 http=429 {"error":{"message":"Allocated quota exceeded, please increase your quota limit. For details, see: https://www.alibabacl... ✗ idx=1 http=429 {"error":{"message":"Allocated quota exceeded, ... ✗ idx=2 http=429 {"error":{"message":"Allocated quota exceeded, ... ... (12 个全挂,文案一致) [P3b-sustain2] n=12 ok=0 fail=12 wall=1090ms 输入token合计=0 ... (同样 12 个全挂)两件事同时发生:
①
n=16这次过了 15 个,不是 12。
这不矛盾,反而是佐证:payload 大了三个数量级,上传耗时被拉长,请求的到达时间被抹开了 —— 同一瞬间在飞的从来没超过 12 个。这说明限制器数的是「此刻在飞的请求数」,不是「你一批发了几个」。② 出现了一个文案完全不同的 429。
"Allocated quota exceeded, please increase your quota limit."它和前面那个
API-Key Requests rate limit exceeded完全不是一回事。而且这句话极具误导性 —— 它看起来就是在说「你的额度用完了,去买更多」。10. 推翻自己的假设:它不是额度,也不是固定窗口
第一反应是「5 小时窗口的 credits 打空了」。去控制台一看:
5-hour Quota Will reset at 2026-08-05 08:46:00 27.37% Used只用了 27.37%。 额度好好的。
那它是不是「触发后关门 N 秒」的固定窗口?测一下 —— 被拦之后每 10 秒探一个小请求:
t+ 1s → OK → 恢复用时约 1s1 秒就恢复了。
所以它既不是额度耗尽,也不是固定时间窗口,而是按瞬时 token 体量放行的限流器:大批量推不进去时拒绝,小请求随时能过。
为了量出这个桶有多大,静置 90 秒清空计数,然后满载连发,数到被拦为止:
静置 90s 让速率窗口清空… wave0: ok=12 速率429=0 额度429=0 本波token=304,801 累计耗时=8s ← 累计成功 token=304,801 wave1: ok=12 速率429=0 额度429=0 本波token=304,723 累计耗时=16s ← 累计成功 token=609,524 wave2: ok=12 速率429=0 额度429=0 本波token=304,738 累计耗时=24s ← 累计成功 token=914,262 wave3: ok=12 速率429=0 额度429=0 本波token=304,809 累计耗时=31s ← 累计成功 token=1,219,071 wave4: ok=0 速率429=0 额度429=12 本波token=0 累计耗时=32s → 第 4 波触发额度型 429,此前累计 1,219,071 token / 32s1,219,071 token。 而 §9 那次意外触发时的累计量是 375k + 469k + 375k = 1,219,000 出头 —— 两次独立测量落在同一个数上。这是个硬桶。
最后验一下正常节奏下会不会碰到它 —— 每 20 秒一波满载:
wave0 @t+ 7s: ok=12/12 额度429=0 token=127,188 wave1 @t+22s: ok=12/12 额度429=0 token=127,179 wave2 @t+43s: ok=12/12 额度429=0 token=127,149约 530k token/min 持续无失败。(这一波的 padding 比校准时短,所以只推到 530k/min,没有逼近上限,不能当作稳态吞吐的上界 —— 这一项测得不彻底。)
️ 更要紧的是:这组根本不能当稳态速率的证据。 它只跑了 43 秒、累计 381k —— 全程在吃桶里的存量,压根没触到回填底。要测稳态只有一个办法:固定供给速率跑够长(排空后以 250k/min 持续 5 分钟,再试 400k、600k,第一个撑不住的就是上限)。本文没测。
11. 结论
三堵墙 (针对 Lite 档, 标明 1-2 Agent 并发)
墙 阈值 报错 什么时候会撞上 并发数 12 个在飞请求 API-Key Requests rate limit exceeded多 worker 编排 请求速率 没测到上限 — 基本撞不上 token 桶容量 ~1.22M token Allocated quota exceeded超大批量;正常对话碰不到 credits 预算 按档位 文案与上一行相同 真的用完时
️ 两个 429 的辨析(全文最该记住的一条)"API-Key Requests rate limit exceeded" → 并发数满了,降低同时在飞的请求数 "Allocated quota exceeded, please …" → 突发量超过桶容量,额度可能好好的排查口径:看到
Allocated quota exceeded,先看控制台的额度窗口用了百分之多少。 没用完就是突发量的问题,把批量拆小即可 —— 别去升档,升档买的是额度,解决不了桶。关于「并发 N 个 agent」这类宣传数字
官方标称 Lite 支持 1–2 个并发 agent,实测 HTTP 层能同时跑 12 个。那个数字是按 agent 工具的典型行为估的,不是硬闸。 如果你的升档理由是「并发不够」,先测一下 —— 大概率不是并发的问题,而是额度的问题,而这两件事的解法不一样。
12. 可迁移的方法论
换任何一家 OpenAI 兼容后端之前,这五条都成立:
- 先 dump 响应头。 有
x-ratelimit-*就直接读数,整套压测都省了。 - 别用你的 agent 框架当探针。 它的重试会吞掉 429,它的上下文体积会让你撞错墙。用裸 HTTP。
- 三堵墙分开测。 并发用同时发,QPS 用串行发,token 桶容量用大 payload 发 —— 看到 429 就下结论,一定会错。
- 每请求独立 nonce,放在最前面。 否则隐式缓存会污染你的延迟和 token 数据。
- 发射抖动毫秒级,
max_tokens别太小。 前者防握手风暴,后者保证请求真的重叠。
13. 局限
- 单次快照。 共享容量会随时段浮动,换个时间跑结果可能不同。
- 只测了非流式。 生产上多数 agent 走
stream=true,流式请求占用并发槽位的方式可能不同,本文结论不能直接外推。
️ 稳态吞吐没测。 本文量到的是桶容量(存量),不是速率。§10 最后那组只跑了 43s / 381k,全程在吃桶里的存量,不能当稳态吞吐的上界。要测得固定供给速率跑够长。- 只测了 Lite 一档、一个模型。 更高档位是否同样是 12 并发,未验证。
- 另外:压测前先看一眼你所在档位的服务条款,不同套餐对调用方式的约定不一样。
附:完整探针脚本
只用 Python 标准库,不需要
pip install。key 只从环境变量或 600 权限的文件读,不接受命令行参数 —— 否则会留在 shell history 和进程列表里。#!/usr/bin/env python3 """OpenAI 兼容 endpoint 的并发 / 限流探针。 ladder 并发梯度 → 在飞数上限 serial 串行连发 → 是不是 QPS 限制 sustain 满并发多波 → 有没有每分钟请求数窗口 bulk 大 payload 满载 → token 桶容量(存量上限,不是 TPM) recover 被拦后每 10s 探一次 → 恢复时间 用法: export PROBE_KEY=sk-... ./probe.py ladder ./probe.py ladder --base-url https://api.deepseek.com/v1 --model deepseek-v4-flash """ from __future__ import annotations import argparse, csv, json, os, random, sys, time, uuid import urllib.error, urllib.request from concurrent.futures import ThreadPoolExecutor DEFAULT_BASE_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" DEFAULT_MODEL = "deepseek-v4-flash-0731" # 各不相同且真的想知道答案的题 —— 不用 "say OK"。 # 内容不同 = 天然绕开隐式缓存。 PROMPTS = [ "Proxmox 里 qm set --onboot 1 和 cloud-init 的 runcmd 谁先执行?顺序为什么重要?", "systemd user service 在用户没登录时靠什么保持运行?loginctl enable-linger 改了什么?", "Ubuntu 24.04 非交互 SSH 为什么读不到 .bashrc 里的 PATH?最小修复是什么?", "OpenAI 兼容 API 里 reasoning_effort 和 extra_body.thinking 语义上有什么区别?", "LXC 和 VM 在容器逃逸场景下的爆炸半径差异,一句话说清楚。", "ZFS 的 ARC 和 Linux page cache 会重复缓存同一份数据吗?为什么?", "为什么 pgrep -f 写在 while 循环里会匹配到轮询进程自身?怎么避免?", "LLM 推理里 prefix cache 的命中条件是什么?为什么 JSON key 顺序变了就 miss?", "Docker bridge 网络和 macvlan 在容器直连局域网时的取舍是什么?", "PVE 的 thin pool 超配到 100% 会发生什么?怎么提前发现?", "为什么 NVMe 的 SMART 里 Percentage Used 超过 100% 不等于盘坏了?", "systemd 的 Restart=always 和 RestartSec 在服务快速崩溃循环时会怎样?", "SSH 的 ControlMaster 复用连接时,一个连接挂了会影响其他会话吗?", "MoE 模型的 active parameters 和 total parameters 分别决定了什么成本?", "为什么 rsync 的 --delete 在源目录挂载失败时特别危险?怎么防?", "HTTP/2 的多路复用会让服务端的并发限制以什么维度计算?", ] # bulk 阶段的填充文本,任意内容都行,重复到需要的长度。实测重复 400 遍 ≈ 25.6k token。 PAD_UNIT = ("虚拟化节点上运行若干虚拟机与容器,存储为 thin pool,网卡桥接到内网网段," "存储池由多块 NVMe 组成 raidz1,监控采集间隔 30 秒,日志保留 14 天。") FIELDS = ["rung", "idx", "launch_ms", "http", "latency_ms", "prompt_tokens", "completion_tokens", "reasoning_tokens", "cached_tokens", "retry_after", "err"] def classify(row: dict) -> str: """把 429 分成两类 —— 文案完全不同,含义完全不同。""" if row["http"] == 200: return "ok" e = row["err"] if "Requests rate limit" in e: return "并发/请求数" if "Allocated quota" in e: return "token桶" if row["http"] == 429: return "429其他" return f"http{row['http']}" def one(cfg, rung, idx, prompt, max_tokens, jitter_ms, t0): """发一个请求。抖动窗口保持毫秒级 —— 单请求耗时以秒计,N 个仍然全部同时在飞。""" time.sleep(random.uniform(0, jitter_ms) / 1000.0) launch = (time.time() - t0) * 1000 body = json.dumps({"model": cfg.model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}).encode() req = urllib.request.Request( cfg.base_url.rstrip("/") + "/chat/completions", data=body, headers={"Content-Type": "application/json", "Authorization": "Bearer " + cfg.key}) t = time.time() code, err, usage, retry_after = 0, "", {}, "" try: r = urllib.request.urlopen(req, timeout=cfg.timeout) code, retry_after = r.status, r.headers.get("retry-after", "") usage = json.loads(r.read()).get("usage") or {} except urllib.error.HTTPError as e: code, retry_after = e.code, e.headers.get("retry-after", "") err = e.read()[:200].decode("utf-8", "replace").replace("\n", " ") except Exception as e: # 连接层失败也要记,别和配额混为一谈 code, err = -1, f"{type(e).__name__}: {e}"[:200] pd = usage.get("prompt_tokens_details") or {} cd = usage.get("completion_tokens_details") or {} return dict(rung=rung, idx=idx, launch_ms=int(launch), http=code, latency_ms=int((time.time() - t) * 1000), prompt_tokens=usage.get("prompt_tokens", 0), completion_tokens=usage.get("completion_tokens", 0), reasoning_tokens=cd.get("reasoning_tokens", 0), cached_tokens=pd.get("cached_tokens", 0), retry_after=retry_after, err=err) def burst(cfg, label, n, max_tokens, pad_repeat=0): """同时发 n 个。⚠️ nonce 必须每请求独立 —— 每梯只生成一次的话,同梯请求共享前缀 会命中隐式缓存,token 数和延迟全部失真。""" pad = (PAD_UNIT * pad_repeat + "\n\n问题:") if pad_repeat else "" t0 = time.time() with ThreadPoolExecutor(max_workers=n) as ex: futs = [ex.submit(one, cfg, label, i, f"[{uuid.uuid4().hex}] {pad}{PROMPTS[i % len(PROMPTS)]}", max_tokens, cfg.jitter, t0) for i in range(n)] rows = [f.result() for f in futs] rows.sort(key=lambda r: r["idx"]) ok = [r for r in rows if r["http"] == 200] tok = sum(r["prompt_tokens"] + r["completion_tokens"] for r in ok) cached = {r["cached_tokens"] for r in ok} lat = [r["latency_ms"] for r in ok] print(f"[{label}] n={n} ok={len(ok)} fail={n - len(ok)} " f"wall={int((time.time() - t0) * 1000)}ms token={tok:,} " f"lat={min(lat) if lat else '-'}~{max(lat) if lat else '-'}ms" + (f" ⚠️缓存命中={cached}" if cached - {0} else "")) for r in rows: if r["http"] != 200: print(f" ✗ idx={r['idx']} http={r['http']} [{classify(r)}] {r['err'][:110]}") return rows def ph_ladder(cfg): """并发梯度。首次失败不停,再爬两梯看形状。""" rows = [] for n in [2, 4, 6, 8, 12, 13, 14, 16, 20]: rows += burst(cfg, f"ladder-n{n}", n, cfg.max_tokens) time.sleep(cfg.gap) return rows def ph_serial(cfg): """串行连发:全程只有 1 个在飞。这里也失败 = 撞的是速率,不是并发。""" rows, t0 = [], time.time() for i in range(20): r = one(cfg, "serial", i, f"[{uuid.uuid4().hex}] {PROMPTS[i % len(PROMPTS)]}", 120, 0, t0) print(f" #{i:02d} http={r['http']} lat={r['latency_ms']}ms [{classify(r)}]") rows.append(r) time.sleep(random.uniform(0.05, 0.2)) print(f"[serial] ok={sum(1 for r in rows if r['http'] == 200)}/20 " f"wall={int((time.time() - t0) * 1000)}ms") return rows def ph_sustain(cfg): """满并发多波不停歇:验有没有「每分钟请求数」这类窗口限制。""" rows, t0 = [], time.time() for w in range(5): rows += burst(cfg, f"sustain-w{w}", cfg.concurrency, 300) ok = sum(1 for r in rows if r["http"] == 200) print(f"[sustain] {ok}/{len(rows)} 过 / {int(time.time() - t0)}s") return rows def ph_bulk(cfg): """大 payload 满载连发,数到被拦为止 → token 桶容量。 ⚠️ 量到的是**桶容量**(排空后一口气能推进去的总量),**不是 TPM** —— 实测撞墙 发生在 32 秒内。稳态速率要用「固定供给速率跑够长」另测,本阶段测不出。 先静置,让上一次测试的计数清空,否则测的是残留。""" print(f"静置 {cfg.settle}s 让速率窗口清空…") time.sleep(cfg.settle) rows, total, t0 = [], 0, time.time() for i in range(cfg.waves): w = burst(cfg, f"bulk-w{i}", cfg.concurrency, 150, pad_repeat=cfg.pad) rows += w ok = [r for r in w if r["http"] == 200] total += sum(r["prompt_tokens"] + r["completion_tokens"] for r in ok) print(f" ← 累计成功 token={total:,} @t+{int(time.time() - t0)}s") if any(classify(r) == "token桶" for r in w): print(f"→ 第 {i} 波触发 token 桶容量上限,此前累计 {total:,} token / {int(time.time() - t0)}s") break return rows def ph_recover(cfg): """被拦之后每 10s 探一个小请求,量恢复时间。 ⚠️ 小请求可能立刻就过 —— 那说明限制器按瞬时体量放行,不是「关门 N 秒」。""" rows, t0 = [], time.time() for i in range(30): r = one(cfg, "recover", i, f"[{uuid.uuid4().hex}] {PROMPTS[i % len(PROMPTS)]}", 40, 0, t0) rows.append(r) print(f" t+{int(time.time() - t0):3d}s → [{classify(r)}] {r['err'][:80]}") if r["http"] == 200: print(f"→ 恢复用时约 {int(time.time() - t0)}s") break time.sleep(10) return rows PHASES = {"ladder": ph_ladder, "serial": ph_serial, "sustain": ph_sustain, "bulk": ph_bulk, "recover": ph_recover} def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("phase", choices=sorted(PHASES)) ap.add_argument("--base-url", default=os.environ.get("PROBE_BASE_URL", DEFAULT_BASE_URL)) ap.add_argument("--model", default=os.environ.get("PROBE_MODEL", DEFAULT_MODEL)) ap.add_argument("--key-file", help="装 API key 的文件(权限 600)。不给就读 $PROBE_KEY") ap.add_argument("--concurrency", type=int, default=12) ap.add_argument("--max-tokens", type=int, default=600, help="别设太小 —— 请求太快结束就重叠不上,并发上限永远测不出来") ap.add_argument("--pad", type=int, default=400, help="bulk 填充重复次数,400 ≈ 25.6k token") ap.add_argument("--waves", type=int, default=8) ap.add_argument("--jitter", type=int, default=300, help="发射抖动窗口(ms)") ap.add_argument("--gap", type=int, default=10, help="ladder 梯间静置秒数") ap.add_argument("--settle", type=int, default=90, help="bulk 开跑前静置秒数") ap.add_argument("--timeout", type=int, default=300) ap.add_argument("--out") cfg = ap.parse_args() cfg.key = (open(cfg.key_file).read().strip() if cfg.key_file else os.environ.get("PROBE_KEY", "").strip()) if not cfg.key: sys.exit("没有 key:设 $PROBE_KEY 或给 --key-file。" "别把 key 写进命令行 —— 会留在 shell history 和进程列表里。") print(f"endpoint={cfg.base_url}\nmodel={cfg.model}\nphase={cfg.phase}\n") rows = PHASES[cfg.phase](cfg) out = cfg.out or f"probe-{cfg.phase}-{time.strftime('%Y%m%d-%H%M%S')}.csv" with open(out, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=FIELDS) w.writeheader() w.writerows(rows) ok = [r for r in rows if r["http"] == 200] print(f"\n合计 {len(rows)} 个请求 · 成功 {len(ok)} · " f"input={sum(r['prompt_tokens'] for r in ok):,} " f"output={sum(r['completion_tokens'] for r in ok):,}\ncsv → {out}") if __name__ == "__main__": main()
全部测试合计 312 个记录在案的请求 + 若干满载波次,约 350 万 token。测试时间 2026-08-05。
- 官方标称 Lite「支持 1–2 个并发 agent」,实测 HTTP 层能同时跑 12 个。
-
这个测试做得太扎实了,尤其是把两种 429 拆开那部分——"Allocated quota exceeded" 骗人去查额度面板这个坑,绝大多数人都会踩,你这一下省了大家半天排查。
补一个你明确标注没测的维度:流式。生产上 agent 基本都走 stream=true,我自己就是在 PVE 上跑 Hermes(跟你一样的玩法),gateway 默认就是 SSE 流式。流式请求的槽位占用跟非流式很可能不一样:连接挂得久,但服务端可以按 token 粒度调度。所以"12 并发"这个数对真实 agent 负载未必成立,更可能的现实是:多 agent 同时跑时,先撞上的不是 12 并发,而是你那堵 1.22M token 的速率墙——agent 请求动辄几十 K token 输入,几轮工具调用就把桶打满了。这也反过来印证你的结论:升档买的是额度,解决不了速率。
另外实操上,两种 429 应该配不同的重试策略:Requests rate limit(并发满)适合指数退避 + 短窗口重试;Allocated quota(token 速率)退了也没用,正确做法是立刻拆小批次或降并发,而不是傻等。很多 SDK 把这两种 429 混在一起重试,你这两堵墙的辨析正好拿来当重试分流依据。
-
,
T terry 固定了此主题
-
补充 :升一档套餐(Standard $18)之后的复测(2026-08-07)
上面正文测的是 Lite 档。Lite 几下就烧光了一周的(阿里现在临时去掉了5小时额度), 后来升到了 Standard($18),把同一套探针原样又跑了一遍,约 900 个请求 / 200 多万 token。
一句话:并发涨了 3 倍多,token 桶容量("速率") 一动没动 —— 而后者是决定「能不能并行开大上下文」的那堵墙. 如果你的使用场景涉及到这个点, 也很重要, 升级套餐档位无效.
两档对照
墙 Lite Standard($18) 变化 并发数(同时在飞) 12(硬闸) ~40(有毛边, 到过48, 60) ≈3.3× token 桶容量 1,219,071 1,259,676 基本没变(测试多次) 请求速率(QPS) 没测到上限 未复测 — 两种 429 的文案和 Lite 完全一致(
API-Key Requests rate limit exceeded/Allocated quota exceeded),响应头里依然一个x-ratelimit-*都没有,retry-after也没有。官方标称的「并发 agent」数(Lite 1–2 / Standard 3–4)两档都不是 HTTP 层的闸,实测都高出一个数量级。正文那条结论继续成立。
桶容量:排空后一口气能推进去的 token 总量,实测约 1.22M(Lite/Standard 一致)——它是存量上限,不是速率,超了报 Allocated quota exceeded。(对应的回填速率约 340k/min,只有单点估算,
未确证) -
@SuperBat 这个复测数据把结论钉死了:升档买的是并发,不是"速率"——Lite 12 并发 → Standard ~40 并发,但 token 桶 1.219M → 1.260M 基本原地踏步。这正好验证了上条回复里那个猜测:多 agent 并行大上下文时,先撞上的确实是桶,不是并发闸。而且两档 429 文案、响应头完全一致,说明阿里只是把并发闸调高了,桶的规格压根没动。
顺着你的数据补三个可以接着挖的点:
-
回填速率 340k/min 那个
单点估算,可以这样确证:把桶打空(连续大请求直到 Allocated quota exceeded),记下时刻,之后每隔一段时间发一个最小请求试探恢复点,或者干脆等桶满再测一次总容量,两次观测之间能推进的 token 数 ÷ 间隔分钟数就是回填速率。按 1.22M 容量 / 340k/min 粗算,空桶回满大约 3 分半——这个数字直接决定"429 之后该等多久"。 -
429 文案写的是 "API-Key Requests rate limit exceeded",说明限流是挂在 key 上的。多 agent 拆到多个 key/账号上,等于各有一桶,总吞吐直接翻倍,比升档划算:升档买的是并发上限,拆 key 买的是桶数量,两者正交。这一点值得实测验证一下(两个 key 同时打,看是不是互不挤占)。
-
重试策略按你的数据精修一版:撞并发闸(Requests rate limit exceeded)——指数退避 + 秒级短窗口重试有效;撞桶空(Allocated quota exceeded)——立刻重试纯属浪费,正确姿势是按回填速率算好等待(空桶约 3 分半)再继续,或者把请求拆小批次。很多 SDK 把两种 429 混在一起退避,你这两档数据正好当分流依据。
-
-
,系统 取消固定了此主题