ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Qwen3.5-4B function calling模型vLLM部署实战:从LoRA微调到3080Ti推理服务

Qwen3.5-4B function calling模型vLLM部署实战:从LoRA微调到3080Ti推理服务 上一期part5刚把Qwen3.5-4B通过LoRA微调出了function calling能力eval脚本里它也都能按格式输出工具调用。我当时以为这事已经结束了结果等真正要交给业务侧用的时候才发现训练只是前半场后半场“怎么把这堆权重变成一台稳定的推理服务”反而更磨人。vllm本地部署就是这次要解决的问题显卡还是那张3080Ti10GB显存说大不大说小不小加上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-5GB3080Ti无FP8张量核心加速收益不大GGUF Q4_K_M约3GB可用但vllm生态不如直接用AWQ顺畅这也是为什么很多教程在vllm部署前都要先做量化不是量化有多高级而是单卡场景下不量化根本装不进这个显存账户。2.2 为什么我绕开FP8选了AWQQwen系列现在官方放出了不少FP8 checkpoint视觉上很诱人。但FP8的推理加速依赖Hopper或Ada架构的FP8 Tensor Core3080Ti的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-AWQvllm加载时加--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-size4g否则加载大文件时会碰到/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-size4g \ -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_urlhttp://127.0.0.1:8000/v1, api_keyEMPTY, ) tools [ { type: function, function: { name: get_weather, description: 查询某个城市的实时天气, parameters: { type: object, properties: { city: {type: string, description: 城市名例如北京} }, required: [city] } } } ] resp client.chat.completions.create( modelqwen3.5-4b-fc, messages[{role: user, content: 北京今天需要带伞吗}], toolstools, tool_choiceauto, ) msg resp.choices[0].message print(msg.tool_calls)如果整个链路正确msg.tool_calls会是一个列表里面元素的function.name是get_weatherfunction.arguments是类似{city: 北京}的字符串。注意arguments是一个JSON字符串不是字典应用层要自己再json.loads一次。这里最容易出现的一个误区是模型说“我来调用get_weather查一下”但tool_calls字段为空。这说明模型的工具调用没有以结构化格式触发大概率是template或parser的问题而不是模型笨。function calling部署的验收标准永远不是“回答里提到工具”而是响应里真的出现了assistant.tool_calls。4.2 执行本地函数并回传tool结果拿到tool_calls之后接着要做的是真正执行本地函数再把执行结果作为roletool的消息回传给模型让模型基于真实结果生成最终答案。我的习惯是把这轮循环封装成一个小函数逻辑大致如下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( modelqwen3.5-4b-fc, messagesmessages, toolstools, ) print(resp2.choices[0].message.content)一个很容易出问题的点把assistant消息追加回messages时必须直接使用vllm返回的那个message对象而不是自己手工拼一个只有content的消息。因为tool_call的id要和后面roletool消息里的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( modelqwen3.5-4b-fc, messages[{role: user, content: 北京今天需要带伞吗}], toolstools, streamTrue, ) 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 context3080Ti的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。这个习惯帮我省掉了大量无效调试时间也推荐你试试。
返回列表