Batch LLM вернул все ответы. Почему они оказались у чужих документов
Пакет завершился, файл результатов скачался, число строк совпало с числом запросов. Но резюме одного документа записалось в карточку другого. Разберу ошибку сопоставления по позиции — и небольшой Python-контроль, который проверяет идентификаторы до применения результатов.
Ошибка, которую не видно по HTTP-статусу
Представим учебную задачу: нужно получить короткие аннотации для набора документов. Перед отправкой сохранили список документов, после обработки прочитали JSONL с ответами. Дальше кажется естественным пройти два списка через zip и сохранить аннотацию в очередной документ.
Здесь скрыто предположение: первый ответ относится к первому запросу. Для асинхронного batch оно может быть неверным. В актуальной документации Anthropic Message Batches прямо указано: порядок результатов может отличаться от порядка входных запросов; сопоставлять их нужно по custom_id.
Если ответы поменялись местами, zip не обязан падать. Даже zip со strict=True проверит только длину, но не принадлежность результата. Две корректные JSON-строки и два существующих документа вполне могут дать две неправильные записи без единого исключения.
Это не описание моего производственного инцидента. Ниже — воспроизводимый локальный пример проверки контракта. Реальные запросы к модели в тестах не отправлялись.
До отправки нужен манифест
Я бы сохранял неизменяемый манифест задания до сетевого вызова: собственный job_id, идентификатор попытки, custom_id каждого элемента, внутренний document_id и версию исходного документа. Версии промпта и выбранной модели также нужны для последующего разбора.
custom_id — технический ключ корреляции, не адрес доставки и не доказательство права доступа. Внешнему сервису не нужен email владельца документа в этом поле. Связь непрозрачного ключа с документом и tenant_id хранится на нашей стороне.
У Anthropic custom_id должен быть уникален внутри batch. Это не означает глобальную уникальность между всеми заданиями или гарантию отсутствия повторной генерации. Для импортёра контекст ключа — конкретная попытка конкретного задания, связанная с конкретным provider batch ID.
Если запрос на создание batch завершился сетевой неопределённостью, импортёр результатов не решает, был ли пакет создан. Повторную отправку надо разбирать отдельно. Нельзя приписывать custom_id свойства idempotency key, которых контракт не обещает.
Сначала сверка, потом запись
Для небольшого пакета можно сначала прочитать и проверить все строки, а уже потом передавать их следующему этапу. Минимальные условия:
- В манифесте нет пустых или повторяющихся ключей.
- Каждый результат относится к ожидаемому ключу ровно один раз.
- Нет незнакомых идентификаторов и пропущенных элементов.
- Тип результата известен; успешная строка содержит объект message.
Вот функция для уже декодированных словарей. Это не JSONL-парсер и не полноценная валидация ответа модели.
Функция восстанавливает порядок манифеста по ключам, а не угадывает его по позиции. При нарушении контракта она не возвращает частичный набор. Тексты ответов и неизвестные идентификаторы не включаются в сообщения исключений.
Цена простоты — память под весь пакет. Для большого объёма я бы заменил словарь staging-таблицей с уникальным ограничением на ключ попытки и custom_id. Данные сначала накапливаются в промежуточном слое; публикация в целевые карточки начинается только после сверки и проверки готовности.
Результат reconcile не следует изменять параллельно с его применением: функция возвращает исходные объекты строк, а не защищённые глубокие копии. В рабочем конвейере полезнее формировать отдельную проверенную запись для хранения.
«Пакет закончен» не значит «все документы готовы»
Документация Message Batches различает succeeded, errored, canceled и expired. Функция сохраняет каждое из этих состояний, а не превращает всё, кроме успеха, в пустую строку.
Полная сверка означает: каждому входному элементу соответствует известный исход. Она не означает, что каждый исход успешен. Пропавший результат — отдельная проблема импорта; явный errored — известный результат конкретного запроса. Смешивать их в один счётчик «не получилось» неудобно для восстановления.
И даже succeeded ещё не означает, что аннотацию можно показывать пользователю. Нужно проверить содержимое message, причину остановки, нужную структуру и прикладные ограничения. Обрезанный ответ, отказ или запрос инструмента не становятся готовой аннотацией только потому, что транспортный этап успешен. Эта проверка намеренно не спрятана в примере корреляции.
Повторять весь пакет из-за одного ошибочного элемента я бы не стал автоматически. Сначала надо классифицировать исход: ошибку входа исправить, временный отказ рассмотреть для ограниченного retry, отмену и истечение — сопоставить с актуальностью задачи. Новая попытка получает собственную запись, а уже принятые результаты не перезаписываются случайно.
Что если скачивание оборвалось
Можно получить несколько корректных строк и потерять соединение на следующей. Если сразу обновлять документы по мере чтения, база уже изменилась, хотя импорт ещё не завершён. Повторное скачивание потребует идемпотентного применения и контроля конфликтов.
В учебной функции исключение из итератора просто передаётся вызывающему коду. Она ничего не записывает наружу, поэтому до её успешного возврата побочных эффектов нет. Это свойство самой функции, не гарантия транзакции во всей системе.
Для промышленного варианта нужны отдельные стадии: сохранить результаты, подтвердить целостность импорта, проверить прикладную готовность, применить их по устойчивому ключу. Повторное получение той же строки можно принять как безопасный повтор только после проверки, что это тот же результат. Другой payload под тем же ключом — конфликт, а не повод молча оставить последнее значение.
Наш строгий пример отклоняет любой дубль. Это полезная политика обнаружения, но не готовая стратегия возобновления загрузки.
Ещё одна гонка: документ успел измениться
Даже правильно найденный document_id может указывать уже на новую редакцию текста. Ответ был рассчитан для старой версии. Поэтому перед применением стоит сверить версию из манифеста с текущей и выполнить условное обновление: записать результат только если версия всё ещё совпадает.
Если не совпала — сохранить исход отдельно и пометить как устаревший, а не затирать свежую аннотацию. То же относится к tenant_id, правам доступа и состоянию удаления документа: наличие custom_id в манифесте не отменяет авторизацию и проверку актуальности.
Именно здесь становится видно различие трёх проверок: результат относится к нужному запросу; содержимое подходит для задачи; целевой объект всё ещё допускает запись. Ни одна не заменяет остальные.
Что проверено и чего тесты не доказывают
Локально выполнены 14 unit-тестов. Все прошли: переставленные ответы, пропуск, дубль, незнакомый ключ при совпадающем количестве строк, неверный манифест, все четыре терминальных типа, незнакомый тип, повреждённые структуры, отсутствие message, пустой набор, неизменность входа и обрыв итератора.
Контрольный случай особенно показателен: ожидаем a и b, получаем a и c. Число строк совпадает, но сверка отклоняет набор. Проверка количества такую подмену не обнаружит.
Тесты проверяют только локальный алгоритм на синтетических данных. Здесь нет проверки реального SDK, отказоустойчивости БД, атомарности применения или качества текста модели. Для интеграционного теста я бы добавил повторное чтение файла, сбой между staging и apply и конкурентное обновление документа.
Вывод
Batch удобно использовать там, где допустим асинхронный результат. Но оценивать нужно не только условия тарификации провайдера: импорт, сверка, хранение и повторные попытки тоже требуют инженерной работы.
Первое полезное исправление часто очень небольшое: убрать сопоставление по позиции и начать проверять множество ожидаемых ключей. Следующее — отделить «получили исход для каждого запроса» от «можно применить все ответы».
Как у вас устроен импорт batch-результатов: сразу обновляет целевые записи или проходит через staging? И что происходит, если под одним ключом пришли два разных ответа?
Источник и границы примера
Anthropic, Batch processing, разделы Retrieving batch results и Batch results may not match input order: https://platform.claude.com/docs/en/build-with-claude/batch-processing
Контракт проверен по документации 10 ноября 2026 года. Код — самостоятельный учебный пример для декодированных строк результата; 14 локальных тестов, без внешних запросов и измерений производственной системы.