本地大模型升级 Qwen3.8 27B,接入 DeepSeek Harness,用 Cloudflare 开到公网

上一篇把 Qwen3.6 27B 跑在了 RX 7900 XTX 上,这套本地推理栈稳定跑了一段时间。这篇记三件事:模型升到 Qwen3.8 27B、接入 DeepSeek Harness(dsh)、再用 Cloudflare Tunnel + Access 把 Agent 安全开到公网。

一、Qwen3.6 27B 升 Qwen3.8 27B

升级最省事。整套链路是 OpenAI 兼容 API,模型换代对上层客户端基本透明。

换模型文件

新模型同样选 abliterated(去拒绝化)版本 + Q4_K_M 量化:

1
2
3
4
模型文件: huihui-qwen3.8-27b-abliterated-q4_k_m.gguf
大小: ~16.8GB
量化: Q4_K_M(4-bit 混合精度)
上下文: 64K

27B、Q4_K_M、16.8GB,放在 24GB 显存上仍然安全,沿用上一篇算好的预算(模型 ~16.8GB + KV cache q8_0 64K ~3.8GB + ROCm 开销 ~1-2GB,约 22-23GB),不用重新调参。

重启 llama-server

服务指向新文件、改下 alias:

1
2
3
4
5
6
7
8
9
10
11
12
# /etc/systemd/system/llama.service(节选)
ExecStart=/home/elric/llama.cpp/build/bin/llama-server \
-m /home/elric/llama.cpp/models/huihui-qwen3.8-27b-abliterated-q4_k_m.gguf \
--alias qwen3.8-27b \
--host 0.0.0.0 \
--port 1234 \
-ngl 99 \
--flash-attn on \
--ctx-size 65536 \
-ctk q8_0 \
-ctv q8_0 \
--jinja
1
2
3
sudo systemctl restart llama.service
# 确认模型已加载
curl http://localhost:1234/v1/models

上层客户端改 alias

端点是 OpenAI 兼容的 http://localhost:1234/v1,Hermes Agent 的 custom provider 里把 model 字段从 qwen3.6-27b 改成 qwen3.8-27b 就行,baseURL、apiKey 都不用动。

升级成本:一个模型文件 + 一行 alias。OpenAI 兼容 API 的好处就在这,模型层可以独立迭代,上层无感。

二、接入 DeepSeek Harness(dsh)

dsh 是 DeepSeek 开源的 Agent 编排框架(TypeScript,MIT,开发者预览版,会有 breaking changes)。它不是推理引擎,是编排层:工具、会话、沙箱、UI。本地模型(llama.cpp / vLLM)作为 OpenAI 兼容 provider 插进来。

dsh 还在 0.1.0-rc 阶段,接口和配置会频繁变动,生产用建议锁版本。

dsh 只绑 127.0.0.1

dsh 出于安全考虑拒绝 --host 0.0.0.0,只能绑 127.0.0.1。但我想在局域网/公网访问它的 Web UI。解法:dsh 跑内部端口,nginx 代理对外端口。

两个容器(compose 编排):

容器 镜像 作用
dsh node:22-alpine dsh 本体,host 网络,绑 127.0.0.1:3081
dsh-proxy nginx 对外 192.168.x.x:3080,转发到 127.0.0.1:3081

dsh 启动命令:

1
2
3
4
node --expose-internals /usr/local/bin/dsh web \
--port 3081 \
--trusted-host 192.168.x.x:3080 \
--trusted-host 192.168.x.x

浏览器信任 403

setup 里最磨人的一段,nginx 改了 4 次才搞定。

dsh 对特权 RPC 方法(比如 settings.describe)有一道 isTrustedApiRequest 信任栅栏,要求同时满足:

  1. Host 头是 loopback
  2. sec-fetch-site 不是 cross-site
  3. Origin 的 host 等于 Host 的 host

请求过一遍 nginx 后,HostOrigin 变成了公网地址,栅栏判定失败,403。

修复:在 nginx 的 /api/ location 里把 HostOrigin 都重写成 loopback:

1
2
3
4
5
6
location /api/ {
proxy_pass http://127.0.0.1:3081;
proxy_set_header Host 127.0.0.1:3081;
proxy_set_header Origin http://127.0.0.1:3081;
proxy_set_header X-Forwarded-Host $host;
}

验证栅栏有没有过:

  • 不带 Origin curl,200
  • Origin: http://<public> curl,403(栅栏拦了)
  • 修复后,400 “body is not JSON”(栅栏过了,400 只是 RPC 层在校验合成 body,正常)

bind mount 的 inode 陈旧

改了 nginx.conf 却没生效?因为覆盖写入 bind-mounted 文件时,容器里挂的还是旧 inode,你改的是宿主机新文件,容器看的是旧文件。

1
2
# 宿主机 vs 容器内文件大小不一致,就是 inode 问题
docker restart dsh-proxy # 重新解析挂载

改 bind mount 的配置后,docker restart 对应容器,再在容器内 grep / wc -c 验证,别只看宿主机。

配置 provider(in-container)

dsh 的配置在容器内:

  • /root/.dsh/settings.yaml,provider + 默认模型
  • /root/.dsh/.credentials.yaml,实际的 API key 值
1
2
3
4
5
6
7
8
9
10
11
12
13
# /root/.dsh/settings.yaml(节选)
providers:
- name: qwen3827b
displayName: Huihui-Qwen3.8-27B-abliterated
api: openai-completions
baseURL: http://192.168.x.x:1234/v1
apiKeyEnv: QWEN3827B_API_KEY
models:
- id: qwen3.8-27b
contextWindow: 65000
agent-default-model:
provider: qwen3827b
model: qwen3.8-27b
1
2
# /root/.dsh/.credentials.yaml
QWEN3827B_API_KEY: [REDACTED]

本地 llama.cpp 的 apiKey 其实可以随便填(它不校验),但 dsh 要求这个字段非空,给个占位值就行。

前端小坑:crypto.randomUUID

dsh 前端用到了 crypto.randomUUID,某些环境下缺失。要往 dsh-web-frontend/dist/index.html 注入一个 polyfill,并用一个标记(比如 DSH_UUID_PATCH)标注,方便更新后 grep 验证还在不在:

1
2
3
docker exec dsh grep -c DSH_UUID_PATCH \
/usr/local/lib/node_modules/@deepseek-ai/dsh/dsh-web-frontend/dist/index.html
# 期望 ≥ 1

验证清单(全过才算成功)

  1. docker ps,两个容器都 Up,docker inspect 看 0 重启
  2. curl 对外端口,HTTP 200
  3. 模型后端:curl http://192.168.x.x:1234/v1/models
  4. polyfill:容器内 grep DSH_UUID_PATCH,≥1
  5. 端到端证据:/root/.dsh/storages/session_projcache.json 里有 session 的 sessionStats(turns/steps/decodeTokens/llmMs),真实调用过的铁证
  6. docker logs dsh | grep -iE 'error|warn|fail',无异常

一个真实 session 证据(脱敏):turns=3, steps=9, llmMs=54836, decodeTokens=1387,模型确实在被调用,不是空转。

备注:dsh 的推理档位只接受 xhigh(默认)/ medium / low,写 high 会报 Jinja 错 Unexpected reasoning effort high。浏览器自动化工具对 dsh UI 时好时坏,curl + SSH 是最可靠的验证路径。

三、Cloudflare 安全公网(Tunnel + Access)

dsh 跑在 192.168.x.x:3080,局域网内访问没问题。但它是 Agent,谁能进 UI,谁就能读工作区文件、跑工具。开到公网,别裸开端口,前面挂一层真正的登录墙。

架构

1
2
3
4
5
6
7
浏览器 ──HTTPS──> Cloudflare 边缘 ──加密隧道──> cloudflared(token 模式, 仅出向)

CF Access 应用(登录墙)
未认证 → 302 跳 CF 登录页


nginx:3080 ──> dsh:3081

分层:

  1. Cloudflare Access 应用(真正的防护)。一个子域(比如 dsh.elric.top)指向 http://192.168.x.x:3080。未认证请求先被 302 到 CF 登录页,根本到不了 nginx。认证用 OIDC(推荐)或 Email OTP(一次性验证码,零配置)。
  2. 边缘 TLS + 隧道通道。CF 在边缘终结 TLS,隧道是加密出向通道,路由器上不用开任何入站端口(契合”国内运营商爱扫 80/443”的顾虑)。
  3. 既有层仍生效。浏览器信任 403 栅栏还在,LLM 后端 192.168.x.x:1234 保持仅局域网。
  4. 可选加固。CF 限流;子域起个不那么显眼的名字(想靠隐蔽就别叫 dsh.);token 最小权限、可随时吊销。

创建 Access 应用(Zero Trust 控制台)

  1. Applications → Add an application → Type: Self-hosted
    • Application domain: dsh.elric.top
    • Service address: http://192.168.x.x:3080
  2. Access policies → Add a policy
    • 认证:One-time PIN (Email)(零配置起步)或 OIDC
    • Include:你的邮箱
  3. 可选:MFA、IP 白名单、受信设备
  4. 后续可加 Service Token,让 CLI/API 免浏览器自动登录

CN 控制台小提示:中文界面下模板选择器没有现成的 “Edit Cloudflare Access” 模板。用自定义令牌,权限框按英文标识搜索(Access / Tunnel / DNS),等约 1s 异步下拉。权限给 Edit,作用域限定到具体 zone elric.top(别选 All resources),TTL 设 0。

端到端验证

  1. curl -sL -o /dev/null -w '%{http_code}\n' https://dsh.elric.top,未认证请求应得到 CF Access 登录跳转(而不是 dsh UI)
  2. 完成认证(OIDC / OTP),dsh UI 加载
  3. 确认登录页是 Cloudflare 的,不是应用的,证明墙在 nginx 前面

总结

  • 模型升级零成本:OpenAI 兼容 API 让模型层独立迭代,换文件 + 改 alias 就完事。
  • dsh 接入的核心问题:loopback-only 绑定要 nginx 代理,403 信任栅栏要重写 Host + Origin,bind mount 改配置要 docker restart 加容器内验证。
  • 公网安全:别裸开端口,Cloudflare Tunnel(仅出向)加 Access 登录墙,分层防御,LLM 后端留在局域网。

这套组合打下来,本地大模型、Agent 编排、安全公网就齐了,基本能直接照抄。


本文环境:ai-server 上 llama.cpp 提供 qwen3.8-27b(Q4_K_M,64K ctx,16.8GB);dsh 为 0.1.0-rc 开发者预览版,接口可能变动。