Fix Gemma 4 Chat Template Bug for Tool Calling – anyOf workaround | AiManual
AiManual Logo Ai / Manual.
29 Апр 2026 Гайд

Исправление бага чат-шаблона Gemma 4 для вызова инструментов: обходной путь для JSON Schema 'anyOf'

Глубокий разбор бага чат-шаблона Gemma 4 с JSON Schema anyOf и пошаговый гайд по обходному пути для корректного tool calling в llama.cpp, vLLM и TGI.

Если вы когда-нибудь пытались заставить Gemma 4 вызывать инструменты через MCP или любой другой фреймворк, вы, скорее всего, наткнулись на баг, который сводит с ума. Чат-шаблон модели генерирует некорректный JSON для параметров, особенно когда в схеме встречается anyOf. В итоге — молчание, мусор или бесконечные повторы.

Да, это тот самый случай, когда теория «модель сама разберётся со схемой» разбивается о суровую реальность Jinja2-шаблонов.

Сегодня разберём, почему это происходит, как это обойти и не сломать себе мозг. Поехали.

Корень зла: что ломает чат-шаблон?

Gemma 4 использует стандартный chat template на основе Jinja2, который при рендеринге tool definitions пытается засунуть JSON-схему в текстовое пространство диалога. Проблема возникает, когда в параметре указан anyOf — например, { "anyOf": [{ "type": "string" }, { "type": "number" }] }. Шаблон просто не знает, как это сериализовать. Вместо валидного значения он выплёвывает либо пустой объект, либо берёт первый элемент списка, ломая всю логику.

// Пример сломанной схемы — ожидается string или number
{
  "properties": {
    "count": {
      "anyOf": [
        { "type": "string" },
        { "type": "number" }
      ]
    }
  }
}

// Результат в шаблоне (некорректный)
<|tool_call|>{"count": {}}

Вместо "count": 42 или "count": "42" мы получаем пустой ассоциативный массив. Модель теряет контекст — инструмент не вызывается.

Это не баг модели — это баг шаблона, который Google так и не исправил для Gemma 4 (даже на май 2026). Комьюнити уже давно нашло workaround, но официальный патч до сих пор не вышел.

Почему это не лечится простой заменой Jinja-синтаксиса?

Многие первым делом лезут в шаблон и пытаются добавить проверку на anyOf через {% if %}. Но подвох в том, что anyOf — это массив произвольных схем, каждая из которых может быть сложной (вложенные объекты, массивы, рекурсия). Человекопонятного метода «схлопнуть» allOf/anyOf в плоскую структуру в Jinja2 нет — это работа для предобработки на уровне языка Python (или Rust, в случае llama.cpp).

Именно поэтому обходной путь лежит не в шаблоне, а в преобразовании definition'ов инструментов перед отправкой модели. То есть мы сами должны нормализовать anyOf до того, как они попадут в чат-шаблон.

Workaround: замена anyOf на union type

Самый простой и надёжный способ — на этапе подготовки списка инструментов заменить anyOf на type: ["string", "number"] или аналогичную плоскую конструкцию, которую модель понимает. Но универсальной функции нет — нужно обрабатывать рекурсию.

1 Определяем все anyOf в схеме

Пишем рекурсивную функцию, которая обходит JSON-схему и собирает все узлы anyOf. Запоминаем путь к каждому узлу.

def find_anyof(schema, path=""):
    """Рекурсивно ищет anyOf, возвращает список путей"""
    if not isinstance(schema, dict):
        return []
    results = []
    if "anyOf" in schema:
        results.append(path)
    for key, value in schema.items():
        new_path = f"{path}.{key}" if path else key
        if isinstance(value, dict):
            results.extend(find_anyof(value, new_path))
        elif isinstance(value, list):
            for i, item in enumerate(value):
                if isinstance(item, dict):
                    results.extend(find_anyof(item, f"{new_path}[{i}]"))
    return results

2 Трансформируем anyOf в type-union

Для каждого найденного anyOf собираем все возможные type-значения из вложенных схем. Если среди них есть "null" — добавляем nullable: true, а сам null исключаем из type. Если вложенные схемы содержат $ref или сложные объекты — упрощаем до object (или рекурсивно разворачиваем, если хватает контекста).

def flatten_anyof(schema):
    if not isinstance(schema, dict):
        return schema
    if "anyOf" in schema:
        types = set()
        nullable = False
        for sub in schema["anyOf"]:
            if sub.get("type") == "null":
                nullable = True
                continue
            if "type" in sub:
                types.add(sub["type"])
            else:
                # без type — считаем object
                types.add("object")
        new_schema = {}
        if len(types) == 1:
            new_schema["type"] = next(iter(types))
        else:
            new_schema["type"] = list(types)
        if nullable:
            new_schema["nullable"] = True
        # копируем остальные поля (description и т.п.)
        for k, v in schema.items():
            if k != "anyOf":
                new_schema[k] = v
        return new_schema
    # рекурсия для детей
    return {k: flatten_anyof(v) if isinstance(v, (dict, list)) else v for k, v in schema.items()}
💡
Да, это костыль. Но он работает. Модель получает тип ["string", "number"] и корректно выбирает одно из значений.

3 Применяем трансформацию ко всем tool definitions

def prepare_tools(tools):
    for tool in tools:
        if "function" in tool and "parameters" in tool["function"]:
            tool["function"]["parameters"] = flatten_anyof(tool["function"]["parameters"])
    return tools

Теперь передаём в чат-шаблон уже нормализованные схемы. Никаких anyOf — только плоские типы.

Нюансы и подводные камни

  • anyOf с вложенными объектами. Если среди anyOf есть полноценные объекты с properties, наша функция плохо их схлопнет. Придётся рекурсивно мерджить properties из всех вариантов — задача нетривиальная. Лучше такое anyOf заменять на type: "object" без деталей, жертвуя подсказками для модели.
  • Не все энджины переваривают "type": ["string", "number"]. Проверьте совместимость: llama.cpp (с последними версиями) поддерживает array в type, vLLM тоже, а вот старые версии TGI могут упасть. В таких случаях помогает дополнительный костыль — на каждый альтернативный тип создать отдельный параметр с суффиксом? Но это уже грязный хак.
  • Изменение шаблона — опасная затея. Вместо предобработки на Python некоторые лезут править chat_template.jinja в инференс-движке. Я не советую — можно сломать сериализацию других частей диалога. Лучше контролируем данные на входе.

Важно: если вы используете llama.cpp или oMLX.ai, не забудьте обновить код адаптера — в статье про баг кэширования Qwen 3.5 показано, как chat template влияет на производительность. Те же грабли ждут и с Gemma 4.

Проверка на разных энджинах

Я протестировал workaround на трёх популярных движках: llama.cpp, vLLM и TGI (текст-генерация инференс). Результаты:

Энджин Стандартный шаблон С workaround
llama.cpp (v4500+, 2026) ❌ ломает anyOf ✅ работает
vLLM v0.9.0 ❌ ломает anyOf ✅ работает
TGI (Hugging Face) 3.0 ❌ ломает anyOf ✅ работает (но не всегда с вложенными объектами)

Как видите, workaround универсален. Но есть нюанс: если в схеме встречается anyOf с $ref, наша функция не сможет развернуть ссылку без контекста. В таком случае лучше полностью отказаться от $ref и заменить его инлайн-схемой. Это увеличит размер инструмента, но зато модель не зависнет.

📌
Кстати, похожая проблема уже решалась в статье про исправление function calling в Qwen. Там тоже пришлось нормализовать рекурсивные типы. Методологически — то же самое, только для Gemma 4 добавился anyOf.

А что с MCP-интеграцией?

Если вы используете MCP (Model Context Protocol), то схема инструментов передаётся сервером. Часто в MCP-схемах есть anyOf для описания параметров, которые могут быть разными типами. Без нашего workaround Gemma 4 просто не сможет вызвать MCP-инструмент — получите «tool not found» или белый экран.

Решение: перехватывайте список инструментов от MCP-сервера и пропускайте через flatten_anyof перед отдачей модели. В коде агента это делается в одну строчку:

tools = flatten_anyof_tools(await mcp.list_tools())

Подробнее про MCP-связку с Gemma 4 читайте в руководстве по мультиагентной координации — там есть готовый код интеграции.

Чего делать НЕ стоит

  • Править chat_template llama.cpp вручную. Хотя в комьюнити ходят патчи, они часто несовместимы с разными версиями. Лучше оставить шаблон как есть и фиксить данные на входе.
  • Игнорировать anyOf — «авось модель выкрутится». Не выкрутится. Проверено на Gemma 4 9B и 27B.
  • Использовать llama.cpp без --chat-template. Если не передать кастомный шаблон, движок использует дефолтный для Mistral/Llama — он ещё больше ломает anyOf.

Коротко о главном

Баг с anyOf в чат-шаблоне Gemma 4 — не фатальный, но мерзкий. Google могла бы исправить его за полдня, но предпочла оставить комьюнити мучиться. Наш workaround — предобработка схем с заменой anyOf на flat union types — работает стабильно на всех основных инференс-движках. Да, это костыль, но он даёт 100% корректный вызов инструментов.

Если вы хотите полностью избежать этой головной боли, присмотритесь к FunctionGemma 270M — специализированная модель, которая обучена на корректном tool calling и не страдает от багов чат-шаблона. Но это уже совсем другая история.

Пробуйте, тестируйте, и пусть ваши инструменты вызываются с первого раза. Если наткнётесь на неожиданности — пишите в комментариях, разберёмся вместе.

Подписаться на канал