Вызов функций на DeepSeek, Qwen и Doubao: эмуляция
6 мин чтенияАвтор genmux API Team
Агентам для программирования нужна одна возможность API прежде всех остальных: tools на входе, tool_calls на выходе. Шлюз, через который genmux ходит к DeepSeek, Qwen3 и Doubao, просто выбрасывает поле tools — сами модели обучены вызову функций, но труба между нами и ними его не переносит. Поэтому с версии 0.4.0 genmux эмулирует его: определения инструментов вписываются в системный промпт, а ответ разбирается обратно в стандартные tool_calls OpenAI — инкрементально, чтобы стримы продолжали стримиться. Этот пост — про инженерию за этим, про цифры и про то, что это значит для вашего запроса.
Что вы отправляете и что получаете
Ничего особенного. Python OpenAI SDK без изменений:
from openai import OpenAI
client = OpenAI(base_url="https://api.genmux.tech/v1", api_key="nb_live_...")
tools = [{"type": "function", "function": {"name": "get_weather", "description": "Weather by city",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}]
r = client.chat.completions.create(model="deepseek-v4-flash",
messages=[{"role": "user", "content": "Weather in Paris?"}], tools=tools)
call = r.choices[0].message.tool_calls[0] # call.function.name == "get_weather"
# run your function, then send the result back:
r2 = client.chat.completions.create(model="deepseek-v4-flash", tools=tools, messages=[
{"role": "user", "content": "Weather in Paris?"},
r.choices[0].message,
{"role": "tool", "tool_call_id": call.id, "content": "18°C, cloudy"},
])tool_choice принимает auto, none, required и {"type":"function","function":{"name":…}}; в стриме приходят чанки delta.tool_calls с index, id и накапливающимися arguments, а завершается всё finish_reason: "tool_calls". Лимиты: 128 инструментов и 64 КиБ определений на запрос.
Почему <function_call>, а не <tool_call>
Очевидный дизайн — инструктировать модель в том формате, на котором её обучали. Чат-шаблоны Qwen3 и DeepSeek используют конвенцию Hermes: <tool_call>{"name":…,"arguments":{…}}</tool_call> для вызовов и <tool_response> для результатов, — и первый прогон нашего евала рендерил ровно это. Результат: 35–60% на DeepSeek и 0–5% на Qwen и Doubao.
Ответы объяснили почему. <tool_call> и <tool_response> — специальные токены в токенизаторе Qwen, и шлюз калечит их в обе стороны. Qwen3.7-Max увидел нашу инструкцию как «внутри тегов </think>» и отвечал <tool_call_invoke> или <tool_call >; у DeepSeek V4 Pro открывающий тег проглатывался, и парсеру доставалось {…}</tool_calls>. Тот же промпт, отрендеренный тремя способами напрямую против вендора, подтвердил картину: нативные теги переписываются, а нейтральные возвращаются от каждой модели дословно.
Поэтому инструктируемый протокол — форма Hermes с нейтральными именами:
<function_call>
{"name": "write_file", "arguments": {"path": "hello.txt", "content": "hi"}}
</function_call>а результаты инструментов уходят обратно блоками <function_result> в ходе пользователя (несколько подряд схлопываются в один ход, потому что шаблон вендора не любит пары user/user). Ходы ассистента с tool_calls в следующем запросе заново рендерятся в этот текст, так что модель видит согласованную историю независимо от того, в какой формат она изначально «уплыла».
Парсер, который терпит дрейф
Сообщить модели формат — не значит помешать ей использовать свой. На 20 задачах × 7 моделей мы собрали все формы, которые они выдают, и парсер принимает каждую:
- инструктированный блок
<function_call>и нативный<tool_call>, когда шлюз его пропускает; - маркеры DeepSeek
<|tool▁call▁begin|>и его собственную вложенность<function name="x"><arguments>…; - стиль Anthropic
<invoke name="x"><parameter name="k">v</parameter>— с приведением типов аргументов по JSON-схеме инструмента, ведь в XML нет чисел и булевых значений; - ограждения
```tool_call,"parameters"или"input"вместо"arguments", аргументы, вытащенные на верхний уровень, обёртку{"type":"function","function":{…}}; - лишнюю
}или]после блока (Doubao Pro и V4 Flash сделали так по одному разу за прогон); - голый JSON-объект вызова в качестве всего ответа — он считается вызовом, если
tool_choiceего требовал или объект называет известный инструмент.
Всё, что всё-таки не разобралось, отдаётся как текст — клиент видит сырой блок вместо сломанного запроса — и увеличивает счётчик malformed по этой модели. Растущий счётчик у одной модели означает новый дрейф, и лечится он ещё одним случаем в парсере, а не передеплоем чего-либо со стороны модели.
Стриминг — самое тонкое место. Парсер инкрементальный: текст отдаётся, как только он точно не может быть началом маркера, блок буферизуется до закрытия, а ответ, начинающийся с {, удерживается до конца — вдруг это голый вызов. Для клиента результат такой: «Сначала проверю файл» стримится как контент, а вызов приходит чуть позже как delta.tool_calls — ровно так, как отправил бы OpenAI.
Измерено: 20 задач, 7 моделей
Евал гоняет по 20 агентских задач на модель через реальный HTTP API: запросы погоды (включая на русском), промпты, где вызывать ничего нельзя, required и именованный tool_choice, none, запись файлов с кодом, многострочным JSON, Unicode и чек-листом на 12 строк, команды оболочки с кавычками, вложенные массивы в аргументах, два вызова за ход, полный цикл (результат role: tool, затем текст) и одну потоковую запись. Результаты от 07.09.2026:
| Модель | Пройдено | Промах |
|---|---|---|
deepseek-v4-pro | 20/20 | — |
deepseek-v4-flash | 19/20 | лишняя ] после записи кода (парсер теперь терпит) |
deepseek-v3 | 19/20 | ответил на required текстом |
qwen3-7-max | 19/20 | написал похожий на вызов JSON при tool_choice: none |
qwen3-max | 19/20 | ответил на required текстом |
doubao-seed-2-pro | 19/20 | лишняя } (парсер теперь терпит) |
doubao-seed-2-code | 18/20 | required текстом; псевдо-XML <bash name=… arguments={…}> в одной задаче |
Базовый цикл — вызов, результат, ответ, в стриме или без — проходит у каждой модели. Оставшиеся промахи — поведение модели: игнорирование tool_choice: required, которое мы можем попросить, но не можем заставить. Порог, при котором флаг tools у модели остаётся включённым, — 90%; три модели без флага (qwen3-6-flash, doubao-seed-2-lite, doubao-seed-2-mini) возвращают 400 tools_unsupported, а в GET /v1/models флаг виден как params_schema.tools.
JSON mode: инструкция, валидация, один повтор
response_format появился в том же релизе и работает по тому же принципу, с одним дополнением — валидацией. {"type":"json_object"} инструктирует; {"type":"json_schema","json_schema":{"name":…,"schema":{…}}} инструктирует и проверяет непотоковый ответ по схеме (JSON Schema 2020-12). Если валидация не прошла, genmux повторяет запрос один раз, скормив модели ошибку валидатора; если не прошло и это, запрос завершается 502 json_invalid, и обе попытки тарифицируются — обе были сгенерированы. Стримы проходят без валидации: проверять нечего, пока стрим не закончился, а к тому моменту он уже доставлен. strict принимается и игнорируется: ограниченного декодирования у вендора нет. JSON mode и инструменты в одном запросе не сочетаются.
Что это значит для вашего запроса
finish_reason: "tool_calls", когда разобран хотя бы один вызов, иначе"stop". Полученное сообщение ассистента можно добавлять вmessagesкак есть, вместе с результатамиrole: tool, — ровно как у OpenAI.- Токены инструментов — это токены промпта. Определения рендерятся текстом, около 40 токенов на инструмент плюс схема, и тарифицируются по входной ставке модели в каждом запросе. ~20 инструментов OpenCode — это примерно 1,5K токенов на запрос: на
deepseek-v4-flashпо $0.198 за 1М это 0,024 юнита≈ $0.0003за запрос; наqwen3-maxпо $1.08 — примерно впятеро больше. История в любом случае весит больше. requiredи именованныйtool_choice— рекомендация. Проектируйте цикл так, чтобы текстовый ответ был мягкой ошибкой: переспросите или откатитесь наauto.- Один вызов за ход — типично.
parallel_tool_callsпринимается и игнорируется; агенты работают циклом, так что на практике это не важно. - Определения и аргументы инструментов никогда не сохраняются — то же правило, что для содержимого сообщений. В записи задания остаются число инструментов, режим
tool_choiceиusage.tool_calls, больше ничего.
С чего начать
Отправьте tools любой из семи моделей выше и читайте tool_calls в ответ; OpenAI SDK нужен только base_url. Настройка клиентов — в постах про OpenCode, aider, Cline и Roo Code и Continue; краткая сводка — на странице про агентов, полный справочник параметров — в документации.