使用Linux系统将大肥鱼(DeepSeek Harness)放入QQ群

· Special

折腾了一晚上,把 QQ 接进了 DeepSeek Harness

我想让 DSH 顺手替我盯几个 QQ 群。现成的方案里,qq-bridge 看起来最省事:把 QQ 消息转成 agent 的输入,回复再发回 QQ。装完之后确实能用了,中间踩的坑够写一篇。

先给结论。你需要三样东西:一个 QQ 网关(SnowLuma)、一个 agent 运行时(DSH)、还有中间那根管子(qq-bridge)。三样都得装,少一样都不通。SnowLuma 在 Linux 上只提供 Docker 镜像,所以宿主机不用装 QQ,也不用装图形环境,这点比我预想的省事。

我用的组合:

部件版本
系统Debian 12
SnowLumamotricseven7/snowluma:latest,里面带的 QQ 是 3.2.32
DSH@deepseek-ai/[email protected]
qq-bridgev0.2.1-r1
Node22.19 以上

版本不是随便写的。桥接只对 DSH 这一代协议实测过,DSH 升个小版本就可能连不上,这一点后面会说。

它在中间干什么

QQ(跑在容器里)
   ↓ 原生 hook 注入
SnowLuma  →  OneBot v11,HTTP 3000 / WS 3001
   ↓
qq-bridge  →  本地控制台 3100
   ↓ Cookie 鉴权 + 事件流
DSH  →  127.0.0.1:3080,需要 DeepSeek 的 API Key

有四件事最好一开始就搞清楚,不然排查起来全是玄学。

桥接本身不含 QQ 网关,也不含模型,它只管转发。一端连 SnowLuma 的 WebSocket 收消息,另一端连 DSH 的 HTTP 接口投喂给 agent,agent 说完话它再用 HTTP 把内容发回 QQ。

SnowLuma 的 OneBot 有两个 token,HTTP 一个、WebSocket 一个,而且这俩不一样。桥接的配置文件里只有一个 accessToken 字段,装不下两个,所以它启动时会去读 SnowLuma 的配置文件自己找。这就是为什么后面那个 homeDir 必须配对,配错了就只能干看着它一直重连。

桥接有单实例锁,同时只能跑一个。我第一次不知道,两个终端各起了一个,第二个直接被拒。

还有一点:所有这些端口默认只绑 127.0.0.1,远程访问一律走 SSH 隧道。控制台能改模式、能发消息、能翻聊天记录,别往公网扔。

动手前认一下风险

要花钱。 所有对话都走 DeepSeek 的 API,群里聊天是一轮一轮地计费。桥接自带一个花费看板,能看到每个群每个好友烧了多少,但预算还是得自己盯。

有封号风险。 SnowLuma 是靠 ptrace 往 QQ 进程里注入 hook 干活的,这事违反《QQ 用户协议》。我全程用小号,主号不敢碰。

数据会出机。 配置里 slang.autoResearch 默认是开着的,它会把群里提取的黑话词条发给搜索后端。介意的话改成 false。

先把地基铺好

sudo apt update && sudo apt install -y ca-certificates curl gnupg git jq

Docker 走官方源:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian bookworm stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
docker compose version

国内拉镜像慢,可以给 daemon 加镜像源。这条只管 Docker Hub 的镜像,ghcr.io 那些不吃这一套:

{
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://docker.1ms.run"
  ],
  "max-concurrent-downloads": 10
}

写进 /etc/docker/daemon.json,然后先验一下 JSON,写错一个逗号 docker 就起不来,整台机器上的容器全躺:

python3 -m json.tool /etc/docker/daemon.json > /dev/null
sudo systemctl daemon-reload && sudo systemctl restart docker
docker info | grep -A5 "Registry Mirrors"

Node 要注意版本。Debian 12 仓库里给的是 18,DSH 在 18 上跑不起来,症状还挺迷惑人的:dsh --version 什么都不输出,退出码是 0,看起来像装成功了。

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v      # 要 22.19 以上

npm 换成镜像会快很多,我常用的版本在 npmmirror 上都有:

npm config set registry https://registry.npmmirror.com
npm config set fund false
npm config set audit false

装 DSH 之前先把编译工具链塞进去。DSH 依赖 node-pty 这类原生模块,没有工具链它会转身去 GitHub 拉预编译包,国内经常卡到超时:

sudo apt install -y build-essential python3

装 DSH

版本要钉死。桥接只对 0.2.1-rc.2 逐项测过,npm 上 latest 现在正好是这个版本,但别指望它一直不变。

sudo npm install -g @deepseek-ai/[email protected]
dsh --version
dsh web

它起在 127.0.0.1:3080,第一次跑会生成 ~/.dsh。本地开个隧道进设置页,把 DeepSeek 的 API Key 填上(platform.deepseek.com 申请,要先充值):

ssh -L 3080:127.0.0.1:3080 你的用户@服务器

起 SnowLuma

我给它的配置单独开了个目录,/opt/snowluma,重点是 ./data 用 bind mount 而且不能改成命名卷:

services:
  snowluma:
    image: docker.m.daocloud.io/motricseven7/snowluma:latest
    container_name: snowluma
    restart: unless-stopped
    shm_size: 1gb                      # QQ 是 Chromium,这个给小了会崩
    ulimits:
      nofile: { soft: 65536, hard: 1048576 }
    cap_add:
      - SYS_PTRACE                     # 注入 hook 必需,不能省
    security_opt:
      - seccomp=unconfined             # 同上
    environment:
      TZ: Asia/Shanghai
      SNOWLUMA_UID: "1000"             # 改成 id -u 的结果
      SNOWLUMA_GID: "1000"             # 改成 id -g 的结果
      SNOWLUMA_ONEBOT_HOST: 0.0.0.0    # 必须是 0.0.0.0,我在这上面栽过
      SNOWLUMA_WEBUI_HOST: 0.0.0.0
      SNOWLUMA_HOOK_AUTOLOAD: "1"
    ports:
      - "127.0.0.1:6081:6081"          # noVNC,扫码用
      - "127.0.0.1:5099:5099"          # WebUI
      - "127.0.0.1:3000:3000"          # OneBot HTTP
      - "127.0.0.1:3001:3001"          # OneBot WebSocket
    volumes:
      - ./data:/app/data               # 配置和 onebot_<QQ号>.json 在这
      - ./qq-config:/app/.config
      - ./qq-data:/app/.local/share

为什么非得 bind mount:桥接要直接读宿主机上的 data/config/onebot_<QQ号>.json,那里面存着 token。用命名卷的话你得先 docker cp 出来,换账号又得重来一遍,很烦。

cd /opt/snowluma
mkdir -p data qq-config qq-data
sudo chown -R 1000:1000 data qq-config qq-data   # 换成你的 id -u:id -g,属主不对会写不进配置
docker compose up -d
docker logs -f snowluma

首次启动会打两个密码,都只打一次,记下来:一个是 noVNC 的远程桌面密码,一个是 WebUI 的临时密码。

docker logs snowluma 2>&1 | grep -E "远程桌面密码|remote desktop password" | tail -1
docker logs snowluma 2>&1 | grep -E "临时密码|initial credentials" | tail -1

然后开隧道,浏览器进 noVNC 扫码:

ssh -L 6081:127.0.0.1:6081 -L 5099:127.0.0.1:5099 你的用户@服务器

扫完码,hook 会自己从「被动观察」切到工作模式。再去 http://127.0.0.1:5099/ 用 admin 加临时密码登 WebUI,确认两个适配器:WebSocket 服务端在 3001,HTTP API 在 3000。token 不用抄,它会自己生成写进配置文件。

验一下 OneBot 通没通:

cd /opt/snowluma
jq -c '{http:.networks.httpServers[0]|{host,port}, ws:.networks.wsServers[0]|{host,port}}' data/config/onebot_*.json

TOKEN=$(jq -r '.networks.httpServers[0].accessToken' data/config/onebot_*.json)
curl -s -X POST http://127.0.0.1:3000/get_login_info \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'

返回里带 user_id 和 nickname,说明 QQ 已经在线上。这一步过了再往下,会省很多事。

装桥接

sudo mkdir -p /opt/qq-bridge && sudo chown $USER:$USER /opt/qq-bridge
git clone https://github.com/Derpyu520/qq-bridge.git /opt/qq-bridge
cd /opt/qq-bridge
npm ci --omit=dev --prefer-offline
cp config.example.json config.json

npm ci 比 npm install 快,它按 lock 文件装,不做依赖解析。有缓存的话一百多个包不到一秒。

千万不要为了快加 --ignore-scripts。这个项目的 postinstall 会修补 @snowluma/sdk 的 ESM 打包 bug,跳过它 WebSocket 那头会直接炸。真跳了手动补一次:node scripts/patch-snowluma-sdk.mjs。

配置只需要改四个地方,用 jq 一把改完:

cd /opt/qq-bridge
MY_QQ=你的QQ号
MY_GROUP=你要放行的群号

jq --argjson me "$MY_QQ" --argjson grp "$MY_GROUP" '
  .snowluma.homeDir = "/opt/snowluma/data"
  | .snowluma.followDiscoveredEndpoint = false
  | .ownerQQ = $me
  | .allow.private = [$me]
  | .allow.groups  = [$grp]' config.json > t && mv t config.json
jq -e . config.json > /dev/null && echo "JSON OK"

这四个字段各管一件事,漏一个就会出问题。

homeDir 指向那个 bind mount 目录,桥接进 config/ 里翻 onebot_<QQ>.json 拿两个 token。accessToken 反而要留空,留空才会走自动发现,填错了是 401。

followDiscoveredEndpoint 设成 false。原因有点绕:容器里的 OneBot 绑的是 0.0.0.0,桥接的端点自愈会把这个地址老老实实写回 config.json,配置就一直漂了。关掉之后它只自愈 token,不动 URL。

白名单留空加 allowAllWhenEmpty: false 的意思是「谁都不回」,这是故意的。把 agent 塞进 QQ 等于把账号的一部分控制权交出去,所以默认什么都不放行。我一开始没填,盯着日志看了十分钟才发现它压根没打算理人。

dsh.authToken 空着。桥接会用 ~/.dsh 里的签名密钥离线铸造 Cookie,填了反而 401。

最容易漏的一步

桥接能启动不代表群里能回话。DSH 那边还缺两个 agent preset 和三个 MCP server:

cd /opt/qq-bridge
node scripts/setup-dsh.mjs

脚本会在 ~/.dsh/profiles/web/ 下装 qq-chat 和 qq-chat-v2,在 cordis.patch.yml 里挂 snowluma、snowluma-host、web-search-safe 三个 MCP。幂等,重复跑没关系,覆盖前会备份。

装完必须重启 DSH。preset 和 MCP 只在启动时加载。不重启不会崩,但桥接会直接拒绝给群建会话,日志里是 ⛔ 群聊/仿真会话缺少 preset,QQ 那边就是没人回。这是有意的设计,它绝不肯降级到带 bash 和文件工具的那个默认 preset。

要是脚本最后报 dsh plugin --profile web install 返回退出码 127,别慌,127 是命令找不到,多半是 DSH 装在别的用户下、或者你用 nvm 装的,当前这个 shell 的 PATH 里没有 dsh。补跑一次 dsh plugin --profile web install 就行。

交给 systemd

手动 npm start 有两个问题。SSH 一断它就死,机器重启更是一个不剩。这是明的。还有一个隐蔽得多:进程活着,但连接死了。

DSH 的服务:

[Unit]
Description=DeepSeek Harness (dsh web)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/root
Environment=HOME=/root
ExecStart=/usr/bin/dsh web --host 127.0.0.1 --port 3080
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

桥接的服务:

[Unit]
Description=qq-bridge (QQ <-> DSH)
After=network-online.target docker.service dsh-web.service
Wants=dsh-web.service

[Service]
Type=simple
User=root
WorkingDirectory=/opt/qq-bridge
Environment=HOME=/root
ExecStart=/usr/bin/node /opt/qq-bridge/src/bridge.js
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target

Restart=always 解决不了「活着但断了」这种状态,因为进程根本没有退出。所以我又加了个看门狗,每两分钟戳一下控制台的接口:

#!/usr/bin/env bash
set -u
TOK=$(cat /opt/qq-bridge/state/console-token 2>/dev/null) || exit 0
J=$(curl -s -m 8 "http://127.0.0.1:3100/api/status?token=$TOK") || J=""

BAD=0
if [ -z "$J" ]; then
  BAD=1
else
  CONN=$(printf '%s' "$J" | jq -r '.snowluma.connected')
  AGO=$(printf '%s' "$J" | jq -r '.snowluma.lastPacketAgoMs // 0')
  if [ "$CONN" != "true" ]; then
    BAD=1
  elif [ "${AGO:-0}" -gt 180000 ]; then
    BAD=1
  fi
fi

if [ "$BAD" = "1" ]; then
  N=$(( $(cat /run/qq-bridge-watchdog.bad 2>/dev/null || echo 0) + 1 ))
  echo "$N" > /run/qq-bridge-watchdog.bad
  if [ "$N" -ge 2 ]; then
    logger -t qq-bridge-watchdog "连续 ${N} 次不健康(conn=$CONN ago=$AGO),重启 qq-bridge"
    rm -f /run/qq-bridge-watchdog.bad
    systemctl restart qq-bridge
  fi
else
  rm -f /run/qq-bridge-watchdog.bad
fi

存成 /usr/local/bin/qq-bridge-watchdog.sh,chmod +x。service 是 Type=oneshot,timer 每两分钟触发一次,OnBootSec=3min 给它留点启动余量。

这里的两个细节是被真实故障逼出来的。一是要连续两次不健康才重启,约等于给它四分钟宽限,不然重启完刚连上还没收到心跳就被判死刑。二是 connected 为 null 也要算不健康。我第一版只判断了 false,结果某次重启后它的状态是 null(从没连上过,压根没进过 false),看门狗一次都没触发,我对着一个「活着但聋了」的进程发呆。

chmod +x /usr/local/bin/qq-bridge-watchdog.sh
systemctl daemon-reload
systemctl enable --now dsh-web.service qq-bridge.service qq-bridge-watchdog.timer
systemctl is-active dsh-web qq-bridge qq-bridge-watchdog.timer

装完之后这样验

按顺序走,哪一步不对就停在那。

docker ps                      # 容器得是 Up,端口映射对
docker exec snowluma ss -lnt | grep 300     # 必须是 0.0.0.0:3000 和 0.0.0.0:3001
cd /opt/qq-bridge && npm run self-test      # 会建一个测试会话,打印 agent 的回复
journalctl -u qq-bridge -n 20               # 看到「SnowLuma 已连接」「DSH 已就绪」
curl -sI http://127.0.0.1:3080              # 200 或 401 都算活着

最后一步是真人测试:用白名单里的号,圈机器人并且带上文字发一条。不带文字它不会理你,原因见下面第七个坑。

我踩的坑

1. SNOWLUMA_ONEBOT_HOST 不能写成 127.0.0.1

这是我白折腾半小时的元凶。我当时看到网上有教程说要让发现到的地址是 127.0.0.1,就照着设了,然后宿主 ss 明明显示 3000/3001 有 docker-proxy 在监听,桥接却一直报 Received network error or non-101 status code。

想通之后很简单:这个变量控制的是 OneBot 在容器里绑哪个地址。绑 127.0.0.1 就只有容器自己的回环能连,而 Docker 的端口转发是打到容器网卡上的 172.x,永远够不着。包进去了,没人接。

容器内绑 0.0.0.0,对外暴露的范围交给 ports: "127.0.0.1:3000:3000" 控制,安全性一点没少。验证方法就一条:

docker exec snowluma ss -lnt | grep 300

看到 0.0.0.0:3000 才对。

2. 端点自愈会把 ws://0.0.0.0:3001 写回配置

坑 1 修好后,SnowLuma 配置里记的地址变成 0.0.0.0 了,桥接的「端点自愈」照着写回 config.json。连接通常还能用,但配置会一直漂。关掉跟随就完了:

{ "snowluma": { "followDiscoveredEndpoint": false } }

日志里会写得很清楚:

(已按 snowluma.followDiscoveredEndpoint=false 忽略发现到的端点 ws://0.0.0.0:3001,继续用配置值 ws://127.0.0.1:3001)

3. 第一次连接必然失败一次,这是我见过最容易误判的现象

因为 HTTP token 和 WS token 是两个值,而配置里只有一个 accessToken 字段。所以启动时先断一次,然后它读配置文件自愈出 WS token 再连上:

[bridge] SnowLuma 连接断开(code=1006),重连中…
[bridge]   自愈完成:账号 123456789
[bridge] SnowLuma 已连接:ws://127.0.0.1:3001

看到 1006 不用管。看到 token 自愈失败 才是真有毛病,一般是 homeDir 指错了,或者 bind mount 的属主不对。

4. 白名单空着,它谁都不回

allowAllWhenEmpty: false 是故意的。我理解这个设计,但第一次遇到还是会怀疑自己装坏了。

5. 忘了重启 DSH,群里一片沉默

坑在第四步就说过,这里再重复一遍,因为我就漏了:setup-dsh.mjs 跑完必须重启 DSH。

6. 只圈不说话,等于发了一条空消息

@机器人 后面只跟一个空格,OneBot 那边解析出来是 [{type:at},{type:text:text:" "}],SnowLuma 记成「空消息」,模型收到的也是空的,当然不回。测试的时候记得带字。

7. 进程活着,连接死了

这个最坑,我一度以为服务没启动。SnowLuma 的 hook 静默几分钟之后会主动关掉 QQ 会话,桥接的 WebSocket 收到 code=1000 断开,重连失败,然后再也不重试了。可它的进程还活着,控制台还打得开,systemctl status 一片绿。

判断只需要一条命令:

curl -s "http://127.0.0.1:3100/api/status?token=$(cat /opt/qq-bridge/state/console-token)" \
  | jq -c '{connected:.snowluma.connected, ago:.snowluma.lastPacketAgoMs}'

connected 是 false,或者 ago 到了几万毫秒,就是僵了。重启 bridge 立刻恢复。想不用半夜爬起来敲命令,就把上面那个看门狗挂上。

8. 重启机器之后,要等 QQ 自己登录

这条我拿截图确认过。服务器重启后,容器里的 QQ 得重新登录,而 OneBot 实例是登录成功之后才创建的。这段时间 SnowLuma 明明在跑,5099 也打得开,但 3000/3001 一个人都没有。

实测下来的时间是这样:

22:28:46  [App] SnowLuma starting          SnowLuma 起来了
22:28:48  [Hook] awaiting login: PID=80    QQ 停在登录界面
22:32:22  [Hook] login detected: UIN=...   自己登上了,用了三分半
22:32:23  [OneBot] session started
22:32:23  [OneBot.HTTP] listening 0.0.0.0:3000/

所以判断状态别只看容器,看容器里有没有在监听:

docker exec snowluma ss -lnt | grep 300

没输出就是还没登录完,急也没用。真要人工介入就用 noVNC 扫码,地址还是 http://127.0.0.1:6081/vnc.html。

内存这块别省

我在一台 2 核 1.8G 的机器上跑,QQ 已经登录、一个群在聊的时候:

进程内存
SnowLuma 容器(QQ + Xvfb + noVNC)约 820M(没登录时 550M 左右)
dsh web约 290M
qq-bridge约 100M,空转四五十秒稳定在这个值
系统本身约 200M

加起来 1.4G,只剩四百兆余量。能跑,但靠 swap 撑着,真出问题的表现就是 QQ 突然没了、容器自己重启。

我的建议是 4G 起步。2G 想先凑合,至少把 swap 加上:

sudo fallocate -l 2G /swapfile2 && sudo chmod 600 /swapfile2
sudo mkswap /swapfile2 && sudo swapon /swapfile2
echo '/swapfile2 none swap sw 0 0' | sudo tee -a /etc/fstab

另外 DSH 是内存大头,而且会随会话历史长大,社区里有人报过跑久了 RSS 涨到十几 G。长期跑的话给它加个定时重启,桥接在 DSH 掉线期间会把 QQ 消息排队(每个会话最多 50 条),不会直接丢。

平时怎么用

端口一览,全部只监听 127.0.0.1:

端口干什么怎么进
3100桥接控制台http://127.0.0.1:3100/?token=<consoleToken>
3000OneBot HTTPPOST /get_status,头里带 OneBot 的 token
3080DSH 界面直接开
5099SnowLuma WebUIadmin 加临时密码
6081noVNC/vnc.html 加远程桌面密码

控制台只认 query 参数,Authorization 头会回「未授权」,这点挺反直觉。token 在 state/console-token:

echo "http://127.0.0.1:3100/?token=$(cat /opt/qq-bridge/state/console-token)"

隧道懒人配置,~/.ssh/config:

Host qq
    HostName <你的服务器IP>
    User root
    LocalForward 3100 127.0.0.1:3100
    LocalForward 3080 127.0.0.1:3080
    LocalForward 5099 127.0.0.1:5099
    LocalForward 6081 127.0.0.1:6081

之后 ssh qq 就全带上了,只要隧道不要 shell 就用 ssh -f -N qq。已经开着的会话临时加端口也行,回车后敲 ~C,进去再敲 -L 3100:127.0.0.1:3100。

常用命令:

systemctl status qq-bridge dsh-web --no-pager
journalctl -u qq-bridge -f
systemctl restart qq-bridge                    # 改完 config.json 记得重启
journalctl -u qq-bridge-watchdog -n 20         # 看门狗都干了什么
docker logs --tail 80 snowluma | grep '\[Hook\]'

顺手装个 ffmpeg。不装的话群里超过 1M 的图片压不下来,只能按原图发,语音那套也不可用:

sudo apt install -y ffmpeg

运行模式有四个,我平时用 chat,最稳。closed-agent 只认 owner 私聊,但给的是完整工具面,等于让模型在你机器上有手脚,别乱开。reserved 和 reserved2 是两代仿真,后一代不自动转发文本,靠 AI 自己调工具看消息、决定什么时候说话。模式存在 state/mode.json,控制台顶上能切。

有个细节要注意:重启之后 DSH 那边如果没保留住设置,模式会掉回 chat,记得回头确认一次。

还有个更省事的方案

如果你手边有台常年开着的 Windows,其实不必在 Linux 上折腾。让 Windows 装 QQ 和 SnowLuma(那是项目唯一被逐项实测过的环境),Linux 只跑 DSH 和桥接。

配置上要做一件事:把它 config/onebot_<QQ>.json 里 wsServers[].accessToken 改成和 httpServers[].accessToken 一样的值,然后把这个值填进 snowluma.accessToken。因为跨机器读不到对方的配置文件,自愈这条路断了,只能手动把两个 token 对齐。

代价是多一台常开的机器,好处是绕开 ptrace、Xvfb、钩子退化这一整类问题。

顺带说一句合规

SnowLuma 是第三方项目,跟腾讯没有关系,用的是「源码可见但非商业」的许可。它靠原生组件往 QQ 进程里注入 hook,这违反《QQ 用户协议》,有封号风险,自己掂量,最好用测试号。

桥接和 DSH 都是独立项目,这篇只是记录一次部署过程,不代表任何一方的立场。

Linux · deepseek · 大肥鱼 · QQ


评论

行为验证™ 安全组件加载中...