·Дилюс Габитов

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

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

Обложка: «Смоук был зелёным. Платные заказы уходили в возврат». Справа три карточки: Brief to Diagram 0 → 6 → 16 из 19, CSV Brief 0 → 10 → 15 из 15, расход на модель меньше $1 на всю кампанию

Я Дилюс Габитов, агентство 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, остальные повторяют его на общем ядре.

  1. Клиент загружает вход. Формат и лимиты проверяются бесплатно, модель на этом шаге не вызывается.
  2. Код считает верхнюю границу расхода на модель для этого заказа: по размеру запроса и max_tokens, с запасом. Если граница больше четверти цены, заказ не создаётся.
  3. До Checkout эта сумма резервируется в бюджет-шлюзе. Шлюз — это общий SQLite-журнал, в который пишут все пилоты. В общем ядре потолков три: на продукт за всё время, на продукт за сутки и общий суточный на все пилоты. Отдельно есть «стоп приёма», который срабатывает раньше потолка.
  4. Оплата проходит через Stripe Checkout, разовым платежом. Возврат клиента со страницы Checkout оплату не подтверждает.
  5. Оплату подтверждает только подписанный вебхук checkout.session.completed. Код проверяет HMAC-SHA256, окно времени в 5 минут, сумму, валюту, livemode, аккаунт, сессию, client_reference_id, metadata.order_id и payment_intent.
  6. Идемпотентность: id события записывается в таблицу, и повтор ничего не делает. Checkout и возврат уходят в Stripe с постоянным Idempotency-Key на заказ.
  7. Обработчик делает к модели не больше двух попыток. Перед каждой резервирует максимум, после списывает по фактическому usage.
  8. Ответ модели проходит строгую проверку. Если не прошёл, заказ уходит в 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.

Схема пути платного заказа из восьми шагов от загрузки до проверки ответа. Красным отмечены четыре места поломки: rate-limit по IP шлюза Docker, worker на database is locked, блок thinking перед text, отказ валидатора по мелочам. Зелёным — подписанный вебхук, который выдержал проверки

Схема закрытая по умолчанию: при любом сомнении заказ идёт в возврат, а не отдаёт клиенту мусор. Поэтому все баги ниже проявились как возвраты, а не как сломанные файлы. В этом есть плюс. Минус в том, что снаружи такой отказ выглядит тихо.

Смоук, который ничего не доказал

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_timeout 30 секунд, то есть ждать, а не падать;
  • идемпотентные шаги повторяются при 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: два отказа из-за неразрывного пробела и один из-за требования, оставшегося без тест-кейса.

Горизонтальные столбцы по трём прогонам: Brief to Diagram — 0, 6 и 16 из 19 заказов дошли до результата; CSV Brief — 0, 10 и 15 из 15. Подписи: что исправлял каждый патч

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

Таблица по восьми пилотам: сколько заказов готово, медиана времени и расход на модель за заказ, с заметками о сбоях
  • 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. Результат, как и у остальных пилотов, нужно проверить глазами.

Почитать по теме

Покажите один процесс, который вас бесит

45 минут разбираем его вместе. Уйдёте со схемой и оценкой — даже если ничего у нас не купите.