QQ机器人图片下载HTTP400排查报告
一次 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) |
| 9.9.36-53644 | |
| 日志配置 | fileLog: false、consoleLogLevel: info(关键:debug 日志默认是关的) |
三、排查过程
3.1 先确认"日志是谁打的"
第一反应容易跑偏——怀疑是框架在下载失败、甚至怀疑 QQ 在审核机器人输出。核对来源后确认,这三个报错点都在机器人框架自己身上:
utils/io.py的_raise_for_download_status():logger.error打出 400preprocess_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 三种都试 | ❌ 否决(结果相同) |
参数被转义(& → &) | 诊断日志打出的 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() 的取值顺序:
- native 通道:
core.apis.PacketApi.pkt.operation.FetchRkey() - 远程服务:两个写死的第三方地址
http://ss.xingzhige.com/music_card/rkeyhttps://secret-service.bietiaop.com/rkeys
- 都失败 → 返回硬编码占位 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 版本核对,根因锁定
| 组件 | 版本 |
|---|---|
| 9.9.36-53644 | |
| NapCat | 4.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.cn | multimedia.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 - ……(机器人正常描述图片内容)
七、我踩的坑(这一节可能最有用)
- 先怪错对象。一开始把日志归因于别的程序,还联想到"平台在清洗机器人输出"。实际上出站审核和这里完全无关——这是入站取图失败。
- 误读脱敏字段。把
len=221当成"响应体 221 字节",实际是 URL 长度。看到脱敏输出,先去看脱敏函数的源码。 - 提权脚本编码踩坑。写重启脚本时用 UTF-8 无 BOM,Windows PowerShell 5.1 按 ANSI 解码,路径里的中文目录名变乱码 →
-WorkingDirectory无效 → 旧进程被杀掉、新进程没起来,机器人直接离线。- 正解:给 PS 5.1 的脚本要么存成 UTF-8 with BOM,要么脚本内容全部 ASCII(用通配符去发现中文路径,而不是把中文写进脚本)。
- 把噪音当证据。日志里
pae has no method onGroupNotifies…这类行看着像"版本不匹配铁证",但升级到 4.18.41 后照样存在(对象名变成lse),它只是探测可选回调的正常噪音。真正的证据是packetStatus=false。 - 过早下结论。曾判断"provider 是纯文本模型,图片下载了也没用"——修复后机器人确实描述了画面,说明该判断错误。
- "重启能修"的假设是错的。重启清空了内存里的失败计数器,但问题是能力缺失(缺 Packet 映射)而非临时状态,重启后 rkey 仍是同一个占位符。这类问题重启只能证伪,不能修复。
八、预防建议
- 版本对齐是硬约束。NapCat 与 QQ 必须版本匹配,QQ 自动升级后 NapCat 往往立刻失效。
- 常备上一次可用的完整备份,出问题可秒回滚。
- 关闭 QQ 自动更新;看到升级提示,先去 NapCat Releases 确认已支持新版本再升。
- 升级 NapCat 用"新目录 + 迁移 config",不要原地覆盖,回滚成本最低。
- 保持
fileLog: false但心里有数:它的代价是关键故障信息打在debug级别、默认不可见。排障时临时打开fileLog: true+fileLogLevel: debug,完事再关。 - 框架侧的一个体验缺陷:兜底
except里把异常原文直接发进会话,等于把内部故障暴露给聊天对象。应该发通用提示语,详细错误只进本地日志。 - 可选加固:
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 参考
- NapCat Releases(版本与 QQ 兼容性以更新说明为准):https://github.com/NapNeko/NapCatQQ/releases
- NapCat Shell 安装与兼容说明:https://napneko.github.io/guide/boot/Shell
本文记录一次真实排障过程,已隐去账号、昵称与本机路径。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)