Hermes Agent QQ Bot 文件传输 & DeepSeek 兼容性修复[AI生成]
本地docker部署的hermes agent,qq通道发送文件给人失败。让hermes自己修复的,下面是修复方案。人工文字到此处。
Hermes Agent 接入 QQ 开放平台的完整方案,包含文件直传、文件名修复、
以及 DeepSeek V4 API 兼容性问题处理。
📦 最终效果
- ✅ QQ Bot 可直接发文件到对话(不经过 paste.rs 等第三方中转)
- ✅ 文件正确显示原始文件名(非"未命名")
- ✅ 长时间会话无 400 错误(
content should be a string or a list)
一、整体方案
1.1 架构
Hermes 运行环境(Docker 容器)
│
├── /opt/hermes/ ← Hermes 代码(临时,重启还原)
├── /opt/data/ ← 持久化存储
│ └── python_patches/ ← PYTHONPATH 指向此目录
│ └── sitecustomize.py ← Python 启动时自动加载
│
Python 启动流程:
Docker 启动 → PYTHONPATH 指向 /opt/data/python_patches
→ 解释器自动加载 sitecustomize.py
→ sys.meta_path 注册两个模块加载拦截器
1. tools.send_message_tool → QQ Bot 媒体发送
2. run_agent → DeepSeek content 类型守卫
1.2 前置条件
- Hermes Agent 已通过 QQ Bot 沙箱模式连接 QQ(
api.sgroup.qq.com) - QQ Bot 应用的
app_id和client_secret已配置 - 环境变量
PYTHONPATH=/opt/data/python_patches已设置(持久化的容器配置) - DeepSeek V4 Flash/Pro 作为主模型
1.3 Patch 文件位置
/opt/data/python_patches/sitecustomize.py
分为三个独立的 patch:
| # | 模块 | 功能 | 触发时机 |
|---|---|---|---|
| 1 | tools.send_message_tool |
QQ Bot 文件直传 | 调用 send_message(target="qqbot", message="MEDIA:...") |
| 2 | run_agent.AIAgent._sanitize_api_messages |
content 类型守卫 | 每次 LLM API 调用前 |
| 3 | run_agent.AIAgent._interruptible_api_call |
预调用类型诊断 | API 请求前记录异常消息 |
二、Patch 1:QQ Bot 文件直传
2.1 解决的问题
Hermes 原生的 _handle_send 中,QQ Bot 不在媒体支持平台名单内,MEDIA: 标记被直接剥离,文件永远无法到达用户。
2.2 实现原理
利用 sys.meta_path 钩子,在 tools.send_message_tool 模块加载完成后,
自动替换 _send_qqbot 和 _handle_send 函数。
2.3 QQ Bot API 文件上传流程(三段式)
Step 1: 获取 Access Token
POST https://bots.qq.com/app/getAppAccessToken
body: {"appId": "...", "clientSecret": "..."}
→ access_token
Step 2: 上传文件
POST https://api.sgroup.qq.com/v2/users/{openid}/files
Header: Authorization: QQBot {access_token}
body: {
"file_type": 4, # 1=图片 2=视频 3=语音 4=文件
"file_data": "<base64编码>", # 文件二进制内容 base64 后
"srv_send_msg": false, # 不自动发送,由我们控制
"file_name": "文件名.csv" # ⚠️ 必须传,否则显示"未命名"
}
→ {"file_info": "JWT令牌", "file_uuid": "..."}
Step 3: 发送媒体消息
POST https://api.sgroup.qq.com/v2/users/{openid}/messages
body: {
"msg_type": 7, # 7=媒体消息
"media": {"file_info": "..."} # 上传返回的令牌
}
2.4 关键代码
async def _send_qqbot_patched(pconfig, chat_id, message, media_files=None):
"""Monkey-patch: 支持直传文件的 _send_qqbot"""
# Step 1: 获取 token
r = await client.post("https://bots.qq.com/app/getAppAccessToken",
json={"appId": appid, "clientSecret": secret})
access_token = r.json()["access_token"]
# Step 2: 上传每个文件
for file_path, is_voice in media_files:
fn = os.path.basename(file_path)
ft = 1 if 图片 else 2 if 视频 else 3 if 语音 else 4 # 默认文件
with open(file_path, 'rb') as f:
file_b64 = base64.b64encode(f.read()).decode('ascii')
body = {
"file_type": ft,
"file_data": file_b64,
"srv_send_msg": False,
"file_name": fn, # ← 关键!服务器需要这个
}
upload_resp = await client.post(upload_url, json=body, ...)
file_info = upload_resp.json()["file_info"]
# Step 3: 发送
payload = {"msg_type": 7, "media": {"file_info": file_info}}
await client.post(send_url, json=payload, ...)
2.5 验证方法
在 Hermes 对话中发送:
发文件:MEDIA:/path/to/file.csv
或通过 send_message 工具:
send_message(target="qqbot", message="测试 MEDIA:/opt/data/file.csv")
三、Patch 2:DeepSeek content 类型守卫
3.1 解决的问题
DeepSeek V4 Flash/Pro 使用 Rust 编写的 API 后端,
其消息 content 字段 schema 定义为 string | array(不包含 null)。
Hermes 在长会话场景下会产出非 string/list 的 content:
| 场景 | 原始类型 | 问题 |
|---|---|---|
| tool_calls-only 的 assistant 消息 | None |
content: null → 拒绝 |
| 多模态 tool 结果 | dict |
content: {...} → 拒绝 |
| 会话从 SQLite 恢复 | NULL (None) |
同上 |
| 上下文压缩后重写的消息 | 可能 None/dict | 同上 |
错误示例:
Error code: 400
Failed to deserialize the JSON body into the target type:
messages[66]: content should be a string or a list
3.2 实现原理
在 run_agent.AIAgent._sanitize_api_messages 上加装类型守卫。
这个方法在每次 LLM API 调用前都会执行(主循环 + 摘要路径),是最佳拦截点。
@staticmethod
def _sanitize_api_messages_patched(messages):
# 先执行原始清理(孤立 tool_call/tool_result)
result = orig_sanitize(messages)
# 再补充 content 类型守卫
for i, msg in enumerate(result):
content = msg.get("content")
if content is None:
msg["content"] = ""
elif not isinstance(content, (str, list)):
msg["content"] = str(content)
return result
3.3 验证方法
手动触发长会话(多轮工具调用),观察日志:
⚠ _sanitize_api_messages: fixed N bad content(s): [(idx, 'dict→str')]
如果没有任何修复日志,说明当前会话的消息 content 全部合规。
四、Patch 3:预调用诊断日志
4.1 解决的问题
当 400 错误仍然发生时,需要精确定位是哪个消息的 content 有问题。
4.2 实现原理
在 _interruptible_api_call 入口处,扫描即将发送的消息列表,
记录任何异常 content 类型:
def _interruptible_api_call_patched(self, api_kwargs):
msgs = api_kwargs.get("messages", [])
for i, m in enumerate(msgs):
c = m.get("content")
if c is not None and not isinstance(c, (str, list)):
_debug(f"🚨 messages[{i}]: content is {type(c).__name__}")
return orig_interruptible(self, api_kwargs)
4.3 查看诊断日志
grep "PRE-CALL\|🚨\|⚠ _sanitize" /opt/data/python_patches/.patch_debug.log
五、踩坑记录
5.1 文件名显示"未命名"
现象:文件成功发送到 QQ,但显示"未命名"(Unnamed)。
原因:上传时字段名用错了。
Hermes 内置 QQ Bot adapter 使用的字段名:
# gateway/platforms/qqbot/adapter.py line 2257
body["file_name"] = file_name # ✅ 正确字段名
而 monkey-patch 中写成了:
body = {..., "name": fn} # ❌ 错误字段名
修复:name → file_name。
5.2 Patch 1 不够、400 错误仍在
第 1 次 patch 只处理了 content=None:
if msg.get("content") is None:
msg["content"] = ""
但错误继续发生在 messages[183]。通过诊断日志发现:
⚠ _sanitize_api_messages: fixed 1 bad content(s): [(199, 'dict→str')]
原来 content 不是 None,而是 dict!
dict 类型的 content 来自多模态工具结果——Hermes 对视觉模型
(DeepSeek V4 Flash 被识别为视觉模型)会保留 content parts 列表,
但这些 parts 中的 dict 对象(如图片格式)在某些路径下直接成了 tool 消息的 content。
第 2 次 patch 扩展为通用类型守卫:
elif not isinstance(content, (str, list)):
msg["content"] = str(content)
5.3 为什么选择 monkey-patch 而不是改源码?
| 方案 | 问题 |
|---|---|
直接改 /opt/hermes/*.py |
容器重启还原(只读挂载) |
| 提 PR 合入 Hermes 主线 | 等待发布周期,不能立即用 |
| Dockerfile 构建时打补丁 | 构建流程复杂,不灵活 |
| sitecustomize + monkey-patch ✅ | 持久化在 /opt/data,重启保留,不改源码 |
5.4 持久化机制
/opt/data/ ← Docker volume 挂载(持久化)
python_patches/ ← PYTHONPATH 指向
sitecustomize.py ← Python 解释器自动加载
.patch_debug.log ← 运行时调试日志
.patch_loaded ← 标记文件(确认 patch 已执行)
容器启动时,sitecustomize.py 通过 Python 内置机制自动加载,
无需任何额外启动脚本。
六、参考
- QQ Bot API v2 官方文档
- Hermes Agent GitHub
- DeepSeek V4 API schema(Rust serde)对
content字段的定义为string | array,不支持null
更多推荐




所有评论(0)