Если вы когда-нибудь пытались заставить 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 и заменить его инлайн-схемой. Это увеличит размер инструмента, но зато модель не зависнет.
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 и не страдает от багов чат-шаблона. Но это уже совсем другая история.
Пробуйте, тестируйте, и пусть ваши инструменты вызываются с первого раза. Если наткнётесь на неожиданности — пишите в комментариях, разберёмся вместе.