给 Hermes Agent 修了个 bug,从发现到合 PR 花了两天。
说出来你可能不信——根因是一条 assistant 回复被拆成了两条。十几行代码修好,写测试用例的时间比写函数还长。
起因:一个不是你问题的400
一天早上起来看后台,DeepSeek v4 Flash 在持续报错:
Error code: 400 - Messages with role 'tool' must be a response to
a preceding message with 'tool_calls'
HTTP 400。模型拒绝了你发送的请求,说你格式不对。
但奇怪的是,同样的请求体发给 Claude 和 GPT,完全正常。 所以一开始我怀疑是 DeepSeek 的兼容性问题,甚至想过在代码里给它加个例外处理。
后来一想不对——400 是 HTTP 4xx,说明是请求方的问题,不是服务方的问题。
排查:一条消息被拆成了两条
翻出实际发出的 payload,对比正常格式:
模型要求的交替规则:
user → assistant(含 tool_calls) → tool → assistant(含 tool_calls) → tool
实际发出的 payload:
...
assistant(只有 content)
assistant(只有 tool_calls) ← 连续两条 assistant!违反交替规则!
tool
DB 里存的是一条(content + tool_calls 在一起),谁在中间拆了它?
翻了半天代码,找到问题路径:某个处理 streaming 响应的路径,在处理完 content 后继续处理 tool_calls,没考虑到这两部分应该合并后再写出。一般情况下 buffer 是累积的,但特定条件下——流式响应被中断继续、session replay 触发了另一个路径——buffer 被分两次 flush 了。
为什么之前没发现
| 模型 | 对连续两条 assistant 的反应 | bug 状态 |
|---|---|---|
| Claude | 自己合并,无异常 | ✅ 隐藏 |
| GPT | 自己合并,无异常 | ✅ 隐藏 |
| DeepSeek v4 Flash | 严格校验 → HTTP 400 | ❌ 暴露 |
这其实是个好bug。 Claude 和 GPT 容错高,这个 bug 在它们上永远不会被发现。DeepSeek 严格校验反而是帮了忙——把数据层面的问题在 HTTP 层面揪了出来。
所以这个 bug 不是 DeepSeek 引入的,是一直都在,只是没有模型触发过。
修:十几行代码 + 7个测试用例
加了一个防御性合并函数,在每次发 API 请求前自动扫描一遍整个消息列表:
def merge_split_assistant_messages(messages):
merged = []
for msg in messages:
# 如果上条是 assistant(content only)
# 本条是 assistant(tool_calls only)
# 那这就是被拆散的——合回去
if (merged and
merged[-1]["role"] == "assistant" and
msg["role"] == "assistant" and
merged[-1].get("content") and
not merged[-1].get("tool_calls") and
msg.get("tool_calls") and
not msg.get("content")):
merged[-1]["tool_calls"] = msg["tool_calls"]
continue
merged.append(msg)
return merged
十几行代码。写测试用例花的时间比写函数还多——7 个 edge case:
| # | 场景 | 预期 |
|---|---|---|
| 1 | 正常不拆的消息 | 不变 |
| 2 | 单组被拆(content + TC) | 合并 |
| 3 | 多组被拆 | 逐组合并 |
| 4 | content+content 连续 | 不动 |
| 5 | TC+TC 连续 | 不动 |
| 6 | 已正确的不动 | 不变 |
| 7 | 集成到主流程 | 全链条通过 |
最后嵌到 sanitize_api_messages() 里,每次 API 请求前自动执行。
一个会话的实际数据
| 指标 | DB 里 | 实际发送 |
|---|---|---|
| 助理消息总数 | 697 | 737 |
| 被拆分 | 40 条正确的 | 80 条(40对) |
DB 存的是对的,但发出去的时候被拆了。数据层面的问题,在 HTTP 层面才暴露出来。
几点感受
- 容错是好东西也是坏东西。 Claude 和 GPT 容错高,这个 bug 在它们上永远不会被发现。DeepSeek 严格校验反而是帮了忙——不是它的问题,但它帮你把问题挖出来了。
- 别急着怪上游。 看到 400 第一反应是"DeepSeek 兼容性不行"——差点就写了 workaround 绕过去。回头检查请求结构才发现是自己的问题。
- 一个函数的 90% 时间花在边界条件上。 十几行代码,7 个 edge case,测完发现函数本身只占了总开发时间的 30%。
- PR 交上去第二天 reviewer 给了意见——去掉一个无用的临时变量。 于是又多了一次 commit。修 bug 的结尾往往不是 final,是 final_v2。
链接
你有没有遇到过"不是我的问题,排查两天发现真是我的问题"的 bug? 评论区聊聊,让我知道我不是一个人。