📅 2026-06-20 ⏱ 3 分钟阅读

一个 HTTP 400 修了两天,最后改了十几行代码

给 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多组被拆逐组合并
4content+content 连续不动
5TC+TC 连续不动
6已正确的不动不变
7集成到主流程全链条通过

最后嵌到 sanitize_api_messages() 里,每次 API 请求前自动执行。

一个会话的实际数据

指标DB 里实际发送
助理消息总数697737
被拆分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? 评论区聊聊,让我知道我不是一个人。

🏠 首页 📝 文章 🏷️ 标签 💬 关于 🔍 搜索