[Bug] openai-compatible 流式解析把 tool_calls:[](空数组)误判为工具调用阶段,思考过程被逐 token 碎片化(一词一行)
Nobody has claimed this yet.
- Dominant language
- No language data
- Stars
- 22
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
类别 Category: 模型设置 / 切换 · Model config | 框架 Framework: ZCode Agent(自研) | 严重程度 Severity: 影响体验 · Major | 频率 Frequency: 必现 · Always
问题描述 · Description
配置 openai-compatible 自定义 provider 时,如果上游网关对每个 SSE chunk 都序列化全部字段(不少国产网关如此:每个 delta 都携带 tool_calls: []、content: ""),ZCode 内嵌的 AI SDK openai-compatible 流式解析会把空数组 tool_calls: [] 误判为"进入工具调用阶段",导致:
- 每输出一个 reasoning token 就强制关闭当前思考块(
reasoning-end),下一个 token 再重新开启(reasoning-start,id 恒为"reasoning-0"); - 运行时消费侧
reasoning_end会把 id 从reasoningById删除,下次reasoning_start重建 block 并 push 进 parts 数组 → 每个 token 一个独立 reasoning part; - 前端把多个 reasoning part 用
\n\n拼接渲染 → 思考过程一词一行;同时每个 token 触发一对 reasoning start/end 流事件,UI 反复开合思考行造成卡顿。
实测一次 13.8k 字符的思考内容在消息存储中被拆成 2187 个 reasoning part(平均每段 1–9 字符);正常应为个位数段落级 part。
复现步骤 · Steps to reproduce
- 配置一个 openai-compatible 自定义 provider,指向"全字段序列化"的上游(即流式响应每个 chunk 的 delta 都包含
tool_calls: []与content: ""字段的 reasoning 模型); - 新建会话发送任意问题,开启思考模型;
- 观察思考过程渲染,并检查消息存储中该次响应的
reasoningText。
期望表现 · Expected
整轮思考聚合为 1 个(或少量段落级)reasoning block,流式渲染连贯成段。
实际表现 · Actual
每个 token 生成独立 reasoning block:渲染一词一行;单次响应产生数千对 reasoning start/end 事件;UI 卡顿、思考区显示杂乱。
根因定位 · Root cause
zcode.cjs(Desktop 3.11.2.6792 / CLI 0.16.5)中 openai-compatible doStream 的 transform:
let fe = ce.reasoning_content ?? ce.reasoning;
if (fe && (y || (b.enqueue({type:"reasoning-start",id:"reasoning-0"}), y=!0),
b.enqueue({type:"reasoning-delta",id:"reasoning-0",delta:fe}))),
ce.content && (y && (b.enqueue({type:"reasoning-end",id:"reasoning-0"}), y=!1), /* text-start/delta */),
ce.tool_calls != null) {
y && (b.enqueue({type:"reasoning-end",id:"reasoning-0"}), y=!1);
/* for (ue of ce.tool_calls) ... */
}
ce.tool_calls != null 对空数组也成立(JS 中 [] != null 为 true),于是每个 reasoning delta 之后紧跟的空 tool_calls 字段都会强制结束思考块。
建议修复 · Suggested fix
将工具调用判定改为长度判断:
ce.tool_calls?.length > 0 // 或 Array.isArray(ce.tool_calls) && ce.tool_calls.length
仅此一处改动即可根治,官方 OpenAI 兼容行为(无工具调用时省略该字段)不受影响。可选加固:对同轮相邻的多个 reasoning part 做合并,避免上游其他形式的块碎片放大存储与渲染成本。
验证证据 · Evidence
用真实捕获的 SSE 流做端到端管线模拟:
- 原样透传 149 个含
tool_calls: []的 reasoning chunk → 产生 149 个 reasoning block、reasoning-start×149(一词一行); - 仅剔除空数组字段后重放 →
reasoning-start×1、1 个 block、文本完全连贯。
环境 · Environment
- ZCode Desktop 3.11.2 (6792) / CLI 0.16.5
- macOS(darwin 25.5.0)arm64
- 复现频率:在受影响上游上必现(Always)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in zcode.cjs at the openai-compatible doStream transform and inspect how ce.tool_calls is handled after reasoning deltas. Replay the captured SSE stream described in the issue, then verify that empty tool_calls arrays no longer end reasoning and that the response remains a single or small number of coherent reasoning blocks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- api, stream-processing
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100