模型侧的工具调用,说简单点,就是让大模型在生成回复时“停下来、先调用你定义好的函数、拿到结果之后再继续编”。qwen3 1.7b这个体量的模型,以前很少被拿来做工具调用,因为大家默认参数越小,指令遵循能力越差。但这代模型在训练时明显做了结构化输出的加强,我实测下来,配合OpenAI兼容的tools格式,即使只跑了量化版,也能在低配机器上完成稳定的多轮工具调用。这篇文章我会从原理、部署、完整可运行的代码示例,到踩坑记录,一次性讲透,适合想在本地低成本场景里接入智能体能力的开发者。
1. 工具调用的原理与本项目拆解
1.1 大模型工具调用到底在做什么
工具调用(Tools调用 / Function Calling)并不是大模型真的去调用某个API,而是模型在推理时根据对话上下文和工具定义,输出一段符合约定的结构化文本,通常是一个JSON片段,里面包含函数名和参数。真正执行这个函数的是外围代码,执行完成后再把结果拼回消息列表,让模型做后续的总结或追问。
打个比方:大模型像一个只负责“动嘴”的前台,它不亲手干活,而是写一张派工单。工具调用就是教会它正确写派工单的格式,然后旁边有个跑腿的人照着派工单把事情干了,再回来告诉它结果。所以你的核心工作不是教模型怎么干活,而是:
- 把你的能力封装成结构清晰的工具定义(Tools Schema)
- 把对话流程组织成模型能理解的Messages序列
- 把模型输出的tool_calls正确解析出来、执行、回填
这个流程三道坎,每一道都会让不少新手卡住。qwen3 1.7b在官方训练里就专门强化了这种“派工单”能力,而且对JSON输出的稳定性不错,所以很适合作为本地小规模智能体的底座模型。
1.2 为什么选qwen3 1.7b而不是大模型
可能有人会问:工具调用能力不是模型越大越强吗,为什么偏要跟一个1.7B的小模型较劲?原因很现实:
第一,成本。1.7B模型在消费级显卡上甚至只用CPU都能跑,一张办公用的旧卡或者一个8GB内存的迷你主机就能带动。对于内部工具、个人自动化脚本、边缘设备,这种规模是可接受的。
第二,延迟。本地推理少了网络往返,单次调用的首token延迟可以压到几十毫秒到几百毫秒级别。很多自动化场景需要快速反馈,没必要为一个意图识别任务跑大模型。
第三,可控性。大模型能力更强,但输出风格更难锁定。小模型只要提示词写得严谨,反而更“听话”——它没有那么多的自由发挥空间,你让它输出JSON它多半就老实输出JSON。
当然,小模型的短板也很明显:对非常复杂的嵌套参数理解弱、长工具定义多了容易漏字段、逻辑推理一多就开始胡编。所以这篇文章里,我会基于“合理使用”的边界来设计演示,不吹它能替代大模型,而是把它的优点榨干。
1.3 采用OpenAI兼容格式的原因
现在主流推理框架(vLLM、Ollama、部分云服务网关)普遍兼容OpenAI的chat/completions接口,其中工具调用通过请求体里的tools字段传入,响应里通过tool_calls字段返回。qwen3官方也支持这套格式。
好处很明显:
- 代码不需要绑定具体某个框架。本地换后端只需改
base_url。 - 生态工具多。很多现成Agent框架、开发库默认消费这种格式。
- 排错方便。把请求和响应打印出来,结构一目了然。
所以下面的示例我会以这种格式为主线,先讲清楚结构,再给出本地推理框架的加载方式。
2. 环境准备与模型加载
2.1 推理框架选型建议
要在本地跑qwen3 1.7b并暴露OpenAI兼容接口,主流有这几种方式:
| 框架 | 定位 | 硬件要求 | 适合场景 |
|---|---|---|---|
| Transformers | 纯Python推理 | 显存2-4GB起步 | 调试模型、研究输出、快速原型 |
| vLLM | 高吞吐服务 | GPU至少4GB | 多并发、生产环境、批量请求 |
| Ollama | 开箱即用 | 内存4-8GB | 本地小工具、快速体验 |
| llama.cpp | CPU优先 | 内存小 | 无GPU的旧电脑 |
我个人的建议是:如果你是新手,代码调试阶段直接用Transformers,它能看到完整细节;如果是正式要做服务,vLLM更合适,它的兼容接口和并发能力都省心。至于Ollama,胜在快,但排查问题不如前两个直观。
2.2 Transformers加载与验证
先把最基础的加载代码写出来。需要注意一点:qwen3 1.7b属于Chat模型,加载时直接用AutoTokenizer和AutoModelForCausalLM就行。
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_name = "qwen3-1.7b" # 实际模型ID请按你本地镜像名调整 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, device_map="auto", trust_remote_code=True, torch_dtype=torch.float16, ) messages = [ {"role": "system", "content": "你是一个擅长使用工具的助手。"}, {"role": "user", "content": "请帮我查一下北京现在的天气。"} ] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, ) inputs = tokenizer([text], return_tensors="pt").to(model.device) outputs = model.generate( inputs.input_ids, max_new_tokens=512, do_sample=True, temperature=0.6, top_p=0.9, ) response = tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True) print(response)这一步是为了确认模型本身能正常加载、正常对话。如果连这一步都报错,先不要急着去搞工具调用,多数是依赖版本问题或者模型路径问题。
2.3 用vLLM暴露OpenAI兼容接口
如果决定走服务路线,vLLM是最省心的一条路。启动命令大概长这样(具体参数以你使用的版本为准):
python -m vllm.entrypoints.openai.api_server \ --model qwen3-1.7b \ --served-model-name qwen3-1.7b \ --max-model-len 8192 \ --dtype float16 \ --port 8000启动后访问http://127.0.0.1:8000/v1,就能用OpenAI SDK来接入了。注意--served-model-name这里是给下游代码引用的名字,可以自定义。
如果你的机器没有GPU,也可以考虑llama.cpp的server子命令,它在CPU上跑量化模型表现不错,但工具调用相关的兼容性需要测试。我实际试过几种后端,qwen3 1.7b跑4bit量化后工具调用仍可用,但明显比fp16更容易出现参数漏填,后面在常见问题章节会细说。
3. 核心代码示例:一次完整的工具调用闭环
3.1 定义工具集合
我先定义两个演示工具:一个是查询天气,一个是做加减乘除。这两个常见又有区分度:一个走外部API,一个纯本地函数。
import json def get_weather(city: str) -> str: """模拟天气接口,实际请替换为真实数据源""" data = { "city": city, "temperature": random.randint(10, 30), "condition": "晴" if random.random() > 0.5 else "小雨", } return json.dumps(data, ensure_ascii=False) def calculate(expression: str) -> str: """安全计算器,只允许简单表达式""" allowed = set("0123456789+-*/(). ") assert all(c in allowed for c in expression), "非法字符" result = eval(expression, {"__builtins__": {}}, {}) return str(result)接着把工具描述成模型能看懂的结构。这个描述就是模型的“眼睛”,写得越清晰,模型越不容易用错。
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气情况", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如 北京、上海"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "计算数学表达式,支持加减乘除和括号", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,如 (12+3)*4"} }, "required": ["expression"] } } } ]3.2 构造请求并接收响应
这里用OpenAI SDK连接本地vLLM服务为例。如果还没安装SDK,先执行pip install openai。
from openai import OpenAI client = OpenAI( api_key="EMPTY", # 本地服务不需要真实key base_url="http://127.0.0.1:8000/v1", ) messages = [ {"role": "system", "content": "你是一个乐于助人的助手。请根据用户问题选择合适的工具,并按格式调用。"}, {"role": "user", "content": "帮我算一下 (12+3)*4 等于多少?"} ] resp = client.chat.completions.create( model="qwen3-1.7b", messages=messages, tools=tools, tool_choice="auto", temperature=0.3, ) print(resp.choices[0].message.model_dump())正常情况,响应里会有一个tool_calls数组:
{ "role": "assistant", "content": null, "tool_calls": [ { "id": "call_xxx", "type": "function", "function": { "name": "calculate", "arguments": "{\"expression\": \"(12+3)*4\"}" } } ] }这个结构就是核心。arguments是字符串,需要你再json.loads解析一次。
3.3 执行工具并回填结果
拿到tool_calls后,外层代码要自己找到函数并执行。这里有一个非常关键的细节:工具名称到函数对象的映射,必须用字典实现,不要用eval或getattr到处找。
tool_map = { "get_weather": get_weather, "calculate": calculate, } for call in resp.choices[0].message.tool_calls: func_name = call.function.name args = json.loads(call.function.arguments) result = tool_map[func_name](**args) messages.append(resp.choices[0].message) # 保留带tool_calls的assistant消息 messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result), })然后再次调用模型,让它基于工具结果生成最终回答:
final = client.chat.completions.create( model="qwen3-1.7b", messages=messages, tools=tools, tool_choice="auto", ) print(final.choices[0].message.content)这段代码跑通,一个完整的工具调用闭环就成立了。很多人在第一步就漏了messages.append(resp.choices[0].message),想着只把工具结果拼回去,结果模型根本不知道是响应哪个调用,自然就会胡言乱语。
3.4 封装成多轮循环
实际应用里不会只调用一次工具,可能第一轮查天气,第二轮根据天气算温差,第三轮还要查第二个城市。所以必须把上面的逻辑循环起来。我给出一个可复用的简化循环:
def run_agent(user_query: str, max_rounds: int = 5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for _ in range(max_rounds): resp = client.chat.completions.create( model="qwen3-1.7b", messages=messages, tools=tools, tool_choice="auto", temperature=0.3, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content tool_list = [] for call in msg.tool_calls: try: args = json.loads(call.function.arguments) result = tool_map[call.function.name](**args) except Exception as e: result = f"工具执行异常: {e}" tool_list.append({ "role": "tool", "tool_call_id": call.id, "content": str(result), }) messages.extend([msg, tool_list[-1]]) return "已达到最大调用轮数,请简化问题或检查工具定义。"这个循环里有个细节:我把tool_list[-1]和msg同步append进messages。如果一次响应里有多个tool_calls,model要求每一条工具结果都要有对应的tool_call_id,顺序最好和原调用顺序一致。
3.5 纯Transformers离线运行的变通做法
不想起vLLM,想在脚本里直接跑,也可以。qwen3的chat模板会自动把系统提示、用户内容和工具描述拼成输入,但tools参数不会像OpenAI接口那样单独立传给模型。所以离线模式要手动把工具描述写进系统提示里,然后从生成的token中找特殊标记。
TOOL_DESC_PROMPT = "你可以使用如下工具:\n" + json.dumps(tools, ensure_ascii=False) + "\n如果需要调用工具,请输出JSON,格式为:{\"name\": \"函数名\", \"arguments\": {...}}" messages = [ {"role": "system", "content": TOOL_DESC_PROMPT}, {"role": "user", "content": "帮我查一下北京天气"}, ] text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) outputs = model.generate(...) raw = tokenizer.decode(...) if "<|tool_call|>" in raw: # 提取tool_call块,解析JSON pass这种方法更底层,你需要自己处理特殊标记的提取。我建议新手先跑通OpenAI兼容模式,理解整个数据流之后,再考虑离线嵌入。否则同时面对模板、标记解析、JSON容错三个难题,排查起来非常痛苦。
4. 小模型工具调用的调优细节
4.1 提示词纪律比模型参数更重要
同样的模型,不同提示词效果差距非常大。对qwen3 1.7b来说,我的建议是:
- 系统提示里明确写出工具调用格式要求,并且给出一个完整示例。
- 工具描述不要写花哨的营销话术,准确描述参数含义就行。
- 用户提问中如果含数字、日期、城市名等实体,建议替模型圈出来,比如“城市是北京”。
目标是减少模型在“要不要调用工具”这一步的犹豫。犹豫就会出错,要么不输出tool_calls直接编答案,要么输出了但参数不对。
4.2 采样参数怎么调
小模型对temperature非常敏感。我实测下来:
temperature=0.1~0.3:输出稳定,工具调用成功率高,回答偏机械。temperature=0.7以上:回答有“人味”,但JSON括号容易飘,工具名偶尔编错。top_p建议固定0.9左右,不用刻意去改动。
如果你发现模型总是重复调用同一个工具,或者反复输出相同的tool_call,可以试试presence_penalty=0.2做轻微抑制。但加了就要重新测试,这类参数在工具调用场景里属于“牵一发动全身”的敏感项。
4.3 工具结果超长怎么办
工具调用成功后,结果文本会作为一条tool消息传回去。如果工具返回几千字的JSON,1.7B模型的注意力会被一堆噪音字段干扰,后面的总结质量断崖式下跌。
这里有个实用技巧:在工具函数里就做裁剪,只返回模型真正需要的关键字段。比如天气接口返回了每小时24条数据,但你只需要当前天气,那就只保留第一条。我习惯在工具返回前做一次json.dumps(result, ensure_ascii=False)[:500]长度限制,防御性截断。
与其后期用代码清洗模型输出,不如前期控制工具输入。这个意识比任何prompt技巧都值钱。
4.4 流式输出与工具调用的冲突
如果开启了stream=True,工具调用信息是在流式响应中分片到达的。OpenAI SDK里可以通过组合chunk.choices[0].delta.tool_calls来拼装完整tool_calls。
但我不建议工具调用场景用流式。因为工具调用本身需要拿到完整JSON才能解析,流式只会增加拼装复杂度,收益并不明显。只有最终答案阶段才需要流式输出给用户看。所以实际工程里我会拆成两段:工具阶段非流式、总结阶段流式。
5. 常见问题与排查实录
5.1 模型死活不输出tool_calls
这大概是最常见的坑,尤其是换模型、换后端后突然就不调了。排查顺序如下:
- 检查是否把tools参数传对了位置。必须在
chat.completions.create()里传tools=tools。 - 检查消息序列里是否有
tool角色的消息,却没有对应的assistant工具调用消息,这会让模型错乱。 - 检查工具描述里参数是否合理。
"type": "object"漏了、required写成了require,这类低级错误会让解析直接失败。 - 试着把“必须调用工具”写进系统提示,让模型先调再答。
如果是量化模型,建议先用fp16跑一遍同问题,看是不是量化造成的退化。
5.2 tool_calls里的arguments解析失败
arguments是字符串,里面是JSON。常见失败原因有:
- 单引号代替双引号
- 键名用了中文
- JSON被截断,比如
{"city": "北京"少了右括号
对这种问题,我的做法是准备一个容错解析函数:
def safe_json_loads(raw: str) -> dict: try: return json.loads(raw) except json.JSONDecodeError: # 尝试补全右花括号 raw = raw.rstrip() if not raw.endswith("}"): raw += "}" return json.loads(raw)如果补全后还失败,就直接返回一条错误消息作为工具结果,让模型自己意识到调用出错,重新来。千万不要在解析阶段抛出异常断掉整个循环。
5.3 量化后工具调用能力退化
用GGUF或AWQ量化到4bit后,模型体积小了,但指令遵循能力也会掉。具体表现就是:工具名开始变样,参数值凭空多几个字,有时候甚至答非所问直接聊天。
我的实测心得是:
- 8bit量化对工具调用影响很小,值得优先考虑。
- 4bit量化在简单场景可用,但复杂参数的工具基本别指望稳定。
- 能跑fp16就fp16,不能就8bit,4bit是最后的妥协。
如果一定要用4bit,那么唯一补救办法是更长的few-shot示例,把用户问题和正确工具调用绑定给模型看,能明显拉回成功率。
5.4 多轮对话后上下文超限
qwen3 1.7b的上下文长度虽然不短,但工具定义本身就占token,每轮工具结果也在涨。三轮下来上下文消耗可能比你想的大得多。
建议:
- 系统提示和工具定义固定不变,可以缓存。
- 每次工具调用后,只保留最终结果摘要。
- 对过期历史做截断,比如只保留最近两轮完整对话。
还不行的话,就把工具参数从系统层抽离,只保留名字和一句话作用描述,必要参数放到问题里让模型结合上下文推理。
5.5 排障清单速查表
| 现象 | 大概率原因 | 对策 |
|---|---|---|
| 没有tool_calls字段 | tools未正确传入或提示词不足 | 检查请求体、强化系统提示 |
| arguments解析失败 | JSON被截断/用了单引号 | 容错解析、补全括号 |
| 工具名错误 | 量化退化或描述歧义 | 换更高精度、加few-shot |
| 模型一直循环调用工具 | 结果回填不稳定 | 检查tool_call_id、缩短工具结果 |
| 总答非所问 | 工具结果太多噪音 | 裁剪返回值、只给关键字段 |
6. 从Demo到落地的几点实话
6.1 简化架构,优先保证成功
把qwen3 1.7b接入真实项目时,我强烈建议在模型外面包一层轻量路由。不是所有问题都要走工具,也不是所有工具都要走模型。简单规则能先挡掉一半请求:用户问“今天天气”“算一下”这种明确句式,直接走一个intent匹配脚本,根本不用叫模型。模型只负责处理那些真正模糊的、需要多轮判断的情况。这样既省时,又降低了模型出错概率。
6.2 小模型真正擅长的自动化场景
在我看来,qwen3 1.7b最适合的是那些延迟敏感、成本敏感、又需要一定语言理解能力的内部场景。比如:
- 本地文档整理助手:让模型从一段文字中提取日期和任务,再调用日历工具写入。
- 智能家居控制中间层:把语音转文字后的指令拆解成开关动作。
- 监控告警过滤:判断告警文本里的设备ID和时间,触发对应处理脚本。
这些场景的共同点:动作类型有限、工具参数简单、结果容错空间大。它们不需要太聪明的模型,反而需要一个能够在本地快速响应的模型。
6.3 边界认知比炫技更重要
最后说一句不那么顺耳但很实在的话:1.7B模型不是用来做复杂Agent大脑的。我见过不少人非要用它串十几个工具、做多轮规划,最后崩溃率奇高,然后反过来骂模型没用。问题不在模型,在于场景设计。
工具调用的本质是把“决策明确、动作可枚举”的任务结构化。模型负责理解意图和填参数,外层流程负责状态管理和异常兜底。想清楚这一点,qwen3 1.7b就能在你手里变成一把顺手的螺丝刀;没想清楚,再强的模型也只是个昂贵的聊天框。
我现在的习惯是:在项目启动前先列一张表,把可能遇到的问题写下来,每一个问题对应一个工具的函数名和参数列表,然后才把模型接进去。模型越弱,决策树越要清晰。越是小模型,越要把“什么情况下调用谁”这个逻辑定义得滴水不漏。这样哪怕某天你换成了更大的模型,这套框架也完全不用重新设计。