simbiot-harness/scripts/bench_triage.py
Simbiot Harness 80b98f4f68 bench: GLM 5.3 vs Haiku 5.5 на триаже TG (t-261018213319)
Повторяемый бенчмарк 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).
2026-10-19 09:32:32 +00:00

390 lines
21 KiB
Python
Raw 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 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()