Files
dsh/tools/probe_models.py

410 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""I-01 模型能力自动探测:每晚自动 + 异常告警方向,最小闭环。
读 ~/.dsh/settings.yaml 枚举全部分组与模型,对每个模型跑三组探测:
- 基线:一次最小对话请求(200 + 非空回复)
- 思考:按模型的 thinkingFormat/reasoningEfforts 配置发带推理参数请求,验证 reasoning 字段
- 视觉:内置 64px 纯色 PNG(base64 内嵌)问主色
铁律:脚本只读 settings.yaml(open(mode='r'),代码层面无写入路径);
任何写入需求只进报告建议,由人执行。报告不打印 API key。
产出:tools/probe-reports/probe-YYYY-MM-DD.json(机读)+ 同名 .md(人读:表格 + 与上次 diff)。
用法:
python tools/probe_models.py --all # 全量探测 + 写报告
python tools/probe_models.py --all --compare # 全量探测 + 写报告 + 打印与上次对比
python tools/probe_models.py --provider b # 只跑一组
python tools/probe_models.py --check-readonly # 自证:grep 本源码无 settings 写入路径
Windows 计划任务(每晚 02:00)示例:
schtasks /create /tn "DSH模型探测" /tr "python C:\\Users\\12914\\Desktop\\dsh\\tools\\probe_models.py --all" /sc daily /st 02:00
"""
from __future__ import annotations
import argparse
import base64
import datetime as _dt
import io
import json
import os
import struct
import sys
import time
import urllib.request
import zlib
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPORT_DIR = HERE / "probe-reports"
SETTINGS = Path(os.path.expanduser("~/.dsh/settings.yaml"))
TIMEOUT = 30 # 单请求超时(秒),失败隔离:单模型失败不中断全量
RED_PNG_B64: str | None = None
def red_png_b64() -> str:
"""64px 纯红 PNG,纯标准库生成(zlib+struct),无外部文件依赖。"""
global RED_PNG_B64
if RED_PNG_B64 is None:
w = h = 64
raw = b"".join(b"\x00" + b"\xff\x00\x00" * w for _ in range(h))
def chunk(typ: bytes, data: bytes) -> bytes:
c = typ + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b""))
RED_PNG_B64 = base64.b64encode(png).decode()
return RED_PNG_B64
def load_settings() -> dict:
"""只读加载 settings.yaml(本函数是全脚本唯一的 settings 打开点,mode='r')。"""
try:
import yaml # type: ignore
with open(SETTINGS, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
except ImportError:
pass
# 无 PyYAML 时的最小缩进解析:只取 providers 下 api/apiKeyEnv/baseURL/models 必要字段
import re
text = None
with open(SETTINGS, "r", encoding="utf-8") as fh:
text = fh.read()
providers: dict = {}
cur_p = cur_m = None
cur: dict = {}
for line in text.splitlines():
m = re.match(r"^ ([a-z0-9-]+):\s*$", line)
if m:
if cur_p and cur:
providers[cur_p] = cur
cur_p, cur, cur_m = m.group(1), {"models": []}, None
continue
if cur_p is None:
continue
m2 = re.match(r"^ (displayName|apiKeyEnv|api|baseURL):\s*(.+?)\s*$", line)
if m2:
cur[m2.group(1)] = m2.group(2)
continue
m3 = re.match(r"^ - id:\s*(\S+)", line)
if m3:
cur_m = {"id": m3.group(1), "input": ["text"], "compat": {}, "reasoningEfforts": None}
cur["models"].append(cur_m)
continue
if cur_m is not None:
m4 = re.match(r"^ (thinkingFormat|supportsDeveloperRole):\s*(\S+)", line)
if m4:
cur_m["compat"][m4.group(1)] = m4.group(2)
continue
m5 = re.match(r"^ input:\s*\[(.+)\]", line)
if m5:
cur_m["input"] = [x.strip() for x in m5.group(1).split(",")]
continue
if re.match(r"^ reasoningEfforts:\s*$", line):
cur_m["reasoningEfforts"] = {}
continue
m6 = re.match(r"^ (off|low|medium|high):\s*(\S+)?", line)
if m6 and isinstance(cur_m.get("reasoningEfforts"), dict):
cur_m["reasoningEfforts"][m6.group(1)] = (m6.group(2) or "").lower() == "null" and None or m6.group(2)
if cur_p and cur:
providers[cur_p] = cur
return {"llm-pi-ai": {"providers": providers}}
def iter_models(cfg: dict):
provs = ((cfg.get("llm-pi-ai") or {}).get("providers") or {})
for pname, p in provs.items():
for m in p.get("models") or []:
yield pname, p, m
def _post(url: str, key: str, payload: dict, timeout: int = TIMEOUT) -> tuple[int, dict, str]:
body = json.dumps(payload, ensure_ascii=False).encode()
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"},
method="POST",
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "replace")
return resp.status, json.loads(raw), f"{(time.time() - t0) * 1000:.0f}ms"
except Exception as exc:
ms = f"{(time.time() - t0) * 1000:.0f}ms"
code = getattr(exc, "code", "")
return -1, {"_error": f"{type(exc).__name__}: {str(exc)[:200]}", "_code": code}, ms
def probe_baseline(base: str, key: str, model: str, api: str):
if api == "openai-responses":
payload = {"model": model, "input": "只回复:OK"}
st, data, ms = _post(base.rstrip("/") + "/responses", key, payload)
text = ""
try:
out = data.get("output") or []
for item in out:
for c in item.get("content") or []:
text += c.get("text", "")
except Exception:
pass
ok = st == 200 and bool(text.strip())
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else str(data.get("_error", data))[:200]}
payload = {"model": model, "messages": [{"role": "user", "content": "只回复:OK"}],
"max_tokens": 16}
st, data, ms = _post(base.rstrip("/") + "/chat/completions", key, payload)
text = ""
try:
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
except Exception:
pass
ok = st == 200 and bool((text or "").strip())
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else str(data.get("_error", data))[:200]}
def probe_thinking(base: str, key: str, model: str, api: str, compat: dict, efforts: dict | None):
"""按 thinkingFormat 发推理参数;验证 reasoning 字段返回。无声明 → skip。"""
fmt = (compat or {}).get("thinkingFormat")
if not fmt or not efforts:
return {"pass": None, "ms": "-", "status": "-", "err": "skip: 无 thinking 声明"}
hi = (efforts or {}).get("high")
extra: dict = {}
if fmt == "zai":
extra = {"enable_thinking": True, **({"reasoning_effort": hi} if hi else {})}
elif fmt == "qwen":
extra = {"enable_thinking": True, **({"reasoning_effort": hi or "xhigh"} if True else {})}
else:
extra = {"enable_thinking": True}
if api == "openai-responses":
payload = {"model": model, "input": "5-2=3,还剩3;2*3-3=3,新买3;3+3=? 只给出数字答案。", **extra}
st, data, ms = _post(base.rstrip("/") + "/responses", key, payload)
raw = json.dumps(data, ensure_ascii=False)
has_reason = "reasoning" in raw
text = ""
try:
for item in data.get("output") or []:
for c in item.get("content") or []:
text += c.get("text", "")
except Exception:
pass
ok = st == 200 and has_reason and ("6" in text)
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else f"reasoning={'有' if has_reason else '无'} 答={'6' in text} " + str(data.get("_error", ""))[:120]}
payload = {"model": model,
"messages": [{"role": "user", "content": "5-2=3,还剩3;2*3-3=3,新买3;3+3=? 只给出数字答案。"}],
"max_tokens": 256, **extra}
st, data, ms = _post(base.rstrip("/") + "/chat/completions", key, payload)
raw = json.dumps(data, ensure_ascii=False)
has_reason = "reasoning" in raw
text = ""
try:
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
except Exception:
pass
ok = st == 200 and has_reason and ("6" in text)
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else f"reasoning={'有' if has_reason else '无'} 答={'6' in text} " + str(data.get("_error", ""))[:120]}
def probe_vision(base: str, key: str, model: str, api: str, inputs: list):
"""视觉:内嵌 64px 纯红 PNG 问主色。未声明 image → skip。"""
if "image" not in (inputs or []):
return {"pass": None, "ms": "-", "status": "-", "err": "skip: 未声明 image 输入"}
img = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + red_png_b64()}}
if api == "openai-responses":
payload = {"model": model, "input": [{"role": "user", "content": [
{"type": "input_text", "text": "这张纯色图的主色是什么?只用一个词回答。"},
{"type": "input_image", "image_url": "data:image/png;base64," + red_png_b64()},
]}]}
st, data, ms = _post(base.rstrip("/") + "/responses", key, payload)
text = json.dumps(data, ensure_ascii=False)
ok = st == 200 and ("红" in text or "red" in text.lower())
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else str(data.get("_error", text[:200]))[:200]}
payload = {"model": model, "max_tokens": 32, "messages": [
{"role": "user", "content": [
{"type": "text", "text": "这张纯色图的主色是什么?只用一个词回答。"},
img,
]}]}
st, data, ms = _post(base.rstrip("/") + "/chat/completions", key, payload)
text = ""
try:
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
except Exception:
pass
ok = st == 200 and ("红" in (text or "") or "red" in (text or "").lower())
return {"pass": ok, "ms": ms, "status": st,
"err": "" if ok else (str(data.get("_error", text))[:200])}
def run(provider_filter: str | None = None):
cfg = load_settings()
rows = []
for pname, p, m in iter_models(cfg):
if provider_filter and pname != provider_filter:
continue
mid = m.get("id", "?")
api = p.get("api", "openai-completions")
base = p.get("baseURL", "")
key = os.environ.get(p.get("apiKeyEnv", ""), "")
if not key:
rows.append({"provider": pname, "model": mid, "api": api,
"baseline": {"pass": None, "ms": "-", "status": "-", "err": "skip: 无 key(未设 " + p.get("apiKeyEnv", "?") + ")"},
"thinking": {"pass": None, "ms": "-", "status": "-", "err": "skip: 无 key"},
"vision": {"pass": None, "ms": "-", "status": "-", "err": "skip: 无 key"}})
continue
compat = m.get("compat") or {}
efforts = m.get("reasoningEfforts")
inputs = m.get("input") or ["text"]
try:
b = probe_baseline(base, key, mid, api)
except Exception as exc:
b = {"pass": False, "ms": "-", "status": -1, "err": f"{type(exc).__name__}: {exc}"[:200]}
try:
t = probe_thinking(base, key, mid, api, compat, efforts)
except Exception as exc:
t = {"pass": False, "ms": "-", "status": -1, "err": f"{type(exc).__name__}: {exc}"[:200]}
try:
v = probe_vision(base, key, mid, api, inputs)
except Exception as exc:
v = {"pass": False, "ms": "-", "status": -1, "err": f"{type(exc).__name__}: {exc}"[:200]}
rows.append({"provider": pname, "model": mid, "api": api,
"baseline": b, "thinking": t, "vision": v})
return rows
def verdict(row: dict) -> str:
vals = [row[k].get("pass") for k in ("baseline", "thinking", "vision")]
if all(v is True or v is None for v in vals) and any(v is True for v in vals):
skips = sum(1 for v in vals if v is None)
return "pass" if skips == 0 else "pass-skip"
if any(v is False for v in vals):
return "fail"
return "skip"
def write_reports(rows: list) -> tuple[Path, Path]:
REPORT_DIR.mkdir(parents=True, exist_ok=True)
day = _dt.date.today().isoformat()
jpath = REPORT_DIR / f"probe-{day}.json"
mpath = REPORT_DIR / f"probe-{day}.md"
payload = {"date": day, "total": len(rows),
"pass": 0, "fail": 0, "skip": 0,
"rows": [{**r, "verdict": verdict(r)} for r in rows]}
for r in payload["rows"]:
if r["verdict"].startswith("pass"):
payload["pass"] += 1
elif r["verdict"] == "fail":
payload["fail"] += 1
else:
payload["skip"] += 1
# 与上次对比(能力变化告警方向)
prev = sorted(REPORT_DIR.glob("probe-*.json"))
prev = [p for p in prev if p.name != jpath.name]
diff_lines: list[str] = []
if prev:
last = json.loads(prev[-1].read_text(encoding="utf-8"))
by_key = {(r["provider"], r["model"]): r.get("verdict") for r in last.get("rows", [])}
for r in payload["rows"]:
old = by_key.get((r["provider"], r["model"]))
if old and old != r["verdict"]:
diff_lines.append(f'- {r["provider"]}/{r["model"]}: {old} → **{r["verdict"]}**')
payload["diff_from"] = prev[-1].name if prev else None
payload["diff"] = diff_lines
jpath.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
alert = "⚠️ 能力变化告警" if diff_lines else "无能力变化"
if payload["fail"]:
alert += f'|{payload["fail"]} 个模型失败'
lines = [f"# 模型探测报告 {day}", "", f"> {alert}", "",
f"总 {payload['total']} · 通过 {payload['pass']} · 失败 {payload['fail']} · 全跳过 {payload['skip']}",
"", "| 分组 | 模型 | 基线 | 思考 | 视觉 | 结论 |",
"|---|---|---|---|---|---|"]
sym = {True: "✅", False: "❌", None: "⏭️"}
for r in payload["rows"]:
b, t, v = r["baseline"], r["thinking"], r["vision"]
lines.append(f"| {r['provider']} | {r['model']} | {sym[b['pass']]}{b['ms']} "
f"| {sym[t['pass']]}{t['ms']} | {sym[v['pass']]}{v['ms']} | {r['verdict']} |")
fails = [r for r in payload["rows"] if r["verdict"] == "fail"]
if fails:
lines += ["", "## 失败明细(错误摘要,不含 key)"]
for r in fails:
for k in ("baseline", "thinking", "vision"):
if r[k].get("pass") is False:
lines.append(f"- {r['provider']}/{r['model']} {k}: [{r[k].get('status')}] {r[k].get('err', '')[:150]}")
if diff_lines:
lines += ["", f"## 与上次对比({payload['diff_from']})"] + diff_lines
else:
lines += ["", "## 与上次对比", "无变化" if prev else "(首次报告,无基线)"]
lines += ["", "## 配置建议(需人执行,本脚本不写配置)",
"失败/变化的模型请对照 2026-09-06 手动结论复核后再改 settings.yaml。"]
mpath.write_text("\n".join(lines) + "\n", encoding="utf-8")
return jpath, mpath
def check_readonly() -> int:
"""自证无 settings 写入路径:源码中不得出现写模式 open/settings 写入调用。"""
src = Path(__file__).read_text(encoding="utf-8")
bad = []
in_checker = False
for i, line in enumerate(src.splitlines(), 1):
s = line.strip()
if s.startswith("def check_readonly"):
in_checker = True
elif in_checker and s.startswith("def "):
in_checker = False
if in_checker:
continue # 跳过检查器自身的字面量(否则自指误报)
if s.startswith("#") or s.startswith('"""') or s.startswith("'''"):
continue
if "SETTINGS" in line and ("\"w\"" in line or "'w'" in line or "\"a\"" in line or "'a'" in line):
bad.append((i, line.strip()))
if "settings.yaml" in line.lower() and (".write" in line or "yaml.dump" in line or "safe_dump" in line):
bad.append((i, line.strip()))
if bad:
print("发现疑似写入路径:")
for i, l in bad:
print(f" L{i}: {l}")
return 1
opens = [l.strip() for l in src.splitlines() if "open(SETTINGS" in l]
print("settings 打开点(应仅 mode='r'):")
for l in opens:
print(f" {l}")
print("readonly-check: PASS(无写入路径)")
return 0
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="DSH 模型能力自动探测(只报告不写配置)")
ap.add_argument("--all", action="store_true", help="全量探测")
ap.add_argument("--provider", default="", help="只跑指定分组")
ap.add_argument("--compare", action="store_true", help="打印与上次报告的对比")
ap.add_argument("--check-readonly", action="store_true", help="自证无 settings 写入路径")
args = ap.parse_args(argv)
if args.check_readonly:
return check_readonly()
if not args.all and not args.provider:
ap.print_help()
return 2
rows = run(args.provider or None)
jp, mp = write_reports(rows)
print(f"报告:{jp}\n报告:{mp}")
if args.compare:
data = json.loads(jp.read_text(encoding="utf-8"))
print(f"对比基线:{data.get('diff_from') or '无(首次)'}")
for d in data.get("diff", []) or ["无变化"]:
print(f" {d}")
fails = sum(1 for r in rows if verdict(r) == "fail")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))