Qwen3.8-27B 远程推理服务与 OpenCode 接入

这篇笔记记录的是一次完整的本地模型接入练习。重点不是“把模型启动起来”,而是分清模型、服务、接口和客户端各自负责什么,并用可重复的请求验证每一层。

先建立整体链路

Windows / OpenCode
        │
        │  OpenAI-compatible HTTP API
        ▼
SSH 隧道:127.0.0.1:8080
        ▼
远程主机 llama-server
        ▼
Qwen3.8-27B GGUF 模型
  • 模型文件:只保存权重和推理所需的模型信息,本身不会监听端口。
  • llama-server:加载 GGUF,分配上下文和推理资源,对外提供 /v1/... 接口。
  • OpenAI 兼容 API:把不同推理后端统一成客户端容易调用的格式。
  • OpenCode:作为客户端调用模型,并把工具定义传给模型;它不是模型本身。

因此,“模型文件存在”不等于“服务已经可用”;“服务返回 200”也不等于工具调用和长上下文都已经验证。

这次实际使用的文件

远程主机上的模型目录是:

/home/lab304/models/efficientthink-q2/
├─ Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf
├─ mmproj-Qwen3.8-27B-Q4_K_M.gguf
└─ dflash2-qwen38-27b-Q4_K_M.gguf

主模型约 13 GB,属于 Qwen3.8-27B 的 GGUF 量化版本。mmproj 和 dflash2 是配套文件,是否能直接使用,仍要以当前 llama.cpp 版本和模型模板兼容性为准,不能只看文件名下结论。

服务程序:

/home/lab304/llama.cpp-efficientthink/build-cuda13-mmq/bin/llama-server

先验证服务,而不是先接 OpenCode

排错时先保留一份 CPU 基线。它能把“模型/模板/API 本身有问题”和“GPU 后端有问题”分开:

llama-server \
  --model /home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf \
  --gpu-layers 0 \
  --parallel 1 \
  --ctx-size 4096 \
  --jinja \
  --reasoning-format none \
  --reasoning off \
  --host 127.0.0.1 \
  --port 8080

这里每个关键参数都对应一个取舍:

参数 作用
--gpu-layers 0 不把模型层放到 GPU,先得到稳定的 CPU 基线
--parallel 1 只允许一个并发槽,降低内存压力,便于排查问题
--ctx-size 4096 上下文窗口为 4096,先控制内存和响应时间
--jinja 使用模型模板组织聊天消息
--reasoning-format none / --reasoning off 暂时关闭特殊 reasoning 输出,避免客户端解析不一致
--host 127.0.0.1 只监听远程主机本机,避免把推理服务直接暴露到局域网

启动后按三层顺序检查:

curl http://127.0.0.1:8080/health
curl http://127.0.0.1:8080/v1/models

最后再发一条最小聊天请求,确认不只是端口打开,而是真的能生成:

curl http://127.0.0.1:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "/home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf",
    "messages": [{"role":"user","content":"只回答:部署测试成功。"}],
    "temperature": 0,
    "max_tokens": 32
  }'

本次 CPU 基线中,/health 返回 {"status":"ok"},聊天请求 HTTP 200,并能生成“部署测试成功”。CPU 推理约为 1 token/s;它适合排错和保底,不适合高并发或追求快速交互。

Windows 端如何安全访问

由于服务最终绑定在远程主机的 127.0.0.1:8080,Windows 端使用 SSH 本地转发:

ssh -L 8080:127.0.0.1:8080 lab304@192.168.8.105

保持这个 SSH 窗口不关闭,Windows 上的客户端就可以访问:

http://127.0.0.1:8080/v1

这种方式比直接把 llama-server 绑定到 0.0.0.0 更安全。若以后确实要开放局域网访问,必须同时配置 --host 0.0.0.0、API key 和防火墙规则,并从另一台设备实测鉴权;不能只改一个 host 参数。

从本机使用扩展到局域网共享

后续测试发现,远端模型服务本身没有掉,真正出问题的是旧 SSH 隧道会话和远端地址变化。最初尝试连接 192.168.8.105 超时,自动发现后确认模型主机当前为 192.168.8.24。本机 WLAN 地址是 192.168.8.104。

现在的链路变成:

其他局域网电脑
        │  http://192.168.8.104:18080/v1
        ▼
本机 qwen_ssh_tunnel.py
        │  SSH + API Key
        ▼
192.168.8.24:22
        │  转发到远端 127.0.0.1:8080
        ▼
llama-server + Qwen3.8-27B

隧道监听 0.0.0.0:18080,但转发目标仍是远端 127.0.0.1:8080;代码只接受回环地址和 192.168.8.0/24 客户端,不应该把它理解成公网服务。验证结果是:

  • 不带 API Key 请求 /v1/models 返回 401;
  • 带正确 API Key 的本机请求返回 200;
  • 另一台局域网设备请求也返回 200;
  • 实际模型对话请求返回 200 和 OK。

所以“Connection error”不能直接推断为模型掉线。排查顺序应是:检查 18080 监听 → 带 key 请求 /v1/models → 检查 SSH 隧道日志 → 再重载 Continue/VS Code。跨不同网络时使用 Tailscale 或 WireGuard,暂时不要把 18080 直接映射到公网。

当时尝试创建更严格的 Windows 防火墙规则时被系统拒绝(需要管理员权限),因此不能把“防火墙已限制”写成已完成事实;当前可确认的保护是 API Key 和隧道代码的来源地址过滤。

隧道脚本还加入了局域网主机发现:先尝试上次的 SSH 地址,再在 192.168.8.x 网段探测 SSH 服务和远端 8080,这样模型主机换地址后不必手工改每个客户端配置。

OpenCode 配置思路

先设置 API key(如果服务启用了 key):

$env:QWEN_REMOTE_API_KEY = (ssh lab304@192.168.8.105 "cat ~/.config/llama-server/api-keys").Trim()

通过 SSH 隧道访问时,OpenCode 的 provider 可以写成:

{
  "$schema": "https://opencode.ai/config.json",
  "model": "qwen-remote/qwen38",
  "providers": {
    "qwen-remote": {
      "name": "Qwen3.8 Remote",
      "package": "@opencode/ai/providers/openai-compatible",
      "settings": {
        "baseURL": "http://127.0.0.1:8080/v1",
        "apiKey": "{env:QWEN_REMOTE_API_KEY}"
      },
      "models": {
        "qwen38": {
          "name": "Qwen3.8 27B EfficientThink Q2",
          "modelID": "/home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf",
          "capabilities": {
            "tools": true,
            "input": ["text"],
            "output": ["text"]
          },
          "limit": {
            "context": 4096,
            "output": 1024
          }
        }
      }
    }
  }
}

baseURL 指向 /v1 而不是根路径;modelID 必须与 /v1/models 返回的模型 ID 一致。若绕过 SSH 隧道直接写 192.168.8.105:8080,前提是服务已经绑定局域网地址并完成鉴权验证,本次最终状态并不是这种配置。

Continue 配置时看懂字段,而不是照抄客户端名称

Continue 使用 YAML,但底层仍然是在调用同一个 OpenAI 兼容接口。字段可以按下面的关系理解:

models:
  - name: Qwen3.8 27B EfficientThink Q2
    provider: openai
    model: /home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf
    apiBase: http://127.0.0.1:8080/v1
    apiKey: ${QWEN_REMOTE_API_KEY}
    contextLength: 4096
    capabilities:
      - tool_use
  • provider: openai 指的是协议格式,不是必须调用 OpenAI 云服务;
  • apiBase 对应 OpenCode 的 baseURL;
  • model 仍要写服务实际返回的模型 ID;
  • capabilities 只是告诉 Continue 可以尝试哪些能力,最终是否成功仍要用真实请求验证;
  • API key 应放环境变量或密钥管理中,不要写进公开仓库。

Continue、OpenCode 和手写 curl 请求可以共用同一条 SSH 隧道;排错时先用 curl 验证服务,再接客户端,能避免把客户端配置错误误判成模型错误。

工具调用验证:不能只测普通对话

Agent 客户端不仅要模型会回答,还要能正确返回结构化工具调用。测试时给 API 传入 tools 和 tool_choice: "required",本次返回了标准的 tool_calls:

{
  "name": "get_weather",
  "arguments": {"city": "上海"}
}

这证明当前服务在该测试请求下能输出 OpenAI 兼容的工具调用结构。它不代表所有复杂 schema、长上下文或多轮工具链都已经通过,后续仍应按实际 OpenCode 工作流测试。

Continue 的局域网配置与一键安装包

Continue 当前使用的远程模型配置要点是:

name: Qwen3.8 LAN Agent
version: 1.0.0
schema: v1

models:
  - name: Qwen3.8 27B LAN - Agent + Code
    provider: openai
    model: /home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf
    apiBase: http://192.168.8.104:18080/v1
    apiKey: ${{ secrets.QWEN_REMOTE_API_KEY }}
    contextLength: 32768
    capabilities:
      - tool_use

这里的 provider: openai 仍然只是协议适配器,不表示请求一定发往 OpenAI;真正的地址是局域网隧道。当前配置把上下文设为 32768、单次输出上限设为 4096,API key 放在 Continue 的密钥环境中,不写入公开配置。

为了让另一台 Windows 电脑快速接入,已经在本机桌面生成两个安装包(它们没有上传到网站仓库):

  • Qwen3.8-LAN-Continue-OneClick.zip:安装时隐藏输入一次 API Key;
  • Qwen3.8-LAN-Continue-OneClick-Private.zip:包含真实 key,只能交给可信电脑,不能上传网盘或代码仓库。

解压后双击 Install-Qwen3.8-Continue.cmd,安装器会备份已有的 .continue 配置、写入模型配置、复制网页 MCP、检测/安装 Python 和 Continue 扩展,并测试 /v1/models 是否返回 HTTP 200。模型主机地址变化时可执行:

.\Install-Qwen3.8-Continue.ps1 -ServerHost 新IP地址

其他电脑只共享模型 API;文件、终端、编辑器和网页工具仍在各自电脑本地运行,不会因为共用模型就自动共享本机文件。

受限网页检索:一次搜索、一次读取、停止

为避免模型反复调用网页工具,Continue 的内置 search_web 和 fetch_url_content 已禁用,改用本地 MCP 服务 qwen_web_mcp.py,只暴露两个工具:

  • web_search_once:对一个精确查询执行一次搜索,并返回候选 URL;
  • read_url_once:读取指定 URL 一次,直接读取失败时只按规则尝试一次代理。

工具链的硬性边界是:搜索得到 URL 后只读取该 URL 一次;相同 URL 命中缓存;失败后返回停止信息,不更换搜索引擎、不重复重试。洛谷题目会先解析为唯一题面 URL,避免模型在不同搜索结果之间循环。

当前超时边界是直接读取 5 秒、代理读取 4 秒。实测洛谷 P10427 的搜索和题面读取约 1.2 秒完成,返回了题目描述、输入格式和输出格式;同一 URL 再次调用会命中缓存而停止。第一次在 Continue 中调用自定义工具时,需要在授权框按 Ctrl+Enter 执行。

工具编辑链也要有明确的提交点

这次还验证了 Continue 的本地工具链,正确顺序不是让模型直接“说已经改好”,而是:

发现文件
  → 读取目标
  → 新建或修改
  → 查看 diff
  → Apply / Accept
  → 保存文件
  → 终端读取或编译验证
  → 清理冒烟测试文件

edit_existing_file 第一次没有按工具要求重读时会失败,改用 single_find_and_replace 后成功;测试文件实际输出 SMOKE_V2,退出码为 0,随后确认测试目录已删除。这个过程说明“界面出现 diff”不等于“磁盘已经写入”,必须把 Apply/Accept 和落盘后的终端验证作为两个独立步骤。

GPU 路线:先失败,再定位并修复

这台远程主机有 RTX 5080 16 GB。最初使用 CUDA 13.2 + CUDA/MMQ 构建时,GPU 进程能够加载约 13 GB 的模型,显存占用也看起来合理,但生成阶段出现了不稳定行为:

The model produced output that does not match the expected peg-native format

日志中还出现了无法解析的 PEG-native 内容,最终聊天请求返回 HTTP 500。问题并不是“显存不够”,而是 RTX 5080(Blackwell)+ CUDA 13.2 + 当前 IQ3_S 量化/llama.cpp 组合的运行时兼容性问题。重新编译同一条 CUDA 13.2 路线仍可复现,所以不能把“模型成功装进显存”当成 GPU 部署成功。

这条失败路线的学习价值在于:

  1. 加载成功 ≠ 推理协议兼容:模板、reasoning 输出格式和客户端解析必须同时匹配。
  2. 显存足够 ≠ 运行稳定:还要检查生成内容、HTTP 状态码和日志。
  3. 先保留可用基线:停止异常 GPU 进程,恢复 CPU 服务,保证 API 和 OpenCode 仍能使用。

这次没有删除原来的 CUDA 13.2 环境,而是在用户目录安装 CUDA 12.8.2,重新编译出:

/home/lab304/llama.cpp-efficientthink/build-cuda128/bin/llama-server

最终 GPU 服务使用:

llama-server \
  --model /home/lab304/models/efficientthink-q2/Qwen3.8-27B-EfficientThink-SimPO-Q2-LynnStyle.gguf \
  --host 127.0.0.1 \
  --port 8080 \
  --gpu-layers all \
  --parallel 1 \
  --ctx-size 4096 \
  --flash-attn on \
  --jinja \
  --reasoning off \
  --reasoning-format none

这条 CUDA 12.8 路线实测:/health 正常,聊天接口 HTTP 200,返回“CUDA 修复测试成功”,nvidia-smi 显示服务实际占用约 12.4 GiB 显存。也就是说,当前推荐方案已经从 CPU 保底升级为 CUDA 12.8 + GPU 全层加载;CPU 命令仍应保留,作为升级或回归时的对照基线。

这里的错误路线要明确否定:

  • 不能因为模型加载进显存,就断言 GPU 部署成功;必须继续测生成、HTTP 状态码和日志。
  • 不能只反复重编 CUDA 13.2;如果错误与运行时/架构组合有关,应改变一个受控变量(这里是 CUDA 12.8.2)再回归。
  • 不能直接删除系统 CUDA 13.2;用户目录并行安装更容易回滚,也不破坏其他项目。

最终验收表

层级 本次结论
模型文件 GGUF 文件可被 llama-server 加载
服务健康 /health 返回 status: ok
模型接口 /v1/models 可列出模型
普通生成 CUDA 12.8 GPU 服务 HTTP 200,能返回测试文本
工具调用 标准 tool_calls 测试通过
CUDA 13.2 GPU 生成 未通过,出现 PEG-native 解析错误和 HTTP 500
CUDA 12.8 GPU 生成 通过,显存约 12.4 GiB,返回“CUDA 修复测试成功”
当前推荐 CUDA 12.8 + gpu-layers all + SSH 隧道 + OpenCode/Continue

一套可复用的排错顺序

  1. 先看进程和端口:确认启动的是目标 llama-server,端口没有被旧进程占用。
  2. 再看 /health:不健康时不要继续调 OpenCode。
  3. 再看 /v1/models:核对真实的 model ID,避免配置文件写错。
  4. 发最小 chat 请求:只用一句话和很小的 max_tokens,区分服务问题与长上下文问题。
  5. 单独测工具调用:确认结构化输出能被客户端解析。
  6. 最后才换 GPU、CUDA、量化、上下文长度:一次只改一个变量,并保留能工作的基线。

参考

这次部署的结论不是“CPU 比 GPU 好”,而是:先用 CPU 建立可用基线,再定位 CUDA 13.2 的 GPU 兼容性问题,最后用用户目录中的 CUDA 12.8.2 完成 GPU 验收;两条路线都保留,便于后续回归。