8GB 显卡自建 Gemma 4 推理网关:11 个只有实跑才会撞上的坑
8GB 显卡自建 Gemma 4 推理网关:11 个只有实跑才会撞上的坑
站内搜索
直接问 AI

8GB 显卡自建 Gemma 4 推理网关:11 个只有实跑才会撞上的坑

把一个开源大模型部署成自己能随时调用的服务,教程写起来通常只有三步:下载权重、启动推理服务、配一个反向代理。真正动手做一遍会发现,能跑起来和跑得对之间隔着一堆不会报错、但结果是错的的坑——参数被静默忽略、下载命令返回 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-markCONNMARK 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 能告诉你”包进来了、回包也发了,但走错了路”。遇到”应该通但就是不通”的时候,先抓包,能省下几小时的猜测。

发表回复

向下探索