Смоук был зелёным, а почти каждый платный заказ уходил в возврат. Разбор тест-кампании восьми мини-сервисов на Claude Haiku 5.5
Почему зелёный смоук не поймал блок thinking в ответах Claude Haiku 5.5: серия из ~250 заказов, четыре находки, починки и что осталось слабым.

Я Дилюс Габитов, агентство Amplituda. В октябре я запустил восемь маленьких платных сервисов на Claude Haiku 5.5. Это пилоты: сервисы новые, клиентов и отзывов у них нет.
Перед открытием приёма я прогнал живой смоук на SRT Rescue: заказ, оплата, результат. Всё сработало.
20 октября я прогнал серию: около 250 заказов с разными входами через тот же живой путь. Оказалось, что почти каждый оплаченный заказ заканчивался статусом refund_pending, то есть «деньги нужно вернуть». У Screenshot Review до результата не дошёл ни один из 8 оплаченных заказов, у Brief to Diagram ни один из 19.
Реальных покупателей в тот момент не было, оплату в тестах я имитировал. Поэтому это статья про инженерию, а не про возвраты. Ниже расскажу, как устроены пилоты, как я их тестировал, что нашёл, что починил и что осталось слабым.
Что за пилоты
Каждый пилот делает одну узкую вещь за разовую оплату, без подписки:
- SRT Rescue переводит субтитры SRT/VTT с русского на английский и обратно. $4 картой или 390 ₽ по СБП.
- Screenshot Review принимает 1–3 скриншота и возвращает до пяти наблюдений с отмеченными областями.
- Catalog Copy превращает CSV с характеристиками товаров (1–50 строк) в английские описания.
- Brief to Diagram рисует схему процесса из текстового брифа.
- CSV Brief делает из недельной CSV короткий отчёт.
- Locale Pack переводит до 40 строк интерфейса на немецкий, испанский, французский или русский. $5.
- Recall Pack делает из ваших фактов карточки с пропусками для Anki. $4.
- Spec Check строит из требований черновую тест-матрицу. $7.
Везде одна и та же оговорка: результат нужно проверить глазами, смысл никто не гарантирует.
Как устроен платный путь
Все восемь пилотов собраны по одной схеме. Первым был SRT Rescue, остальные повторяют его на общем ядре.
- Клиент загружает вход. Формат и лимиты проверяются бесплатно, модель на этом шаге не вызывается.
- Код считает верхнюю границу расхода на модель для этого заказа: по размеру запроса и
max_tokens, с запасом. Если граница больше четверти цены, заказ не создаётся. - До Checkout эта сумма резервируется в бюджет-шлюзе. Шлюз — это общий SQLite-журнал, в который пишут все пилоты. В общем ядре потолков три: на продукт за всё время, на продукт за сутки и общий суточный на все пилоты. Отдельно есть «стоп приёма», который срабатывает раньше потолка.
- Оплата проходит через Stripe Checkout, разовым платежом. Возврат клиента со страницы Checkout оплату не подтверждает.
- Оплату подтверждает только подписанный вебхук
checkout.session.completed. Код проверяет HMAC-SHA256, окно времени в 5 минут, сумму, валюту,livemode, аккаунт, сессию,client_reference_id,metadata.order_idиpayment_intent. - Идемпотентность: id события записывается в таблицу, и повтор ничего не делает. Checkout и возврат уходят в Stripe с постоянным
Idempotency-Keyна заказ. - Обработчик делает к модели не больше двух попыток. Перед каждой резервирует максимум, после списывает по фактическому
usage. - Ответ модели проходит строгую проверку. Если не прошёл, заказ уходит в
refund_pendingи результат не выдаётся. Возврат делает администратор с явным подтверждением.
Тексты клиентов удаляются через 24 часа и в логи не пишутся.
Бюджетная часть в коде ядра выглядит так:
if upper > self.cfg.price*10000*.25:
raise BudgetError('Maximum LLM cost exceeds 25% of price')
self.check_budget(db, upper)
def check_budget(self, db, amount):
totals = self.totals(db)
caps = (self.cfg.total_cap, self.cfg.daily_cap, self.cfg.shared_cap)
if any(spent+amount > math.floor(cap*1e6) for spent, cap in zip(totals, caps)):
raise BudgetError('Budget exhausted; review/refund required')Цена хранится в центах, расход в микродолларах, отсюда *10000.

Схема закрытая по умолчанию: при любом сомнении заказ идёт в возврат, а не отдаёт клиенту мусор. Поэтому все баги ниже проявились как возвраты, а не как сломанные файлы. В этом есть плюс. Минус в том, что снаружи такой отказ выглядит тихо.
Смоук, который ничего не доказал
17 октября я прогнал смоук на SRT Rescue: загрузил файл, получил котировку, открыл настоящую сессию Stripe Checkout в live-режиме, затем отправил вебхук, который подписал сам секретом. Перевод пришёл, тайм-коды остались на месте. Смоук был зелёным.
Смоуки остальных пилотов шли на их собственных образцах из product.json. У Recall Pack и Spec Check смоук закончился refund_pending, причём в логе было пусто: исключение ловилось и проглатывалось. Я воспроизвёл путь офлайн, подставив «канонический» ответ модели, то есть чистый JSON, и всё прошло. Я добавил диагностику (в лог пишется класс исключения, без пользовательских данных), уточнил промпты под валидаторы и двинулся дальше.
Причину я так и не нашёл. Моки отдавали ровно то, что я ожидал от API, и поэтому офлайн-тесты были зелёными.
Как я тестировал 20 октября
Важная оговорка: это не нагрузочный тест. Заказы шли по одному или по два параллельно. Это серия: много разных входов через живой платный путь.
- Оплата. Как в смоуке: настоящая Checkout-сессия Stripe в live-режиме, затем подписанный вебхук. Реальных платежей не было.
- Корпус входов для каждого пилота: обычные случаи и граничные (максимальный размер, пустые реплики, кириллица, эмодзи и символы вне BMP, смешанные языки), плюс prompt-injection прямо в данных.
- Проверки вокруг оплаты. От 15 до 19 вариантов поддельного или испорченного вебхука, в зависимости от набора: нулевая подпись, чужой секрет, старая и будущая метка времени, сумма на цент меньше, валюта
eur, чужой аккаунт, режимsubscriptionи так далее. После каждого заказ должен остаться неоплаченным. Повтор события, в том числе два одинаковых вебхука одновременно, не должен запускать генерацию и тратить деньги. Оплата, пришедшая при закрытом приёме, должна уходить вrefund_pendingбез вызова модели. - Независимая сверка результата. Для субтитров я побайтно сравнивал номера и тайм-коды с оригиналом. Для CSV Brief пересчитывал все агрегаты отдельным кодом на
Decimal. Для диаграмм считал, какая доля слов метки встречается в цитате. Для скриншотов заранее закладывал дефекты. - Лимиты прогона. У раннера свой стоп-кран: например, для Brief to Diagram и CSV Brief он останавливался, если расход на пилот доходил до $6.
Находка 1: блок thinking
Первые прогоны Screenshot Review и Catalog Copy: из 8 оплаченных заказов до результата дошли 0 и 1. В логе всё выглядело правильно. Вебхук принят (200), заказ оплачен, модель вызвана, а дальше refund_pending. Каждый такой заказ у Screenshot Review занимал 13–20 секунд.
Причина оказалась в формате ответа. В Haiku 5.5 адаптивное мышление включено по умолчанию: если параметр thinking не передан, модель сама решает, думать ли. Когда она думает, первым элементом content приходит блок thinking, а уже за ним text. Наши провайдеры принимали только text. Это было сделано честно, как защита от неожиданных блоков вроде tool_use. Ответ отвергался, вторая попытка давала то же самое, и заказ уходил в возврат. Обе попытки при этом оплачены.
Почему смоук этого не поймал? Единственный заказ Catalog Copy, прошедший до починки, был образцом платформы на 5 позиций, то есть ровно тем входом, на котором гоняется смоук. Моя гипотеза, не доказанная: мышление адаптивное, и на простом входе модель, видимо, отвечает сразу, без блока thinking. Смоук на одном-двух примерах проверяет не «работает ли сервис», а «работает ли он на этом примере».
Починка в провайдере ядра:
# claude-haiku-5-5 thinks by default and returns a leading 'thinking' block: skip it, accept only text.
blocks = [b for b in result['content'] if b.get('type') not in ('thinking', 'redacted_thinking')]
if not blocks or any(b.get('type') != 'text' for b in blocks):
raise _Reject('Unexpected response')
data = model_json(''.join(b['text'] for b in blocks))Дальше пилоты разошлись по двум стратегиям. В SRT Rescue и двух группах пилотов мышление в запросе выключено (thinking: {"type": "disabled"}): чтобы извлечь JSON, размышления не нужны. В третьей группе (Locale, Recall, Spec) мышление оставлено, а блоки фильтруются. На повторном прогоне Spec Check с выключенным мышлением 3 пакета из 12 не прошли проверки покрытия и цитат, а с адаптивным прошли 11 из 12.
У выключенного мышления есть побочный эффект: модель может начать рассуждать прямо в тексте ответа. На прямом прогоне диаграмм один ответ начался словами «I need to compute code point offsets carefully…», а не с JSON. Поэтому model_json() теперь терпит текст вокруг объекта, а сам объект по-прежнему разбирается строго, с запретом повторяющихся ключей.
После починки у Screenshot Review готовы 11 заказов из 11, у Catalog Copy тоже 11 из 11. Время ответа у Screenshot Review стало 1,8–6,7 секунды вместо 13–20.
Находка 2: обработчик тихо умирал на блокировке SQLite
Пилоты пишут в один SQLite-журнал бюджета через общий volume. После перезапуска хоста или общего выката семь контейнеров стартуют одновременно, и каждый при старте чистит устаревшие данные и открывает журнал на запись. Один держит блокировку, остальные получают database is locked. Исключение роняло фоновую задачу, а HTTP-часть продолжала отвечать.
Docker с restart: unless-stopped перезапускает упавший контейнер, а не «нездоровый». Процесс жив, страница открывается, а заказы обрабатывать некому. В /healthz это было видно, но только если туда смотреть.
Что сделано:
- на каждом соединении
busy_timeout30 секунд, то есть ждать, а не падать; - идемпотентные шаги повторяются при
lockedограниченное число раз; - старт приложения не падает, если первая уборка упёрлась в блокировку;
- фоновые задачи работают под супервизором: перезапуск с растущей паузой, не больше 8 перезапусков подряд, после этого
/healthzчестно показываетworker_failed.
def retry_locked(fn, what='sqlite', attempts=None, pause=None):
"""Run an idempotent step; retry a bounded number of times on 'database is locked'."""
attempts, pause = attempts or LOCK_RETRIES, LOCK_PAUSE_S if pause is None else pause
for n in range(1, attempts+1):
try:
return fn()
except sqlite3.OperationalError as exc:
if not is_locked(exc) or n == attempts:
raise
log.warning('%s: database is locked, retry %d/%d', what, n, attempts-1)
time.sleep(min(pause*n, 5))Тест на это простой: второе соединение держит BEGIN IMMEDIATE на журнале, и старт должен дождаться, а не упасть.
Находка 3: один rate-limit на всех клиентов
Лимиты считаются по IP: 120 запросов к API в минуту, а в Brief to Diagram и CSV Brief ещё 12 загрузок в час. В части пилотов uvicorn в контейнере не доверял proxy-заголовкам. В итоге для каждого клиента request.client.host был адресом шлюза Docker-сети, и все клиенты попадали в одну корзину.
Тест: 135 запросов, у каждого свой X-Forwarded-For. 429 начался на 121-м запросе, то есть корзина была одна на всех. После исправления те же 135 запросов дали ноль ответов 429.
Исправление — --proxy-headers --forwarded-allow-ips в команде запуска. Контейнер слушает только loopback хоста, а nginx перед ним перезаписывает X-Forwarded-For адресом клиента.
Рядом нашлась похожая мелочь: отклонённые загрузки тоже съедали часовую квоту. Клиент, который двенадцать раз подряд ошибся с форматом, остался бы без попыток на час. Теперь отклонённый вход квоту не тратит. Тест: 14 плохих загрузок подряд, и все получают 422, а не 429.
Находка 4: валидаторы отвергали ответы по мелочам
После починки thinking через живой конвейер прошли 6 диаграмм из 19 и 10 CSV-отчётов из 15. Большую часть остальных срезала уже моя собственная проверка ответа.
Brief to Diagram. Модель возвращала узлы и source_span, то есть смещения начала и конца фрагмента брифа в кодовых точках. Считает она плохо. В прошлом раунде 6 узлов из 24 ссылались не туда. Например, узел «Classify the complaint by severity.» указывал на кусок « prompt verbatim as the label of t». Валидатор либо отказывал (смещение за границей текста), и заказ уходил в возврат, либо пропускал неверную ссылку.
Теперь модель возвращает дословную цитату, а смещения считает код:
for q in (quote.strip(), quote.strip(EDGE_PUNCT)):
if not q or len(q) > 400:
continue
i = text.find(q)
if i >= 0:
return i, i + len(q)
m = re.search(r'\s+'.join(re.escape(part) for part in q.split()), text)
if m:
return m.span()
raise GraphError('quote_not_found')Слишком длинные метки теперь обрезаются, а не ведут к возврату. Итог: 16 из 19. В 16 схемах 95 узлов, средняя доля слов метки в цитате 0,91. Из трёх отказов два правильные: явный цикл «вернись к шагу 1» и стишок вместо процесса. Третий нестабилен: бриф из одного шага на 2400 знаков упал в обеих попытках, а при повторе прямым вызовом прошёл.
CSV Brief. Числа в отчёт подставляет код. Модель пишет только связки из маленького словаря плюс плейсхолдеры вроде {{total.count_current}}. Любое слово вне словаря отклоняет ответ целиком, а значит, и оплаченный заказ:
# Neutral inflections/synonyms the live model kept using (each one rejected a whole paid order): no numbers, causes or advice.
PROSE_WORDS |= set("category categories administration percent в минутах минут операций категория категории".split())Итог: 15 из 15. Независимый пересчёт агрегатов по всем 15 готовым заказам дал 0 расхождений и 0 чисел вне данных.
Spec Check. Модель заменяла неразрывный пробел в цитате обычным, проверка «цитата должна совпадать с источником» отказывала, и заказ уходил в возврат. Исправление такое: если цитата отличается от оригинала только заменой пробельного символа на пробельный той же длины, код подставляет оригинал. Любое другое расхождение по-прежнему отклоняется. Это исправление появилось уже после прогона. Итог Spec Check в прогоне — 9 из 12: два отказа из-за неразрывного пробела и один из-за требования, оставшегося без тест-кейса.

Что осталось слабым

- SRT Rescue: русский сленг. «Красава, это прям имба!» в одном прогоне переведено верно, а в двух предыдущих смысл получился обратным. «По фану» и «навешивать ярлыки» переведены с ошибками. Проверка качества смотрит на структуру, а не на смысл, и такое не ловит.
- Spec Check: покрытие нестабильно. Иногда требование остаётся без тест-кейса. На инъекции в требованиях модель иногда выдаёт странные мета-кейсы. Секрет при этом не раскрыт, команды не выполнены.
- Brief to Diagram: клиент платит и только потом узнаёт, что модель отказалась рисовать циклический процесс. Это в работе. Неявный возврат рисуется без петли. Если шагов больше лимита в 12 узлов, они сливаются, и цитата подтверждает только первый шаг слитого узла.
- CSV Brief: русский текст «рубленый» («количество операций текущий период»), потому что словарь маленький. Отрицательные числа в CSV пишутся с апострофом (
'-24): это защита от формул. - Catalog Copy: служебные слова всегда английские, строгие форматы (
Woodс заглавной буквы,10x20x30 cmбез пробелов) отклоняются, а часть слов блокируется как возможные инструкции. - Locale Pack: обращение непоследовательное: в одном немецком заказе вышло Sie, в другом du.
- Recall Pack: готовы 10 заказов из 12, иногда получается слабая карточка.
Сколько это стоило
Расход на модель за всю кампанию, около 250 заказов на восемь пилотов, меньше $1. Haiku 5.5 стоит $0,10 за миллион входных токенов и $0,50 за миллион выходных (для запросов до 100 тысяч токенов). Обычный заказ обходится в доли цента: медианный — от $0,00024 у Locale Pack до $0,00088 у Spec Check, средний файл SRT около $0,001. Самый тяжёлый заказ, субтитры на 2000 реплик (40 запросов к модели), стоит около $0,017.
Забавная деталь: возврат стоил дороже успеха. До починки заказ Screenshot Review, ушедший в возврат, обходился в $0,0010–0,0017: две оплаченные попытки, обе с размышлениями. После починки готовый заказ стоит $0,0001–0,0006.
Дорогими здесь оказались не токены, а часы. И риск: будь у пилотов в эти дни покупатели, они получили бы возвраты вместо результата.
Уроки
Смоук на одном-двух примерах не заменяет серию. Смоук отвечает на вопрос «соединён ли путь», серия — на вопрос «работает ли сервис». Перед открытием приёма нужно провести через живой платный путь десяток разных входов, а не образец. И моки должны отдавать то, что API возвращает на самом деле. Тест «провайдер принимает ответ с блоком thinking» теперь есть в каждой группе.
Закрывать приём, пока платный путь не подтверждён. У пилотов есть жёсткий запрет ACCEPT_ORDERS=0 в окружении, который админка не может снять. Открыть приём можно только сменой окружения и пересозданием контейнера. Актуальный статус приёма у каждого пилота — на его странице.
Ограничить бюджет и запретить повторы. Не больше двух попыток на заказ, резерв перед каждым вызовом, потолки на продукт и общий на все пилоты, стоп-кран в раннере, повтор вебхука без повторной генерации, возврат только руками и с подтверждением. Поэтому дефект, который ронял почти каждый заказ, обошёлся в центы.
И последнее. Закрытая по умолчанию система без диагностики выглядит как система, которая просто молчит. Класс исключения в логе, без данных клиента, стоит добавить раньше, чем понадобится.
Страница SRT Rescue: contractkit.amplituda.pro. Результат, как и у остальных пилотов, нужно проверить глазами.