用 Rust 给内网装个 AI:Qwen3-VL 部署踩坑实录


https://github.com/T1mn/LocalTerminalAgent
背景:Copilot 断供后的内网 AI 困境
我们公司内网和外网完全隔离。GitHub Copilot?废了。ChatGPT?上不去。最难受的是代码报个错,想截个图发给 AI 分析都不行——数据合规部门明确禁止往外传任何代码片段、日志、架构图。
开发效率直线下降。有次查 Kubernetes 报错,盯着一堆 YAML 看了半小时,最后发现是缩进问题。这要在外网,Copilot 早提示了。
没办法,只能在本地 GPU 服务器上自己部署一个。选了 Qwen3-VL-32B(支持图片输入),用 Rust 写了个终端客户端,目标很简单:不离开命令行就能分析报错截图和日志。
前后折腾了三周,其中一周都在调显存参数。
服务端部署:vLLM 启动 Qwen3-VL
硬件配置
- 模型:Qwen3-VL-32B-Instruct(32B 版本,显存和性能的平衡点)
- 推理框架:vLLM(PagedAttention + FP8 KV Cache)
- GPU:单卡 A100-80G
一开始以为 80G 显存够用,结果并发一上去就炸了。后来发现关键在 kv-cache-dtype 和 gpu-memory-utilization 这两个参数。
启动脚本
nohup python -m vllm.entrypoints.openai.api_server \ --model /nas/models/Qwen3-VL-32B-Instruct \ --served-model-name qwen3-vl-32b \ --host 0.0.0.0 \ --port 8000 \ --trust-remote-code \ --dtype bfloat16 \ --max-model-len 16384 \ --gpu-memory-utilization 0.95 \ --kv-cache-dtype fp8 \ --limit-mm-per-prompt '{"image": 8}' \ --media-io-kwargs '{"video": {"num_frames": -1}}' \ > vllm_server.log 2>&1 &踩坑记录
坑 1:显存利用率设太高直接 OOM
刚开始把 --gpu-memory-utilization 设成 0.99,想榨干显存。结果第一个请求就 Out of Memory。降到 0.95 才稳定,给 PyTorch 和 CUDA 留点喘息空间。
坑 2:没开 FP8 KV Cache,并发撑不住
最开始没加 --kv-cache-dtype fp8,三路并发就爆了。开启 FP8 后 KV Cache 显存占用减半,勉强能撑到 6 路。不过这个参数需要 vLLM 0.6.0+,旧版本会报 ValueError: unsupported kv_cache_dtype。
坑 3:max-model-len 设太大也会炸
想分析长日志,把这个参数拉到 32768。启动时显存预分配直接 OOM。后来发现 16384 够用了,再长的日志也得分段喂。
核心参数说明:
| 参数 | 设定值 | 作用 |
|---|---|---|
--dtype | bfloat16 | 避免精度溢出,比 float16 稳 |
--kv-cache-dtype | fp8 | 救命参数,KV Cache 显存省一半 |
--max-model-len | 16384 | 长日志分析场景,再大就 OOM |
--limit-mm-per-prompt | 8 | 限制单次请求图片数量,防止显存暴涨 |
验证服务是否活着:
curl http://localhost:8000/v1/models# 返回包含 "id": "qwen3-vl-32b" 就算成功客户端开发:Rust 实现无依赖终端工具
一开始想用 Python 写客户端,结果每次分发都要解释虚拟环境、pip 依赖。运维烦死了。干脆换 Rust,编译成单二进制文件,扔过去就能跑。
技术栈
- Rust 2021 edition
- Tokio(异步 I/O)
- Clap(命令行参数解析)
- Reqwest(HTTP Client)+ SSE 流式响应
视觉消息构建逻辑
Qwen3-VL 的 Vision API 要求特定的 JSON 格式。本地图片需要转 Base64 嵌入请求体。
use base64::{Engine as _, engine::general_purpose};use serde_json::{json, Value};
fn build_user_message(text: &str, image_path: Option<&str>) -> Result<Value, Box<dyn std::error::Error>> { if let Some(path_str) = image_path { let path = std::path::Path::new(path_str);
// 读取并 Base64 编码 let image_data = std::fs::read(path).map_err(|_| "Error: Image file not found")?; let base64_str = general_purpose::STANDARD.encode(&image_data);
// 自动探测 MIME 类型 let mime_type = mime_guess::from_path(path).first_or_octet_stream().to_string(); let data_url = format!("data:{};base64,{}", mime_type, base64_str);
// 构建多模态 Payload Ok(json!({ "role": "user", "content": [ {"type": "text", "text": text}, {"type": "image_url", "image_url": {"url": data_url}} ] })) } else { // 纯文本 Payload Ok(json!({ "role": "user", "content": text })) }}踩过的坑:Base64 编码大图会卡死
有次传了张 8MB 的监控截图,客户端卡了快 10 秒才发出去。后来加了大小检查,超过 5MB 就提示压缩。还发现 mime_guess 对某些 WebP 图片识别错误,会导致服务端解析失败,最后强制判断了后缀名。
SSE 流式响应处理
为了获得类似 ChatGPT 的即时反馈感,必须处理 Server-Sent Events 流。
async fn stream_request(client: &reqwest::Client, payload: &Value) -> Result<(), Box<dyn std::error::Error>> { let mut stream = client.post("http://localhost:8000/v1/chat/completions") .json(payload) .send() .await? .bytes_stream();
print!("Qwen > "); // 强制刷新缓冲区,确保 prompt 立即显示 std::io::Write::flush(&mut std::io::stdout())?;
while let Some(item) = futures_util::StreamExt::next(&mut stream).await { let chunk = item?; let chunk_str = String::from_utf8_lossy(&chunk);
// 解析 SSE 数据帧 for line in chunk_str.lines() { if line.starts_with("data: ") { let json_part = &line[6..]; if json_part == "[DONE]" { break; }
if let Ok(val) = serde_json::from_str::<Value>(json_part) { if let Some(content) = val["choices"][0]["delta"]["content"].as_str() { print!("{}", content); std::io::Write::flush(&mut std::io::stdout())?; } } } } } println!(); Ok(())}刚开始忘了 flush(),输出会缓冲一大段才刷新,体验很割裂。
打包分发
用 cargo-deb 打包,方便运维批量装到开发机。
cargo build --releasecargo deb实际使用:三种典型场景
场景一:单命令快速查询
无需上下文,用完即走。
$ ask tar命令如何排除特定文件夹?
Qwen > 使用 --exclude 参数,例如:tar -czf backup.tar.gz --exclude='node_modules' --exclude='.git' /path/to/dir场景二:报错截图分析(Vision)
针对无法复制文本的 VNC 界面或监控面板截图。有次 Java 应用报错,VNC 界面复制不了文本,直接截图扔给它。
$ ask "这个 Java 堆栈报错是什么原因?" --image ~/monitor_error.png
Qwen > 从截图看是 OutOfMemoryError: Java heap space。建议检查 JVM 启动参数 -Xmx 设置,当前配置似乎只有 512MB。场景三:交互式会话(Context)
进入 REPL 模式,支持多轮对话和图片暂存。
$ askLocalTerminalAgent (Vision Enabled) Ready.
User > :img ~/arch_diagram.png[系统] 图片已暂存缓冲区。
User (带图) > 这里的负载均衡配置有什么单点风险?Qwen > 架构图中 Nginx 只有单节点,建议使用 Keepalived + VIP 配置双机热备。性能实测:显存占用与并发瓶颈
在 A100-80G 环境下的真实数据:
| 显存分项 | 占用量 | 说明 |
|---|---|---|
| 模型权重 (bf16) | ~60 GB | 32B 参数的硬性开销 |
| KV Cache (fp8) | ~15 GB | 开启 FP8 后的缓存池 |
| 系统预留 | ~5 GB | PyTorch 运行时及 CUDA 上下文 |
| 总计 | ~80 GB | 显存几乎吃满 |
权重吃了 60GB,KV Cache 又占 15GB,最后只剩 5GB 给系统。刚开始没开 FP8,三路并发就炸了。
性能表现(有波动):
- 首字延迟(TTFT):纯文本 200-400ms,带图请求 500-1200ms(取决于队列长度和图片大小)
- 生成速度:单用户约 40 tokens/s,多用户会降到 25-30 tokens/s
- 并发瓶颈:稳定支持 4-6 路并发。超过 6 路时,第 7 个请求会在队列里等 10 秒以上
有点失望的是并发能力,但考虑到单卡限制,也只能这样了。
上线前检查清单
交付给开发团队前务必执行:
服务端
- 确认 vLLM 版本 >= 0.6.0(旧版本 FP8 不稳定)
- 确认
--max-model-len不超过显卡物理限制(太大直接 OOM) - 端口 8000 防火墙策略已放行内网段
客户端
- 确认目标机器已安装
.deb包 - 执行
ask --version确认版本一致性 - 验证大图上传(>5MB)是否会导致 Base64 编码超时或请求体过大
写在最后
这套方案上线两周了,开发团队反馈还不错。最常用的场景是分析 Kubernetes 报错和数据库慢查询日志。但负载均衡还没做,单点故障就完蛋,这是下一步要解决的。
后续计划(正在测试/纠结中):
- INT4 量化:GPTQ-INT4 能把显存压到 40GB 左右,但推理速度有点慢,还在纠结要不要上
- 负载均衡:前端加 Nginx,后端挂多台 GPU 服务器。方案倒是简单,就是没多余的卡
- 剪贴板集成:Rust 集成
arboard库直接读剪贴板图片。功能已经在分支里了,就差测试
如果你也在内网环境部署 LLM,建议先在测试环境跑几天,别直接上生产。显存配置这块真的容易翻车。