Перейти к содержимому
5 мин чтения

Chat template модели нужно версионировать вместе с весами

Chat template модели нужно версионировать и тестировать вместе с весами, чтобы не получать пустые ответы, сбои thinking blocks и tool calling.

Chat template модели нужно версионировать вместе с весами

Пустой ответ после смены модели почти никогда не объясняется одной только «капризностью» LLM. Часто команда скачала те же веса, подняла тот же inference server и незаметно подала модели другую последовательность токенов. Причина сидит в chat template: в Jinja-файле, настройке токенизатора или прослойке, которая решила «унифицировать» роли под собственный API.

Chat template модели нужно считать частью поставки, наравне с весами и токенизатором. Если вы не фиксируете его версию и не гоняете регрессии, то у вас нет воспроизводимой модели. У вас есть набор артефактов, который иногда отвечает, иногда возвращает пустую строку и иногда превращает tool calling в декоративный JSON.

Hugging Face формулирует это без лишней мистики: чатовая модель продолжает линейную последовательность токенов, а роли и границы сообщений добавляет шаблон. Две модели, дообученные от одной базовой, могут ожидать совершенно разные управляющие токены. Ошибка в этих токенах ухудшает поведение модели, даже если сами веса не менялись.

Шаблон чата входит в контракт модели

Контракт модели состоит не из одного имени в конфиге. Он включает веса, tokenizer, special tokens, chat template, параметры генерации и правила разбора ответа. Когда команда обновляет только model_id, она часто меняет половину контракта вслепую.

Это особенно заметно у open-weight моделей. Один формат отделяет сообщения тегами вида <|role|>, другой использует пары инструкционных маркеров, третий добавляет служебный канал для рассуждений, четвёртый печатает описание инструментов в специальной XML-подобной разметке. Для человека все эти варианты означают «диалог». Для модели это разные префиксы, на которых она училась предсказывать следующий токен.

Есть полезное различие, которое команды регулярно размывают: API-схема сообщения и обучающий формат сообщения не одно и то же. API-схема может выглядеть одинаково:

{
  "role": "user",
  "content": "Найди статус заявки 4815"
}

Но после рендеринга одна модель увидит такую строку:

<|user|>
Найди статус заявки 4815<|end|>
<|assistant|>

А другая такую:

<s>[INST] Найди статус заявки 4815 [/INST]

Нельзя вывести правильный второй вариант из первого JSON. Его надо получить из артефактов модели или из формата, который использовал её автор при обучении.

Практическое правило простое: в release manifest указывайте не только ревизию весов, но и хэш шаблона. Если шаблон хранится внутри tokenizer config, хэшируйте весь набор файлов токенизатора. Если inference server получает шаблон через флаг или отдельный путь, фиксируйте именно поданный файл, а не красивое название шаблона в wiki.

model:
  repository: org/model-instruct
  revision: 8f31c2a
  tokenizer_sha256: "..."
  chat_template_sha256: "..."
serving:
  runtime: vllm
  runtime_version: "..."
  add_generation_prompt: true
  temperature: 0

Этот manifest не делает ответы лучше сам по себе. Он делает инцидент расследуемым. Через две недели вы сможете восстановить, что именно попало в prompt, а не спорить, «не меняли ли мы что-то в шаблоне».

Пустая строка часто начинается с одного лишнего токена

Пустой ответ возникает по разным причинам: фильтр сервера, слишком маленький лимит вывода, stop sequence, ошибка парсера. Но форматирование стоит проверять одним из первых, потому что оно может привести модель прямо к EOS-токену или к закрытому служебному блоку.

Типичный сбой выглядит так. Адаптер рендерит историю, а затем вручную дописывает маркер ассистента. Шаблон уже добавил этот маркер при add_generation_prompt=true. В prompt появляются две подряд стартовые метки assistant. Одна модель начинает печатать повтор роли, другая выбирает EOS, третья выводит текст, который сервер затем отбрасывает как «невалидный ответ ассистента».

Обратная ошибка встречается не реже. Команда отключает add_generation_prompt, потому что «последнее сообщение и так от пользователя». Для конкретной модели это значит, что вход заканчивается внутри пользовательского блока. Она честно продолжает пользователя, а не начинает ответ ассистента.

Не пытайтесь лечить это temperature. Temperature влияет на выбор следующего токена, но не объясняет модели, чей сейчас ход. Сначала сохраните точную строку до токенизации и список token IDs вокруг её конца.

from transformers import AutoTokenizer

model_id = "org/model-instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
messages = [
    {"role": "system", "content": "Отвечай кратко."},
    {"role": "user", "content": "Сколько будет 19 * 3?"},
]

rendered = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)
ids = tokenizer.encode(rendered, add_special_tokens=False)

print(repr(rendered[-240:]))
print(ids[-40:])
print(tokenizer.convert_ids_to_tokens(ids[-40:]))

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

Отдельная ловушка связана с повторным добавлением special tokens. Документация Transformers советует при подготовке к генерации использовать apply_chat_template(..., tokenize=True), поскольку при раздельных этапах рендеринга и токенизации легко добавить служебные токены ещё раз. Если ваш код обязан работать со строкой, явно задайте токенизатору add_special_tokens=False и закрепите это тестом.

Роли нельзя «нормализовать» без правил конкретной модели

Универсальный внутренний формат сообщений полезен. Универсальная семантика ролей без адаптера для каждой модели опасна. Самые неприятные ошибки рождаются там, где платформа тихо «чинит» историю.

Например, продукт разрешает несколько system-сообщений: одно от приложения, другое от администратора, третье от пользователя через конфигурацию задачи. Модель может принимать только один system-turn в начале. Если адаптер склеивает их без разделителя, границы инструкций исчезают. Если переносит второе system-сообщение в user, он меняет его приоритет. Если молча выбрасывает его, команда получает расхождение между тем, что записано в audit log, и тем, что увидела модель.

Правильная политика должна быть явной для каждой модели:

  • принимать последовательность ролей без преобразований;
  • объединять разрешённые system-сообщения по зафиксированному правилу;
  • отклонять неподдерживаемую историю с понятной ошибкой;
  • использовать отдельный шаблон, если модель поставляет ветку для tool use;
  • проверять, что финальный ход действительно готовит генерацию assistant.

Последний пункт важнее, чем кажется. Нельзя считать, что роль assistant в вашем JSON всегда означает «модель должна продолжать». Иногда последний assistant-turn означает prefill: вы уже начали строку ответа и просите модель дописать её. Это другой режим. В Transformers для него существует continue_final_message; его нельзя сочетать с add_generation_prompt, потому что один режим открывает новый ответ, а другой продолжает текущий.

Такой тест должен падать до запуска GPU:

def validate_history(messages: list[dict]) -> None:
    allowed = {"system", "user", "assistant", "tool"}
    for index, message in enumerate(messages):
        if message.get("role") not in allowed:
            raise ValueError(f"messages[{index}].role is unsupported")
        if message["role"] == "tool" and not message.get("content"):
            raise ValueError(f"messages[{index}] has an empty tool result")

    if messages and messages[0]["role"] == "tool":
        raise ValueError("tool result cannot start a conversation")

Это не универсальный валидатор всех моделей. В этом его достоинство: он показывает, где вы обязаны записать собственные правила вместо надежды на «совместимый API».

Thinking blocks требуют отдельного тестового набора

Reasoning-модели добавили ещё один источник ложной уверенности. Команда видит поля reasoning_content, thinking или блоки вроде <think>, выбирает один формат и начинает прокидывать его во все модели. После этого часть ответов обрывается, часть скрытого рассуждения попадает пользователю, а часть инструментальных вызовов оказывается внутри закрытого блока.

Thinking block не равен обычному тексту assistant. Шаблон может открывать его перед генерацией, закрывать перед видимым ответом или ожидать отдельное поле в последнем сообщении. Документация Transformers прямо предупреждает: если prefill положить в content, шаблон может закрыть reasoning block до старта генерации; для продолжения рассуждения нужно заполнять поле reasoning, на которое ссылается сам шаблон.

Здесь нужна чёткая граница между двумя задачами:

  1. Модель рассуждает внутри своего формата, а приложение сохраняет только финальный ответ.
  2. Приложение продолжает уже начатое рассуждение или воспроизводит многоходовой trace.

В первом случае не передавайте внутренний блок назад в историю, если модель и продукт не требуют этого. Вы только увеличите prompt, раскроете то, что не нужно пользователю, и добавите точки отказа.

Во втором случае сделайте отдельную матрицу тестов. Обычные диалоги её не заменяют. Проверяйте минимум такие случаи: пустой reasoning, prefill reasoning с пустым content, завершённый reasoning перед финальным ответом, tool call после рассуждения, возврат tool result и следующий assistant-turn.

Полезный контрактный тест проверяет не красоту текстового вывода, а структуру рендеринга:

def test_reasoning_prefill_keeps_block_open(tokenizer):
    messages = [
        {"role": "user", "content": "Объясни 1 + 1"},
        {
            "role": "assistant",
            "reasoning_content": "Нужно сложить два числа. ",
            "content": "",
        },
    ]

    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        continue_final_message="reasoning_content",
    )

    assert prompt.endswith("Нужно сложить два числа. ")
    assert "<think_end>" not in prompt[-80:]

Конкретные токены здесь условны, а проверка принципа нет: тест должен знать ожидаемое состояние служебного блока на последнем символе prompt. Подставьте реальные границы своей модели. Если шаблон не использует reasoning-поля, тест должен не «адаптироваться», а завершиться ошибкой конфигурации.

Tool calling ломается на стыке трёх протоколов

Держите open-weight ближе
AI Router хостит Llama 4, Qwen 3 и DeepSeek V3.2 на собственной GPU-инфраструктуре.

Tool calling включает минимум три разных формата: то, как приложение описывает инструмент; то, как шаблон показывает это описание модели; и то, как сервер разбирает сгенерированный вызов в объект API. Команда часто тестирует только последний слой. Модель возвращает JSON, значит «инструменты работают». Нет, это говорит лишь о том, что один пример прошёл через парсер.

Шаблон может превратить JSON Schema в Python-подобную сигнатуру, XML-теги или собственный блок. Он может выводить один вызов или список. Он может требовать, чтобы tool result пришёл ролью tool, с именем функции, идентификатором вызова или только строковым content. Hugging Face описывает tool_calls как список в assistant-сообщении и отдельно показывает роль tool для результата, но подчёркивает, что разметка и special tokens зависят от модели и должны совпадать с обучающим форматом.

Вот минимальный диалог, который обязан быть в вашей регрессии:

messages = [
    {"role": "user", "content": "Какая погода в Алматы?"},
    {
        "role": "assistant",
        "tool_calls": [
            {
                "type": "function",
                "function": {
                    "name": "get_weather",
                    "arguments": {"city": "Алматы"},
                },
            }
        ],
    },
    {
        "role": "tool",
        "name": "get_weather",
        "content": "{\"temperature_c\": 18, \"condition\": \"ясно\"}",
    },
]

Для этого сценария проверяйте пять вещей.

  • Шаблон выводит описание get_weather в режиме, который модель ожидает при наличии tools.
  • Assistant tool call содержит именно имя get_weather, а аргумент city не превращается в строку с лишним экранированием.
  • Tool result получает правильную роль и правильные границы блока.
  • После tool result шаблон добавляет начало нового ответа assistant.
  • Инструментальный JSON не появляется в финальном тексте пользователю, если модель должна вернуть обычный ответ.

Популярная, но плохая рекомендация звучит так: «Давайте заставим любую модель выдавать OpenAI function calling строгим system prompt». Она популярна, потому что быстро даёт демо. В продакшене она проваливается на длинной истории, нескольких инструментах и обновлении модели. System prompt не заменяет токены, формат и примеры, на которых модель обучали вызывать инструменты.

Нужен ещё один тест, который многие пропускают: несколько вызовов в одном ходе. Даже если выбранная модель обычно вызывает один инструмент, ваш контракт обязан определить поведение. Либо исполнитель поддерживает массив и выполняет разрешённые вызовы в заданном порядке, либо валидатор отклоняет второй вызов. Молчаливо брать первый вызов означает терять действие без записи об ошибке.

Snapshot prompt полезнее оценки «ответ выглядит нормально»

Поведенческие оценки нужны, но они плохо локализуют поломку шаблона. Один и тот же prompt может случайно дать хороший ответ при temperature 0.7 и плохой при temperature 0.0. Snapshot рендеринга показывает причину раньше генерации.

Для каждого поддерживаемого семейства моделей заведите каталог fixtures. В нём должны лежать входные сообщения и эталонные результаты рендеринга. Не храните там реальный клиентский текст, токены доступа или ответы внутренних систем. Возьмите короткие синтетические фразы, которые делают границы видимыми.

fixtures/
  normal_chat.json
  normal_chat.prompt.txt
  system_and_user.json
  system_and_user.prompt.txt
  tool_roundtrip.json
  tool_roundtrip.prompt.txt
  reasoning_prefill.json
  reasoning_prefill.prompt.txt

Сам snapshot не должен быть единственным оракулом. Добавьте инварианты, которые сообщают о сути ошибки. Например, в обычном запросе prompt обязан заканчиваться маркером начала assistant. В tool roundtrip после tool result должен появляться ровно один такой маркер. В истории с assistant-prefill нельзя добавлять новый assistant header. Вход без tools не должен содержать описание функций из прошлого теста, иначе у вас утечка состояния между запросами.

assert rendered.count(assistant_start) == 1
assert rendered.endswith(assistant_start)
assert tool_schema_marker not in rendered_without_tools
assert tokenizer.eos_token_id not in input_ids[:-1]

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

Токенизационный тест ловит ошибки, которых не видно в строке

Оставляйте след инцидента
Аудит-логи AI Router помогают разбирать обращения после смены модели или шаблона.

Два prompt могут выглядеть одинаково в логе и токенизироваться по-разному. Причина бывает в добавленном BOS, в невидимом пробеле, в различии Unicode-нормализации или в том, что второй вызов токенизатора автоматически вставил special tokens.

Поэтому для нескольких коротких fixture храните не только rendered text, но и ожидаемый хвост token IDs. Не обязательно фиксировать весь массив: он меняется при корректной правке текста. Достаточно закрепить границы ролей и финальные служебные токены.

def test_generation_suffix(tokenizer):
    messages = [{"role": "user", "content": "ping"}]
    ids = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
    )

    tail = ids[-6:]
    assert tail == [151644, 8948, 198, 151645, 198, 151646]

Числа в примере не являются универсальными. В вашем репозитории они должны быть реальными IDs выбранного токенизатора. Если команда боится хранить их, потому что «они нечитаемые», она лишает себя лучшего сигнала о том, что шаблон и tokenizer перестали совпадать.

Отдельно проверяйте Unicode. Казахстанские продукты часто принимают русский, казахский на кириллице, казахский на латинице, английский и смешанный текст в одном сообщении. Шаблон не должен менять содержимое пользователя при конкатенации ролей. Прогоните fixture с І, ң, ғ, апострофами, переносами строк и JSON-строкой в tool result. Это не проверка качества перевода. Это проверка того, что ваша прослойка не портит байты до токенизации.

Матрица тестов должна идти по переходам, а не по «функциям»

Ограничивайте ключи отдельно
AI Router применяет rate-limits на уровне ключа, изолируя трафик разных сервисов.

Плохой набор тестов устроен по названиям возможностей: «есть тест чата», «есть тест tools», «есть тест reasoning». Такой набор легко проходит, хотя переход assistant → tool → assistant сломан. Форматирование зависит от соседних сообщений, поэтому тестировать нужно переходы ролей и режимов.

Минимальная матрица для модели без reasoning включает: system → user → assistant generation, user → assistant-prefill, user → assistant tool call, assistant tool call → tool result → assistant generation, а также несколько user-turn подряд, если продукт допускает их до обращения к модели.

Для reasoning-модели добавьте переходы внутри и вокруг thinking block. Для мультимодальной модели добавьте каждый тип content block, который принимает шаблон. Для сервера с автоматическим tool choice добавьте тест, что parser понимает ровно тот формат, который выдаёт модель. vLLM и похожие рантаймы разделяют выбор chat template и выбор tool-call parser; совместимость этих двух частей нельзя считать автоматической.

Я бы начал с десяти fixture на модель, а не со ста. Пять диалогов для базовых ролей, три для инструментов, два для reasoning или prefill. Когда один из них нашёл инцидент, добавьте минимальный воспроизводимый fixture и запретите закрывать задачу без него. Так набор растёт из реальных отказов, а не из фантазии о покрытии.

Обновление модели требует canary, а не доверия к совместимости

OpenAI-совместимый endpoint удобен, потому что клиент меняет base URL, а не весь код. Но совместимость транспорта не даёт совместимость chat template. Два сервера могут принять один messages payload и собрать для разных моделей разные prompt. Это нормально, пока платформа делает это прозрачно и проверяемо.

Перед обновлением прогоните fixture на старом и новом артефакте. Сравнивайте сначала рендеринг, затем token IDs, затем структурный результат tool calling и только после этого качество свободного ответа. Если шаблон сознательно изменился, review должен содержать причину каждой разницы в snapshot. «Новый шаблон из репозитория модели» не причина, а источник изменения.

В AI Router команды могут сохранять один OpenAI-совместимый клиент, но для production-маршрута всё равно стоит держать отдельный профиль контракта для каждого семейства моделей: формат ролей, режим инструментов, thinking-поля, parser и матрицу проверок. Это особенно полезно, когда маршрутизация меняет модель по стоимости, задержке или требованиям к размещению данных.

Не выпускайте модель в общий трафик, пока не знаете ответ на простой вопрос: какая точная строка попадёт ей на вход для каждого критичного перехода в вашем приложении. Если команда не может показать эту строку, она тестирует разговор с моделью на слух. Для production этого мало.

Часто задаваемые вопросы

Почему chat template влияет на качество ответа модели?

Потому что веса обучались продолжать конкретную последовательность токенов, а не абстрактный JSON с ролями. Шаблон решает, где стоят управляющие токены, границы сообщений, вызовы инструментов и начало ответа ассистента. Подмена шаблона меняет вход модели даже при тех же весах.

Что нужно версионировать вместе с LLM?

Минимум нужны идентификатор или commit весов, файлы токенизатора, chat_template.jinja, версия рантайма, параметры декодирования и набор контрактных тестов. Если вы храните только имя модели, вы не сможете уверенно воспроизвести инцидент после обновления репозитория.

Может ли неверный шаблон вызывать пустые ответы?

Да, часто именно так и выглядит ошибка. Если рантайм не добавил заголовок assistant, модель может продолжить текст пользователя, закончить последовательность EOS-токеном или вывести служебную разметку. Сначала сравните отрендеренный prompt с эталоном, а уже потом меняйте temperature.

Как тестировать tool calling у open-weight модели?

Проверяйте не только валидность JSON. Тест должен подтвердить имя функции, типы аргументов, наличие обязательных полей, порядок tool-call и tool-result, а также нормальный ответ после результата инструмента. JSON может быть синтаксически корректным и при этом не соответствовать контракту вашего исполнителя.

Можно ли подставлять thinking block в content?

Нет. Многие модели ожидают специальный блок для reasoning_content или thinking, а обычное поле content может закрывать этот блок до начала генерации. Нельзя переносить thinking-разметку между семействами моделей по аналогии: сверяйтесь с шаблоном и обучающим форматом конкретной модели.

Всегда ли system message должен быть первым?

Это зависит от модели. Некоторые шаблоны допускают system только в первом сообщении, некоторые встраивают его в первый user-turn, а некоторые принимают несколько системных сообщений. Ваш адаптер должен отвергать неподдерживаемую историю явно, а не тихо переставлять роли.

Достаточно ли snapshot-тестов для chat template?

Обычные текстовые снапшоты полезны, но они не ловят всё. Добавьте токенизационные инварианты, проверку завершения служебных блоков, тест полного цикла с инструментом и короткий запуск генерации. Особенно важно тестировать границы между последним сообщением и generation prompt.

Как проверять шаблон у модели через совместимый API?

Да, если провайдер отдаёт шаблон и токенизатор как часть артефакта модели. Если он скрывает форматирование за API, фиксируйте наблюдаемый запрос на уровне совместимого клиента и гоняйте поведенческие тесты. Слепо предполагать OpenAI-совместимость недостаточно: схема API и реальный prompt внутри сервера могут расходиться.

Нужно ли проводить изменения Jinja-шаблона через code review?

Не как обычный патч. Изменение пробела, EOS-токена, порядка полей или ветки Jinja может сломать ответы на части диалогов. Проводите его через review, обязательный прогон матрицы и canary на ограниченном трафике.

С чего начать регрессионные тесты chat template?

Сначала возьмите пять реальных диалогов из вашего продукта: простой вопрос, system-инструкцию, несколько ходов, один вызов инструмента и ответ после инструмента. Отрендерьте их в строку и сохраните как baseline. Эти пять примеров быстро покажут, что команда на самом деле считает корректным форматом.