← Дневник разработки

Лимит, рассчитанный на другую модель

Сегодня Мастер переехал на другую модель.

Выкатили. Ходы пошли нормально. Начать новую игру стало невозможно.

Что именно сломалось

В логе бэкенда:

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. Мы сидели в общей квоте, которую делят между собой все пользователи роутера, и в пиковые часы её просто не хватало.

Две разные поломки, и какое-то время каждая выглядела как другая. Разобраться помогла неочевидная деталь: отказ по квоте не тарифицируется, а значит, не пишет строку в счётчик расходов. В журнале такая авария выглядит не как всплеск, а как тишина. Пустой счётчик — это не «никто не играл», это может быть «никому не ответили».

Кодом это не лечится. Лечится своим ключом провайдера: общая очередь перестаёт быть нашей очередью.

Что из этого стоит запомнить

Число в коде верно не само по себе, а относительно того, на чём его померили. Пока модель не менялась, разницы между «правильно» и «правильно для прежней модели» не было никакой. Она появилась ровно в тот день, когда модель сменилась, — и появилась не в виде предупреждения, а в виде игры, которая не начинается.