中文 English

一次流式响应被重复消费:NewAPI v1.0.0-rc.10 MiniMax 转发 Bug 排查实录

发布时间: 2026-05-31
NewAPI OneAPI MiniMax 流式响应 AI Agent Bug排查 OpenAI兼容接口 工程实践

先说结论

问题现象:AI Agent 在 DingTalk 通道中回复了重复的文本。消息不是发送了两次,而是一条消息里的内容被复制了一遍。排障最终定位到 NewAPI(QuantumNous 维护的 OneAPI 分支)v1.0.0-rc.10 版本的一个流式响应 Bug:当通过 OpenAI Chat Completions 协议转发 MiniMax 模型时,finish chunk 会同时包含 delta.contentmessage.content,且两者内容完全一致。Agent 框架的流式处理器将两者都当作"可见文本"消费,导致最终文本被拼接两次。修复方案也很朴素:将 OpenClaw provider 协议从 openai-completions 切换到 anthropic-messages,OneAPI 原生支持该协议,且该路径下流式响应正常。

本文不会出现任何真实内网地址、Token、模型 ID 或私有路径。所有配置片段都已脱敏。

流式响应重复问题分析图

图 1:本文自制分析图。展示了从用户请求到最终重复回复的完整请求链路,以及流式 chunk 中的重复内容。

1. 问题背景:当 AI Agent 接入 IM,每一处异常都无处遁形

将 AI Agent 接入即时通讯工具(钉钉、微信、飞书等)已经成了很常见的使用模式。相比在终端里和 Agent 对话,IM 入口的好处显而易见:你不需要记住某个 Dashboard 地址,也不需要 SSH 到服务器上敲命令。随手发一条消息,Agent 就能执行脚本、查询状态、分析数据或整理摘要。

但这也会把问题放大。终端里多输出一段内容,你可能只是觉得有点烦。可如果这段内容被发送到钉钉或微信里,它就是一条正式消息,每一个字都暴露在用户眼前。更重要的是,IM 入口背后通常是一条长链路:消息接收 → 去重 → 会话路由 → 模型选择 → Agent 执行 → 流式拼接 → 文本归一化 → 发送 API。任何一层出现问题,最终用户看到的都是"这条消息不对劲"。

这次的场景是:用户通过 DingTalk 和 OpenClaw Agent 对话,无论问什么,Agent 都会把同一段回复在一条消息里重复两次。注意关键细节:不是收到两条消息,而是一条消息里有两份完全相同的文本。这个差异非常重要——它决定了排障的起点应该放在发送层还是模型层。

2. 问题表现:一条消息,两份内容

问题的表现用一句话就能说清:

用户问:hi
期望:你好!有什么需要帮忙的吗?
实际:你好!有什么需要帮忙的吗?你好!有什么需要帮忙的吗?

从 OpenClaw 的会话日志中可以看到明确的证据。使用 custom/DIY-123 模型(通过 OneAPI 网关转发)时,Agent 记录到的最终可见文本是:

{
  "provider": "custom",
  "model": "DIY-123",
  "finalText": "你好!有什么需要帮忙的吗?你好!有什么需要帮忙的吗?"
}

而使用原生 MiniMax provider 时,同一 prompt 的输出是正常的:

{
  "provider": "minimax",
  "model": "MiniMax-M2.7",
  "finalText": "你好!有什么需要帮忙的吗?"
}

对比非常清晰:同一个模型,在不同 provider 协议路径下,输出了不同质量的结果。 兼容网关路径产生了重复文本,原生路径完全正常。

Provider 对比调试图

图 2:同一 prompt 在不同 provider 下的输出对比。红色路径(openai-completions 协议)产生重复,绿色路径(anthropic-messages 协议)正常。

3. 排障过程:逆向追溯,逐层缩小范围

我的排障思路是:不先假设问题出在哪一层,而是用最小可复现样本逐层排除。基本上是一个"从外到内"的逆向追溯过程。

3.1 第一层:钉钉发送层是否重复发送?

很多 IM 集成问题,第一反应都是检查发送层。这是合理的——IM 平台通常有重试、回调、ack、消息去重等机制。如果是发送 API 被调用了两次,那问题就出在发送层。

但我注意到一个关键区别:用户看到的不是两条消息,而是一条消息里有两份内容。 这意味着发送层很可能只调用了一次,但调用时的 text 参数本身就已经重复了。

为了验证这一点,我查看了 DingTalk 插件的发送路径。发送层构造的是单条文本 item,并非天然会循环发送。我需要进一步确认:Agent 输出的最终文本是否已经重复。

3.2 第二层:Agent 执行结果检查

在 OpenClaw 的会话日志中,我找到了明确的原始输出记录。关键字段包括:

日志显示 custom/DIY-123finalText 已经包含了重复内容——这证明了重复发生在发送到 DingTalk 之前,发送层只是忠实地把已经重复的文本发出去。

这就把问题定位到了模型路由层和 provider 层。接下来需要找出:为什么通过 OneAPI 网关转发时,内容会重复?

3.3 第三层:直接测试 OneAPI 的流式响应

这一步是最关键的转折点。我不想在代码里猜,而是直接用 curl 测试 OneAPI 转发 MiniMax 的流式响应。测试脚本如下:

curl -s -X POST "https://<ONEAPI_ENDPOINT>/v1/chat/completions" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"hi"}],"stream":true,"max_tokens":500}' \
  | python3 -c '
import sys, json
for line in sys.stdin:
    line = line.strip()
    if not line or line == "data: [DONE]": continue
    if line.startswith("data: "):
        d = json.loads(line[6:])
        for c in d.get("choices",[]):
            delta = c.get("delta",{})
            finish = c.get("finish_reason","")
            msg = c.get("message",{})
            if "content" in delta and delta["content"]:
                print("DELTA content:", repr(delta["content"]))
            if finish:
                if "content" in msg and msg["content"]:
                    print("FINAL message.content:", repr(msg["content"]))
                print("FINISH:", finish)
'

输出结果:

DELTA content: 'Hello! How can I help you today?'
FINISH: stop
FINAL message.content: 'Hello! How can I help you today?'
FINISH: stop

Bug 确凿无疑。 同一个 "Hello! How can I help you today?" 被输出了两次:一次通过 delta.content(正常流式事件),一次通过 finish chunk 中的 message.content(不该出现)。

Curl 验证输出

图 3:Curl 命令直接验证——finish chunk 中同时存在 delta.content 和 message.content,内容完全一致。

4. 根因分析:OneAPI 的 finish chunk 包含了冗余的 message.content

4.1 OpenAI Chat Completions 协议标准

OpenAI Chat Completions 流式协议的标准行为是:在 stream 模式下,除了最后一个 chunk 之外,每个 chunk 的 choices[0] 只包含 delta 字段,不包含 message 字段。最后一个 chunk(finish_reason 不为 null)可以包含 message 字段,但标准实现中不会同时提供非空的 delta.contentmessage.content

标准 finish chunk 示例:

{
  "choices": [{
    "delta": {},
    "message": {"content": "Hello!"},
    "finish_reason": "stop"
  }]
}

或者:

{
  "choices": [{
    "delta": {},
    "finish_reason": "stop"
  }]
}

4.2 OneAPI v1.0.0-rc.10 的实际行为

但 OneAPI v1.0.0-rc.10(QuantumNous 分支)在转发 MiniMax 模型时,finish chunk 是这样的:

{
  "choices": [{
    "delta": {"content": "Hello! How can I help you today?"},
    "message": {"content": "Hello! How can I help you today?"},
    "finish_reason": "stop"
  }]
}

注意:deltamessage 中同时包含了相同的 content 对于 OpenAI 兼容协议的客户端来说,通常有两种处理策略:

  1. 只消费 delta.content,忽略 message.content——部分客户端使用此策略,不会触发此 Bug
  2. 同时消费两者——OpenClaw 的流式处理器会将两者都视为"可见文本",最终合并输出

OpenClaw 选择了策略 2,这在大多数兼容网关中工作正常。但 OneAPI 的 finish chunk 双重输出导致了这个差异。

4.3 为什么只有 MiniMax 有此问题?

这与 OneAPI 中 MiniMax 通道类型的实现有关。OneAPI 的 MiniMax 适配器(通道类型 35)在将 MiniMax 原生响应映射到 OpenAI 兼容格式时,在 finish chunk 中额外添加了 message.content,同时没有清除 delta.content,导致了双重输出。其他模型通道(如 OpenAI 原生通道)可能没有这个问题,因为它们的 finish chunk 处理逻辑不同。

5. 修复方案:切换到 anthropic-messages 协议

5.1 方案选择

找到根因后,有几个可能的修复方向:

  1. 修改 OpenClaw 流式处理器:让它在 finish chunk 中忽略 delta.content。但这是全局修改,可能影响其他 provider 的行为。
  2. 修改 OneAPI 代码:修复 finish chunk 的双重输出问题。但 OneAPI 不是我们维护的项目,升级或修复需要等上游发布。
  3. 切换协议通道:OneAPI 同时支持 /v1/chat/completions(OpenAI Chat Completions)和 /v1/messages(Anthropic Messages)两个协议端点。切换到 anthropic-messages 协议,该路径下流式响应正常。

方案 3 是最低成本、最高收益的选择。OneAPI 原生支持 Anthropic Messages 协议,且 OpenClaw 也内置了 anthropic-messages 作为 GENERIC_PROVIDER_APIS 之一。

5.2 具体更改

在 OpenClaw 配置中,将 provider 的 api 类型从 openai-completions 改为 anthropic-messages

{
  "providers": [
    {
      "name": "custom",
      "api": "anthropic-messages",  // 之前是 "openai-completions"
      "baseUrl": "https://<ONEAPI_ENDPOINT>",
      "apiKey": "<TOKEN>",
      "models": [
        {"model": "DIY-123", "reasoning": true}
      ]
    }
  ]
}

同时调整 baseUrlanthropic-messages 协议会自动在 base URL 后追加 /v1/messages 路径。因此只需写基础地址,不需要手动加 /v1 后缀。

5.3 验证结果

切换协议后,再次用 curl 测试 /v1/messages 端点的流式响应:

data: {"type":"content_block_delta","delta":{"text":"Hello!"}}
data: {"type":"message_stop"}

没有重复,没有再出现冗余的 message.content。Agent 在实际对话中也恢复了正常:

用户:hi
Agent:你好!有什么需要帮忙的吗?

6. 为什么不是修改 OpenClaw 的流式处理器?

你可能想问:既然 OpenClaw 同时消费 delta.contentmessage.content,难道不是 OpenClaw 的 Bug?

这个思考方向是对的——严格来说,OpenClaw 的流式处理可以更"防御性"地忽略 finish chunk 中的 delta.content。但从协议兼容性的角度来看,OpenClaw 的行为在大多数网关下是正常的。大多数 OpenAI 兼容网关的 finish chunk 不同时设置这两个字段,或者 delta 在 finish chunk 中是空的。问题出在 OneAPI 这个特定的实现上。

另外,修改 OpenClaw 的流式处理器是一个全局变更。如果将来有某个 provider 需要在 finish chunk 中通过 delta.content 传递额外信息(比如工具调用的结果片段),全局忽略可能会引入新的 Bug。相比之下,切换协议路径是一个精确、可逆、不影响其他 provider 的修复。

7. 总结与可复用排障方法

这次排障最有价值的收获,不是"改了一个配置",而是验证了一套可复用的排障方法:

1. 先区分重复类型

2. 用最小 prompt 复现

只回复这两个字:测试

不要一开始就用长 prompt 测试。最小 prompt 能排除上下文干扰,让问题在最短路径上暴露。

3. 直接验证原始响应 不要依赖日志推断,直接用 curl 测试目标端点的原始输出。通过自描述的 Python 过滤脚本,只保留关键字段,让判断变得一目了然。

4. 做 provider 对照实验 如果系统里有多个 provider 或协议路径,做同 prompt 对照。这能快速区分是"模型本身的问题"还是"某个协议路径的问题"。

5. 跟踪最终可见文本 Agent 环境中,不能只看模型 API 是否返回 200。必须检查 Agent 拼接后的 finalText,因为流式处理、工具调用、reasoning 和 fallback 都可能影响最终输出。

8. Q&A

Q1:这个 Bug 在 OneAPI 官方仓库有提交 Issue 吗?

QuantumNous/new-api 仓库中,关于 MiniMax 通道的相关 Issue 较少,该 Bug 可能尚未被充分报告。建议遇到此问题的用户向项目提交 Issue 或 PR,修复方向是在 MiniMax 通道的 finish chunk 生成逻辑中,确保不同时设置 delta.contentmessage.content

Q2:升级 OneAPI 版本能解决吗?

需要验证。该 Bug 在 v1.0.0-rc.10 版本中存在。如果后续版本修复了 MiniMax 通道的 finish chunk 处理逻辑,升级可以解决。但在此之前,切换协议路径是安全的 workaround。

Q3:anthropic-messages 协议和 openai-completions 协议有什么区别?

两者都是 OneAPI 原生支持的协议端点。主要差异在数据格式:

OneAPI 接收 MiniMax 原生响应后,会分别转换为这两种格式。Bug 只出现在前者,后者正常工作。

Q4:这种问题会在非流式请求中出现吗?

不会。非流式请求只返回一个完整的 response body,不存在流式事件拼接的问题。该 Bug 是流式响应特有的。

Q5:如果使用其他模型(非 MiniMax),会触发同样的问题吗?

取决于 OneAPI 中该模型的通道类型实现。不同模型通道(OpenAI、Azure、Google、MiniMax 等)有各自独立的响应转换逻辑。该问题目前确认存在于 MiniMax 通道(类型 35)中。如果其他通道在 finish chunk 中有类似的双重输出逻辑,同样可能触发。

Q6:这个问题在 WeChat 等其他 IM 通道中也会出现吗?

会。这不是 IM 通道特定的问题,而是模型层的输出问题。无论后端是 DingTalk、WeChat 还是其他 IM 通道,只要 Agent 使用相同的 provider 路径和协议,都会产生相同的重复文本。这也是为什么根因修复比在各个 IM 通道分别打补丁更靠谱。

参考资料