一次 QQ 机器人"图片必挂"的排查实录:从 HTTP 400 到 rkey 失效

摘要:机器人收到图片必定失败,报 400 download url has expired。表面看像图片链接过期,实际是 NapCat 版本落后于 QQ 版本,拿不到 rkey,于是用打包代码里的硬编码占位符去拼链接。本文记录完整的排除过程、代码级证据和修复办法,也记录了我自己踩的几个坑。


TL;DR

项内容
现象QQ 机器人收到图片 100% 失败,返回 Failed to download file ... HTTP status code: 400
真因NapCat 4.18.19 缺少 QQ 9.9.36-53644 的 Packet 映射 → packetStatus=false → native rkey 通道被静默跳过 → 远程 rkey 服务全挂 → 使用硬编码占位 rkey
修复NapCat 升级到 4.18.41(该 QQ 版本的支持自 4.18.34 起才补齐)
耗时点不是"过期"这种动态问题,而是能力缺失,重启只能证伪不能修复

一、故障现象

给机器人发一张图,它不回答图片内容,而是回一句英文报错:

Error occurred while processing agent request:
Failed to download file from https URL host='gchat.qpic.cn'
file='download' len=221. HTTP status code: 400

服务端日志里同一个错误出现了两次(这一点后面很关键):

[ERRO] [utils.io:188]: Failed to download file from https URL host='gchat.qpic.cn' file='download' len=221. HTTP status code: 400
[WARN] [preprocess_stage.stage:126]: Image processing failed for ... HTTP status code: 400
[ERRO] [utils.io:188]: Failed to download file ... HTTP status code: 400
[ERRO] [agent_sub_stages.internal:437]: Error occurred while processing agent: ... HTTP status code: 400

统计当天日志:同类失败 172 条,从 01:10 一直到 13:03,每张图都失败,无一例外。


二、环境信息

项目版本 / 配置
操作系统Windows
机器人框架AstrBot v4.28.0-beta.1
接入方式aiocqhttp(OneBot v11),反向 WS ws://127.0.0.1:6199/ws
OneBot 实现NapCat 4.18.19(构建于 2026-09-08)
QQ9.9.36-53644
日志配置fileLog: false、consoleLogLevel: info(关键:debug 日志默认是关的)

三、排查过程

3.1 先确认"日志是谁打的"

第一反应容易跑偏——怀疑是框架在下载失败、甚至怀疑 QQ 在审核机器人输出。核对来源后确认,这三个报错点都在机器人框架自己身上:

  • utils/io.py 的 _raise_for_download_status():logger.error 打出 400
  • preprocess_stage/stage.py:126:图片预处理阶段吞掉异常,只告警,不中断
  • agent_sub_stages/internal.py:437:致命点——兜底 except 把异常文本当回复发进会话

所以:这是入站取图失败,与"QQ 审核机器人输出"完全无关。定位的第一步永远是确认日志的出处。

3.2 拿到未脱敏的完整 URL

框架出于安全考虑会先脱敏再打日志:

def _safe_url_for_log(url: str) -> str:
    # 只保留 scheme / host / 末段路径 / URL 总长度
    return f"{parsed.scheme} URL host={parsed.netloc!r}{suffix} len={len(url)}"

这也解释了报错里两个容易误读的字段:

  • file='download' 只是 URL 的末段路径,不是文件名
  • len=221 是 URL 字符串长度,不是响应体大小

在 download_file() 入口临时加一行日志(诊断后已回滚):

# --- TEMP DIAG PATCH ---
logger.error("[DIAG] download_file full url: %r", url)

得到:

https://gchat.qpic.cn/download?appid=1406&fileid=<fileid>&rkey=<占位rkey>&spec=0

3.3 直接请求,读服务端的明文错误

用 curl 复现三种请求(默认 UA / 浏览器 UA / 浏览器 UA + Referer),结果完全一致:

{"retcode":-5503007,"retmsg":"download url has expired","retryflag":0}

HTTP 400。

这一步的价值在于:拿到了服务端的语义化错误码,把"猜"变成了"读"——对方明确说这个 URL 已过期。

3.4 用排除法逐个否掉假设

假设验证方法结论
链接过期(时间流逝导致)收到消息 .440 → 发起下载 .459,仅 19 毫秒后就开始请求,仍在 .699 收到 “expired”❌ 否决
缺少 UA / Referer默认 UA、Chrome UA、Chrome UA + Referer: qun.qq.com 三种都试❌ 否决(结果相同)
参数被转义(& → &amp;)诊断日志打出的 URL 中 & 正常,无 HTML 实体❌ 否决
本机时钟漂移导致签名校验失败本机 UTC 与公网服务器的 Date 响应头秒级一致❌ 否决
网络 / 代理问题同机能正常拿到 400 响应体;系统代理未启用❌ 否决
单张图片本身坏了同日 172 次失败、多张不同图片全失败❌ 否决

3.5 决定性线索:rkey 是个"恒定值"

对比两次不同图片(fileid 完全不同)的 URL:

13:18:27  fileid=<fileid-A>  rkey=<占位rkey>
13:20:02  fileid=<fileid-B>  rkey=<占位rkey>   ← rkey 一模一样

正常的签名令牌不可能对不同图片完全一致。它只能是常量,而不是签发值。

3.6 反查打包产物:找到硬编码常量

在 NapCat 的打包产物里检索这个字符串:

URL 里的 rkey 出现在 napcat.mjs 中: True
首次出现位置: 305947
该字符串全文出现次数: 1

逐字节相同,全库仅出现 1 次,位置就在兜底常量处:

async getRkeyData() {
  const e = {
    private_rkey: "CAQSKAB6JWENi5LM…Ec3Rbib9LP4",  // ← 出问题的 URL 里就是这个
    group_rkey:   "CAQSKAB6JWENi5LM…2qffcqm614gds",
    online_rkey: false
  };
  ...
}

3.7 读代码:rkey 有三个来源,前两个都废了

getImageUrlFromParsedUrl(e, n, r) {
  const i = n === "1406" ? r.private_rkey : r.group_rkey;
  return r.online_rkey
    ? b7 + `/download?appid=${n}&fileid=${e}&rkey=${i}`          // 真 rkey → multimedia.nt.qq.com.cn
    : jy + `/download?appid=${n}&fileid=${e}&rkey=${i}&spec=0`;  // 假 rkey → gchat.qpic.cn
}

故障 URL 结尾带 &spec=0 且域名是 gchat.qpic.cn → 走的正是 online_rkey === false 的降级分支。

getRkeyData() 的取值顺序:

  1. native 通道:core.apis.PacketApi.pkt.operation.FetchRkey()
  2. 远程服务:两个写死的第三方地址
    • http://ss.xingzhige.com/music_card/rkey
    • https://secret-service.bietiaop.com/rkeys
  3. 都失败 → 返回硬编码占位 rkey

3.8 打开 debug 日志,抓到两条真实报错

原本 fileLog: false,而关键失败信息都打在 logDebug 级别,根本不可见。改成 fileLog: true + fileLogLevel: debug 并重启后,抓到:

远程服务①:返回体结构不对

[Rkey] 异常服务 http://ss.xingzhige.com/music_card/rkey 异常 /
  TypeError: Cannot read properties of undefined (reading 'slice')
    at Dj.refreshRkey (napcat.mjs:8375:36)
    at async Dj.getRkey  (napcat.mjs:8347:9)
    at async hQ.getRkeyData (napcat.mjs:9529:19)
    at async hQ.getImageUrl  (napcat.mjs:9508:17)

该服务实际返回 {"expired_time":…,"name":"rkeys"},没有 group_rkey / private_rkey 字段,于是 undefined.slice(6) 抛异常。

远程服务②:TLS 握手被拒

[Rkey] 异常服务 https://secret-service.bietiaop.com/rkeys 异常 /
  Error: write EPROTO ... TLSV1_ALERT_UNRECOGNIZED_NAME ... SSL alert number 112

获取remote.rkey失败 Error: 获取rkey失败: ...

native 通道:一条日志都没有

检索日志中 native.rkey / FetchRkey / PacketApi 相关行数 = 0。

按代码,失败会打 获取native.rkey失败;这里连这条都没有,说明 packetStatus 为 false,整个 native 分支被静默跳过,压根没执行。

3.9 版本核对,根因锁定

组件版本
QQ9.9.36-53644
NapCat4.18.19(2026-09-08)
NapCat 首次支持该 QQ 版本的 Packet 通道4.18.34(2026-10-09)

上游更新说明原文:

新增 … Windows QQ 9.9.36-53644(x64) 的 Packet、Napi2Native 和 AppID 映射。

即:NapCat 落后于 QQ 一个版本周期,缺少 Packet 映射 → native rkey 通道不可用,于是每次都被迫走"两个已死的远程服务 + 硬编码占位符"这条路。


四、根因因果链

NapCat 4.18.19 缺少 QQ 9.9.36-53644 的 Packet 映射
        │
        ├─→ native 通道:packetStatus = false,整块被静默跳过(无任何日志)
        │
        ├─→ 远程 rkey 服务①:返回残缺 JSON → TypeError: undefined.slice
        └─→ 远程 rkey 服务②:TLS SNI 被拒 → EPROTO / SSL alert 112
        │
        ↓
getRkeyData() 返回硬编码占位 rkey
        │
        ↓
online_rkey=false → 降级分支拼出 gchat.qpic.cn/download?...&spec=0
        │
        ↓
腾讯校验:{"retcode":-5503007,"retmsg":"download url has expired"} → HTTP 400
        │
        ↓
框架下载失败 → 预处理只告警 → agent 阶段抛异常
        │
        ↓
兜底 catch 把异常文本当回复发进会话

一句话概括:一个版本号错配,被两层"静默降级"和一个"兜底把异常发给用户"放大成了看得见的故障。


五、修复步骤

5.1 升级 NapCat

# 1) 下载(版本号按当时的最新稳定版替换)
$dst = 'D:\apps\NapCat.Shell-v4.18.41.zip'
curl.exe -L -o $dst `
  'https://github.com/NapNeko/NapCatQQ/releases/download/v4.18.41/NapCat.Shell.zip'

# 2) 解压到新目录(旧目录原封不动,保留回滚能力)
$new = 'D:\apps\NapCat.Shell-4.18.41'
Expand-Archive -LiteralPath $dst -DestinationPath $new -Force

# 3) 迁移配置(保留 WebUI token、OneBot 连接、账号配置)
Copy-Item 'D:\apps\NapCat.Shell\config\*' "$new\config" -Force

5.2 以提权方式重启(关键)

QQ 进程通常是提权运行的,普通权限的 taskkill 会被拒绝(Access is denied),必须提权:

# 1) 停旧实例 + 杀 QQ(需管理员)
Stop-Process -Name NapCatWinBootMain -Force
taskkill /f /im QQ.exe

# 2) 设定环境变量并启动新版
$dir  = 'D:\apps\NapCat.Shell-4.18.41'
$env:NAPCAT_PATCH_PACKAGE = "$dir\qqnt.json"
$env:NAPCAT_LOAD_PATH     = "$dir\loadNapCat.js"
$env:NAPCAT_INJECT_PATH   = "$dir\NapCatWinBootHook.dll"
$env:NAPCAT_LAUNCHER_PATH = "$dir\NapCatWinBootMain.exe"
$env:NAPCAT_MAIN_PATH     = ($dir -replace '\\','/') + '/napcat.mjs'
Start-Process "$dir\NapCatWinBootMain.exe" `
  -ArgumentList @('D:\apps\QQ\QQ.exe', "$dir\NapCatWinBootHook.dll") `
  -WorkingDirectory $dir

注意:NAPCAT_MAIN_PATH 必须显式指向新目录的 napcat.mjs;否则新版 loadNapCat.js 会按 __dirname 相对解析,指到 QQ 的 app 目录去。

5.3 验证

重启后约 60~90 秒 QQ 冷启动完成并自动登录,日志出现:

[INFO] [aiocqhttp.aiocqhttp_platform_adapter:107]: aiocqhttp(OneBot v11) 适配器已连接。

然后发一张图,观察下游拿到的 URL 是否变化(见下一节)。


六、修复前后对比

观测项修复前(4.18.19)修复后(4.18.41)
rkey 来源硬编码占位符native Packet 通道真实签名
rkey 值固定不变(所有图片同一个值)每图不同(真实签名)
图片域名gchat.qpic.cnmultimedia.nt.qq.com.cn
查询参数带 &spec=0(降级分支)无 spec(正常分支)
下载结果每张 400成功
[Rkey] 异常服务每张图刷 4 条0 条
机器人行为回一句报错正常描述图片内容

修复后的实际日志:

13:42:03 [DIAG] ... 'https://multimedia.nt.qq.com.cn/download?appid=1406&fileid=<fileid>&rkey=<真实rkey>'
13:42:06 Prepare to send - ……(机器人正常描述图片内容)

七、我踩的坑(这一节可能最有用)

  1. 先怪错对象。一开始把日志归因于别的程序,还联想到"平台在清洗机器人输出"。实际上出站审核和这里完全无关——这是入站取图失败。
  2. 误读脱敏字段。把 len=221 当成"响应体 221 字节",实际是 URL 长度。看到脱敏输出,先去看脱敏函数的源码。
  3. 提权脚本编码踩坑。写重启脚本时用 UTF-8 无 BOM,Windows PowerShell 5.1 按 ANSI 解码,路径里的中文目录名变乱码 → -WorkingDirectory 无效 → 旧进程被杀掉、新进程没起来,机器人直接离线。
    • 正解:给 PS 5.1 的脚本要么存成 UTF-8 with BOM,要么脚本内容全部 ASCII(用通配符去发现中文路径,而不是把中文写进脚本)。
  4. 把噪音当证据。日志里 pae has no method onGroupNotifies… 这类行看着像"版本不匹配铁证",但升级到 4.18.41 后照样存在(对象名变成 lse),它只是探测可选回调的正常噪音。真正的证据是 packetStatus=false。
  5. 过早下结论。曾判断"provider 是纯文本模型,图片下载了也没用"——修复后机器人确实描述了画面,说明该判断错误。
  6. "重启能修"的假设是错的。重启清空了内存里的失败计数器,但问题是能力缺失(缺 Packet 映射)而非临时状态,重启后 rkey 仍是同一个占位符。这类问题重启只能证伪,不能修复。

八、预防建议

  1. 版本对齐是硬约束。NapCat 与 QQ 必须版本匹配,QQ 自动升级后 NapCat 往往立刻失效。
    • 常备上一次可用的完整备份,出问题可秒回滚。
    • 关闭 QQ 自动更新;看到升级提示,先去 NapCat Releases 确认已支持新版本再升。
  2. 升级 NapCat 用"新目录 + 迁移 config",不要原地覆盖,回滚成本最低。
  3. 保持 fileLog: false 但心里有数:它的代价是关键故障信息打在 debug 级别、默认不可见。排障时临时打开 fileLog: true + fileLogLevel: debug,完事再关。
  4. 框架侧的一个体验缺陷:兜底 except 里把异常原文直接发进会话,等于把内部故障暴露给聊天对象。应该发通用提示语,详细错误只进本地日志。
  5. 可选加固:download_file() 目前是裸 aiohttp GET(无 UA / Referer)。本例中不是病因(多种请求头都试过),但对其它 CDN 可能有影响。

九、附录

9.1 关键报错原文

# 框架侧
Failed to download file from https URL host='gchat.qpic.cn' file='download' len=221. HTTP status code: 400
Error occurred while processing agent: Failed to download file from ... HTTP status code: 400

# 服务端
{"retcode":-5503007,"retmsg":"download url has expired","retryflag":0}

# NapCat 侧
[Rkey] 异常服务 http://ss.xingzhige.com/music_card/rkey 异常 /
  TypeError: Cannot read properties of undefined (reading 'slice')
[Rkey] 异常服务 https://secret-service.bietiaop.com/rkeys 异常 /
  Error: write EPROTO ... TLSV1_ALERT_UNRECOGNIZED_NAME ... SSL alert number 112
获取remote.rkey失败 Error: 获取rkey失败: ...

9.2 排查用到的命令

# 看日志(注意 UTF8 解码,否则中文乱码)
Get-Content 'C:\Users\<用户名>\.astrbot\logs\backend.log' -Tail 40 -Encoding UTF8 |
  ForEach-Object { $_ -replace "`e\[[0-9;]*m", '' }

# 统计失败次数
(Select-String -Path 'C:\Users\<用户名>\.astrbot\logs\backend.log' -Pattern 'gchat\.qpic\.cn').Count

# 直接验证图片链接(三种请求头对比)
curl.exe -sS -o - -w "`nHTTP:%{http_code}`n" "<图片URL>"
curl.exe -sS -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120" `
  -e 'https://qun.qq.com/' -o - -w "`nHTTP:%{http_code}`n" "<图片URL>"

# 在打包产物里定位常量
$t = Get-Content -Raw 'D:\apps\NapCat.Shell\napcat.mjs'
$t.IndexOf('CAQSKAB6JWENi5LM')

# 比较本机时钟与服务器时间
curl.exe -sS -I https://www.baidu.com | Select-String '^date:'
(Get-Date).ToUniversalTime()

9.3 参考


本文记录一次真实排障过程,已隐去账号、昵称与本机路径。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐