Repository navigation
fix(agent): 修复结构化响应校验失败后无法重试 - #369
Conversation
AI Code Review审查结论不通过 审查范围 base b33f164 → head 35d6d04,共 2 个文件(trpc_agent_sdk/agents/core/_output_schema_processor.py 与 tests/agents/core/test_output_schema_processor.py),净增 173 行、删 11 行,与计划「修复结构化响应校验失败后无法重试」一致。已验证正确链路:错误信封由 _tools_processor._create_error_event 构造(error_code + status/error/message 载荷)→ 落入工具事件 → get_structured_model_response 检测 → return None → _llm_agent 主循环 continue 重试,迭代数受 RunConfig.max_llm_calls=500(默认)约束;合并并行事件不保留子事件 error_code 的动机成立,因此载荷级检测是必要的;新增单元测试与集成测试(ScriptedToolCallModel 脚本化调用、model.calls==2、最终事件文本断言)可复现且确定性强。主要风险:(1) 失败检测改为对 set_model_response 成功载荷做内容特征匹配(status=="failed" 且含 error/message 键),与合法结构化输出发生形状碰撞时会把有效结果误判为失败,触发无界重试直至 max_llm_calls 耗尽抛 RunLimitException(SEVERE,兼容性回归);(2) 失败重试没有独立预算与终止条件,不可恢复的校验失败(含始终命中判定的 invalid_tool_arguments 信封)被放大为最多 500 次 LLM 调用、历史无界增长并以硬异常收场(MODERATE);(3) 新测试仅覆盖可恢复重试路径,未覆盖碰撞误判与预算耗尽两个行为面(LOW)。测试对目标场景覆盖充分。门禁结论:FAILED,建议修复载荷判定信号后合入。 发现的问题严重
问题: 失败检测改为对 触发条件: 用户 实际影响: 合法结构化结果被丢弃并触发重试,每轮把含全部历史(含上次成功响应)的上下文重新发送,直至默认 修正方向: 不要在内容上区分失败信封与合法载荷,改为让失败路径携带判别性信号:工具层失败时 中等
问题: 失败路径统一 触发条件: 模型持续产出无法通过输出 schema 校验的参数(如 schema 要求整数而模型反复输出字符串、枚举值不可达),或参数 JSON 反复解析失败;此类失败每次重试通常得到相同结果,可恢复概率低。 实际影响: 每次失败消耗一轮完整 LLM 往返,历史消息随重试单调增长导致上下文膨胀与成本放大,最终以 修正方向: 引入针对 较低
问题: 新增测试只覆盖「错误被识别 → 重试 → 成功」的可恢复路径,未覆盖本变更引入的两个关键行为面:合法载荷与错误信封形状碰撞时不得被误判(即校验通过的输出无论内容如何都应作为最终响应),以及连续失败耗尽重试预算时的终止行为。 触发条件: 运行 实际影响: 碰撞误判与无界重试两类缺陷在测试中不可见,回归风险滞留到线上:结构化输出间歇性被吞、成本激增或 修正方向: 补充用例: (a) 输出 schema 含 |
| if function_response_event.error_code or ( | ||
| isinstance(response, dict) | ||
| and response.get("status") == "failed" | ||
| and "error" in response | ||
| and "message" in response | ||
| ): |
There was a problem hiding this comment.
问题: 失败检测改为对 set_model_response 的响应内容做特征匹配(isinstance(response, dict) 且 status == "failed" 并含 error/message 键),但成功路径返回的是 SetModelResponseTool._run_async_impl 经 model_validate 校验通过后的 model_dump()——任何恰好包含这三个键的合法用户输出 schema 与错误信封信息上不可区分,会被误判为失败。
触发条件: 用户 output_schema 含 status/error/message 字段,且模型产出的合法输出取值形如 {"status": "failed", "error": ..., "message": ...}(监控、上报、流水线结果类 schema 很常见);模型在下一轮看到自己上次成功的函数响应被忽略,会大概率重复输出同一结果。
实际影响: 合法结构化结果被丢弃并触发重试,每轮把含全部历史(含上次成功响应)的上下文重新发送,直至默认 max_llm_calls=500 耗尽抛出 RunLimitException;变更前该输出可正常作为最终响应返回,属于兼容性回归,并伴随巨额 LLM 调用成本与长延迟。
修正方向: 不要在内容上区分失败信封与合法载荷,改为让失败路径携带判别性信号:工具层失败时 _create_error_event 已设置事件级 error_code(顺序路径可用),并行合并事件可显式透传子事件失败标记,或给失败信封加专用键(如 is_error/结构化占位);检测侧只依据该信号,对已通过 schema 校验的载荷一律视为成功。
| logger.debug( | ||
| "set_model_response failed; continuing the agent loop for retry" | ||
| ) | ||
| return None | ||
|
|
||
| # Convert dict to JSON string | ||
| return json.dumps(func_response.response, ensure_ascii=False) | ||
| return json.dumps(response, ensure_ascii=False) |
There was a problem hiding this comment.
问题: 失败路径统一 return None 进入重试,但没有任何针对 set_model_response 连续失败的重试预算或终止条件;_base_tool._get_argument_error_response 返回的 invalid_tool_arguments 信封(含 status/error/message)与 tool_execution_error 信封都会稳定命中检测条件,且每次失败的错误事件都会追加进 session 历史。
触发条件: 模型持续产出无法通过输出 schema 校验的参数(如 schema 要求整数而模型反复输出字符串、枚举值不可达),或参数 JSON 反复解析失败;此类失败每次重试通常得到相同结果,可恢复概率低。
实际影响: 每次失败消耗一轮完整 LLM 往返,历史消息随重试单调增长导致上下文膨胀与成本放大,最终以 RunLimitException 硬失败收场(变更前错误信封会作为最终响应温和返回);用户无法区分「可恢复的偶发失败」与「不可恢复的契约失配」。
修正方向: 引入针对 set_model_response 失败的重试预算(如连续 N 次失败后终止循环并产出带错误语义的最终事件,或直接抛出专用异常),「模型可修复的格式错误」与「结构性失配」分开计数;同时把失败告警从 logger.debug 提升到 logger.warning,便于观测与诊断。
| @pytest.mark.asyncio | ||
| async def test_invalid_set_model_response_retries_until_valid(): | ||
| model = ScriptedToolCallModel( | ||
| [ | ||
| {"answer": "missing confidence"}, | ||
| {"answer": "42", "confidence": 0.95}, | ||
| ] | ||
| ) | ||
|
|
||
| def dummy_tool() -> str: | ||
| """Keep the agent on the output-schema-plus-tools code path.""" | ||
| return "ok" | ||
|
|
||
| from trpc_agent_sdk.agents import LlmAgent | ||
|
|
||
| agent = LlmAgent( | ||
| name="retry_output_agent", | ||
| model=model, | ||
| output_schema=SampleOutputSchema, | ||
| tools=[FunctionTool(dummy_tool)], | ||
| ) | ||
| session_service = InMemorySessionService() | ||
| await session_service.create_session( | ||
| app_name="test", | ||
| user_id="u1", | ||
| session_id="retry", | ||
| ) | ||
| runner = Runner( | ||
| app_name="test", | ||
| agent=agent, | ||
| session_service=session_service, | ||
| ) | ||
|
|
||
| events = [ | ||
| event | ||
| async for event in runner.run_async( | ||
| user_id="u1", | ||
| session_id="retry", | ||
| new_message=Content( | ||
| role="user", | ||
| parts=[Part(text="Return a structured answer")], | ||
| ), | ||
| ) | ||
| ] | ||
|
|
||
| assert model.calls == 2 | ||
| assert any(event.error_code == "tool_execution_error" for event in events) | ||
| final_event = events[-1] | ||
| assert json.loads(final_event.get_text()) == { | ||
| "answer": "42", | ||
| "confidence": 0.95, | ||
| } |
There was a problem hiding this comment.
问题: 新增测试只覆盖「错误被识别 → 重试 → 成功」的可恢复路径,未覆盖本变更引入的两个关键行为面:合法载荷与错误信封形状碰撞时不得被误判(即校验通过的输出无论内容如何都应作为最终响应),以及连续失败耗尽重试预算时的终止行为。
触发条件: 运行 tests/agents/core/test_output_schema_processor.py;后续任何对检测条件或重试语义的修改收紧/放宽上述行为时,CI 无测试报警。
实际影响: 碰撞误判与无界重试两类缺陷在测试中不可见,回归风险滞留到线上:结构化输出间歇性被吞、成本激增或 RunLimitException 上游抛错,而修复者无法从测试区分意图。
修正方向: 补充用例: (a) 输出 schema 含 status/error/message 字段且校验通过的场景,断言 get_structured_model_response 返回该 JSON 并结束 agent; (b) ScriptedToolCallModel 持续返回失败信封的场景,断言循环在有限轮次内以可预期方式终止(抛出 RunLimitException 或产出带错误标记的最终事件),并断言历史长度有界。
- 原因: set_model_response 校验失败产生的错误响应被误判为最终结果,导致 Agent 直接结束。 - 修复: 识别工具失败事件及并行合并后的错误响应,使校验错误返回模型并进入下一轮重试。 - 测试: 覆盖首次校验失败、模型修正后成功返回及并行工具错误场景。
35d6d04 to
cbb8c31
Compare
AI Code Review审查结论通过 审查范围:b33f164..cbb8c31,共 2 个非生成文件(trpc_agent_sdk/agents/core/_output_schema_processor.py +12/-1,tests/agents/core/test_output_schema_processor.py +145/-2)。计划符合性:提交目标(识别 set_model_response 校验失败的错误封套并返回 None 进入重试,含并行合并事件丢失子事件 error_code 的场景)已正确实现,新增 3 个单元/集成测试覆盖了 error_code 分支、载荷分支以及“1 次失败后重试成功”主路径,修复的核心思路成立,与原行为“把失败封套当作最终结构化输出返回”相比是正确改进。 主要风险(均为本次变更引入):
测试充分性:成功路径与两个判定分支有覆盖,但持续失败与载荷误判两类新风险无回归测试。门禁结论:REVIEW_CODE_STATE_PASSED(无 SEVERE;2 项 MODERATE、1 项 LOW,建议合入前按修正方向补充处理)。 发现的问题中等
问题: 新增失败判定通过载荷内容嗅探识别错误封套:只要 触发条件: 用户 实际影响: Agent 的有效最终结构化答案被静默丢弃,被要求“修复”一个本已正确的答案,循环重试直到默认 修正方向: 不要按任意键名嗅探。改为比较 中等
问题: 修复将校验失败从“立即以错误封套终止”改为“返回 None 让 Agent 循环重试”,但该重试没有针对此失败模式的重试预算或优雅终止路径:循环内仅受 触发条件: 模型持续输出无法通过 schema 校验的 实际影响: 默认配置下累计约 500 次 LLM 调用与工具执行(真实计费成本)后以异常终止,不产出任何最终事件, 修正方向: 为 较低
问题: 新集成测试 触发条件: 需要验证持续校验失败时的系统行为(期望的受限重试、优雅终止事件),或 实际影响: 本提交新增的两个高风险行为(无界重试、载荷误判)均无自动化保护,后续改动易回归;测试双端在响应耗尽时抛出原始 修正方向: 增加“恒失败模型”测试,断言达到重试上限后产生含最后一次校验错误的终态事件且不抛出异常;增加 |
| if function_response_event.error_code or (isinstance(response, dict) and response.get("status") == "failed" | ||
| and "error" in response and "message" in response): |
There was a problem hiding this comment.
问题: 新增失败判定通过载荷内容嗅探识别错误封套:只要 set_model_response 返回载荷是字典且顶层同时含 status == "failed"、error、message 三个键就判定为工具失败。但该载荷是用户 output_schema 经 SetModelResponseTool._run_async_impl 校验后原样返回(trpc_agent_sdk/tools/_set_model_response_tool.py:111-114),并非框架封套;当用户 schema 顶层恰好声明同名字段(如错误上报、健康检查类结构化输出,status 取 "failed" 枚举值)时,合法成功输出会被误判为失败。
触发条件: 用户 output_schema 顶层含 status/error/message 字段且模型给出的合法最终答案中 status 为 "failed"(如报告一个失败的业务结果)。此时成功事件 error_code 为 None,判定落到载荷分支而误触发;并行合并事件(_merge_parallel_function_response_events 构造的新 Event 不带 error_code)中载荷检查是唯一信号,同样误触发。
实际影响: Agent 的有效最终结构化答案被静默丢弃,被要求“修复”一个本已正确的答案,循环重试直到默认 max_llm_calls=500 抛出 RunLimitException(max_llm_calls<=0 时无限循环),用户永远拿不到结构化输出,且产生大量无效计费调用与日志噪音。
修正方向: 不要按任意键名嗅探。改为比较 response.get("error") 是否属于框架自有错误码(tool_execution_error/tool_not_found/invalid_tool_arguments),或在 _merge_parallel_function_response_events 合并时保留子事件的 error_code,使 function_response_event.error_code 成为唯一判定信号;并补充“schema 含 status/error/message 且值为 failed 时仍按成功返回”的回归测试。
| logger.debug("set_model_response failed; continuing the agent loop for retry") | ||
| return None |
There was a problem hiding this comment.
问题: 修复将校验失败从“立即以错误封套终止”改为“返回 None 让 Agent 循环重试”,但该重试没有针对此失败模式的重试预算或优雅终止路径:循环内仅受 MAX_LLM_CALLS 约束(默认 500,trpc_agent_sdk/configs/_run_config.py:31;max_iterations/max_tool_calls 默认 0 即禁用),耗尽后 RunLimitException 从 runner.run_async 直接抛出(trpc_agent_sdk/runners.py:630-639 仅记录日志后重新 raise)。
触发条件: 模型持续输出无法通过 schema 校验的 set_model_response(如始终漏字段、或无视错误提示重复同样的错误格式),或部署方按文档将 max_llm_calls 配置为 <=0(_run_config.py:112-118 文档允许,表示不限制)。
实际影响: 默认配置下累计约 500 次 LLM 调用与工具执行(真实计费成本)后以异常终止,不产出任何最终事件,output_key 会话状态也不写入,streaming 客户端先收到数百条失败的 tool_execution_error 事件;max_llm_calls<=0 时退化为无限循环、成本失控。修复前该场景 1 次调用即以(内容为错误封套的)终态事件结束,本次变更将其改变为“最多 500 次计费调用 + 异常”,且没有覆盖该路径的测试。
修正方向: 为 set_model_response 的失败重试引入专门的次数上限(仓库内 goal_tools/_setup.py 已有 max_retries=3 fail-open 的可复用模式),达到上限后产出携带最后一次校验错误信息的终态事件(而非直接抛异常),并补充“模型持续失败”的回归测试断言受限重试与优雅终止。
| ) | ||
| ] | ||
|
|
||
| assert model.calls == 2 |
There was a problem hiding this comment.
问题: 新集成测试 test_invalid_set_model_response_retries_until_valid 只覆盖“1 次失败后成功”路径,未覆盖持续失败路径;ScriptedToolCallModel._generate_async_impl 以 self.responses[self.calls] 取响应,第 3 次调用即抛 IndexError,使持续失败场景无法用该测试双端自然表达;并行合并分支仅用手工构造的合并事件覆盖,未经过真实 _merge_parallel_function_response_events 端到端路径。
触发条件: 需要验证持续校验失败时的系统行为(期望的受限重试、优雅终止事件),或 parallel_tool_calls=True 下真实合并事件对失败封套的检测时,现有测试均无法提供覆盖。
实际影响: 本提交新增的两个高风险行为(无界重试、载荷误判)均无自动化保护,后续改动易回归;测试双端在响应耗尽时抛出原始 IndexError 还会掩盖断言失败、误导定位。
修正方向: 增加“恒失败模型”测试,断言达到重试上限后产生含最后一次校验错误的终态事件且不抛出异常;增加 parallel_tool_calls=True 下真实合并事件的端到端测试;测试双端对越界响应做截断或抛出语义明确的错误。
原因: set_model_response 校验失败产生的错误响应被误判为最终结果,导致 Agent
直接结束。
修复: 识别工具失败事件及并行合并后的错误响应,使校验错误返回模型并进入下一轮重试。
测试: 覆盖首次校验失败、模型修正后成功返回及并行工具错误场景。