Download scripts/probe_max_tokens.py from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/scripts/probe_max_tokens.py
- Command line
-
hf download hf://spaces/a3216/gcli2api/scripts/probe_max_tokens.py
-
curl -L -o probe_max_tokens.py https://huggingface.co/spaces/a3216/gcli2api/resolve/main/scripts/probe_max_tokens.py
21.6 kB
| #!/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() | |