Skip to content

fix(provider): 修复流式工具调用元数据重复拼接(可选兼容模式) - #10146

Open
x1051445024 wants to merge 2 commits into
AstrBotDevs:masterfrom
x1051445024:fix/stream-toolcall-metadata-duplication
Open

x1051445024 wants to merge 2 commits into
AstrBotDevs:masterfrom
x1051445024:fix/stream-toolcall-metadata-duplication

Conversation

@x1051445024

@x1051445024 x1051445024 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

问题说明

关联 #7694,以及 #7735 中的讨论。本 PR 提供需要显式开启的兼容处理,不是通用字符串去重器。

部分 OpenAI 兼容网关会在每个 arguments 增量片段中重复发送完整的工具调用 idfunction.name。SDK 会将这些字符串直接拼接,最终产生不存在的工具名或过长的调用 ID。

但相同字符串也可能是合法分片。例如,下面这段流有两种可能的含义:

{"index":0,"id":"id","function":{"name":"ping","arguments":"{"}}
{"index":0,"id":"id","function":{"name":"ping","arguments":"}"}}

它既可能是重复发送 id / ping,也可能是合法增量,最终应得到 idid / pingping。仅凭字段相等、字符串长度或 arguments 已经开始,都无法区分这两种情况。因此,默认行为保持不变,不自动去重

修改内容

  • 新增默认关闭、仅接受 JSON 布尔值 true 的 provider 配置项 deduplicate_streaming_tool_metadata
  • 显式开启后,按工具调用 index 分别处理:只有与该槽位初始非空 ID/name 字段对完全一致的重放,才会在进入 ChatCompletionStreamState.handle_chunk() 前移除重复字段。
  • 不修改 arguments 增量或 extra_content。出现部分字段、变化字段或空字符串字段后,该槽位在本次请求剩余阶段停止去重,保留 SDK 增量语义。
  • 所有状态只保存在本次 _query_stream() 调用内。不根据 ID 变化自动拆出新调用,不改写最终字符串,不生成兜底 ID,也不重复实现 index 重映射。
  • 新增 35 个参数化回归用例,经过 AstrBot 的真实 _query_stream() 和 SDK 累加器;仅替换网络返回流。

开启方式

只有通过网关 chunk 样本确认其发送的是完整 ID/name 字段对,而非字段增量时,才应开启。

这是高级的本地 provider / 模型配置项。在 data/cmd_config.jsonprovider 数组中,为受影响的现有配置项添加以下字段,并保留其他配置:

{
  "deduplicate_streaming_tool_metadata": true
}

手动修改前先停止 AstrBot 并备份配置,修改后重新启动。删除此字段或改为 JSON 布尔值 false 即可恢复标准增量行为;字符串 "true" 不会启用此功能。

该字段不应放入 custom_extra_body,也不会发送给上游 API。本 PR 没有新增 WebUI 控件或修改导航。

最小合成回放

以下是构造的回归输入,不是生产环境原始 SSE 抓包。每行表示按顺序到达的 choices[0].delta.tool_calls[0]

{"index":0,"id":"call_abc","type":"function","function":{"name":"reverse_image_search","arguments":"{\"query\":"}}
{"index":0,"id":"call_abc","type":"function","function":{"name":"reverse_image_search","arguments":"\"sample\""}}
{"index":0,"id":"call_abc","type":"function","function":{"name":"reverse_image_search","arguments":"}"}}

开启兼容选项后,应得到一个 call_abc / reverse_image_search 调用,参数为 {"query":"sample"}

测试还覆盖初始 arguments 为空、多个槽位交错、同一 provider 连续请求、合法重复字符串保持不变、重复参数片段不被吞掉,以及 extra_content 的键不能发生碰撞等情况。

适用范围与相关 PR

  • fix(provider): normalize 1-based streaming tool_call indexes and fallback missing ids (#9590) #9593fix(provider): fall back when upstream omits tool_call id #9681 处理 index 归一化及缺失 ID,不包含本 PR 的重复元数据兼容处理。本 PR 基于 master,不依赖这两个 PR,也不重复修改它们负责的逻辑。
  • 同 index 出现不同 ID 可能只是正常分片,本 PR 不会将其自动拆成新的逻辑调用。
  • 不声称解决超出现有处理范围的缺失 index、从 1 开始或不连续的 index、完整 arguments 快照重复发送、仅 name 或仅 ID 的重放,以及尚未确认协议行为的网关变体。
  • 之前事故记录中的 OpenAI SDK 为 2.33.0。本次合成回放环境为 Windows、Python 3.12.12、OpenAI 2.46.0、Pydantic 2.13.4。手头没有当时的原始 SSE 或准确网关构建版本。

测试结果

python -m pytest tests/test_openai_source.py tests/test_tool_loop_agent_runner.py -q
161 passed, 1 warning

ruff check . tests/test_openai_source.py
All checks passed!

ruff format --check . tests/test_openai_source.py
514 files already formatted

git diff --check
No whitespace errors

Ruff 使用仓库指定的 0.15.22 版本。pytest 的警告来自已有的 Python audioop 弃用提示。本地未运行整个仓库的 pytest,验证范围为上面的 provider 与 tool-loop 两套测试。

作为反向对照,将 _query_stream() 替换为基线提交 b06550567b25d7ef4404bf526e448c869cc5d4ec 中的原始实现,再运行 35 个 metadata 用例,结果为 3 failed、32 passed:两个重复字段对用例及交错重放用例因 ID 被拼接而失败,其他用例保持原有行为。

针对 Sourcery 的审查意见,还补充了显式空字符串 ID/name 的测试。修改前 3 个空字符串用例失败;将真值判断改为显式 is not None 判断后,全部通过。目前 6 个部分字段或变化字段用例均会验证:该槽位在本次请求后续阶段不会重新开始去重。

检查清单

  • 非破坏性变更:现有配置保持标准增量行为。
  • 问题与流式边界处理方案已在 fix: 修复工具调用名重复拼接问题(流式响应chunk重复累积) #7735 讨论。
  • 已提供针对性回归测试和反向对照结果。
  • 未引入新的运行时依赖。
  • 未修改 WebUI 导航、标签或 API schema,无需更新相关截图。
  • 未包含凭证、私人用户内容或生产环境原始请求数据。

Sourcery 摘要(中文翻译)

为在流式响应中重复发送完整工具调用元数据的网关增加可选兼容处理。

  • 新增功能:提供默认关闭的 provider 配置项,用于抑制重复的完整工具调用 ID 与函数名。
  • 问题修复:在显式开启兼容模式时,避免重复元数据被拼接成错误的 ID 或工具名;默认仍保留标准增量行为。
  • 行为约束:按工具调用 index 和请求隔离处理,不修改参数片段、额外内容或合法元数据。
  • 测试覆盖:重复元数据、交错调用、请求隔离、部分字段更新及合法重复分片。

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/openai_source.py" line_range="678" />
<code_context>
+                        elif metadata and metadata == tool_call_metadata[tc.index]:
+                            tc.id = None
+                            tc.function.name = None
+                        elif raw_id or raw_name:
+                            # Partial or changing metadata is not an exact replay.
+                            # Keep SDK delta semantics for the rest of this slot.
+                            tool_call_metadata[tc.index] = None
             # 跳过 delta=None 的 chunk,避免 SDK 内部 _convert_initial_chunk_into_snapshot
             # 第 747 行 choice.delta.to_dict() 抛出 NoneType 错误。
</code_context>
<issue_to_address>
**issue (bug_risk):** When compatibility mode is enabled, a partial metadata update whose supplied ID/name value is an empty string does not clear the slot because `raw_id or raw_name` is false. A later complete pair equal to the initial pair is then incorrectly suppressed even though replay suppression should have been disabled for the rest of that slot.

**Triggers:** When a tool-call slot starts with a complete nonempty ID/name pair, then receives an empty-string partial metadata update, followed by the original pair again.

**Suggested fix:** Track whether either metadata field was supplied rather than using truthiness, and disable the slot for any partial or changing metadata update, including empty strings.

```suggestion
                        elif raw_id is not None or raw_name is not None:
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 1 finding to address first, and when enabled, a misclassified repeated metadata fragment could produce the wrong tool-call ID or function name and cause an incorrect tool invocation before the stream is reverted. The parsing behavior is opt-in and future executions stop after reverting, but any side effect from a mistaken tool call would need separate remediation.

Blocking findings: astrbot/core/provider/sources/openai_source.py:678


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread astrbot/core/provider/sources/openai_source.py Outdated

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sourcery assessment

Approved.

@x1051445024 x1051445024 changed the title fix(provider): opt in to repeated streaming tool-call metadata handling fix(provider): 修复流式工具调用元数据重复拼接(可选兼容模式) Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant