Ollama 的函数调用能跑通,但七处出错时它不报错,只是安静地少做一次调用。下面的数字来自 ollama 0.40.14 配 qwen3:8b,全部在一台 M4 16GB 的机器上逐条复现,每条都附了判断方法。
先说结论:单轮、单工具、参数简单的场景可以直接用。工具数量上去、上下文收紧、或者你要把它塞进 OpenAI 兼容的客户端时,必须先按下面七条过一遍,否则线上会出现查不到数据却回复得很自信的情况。
先把最小请求跑通
原生端点 /api/chat 的请求体长这样,注意 tools 是顶层字段,不在 options 里面。
curl -s http://localhost:11434/api/chat -H 'Content-Type: application/json' -d '{
"model": "qwen3:8b",
"messages": [{"role": "user", "content": "查一下根目录还剩多少空间"}],
"tools": [{
"type": "function",
"function": {
"name": "get_disk_free",
"description": "查询指定挂载点的剩余磁盘空间,单位 GB",
"parameters": {
"type": "object",
"properties": {"mount": {"type": "string"}},
"required": ["mount"]
}
}
}],
"stream": false,
"think": false,
"options": {"num_ctx": 4096}
}'
返回体里工具调用在 message.tool_calls,arguments 已经是对象,不需要再解析 JSON。这一条与 OpenAI 的格式不同,第五个坑会细说。
{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_e8npmy17",
"function": {"index": 0, "name": "get_disk_free",
"arguments": {"mount": "/"}}}
]
},
"done_reason": "stop",
"prompt_eval_count": 164,
"eval_count": 20
}
拿到调用之后,把结果按 role 为 tool 的消息塞回 messages,再发一次就有了最终回答。这一来一回大约 1.7 秒,比同机云端 API 慢不了多少,本地跑工具链是够用的。
坑一:format 与 tools 同时给,调用会消失
想让它既走工具又输出结构化 JSON,很自然会两个都传。实测三次三次都是同样的结果:工具调用被完整吞掉,返回 200,content 里是一段解释文字,被 format 的 schema 包成 JSON。
# 同一句话,只差一个 format 字段
# 不带 format:tool_calls 有,content 为空
# 带 format:tool_calls 为 null,content 是 {"note": "调用 get_disk_free 工具来查询 / 挂载点的磁盘空间。"}
两次请求的 prompt_eval_count 都是 162,说明工具定义照样进了上下文,只是输出被 format 的约束接管了。判断方法很直接:只要 response 的 content 里出现了工具名的拼音化描述,而 tool_calls 是 null,就是踩了这个。
做法是二选一。要么这一轮只给 tools,让模型决定调用;要么这一轮只给 format,把已经拿到的工具结果整理成结构。两个约束不要同轮叠加。
坑二:num_ctx 不够时会选错工具
工具定义是吃上下文的。20 个工具、每个带一段中文描述和参数说明,prompt_eval_count 直接到 1027。用同一句话去测不同 num_ctx,结果三档完全不同。
num_ctx=512 prompt=258 无任何调用,content 为空
num_ctx=1024 prompt=514 调用了 read_smart(错的那个)
num_ctx=1536 prompt=1027 调用 get_disk_free(正确)
1024 那一档最危险。模型没有拒绝,也没有报错,它从被截断的工具列表里挑了一个还看得见的,read_smart 是读磁盘健康信息的,跟剩余空间差得很远,但回复的语气一样笃定。512 那一档更彻底,连工具调用都不生成,content 空串,done_reason 还是 stop。
可用预算比 num_ctx 小,16K 以内大致只给一半,这个规律我此前单独测过,见 Ollama 上下文怎么设,1024 档只放得下 514 个 token 就是这条规律在起作用。所以装 20 个以上工具的 agent,num_ctx 起步给 8192,别用默认值。
坑三:num_predict 太小,调用变成空回答
把 num_predict 压到 12,返回体是 content 空、tool_calls 为 null、done_reason 为 length。客户端如果只看 tool_calls 有没有值,会认为模型主动选择不调工具。
num_predict=6 done_reason=length eval=6 tool_calls=null
num_predict=12 done_reason=length eval=12 tool_calls=null
num_predict=20 done_reason=length eval=20 tool_calls 完整
num_predict=30 done_reason=stop eval=22 tool_calls 完整
临界点在 20 附近,因为一次 tool_calls 的完整信封本身就要 20 个 token 左右。20 那一档 done_reason 仍是 length,但信封刚好闭合,所以能被解析出来,这说明 length 不等于废输出,判据要看 tool_calls 是否为空。
顺带一个同类现象:思考链会把预算吃光,正文一个字不剩,见 Qwen3-VL 读表格实测 里的 max_tokens 一节。工具调用这一侧的表现就是上面的 null,处理方式一样,把预算给到 6000 以上,或者关掉思考。
坑四:tool_choice 三个值都没生效
在 /v1/chat/completions 上分别传 none、required、auto,三次全部返回了工具调用,耗时 13.17 秒、12.91 秒、13.33 秒,行为完全一致。
# 全都照样调用,none 没能禁用
{"tools": [...], "tool_choice": "none"}
这意味着你没有办法在不删掉 tools 字段的前提下临时禁止调用。想禁用只能在客户端把 tools 整个拿掉,然后按普通对话发一次。工程上更稳的写法是维护两组请求模板,一组带工具一组不带,不要指望服务端帮你切换。
坑五:两个端点的 arguments 类型不一样
同一份工具声明,/api/chat 返回的 arguments 是对象,/v1/chat/completions 返回的是 JSON 字符串。类型不同,客户端代码不能一套通吃。
# /api/chat
"arguments": {"mount": "/"}
# /v1/chat/completions
"arguments": "{\"mount\":\"/\"}"
两边还有三处细节差。原生端点没有 finish_reason,/v1 给的是 tool_calls。原生端点的 tool_calls 元素没有 type 字段,/v1 有。原生端点把序号放在 function.index,/v1 把 index 放在元素顶层。
写适配层的办法是拿到参数后先判类型,是字符串就 json.loads,是字典就直接用。见过太多人在这里抛 TypeError 然后去换模型、重下权重,白折腾半天。同类适配问题在 aider 接本地 Ollama 里也出现过一次,那边踩的是 num_ctx 每轮重算。
坑六:/v1 的 tool 消息 content 必须是字符串
把工具执行结果直接塞字典进去,服务端立刻返 400,报错文本是 invalid message content type: map[string]interface {}。这个报错还算友好,至少指到了类型。
# 错:直接给字典
{"role": "tool", "tool_call_id": "call_abc", "content": {"free_gb": 142.7}}
# 对:先序列化
{"role": "tool", "tool_call_id": "call_abc", "content": "根目录 / 剩余 142.7 GB"}
更隐蔽的是漏掉 assistant 那一轮。把 user 和 tool 两条消息直接发过去,/v1 不报错,还是 200,prompt_tokens 从 202 掉到 179,模型自己把上下文补全了,回答看着也对。少掉的 23 个 token 就是被丢掉的那条调用记录,它不会提醒你。
所以历史必须按顺序保留完整三段:user、带 tool_calls 的 assistant、role 为 tool 的结果。原生端点在 tool 消息上用 tool_name 字段也能通,/v1 认的是 tool_call_id。
坑七:/v1 关思考只有 reasoning_effort 管用
qwen3 默认带思考,一次工具调用前它先写几百字推理。想省这部分开销,四个写法实测差别很大。
不传开关 思考 300 字 completion_tokens=206 14.0s
"think": false 思考 184 字 completion_tokens=139 7.2s
"reasoning_effort":"none" 思考 0 字 completion_tokens=20 1.1s
chat_template_kwargs 思考 416 字 completion_tokens=269 14.0s
只有 reasoning_effort 传 none 能真正压到零,耗时从 14 秒降到 1.1 秒,差了 12.7 倍。think 字段在 /v1 上只减了一部分,chat_template_kwargs 那套写法则完全没被解析,思考反而变长了。原生 /api/chat 认的是顶层 think 布尔值,同一个名字在两个端点上语义不同,这一点与 LiteLLM 代理 Ollama 的五处失败 里记的 think 差异是同一个根因。
工具定义的 token 成本
工具越多越贵,这个成本是固定的,跟问什么无关。同一句话、同一模型,只改工具数量,prompt_eval_count 的变化如下。
| 工具数 | prompt token | 单个工具均摊 | 请求体大小 |
|---|---|---|---|
| 1 | 157 | — | 335 字节 |
| 5 | 342 | 46.3 | 1480 字节 |
| 10 | 574 | 46.4 | 2851 字节 |
| 20 | 1027 | 45.3 | 5561 字节 |
每个工具稳定在 46 个 token 上下,与工具数量无关,是线性叠加。换句话说,一个接了 20 个工具的 MCP 服务,每轮对话光是工具清单就固定花掉 870 个 token,这部分预算不随上下文压缩而消失。
按这个系数倒推,工具清单占用不要超过 num_ctx 的四分之一。8192 的上下文最多挂 40 个工具,4096 的上下文建议控制在 15 个以内。超过这个比例,第二个坑就会开始出现,模型会挑错工具而不报错。
踩坑:三个真实的失败现场
第一个是工具声明根本没被校验。空数组、缺 type 字段、缺 parameters 字段,三种畸形写法全部返回 200,一个错误都没有,接口静默接受然后按没有工具处理。如果你的工具列表是拼接出来的,拼错了不会有人告诉你,只能靠 prompt_eval_count 是否明显偏小来发现。
第二个是能力徽标不可信。ollama show 给 qwen3-embedding:0.6b 列出了 tools 能力,但真喂 tools 过去,两个端点都返 400,报 does not support chat。这条徽标只说明训练数据里有工具调用样本,不代表这个模型能进对话链路。判断能不能用,看的是 chat 能力,不是 tools。
第三个是 response_format 的偶发 500。/v1 上带 json_object 连测三次,第一次跑了 31.29 秒后返 500,报错是 prediction aborted, token repeat limit reached,后两次正常。同类请求在原生端点加 format 从来没复现过。要稳定拿 JSON,用 format 字段走原生端点,别用 OpenAI 那套 response_format。
另外有两个正面结论值得记下。并行调用是支持的,一次要三个结果时它能在一条消息里返回三个 tool_calls,顺序和参数都对,实测 3.89 秒。流式也支持,tool_calls 完整落在第一帧,不会截断。
常见问题
模型明明支持工具,为什么一次都不调用?
先看 prompt_eval_count。如果只有一两百而不是上千,说明工具定义没进上下文,检查是不是写进了 options 里,tools 必须在顶层。再看 done_reason,是 length 就是被 num_predict 掐断了,放宽到 6000 以上再看。这两个都不是,才去怀疑模型本身。
num_ctx 设多大才够用?
把工具数乘以 46,加上你平时提示词的 token 数,再乘以二,就是下限。20 个工具加一段 500 token 的系统提示,算下来要 4000 上下,实际给 8192 才稳妥。低于这个值不会报错,只会选错工具或者干脆不调。
本地小模型的工具调用能直接上生产吗?
简单 schema、单工具、参数只有两三个字符串字段的场景,qwen3:8b 十次连测全部合规,可以直接用。带数组、带枚举、带嵌套对象的复杂 schema 也能过,但前提是上下文给足、format 不与 tools 同轮出现。真要上生产,客户端必须自己校验参数类型再执行,别把工具里抛的异常当成模型能力问题。
把工具链搭起来之后,下一步通常是把它接到客户端里。这一层协议怎么起服务,见 FastMCP 改名后 MCP 服务怎么起,本地工具打包成服务端之后,上面七个坑全部照样存在。
延伸阅读
- ComfyUI 装 FLUX 模型:safetensors 损坏与下拉框空白怎么修1 周前
- ComfyUI API 批量出图:提交成功却拿不到图的五个坑1 周前
- DeepSeek 服务中断 45 分钟:11 月 7 日 15 时 11 分起1 周前
- DeepSeek Harness 密钥放哪:四层解析与五个报错1 周前
继续读
往下这几篇值得接着看:
- PDF 转 Markdown 选 docling 还是 MinerU:三份文件实测2026-11-15
- LiteLLM 代理 Ollama 怎么配:五处静默失败实测2026-11-15
- aider 接本地 Ollama:num_ctx 每轮重算的四个坑2026-11-14
想快速查定义?对应的词条在这里:
- Llama词条 · AI 自学笔记
