这篇能做出什么
照着做完,你手里会有三样可直接落地的东西:
1. 一条能跑通的高速档调用链路:从密钥配置、单次调用、流式输出到带重试的客户端封装。
2. 一份在你自己机器上测出来的延迟与吞吐数据:P50 / P95 / P99 延迟、每秒请求数(RPS)、错误率,而不是看别人给的截图。
3. 一张“高速档 vs 常规档”的成本对照表:把 token 用量和延迟放进同一张表,回答“多花的钱换回来多少毫秒”。
适合的场景很具体:在线客服的首字响应、IDE 里的代码补全、会议实时纪要、语音助手背后的意图理解。这些场景的共同点是单次请求不长,但并发高、用户对等待敏感。如果你的任务是离线批量生成报告,本文的压测方法照样能用,但结论大概率是“用常规档更划算”。
---
前置条件清单
动手前先确认这五项,缺一项都会在中间卡住:
- [ ] 一个已开通 API 的账号,以及可用的 API Key(形如
sk-开头,具体前缀以官方文档为准) - [ ] 账号下已开通目标模型的使用权限,并能查到它的准确模型 ID
- [ ] Python 3.9 及以上环境(
python --version能输出版本号即可) - [ ] 能正常访问 API 域名的网络环境;如果走代理,确认 HTTPS 也走代理
- [ ] 账户里有可用额度,压测会真实消耗 token,建议先小额充一点试水
另外准备一个记事本,用来记录每次压测的时间、并发数、模型 ID。后面做对照时,这些元信息比数字本身更重要。
---
分步骤
第一步:把配置项抽成环境变量
不要在代码里硬编码 Key,也不要把模型 ID 写死在二十个地方。统一用环境变量:
```bash
写入 ~/.bashrc 或 ~/.zshrc 后执行 source,避免每次重开终端都要设
export MOONSHOT_API_KEY="sk-你的密钥"
export BASE_URL="https://api.moonshot.cn/v1" # 以官方文档当前版本为准
export MODEL="kimi-k3.1-pro-highspeed" # 模型 ID 以官方文档当前版本为准
```
模型 ID 一定要去官方文档的模型列表页复制,不要凭记忆敲。差一个连字符就是 404,而这个错误在压测时会被误读成“服务不稳定”。
第二步:安装依赖并跑通第一次调用
```bash
python -m venv .venv
source .venv/bin/activate # Windows 用 .venv\Scripts\activate
pip install httpx
```
先用 curl 验证链路是否通。把请求体写成文件,避免 shell 引号层层转义出错:
```bash
cat > payload.json <<JSON
{
"model": "$MODEL",
"messages": [{"role": "user", "content": "用一句话解释什么是向量检索"}],
"temperature": 0.3,
"max_tokens": 200
}
JSON
curl -s "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $MOONSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d @payload.json
```
返回体里重点看两个字段:choices[0].message.content 是结果,usage 里是 prompt_tokens / completion_tokens。把 usage 记下来,后面算成本全靠它。
如果这里就报错,先解决报错,不要往下走。排错顺序写在文末的“常见坑”里。
第三步:封装一个带超时、重试的客户端
压测脚本最忌讳“一把梭”,因为一次偶发超时就会让整轮数据失真。先写一个稳的客户端:
```python
client.py
import os
import time
import httpx
BASE_URL = os.environ["BASE_URL"].rstrip("/")
API_KEY = os.environ["MOONSHOT_API_KEY"]
MODEL = os.environ["MODEL"]
class ChatClient:
def __init__(self, timeout=120.0, max_retries=3):
self.max_retries = max_retries
self._client = httpx.Client(
base_url=BASE_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
connect 短、read 长:连接该快,生成该慢
timeout=httpx.Timeout(timeout, connect=10.0),
)
def close(self):
self._client.close()
def chat(self, prompt, **kwargs):
body = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
**kwargs,
}
last_exc = None
for attempt in range(self.max_retries):
try:
resp = self._client.post("/chat/completions", json=body)
if resp.status_code in (429, 500, 502, 503, 504):
可重试错误:指数退避
time.sleep(2 ** attempt)
continue
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
time.sleep(2 ** attempt)
raise RuntimeError(f"重试 {self.max_retries} 次后仍失败: {last_exc}")
```
几个细节值得说明:
connect=10.0和timeout=120.0分开设。连接慢通常是网络问题,生成慢则可能是输出长,两者要用不同阈值判断。429和5xx才重试,400/401/404重试没有意义,只会放大错误。- 退避用
2 ** attempt,不要用固定间隔,否则并发高时会持续触发限流。
第四步:定义你要测的指标
很多人压测只测“平均延迟”,这个数几乎没有参考价值。至少记录这五个:
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| P50 延迟 | 一半请求快于这个值 | 代表“典型体验” |
| P95 / P99 延迟 | 尾部延迟 | 用户投诉基本来自这里 |
| RPS | 每秒成功请求数 | 决定你需要多少配额 |
| TTFT | 从发出请求到收到第一个 token | 流式场景的真实体感 |
| 错误率 | 非 200 占比 | 判断是不是撞到限流 |
P95 和 P50 的差距是最有价值的一个数字。差距小说明服务稳定,差距大说明有排队或长尾,扩并发前要先解决。
第五步:写并发压测脚本
用 asyncio + 信号量控制并发,比多线程干净:
```python
bench.py
import argparse
import asyncio
import json
import os
import statistics
import time
import httpx
BASE_URL = os.environ["BASE_URL"].rstrip("/")
API_KEY = os.environ["MOONSHOT_API_KEY"]
MODEL = os.environ["MODEL"]
固定输入,保证多轮测试之间可比
PROMPT = "用三句话说明如何评估一个 API 的稳定性。"
def pct(values, p):
if not values:
return float("nan")
values = sorted(values)
k = (len(values) - 1) * p
lo, hi = int(k), min(int(k) + 1, len(values) - 1)
return values[lo] + (values[hi] - values[lo]) * (k - lo)
def new_bucket():
return {"latency": [], "in_tokens": [], "out_tokens": [],
"http_errors": [], "exceptions": []}
async def one_call(client, sem, bucket):
async with sem:
t0 = time.perf_counter()
try:
resp = await client.post(
"/chat/completions",
json={
"model": MODEL,
"messages": [{"role": "user", "content": PROMPT}],
"temperature": 0.2,
"max_tokens": 256,
},
)
dt = time.perf_counter() - t0
if resp.status_code != 200:
bucket["http_errors"].append(resp.status_code)
return
body = resp.json()
usage = body.get("usage") or {}
bucket["latency"].append(dt)
bucket["in_tokens"].append(usage.get("prompt_tokens", 0))
bucket["out_tokens"].append(usage.get("completion_tokens", 0))
except Exception as exc:
bucket["exceptions"].append(type(exc).__name__)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--concurrency", type=int, default=16)
ap.add_argument("--total", type=int, default=128)
ap.add_argument("--warmup", type=int, default=8)
args = ap.parse_args()
limits = httpx.Limits(
max_connections=args.concurrency + 8,
max_keepalive_connections=args.concurrency + 8,
)
res = new_bucket()
async with httpx.AsyncClient(
base_url=BASE_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=httpx.Timeout(120.0, connect=10.0),
limits=limits,
) as client:
sem = asyncio.Semaphore(args.concurrency)
预热:把连接和 DNS 开销提前消化掉,否则 P99 会被首次请求污染
await asyncio.gather(
*[one_call(client, sem, new_bucket()) for _ in range(args.warmup)]
)
t0 = time.perf_counter()
await asyncio.gather(
*[one_call(client, sem, res) for _ in range(args.total)]
)
wall = time.perf_counter() - t0
n = len(res["latency"])
summary = {
"model": MODEL,
"concurrency": args.concurrency,
"requests": args.total,
"success": n,
"wall_seconds": round(wall, 2),
"rps": round(n / wall, 3) if wall else 0,
"latency_p50": round(pct(res["latency"], 0.50), 3),
"latency_p95": round(pct(res["latency"], 0.95), 3),
"latency_p99": round(pct(res["latency"], 0.99), 3),
"avg_in_tokens": round(statistics.mean(res["in_tokens"]), 1) if res["in_tokens"] else 0,
"avg_out_tokens": round(statistics.mean(res["out_tokens"]), 1) if res["out_tokens"] else 0,
"out_tokens_per_sec": round(sum(res["out_tokens"]) / wall, 1) if wall and res["out_tokens"] else 0,
"http_errors": len(res["http_errors"]),
"exceptions": len(res["exceptions"]),
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
if __name__ == "__main__":
asyncio.run(main())
```
单跑一次:
```bash
python bench.py --concurrency 16 --total 128
```
第六步:扫并发梯度,找到拐点
单点数据说明不了问题,要扫一条曲线:
```bash
for c in 1 2 4 8 16 32; do
python bench.py --concurrency $c --total $((c * 10)) > "result_c${c}.json"
echo "并发 $c 完成"
done
```
每轮之间歇 30 秒再跑下一轮,避免上一轮的限流冷却影响下一轮。
观察三件事:
- RPS 随并发上升,但增速越来越慢——说明快碰到瓶颈了。
- P95 开始在某个并发点跳一个台阶——这个点之前是“安全区”,之后是“压力区”。
- 错误率从 0 变成非 0——你已经超出账号配额,继续加压没有意义。
把这三条曲线画在同一张图上(横轴并发,纵轴分别是 RPS、P95、错误率),就能一眼看出你的业务应该配多少并发、留多少余量。
第七步:做成本对照
成本不能只看“每百万 token 单价”,因为高速档和常规档的输出长度、重试次数、失败率都可能不同。要算的是“完成一件任务花多少钱”。
```python
cost.py
import json
import os
import sys
PRICE_IN = float(os.environ["PRICE_IN_PER_MTOK"]) # 输入单价,以官方定价页为准
PRICE_OUT = float(os.environ["PRICE_OUT_PER_MTOK"]) # 输出单价,以官方定价页为准
def per_request(in_tok, out_tok):
return in_tok / 1_000_000 * PRICE_IN + out_tok / 1_000_000 * PRICE_OUT
report = json.load(open(sys.argv[1], encoding="utf-8"))
unit = per_request(report["avg_in_tokens"], report["avg_out_tokens"])
peak_qps = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
hours = float(sys.argv[3]) if len(sys.argv) > 3 else 24.0
daily = unit * peak_qps * 3600 * hours * 0.3 # 0.3 是峰值折算系数,按业务实际调整
print(f"模型 : {report['model']}")
print(f"单请求成本 : {unit:.6f} 元")
print(f"千次请求成本 : {unit * 1000:.3f} 元")
print(f"P95 延迟(秒) : {report['latency_p95']}")
print(f"日估算成本 : {daily:.2f} 元")
```
用法:
```bash
export PRICE_IN_PER_MTOK="官方输入单价" # 数字从官方定价页拿
export PRICE_OUT_PER_MTOK="官方输出单价"
python cost.py result_c16.json 5 24
```
把两档的结果填进下面这张表(数字全部由你自己的压测填写):
| 项目 | 常规档 | 高速档 | 差异 |
|---|---|---|---|
| P50 延迟(秒) | |||
| P95 延迟(秒) | |||
| 安全并发上限 | |||
| 平均输出 token | |||
| 单请求成本 | |||
| 千次请求成本 | |||
| 单请求成本 ÷ P95 延迟 |
最后一行是关键指标:每毫秒延迟花多少钱。用它来判断“这次升级值不值”。
第八步:解读结果并做决策
三种典型结论,对应三种策略:
- 高速档 P95 明显更低,单请求成本增幅小于延迟降幅 → 全量切高速档,或者至少在交互式接口上用。
- 高速档延迟优势只在低并发时存在 → 说明瓶颈在你自己的出口带宽或账号配额,先扩容再谈换档。
- 两档延迟差别不大 → 说明你的任务本身输出很长,延迟主要被输出长度吃掉,换档没用,应该优化 prompt 或
max_tokens。
---
常见坑与排错
401 / 403
九成是 Key 的问题。检查三处:环境变量里有没有混入换行或空格(用 echo -n "$MOONSHOT_API_KEY" | wc -c 看长度是否合理)、Bearer 后面有没有多一个空格、Key 是不是已经失效或被轮换。
404 model not found
模型 ID 不对,或者账号没有该模型的权限。去官方文档的模型列表页复制 ID,逐字符比对。特别注意大小写、连字符和点号。
429 Too Many Requests
并发数 ≠ 你的配额。多数平台的限额是按 RPM(每分钟请求数)或 TPM(每分钟 token 数)计的,而且可能分档。压测时把并发降一半再跑,如果 429 消失,就是配额问题,不是代码问题。
读超时频繁
先区分是连接超时还是读取超时。如果是读取超时,且 max_tokens 设得很大,那是正常的——生成 2000 token 本来就要时间。把 max_tokens 降到业务真实需要的值,超时会大幅减少,成本也一起降。
流式和非流式的延迟不能混着比
非流式返回的是一次完整生成的总耗时,流式的 TTFT 只到第一个 token。做对照时,两档必须用同一种模式测,否则结论全错。
P99 特别难看,但 P50 很好
通常是预热不足或连接池太小。确认压测脚本里有 warmup,且 max_keepalive_connections 不小于并发数。否则每个请求都在新建 TLS 连接,测的其实是握手耗时。
压测机自己成了瓶颈
并发上到几十之后,用 top 看一下 Python 进程的 CPU。如果单核跑满,说明是客户端在排队,不是服务端慢。此时换 uvloop 或分多进程发压,或者换一台机器。
成本算出来对不上账单
检查三点:有没有命中上下文缓存(命中部分通常有折扣,规则以官方文档为准)、重试的请求是否也计费、图片等多模态输入是否按不同规则计量。
别忘了设 max_tokens 上限
不设上限时,模型偶尔会输出超长内容,单次成本可能是预期的好几倍。压测和线上都建议显式设置。
---
下一步建议
跑完这一轮,你已经有了自己的基线数据。往下可以接这几件事:
1. 做双档路由:在网关层按请求类型分流——交互式请求走高速档,后台批处理走常规档。用一个简单的规则表就能实现,不需要复杂架构。
2. 把指标接进监控:把 latency_p95、http_errors、out_tokens_per_sec 定期上报到你们的监控系统,设置阈值告警。压测数据是一次性的,监控数据才是长期的。
3. 加结果缓存:很多高并发场景里,重复问题占比很高。在调用前加一层语义缓存或精确匹配缓存,能直接砍掉一大块成本和延迟。
4. 做长稳压测:本文的方法是短时压测。真实线上是 7×24 小时,建议再跑一次 30 分钟以上的稳定负载,看延迟是否随时间漂移——漂移往往意味着内存泄漏或连接未释放。
5. 定期重跑:模型和配额都会更新,三个月前的压测结论不一定还成立。把 bench.py 和 cost.py 放进仓库,每次换档或调价后重跑一遍,十分钟的事。
最后提醒一句:所有单价、配额、模型 ID、限流规则都以官方文档和定价页的当前版本为准。本文给的是方法和脚本,数字要你自己去填。
