Перенос агентного приложения ломается не на base_url
Перенос агентного приложения через совместимый API: как проверить Responses, Chat Completions, вызовы инструментов, стриминг и MCP до миграции.

Смена base_url доказывает только то, что SDK смог открыть соединение и получить правдоподобный JSON. Для агентного приложения этого мало. Агент зависит от нескольких контрактов сразу: формата запроса, формы ответа, порядка потоковых событий, цикла вызова инструментов, состояния диалога и поведения модели при ошибках. Шлюз может честно поддерживать OpenAI-совместимый API и при этом не поддерживать именно ту комбинацию, на которой держится ваш агент.
Я видел миграции, где обычный чат проходил за пять минут, а первая параллельная выдача инструментов смешивала аргументы двух вызовов. В другой системе текст отображался правильно, но оркестратор не отправлял результат функции обратно модели, потому что ждал сообщение с ролью tool, а получил элемент function_call_output. Такие поломки не ловит проверка «модель ответила 200 OK».
Переносимость нужно оценивать по наблюдаемому протоколу. Зафиксируйте, что приложение отправляет и принимает на каждом ходе, прогоните одинаковые сценарии через старый и новый шлюз, а различия классифицируйте до переключения трафика. Совпадение названий эндпоинтов помогает начать проверку, но не заменяет ее.
Совместимость API заканчивается раньше поведения агента
OpenAI-совместимость обычно означает поддержку некоторого подмножества полей и ответов известного API. Она редко означает полную эквивалентность всех моделей, встроенных инструментов, потоковых событий и правил хранения состояния. Поэтому вопрос «совместим ли провайдер с OpenAI» слишком широкий. Нужен перечень возможностей, которые реально вызывает ваше приложение.
Разделите контракт на пять слоев. HTTP-слой включает путь, заголовки, коды ошибок, тайм-ауты и отмену. Слой данных определяет поля запроса и форму результата. Потоковый слой задает имена событий, дельты и признак окончания. Инструментальный слой описывает схему функции, идентификатор вызова и возврат результата. Слой исполнения охватывает выбор модели, следование схеме, параллельные вызовы и повтор после ошибки.
Первые два слоя проще всего имитировать, поэтому маркетинговая совместимость часто останавливается на них. Агент ломается на последних трех. Например, сервер может принять tools, но игнорировать strict; вы получите синтаксически допустимые аргументы, которые не проходят вашу бизнес-валидацию. Сервер может принять parallel_tool_calls: false, а модель все равно вернет два вызова. Сервер может отдать финальный текст, но не передать промежуточное событие, от которого интерфейс ожидает запрос подтверждения.
Есть еще различие, которое команды часто стирают: совместимость транспорта и переносимость приложения. Транспорт совместим, если клиент и сервер понимают запрос. Приложение переносимо, если после замены провайдера сохраняются разрешенные действия, состояние, ошибки, наблюдаемость и пользовательский результат. Первый факт проверяет smoke-тест. Второй требует набора сценариев с утверждениями.
Составьте профиль используемой поверхности до разговора с новым поставщиком. Выпишите эндпоинты, поля, модели, типы контента, инструменты, события и способы продолжения диалога из реальных трасс. Документация и исходный код часто расходятся: код уже использует previous_response_id, хотя архитектурная схема по-прежнему показывает массив messages. Верить следует трассе, потому что именно ее предстоит перенести.
Chat Completions и Responses передают разные сущности
Chat Completions и Responses нельзя безопасно менять местами через переименование пути. Chat Completions строится вокруг сообщений и массива choices; Responses строится вокруг входных и выходных элементов разных типов. Даже когда оба эндпоинта возвращают один и тот же текст, оркестратору приходится читать его по-разному.
В Chat Completions приложение отправляет messages. Ответ ассистента обычно находится в choices[0].message, а вызовы функций в choices[0].message.tool_calls. Результат инструмента возвращается новым сообщением с ролью tool и полем tool_call_id. Этот формат удобен для линейной истории, но приложение само хранит и повторно отправляет нужные сообщения.
В Responses вход может быть строкой или списком типизированных элементов. Выход находится в массиве output: там могут соседствовать сообщение, рассуждение, вызов функции и другие элементы. Свойство output_text в SDK удобно собирает текст, но это помощник клиента, а не универсальное поле сетевого ответа. Если ваш адаптер ищет только его в сыром JSON, совместимый сервер имеет полное право ничего не вернуть по этому пути.
Вызов функции в Responses имеет тип function_call, поля name, arguments и call_id. Результат возвращают входным элементом типа function_call_output с тем же call_id. Переносчик, который преобразует лишь messages в input, потеряет связь между вызовом и результатом. Модель увидит историю без ответа инструмента и может повторить действие либо объяснить, что данных нет.
Формы объявления функции тоже различаются. В Chat Completions поля name, description, parameters и strict вложены в объект function. В Responses они лежат рядом с type: "function". Некоторые шлюзы нормализуют обе формы внутри, некоторые реализуют одну, а вторую принимают частично. Проверяйте не только HTTP-код, но и то, появилась ли функция в фактическом запросе к модели.
Responses также предлагает серверное продолжение через previous_response_id. Это не эквивалент локальной пересылки полной истории. Официальное описание Responses отдельно предупреждает: инструкции предыдущего ответа не переносятся автоматически при использовании previous_response_id. Если системное правило о разрешениях было только в первом запросе, следующий ход может пройти без него. При миграции явно решите, кто владеет состоянием, сервер или приложение, и повторяйте обязательные инструкции на каждом ходе.
Хранение состояния влияет на требования к данным. Параметр store, идентификаторы ответов и серверная история имеют смысл только при заявленной поддержке провайдера. Нельзя считать локальный шлюз «без состояния» лишь потому, что клиент не посылает историю: он может сохранять ее ради продолжения. Для регулируемого контура нужны отдельные ответы о месте хранения, сроке жизни, удалении и попадании содержания инструментов в логи.
Tool calling ломается на идентификаторах и схеме
Безопасный цикл инструмента держится на трех вещах: валидных аргументах, неизменном идентификаторе вызова и однократном исполнении. Текстовое сходство ответов здесь почти ничего не говорит. Одна потерянная связь call_id опаснее слегка другого стиля модели, потому что может повторить платеж, письмо или изменение записи.
Сначала проверьте JSON Schema. Режим strict сужает допустимый ответ модели, но поставщики и модели поддерживают разные подмножества схемы. Успешное принятие параметра не доказывает соблюдение схемы. Дайте функции обязательное поле, перечисление, запрет дополнительных свойств и вложенный объект. Затем намеренно попросите модель сформировать пограничные значения. Ваша сторона все равно обязана разобрать JSON и повторно проверить его обычным валидатором перед исполнением.
Не исполняйте инструмент по мере прихода фрагментов аргументов. Поток может разделить строку внутри escape-последовательности, многобайтового символа или числа. Собирайте фрагменты отдельно для каждого вызова по идентификатору и индексу, ждите событие завершения аргументов, только потом разбирайте JSON. Буфер «текущие аргументы» без привязки к вызову работает ровно до первой параллельной выдачи.
Параллельность надо считать отдельной возможностью. Модель может предложить два независимых чтения одновременно, а приложение затем вернет два результата. Проверьте порядок, сопоставление и повторную отправку обоих результатов. После этого запретите параллельные вызовы и убедитесь, что шлюз действительно соблюдает ограничение. Если бизнес-действия нельзя безопасно выполнять вместе, ограничение должно жить и в оркестраторе, а не только в подсказке модели.
Добавьте идемпотентность вокруг действий с побочным эффектом. Идентификатор модели годится для корреляции внутри хода, но не всегда подходит как вечный ключ операции: при повторе запроса провайдер может выдать новый call_id. Сформируйте собственный ключ из идентификатора пользовательской операции, имени инструмента и нормализованных аргументов. Хранилище исполнения должно вернуть прежний результат при повторе, не запуская действие заново.
Отказы тоже входят в контракт. Инструмент может вернуть бизнес-ошибку, истечь по времени или дать слишком большой результат. Зафиксируйте одну форму ответа, например {"ok":false,"error":{"code":"LIMIT_EXCEEDED","retryable":false}}, и проверьте, что новая модель не превращает ее в повтор без разрешения. Не прячьте ошибку в свободном тексте: оркестратор должен решать, можно ли повторять, до передачи результата модели.
Потоковые события требуют отдельного адаптера
Поток Chat Completions и поток Responses имеют разную грамматику. Универсальный обработчик, который читает каждую строку SSE и ищет choices[0].delta.content, не переносится на Responses. Там тип события сообщает, что именно изменилось, а индексы связывают дельту с выходным элементом и его частью.
В Chat Completions сервер присылает объекты chat.completion.chunk. Текст появляется в choices[].delta.content, фрагменты инструментов в choices[].delta.tool_calls, причина завершения в finish_reason. Маркер data: [DONE] закрывает поток. Если включен stream_options.include_usage, перед [DONE] приходит дополнительный chunk с пустым choices и общей статистикой; при обрыве этот последний chunk может не прийти. Код, который считает пустой choices ошибкой, потеряет метрики на нормальном завершении.
Responses начинает с response.created, добавляет выходные элементы и части содержимого, передает текст через response.output_text.delta, а аргументы функции через response.function_call_arguments.delta. Завершение аргументов обозначает response.function_call_arguments.done; весь ответ заканчивается response.completed либо событием ошибки или незавершенности. События содержат sequence_number, output_index, а для частей текста еще и content_index. Это готовые координаты сборки, их не стоит выбрасывать ради одного общего буфера.
Особенно неприятна поломка, когда интерфейс выглядит исправным. Пользователь видит текстовые дельты, но событие вызова инструмента не попадает в оркестратор, потому что адаптер фильтрует все типы кроме текста. Модель уже закончила ход и ждет результат, а приложение считает ответ завершенным. Тайм-аут наступает позже и указывает не на ту часть системы.
Пишите преобразование событий как конечный автомат. У него должны быть состояния создания ответа, добавления элемента, накопления дельт, завершения элемента и завершения ответа. Не вызывайте бизнес-обработчики прямо из парсера SSE. Сначала преобразуйте провайдерские события во внутренние события вроде text_delta, tool_call_ready, usage_final, response_failed, затем отдайте их интерфейсу и оркестратору.
Проверьте обрыв в каждом значимом месте: в середине текста, посреди JSON аргументов, после готового вызова до финального события и после последней дельты до статистики. После разрыва нельзя автоматически исполнять частично собранный вызов. Если действие уже ушло во внешнюю систему, повтор соединения обязан пройти через ваш идемпотентный слой.
Один зонд обнаруживает большую часть ложной совместимости
Короткий воспроизводимый зонд полезнее ручного чата, если он сохраняет сырые заголовки, тело и поток. Запустите его против обоих шлюзов с одной модельной ролью: попросите вызвать функцию с обязательным перечислением, вложенным объектом и двумя параллельными вызовами. Не ждите одинакового текста, сравнивайте структурные инварианты.
Ниже запрос к Chat Completions. Переменные API_BASE, API_KEY и MODEL задайте для проверяемой среды. Флаг -N отключает буферизацию вывода curl, а -D сохраняет заголовки отдельно. Этот зонд ничего не исполняет, поэтому его безопасно запускать на тестовом ключе.
curl -N -D probe.headers -sS "$API_BASE/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$MODEL"'",
"stream": true,
"stream_options": {"include_usage": true},
"messages": [{"role":"user","content":"Узнай погоду отдельно для Казани и Омска. Обязательно вызови инструмент для каждого города."}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Возвращает тестовую погоду",
"strict": true,
"parameters": {
"type": "object",
"properties": {
"location": {"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},
"unit": {"type":"string","enum":["celsius"]}
},
"required": ["location","unit"],
"additionalProperties": false
}
}
}]
}' | tee probe.sse
Ожидаемая форма потока, а не буквальные значения, выглядит так: один или несколько chunks с delta.tool_calls, стабильный index для каждого вызова, накопленные строки function.arguments, chunk с finish_reason: "tool_calls", затем необязательный usage-chunk и [DONE]. Аргументы после сборки должны пройти схему, а города должны остаться в разных вызовах.
Для Responses используйте ту же схему, но перенесите поля функции на уровень объекта инструмента, замените messages на input и путь на /v1/responses. Ожидайте типизированные события response.output_item.added, response.function_call_arguments.delta, response.function_call_arguments.done, response.output_item.done и response.completed. Затем отправьте два элемента function_call_output с исходными call_id и проверьте, что модель учитывает оба результата.
Сохраните файлы зонда как артефакты CI. Удалите секреты из заголовков, но не нормализуйте имена событий, идентификаторы, коды ошибок и usage: именно они показывают расхождение. Такой тест быстро отличает адаптер, который можно дописать, от отсутствующей возможности провайдера.
MCP не равен списку функций в запросе
MCP и tool calling решают связанные, но разные задачи. Tool calling позволяет модели предложить именованный вызов с аргументами. MCP описывает взаимодействие клиента и сервера инструментов: JSON-RPC сообщения, согласование версии и возможностей, обнаружение инструментов, выполнение, уведомления, транспорт и иногда авторизацию. Наличие поля tools не делает шлюз MCP-клиентом.
Спецификация Model Context Protocol требует начать соединение запросом initialize, согласовать версию и capabilities, а затем отправить уведомление об окончании инициализации. Клиент должен использовать только согласованные возможности. Если переносчик просто вызвал tools/list по известному адресу, он пропустил жизненный цикл и может работать с одним сервером лишь по случайности.
Стандартные транспорты тоже надо различать. Для локального сервера клиент запускает процесс и общается через stdio; в stdout допустимы только JSON-RPC сообщения, журналы идут в stderr. Для удаленного сервера применяется Streamable HTTP с POST и GET на одном MCP-эндпоинте, а SSE может передавать несколько сообщений. Старый HTTP+SSE транспорт относится к прежней ревизии протокола. Проверьте, какую ревизию и обратную совместимость поддерживают обе стороны.
Responses API умеет представить удаленный MCP-сервер как встроенный инструмент провайдера. Это отдельная возможность: провайдер сам подключается к server_url, получает список инструментов, обрабатывает вызовы и запросы подтверждения. OpenAI-совместимый шлюз может полностью поддерживать обычные функции и не реализовать удаленный MCP. Он также может принять объект type: "mcp", но не поддержать заголовки авторизации, фильтр разрешенных инструментов или политику require_approval.
Не переносите секреты MCP как обычный текст модели. Авторизационный токен, служебные заголовки и учетные данные принадлежат соединению между клиентом и сервером. Определите, кто устанавливает это соединение после миграции: ваше приложение или провайдер. Во втором случае данные и полномочия уходят еще одному обработчику, а сетевой доступ к внутреннему MCP-серверу придется открыть и ограничить.
Подтверждение действия нельзя сводить к красивой карточке в интерфейсе. Зафиксируйте машинное состояние: запрос подтверждения, показанные пользователю имя и аргументы, решение, время и идентификатор вызова. После отказа инструмент не должен исполняться даже при повторной доставке события. После подтверждения повторный поток не должен запрашивать или выполнять то же действие заново без ясной политики.
Поведение модели остается частью контракта
Даже идеальный протокольный адаптер не гарантирует прежний агентный результат. Разные модели по-разному выбирают инструменты, соблюдают описания, уточняют недостающие данные и останавливаются после ошибки. Шлюз может подменить недоступную модель другой или маршрутизировать один псевдоним между несколькими версиями. JSON останется совместимым, а доля неверных действий изменится.
Не сравнивайте ответы посимвольно. Для агента нужны утверждения о поведении: вызван ли разрешенный инструмент, не вызван ли запрещенный, совпали ли нормализованные аргументы, потребовалось ли подтверждение, учтен ли результат, закончился ли цикл в пределах лимита. Текст оценивайте отдельно, иначе небольшая перефразировка скроет серьезное расхождение в действии.
Соберите набор случаев из производственных трасс после удаления персональных данных. В него должны попасть обычный вызов, отсутствие обязательного параметра, неоднозначный запрос, отказ пользователя, ошибка инструмента, тайм-аут, два независимых чтения, конфликтующие действия и попытка вызвать неразрешенную функцию. Добавьте атаки через содержимое результата инструмента: модель не должна принимать инструкцию из документа или веб-страницы за команду более высокого уровня.
Для каждого случая храните допустимое множество исходов. При неоднозначном адресе правильным может быть уточнение, но не выдуманный адрес и не немедленный вызов. При временной ошибке чтения допустим один контролируемый повтор, а при отклоненном платеже повтор запрещен. Такая разметка переживает смену формулировок и показывает, где различие модели требует новой политики, а не патча JSON.
Зафиксируйте параметры модели и маршрутизации. Температура, лимит выходных токенов, режим рассуждения и доступность параллельных инструментов влияют на результат. Если провайдер игнорирует параметр, тест должен пометить возможность как отсутствующую. Молчаливое игнорирование хуже явной ошибки 400: команда думает, что ограничение действует.
Оценку запускайте несколько раз для недетерминированных сценариев, но не придумывайте универсальный проходной процент без цены ошибки. Для поиска каталога допустим один порог, для перевода денег нужен иной барьер и обязательная защита вне модели. Решение о миграции принимает владелец риска, опираясь на наблюдаемые отказы.
Матрица переноса должна проверять трассу целиком
Приемочная матрица должна связывать возможность с запросом, ожидаемым наблюдением и последствием отказа. Строка «tool calling поддерживается» бесполезна. Строка «два вызова сохраняют разные call_id, оба результата возвращаются модели, повтор не исполняет действие снова» уже проверяема.
Оставьте в матрице как минимум такие группы:
- базовые запросы: роли, типы контента, лимиты, формат ошибок и отмена;
- состояние: локальная история,
previous_response_id, повтор обязательных инструкций и хранение; - инструменты: strict-схема, параллельность, связь результатов, отказ и идемпотентность;
- поток: текст, аргументы, usage, обрыв, отмена и финальное событие;
- MCP: инициализация, версия, обнаружение, транспорт, авторизация и подтверждение.
У каждой строки должны быть четыре статуса: поддержано без адаптации, поддержано адаптером, не поддержано, не проверено. Последний статус нельзя автоматически считать успехом. Рядом укажите доказательство: имя теста и сохраненный trace ID. Скриншот ответа для этого слаб: он не показывает запрос, промежуточные события и повторное исполнение.
Прогоните трассу в обе стороны через один внутренний формат событий. Адаптер старого провайдера и адаптер нового должны выдавать одинаковые доменные события при одинаковом допустимом исходе. Так вы тестируете собственную границу, а не размазываете условия if provider == ... по интерфейсу, оркестратору и журналированию.
Ошибки разделите на преобразуемые и смысловые. Другое имя поля или события обычно закрывает адаптер. Отсутствие MCP approval, неподдерживаемый тип контента или модель, которая систематически вызывает запрещенное действие, адаптером не исправить. Для таких строк нужен отказ от функции, другой маршрут либо изменение продукта с повторной оценкой риска.
В RU LLM переход начинается со смены base_url на единый OpenAI-совместимый эндпоинт, но агентный контур все равно стоит проверять этой матрицей для выбранной модели и используемой поверхности API. Совместимый вход сокращает объем механической работы, а результаты зонда показывают, какие различия надо закрыть до трафика.
Не переключайте весь поток сразу после зеленого smoke-теста. Сначала запустите теневое сравнение для операций без побочных эффектов, затем малую долю чтений, после этого действия под подтверждением. Для каждой ступени заранее задайте условие отката по техническим ошибкам и нарушениям политики. Откат должен возвращать не только адрес шлюза, но и совместимую схему состояния, иначе старый провайдер не поймет ходы, созданные новым.
Регуляторный контур проверяют по каждому переходу данных
API-совместимость ничего не говорит о месте обработки данных и полномочиях сторон. Агентный запрос содержит больше, чем пользовательская реплика: системные инструкции, историю, аргументы инструментов, их результаты, идентификаторы и журналы решений. При переносе каждый из этих элементов может пойти по новому маршруту.
Нарисуйте поток данных на уровне переходов: приложение, шлюз, провайдер модели, MCP-сервер, внешняя система инструмента, хранилище трасс и резервные копии. Для каждого перехода запишите категории данных, цель, место обработки, срок хранения и ответственную сторону. Формулировка «трафик идет через российский шлюз» не отвечает, куда шлюз отправляет запрос выбранной модели.
Маскирование PII тоже проверяют фактическим тестом. Поместите синтетические телефон, адрес электронной почты и номер договора в пользовательский ввод, аргумент функции и результат инструмента. Затем изучите все доступные журналы. Частая ошибка состоит в том, что вход маскируют, а результат MCP-инструмента записывают целиком, хотя в нем больше персональных данных, чем в исходной просьбе.
Аудит-трейл должен связывать решение модели с действием приложения. Минимальная запись содержит идентификаторы запроса и пользователя, версию модели и политики, имя инструмента, хеш или защищенную копию нормализованных аргументов, факт подтверждения, результат, код ошибки и идентификатор повтора. Не записывайте секретные заголовки и токены. Доступ к журналу и срок хранения задаются отдельно от доступа к рабочему API.
Проверьте удаление и резервные копии на практике. Если пользовательские данные удалены из основной базы, связанные трассы, серверное состояние Responses и результаты инструментов могут сохраниться в других системах. Команде нужен процесс поиска по корреляционному идентификатору, а не обещание, что «логи очищаются». Для требований 152-ФЗ юридическая оценка должна опираться на реальную схему потоков и договоры, не на название API.
Последнее решение о переносе должно содержать список оставшихся расхождений с владельцами. Если шлюз не отдает финальный usage при обрыве, это может быть приемлемой потерей метрики. Если он не сохраняет связь подтверждения с вызовом, это блокер для действий. Такое различие и есть работа архитектора: не требовать абстрактных сто процентов совместимости, а не пропустить несовместимость, которая меняет полномочия агента.
Часто задаваемые вопросы
Достаточно ли заменить base_url для переноса агента?
Нет. Замена адреса проверяет соединение и базовую форму запроса. Отдельно проверьте потоковые события, вызовы инструментов, состояние, ошибки и поведение выбранной модели.
Можно ли использовать один обработчик для Chat Completions и Responses API?
Можно только через явный внутренний формат. Провайдерские ответы и события сначала преобразуйте в свои text_delta, tool_call_ready и response_failed, а бизнес-логику подключайте уже к ним.
Почему function calling работает без стриминга, но ломается в потоке?
В потоке аргументы приходят фрагментами и могут чередоваться между вызовами. Если приложение собирает один общий буфер или разбирает JSON до события завершения, первый параллельный вызов обнаружит ошибку.
Гарантирует ли strict валидные аргументы функции?
Он усиливает соблюдение схемы только там, где модель и провайдер поддерживают нужное подмножество JSON Schema. Приложение все равно должно валидировать собранные аргументы перед любым действием.
Как безопасно повторять неудачный вызов инструмента?
Разделяйте транспортную ошибку и бизнес-отказ, а действия защищайте собственным ключом идемпотентности. Повтор разрешает оркестратор по коду ошибки, не модель по свободному тексту.
Означает ли поддержка tools поддержку MCP?
Нет. Обычный tool calling передает модели схемы функций, а MCP добавляет JSON-RPC, инициализацию, согласование возможностей, транспорт, обнаружение и авторизацию. Это разные пункты приемочной матрицы.
Нужно ли сравнивать ответы моделей посимвольно?
Нет. Сравнивайте разрешенные действия, нормализованные аргументы, использование результатов, подтверждения и завершение цикла. Формулировка текста может меняться без функциональной поломки.
Что считать блокером миграции?
Блокером становится расхождение, которое меняет полномочия или не закрывается вашим адаптером: потерянное подтверждение, повтор действия, отсутствие нужного транспорта или систематический вызов запрещенного инструмента. Потерю необязательной метрики можно принять явно.
Как проверить хранение данных при Responses API?
Определите владельца состояния и проследите пользовательский ввод, инструкции, аргументы и результаты инструментов до журналов и резервных копий. Затем выполните тест удаления по корреляционному идентификатору.
С чего начать проверку нового шлюза?
Возьмите реальные обезличенные трассы, запустите потоковый зонд с двумя функциями и заполните матрицу по наблюдаемым результатам. До этого момента зеленый ответ обычного чата не дает основания переносить агентные действия.