一次流式响应被重复消费:NewAPI v1.0.0-rc.10 MiniMax 转发 Bug 排查实录
先说结论
问题现象:AI Agent 在 DingTalk 通道中回复了重复的文本。消息不是发送了两次,而是一条消息里的内容被复制了一遍。排障最终定位到 NewAPI(QuantumNous 维护的 OneAPI 分支)v1.0.0-rc.10 版本的一个流式响应 Bug:当通过 OpenAI Chat Completions 协议转发 MiniMax 模型时,finish chunk 会同时包含
delta.content和message.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 协议路径下,输出了不同质量的结果。 兼容网关路径产生了重复文本,原生路径完全正常。
图 2:同一 prompt 在不同 provider 下的输出对比。红色路径(openai-completions 协议)产生重复,绿色路径(anthropic-messages 协议)正常。
3. 排障过程:逆向追溯,逐层缩小范围
我的排障思路是:不先假设问题出在哪一层,而是用最小可复现样本逐层排除。基本上是一个"从外到内"的逆向追溯过程。
3.1 第一层:钉钉发送层是否重复发送?
很多 IM 集成问题,第一反应都是检查发送层。这是合理的——IM 平台通常有重试、回调、ack、消息去重等机制。如果是发送 API 被调用了两次,那问题就出在发送层。
但我注意到一个关键区别:用户看到的不是两条消息,而是一条消息里有两份内容。 这意味着发送层很可能只调用了一次,但调用时的 text 参数本身就已经重复了。
为了验证这一点,我查看了 DingTalk 插件的发送路径。发送层构造的是单条文本 item,并非天然会循环发送。我需要进一步确认:Agent 输出的最终文本是否已经重复。
3.2 第二层:Agent 执行结果检查
在 OpenClaw 的会话日志中,我找到了明确的原始输出记录。关键字段包括:
provider:实际使用的 providermodel:实际使用的模型finalText:Agent 拼接后的最终可见文本
日志显示 custom/DIY-123 的 finalText 已经包含了重复内容——这证明了重复发生在发送到 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(不该出现)。

图 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.content 和 message.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"
}]
}
注意:delta 和 message 中同时包含了相同的 content。 对于 OpenAI 兼容协议的客户端来说,通常有两种处理策略:
- 只消费
delta.content,忽略message.content——部分客户端使用此策略,不会触发此 Bug - 同时消费两者——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 方案选择
找到根因后,有几个可能的修复方向:
- 修改 OpenClaw 流式处理器:让它在 finish chunk 中忽略
delta.content。但这是全局修改,可能影响其他 provider 的行为。 - 修改 OneAPI 代码:修复 finish chunk 的双重输出问题。但 OneAPI 不是我们维护的项目,升级或修复需要等上游发布。
- 切换协议通道: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}
]
}
]
}
同时调整 baseUrl:anthropic-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.content 和 message.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. 先区分重复类型
- 多条重复消息 → 检查发送层:webhook 重试、队列消费、消息去重
- 一条消息内重复 → 检查模型层:provider 输出、流式拼接、最终文本
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.content 和 message.content。
Q2:升级 OneAPI 版本能解决吗?
需要验证。该 Bug 在 v1.0.0-rc.10 版本中存在。如果后续版本修复了 MiniMax 通道的 finish chunk 处理逻辑,升级可以解决。但在此之前,切换协议路径是安全的 workaround。
Q3:anthropic-messages 协议和 openai-completions 协议有什么区别?
两者都是 OneAPI 原生支持的协议端点。主要差异在数据格式:
openai-completions(/v1/chat/completions):使用choices[].delta字段传递流式内容anthropic-messages(/v1/messages):使用type: content_block_delta事件传递流式内容
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 通道分别打补丁更靠谱。
参考资料
- OpenAI Chat Completions API 文档:
https://platform.openai.com/docs/api-reference/chat/create - Anthropic Messages Streaming 文档:
https://docs.anthropic.com/en/api/messages-streaming - QuantumNous/new-api GitHub:
https://github.com/QuantumNous/new-api - OpenClaw 官方文档:
https://docs.openclaw.ai