Skip to content

feat(model): trace上报llm返回的 finish_reason 字段 - #370

Merged
helloopenworld merged 1 commit into
mainfrom
feature/trace_add_finish_reason
Nov 5, 2026
Merged

helloopenworld merged 1 commit into
mainfrom
feature/trace_add_finish_reason

Conversation

@weimch

@weimch weimch commented Nov 5, 2026

Copy link
Copy Markdown
Contributor
  • 问题:trace没有上报 llm 返回的 finish_reason
  • 解决方案:LlmResponse可以新增两个字段 finish_reason_raw(llm 返回的 finish_reason),finish_reason(将 raw 转为标准的 google-genai 的 FinishReason,OpenAI/Antropic等协议返回的raw字段均不同,需要映射成统一的FinishReason),方便根据标准类型做统一处理

- 问题:trace没有上报 llm 返回的 finish_reason
- 解决方案:LlmResponse可以新增两个字段 finish_reason_raw(llm 返回的 finish_reason),finish_reason(将 raw 转为标准的 google-genai 的 FinishReason,OpenAI/Antropic等协议返回的raw字段均不同,需要映射成统一的FinishReason),方便根据标准类型做统一处理
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

审查结论

通过

审查范围:b33f1642..ed21ccd,共 12 个文件(6 源码 + 6 测试),417 增 21 删。计划「trace 上报 llm 返回的 finish_reason 字段」的实现方式为:给 LlmResponse/Event 新增 finish_reason(google-genai 标准枚举)与 finish_reason_raw(厂商原始值)两个字段,在 OpenAI(Chat Completions/Responses API/流式)、LiteLLM、Anthropic 三套适配器中做映射与透传,并在 LlmProcessor 事件创建与 CustomTraceReporter 合成响应中贯通。经与 google-genai 1.24.0 类型定义(CaseInSensitiveEnum 为 str 子类)及 ADK 参考映射逐一核对:三份映射表的值与 ADK 一致(OpenAI/LiteLLM 未知值分别回落 UNSPECIFIED/OTHER 符合 ADK 惯例);_custom_trace.py 的 isinstance(str) 守卫对真实枚举值(str 子类)不会误丢弃;流式终包 finish_reason 捕获逻辑在内容/无内容两个分支均可达,无漏采;非流式 _create_response_with_content/without_content 重构与旧逻辑语义等价(choices 缺省/None/空列表场景均无回归);所有 Event/LlmResponse 构造点均为关键字参数且新字段带默认值,向后兼容。测试覆盖较好(6 个测试文件覆盖各适配器主路径、流式终包、Responses 状态映射、custom trace 转发)。发现的问题均为有界缺陷(无崩溃、无安全、无数据损坏):(1) Anthropic 映射表缺失 model_context_window_exceeded(其注释所引用的 ADK 上游映射将其归为 MAX_TOKENS,此处回落到 FINISH_REASON_UNSPECIFIED,导致上下文窗口截断在 trace 中被误报为「未指定」);(2) 新增的 _responses_finish_reason 将 completed 映射为 STOP,而 _generate_responses_stream 在无终包事件时合成的 status=completed 字典(该行为为既有代码,但新映射使其对异常终止虚报为 STOP);(3) SQL 会话持久化(from_event/to_event)未保存/恢复新字段,SQL 会话往返后事件丢失 finish_reason(Redis 往返可保留,行为不一致);(4) 新映射表及 _responses_finish_reason 的 failed/cancelled 分支缺少测试。无 SEVERE 级别问题,门禁结论:PASSED。

发现的问题

中等

trpc_agent_sdk/models/_anthropic_model.py:99-113

问题: _ANTHROPIC_STOP_REASON_TO_GENAI 映射表缺少 "model_context_window_exceeded" 键。该表注释明确声称与 ADK 的 _STOP_REASON_MAPPING 对齐,而 ADK 源将该值映射为 GenaiFinishReason.MAX_TOKENS(上下文窗口耗尽等同于截断);此处未收录的字符串会经 _to_genai_finish_reason 落到 FINISH_REASON_UNSPECIFIED 兜底。

触发条件: Anthropic/Claude 模型因上下文窗口占满而停止生成时,API 返回 stop_reason="model_context_window_exceeded"(Anthropic SDK StopReason 字面量已包含该值并实际出现),经 _message_to_llm_response(第 512 行)或流式 get_final_message()(第 716 行)映射时触发。

实际影响: 本次变更的核心目标就是在 trace 中上报标准化的 finish_reason,而上下文窗口截断恰恰是最需要区分的异常终止之一。此场景下 LlmResponse.finish_reason 为 FINISH_REASON_UNSPECIFIED,与正常终止混淆,下游按标准枚举统一处理(如统计被截断的调用、对截断结果做重试)时产生假阴性,且与注释声称的 ADK 对齐不一致。

修正方向: 将 "model_context_window_exceeded": GenaiFinishReason.MAX_TOKENS 加入 _ANTHROPIC_STOP_REASON_TO_GENAI,与 ADK 的 _STOP_REASON_MAPPING 完全对齐,并补充 test_model_context_window_exceeded_maps_to_max_tokens 测试。

较低

trpc_agent_sdk/models/_openai_model.py:1787-1788

问题: 新增的 _responses_finish_reason 把 status == "completed" 固定映射为 ("completed", GenaiFinishReason.STOP),而 _generate_responses_stream 中当流未收到任何 response.completed/response.failed/response.incomplete 终包事件就结束时,会合成 completed_response = {"id": ..., "status": "completed", ...} 交给 _create_responses_response,使该合成状态同样被映射为 STOP。

触发条件: Responses API 流在输出部分内容后异常终止(如服务端连接中断但 HTTP 层未抛错、或供应商省略终包事件),循环结束后走进第 2474 行的 fallback 分支,再经本函数映射。

实际影响: trace 上报的 finish_reason=STOP 表示"正常完成",而实际生成是不完整/被中断的,与本次变更要如实上报 finish_reason 的目标相悖;下游若依据 STOP 判定完整性会误判。

修正方向: 让 _responses_finish_reason 仅对真实携带 status 的响应做映射;fallback 合成的字典(第 2494 行)改用在不确定时返回 (None, None) 的状态(如 incomplete),或对合成字典单独处理,避免虚报 STOP。

较低

trpc_agent_sdk/models/_llm_response.py:57-61

问题: 本次变更在 LlmResponse(Event 的基类)上新增 finish_reason/finish_reason_raw 字段并经 _llm_processor._create_event_from_response 透传进 Event,但 SqlSessionService 的持久化链路没有同步跟进:SessionStorageEvent.from_event 与 to_event 均未保存/恢复这两个字段(对照 response_id、usage_metadata 等既有字段的存取方式)。

触发条件: 使用 SqlSessionService(或基于该存储模型的会话实现)的应用执行一轮对话——事件写入 SQL 后再从 get_session_events 读回时触发。

实际影响: 变更引入 Event 的 finish_reason 数据在 SQL 会话的写读往返中被静默丢弃,多轮对话/恢复会话场景下下游读取会话历史事件时得到 finish_reason=None,导致该特性的数据在持久化场景不完整;且 Redis 会话(model_dump_json 序列化)能保留而 SQL 不能,行为不一致。

修正方向: 在 SessionStorageEvent 上增加 finish_reason/finish_reason_raw 列,from_event 中写入、to_event 中恢复(参考 response_id 的存取方式),保证 SQL 往返不丢字段。

较低

trpc_agent_sdk/models/_openai_model.py:1781-1798

问题: 新映射逻辑的测试覆盖不完整:TestLlmResponseFinishReason 未覆盖 _ANTHROPIC_STOP_REASON_TO_GENAI 中的 pause_turn/refusal/stop_sequence 键,以及本应存在但缺失的 model_context_window_exceeded;test_responses_finish_reason_* 也未覆盖 _responses_finish_reason 的 failed 与 cancelled 分支(第 1796-1797 行)。

触发条件: 后续有人改动映射表(增删键、改目标枚举)时,上述未覆盖分支的回归不会被现有测试发现。

实际影响: 映射表是本次变更的核心逻辑,pause_turn/refusal 等键的错误映射或缺失会直接反映为 trace 中错误的 finish_reason,而当前测试无法拦截;failed/cancelled 分支同样无保护(其中 cancelled 与 _responses_error 的 error_code 逻辑联动)。

修正方向: 为 _ANTHROPIC_STOP_REASON_TO_GENAI 全表(含 pause_turn、refusal、stop_sequence,以及补上的 model_context_window_exceeded)各补一条断言;为 _responses_finish_reason 补 failed 与 cancelled 两个分支的用例(("failed", OTHER)/("cancelled", OTHER))。

Comment on lines +99 to +113
_ANTHROPIC_STOP_REASON_TO_GENAI = {
"end_turn": GenaiFinishReason.STOP,
"stop_sequence": GenaiFinishReason.STOP,
"tool_use": GenaiFinishReason.STOP,
"pause_turn": GenaiFinishReason.STOP,
"max_tokens": GenaiFinishReason.MAX_TOKENS,
"refusal": GenaiFinishReason.SAFETY,
}


def _to_genai_finish_reason(stop_reason: Optional[str]) -> Optional[GenaiFinishReason]:
"""Map an Anthropic stop_reason to google-genai's enum."""
if not stop_reason:
return None
return _ANTHROPIC_STOP_REASON_TO_GENAI.get(stop_reason, GenaiFinishReason.FINISH_REASON_UNSPECIFIED)

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.

问题: _ANTHROPIC_STOP_REASON_TO_GENAI 映射表缺少 "model_context_window_exceeded" 键。该表注释明确声称与 ADK 的 _STOP_REASON_MAPPING 对齐,而 ADK 源将该值映射为 GenaiFinishReason.MAX_TOKENS(上下文窗口耗尽等同于截断);此处未收录的字符串会经 _to_genai_finish_reason 落到 FINISH_REASON_UNSPECIFIED 兜底。

触发条件: Anthropic/Claude 模型因上下文窗口占满而停止生成时,API 返回 stop_reason="model_context_window_exceeded"(Anthropic SDK StopReason 字面量已包含该值并实际出现),经 _message_to_llm_response(第 512 行)或流式 get_final_message()(第 716 行)映射时触发。

实际影响: 本次变更的核心目标就是在 trace 中上报标准化的 finish_reason,而上下文窗口截断恰恰是最需要区分的异常终止之一。此场景下 LlmResponse.finish_reason 为 FINISH_REASON_UNSPECIFIED,与正常终止混淆,下游按标准枚举统一处理(如统计被截断的调用、对截断结果做重试)时产生假阴性,且与注释声称的 ADK 对齐不一致。

修正方向: 将 "model_context_window_exceeded": GenaiFinishReason.MAX_TOKENS 加入 _ANTHROPIC_STOP_REASON_TO_GENAI,与 ADK 的 _STOP_REASON_MAPPING 完全对齐,并补充 test_model_context_window_exceeded_maps_to_max_tokens 测试。

Comment on lines +1787 to +1788
if status == "completed":
return "completed", GenaiFinishReason.STOP

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.

问题: 新增的 _responses_finish_reason 把 status == "completed" 固定映射为 ("completed", GenaiFinishReason.STOP),而 _generate_responses_stream 中当流未收到任何 response.completed/response.failed/response.incomplete 终包事件就结束时,会合成 completed_response = {"id": ..., "status": "completed", ...} 交给 _create_responses_response,使该合成状态同样被映射为 STOP。

触发条件: Responses API 流在输出部分内容后异常终止(如服务端连接中断但 HTTP 层未抛错、或供应商省略终包事件),循环结束后走进第 2474 行的 fallback 分支,再经本函数映射。

实际影响: trace 上报的 finish_reason=STOP 表示"正常完成",而实际生成是不完整/被中断的,与本次变更要如实上报 finish_reason 的目标相悖;下游若依据 STOP 判定完整性会误判。

修正方向: 让 _responses_finish_reason 仅对真实携带 status 的响应做映射;fallback 合成的字典(第 2494 行)改用在不确定时返回 (None, None) 的状态(如 incomplete),或对合成字典单独处理,避免虚报 STOP。

Comment on lines +57 to +61
finish_reason: Optional[GenaiFinishReason] = None
"""The normalized finish reason, aligned with google-genai."""

finish_reason_raw: Optional[str] = None
"""The raw finish reason returned by the provider."""

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.

问题: 本次变更在 LlmResponse(Event 的基类)上新增 finish_reason/finish_reason_raw 字段并经 _llm_processor._create_event_from_response 透传进 Event,但 SqlSessionService 的持久化链路没有同步跟进:SessionStorageEvent.from_event 与 to_event 均未保存/恢复这两个字段(对照 response_id、usage_metadata 等既有字段的存取方式)。

触发条件: 使用 SqlSessionService(或基于该存储模型的会话实现)的应用执行一轮对话——事件写入 SQL 后再从 get_session_events 读回时触发。

实际影响: 变更引入 Event 的 finish_reason 数据在 SQL 会话的写读往返中被静默丢弃,多轮对话/恢复会话场景下下游读取会话历史事件时得到 finish_reason=None,导致该特性的数据在持久化场景不完整;且 Redis 会话(model_dump_json 序列化)能保留而 SQL 不能,行为不一致。

修正方向: 在 SessionStorageEvent 上增加 finish_reason/finish_reason_raw 列,from_event 中写入、to_event 中恢复(参考 response_id 的存取方式),保证 SQL 往返不丢字段。

Comment on lines +1781 to +1798
def _responses_finish_reason(response_dict: dict) -> tuple[Optional[str], Optional[GenaiFinishReason]]:
"""Derive the raw and normalized finish reason from a Responses payload."""
status = response_dict.get("status")
details = response_dict.get("incomplete_details") or {}
reason = details.get("reason") if isinstance(details, dict) else None

if status == "completed":
return "completed", GenaiFinishReason.STOP
if status == "incomplete":
raw = str(reason) if reason else "incomplete"
if reason == "max_output_tokens":
return raw, GenaiFinishReason.MAX_TOKENS
if reason == "content_filter":
return raw, GenaiFinishReason.SAFETY
return raw, GenaiFinishReason.OTHER
if status in {"failed", "cancelled"}:
return str(status), GenaiFinishReason.OTHER
return None, None

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.

问题: 新映射逻辑的测试覆盖不完整:TestLlmResponseFinishReason 未覆盖 _ANTHROPIC_STOP_REASON_TO_GENAI 中的 pause_turn/refusal/stop_sequence 键,以及本应存在但缺失的 model_context_window_exceeded;test_responses_finish_reason_* 也未覆盖 _responses_finish_reason 的 failed 与 cancelled 分支(第 1796-1797 行)。

触发条件: 后续有人改动映射表(增删键、改目标枚举)时,上述未覆盖分支的回归不会被现有测试发现。

实际影响: 映射表是本次变更的核心逻辑,pause_turn/refusal 等键的错误映射或缺失会直接反映为 trace 中错误的 finish_reason,而当前测试无法拦截;failed/cancelled 分支同样无保护(其中 cancelled 与 _responses_error 的 error_code 逻辑联动)。

修正方向: 为 _ANTHROPIC_STOP_REASON_TO_GENAI 全表(含 pause_turn、refusal、stop_sequence,以及补上的 model_context_window_exceeded)各补一条断言;为 _responses_finish_reason 补 failed 与 cancelled 两个分支的用例(("failed", OTHER)/("cancelled", OTHER))。

@helloopenworld
helloopenworld merged commit 4e00239 into main Nov 5, 2026
5 checks passed
raychen911 pushed a commit that referenced this pull request Nov 6, 2026
- 问题:trace没有上报 llm 返回的 finish_reason
- 解决方案:LlmResponse可以新增两个字段 finish_reason_raw(llm 返回的 finish_reason),finish_reason(将 raw 转为标准的 google-genai 的 FinishReason,OpenAI/Antropic等协议返回的raw字段均不同,需要映射成统一的FinishReason),方便根据标准类型做统一处理
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.

2 participants