20 августа я зафиксировал в файле 6 задач и начал задавать claude-opus-5 одни и те же вопросы через Claude Code CLI. Каждый ответ проверяет грейдер. Мне нужна была точка отсчёта для постов “модель на этой неделе сделали глупее”, но файл первым делом показал разницу внутри одного захода. Одна из задач: MRR за 60 месяцев со сложным процентом, где темп роста меняется после 6-го месяца. Код считает 58937.5857. За 64 вызова с 20 августа по 12 сентября модель вернула 21 разное число. На остальных 5 задачах она давала один ответ на задачу. 1 вызов упал на лимите запросов.
Я всегда объяснял это себе температурой. В CLI её не задать, поэтому я ни разу не ставил её в 0 и считал, что при нуле ответ перестанет гулять. Ещё я читал “Defeating Nondeterminism in LLM Inference” Хораса Хе из Thinking Machines, сентябрь 2025. Там сказано, что даже при температуре 0 сервер отдаёт разный текст, потому что его вычислительные ядра (kernels) не инвариантны к размеру батча. А батч зависит от чужого трафика. В это я тоже поверил и тоже не проверял. Поэтому на этот раз я взял модель, в которой ничего не спрятано. В ней я стал искать место, где разница начинается.
Выборка выключена, float32
Qwen2.5 0.5B Instruct, transformers 5.16.1 и torch 2.14.0 на 4 ядрах процессора AMD EPYC, жадное декодирование, то есть никакой выборки. Qwen кладёт в свой generation config штраф за повтор 1.1 и я оставил его включённым во всех прогонах. 8 промптов с длинными ответами, от хеш-таблиц до домашнего хлеба.
Во float32 не сдвинулось ничего. 5 повторов подряд дали те же логиты бит в бит. 1, 2 и 4 потока тоже дали те же биты, чего я не ожидал. Батч из 2, 4 или 8 менял логиты во всех 8 промптах, но только в пятом знаке после запятой. Токены совпали на всех 200 шагах, а во второй серии на всех 400.
Жадное декодирование берёт токен с наибольшей оценкой, значит для развилки зазор до второго токена должен быть меньше шума. Самый маленький зазор в 8 ответах длиной до 400 токенов был 0.000122, на промпте про сумму. В батче из 8 тот же шаг дал 0.000109. Батч ни разу не сдвинул зазор больше чем на 0.00004.
bfloat16
Я загрузил те же веса в bfloat16. Повтор на батче 1 снова дал те же токены, то есть с самой собой модель по-прежнему детерминирована.
У bfloat16 8 бит точности и логит около 17 хранится с шагом 0.125. 2 токена могут получить в точности одинаковую оценку. Во float32 такого не было ни разу за 2782 шага, а в bfloat16 2 верхних токена сравнялись 38 раз за 2709 шагов. Промпт про хлеб начался с ничьей: у Certainly и Sure по 17.625, а во float32 у них было 17.693 и 17.598. Без батча прогон начался с “Sure! Here’s a simple guide”. В батче из 8 он начался с “Certainly! Making bread at home”.
В простом прямом проходе батч сдвигал логиты первого шага не больше чем на 0.000047 во float32 и на 0.29 в bfloat16. Через generate в батче из 8 другим путём пошли 4 промпта из 8. float32 против bfloat16, оба на батче 1, разошлись в 7 промптах из 8. Промпт про сумму просит сложить числа от 1 до 250, делящиеся на 3 или 7, сумма равна 13482. Оба прогона вместо суммы выдали количество. Правильное количество, 107, получилось только у bfloat16, а float32 ошибся на кратных 21 и написал 106.
Тут я застрял. 2 из 4 развилок в батче случились там, где зазор между 2 верхними токенами был 1.375, далеко от любой ничьей. Я не понимал, как шум в 0.29 может такое перевернуть. Ответ лежал в моём же скрипте. Зазоры я брал из сырых логитов, а декодер выбирает после штрафа за повтор. Во float32 токен с наибольшим сырым логитом не совпадал с выбранным в 396 шагах из 2782. Я прогнал серию заново, теперь с зазором после штрафа. Токены вышли те же во всех 32 прогонах. На этих 2 шагах зазор после штрафа был 0.09 без батча, а в батче 0.034 и 0.023. Остальные 2 развилки были точными ничьими.
Обратно к opus
Потом я 20 раз попросил claude-opus-5 объяснить коллизии в хеш-таблице примерно в 150 словах. CLI 2.1.283, thinking выключен, без инструментов, пустой конфиг MCP, простой системный промпт и папка без единого CLAUDE.md выше по дереву. Я получил 20 разных текстов, все начинаются с “A hash table” и говорят про метод цепочек и открытую адресацию. В 132 парах из 190 у 2 текстов общее начало не длиннее 11 слов.
После локальных прогонов я читаю 21 ответ на арифметику как группы, а не как шум. Те вызовы шли через CLI от 2.1.237 до 2.1.269 с включённым thinking. Грейдер допускает относительную ошибку до 1e-6, это около 6 центов. В допуск попали 46 ответов из 64. 31 из них совпадает с верным значением до цента: 16 округлили до 58937.59, 15 отбросили хвост до 58937.58. Из 18 вне допуска 6 лежат около 58928.2, а 7 промахиваются меньше чем на 20 центов.
Разобрать модель в сервисе так, как я разобрал маленькую, я не могу. CLI берёт ту выборку, которая стоит у сервиса, поэтому выборка и батчинг попадают в одно число вместе со всем остальным, что происходит на сервере. Локальные прогоны показывают только то, чего хватает, чтобы текст поменялся при выключенной выборке: ничья в bfloat16 или соседи по батчу. Про арифметику у меня догадка: модель считает её в длинном скрытом рассуждении, от 691 до 6632 выходных токенов на вызов. Один рано разошедшийся шаг потянул бы за собой всё остальное. Ответы ложатся группами и это с догадкой сходится, но я её не проверял.
Для моих собственных инструментов вывод скромный. Грейдер и так сравнивает числа с допуском. Допуск я оставляю. Критик, который оценивает мои черновики, шумит так же. На одном черновике он 30 раз из 30 вынес один и тот же отрицательный вердикт. При этом он назвал 160 разных претензий и 114 из них прозвучали по одному разу.
Чего я не проверял
- GPU. Все локальные прогоны шли на CPU, а вычислительные ядра, о которых пишет Thinking Machines, написаны для GPU. Юань с соавторами (arXiv 2506.09501) мерили, как влияют bf16 против fp32 и размер батча, на GPU с моделями на 7B. Мой результат с bfloat16 это тот же эффект точности на гораздо меньшей модели.
- Модели по API на большом числе вызовов. Атил с соавторами (arXiv 2408.04667) мерили этот разброс на 5 моделях.
- Температуру 0 у модели в сервисе. В CLI такой настройки нет.
- Почему батч в прямом проходе сохраняет ничью в промпте про хлеб на 17.625, а батч из 8 через
generateразбивает её на 0.125. Самому длинному промпту паддинг не нужен и ведёт он себя так же. При прямом проходе батчем он совпадает бит в бит, а черезgenerateрасходится в пятом знаке. - Какое вычисление даёт 58928.2. Это число вернулось 6 раз, так что это, скорее всего, устойчивый неверный путь, но шаг я не нашёл.