Repository navigation
models: preserve tool calls in mixed text stream chunks - #363
cloviswang wants to merge 4 commits into
Conversation
|
CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅ |
dd59dc6 to
99396f8
Compare
AI Code Review审查结论通过 审查范围:base 594bfb0..head 99396f8,共 2 个文件(trpc_agent_sdk/models/_openai_model.py 的 _generate_stream 重构 + 新增 tests/models/test_openai_mixed_stream.py)。计划意图为"保留混合文本流块中的工具调用",核心改动:将 _process_chunk_without_content(工具调用累积 + usage 提取)从"仅无文本块"改为对所有块执行,流式工具调用事件提前到文本校验之前 yield,并删除循环末尾的 usage 处理(已上移)。通过全链路追踪验证:直接 LLM 通路(LlmAgent/LlmProcessor→消费方)中修复目标达成——混合块的工具调用不再丢失,usage 处理对所有块形态(usage-only、choices=None、delta=None、纯文本)均与旧行为等价,无双重处理、无重复 yield,thinking 事件仍先于一切处理。新增测试设计正确、覆盖好(分片/不分片、1-2 个工具、流式开关、非法 JSON 保持错误信封、混合分片不静默改变参数),断言与代码路径一致(在本环境 Python3.6 下无法执行,靠逐行追踪验证)。主要风险:混合块现在先生成流式工具事件再生成文本部分事件,破坏了下游 Anthropic 兼容 SSE 代理(server/agents/claude/_proxy.py,未改动文件)的"首个流式工具事件即关闭文本块"假设,导致该代理在混合块场景下静默丢失全部助手文本(本变更使其变得可达的回归)。测试未覆盖该代理消费方。门禁结论:核心修复正确、有测试支撑,但存在一个有界影响的文本丢失回归,建议合入前确认或补充代理层处理。 发现的问题中等
问题: 本变更将流式工具调用事件( 触发条件: AnthropicProxyApp 配置 OpenAI 兼容模型, 实际影响: 通过该代理接入的客户端(如 Claude Code 风格的 Anthropic 流式消费方)拿到的 SSE 流中助手文本整段缺失,表现为"只见工具调用、不见任何前言文本";而变更前该场景文本可正常送达。工具调用增量 JSON 仍可通过 修正方向: 代理层(_proxy.py)在收到流式工具事件时不要立即关闭文本块,改为在最终响应到达(或流结束)时统一关闭并补发文本增量;或在 |
| # Tool deltas and text can coexist in the same chunk. | ||
| _, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls) | ||
|
|
||
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # If streaming tool call arguments is enabled, yield partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event | ||
|
|
||
| if not self._verify_text_content_in_delta_response(chunk_dict): | ||
| continue |
There was a problem hiding this comment.
问题: 本变更将流式工具调用事件(_create_streaming_tool_call_response 生成的 partial LlmResponse)从"仅无文本块"提前到对所有块(含文本+工具调用混合块)在文本处理之前 yield。下游 Anthropic 兼容 SSE 代理 server/agents/claude/_proxy.py 在收到第一个流式工具事件时即置 text_block_closed=True 并关闭 index 0 文本块(_proxy.py:779-780),其后的文本部分事件全部被 if part.text and not text_block_closed(_proxy.py:825)丢弃;而该代理的最终响应分支从不发送最终累积文本。变更前混合块只产生文本部分事件(文本保留、工具调用被 _generate_stream 静默丢弃),变更后文本被代理丢弃——文本丢失是本变更新引入的可达回归。
触发条件: AnthropicProxyApp 配置 OpenAI 兼容模型,request.stream=True 且请求携带 tools(此时代理总是设置 streaming_tool_names={所有工具名}),模型在某一 chunk 中同时返回 content 文本与 tool_calls,或在首个工具 delta 之后继续输出文本。
实际影响: 通过该代理接入的客户端(如 Claude Code 风格的 Anthropic 流式消费方)拿到的 SSE 流中助手文本整段缺失,表现为"只见工具调用、不见任何前言文本";而变更前该场景文本可正常送达。工具调用增量 JSON 仍可通过 input_json_delta 送达,故工具执行不受影响,但用户可见输出数据丢失。
修正方向: 代理层(_proxy.py)在收到流式工具事件时不要立即关闭文本块,改为在最终响应到达(或流结束)时统一关闭并补发文本增量;或在 _generate_stream 中为混合块保持"先文本、后工具事件"的 yield 顺序并在代理层对工具事件后的文本做透传。同时建议新增代理层消费方的回归测试,覆盖"文本+工具调用混合 chunk"流经 _handle_streaming 时文本不丢失。
AI Code Review审查结论不通过 本次变更(dd59dc6 "models: preserve tool calls in mixed text stream chunks")重排了 发现的问题严重
问题: 触发条件: 通过 Claude 代理( 实际影响: 混合 chunk 型提供商在代理路径下的助手文本(含 reasoning 文本)从“必然送达”变为“完全丢失”:首个工具事件之后的全部文本 partial 被丢弃,且 final 响应分支不输出文本,客户端只能看到空文本块 + 工具调用,无任何错误或告警。 修正方向: 在代理侧将文本块关闭逻辑改为“惰性关闭”,或在模型层保证流式工具事件与文本 partial 的产出顺序与旧语义兼容(例如将工具的流式事件延后到文本之后产出、或为代理补充可恢复的文本块 re-open 逻辑)。最小修复可先在 中等
问题: 同一根因的另一消费端症状:混合 chunk 的【流式工具事件先于文本 partial】顺序使 触发条件: LLM 回合中出现混合 chunk(文本与工具起始 delta 同块),且该工具开启流式( 实际影响: 单个 LLM 回合在 AG-UI 客户端显示为多条独立助手文本消息(工具调用前一段、工具调用后一段被拆开),消息语义与旧行为(整段连续文本 + 工具事件)不一致,前端渲染和会话记录出现碎片化。 修正方向: 在 AG-UI 翻译器中对“文本消息随后被工具事件关闭”的场景保持文本累积:工具事件到达时若文本消息仍处于流式状态,将 pending 文本合并到当前消息后再关闭,或让模型层对混合 chunk 先产文本事件再产工具流事件,保持旧顺序契约。 较低
问题: 流式工具事件( 触发条件: 混合 chunk 提供商 + 开启工具流式 + A2A 或 a2a_v1 输出路径。 实际影响: 消费端按 修正方向: 在 较低
问题: 触发条件: 混合 chunk 提供商 + 工具流式开启( 实际影响: 遥测 TTFT 指标系统性偏低/失真,工具型回合与纯文本回合的 TTFT 对比失去可比性(观测类偏差,不影响功能正确性)。 修正方向: 在 较低
问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 partial 的相对顺序(及二者在同一 chunk 内的交错)。 触发条件: 任何未来改动如翻转事件产出顺序(先文本后工具、或合并事件)都会通过这些测试但改变消费端可见契约。 实际影响: 本次已确认的代理文本丢失与 AG-UI 消息拆分回归(见前面评论)在测试中不可见,回归防护缺失,测试对消费端协议顺序给出虚假的安全感。 修正方向: 增加对完整事件序列(含顺序)的断言,例如按序收集所有 较低
问题: 本提交将 触发条件: 代码维护期;高 token 速率流式场景下每个文本 chunk 的多余 dict 遍历。 实际影响: 命名与调用语义背离易诱导后续维护者把调用移回旧的 "if not verify" 分支内(重新引入本提交修复的 bug);重复解析是热点循环中的冗余开销(数值上很小)。 修正方向: 将方法更名为 |
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # If streaming tool call arguments is enabled, yield partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event |
There was a problem hiding this comment.
问题: _generate_stream 现在对每个 chunk 先执行 _process_chunk_without_content 并立即 yield streaming_event(本区块 2525-2537 行),随后才处理同一 chunk 的文本。对“文本 + tool_calls 共存”的 chunk,事件顺序变为【流式工具事件 → 文本 partial】,而旧代码对同一 chunk 只产出文本(工具 delta 被丢弃)。server/agents/claude/_proxy.py 的 _handle_streaming 收到首个流式工具事件时将 text_block_closed 置为 True(779-783 行)并永久关闭 index 0 文本块,此后所有文本 partial 因 if part.text and not text_block_closed(822-836 行)被静默丢弃,final 分支(838 行起)也只输出 function_call 与 message_delta,从不输出已累积的文本。
触发条件: 通过 Claude 代理(setup_claude_env,后端为 OpenAIModel,流式请求时 streaming_tool_names 被设为全部工具名)调用一个会在同一 chunk 中同时发出文本与 tool_calls delta 的提供商——这正是本提交要修复的目标场景(提交自身测试即如此构造 chunk)。
实际影响: 混合 chunk 型提供商在代理路径下的助手文本(含 reasoning 文本)从“必然送达”变为“完全丢失”:首个工具事件之后的全部文本 partial 被丢弃,且 final 响应分支不输出文本,客户端只能看到空文本块 + 工具调用,无任何错误或告警。
修正方向: 在代理侧将文本块关闭逻辑改为“惰性关闭”,或在模型层保证流式工具事件与文本 partial 的产出顺序与旧语义兼容(例如将工具的流式事件延后到文本之后产出、或为代理补充可恢复的文本块 re-open 逻辑)。最小修复可先在 _proxy.py 中跟踪未关闭文本块,在工具事件后收到文本 partial 时重新打开 index 0 文本块再输出 text_delta。
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # If streaming tool call arguments is enabled, yield partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event |
There was a problem hiding this comment.
问题: 同一根因的另一消费端症状:混合 chunk 的【流式工具事件先于文本 partial】顺序使 server/ag_ui/_core/_event_translator.py 的状态机在单个 LLM 回合内多次拆消息。_translate_streaming_tool_call 对每个新 tool id 的首次事件调用 force_close_streaming_message()(447-453 行,发出 TEXT_MESSAGE_END 并重置 _is_streaming/_streaming_message_id,见 648-673 行),随后同一 chunk 的文本 partial 在 _translate_text_content(319-332 行)发现 not _is_streaming 而生成新的 TEXT_MESSAGE_START 与新的 message_id。
触发条件: LLM 回合中出现混合 chunk(文本与工具起始 delta 同块),且该工具开启流式(streaming_tool_names 非空)。多工具回合可在文本片段之间反复触发。
实际影响: 单个 LLM 回合在 AG-UI 客户端显示为多条独立助手文本消息(工具调用前一段、工具调用后一段被拆开),消息语义与旧行为(整段连续文本 + 工具事件)不一致,前端渲染和会话记录出现碎片化。
修正方向: 在 AG-UI 翻译器中对“文本消息随后被工具事件关闭”的场景保持文本累积:工具事件到达时若文本消息仍处于流式状态,将 pending 文本合并到当前消息后再关闭,或让模型层对混合 chunk 先产文本事件再产工具流事件,保持旧顺序契约。
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event |
There was a problem hiding this comment.
问题: 流式工具事件(_create_streaming_tool_call_response 产出的 LlmResponse)不带 response_id,其 custom_metadata 也不含 CHUNK/TOOL_STREAMING 标记(对比 Responses API 路径 2392-2401 行)。本提交的循环重排使这类事件与携带 response_id 的文本 partial 在同一 chunk 内交替产出,“无 response_id 事件穿插于有 id 事件之间”成为常态,A2A/A2A_v1 转换器对每个事件回退生成新的 llm_response_id(a2a/_event_converter.py:247-253),同一 assistant 回合内的工具流消息与文本消息无法按响应 id 归组。
触发条件: 混合 chunk 提供商 + 开启工具流式 + A2A 或 a2a_v1 输出路径。
实际影响: 消费端按 llm_response_id 关联消息时,流式工具消息被孤立为独立响应,会话关联与前端分组错乱(无数据丢失,属关联性缺陷)。
修正方向: 在 _create_streaming_tool_call_response 中补充 response_id(从 _generate_stream 传入已捕获的 response_id)并参照 Responses 路径附带 custom_metadata={const.CHUNK:..., const.TOOL_STREAMING: True},与 Responses 路径契约对齐。
| if usage: | ||
| last_usage = usage | ||
|
|
||
| # If streaming tool call arguments is enabled, yield partial tool call events |
There was a problem hiding this comment.
问题: _llm_processor.call_llm_async 的 t_first_token(138-141 行)依赖 llm_response.has_content(),而 has_content() 将 function_call part 也计为内容(_llm_response.py:52-63)。本提交使混合 chunk 提供商开启流式时每 chunk 首个有内容的响应变为流式工具事件而非文本(工具事件先于文本产出),导致 t_first_token/TTFT 遥测以首个工具 delta 到达时间代替首文本 token 时间。
触发条件: 混合 chunk 提供商 + 工具流式开启(streaming_tool_names 非空),工具 delta 先于文本产出(本提交新的输出顺序)。
实际影响: 遥测 TTFT 指标系统性偏低/失真,工具型回合与纯文本回合的 TTFT 对比失去可比性(观测类偏差,不影响功能正确性)。
修正方向: 在 _llm_processor 中仅以文本 part(part.text 非空)作为首 token 判定,或让 has_content 对 TOOL_STREAMING_ARGS 型 function_call 不计数。
| for thought, field in [(False, "content"), (True, "reasoning_content")]: | ||
| expected = (text.get(field) or "") * len(fragments) | ||
| assert "".join(part.text for part in final.content.parts | ||
| if part.text and bool(part.thought) == thought) == expected | ||
| assert "".join(part.text for response in responses[:-1] if response.content for part in response.content.parts | ||
| if part.text and bool(part.thought) == thought) == expected | ||
| partial_calls = [ | ||
| part.function_call for response in responses[:-1] if response.content for part in response.content.parts | ||
| if part.function_call | ||
| ] | ||
| assert [(call.id, call.name, call.args) for call in partial_calls] == ([("call_0", "tool_0", { | ||
| const.TOOL_STREAMING_ARGS: fragment | ||
| }) for fragment in fragments] if streaming else []) |
There was a problem hiding this comment.
问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 partial 的相对顺序(及二者在同一 chunk 内的交错)。test_mixed_stream_preserves_calls_and_text(本文件 79-91 行)分别断言文本聚合与工具 delta 序列,test_one_mixed_fragment...(123-132 行)仅断言工具 delta 序列;text={} 参数化用例(46 行第一个参数)使 deltas 退化为纯工具 chunk,断言为空;混合 chunk + 分片 + 非法 JSON 修复路径组合也未覆盖。
触发条件: 任何未来改动如翻转事件产出顺序(先文本后工具、或合并事件)都会通过这些测试但改变消费端可见契约。
实际影响: 本次已确认的代理文本丢失与 AG-UI 消息拆分回归(见前面评论)在测试中不可见,回归防护缺失,测试对消费端协议顺序给出虚假的安全感。
修正方向: 增加对完整事件序列(含顺序)的断言,例如按序收集所有 responses 的事件类型列表并校验混合 chunk 的事件顺序为 [streaming-tool, text],并补充混合 + 分片 + 非法 JSON 的修复路径用例。
| self, chunk_dict: dict, accumulated_tool_calls: list[dict] | ||
| ) -> tuple[Optional[FinishReason], Optional[GenerateContentResponseUsageMetadata], dict[int, str]]: | ||
| """Process a chunk that doesn't contain content. | ||
| """Process tool calls and usage, including chunks that also contain text. |
There was a problem hiding this comment.
问题: 本提交将 _process_chunk_without_content 改为对每个 chunk 无条件调用,其方法名("without content")与新语义(含文本 chunk 也调用)相矛盾——此处 docstring 已更新但名称未改;同时每个 chunk 现在对同一嵌套结构做多次重复解析(_process_chunk_without_content 解析 choices[0].delta、_verify_text_content_in_delta_response 再次解析、内容区第三次取 choice[const.DELTA]),finish_reason 返回值在 2525-2526 行仍被 _ 丢弃。
触发条件: 代码维护期;高 token 速率流式场景下每个文本 chunk 的多余 dict 遍历。
实际影响: 命名与调用语义背离易诱导后续维护者把调用移回旧的 "if not verify" 分支内(重新引入本提交修复的 bug);重复解析是热点循环中的冗余开销(数值上很小)。
修正方向: 将方法更名为 _process_chunk(或 _process_tool_calls_and_usage)并合并单次 delta 解析:让该方法同时返回文本存在性,消除重复遍历;顺带删除已无消费者的 finish_reason 返回值。
AI Code Review审查结论通过 本次变更(99396f8 "models: preserve tool calls in mixed text stream chunks")审查范围为基础提交 594bfb0 至 head 99396f8 的两个文件: 发现的问题中等
问题: 本次变更在处理同时携带文本与 触发条件: 通过 Claude 代理( 实际影响: 代理 SSE 流中助手文本被截断:混合 chunk 的文本以及其后所有文本(包括最终响应中未单独重发的文本)全部丢失,客户端只看到工具调用而看不到正文;改动前该文本能正常送达(当时的缺陷仅为工具调用延迟到 final)。AG-UI 路径同样因该顺序把本应位于工具调用之前的文本拆到工具调用之后的新文本消息中,影响前端消息渲染顺序。 修正方向: 在 |
| # If streaming tool call arguments is enabled, yield partial tool call events | ||
| # delta_arguments being non-empty means tool calls were processed | ||
| if streaming_tool_names and delta_arguments and accumulated_tool_calls: | ||
| # Yield streaming tool call event with delta arguments | ||
| # Only for tools in streaming_tool_names | ||
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) | ||
| if streaming_event: | ||
| yield streaming_event | ||
|
|
||
| if not self._verify_text_content_in_delta_response(chunk_dict): | ||
| continue |
There was a problem hiding this comment.
问题: 本次变更在处理同时携带文本与 tool_calls 的混合 chunk 时,先产出流式工具调用事件(第 2530-2537 行),再处理并产出文本 partial 事件(第 2570/2600 行),同一 chunk 的事件顺序被颠倒为"工具调用在前、文本在后"。下游 Claude 代理(server/agents/claude/_proxy.py 的 _handle_streaming)在收到第一个流式工具事件时即永久设置 text_block_closed = True(_proxy.py:779-782 的 content_block_stop),随后到达的同 chunk 文本 partial 事件在第 825 行 if part.text and not text_block_closed 处被静默丢弃,且该状态持续到流结束;final 分支(_proxy.py:839-898)不重发文本。
触发条件: 通过 Claude 代理(setup_claude_env,代理对所有工具启用 streaming_tool_names,_proxy.py:567-569)使用 OpenAI 兼容模型,且模型返回的某个 chunk 同时携带 content 与 tool_calls 增量——这正是本提交要支持的场景(commit message 即为此)。
实际影响: 代理 SSE 流中助手文本被截断:混合 chunk 的文本以及其后所有文本(包括最终响应中未单独重发的文本)全部丢失,客户端只看到工具调用而看不到正文;改动前该文本能正常送达(当时的缺陷仅为工具调用延迟到 final)。AG-UI 路径同样因该顺序把本应位于工具调用之前的文本拆到工具调用之后的新文本消息中,影响前端消息渲染顺序。
修正方向: 在 _generate_stream 中把流式工具事件的产出移到文本处理逻辑之后(先 yield 文本 partial,再 yield 流式工具事件,保持"文本在前、工具在后"的原始语义),或在文本产出前暂存 delta_arguments 延迟到循环尾再产出;同时为 Claude 代理路径补充混合 chunk 的集成测试,覆盖"流式工具事件 + 同 chunk 文本"的消费顺序。
Attach response identity and chunk metadata to Chat Completions tool deltas, matching the Responses stream contract. Cover mixed and tool-only fragments plus fragmented invalid JSON. RELEASE NOTES: Preserve response correlation for streaming tool calls.
|
已核对这两轮新评审。请基于当前 HEAD 2d8ec8b 重新 review;新评论引用的 dd59dc6 / 99396f8 均不是当前 HEAD。
验证:Python 3.12 相关测试 369 passed;flake8 和 git diff --check 通过。本次增量仅补元数据及回归,未重跑真实请求。此前 2845ebb 的 10 次真实网络请求(3 次混合、0 丢失)仍作为该次修复的实际验证,PR 正文已标明提交边界。 |
|
I have read the CLA Document and I hereby sign the CLA |
AI Code Review审查结论通过 审查范围:594bfb0..2845ebb(99396f8 "models: preserve tool calls in mixed text stream chunks" + 2845ebb "models: preserve text alongside streaming tool events"),共 3 个文件(_openai_model.py 的 _generate_stream 重构、claude/_proxy.py 的 _handle_streaming 文本块重开逻辑、新增测试 tests/models/test_openai_mixed_stream.py)。 计划符合性:两个提交的核心目标(同一 chunk 内文本与 tool_calls 共存时两者都保留;文本事件先于工具事件产出)在支持的配置下实现正确。经逐一验证:streaming_event 在所有 chunk 形态下恰好产出一次(无丢失/无重复);usage 每 chunk 只计算一次(hoist 安全,底部重复调用删除正确);_create_streaming_tool_call_response 的 enumerate 索引与真实工具索引因 _process_tool_call_delta 的槽位补齐而恒等(该候选已排除);过滤命中空文本的 continue 路径因仓库内适配器组合(唯一开启 should_filter_reasoning_text 的 HunyuanHy3PreviewAdapter 恰好不产原生 tool_calls)不可达(已排除);代理 stop_reason="tool_use" 在有 tool_use 内容块时符合 Anthropic 协议(已排除)。 主要风险集中在 _proxy.py 新引入的"工具块之后重开文本块"状态机:块停止事件索引非递增、异常路径下重开文本块悬空、以及该特性和模型端"文本+工具事件拆分产出"对下游消费方(AG-UI 翻译器会把同回合拆成多条消息)和流式工具事件缺失 response_id(a2a/会话存储按随机 UUID 关联)造成的影响。均为影响真实但有界的问题,无 SEVERE 级缺陷。 测试影响:新增测试覆盖了混合 chunk 保留文本与工具调用、分片参数、非法 JSON 错误信封、混合 chunk 经代理转 Anthropic SSE 等核心路径,质量良好;但存在盲区——过滤抑制路径(新加的 门禁结论:PASSED(无 SEVERE,2 条 MODERATE + 2 条 LOW,均给出根因、触发条件、影响与修正方向;全部锚定在本次变更文件的可评论行范围内)。 发现的问题中等
问题: 新引入的"工具块之后重开文本块"逻辑( 触发条件: 任何在工具流中间继续输出文本的模型(Anthropic 真实 API 允许工具块之后续输出文本,OpenAI 兼容供应商也存在该行为)都会走到重开路径;当流在最终 non-partial 响应之前异常终止(供应商连接中断、超时等)时,重开文本块悬空;正常收尾时 stop 索引顺序为非递增。 实际影响: 对严格按 Anthropic Messages 流式协议解析的第三方客户端(本代理的服务对象),异常回合出现"有 start 无 stop"的悬空内容块,或 stop 索引乱序导致块归属错配,可能报协议错误或悬挂等待;正常路径下则与官方文档"索引按块出现顺序递增"的约定相悖。 修正方向: 在 中等
问题: 本次变更使 OpenAIModel 对"文本+tool_calls 共存"的同一 chunk 拆分为两个事件产出(先文本 partial,再于末尾 yield 流式工具事件),这是下游 AG-UI 翻译器回合割裂的根因所在: 触发条件: 启用流式工具( 实际影响: 同一回合被切分为多个 AG-UI 消息(如"Recording"与"now."分属两个 message_id),按 message_id 聚合、去重或渲染为独立气泡的前端会看到回合被割裂,消息时序与原文顺序错乱。 修正方向: 在事件翻译层对同一回合内工具事件之后的文本延续复用原消息(如记录"工具间文本"状态,不重新 START),或将本行末尾的独立事件 yield 与文本 partial 合并为同一 较低
问题: 此处调用的 触发条件: 任何启用流式工具且模型产出工具增量的回合; 实际影响: 同一回合的流式工具事件与文本/终态事件无法按 response_id 关联,会话持久化与 a2a 消息聚合把同回合事件当作不同消息处理,影响消息分组、压缩与检索的完整性。 修正方向: 在 较低
问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面:1) 测试模型均未设置 触发条件: 后续修改若破坏了过滤抑制路径下的工具事件产出、stop 顺序或转换链路上的 streaming_tool_names 设置,整套测试仍保持绿色。 实际影响: 本次变更新增的最复杂状态机(文本块重开、事件经多个 continue 分支产出)缺少有效回归防护,回归只能靠生产事故暴露。 修正方向: 为工具提示过滤路径补充用例(构造 |
| # Handle partial responses (streaming text only) | ||
| elif llm_response.partial and llm_response.content: | ||
| for part in llm_response.content.parts: | ||
| if part.text and not text_block_closed: | ||
| if part.text: | ||
| if text_block_closed: | ||
| # A model may emit more text after starting a tool call. | ||
| next_tool_index += 1 | ||
| text_block_index = next_tool_index | ||
| text_block_closed = False | ||
| text_start_event = { | ||
| "type": "content_block_start", | ||
| "index": text_block_index, | ||
| "content_block": {"type": "text", "text": ""}, | ||
| } | ||
| yield ("event: content_block_start\n" | ||
| f"data: {json.dumps(text_start_event)}\n\n") | ||
| # Stream text deltas |
There was a problem hiding this comment.
问题: 新引入的"工具块之后重开文本块"逻辑(text_block_closed 为 True 时 next_tool_index += 1 并重新发出 content_block_start)使同一回合出现多个文本/工具内容块,但块的生命周期管理与既有约定不配套:最终分支(855-866 行)先关文本块再关已流式工具块,导致 content_block_stop 的索引非递增发出(如 stop(2) 先于 stop(1));且 953-970 行的异常处理路径不关闭任何已开内容块,重开的文本块在生成器中途异常时永远收不到 content_block_stop。
触发条件: 任何在工具流中间继续输出文本的模型(Anthropic 真实 API 允许工具块之后续输出文本,OpenAI 兼容供应商也存在该行为)都会走到重开路径;当流在最终 non-partial 响应之前异常终止(供应商连接中断、超时等)时,重开文本块悬空;正常收尾时 stop 索引顺序为非递增。
实际影响: 对严格按 Anthropic Messages 流式协议解析的第三方客户端(本代理的服务对象),异常回合出现"有 start 无 stop"的悬空内容块,或 stop 索引乱序导致块归属错配,可能报协议错误或悬挂等待;正常路径下则与官方文档"索引按块出现顺序递增"的约定相悖。
修正方向: 在 except 分支(953-970 行)发出 content_block_stop(携带当前 text_block_index)以关闭未收尾的文本块;最终收尾分支按索引升序(先小后大)发出各块的 stop,或将重开文本块的分配改为复用单一文本块索引而仅交替 start/stop。
| if streaming_event: | ||
| yield streaming_event |
There was a problem hiding this comment.
问题: 本次变更使 OpenAIModel 对"文本+tool_calls 共存"的同一 chunk 拆分为两个事件产出(先文本 partial,再于末尾 yield 流式工具事件),这是下游 AG-UI 翻译器回合割裂的根因所在:_translate_streaming_tool_call 在首个流式工具事件时调用 force_close_streaming_message() 结束当前文本消息;当模型在工具增量之后继续输出文本(本变更新增支持的场景)时,后续文本事件在 _translate_text_content 中生成新的 TEXT_MESSAGE_START(新的随机 message_id)。
触发条件: 启用流式工具(StreamingFunctionTool)+ 模型在工具增量之间穿插输出文本的任一回合;变更前同 chunk 混合文本+工具只产出文本事件(工具增量被丢弃),因此该拆分与后续消息割裂是本次变更引入的。
实际影响: 同一回合被切分为多个 AG-UI 消息(如"Recording"与"now."分属两个 message_id),按 message_id 聚合、去重或渲染为独立气泡的前端会看到回合被割裂,消息时序与原文顺序错乱。
修正方向: 在事件翻译层对同一回合内工具事件之后的文本延续复用原消息(如记录"工具间文本"状态,不重新 START),或将本行末尾的独立事件 yield 与文本 partial 合并为同一 LlmResponse(同 chunk 的文本与工具增量合入同一 Content),从源头保证一轮内文本与工具事件的消息连续性。
| streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, | ||
| delta_arguments, | ||
| streaming_tool_names) |
There was a problem hiding this comment.
问题: 此处调用的 _create_streaming_tool_call_response(1348-1352 行)构造的流式工具事件 LlmResponse 未携带 response_id,而同 chunk 的文本事件(2608-2613 行)携带了 response_id=response_id。本次变更使流式工具事件在更多 chunk 上产出(混合 chunk 也会产出),放大了该缺口。
触发条件: 任何启用流式工具且模型产出工具增量的回合;LlmProcessor._create_event_from_response 将缺失的 response_id 透传为 None,a2a _event_converter._effective_response_id(257-263 行)回退为每事件随机 UUID,_sql_session_service(290 行)按事件存储 response_id。
实际影响: 同一回合的流式工具事件与文本/终态事件无法按 response_id 关联,会话持久化与 a2a 消息聚合把同回合事件当作不同消息处理,影响消息分组、压缩与检索的完整性。
修正方向: 在 _create_streaming_tool_call_response 返回的 LlmResponse 上补充 response_id(由调用方把当前 response_id 传入并设置到返回对象),并同步检查 _anthropic_model.py 中同类实现(430-433 行)。
| }, | ||
| { | ||
| "content": "now.", | ||
| "tool_calls": [tool_delta(0, '12}', first=False)] | ||
| }, | ||
| ], {"tool_0"}) | ||
|
|
||
| async def generate(request, stream=True): | ||
| for response in responses: | ||
| yield response | ||
|
|
||
| model = SimpleNamespace(generate_async=generate) | ||
| request = AnthropicMessagesRequest(model="synthetic", messages=[{"role": "user", "content": "record"}], stream=True) | ||
| events = [event async for event in AnthropicProxyApp()._handle_streaming(LlmRequest(), request, model)] | ||
| payloads = [ | ||
| json.loads(line[6:]) for event in events for line in event.splitlines() | ||
| if line.startswith("data: ") and line != "data: [DONE]" | ||
| ] | ||
| deltas = [payload["delta"] for payload in payloads if payload["type"] == "content_block_delta"] | ||
| assert "".join(delta["text"] for delta in deltas if delta["type"] == "text_delta") == "Recording now." | ||
| assert "".join(delta["partial_json"] for delta in deltas if delta["type"] == "input_json_delta") == '{"count":12}' | ||
|
|
||
| starts = [payload["index"] for payload in payloads if payload["type"] == "content_block_start"] | ||
| stops = [payload["index"] for payload in payloads if payload["type"] == "content_block_stop"] | ||
| assert len(starts) == len(set(starts)) | ||
| assert sorted(starts) == sorted(stops) |
There was a problem hiding this comment.
问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面:1) 测试模型均未设置 add_tools_to_prompt(tool_prompt=None),因此 2563-2566 与 2594-2599 新增的 yield streaming_event; continue 过滤路径从未被执行;2) 代理测试断言 sorted(starts) == sorted(stops) 对索引顺序不敏感,无法发现 stop 非递增、missing stop、stop_reason 错误等新逻辑回归;3) 代理测试直接调用 _handle_streaming(LlmRequest(), ...),未经过 _convert_anthropic_to_llm_request 转换链路(生产路径在此设置 streaming_tool_names 与系统提示词)。
触发条件: 后续修改若破坏了过滤抑制路径下的工具事件产出、stop 顺序或转换链路上的 streaming_tool_names 设置,整套测试仍保持绿色。
实际影响: 本次变更新增的最复杂状态机(文本块重开、事件经多个 continue 分支产出)缺少有效回归防护,回归只能靠生产事故暴露。
修正方向: 为工具提示过滤路径补充用例(构造 add_tools_to_prompt=True、ToolPromptTextFilterMixin 抑制状态 + 混合 chunk);代理测试改为断言 stop 事件与 start 事件按索引成对且严格有序、message_delta.stop_reason 正确,并通过 AnthropicProxyApp 的真实路由(/v1/messages)或 _convert_anthropic_to_llm_request 后的 request 走一遍端到端路径。
Close active text and tool blocks before the existing error termination. Cover an interrupted stream with a reopened text block. RELEASE NOTES: Close content blocks when an interleaved proxy stream fails.
|
已核对 2845ebb 这轮评审并补充 ed2c396:代理中途异常时,先关闭已打开的文本和工具块,再发送原有错误结束事件。新增异常注入测试在修改前失败(message_delta 到达时索引 1、2 仍打开),修改后通过;相关测试共 370 passed,flake8 和 diff --check 通过。本次为确定性异常回归,未重复真实网络请求。 response_id 已在 2d8ec8b 修复。其余建议暂不扩大修改:AG-UI 在工具开始时关闭文本消息是现有设计,工具之后的新文本应如何聚合需要维护者确认;将已结束的消息直接继续输出或将文本/工具合入同一响应,都可能改变其他消费方契约。Anthropic 文档按 index 标识内容块,目前未找到 stop 索引必须全局递增的明确要求,因此不复用已关闭索引,也不将该项描述为已确认的协议错误。当前测试会验证结束前所有已启动块恰好关闭。 请基于最新 HEAD review,并请维护者确认 AG-UI 消息分段是否需要另行调整。 |
AI Code Review审查结论通过 审查范围:base 594bfb0..head ed2c396,共 4 个提交(99396f8 混合块工具调用保留、2845ebb 代理文本块重建、2d8ec8b 工具事件响应身份、ed2c396 流失败关闭内容块),涉及 trpc_agent_sdk/models/_openai_model.py、trpc_agent_sdk/server/agents/claude/_proxy.py 及新增测试 tests/models/test_openai_mixed_stream.py(332 增/31 删)。 计划符合性:核心需求"在混合文本流 chunk 中保留工具调用"已正确实现。模型层 主要风险:期望的 8/8 个测试断言路径全部通过推演验证,未被测试覆盖的唯一边界是"异常发生在最终完整响应分支(非流式工具块)内部"——该分支启动的 tool_use 块未注册进 测试充分性:新增 9 个测试 266 行,覆盖文本×推理×分片×工具数的参数化组合、流式/非流式事件、无效 JSON 错误信封、代理层文本保留与块索引唯一性、响应身份与元数据、以及交错流失败时关闭开放块,测试设计将根因与行为断言直接绑定,充分性总体很高,仅上述最终分支故障路径无覆盖。 门禁结论:核心功能正确、测试覆盖良好,未发现 SEVERE 级问题;一个 MODERATE 结构性缺口(最终分支工具块在流失败时无法被关闭)建议修复后合入。 发现的问题中等
问题: ed2c396 新增的失败关闭逻辑(except 路径,第 957-968 行)只能关闭 触发条件: 模型流已结束、进入最终响应分支后发生异常——例如 实际影响: 客户端(Anthropic SDK 流解析)已收到该 tool_use 块的 修正方向: 在最终分支 yield 较低
问题: ed2c396 新增的 except 关闭逻辑(本次变更中结构最复杂的部分)只被 触发条件: 覆盖缺失本身不触发,但它与上述 MODERATE 问题的根因绑定——测试套件未驱动最终分支中途抛错,导致该分支工具块无法关闭的缺陷不会被 CI 发现。 实际影响: 新增失败处理的最薄弱路径无回归保护;若后续修改了最终分支的 yield 顺序或 args 序列化,未终止块问题会在用户侧静默浮现而 CI 依旧全绿。 修正方向: 新增一个测试:生成器先产出完整最终响应(含未流式工具调用),随后在最终分支处理过程中抛错(例如让 |
| # Close started blocks before terminating the interrupted message. | ||
| open_indices = [] | ||
| if not text_block_closed: | ||
| open_indices.append(text_block_index) | ||
| text_block_closed = True | ||
| for tool_info in streaming_tool_calls.values(): | ||
| if tool_info.get("started") and not tool_info.get("closed"): | ||
| open_indices.append(tool_info["index"]) | ||
| tool_info["closed"] = True | ||
| for index in sorted(open_indices): | ||
| stop_event = {"type": "content_block_stop", "index": index} | ||
| yield f"event: content_block_stop\ndata: {json.dumps(stop_event)}\n\n" |
There was a problem hiding this comment.
问题: ed2c396 新增的失败关闭逻辑(except 路径,第 957-968 行)只能关闭 streaming_tool_calls 中已注册的流式工具块和当前文本块;而最终完整响应分支(not llm_response.partial)在 yield content_block_start 前并未把该工具注册进 streaming_tool_calls(此处直接使用局部 tool_index,随后才 yield start/delta/stop)。因此该分支启动的 tool_use 块在失败时处于"不可关闭"状态。
触发条件: 模型流已结束、进入最终响应分支后发生异常——例如 json.dumps(part.function_call.args) 遇到不可序列化参数、llm_response.usage_metadata/stop_reason 处理抛错,或该分支内任意 yield 失败;此时 except 路径运行,但 streaming_tool_calls 中不存在该工具,open_indices 收集不到它的索引。
实际影响: 客户端(Anthropic SDK 流解析)已收到该 tool_use 块的 content_block_start,却永远不会收到对应 content_block_stop,随后收到 message_delta(stop_reason=error)+message_stop,造成未终止的内容块——与提交"close open content blocks on streaming failure"的承诺相悖,客户端可能报协议错误或悬挂聚合。
修正方向: 在最终分支 yield content_block_start 之前,将 {index: tool_index, started: True, closed: False} 以工具 id 或独立键注册进 streaming_tool_calls(或在 start 与 stop 之间用 try/finally 保证 stop 必然发出),使 except 路径能像对待流式工具块一样补发 content_block_stop;并补一个在该分支中途抛错的测试用例。
| open_indices = [] | ||
| if not text_block_closed: | ||
| open_indices.append(text_block_index) | ||
| text_block_closed = True | ||
| for tool_info in streaming_tool_calls.values(): | ||
| if tool_info.get("started") and not tool_info.get("closed"): | ||
| open_indices.append(tool_info["index"]) | ||
| tool_info["closed"] = True | ||
| for index in sorted(open_indices): | ||
| stop_event = {"type": "content_block_stop", "index": index} | ||
| yield f"event: content_block_stop\ndata: {json.dumps(stop_event)}\n\n" |
There was a problem hiding this comment.
问题: ed2c396 新增的 except 关闭逻辑(本次变更中结构最复杂的部分)只被 test_proxy_closes_open_blocks_when_interleaved_stream_fails 覆盖了"流式工具+文本交错中模型抛错"这一种故障形态;"异常发生在最终完整响应分支内部(非流式工具块已 start 未 stop)"这一唯一无法被该逻辑清理的路径没有任何测试。
触发条件: 覆盖缺失本身不触发,但它与上述 MODERATE 问题的根因绑定——测试套件未驱动最终分支中途抛错,导致该分支工具块无法关闭的缺陷不会被 CI 发现。
实际影响: 新增失败处理的最薄弱路径无回归保护;若后续修改了最终分支的 yield 顺序或 args 序列化,未终止块问题会在用户侧静默浮现而 CI 依旧全绿。
修正方向: 新增一个测试:生成器先产出完整最终响应(含未流式工具调用),随后在最终分支处理过程中抛错(例如让 part.function_call.args 含不可 json 序列化对象),断言所有已发出的 content_block_start 索引都收到过 content_block_stop 后再出现 message_delta(error)。
AI Code Review审查结论通过 审查范围:594bfb0..2d8ec8b(3 个提交:models: preserve tool calls in mixed text stream chunks / preserve text alongside streaming tool events / retain response identity on streaming tool events),涉及 发现的问题中等
问题: 流式工具调用事件新增 触发条件: 启用 实际影响: 客户端每收到一个工具参数片段就多收到一条携带原始 provider JSON 的 修正方向: 若该元数据仅用于响应关联/调试,可仅在首片段或末片段附加,或复用与文本事件一致的轻量元数据(如仅 中等
问题: 混合流中“工具块启动后再次出现文本”时,代理层重新打开文本块( 触发条件: 模型输出形如 chunk1={content:"Recording " + tool 片段1}、chunk2={content:"now." + tool 片段2} 的交错流;代理层最终发出 实际影响: 采用严格交替校验的 Anthropic SDK 客户端(跟踪打开态,或在新 start 到达时定稿前一工具块)可能拒绝该流或把工具参数截断为不完整 JSON(如 修正方向: 在工具块已启动后遇到文本时,不立即开启新文本块:将文本缓冲至工具块完成后再以索引递增的新文本块连续追加(保持严格交替序与模型输出相对顺序),并在测试中断言重组后的块顺序/交替序。 较低
问题: 工具分支在收到空 arguments 片段( 触发条件: OpenAI 风格流式工具调用的首个片段常为 实际影响: 客户端收到参数为空且内容不完整的 修正方向: 对 |
| if streaming_event: | ||
| streaming_event.response_id = response_id | ||
| streaming_event.custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True} |
There was a problem hiding this comment.
问题: 流式工具调用事件新增 custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True},把包含原始 tool_calls/function.arguments 增量的完整 provider chunk 塞进每条事件;此前流式工具事件的 custom_metadata 为空。
触发条件: 启用 streaming_tool_names 且 provider 逐片段下发工具参数时,每个片段都会产生一条带此元数据的事件;AG-UI 翻译器对任何带 custom_metadata 的事件都产出 CustomEvent(name="trpc_metadata") 下发客户端(_event_translator.py:232-239),同一混合 chunk 的文本事件({CHUNK: chunk_dict})与工具事件各发一条,内容几乎重复。
实际影响: 客户端每收到一个工具参数片段就多收到一条携带原始 provider JSON 的 trpc_metadata CustomEvent,负载随片段数线性增长且与文本事件元数据重复;将 provider 内部字段(含参数增量原文)暴露给前端,属新增的信息面。
修正方向: 若该元数据仅用于响应关联/调试,可仅在首片段或末片段附加,或复用与文本事件一致的轻量元数据(如仅 const.TOOL_STREAMING: True),避免逐片段透传完整 chunk_dict。
| # Handle partial responses (streaming text only) | ||
| elif llm_response.partial and llm_response.content: | ||
| for part in llm_response.content.parts: | ||
| if part.text and not text_block_closed: | ||
| if part.text: | ||
| if text_block_closed: | ||
| # A model may emit more text after starting a tool call. | ||
| next_tool_index += 1 | ||
| text_block_index = next_tool_index | ||
| text_block_closed = False | ||
| text_start_event = { | ||
| "type": "content_block_start", | ||
| "index": text_block_index, | ||
| "content_block": {"type": "text", "text": ""}, | ||
| } | ||
| yield ("event: content_block_start\n" | ||
| f"data: {json.dumps(text_start_event)}\n\n") |
There was a problem hiding this comment.
问题: 混合流中“工具块启动后再次出现文本”时,代理层重新打开文本块(_proxy.py:828-839),在仍在打开的工具块期间发出 content_block_start(新索引,text),并在其后再对该工具块发 input_json_delta,既违背 Anthropic parts-stream 的严格 start/stop 交替序,也使重建消息的文本/工具相对顺序与模型原始输出不一致。
触发条件: 模型输出形如 chunk1={content:"Recording " + tool 片段1}、chunk2={content:"now." + tool 片段2} 的交错流;代理层最终发出 start(0,text)、delta(0)、stop(0)、start(1,tool)、delta(1)、start(2,text)、delta(2)、stop(2)、delta(1)、stop(1) —— 工具块 1 跨越文本块 2 的完整生命周期保持打开,delta(1) 在 stop(2) 之后发出,且客户端按索引重组后的消息为 text0="Recording "、tool1、text2="now."。
实际影响: 采用严格交替校验的 Anthropic SDK 客户端(跟踪打开态,或在新 start 到达时定稿前一工具块)可能拒绝该流或把工具参数截断为不完整 JSON(如 '{"count":'),导致工具调用失败;其余客户端按索引重建时工具调用块被插入句子中间("Recording [tool] now."),与模型原始输出("Recording now." 后跟工具)顺序不同,多轮对话历史与用户效果错位;test_mixed_chunks_keep_text_through_anthropic_proxy 仅断言同类 delta 拼接与 starts/stops 集合相等,未断言块拓扑/交替序,掩盖该偏差。
修正方向: 在工具块已启动后遇到文本时,不立即开启新文本块:将文本缓冲至工具块完成后再以索引递增的新文本块连续追加(保持严格交替序与模型输出相对顺序),并在测试中断言重组后的块顺序/交替序。
| # Close text block if not already closed | ||
| if not text_block_closed: | ||
| text_block_closed = True | ||
| text_stop_event = {"type": "content_block_stop", "index": text_block_index} | ||
| yield ("event: content_block_stop\n" | ||
| f'data: {json.dumps({"type": "content_block_stop", "index": 0})}\n\n') | ||
| f"data: {json.dumps(text_stop_event)}\n\n") |
There was a problem hiding this comment.
问题: 工具分支在收到空 arguments 片段(delta_json == "")时仍将工具注册进 streaming_tool_calls 并发出 content_block_start(tool_use, input={}),但 if delta_json: 守卫导致不发出任何 input_json_delta;最终响应随后因 tool_id in streaming_tool_calls 跳过该工具的完整 tool_use(_proxy.py:875)。
触发条件: OpenAI 风格流式工具调用的首个片段常为 {tool_calls:[{id, name, function:{arguments:""}}]},_openai_model._process_chunk_without_content 会将空串写入 delta_arguments(仅判 is not None),从而产出 TOOL_STREAMING_ARGS:"" 的事件;若该工具后续片段不再携带参数(参数仅在最终响应中完整给出),客户端最终只能得到 input: {} 的工具块。混合块改动(此前混合块直接丢弃工具增量)使该路径在带文本的块中变可达。
实际影响: 客户端收到参数为空且内容不完整的 tool_use 块,stop_reason 仍为 tool_use;下游按块渲染/执行时工具以空输入呈现。常见场景(后续片段继续下发参数)不受影响,影响面窄但真实。
修正方向: 对 delta_json.strip() 为空的首片段不注册/不发送 content_block_start,或至少延迟注册直到首个非空片段;并在最终响应侧对“已注册但从未收到任何 delta”的工具回退到完整 tool_use 路径。
Fixes #364
真实 OpenAI-compatible 流式请求中,同一个 delta 可以包含非空 content 与工具名、调用 ID。原实现只在没有文本时累积 tool_calls,导致工具头丢失。后续参数即使是完整合法 JSON,也无法生成最终调用,且 error_code 为空。
本修改让工具字段累积独立于文本判断。同包先输出文本事件,再输出工具参数事件;已有参数解析策略保持不变。
同时修复 Anthropic 兼容代理的文本处理:工具事件后若继续收到文本,代理创建新的文本块,并使用唯一索引正确关闭文本块。这样可以保留多个混合包交替返回的正文,避免恢复工具调用后又丢失文本。
真实请求验证
从 PyPI 下载并校验正式 wheel。原版实验不替换客户端或解析方法,也不构造、重排或回放响应包。每个版本发起 10 次真实请求:
随后在隔离的 1.2.0 包上应用本 PR 在 2845ebb 提交时的解析器修改,重新发起 10 次真实请求。3 次遇到混合包,调用与参数均完整保留;其余 7 次也一致,没有请求异常。修复文件与提交文件的 SHA256 一致。这是独立网络请求的逐次对照,不是同一响应回放。
上述结果只确认本次 OpenAI-compatible 路径的实际触发,不代表生产发生率。没有执行真实业务工具,也未验证供应商直连路径或业务流水线。
回归与评审修正
RELEASE NOTES: 修复混合文本流式响应中的工具调用丢失,并保留兼容代理在工具事件前后收到的文本。
补充:ed2c396 在代理流中途异常时关闭所有已打开的文本和工具块,再发送原有错误结束事件。新增异常注入回归在修改前失败(未关闭索引 1、2),修改后通过;本次未重跑真实网络请求。