单请求测试无法暴露队列、速率限制和调度器竞争问题;作者用 1-32 并发扫描摸出免费服务器的断点并开源了测试方法。
延迟测试会说谎。单请求看起来很快。然后十个请求同时到来,服务器就卡死了。我是在这里踩了坑的。
我之前的探测都是一次发一个请求。它们显示了方差,显示了时段波动,但没有显示任何关于争用的信息。真正的应用会并行发出很多请求。队列、速率限制和调度器噪声只在负载下才会显现。如果你的第十个并行调用超时了,那么 2 秒的中位数有什么意义?
所以我做了一个并发扫描。我把一个免费模型服务器从 1 并发逐步推到 32 并发。我测量了它表现良好的区间,也测量了它崩溃的区间。断点在 16。
我在 MonkeyCode 的免费模型服务器上运行了扫描。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。
实验设计
无聊的测试给出干净的信号。我刻意让 prompt 保持无聊。
固定 prompt:回复恰好一个词:ACK。
短的补全把服务器开销和生成时间隔离开来。
并发级别:1、2、4、8、16、32。
每个级别 50 个请求。
每个请求 60 秒超时。
每个级别之间 10 秒冷却。
为什么这样选?短 prompt 消除了 prompt 处理噪声。短补全防止 token 生成时间占主导。冷却时间防止一个级别的饱和渗透到下一个级别。
统计中位数、p90 和 p99 延迟。
吞吐量(每分钟请求数)。
Python + asyncio + httpx。信号量强制执行并发级别。asyncio.gather 启动所有 50 个请求。墙上时钟时间给出真实吞吐量。硬超时防止挂起。
# concurrency_sweep.py
import argparse
import asyncio
import json
import statistics
import time
import httpx
PROMPT = "Reply with exactly one word: ACK."
TIMEOUT_S = 60.0
COOLDOWN_S = 10
async def one_call(client, endpoint, key, model):
started = time.perf_counter()
try:
resp = await client.post(
endpoint,
headers={"Authorization": f"Bearer {key}"},
json={
"model": model,
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 8,
"temperature": 0,
},
timeout=TIMEOUT_S,
)
return {
"ok": resp.status_code == 200,
"status": resp.status_code,
"latency_s": time.perf_counter() - started,
}
except httpx.TimeoutException:
return {"ok": False, "status": "timeout", "latency_s": TIMEOUT_S}
except httpx.HTTPError:
return {"ok": False, "status": "http_error", "latency_s": time.perf_counter() - started}
async def run_level(client, endpoint, key, model, level, n):
gate = asyncio.Semaphore(level)
async def limited():
async with gate:
return await one_call(client, endpoint, key, model)
started = time.perf_counter()
results = await asyncio.gather(*(limited() for _ in range(n)))
wall_s = time.perf_counter() - started
return results, wall_s
def summarize(level, results, wall_s):
ok = [r for r in results if r["ok"]]
latencies = sorted(r["latency_s"] for r in ok)
errors = {}
for r in results:
if not r["ok"]:
errors[str(r["status"])] = errors.get(str(r["status"]), 0) + 1
def pct(p):
if not latencies:
return None
idx = min(len(latencies) - 1, int(len(latencies) * p))
return round(latencies[idx], 2)
return {
"concurrency": level,
"success": f"{len(ok)}/{len(results)}",
"median_s": round(statistics.median(latencies), 2) if latencies else None,
"p90_s": pct(0.90),
"p99_s": pct(0.99),
"throughput_req_min": round(len(ok) / wall_s * 60, 1),
"errors": errors,
}
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", required=True)
parser.add_argument("--key", required=True)
parser.add_argument("--model", required=True)
parser.add_argument("--levels", type=int, nargs="*", default=[1, 2, 4, 8, 16, 32])
parser.add_argument("--n", type=int, default=50)
args = parser.parse_args()
async with httpx.AsyncClient() as client:
for level in args.levels:
print(f"--- concurrency={level} ---")
results, wall_s = await run_level(
client, args.endpoint, args.key, args.model, level, args.n
)
print(json.dumps(summarize(level, results, wall_s), indent=2))
if level != args.levels[-1]:
await asyncio.sleep(COOLDOWN_S)
if __name__ == "__main__":
asyncio.run(main())
python concurrency_sweep.py \
--endpoint https://your-endpoint/v1/chat/completions \
--key YOUR_KEY \
--model your-model-id
替换 endpoint、key 和 model,然后等待。脚本会为每个级别打印一个 JSON 摘要。
这是我的运行结果。你的数字会不同。在信任任何结论之前先自己跑一下这个测试框架。
延迟在 8 之前保持平稳。服务器有余量。吞吐量在 8 左右达到峰值。然后开始崩塌。
到了 16,尾部延迟爆炸了。p90 从 4.1 秒跳到 28.9 秒。超时出现了。速率限制出现了。到了 32,超过一半的请求失败。吞吐量跌到单请求基线以下。
超时最先出现。速率限制紧随其后。服务器错误最后堆积。这是服务器先排队处理工作,然后卸负载,最后崩溃的典型模式。
这些数字也暴露了我自己的假设。我预期的是优雅降级,结果得到的是悬崖。8 到 16 的下降不是渐进的,而是一堵墙。
并发 1–2:适合交互式使用。p90 保持在 3 秒以下。
并发 4:适合带重试的后台任务。
并发 8:吞吐量峰值。p99 尾部延迟仍在 7 秒以下。
并发 16:p90 飙过 28 秒。超时变得常见。
并发 32:失败多于成功。吞吐量崩塌。
任何级别:共享的免费服务器都可能毫无预警地降级。在重大发布前重新运行扫描。
给并行调用设置上限。信号量是最便宜的修复方案。
from asyncio import Semaphore
CAP = 8 # your measured safe concurrency
gate = Semaphore(CAP)
async def call_with_cap(client, endpoint, key, model):
async with gate:
return await one_call(client, endpoint, key, model)
根据 p99 而不是中位数设置超时。中位数会掩盖尾部延迟。尾部延迟才是杀死用户的元凶。
对 429 和 5xx 进行重试。使用指数退避。加上抖动。不要盲目重试超时。服务器可能仍在处理你的请求。
把免费服务器当作突发缓冲区,而不是骨干。它很适合原型开发、批处理任务和开发循环。它不是数据库,不是队列,是一个共享资源。
这是一次运行、一天、一个区域的数据。免费服务器会毫无通知地改变行为。我的数字是快照,不是定律。
测试使用了一个极小的 prompt 和极小的补全。长文档会改变一切。生成时间会占主导。并发行为也会随之改变。
这个测试框架衡量的是服务器行为。它不涉及模型质量。快速的错误答案仍然是错误。单独运行质量评估。
如果你需要亚秒级延迟,跳过此工作流。如果你的工作负载是突发性的且没有重试容忍度,跳过。如果你有硬 SLA,跳过。这些情况存在付费层级。
如果你的工作负载是突发性的且没有重试容忍度,跳过此工作流。如果有硬 SLA,也跳过。这些情况有付费层级可用。
当你决定一个免费端点是否能承载真实工作负载时,使用这个扫描。答案通常是肯定的——只要并发级别选对。
我在 MonkeyCode 的免费服务器上跑了我的测试。在信任任何免费端点之前先跑你自己的。断点会让你惊讶。