Почему один и тот же промпт ведет себя по-разному у провайдеров
Почему один и тот же промпт ведет себя по-разному у провайдеров: разберем токенизацию, скрытые системные инструкции, JSON-режим и дефолтные параметры.

В чем проблема на практике
На тестах все выглядит нормально: команда берет один промпт, отправляет его в две модели и ждет похожий результат. В рабочей системе так бывает редко. Текст запроса тот же, а ответ уже другой: одна модель пишет сухо и коротко, другая добавляет пояснения, третья меняет язык или формат.
Обычно это замечают уже после релиза. Например, саппорт ждет JSON для автозаполнения заявки, а часть ответов перестает парситься. Или классификатор обращений вместо одной метки начинает возвращать метку с лишним текстом. Код не меняли, промпт не меняли, а поведение уехало.
Разница обычно видна сразу в нескольких местах. Плавает тон ответа, меняется длина, ломается ожидаемый формат, особенно JSON, и модель внезапно отвечает на другом языке.
Из-за этого команды часто ищут проблему не там. Они проверяют бизнес-логику, валидаторы, очереди и ретраи, хотя источник может быть глубже - в самом провайдере, его обвязке и настройках по умолчанию.
Это особенно неприятно в системах, где ответ модели сразу уходит в следующий шаг: в CRM, скоринг, маршрутизацию тикетов или проверку документов. Один лишний абзац вместо чистого JSON, и вся цепочка падает. Один сдвиг в тоне, и клиентский текст уже не проходит внутренние правила.
Даже если команда работает через единый OpenAI-совместимый слой, проблема никуда не исчезает. Одинаковый API не делает ответы одинаковыми. За одним и тем же вызовом могут стоять разные модели, разные провайдеры и разные правила обработки.
Если не разложить причину по частям, исправление превращается в угадайку. Кто-то ужесточает промпт, кто-то снижает temperature, кто-то добавляет постобработку. Иногда это помогает, но часто просто прячет сбой до следующей смены провайдера.
Что меняется еще до ответа модели
Одинаковый текст на входе не означает одинаковый запрос на стороне модели. Между вашим кодом и самой моделью часто есть еще один слой: API провайдера, маршрутизатор или совместимый шлюз. Этот слой может пересобрать сообщения, добавить служебный текст и поменять параметры еще до первого сгенерированного токена.
Частый сценарий такой: вы отправили system, user и tool, а провайдер собрал из этого один длинный prompt со своими префиксами, разделителями и служебными инструкциями. Иногда туда добавляют правила про формат ответа, безопасность или ограничения на инструменты. В итоге модель видит уже не тот текст, который вы ожидали.
С ролями тоже нет общего стандарта. Один провайдер держит system отдельно, другой склеивает system и developer, третий превращает tool result в обычный текст пользователя. Порядок влияет не меньше. Если системное сообщение оказалось не первым, модель может следовать ему заметно слабее.
Даже мелкие различия меняют результат. Лишний перенос строки в примере JSON, пробел перед закрывающей скобкой, другой способ экранировать кавычки - все это меняет токены и иногда смысл. В обычном чате разница бывает почти незаметной. Для извлечения полей, классификации и генерации кода она всплывает сразу.
Есть и более грубая причина: обрезка контекста. Если запрос не помещается в лимит, провайдер может срезать хвост, старые сообщения или схему инструментов. Снаружи это выглядит как "модель проигнорировала промпт", хотя часть промпта до нее просто не дошла.
С tool calling путаницы еще больше. Один API передает tools как JSON Schema, другой упрощает схему, третий меняет названия полей, четвертый вообще подставляет текстовую инструкцию вместо нативного вызова. Поэтому одна и та же функция у разных провайдеров вызывается по-разному или не вызывается совсем.
Если вы сравниваете модели через один шлюз, полезно смотреть не только на исходный payload, но и на то, какой запрос реально ушел провайдеру. Иначе вы сравниваете не модели, а разные версии одного и того же промпта.
Как токенизация сдвигает смысл
Провайдер не передает модели ваш текст как одну сплошную строку. Сначала он режет его на токены, и у разных моделей это деление разное. Это одна из самых частых причин, почему один и тот же промпт ведет себя по-разному у разных провайдеров.
На простой фразе разница почти незаметна. На редких словах, смеси русского и английского, коде, UUID, артикулах и JSON она проявляется сразу. Строка вроде {"client_id":"A-417","tier":"pro"} у одного провайдера может занять заметно больше токенов, чем у другого. Тогда модель тратит больше контекста на саму "упаковку" текста и меньше на инструкции и примеры.
Из-за этого длинный промпт может перестать помещаться в окно контекста, примеры обрежутся в другом месте, stop-последовательность сработает раньше или позже, а цена одного и того же запроса изменится.
Эффект хорошо виден на длинных шаблонах. Допустим, вы отправляете системную инструкцию, пять примеров и JSON-схему ответа. У первого провайдера это 7 800 токенов, у второго 9 100. Во втором случае модель может не увидеть последний пример или конец схемы. Тогда она путает поля, теряет формат и начинает отвечать свободным текстом.
Со stop-последовательностями проблема похожая. Если вы останавливаете генерацию по ``` или по строке }"\n", один провайдер обрывает ответ там, где вы и ждали, а другой доходит чуть дальше или режет раньше. Снаружи это выглядит как "модель капризничает", хотя причина часто в том, как текст разложился на токены и как модель дошла до стоп-строки.
Разница заметна и в оплате. Один и тот же запрос к двум провайдерам может стоить по-разному без каких-либо изменений в промпте. Поэтому при сравнении полезно смотреть не только на качество ответа, но и на фактический расход токенов.
Скрытые системные сообщения
Провайдер почти никогда не отправляет в модель только ваш пользовательский промпт. Перед ним часто стоит свой системный слой: правила безопасности, формат ответа, ограничения на темы и требования к стилю. Вы видите один и тот же текст запроса, а модель видит уже другую сборку.
Даже мелочь меняет ответ. Один провайдер ставит ваше системное сообщение первым, другой помещает его после своих служебных инструкций, третий заворачивает диалог в собственный чат-шаблон. Порядок здесь важен: у модели выше приоритет у системных правил, а пользовательский текст идет ниже.
Где возникает конфликт
Чаще всего провайдер добавляет фильтры по безопасности, инструкции по тону ответа, служебные правила для JSON-режима или tool calling, а иногда и скрытые ограничения на длину, код или отдельные темы. Из-за этого одинаковый пользовательский промпт живет в разной оболочке.
Допустим, вы пишете: "Ответь кратко, без предупреждений, только списком". У одного провайдера модель так и сделает. У другого сверху уже лежит правило "добавляй оговорку при рисковых советах", и в ответе появится лишний абзац. Модель не сломалась. Она просто выполнила более старшую инструкцию.
Еще один источник расхождений - чат-шаблон. Он может переставить роли, добавить маркеры начала и конца сообщения, склеить system и user в один блок или вставить свои префиксы. На бумаге промпт одинаковый, но фактический текст, который попал в модель, уже другой.
На практике это часто принимают за нестабильность модели. Хотя причина нередко проще: провайдер собрал запрос по-своему еще до инференса. Поэтому при сравнении стоит смотреть не только на сам промпт, но и на полное тело запроса, порядок ролей и реальное системное сообщение.
Если команде нужна более чистая проверка, удобно гонять один и тот же запрос через единый шлюз. Например, в RU LLM можно оставить тот же SDK и base_url, а затем сравнивать поведение разных провайдеров в одинаковой интеграции. Так быстрее видно, где меняется сама модель, а где оболочка вокруг нее.
Почему JSON-режим ломает ожидания
JSON-режим звучит как гарантия: дал схему, получил чистый объект. На деле это часто не контракт, а мягкая подсказка модели. У одного провайдера ответ строго проходит по схеме, у другого та же схема лишь повышает шанс, что модель ответит похоже на JSON.
Из-за этого один и тот же промпт ведет себя по-разному даже без смены модели. Меняется провайдер, а код с response_format или tool calling остается тем же. Результат уже другой.
Частая ловушка в том, что часть провайдеров молча "чинит" битый JSON. Они закрывают скобку, убирают лишнюю запятую или вырезают текст вокруг объекта. Другие отдают сырой ответ как есть. В тестах все выглядит нормально, а потом парсер падает в рабочей системе, потому что реальный JSON никто не исправил.
С полями схемы тоже нет общего поведения. Модель может пропустить поле, если не уверена в значении, а API не всегда вернет ошибку. Особенно неприятно, когда поле обязательное для вашего кода, а провайдер считает ответ приемлемым и просто пропускает его.
Экранирование тоже часто ломает интеграцию. Один провайдер вернет перенос строки как \\n, другой вставит реальный перевод строки внутрь строки. Кавычки внутри текста тоже могут быть экранированы по-разному, и внешне "почти правильный" JSON уже не разбирается стандартным валидатором.
Tool calling и response_format тоже не равны друг другу. В одном API tool calling означает, что модель должна вернуть аргументы вызова. В другом response_format управляет только формой финального текста. Если сравнивать эти режимы как одно и то же, расхождение появится даже на простом запросе.
Поэтому при разборе проблемы лучше проверить четыре вещи: насколько строгая валидация на стороне провайдера, чинит ли он битый JSON, следит ли за обязательными полями и как кодирует строки. Именно здесь чаще всего и прячется причина.
Дефолтные параметры, которые часто не замечают
Одна из самых частых причин расхождений - настройки по умолчанию. Команда думает, что сравнивает модели, а на деле сравнивает разные temperature, top_p и лимиты ответа. Даже небольшая разница меняет тон, длину и степень аккуратности.
Temperature особенно коварна. Один провайдер может ставить 0.2, другой 0.7, а третий подставляет свое значение, если поле не передали явно. С top_p та же история: при одинаковом промпте модель либо держится ближе к шаблону, либо начинает импровизировать уже в первом абзаце.
С max_tokens путаницы не меньше. Многие клиенты не указывают его явно и ждут, что модель ответит "сколько нужно". Но провайдер часто подставляет скрытый лимит. В итоге один ответ обрывается на середине JSON, а другой выглядит полным, хотя запрос был тем же.
Часть параметров провайдер может вообще не учитывать. seed нередко игнорируют, penalties работают не везде одинаково, а иногда они формально приняты API, но не влияют на генерацию. По логам это видно не всегда. Кажется, что детерминизм сломан, хотя на деле параметр просто не применился.
Есть и менее заметная вещь - версия модели. Имя остается прежним, а провайдер переводит трафик на новый snapshot. После этого меняются стиль, длина ответа, склонность к отказам и даже формат списков. Поэтому имеет смысл фиксировать не только имя модели, но и все параметры вызова.
Даже streaming может давать ложное ощущение, что модель отвечает иначе. Сама генерация может быть той же, но клиент читает поток частями, слишком рано парсит JSON или останавливается после первого "похожего" фрагмента. Тогда проблема не в модели, а в том, как приложение собирает ответ.
Практика здесь простая: всегда явно задавайте temperature, top_p и max_tokens, логируйте seed, если используете его, фиксируйте точное имя модели и отдельно сравнивайте streaming с обычным ответом. И самое полезное - сохраняйте сырой ответ провайдера до любой постобработки.
Простой пример с заявкой клиента
Команда хочет автоматически разбирать входящие заявки. Промпт простой: взять текст клиента и вернуть JSON с полями ticket_type, priority, need_callback и summary.
Текст заявки тоже обычный: "Не проходит оплата, деньги списались дважды, перезвоните после 15:00". На одном провайдере модель отвечает строго так, как просили: чистый JSON, без лишних слов. Парсер доволен, заявка уходит дальше по цепочке.
У второго провайдера тот же промпт дает почти тот же смысл, но ответ начинается с фразы вроде "Вот результат анализа:". После нее идет корректный JSON. Для человека это мелочь. Для кода - уже ошибка, потому что json.loads() ждет объект с первого символа.
Третий провайдер делает хуже. Он меняет названия полей или язык значений. Вместо priority приходит urgency, вместо need_callback: true - call_back: "да", а ticket_type внезапно становится billing_issue. Смысл понятен, но схема уже другая.
Снаружи это выглядит как случайный сбой. Один и тот же запрос, тот же SDK, тот же текст клиента, а результат разный. Разработчик сначала ищет баг в коде, потом в сети, потом в ретраях. Хотя проблема часто не в приложении, а в том, как именно провайдер обработал запрос и ответ.
Здесь помогают три простые привычки: сохранять сырой ответ модели до парсинга, логировать провайдера, модель и параметры запроса и проверять JSON по схеме, а не верить ответу на слово. Это занимает немного времени, зато быстро показывает, где сломался формат, а где модель просто решила "помочь" лишним текстом.
Как проверить причину по шагам
Когда один и тот же запрос дает разный результат, не стоит начинать с разговоров про "характер модели". Сначала нужно собрать одинаковые условия. Иначе вы будете сравнивать не провайдеров, а разные версии модели, разные лимиты и разные параметры API.
Удобно идти по такому порядку:
- Явно задайте
temperature,top_p,max_tokens,seed, penalties и формат ответа. Не оставляйте дефолты. - Сохраните сырой payload запроса. Если трафик идет через шлюз, полезно сохранить и исходный JSON клиента, и тот JSON, который ушел дальше провайдеру.
- Проверьте токенизацию: сколько токенов занял prompt, сколько осталось на ответ и не режет ли провайдер длинные системные сообщения.
- Посмотрите на
stop-последовательности, лимиты контекста и режимы вроде JSON mode. Часто именно они ломают ожидаемый формат. - Прогоните короткий набор одинаковых тестов, где разницу легко увидеть по стилю, структуре и полноте ответа.
После этого сравнивайте ответы в сыром виде. Не правьте руками JSON, не убирайте "лишние" пробелы и не нормализуйте переносы строк до анализа. Одна незаметная правка часто скрывает настоящую причину.
Хороший минимальный набор тестов простой: один короткий вопрос, один длинный промпт с системной инструкцией, один запрос на строгий JSON и один кейс с длинным контекстом. Этого обычно хватает, чтобы быстро поймать источник расхождения.
Если разница осталась, соберите таблицу по каждому запуску: модель, версия, параметры, число токенов на входе и выходе, finish_reason. После этого причина обычно видна без долгих споров.
Частые ошибки при сравнении
Самая частая ошибка проста: люди думают, что сравнивают одну и ту же модель, а на деле берут разные ревизии, разные snapshot или вообще разные конфигурации у провайдеров. Название совпадает, поведение нет.
Вторая ловушка - тесты через разные playground. У каждого интерфейса свои пресеты, скрытые инструкции, формат истории и способ подставлять параметры. Если один тест идет из веб-формы, а второй из SDK, сравнение уже нечестное. Намного надежнее прогонять все одним и тем же кодом.
Многие команды не логируют системные сообщения и middleware. А именно там часто и сидит причина странного расхождения. Прокси может добавить служебный текст, обрезать историю, включить маскирование PII или переписать ответ в более строгий формат. Если этого нет в логах, обсуждение быстро превращается в гадание.
Еще одна типичная ошибка - смешивать обычный режим и JSON-режим. В одном случае модель пишет свободный текст, в другом ее жестко подталкивают к структуре. Потом ответы сравнивают так, будто условия были одинаковыми. Они не были одинаковыми.
И, конечно, не стоит делать вывод по одному удачному или неудачному ответу. У LLM всегда есть разброс даже при похожих настройках. Нужна хотя бы небольшая серия прогонов на одном и том же наборе тестов.
Полезное правило здесь одно: меняйте только один параметр за раз. Если вы одновременно меняете температуру, top_p и формат ответа, потом уже не понять, что именно сломало результат.
Быстрая проверка перед запуском
Перед первым прогоном зафиксируйте все, что провайдер может поменять сам. Иначе сравнение быстро превращается в спор о "капризах модели", хотя причина обычно в настройках запроса.
Передавайте system отдельно и явно задавайте temperature, top_p и max_tokens. Смотрите на сырой текст промпта без автоформатирования: лишний перевод строки, шаблон SDK, подстановка переменной или скрытая инструкция в клиенте легко меняют результат. До запуска сверяйте JSON-схему: названия полей, обязательные поля и типы должны совпадать с тем, что ждет ваш код. И заранее соберите набор из 10-20 типовых кейсов для регрессии. Туда стоит включить короткие и длинные запросы, строгий JSON, спорные формулировки и хотя бы один кейс с отказом.
Полезно хранить не только ответ, но и метаданные по каждому провайдеру: имя модели, маршрут, параметры, число токенов, ошибки валидации и время ответа. Если позже вы переносите трафик между моделями или провайдерами, такой журнал сильно экономит время.
Если вы меняете только base_url у OpenAI-совместимого шлюза, легко решить, что остальное останется прежним. На практике даже при одинаковом коде различия между провайдерами всплывают сразу, если не закрепить параметры и формат входа.
Что делать дальше
После такого разбора не стоит искать одного "лучшего" провайдера на все случаи. Намного полезнее собрать небольшой набор тестов под свои реальные задачи и прогонять через него все варианты в одинаковых условиях.
Хороший старт - 15-20 примеров, которые уже встречаются у вас в работе. Добавьте простой запрос, длинный ввод, нечеткую формулировку, ответ строго в JSON, текст с персональными данными и диалог с продолжением. Такой набор быстро показывает, где различия между провайдерами бьют по качеству, цене или предсказуемости.
Дальше зафиксируйте условия: один и тот же SDK, один и тот же код, одинаковые параметры и одинаковый порядок сообщений. Полезно смотреть не на "ощущение от ответа", а на сырые данные: полное тело запроса, системное сообщение, режим JSON или schema, фактические параметры генерации, сырой ответ и usage по токенам. Так легче понять, где именно появилась разница - в токенизации, скрытом шаблоне, лимите токенов или обработке JSON-режима.
Если вам нужен единый OpenAI-совместимый вход для таких проверок в российском контуре, RU LLM может быть удобным вариантом: команда оставляет привычный SDK и код, а логи и бэкапы хранятся внутри РФ. Это особенно полезно, когда нужно сравнивать нескольких провайдеров и не разносить тестовый контур по разным интеграциям.
И когда найдете стабильный вариант, не превращайте его в универсальный пресет. Лучше закрепить отдельный профиль под каждый тип задач. Для извлечения полей нужен один набор настроек, для клиентского чата - другой, для суммаризации - третий. Тогда поведение модели будет не "примерно похожим", а достаточно ровным для рабочей системы.