Повторяемый бенчмарк scripts/bench_triage.py: 8 реальных кейсов триажа, детерминированная рубрика качества, GLM через 9router (цена по токенам, тарифы откалиброваны МНК по usageHistory 14-18.10: $0.51/M вх + $8.46/M вых), Haiku через claude -p как движок тика. Итог: качество паритет (0.993 vs 0.983), GLM быстрее (4.45с vs 6.09с), но Haiku cash=0 по подписке. Рекомендация: Haiku на мин-тике, конфиг не менялся. Тесты: tests/test_bench_triage.py (9 passed).
390 lines
21 KiB
Python
390 lines
21 KiB
Python
#!/usr/bin/env python
|
||
"""Бенчмарк моделей минутного триажа: GLM 5.3 (9router) vs Haiku 5.5 (Claude Code).
|
||
|
||
Задача t-261018213319-5a165e: один и тот же батч реальных TG-входящих на обеих
|
||
моделях, сравнение качества/времени/стоимости. Цифры — только из измерений:
|
||
- GLM: latency + tokens из ответа /v1/chat/completions, cost — из usageHistory
|
||
роутера (окно вызова, ключ харнеса, model=glm-5.3);
|
||
- Haiku: `claude -p --output-format json` (тот же вызов, что и тик
|
||
cc_backend.py), tokens из usage; cost — подписка Claude Code (маржинальный
|
||
кэш-расход = 0 у.е., фиксируем как cost_cash=0, cost_sub_tokens=<tokens>).
|
||
|
||
Качество — детерминированная рубрика score_case(): по каждому кейсу задан список
|
||
обязательных паттернов (regex) + универсальные правила (краткость, отсутствие
|
||
вредных обещаний). Без LLM-судьи, воспроизводимо.
|
||
|
||
Запуск: ./venv/bin/python scripts/bench_triage.py --out artifacts/bench_triage.json [--rounds 2]
|
||
Флаг --dry-run печатает корпус и рубрику без вызовов моделей.
|
||
"""
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
|
||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, REPO)
|
||
|
||
ROUTER_DB = os.environ.get("SIMBIOT_ROUTER_DB", "/root/.9router/db/data.sqlite")
|
||
|
||
# ---------------------------------------------------------------- корпус -----
|
||
# Реальные входящие Alex (store, 17–18.10.2026) + классы триажа. Паттерны рубрики
|
||
# подобраны так, чтобы проверять СУЩЕСТВО ответа, а не формулировку.
|
||
CASES = [
|
||
{
|
||
"id": "c1_password",
|
||
"cls": "факт-запрос (пароль)",
|
||
"text": "какой пароль от https://router.cil.by/l",
|
||
"context": "Пароль админки роутера 9router: srax7JompNeELsdRUoV3 (логин admin; файл /root/9router-access.md). Есть ssh-туннель.",
|
||
"patterns": [r"srax7JompNeELsdRUoV3", r"admin"],
|
||
"anti": [r"не знаю", r"не могу найти"],
|
||
},
|
||
{
|
||
"id": "c2_model_choice",
|
||
"cls": "вопрос-сравнение (модели)",
|
||
"text": "ну а как думаешь какая модель лучше у тебя будет работать glm 5.3 или хайку?",
|
||
"context": "Факты: движок минутного тика сегодня переехал на claude -p (модель Haiku 5.5); до этого тики шли через 9router c GLM. Сравнительных измерений качества GLM vs Haiku пока нет.",
|
||
"patterns": [r"haiku", r"glm"],
|
||
# честность: не выдумывать сравнение, которого не делали
|
||
"honesty": r"(не сравнивал|нет данных|не измерял|честн|проверим|померить|бенчмарк|сравню)",
|
||
"anti": [],
|
||
},
|
||
{
|
||
"id": "c3_improve",
|
||
"cls": "открытый вопрос (самоулучшение)",
|
||
"text": "ок предложи улучшения себя",
|
||
"context": "Ты — агент харнеса, отвечаешь за триаж TG, задачи, звонки и реестры портала.",
|
||
"patterns": [r"1", r"2"], # структурированный список
|
||
"anti": [],
|
||
},
|
||
{
|
||
"id": "c4_who",
|
||
"cls": "вопрос о проекте",
|
||
"text": "чем мы занимаемся в целом знаешь?",
|
||
"context": "Кооператив 42: стартап-студия и продукты; центральная идея — Simbiot, общий агентный harness.",
|
||
"patterns": [r"(кооператив|42)", r"(simbiot|харнес|harness)"],
|
||
"anti": [],
|
||
},
|
||
{
|
||
"id": "c5_ack",
|
||
"cls": "короткая реплика (диалог)",
|
||
"text": "Гуд",
|
||
"context": "Предыдущий ответ Alex оценил положительно, отдельного вопроса нет.",
|
||
"patterns": [r"."], # любой ответ
|
||
"short_only": True, # не должен разворачивать работу из «Гуд»
|
||
"anti": [r"задач[ау]"],
|
||
},
|
||
{
|
||
"id": "c6_incident",
|
||
"cls": "инцидент (панель, 401)",
|
||
"text": "все равно ошибка ✻ 401 Invalid API key · Retrying in 2s · attempt 5/10",
|
||
"context": "Панель https://simbiot.ui.shkor.com/ шлёт 401 после правки настроек; фикс делался, но проблема вернулась.",
|
||
"patterns": [r"(провер|смотр|ищу|разбер|копа)"],
|
||
"anti": [r"исправил(а)? окончательно"],
|
||
},
|
||
{
|
||
"id": "c7_task",
|
||
"cls": "большая просьба (мерить модели)",
|
||
"text": "ну кстати норм задача померить что лучше будет работать",
|
||
"context": "Идея: один и тот же батч триажа на GLM 5.3 и Haiku 5.5, сравнить ответы и расход.",
|
||
"patterns": [r"(беру|сделаю|запущ|прогон|меря|измер|задач)"],
|
||
"anti": [],
|
||
},
|
||
{
|
||
"id": "c8_greeting",
|
||
"cls": "служебное/приветствие",
|
||
"text": "тест",
|
||
"context": "Одно слово без вопроса.",
|
||
"patterns": [r"."],
|
||
"no_task": True, # НЕ подавать задачу из служебного сообщения
|
||
"anti": [r"задач[ауу] (создан|подан)"],
|
||
},
|
||
]
|
||
|
||
# Мандат минутного тика — сокращённая выжимка из harness/levels.py (studio-профиль):
|
||
# тон, сначала-ответ-потом-работа, не создавать задачи из служебных, честность.
|
||
SYSTEM = (
|
||
"Ты — минутный пульс харнеса Simbiot. В Telegram ты — Коди: говори о себе в "
|
||
"первом лице. Триаж входящих TG-сообщений: на каждое сообщение владельца (Alex) "
|
||
"нужно ответить в этом же тике. Тон: кратко, по-русски, тепло. Не выдумывай "
|
||
"фактов: если данных нет — скажи прямо. Не создавай задачи из служебных "
|
||
"сообщений («тест», «привет», «ок»). Быстрые просьбы выполняй сразу и отвечай "
|
||
"результатом; большие — подтверди, что берёшь в работу. Отвечай ТОЛЬКО "
|
||
"готовым текстом ответа в чат (JSON: {\"reply\": \"...\"}), без команд и без "
|
||
" markdown-заголовков."
|
||
)
|
||
|
||
|
||
def build_user_prompt(case: dict) -> str:
|
||
return (f"КОНТЕКСТ ТИКА (факты, которым можно доверять):\n{case['context']}\n\n"
|
||
f"ВХОДЯЩЕЕ ОТ Alex: {case['text']}\n\n"
|
||
f"Ответь JSON {{\"reply\": \"...\"}}.")
|
||
|
||
|
||
# --------------------------------------------------------------- рубрика -----
|
||
def score_case(case: dict, reply: str) -> dict:
|
||
"""Детерминированная оценка ответа по кейсу. Возвращает {score, checks[]}."""
|
||
checks = []
|
||
ok = 0
|
||
total = 0
|
||
text = (reply or "").strip()
|
||
|
||
def add(name, cond):
|
||
nonlocal ok, total
|
||
total += 1
|
||
if cond:
|
||
ok += 1
|
||
checks.append({"check": name, "ok": bool(cond)})
|
||
|
||
valid_json = False
|
||
m = re.search(r'\{[\s\S]*"reply"[\s\S]*\}', text)
|
||
inner = text
|
||
if m:
|
||
try:
|
||
inner = json.loads(m.group(0))["reply"]
|
||
valid_json = True
|
||
except Exception:
|
||
inner = text
|
||
add("формат: JSON с reply", valid_json)
|
||
body = inner.strip()
|
||
|
||
for p in case.get("patterns", []):
|
||
add(f"паттерн {p[:40]}", re.search(p, body, re.I) is not None)
|
||
for p in case.get("anti", []):
|
||
add(f"АНТИ {p[:40]}", re.search(p, body, re.I) is None)
|
||
if case.get("honesty"):
|
||
add("честность (нет данных — так и says)", re.search(case["honesty"], body, re.I) is not None)
|
||
if case.get("short_only"):
|
||
add("краткость на реплику-ack", len(body) <= 400)
|
||
if case.get("no_task"):
|
||
add("не создал задачу из служебного", not re.search(r"задач", body, re.I))
|
||
add("краткость общая (≤1500 зн.)", len(body) <= 1500)
|
||
add("не пусто", len(body) >= 2)
|
||
return {"score": round(ok / total, 3), "ok": ok, "total": total, "checks": checks}
|
||
|
||
|
||
# ------------------------------------------------------------- движки --------
|
||
def _router_cfg():
|
||
from harness import paths
|
||
key = base = None
|
||
for line in open(paths.env_path(), encoding="utf-8"):
|
||
if line.startswith("ROUTER_KEY="):
|
||
key = line.strip().split("=", 1)[1]
|
||
if line.startswith("ROUTER_BASE_URL="):
|
||
base = line.strip().split("=", 1)[1]
|
||
return key, (base or "https://router.cil.by/v1").rstrip("/")
|
||
|
||
|
||
def _parse_sse_or_json(raw: bytes) -> dict:
|
||
"""9router на /chat/completions отдаёт SSE ('data: {...}data: [DONE]') даже
|
||
без stream:true. Парсим оба формата; при дельтах склеиваем content."""
|
||
txt = raw.decode("utf-8", errors="replace").strip()
|
||
if not txt.startswith("data:"):
|
||
return json.loads(txt) # обычный JSON-ответ
|
||
content, usage, last = [], {}, {}
|
||
for line in txt.splitlines():
|
||
line = line.strip()
|
||
if not line.startswith("data:"):
|
||
continue
|
||
payload = line[5:].strip()
|
||
if payload in ("[DONE]", ""):
|
||
continue
|
||
try:
|
||
d = json.loads(payload)
|
||
except Exception:
|
||
continue
|
||
last = d
|
||
if "usage" in d and d["usage"]:
|
||
usage = d["usage"]
|
||
for ch in d.get("choices") or []:
|
||
msg = ch.get("message") or ch.get("delta") or {}
|
||
if msg.get("content"):
|
||
content.append(msg["content"])
|
||
if ch.get("finish_reason"):
|
||
last["finish_reason"] = ch["finish_reason"]
|
||
return {"choices": [{"finish_reason": last.get("finish_reason"),
|
||
"message": {"content": "".join(content)}}],
|
||
"usage": usage or (last.get("usage") or {})}
|
||
|
||
|
||
def call_glm(system: str, user: str, model: str = "glm/glm-5.3"):
|
||
"""Один вызов GLM через 9router. cost берём из usageHistory по окну вызова."""
|
||
key, base = _router_cfg()
|
||
t0 = time.time()
|
||
req = urllib.request.Request(
|
||
base + "/chat/completions",
|
||
data=json.dumps({"model": model, "temperature": 0.3, "stream": False,
|
||
"messages": [{"role": "system", "content": system},
|
||
{"role": "user", "content": user}]}).encode(),
|
||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
||
lat = err = None
|
||
reply, usage = "", {}
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=180) as r:
|
||
d = _parse_sse_or_json(r.read())
|
||
reply = d["choices"][0]["message"]["content"] or ""
|
||
usage = {"prompt_tokens": d.get("usage", {}).get("prompt_tokens"),
|
||
"completion_tokens": d.get("usage", {}).get("completion_tokens"),
|
||
"reasoning_tokens": ((d.get("usage") or {}).get("completion_tokens_details") or {}).get("reasoning_tokens")}
|
||
except Exception as e: # noqa: BLE001
|
||
err = str(e)[:200]
|
||
lat = round(time.time() - t0, 2)
|
||
# основная цена — по токенам вызова (детерминирована); окно БД — кросс-чек
|
||
cost = _glm_cost_by_tokens(usage.get("prompt_tokens"), usage.get("completion_tokens"))
|
||
window = _router_cost_window(model.split("/")[-1], t0, time.time() + 1.0)
|
||
return {"reply": reply, "latency_s": lat, "usage": usage, "cost_router": cost,
|
||
"cost_window_crosscheck": window, "error": err}
|
||
|
||
|
||
# Тарифы glm-5.3 в 9router — МНК-регрессия суточных cost по токенам usageHistory,
|
||
# 14–18.10.2026 (остатки ±0.3 у.е. на суточных 30–90 у.е.):
|
||
# cost = 5.084e-07*prompt + 8.464e-06*completion
|
||
# В отличие от cost-окна по времени (ловит параллельный трафик других агентов),
|
||
# расчёт по токенам конкретного вызова не зависит от того, что делал флот рядом.
|
||
GLM_PRICE_PROMPT = 5.084e-07
|
||
GLM_PRICE_COMPLETION = 8.464e-06
|
||
|
||
|
||
def _router_cost_window(model: str, t0: float, t1: float) -> float:
|
||
"""Сырая сумма cost в usageHistory за окно вызова (наш ключ, эта модель).
|
||
НЕТЬЮТРАЛЬНО: ловит параллельные запросы флота в том же окне — только как
|
||
кросс-чек, основная цена — _glm_cost_by_tokens.
|
||
Формат timestamp в БД — ISO с 'T' (2026-10-18T21:33:00), пишем так же."""
|
||
try:
|
||
db = sqlite3.connect(f"file:{ROUTER_DB}?mode=ro", timeout=5, uri=True)
|
||
w0 = datetime.fromtimestamp(t0 - 2, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||
w1 = datetime.fromtimestamp(t1 + 2, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||
(c,) = db.execute(
|
||
"SELECT ROUND(SUM(cost),5) FROM usageHistory "
|
||
"WHERE model=? AND timestamp BETWEEN ? AND ?", (model, w0, w1)).fetchone()
|
||
db.close()
|
||
return round(c or 0.0, 5)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def _glm_cost_by_tokens(prompt_tokens: int, completion_tokens: int) -> float:
|
||
"""Цена конкретного вызова glm-5.3 по тарифам роутера (см. константы)."""
|
||
return round((prompt_tokens or 0) * GLM_PRICE_PROMPT
|
||
+ (completion_tokens or 0) * GLM_PRICE_COMPLETION, 5)
|
||
|
||
|
||
def call_haiku(system: str, user: str, model: str = "claude-haiku-5-5"):
|
||
"""Один вызов Haiku через claude -p — как движок тика (cc_backend.py)."""
|
||
t0 = time.time()
|
||
try:
|
||
p = subprocess.run(
|
||
["claude", "-p", "--model", model, "--output-format", "json", "--no-session-persistence"],
|
||
input=f"{system}\n\n{user}", capture_output=True, text=True, timeout=240,
|
||
cwd=REPO)
|
||
lat = round(time.time() - t0, 2)
|
||
if p.returncode != 0:
|
||
return {"reply": "", "latency_s": lat, "usage": {}, "cost_router": 0.0,
|
||
"error": (p.stderr or "rc!=0")[:200]}
|
||
d = json.loads(p.stdout)
|
||
u = d.get("usage") or {}
|
||
# cost_usd: у подписки Claude Code маржинальный cash = 0, но CLI честно
|
||
# отдаёт list-цену total_cost_usd — сохраняем её отдельно для сравнения,
|
||
# как если бы платили по прайсу (cash при подписке не списывается)
|
||
return {"reply": d.get("result") or "", "latency_s": lat,
|
||
"usage": {"prompt_tokens": u.get("input_tokens"),
|
||
"completion_tokens": u.get("output_tokens"),
|
||
"cache_read": u.get("cache_read_input_tokens"),
|
||
"cache_write": u.get("cache_creation_input_tokens"),
|
||
"thinking": ((u.get("output_tokens_details") or {}).get("thinking_tokens"))},
|
||
"cost_router": 0.0, "cost_usd_list": d.get("total_cost_usd"),
|
||
"error": None}
|
||
except Exception as e: # noqa: BLE001
|
||
return {"reply": "", "latency_s": round(time.time() - t0, 2), "usage": {},
|
||
"cost_router": 0.0, "error": str(e)[:200]}
|
||
|
||
|
||
ENGINES = {"glm": call_glm, "haiku": call_haiku}
|
||
|
||
|
||
# ----------------------------------------------------------------- main ------
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--out", default=None)
|
||
ap.add_argument("--rounds", type=int, default=2)
|
||
ap.add_argument("--engines", default="glm,haiku")
|
||
ap.add_argument("--only", default=None, help="id кейсов через запятую")
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
args = ap.parse_args()
|
||
|
||
cases = CASES
|
||
if args.only:
|
||
want = {s.strip() for s in args.only.split(",")}
|
||
cases = [c for c in CASES if c["id"] in want]
|
||
|
||
if args.dry_run:
|
||
for c in cases:
|
||
print(f"[{c['id']}] {c['cls']}: {c['text'][:60]!r} "
|
||
f"patterns={[p[:30] for p in c.get('patterns', [])]}")
|
||
print(f"кейсов: {len(cases)}; движки: {args.engines}; раундов: {args.rounds}")
|
||
return
|
||
|
||
results = {"started": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||
"rounds": args.rounds, "cases": [], "meta": {"glm_model": "glm/glm-5.3",
|
||
"haiku_model": "claude-haiku-5-5"}}
|
||
for rnd in range(1, args.rounds + 1):
|
||
for c in cases:
|
||
prompt = build_user_prompt(c)
|
||
for eng in [e.strip() for e in args.engines.split(",") if e.strip()]:
|
||
r = ENGINES[eng](SYSTEM, prompt)
|
||
sc = score_case(c, r.get("reply") or "")
|
||
row = {"case": c["id"], "cls": c["cls"], "round": rnd, "engine": eng,
|
||
**r, "quality": sc}
|
||
results["cases"].append(row)
|
||
print(f"r{rnd} {eng:6s} {c['id']:16s} q={sc['score']:.2f} "
|
||
f"t={r['latency_s']:5.1f}s cost={r.get('cost_router', 0):.5f}"
|
||
+ (f" ERR={r['error']}" if r.get("error") else ""))
|
||
|
||
# агрегат по движкам
|
||
agg = {}
|
||
for eng in [e.strip() for e in args.engines.split(",") if e.strip()]:
|
||
rows = [r for r in results["cases"] if r["engine"] == eng and not r.get("error")]
|
||
errs = len([r for r in results["cases"] if r["engine"] == eng and r.get("error")])
|
||
agg[eng] = {
|
||
"n": len(rows), "errors": errs,
|
||
"quality_avg": round(sum(r["quality"]["score"] for r in rows) / max(len(rows), 1), 3),
|
||
"quality_per_case": {c["id"]: round(
|
||
sum(r["quality"]["score"] for r in rows if r["case"] == c["id"]) /
|
||
max(len([r for r in rows if r["case"] == c["id"]]), 1), 3) for c in cases},
|
||
"latency_avg_s": round(sum(r["latency_s"] for r in rows) / max(len(rows), 1), 2),
|
||
"latency_p95_s": round(sorted(r["latency_s"] for r in rows)[
|
||
max(0, int(len(rows) * 0.95) - 1)] if rows else 0, 2),
|
||
"prompt_tokens_sum": sum((r["usage"] or {}).get("prompt_tokens") or 0 for r in rows),
|
||
"completion_tokens_sum": sum((r["usage"] or {}).get("completion_tokens") or 0 for r in rows),
|
||
# эффективные токены: input + кэш-чтение/запись + output (для Haiku
|
||
# system-промпт уходит в кэш и в input_tokens почти не попадает)
|
||
"eff_tokens_sum": sum(
|
||
((r["usage"] or {}).get("prompt_tokens") or 0)
|
||
+ ((r["usage"] or {}).get("cache_read") or 0)
|
||
+ ((r["usage"] or {}).get("cache_write") or 0)
|
||
+ ((r["usage"] or {}).get("completion_tokens") or 0) for r in rows),
|
||
"cost_router_sum": round(sum(r.get("cost_router") or 0 for r in rows), 5),
|
||
"cost_usd_list_sum": round(sum(r.get("cost_usd_list") or 0 for r in rows), 6),
|
||
}
|
||
results["aggregate"] = agg
|
||
results["finished"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
out = args.out or os.path.join(REPO, "artifacts",
|
||
f"bench_triage_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M')}.json")
|
||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||
with open(out, "w", encoding="utf-8") as f:
|
||
json.dump(results, f, ensure_ascii=False, indent=1)
|
||
print(f"\n=== {out}")
|
||
for eng, a in agg.items():
|
||
print(f"{eng:6s} q={a['quality_avg']:.3f} t_avg={a['latency_avg_s']}s "
|
||
f"cost_router={a['cost_router_sum']} p_tok={a['prompt_tokens_sum']} "
|
||
f"c_tok={a['completion_tokens_sum']} err={a['errors']}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|