Фактическая модель в ответе API без догадок
Фактическая модель в ответе API: схема метаданных для алиаса, провайдера, версии, fallback и аудита исполнения LLM-запросов.

Алиас модели удобен для клиента, но он не доказывает, кто обработал запрос. В продакшене это различие быстро перестаёт быть академическим: команда видит скачок стоимости, неожиданную разницу в качестве ответа или жалобу на размещение данных и обнаруживает, что в журнале есть только строка model: "smart-route".
Нужен отдельный контракт метаданных результата. Он должен связывать намерение клиента с фактическим исполнением: запрошенный алиас, разрешённую каноническую модель, провайдера, конкретную попытку, статус и условия, при которых маршрутизатор сменил путь. Тогда ответ модели можно проверять, объяснять и сопоставлять с расходами без догадок.
Алиас описывает намерение, а не исполнение
Алиас нужен для управления, но он не является доказательством фактической модели. Команда может отправить support-assistant, а правило маршрутизации развернёт его в одну из нескольких моделей по классу задачи, доступности, лимиту стоимости или требованию к региону. Даже если каноническая модель остаётся той же, запрос могут принять разные провайдеры.
Проблема начинается, когда одно поле пытаются заставить отвечать на два разных вопроса:
- Что попросил клиент?
- Что реально выполнило инференс?
Первый вопрос относится к контракту вашего приложения. Второй относится к наблюдаемости исполнения. Смешав их, вы теряете возможность объяснить результат после fallback, повторной попытки или смены политики.
Документация OpenRouter прямо отделяет маршрутизацию между провайдерами от имени модели: по умолчанию запросы могут распределяться между доступными провайдерами, а fallback может переводить вызов на другой путь после ошибки или ограничения. В его нормализованном ответе есть поле model, но одного этого поля недостаточно, если вам требуется история попыток и фактический поставщик.
Не называйте алиас «моделью, которая ответила» в интерфейсе аудита. Называйте его requested_alias или requested_model. Это небольшая дисциплина в именах, которая предотвращает много неверных выводов на разборе инцидента.
Четыре идентификатора нельзя склеивать в одну строку
Для трассировки нужны как минимум четыре разных значения, потому что они отвечают на разные вопросы.
requested_alias отвечает за выбор клиента. Это может быть legal-review, fast-chat или имя, которое использует продуктовая команда. Алиас меняется вместе с вашей политикой и обычно не годится для сравнения результатов за месяцы.
resolved_model отвечает за то, какой канонический идентификатор разрешил маршрутизатор. Для него нужен формат, который не зависит от витринного названия. Документация каталога OpenRouter различает id, отображаемое имя и canonical_slug, который заявлен как постоянный идентификатор модели. Это полезное разделение: красивое имя читают люди, а неизменяемый идентификатор нужен журналам и правилам.
provider отвечает за организацию или вычислительный контур, который обслужил попытку. Он особенно важен, когда одна и та же модель доступна у нескольких поставщиков с разными регионами, очередями, ценой и поддержкой параметров.
execution_model отвечает за идентификатор, который был передан или подтверждён на фактическом endpoint. Иногда он совпадает с resolved_model. Иногда поставщик раскрывает более точное имя развёртывания. Если его нет, не подставляйте туда алиас и не копируйте значение из каталога. Поставьте null и зафиксируйте, что источник его не выдал.
Есть ещё пятый объект, который многие забывают: route_policy. Это не имя модели и не имя провайдера. Это версия правила, которое приняло решение. Без неё вы не сможете ответить, почему в понедельник один и тот же алиас выбрал один маршрут, а во вторник другой.
Контракт результата должен отделять факты от предположений
Хорошая схема не пытается заполнить все поля любой ценой. Она хранит источник каждого значения и допускает неизвестность. Это важно для версии модели: публичное название семейства, дата в каталоге и реальный revision конкретного развёртывания не равны друг другу.
Я обычно добавляю source рядом с полями, которые приходят из разных мест. Значение, подтверждённое ответом провайдера, весит больше, чем догадка, сделанная по настройке маршрута. Поле, вычисленное шлюзом, тоже полезно, но его нельзя выдавать за подтверждение от поставщика.
Ниже пример полезного фрагмента итогового ответа. Это не универсальный стандарт, а контракт, который удобно положить рядом с обычным OpenAI-совместимым объектом ответа.
{
"id": "req_01J9X7KQ5Y",
"object": "chat.completion",
"model": "support-assistant",
"routing": {
"requested_alias": "support-assistant",
"resolved_model": {
"value": "vendor-x/chat-pro",
"source": "router_catalog"
},
"selected_attempt_id": "att_02",
"policy": {
"id": "support-default",
"revision": "2026-07-23.3",
"hash": "sha256:6c33c8..."
},
"attempts": [
{
"id": "att_01",
"provider": "provider-a",
"execution_model": null,
"execution_model_source": "not_disclosed",
"status": "failed",
"failure_class": "timeout",
"started_at": "2026-07-23T09:14:01Z",
"finished_at": "2026-07-23T09:14:16Z"
},
{
"id": "att_02",
"provider": "provider-b",
"execution_model": "chat-pro-2026-06",
"execution_model_source": "provider_response",
"model_revision": null,
"model_revision_source": "not_disclosed",
"status": "selected",
"started_at": "2026-07-23T09:14:16Z",
"finished_at": "2026-07-23T09:14:18Z"
}
]
}
}
У такого объекта есть два полезных свойства. Во-первых, он не врёт о версии: null означает, что точный revision неизвестен. Во-вторых, он показывает не только победившую попытку, но и путь, который к ней привёл.
Поле верхнего уровня model стоит сохранить для совместимости со старым клиентским кодом. Но не делайте его единственным источником правды. В контракте выше оно остаётся удобной меткой результата, а подробности живут в отдельном пространстве имён routing.
Fallback нужно записывать как последовательность попыток
Итог «запрос успешен» скрывает половину истории. Если первая попытка получила 429, вторая не поддержала response_format, а третья вернула текст, именно эта цепочка объясняет задержку, стоимость и различие в поведении.
Каждая попытка должна иметь собственный идентификатор, время начала и окончания, статус, провайдера и класс отказа. Не записывайте только сырой текст ошибки. Сырым текстом полезно владеть в защищённом журнале, но аналитике нужны стабильные классы: timeout, rate_limited, upstream_5xx, unsupported_parameter, policy_rejected, cancelled.
Отдельно храните selection_reason для удачной попытки. Допустимые значения могут быть такими:
primary_routeдля первого выбранного пути;fallback_after_failureпосле технического отказа;fallback_after_policy_rejectionпосле запрета по данным или региону;retry_same_providerпри повторе на том же поставщике;manual_override, если маршрут закрепил оператор или правило клиента.
Не превращайте этот список в свободный текст. Свободный текст годится для пояснения инженеру, но его нельзя нормально агрегировать. Через месяц вам понадобится ответить, какая доля запросов ушла с основного пути из-за лимита, и вы не захотите разбирать тысячи строк логов регулярными выражениями.
Есть неприятный случай, который часто пропускают. Маршрутизатор отправляет запрос провайдеру, провайдер успевает начать генерацию, затем соединение между ними обрывается. У вас может не быть уверенности, была ли сгенерирована полная стоимость и дошёл ли ответ до клиента. Не ставьте такой попытке failed без уточнения. Используйте статус unknown_outcome и не обещайте точный биллинг до сверки с данными поставщика.
Потоковый ответ получает факт только в финале
При streaming нельзя считать первый chunk подтверждением того, что весь запрос завершился у выбранного провайдера. Первые события могут содержать роль, текст или служебные поля, затем поток может оборваться. Если вы записали маршрут как окончательный в момент первого байта, журнал начнёт показывать успешные выполнения, которых фактически не было.
Создайте запись попытки до отправки запроса наверх. На первом событии зафиксируйте first_byte_at. При нормальном финале добавьте finished_at, итоговый finish_reason, usage и статус selected. При обрыве сохраните уже известное и поставьте один из статусов stream_interrupted, client_disconnected или unknown_outcome.
Схема событий может выглядеть так:
request accepted
-> attempt created
-> upstream connected
-> first token received
-> final usage received
-> attempt selected
-> client stream closed
Не все провайдеры передают usage в самом конце потока одинаково. Поэтому разделите usage_reported_by_provider и usage_estimated_by_gateway. Первое можно использовать для сверки счёта. Второе годится для оперативной аналитики, но должно быть помечено как оценка.
Документация OpenRouter для потокового режима также предупреждает о служебных SSE-комментариях, которые клиент должен игнорировать. Для аудита это означает простое правило: не создавайте новую попытку и не меняйте статус по каждому входящему событию, сначала классифицируйте тип события.
Версия модели полезна только с известным происхождением
Поле model_version часто добавляют в схему для галочки, а потом заполняют маркетинговым именем модели. Так делать нельзя. Chat Pro, chat-pro, chat-pro-latest и chat-pro-2026-06 могут описывать семейство, алиас, канал обновлений и конкретную сборку. Они не взаимозаменяемы.
Разделите минимум три поля:
model_familyдля устойчивого семейства, если оно известно;model_releaseдля опубликованного поставщиком релиза или снимка;deployment_revisionдля точной версии работающего развёртывания.
Поставщики часто раскрывают только первое. Иногда второе. Третье доступно редко, особенно для закрытых моделей. Ваш аудит должен переживать это ограничение честно: значение null плюс not_disclosed лучше, чем строка, собранная из предположений.
Для собственных open-weight развёртываний ситуация иная. Там вы можете и должны фиксировать хэш весов, версию токенизатора, шаблон чата, revision контейнера и идентификатор пула GPU. Иначе выражение «мы запускали ту же модель» ничего не доказывает. Изменение шаблона чата или квантования способно заметно изменить ответ без смены названия модели.
Не пытайтесь заставить одну схему одинаково подробно описывать закрытый внешний endpoint и собственный кластер. Оставьте общие поля, а специфичные детали вынесите в provider_metadata. При этом запретите непроверяемые поля в главной части контракта.
Политика маршрута должна быть воспроизводимой
Фактический исполнитель не объясняет решение сам по себе. Если вы знаете, что ответ дал provider-b, но не знаете, какие правила действовали в эту минуту, вы не поймёте, был ли выбор ожидаемым.
Сохраняйте неизменяемую ревизию политики на момент приёма запроса. Подойдёт номер ревизии из Git, идентификатор опубликованного правила и криптографический хэш нормализованной конфигурации. Не сохраняйте только имя default-policy, потому что его содержание со временем меняется.
Например, логика может разрешать fallback только между маршрутами, которые удовлетворяют требованиям по региону и обработке данных:
{
"policy_id": "claims-assistant",
"revision": "2026-07-23.3",
"allowed_regions": ["KZ"],
"fallback": "same_data_boundary_only",
"providers": ["local-gpu", "provider-kz"],
"max_attempts": 2
}
Здесь важно не само имя полей, а проверяемое следствие: если в аудите появилась попытка у внешнего поставщика, запись можно сопоставить с политикой и сразу определить, нарушил ли шлюз правило или оператор изменил конфигурацию.
В AI Router такая метаинформация особенно уместна для команд, которым нужно совместить единый OpenAI-совместимый endpoint с аудитом, маскированием PII и требованиями к хранению данных в Казахстане. Но схема остаётся полезной и при прямой работе с одним поставщиком: сегодня у вас один путь, а завтра появятся резервный контур и отдельные правила для чувствительных задач.
Идентификаторы связывают ответ с журналом, но не заменяют журнал
Каждый вызов должен иметь как минимум два идентификатора. request_id создаёт шлюз и использует его в ответе, трассировке и журналах. client_request_id приходит от приложения и связывает вызов с действием пользователя, заданием очереди или операцией в CRM.
Добавьте trace_id, если ваш сервис уже использует распределённую трассировку. Тогда один trace связывает HTTP-вход, проверку политики, маскирование данных, запрос к модели, инструменты, запись результата и повторную попытку. Но не подменяйте этим журнал маршрута: trace показывает последовательность работы сервисов, а объект routing.attempts хранит смысл решения.
В ответ клиенту обычно достаточно вернуть короткую сводку:
{
"request_id": "req_01J9X7KQ5Y",
"requested_model": "support-assistant",
"resolved_model": "vendor-x/chat-pro",
"routing_status": "completed"
}
Полный массив попыток лучше выдавать доверенному серверному клиенту, администратору или системе аудита. Название провайдера, идентификатор endpoint и причины переключения могут раскрывать внутреннюю топологию и условия договоров. Разделяйте внешний диагностический контракт и внутреннюю операционную запись.
Не кладите в метаданные исходный промпт, ответ модели, заголовок авторизации или персональные данные ради удобства поиска. Храните хэш нормализованного запроса, размер входа, классификацию чувствительности и ссылку на отдельное защищённое хранилище только там, где полный текст действительно нужен.
Контракт нужно проверять на управляемых сбоях
Маршрутизация считается работающей не тогда, когда первый запрос вернул 200. Она считается работающей, когда команда может искусственно вызвать известный отказ и получить предсказуемую историю попыток.
Сделайте в тестовом контуре пять проверок:
- Отправьте запрос через алиас и убедитесь, что ответ содержит и алиас, и разрешённую каноническую модель.
- Принудительно верните timeout на первом маршруте и проверьте, что появился
att_01со статусомfailed, аatt_02получилselected. - Передайте параметр, который второй маршрут не поддерживает, и проверьте классификацию
unsupported_parameter, а не безмолвное изменение запроса. - Оборвите поток после нескольких токенов и проверьте, что запись не получила статус успешного завершения.
- Измените политику, повторите вызов и убедитесь, что старый ответ всё ещё ссылается на старую ревизию.
Не сравнивайте в CI точный текст, который сгенерировала вероятностная модель. Сравнивайте схему, статусы, порядок попыток, обязательные идентификаторы и инварианты политики. Например, тест для задач с ограничением по региону должен падать, если в attempts появился запрещённый провайдер, даже когда текстовый ответ выглядит безупречно.
Когда в журнале остаются только алиас и HTTP-статус, расследование превращается в реконструкцию по косвенным признакам. Добавьте фактический маршрут в контракт результата до того, как вам придётся объяснять расхождение в качестве, счёте или размещении данных задним числом.
Часто задаваемые вопросы
Почему поля model в запросе недостаточно для аудита LLM-вызова?
Нет. Алиас говорит, что клиент попросил у маршрутизатора, а не о том, какой исполнитель принял запрос. Если маршрутизатор умеет переключать провайдеров, модели или площадки после ошибки, одного поля model в запросе для расследования недостаточно.
Какие метаданные нужно хранить для каждого запроса к LLM?
В минимальном варианте сохраните ID вызова, алиас, каноническую модель, провайдера, фактический идентификатор исполнения, статус и время. Для продакшена добавьте массив попыток, причину переключения, версию политик маршрутизации и хэш конфигурации.
Можно ли всегда записывать версию модели в API-ответ?
Только если поставщик действительно передаёт неизменяемый идентификатор версии, сборки или развёртывания. Если такого значения нет, храните null и признак, что версия не раскрыта. Выдуманная версия хуже отсутствующей: она создаёт ложную уверенность при разборе инцидента.
Как записывать fallback между провайдерами?
Сохраняйте каждую попытку отдельно, включая неудачные. Итоговый объект должен явно показывать, какая попытка дала результат, а какие завершились тайм-аутом, ошибкой совместимости параметров или отказом провайдера.
Как сохранять фактическую модель при streaming-ответе?
Промежуточные события нельзя считать окончательной истиной, потому что поток может оборваться после отправки части текста. Создайте запись при старте, дополняйте её по мере работы и помечайте как завершённую только после финального события или серверного подтверждения.
Нужно ли показывать пользователю провайдера и маршрут запроса?
Провайдер, регион, внутренние endpoint ID и причина fallback могут быть операционными деталями. В пользовательский ответ обычно стоит отдавать только безопасную сводку, а полный журнал оставлять в защищённом аудите с разграничением доступа.
Чем request_id отличается от client_request_id?
Системный request_id связывает HTTP-запрос, журналы шлюза, запись очереди и трассу приложения. Клиентский идентификатор нужен отдельно: его передаёт ваше приложение, чтобы найти все вызовы, относящиеся к одному действию пользователя или бизнес-процессу.
Можно ли хранить метаданные маршрутизации без текста промпта?
Да. Логи не должны содержать исходные промпты, ответы, токены доступа и персональные данные без явной причины. Обычно достаточно хранить хэши, размеры, классификацию данных, идентификаторы и ссылку на защищённое хранилище, если полный текст нужен для расследования.
Как тестировать маршрутизацию моделей в CI?
Сравнивайте контракт ответов, а не случайные формулировки модели. Фиксируйте ожидаемый алиас, допустимые канонические модели, схему метаданных, число попыток и правило, по которому итоговая попытка получает статус selected.
Нужен ли журнал фактической модели для каждого LLM-запроса?
Не делайте его обязательным для каждого пользовательского сценария. Он нужен там, где важны воспроизводимость, расчёт стоимости, разбор качества, требования к размещению данных или спор по результату. Для внутренних экспериментов можно хранить сокращённую запись, но не путайте её с аудитом.