Перейти к содержимому
ПереводыQwen, InternLM и другие

Документация Qwen, часть 3: вызов функций

Пересказ раздела Function Calling документации Qwen: что такое вызов функций, как его делают через Qwen-Agent и через vLLM с OpenAI SDK, как выглядит шаблон внутри и почему в продакшене нужен свой разбор ответа. С полным рабочим примером цикла.

Что такое вызов функций (пересказ предисловия)

Заголовок раздела «Что такое вызов функций (пересказ предисловия)»

Документация начинает с двух слабостей языковых моделей.

  1. Ограничения самой модели. Она не знает того, чего не было в обучающих данных (в том числе всё, что случилось после конца обучения), и рассуждает «по вероятности» — поэтому неточна там, где нужны жёсткие правила, например в арифметике.
  2. Трудно встроить в программы. Модель говорит словами, которые можно понять по-разному, а программы общаются через строго заданные интерфейсы.

Вызов функцийвызов инструментовEN tool calling / function calling (function calling; термин «tool use», использование инструментов, документация считает синонимом) — договорённость, как модели общаться с внешним миром:

sequenceDiagram
participant A as Приложение
participant M as Модель
participant F as Функция
A->>M: вопрос + описания функций (JSON Schema)
M->>A: решение: вызвать f(аргументы) — одну или несколько
A->>F: вызов с этими аргументами
F->>A: результат
A->>M: результат как новое сообщение
M->>A: итоговый ответ текстом
Цикл вызова функций: модель только «говорит», какую функцию вызвать; вызывает её ваша программа

Ключевая мысль оригинала: модель ничего не вызывает сама. Она лишь пишет текст особого вида, а «понимать» этот протокол её научили шаблоном и обучением. Для Qwen3 рекомендуется формат вызова в стиле Hermes (его вид — в части 1).

Документация описывает инструменты в формате OpenAI. Каждый инструмент — JSON-объект:

  • type — пока всегда "function";
  • function — описание функции из трёх полей:
    • name — имя;
    • description — что делает функция (модель читает это как инструкцию — пишите понятно!);
    • parameters — JSON Schema аргументов: типы (type), обязательные поля (required), допустимые значения (enum).

Совет из оригинала: в сообщение пользователя кладите всю доступную информацию. В примере документации пользователь сам пишет текущую дату — иначе модель не сможет посчитать «завтра».

Наш пример на всю страницу — два инструмента: курс валют и калькулятор.

import json
def get_exchange_rate(base: str, quote: str) -> dict:
"""Курс валюты base в единицах quote (заглушка вместо настоящего API)."""
rates = {("CNY", "RUB"): 11.2, ("USD", "RUB"): 81.5, ("USD", "CNY"): 7.1} # придуманные числа для примера
return {"base": base, "quote": quote, "rate": rates.get((base, quote))}
def calculate(expression: str) -> dict:
"""Посчитать арифметическое выражение. Разрешены только цифры, точка, скобки и + - * /."""
if not set(expression) <= set("0123456789.+-*/() "):
return {"error": "недопустимые символы"}
return {"result": eval(expression)} # eval безопасен только благодаря проверке выше
FUNCTIONS = {"get_exchange_rate": get_exchange_rate, "calculate": calculate}
TOOLS = [
{"type": "function", "function": {
"name": "get_exchange_rate",
"description": "Узнать текущий курс одной валюты в другой.",
"parameters": {
"type": "object",
"properties": {
"base": {"type": "string", "enum": ["USD", "CNY"], "description": "Какую валюту переводим"},
"quote": {"type": "string", "enum": ["RUB", "CNY"], "description": "В какую валюту"},
},
"required": ["base", "quote"],
},
}},
{"type": "function", "function": {
"name": "calculate",
"description": "Точно посчитать арифметическое выражение, например '2350 * 11.2'.",
"parameters": {
"type": "object",
"properties": {"expression": {"type": "string", "description": "Выражение из чисел и + - * /"}},
"required": ["expression"],
},
}},
]
MESSAGES = [{"role": "user", "content": "Сколько рублей стоит чайник за 2350 юаней?"}]

Шаблон чата Qwen3 (в tokenizer_config.json) уже умеет формат Hermes. Нужно только запустить сервер с разбором вызовов:

Окно терминала
vllm serve Qwen/Qwen3-8B --enable-auto-tool-choice --tool-call-parser hermes --reasoning-parser qwen3

(В оригинале на момент написания стоял --reasoning-parser deepseek_r1 — так было в vLLM 0.8.5; с 0.9.0 есть парсер qwen3, см. часть 2.)

Дальше — обычный цикл агента из урока 8.3.1:

from openai import OpenAI
client = OpenAI(api_key="EMPTY", base_url="http://localhost:8000/v1")
messages = list(MESSAGES)
for step in range(5): # не больше 5 шагов, чтобы не зациклиться
resp = client.chat.completions.create(
model="Qwen/Qwen3-8B",
messages=messages,
tools=TOOLS,
temperature=0.7, top_p=0.8, max_tokens=1024,
extra_body={"chat_template_kwargs": {"enable_thinking": False}}, # без размышления — быстрее
)
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True)) # ответ модели — в историю, как есть
if not msg.tool_calls: # вызовов нет — это итоговый ответ
print("ОТВЕТ:", msg.content)
break
for call in msg.tool_calls: # вызовов может быть несколько — выполняем по порядку
args = json.loads(call.function.arguments) # аргументы приходят СТРОКОЙ с JSON
result = FUNCTIONS[call.function.name](**args)
print(f"вызов {call.function.name}({args}) -> {result}")
messages.append({
"role": "tool",
"content": json.dumps(result, ensure_ascii=False),
"tool_call_id": call.id, # связывает результат с конкретным вызовом
})

Примерный вывод (порядок шагов и формулировки у вас могут отличаться — генерация случайна):

вызов get_exchange_rate({'base': 'CNY', 'quote': 'RUB'}) -> {'base': 'CNY', 'quote': 'RUB', 'rate': 11.2}
вызов calculate({'expression': '2350 * 11.2'}) -> {'result': 26320.0}
ОТВЕТ: Чайник за 2350 юаней стоит примерно 26 320 рублей (по курсу 11,2 рубля за юань).

Модель сделала два шага: сначала узнала курс, потом — получив число — попросила калькулятор. Проверка устойчивости: спросите про евро. Такой валюты нет в enum, и хорошая модель честно скажет, что курса не знает, а не выдумает его.

Что подчёркивает документация:

  • finish_reason в таком ответе — "tool_calls", content пустой (None), вызовы — в поле tool_calls;
  • у OpenAI-формата аргументы — строка с JSON, её нужно разобрать json.loads (а в шаблоне Qwen — JSON-объект; конвертацию делает парсер vLLM);
  • tool_call_id нужен, чтобы при нескольких параллельных вызовах модель знала, какой результат к какому вызову;
  • парсер может не справиться: модель иногда пишет вызов с ошибкой в JSON, и vLLM вернёт его как обычный текст. Для продакшена документация советует разбирать ответ самим и быть готовыми к поломкам.

Qwen-Agent — фреймворк агентов от команды Qwen (наш путеводитель — здесь). Документация называет его эталонной реализацией вызова функций для Qwen3: он сам строит шаблон и разбирает ответ, поверх любого OpenAI-совместимого API — даже такого, который вызов функций не поддерживает.

Важное предупреждение оригинала: для рассуждающих моделей вроде Qwen3 не рекомендуется формат вызова на основе стоп-слов (например, ReAct с «Action:» и «Observation:»). Модель может написать стоп-слово прямо внутри рассуждения — и вызов сломается.

Окно терминала
pip install -U qwen-agent
from qwen_agent.llm import get_chat_model
llm = get_chat_model({
"model": "Qwen/Qwen3-8B",
"model_server": "http://localhost:8000/v1", # то же, что base_url в OpenAI SDK
"api_key": "EMPTY",
"generate_cfg": {"extra_body": {"chat_template_kwargs": {"enable_thinking": False}}},
})
messages = list(MESSAGES)
functions = [t["function"] for t in TOOLS] # Qwen-Agent принимает «функции», а не «инструменты»
for step in range(5):
for responses in llm.chat(messages=messages, functions=functions):
pass # chat — генератор (для стриминга); берём последний результат
messages.extend(responses)
calls = [m for m in responses if m.get("function_call")]
if not calls:
print("ОТВЕТ:", responses[-1]["content"])
break
for m in calls:
name = m["function_call"]["name"]
args = json.loads(m["function_call"]["arguments"])
result = FUNCTIONS[name](**args)
messages.append({"role": "function", "name": name, "content": json.dumps(result, ensure_ascii=False)})

Отличия от OpenAI-формата, на которые указывает документация:

  • каждый вызов — отдельное сообщение ассистента с полем function_call (name + arguments строкой);
  • в режиме размышления сначала идёт сообщение с полем reasoning_content, потом вызовы;
  • результат возвращается с ролью "function" и полем name (а не "tool" + tool_call_id).

Чтобы понять, что видит модель, напечатайте промпт, который строит токенизатор:

from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
print(tok.apply_chat_template(MESSAGES, tools=TOOLS, tokenize=False,
add_generation_prompt=True, enable_thinking=False))

Вы увидите системное сообщение с разделом # Tools, JSON-схемы внутри <tools>…</tools> и инструкцию возвращать вызовы в <tool_call>…</tool_call>. Ответ модели — тоже просто текст, например:

<tool_call>
{"name": "get_exchange_rate", "arguments": {"base": "CNY", "quote": "RUB"}}
</tool_call>

Это и делает парсер hermes в vLLM: находит такие блоки регулярным выражением и превращает в tool_calls. Вот минимальная своя версия — то, что документация советует иметь «на случай, если сломается»:

import re, json
TOOL_CALL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
def parse_tool_calls(text: str):
"""Вернуть (список вызовов, текст без вызовов). Битые JSON пропускаем, но сообщаем о них."""
calls = []
for raw in TOOL_CALL_RE.findall(text):
try:
obj = json.loads(raw)
calls.append({"name": obj["name"], "arguments": obj.get("arguments", {})})
except (json.JSONDecodeError, KeyError) as e:
print("не удалось разобрать вызов:", raw[:80], e)
return calls, TOOL_CALL_RE.sub("", text).strip()
print(parse_tool_calls('<tool_call>\n{"name": "calculate", "arguments": {"expression": "2350*11.2"}}\n</tool_call>'))
# ([{'name': 'calculate', 'arguments': {'expression': '2350*11.2'}}], '')

Раздел «Finally» оригинала сводится к трём советам.

  1. Протокол не гарантирован. Даже с правильным шаблоном модель иногда отвечает не по формату. Чем сложнее шаблон и чем больше он полагается на «сообразительность» модели (а не на специальные токены, которым её обучили), тем чаще сбои. В продакшене заранее продумайте, что делать при поломке: повтор, исправление, отказ.
  2. Шаблон можно уточнять. Если в вашей задаче модель ведёт себя не так, добавьте в шаблон инструкций или ограничений: стандартный шаблон общий, но не обязательно лучший для вас.
  3. Окончательное решение — дообучение на своих данных (см. часть 4).
  1. Запустите цикл из способа 1 на Qwen3-8B (или Qwen3-0.6B — и посмотрите, как часто маленькая модель ошибается в формате).
  2. Включите размышление (enable_thinking: True) и посмотрите, сколько токенов уходит на рассуждение перед вызовом.
  3. Добавьте третий инструмент с почти таким же описанием, как у первого. Как часто модель путает их? Перепишите description, чтобы путаница исчезла.
  4. Подключите те же функции через MCP-сервер из урока 8.3.2 — и сравните, что изменилось в коде клиента.
EnglishРусский
function calling / tool useвызов функций / использование инструментов
tool call parserпарсер вызовов
JSON SchemaJSON-схема (описание формата аргументов)
parallel tool callsпараллельные вызовы
tool result / tool responseрезультат инструмента
stopword-based template (ReAct)шаблон на стоп-словах