在支持工具调用的 AI 应用中,最难定位的一类问题不是 HTTP 请求失败,而是请求返回了 200,界面也开始出现流式内容,最后却出现“工具参数不是合法 JSON”“函数名为空”“工具重复执行”或“回答停在半截”。这类故障经常被误判为模型不支持工具,实际上更常见的原因是客户端把网络分片、SSE 事件、JSON 增量和工具执行混成了同一层。在这里插入图片描述

一次流式响应至少经过四个边界:网络传输把字节分成任意大小的 TCP 片段;SSE 解析器按空行识别事件;接口协议把事件转换成内容或工具调用增量;应用层在确认参数完整后才执行工具。任意一个边界处理错误,都可能出现“普通文本看起来正常,工具调用却失败”的现象。

本文以 OpenAI 兼容的 Chat Completions 流式响应为主要例子,建立一套不依赖具体客户端界面的排障方法,同时说明它与 Responses 流式事件的差异。重点不是复制一个 SDK 调用,而是让原始流可以记录、解析、回放和测试。

一、先区分四种“分片”,不要把它们当成同一件事

很多问题来自一个含糊的词:“chunk”。在流式链路中,至少有四种粒度:

粒度 产生位置 能否直接解析为 JSON 常见误区
网络字节片段 TCP、TLS、HTTP 客户端 每次 read() 都当成一个完整事件
SSE 事件 event-stream 协议 事件里的 data 才可能是 JSON 看到换行就立即解析
接口增量块 Chat Completions 或 Responses 协议 通常是 把内容增量当作完整回答
工具参数片段 函数调用的参数字符串 通常不是 每收到一段就 JSON.parse

网络层可以在任意字节位置切断一个 UTF-8 字符、一个 data: 行或一个 JSON 字符串。SSE 层则以事件边界为单位,通常由空行结束一个事件。事件的 data: 内容又可能只携带一段参数字符串。即使每个 SSE 事件都能单独解析为 JSON,function.arguments 仍可能只有半个对象。

因此,正确的处理顺序应是:字节解码、SSE 分帧、事件 JSON 解析、增量状态合并、完整参数校验、工具授权与执行。把其中任意两步合并,都会让错误边界变得模糊。

二、先定义成功标准:200 只是传输层结果

流式工具调用至少有五个验收层次:

  1. HTTP 连接建立,状态码和响应媒体类型正确;
  2. SSE 事件可以被完整分帧,没有因代理缓冲或编码错误丢失边界;
  3. 每个 Chat Completions 增量块都能解析,choice 和工具索引没有错位;
  4. 工具调用的名称、调用 ID 和参数字符串在结束前完整收集;
  5. 参数通过 schema、权限和业务幂等性检查后,工具才被执行。

其中任何一层失败,都不能简单写成“接口成功”。例如,服务端返回 200,但代理把全部事件缓存到连接结束才发送,用户看到的不是实时流;又或者文本增量已经显示,工具参数被截断,应用却把半个 JSON 交给执行器。在这里插入图片描述

建议在测试报告中把结果写成分层状态:http_oksse_okdelta_oktool_args_completetool_authorizedtool_executed。这样可以区分“模型生成完成但客户端没解析”与“参数根本没有生成完整”。

三、用原始请求建立可回放基线

排障第一步不是打开某个客户端的调试开关,而是保存一条最小、无副作用的流式请求。示例使用占位变量,禁止把真实密钥写入脚本或日志:

curl -N -i "$API_BASE/chat/completions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{
    "model":"MODEL_ID",
    "messages":[{"role":"user","content":"Call the add_numbers tool with 2 and 3."}],
    "tools":[{"type":"function","function":{"name":"add_numbers","description":"Add two numbers","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false}}}],
    "tool_choice":{"type":"function","function":{"name":"add_numbers"}},
    "stream":true
  }'

-N 用于尽量避免 curl 自身缓冲,-i 用于同时查看响应头。测试工具只做加法,不写数据库、不发送消息、不调用外部系统。原始输出应保存为脱敏测试样本,至少保留时间戳、状态码、Content-Type、事件顺序和结束信号。

一个正常的 Chat Completions 流可能包含类似以下的逻辑序列,但具体字段分布和事件数量以服务端实际返回为准:

data: {"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"add_numbers","arguments":"{\"a\":"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"2,\"b\":3}"}}]},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}

data: [DONE]

上面的内容只是展示分片形态的示意数据,不是某次真实生产调用的结果。关键点有三个:arguments 是字符串片段,不保证是合法 JSON;同一个工具调用由 index 关联;结束原因出现在 choice 层,而不是增量内容里。

四、SSE 解析必须先处理字节,再处理事件

1. 不要把一次读取当成一次事件

Node.js、浏览器 Fetch、Python HTTP 客户端和各种 SDK 都可能在任意位置返回数据。一次读取可能只包含 da,下一次才包含 ta: {...};也可能一次读取含有多个事件。解析器必须维护跨读取的缓冲区,只有遇到 SSE 事件分隔的空行,才把事件交给上层。

如果直接对每次读取调用 JSON.parse,常见结果包括:偶发 JSON 语法错误、某些网络环境正常而另一些环境失败、中文字符被截断、工具参数在本地测试通过但经过代理后失败。这些现象的共同点是解析边界取错了。

2. 正确处理 UTF-8 跨片段字符

网络片段可能在一个多字节字符中间断开。使用 TextDecoder 时应开启流式解码,让解码器保留不完整的尾部字节;不要先把每个 Uint8Array 强行转成字符串再拼接。虽然工具参数通常以 ASCII JSON 为主,但用户内容、工具描述和错误消息可能包含中文,整个流的解码策略不能只为英文测试服务。

3. 处理多行 data: 与未知字段

SSE 事件可以包含多个 data: 行,解析器应按协议把它们组合后再交给 JSON 层。event:id: 或注释行不应被误当成 JSON。兼容服务还可能加入额外字段,因此解析器要忽略不影响当前状态的未知字段,而不是因为一个新字段就丢弃整个事件。

4. [DONE] 不是普通 JSON

不少 Chat Completions 兼容实现使用 data: [DONE] 表示流结束。它不是 JSON,不能交给 JSON.parse。同时也不能把收到 [DONE] 当成工具一定可执行:还需要检查对应调用的参数是否完整、是否通过 schema 和权限校验。第三方兼容层可能省略这个哨兵或使用不同的结束事件,因此应用应同时支持“协议结束”和“连接结束”的异常诊断。

下面是一段简化的 Node.js SSE 事件解析骨架。它只负责从字节流中产生事件,不负责解析工具参数,也不执行工具:

async function* readSseEvents(body) {
  const decoder = new TextDecoder("utf-8");
  let buffer = "";

  for await (const chunk of body) {
    buffer += decoder.decode(chunk, { stream: true });
    buffer = buffer.replaceAll("\r\n", "\n");

    let boundary;
    while ((boundary = buffer.indexOf("\n\n")) >= 0) {
      const raw = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);
      const data = raw
        .split("\n")
        .filter((line) => line.startsWith("data:"))
        .map((line) => line.slice(5).trimStart())
        .join("\n");
      if (data) yield data;
    }
  }

  buffer += decoder.decode();
  if (buffer.trim()) {
    throw new Error("stream ended with an incomplete SSE event");
  }
}

生产代码还应处理单独的 \r、超大事件、异常关闭、解码错误和取消信号。这里的重点是分层:SSE 解析器只产生 data 字符串,Chat Completions 适配器再判断 [DONE] 和 JSON,工具状态机最后才合并参数。
在这里插入图片描述

五、Chat Completions 的工具增量如何合并

在 Chat Completions 流中,普通文本一般从 choices[i].delta.content 增量到达;工具调用则位于 choices[i].delta.tool_calls。一个工具调用可能先发送 idtype 和函数名,随后多次发送 arguments 片段,最后在 choices[i].finish_reason 中出现工具调用结束原因。

不能使用“最后收到的工具对象”覆盖前一个对象,因为后续对象可能只包含 function.arguments,没有重复发送函数名和 ID。也不能只用到达顺序作为 key,因为同一个 choice 可能存在并行工具调用,调用对象由各自的 index 区分。

建议使用二级键:choice.index + tool_call.index。状态结构可以包含:

choices[choiceIndex].content_parts
choices[choiceIndex].tool_calls[toolIndex].id
choices[choiceIndex].tool_calls[toolIndex].type
choices[choiceIndex].tool_calls[toolIndex].name
choices[choiceIndex].tool_calls[toolIndex].arguments_text
choices[choiceIndex].finish_reason

其中 arguments_text 必须是字符串累加器,不是对象。只有当流结束、调用状态完整且参数文本通过 JSON 解析后,才把它转换为对象。解析失败时要保留原始片段和调用 ID,方便定位是服务端生成了非法参数,还是客户端丢了中间事件。

下面的适配器只展示核心合并逻辑。它假设输入已经经过 SSE 分帧,传入的是一个 Chat Completions JSON 块;真实项目还需加入字段类型和大小限制:

function createChatState() {
  return { choices: new Map(), done: false };
}

function applyChatChunk(state, chunk) {
  if (chunk === "[DONE]") {
    state.done = true;
    return;
  }

  const payload = JSON.parse(chunk);
  for (const choice of payload.choices ?? []) {
    const choiceState = state.choices.get(choice.index) ?? {
      text: "",
      tools: new Map(),
      finishReason: null,
    };
    const delta = choice.delta ?? {};

    if (typeof delta.content === "string") {
      choiceState.text += delta.content;
    }

    for (const part of delta.tool_calls ?? []) {
      const tool = choiceState.tools.get(part.index) ?? {
        id: "",
        type: "function",
        name: "",
        argumentsText: "",
      };
      if (typeof part.id === "string") {
        if (tool.id && tool.id !== part.id) {
          throw new Error("tool call id changed during streaming");
        }
        tool.id = part.id;
      }
      if (typeof part.type === "string") tool.type = part.type;
      const fn = part.function ?? {};
      if (typeof fn.name === "string") {
        if (tool.name && tool.name !== fn.name) {
          throw new Error("tool name changed during streaming");
        }
        tool.name = fn.name;
      }
      if (typeof fn.arguments === "string") {
        tool.argumentsText += fn.arguments;
      }
      choiceState.tools.set(part.index, tool);
    }

    if (choice.finish_reason !== undefined) {
      choiceState.finishReason = choice.finish_reason;
    }
    state.choices.set(choice.index, choiceState);
  }
}

这段代码故意不在 applyChatChunk 中执行工具。原因是“收到参数片段”和“允许执行外部动作”是两个不同的安全边界。上层应在状态机确认结束后,逐个检查工具名、调用 ID、参数 JSON、schema、权限、租户和幂等键。

把合并过程写成显式状态机

只用几个布尔变量很容易产生不可能状态,例如 done=true 但参数仍在增长,或工具已经执行却又收到新的参数片段。更稳妥的实现是为每个工具调用定义显式阶段:collectingcompletevalidatedauthorizedexecutingsucceededfailed。状态只能沿允许的方向变化,任何逆向或跳跃都要被记录为协议异常。

collecting 阶段只接收 ID、名称和参数增量;看到 choice 结束后,根据结束原因决定能否进入 complete。进入 complete 时要冻结参数缓冲区,后续再到达相同调用的增量应触发错误,而不是继续追加。JSON 解析和 schema 检查通过后进入 validated,权限检查通过后进入 authorized,真正提交外部动作时才进入 executing

状态机还需要区分“流结束”和“工具调用完成”。一个流可以因为网络断开而结束,但工具仍停留在 collecting;也可以收到完整工具调用后,连接在最终结束事件之前断开。前者必须拒绝执行,后者则应根据是否存在明确的协议完成信号采取保守策略,不能靠参数恰好能解析就推断模型已经完成。

对于一个 choice 中的多个工具调用,每个调用拥有独立状态,choice 自身还要维护整体阶段。只有全部调用都达到允许执行的状态,且 choice 没有出现长度截断、解析错误或未知冲突,才向业务层发布“工具批次就绪”。这能避免第一个工具已经执行、第二个工具随后发现参数损坏,造成难以回滚的半完成结果。

ID 和函数名不能无条件追加

参数字段天然适合字符串追加,但调用 ID 和函数名通常是标识字段。某些兼容服务只在第一块发送一次,也可能在后续块重复同一个值。若客户端对 ID 执行字符串追加,重复值会变成 call_xcall_x;若后续值不同,更不能静默覆盖。

因此,标识字段应采用“首次设置,后续一致性校验”的策略。第一次收到非空值时保存,后续再次出现时必须与现有值一致;不一致就把流标记为协议冲突。只有服务文档明确声明某个标识本身也是增量字符串时,才使用追加语义。参数、内容和标识要分别定义合并规则,不能用一个通用的深度合并函数处理所有字段。

六、finish_reason 为 null 并不代表失败

流式传输中,finish_reason 在大多数中间块里可能是 null,因为模型还没有决定输出如何结束。客户端如果把第一个 null 当成异常,或者把没有 finish_reason 的增量块直接丢弃,就会丢失工具参数。
在这里插入图片描述

常见的结束语义包括正常停止、长度限制和工具调用结束,但兼容服务可能返回扩展值或省略某些字段。解析器应保存未知值并记录日志,不要把未知结束原因强行映射成普通文本结束。对于 Chat Completions,结束原因位于 choice 层;对于 Responses 流式事件,完成状态通常通过带类型的事件表达,不能照搬同一个字段判断。

还要注意两个边界:

  • 最后一个有用的参数片段可能出现在结束原因之前,不能收到结束原因才开始收集;
  • 最后一个块的 delta 可能为空,但其中的 finish_reason 仍然重要,不能因为没有文本就跳过。

如果工具参数已经完整,但结束原因是 length,应默认拒绝执行,因为这表示输出可能被截断。若服务端明确返回工具调用结束,仍需重新解析完整参数,不能仅凭 finish_reason 就相信参数合法。

为每种终止路径设置守卫条件

流式状态机至少要处理四种终止路径:协议正常完成、服务端返回错误事件、客户端主动取消、网络异常断开。它们不能共用一个 finally 分支并统一标记成功。正常完成要求收到与接口族一致的完成信号;错误事件要求保留错误阶段和可重试属性;主动取消要求确认是否已经提交工具;异常断开则默认把未完成参数标记为不可执行。

如果连接结束时没有 [DONE],但已经出现明确的 choice 结束原因,兼容客户端可以根据自身契约决定是否接受,不过必须记录“缺少流结束哨兵”。如果既没有结束原因也没有结束哨兵,就不应把连接关闭解释成正常完成。严格模式可直接拒绝,兼容模式也至少要把结果标记为降级,而不是与完整流使用同一个成功状态。

守卫条件还应验证序列顺序。例如,收到 finish_reason 后又出现新的参数片段,可能是代理重排、服务实现异常或客户端复用了旧缓冲区。此时继续执行会掩盖真实问题。更安全的处理是终止该调用、保存脱敏事件摘要,并要求重新验证上游兼容性。

七、并行工具调用最容易造成状态错位

当模型一次输出多个工具调用时,客户端可能交错收到不同调用的参数。例如工具 0 先收到函数名,工具 1 随后收到函数名,接着工具 0 和工具 1 的参数交替到达。若代码只维护一个全局 currentTool,最终会把两个 JSON 拼成一个无效字符串。

正确做法是使用 choice 索引和工具索引定位状态,并分别限制每个参数缓冲区大小。完成一个调用后,也不能立刻清空整个 choice 的状态,因为同一个 choice 可能还有另一个工具调用或最终文本。只有当所有调用都完成、且整体结束状态明确后,才能把 choice 交给执行阶段。

测试并行调用时,不要使用真实副作用工具。可以定义两个纯函数工具,例如 add_numbersformat_date,让模型同时生成它们的参数,再人为把事件交错、拆分到不同字节边界。这样既能验证状态机,又不会因为测试重试产生外部数据变化。

八、Responses 流式事件不能套用 Chat Completions 解析器

许多客户端为了兼容多个平台,只写了一个“读取 choices[0].delta”的解析器。当上游切换到 Responses 接口时,HTTP 可能仍返回 200,文本事件也可能看起来正常,但工具参数事件会被静默丢弃。在这里插入图片描述

两套接口至少存在这些差异:

对比项 Chat Completions Responses
主要结构 choicesdeltafinish_reason 具名事件与响应对象状态
文本增量 从消息 delta 提取 从文本输出增量事件提取
工具参数 tool_calls[].function.arguments 片段 函数参数增量事件或对应输出项
结束判断 choice 结束原因与流结束信号 完成类事件和响应状态
适配策略 Chat 专用状态机 Responses 专用事件适配器

表格只表达工程上的分层原则,不替代具体版本的 API Reference。实现时应先记录实际事件类型,再为每个接口编写独立适配器,最后向业务层输出统一的内部事件,例如 text_deltatool_call_deltastream_completedstream_error。不要让业务代码直接依赖某个接口的原始字段。

如果一个兼容网关同时暴露两个端点,也不能因为它们都叫“OpenAI 兼容”就假设事件语义相同。能力矩阵应分别记录非流式、流式和工具调用结果,并把客户端使用的接口族固定下来。升级 SDK 或切换路由后,要重新回放原始事件样本。

九、代理缓冲会把正确的流变成“卡住”或一次性返回

服务端持续产生事件,并不代表浏览器或桌面客户端能及时看到事件。反向代理可能开启响应缓冲、压缩或较长的聚合窗口;网关也可能设置流式空闲超时。客户端看到的症状包括:连接建立后几十秒没有内容、最后一次性出现全部文本、工具参数在中途被截断或收到代理生成的 HTML 错误。

排查时同时记录四个时间点:连接建立、响应头到达、首个有效事件到达、最后一个事件到达。再记录中间最长的事件间隔。只有知道是“首字节慢”还是“事件间隔被代理截断”,才能决定调整模型超时、代理缓冲还是上游路由。

测试代理配置时,应在受控环境确认响应 Content-Typetext/event-stream,并观察小事件是否按预期及时到达。某些网关支持通过响应头关闭缓冲,例如 X-Accel-Buffering: no,但这不是所有代理都支持的通用保证,最终仍需用真实链路验证。不要为了让流看起来更快而关闭所有压缩或安全策略,应该只对明确的流式端点调整。

代理还可能重写换行。SSE 事件边界依赖空行,如果中间层把 \n\n 合并、转码或插入额外内容,解析器会表现为一直等待一个永远不会出现的边界。保存入口和上游两侧的事件摘要,能够判断问题发生在服务端还是代理层。

背压、取消和资源上限同样属于协议实现

流式解析并不意味着可以无限接收。若上游持续产生事件,而下游 UI、日志或工具校验速度较慢,缓冲区会不断增长。应用需要利用运行时提供的背压机制,或设置事件队列上限;达到上限后应取消上游请求并标记资源错误,不能继续把全部内容堆在内存中。

每个层级都应有限制:单个网络缓冲区长度、单个 SSE 事件长度、单个工具参数字符串长度、一个 choice 的工具调用数量以及整条流的最大持续时间。限制触发后,日志只记录长度、阶段和调用标识,不记录可能包含敏感信息的完整参数。这样既能防止意外大响应,也能降低恶意或错误上游拖垮客户端的风险。

用户主动点击停止时,客户端要向上游传播取消信号,并冻结当前状态。取消前已经提交的工具不能假设自动回滚;取消时仍在收集的参数必须废弃。若 UI 只是停止渲染而没有取消网络读取,后台仍可能持续累积数据、占用连接,甚至在用户以为操作结束后执行工具。

工具执行器与流读取器之间最好使用明确的消息队列。读取器只能发布已验证的内部事件,执行器消费事件时再次检查任务是否被取消、租户是否仍有效、幂等键是否已使用。这样即使取消信号与最后一个事件同时到达,也能通过状态和幂等记录决定是否执行,而不是依赖线程调度顺序。

十、工具执行必须晚于完整参数校验

在这里插入图片描述

流式解析器不能直接执行模型发来的工具名和参数。至少应经过以下检查:

  1. 工具名是否在当前租户和当前工作流的白名单中;
  2. 调用 ID 是否存在且没有被重复使用;
  3. 参数字符串是否完整、大小是否在限制内;
  4. JSON 是否可解析,字段类型是否通过 schema;
  5. 是否包含禁止的额外字段、路径、URL 或资源标识;
  6. 当前用户和服务账号是否有执行权限;
  7. 工具是否幂等,是否需要幂等键或人工确认。

即使参数 JSON 语法正确,也不能认为动作安全。例如一个合法的文件路径可能超出当前工作区,一个合法的 URL 可能不在允许的域名集合内,一个合法的数据库查询可能超出当前租户范围。工具层应再次执行授权和资源边界检查,不能把模型输出当作可信控制指令。

工具调用失败后,重试策略也要区分阶段。若失败发生在连接建立前,通常可以在明确条件下重试;若工具已经执行但响应丢失,不能简单重新执行,因为可能造成重复写入。应通过幂等键、执行记录和状态查询判断结果,而不是只看客户端是否收到完整流。

十一、把 429、超时和断流分开处理

三类错误经常被客户端统一显示为“流式请求失败”,但恢复方式不同:

错误 可能发生的阶段 是否适合自动重试
429 建立请求前或响应过程中 依据限制信息、预算和幂等性决定
连接超时 DNS、TCP、TLS 无副作用时可有限重试
首字节超时 请求已发出但模型未开始返回 检查上游负载与输入规模
流式空闲超时 已收到部分事件 先判断工具是否已执行
解析错误 客户端或代理处理阶段 先保存原始流,不能盲目重试
结束信号缺失 连接异常关闭或兼容层差异 根据已收集状态决定恢复方式

在流式响应已经产生部分工具参数后切换上游,会带来重复生成和状态不一致。故障转移策略应保存原始请求 ID、已收到的事件数量、工具执行状态和重试原因。只有在工具尚未执行、请求被定义为可重放且新请求不会造成重复副作用时,才适合切换路由。

断线恢复不是从上次字符串末尾继续拼接

多数流式生成接口并不保证客户端可以携带“已收字节数”恢复同一条响应。网络断开后重新发起请求,模型可能生成不同的文本、不同的工具调用 ID 或不同的参数顺序。因此,不能把新请求的增量直接追加到旧缓冲区,也不能因为开头内容相似就认为是同一条流。

恢复策略应从业务语义出发。若断线发生在任何工具执行之前,可以丢弃旧状态,使用新的请求 ID 完整重放,并把两次尝试关联到同一个业务操作。若工具已经执行,应先查询幂等记录或工具结果,再决定是否继续生成后续回答。对于不具备查询能力的副作用工具,默认不自动重试更安全。

网关做多模型故障转移时也要遵守这一原则。切换模型相当于开始新的生成,不是原流续传。网关应向客户端暴露尝试编号、路由标识和切换原因,并保证旧流不会在后台继续触发工具。否则主路由迟到的事件可能与备用路由事件同时进入同一状态机,造成调用 ID 冲突和重复执行。

如果产品需要真正的恢复能力,应把工具调用状态持久化到独立执行记录中,把模型流视为产生意图的输入,而不是唯一事实来源。恢复时读取执行记录,重新构造后续对话上下文,再启动新的模型请求。这样恢复的是业务工作流,不是不可控的网络字节流。
在这里插入图片描述

十二、建立一个可回放的流式测试集

只用一次人工请求很难覆盖分片边界。应准备固定的脱敏事件样本,并在测试中主动改变切分方式。至少覆盖:

  • data: 行被拆成多个网络片段;
  • 空行分隔符跨越两次读取;
  • UTF-8 中文字符被拆在不同字节片段;
  • 工具 ID、函数名和参数分别在不同事件出现;
  • 两个工具调用交错到达;
  • 参数字符串包含转义引号、换行、Unicode 和嵌套数组;
  • 最后一个块只有 finish_reason,没有文本增量;
  • 收到 [DONE]、提前断开或结束事件缺失;
  • 代理延迟一批事件后再发送;
  • 参数超长、JSON 非法或字段缺失。

每个样本都应有预期的内部事件和最终状态,而不是只断言“没有抛异常”。例如,给定三段 arguments,预期工具状态中的完整字符串应与固定 JSON 相同;给定两个交错工具调用,预期两个调用 ID 和参数不能互相污染;给定 length 结束原因,预期状态为拒绝执行。

一种简单的测试策略是把原始事件分割点遍历一遍:在每个字节位置切断输入,交给同一个 SSE 解析器,最后比较输出事件序列是否一致。这样可以发现“只要某个网络包恰好在 JSON 中间切断就失败”的隐蔽问题。对于生产级 SDK,还可以增加随机分片和属性测试,但必须保留失败样本,便于回归。

契约测试还要验证失败输出

测试不能只比较最终文本和工具参数,还要验证失败时是否产生正确的内部状态。给定缺少结束事件的样本,预期应是 incomplete_stream;给定工具 ID 中途变化的样本,预期应是 protocol_conflict;给定超长参数,预期应在 JSON 解析前触发 argument_limit_exceeded。如果所有异常最终都变成同一个“连接失败”,运维仍然无法定位。

每次升级 SDK、代理或网关后,都应运行同一套黄金样本。若输出事件数量、顺序、错误阶段或完成判断发生变化,就必须人工确认这是预期升级,而不是兼容性回归。对于第三方兼容层,还应保存该版本真实返回的脱敏样本,因为“接口路径相同”不能证明分片语义始终相同。

契约测试不需要连接生产模型。SSE 分帧、UTF-8 解码、状态合并和工具授权都可以用本地固定数据验证;只有上游能力和端到端延迟需要真实接口测试。把确定性测试与联网测试分开,可以降低成本,也能让解析器错误在提交阶段被快速发现。

十三、客户端适配层要输出统一内部事件

Cursor、Dify、LangChain、Cherry Studio 等客户端的界面和版本会变化,但业务层不应直接依赖它们的原始流格式。建议在网关或 SDK 内部建立适配层,把不同上游转换成稳定的内部事件:

stream_started
text_delta { text }
tool_call_started { call_id, name }
tool_call_arguments_delta { call_id, text }
tool_call_ready { call_id, parsed_arguments }
stream_completed { reason }
stream_error { phase, code, retryable }

Chat Completions 适配器负责从 choices[].deltafinish_reason 产生这些事件;Responses 适配器负责从具名事件和响应状态产生同样的内部事件。工具执行器只接收 tool_call_ready,不接收原始网络块。这样,客户端升级或上游路由变化时,影响被限制在适配层。

能力注册表中可以把 https://178.nz/dn 作为一条配置来源记录,但不能用来源页面代替实测。每个上游都应独立记录“是否支持流式文本”“是否支持工具调用”“参数是否增量返回”“结束事件是什么”“代理是否保持事件边界”。一项能力通过,不代表其他能力自动通过。
在这里插入图片描述

十四、日志和指标怎样帮助定位

流式日志不应直接保存完整提示词、工具参数和用户数据。更有价值的是记录协议元数据:

指标 用途
time_to_headers 判断连接与上游响应头延迟
time_to_first_event 判断模型首字节和代理首包延迟
event_count 判断是否收到了完整事件序列
text_delta_bytes 判断文本是否真正产生
tool_argument_bytes 判断工具参数是否在途中截断
finish_reason 判断 choice 如何结束
sse_parse_error 定位分帧、编码或格式问题
tool_validation_result 区分参数无效与权限拒绝
upstream_request_id 对齐服务端和网关日志

工具参数可以记录哈希、长度和字段摘要,不必记录原文。发生解析失败时,应保存经过权限控制的脱敏样本,并关联调用 ID、上游请求 ID 和客户端版本。日志中不能出现完整 API Key、Cookie、个人信息、内部凭据或未经处理的文件内容。

如果指标显示首个事件正常、参数字节数达到预期,但 JSON.parse 失败,优先检查增量拼接和转义处理;如果参数字节数明显少于上游日志,检查代理缓冲、断流和超时;如果参数完整且校验通过但工具没有执行,检查授权和执行队列,而不是继续调整模型温度。

十五、典型故障的定位示例

示例一:文本正常,工具参数总是少最后一个括号

现象是普通回答可以流式显示,工具调用却报 JSON 语法错误。日志显示最后一个事件含有 finish_reason=tool_calls,但客户端在收到该事件前已经把缓冲区交给执行器。根因不是模型一定生成了错误 JSON,而是应用把“出现工具调用对象”误认为“参数已完整”。修复方式是持续累加 arguments,等流结束和 choice 状态明确后再解析。

示例二:本地正常,经过网关后一直等待

本地直连时事件逐个到达,经过代理后只在几十秒后一次性收到全部数据。入口日志显示服务端每隔几百毫秒产生事件,但客户端只有一个大响应包。根因是代理响应缓冲或压缩策略,不是 SSE 生成失败。应在测试环境关闭对应流式端点的缓冲,确认 Content-Type 和事件边界,再重新测量首事件延迟。

示例三:两个工具调用互相污染

模型同时请求两个纯函数工具,客户端偶尔得到一个无法解析的合并对象。日志中两个调用的参数片段交错出现,而代码使用一个全局字符串拼接。根因是没有按 choice.indextool_calls[].index 建立独立状态。修复后,每个调用维护自己的 ID、函数名和参数缓冲区,完成一个调用不会清空另一个调用。

示例四:切换到 Responses 后文本有了,工具事件消失

客户端仍使用 Chat Completions 的 choices[].delta 解析器,Responses 端点返回的事件没有该字段,于是文本适配分支偶然工作,工具分支被静默忽略。根因是把两个接口族当成同一事件格式。修复方式是增加独立 Responses 适配器,向业务层输出统一的工具参数增量事件,并为两套接口分别维护回放样本。
在这里插入图片描述

十六、上线前的最小验收清单

完成接入前,可以用以下问题作为硬性检查:

  1. 是否记录了原始响应头和脱敏事件序列?
  2. 是否证明解析器能够跨网络分片和 UTF-8 边界工作?
  3. 是否按 choice 与工具索引分别累加参数?
  4. 是否只在参数完整、JSON 合法、schema 通过后执行工具?
  5. 是否将 finish_reason=null 视为中间状态,而非失败?
  6. 是否为 Chat Completions 和 Responses 使用不同适配器?
  7. 是否验证了代理缓冲、首事件延迟和流式空闲超时?
  8. 是否区分了 429、断流、解析错误和工具执行失败?
  9. 是否为工具副作用设计了幂等键、重试边界和审计记录?
  10. 是否准备了可以在 SDK、网关或模型路由升级后重放的固定样本?

如果其中一项只能回答“应该可以”,而没有日志、样本或测试结果,就不应把流式工具调用标记为已验证。兼容接口的稳定性不取决于某次回答看起来正常,而取决于事件边界、状态合并和执行授权是否能够被重复证明。

结语

流式接口返回 200,并不能说明工具调用链路已经成功。真正需要验证的是:字节是否被正确解码,SSE 事件是否完整分帧,增量块是否按 choice 和工具索引合并,finish_reason 是否被放在正确的状态层,工具参数是否经过完整性、schema、权限和幂等性检查。

把网络解析、接口适配、状态机和工具执行拆开,保存可回放的原始事件样本,并为每个上游维护独立能力矩阵,才能在客户端升级、代理切换或模型路由变化后快速判断问题发生在哪一层。对流式工具调用来说,可观测、可回放、可拒绝,比“偶尔能跑通一次”更接近真正的工程可靠性。
在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐