把一个开源大模型部署成自己能随时调用的服务,教程写起来通常只有三步:下载权重、启动推理服务、配一个反向代理。真正动手做一遍会发现,能跑起来和跑得对之间隔着一堆不会报错、但结果是错的的坑——参数被静默忽略、下载命令返回 0 却什么都没下、服务明明在监听却收不到包。
这篇记录我在一台 8 GB 显存的笔记本显卡上自建 Gemma 4 推理网关的完整过程:模型选型、推理引擎、对外暴露、翻译接入,最后加上检索增强(RAG)。重点不是步骤,而是那些只有真机跑一遍才会撞上的问题——每一条都附上当时的实测数据。
1. 参数量不等于显存占用:官方量化检查点可能比你想的大一倍
选型时最容易犯的错,是拿”参数量 × 每参数字节数”去估显存。Gemma 4 的官方 QAT 检查点(后缀 -qat-w4a16-ct)看名字是 4bit 量化,直觉上一个 4B 级别的模型应该只要 2–3 GB。
实际查一下仓库里 safetensors 的字节数:
gemma-4-E4B-it-qat-w4a16-ct 11.51 GB
gemma-4-12B-it-qat-w4a16-ct 10.26 GB
gemma-4-31B-it-qat-w4a16-ct 23.27 GB
“4.5B 有效参数”的 E4B 反而比 12B 还大。原因在 config.json 的量化配置里:
{
"quantization_config": {
"config_groups": {
"group_0": {
"targets": ["Linear"],
"weights": { "num_bits": 4, "group_size": 32 }
}
}
}
}
targets 只有 Linear。也就是说只有线性层被量化成 4bit,embedding 和 per-layer embedding(PLE)全部保留 bf16。E 系列用了 PLE 架构,它的表是 vocab_size × 层数 × per_layer_dim,对 E4B 来说是 262144 × 42 × 256 ≈ 28 亿参数——光这一块 bf16 就 5.6 GB。
顺带一个更实用的结论:24 GB 显卡跑不了 31B 的 w4a16-ct。权重 23.27 GB 装进去,KV cache 就没地方放了。
怎么避免
别信参数量,直接查 API 拿真实字节数:
curl -s "https://huggingface.co/api/models/<org>/<repo>?blobs=true" | python3 -c "
import json,sys
d=json.load(sys.stdin)
w=[(s['rfilename'], s.get('size') or 0) for s in d.get('siblings',[])
if s['rfilename'].endswith(('.safetensors','.gguf'))]
print(f\"{sum(s for _,s in w)/1e9:.2f} GB\")"
GGUF 的 q4_0 把 embedding 也一起量化了,同一个 E4B 只要 5.15 GB,这才是小显存下唯一装得进去的形式。所以最终的引擎选择是按显存分档的:显存宽裕用 vLLM(吞吐和并发更好),显存吃紧用 llama.cpp + GGUF。
2. 下载命令返回 0,但一个权重都没下
预下载权重时我写了这么一行:
hf download "$MODEL_ID" --exclude '*.pth' '*.gguf' 'original/*'
它打印了 ✓ Downloaded,退出码是 0,脚本继续往下走,然后推理服务起不来。
问题在于 --exclude 每次只吃一个 glob。后面两个模式被当成了”要下载的文件名”,于是它去下载两个不存在的文件、真正的权重一个都没碰。日志里其实有提示,但混在进度条里很容易滑过去:
UserWarning: Ignoring `--exclude` since filenames have been explicitly set.
Fetching 0 files: 0it [00:00, ?it/s]
正确写法是重复这个 flag:
hf download "$MODEL_ID" \
--exclude '*.pth' --exclude '*.gguf' --exclude 'original/*'
更重要的教训是:凡是”成功也可能什么都没做”的命令,后面都要加一道实测校验。我在脚本里补了这个:
# 上面即使返回 0 也可能什么都没下,实测体积把关
WEIGHT_MB="$(du -sm "$HF_HOME_DIR" | cut -f1)"
[ "${WEIGHT_MB:-0}" -ge 500 ] || die "权重只有 ${WEIGHT_MB} MB,明显没下全"
3. 一个参数让 prompt 处理慢 40 倍
服务起来之后,生成速度正常(60+ tok/s),但 prompt 处理慢得离谱——544 个 token 的输入要等十几秒。
用 llama-bench 单独测硬件,pp512 有 3254 tok/s,说明 GPU 没问题。逐个参数排查后定位到 flash attention:
配置 prompt 处理(544 token)
干净配置(不开 FA) 39.6 t/s
+ flash-attn on 1726.5 t/s
+ q8_0 KV cache 1654.4 t/s
相差 40 倍以上。原因是 Gemma 4 的 E 系列用了滑动窗口注意力(SWA),在 Vulkan 后端上不开 flash attention 时会走一条极慢的通用路径。
反直觉的地方在于:很多教程把 --flash-attn 描述成”可选的性能优化”。对这个模型 + 这个后端的组合,它是必需的。
4. --ctx-size 是总量,会被并发数平分
我一开始按”单请求上下文”理解,填了 --ctx-size 16384。llama.cpp 的 --parallel 默认是 4,于是它实际分配了 65536 的 KV cache。显存被吃光后,模型层被挤到 CPU,prompt 处理掉到 1.3 tok/s。
启动日志里有线索,但不显眼:
srv load_model: initializing, n_slots = 4, n_ctx_slot = 16384
n_slots × n_ctx_slot 才是真正的 KV cache 总量。正确的理解是:
# --ctx-size 是总量,单请求可用上下文 = ctx_size / parallel
--ctx-size 131072 --parallel 4 # 4 路并发,每路 32K
顺带说,这个平分关系在选并发数时很有用:翻译类客户端会同时发几十个请求,多槽位比长上下文更重要;写代码则相反。
5. Vulkan 着色器是懒编译的,冷启动的第一次测量不作数
服务重启后第一次发大 prompt,测出来 21 tok/s;同样的请求第二次就是 900+ tok/s。我一度以为是配置有问题,反复改参数。
实际原因是 llama.cpp 的 Vulkan 后端在首次用到某个计算管线时才编译着色器。用几个 token 预热是没用的——那触发不了大批量矩阵乘的管线。要预热就得发一个几百 token 的真实 prompt。
这个坑的危害在于它会污染你所有的性能对比:如果 A 方案先测、B 方案后测,A 会无端背上编译开销的锅。
6. 模型”回答为空”,其实是思考把 token 额度吃光了
Gemma 4 有思考模式。llama.cpp 会把思考段解析进 message.reasoning_content,正式答案才走 message.content。
于是出现了这样的响应:
{
"choices": [{
"finish_reason": "length",
"message": {
"content": "",
"reasoning_content": "Thinking Process:\n\n1. **Analyze the Request:** ..."
}
}],
"usage": { "completion_tokens": 150 }
}
content 是空字符串,不是 null,也不报错。只读 content 的客户端会显示”模型没有回答”。
两个应对:
max_tokens给够。思考也算 completion tokens,给小了会在思考阶段就被截断。复杂问题建议 2048 以上。- 客户端要处理
reasoning_content。流式下它走delta.reasoning_content,不处理的话思考的几十秒里界面完全静止,看起来像卡死。
关掉思考:一个有效、一个无效
翻译、摘要、分类这类”照做就行”的任务不需要思考。试了两种方式:
// 无效 —— 实测照样思考 1239 字
{ "reasoning_budget": 0 }
// 有效 —— 思考 0 字
{ "chat_template_kwargs": { "enable_thinking": false } }
差距有多大?同一段技术文字翻译:
带思考: 思考 1762 字,输出 560 token
关思考: 思考 0 字,输出 61 token
token 差 9 倍,译文质量没有差别。
7. 限流值按”感觉”设,会被当成网络故障
把网关接进浏览器翻译扩展后,它报了个”网络连接失败(可能由服务器无响应、网络不稳定或安全策略限制引起)”。
查服务端日志,真相和网络无关:
[error] limiting requests, excess: 40.800 by zone "api_zone",
request: "POST /v1/chat/completions"
我设的是 rate=120r/m(每秒 2 个)+ burst=40。翻译扩展翻一页会瞬间发几十个请求,burst 一下就满,后续全部 503。
这里的思维错误是:把限流当成了节流工具。限流的目的是防止 Key 泄露后被滥用,值应该远高于真实负载;真正约束正常使用的是后端的并发槽位(超了会排队,不会丢请求)。
8. 端口转发配对了,抓包看到 SYN-ACK 发出去了,客户端却一直重传
这是整个过程里最难查的一个。把 API 直接暴露到公网时,端口转发规则填得完全正确(外部端口、内网 IP、内网端口、协议全对),但外部就是连不上,TCP 握手都完不成。
抓包看到的现象很奇怪:
<客户端> > <服务器>:9443 Flags [S] ← SYN 到了
<服务器>:9443 > <客户端> Flags [S.] ← SYN-ACK 也发了
<客户端> > <服务器>:9443 Flags [S] ← 客户端一直重传
<客户端> > <服务器>:9443 Flags [S]
包进来了,回包也发了,但客户端收不到。原因是这台机器的默认网关不是做 NAT 的那台路由器——它为了走代理,默认路由指向局域网里另一台设备。于是回包从”另一条路”出去了,路由器的 NAT 表里没有这条连接的记录,包就被丢掉。
同一个局域网里另一台服务器的端口转发一直工作正常,正是因为它的默认网关就是路由器本身。
解法:conntrack 打标记 + 策略路由
让”从路由器进来的连接”,回包也从路由器出去:
# 1. 给从路由器 DNAT 进来的新连接打标记
iptables -t mangle -A PREROUTING -i "$IFACE" ! -s "$LAN_NET" \
-p tcp --dport "$PORT" -m conntrack --ctstate NEW \
-j CONNMARK --set-mark "$MARK"
# 2. 把标记恢复到该连接的后续包上(含本机生成的 SYN-ACK)
iptables -t mangle -A PREROUTING -i "$IFACE" -j CONNMARK --restore-mark
iptables -t mangle -A OUTPUT -j CONNMARK --restore-mark
# 3. 带标记的包查一张只指向路由器的独立路由表
ip route replace default via "$ROUTER" dev "$IFACE" table 100
ip rule add fwmark "$MARK" lookup 100
这里有两个细节容易踩:
第一,--restore-mark 是 CONNMARK target 的选项,不是 connmark match 的。写成 -m connmark --restore-mark 会直接报 unknown option。
第二,标记规则必须排除内网来源(! -s $LAN_NET)。我一开始没加,结果内网客户端直连内网 IP 时回包也被塞进那张只有默认路由的表,被丢到路由器再也回不来——内网直连全部超时。这个”修一个坑、砸一个洞”的过程,只有在修完之后回归测试才能发现。
还有 NAT 回环
加了内网排除之后,内网设备用公网域名访问(NAT hairpin)又不通了:这种流量源 IP 是内网的(第一条规则匹配不到),但它确实是路由器 DNAT 进来的,需要原路返回。
区分点是源 MAC——经路由器进来的包,源 MAC 是路由器的网卡:
iptables -t mangle -A PREROUTING -i "$IFACE" \
-m mac --mac-source "$ROUTER_MAC" \
-p tcp --dport "$PORT" -m conntrack --ctstate NEW \
-j CONNMARK --set-mark "$MARK"
加上这条之后,hairpin 从 4/6 成功、中位 8.96 秒,变成 8/8 成功、中位 0.369 秒。
9. 你的测量机器可能在骗你
做延迟对比时我得出过一个结论:”隧道方案要 1.5–4.8 秒,直连只要 0.2 秒”。后来发现这组数据完全不可信。
本机跑着代理软件(Clash 类),它按域名分流。请求 api.example.com 时被代理绕出去再绕回来——服务端 access log 里看到的来源 IP 是某个运营商的公网地址,而不是本机内网 IP。同一台机器请求裸 IP 却是直连。
# 服务端看到的来源 IP 才是真相
sudo tail -5 /var/log/nginx/<site>.access.log
# 强制绑物理网卡,绕开 TUN
curl --interface en0 ...
更普遍的一条:“哪个方案更快”完全取决于客户端在哪。同一套系统,从家里网络测是直连快一倍,从境外某台服务器测反而是隧道快 15 倍(因为那台机器就在隧道落地的边缘节点旁边)。别把一个位置的结论套到另一个位置。
10. 加 RAG 时的四个坑
最后给网关加了检索增强。链路是:文档 → 切片 → 向量化 → sqlite-vec + FTS5 → 混合检索 → 拼进 system 消息。
10.1 embedding 的批大小限制
入库时直接 500:
input (573 tokens) is too large to process.
increase the physical batch size (current batch size: 512)
llama.cpp 的 --ubatch-size 默认 512 token,而一个切片很容易超过。embedding 模型本身支持 8192 输入,把 batch 提上去即可:
--ctx-size 8192 --batch-size 8192 --ubatch-size 8192
10.2 必须先按标题切,再按大小切
我最初的实现是先按字数递归切分,再取每片里最后一个 markdown 标题当标签。结果:
- 小文档整篇变成一个切片,检索粒度全丢;
- 一个切片跨越多个章节时,第一节的内容会被标上最后一节的标题——引用张冠李戴,而且很难发现。
正确的顺序是两级:先按标题分节(语义边界),节内超长再递归切。这样每个切片都归属唯一一个章节。
10.3 找标题要排除代码块
markdown 的标题正则会命中围栏代码块里的 shell 注释——两者长得一模一样:
```bash
# 3. 下完记得撤掉——路由重启即失效
sudo sed -i '/example/d' /etc/hosts
```
那行 # 注释被当成了 H1,于是引用面包屑变成了「README.md › 3. 下完记得撤掉——路由重启即失效…」这种东西。解法是先算出所有围栏区间,匹配标题时跳过落在区间内的。
10.4 中文全文检索:分词器和查询写法都要改
两个独立的问题:
分词器。SQLite FTS5 默认的 unicode61 不切中文,一整句会变成一个 token,中文全文检索直接废掉。要用 trigram:
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text, content='chunks', content_rowid='id', tokenize='trigram'
);
查询写法。我一开始把整个问句加引号做短语匹配。trigram 下短语匹配等于子串匹配,「reasoning_budget 为什么无效」这样的问句原样去查,一条都命中不了——全文这一路等于白搭。改成先试整句,没命中就拆词(≥3 字符)做 OR。
为什么一定要混合检索
这一点值得单独说:纯向量和纯全文各有各的盲区,不是二选一。
- 问「怎么让翻译不思考」——语义问题,只有向量能找到;
- 问
reasoning_budget或某个.gguf文件名——向量对这类标识符很不敏感,实测排不到正确段落,靠全文精确命中。
两路召回用 RRF(Reciprocal Rank Fusion)融合,按名次而不是分数——省得给两种不可比的分数做归一化:
K = 60
score = {}
for rank, cid in enumerate(vec_hits):
score[cid] = score.get(cid, 0.0) + 1.0 / (K + rank + 1)
for rank, cid in enumerate(fts_hits):
score[cid] = score.get(cid, 0.0) + 1.0 / (K + rank + 1)
11. 前端也有一个静默失效
写控制面板时,登录页和主界面用 hidden 属性切换。结果两个界面同时渲染,登录卡片被顶出视口。
原因是 CSS 里用 ID 选择器设了 display:
#app { display: flex; } /* ID 选择器,优先级高 */
/* 浏览器默认样式:[hidden] { display: none } —— 优先级低,被盖掉了 */
元素带着 hidden 属性,但计算样式仍然是 flex。修法是显式声明一条:
[hidden] { display: none !important; }
一点总结
回头看,这些坑可以归成三类:
- 静默失效——参数被忽略、命令返回 0 但没做事、
content是空字符串而不是报错。对策是凡是关键步骤都加一道独立的事实校验,别信返回码。 - 默认值不符合直觉——
--ctx-size是总量、--parallel默认 4、--ubatch-size默认 512、FTS5 默认分词器不切中文。对策是看启动日志里它实际算出来的值,而不是你以为你设了什么。 - 测量本身不可靠——冷启动的着色器编译、代理软件按域名分流、测量位置决定结论。对策是永远带一个对照组:一个应该失败的端口、一个已知正确的基线、一个不同位置的测量点。
最后一个可能最有用:抓包解决了两个我靠读日志和改配置绝对查不出来的问题。端口转发那个尤其典型——所有配置都对、服务在监听、防火墙全关,只有 tcpdump 能告诉你”包进来了、回包也发了,但走错了路”。遇到”应该通但就是不通”的时候,先抓包,能省下几小时的猜测。