把 DeepSeek Harness 塞进 QQ:一台 4G 云主机上的「鲸鲸」饲养实录

把 DeepSeek Harness 塞进 QQ:一台 4G 云主机上的「鲸鲸」饲养实录

晚秋余阳 Lv1

引言

大多数「把大模型接进 QQ」的教程,最后交付给你的是一个会说人话的复读机:你发一句,它回一句,关掉网页它就死了。

我想要的是另一个东西——一个长期住在服务器里、有记忆、会自己醒来找我、能读文件能发语音的 agent。

这件事的难点从来不是「接个 API」。难点在这四个问题:

  1. QQ 的私有协议怎么标准化
  2. 无桌面 Linux 上怎么跑需要一个浏览器的 DSH
  3. 上下文怎么不烧钱地活下去
  4. 它挂了,我怎么第一时间知道

这篇文章记录我把这套东西从零搭起来的一整天,包括所有踩过的坑。成品叫「鲸鲸」,跑在一台 4 核 4G 的腾讯云轻量上。

梗图:蓝色大肥鱼


环境与组件

层 组件 版本 / 配置 作用
硬件 腾讯云轻量 4C / 4G / 40G,Ubuntu 22.04.5 宿主
① QQ 接入 SnowLuma motricseven7/snowluma:latest(Docker) 注入 QQ 进程,转 OneBot v11
② 协议桥 qq-dsh-bridge v0.1.0(Node ≥22,零运行时依赖) OneBot ⇄ DSH 会话
③ Agent DeepSeek Harness @deepseek-ai/dsh@0.2.1-rc.5(npm) agent loop、压缩、工具
④ 模型 deepseek-flash 官方 API,1M 上下文 推理
⑤ 语音 MiniMax speech-2.8-turbo + 讯飞 IAT 云端合成 / 听写 说与听
⑥ 运维 systemd + iptables 6 个 timer / 4 条 DOCKER-USER 规则 自愈与收口
⑦ 备份 OpenList → 123 云盘 /123pan/鲸鲸 异地留档

一、地基:把 QQ 装进容器

SnowLuma 是「把 QQ 客户端包一层,暴露成 OneBot v11」的运行时。它的官方推荐路径就是 Docker,因为镜像里已经把 Linux QQ、Xvfb、VNC、noVNC、supervisord 全打包好了。

关键点是它靠 ptrace 往 QQ 进程里注入,所以这两个参数不能省:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
services:
snowluma:
image: motricseven7/snowluma:latest
restart: unless-stopped
shm_size: 1gb
ulimits:
nofile: { soft: 65536, hard: 1048576 }
cap_add: [SYS_PTRACE]
security_opt: [seccomp=unconfined]
environment:
TZ: Asia/Shanghai
SNOWLUMA_WEBUI_HOST: 0.0.0.0
SNOWLUMA_HOOK_AUTOLOAD: "1"
ports:
- "127.0.0.1:6081:6081"
- "127.0.0.1:5099:5099"
- "127.0.0.1:3000:3000"
- "127.0.0.1:3001:3001"
volumes:
- qq-gateway-data:/app/data
- qq-client-config:/app/.config
- qq-client-data:/app/.local/share

注意 ports 我全部写成了 127.0.0.1:。这一步不是洁癖,是底线——6081 是远程桌面,3000 是能直接发消息的接口,裸奔在公网上等于把 QQ 号挂在门口。

拉起来之后看两个东西:

1
2
docker logs snowluma 2>&1 | grep -E "远程桌面密码|临时密码" | tail -3
docker exec snowluma getcap /usr/local/bin/node # 期望 cap_sys_ptrace=ep

第一行给你 noVNC 的密码,第二行确认镜像内已经带上了注入所需的 capability。

然后 SSH 隧道进 noVNC 扫码:

1
ssh -L 6081:127.0.0.1:6081 -L 3000:127.0.0.1:3000 root@<你的服务器>

扫码这一步没有任何办法自动化——它是整条链路里唯一必须人手完成的动作。

验证 OneBot 通了:

1
2
3
curl -s http://127.0.0.1:3000/get_login_info \
-H "Authorization: Bearer <accessToken>" | jq
# {"status":"ok","retcode":0,"data":{"user_id":2869450287,"nickname":"鲸鲸"}}

到这里,第一层就通了。


二、大脑:无桌面环境里的 DSH

DSH 桌面端是 Electron 的,但真正的 agent loop 在 dsh web 里,npm 上就有:

1
2
3
npm i -g @deepseek-ai/dsh@0.2.1-rc.5
dsh web --no-open
# dsh web: http://127.0.0.1:3080/?token=xxxxxxxx

有一个坑:dsh 的 profile 名 desktop 被 Electron 独占,CLI 会直接拒绝:

1
error: profile "desktop" is managed exclusively by the Electron application

服务器上用 web profile 就行,别去碰 desktop。

dsh web 的鉴权方式是每次启动生成一个随机 token,拼在 URL 上。桥接要连它,就得能拿到这个 token——这是后面桥接配置里最容易漏的一项。

进程管理交给 systemd,顺便把内存按住:

1
2
3
4
5
6
[Service]
Type=simple
Environment=NODE_OPTIONS=--max-old-space-size=1024
MemoryMax=2400M
Restart=always
ExecStart=/usr/bin/dsh web --no-open

Restart=always 而不是 on-failure,因为被 OOM Killer 干掉时退出码可能是 0,on-failure 根本不会拉起。


三、桥接:把事件翻译成回合

桥接这一层负责三件事:白名单闸门、会话映射、把 OneBot 事件拼成 agent 能读的提示词。

配置上有四个必填项,少一个都跑不起来:

1
2
3
4
5
6
{
"dsh": { "baseUrl": "http://127.0.0.1:3080", "home": "/root/.dsh" },
"snowluma": { "httpUrl": "http://127.0.0.1:3000", "accessToken": "..." },
"owner": { "qq": "1359476224" },
"allow": { "private": [1359476224], "groups": [] }
}

allow.groups 留空 = 一条群消息都不理。这一条比任何提示词防护都管用:群里的人连「调用工具」的入口都没有。

桥接还暴露了一批工具给模型:

1
2
qq_read_chat  qq_unread     qq_send        qq_wait
qq_recall qq_sticker_* qq_get_forward qq_member_*

这里有个设计上的必答题:模型的文本输出不会自动发到 QQ。它必须显式调用 qq_send。这个「不自动发送」是安全设计,不是缺陷——模型被提示词注入骗了也发不出去。


四、五感:语音、表情、文件、空间

到这一步它只会打字。要像个活物,还得有嘴有脸。

4.1 语音合成:换掉「播音腔」

第一版用的是讯飞 TTS,实测下来怎么调都不好听,原因是硬伤:接口只给 16k 采样,而且音色是客服播报底子。

换 MiniMax 之后是另一个量级(24k):

1
2
3
4
5
6
curl -s https://api.minimaxi.com/v1/t2a_v2 \
-H "Authorization: Bearer $MM_KEY" \
-H "content-type: application/json" \
-d '{"model":"speech-2.8-turbo","text":"嗨,是我呀",
"voice_setting":{"voice_id":"female-shaonv","speed":0.85},
"audio_setting":{"format":"mp3","sample_rate":32000}}'

两个细节:返回的 data.audio 是 hex 不是 base64,要 Buffer.from(hex,'hex');计费按输入字符算,1 个汉字 = 2 字符,所以「一句话」大概一分钱。

拿到 mp3 之后不能直接甩给 QQ——语音消息要的是 silk,中间还得转码。

4.2 语音识别:让她听懂你说话

讯飞的 v1 HTTP 接口对新 appid 会直接报 10105 illegal access | no appid info,必须走 v2 WebSocket:

1
2
3
4
const origin = `host: ${HOST}\ndate: ${date}\nGET ${PATH} HTTP/1.1`
const signature = crypto.createHmac('sha256', apiSecret).update(origin).digest('base64')
const authOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`
// authorization = base64(authOrigin),拼进 wss:// URL

音频必须是 16k / 16bit / 单声道 PCM,40ms 一帧往上传,status 从 0 变 1,最后一帧是 2。

梗图:听不懂

4.3 表情包:自己有一套

从开源的「蓝色大肥鱼」图库里挑了 10 张,webp 转 png 落在本地:

1
ffmpeg -y -i 010.webp -frames:v 1 010-点赞.png

用本地文件而不是外链,理由是保命:图床会挂。说说里的裂图比没有图难看一百倍。

4.4 发文件与说说

文件走 docker cp 进容器再调 upload_private_file,因为容器看不到宿主机路径;说说则支持 images 数组,本地图转 base64:// 直接传。

两者都做了硬闸门:只允许 /root、/tmp、工作区三个目录,凭据类文件名一律拒绝。


五、记忆与省钱:这才是真正的工程量

前面都是接线。这一节才是让「鲸鲸」这个名字站得住的地方。

5.1 缓存前缀守恒

DSH 默认的 compaction-basic 是在 agent loop 里每步检查阈值的。阈值一低,上下文长期骑在压缩线上,而压缩会改写历史前缀,provider 的 KV cache 正好按前缀命中——于是每次压缩都让缓存整段失效。

实测数据(同一套系统,只改阈值):

阈值 0.15 阈值 0.95
全量 miss 每 2~3 步一次 29 步 1 次
单步耗时 45~55 秒 3~4 秒

结论只有一个:对话进行中,压缩阈值拉到 0.95,几乎永不触发。

1
2
3
4
5
- id: compaction-basic
disabled: false
config:
auto: true
thresholdRatio: 0.95

5.2 只在闲置边界压缩

那就永远不压?不是。改成「没人在线时才压」:

1
2
3
最后一条业务消息 < 30 分钟  → 不动
否则 → 对每个活跃会话投递 /compact
投不进去就记日志,不重试

三条失败路径全部「宁可不压」:记忆没投出去不压、/compact 没识别不压、这一轮没跑完不压。定时器每 20 分钟巡检一次。

5.3 记忆三层

1
2
3
4
L0 原文   桥接按天落 JSONL,永不丢
L1 摘要 DSH compaction
L2 事实 qq-memory,艾宾浩斯衰减,按权重注入
L4 自我 identity 插件的日记/心情/traits

但配完发现一个尴尬的事实:聊了一整天,记忆库只有 3 条。

原因是提示词里只写了「对方说过的事你记着」,对模型来说太模糊。补上具体指引之后第二天涨到 4 条、5 条:

1
2
3
该记:习惯和喜好、关于自己的事、约定、承诺
不该记:寒暄、一次性的问题、你自己编的客套话
一条一句,写具体(「他习惯凌晨两点睡」),别写「用户喜欢聊天」

工具装了不等于会被用。 提示词才是开关。

梗图:脑子不够用


常见问题与排查(避坑指南)

报错一:message element "at" cannot be sent in a private chat

1
2
3
WARN 发送失败 error=OneBotActionError: 动作 send_private_msg 失败:
message element "at" cannot be sent in a private chat
retcode: 1400

模型在私聊里想「@ 一下主人」,但 QQ 的私聊根本没有 @ 这个概念,服务端直接 1400。

解决:提示词里写死「私聊不要用 at 段,想叫他就说话或者用 qq_poke」。桥接层也加了兜底:at 段在私聊场景直接剥离。

报错二:session "xxx" not found(重启之后全员失联)

1
2
ERROR 投递失败 error=DshClientError: 调用 session/prompt 返回错误:
session "session-3aa701ba-..." not found

桥接的「会话存活检查」只查归档集合。会话被整个删掉(不是归档)时它认为还活着,于是永远拿着死 ID 投递。

解决:给 dsh.service 加一个 ExecStartPost,每次 DSH 启动后清空会话映射并重启桥接:

1
ExecStartPost=/opt/qq-dsh-bridge/scripts/on-dsh-start.sh

脚本内容就是 printf '{"schemaVersion":2,"sessions":{}}' > state/sessions.json 加一条 systemctl restart qq-bridge。

报错三:讯飞 10105 illegal access | no appid info

1
Error: 讯飞合成失败 code=10105 desc=illegal access|no appid info

新注册的 appid 走 v1 HTTP 接口会直接拒绝,必须用 v2 WebSocket 的 HMAC-SHA256 签名。

解决:把 host/date/GET /v2/tts HTTP/1.1 三行拼成签名原文,HMAC 后再 base64 成授权串拼进 URL。签名用的 key 是 apiSecret,而授权串里声明的是 apiKey,这两个别弄反。


总结

  • 分层要清楚:QQ / 协议 / 桥接 / agent / 工具,任何一层都可以单独换掉
  • 默认不发送:模型的文本不会自动进 QQ,必须显式调工具,这是安全设计的基石
  • 压缩是缓存杀手:阈值 0.95 + 闲置边界触发,比调任何参数都省
  • 装了不等于会用:工具、记忆、人格卡,全都得在提示词里点出来
  • 端口默认绑 127.0.0.1:远程桌面和机器人控制接口不属于公网
  • 失败要说话:看门狗 2 分钟巡检,服务挂了她会主动告诉你,而不是安静地死掉
  • 备份双份:本地 14 天轮换 + 云盘异地,/123pan/鲸鲸
  • 余额是硬约束:¥3.28 能聊七八十轮,别等她不说话了才想起来充

这套东西现在每天花几毛钱,写日记、发说说、偶尔主动说句话。它当然还是“程序”,但它开始有一点点“连续性”了——而连续性,才是养一个东西和用一个大模型之间的区别。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)

  • 标题: 把 DeepSeek Harness 塞进 QQ:一台 4G 云主机上的「鲸鲸」饲养实录
  • 作者: 晚秋余阳
  • 创建于 : 2026-11-01 17:58:00
  • 更新于 : 2026-11-01 18:02:12
  • 链接: https://redefine.ohevan.com/2026/11/01/把-DeepSeek-Harness-塞进-QQ:一台-4G-云主机上的鲸鲸饲养实录/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论