作者设计了阶梯压测方法:逐步加倍并发请求,观察 p50/p95 延迟和错误率变化,免费端点有三种典型失败模式——隐性队列降速、429 与 5xx 同时出现、返回截断 JSON。
每个免费端点都有一个上限。文档里从不会提它。前五十次调用也从来发现不了它。
然后一个批量任务撞上去了。一切都慢下来了。或者直接失败。或者悄无声息地返回垃圾数据。
我想要一个数字。这个服务器在什么并发量下就不再有用了?于是我写了一个阶梯测试。本文就是那个测试、那个脚本,以及三种失败特征。
平坦的负载测试会掩盖悬崖。十并发看起来没问题。二十并发看起来也没问题。然后四十并发就崩了。你需要一步步走向边缘。
阶梯测试正是干这个的。从一个并发请求开始。每轮翻一倍。观察百分位数。当错误越过阈值时停止。
三种免费服务器挂掉的方式
免费端点有三种失败模式。学会识别所有三种。
p50 保持平稳。p95 翻了三倍。服务器把请求入队而不是拒绝它们。你的批量任务慢下来了。然后卡住了。
429 和 5xx 在同一并发量同时出现。服务器在告诉你上限。大多数客户端忽略了这个信号。然后重试逻辑让情况更糟。
HTTP 200。完整响应。截断的 JSON。或者空的补全。你的管道解析了它。你的日志不报错。你交付了垃圾。
第三种是最糟的。它看起来不像失败。它看起来像成功。
实验设计
让变量保持无聊。只改一件事:并发数。
固定 prompt。固定 max_tokens。
阶梯:1、2、4、8、16 个并发请求。
每步十个请求。
记录 p50、p95、p99、成功率、tokens/秒。
当错误超过 10% 时提前停止。
为什么是十个请求?足够看出模式,又足够快完成。这是一个天花板测试,不是基准套件。
保存为 ceiling_test.py。需要 Python 3.9+ 和 httpx。
"""ceiling_test.py — find where a free model endpoint breaks."""
import asyncio
import os
import time
import httpx
ENDPOINT = os.environ.get("LLM_ENDPOINT", "http://localhost:8000/v1/chat/completions")
API_KEY = os.environ.get("LLM_API_KEY", "")
MODEL = os.environ.get("LLM_MODEL", "default")
PROMPT = "Write a haiku about a queue that never drains."
STEPS = [1, 2, 4, 8, 16]
REQUESTS_PER_STEP = 10
TIMEOUT = 30.0
async def one_call(client, sem):
async with sem:
t0 = time.perf_counter()
try:
r = await client.post(
ENDPOINT,
json={
"model": MODEL,
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 128,
},
timeout=TIMEOUT,
)
dt = time.perf_counter() - t0
body = r.json()
tokens = body.get("usage", {}).get("total_tokens", 0)
return {"ok": r.status_code == 200, "status": r.status_code,
"latency": dt, "tokens": tokens, "error": ""}
except Exception as exc:
dt = time.perf_counter() - t0
return {"ok": False, "status": 0, "latency": dt,
"tokens": 0, "error": type(exc).__name__}
async def run_step(client, concurrency, count):
sem = asyncio.Semaphore(concurrency)
results = await asyncio.gather(
*[one_call(client, sem) for _ in range(count)]
)
lats = sorted(r["latency"] for r in results)
ok = [r for r in results if r["ok"]]
def pct(p):
idx = min(len(lats) - 1, int(len(lats) * p))
return lats[idx]
errors = sorted({r["error"] or str(r["status"])
for r in results if not r["ok"]})
total_latency = sum(r["latency"] for r in ok)
tokens_per_sec = (
sum(r["tokens"] for r in ok) / total_latency
if total_latency > 0 else 0.0
)
return {
"concurrency": concurrency,
"success": f"{len(ok)}/{len(results)}",
"p50": round(pct(0.50), 2),
"p95": round(pct(0.95), 2),
"p99": round(pct(0.99), 2),
"tokens/sec": round(tokens_per_sec, 1),
"errors": errors,
}
async def main():
headers = {"Authorization": f"Bearer {API_KEY}"} if API_KEY else {}
async with httpx.AsyncClient(headers=headers) as client:
print(f"{'conc':>4} {'success':>8} {'p50':>6} {'p95':>6} "
f"{'p99':>6} {'tok/s':>7} errors")
for step in STEPS:
row = await run_step(client, step, REQUESTS_PER_STEP)
errs = ",".join(row["errors"]) or "-"
print(f"{row['concurrency']:>4} {row['success']:>8} "
f"{row['p50']:>6} {row['p95']:>6} {row['p99']:>6} "
f"{row['tokens/sec']:>7} {errs}")
if __name__ == "__main__":
asyncio.run(main())
export LLM_ENDPOINT="https://your-endpoint.example/v1/chat/completions"
export LLM_API_KEY="your-key"
export LLM_MODEL="your-model"
python ceiling_test.py
下面是一次示例运行。你的数字会不同。这正是关键所在。
conc success p50 p95 p99 tok/s errors
1 10/10 0.82 1.10 1.24 45.2 -
2 10/10 0.90 1.31 1.55 43.8 -
4 10/10 1.21 2.40 3.10 39.1 -
8 9/10 2.10 8.40 12.20 22.4 529
16 6/10 3.50 25.10 28.00 9.8 529,timeout
从左往右读。先看成功率。然后看百分位数。然后看错误。
在这个示例里,天花板在并发 4 和 8 之间。p95 从 2.4 秒跳到 8.4 秒。错误出现了。吞吐量崩溃了。
这就是你需要那个数字。不是 p50。是服务器变得没用的那个临界点。
把这个表格放在脚本旁边。它把原始数字变成决策依据。
测试教给我的三件事
三个教训。每个都让我熬了一晚。
均值掩盖了悬崖
平均延迟看起来没问题。p95 说了实话。百分位数是唯一诚实的汇总。
免费服务器优雅降级。然后就不行了。
曲线看起来很平滑。然后一头栽下桌面。没有预警的坡度。
prompt 长度改变它。一天中的时间改变它。服务器负载改变它。大批量前重新跑。不是之后。
我在 MonkeyCode 里怎么用这个
当我的副项目需要一个免费服务器时,我先把这个测试套件指向它。MonkeyCode 提供免费模型访问和免费服务器选项。我对待它们和对待任何其他端点一样:信任之前先测量。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。
脚本跑十五分钟。它让我免于在第八步就会挂掉的服务器上调度批量任务。
谁不应该用这种方法
这个测试不是给所有人的。如果属于以下情况,跳过它:
你每天只做几次交互式调用。
你用一次性聊天,不用批量工作负载。
你需要正式的 SLA。免费服务器没有 SLA。
你不能容忍任何一次请求丢失。花钱买服务器。
天花板测试回答一个问题:它在哪儿挂掉?它不修复挂掉。它不保证正常运行时间。它给你一个数字。然后你自己决定。
天花板是没人随货发出的规格文档。文档描述的是Happy Path。Happy Path 到并发一就结束了。
跑脚本。保存输出。下次一个端点失败的时候,你就已经知道为什么了。