File size: 21,562 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 | #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenAI 兼容端点的「真实输出上限」探测工具
==========================================
背景:许多网关/中转会在 /v1/models 里透出 max_output_tokens,但它常常与实际能力不符
(实测某网关对 deepseek-v4.1-flash 声称 393216,真实只有 32000)。更麻烦的是**静默钳制**:
请求 max_tokens 超过真实上限时不报错,而是内部截断——从 usage 只能看出"输出到某个值就停了"。
本工具用「强制长输出 + 阶梯上探」测出真实上限,关键判据区分两种停止:
finish=length 且 输出 < 请求 → 上游截断,**输出值即真实上限**(可信,直接得出结果)
finish=length 且 输出 = 请求 → 支持至少这么多,继续上探更高阶梯
finish=stop 且 输出 < 请求 → 模型自己不想写了,**不可作判据**,需强化提示重试
用法:
# 测某网关全部以给定前缀开头的模型(默认 cn:)
python probe_max_tokens.py --base http://211.154.25.123:7863/v1 --key sk-xxx
# 指定模型 / 多个前缀 / 只看计划
python probe_max_tokens.py --base ... --key ... --models flash,glm-5.3
python probe_max_tokens.py --base ... --key ... --prefix cn: --prefix global:
python probe_max_tokens.py --base ... --key ... --dry-run
# 控制成本与时长
python probe_max_tokens.py --base ... --key ... --tiers 40000,100000 --retries 2 --timeout 900
结果:
- 控制台表格:模型 | 声称上限 | 实测上限 | 判据 | 耗时
- JSONL 落盘(--out,默认 probe-max-tokens.jsonl),**支持 --resume 断点续测**
- --panel-out PATH 额外写面板契约文件(默认关闭;网关面板「模型与档位」的实测列读它):
{"version":1,"probes":{"cn:glm-5.2":{"claimed":131072,"measured":48000,
"verdict":"clamped|at_least|inconclusive","note":"原始判据文本",
"tested_at":"...","source":"probe_max_tokens.py"}}}
写入为合并语义:未重测的模型保留旧记录;文件放网关 state 文件同目录
(默认部署即 data/output_probes.json),面板下次查询即展示,网关零重启。
- --limit N 只测前 N 个模型(先小批量试跑)
成本提示:一个模型若真有极大上限,上探到高档位会生成数十万 token,耗时长且消耗额度。
默认最高阶梯 40000、上限 160000 已足够识别常见的 32K/48K/64K 钳制。
"""
import argparse
import json
import os
import re
import socket
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
# 强制长输出的提示:目标值需与被测档位"量级相当"。
# 实测教训:目标写得过大(数到 999999)模型会判断"不可能完成"而提前收尾(只输出 1~3k);
# 目标设为 12000~20000(能写满但看起来合理)时,模型会持续输出直到被硬截断,4/4 稳定命中。
# 下面按档位缩放目标值:约 max_tokens/3 个数字(每数字行约 2~3 token,留余量防提前写完)。
def long_prompt(max_tokens: int) -> str:
target = max(2000, min(200000, max_tokens // 3))
return (
f"请从 1 开始,每行输出一个数字,依次递增,一直数到 {target}。"
"严格只输出数字本身,每行一个;不要解释、不要总结、不要省略、不要合并、不要提前结束。"
)
# 强化版(第一次被模型主动收尾时使用,仍保持量级匹配)
def long_prompt_hard(max_tokens: int) -> str:
target = max(2000, min(200000, max_tokens // 3))
return (
f"Count from 1 to {target}, one number per line, incrementing by 1. "
"Output digits only, one per line. Do not summarize, do not explain, "
"do not skip numbers, do not stop early — you must reach the target."
)
def fetch_models(base: str, key: str, timeout: int = 20):
"""取 /v1/models 列表:[{id, claimed_output, claimed_ctx}, ...]"""
url = base.rstrip("/") + "/models"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {key}", "x-api-key": key, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
data = json.loads(r.read().decode("utf-8"))
items = data.get("data") if isinstance(data, dict) else data
out = []
for it in items or []:
if isinstance(it, str):
out.append({"id": it.strip(), "claimed_output": None, "claimed_ctx": None})
continue
if not isinstance(it, dict):
continue
mid = (it.get("id") or it.get("name") or "").strip()
if not mid:
continue
top = it.get("top_provider") if isinstance(it.get("top_provider"), dict) else {}
out.append({
"id": mid,
"claimed_output": it.get("max_output_tokens") or it.get("max_completion_tokens")
or top.get("max_completion_tokens"),
"claimed_ctx": it.get("context_length") or it.get("max_input_tokens"),
})
return out
def probe_once(base: str, key: str, model: str, max_tokens: int, prompt: str, timeout: int):
"""发一次强制长输出请求,返回 (out_tokens, reasoning_tokens, finish, err)。"""
body = json.dumps({
"model": model, "stream": True, "max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
}).encode()
req = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=body, headers={
"Authorization": f"Bearer {key}", "x-api-key": key,
"Content-Type": "application/json", "Accept": "text/event-stream"}, method="POST")
out = None
reasoning = None
finish = None
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
buf = ""
while True:
piece = r.read(8192)
if not piece:
break
buf += piece.decode("utf-8", "replace")
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if not payload or payload == "[DONE]":
continue
try:
obj = json.loads(payload)
except Exception:
continue
u = obj.get("usage")
if isinstance(u, dict):
out = u.get("completion_tokens")
det = u.get("completion_tokens_details")
if isinstance(det, dict):
reasoning = det.get("reasoning_tokens")
for c in obj.get("choices") or []:
if isinstance(c, dict) and c.get("finish_reason"):
finish = c["finish_reason"]
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:300]
return None, None, None, f"HTTP {e.code}: {detail}"
except Exception as e:
return None, None, None, f"{type(e).__name__}: {str(e)[:150]}"
return out, reasoning, finish, None
def probe_model(base: str, key: str, model: str, tiers: list[int],
retries: int, timeout: int, quiet: bool, budget: int = 0) -> dict:
"""阶梯上探单个模型,返回结果字典。
budget>0 时为单模型总时间预算(秒):超出即中止并记录"未测完",避免个别大上限模型
把整体拖到几十分钟(实测某模型生成 40000 token 需 720s,上探 100000 档要半小时)。"""
result = {"model": model, "measured": None, "verdict": None, "evidence": []}
started = time.time()
all_failed = True
for tier in tiers:
if budget and time.time() - started > budget:
result.update(verdict=f"未测完(超出时间预算 {budget}s;已知至少 >{result.get('max_full') or 0})")
result["elapsed"] = round(time.time() - started, 1)
return result
tier_ok = False
for attempt in range(1, retries + 1):
prompt = long_prompt(tier) if attempt == 1 else long_prompt_hard(tier)
# 单次请求也受剩余预算约束,防止单请求无上限地跑
req_timeout = timeout
if budget:
remain = budget - (time.time() - started)
if remain <= 5:
break
req_timeout = int(min(timeout, remain))
t0 = time.time()
out, reasoning, finish, err = probe_once(base, key, model, tier, prompt, req_timeout)
dt = time.time() - t0
if err:
result["evidence"].append(f"tier={tier} 请求失败: {err[:120]}")
if not quiet:
print(f" [{model}] tier={tier}: 失败 {err[:70]}", flush=True)
break
tier_ok = True
all_failed = False
rs = f" 推理{reasoning}" if reasoning else ""
if not quiet:
print(f" [{model}] tier={tier} #{attempt}: 输出 {out} tok{rs} "
f"finish={finish} {dt:.0f}s", flush=True)
if finish == "length" and out is not None:
if out < tier:
# 上游截断。注意:若上一档已满额通过(max_full),说明真实上限在
# [max_full, out] 之间,语义是"≥out 附近被钳",用区间表达更准确。
prev = result.get("max_full")
if prev and out < prev:
verdict = f"≈{out}(钳制;上一档满额 {prev},取值 {out})"
else:
verdict = "钳制"
result.update(measured=out, verdict=verdict,
evidence=result["evidence"] + [f"要 {tier} 只给 {out}(finish=length)"])
result["elapsed"] = round(time.time() - started, 1)
return result
result["evidence"].append(f"tier={tier}: 输出满额 {out}(支持≥{tier})")
result["max_full"] = out
break
if finish == "stop":
result["evidence"].append(f"tier={tier} #{attempt}: finish=stop(模型主动收尾,输出 {out})")
if out is not None:
result["max_full"] = max(result.get("max_full") or 0, out)
if attempt == retries:
# 已满额通过的档位仍然有效:结论是"≥max_full",不是"无结论"
if result.get("max_full"):
result.update(measured=None,
verdict=f"≥{result['max_full']}(模型主动收尾,未测到钳制点;"
f"声称 {result.get('claimed_output') or '?'})")
else:
result.update(verdict="模型主动停止(未能测出上限,非钳制)")
result["elapsed"] = round(time.time() - started, 1)
return result
continue
result["evidence"].append(f"tier={tier}: 异常返回 out={out} finish={finish}")
break
if all_failed:
result.update(measured=None, verdict="全部请求失败(模型/账号不可用,无结论)")
result["elapsed"] = round(time.time() - started, 1)
return result
if result.get("max_full"):
result.update(measured=result["max_full"],
verdict=f"≥{result['max_full']}(最高档位仍满额,未触顶)")
result["elapsed"] = round(time.time() - started, 1)
return result
def classify(res: dict):
"""把一条探测记录映射为面板契约五态之三:clamped / at_least / inconclusive。
verdict 原文是自由中文文本,这里按前缀归类:
钳制 / ≈N(钳制…) → clamped,measured = 截断值(可信)
≥N(…)/ 未测完(…至少 >N)→ at_least,measured = 已知下界
其余(主动停止/全部失败) → inconclusive
下界取值链:measured → max_full → 文本里的 ≥N / >N。"""
v = res.get("verdict") or ""
meas = res.get("measured")
if v.startswith("钳制") or v.startswith("≈"):
return "clamped", meas
floor = meas if isinstance(meas, int) else res.get("max_full")
if not floor:
m = re.search(r"[≥>]\s*(\d+)", v)
floor = int(m.group(1)) if m else None
if v.startswith("≥") or v.startswith("未测完"):
return ("at_least", floor) if floor else ("inconclusive", None)
return "inconclusive", None
def write_panel_out(path: str, results: list) -> None:
"""写面板契约文件(合并语义:未重测的模型保留旧记录,上游改限后重跑即覆盖)。"""
p = Path(path)
contract = {"version": 1, "probes": {}}
if p.is_file():
try:
old = json.loads(p.read_text(encoding="utf-8"))
if isinstance(old.get("probes"), dict):
contract["probes"].update(old["probes"])
except Exception:
pass # 旧文件损坏则整体重建,不让坏文件卡住新结果
for r in results:
verdict, meas = classify(r)
contract["probes"][r["model"]] = {
"claimed": r.get("claimed_output"),
"measured": meas,
"verdict": verdict,
"note": r.get("verdict") or "",
"tested_at": r.get("time") or "",
"source": "probe_max_tokens.py",
}
tmp = p.with_suffix(p.suffix + ".tmp")
tmp.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(json.dumps(contract, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(p)
print(f"面板探测数据已写入 {p}(共 {len(contract['probes'])} 条,合并保留未重测模型)")
def main() -> None:
ap = argparse.ArgumentParser(
description="探测 OpenAI 兼容端点的真实输出上限(区分静默钳制与模型主动停止)")
ap.add_argument("--base", required=True, help="接口基址,如 http://host:7863/v1")
ap.add_argument("--key", default=None, help="API Key(也可用环境变量 PROBE_API_KEY)")
ap.add_argument("--prefix", action="append", default=None,
help="只测以此前缀开头的模型(可多次;默认 cn:)")
ap.add_argument("--models", default=None, help="按子串匹配模型 id(逗号分隔,优先级高于 --prefix)")
ap.add_argument("--tiers", default="40000,100000",
help="阶梯请求值,逗号分隔(默认 40000,100000;越高越慢越费额度)")
ap.add_argument("--retries", type=int, default=3, help="finish=stop 时的重试次数(默认 3)")
ap.add_argument("--timeout", type=int, default=900, help="单次请求超时秒数(默认 900)")
ap.add_argument("--limit", type=int, default=0, help="只测前 N 个模型(0=全部)")
ap.add_argument("--out", default="probe-max-tokens.jsonl", help="结果 JSONL 路径")
ap.add_argument("--panel-out", default=None,
help="额外写网关面板契约文件(如 data/output_probes.json;默认关闭)。"
"合并写入:未重测的模型保留旧记录")
ap.add_argument("--resume", action="store_true", help="跳过已有结果的模型(断点续测)")
ap.add_argument("--jobs", type=int, default=4,
help="并行探测的模型数(默认 4;瓶颈是墙钟等待,并行可大幅缩短总时长)")
ap.add_argument("--budget", type=int, default=600,
help="单模型时间预算秒数(默认 600;超出记「未测完」,避免大上限模型拖垮整体)")
ap.add_argument("--dry-run", action="store_true", help="只列出将要测的模型与阶梯,不发请求")
ap.add_argument("--quiet", action="store_true", help="不打印每次尝试明细")
args = ap.parse_args()
key = args.key or os.environ.get("PROBE_API_KEY") or ""
if not key and not args.dry_run:
raise SystemExit("[!] 需要 --key 或环境变量 PROBE_API_KEY")
tiers = [int(x) for x in args.tiers.split(",") if x.strip()]
tiers.sort()
socket.setdefaulttimeout(args.timeout)
print(f"目标: {args.base}")
all_models = fetch_models(args.base, key)
if args.models:
kws = [k.strip().lower() for k in args.models.split(",") if k.strip()]
def _match(mid: str) -> int:
"""返回匹配优先级:0=不匹配,3=带前缀的完整 id,2=后缀精确,1=子串。
关键词含 ':' 时按完整 id 比对(cn:xxx 只命中 cn:xxx,不含 global:xxx)。"""
low = mid.lower()
for k in kws:
if ":" in k and low == k:
return 3
for k in kws:
if ":" not in k and (low == k or low.endswith(":" + k)):
return 2
for k in kws:
if k in low:
return 1
return 0
scored = [(m, _match(m["id"])) for m in all_models]
best = max((s for _, s in scored), default=0)
targets = [m for m, s in scored if s == best and s > 0]
else:
prefixes = args.prefix or ["cn:"]
targets = [m for m in all_models if any(m["id"].startswith(p) for p in prefixes)]
if args.limit:
targets = targets[:args.limit]
if not targets:
raise SystemExit(f"[!] 没有匹配的模型(共 {len(all_models)} 个可测)")
done = {}
out_path = Path(args.out)
if args.resume and out_path.is_file():
for line in out_path.read_text(encoding="utf-8").splitlines():
try:
rec = json.loads(line)
done[rec["model"]] = rec
except Exception:
pass
print(f"待测模型 {len(targets)} 个 | 阶梯 {tiers} | 声称上限来自 /v1/models")
if args.dry_run:
print("\n[dry-run] 计划:")
for m in targets:
skip = " (已有结果,将跳过)" if m["id"] in done else ""
print(f" {m['id']:44} 声称 {str(m['claimed_output'] or '-'):>8}{skip}")
return
print()
results = []
todo = [m for m in targets if m["id"] not in done]
for m in targets:
if m["id"] in done:
results.append(done[m["id"]])
# 并行探测:墙钟时间是瓶颈(单模型生成 4 万 token 需数分钟),并发能有效压缩总时长。
# 结果仍按模型去重,落盘用锁保护。
import concurrent.futures as cf
import threading
write_lock = threading.Lock()
finished = {"n": 0}
def _work(m):
res = probe_model(args.base, key, m["id"], tiers, args.retries, args.timeout,
args.quiet, budget=args.budget)
res["claimed_output"] = m["claimed_output"]
res["claimed_ctx"] = m["claimed_ctx"]
res["time"] = time.strftime("%Y-%m-%d %H:%M:%S")
with write_lock:
finished["n"] += 1
n = finished["n"]
with out_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(res, ensure_ascii=False) + "\n")
measured = res["measured"] if res["measured"] is not None else "-"
print(f"[{n}/{len(todo)}] {m['id']}(声称 {m['claimed_output'] or '-'})"
f" → 实测 {measured} | {res['verdict']} | {res.get('elapsed','-')}s", flush=True)
return res
if todo:
print(f"开始探测 {len(todo)} 个模型(并行 {args.jobs},单模型预算 {args.budget}s)...\n")
with cf.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex:
results.extend(ex.map(_work, todo))
# 汇总表
print("=" * 96)
print(f"{'模型':44} {'声称':>9} {'实测':>9} {'判据':<28} {'耗时':>6}")
print("-" * 96)
for r in results:
claim = r.get("claimed_output") or "-"
meas = r.get("measured") if r.get("measured") is not None else "-"
verdict = (r.get("verdict") or "")[:26]
print(f"{r['model']:44} {str(claim):>9} {str(meas):>9} {verdict:<28} {str(r.get('elapsed','-')):>6}")
print("=" * 96)
mism = [r for r in results if r.get("measured") and r.get("claimed_output")
and int(r["measured"]) < int(r["claimed_output"])]
if mism:
print(f"\n⚠ {len(mism)} 个模型的声称上限高于实测(静默钳制):")
for r in mism:
print(f" {r['model']}: 声称 {r['claimed_output']} → 实际 {r['measured']}")
print(f"\n结果已写入 {out_path}(--resume 可跳过已测模型继续)")
if args.panel_out:
write_panel_out(args.panel_out, results)
if __name__ == "__main__":
main()
|