Документация Qwen, часть 3: вызов функций
Пересказ раздела Function Calling документации Qwen: что такое вызов функций, как его делают через Qwen-Agent и через vLLM с OpenAI SDK, как выглядит шаблон внутри и почему в продакшене нужен свой разбор ответа. С полным рабочим примером цикла.
Что такое вызов функций (пересказ предисловия)
Заголовок раздела «Что такое вызов функций (пересказ предисловия)»Документация начинает с двух слабостей языковых моделей.
- Ограничения самой модели. Она не знает того, чего не было в обучающих данных (в том числе всё, что случилось после конца обучения), и рассуждает «по вероятности» — поэтому неточна там, где нужны жёсткие правила, например в арифметике.
- Трудно встроить в программы. Модель говорит словами, которые можно понять по-разному, а программы общаются через строго заданные интерфейсы.
Вызов функцийвызов инструментов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 юаней?"}]Способ 1: через vLLM и OpenAI SDK
Заголовок раздела «Способ 1: через vLLM и OpenAI SDK»Шаблон чата 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 вернёт его как обычный текст. Для продакшена документация советует разбирать ответ самим и быть готовыми к поломкам.
Способ 2: через Qwen-Agent
Заголовок раздела «Способ 2: через Qwen-Agent»Qwen-Agent — фреймворк агентов от команды Qwen (наш путеводитель — здесь). Документация называет его эталонной реализацией вызова функций для Qwen3: он сам строит шаблон и разбирает ответ, поверх любого OpenAI-совместимого API — даже такого, который вызов функций не поддерживает.
Важное предупреждение оригинала: для рассуждающих моделей вроде Qwen3 не рекомендуется формат вызова на основе стоп-слов (например, ReAct с «Action:» и «Observation:»). Модель может написать стоп-слово прямо внутри рассуждения — и вызов сломается.
pip install -U qwen-agentfrom 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» оригинала сводится к трём советам.
- Протокол не гарантирован. Даже с правильным шаблоном модель иногда отвечает не по формату. Чем сложнее шаблон и чем больше он полагается на «сообразительность» модели (а не на специальные токены, которым её обучили), тем чаще сбои. В продакшене заранее продумайте, что делать при поломке: повтор, исправление, отказ.
- Шаблон можно уточнять. Если в вашей задаче модель ведёт себя не так, добавьте в шаблон инструкций или ограничений: стандартный шаблон общий, но не обязательно лучший для вас.
- Окончательное решение — дообучение на своих данных (см. часть 4).
Что сделать руками
Заголовок раздела «Что сделать руками»- Запустите цикл из способа 1 на
Qwen3-8B(илиQwen3-0.6B— и посмотрите, как часто маленькая модель ошибается в формате). - Включите размышление (
enable_thinking: True) и посмотрите, сколько токенов уходит на рассуждение перед вызовом. - Добавьте третий инструмент с почти таким же описанием, как у первого. Как часто модель путает их? Перепишите
description, чтобы путаница исчезла. - Подключите те же функции через MCP-сервер из урока 8.3.2 — и сравните, что изменилось в коде клиента.
Словарик
Заголовок раздела «Словарик»| English | Русский |
|---|---|
| function calling / tool use | вызов функций / использование инструментов |
| tool call parser | парсер вызовов |
| JSON Schema | JSON-схема (описание формата аргументов) |
| parallel tool calls | параллельные вызовы |
| tool result / tool response | результат инструмента |
| stopword-based template (ReAct) | шаблон на стоп-словах |