问题概述
当 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 提交。
问题概述
当 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 的 index=1 时,对空列表取下标 [1] 会抛 IndexError: list index out of range。
而 astrbot/core/provider/sources/openai_source.py 把这个异常吞掉了(只记日志、不做处理):
于是快照被写坏:同一个 tool_call 的 name 与 arguments 被拆进两个错位的槽位,中间多出一个空洞条目。随后 get_final_completion() 从这个已损坏的快照中取结果。
日志里那条反复出现的 Saving chunk state error: list index out of range 并不是无害噪音,而是本故障的第一现场。
为什么现有补丁没能覆盖
openai_source.py 中针对 #6661 的补丁只处理 index 缺失,没有处理 index 偏移:
当 index 字段存在且等于 1 时,这段判断不生效。
同时 astrbot/core/provider/entities.py 的 to_openai_tool_calls_model() 对 id 没有任何兜底:
ToolCall.id 声明为 str,传入 None 必然抛出:
值得注意的是,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 继承,均共享此问题:
加上 openai_source.py 自身共 8 个。只在流式 + 多个并行 tool_call 时触发,单个工具调用通常不致命,因此表现为偶发。
建议修复
1. 归一化 index(治本) — openai_source.py,紧接现有 #6661 补丁:
2. 给 id 兜底(防御) — entities.py 的 to_openai_tool_calls_model():
并把 extra_content 的查询键改用 raw_id(兜底 id 不存在于该字典中),同时对越界访问做保护。
这样即使将来遇到其它形式的畸形响应,单个坏 tool_call 也不会拖垮整次请求。
环境
是否愿意提交 PR
愿意。以上两处改动我已在本地实装并通过复现验证,如维护者认可该方案,我可以整理成 PR 提交。