From 64839559190d98830df558293ccef3c4692aaaac Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:11:03 +0000 Subject: [PATCH] fix(llm): Add retry mechanism for empty API responses --- src/llm_models/exceptions.py | 11 +++++ src/llm_models/model_client/gemini_client.py | 46 ++++++++++++-------- src/llm_models/model_client/openai_client.py | 9 +++- src/llm_models/utils_model.py | 21 ++++++--- 4 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/llm_models/exceptions.py b/src/llm_models/exceptions.py index 5b04f58c..ff847ad8 100644 --- a/src/llm_models/exceptions.py +++ b/src/llm_models/exceptions.py @@ -96,3 +96,14 @@ class PermissionDeniedException(Exception): def __str__(self): return self.message + + +class EmptyResponseException(Exception): + """响应内容为空""" + + def __init__(self, message: str = "响应内容为空,这可能是一个临时性问题"): + super().__init__(message) + self.message = message + + def __str__(self): + return self.message diff --git a/src/llm_models/model_client/gemini_client.py b/src/llm_models/model_client/gemini_client.py index d253d29c..2b2d9183 100644 --- a/src/llm_models/model_client/gemini_client.py +++ b/src/llm_models/model_client/gemini_client.py @@ -37,6 +37,7 @@ from ..exceptions import ( NetworkConnectionError, RespNotOkException, ReqAbortException, + EmptyResponseException, ) from ..payload_content.message import Message, RoleType from ..payload_content.resp_format import RespFormat, RespFormatType @@ -224,6 +225,9 @@ def _build_stream_api_resp( resp.tool_calls.append(ToolCall(call_id, function_name, arguments)) + if not resp.content and not resp.tool_calls: + raise EmptyResponseException() + return resp @@ -284,26 +288,27 @@ def _default_normal_response_parser( """ api_response = APIResponse() - if not hasattr(resp, "candidates") or not resp.candidates: - raise RespParseException(resp, "响应解析失败,缺失candidates字段") + # 解析思考内容 try: - if resp.candidates[0].content and resp.candidates[0].content.parts: - for part in resp.candidates[0].content.parts: - if not part.text: - continue - if part.thought: - api_response.reasoning_content = ( - api_response.reasoning_content + part.text if api_response.reasoning_content else part.text - ) + if (candidates := getattr(resp, "candidates", None)) and candidates: + if candidates[0].content and candidates[0].content.parts: + for part in candidates[0].content.parts: + if not part.text: + continue + if part.thought: + api_response.reasoning_content = ( + api_response.reasoning_content + part.text if api_response.reasoning_content else part.text + ) except Exception as e: logger.warning(f"解析思考内容时发生错误: {e},跳过解析") - if resp.text: - api_response.content = resp.text + # 解析响应内容 + api_response.content = getattr(resp, "text", None) - if resp.function_calls: + # 解析工具调用 + if function_calls := getattr(resp, "function_calls", None): api_response.tool_calls = [] - for call in resp.function_calls: + for call in function_calls: try: if not isinstance(call.args, dict): raise RespParseException(resp, "响应解析失败,工具调用参数无法解析为字典类型") @@ -313,17 +318,22 @@ def _default_normal_response_parser( except Exception as e: raise RespParseException(resp, "响应解析失败,无法解析工具调用参数") from e - if resp.usage_metadata: + # 解析使用情况 + if usage_metadata := getattr(resp, "usage_metadata", None): _usage_record = ( - resp.usage_metadata.prompt_token_count or 0, - (resp.usage_metadata.candidates_token_count or 0) + (resp.usage_metadata.thoughts_token_count or 0), - resp.usage_metadata.total_token_count or 0, + usage_metadata.prompt_token_count or 0, + (usage_metadata.candidates_token_count or 0) + (usage_metadata.thoughts_token_count or 0), + usage_metadata.total_token_count or 0, ) else: _usage_record = None api_response.raw_data = resp + # 最终的、唯一的空响应检查 + if not api_response.content and not api_response.tool_calls: + raise EmptyResponseException("响应中既无文本内容也无工具调用") + return api_response, _usage_record diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index bba00f94..51bb692f 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -30,6 +30,7 @@ from ..exceptions import ( NetworkConnectionError, RespNotOkException, ReqAbortException, + EmptyResponseException, ) from ..payload_content.message import Message, RoleType from ..payload_content.resp_format import RespFormat @@ -235,6 +236,9 @@ def _build_stream_api_resp( resp.tool_calls.append(ToolCall(call_id, function_name, arguments)) + if not resp.content and not resp.tool_calls: + raise EmptyResponseException() + return resp @@ -332,7 +336,7 @@ def _default_normal_response_parser( api_response = APIResponse() if not hasattr(resp, "choices") or len(resp.choices) == 0: - raise RespParseException(resp, "响应解析失败,缺失choices字段") + raise EmptyResponseException("响应解析失败,缺失choices字段或choices列表为空") message_part = resp.choices[0].message if hasattr(message_part, "reasoning_content") and message_part.reasoning_content: # type: ignore @@ -377,6 +381,9 @@ def _default_normal_response_parser( # 将原始响应存储在原始数据中 api_response.raw_data = resp + if not api_response.content and not api_response.tool_calls: + raise EmptyResponseException() + return api_response, _usage_record diff --git a/src/llm_models/utils_model.py b/src/llm_models/utils_model.py index 1125e9fd..7ab76969 100644 --- a/src/llm_models/utils_model.py +++ b/src/llm_models/utils_model.py @@ -14,7 +14,13 @@ from .payload_content.resp_format import RespFormat from .payload_content.tool_option import ToolOption, ToolCall, ToolOptionBuilder, ToolParamType from .model_client.base_client import BaseClient, APIResponse, client_registry from .utils import compress_messages, llm_usage_recorder -from .exceptions import NetworkConnectionError, ReqAbortException, RespNotOkException, RespParseException +from .exceptions import ( + NetworkConnectionError, + ReqAbortException, + RespNotOkException, + RespParseException, + EmptyResponseException, +) install(extra_lines=3) @@ -192,12 +198,6 @@ class LLMRequest: endpoint="/chat/completions", time_cost=time.time() - start_time, ) - - if not content: - if raise_when_empty: - logger.warning(f"生成的响应为空, 请求类型: {self.request_type}") - raise RuntimeError("生成的响应为空") - content = "生成的响应为空,请检查模型配置或输入内容是否正确" return content, (reasoning_content, model_info.name, tool_calls) @@ -367,6 +367,13 @@ class LLMRequest: can_retry_msg=f"任务-'{task_name}' 模型-'{model_name}': 连接异常,将于{retry_interval}秒后重试", cannot_retry_msg=f"任务-'{task_name}' 模型-'{model_name}': 连接异常,超过最大重试次数,请检查网络连接状态或URL是否正确", ) + elif isinstance(e, EmptyResponseException): # 空响应错误 + return self._check_retry( + remain_try, + retry_interval, + can_retry_msg=f"任务-'{task_name}' 模型-'{model_name}': 收到空响应,将于{retry_interval}秒后重试。原因: {e}", + cannot_retry_msg=f"任务-'{task_name}' 模型-'{model_name}': 收到空响应,超过最大重试次数,放弃请求", + ) elif isinstance(e, ReqAbortException): logger.warning(f"任务-'{task_name}' 模型-'{model_name}': 请求被中断,详细信息-{str(e.message)}") return -1, None # 不再重试请求该模型