Ollama+Qwen本地模型工具调用实战:让AI真正干活
最近好几个朋友都在折腾本地模型,问得最多的一个问题不是“哪个模型聊天更聪明”,而是“怎么让本地模型真正干点活”。说白了,聊天谁都会,但要让它去查天气、算数据、调接口,就得靠工具调用(Tool Calling)。我拿Ollama部署的Qwen模型跑通了一个完整的天气查询工具调用,从选模型、写工具定义到消息回填一步步走下来,踩了不少坑,这篇就把整个过程拆开讲清楚。如果你也想让本地模型接入自己的工具,或者准备用它做AI代理助手的底层,这篇文章可以直接当操作手册用。
先说清楚一个容易被忽略的事实:本地模型做工具调用,跟云端API完全不是一回事。云端模型给你封装好了,传个tools参数就行;本地模型从模型选型、提示词模板到消息结构全得自己操心,任何一个环节不对,模型就会一本正经地告诉你“我无法调用外部工具”。但好处也明显,数据不出本机、不按token计费、可以反复调试,适合做数字人、代理助手这类对隐私和成本敏感的场景。
1. 先搞清楚一件事:本地模型的工具调用到底是怎么回事
1.1 Tool Calling不是聊天,是“让模型学会点菜”
你要理解工具调用,最贴切的比喻是:模型像一个知识渊博但不会动手的实习生。你问他“北京今天冷不冷”,他能跟你聊气候带、纬度、季风,但他没法告诉你此刻的实际温度。工具调用就是给这个“实习生”接上一双手——他能写一张纸条告诉你“需要调用温度查询接口,参数是北京”,你拿着纸条去执行,再把结果拿回来给他,他才能给出真正有用的回答。
这个过程在技术上叫Function Calling,Ollama这边把它叫Tool Calling,本质是一回事。普通的聊天模型只会输出文字,支持工具调用的模型会在特定情况下输出结构化的JSON动作。这个JSON不是给你看的,是给程序解析的。程序看到它,就去执行对应的函数,然后把函数返回值作为一条新消息塞回对话里,模型再基于这个结果组织语言。整个链路中模型只负责“决策”,真正干活的是你写的函数。
区分一下两种模式的差异:
| 对比项 | 普通对话 | 工具调用 |
|---|---|---|
| 模型输出 | 纯文本回复 | 结构化JSON指令或文本 |
| 程序职责 | 展示文本 | 解析指令、执行函数、回填结果 |
| 应用场景 | 问答、写作、翻译 | 查天气、查数据库、操作接口 |
| 对模型要求 | 任何模型都行 | 模型必须经过专门训练 |
这也能解释为什么你拿一个不支持工具调用的模型硬试,它只会胡编乱造一个函数名,或者干脆说“我是语言模型,做不到”。因为它脑子里没有“输出结构化动作”这个概念。
1.2 为什么选Ollama作为本地运行环境
本地部署大模型的工具,市面上有llama.cpp、LM Studio、Ollama、vLLM等一堆选择。我最后选了Ollama,理由很直接:它的API设计极其简洁,一条命令就能跑起来,而且原生支持tools字段,不需要自己去拼提示词模板。
Ollama的Python库和HTTP API都遵循OpenAI兼容格式。这意味着你在OpenAI的GPT上写的工具调用代码,稍微改改就能跑在本地模型上;反过来,以后想切换到云端模型,代码改动也小。这种“上头兼容”的架构,对做应用开发的人来说非常重要——你不会被某个特定方案锁死。
Ollama对工具调用的支持需要版本不低于0.4.0,建议拉最新版。很多人在Mac上部署本地大模型发现工具不生效,查了半天发现是Ollama版本太旧,这个坑我一开始也踩过。另外,工具支持还跟模型本身强相关:Ollama是一个运行环境,它只负责把模型跑起来并转发消息,模型支不支持工具调用,得看模型权重本身有没有这个能力。
2. 准备阶段:选模型、装环境、确认工具能力
2.1 模型怎么选:不是所有模型都能干工具调用的活
热词里有个搜索是“ollama本地部署大模型哪个模型最佳”,我可以直接给你一个结论:做工具调用,别只看对话能力强不强,要看它有没有专门的工具调用训练。以下是我实测过、确认支持Tool Calling的模型:
- Qwen2.5系列(7B及以上):工具调用格式最稳定,是目前本地工具调用的首选。
- Llama 3.1系列(8B及以上):支持,但偶尔会出现工具名称拼写错误。
- Mistral系列(7B):支持,参数提取比较准,但中文能力偏弱。
- GLM4系列:支持,中文场景表现不错。
- Phi-4:支持,代码能力好,但生态不如前几个。
我目前主力用的是
qwen2.5:7b
,主要原因是它在工具调用时输出的JSON格式出错率最低。工具调用是一个对格式要求极高的场景,稍有一点偏差,整个链路就断了。像Llama 3.1有时候会把函数名给我改成别的词,这种模型就不太适合做生产环境。
反例是Ollama默认推荐的
llama3.2
。它对话能力不错,但在Ollama当前版本里工具支持并不完整——我试过一次,它从头到尾都不返回tool_calls字段,只会在回复里写“我可以帮你查询天气,但需要相关接口权限”,这种回答对一个Agent系统来说毫无用处。
2.2 安装Ollama并拉取模型
安装Ollama本身没什么技术含量。到官网下载对应操作系统的安装包,Mac版直接拖进Applications,Linux和Windows也都有对应安装程序。装完在终端里跑一下:
ollama --version
能输出版本号就说明装好了。接下来拉取模型:
ollama pull qwen2.5:7b
这一步会把模型权重下载到本地,7B的量化版大概4.7GB左右,网速快的话十分钟以内。然后启动服务:
ollama serve
默认监听
http://localhost:11434
。这一行命令起来之后,本地模型就是一个标准的API服务了,后面所有代码都是对着这个地址发请求。
在写Python代码之前,先装官方SDK:
pip install ollama
装好之后,最直接的使用方式是这样:
import ollama
response = ollama.chat(
model='qwen2.5:7b',
messages=[{'role': 'user', 'content': '你好'}]
)
print(response['message']['content'])
这个库的本质就是封装了HTTP请求,所以你用
requests
直接调API也完全可以,我后面会展示两种方式。
2.3 用一条命令验证模型是否真的支持Tool Calling
别急着写业务代码,先花一分钟确认你的模型是否真的具备工具调用能力。打开Python交互环境,跑下面这段:
import ollama
response = ollama.chat(
model='qwen2.5:7b',
messages=[{'role': 'user', 'content': '北京现在多少度?'}],
tools=[{
'type': 'function',
'function': {
'name': 'get_weather',
'description': '获取指定城市的当前温度',
'parameters': {
'type': 'object',
'properties': {
'city': {'type': 'string', 'description': '城市名'}
},
'required': ['city']
}
}
}]
)
print(response['message'])
如果模型支持工具调用,返回的
message
里会有一个
tool_calls
字段,里面是结构化的调用指令。如果不支持,这个字段压根不会出现,模型只会回复一段纯文字。这一步花不了两分钟,但能让你后面省下大量排查时间。
3. 完整实操:让模型调用第一个工具(天气查询示例)
3.1 定义工具:用JSON Schema告诉模型“你有个什么工具”
工具调用的第一步不是写代码,而是把工具“介绍”给模型。这个介绍格式是固定的JSON Schema,Ollama和OpenAI都遵循同一套规范。一个完整的工具定义包含三部分:函数名、函数说明、参数定义。
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的当前天气信息,包括温度和天气状况",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "要查询天气的城市名称,如北京、上海"
}
},
"required": ["city"]
}
}
}
description
字段是整个定义里最关键的部分。模型靠它来决定“什么时候该调用这个工具”以及“参数该怎么填”。如果你写的是“天气查询功能”,模型可能不知道什么时候触发它;如果你写清楚“获取指定城市的当前天气信息”,模型遇到天气类问题时就会倾向于调用这个工具。参数描述同理,写清楚“城市名称,如北京、上海”,模型才能正确提取参数。
实操中我的经验是:描述要具体到“何时使用”和“怎么用”,但不要太啰嗦。
name
尽量用英文小写加下划线,模型更容易稳定输出。
3.2 核心代码:请求、响应、执行、回填的完整闭环
这是全文最核心的部分,跟着我做一遍就能跑通。我用一个模拟的天气接口来演示,这样不依赖外部服务,本地就能运行。实际开发中,你只需要把
get_city_weather
函数体替换成真实接口调用即可。
import json
import ollama
# 模拟天气数据,实际开发中这里调用真实天气API
WEATHER_DB = {
"北京": {"temp": 12, "condition": "晴"},
"上海": {"temp": 18, "condition": "多云"},
"广州": {"temp": 26, "condition": "小雨"},
}
def get_city_weather(city: str) -> str:
"""真实的工具函数,执行后返回JSON字符串"""
if city not in WEATHER_DB:
return json.dumps({"error": f"未找到城市 {city} 的天气数据"})
data = WEATHER_DB[city]
return json.dumps({"city": city, "temp": data["temp"], "condition": data["condition"]})
# 1. 定义工具,让模型知道它可以调用什么
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的当前天气信息,包括温度和天气状况",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "要查询天气的城市名称,如北京、上海"}
},
"required": ["city"]
}
}
}
]
# 2. 第一轮:把用户问题和工具列表发给模型
messages = [{"role": "user", "content": "北京现在多少度?"}]
response = ollama.chat(
model="qwen2.5:7b",
messages=messages,
tools=tools,
)
print("模型返回完整对象:", response["message"])
# 这里会看到 tool_calls 字段,例如:
# {'role': 'assistant', 'content': '', 'tool_calls': [{'function': {'name': 'get_weather', 'arguments': {'city': '北京'}}}]}
# 3. 检查模型是否要求调用工具
if response["message"].get("tool_calls"):
# 把assistant的响应追加到对话历史,保留模型的决定
messages.append(response["message"])
for tool_call in response["message"]["tool_calls"]:
fn_name = tool_call["function"]["name"]
fn_args = tool_call["function"]["arguments"]
print(f"模型决定调用 {fn_name},参数: {fn_args}")
if fn_name == "get_weather":
result = get_city_weather(**fn_args)
# 4. 把工具执行结果作为tool角色消息回填
messages.append({
"role": "tool",
"name": fn_name,
"content": result,
})
# 5. 带着工具结果再次请求模型,让它生成最终回答
final_response = ollama.chat(
model="qwen2.5:7b",
messages=messages,
tools=tools,
)
print("最终回复:", final_response["message"]["content"])
else:
print("模型没有调用工具,直接回复:", response["message"]["content"])
这段代码跑通后,你会看到类似这样的输出:
模型返回完整对象: {'role': 'assistant', 'content': '', 'tool_calls': [{'function': {'name': 'get_weather', 'arguments': {'city': '北京'}}}]}
模型决定调用 get_weather,参数: {'city': '北京'}
最终回复: 北京当前温度是12度,天气晴。
看到这个结果,恭喜你,你的本地模型已经会“干活”了。
3.3 为什么要“回填”工具结果:一次工具调用就是一次完整的多轮对话
上面代码里最关键的一步,是把工具执行结果以
role="tool"
的消息追加回
messages
。很多人第一次写工具调用,漏的就是这一步,结果模型给出的回答完全是瞎编的。
你要理解其中的原理:模型本身是个“无状态”的文本预测器,每次请求都是一次推理。它第一次看到“北京现在多少度?”时,只能决定“我需要查一下工具”,但它并不知道查询结果是多少。如果你不把结果回填,它第二次生成的时候手里没有新信息,就只能编造一个温度数据。这跟你让实习生去查资料,他查完不记笔记、直接凭印象汇报是一个道理。
完整消息流是这样的:
-
user: “北京现在多少度?” -
assistant(带tool_calls): “我要调用get_weather,参数city=北京” -
tool: “查询结果:北京12度,晴” -
assistant(最终回答): “北京当前12度,天气晴。”
这四步消息缺一不可,顺序也不能乱。尤其是第2步,很多人图省事直接在第1步后面接第3步,模型就蒙了——它根本不记得自己“决定过”要调用工具,也不知道这个tool结果从哪来的。
还有一个小细节:
tool
消息里的
name
字段必须和工具定义里的
name
严格一致,大小写都不能错。模型对字符串匹配非常敏感,你写个
Get_Weather
它就可能不认。
4. 进阶场景:让代理连续调用多个工具,处理流式输出
4.1 从单个工具到自动循环:模型会自己决定下一步
单个工具调用跑通之后,下一个自然的需求是:让模型在同一个对话里连续调用多个工具。比如用户问“北京和上海哪个冷?”,模型应该先查北京,再查上海,然后对比两个结果给出结论。
实现这个能力不需要改多少代码,核心是加一个循环:只要模型返回了
tool_calls
,就执行工具、回填结果、继续请求;直到模型不再请求调用工具,把循环停下来。
def run_with_tools(user_input: str, tools: list, max_turns: int = 5):
messages = [{"role": "user", "content": user_input}]
for _ in range(max_turns):
response = ollama.chat(
model="qwen2.5:7b",
messages=messages,
tools=tools,
)
messages.append(response["message"])
tool_calls = response["message"].get("tool_calls")
if not tool_calls:
return response["message"]["content"]
for tool_call in tool_calls:
fn_name = tool_call["function"]["name"]
fn_args = tool_call["function"]["arguments"]
result = execute_tool(fn_name, fn_args)
messages.append({
"role": "tool",
"name": fn_name,
"content": result,
})
return "达到最大执行轮数"
def execute_tool(name: str, args: dict) -> str:
if name == "get_weather":
return get_city_weather(**args)
return json.dumps({"error": f"unknown tool: {name}"})
print(run_with_tools("北京和上海哪个更冷?", tools))
这个循环结构就是AI代理的精髓了——模型不再只能回答一次,而是可以根据工具结果继续追问、继续决策,直到得到最终结论。我在实际测试中,Qwen2.5对这个场景的处理质量相当高,它会真的先查两个城市,再对比回答。
max_turns
这个参数务必加上,不然如果模型的工具定义有循环引用,或者它陷入“查了又查”的怪圈,你的程序就会无限循环下去。设置一个上限,与其让程序卡死,不如让它报错退出。
4.2 流式输出时怎么拿tool_calls
本地模型在Mac上跑,速度本来就不如云端快。如果每次都要等模型完全生成完再返回,体验非常糟糕。解决办法是流式输出——token一个接一个地蹦出来,像打字机一样。但流式模式下,工具调用的处理方式跟普通模式不一样。
流式模式下,
tool_calls
不是一次性完整返回的,而是分块到达,需要你手动拼接。看这段代码:
import ollama
response = ollama.chat(
model="qwen2.5:7b",
messages=[{"role": "user", "content": "北京天气怎么样?"}],
tools=tools,
stream=True,
)
tool_name = None
tool_args = {}
text_content = ""
for chunk in response:
msg = chunk["message"]
# 普通文本内容,直接拼
if msg.get("content"):
text_content += msg["content"]
print(msg["content"], end="", flush=True)
# 工具调用指令,分块拼接
if msg.get("tool_calls"):
for tc in msg["tool_calls"]:
fn = tc["function"]
if tool_name is None:
tool_name = fn["name"]
for key, value in fn.get("arguments", {}).items():
tool_args[key] = value
if tool_name:
print(f"\n检测到工具调用: {tool_name}, 参数: {tool_args}")
注意,
msg["tool_calls"]
在流式模式下可能多次出现,每次只带一部分参数。上面的代码用
tool_name
判空来确认是否已经在累加同一个工具的参数,避免同一个函数的参数被多个chunk覆盖。如果你运行时不处理这个细节,很容易出现参数丢一半的情况,特别是在参数较长的时候。
这里有个实际的坑:如果你用
response["message"]["content"]
去拿完整内容,在流式模式下拿不到;如果你不加
stream=True
参数,响应又不会边生成边返回。所以流式工具调用的解析逻辑要单独维护,不能跟普通模式混用。
4.3 给工具加System提示词:模型什么时候该调工具
模型决定是否调用工具,不只取决于工具定义,还取决于System提示词。不加任何System提示词的情况下,模型经常“过度调用”——明明用户问个常识问题,它也非要调一次工具,浪费计算资源。
我现在的做法是在System提示词里明确边界:
你是一个智能助手。当用户询问天气、温度、气温等需要实时数据的问题时,
你必须调用get_weather工具获取真实数据。对于其他常识性问题,直接回答即可,不要调用工具。
就这么一句话,模型的工具调用精准度大幅提升。原因是工具调用的本质是“文本生成任务”,模型会从你的描述中归纳触发条件。你明确告诉它“什么时候用、什么时候不用”,比让它自己猜要稳定得多。
另一个好习惯是设置
temperature=0
。工具调用本身是个确定性任务,如果temperature太高,模型输出JSON时会“发挥创意”,生成一些schema里不存在的字段。我实测
temperature
超过0.7时,偶尔会出现参数变成字符串而不是对象的情况,解析时直接报错。工具调用场景下,建议
temperature
设为0到0.3之间。
5. 常见问题与排查技巧实录
5.1 问题速查表
本地模型工具调用的问题花样很多,但归纳起来就那几类。给你整理了一个速查表,遇到问题对照着排查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 模型从不返回tool_calls,只会说“我无法调用工具” | 模型本身不支持工具调用 |
用
ollama show
确认Capabilities,换Qwen2.5或Llama3.1
|
| 返回了tool_calls,但参数是空的 | 工具描述不够具体,模型不知道填什么 | 完善parameters里的description,明确字段含义和取值范围 |
| 参数全是乱填,比如城市名写成英文 | 中文指令和参数描述不匹配 | 在description里给出中文示例“如:北京、上海” |
| 工具执行了,但最终回答还是错的 | tool消息没回填,或name字段不一致 | 检查消息顺序,确认tool消息的name和工具名严格一致 |
| 模型调用了不存在的工具名 | 模型输出幻觉,或格式漂移 | 降低temperature,换工具调用更稳定的模型 |
| 调用工具后,最终回答说“我不知道结果” | tool消息的content格式不对 | 返回的content必须是字符串,不要传dict,用json.dumps序列化 |
| 速度很慢,一次工具调用要等很久 | 模型太大、context太长、没走GPU |
换小模型(如qwen2.5:3b)、精简工具描述、用
ollama ps
确认GPU占用
|
5.2 一个典型的翻车现场:llama3.2的教训
这里分享一个我折腾很久才开窍的经历。最开始我图省事,直接拉了一个
llama3.2
,心想“官方推荐的肯定没问题”。结果我写了完整的工具调用代码,模型从头到尾都只回复一句话:“As an AI assistant, I don't have the ability to call external tools.”。
我当时以为是代码问题,反复检查消息格式、工具定义,甚至把Ollama升级到最新,都没用。后来用
ollama show llama3.2
一看,Capabilities里只显示
vision
和
embedding
,压根没有
tools
这一项。换到
qwen2.5:7b
之后,同样的代码一次跑通,那一刻才明白:工具支持是模型权重决定的,不是框架决定的。
这也是为什么我一直强调:动手写代码之前,先花两分钟验证模型支持能力。别像我一样,把时间浪费在一个先天不支持工具调用的模型上。
5.3 关于“本地模型不消耗token”这件事的真相
热词里有人搜“本地模型不消耗token”,这个说法对了一半。本地模型确实不花API费用,但token本身依然存在——模型该算多少算多少,只是不计费而已。而且工具调用场景下,你的token消耗会比纯聊天高得多:工具描述、函数参数、工具结果都会进入上下文,占用模型的context窗口。
这个细节在工具调用里很致命。比如你定义10个工具,每个工具的描述加参数schema有500个token,光工具定义就吃掉5000个token。再加上对话历史和工具结果,一个2K context的小模型直接就塞满了。结果就是模型“忘记”了前面的指令,行为变得不可预测。
解决思路有两个:一是尽量精简工具描述,只保留必要信息,不要堆砌长篇说明;二是选择context更大的模型,Qwen2.5 7B有32K上下文,实际用起来问题不大。但要注意,context撑得越长,本地模型的推理速度越慢,尤其在没有GPU加速的设备上会更明显。
5.4 工具调用过程中的一个隐藏坑:参数Schema别搞得太复杂
很多人在定义工具参数时会想:既然都定义了,不如支持复杂结构,比如嵌套对象、数组、枚举。我不建议这么做。语言模型对复杂JSON schema的理解能力有限,嵌套层级一多,它输出的参数格式就会错乱。
我现在定义工具参数的原则是“扁平化”:尽量全部用字符串和数字这种基础类型,必须用枚举的地方写明可选值。比如城市查询工具,
city
就是一个
string
,配合description里的示例就够了。如果实在要传数组、对象,也要在description里给出明确的JSON格式示例,引导模型按格式输出。
6. 一些操作过程中的实测体验和补充
6.1 工具描述的质量,直接决定工具调用的成功率
我前前后后调整了十几次
description
的措辞,发现它对工具调用成功率的影响是最大的。同样一个天气工具,我第一次写的是“查询天气”,模型经常不明白该不该调;改成“获取指定城市的当前天气信息,包括温度和天气状况,当用户询问天气、温度时调用”之后,触发准确率几乎到了百分之百。
好的描述标准是:让一个完全不了解你系统的工程师,只看description就知道这个工具什么时候用、参数怎么填。顺便说一句,多语言场景下description用中文写,模型对中文参数的理解会更准,我用qwen2.5的实测感受很明显。
6.2 把日志加进工具调用链路,调试效率翻倍
工具调用链路有多个环节,任何一个环节出错都很难一眼看出来。我的做法是在每个环节加打印语句,把模型返回的完整对象、工具执行结果、回填后的消息列表全部打出来。尤其是打印回填后的
messages
,能很直观地看到历史消息是否符合预期。
下面这个是我调试时期使用的模式,现在也留着,生产环境里可以换成日志库:
import json
def debug_log(stage: str, data):
print(f"[DEBUG] {stage}: {json.dumps(data, ensure_ascii=False, indent=2)}")
# 消息列表每一步都打出来
debug_log("model response", response["message"])
debug_log("tool result", result)
debug_log("messages after tool", messages[-3:])
多轮工具调用场景下这个习惯特别有用,因为消息一旦长了,出错的概率也随之上升。
6.3 这个能力能做什么:从查天气到AI代理助手
工具调用跑通之后,你的本地模型就不再是一个“聊天玩具”了。往上接一层逻辑,你可以做出相当实用的东西。我自己目前在折腾的就是本地模型的AI代理助手:模型负责理解意图、调度工具,工具负责操作真实的应用——查日历、读写文件、搜索资料。
热词里提到的数字人、代理助手、WorkBuddy这类应用,底层核心都是这个工具调用循环。区别只在于工具的类型和数量不同。所以别小看这个天气查询的demo,它就是整个代理架构的地基。地基打牢了,后面接什么工具都是水到渠成的事。
根据我个人的实测体会,工具调用让本地模型从一个“什么都知道一点的聊天窗口”变成了“能动手解决问题的助手”,这个转变是质变。下一步如果你想让模型接入更多工具,只需要持续往
tools
列表里加定义、在
execute_tool
里加分支就行,核心循环完全不用动。
最后再分享一个实用小技巧:如果你在Mac上部署,内存16GB以上直接跑Qwen2.5 7B没有问题;内存紧张的话,选
qwen2.5:3b
,工具调用能力会稍弱一点,但速度会快很多。本地模型的乐趣就在于你可以随便折腾,反正不花钱,多失败几次,很多原理自然就通了。
更多推荐


所有评论(0)