同一个提示词有时秒回、有时卡住,最先要分清的是“你自己的并发打满”还是“服务端在排队”。这两类慢在日志里的样子不同:前者通常在超过某个并发档位后耗时整体抬升,并且客户端侧能观察到排队等待;后者更常表现为部分请求在状态码或响应头部出现异常信号,单并发时也可能偶发变慢。比较稳妥的做法是先固定单并发取基线,再按 1/2/4/8 做并发阶梯,用慢请求分布和状态码交叉表把原因落到某一侧。下面每一步都只依赖你能看到的配置、日志和命令输出,结论需要结合你的实际环境确认。
判断 DeepSeek-V4.1-Flash-1017 时快时慢,建议先跑单并发基线,确认无竞争状态下的耗时中位数和尾部;再按 1/2/4/8 阶梯加压,找到耗时开始抬升的并发档位。若拐点出现在你设定的并发上限附近,且超限请求的等待时间集中在客户端,优先怀疑自身并发打满;若低并发下仍出现状态码 429/5xx 与长耗时同区间聚集,更可能是服务端排队或限流。两种情况的处理方向不同,不要只靠调大超时掩盖问题。
先固定单并发跑二十次取基线
基线的作用是拿到“没有任何本地竞争”时的耗时形状。把并发固定为 1,串行发二十次同一个提示词,记录每次的耗时和响应状态。二十次只是常用起点,样本太少容易把偶发抖动当规律,太多则浪费时间,可以先跑二十次看分布是否稳定再决定是否加量。
下面是一个通用请求骨架,接口地址、鉴权头和请求体字段需要替换成你实际使用的形式;如果所用 SDK 不方便逐次计时,用等价的 HTTP 客户端也可以。
import time, json, statistics, urllib.request
URL = "https://your-endpoint/v1/chat/completions" # 替换为实际地址
KEY = "YOUR_API_KEY"
PROMPT = "把下面这句话改写成一句更短的话:今天下午三点开会。"
def one_call(i):
body = json.dumps({
"model": "DeepSeek-V4.1-Flash-1017",
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 256
}).encode()
req = urllib.request.Request(URL, data=body, headers={
"Content-Type": "application/json",
"Authorization": "Bearer " + KEY
})
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=120) as r:
code = r.status
raw = r.read()
err = ""
except Exception as e:
code = getattr(e, "code", -1)
raw = b""
err = type(e).__name__
cost = time.perf_counter() - t0
return {"seq": i, "code": code, "cost_ms": round(cost * 1000),
"resp_bytes": len(raw), "err": err}
rows = [one_call(i) for i in range(20)]
for r in rows:
print(r)
ok = [r["cost_ms"] for r in rows if r["code"] == 200]
print("n_ok=", len(ok))
if ok:
print("p50=", statistics.median(ok),
"min=", min(ok), "max=", max(ok))
每次请求建议至少记录这些字段:序号、开始时间戳、HTTP 状态码、总耗时(毫秒)、响应体字节数、异常类型。响应体字节数有用,因为很短的响应体配上很长的耗时,往往说明时间花在等待而不是生成内容上。基线统计口径固定为:只统计状态码 200 的样本,报中位数、最小值、最大值;异常样本单独列出不混入中位数。如果基线本身最大值就远高于中位数,说明服务端侧在无竞争时也会抖动,后续阶梯实验要带着这个背景看。
按 1/2/4/8 阶梯加并发观察拐点
阶梯实验的目标不是压出极限,而是找“耗时从平稳变成明显抬升”的那一档。常见写法是用线程池或信号量控制同时在飞的请求数,每一档跑固定样本数,比如每档二十到五十次请求,档位之间留一点间隔让前一批彻底收尾,避免互相污染。
from concurrent.futures import ThreadPoolExecutor
import time
def run_level(level, total):
results = []
with ThreadPoolExecutor(max_workers=level) as pool:
futures = [pool.submit(one_call, i) for i in range(total)]
for f in futures:
results.append(f.result())
ok = [r["cost_ms"] for r in results if r["code"] == 200]
ok.sort()
print(f"level={level} n={len(results)} n_ok={len(ok)}",
"p50=", ok[len(ok)//2] if ok else None,
"p90=", ok[int(len(ok)*0.9)] if ok else None)
return results
all_rows = []
for lv in (1, 2, 4, 8):
all_rows += run_level(lv, 30)
time.sleep(2)
样本数取三十次是折中:既能看出分位数,又不至于把等待时间拖得太长。拐点判定建议用可比较的规则,比如某档的 p90 超过单并发基线 p50 的两倍,并且该档尾部样本数占比明显增加,就把这一档记为拐点候选。不要只看平均值,平均值会被少数长尾拉偏。如果拐点出现在 4 而你把本地并发设成 8,那 8 档的慢就更像是自身并发上限造成;如果单并发和 2 档耗时接近、4 档开始抬升但状态码同时变差,还要看下一步的状态码分组。
把慢请求的状态码与耗时对齐
这一步回答“慢是不是集中在限流或服务端错误上”。把阶梯实验的所有样本落成一份记录,按状态码分组统计数量和耗时分位,再做一个状态码与耗时区间的交叉表,看长耗时样本究竟落在 200 上还是落在 429、500、502、504 上。若长耗时几乎全是 200,偏向自身排队或并发压住了客户端;若长耗时和 429 或 5xx 同区间聚集,服务端限流或排队的分量更大。
import csv, collections
# all_rows 来自上一节,字段:seq/code/cost_ms/resp_bytes/err
buckets = [(0, 2000), (2000, 5000), (5000, 15000), (15000, 10**9)]
def bucket(ms):
for lo, hi in buckets:
if lo <= ms < hi:
return f"{lo}-{hi}"
return "unk"
table = collections.Counter()
for r in all_rows:
table[(r["code"], bucket(r["cost_ms"]))] += 1
for (code, b), n in sorted(table.items()):
print(code, b, n)
with open("ladder.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["seq","code","cost_ms","resp_bytes","err"])
w.writeheader()
w.writerows(all_rows)
日志筛选方式要提前定好,否则事后很难对齐:按请求发起时间排序,保留状态码、耗时、请求序号三个字段即可横向比对。耗时区间可以先用上面的固定分桶,跑完再根据实际分布调整阈值,但调整后要重新出一张表,不要混用两套口径下结论。若看到 429 与长耗时强相关,基本可以判断服务端在限流,此时继续加大并发只会让更多请求进入等待。
在本地记录建连与解析耗时
有些“假慢”来自客户端网络、DNS 或连接复用不佳,而不是服务端。要排除这一层,需要把总耗时拆成几个计时点:请求开始、TCP 建连完成、TLS 握手完成(若使用 HTTPS)、首字节到达、响应体读完后。用 curl 可以直接看到分段耗时,适合快速核对。
curl -s -o /dev/null -w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $KEY" \
-d '{"model":"DeepSeek-V4.1-Flash-1017","messages":[{"role":"user","content":"ping"}]}' \
"$URL"
对比展示方式建议把总耗时减去建连与首字节之前的耗时,得到“纯等待响应”的部分,再和前一节的耗时区间并列看。如果某几次慢请求的 dns 或 conn 明显偏大,而 ttfb 稳定,那问题在本地解析或建连,不在服务端排队。若使用长连接客户端,还要注意连接池大小,连接池过小会让并发被客户端自己卡住,表现形式和并发打满很像。计时点要统一,全部用单调时钟,避免系统时间跳变带来偏差。
按拐点决定限流与排队策略
拿到拐点后,并发配置应当设在拐点之下并留出余量,而不是贴着拐点跑。常用做法是在客户端加信号量限制同时在飞的请求数,超限请求进入有界队列;队列满时立即返回可识别的降级结果,而不是无限等待。下面是一个可替换的配置骨架,数值需要按你自己的拐点实验填写。
# 伪配置,放入你的客户端初始化处
client:
endpoint: "https://your-endpoint/v1/chat/completions"
model: "DeepSeek-V4.1-Flash-1017"
max_concurrency: 4 # 取自拐点档位减一档,跑完阶梯再定
queue_size: 8 # 有界队列,避免无限堆积
queue_wait_ms: 3000 # 排队超时,超时即降级
request_timeout_ms: 60000
retry:
on: [429, 502, 503, 504]
max_attempts: 2
backoff_ms: [500, 1500] # 只对可重试状态码生效
超限时的降级动作建议明确成三档:队列未满时排队等待并记录等待耗时;队列已满或等待超时时返回“稍后重试”的提示,不要把请求继续挂在网络上;遇到 429 时按退避重试,但重试次数要有限,且重试请求不占用新的并发名额。这样拐点实验的结论就能直接变成配置参数,而不是停在一次性的测试报告里。上线后建议再抽查一次状态码与耗时交叉表,确认长耗时请求没有重新出现在 200 区间之外。