Repository navigation
feat(model): trace上报llm返回的 finish_reason 字段 - #370
Conversation
weimch
commented
Nov 5, 2026
- 问题: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),方便根据标准类型做统一处理
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。 发现的问题中等
问题: 触发条件: Anthropic/Claude 模型因上下文窗口占满而停止生成时,API 返回 实际影响: 本次变更的核心目标就是在 trace 中上报标准化的 finish_reason,而上下文窗口截断恰恰是最需要区分的异常终止之一。此场景下 修正方向: 将 较低
问题: 新增的 触发条件: Responses API 流在输出部分内容后异常终止(如服务端连接中断但 HTTP 层未抛错、或供应商省略终包事件),循环结束后走进第 2474 行的 fallback 分支,再经本函数映射。 实际影响: trace 上报的 修正方向: 让 较低
问题: 本次变更在 触发条件: 使用 实际影响: 变更引入 Event 的 finish_reason 数据在 SQL 会话的写读往返中被静默丢弃,多轮对话/恢复会话场景下下游读取会话历史事件时得到 修正方向: 在 较低
问题: 新映射逻辑的测试覆盖不完整: 触发条件: 后续有人改动映射表(增删键、改目标枚举)时,上述未覆盖分支的回归不会被现有测试发现。 实际影响: 映射表是本次变更的核心逻辑, 修正方向: 为 |
| _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) |
There was a problem hiding this comment.
问题: _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 测试。
| if status == "completed": | ||
| return "completed", GenaiFinishReason.STOP |
There was a problem hiding this comment.
问题: 新增的 _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。
| 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.""" |
There was a problem hiding this comment.
问题: 本次变更在 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 往返不丢字段。
| 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 |
There was a problem hiding this comment.
问题: 新映射逻辑的测试覆盖不完整: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))。
- 问题:trace没有上报 llm 返回的 finish_reason - 解决方案:LlmResponse可以新增两个字段 finish_reason_raw(llm 返回的 finish_reason),finish_reason(将 raw 转为标准的 google-genai 的 FinishReason,OpenAI/Antropic等协议返回的raw字段均不同,需要映射成统一的FinishReason),方便根据标准类型做统一处理