2025年7月2日

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


Qwen3-VL 终端演示

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-dtypegpu-memory-utilization 这两个参数。

启动脚本

Terminal window
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 够用了,再长的日志也得分段喂。

核心参数说明:

参数设定值作用
--dtypebfloat16避免精度溢出,比 float16 稳
--kv-cache-dtypefp8救命参数,KV Cache 显存省一半
--max-model-len16384长日志分析场景,再大就 OOM
--limit-mm-per-prompt8限制单次请求图片数量,防止显存暴涨

验证服务是否活着:

Terminal window
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 打包,方便运维批量装到开发机。

target/debian/localterminalagent_0.2.1-1_amd64.deb
cargo build --release
cargo deb

实际使用:三种典型场景

场景一:单命令快速查询

无需上下文,用完即走。

Terminal window
$ ask tar命令如何排除特定文件夹?
Qwen > 使用 --exclude 参数,例如:
tar -czf backup.tar.gz --exclude='node_modules' --exclude='.git' /path/to/dir

场景二:报错截图分析(Vision)

针对无法复制文本的 VNC 界面或监控面板截图。有次 Java 应用报错,VNC 界面复制不了文本,直接截图扔给它。

Terminal window
$ ask "这个 Java 堆栈报错是什么原因?" --image ~/monitor_error.png
Qwen > 从截图看是 OutOfMemoryError: Java heap space。
建议检查 JVM 启动参数 -Xmx 设置,当前配置似乎只有 512MB。

场景三:交互式会话(Context)

进入 REPL 模式,支持多轮对话和图片暂存。

Terminal window
$ ask
LocalTerminalAgent (Vision Enabled) Ready.
User > :img ~/arch_diagram.png
[系统] 图片已暂存缓冲区。
User (带图) > 这里的负载均衡配置有什么单点风险?
Qwen > 架构图中 Nginx 只有单节点,建议使用 Keepalived + VIP 配置双机热备。

性能实测:显存占用与并发瓶颈

在 A100-80G 环境下的真实数据:

显存分项占用量说明
模型权重 (bf16)~60 GB32B 参数的硬性开销
KV Cache (fp8)~15 GB开启 FP8 后的缓存池
系统预留~5 GBPyTorch 运行时及 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,建议先在测试环境跑几天,别直接上生产。显存配置这块真的容易翻车。