genmux API 现已全面开放。 查看文档 →
genmux API

博客

在 DeepSeek、Qwen 和豆包上模拟实现函数调用

2 分钟阅读作者 genmux API Team


编程智能体最需要的一个 API 特性压过其他一切:传入 tools,返回 tool_calls。而 genmux 前面那层对接 DeepSeek、Qwen3 和豆包的网关,会把 tools 字段直接丢掉——模型本身是用函数调用训练过的,只是我们和它们之间的这根管子不传这个字段。于是从 v0.4.0 起,genmux 把它模拟出来:工具定义被渲染进 system prompt,回复再被增量解析回标准的 OpenAI tool_calls,这样流式依然能流。本文讲的是这背后的工程、实测数字,以及它对你发出的请求意味着什么。

你发出和拿回的结构

没有任何自定义。用的就是未经修改的 Python OpenAI SDK:

python
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":…}};流式回复以带 index、id 和增量 arguments 的 delta.tool_calls 分块到达,最后以 finish_reason: "tool_calls" 结束。限制:每个请求最多 128 个工具、定义总量 64 KiB。

为什么用 <function_call> 而不是 <tool_call>

最直观的设计,是用模型训练时见过的格式去指示它。Qwen3 和 DeepSeek 的对话模板用的是 Hermes 约定——调用写成 <tool_call>{"name":…,"arguments":{…}}</tool_call>,结果写成 <tool_response>——所以我们评测的第 1 轮就照原样渲染。结果在 DeepSeek 上得分 35–60%,在 Qwen 和豆包上是 0–5%。

回复本身给出了原因。<tool_call> 和 <tool_response> 是 Qwen 分词器里的特殊 token,网关在两个方向上都会把它们弄坏。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/user 相邻)。含 tool_calls 的 assistant 轮次,在下一次请求时会被重新渲染成那段文本,这样无论模型原先漂移成什么格式,它看到的历史都是一致的。

一个能容忍漂移的解析器

告诉模型格式,并不能阻止它用自己的格式。在 20 个任务 × 7 个模型的过程中,我们收集了它们产出的每一种形状,解析器全都接受:

  • 我们指示的 <function_call> 块,以及网关放行时的原生 <tool_call>;
  • DeepSeek 的 <|tool▁call▁begin|> 标记,以及它自己的嵌套写法 <function name="x"><arguments>…;
  • Anthropic 风格的 <invoke name="x"><parameter name="k">v</parameter>——参数类型按工具的 JSON schema 强制转换,因为 XML 里没有数字和布尔;
  • ```tool_call 代码围栏、用 "parameters" 或 "input" 代替 "arguments"、把参数提升到顶层,以及 {"type":"function","function":{…}} 这种包装;
  • 块之后多出来的一个 } 或 ](这一轮里 Doubao Pro 和 V4 Flash 各出现过一次);
  • 整个回复就是一个裸的 JSON 调用对象——当 tool_choice 强制要求调用,或该对象点名了一个已知工具时,算作一次调用。

仍然解析不出来的内容会当作文本输出——客户端看到的是原始块,而不是一个坏掉的请求——同时把该模型的 malformed 计数加一。某个模型上这个计数上升,说明出现了新的漂移,修法是给解析器再加一个分支,而不是在模型侧重新部署什么。

流式是这里比较微妙的地方。解析器是增量的:文本一旦不可能是某个标记的开头就立刻转发,遇到块就缓冲到它闭合,而以 { 开头的回复会一直挂到最后,以防它是一个裸调用。对客户端来说,净效果就是「我先看一下这个文件」作为内容流出来,紧接着调用以 delta.tool_calls 到达——和 OpenAI 会发的一模一样。

实测:20 个任务,7 个模型

这套评测通过真实 HTTP API 对每个模型跑 20 个智能体风格的任务:天气查询(含俄语)、要求不得调用任何工具的提示词、required 和指定名称的 tool_choice、none、写入含代码的文件、多行 JSON、Unicode 和一份 12 行清单、带引号的 shell 命令、嵌套数组参数、一轮里两次调用、一次完整往返(role: tool 结果后再作答),以及一次流式写入。2026-09-07 的结果:

ModelPassThe miss
deepseek-v4-pro20/20—
deepseek-v4-flash19/20stray ] after a code write (parser now tolerates)
deepseek-v319/20answered required in prose
qwen3-7-max19/20wrote a call-like JSON under tool_choice: none
qwen3-max19/20answered required in prose
doubao-seed-2-pro19/20stray } (parser now tolerates)
doubao-seed-2-code18/20required in prose; <bash name=… arguments={…}> pseudo-XML on one task

每个模型都能跑通基本循环——调用、结果、作答,流式与否都一样。剩下的失败都出在模型行为上:忽略 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 模式:指示、校验、重试一次

response_format 和这套机制在同一个版本发布,原理相同,只多了一样东西——校验。{"type":"json_object"} 只做指示;{"type":"json_schema","json_schema":{"name":…,"schema":{…}}} 则在指示之外,还按 schema(JSON Schema 2020-12)校验非流式回复。校验失败时,genmux 会把校验器的错误反馈给模型并重试一次;再失败则以 502 json_invalid 结束,两次尝试都计费,因为两次都真的生成了。流式响应直接透传不校验——流结束之前没什么可校验的,而到那时内容已经交付出去了。strict 会被接受并忽略:上游没有受约束解码。JSON 模式和 tools 不能出现在同一个请求里。

这对你的请求意味着什么

  • 解析出至少一次调用时 finish_reason 为 "tool_calls",否则为 "stop"。你拿回的 assistant 消息可以原样追加进 messages,role: tool 的结果也一样,和 OpenAI 完全一致。
  • 工具 token 算 prompt token。 定义会被渲染成文本,每个工具约 40 个 token 外加它的 schema,并在每次请求上按该模型的输入费率计费。OpenCode 那约 20 个工具,每次请求大概 1.5K token:在 deepseek-v4-flash(每百万 $0.198)上是 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 几篇;编程智能体页面有一屏之内的汇总,完整参数说明见文档。

关于作者

作者 genmux API Team — genmux API.

了解我们