最近好几个朋友都在折腾本地模型,问得最多的一个问题不是“哪个模型聊天更聪明”,而是“怎么让本地模型真正干点活”。说白了,聊天谁都会,但要让它去查天气、算数据、调接口,就得靠工具调用(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 。很多人第一次写工具调用,漏的就是这一步,结果模型给出的回答完全是瞎编的。

你要理解其中的原理:模型本身是个“无状态”的文本预测器,每次请求都是一次推理。它第一次看到“北京现在多少度?”时,只能决定“我需要查一下工具”,但它并不知道查询结果是多少。如果你不把结果回填,它第二次生成的时候手里没有新信息,就只能编造一个温度数据。这跟你让实习生去查资料,他查完不记笔记、直接凭印象汇报是一个道理。

完整消息流是这样的:

  1. user : “北京现在多少度?”
  2. assistant (带tool_calls): “我要调用get_weather,参数city=北京”
  3. tool : “查询结果:北京12度,晴”
  4. 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 ,工具调用能力会稍弱一点,但速度会快很多。本地模型的乐趣就在于你可以随便折腾,反正不花钱,多失败几次,很多原理自然就通了。

更多推荐