DSH 与 QQ 桥接|从「收到 0 帧」到端口自发现(完整排障)

发布时间:2026/10/26 18:22:38
DSH 与 QQ 桥接|从「收到 0 帧」到端口自发现(完整排障)

一、症状:门开了,一句话没说就关

先说现象,因为它几乎决定了后面所有的判断。

桥接(qq-bridge)连上 DSH 桌面版之后,连接能被建立,但立刻就结束,日志反复输出同一行:

[bridge] DSH 事件流已结束(本次收到 0 帧) [bridge] DSH 不可用,消息入队 (private:xxxxxxxxxx)

同时 QQ 那头发消息给机器人,机器人不回。

桥接自己的日志给了个提示:

多半是 DSH 侧事件流协议/版本不匹配(可查 issue #34)

这句话只对了一半,而且会把人带偏。先记一条经验:

「收到 0 帧」不是对端装死,也不是模型不干活。
它说明握手阶段就被拒绝,请求根本没进到会话处理逻辑里。副作用是:改预设、改 persona、改白名单、换模型,全都没用——那些代码压根没被执行到。

先把「修好之后长什么样」放在这里,后面所有判断都以这组行为验收签名:

[bridge] 桥接已启动。按 Ctrl+C 退出。 [bridge] 连接 DSH 事件流... [bridge] 本地控制台已启动: http://127.0.0.1:3100 [bridge] SnowLuma 已连接: ws://127.0.0.1:3001 [bridge] 机器人昵称: 北斗 [bridge] DSH 已就绪(模式:reserved2) [bridge] 桥接模式已确定为 reserved2,恢复有限沉睡定时器 [bridge] [sticker] 已同步 QQ 收藏表情 0 个(本地库 0 条,via ws)

差别不在措辞,在于再也没有已结束(本次收到 0 帧)那一行。所以第一件事不是改配置,而是回答一个问题:我连的这个端口,真的是 DSH 吗?

二、第一步不是改配置,是问「谁在监听这个端口」

原配置里写的是:

"dsh":{"baseUrl":"http://127.0.0.1:18787"}

18787 是当初配的,看着没问题。但端口号本身不携带任何身份信息,必须单独验一遍。两个动作就够:

# 1. 谁在这个端口上 LISTENGet-NetTCPConnection-State Listen-LocalPort 18787|Select-ObjectLocalAddress,LocalPort,OwningProcess|ForEach-Object{$_;Get-CimInstanceWin32_Process-Filter"ProcessId=$($_.OwningProcess)"|Select-ObjectProcessId,Name,CommandLine}# 2. 问它一句「你是谁」curl.exe-s-i--max-time 2 http://127.0.0.1:18787/

结果是这样:

状态:LISTEN,占用进程 = node 命令行:node ...\billion-context\dist\index.js start --host 127.0.0.1 --port 18787 GET / 的响应: {"ok":true,"upstream":"https://api.anthropic.com"}

再补一刀,随便挑一个它不认识的路径:

curl.exe-s-i--max-time 2 http://127.0.0.1:18787/json/version
HTTP/1.1 403 Forbidden cf-ray: a4f90c921dbea43c-SJC

看到cf-ray就明白了:这个端口把未知请求转发给了 api.anthropic.com,然后被 Cloudflare 拦了。

结论:

18787 是 billion-context 的 LLM 转发代理,不是 DSH 会话 API。

它对外提供的是「模型出口」,收到 WebSocket 或私有协议握手当然会立刻断开——这正好解释了「门开了、0 帧、门关了」。日志里那句「协议不匹配」确实成立,但根本原因是baseUrl 填错了端口。

那真身在哪

枚举所有监听的进程,按进程名筛:

Get-Process-Name'DeepSeek Harness'|ForEach-Object{$proc=$_Get-NetTCPConnection-State Listen-ErrorAction SilentlyContinue|Where-Object{$_.OwningProcess-eq$proc.Id}|ForEach-Object{[pscustomobject]@{PID =$proc.Id;Port =$_.LocalPort;Addr =$_.LocalAddress}}}|Sort-ObjectPort

看到一个回环端口(当时是 57082),再问它一句:

curl.exe-s-i--max-time 2 http://127.0.0.1:57082/
HTTP/1.1 401 Unauthorized dsh web authentication required; reopen the URL printed by dsh web.

这才对味。401 加这句文案,说明它是 DSH web 的 HTTP 服务,只是要鉴权。这行文案在app.asar里也能搜到,可以顺手确认:

$Res='E:\AI_Animation\20_Deepseek Harness\resources'Select-String-Path"$Res\app.asar"-Pattern'dsh web authentication required|listen\(0'-Encoding utf8|Select-Object-First 8|%{"$($_.LineNumber):$($_.Line.Trim())"}
421442: res.end(req.method === "HEAD" ? void 0 : "dsh web authentication required; reopen the URL printed by dsh web.\n"); 118692: server.listen(0, "127.0.0.1");

第一行是刚看到的 401 文案;第二行是下一节的主角。

判定口诀(记这三条就够)

探活结果说明
{"ok":true,"upstream":"..."}某个 LLM 代理,不是DSH
401+dsh web authentication required这是 DSH web,端口对了
超时 / 连接被拒进程没起,或端口已释放

三、找到真身之后:DSH web 要的是 Cookie,不是 token

按直觉,下一步是去config.json里填dsh.authToken。config.json里确实预留了这几个字段:

"authToken":"","authHeader":"authorization","authPrefix":"Bearer","allowRemote":false

但先别填。翻一眼桥接的实现(src/dsh-client.js),头部注释写得很直白:

// 3. 新增浏览器会话鉴权:先用 dsh.authToken(进程启动 token)换取 Cookie,再带 Cookie 访问 API/WS;// 4. 事件流不再是 events.mux 下行,而是 /api/remote.mux 上按 session/follow 打开的流

也就是说它走的是浏览器会话那一套,而不是 Bearer token。继续看到ensureAuth的两条路径,注释里已经把优先级讲清楚了:

1. 有 launch token(config.json 的 dsh.authToken,或从日志发现)→ 走 DSH 的官方 token 交换 2. 没有 launch token → 用持久化签名密钥离线铸造 Cookie …这条路不依赖启动器是否把 stdout 记进日志,所以 DSH 换启动方式/升级都不需要重新适配 // 优先级:默认铸造优先——它不依赖日志,也不随 DSH 重启失效

铸造那一条读的是:

~/.dsh/.credentials.yaml └── BROWSER_SESSION_KEY ├── kind: grant └── payload.version === 1 └── payload.secret(base64url,正好 32 字节)

本地做一次 HMAC 就得到一个set-cookie,全程不需要网络、不需要日志、不需要你手动贴任何东西。

于是可以下第二个结论:

authToken留空反而是推荐状态。
手填一个「进程启动 token」只是走了「尊重你的显式意图」那条路,DSH 一重启 token 就变,401 卷土重来。

还有一个容易被忽略的细节:Cookie 和host:port绑定(authority 进了签名载荷)。换端口 → 旧 Cookie 自动失效 → 桥接按当前 baseUrl 重新铸造。这句话是本次排查里最值钱的一句,它同时解释了「为什么端口对了就全对」和「为什么端口一换就全坏」。

顺手确认密钥在不在(不希望打印明文):

$cred="$env:USERPROFILE\.dsh\.credentials.yaml""文件存在: "+(Test-Path$cred)Get-Content$cred-Encoding UTF8|Select-String-Pattern'kind|version|browser|session|secret'|ForEach-Object{$_.Line-replace'(:\s*)[A-Za-z0-9_\-]{24,}','$1<hidden>'}

四、为什么今天修好、明天又坏:listen(0)

端口改成 57082,日志开始出帧,QQ 也能回话了。但重开一次桌面版,端口变成了 64023。

回到第二节搜到的那行:

server.listen(0,"127.0.0.1");

listen(0)=让操作系统随机分配一个空闲端口。这是桌面应用避免端口冲突的标准做法,也意味着:

  • 没有DSH_WEB_PORT之类的固定入口可配置(搜过,没有);
  • 端口每次启动都可能变;
  • 手上那份baseUrl是一份快照,不是事实。

所以「手动填端口」这条路在结构上就是不可持续的。要修的不是端口,是获取端口的方式。

五、把「发现」这件事交给脚本:四个动作

思路很朴素:与其记住端口,不如每次启动前现场问一遍。脚本只做四件事:

  1. 找到DeepSeek Harness进程;
  2. 枚举它名下127.0.0.1的 LISTEN 端口;
  3. 逐个curl /,命中dsh web authentication required(或200)就是它;
  4. 改写config.json的dsh.baseUrl,ConvertFrom-Json校验通过后写盘,再调start.bat。

完整脚本(全英文 + 带 BOM 写出,理由见第六节):

$Base='E:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1'$ps1= @' param([string]$Base = 'E:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1') $ErrorActionPreference = 'Stop' $procs = @(Get-Process -Name 'DeepSeek Harness' -ErrorAction SilentlyContinue) if ($procs.Count -eq 0) { Write-Host 'DSH desktop is not running'; exit 1 } $found = $null foreach ($proc in $procs) { $listen = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -eq $proc.Id -and $_.LocalAddress -eq '127.0.0.1' } foreach ($item in $listen) { $probe = curl.exe -s -i --max-time 2 ('http://127.0.0.1:' + $item.LocalPort + '/') if ($probe -match 'dsh web authentication required' -or $probe -match 'HTTP/1\.1 200') { $found = $item.LocalPort; break } } if ($found) { break } } if (-not $found) { Write-Host 'DSH web port not found'; exit 1 } $cfg = Join-Path $Base 'config.json' $txt = [System.IO.File]::ReadAllText($cfg) $url = 'http://127.0.0.1:' + $found $new = [regex]::Replace($txt, '("baseUrl"\s*:\s*")[^"]*(")', ('${1}' +$url+ '${2}')) try {$null= ($new| ConvertFrom-Json) } catch { Write-Host ('json invalid, not written: ' +$_.Exception.Message); exit 1 } [System.IO.File]::WriteAllText($cfg,$new, (New-Object System.Text.UTF8Encoding($false))) Write-Host ('baseUrl -> ' +$url) & (Join-Path$Base'start.bat') '@ # 注意两种编码是故意的 [System.IO.File]::WriteAllText((Join-Path$Base'start-auto.ps1'),$ps1, (New-Object System.Text.UTF8Encoding($true))) # 脚本本身:带 BOM$cmd= '@echo off' + "`r`n" + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-auto.ps1"' + "`r`n" + 'pause' + "`r`n"[System.IO.File]::WriteAllText((Join-Path$Base'run-auto.cmd'),$cmd,[System.Text.Encoding]::ASCII)'generated start-auto.ps1 + run-auto.cmd'

跑之前先做语法自检,别靠撞错:

$ps='E:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1\start-auto.ps1'$errs=$null[void][System.Management.Automation.Language.Parser]::ParseFile($ps,[ref]$null,[ref]$errs)if($errs){$errs|ForEach-Object{$_.Message}}else{'syntax OK'}

看到syntax OK再跑:

powershell-NoProfile-ExecutionPolicy Bypass-File"E:\...\qq-bridge-0.2.1-r1\start-auto.ps1"

实测输出(本机已跑通)。脚本自己只打一行:

baseUrl -> http://127.0.0.1:58249

随后它拉起start.bat,桥接打印的就是第一节那组「DSH 已就绪(模式:reserved2)」。这里有三个容易看错的点:

  • 本地控制台已启动: http://127.0.0.1:3100——这是桥接自己的控制台,端口来自config.json的consolePort,和 DSH 端口是两回事,别拿它当 baseUrl;
  • 控制台未配置 consoleToken,已自动生成: 537662...——这串是浏览器访问控制台用的,和上一节的 DSH 会话 Cookie 无关;
  • 桥接模式已确定为 reserved2——模式是从 SnowLuma 的网关能力探测出来的,出现它就是「事件流真的通了」的最直接证据。

从此以后的启动顺序固定为:先开 DSH 桌面版 → 等界面出来 → 双击run-auto.cmd。不要直接跑start.bat,它不会自己找端口。

为什么.cmd还要包一层

.ps1双击默认是「用记事本打开」而不是执行(还受执行策略限制)。.cmd壳负责把「解释器 +-ExecutionPolicy Bypass+ 脚本路径」一次说清,双击才真的跑起来。

六、七个连坐的坑(PowerShell 5.1 的编码地狱)

这一节是本文真正的「抄作业」部分。上面那段脚本是第七版,前六版全部因为下面这些原因挂掉。

坑 1:无 BOM 的.ps1+ 中文 = 语法错误

现象长这样,看着像脚本写错了,其实是解码错了:

所在位置 ...\start-auto.ps1:16 字符: 42 + $new = [regex]::Replace($txt, '("baseUrl"\s*:\s*")[^"]*(")', { param( ... 表达式中缺少右“)”。 ... 字符串缺少终止符: "

原因:PowerShell 5.1 读没有 BOM 的文件时按 ANSI(GBK)解码。脚本里只要有中文,某个汉字的 UTF-8 末字节就可能和后面的单引号黏成一个 GBK 双字节字符,于是引号被吃掉,后面全军覆没。同一现象还会附带出现JSON 鏍¢獙澶辫触这种乱码——那是同一个原因留下的指纹。

修法(二选一,我两个都用了):

  • 脚本内容全英文;
  • 写出时用[System.IO.File]::WriteAllText(..., (New-Object System.Text.UTF8Encoding($true))),即带 BOM。

坑 2:写config.json时反而不能带 BOM

Set-Content -Encoding utf8在 PS 5.1 下写的是带 BOM 的 UTF-8。BOM 对 PowerShell 无害,但 Node 的JSON.parse会因为开头多了\uFEFF直接报错:

JSON 校验: 坏了 -> Unexpected token

所以同一个脚本里出现两种编码是故意的:

目标文件编码原因
start-auto.ps1UTF-8带BOMPS 5.1 才按 UTF-8 解码
config.jsonUTF-8无BOMNodeJSON.parse不接受 BOM

坑 3:Get-Content -Raw | ... | Set-Content会双重编码

Get-Content读无 BOM 的 UTF-8 文件时按 GBK 解码,再写回去就变成双重编码。文件里会留下这种痕迹:

"workspaceTitle":"QQ 鑱婂ぉ","label":"鎴戠殑涓浆妯″瀷",

而且它是静默发生的——没有报错,只是中文全烂,最终在ConvertFrom-Json时炸掉:

传入的数组无效,应为“,”。(1644): {

修法:

  • 读:[System.IO.File]::ReadAllText($p);
  • 写:[System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($false)));
  • 或者至少给Get-Content/Set-Content显式加-Encoding UTF8。

已经写坏了就别再改字,直接Copy-Item config.example.json config.json,再用ConvertFrom-Json回填dsh.baseUrl、ownerQQ、allow.private三个字段——比修乱码快得多。

坑 4:-replace默认不区分大小写,会误伤隔壁字段

配置里有两个长得几乎一样的键:

"dsh":{"baseUrl":"http://127.0.0.1:18787"},"webSearch":{"baseURL":""}

-replace '"baseUrl"\s*:\s*"[^"]*"'会把两个都改掉,于是出现「两行都是一样的端口」:

"baseUrl":"http://127.0.0.1:64023","baseUrl":"http://127.0.0.1:64023",

修法:用[regex]::Replace(区分大小写),并且用${1}/${2}做替换,别用MatchEvaluator闭包:

$new=[regex]::Replace($txt,'("baseUrl"\s*:\s*")[^"]*(")',('${1}'+$url+'${2}'))

坑 5:PowerShell 变量名不区分大小写

$P='E:\AI_Animation\20_Deepseek Harness'$p=Get-Process'QQMusic'# 把 $P 覆盖了Join-Path$p'README.md'# → System.Diagnostics.Process (QQMusic)\README.md

习惯:变量名里不要只靠大小写区分。

坑 6:New-Object System.Text.ASCIIEncoding()空括号非法

“(”后面应为表达式。

UTF8Encoding($false)有参数所以能过,ASCIIEncoding()无参数就被解析器判成非法。改用静态属性:

[System.Text.Encoding]::ASCII

坑 7:先别动 SnowLuma 和白名单

症状在握手层,收到 0 帧意味着请求没进会话逻辑。这时候去改机器人白名单、改 persona、改 preset,全是无效劳动——先用 401 有没有出现来判断位置:

  • 有 401 → 端口对了,问题在 Cookie 交换;
  • 连 401 都没有 → 端口或进程就不对,别往下折腾鉴权。

七、整条链路:一张图看尽

QQ 客户端 │ 收发消息 ▼ SnowLuma(OneBot,ws://127.0.0.1:3001 / http://127.0.0.1:3000) │ HTTP + WS ▼ qq-bridge ────── config.json │ ├── dsh.baseUrl ← 每开机都会变的那个值 │ │ (由 start-auto.ps1 现场发现并改写) │ ├── dsh.authToken ← 留空,走"铸造 Cookie"路径 │ └── allow.private ← 填错会直接拒答 │ │ ① GET /?token=<launch token> → set-cookie(或者本地 HMAC 铸造) │ ② 带 Cookie 打开 /api/remote.mux(按 session/follow) │ ③ respond:POST /api/respond、POST /api/$events/result ▼ DSH 桌面版 web 服务 └── server.listen(0, "127.0.0.1") ← 端口由系统随机分配 ✗ 曾经指向:127.0.0.1:18787 └── billion-context 的 LLM 转发代理(upstream: api.anthropic.com) → 握手不认,门开了就关 → 「本次收到 0 帧」

这张图同时解释了四件事:

  • 为什么「收到 0 帧」和模型、预设、白名单一点关系都没有;
  • 为什么authToken留空反而最稳;
  • 为什么重开桌面版就复发——端口是listen(0);
  • 为什么修好端口之后,连 Cookie 都不用管——它会按新端口自动重铸。

八、抄作业清单(三分钟版)

  1. 看日志:出现已结束(本次收到 0 帧)→ 先当「握手被拒」处理,不要碰预设。
  2. 验端口:Get-NetTCPConnection -State Listen找DeepSeek Harness进程的127.0.0.1端口,curl -s -i http://127.0.0.1:<port>/:
    • 401+dsh web authentication required→ 锁定;
    • {"ok":true,"upstream":...}→ LLM 代理,换下一个。
  3. 别填 token:确认~/.dsh/.credentials.yaml里有BROWSER_SESSION_KEY(kind: grant/payload.version: 1/ 32 字节payload.secret)。
  4. 装脚本:start-auto.ps1(全英文、带 BOM 写出)+run-auto.cmd壳;改config.json时无 BOM 写出;替换用[regex]::Replace+${1}/${2}。
  5. 验收:ParseFile出syntax OK→ 跑脚本看到baseUrl -> http://127.0.0.1:xxxxx→ 日志出现DSH 已就绪(模式:reserved2)且不再有已结束(本次收到 0 帧)→ 去 QQ 里发一条,机器人回话。冷启动三次都稳定再算修完。

一句话总结
「收到 0 帧」不是 DSH 不说话,是你敲错了门;而门牌号每次开机都会变——所以别记端口,去问端口。