2026 年 9 月,我把同一个 4B 模型用两种方式放到 RTX 3090 24GB 上,对外都走 OpenAI 兼容接口,做同一件事:读完一篇舆情,只回答一个字母。A 表示文中能看出具体毕业生和他的毕业院校,B 表示看不出来。
两边都是 4 bit,上下文都是 8192,思考模式都关掉。差别在运行时和量化落点。测完之后的结论很短:
- 要吞吐,用 vLLM AWQ。 220~440 token 的判别请求,单条延迟大约是 llama.cpp 的五分之一,8 路并发吞吐高 3~5 倍。这张卡上还同时跑着 OCR。
- 要少把「在读生」放进下一轮,用 llama.cpp GGUF。 18 条标注里它 18/18,vLLM 16/18。错的两条都是边界句,明确的校友新闻和纯企业新闻两边一致。
- 量化档次差得不多。 大矩阵都是 4 bit、组大小 32。GGUF 把一部分敏感矩阵留在 5~6 bit,AWQ 把线性注意力的输入投影和 lm_head 留在原精度。
下面是部署、踩坑和测试记录。
1. 这个模型到底在判什么
SemIf-OpenJev 不是一套单独的微调权重。官方基线就是冻结的 Qwen/Qwen3.5-4B。所谓 SemIf,是一次前向、只读选项字母 logits 的决策协议。量化之后的概率不会和 BF16 逐位相同,项目 README 里的准确率也是 BF16 上的数字,不能直接套到 Q4 上。
我用的协议如下。系统提示固定为:
text
Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. Respond with only its uppercase letter, with no explanation or reasoning.
用户消息是一段 JSON:
json
{
"evidence": "标题:......\n正文:......",
"criterion": "这篇舆情是否包含高校,并且能看出毕业生及其毕业院校?",
"options": [
{"letter": "A", "description": "文中有高校,且能看出具体毕业生是谁,以及其毕业院校。"},
{"letter": "B", "description": "没有高校,或看不出具体毕业生,或看不出其毕业院校。仅有企业、合作或泛称校友也不够。"}
]
}
请求参数:
| 参数 | 值 | 原因 |
|---|---|---|
max_tokens |
1 | 只要首字母 |
temperature |
1.0 | 跟 SemIf 直接打分的设置对齐,真正的决策看 logprob,不看采样 |
logprobs / top_logprobs |
true / 8 | 取出 A、B 的对数概率 |
chat_template_kwargs.enable_thinking |
false | Qwen3.5 默认会思考,思考 token 会把 1 个字母的请求拖成一段推理 |
决策时对 A、B 的 logprob 做 softmax。缺字母就当保留。生产上的丢弃规则更严:只有判成 B,且 B 的概率 ≥ 0.85,才丢掉。接口超时、报错、置信度不够,都保留,交给后面的大模型。彩票、招生简章不走这个模型,标题正则在更前面就过滤掉。
输入必须是标题加全文。毕业院校经常写在正文后半段,只看标题会误杀。
2. 两张卡的约束不一样
机器是多张 RTX 3090 24GB。两种部署占的卡不同,比较速度时要带着这个前提。
| 方案 A | 方案 B | |
|---|---|---|
| 运行时 | llama.cpp llama-server |
vLLM 0.28 vllm serve |
| 权重 | bartowski Qwen3.5-4B-Q4_K_M GGUF |
cyankiwi/Qwen3.5-4B-AWQ-4bit |
| 卡 | GPU1,整卡独占 | GPU4,和已有 OCR 进程分卡 |
| 端口 | 8091 | 8092 |
| 对外模型名 | Qwen3.5-4B-SemIf |
进程内是 Qwen3.5-4B-AWQ,网关再映射成 SemIf |
| 上下文 | 每槽 8192 | max-model-len 8192 |
| 镜像 | vllm/vllm-openai:v0.28.0-cu129 |
同一份镜像 |
镜像名字容易看错。两个容器用的是同一份 vLLM 镜像,GPU1 那个容器把入口换成了自己挂进去的 llama-server,所以界面上的镜像名一样,里面跑的不是 vLLM。
3. 方案 A:GPU1 上的 llama.cpp
GGUF 来自 bartowski/Qwen_Qwen3.5-4B-GGUF,文件 Qwen_Qwen3.5-4B-Q4_K_M.gguf,约 3.01 GB。/v1/models 回报:词表 248320,嵌入 2560,参数量约 43.3 亿,量化标记 Q4_K - Medium,训练上下文 262144。服务时把每条请求限制在 8192。
二进制是事先编好的 llama-server,需要 glibc 2.38,宿主机跑不了,只能放进容器。镜像只是提供这个 glibc 和 CUDA,入口被 --entrypoint 换掉。
3.1 槽位怎么算
llama.cpp 里 -c 是整池上下文,再被并行槽数切开。-c 8192 -np 64 不会给每条请求 8192,日志里每槽只剩几百 token。正确写法是统一 KV 池:
text
-np 56 --kv-unified --kv-unified-per-slot 8192
启动日志:
text
sizing KV pool to n_parallel * kv_unified_per_slot = 56 * 8192 = 458752
n_slots = 56, n_ctx_slot = 8192, kv_unified = true
458752 > n_ctx_train 这条警告可以忽略。那是池子的总 token 数,单条请求仍然被帽在 8192。
3.2 为什么是 56 而不是 64
Qwen3.5-4B 有 32 层,每 4 层才有一层完整注意力,也就是 8 层真正占 KV。KV 头 4 个,head_dim 256,FP16:
text
8 层 × 2(K+V) × 4 头 × 256 × 2 字节 = 32 KB/token
8192 token × 32 KB ≈ 256 MB/槽
64 槽的 KV 大约 16 GB,加上约 3 GB 权重,再加 ubatch=2048 的计算图(约 2.5 GB),24 GB 放不下,启动时还差大约 2.5 GB。ubatch=4096 的计算图更大,大约 4.7 GB,更不可能。56 槽、ubatch=2048 能稳住,显存大约 22.4 GB / 24 GB。
实际启动参数:
bash
llama-server \
-m Qwen_Qwen3.5-4B-Q4_K_M.gguf \
--host 0.0.0.0 --port 8091 \
-np 56 --kv-unified --kv-unified-per-slot 8192 \
-b 8192 -ub 2048 -ngl 99 --flash-attn on \
-t 8 \
--alias Qwen3.5-4B-SemIf --metrics
容器侧还要加上 --gpus device=1、--ipc=host、--network、--cpus=8、--restart unless-stopped,以及把 GGUF 和二进制只读挂进去。llama.cpp 这一侧没有 API Key。
短提示、8 路、预热之后,16 到 56 槽的预填充吞吐差不多,都在每秒 2400~2800 token 附近。槽位加到 56 不是为了把短请求的 token/s 再抬一截,而是为了让 56 篇 8192 以内的文章可以同时占着 KV,不被切成 256 token 的残槽。
4. 方案 B:GPU4 上的 vLLM AWQ
权重是 cyankiwi/Qwen3.5-4B-AWQ-4bit,revision ef85d23。config.json 里的量化方法是 compressed-tensors,版本 0.14.1.dev20,格式 pack-quantized。这不是旧的 AutoAWQ 格式。不要加 --quantization awq,vLLM 0.28 会按 compressed-tensors 自己识别。
量化配置:
- 目标是
Linear,权重量化,激活不量化 - 4 bit,对称 int,组大小 32,observer 是
mse - 不量化的有 148 个张量:视觉塔 98 个,线性注意力
in_proj_a/in_proj_b共 48 个,再加上lm_head和 MTP 的fc
服务时加了 --language-model-only,视觉塔不进推理,但它仍然占着「不量化」名单里的那部分权重体积。
4.1 和 OCR 分卡时,利用率不能按空闲显存来填
vLLM 的 gpu_memory_utilization 乘的是整卡显存 ,并且启动时要求空闲显存不少于这个乘积。GPU4 上 OCR 已经占了大约 12.9 GB,空闲大约 11.5 GB。0.9 会去要 21 GB 以上,启动直接失败。0.42 × 24 GB ≈ 10.1 GB,低于当时的空闲,可以启动。
启动之后的日志:
text
Using FLASH_ATTN attention backend
out of potential backends: FLASH_ATTN, FLASHINFER, TRITON_ATTN, FLEX_ATTENTION
Model loading took 3.28 GiB
Available KV cache memory: 4.4 GiB
GPU KV cache size: 101,282 tokens
Maximum concurrency for 8,192 tokens per request: 12.36x
3090 是 SM 8.6,不是 Hopper,也不是 SM100。这条日志里的优先级就是实测选中的顺序,FlashAttention 2 已经是这张卡上的第一选择。32 层里只有 8 层走这个后端,另外 24 层是线性注意力。GDN 的 decode 在这个镜像里回退到了 Triton,因为对应的 fused CUDA kernel 没有编进来。这不影响正确性,只是 decode 不是最快的那条路径。判别请求几乎只有 prefill 加 1 个 token,这条回退影响很小。
首次启动要给 Dynamo 编译留时间,这次大约 3~4 分钟。编译缓存写在容器可写层里,docker rm -f 再拉会重新编译。
当前正在跑的进程只注册了 Qwen3.5-4B-AWQ。启动脚本后来改成了两个 --served-model-name(Qwen3.5-4B-SemIf 和 Qwen3.5-4B-AWQ),但没有重建容器,所以上游名字还是 AWQ。网关做名字映射即可,不必为了改名重启。
bash
vllm serve cyankiwi/Qwen3.5-4B-AWQ-4bit \
--served-model-name Qwen3.5-4B-SemIf \
--served-model-name Qwen3.5-4B-AWQ \
--host 0.0.0.0 --port 8092 \
--max-model-len 8192 \
--gpu-memory-utilization 0.42 \
--language-model-only \
--reasoning-parser qwen3 \
--default-chat-template-kwargs '{"enable_thinking": false}' \
--trust-remote-code
HF 缓存只读挂进容器,并设置 HF_HUB_OFFLINE=1,避免启动时再去拉仓库。
5. 量化差在哪
两边文件都读过张量类型,不是看仓库名字猜的。
GGUF 共 441 个张量:
| 类型 | 个数 | 主要落在 |
|---|---|---|
| FP32 | 232 | LayerNorm、大部分 SSM 小参数 |
| Q4_K | 124 | FFN gate/up、注意力 gate,以及一半 FFN down |
| Q6_K | 49 | 词嵌入、线性注意力的 QKV、另一半 FFN down、一部分全注意力 Q/V |
| Q5_K | 16 | 全注意力的 K 和 output |
| Q8_0 | 20 | 少量 SSM,以及 MTP / 末层的个别矩阵 |
Q4_K 是超块 256、组大小 32,每组有 scale 也有 min,不是纯对称量化。Q4_K_M 的「M」就是这张混精度表:敏感矩阵升到 5 bit、6 bit 甚至 8 bit,其余大矩阵留在 4 bit。
AWQ 这一侧更整齐:该量化的 Linear 全部是对称 int4、组大小 32;线性注意力的 in_proj、lm_head、视觉塔保持原精度。所以文件大约 3.8 GB,比 GGUF 的 3.0 GB 大,运行时权重约 3.28 GiB。
对应关系可以收成三句话:
- FFN 的 gate/up,两边都是 4 bit。
- 线性注意力的大投影,AWQ 留原精度,GGUF 压到 Q6_K。AWQ 在这块更满。
- 全注意力的 QKV/O,GGUF 多用 5~6 bit,AWQ 用 4 bit。GGUF 在这块更满。
没有出现「一边 2 bit、一边 8 bit」这种档次差。对只输出一个字母的判别来说,这个差距会表现成边界句上的分歧,而不是明显样本被判反。官方 BF16 的分数两边都对不齐,互相之间也不会逐位相同。
6. 对外合成一个模型名
业务侧只认 Qwen3.5-4B-SemIf。两个后端都挂到 New API 上:
- 通道一:
http://semif-qwen35-4b:8091,模型名就是Qwen3.5-4B-SemIf - 通道二:
http://qwen35-4b-awq-gpu4:8092,上游模型名Qwen3.5-4B-AWQ,model_mapping把Qwen3.5-4B-SemIf改写成Qwen3.5-4B-AWQ
改完通道、能力和模型倍率之后要重启网关,否则内存里的模型表不会出现新名字。没配模型倍率时,网关会直接 400,提示价格未配置。两个通道的权重都设成 0 时,网关会把同名请求分到两边。做对比测试时不要走网关,直接打 8091 和 8092。
思考开关也可以在网关的 param_override 里再写一次 chat_template_kwargs.enable_thinking=false,避免某个客户端漏传。
7. 测试怎么做的才公平
三件事如果不做,数字会骗人。
- 先把网关上的两条通道暂时禁用,再直接打容器端口。否则线上请求和测试请求挤在同一批槽位里,llama.cpp 会被拖到几十秒一条。测完再打开通道。
- 请求体必须是上面的 SemIf JSON。 用一段普通续写去压测,模型会输出 C、E、N 之类的字母,那个吞吐不能当成判别速度。
- 重复同一条长文会命中 prompt cache。 我第一次把同一篇 732 token 的文章打 8 遍,llama.cpp 的单条延迟从 0.42 秒掉到 0.13 秒。后面改成每条提示都旋转正文、前缀不同,才作为速度结论。
准确率用了 18 条我自己标的短句。判 A 必须同时看见「是谁」和「毕业于哪所高校」,原文和每一条的理由写在第 9 节。样本不大,其中一部分短句自己把结论写在了句尾。
8. 速度
去缓存之后的结果:
| 规模 | llama.cpp / GPU1 | vLLM AWQ / GPU4 |
|---|---|---|
| 约 220 token,单条中位 | 0.41 秒 | 0.07 秒 |
| 约 220 token,8 路 | 5.2 条/秒,约 1150 token/秒 | 24.8 条/秒,约 5500 token/秒 |
| 约 440 token,单条中位 | 0.48 秒 | 0.09 秒 |
| 约 440 token,8 路 | 5.6 条/秒,约 2500 token/秒 | 15.8 条/秒,约 6900 token/秒 |
另外一组 16 条互不重复的短句(约 175 token):
| llama.cpp | vLLM | |
|---|---|---|
| 单条中位 | 0.42 秒 | 0.07 秒 |
| 16 条串行总时间 | 7.6 秒 | 1.3 秒 |
vLLM 单条大约快 5 倍。8 路并发下,短请求大约快 5 倍,440 token 大约快 3 倍。GPU1 有 56 个槽,GPU4 满上下文理论并发只有 12 左右,但判别请求远短于 8192,vLLM 的连续批处理把这 8 条叠在一起,56 个槽并没有换成更高的短请求吞吐。
更长的全文(一两千 token 以上,接近 8192)这次没有单独拉满。220 和 440 两档的差距方向一致。如果生产里每篇都顶满 8192,GPU4 的 KV 只有 4.4 GiB,并发会被 12 这条线卡住;GPU1 的 56×8192 池子更大。那种负载要另测,不能把上面的 5 倍直接外推到满上下文。
9. 准确率
先把判分规则说死。一条文章要标 A(留下),必须同时看见两件事:
- 具体是谁
- 这个人毕业于哪所高校
少一条就是 B(丢掉)。在读生、只写在高校上班、只说「多名校友」但没有人名、只有高中、只有校名没有人,都是 B。
模型输出的是 A 或 B 的概率。线上还有第二道闸:判成 B 并且 B 的概率不到 0.85,仍然留下。所以「模型选了哪个字母」和「线上会不会丢掉」不是一回事。
18 条里,只看字母谁的概率更高:llama.cpp 18/18,vLLM 16/18。两边错的方向都是该丢的没丢,没有把写明毕业院校的人误删。下面按原文列出来。
9.1 应该留下:原文写明了人和毕业院校
这 5 条两边都判 A,置信度都 ≥ 0.85,线上都会留下。
高拯。 原文:「高拯,云霄一中 1956 届高中毕业生。1961 年毕业于上海同济大学建筑工程系,毕业后留校任教。」人是高拯,高校是同济大学,动词是「毕业于」。前面的高中不改变结论。
刘欢,写了毕业。 原文:「1985 年,刘欢刚从国际关系学院毕业,随后到宁夏支教。1991 年入职对外经济贸易大学,任教三十余年。」人是刘欢,毕业院校是国际关系学院。后面的对外经济贸易大学是任职,不是这条要找的毕业信息,但毕业那一句已经够了。
小米创始团队。 原文:「雷军,小米创始人,毕业于武汉大学计算机科学系。林斌毕业于中山大学无线电电子工程系,后获美国德雷克塞尔大学硕士。卢伟冰毕业于清华大学化学系。」三个人、三所高校,都写了「毕业于」。
撒贝宁、李健、张鲁一。 原文:「撒贝宁在节目中自述保送北京大学法学院。李健毕业于清华大学电子工程系。张鲁一有中央戏剧学院本科和北京大学艺术硕士学历。」三个人都能对上学校。保送北大法学院、本科、艺术硕士,都算能看出毕业(或就读并完成)院校。
陈七。 原文:「陈七是浙江大学电气工程学院 1998 级校友,现就职于宁德时代研发部。」有人名、有学院、有年级。「校友」在这里指向这个人的毕业院校,不是空泛的「多名校友」。
9.2 应该丢掉,而且两边都丢掉了
这 10 条两边都判 B,置信度都 ≥ 0.85,线上都会丢掉。
| 送进模型的原文 | 为什么是 B |
|---|---|
| 中建八局西南公司 2027 届校园招聘启动,面向应届本科、硕士毕业生以及博士研究生。公司总部位于成都,未点名任何毕业生及其毕业院校。 | 只有「应届毕业生」这个统称,没有某一个人,也没有他的学校 |
| 船舶电子电气工程毕业生可以入职中船集团。文章只谈专业去向,没有写出任何具体毕业生姓名和毕业院校。 | 只有专业名,没有人 |
| 南通大学人工智能研究院主办国庆一日 AI 科技研学营,面向小学到高中学生,结营颁发研学证书。文中没有高校毕业生。 | 南通大学是主办方,学员是中小学生 |
| 行程包括剑桥大学卡文迪许实验室。郭毅可任英国帝国理工学院数据科学研究所所长。马云出席开幕。没有写谁毕业于这些学校。 | 有高校、有人名,但写的是职务和出席,不是毕业 |
| 电影《小小的我》故事落点是脑瘫青年刘春和收到师范大学录取通知书。这是入学,不是毕业。 | 录取通知书是即将入学 |
| 9 月 26 日热门股:雷科防务、康强电子、平潭发展、新华传媒。全文是股票名单,没有高校也没有毕业生。 | 没有高校,也没有人 |
| 68 岁的周老太太每天通勤 14 小时帮儿子带娃,退休金三千全部倒贴。全文没有高校,也没有毕业生。 | 没有高校,也没有毕业生 |
| 北京大学与华为技术有限公司签署战略合作协议,共同建设联合实验室。张三代表华为出席,文中没有他的毕业院校。 | 有高校、有人名,张三的学校没写 |
| 多位清华校友创办了这家公司,但文章没有写出任何一位校友的姓名。 | 有清华,没有具体的人 |
| 刘欢在对外经济贸易大学任教三十年,开设西方音乐史,职称副教授。文章没有写他毕业于哪所学校。 | 有人、有高校,但这是任职。毕业院校没出现 |
这 10 条有一个测试上的缺陷:不少句子的后半句已经把结论写出来了,例如「未点名任何毕业生」「这是入学,不是毕业」。模型等于看见了提示。两边都判对,只能说明它们读得懂这种短句,不能当成对长新闻的考试。
9.3 三条例外
这三条正文没有把「请判 B」写在脸上,也是两个模型会分开、或者线上不会照字母执行的地方。
1. 张展硕还在读书。两边判反了。
原文:「张展硕不是体育特招的刻板印象。他是复旦大学新闻学院 2025 级本科生,去年接受央视采访时说,学新闻是为了让更多人了解游泳。」
人是张展硕,学校是复旦大学新闻学院,但身份是 2025 级本科生,文章没有写他已经毕业。规则要求的是毕业生,所以人标 B。
| 字母 | 概率 | 按 0.85 阈值,线上会不会丢掉 | |
|---|---|---|---|
| llama.cpp | B | 0.95 | 会丢掉 |
| vLLM AWQ | A | 0.98 | 不会。它把在读生当成了毕业生,而且很确信 |
这是 18 条里唯一一条干净的分歧。vLLM 会把这篇放进下一轮抽取。
2. 只写了「就学」,没写「毕业」。字母不同,线上结果相同。
原文:「刘欢及长,就学京师国际关系学院,专修法文。其后半生执鞭杏坛,任教对外经贸大学三十余载。」
能看出刘欢在国际关系学院读过书,也能看出他后来在对外经贸大学教书。正文没有「毕业」两个字。我按严口径标了 B:没写毕业,就不算看出毕业院校。这个标注偏严,人读也会犹豫。
| 字母 | 概率 | 线上会不会丢掉 | |
|---|---|---|---|
| llama.cpp | B | 约 0.85 | 刚到丢弃线,会丢掉 |
| vLLM AWQ | A | 0.56 | 不会。0.56 远低于 0.85,就算它判了 A,规则也是留下 |
3. 只有高中。字母相同,但都不够确信,线上都留下。
原文:「高拯是云霄一中 1956 届高中毕业生,后来长期在建设部工作。文中没有出现高校。」
有人名,有「毕业生」,但学校是中学,不是高校。人标 B。
| 字母 | B 的概率 | 线上会不会丢掉 | |
|---|---|---|---|
| llama.cpp | B | 0.56 | 不会 |
| vLLM AWQ | B | 0.78 | 不会 |
两个模型都看出了「更像该丢」,但置信度都没过 0.85。这不是谁判反,是阈值把犹豫样本留了下来。
9.4 这 18 条能说明什么
写明「某人毕业于某高校」的 5 条,两边都留下。带有答案提示的 10 条短句,两边都丢掉。真正拉开差距的是张展硕那一条:llama.cpp 把在读本科生挡在外面,vLLM 以 0.98 的概率放了进去。
10. 踩坑清单
这些都是这次部署里实际撞上的。
-c和-np是整池再均分。 要每条 8192,用--kv-unified --kv-unified-per-slot。- 64 槽在 24GB 上会 OOM。 先减 ubatch,再减槽数。56 和 ubatch 2048 是这张卡的实测上限。
gpu_memory_utilization乘总显存,不乘空闲显存。 卡上已有进程时,先算空闲 / 总显存,再留一点余量。- 这份 AWQ 不是 AutoAWQ。 不要传
--quantization awq。 - Qwen3.5 不关思考,
max_tokens=1没有意义。 服务端默认模板和请求体里都要enable_thinking: false。 - 同一个 vLLM 镜像可以只当运行环境。 入口换成 llama.cpp 之后,界面上的镜像名仍然是 vLLM。看进程和
/v1/models的owned_by,不要看镜像仓库前缀。 - 网关要配模型倍率,改完要重启。 否则新模型名要么 400,要么根本不在
/v1/models里。 - 压测必须绕开网关,并且每条提示的前缀要不同。 否则测到的是负载均衡、排队和 prompt cache。
11. 怎么选
| 需求 | 选择 |
|---|---|
| 同样的卡还要留给别的服务,又要更高的短文本吞吐 | vLLM AWQ,把利用率压到空闲显存允许的比例 |
| 整卡可用,希望同时挂住更多篇 8192 上下文 | llama.cpp,56 槽的统一 KV 池 |
| 判别规则卡得很死,在读生、未写明毕业的不能进下一轮 | llama.cpp。这次样本里它没有把在读生判成毕业生 |
| 明确校友 / 明确非校友的粗过滤,后面还有大模型兜底 | 两边都能用。0.85 的阈值本身就会把犹豫样本留下来 |
我现在的接法是两个都挂在同一个模型名后面。对比和回归打到端口上;线上如果更在意速度,就把 AWQ 那条通道的权重调高。阈值继续放在 0.85,调用失败仍然保留。
12. 这次结论的边界
速度是在禁用线上流量之后,用互不重复的 220 和 440 token 提示测的,8 路并发,各做了预热。准确率是 18 条人工短句:5 条写明了毕业院校,10 条句尾带了答案提示,真正拉开差距的是「在读本科生」那一条。「就学」那条标得偏严。更大的标注集上,边界分歧可能会多几条,但「写明毕业院校时两边一致、vLLM 明显更快」这两点,以这次的重复测量看,方向是稳的。