本地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_idclient_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}         # ❌ 错误字段名

修复namefile_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 内置机制自动加载,
无需任何额外启动脚本。


六、参考

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐