vLLM部署Qwen3.5-4B微调模型:Function Calling与显存优化实战
发布时间:2026/9/8 16:21:34 锦皓数字建站

模型微调这件事训练跑完只能算完成一半真正让人头疼的是后半程——把微调出来的模型部署成一个能被业务系统调用的服务。系列做到Part 6我用vllm把Qwen3.5-4B的function calling微调模型在本地跑了起来硬件是手头这张3080Ti12GB显存刚好卡在能跑但必须精细规划的档位上。这篇就把从微调完的模型文件到可对外提供OpenAI兼容接口的服务全过程写清楚包括vllm部署Qwen3.5-4B的启动参数、function calling验证链路、显存边界控制以及我在3080Ti上踩过的那些文档里不会细讲的坑。适合正好走到微调完准备部署这一步的开发者参考。1. 微调模型过vllm这关先想清楚三个前置问题1.1 为什么部署环节几乎绕不开vllm微调完模型最常见的做法是直接用transformers加载权重本地跑几个测试用例验证效果。这一步没问题但一旦要接入业务系统transformers的推理方式就会出现三个硬伤不支持并发请求的连续批处理、显存里塞不下动态的KV cache、吞吐量上不去。vllm针对这三点做了专门优化——PagedAttention把KV cache分页管理continuous batching让多个请求在GPU上交错执行等于把GPU的每一分算力都压榨出来。实测下来同样一张3080Titransformers只能串行处理请求vllm可以并行扛住几十个并发吞吐差异是数量级的。有朋友问过我我就内部用用QPS不高transfomers跑个API封装不行吗短期可以但vllm还有另一个价值它对OpenAI接口格式的兼容做得很完整尤其是工具调用function calling这一块。微调了一个带工具调用能力的模型最终目的就是让Agent系统通过标准API来使用它这时候vllm的/v1/chat/completions接口可以直接对接省掉自己写协议转换的功夫。1.2 3080Ti的显存边界4B模型的真实成本3080Ti是12GB显存这个容量放今天有点尴尬——跑7B以上模型必须量化但跑4B模型又绰绰有余。拿Qwen3-4B这个底座来说FP16精度权重大约8GB左右加载后留给KV cache和推理算子的空间大概3-4GB。如果不加节制地开长上下文这份余量很快就会耗尽。所以在部署之前一定要算清楚三笔账权重占用FP16权重大约占显存8GB如果做AWQ/GPTQ 4bit量化可以压到3GB左右。KV cache占用序列长度越长KV cache线性增长。8k上下文对应的KV cache占用在1GB上下32k就会超过3GB。CUDA graph和算子临时显存这部分一般几百MB但不同版本vllm差异不小。算下来3080Ti跑FP16的4B模型max-model-len设在8192左右是安全的想要更长上下文就得走量化路线。这个决策直接决定了后面的启动参数怎么配。1.3 微调的产物形态决定了你走到哪一步才能部署很多人在这一步才开始纠结微调用的是LoRA产出一堆adapter文件能不能直接用vllm加载vllm虽然支持LoRA adapter动态加载但需要额外维护adapter列表而且动态加载对模型目录结构有严格约束实际使用中光排查adapter加载失败base model与adapter不匹配这类问题就能耗掉半天。我的建议很直接部署前先把LoRA权重合回底座得到一个完整的全量模型目录。这样vllm启动时只认一个模型路径排查问题也简单后续换机器、换框架、发模型包都方便。合并方法很简单用transformers的PeftModel加载base model和adapter然后save_pretrained保存合并后的权重。这一步放在部署前面做后面省心十倍。2. 环境准备版本、镜像与硬件驱动的组合拳2.1 版本选型避开latest的坑vllm迭代非常快新版本经常引入breaking change其中影响最大的就是function calling相关参数。早期版本用--enable-function-calling后来改成--enable-auto-tool-choice加--tool-call-parser的组合如果你拿着旧教程的命令去跑新版本直接报参数错误。我的做法是锁版本不用latest。以当前阶段为例选择0.7.x或0.8.x的稳定版对应Docker镜像tag就是vllm/vllm-openai:v0.8.3这样明确的版本号。锁版本的目的不是拒绝新功能而是保证部署行为可复现——今天部署好了三个月后需要扩容拉一个新机器照着一模一样的命令跑不应该出现上次能启动这次起不来的情况。另外注意Python环境vllm对Python版本有要求一般3.10到3.12都支持但别用太老的3.8。如果用Docker方式部署Python环境由镜像隔离本机只需要装好驱动和容器运行时即可。2.2 Docker部署的参数和目录挂载3080Ti的Ampere架构在vllm支持列表里很成熟不需要额外编译算子直接用官方Docker镜像最省事。部署之前确认两件事本机NVIDIA驱动版本要够新并且装好了nvidia-container-toolkit。验证方式很简单docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi如果这条命令能正确输出GPU信息说明Docker的GPU透传没问题。之后启动vllm核心是把模型目录挂载进容器并映射8000端口docker run --rm --gpus all \ -p 8000:8000 \ -v /home/zp/models/Qwen3.5-4B-FC:/models/Qwen3.5-4B-FC \ vllm/vllm-openai:v0.8.3 \ --model /models/Qwen3.5-4B-FC \ --served-model-name Qwen3.5-4B-FC \ --dtype auto \ --max-model-len 8192 \ --gpu-memory-utilization 0.92 \ --enable-auto-tool-choice \ --tool-call-parser hermes \ --trust-remote-code这条命令有几个细节要注意-v把宿主机的模型目录挂载到容器内路径别搞混--rm表示容器退出自动删除日志会直接打到当前终端方便调试。如果遇到PermissionError可能是容器内用户对挂载目录没有读权限要么调整目录权限要么在docker run里加--user root。实战中这个权限问题出现的频率比想象中高。2.3 启动前的一分钟自检每次启动前我习惯花一分钟做快速自检能省掉后面排查的很多时间模型目录是否包含config.json、tokenizer.json、tokenizer_config.json、generation_config.json这几个关键文件。目录里的权重文件是不是完整的.safetensors.index.json指向的分片是否存在且大小与config里声明一致。模型路径是否正确Docker里尤其要确认是容器内路径而不是宿主机路径。显存是否被其他进程占用用nvidia-smi看一遍确认没有残留的推理进程占着显存。这套自检做完再启动成功率会高很多。尤其是第四条很多人部署失败不是因为配置写错而是前一个测试进程没杀干净显存不够导致启动中途崩溃。3. 模型整理部署前必做的三件事少一件都会出幺蛾子3.1 合并LoRA权重静态部署比动态加载省心训练阶段用LoRA是因为它省显存、训练快但部署阶段情况完全不同。vllm虽然支持--enable-lora动态加载adapter但这套机制更适合同一个底座模型挂多个adapter做多租户的场景。如果你只是一个微调模型对外提供服务完全没必要引入动态加载的复杂度。合并权重的脚本很简单这个操作本质上就是把增量加回原权重from transformers import AutoModelForCausalLM, AutoTokenizer from peft import PeftModel base_model_path /home/zp/models/Qwen3-4B adapter_path /home/zp/models/Qwen3-4B-lora-final merged_path /home/zp/models/Qwen3.5-4B-FC model AutoModelForCausalLM.from_pretrained( base_model_path, torch_dtypeauto, trust_remote_codeTrue ) model PeftModel.from_pretrained(model, adapter_path) model model.merge_and_unload() tokenizer AutoTokenizer.from_pretrained(base_model_path, trust_remote_codeTrue) model.save_pretrained(merged_path, safe_serializationTrue) tokenizer.save_pretrained(merged_path)合并完之后设一个独立的模型名比如Qwen3.5-4B-FC作为后续所有部署操作的入口。这里的safe_serializationTrue很关键它会把权重保存成safetensors格式加载更快且更安全。3.2 检查模型目录里的四件套合并完的模型目录先别急着上vllm。手动检查四个文件这一步能提前暴露绝大多数启动问题。config.json是最重要的重点看三个字段model_type是否被vllm支持、max_position_embeddings是否足够大、quantization_config是否存在如果存在说明权重是量化过的启动参数要对应调整。tokenizer.json和tokenizer_config.json决定了分词行为如果微调时扩展过词表这两个文件必须是从微调后的tokenizer保存出来的。generation_config.json影响生成时的默认参数比如pad_token_id、eos_token_id如果这个文件缺失或字段不对生成阶段可能产生异常输出。检查命令很简单ls -lh /home/zp/models/Qwen3.5-4B-FC/ du -sh /home/zp/models/Qwen3.5-4B-FC/权重文件加起来应该有8GB左右如果只有几百MB八成是只保存了adapter权重或保存不完整。这种情况下vllm启动时虽然不会立刻报错但加载的模型实际上是不完整的推理结果必然有问题。3.3 chat_templatefunction calling的隐形开关这一步是最容易被忽略、但影响最大的。function calling模型在推理时依赖模型内置的chat template系统提示词、工具定义、用户消息、模型回复、工具结果全都通过template组装成特定的对话格式。如果微调时改过chat template但保存模型时没有正确写入tokenizer_config.json部署后会出现一个诡异的现象直接问普通问题一切正常但一涉及工具调用模型要么返回一堆奇怪的标记要么生成的JSON格式完全不对。检查方法很直接在Python里加载tokenizer查看它的chat_templatefrom transformers import AutoTokenizer tok AutoTokenizer.from_pretrained(/home/zp/models/Qwen3.5-4B-FC, trust_remote_codeTrue) print(tok.chat_template)如果输出是None说明模型没有记录chat templatevllm会退回到通用模板工具调用几乎必然失败。解决办法是微调阶段就把template明确设置好或者部署前手动把template写入tokenizer_config.json。我的经验是凡是自己做过SFT的模型这一步必须验证不要因为基座模型自带template就想当然。4. vllm参数逐项拆解从demo命令到生产级配置4.1 完整启动命令与核心参数在第2章基础上把启动命令再完整展开一次所有参数背后都有一个明确理由docker run --rm --gpus all \ -p 8000:8000 \ -v /home/zp/models/Qwen3.5-4B-FC:/models/Qwen3.5-4B-FC \ vllm/vllm-openai:v0.8.3 \ --model /models/Qwen3.5-4B-FC \ --served-model-name Qwen3.5-4B-FC \ --dtype auto \ --max-model-len 8192 \ --gpu-memory-utilization 0.92 \ --enable-auto-tool-choice \ --tool-call-parser hermes \ --trust-remote-code各参数的作用整理如下参数作用我的选择理由--model指定模型目录用容器内路径宿主机的模型目录通过-v挂载--served-model-name对外暴露的模型名命名成Qwen3.5-4B-FC接口请求时用这个名字--dtype auto权重精度自动选择自动读取config里的torch_dtype通常就是float16--max-model-len最大序列长度4B模型在12GB卡上设8192平衡上下文和显存--gpu-memory-utilizationGPU显存利用率上限设0.92留出余量给CUDA context--enable-auto-tool-choice开启自动工具调用function calling部署的核心开关--tool-call-parser指定工具调用解析器根据训练数据的tool calling格式选择hermes或qwen--trust-remote-code信任模型目录里的远端代码部分模型加载需要执行自定义代码4.2 显存、上下文长度与吞吐量的三角平衡--max-model-len和--gpu-memory-utilization这两个参数是联动的调不好就会遇到启动失败或推理OOM。先说--max-model-len。这个值决定模型能处理的最长输入输出总长度也决定了KV cache的预分配上限。Qwen3.5-4B-FC这种4B模型FP16权重约占8GBKV cache在8k长度时约占1GB上下再加上激活值和CUDA graph12GB的3080Ti差不多是贴着上限运行。设16k或32k不是不行但要配合量化或者接受更小的并发余量。对于大部分业务场景8k已经够用这也是我推荐这个值的原因。再说--gpu-memory-utilization。设置成0.92意味着vllm最多使用11GB左右显存。为什么不设0.98因为还要给驱动、CUDA context以及其他进程留一点空间。如果你的机器上没有别的GPU任务可以适当调大如果还要同时跑数据预处理之类的CPU任务建议保守一点。如果启动后报CUDA out of memory优先尝试的调整顺序是降低--max-model-len、降低--gpu-memory-utilization、加--enforce-eager。前两个是减少实际显存占用最后一个关闭CUDA graph优化牺牲一点推理速度换取显存余量。4.3 工具调用解析器--enable-auto-tool-choice与--tool-call-parservllm对function calling的支持核心机制是训练时约定一种输出格式推理时用解析器把模型输出转成OpenAI风格的结构化字段。--enable-auto-tool-choice打开自动工具调用开关后vllm会在收到带tools的请求时自动在系统提示词里注入工具定义并让模型倾向输出结构化的工具调用。--tool-call-parser则负责把模型输出的文本解析成标准字段。比较麻烦的是parser的选择这里直接给结论如果你的微调数据参考的是Qwen官方的工具调用格式也就是用|tool_call|这类特殊标记组织训练样本选qwen。如果训练时使用的是更通用的hermes风格也就是让模型输出一个JSON数组形式的tool calls选hermes。不确定的话先用hermes试。因为很多微调框架在构造训练数据时实际采用的格式更接近hermes风格即使原始数据来自Qwen官方。判断parser是否生效的最快方法是我后面第5章要讲的第一轮工具调用验证返回结果里如果出现了结构化的tool_calls字段说明parser选对了如果模型输出了一堆JSON文本但接口返回里没有tool_calls基本就是parser不匹配。4.4 如何确认服务真正启动成功启动命令敲下去之后观察日志输出重点看几个标志性节点出现INFO: Loading model weights took ...说明权重加载完成。出现INFO: Starting vLLM server ...和Uvicorn running on http://0.0.0.0:8000说明服务已经起来了。日志里如果出现WARNING级别的显存相关提示比如GPU memory usage is too high需要回看4.2节的参数调整。服务起来后先做接口级验证。查询已加载模型列表curl http://localhost:8000/v1/models这个请求会返回模型列表确认served-model-name设置正确。我见过有人启动成功了但请求时报model not found就是因为接口请求时用的模型名和--served-model-name不一致。5. Function Calling验证从curl到多轮工具调用闭环5.1 tools请求格式与OpenAI兼容接口服务启动后第一步是用curl走通最基础的工具调用请求。请求体里通过tools字段传入工具定义格式和OpenAI的Function Calling API保持一致。一个典型工具定义是JSON Schema风格{ type: function, function: { name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: { type: string, description: 城市名例如北京、上海 } }, required: [city] } } }把这个工具定义放进请求体的tools数组再给一句用户消息北京今天天气怎么样然后看模型返回什么。5.2 首轮调用从系统提示到tool_calls返回完整的curl请求curl http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: Qwen3.5-4B-FC, messages: [ {role: user, content: 北京今天天气怎么样} ], tools: [ { type: function, function: { name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } } ], temperature: 0.7 }正常的返回应该包含一个tool_calls字段{ choices: [ { message: { role: assistant, content: null, tool_calls: [ { id: call_xxx, type: function, function: { name: get_weather, arguments: {\city\: \北京\} } } ] }, finish_reason: tool_calls } ] }看到这个结构说明vllm的function calling链路已经通了。有两个细节要留意content是null而不是空字符串这是OpenAI格式的规范arguments是一个JSON字符串不是对象客户端拿到之后需要做一次json.loads解析。5.3 多轮闭环把工具结果喂回去单轮返回tool_calls只是第一步真正的业务闭环要把工具执行结果回传给模型让模型基于工具输出生成最终回答。这一步在协议上等价于把工具结果作为一条role为tool的消息追加进对话历史。我用Python的OpenAI SDK演示这个完整循环from openai import OpenAI client OpenAI(base_urlhttp://localhost:8000/v1, api_keyEMPTY) tools [ { type: function, function: { name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市名} }, required: [city] } } } ] messages [{role: user, content: 北京今天天气怎么样}] resp client.chat.completions.create( modelQwen3.5-4B-FC, messagesmessages, toolstools, temperature0.7 ) resp_message resp.choices[0].message # 把模型返回的tool_calls追加进对话 messages.append(resp_message) # 模拟执行工具这里直接返回一个固定天气 if resp_message.tool_calls: for tool_call in resp_message.tool_calls: result {city: 北京, temperature: 23度, condition: 晴} messages.append({ role: tool, tool_call_id: tool_call.id, name: tool_call.function.name, content: str(result) }) # 第二轮请求模型生成最终回复 final_resp client.chat.completions.create( modelQwen3.5-4B-FC, messagesmessages, toolstools ) print(final_resp.choices[0].message.content)这个例子里的两个关键动作把第一步返回的assistant message原样追加到消息列表因为其中带tool_calls然后为每个tool_call构造一条tool消息并且tool_call_id必须对应上。这两处不对模型就无法正确理解工具执行结果。我建议先用curl跑通单轮再用SDK跑通多轮不仅是因为SDK封装了很多细节更重要的是排查问题时能区分是接口协议问题还是是模型能力问题。5.4 最容易翻车的三类返回结果部署function calling模型最常见的三类异常结果第一类模型返回了看起来像JSON的文本工具调用但接口返回里没有tool_calls字段。这种通常是--tool-call-parser没生效或者模型训练时使用的工具调用格式与parser不匹配。排查方法先确认启动命令里加没加--enable-auto-tool-choice再加--tool-call-parser hermes试一遍。第二类模型完全不调用工具直接回答我不知道今天的天气。这说明模型在给定输入下没有走到工具调用的token路径最常见的原因是提示词里工具定义格式和微调数据的格式不一致。微调时工具定义是放在系统提示词里用特定模板包裹的部署时由于vllm自动注入了自己的模板两者拼接起来可能变形。第三类模型返回了tool_calls但arguments字段内容严重乱码或JSON解析失败。这种情况要检查是不是temperature设得太高导致输出不稳定。工具调用场景建议把temperature控制在0.2到0.7之间太高会引入随机性让工具选择不稳定。6. 实测性能与部署中的隐藏坑6.1 首字延迟prefill阶段慢在哪部署之后很多人会注意到一个现象第一个token出来得慢但后面的token吐得快。这是LLM推理的正常结构——prefill阶段要并行处理整个输入提示词计算量很大decode阶段逐token生成单步计算量小。vllm的continuous batching能提升整体吞吐但对单请求的TTFT首token延迟没有质的改善。在3080Ti上8k上下文的输入prefill时间可能在几百毫秒到几秒之间取决于输入长度和当前并发数。如果TTFT明显异常需要先确认是不是前缀缓存prefix caching没有生效。这个问题单独展开6.2 前缀缓存命中率工具定义要放在对的位置function calling场景有一个天然特性同一个Agent系统工具定义基本不变每次请求的系统提示词都是同一段文字。如果把工具定义放在请求的前半部分vllm的前缀缓存就能复用上一次请求的KV cacheprefill阶段的计算量大幅下降TTFT能快一个量级。我实测的对比很直观在未开启缓存或缓存未命中的情况下几百token的系统提示词会让首次请求慢1秒以上命中缓存后同样的前缀计算时间降到几十毫秒。前提是vllm版本开启了前缀缓存——在0.6.x版本需要显式加--enable-prefix-caching0.7.x之后默认开启可以通过--disable-prefix-caching关闭。所以部署时有一个实操建议工具定义尽量放在系统提示词最前面的固定位置不要每次请求动态改变顺序。因为前缀缓存靠的是逐token精确匹配任何一点差异都会导致缓存失效、回到完整prefill。前后缀顺序固定之后你会发现请求越快越稳定。6.3 压测一把并发、显存与卡死的边界部署完成只是起点下一步是拿真实请求压一下看看这张卡到底能扛多少并发。我用的压测方式很简单不引入额外的压测平台就用Python的asyncio加httpx快速测import asyncio import httpx async def single(client, messages): resp await client.post( http://localhost:8000/v1/chat/completions, json{ model: Qwen3.5-4B-FC, messages: messages, max_tokens: 256, temperature: 0.7 }, timeout120 ) return resp.status_code async def main(): messages [{role: user, content: 用一句话介绍北京}] async with httpx.AsyncClient() as client: results await asyncio.gather(*[single(client, messages) for _ in range(20)]) print(results) asyncio.run(main())20个并发请求同时打上去3080Ti跑FP16的4B模型是能稳住的总吞吐大概在每秒几百token的量级单请求完全无感的级别。但如果把并发拉到50甚至100就要注意一个现象vllm会把请求排队oversized请求会一直pending表现就是请求发出去但很久没有响应。这不一定说明服务挂了可能是队列积压。遇到这种情况优先观察日志里的GPU显存占用和当前running sequence数量来判断瓶颈。实测下来3080Ti上这个模型我把并发控制在30以内比较放心。再往上要么接受更高的平均延迟要么上量化模型换取显存空间要么直接加一张卡做多副本。6.4 报错现场与排查思路汇总把部署过程中容易遇到的报错整理成一张表方便快速定位报错或异常现象根因处理方法CUDA out of memory显存超限降低max-model-len、降低gpu-memory-utilization、加--enforce-eagerValueError: The models max model len is ...max-model-len超过模型config上限调小启动参数或修改config.json里的max_position_embeddings请求返回model not foundserved-model-name与请求model字段不一致用/v1/models确认模型名请求时保持一致Tool call parser ... not foundtool-call-parser指定的解析器不存在换成hermes或qwenPermissionError容器内无权限读挂载目录调整宿主机目录权限或docker run加--user root模型输出正常但无tool_calls字段parser未生效或训练格式与parser不匹配检查--enable-auto-tool-choice换parser重试首字延迟突然变高前缀缓存未命中或未开启确认版本参数固定系统提示词前缀顺序还有一个社区反馈的高频问题vllm升级版本后某些参数或者行为发生了变化导致原来自定义工具调用的链路突然失效。所以我的原则很明确——代码和配置文件里把vllm版本写死不跟随latest滚动升级。每次升级前先在测试环境跑一遍工具调用回归用例再决定要不要上生产。部署这件事越往后越能体会到细节决定成败。很多人微调完急着把模型扔上线结果被显存规划、parser选择、缓存命中这些环节来回折腾。我的经验是先把部署链路走通再回头调参数优化。先用8k上下文、FP16精度、单个工具定义把function calling的首轮调用跑通确认模型真的能输出标准tool_calls之后再逐步加长上下文、增加工具数量、压并发。每个环节单独验证出了问题也知道该改哪里。后续如果业务需求上来还可以做两件事一是把模型量化到4bit用长上下文和更大并发换一点质量损失vllm对AWQ和GPTQ的兼容都做得很成熟二是如果单卡吞吐确实到了瓶颈vllm也支持分布部署多卡之间做张量并行。不过这些都是后话了先把3080Ti上的单卡服务跑稳function calling链路真正通了微调这件事才算真正落地。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。