上一期part5刚把Qwen3.5-4B通过LoRA微调出了function calling能力,eval脚本里它也都能按格式输出工具调用。我当时以为这事已经结束了,结果等真正要交给业务侧用的时候,才发现训练只是前半场,后半场“怎么把这堆权重变成一台稳定的推理服务”反而更磨人。vllm本地部署就是这次要解决的问题,显卡还是那张3080Ti,10GB显存,说大不大说小不小,加上Qwen3.5-4B这种4B级模型,刚好卡在一个特别尴尬的位置。这篇文章就把我踩过的坑、验过的参数、跑通的调用链路完整记录下来,给同样在做function calling模型微调后部署的朋友一个能直接参考的版本。
1. 训练回调之后,真正卡壳的是推理侧的function calling还原
1.1 三层工程里这条链路属于哪一层
很多人会问:function calling到底是提示词工程、RAG还是模型微调?这个问题本身就把三件事混在一起了,其实一条完整的工具调用链路里三层都在起作用,只是作用点不同。
提示词工程负责的是“规则”:在system提示里告诉模型有哪些工具、每个工具的参数长什么样、你必须在什么场景下调用。RAG负责的是“知识”:给模型额外塞外部资料,让它在回答前先检索到订单状态、库存数量这些动态信息。而模型微调负责的是“肌肉记忆”:让模型不是碰运气式地输出JSON,而是在面对工具类请求时能稳定进入工具调用形态,尤其是那些提示词引导不太管用的边界情况。
这次part6做的事情已经很明显落在微调之后的部署层:模型权重是微调产物,但要把weight变成response里的tool_calls结构化字段,还要靠推理框架和chat template一起配合。换句话说,微调负责让模型“会调用工具”,vllm负责让服务接口“能识别和转述这次Tool Call”。
1.2 微调产物离可调用的AI服务差在哪
训练结束之后,如果只是用transformers的model.generate()做验证,模型吐出来的是纯文本token流。这个阶段你会看到模型确实输出了类似{"name": "get_weather", "arguments": {}}的内容,但eval代码能看懂,不代表业务系统能看懂。
真正做AI客服或者Agent中间层的时候,下游关心的是OpenAI兼容协议里的assistant.tool_calls字段,里面要有规整的function.name和function.arguments。从原始token到结构化的tool_calls,中间至少隔着三件事:并发请求的调度、传输层的协议转换、工具调用格式的识别与切分。
vllm处理得比较顺手的就是这三件事。它原生实现了OpenAI格式的/v1/chat/completions接口,同时把continuous batching、PagedAttention这些推理优化机制内建好了,不用自己写调度器。坦白讲,如果要自己基于transformers去实现同样的并发服务,光是处理不同长度请求的KV cache分配就能写掉大半年。
1.3 为什么选了vllm而不是sglang/ollama
这个决定我做之前确实对比过,不是单纯跟风。sglang在部分数据集上吞吐表现不错,但工具调用解析的配置链跟vllm不一样,而且它更偏“研究者自己折腾协议”的路线,对OpenAI兼容接口的覆盖没有vllm那么直接。ollama则胜在安装方便,但并发和动态批处理的能力差距比较明显,模型稍微大一点或者并发稍微高一点就比较吃力。
vllm还有一个很现实的好处:社区里的踩坑记录最多。你只要是在本地部署Qwen系列并且开了function calling,搜出来的经验基本都带vllm关键词,遇到问题很容易找到对照参考。对于3080Ti这种单卡环境,稳定性和可排查性比那一点吞吐差异更重要,所以最终选了vllm。
2. 3080Ti 的显存账本:4B模型不量化跑不太动
2.1 10GB显存账单
先说一个很多新手会有的直觉错误:4B模型而已,10GB显存不是绰绰有余吗?账不能这么算。Qwen3.5-4B这种4B参数模型如果直接用BF16/FP16权重,光模型本体就是4B参数乘以2字节,约等于8GB。这是纯权重,还没算激活值、CUDA context、KV cache。
vllm加载模型后会在显存里额外占掉一部分空间,用于CUDA graph等运行时开销。真正留给KV cache的显存,取决于--gpu-memory-utilization的设置。在只有10GB的3080Ti上,如果直接裸跑BF16权重,vllm经常在启动阶段就给你报torch.OutOfMemoryError,而不是等到请求来了才崩。
所以动手部署前先做一个简单的显存预算:
| 模型形式 | 预估权重占用 | 10GB显存下结论 |
|---|---|---|
| BF16/FP16 全精度 | 约8GB+ | 启动或运行必OOM,不建议 |
| AWQ/GPTQ 4bit量化 | 约2.5-3.5GB | 可稳定运行,推荐 |
| FP8量化 | 约4-5GB | 3080Ti无FP8张量核心加速,收益不大 |
| GGUF Q4_K_M | 约3GB | 可用,但vllm生态不如直接用AWQ顺畅 |
这也是为什么很多教程在vllm部署前都要先做量化,不是量化有多高级,而是单卡场景下不量化根本装不进这个显存账户。
2.2 为什么我绕开FP8选了AWQ
Qwen系列现在官方放出了不少FP8 checkpoint,视觉上很诱人。但FP8的推理加速依赖Hopper或Ada架构的FP8 Tensor Core,3080Ti的Ampere架构没有对应硬件支持,vllm在跑FP8量化模型时拿不到完整的加速红利,体积也没有比4bit小,整体不划算。
我最后用的是AWQ 4bit量化。AWQ做的是激活感知的权重量化,比纯GPTQ更在意哪些权重通道更重要,微调后的模型做function calling这种结构化输出任务,AWQ在实际表现上更稳。量化本身可以放在显存更大的机器或者云上跑,等量化完再拷回到3080Ti这台机器。
主要路径是这样:
- 合并LoRA权重,得到完整的BF16 checkpoint;
- 用AutoAWQ跑4bit量化,group size 128;
- 把量化后的模型目录放到
/data/ai/models/Qwen3.5-4B-fc-AWQ; - vllm加载时加
--quantization awq。
有一个容易忽略的细节:量化工具会重新生成模型文件,但它不一定会把微调阶段改过的tokenizer配置完整保留下来。我建议量化完先检查一下目录里tokenizer_config.json是否存在且chat_template字段没有丢,这个后面会专门展开说。
2.3 环境基线:驱动、Python和vllm的镜像
环境方面我直接用了vllm官方镜像,省掉了很多Python依赖编译的麻烦。比如vllm/vllm-openai:v0.8.4这种版本标签,镜像里自带CUDA和推理依赖,只要宿主机NVIDIA驱动版本够新,nvidia-smi能看见显卡,docker里就能直接跑。
实测下来3080Ti需要驱动版本至少在535以上,太老的驱动在启动时会出现CUDA initialization failed。另外docker跑vllm时建议加上--shm-size=4g,否则加载大文件时会碰到/dev/shm空间不足的问题。
如果你不想用docker,也可以pip安装vllm,但Python版本得对齐官方要求,vllm不同版本对Python的兼容范围不一样。考虑到vllm版本频繁迭代,强烈建议固定一个版本,不要无脑追latest。我这次用的是0.8.x这条线,后面关于tool parser的参数名也是基于这个版本讨论,虽然参数大体兼容,但如果你用的是0.6.x或更早版本,需要注意差异。
3. 启动参数拆解:从模型加载到tool_calls能识别
3.1 一次启动要传多少参数,分别管什么
真正把vllm拉起来只需要一个命令,但参数如果没配明白,后面排查会非常痛苦。先给一个我实际用的docker run版本:
docker run -d --name vllm-qwen-fc --gpus all \ --shm-size=4g \ -v /data/ai/models:/models \ -v /data/ai/templates:/templates \ -p 8000:8000 \ vllm/vllm-openai:v0.8.4 \ --model /models/Qwen3.5-4B-fc-AWQ \ --served-model-name qwen3.5-4b-fc \ --quantization awq \ --trust-remote-code \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --max-num-seqs 8 \ --enable-chunked-prefill \ --enable-auto-tool-choice \ --tool-call-parser qwen3 \ --chat-template /templates/qwen3_fc_tools.jinja这些参数里,--host 0.0.0.0是容器内必须设的,否则容器外部访问不到;--served-model-name是给OpenAI请求用的模型名,注意它和--model指向的路径是两码事。--model告诉vllm去哪里读权重,--served-model-name决定你在请求体里写什么model字段。
--gpu-memory-utilization 0.9表示最多给vllm用到90%显存,不要设成1.0,因为要留一点余量给其它进程,否则驱动层分配显存时会撞墙。
--max-model-len 8192和--max-num-seqs 8会直接影响显存占用:前者限制单条请求的最大长度,后者限制并发序列数。10GB显存下如果max-model-len开到32K甚至更长,KV cache会迅速把显存吃光,哪怕模型只有4B也会OOM。
为了照顾长system prompt的场景,我加了--enable-chunked-prefill。它会把prefill阶段的长提示切成块,跟decode阶段的请求交错执行,这样不会因为某个请求的system提示太长而让所有后续请求都排队等着。代价是单请求的绝对吞吐略有下降,但在客服这种场景里,首字延迟的改善更重要。
3.2 与function calling直接相关的三个参数
function calling要真正返回结构化字段,启动参数里有几个关键角色。
--enable-auto-tool-choice是最容易被漏掉的。它的作用是让模型在收到带tools的请求时,自行决定是否调用工具、调用哪个工具。如果没有这个参数,即使请求里带了tools列表,vllm也不一定会触发tool calling,模型可能只管文字回答,硬生生把一次函数调用请求变成普通问答。
--tool-call-parser解决的是“从模型原始输出里怎么切出tool_calls”的问题。这个参数的值不是随便填的,它必须和微调时使用的chat template格式匹配。比如:
- 如果你微调时使用Qwen系列官方工具调用格式,vllm 0.8.x及更新版本里推荐填
qwen3,如果你用的是0.6.x那批版本,填hermes可能更常见; - 如果你微调时用的是Hermes风格,输出里是
<tool_call>包裹的JSON,那就填hermes; - 如果你的输出是纯JSON裸文本,可以考虑
llama3_json或者直接不依赖parser,自己在应用层解析。
有一个判断技巧:先去翻你微调训练时构造的assistant样本,看模型收到的ground truth是什么形态。比如样本长这样:
<|im_start|>assistant <|tool_call|>{"name":"get_weather","arguments":{"city":"北京"}}<|/tool_call|><|im_end|>这个形态对应Qwen系新格式,vllm里就填qwen3,并配合支持该格式的模板。如果是Hermes的<tool_call>,则匹配hermes。
还有一个跟--tool-call-parser配套的--tool-call-prompt-format参数,在部分新vllm版本里会看到,用于控制tools列表以什么格式注入提示词,取值一般也要与训练格式对齐。如果你不确定,先不传这个参数,让vllm用parser默认的prompt格式,再通过请求返回值判断是否一致。
3.3 chat-template:常常被忽略但最致命的一个
如果只能给读者一个建议,我会说function calling微调模型部署时,最值得花时间检查的就是--chat-template。template负责把OpenAI格式的messages和tools转换成模型真正看到的token序列。你在训练时用什么样的系统提示格式、工具定义格式、assistant输出格式,推理时就必须用几乎一致的结构,模型才可能稳定复现。
训练阶段的template一般被写进tokenizer_config.json的chat_template字段,但不一定所有微调代码都会把这个字段保存下来。常见情况是:保存checkpoint时漏掉了修改后的chat_template,于是服务端加载到的是基础版本的template,模型虽然有能力做tool call,但传入的提示词里工具的排列方式完全是另一种样子,输出自然乱掉。
如果你怀疑是这个问题,可以先把模板导出来看看:
python -c " import json cfg = json.load(open('/models/Qwen3.5-4B-fc-AWQ/tokenizer_config.json')) print(cfg.get('chat_template', 'NOT FOUND')) "如果看到NOT FOUND,基本坐实了模板丢失。处理方法是从训练工程里找到你自定义的template,单独存成.jinja文件,然后启动vllm时用--chat-template /templates/qwen3_fc_tools.jinja指过去。
3.4 不同版本vllm的tool-call-parser兼容经验
vllm版本迭代真的很快,tool parser这个东西几乎每个版本都在加新支持。如果你的vllm比较老,填qwen3可能会直接报错找不到parser,这时候不要硬试,要么把vllm镜像升级到0.8.x及以后,要么先改用hermes把流程跑通。
为什么旧版本里Qwen相关模型经常填hermes也能用?因为Qwen的function calling训练格式很大程度吸收了Hermes风格,核心都是让模型在assistant输出中吐一段可解析的函数调用JSON,只是包裹标记略有不同。所以只要你的模板和输出形态能让hermes parser切对,它就能转成OpenAI结构。
为了不再踩这个坑,我在本地固定了vllm 0.8.4,参数名都以这个版本为准。上线环境跟测试环境必须用同一个vllm版本,不然开发环境能返回tool_calls,生产环境升级个镜像突然全变普通文本,这是很常见的事故。
4. 用OpenAI SDK走完一轮带工具的真实请求
4.1 先验证模型是否真的会返回tool_calls
启动服务后,可以在浏览器或curl里先确认/v1/models能访问,然后发一个最简单的带tools请求验证。我这里用的是OpenAI Python SDK,把base_url指到本地vllm服务就行:
from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:8000/v1", api_key="EMPTY", ) tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询某个城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如北京"} }, "required": ["city"] } } } ] resp = client.chat.completions.create( model="qwen3.5-4b-fc", messages=[{"role": "user", "content": "北京今天需要带伞吗?"}], tools=tools, tool_choice="auto", ) msg = resp.choices[0].message print(msg.tool_calls)如果整个链路正确,msg.tool_calls会是一个列表,里面元素的function.name是get_weather,function.arguments是类似{"city": "北京"}的字符串。注意arguments是一个JSON字符串,不是字典,应用层要自己再json.loads一次。
这里最容易出现的一个误区是:模型说“我来调用get_weather查一下”,但tool_calls字段为空。这说明模型的工具调用没有以结构化格式触发,大概率是template或parser的问题,而不是模型笨。function calling部署的验收标准永远不是“回答里提到工具”,而是响应里真的出现了assistant.tool_calls。
4.2 执行本地函数并回传tool结果
拿到tool_calls之后,接着要做的是真正执行本地函数,再把执行结果作为role=tool的消息回传给模型,让模型基于真实结果生成最终答案。我的习惯是把这轮循环封装成一个小函数,逻辑大致如下:
import json import requests def call_weather_api(city: str) -> str: # 真实业务API,这里只是示意 return json.dumps({"city": city, "weather": "多云", "need_umbrella": False}) if msg.tool_calls: tc = msg.tool_calls[0] args = json.loads(tc.function.arguments) observation = call_weather_api(args["city"]) messages = [ {"role": "user", "content": "北京今天需要带伞吗?"}, msg, # assistant的tool_calls消息必须保留 { "role": "tool", "tool_call_id": tc.id, "content": observation } ] resp2 = client.chat.completions.create( model="qwen3.5-4b-fc", messages=messages, tools=tools, ) print(resp2.choices[0].message.content)一个很容易出问题的点:把assistant消息追加回messages时,必须直接使用vllm返回的那个message对象,而不是自己手工拼一个只有content的消息。因为tool_call的id要和后面role=tool消息里的tool_call_id一一对应,服务端靠这个关联才知道当前工具结果是在响应哪一次调用。
这个流程也顺带回答了一个高频疑问:本地部署的模型能联网吗?vllm本身不联网,模型也没有联网能力。真正的联网或业务查询发生在本地执行函数这一步,是业务代码调用真实API,模型只负责生成“该调用什么函数、传什么参数”的结构化决策。Function calling的边界就在这:模型是调度员,不是执行员。
4.3 流式请求里如何拼接tool_calls片段
非流式方案能跑通之后,接Agent框架或客服系统时十有八九要换成流式。OpenAI SDK的流式接口会把tool_calls拆成很多片段返回,具体表现是chunk.choices[0].delta.tool_calls里的index从0开始递增,每个片段只有部分id、function.name和function.arguments。
实践中的处理逻辑是累积而不是覆盖:
stream = client.chat.completions.create( model="qwen3.5-4b-fc", messages=[{"role": "user", "content": "北京今天需要带伞吗?"}], tools=tools, stream=True, ) tool_calls_map = {} for chunk in stream: delta = chunk.choices[0].delta if delta and delta.tool_calls: for tc_chunk in delta.tool_calls: idx = tc_chunk.index if idx not in tool_calls_map: tool_calls_map[idx] = {"name": "", "arguments": ""} if tc_chunk.id: tool_calls_map[idx]["id"] = tc_chunk.id if tc_chunk.function: if tc_chunk.function.name: tool_calls_map[idx]["name"] += tc_chunk.function.name if tc_chunk.function.arguments: tool_calls_map[idx]["arguments"] += tc_chunk.function.arguments我见过不少人在流式场景下直接拿最后一次delta覆盖之前的tool call,导致arguments只剩半个JSON。正确的做法就是累加字符串,等流结束后再统一json.loads。首次调试流式时,建议同时打印一下原始chunk内容,你会看到vllm可能先把id发出来,再分多次把arguments吐完,这属于正常现象。
有一点额外提示:流式响应里模型有时会先吐一小段自然语言content,再吐tool_calls,比如“好的,我来查询一下”。这个内容是否要原样转发给用户,取决于你的产品设计。如果不想让用户看到这段废话,可以在流式拼接时忽略掉非tool call的content片段,只保留最终解析出的tool_calls。
5. 实测踩坑复盘:OOM、template丢失、parser不识别、首字慢
5.1 裸权重启动直接OOM
我刚开始图省事,想先不量化,把BF16的checkpoint直接丢给vllm看能不能跑起来。服务启动到一半报错,日志里是典型的CUDA out of memory。回头看并不意外,前面算了账,BF16权重8GB加上CUDA context,3080Ti的10GB已经接近上限,再想给KV cache腾空间根本不现实。
解决方式不是去调gpu-memory-utilization,那个参数只影响vllm最多占用多少显存,并不能凭空提高显存总量。真正有效的动作是换AWQ/GPTQ量化模型,或者换更小规模的模型。如果你确实只有BF16权重且没有条件量化,可以尝试把--max-model-len降到4096甚至2048,并把--max-num-seqs调小到2-4,但体验不会好,并发稍高就会报OOM。
这里也提醒一下:--gpu-memory-utilization不要因为显存紧张就调到1.0,那样会在驱动层出现无法分配的碎片化错误。留出10%给系统驱动,看起来是浪费,实际是规避了各种莫名其妙的显存分配失败。
5.2 微调时改过的template没被保存
量化后第一次启动服务,请求模型查天气,结果模型回复的是“我可以帮您查询天气,但需要调用工具接口……”这种废话,完全没有任何tool_calls。查日志没有报错,查vllm启动参数也都对,最后定位到tokenizer_config.json里的chat_template字段丢失。
这个坑在LoRA微调流程里很典型:训练时用的chat template可以是指定路径的jinja文件,并没有被写进checkpoint的tokenizer配置里;合并权重后,文件里可能只保留了base模型的默认template。vllm的基础Qwen模板和微调时的工具提示模板不一致,模型自然就懵了。
保险做法是在微调结束后就把你训练的template固化到checkpoint目录里,或者像我这次一样另存一份文件,启动时用--chat-template强制指定。经过这一步之后,tool_calls才正常返回。
5.3 tool-call-parser填错/工具不触发
还有一个排查了很久的问题是:模型明明知道该调用工具,也以文本形式输出了意图,但响应里的tool_calls字段一直是null。我一开始用的parser是hermes,但我的微调数据实际上是Qwen新格式,模型吐的形态是<|tool_call|>...<|/tool_call|>,跟hermes期望的包裹格式对不上,parser根本切不出来。
很多讨论帖里反复出现“vllm部署qwen3 tool-call-parser填什么”,答案其实已经很清楚:如果你微调时用的是Qwen官方新格式,vllm 0.8.x以上版本里填qwen3最稳。你拿不准的时候,不要靠猜,直接把模型的一次输出dump下来看原始文本,再对照parser支持的格式列表判断。改完parser参数重启服务,立刻就能在响应里看到完整的tool_calls结构。
另外检查一下请求是否真的带了tools,以及tool_choice是否为"auto"。如果你在请求里省略了tools列表,那模型就完全没有工具可选,即使启动了--enable-auto-tool-choice也不会触发tool call,只会输出纯文本。这个问题经常出现在接入方代码和部署方配置没有对齐的情况下,不是模型问题。
5.4 并发一高就首字慢怎么办
服务能跑了之后,我开始用几路并发做压测,发现请求稍微多起来,首字延迟就会变得很不稳定,从几百毫秒跳到好几秒。这里要理解vllm的continuous batching机制:它并不是给每个请求单独占一条执行通道,而是把多个请求的token级计算动态塞进同一个batch。问题出在prefill阶段,一个很长的system prompt或工具列表如果一次性做prefill,会占住整个GPU很久,后面的decode请求只能排队。
缓解方案就是前面启动命令里的--enable-chunked-prefill。它允许一个长提示被切成多个chunk,中间穿插执行其它请求的decode,牺牲一点单个请求的极端吞吐,换取更平滑的首字延迟。实测开启之后,并发8路的首字延迟波动明显变小。
还有一个容易被忽视的点:尽量避免在每轮请求里动态拼接一大堆内容差异很大的tools定义。vllm对共享前缀有缓存优化,如果所有请求的tools列表和system提示都一样,这部分前缀KV cache可以被复用,首字速度会快很多。反过来,如果每个用户请求都把工具列表改得面目全非,缓存命中率会骤降,服务端就要重新计算全部提示词。保持tools顺序固定、system提示稳定,是成本最低的优化手段。如果你确认自己不是并发过高,而是单个请求本身处理慢,再检查--max-model-len是不是被设得太大,它会影响KV cache总量,过大时会挤压并发空间。
整套部署跑通之后,我发现很多问题都指向同一个核心:function calling模型能不能在服务端被正确还原,关键不在选多贵的显卡,而在chat template、tool parser和模型微调时的格式三者是否对齐。我后来每次拿到一个新微调模型,都会先看一眼样本输出、template和parser格式这“三角关系”,确认一致后再上vllm。这个习惯帮我省掉了大量无效调试时间,也推荐你试试。