Skip to content

models: preserve tool calls in mixed text stream chunks - #363

Open
cloviswang wants to merge 4 commits into
trpc-group:mainfrom
cloviswang:codex/preserve-mixed-stream-tool-calls
Open

cloviswang wants to merge 4 commits into
trpc-group:mainfrom
cloviswang:codex/preserve-mixed-stream-tool-calls

Conversation

@cloviswang

@cloviswang cloviswang commented Oct 29, 2026 •

Copy link
Copy Markdown

Fixes #364

真实 OpenAI-compatible 流式请求中,同一个 delta 可以包含非空 content 与工具名、调用 ID。原实现只在没有文本时累积 tool_calls,导致工具头丢失。后续参数即使是完整合法 JSON,也无法生成最终调用,且 error_code 为空。

本修改让工具字段累积独立于文本判断。同包先输出文本事件,再输出工具参数事件;已有参数解析策略保持不变。

同时修复 Anthropic 兼容代理的文本处理:工具事件后若继续收到文本,代理创建新的文本块,并使用唯一索引正确关闭文本块。这样可以保留多个混合包交替返回的正文,避免恢复工具调用后又丢失文本。

真实请求验证

从 PyPI 下载并校验正式 wheel。原版实验不替换客户端或解析方法,也不构造、重排或回放响应包。每个版本发起 10 次真实请求:

原版 SDK 收到混合包 丢调用
1.1.4 5 5
1.1.20 3 3
1.1.21 3 3
1.2.0 3 3

随后在隔离的 1.2.0 包上应用本 PR 在 2845ebb 提交时的解析器修改,重新发起 10 次真实请求。3 次遇到混合包,调用与参数均完整保留;其余 7 次也一致,没有请求异常。修复文件与提交文件的 SHA256 一致。这是独立网络请求的逐次对照,不是同一响应回放。

上述结果只确认本次 OpenAI-compatible 路径的实际触发,不代表生产发生率。没有执行真实业务工具,也未验证供应商直连路径或业务流水线。

回归与评审修正

  • OpenAI、Responses 和 Anthropic 代理相关测试:Python 3.12 下 370 passed。
  • 新增 52 项回归,覆盖文本/工具混合、分片、多工具、流式参数、非法 JSON、同包事件顺序及代理交替文本块。
  • 针对评审发现的事件顺序与代理正文丢失,两个新增测试在前一版补丁上均失败,在当前修改上通过。
  • 2d8ec8b 补齐 Chat Completions 工具流事件的 response_id、CHUNK 和 TOOL_STREAMING 元数据,并增加混合/纯工具分片关联及混合分片非法 JSON 回归。本次未重复真实请求;上面的真实请求数据属于 2845ebb。
  • flake8 与 git diff --check 通过。
  • 真实请求用于证明实际触发;人工输入回归用于固定边界行为,两者不混作同一证据。

RELEASE NOTES: 修复混合文本流式响应中的工具调用丢失,并保留兼容代理在工具事件前后收到的文本。

补充:ed2c396 在代理流中途异常时关闭所有已打开的文本和工具块,再发送原有错误结束事件。新增异常注入回归在修改前失败(未关闭索引 1、2),修改后通过;本次未重跑真实网络请求。

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@helloopenworld

Copy link
Copy Markdown
Contributor

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,未改动文件)的"首个流式工具事件即关闭文本块"假设,导致该代理在混合块场景下静默丢失全部助手文本(本变更使其变得可达的回归)。测试未覆盖该代理消费方。门禁结论:核心修复正确、有测试支撑,但存在一个有界影响的文本丢失回归,建议合入前确认或补充代理层处理。

发现的问题

中等

trpc_agent_sdk/models/_openai_model.py:2522-2540

问题: 本变更将流式工具调用事件(_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 时文本不丢失。

Comment on lines +2522 to 2540
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本变更将流式工具调用事件(_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 时文本不丢失。

@cloviswang cloviswang reopened this Oct 29, 2026
@cloviswang
cloviswang marked this pull request as ready for review October 29, 2026 09:38
@cloviswang

Copy link
Copy Markdown
Author

已在 2845ebb 修正评审指出的正文丢失问题:同包先发文本再发工具事件;Anthropic 代理在工具事件后继续收到文本时创建新文本块,并分配唯一索引。两个新增回归在前一版补丁上均失败,在当前版本上通过,覆盖同包事件顺序和多个混合包的代理文本保留。

当前相关回归 366 passed。最终解析器修复另外完成 10 次真实网络请求,3 次遇到混合包,调用和参数均保留,无请求异常。正式发布包 1.1.4、1.1.20、1.1.21、1.2.0 的原版复现结果已补充到 #364。

PR 已转为 Ready for review,请按最新提交评审。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

不通过

本次变更(dd59dc6 "models: preserve tool calls in mixed text stream chunks")重排了 OpenAIModel._generate_stream 的逐 chunk 处理顺序:原来仅对无文本内容的 chunk 执行工具调用 delta 处理,现在对所有 chunk 先执行 _process_chunk_without_content(累积 tool_calls、提取 usage、必要时产出流式工具事件),再处理文本;同时删除了内容区末尾的 usage 处理块。计划符合性:修复目标正确——文本与 tool_calls 共存的流式 chunk(此前工具调用被静默丢弃)现在可同时保留文本与工具调用。新增 132 行测试覆盖分片、index 对齐、非法 JSON 信封、单混合分片不静默改参等场景。经逐点验证:_process_tool_call_delta 先累积后产事件(首分片 name 可用)、usage 单次提取、choices 为空提前返回、双调用/重复累积均不存在,usage 提取不变量完整保留。主要风险:修复使「流式工具事件先于同一 chunk 的文本事件」成为新常态,两个消费端出现回归——(1) server/agents/claude/_proxy.py SSE 代理在首个流式工具事件后永久关闭文本块,其后所有文本 partial 被静默丢弃且 final 分支不输出文本,对混合 chunk 型提供商(正是本提交目标场景)文本由「必然送达」变为「必然丢失」(经验证代理逐行确认);(2) AG-UI 翻译器在流式工具事件时强制关闭当前文本消息,同一 chunk 之后的文本开启新助手消息,单回合被拆成多条消息(经验证代理确认)。次要问题:流式工具事件缺 response_id/TOOL_STREAMING 元数据(与 Responses 路径不一致,A2A 消息归组错乱)、TTFT 遥测以工具 delta 计为首 token、测试缺少事件顺序断言且 text={} 参数化为空断言、_process_chunk_without_content 命名与无条件调用语义不符。排除项:name 缺失致首分片丢失(trace 证明不成立)、finish chunk 重复参数累积(边际且预存)、A2A 混合 part 潜在危害(当前无路径可达)、finally 异常掩蔽与延迟 drain(预存架构)。测试充分性:核心修复行为有覆盖,但消费端顺序契约与混合+分片+非法 JSON 路径未覆盖。门禁结论:存在 SEVERE 级问题(Claude 代理端混合流文本静默丢失),不满足合入条件。

发现的问题

严重

trpc_agent_sdk/models/_openai_model.py:2525-2537

问题: _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。

中等

trpc_agent_sdk/models/_openai_model.py:2525-2537

问题: 同一根因的另一消费端症状:混合 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 先产文本事件再产工具流事件,保持旧顺序契约。

较低

trpc_agent_sdk/models/_openai_model.py:2537

问题: 流式工具事件(_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 路径契约对齐。

较低

trpc_agent_sdk/models/_openai_model.py:2525-2528

问题: _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 不计数。

较低

tests/models/test_openai_mixed_stream.py:79-91

问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 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 的修复路径用例。

较低

trpc_agent_sdk/models/_openai_model.py:1121

问题: 本提交将 _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 返回值。

Comment thread trpc_agent_sdk/models/_openai_model.py Outdated
Comment on lines +2525 to +2537
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _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。

Comment thread trpc_agent_sdk/models/_openai_model.py Outdated
Comment on lines +2525 to +2537
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 同一根因的另一消费端症状:混合 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 先产文本事件再产工具流事件,保持旧顺序契约。

Comment thread trpc_agent_sdk/models/_openai_model.py Outdated
delta_arguments,
streaming_tool_names)
if streaming_event:
yield streaming_event

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 流式工具事件(_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 路径契约对齐。

Comment thread trpc_agent_sdk/models/_openai_model.py Outdated
Comment on lines +2525 to +2528
if usage:
last_usage = usage

# If streaming tool call arguments is enabled, yield partial tool call events

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: _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 不计数。

Comment on lines +79 to +91
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 [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新增测试未覆盖本次变更最关键的对外契约——流式工具事件与文本 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本提交将 _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 返回值。

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

本次变更(99396f8 "models: preserve tool calls in mixed text stream chunks")审查范围为基础提交 594bfb0 至 head 99396f8 的两个文件:trpc_agent_sdk/models/_openai_model.py(44 行改动)与新增测试 tests/models/test_openai_mixed_stream.py(132 行)。计划符合性:变更正确实现了"混合 chunk 下保留工具调用"的核心目标——_generate_stream 现在无条件调用 _process_chunk_without_content 处理工具调用与 usage,不再被 _verify_text_content_in_delta_response 短路,同时移除文本分支中重复的 _process_usage(无重复统计)。测试充分性整体良好:新增测试覆盖文本/推理内容 × 碎块 × 工具数量 × 流式开关的 40 种参数组合、流式增量事件、无效 JSON 错误信封、混合碎块参数重组及 usage 位置两种形态,断言精确。主要风险:混合 chunk 的事件产出顺序被颠倒——流式工具调用事件在文本 partial 事件之前产出(_openai_model.py:2530-2537 先于 2549-2605),直接破坏 Claude 代理 SSE 路径(_proxy.py:779-782 在首个流式工具事件时永久关闭文本块,if part.text and not text_block_closed 会静默丢弃同 chunk 文本及其后所有文本),属本次变更引入的代理端文本截断回归(MODERATE),且新测试未覆盖代理路径。门禁结论:未发现 SEVERE 级缺陷,核心修复正确且测试充分,但建议在合入前修复混合 chunk 的事件顺序并补充代理路径集成测试。

发现的问题

中等

trpc_agent_sdk/models/_openai_model.py:2528-2540

问题: 本次变更在处理同时携带文本与 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 文本"的消费顺序。

Comment thread trpc_agent_sdk/models/_openai_model.py Outdated
Comment on lines 2528 to 2540
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本次变更在处理同时携带文本与 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.

Copy link
Copy Markdown
Author

已核对这两轮新评审。请基于当前 HEAD 2d8ec8b 重新 review;新评论引用的 dd59dc6 / 99396f8 均不是当前 HEAD。

  1. 同包工具事件先于正文、Claude 代理丢正文:2845ebb 已修复。同包先发文本再发工具事件;代理在后续文本到达时创建新文本块,索引唯一。已有事件顺序和代理消费回归。AG-UI 评论所指的同包顺序根因也已修正;不改变工具之后真正到达的新文本的现有消息边界语义。
  2. 工具流事件缺少 response_id:本次 2d8ec8b 已补齐,并附加 CHUNK / TOOL_STREAMING 元数据,与 Responses 路径一致。新增混合与纯工具分片的关联测试。
  3. 测试补充:新增混合、分片、不可解析 JSON 组合,确认按文本/工具交替输出并保留错误信封。空文本参数组是纯工具控制组,不是混合场景证据。
  4. TTFT:当前已先产出同包文本。未改动 has_content() 的全局语义,避免改变原有纯工具响应的计时口径。
  5. helper 命名建议:保留现有私有方法签名与返回值,docstring 和调用处已明确它处理所有 chunk 的工具字段与 usage。本次不扩展为解析器重构。

验证:Python 3.12 相关测试 369 passed;flake8 和 git diff --check 通过。本次增量仅补元数据及回归,未重跑真实请求。此前 2845ebb 的 10 次真实网络请求(3 次混合、0 丢失)仍作为该次修复的实际验证,PR 正文已标明提交边界。

@cloviswang

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

Rook1ex added a commit to trpc-group/cla-database that referenced this pull request Oct 29, 2026
@helloopenworld

Copy link
Copy Markdown
Contributor

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 等核心路径,质量良好;但存在盲区——过滤抑制路径(新加的 yield streaming_event; continue 行)未被任何用例触发,代理测试用 sorted(starts)==sorted(stops) 断言无法发现 stop 顺序/stop_reason 类回归,且代理测试传入空 LlmRequest 未覆盖真实转换链路。

门禁结论:PASSED(无 SEVERE,2 条 MODERATE + 2 条 LOW,均给出根因、触发条件、影响与修正方向;全部锚定在本次变更文件的可评论行范围内)。

发现的问题

中等

trpc_agent_sdk/server/agents/claude/_proxy.py:824-840

问题: 新引入的"工具块之后重开文本块"逻辑(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。

中等

trpc_agent_sdk/models/_openai_model.py:2614-2615

问题: 本次变更使 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),从源头保证一轮内文本与工具事件的消息连续性。

较低

trpc_agent_sdk/models/_openai_model.py:2535-2537

问题: 此处调用的 _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 行)。

较低

tests/models/test_openai_mixed_stream.py:156-181

问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面: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 走一遍端到端路径。

Comment on lines 824 to 840
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新引入的"工具块之后重开文本块"逻辑(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。

Comment on lines +2614 to +2615
if streaming_event:
yield streaming_event

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 本次变更使 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),从源头保证一轮内文本与工具事件的消息连续性。

Comment on lines +2535 to +2537
streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls,
delta_arguments,
streaming_tool_names)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 此处调用的 _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 行)。

Comment on lines +156 to +181
},
{
"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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 新测试存在两处盲区,无法覆盖本次变更引入的若干新代码行与回归面: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.

Copy link
Copy Markdown
Author

已核对 2845ebb 这轮评审并补充 ed2c396:代理中途异常时,先关闭已打开的文本和工具块,再发送原有错误结束事件。新增异常注入测试在修改前失败(message_delta 到达时索引 1、2 仍打开),修改后通过;相关测试共 370 passed,flake8 和 diff --check 通过。本次为确定性异常回归,未重复真实网络请求。

response_id 已在 2d8ec8b 修复。其余建议暂不扩大修改:AG-UI 在工具开始时关闭文本消息是现有设计,工具之后的新文本应如何聚合需要维护者确认;将已结束的消息直接继续输出或将文本/工具合入同一响应,都可能改变其他消费方契约。Anthropic 文档按 index 标识内容块,目前未找到 stop 索引必须全局递增的明确要求,因此不复用已关闭索引,也不将该项描述为已确认的协议错误。当前测试会验证结束前所有已启动块恰好关闭。

请基于最新 HEAD review,并请维护者确认 AG-UI 消息分段是否需要另行调整。

@helloopenworld

Copy link
Copy Markdown
Contributor

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 中保留工具调用"已正确实现。模型层 _generate_stream 现在对每个 chunk 无条件调用 _process_chunk_without_content,使文本与 tool_calls 共存的 chunk 中工具 delta 被累积并进入最终响应(旧代码会整体丢弃);流式工具事件按"文本先行、事件后发"顺序分别产出,并通过 response_id/custom_metadata 保留响应身份与 chunk 元数据。代理层 _handle_streaming 支持在工具块之后用共享索引分配器重新打开文本块(2845ebb),text_block_index/next_tool_index 分配无冲突;文本块在工具事件到达时正确关闭、在后续文本到达时以新索引重建,最终分支的关闭顺序与协议一致。ed2c396 的 except 路径对已注册的流式工具块和当前文本块能正确补发 content_block_stop。

主要风险:期望的 8/8 个测试断言路径全部通过推演验证,未被测试覆盖的唯一边界是"异常发生在最终完整响应分支(非流式工具块)内部"——该分支启动的 tool_use 块未注册进 streaming_tool_calls,新失败关闭逻辑无法为其补发 stop,与提交"关闭未完成内容块"的承诺相悖(MODERATE 问题,附低严重度测试缺口)。

测试充分性:新增 9 个测试 266 行,覆盖文本×推理×分片×工具数的参数化组合、流式/非流式事件、无效 JSON 错误信封、代理层文本保留与块索引唯一性、响应身份与元数据、以及交错流失败时关闭开放块,测试设计将根因与行为断言直接绑定,充分性总体很高,仅上述最终分支故障路径无覆盖。

门禁结论:核心功能正确、测试覆盖良好,未发现 SEVERE 级问题;一个 MODERATE 结构性缺口(最终分支工具块在流失败时无法被关闭)建议修复后合入。

发现的问题

中等

trpc_agent_sdk/server/agents/claude/_proxy.py:957-968

问题: 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;并补一个在该分支中途抛错的测试用例。

较低

trpc_agent_sdk/server/agents/claude/_proxy.py:958-968

问题: 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)。

Comment on lines +957 to +968
# 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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;并补一个在该分支中途抛错的测试用例。

Comment on lines +958 to +968
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 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)。

@helloopenworld

Copy link
Copy Markdown
Contributor

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),涉及 trpc_agent_sdk/models/_openai_model.py(_generate_stream 重构:混合文本+tool delta 块现在也累积工具调用并产出流式工具事件)、trpc_agent_sdk/server/agents/claude/_proxy.py(文本块可在工具块启动后以新索引重新打开,文本块索引不再恒为 0)及新增测试文件 tests/models/test_openai_mixed_stream.py(225 行,覆盖混合块、分片、无效 JSON、Anthropic 代理、响应身份保持等)。计划符合性:核心意图(混合块中的工具调用不再丢失、文本与工具事件顺序保持、流式事件携带 response_id 与 chunk 元数据)已实现且由测试覆盖,测试与实现行为一致。主要风险:流式工具事件新增的 custom_metadata(含原始 chunk)会被 AG-UI 翻译器逐条转发为 CustomEvent(_event_translator.py:232-239),带来每片段一次的新客户端负载;代理层在工具块期间重新打开文本块,构成 Anthropic parts-stream 严格交替序违背(严格客户端可能拒绝流或截断工具参数)并使重建消息顺序与模型输出错位(协议规则与语义重排同根因,合并为一条);空 arguments 首片段将工具注册为已流式化导致最终完整 tool_use 被跳过(机制为既有代码,混合块使其在带文本块中新可达)。全部 8 个后台分析角度均已返回并核验,结论一致:usage 单次处理无回归、索引无碰撞、文本顺序(拼接后)保持、无双重收集、a2a 转换器不回归、response_id 消费者均用 fallback 无破坏、partial 事件不入持久化、无 CLAUDE.md 违规;其余候选为纯维护性建议或既有代码,不满足上报门槛。影响均有界,未发现需阻止合入的 SEVERE 缺陷,门禁结论:PASSED。

发现的问题

中等

trpc_agent_sdk/models/_openai_model.py:2538-2540

问题: 流式工具调用事件新增 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。

中等

trpc_agent_sdk/server/agents/claude/_proxy.py:824-839

问题: 混合流中“工具块启动后再次出现文本”时,代理层重新打开文本块(_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 集合相等,未断言块拓扑/交替序,掩盖该偏差。

修正方向: 在工具块已启动后遇到文本时,不立即开启新文本块:将文本缓冲至工具块完成后再以索引递增的新文本块连续追加(保持严格交替序与模型输出相对顺序),并在测试中断言重组后的块顺序/交替序。

较低

trpc_agent_sdk/server/agents/claude/_proxy.py:779-784

问题: 工具分支在收到空 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 路径。

Comment on lines +2538 to +2540
if streaming_event:
streaming_event.response_id = response_id
streaming_event.custom_metadata = {const.CHUNK: chunk_dict, const.TOOL_STREAMING: True}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 流式工具调用事件新增 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。

Comment on lines 824 to +839
# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 混合流中“工具块启动后再次出现文本”时,代理层重新打开文本块(_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 集合相等,未断言块拓扑/交替序,掩盖该偏差。

修正方向: 在工具块已启动后遇到文本时,不立即开启新文本块:将文本缓冲至工具块完成后再以索引递增的新文本块连续追加(保持严格交替序与模型输出相对顺序),并在测试中断言重组后的块顺序/交替序。

Comment on lines 779 to +784
# 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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

问题: 工具分支在收到空 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 路径。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

流式响应同包包含 content 与 tool_calls 时丢失工具调用(真实请求复现)

2 participants