FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

[Bug] 上游 tool_call.index 从 1 开始编号时,流式快照错位产生 id=None 的畸形 tool_call 导致工具调用整体失败 · Issue #9590 · AstrBotDevs/AstrBot · GitHub

[Bug] 上游 tool_call.index 从 1 开始编号时,流式快照错位产生 id=None 的畸形 tool_call 导致工具调用整体失败 #9590

Description

问题概述

当 OpenAI 兼容上游把流式 tool_call.index 从 1 开始编号(OpenAI 规范要求从 0 开始)时,AstrBot 会产出 id=None / name="" 的畸形 tool_call,进而在构造 ToolCall 时抛 pydantic ValidationError,导致整轮工具调用失败。在子代理委托、多工具并行调用的场景下,表现为委托整体崩掉。

这与已关闭的 #8911#6661 属于同一条故障链上的不同环节:那两个 issue 修的是下游症状(过滤 None 名称、补齐缺失的 index),但没有修 index 编号偏移这个源头,也没有给 id 做兜底。因此在网关类上游(new-api / Octopus / LiteLLM 等)下问题仍会复现。

根因分析

OpenAI SDK 的流式快照累加器直接把 index 当作列表下标使用:

openai/lib/streaming/chat/_completions.py(实测 openai==2.24.0)

tool_call = tool_calls[tool_call_delta.index]                        # 第 535 行
tool_call_snapshot = choice_snapshot.message.tool_calls[tool_index]  # 第 714 行

当上游首个 tool_call 的 index=1 时,对空列表取下标 [1] 会抛 IndexError: list index out of range。

而 astrbot/core/provider/sources/openai_source.py 把这个异常吞掉了(只记日志、不做处理):

if delta is not None:
    try:
        state.handle_chunk(chunk)
    except Exception as e:
        logger.error("Saving chunk state error: " + str(e))

于是快照被写坏:同一个 tool_call 的 name 与 arguments 被拆进两个错位的槽位,中间多出一个空洞条目。随后 get_final_completion() 从这个已损坏的快照中取结果。

日志里那条反复出现的 Saving chunk state error: list index out of range 并不是无害噪音,而是本故障的第一现场。

为什么现有补丁没能覆盖

openai_source.py 中针对 #6661 的补丁只处理 index 缺失,没有处理 index 偏移

# Fix for #6661: Add missing 'index' field to tool_call deltas
# Gemini and some OpenAI-compatible proxies omit this field
if not hasattr(tc, "index") or tc.index is None:
    tc.index = idx

当 index 字段存在且等于 1 时,这段判断不生效。

同时 astrbot/core/provider/entities.py 的 to_openai_tool_calls_model() 对 id 没有任何兜底:

ToolCall(
    id=self.tools_call_ids[idx],   # 上游给 None 时直接抛 ValidationError
    ...
)

ToolCall.id 声明为 str,传入 None 必然抛出:

pydantic_core._pydantic_core.ValidationError: 1 validation error for ToolCall
id
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]

值得注意的是,tool_loop_agent_runner.py 已经为畸形名称做了兜底(MALFORMED_TOOL_NAME_PLACEHOLDER = "__malformed_tool_name__"),openai_source.py 也为非法 arguments 做了兜底(解析失败则置为 {}),唯独 id 没有。这正是本 issue 仍会崩溃的原因。

复现结果

用同一份真实上游响应做对照(模型经 Octopus 网关,tool_choice=required,两个工具并行调用):

修复前

上游首个 tool_call 的 index = 1
handle_chunk 失败次数 = 1
最终快照 tool_calls 数 = 3
   id='call_tjoaBjx1...'  name='es_search'    args=''
   id=None                name=''             args='{"query":"SKILL.md"}'   <<< 畸形
   id='call_LDfNsqfl...'  name='file_search'  args='{"query":"summarize 目录"}'

修复后

handle_chunk 失败次数 = 0
最终快照 tool_calls 数 = 2
   id='call_tjoaBjx1...'  name='es_search'    args='{"query":"SKILL.md"}'
   id='call_LDfNsqfl...'  name='file_search'  args='{"query":"summarize 目录"}'

es_search 的 name 与 arguments 正确合并,幽灵条目消失。

补充:本人另做了对照实验,把同一请求分别发往网关直连与旁路代理,两条路径返回结果逐字段一致,说明问题不在某一个代理实现,而在 index 编号不符合规范时 AstrBot 侧缺少归一化。

影响范围

ProviderOpenAIOfficial 被以下 provider 继承,均共享此问题:

  • groq_source.py → ProviderGroq
  • longcat_source.py → ProviderLongCat
  • oai_aihubmix_source.py → ProviderAIHubMix
  • openrouter_source.py → ProviderOpenRouter
  • xai_source.py → ProviderXAI
  • xiaomi_source.py → ProviderXiaomi
  • zhipu_source.py → ProviderZhipu

加上 openai_source.py 自身共 8 个。只在流式 + 多个并行 tool_call 时触发,单个工具调用通常不致命,因此表现为偶发。

建议修复

1. 归一化 index(治本) — openai_source.py,紧接现有 #6661 补丁:

# 每次请求独立的局部状态,不能挂在 self 上,否则并发请求会互相污染
tool_call_index_map: dict[int, int] = {}

...

if not hasattr(tc, "index") or tc.index is None:
    tc.index = idx
else:
    # 某些 OpenAI 兼容上游把 index 从 1 开始编号,SDK 直接用它做列表下标,
    # 会抛 IndexError 并导致快照错位。这里重映射为从 0 开始的连续序号。
    remapped = tool_call_index_map.setdefault(tc.index, len(tool_call_index_map))
    if remapped != tc.index:
        tc.index = remapped

2. 给 id 兜底(防御) — entities.py 的 to_openai_tool_calls_model():

raw_id = self.tools_call_ids[idx] if idx < len(self.tools_call_ids) else None
func_name = self.tools_call_name[idx] if idx < len(self.tools_call_name) else None
call_id = raw_id
if not isinstance(call_id, str) or not call_id:
    call_id = f"call_{idx}"
    logger.warning("上游返回的 tool_call 缺少合法 id,已回退为 %s(函数: %s)", call_id, func_name or "unknown")

并把 extra_content 的查询键改用 raw_id(兜底 id 不存在于该字典中),同时对越界访问做保护。

这样即使将来遇到其它形式的畸形响应,单个坏 tool_call 也不会拖垮整次请求。

环境

  • AstrBot v4.26.7(Windows 本地部署)
  • openai SDK 2.24.0
  • 上游:OpenAI 兼容网关(自建)转发,reasoning_effort=max
  • 已确认上述两处代码在当前 master 分支仍未修复

是否愿意提交 PR

愿意。以上两处改动我已在本地实装并通过复现验证,如维护者认可该方案,我可以整理成 PR 提交。

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:providerThe bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner.priority: p0

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions


    Back | FazBrowse Home | New Git URL