Лимит, рассчитанный на другую модель
Сегодня Мастер переехал на другую модель.
Выкатили. Ходы пошли нормально. Начать новую игру стало невозможно.
Что именно сломалось
В логе бэкенда:
app.dm.OutputTruncated: the DM could not produce a valid narrate_turn call in 3 attempts:
attempt 1: the narrate_turn call was cut off at the 3000-token cap
attempt 2: the narrate_turn call was cut off at the 3000-token cap
attempt 3: the narrate_turn call was cut off at the 3000-token cap
Модель упиралась в потолок вывода. Три раза подряд, девять тысяч токенов, 331 секунда — и ни одной строчки игроку. Прокси сдавался раньше, на сто двадцатой секунде, так что человек видел «сервер не ответил вовремя» и больше ничего. Настоящая причина не доходила ни до него, ни до нас.
Почему сломалось именно открытие
Открытие сессии — самый большой ответ, который модель пишет за всю игру.
В обычном ходу она возвращает текст, изменения состояния и варианты действий. В открытии к этому добавляется сцена: флаг opening=True переводит поле scene из необязательного в обязательное (contract.py:2643). Так и задумано — это единственный ход, у которого нет предыдущей картинки, чтобы на неё опереться.
А лимит на вывод у этого вызова был общий. Тот самый MAX_TOKENS, рассчитанный на обычный ход (dm.py:828). Три тысячи токенов.
И это работало. Долго. По нашим же записям самое длинное открытие за 58 сессий на одной модели — 878 токенов, за 73 сессии на другой — 1253. Трёх тысяч хватало всем с тройным запасом.
Константа не была ошибкой. Она была верной ровно для тех моделей, на которых её когда-то измерили.
Что сделали
Открытие получило собственный лимит — вдвое больше обычного хода, и выведенный из него, а не записанный отдельным числом:
OPENING_MAX_TOKENS = MAX_TOKENS * 2
Отдельная константа рано или поздно разошлась бы с той, от которой должна зависеть. А так один переключатель двигает оба значения сразу — это важно, когда лимит поднимают на время замера новой модели.
Второе — повторные попытки. Когда ответ обрывается по лимиту, исправлять нечего: модель не ошиблась, ей просто не хватило места. Старый код перезапрашивал тот же вопрос с тем же лимитом три раза подряд. Если перебор случайный, это дешёвая ставка и она обычно выигрывает. Если систематический — это три одинаковых провала по цене трёх вызовов и втрое дольше ожидания. На этом вызове попытка теперь одна.
И это была не вся поломка
Пока чинили лимит, вылезла вторая авария, которую первая всё это время загораживала: провайдер отдавал 429. Мы сидели в общей квоте, которую делят между собой все пользователи роутера, и в пиковые часы её просто не хватало.
Две разные поломки, и какое-то время каждая выглядела как другая. Разобраться помогла неочевидная деталь: отказ по квоте не тарифицируется, а значит, не пишет строку в счётчик расходов. В журнале такая авария выглядит не как всплеск, а как тишина. Пустой счётчик — это не «никто не играл», это может быть «никому не ответили».
Кодом это не лечится. Лечится своим ключом провайдера: общая очередь перестаёт быть нашей очередью.
Что из этого стоит запомнить
Число в коде верно не само по себе, а относительно того, на чём его померили. Пока модель не менялась, разницы между «правильно» и «правильно для прежней модели» не было никакой. Она появилась ровно в тот день, когда модель сменилась, — и появилась не в виде предупреждения, а в виде игры, которая не начинается.