流式接口返回 200 但工具调用失败:SSE 分帧、finish_reason 与 tool_calls 增量拼接排障
在支持工具调用的 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 事件都能单独解析为 JSONfunction.arguments仍可能只有半个对象。因此正确的处理顺序应是字节解码、SSE 分帧、事件 JSON 解析、增量状态合并、完整参数校验、工具授权与执行。把其中任意两步合并都会让错误边界变得模糊。二、先定义成功标准200 只是传输层结果流式工具调用至少有五个验收层次HTTP 连接建立状态码和响应媒体类型正确SSE 事件可以被完整分帧没有因代理缓冲或编码错误丢失边界每个 Chat Completions 增量块都能解析choice和工具索引没有错位工具调用的名称、调用 ID 和参数字符串在结束前完整收集参数通过 schema、权限和业务幂等性检查后工具才被执行。其中任何一层失败都不能简单写成“接口成功”。例如服务端返回 200但代理把全部事件缓存到连接结束才发送用户看到的不是实时流又或者文本增量已经显示工具参数被截断应用却把半个 JSON 交给执行器。建议在测试报告中把结果写成分层状态http_ok、sse_ok、delta_ok、tool_args_complete、tool_authorized、tool_executed。这样可以区分“模型生成完成但客户端没解析”与“参数根本没有生成完整”。三、用原始请求建立可回放基线排障第一步不是打开某个客户端的调试开关而是保存一条最小、无副作用的流式请求。示例使用占位变量禁止把真实密钥写入脚本或日志curl-N-i$API_BASE/chat/completions\-HAuthorization: Bearer$API_KEY\-HContent-Type: application/json\-HAccept: 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 事件解析骨架。它只负责从字节流中产生事件不负责解析工具参数也不执行工具asyncfunction*readSseEvents(body){constdecodernewTextDecoder(utf-8);letbuffer;forawait(constchunkofbody){bufferdecoder.decode(chunk,{stream:true});bufferbuffer.replaceAll(\r\n,\n);letboundary;while((boundarybuffer.indexOf(\n\n))0){constrawbuffer.slice(0,boundary);bufferbuffer.slice(boundary2);constdataraw.split(\n).filter((line)line.startsWith(data:)).map((line)line.slice(5).trimStart()).join(\n);if(data)yielddata;}}bufferdecoder.decode();if(buffer.trim()){thrownewError(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。一个工具调用可能先发送id、type和函数名随后多次发送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 块真实项目还需加入字段类型和大小限制functioncreateChatState(){return{choices:newMap(),done:false};}functionapplyChatChunk(state,chunk){if(chunk[DONE]){state.donetrue;return;}constpayloadJSON.parse(chunk);for(constchoiceofpayload.choices??[]){constchoiceStatestate.choices.get(choice.index)??{text:,tools:newMap(),finishReason:null,};constdeltachoice.delta??{};if(typeofdelta.contentstring){choiceState.textdelta.content;}for(constpartofdelta.tool_calls??[]){consttoolchoiceState.tools.get(part.index)??{id:,type:function,name:,argumentsText:,};if(typeofpart.idstring){if(tool.idtool.id!part.id){thrownewError(tool call id changed during streaming);}tool.idpart.id;}if(typeofpart.typestring)tool.typepart.type;constfnpart.function??{};if(typeoffn.namestring){if(tool.nametool.name!fn.name){thrownewError(tool name changed during streaming);}tool.namefn.name;}if(typeoffn.argumentsstring){tool.argumentsTextfn.arguments;}choiceState.tools.set(part.index,tool);}if(choice.finish_reason!undefined){choiceState.finishReasonchoice.finish_reason;}state.choices.set(choice.index,choiceState);}}这段代码故意不在applyChatChunk中执行工具。原因是“收到参数片段”和“允许执行外部动作”是两个不同的安全边界。上层应在状态机确认结束后逐个检查工具名、调用 ID、参数 JSON、schema、权限、租户和幂等键。把合并过程写成显式状态机只用几个布尔变量很容易产生不可能状态例如donetrue但参数仍在增长或工具已经执行却又收到新的参数片段。更稳妥的实现是为每个工具调用定义显式阶段collecting、complete、validated、authorized、executing、succeeded、failed。状态只能沿允许的方向变化任何逆向或跳跃都要被记录为协议异常。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_numbers和format_date让模型同时生成它们的参数再人为把事件交错、拆分到不同字节边界。这样既能验证状态机又不会因为测试重试产生外部数据变化。八、Responses 流式事件不能套用 Chat Completions 解析器许多客户端为了兼容多个平台只写了一个“读取choices[0].delta”的解析器。当上游切换到 Responses 接口时HTTP 可能仍返回 200文本事件也可能看起来正常但工具参数事件会被静默丢弃。两套接口至少存在这些差异对比项Chat CompletionsResponses主要结构choices、delta、finish_reason具名事件与响应对象状态文本增量从消息 delta 提取从文本输出增量事件提取工具参数tool_calls[].function.arguments片段函数参数增量事件或对应输出项结束判断choice 结束原因与流结束信号完成类事件和响应状态适配策略Chat 专用状态机Responses 专用事件适配器表格只表达工程上的分层原则不替代具体版本的 API Reference。实现时应先记录实际事件类型再为每个接口编写独立适配器最后向业务层输出统一的内部事件例如text_delta、tool_call_delta、stream_completed和stream_error。不要让业务代码直接依赖某个接口的原始字段。如果一个兼容网关同时暴露两个端点也不能因为它们都叫“OpenAI 兼容”就假设事件语义相同。能力矩阵应分别记录非流式、流式和工具调用结果并把客户端使用的接口族固定下来。升级 SDK 或切换路由后要重新回放原始事件样本。九、代理缓冲会把正确的流变成“卡住”或一次性返回服务端持续产生事件并不代表浏览器或桌面客户端能及时看到事件。反向代理可能开启响应缓冲、压缩或较长的聚合窗口网关也可能设置流式空闲超时。客户端看到的症状包括连接建立后几十秒没有内容、最后一次性出现全部文本、工具参数在中途被截断或收到代理生成的 HTML 错误。排查时同时记录四个时间点连接建立、响应头到达、首个有效事件到达、最后一个事件到达。再记录中间最长的事件间隔。只有知道是“首字节慢”还是“事件间隔被代理截断”才能决定调整模型超时、代理缓冲还是上游路由。测试代理配置时应在受控环境确认响应Content-Type为text/event-stream并观察小事件是否按预期及时到达。某些网关支持通过响应头关闭缓冲例如X-Accel-Buffering: no但这不是所有代理都支持的通用保证最终仍需用真实链路验证。不要为了让流看起来更快而关闭所有压缩或安全策略应该只对明确的流式端点调整。代理还可能重写换行。SSE 事件边界依赖空行如果中间层把\n\n合并、转码或插入额外内容解析器会表现为一直等待一个永远不会出现的边界。保存入口和上游两侧的事件摘要能够判断问题发生在服务端还是代理层。背压、取消和资源上限同样属于协议实现流式解析并不意味着可以无限接收。若上游持续产生事件而下游 UI、日志或工具校验速度较慢缓冲区会不断增长。应用需要利用运行时提供的背压机制或设置事件队列上限达到上限后应取消上游请求并标记资源错误不能继续把全部内容堆在内存中。每个层级都应有限制单个网络缓冲区长度、单个 SSE 事件长度、单个工具参数字符串长度、一个 choice 的工具调用数量以及整条流的最大持续时间。限制触发后日志只记录长度、阶段和调用标识不记录可能包含敏感信息的完整参数。这样既能防止意外大响应也能降低恶意或错误上游拖垮客户端的风险。用户主动点击停止时客户端要向上游传播取消信号并冻结当前状态。取消前已经提交的工具不能假设自动回滚取消时仍在收集的参数必须废弃。若 UI 只是停止渲染而没有取消网络读取后台仍可能持续累积数据、占用连接甚至在用户以为操作结束后执行工具。工具执行器与流读取器之间最好使用明确的消息队列。读取器只能发布已验证的内部事件执行器消费事件时再次检查任务是否被取消、租户是否仍有效、幂等键是否已使用。这样即使取消信号与最后一个事件同时到达也能通过状态和幂等记录决定是否执行而不是依赖线程调度顺序。十、工具执行必须晚于完整参数校验流式解析器不能直接执行模型发来的工具名和参数。至少应经过以下检查工具名是否在当前租户和当前工作流的白名单中调用 ID 是否存在且没有被重复使用参数字符串是否完整、大小是否在限制内JSON 是否可解析字段类型是否通过 schema是否包含禁止的额外字段、路径、URL 或资源标识当前用户和服务账号是否有执行权限工具是否幂等是否需要幂等键或人工确认。即使参数 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[].delta和finish_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_reasontool_calls但客户端在收到该事件前已经把缓冲区交给执行器。根因不是模型一定生成了错误 JSON而是应用把“出现工具调用对象”误认为“参数已完整”。修复方式是持续累加arguments等流结束和 choice 状态明确后再解析。示例二本地正常经过网关后一直等待本地直连时事件逐个到达经过代理后只在几十秒后一次性收到全部数据。入口日志显示服务端每隔几百毫秒产生事件但客户端只有一个大响应包。根因是代理响应缓冲或压缩策略不是 SSE 生成失败。应在测试环境关闭对应流式端点的缓冲确认Content-Type和事件边界再重新测量首事件延迟。示例三两个工具调用互相污染模型同时请求两个纯函数工具客户端偶尔得到一个无法解析的合并对象。日志中两个调用的参数片段交错出现而代码使用一个全局字符串拼接。根因是没有按choice.index与tool_calls[].index建立独立状态。修复后每个调用维护自己的 ID、函数名和参数缓冲区完成一个调用不会清空另一个调用。示例四切换到 Responses 后文本有了工具事件消失客户端仍使用 Chat Completions 的choices[].delta解析器Responses 端点返回的事件没有该字段于是文本适配分支偶然工作工具分支被静默忽略。根因是把两个接口族当成同一事件格式。修复方式是增加独立 Responses 适配器向业务层输出统一的工具参数增量事件并为两套接口分别维护回放样本。十六、上线前的最小验收清单完成接入前可以用以下问题作为硬性检查是否记录了原始响应头和脱敏事件序列是否证明解析器能够跨网络分片和 UTF-8 边界工作是否按 choice 与工具索引分别累加参数是否只在参数完整、JSON 合法、schema 通过后执行工具是否将finish_reasonnull视为中间状态而非失败是否为 Chat Completions 和 Responses 使用不同适配器是否验证了代理缓冲、首事件延迟和流式空闲超时是否区分了 429、断流、解析错误和工具执行失败是否为工具副作用设计了幂等键、重试边界和审计记录是否准备了可以在 SDK、网关或模型路由升级后重放的固定样本如果其中一项只能回答“应该可以”而没有日志、样本或测试结果就不应把流式工具调用标记为已验证。兼容接口的稳定性不取决于某次回答看起来正常而取决于事件边界、状态合并和执行授权是否能够被重复证明。结语流式接口返回 200并不能说明工具调用链路已经成功。真正需要验证的是字节是否被正确解码SSE 事件是否完整分帧增量块是否按 choice 和工具索引合并finish_reason是否被放在正确的状态层工具参数是否经过完整性、schema、权限和幂等性检查。把网络解析、接口适配、状态机和工具执行拆开保存可回放的原始事件样本并为每个上游维护独立能力矩阵才能在客户端升级、代理切换或模型路由变化后快速判断问题发生在哪一层。对流式工具调用来说可观测、可回放、可拒绝比“偶尔能跑通一次”更接近真正的工程可靠性。