Skip to content

Latest commit

 

History

History
315 lines (251 loc) · 27.5 KB

File metadata and controls

315 lines (251 loc) · 27.5 KB

Ревью llm-bench

Дата: 2026-07-03 · состояние кода: commit 5ba285b · номера строк относятся к коду ДО исправлений.

Как проводилось. Многоагентное ревью: 5 независимых ревьюеров с разными линзами (методология/статистика, скоринг, движки против документации API, эксплуатация ранера, доки/тесты) → слияние и дедупликация → состязательная верификация каждой находки (верификаторы читали код, запускали llmbench.scoring/fixtures на контрпримерах и сверяли клеймы про API с официальной документацией Anthropic/OpenAI/z.ai). Из 40 кандидатов 37 подтверждено, 3 опровергнуто.

Вердикт. Каркас правильный: фикстуры как единый источник правды, ключевые метрики считаются кодом, честные caveats, детерминированный fixed-режим. Но две критические и ~14 серьёзных проблем искажали или могли молча исказить именно те цифры, ради которых бенчмарк существует (Accuracy, Score, Stability, Pareto).

Статусы исправлений — в конце файла.


Критичные

R1. Safe-fallback молча подменяет конфиг под тем же лейблом

runner.py:57-60, 84-91, 257-263 · найдено 4 ревьюерами независимо

При ошибке _run_case_fb перегонял кейс с thinking=disabled / effort=high / reasoning_effort=None, и результат записывался под исходным лейблом варианта. Флаг FB жил только в stdout — в записи, агрегатах и отчёте его не было. Строка «Sonnet adaptive/low» могла содержать прогоны disabled/high, и по отчёту это не увидеть. Ретрай получали только варианты с не-дефолтными настройками (асимметрия), стоимость упавшей попытки выбрасывалась. В сохранённом прогоне (run-log.txt) FB-случаев не было — текущие опубликованные цифры не задеты, но для будущих прогонов это мина: отвергни провайдер параметр — вся строка молча превращается в safe-режим. Фикс: ретрай тем же конфигом, никакой подмены; ошибки — в отчёт.

R2. ANY-семантика алиасов: зачёт чужих чисел + коллизия фикстур 1230≈1200

scoring.py:58-71 + cases.py:49-52 + fixtures.py:30-31 · подтверждено запуском кода

_alias_near зачитывал число, если любой алиас оказался в окне ±40 символов. У фактов кампания и метрика лежали в одном списке (['Москва','CPA']), поэтому «CPA» в одиночку анкерил число — принадлежность кампании не проверялась вовсе (вопреки докстрингу модуля). Проверенные последствия: ответ с перепутанными местами CPA двух кампаний получает Accuracy 5.0; «средний CPA по аккаунту: 5000» зачитывается как CPA РСЯ (5150±5%). Хуже: клики РСЯ (1230) попадают в допуск CPA Москвы (1200±60), поэтому во флагманском tool_multi_step факт cpa_poisk автозачитывался от чужого числа — колонка Accuracy в этом кейсе не могла поймать пропущенный/неверный CPA Москвы. Фикс: entity-алиас И metric-алиас раздельно, оба обязательны в мультикампейн-кейсах; развести коллизию в фикстурах.

Серьёзные — честность отчёта

R3. Упавшие прогоны скорятся как ответы, а колонки ошибок в отчёте нет

runner.py:94-116, 124-139, 198-207. Ошибка API скорилась как обычный ответ: на forbid_tools-кейсах давала незаслуженные 5.0 по Tools Use (тулы же не вызваны), на остальных — нули, утягивающие среднее. _agg считает errors, но _build_md их не выводит — читатель не отличит «модель слабая» от «у API был плохой день».

R4. Stability измеряет не то, что обещает глоссарий

runner.py:127, 138. pstdev считался по всем записям варианта (кейсы × повторы) — в нём доминирует разброс сложности кейсов, а не «стабильность ответов между повторами». Модель, детерминированно решающая лёгкие кейсы на 5.0 и трудный на 3.0, выглядела «нестабильной». Фикс: σ по повторам внутри кейса, усреднённая по кейсам.

R5. Score не совпадает со своим определением в глоссарии

runner.py:94-100, 176. Глоссарий и оба опубликованных отчёта: «среднее четырёх метрик (Accuracy/Tools Use/Edge Cases/Lang quality)». Фактический _composite = mean(tool, numeric?, soft_quality?) — Lang quality не входила вовсе; состав компонент различался по кейсам; судейская шкала 1–5 смешивалась с кодовой 0–5 (пол у судей — 1, у кода — 0).

R6. Survivorship bias в колонке Edge Cases

runner.py:107, 126. Судьи запускались только при not fail_fast and not error, а колонка Edge усредняла только оценённые записи. Худшие edge-прогоны (модель полезла в тулы на «подними ставку» — ровно то, за что README ругает gpt-5) выпадали из среднего и завышали его: чем больше нарушений, тем выше Edge-балл.

R7. Нонс в системном промпте подсказывает модели суть теста

runner.py:257, engines.py:30-31. В промпт уходило [bench:refuse_change_bid:Sonnet adaptive/high:0] — самоописывающийся id кейса («откажись менять ставку», «задай уточняющий вопрос») плюс собственный конфиг модели. Модели по-разному эксплуатируют такие подсказки → неравные условия ровно на edge-измерении. Фикс: хэш вместо говорящей строки.

R8. Кэш-множитель OpenAI завышал стоимость GPT в 2–5×

core.py:68. В коде CACHE_MULT['openai'] = 0.5×, реально: gpt-5 — 0.10×, gpt-4.1 — 0.25× (проверено по официальному прайсу). Cost per Answer / Score per USD / Pareto систематически смещены против GPT. Множители Anthropic и z.ai корректны — перекос односторонний.

Серьёзные — надёжность прогона

R9. В Anthropic-пути нет ретраев

engines.py:109-112, 136 vs 170. retry_call (5 попыток, экспоненциальный бэкофф) есть у OpenAI-пути и судей, но вызовы client.messages.stream не обёрнуты. Транзиентный 529 у Claude/GLM сразу становился ошибкой → срабатывала подмена конфига (R1).

R10. Финальный вызов после исчерпания итераций гарантированно ловит 400

engines.py:134-141. Ветка if not completed отправляла историю с tool_use/tool_result блоками без параметра tools — Anthropic API это отвергает (сверено с документацией). Заодно терялся thinking-конфиг и не учитывались cache-токены в usage.

R11. max_tokens=4096 включает thinking-токены; обрезка молча считается ответом

engines.py:88, 102, 119-122, core.py:54. У adaptive-thinking токены размышлений входят в max_tokens: thinking-варианты Opus рисковали получать обрезанные/пустые ответы, а stop_reason == "max_tokens" молча трактовался как финальный ответ → незаслуженные нули ровно у thinking-конфигураций, которые бенчмарк сравнивает. GPT при этом гонялся без капа.

R12. Опечатка в --variants/--cases молча запускает полный платный грид

runner.py:229-230. Паттерн [...] or list(VARIANTS): пустой результат фильтра разворачивался в полный список — 288 оплаченных прогонов вместо ошибки.

R13. Нет сырого артефакта: ни пере-скоринга, ни резюма, ни защиты отчёта

runner.py:254-263, 283-285, README.md:78-82. Писался только итоговый markdown: ответы, трейсы, per-judge оценки, usage терялись; после фикса скоринга пересчитать нечего; упавший на 200-м прогоне грид начинался заново; дефолтный --out затирал выверенный отчёт; обещанный README results/run-log.txt ранер не писал (в репо лежит сохранённый вручную stdout от более старой версии кода). Фикс: JSONL per-run + --report-from.

R14. Live-режим: один зависший MCP-сервер вешает весь грид

mcp.py:54-58, engines.py:78. Ни на инициализации сессии, ни на call_tool не было таймаутов; при последовательном исполнении это стоп всего прогона.

R15. Документация описывает удалённую функциональность

README.md:35-38, 72-76, 85, README.en.md, докстринги runner.py:4, tests/test_fixtures.py:4. Секция «Правило решения» ссылается на scoring.DECISION_RULE и PASS/FAIL-печать, которых нет (в scoring.py прямо написано, что правило намеренно убрано); строка структуры обещает «decision rule» в scoring.py; итоги упоминают «SWITCH».

Скоринг чисел (парсер и матчеры)

R16. NBSP и узкий NBSP не распознаются как разделители тысяч

scoring.py:16-17. _SPACES содержал три обычных ASCII-пробела (проверено hexdump). Модели часто форматируют «58 800» через NBSP/узкий NBSP — такое число парсилось как два (58 и 800) → ложные недосчёты Accuracy, неравномерные по моделям (зависят от форматирования).

R17. Absence-чекер зануляет корректные ответы (false positives)

scoring.py:27-28, 91-103. В _DISTRACTORS не было cpc; «CPA рассчитать нельзя (0 конверсий)» ловил «0» как выдуманный CPA; даты и id кампаний в окне после алиаса тоже зануляли полностью правильный ответ (violation → score 0 на весь кейс).

R18. Absence-чекер пропускает типовые формулировки выдуманного CPA (false negatives)

scoring.py:25-26, 94-102, cases.py:92. «Стоимость одной конверсии — 180 ₽» не матчился алиасом «стоимость конверси»; часть формулировок выходила за окно в 25 символов.

R19. US-формат «58,800» парсится как 58.8

scoring.py:17-19, 47. Запятая безусловно заменялась на точку — ложный промах для моделей, пишущих в англо-числовом формате (GPT так делает регулярно).

R20. Алиас 'цел' матчит «в целом»; golden-значение 612 захардкожено дважды

cases.py:102-108, fixtures.py. Подстрочный матч 'цел' зачитывал числа рядом с «в целом»; 612 продублировано в кейсе и в фикстуре (нарушение single source of truth).

R21. score_tooluse засчитывает упавшие вызовы; max_calls по умолчанию 99

scoring.py:124-125, 141, 144. is_error из трейса игнорировался — требуемый тул считался вызванным, даже если все вызовы упали. Дефолт max_calls=99 — фиктивный бюджет.

Судьи

R22. Оценки судей не валидируются

judges.py:72-76, 84-85. _num — голый float(): судья, вернувший 45 (или NaN), безгранично раздувал composite.

R23. Подводные камни состава панели

judges.py:98-108, runner.py:234-237. Один нейтральный вендор схлопывает первичный балл до единственного судьи; --judges neutral без нейтрального ключа молча выключает всё судейство; GPT-судья сам является кандидатом; упавшие судьи молча сжимают панель.

R24. GPT-судья с max_tokens ломается на reasoning-моделях

judges.py:61-62. BENCH_GPT_JUDGE_MODEL=gpt-5 → ошибка max_tokens не поддерживается → судья молча даёт None.

R25. Судьи недетерминированы и на другой шкале

judges.py:49-69. Ни одного temperature=0, один сэмпл на судью, рубрика «1–5» против кодовых 0–5.

Движки и стоимость

R26. Неизвестная модель молча тарифицируется по цене Sonnet

core.py:67, scoring.py:154-155. Добавил новый вариант с опечаткой в id — получил дефолтные (3.0, 15.0) без предупреждения; Cost/Pareto для новых моделей тихо врут.

R27. Ошибочные tool_result не клампятся

engines.py:79-82, core.py:213-215. Огромный error-payload из live-MCP обходил оба слоя обрезки и раздувал контекст/стоимость.

R28. Стоимость упавших попыток выбрасывается

runner.py:73-79, 84-91. Usage ошибочных тёрнов и отброшенных fallback-попыток не попадал в Cost per Answer — флаки-конфиги выглядели дешевле, чем стоили.

R29. При ошибке в середине диалога скорится ответ предыдущего тёрна

runner.py:73-81. break до присваивания answer — на multi-turn кейсе оценка первого ответа против golden-фактов второго вопроса.

R30. Falsy-нули в отчёте

runner.py:129-133, 143-145, 205-207. Легитимный composite 0.0 рисовался как «—» в Score per USD; варианты с cost 0/None выпадали из Pareto; cost=None печатался как $0.00000.

Live-режим и эксплуатация

R31. Отказ Метрики молча проглатывается

engines.py:41-51. except Exception: pass — ошибка конфигурации харнесса скорится как провал модели (metrika-кейс без metrika-тулов = гарантированный ноль).

R32. Нет префлайта live-режима

mcp.py:35-40, 47-49. Отсутствие node_modules/токенов выяснялось по-прогонно, с перезаписью прошлого отчёта нулями.

R33. env для MCP-сервера обрезан до PATH+токен

mcp.py:50-54. Терялись HOME/прокси/CA-переменные, нужные реальным node-серверам.

R34. Строго последовательное исполнение 288+ прогонов

runner.py:249-263. Часы wall-time; в связке с отсутствием персиста и таймаутов — риск потерять всё на середине.

R35. Нет метаданных воспроизводимости; смета врёт; exit 0 при пустом прогоне

runner.py:186-191, 243-253, 268-282. В отчёте нет git-коммита; смета в шапке включает скипнутые варианты; при полном отсутствии ключей ранер завершался успешно с пустым отчётом.

R36. Зависимости не запинены

requirements.txt, requirements-dev.txt. anthropic/openai/mcp без версий; локально pytest 9.1/py3.13 против CI pytest 8/py3.11.

R37. CI не видит engines/judges/runner

.github/workflows/ci.yml:17, tests/test_fixtures.py:9-14. Тесты импортируют только cases/core/fixtures/mcp/scoring — синтаксическая ошибка в engines.py/runner.py проходит CI зелёным (проверено инъекцией ошибки). Агрегация и обрезка, производящие публикуемые числа, не покрыты ни одним тестом.


Проверено и опровергнуто

  • «GLM thinking молча не работает на z.ai» — по сохранённому прогону thinking-строки GLM отличаются от disabled (стоимость, Accuracy), FB-маркеров нет: thinking реально включался. Мелкая правда внутри: для GLM отправлялся output_config.effort, хотя отчёт показывает «—».
  • «Текстовая история между тёрнами расходится с продом» — верификатор проверил исходники прод-системы (askads): она тоже передаёт между тёрнами только текст. Бенчмарк меряет то поведение.
  • «Кэш-префикс ниже минимума кэшируемости на части моделей» — по актуальной документации и замеру реального префикса (tools+system) проблема не подтверждается.

Статус исправлений

Все 37 находок исправлены (проверено многоагентным верификационным прогоном по диффу: каждая находка перепроверена запуском кода, покрытие — 37/37 fixed). Ключевые правки разнесены по файлам; агрегация/отчёт вынесены в новый report.py и покрыты тестами.

# Находка Статус Где
R1 Подмена конфига в fallback runner._run_case_retried — повтор тем же конфигом
R2 ANY-семантика алиасов + коллизия 1230/1200 entity/metric-анкоринг в scoring; фикстура RSYA clicks→1530
R3 Ошибки скорятся/невидимы report.composite/agg (error→None), колонка Err
R4 Stability report.agg — σ по повторам внутри кейса
R5 Score ≠ глоссарию report.composite (+Lang quality), глоссарий
R6 Edge survivorship судьи и на fail_fast (runner._score)
R7 Нонс-подсказка runner._nonce — sha1-хэш
R8 Кэш-множители OpenAI core.MODEL_CACHE_MULT gpt-5=0.10/gpt-4.1=0.25
R9 Ретраи Anthropic retry_call вокруг stream (engines)
R10 tools в финальном вызове _params(False) — tools + tool_choice none
R11 max_tokens + thinking MAX_OUTPUT_TOKENS_THINKING, stop_reason=max_tokens→error
R12 Фильтры CLI runner._filter_or_die
R13 JSONL-персист runs-*.jsonl + --report-from, дефолт out — generated
R14 Таймауты MCP asyncio.wait_for в engines, core.MCP_*_TIMEOUT
R15 Доки про DECISION_RULE README ru/en переписаны
R16 NBSP scoring._SPACES (NBSP/узкий/тонкий)
R17 Absence false positives _unit_after/_looks_like_date, +cpc
R18 Absence false negatives расширенные алиасы, _GAP_AFTER=30
R19 US-запятая _US_THOUSANDS_RE в parse_numbers
R20 'цел' + 612 алиасы цель/цели/целей; METRIKA_GOAL_REACHES
R21 tooluse is_error/max_calls _ok_names, max_calls без дефолта 99
R22 Валидация оценок судей judges._num (0–5)
R23 Состав панели runner предупреждает, судьи для edge на fail_fast
R24 GPT-судья reasoning max_completion_tokens, без temperature
R25 Детерминизм судей/шкала temperature=0 где можно, шкала 0–5
R26 Тариф неизвестной модели scoring._rates warn
R27 Кламп error-результатов engines._exec_tool клампит и ошибки
R28 Стоимость упавших попыток cost_wasted, agg.cost_total
R29 Ответ прошлого тёрна runner._run_case (error→None метрики)
R30 Falsy-нули report — явные is None/>0 проверки
R31 Metrika swallow engines._assemble warn
R32 Live-префлайт mcp.preflight_live
R33 env MCP mcp._PASSTHROUGH_ENV
R34 Параллелизм runner._run_variant (asyncio.Semaphore)
R35 Метаданные/exit-коды git-коммит в meta, sys.exit на пустом прогоне
R36 Пиннинг зависимостей requirements*.txt (>=), CI закреплён
R37 Покрытие CI CI импортирует все модули + pytest -q; test_report/test_runner_e2e

Регрессии, найденные и исправленные на верификации

Три раунда состязательной верификации (по диффу + два прохода по переписанному скорингу, каждый клейм воспроизведён запуском кода) выявили регрессии, внесённые самими правками — все исправлены и покрыты тестами. Скоринг чисел переписан на принципиальную многослойную модель.

Раунд 1 (по диффу): absence-перекоррекция (дистракторы период/дата/неделя/кампания глушили выдуманный CPA), дефис-диапазон 1500-2000 как «дата», entity в сравнительных ответах (nearest-left ломал «Москва vs РСЯ: 1200 vs 5150»), таблицы → Accuracy 0.0 (метрика в шапке столбца), протечка метрик в прозе, retried over-count.

Раунды 2–3 (скоринг): маркеры «чужих метрик» перетягивали настоящий CPA («8 конверсиям — 5150 ₽»); id кампании ловился как выдуманный CPA; хрупкий разбор таблиц (key-value, in-cell, одиночная труба, шапка над разделителем); списки/эллипсис метрики («Топ по CPA: РСЯ 5150; Москва 1200», «в 4 раза выше»); метка чужой метрики справа («CPA 1200 ₽ (расход …)»); транспонированные таблицы (кампании в шапке столбцов).

Итоговая модель metric/entity-атрибуции (scoring.py):

  1. _wrong_unit — число относится к метрике по СВОЕЙ единице: денежная метрика отвергает count-числа («1530 кликов», «12%»), count-метрика (Метрика) — денежные.
  2. _metric_ok — метка своей метрики есть в тексте, и к числу не ближе метка ЧУЖОЙ метрики (левая приоритетнее — метки стоят перед значением); в таблице — строка/шапка столбца.
  3. позиционное сопоставление кампаний только среди чисел из пула целевых значений (sibling_values) — посторонние числа («в 4 раза») не сдвигают выравнивание.
  4. коллизий значений между метриками фикстур нет (test_no_golden_value_collisions).

Покрытие: test_attribution_comparative_and_table (сравнения, все виды таблиц, транспонированные, списки, вспомогательные числа), test_absence_no_overcorrection, test_retried_flag_only_on_recovery, расширенный test_anchoring_and_absence + широкий smoke-прогон реалистичных ответов моделей.