Hermes 连环 400 真凶找到了:一个 call_id 让人炸毛

作者:吴佳浩
撰稿时间:2026-06-21
最后更新:2026-06-22
引言
这两天排查 Hermes 的一个 Bug,差点把我绕进去。
一开始我以为是模型的问题,后来怀疑是代理的问题,再后来甚至怀疑是自己改坏了代码。因为每次请求都会直接返回 400 ,而且报错还越来越离谱:38 个 validation error、54 个 validation error......
最诡异的是,回退代码版本没有用,换模型没有用,甚至重新开一个 Provider 还是一样报错。
直到把请求体一层层剥开,我才发现真正的问题根本不在模型,也不在网关,而是在 Hermes 自己维护的消息历史。
一个原本只应该存在于 Codex Responses API 的 call_id,连同 response_item_id、function.parameters 等内部字段,被一起带到了严格 OpenAI Compatible API,最终触发了一连串 Schema 校验失败。
这篇文章就完整记录一下整个定位过程,以及 Hermes 最终是如何修复这个兼容性问题的。(如果你没出现当可以不用关注 大多数使用中转机场的朋友可能会遇到)
一、错误信息
javascript
Error: Error code: 400 - {
'error': {
'message': "upstream status 400: N request validation errors:
Extra inputs are not permitted, field: 'messages[].tool_calls[].***.***.parameters', value: None;
Input should be a valid string, field: 'messages[].tool_calls[].***.str'",
'type': 'invalid_request_error',
'code': 'invalid_request_error'
}
}
关键字段:
tool_calls[].function.parameters: value: None → 严格 API 要求必须是合法 JSON 字符串tool_calls[].***.str: 不够是合法字符串 → strict validator 拒绝- 错误数量会随对话历史增长:38 → 54 → 更多
二、根因分析
2.1 为什么会有 parameters: None?
Hermes 内部消息结构为了兼容 Codex Responses API ,在 tool_calls 上额外存储了非标准字段:
| 字段 | 作用 | 问题 |
|---|---|---|
call_id |
Codex Responses 需要的调用标识 | 严格 OpenAI API 不认识 |
response_item_id |
Codex 响应追踪 | 同上 |
extra_content |
Gemini thought_signature | 非 Gemini 模型不能带 |
function.parameters |
工具 schema 定义(非本次调用参数) | 为 None 时,严格 API 拒绝 |
关于
function.parameters:它并不是 OpenAI Chat Completions 消息协议中的字段,而是属于工具定义(Tool Schema)信息,并非一次tool_call的调用参数。Hermes 在内部对象中保留了该字段,当序列化后被一起发送给严格 API 时,就会因为未知字段而触发Extra inputs are not permitted。
关于function.arguments:OpenAI Chat Completions 协议要求arguments必须是一个合法的 JSON 字符串(类型为string)。但 Hermes 内部消息中的arguments未规范化为合法 JSON 字符串------可能为None、dict或其他非法类型------发送到严格校验的 API 时同样会被拒绝。
当 Hermes 切换到 非 Codex API(如通过 babelark → DeepSeek)发送消息时,这些字段就会触发上游的 400 校验错误。
2.2 触发条件
- API 模式 ≠
codex_responses - 消息历史中包含 tool_calls(几乎任何有工具调用的会话都会有)
- 上游是严格 OpenAI 兼容网关(babelark、Fireworks、Mistral、Moonshot/Kimi 等)
三、修复方案
3.1 核心修复
位置 :run_agent.py:5004 --- AIAgent._sanitize_tool_calls_for_strict_api()
逻辑:在发送 API 请求前,对消息做一次性清洗:
关键设计决策 :创建新的 tool_call dict 而非原地修改 → 保留原始消息中的 call_id/response_item_id,以便后续回退到 Codex provider 时仍能正常工作。
3.2 清洗触发条件
run_agent.py:5077 --- _should_sanitize_tool_calls():
python
def _should_sanitize_tool_calls(self) -> bool:
return self.api_mode != "codex_responses"
只在非 Codex API 模式下执行清洗,Codex Responses API 本身需要这些字段。
3.3 调用位置
清洗在两个路径被调用:
| 路径 | 文件 | 行号 |
|---|---|---|
| 主对话循环发送请求前 | agent/conversation_loop.py |
751-752 |
| 压缩总结生成 API 请求前 | agent/chat_completion_helpers.py |
1319-1339 |
| 流式请求构建前 | agent/chat_completion_helpers.py |
1831-1836 |
3.4 辅助修复
agent/agent_runtime_helpers.py:237 --- sanitize_tool_call_arguments():
- 专门处理已损坏的 tool_call argument JSON
- 将 None/空值替换为
"{}" - 对无法解析的 JSON 做降级处理
- 标记损坏的 tool result 消息以便追踪
四、修复前后对比


修复前
- 任何有工具调用的会话,切换到非 Codex API 后立即 400
- 错误随对话历史增长而恶化(更多 tool_calls = 更多字段 = 更多校验错误)
- 回退到旧版本无效(历史消息已有这些字段)
修复后
- 非 Codex API 自动清洗消息
- Codex API 保留所有字段
- 清洗创建新 dict,原始消息不丢失,后续切换 provider 也不受影响
五、影响范围
受影响场景
- Hermes 使用非 Codex API 模式(chat_completions / anthropic_messages)
- 会话有工具调用历史
- 上游是严格校验的网关
不受影响场景
- 纯 Codex Responses API 模式
- 无工具调用的纯文本会话
- 部分兼容接口可能会忽略未知字段,但严格实现(例如基于 Pydantic Schema 校验的 OpenAI Compatible API)通常会直接返回 400
六、关于版本回退
曾尝试通过 git 回退到旧版本修复此问题,但无效。原因:
- 旧版本也没有清洗逻辑 --- 旧代码同样会在非 Codex API 消息中携带非标准字段
- 问题出在历史消息,不是新增代码 --- 只要消息中有
call_id/response_item_id,发送到严格 API 就会触发 400 - 真正需要的是请求前的清洗逻辑 --- 这是后来才加的
结论:这不是回归 bug,而是一直存在、直到换了严格 upstream 才暴露的兼容性问题。
七、经验总结
核心教训
- API 间字段兼容性是常见坑 --- Codex 的便利字段到了严格 OpenAPI 就是炸弹
- 在出口做清洗,不在源头砍功能 --- 保留原始数据,只清洗 outgoing 副本
- 400 报错的 field 路径是定位关键 --- 直接告诉你哪个字段、什么类型不对
- 版本回退治标不治本 --- 旧代码也有同样问题,只是换了 upstream 才暴露
- 错误数量增长现象 --- 38→54 的递增是因为历史中有越来越多的 tool_calls 消息
架构启示
对于 Agent 框架而言,内部消息模型与外部 API 协议应该彻底解耦。 内部可以保存任意元数据(call_id、response_item_id、thought_signature 等),但真正发送请求时,必须经过一层协议适配(Protocol Adapter),确保输出严格符合目标 API Schema。
八、小结结
Hermes 的 400 报错是因为 tool_calls 消息中携带了 Codex-only 字段(call_id / response_item_id)且 function.arguments 未规范化为合法 JSON 字符串,发送到严格 OpenAI 兼容 API 时被拒绝;修复方案是在发送前对非 Codex API 做消息清洗,去除不合规字段并规范化 arguments。
Agent 的内部协议可以自由演进,但对外协议必须绝对严格。所有兼容性问题,本质上都是协议边界没有处理好。
很多人看到 400,会第一时间怀疑模型、怀疑代理、怀疑网络。
但这次的问题恰恰相反。
模型没有错,代理没有错,严格的 Schema 校验也没有错。真正的问题,是 Agent 在不同协议之间传递消息时,把内部状态 和对外协议混在了一起。
对于 Agent 框架来说,内部消息可以保存任意元数据,例如 call_id、response_item_id、thought_signature 等,它们都是框架运行所需要的信息。但真正发给模型之前,这些数据都应该经过协议适配,只保留目标 API 所允许的字段。
否则,今天是 Hermes,明天可能就是 Claude Code,后天也可能是任何一个 Agent 框架。
这次修复解决的并不仅仅是一个 400,而是补上了 Hermes 在多协议兼容上的最后一块拼图。
希望这篇文章,能帮后来遇到同样问题的人少踩几个坑,也希望越来越多的 Agent 框架,在内部协议和外部协议之间,建立起真正清晰的边界。
九、同类问题也值得关注
Hermes 的经验并非孤例。Claude Code 这类采用内部消息协议、再转换为 OpenAI 格式的 Agent,同样存在出现此类兼容性问题的可能。
为什么
Claude Code 内部使用 Anthropic Messages 格式,tool_use 块的字段结构与 OpenAI tool_calls 不同:
- Anthropic 的
tool_use.input可能为None或空 object - 适配层转换成 OpenAI 格式时,
function.arguments可能仍是None - 如果转换层没有在请求出口完成字段规范化,就可能出现与 Hermes 类似的 validation error
共性
修复思路是一致的:在请求出口做字段清洗和规范化,而非在源头砍功能。
十、关键文件与行号
| 文件 | 行号 | 作用 |
|---|---|---|
run_agent.py |
5004-5077 | 核心清洗函数 + 触发判断 |
agent/conversation_loop.py |
751-752 | 主循环中调用清洗 |
agent/chat_completion_helpers.py |
1319-1339 | 压缩总结路径调用清洗 |
agent/chat_completion_helpers.py |
1831-1836 | 流式请求路径调用清洗 |
agent/agent_runtime_helpers.py |
237-337 | 辅助:修复损坏的 tool_call JSON |
十一、复现与验证
复现条件:
- Hermes 使用
chat_completionsAPI 模式 - 会话中有过工具调用(产生 tool_calls 历史)
- 上游是严格校验的网关
验证修复:
- 有工具调用历史的会话能正常在多轮对话中继续
- 不再出现
Extra inputs are not permitted或Input should be a valid string错误 - Codex API 模式下字段完整保留
总结
这次排查前后花了不少时间,但最终定位下来,真正的问题其实并不复杂。
Hermes 为了兼容不同 Provider,在内部消息中保留了大量运行时元数据,例如 call_id、response_item_id、extra_content 等。这些字段对于框架内部是有意义的,但一旦未经处理直接发送到严格的 OpenAI Compatible API,就会因为协议不匹配而触发 Schema 校验失败。
从表面上看,这是一个 400 Bad Request;从本质上看,这是内部消息模型与外部协议没有完全解耦。
对于任何 Agent 框架来说,都不可避免地需要维护自己的内部状态,也需要适配越来越多的模型和 API。真正稳健的做法,不是在源头限制功能,而是在请求出口增加一层 Protocol Adapter(协议适配层),针对不同 Provider 输出符合其协议规范的请求格式。
这次 Hermes 的修复采用的正是这种思路:保留完整的内部消息,只对发送副本进行清洗。既保证了 Codex Responses API 的兼容性,也解决了严格 OpenAI Compatible API 的校验问题,同时不会影响后续切换 Provider。
很多时候,一个看似普通的 400,并不是模型出了问题,也不是网络出了问题,而是在提醒我们:协议边界没有处理好。
希望这次踩坑记录,能够帮助后来遇到相同问题的朋友少走一些弯路,也希望越来越多的 Agent 框架,在支持多模型、多 Provider 的同时,把协议兼容这一层做得更加完善。