Skip to content

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

Description

@x1051445024

问题概述

当 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 提交。

Activity

  1. added
    area:providerThe bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner.
    on Aug 7, 2026
  2. dosubot commented on Aug 7, 2026

    @dosubot

    感谢这份详尽的根因分析!问题描述得非常清晰,修复方案也很合理。我验证了当前代码状态,确认你的分析是准确的:

    现有 #6661 补丁确实只覆盖了 index 缺失的情况,对 index 存在但从 1 开始编号的场景无效 [1]。当 index=1 时条件 tc.index is None 不成立,补丁被跳过,SDK 内部以 [1] 访问空列表触发 IndexError,被外层 try/except 静默吞掉 [2]。

    to_openai_tool_calls_model() 中 id 确实没有兜底,直接将 self.tools_call_ids[idx](可能为 None)传给 ToolCall 构造器,必然触发 pydantic ValidationError [3]。

    值得一提的是,之前有两个未合并的 PR 提出了更广泛的防御方案:

    但这两个 PR 都没有合并,所以问题在当前 master 上仍然存在。

    你提出的两处修复(index 重映射 + id 兜底)方向是正确的,且与现有代码风格一致——tool_loop_agent_runner.py 已经为畸形 name 做了 placeholder,openai_source.py 已经为非法 arguments 做了 {} 回退,补上 id 兜底是自然的延续。期待你的 PR 🎉

    To reply, just mention @dosu.


    Share context across your team and agents. Try Dosu.

    Leave Feedback Learn about AstrBot with Dosu Add Dosu to your team

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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