diff --git a/src/agent/llm.py b/src/agent/llm.py index ec9f918..10d6ff1 100644 --- a/src/agent/llm.py +++ b/src/agent/llm.py @@ -27,6 +27,16 @@ class LLMAPIError(LLMError): """provider 返回错误或无效响应。""" +class LLMAuthError(LLMAPIError): + """provider 拒绝凭证(401)。不可重试,需区分上报(对照 + FastGPT 后端的 ChatBackendAuthenticationError 语义)。""" + + +class LLMRateLimitError(LLMAPIError): + """provider 限流(429)。重试耗尽后抛出,需区分上报(对照 + FastGPT 后端的 ChatBackendRateLimitError 语义)。""" + + class LLMService(Protocol): """节点依赖的最小 LLM 接口。""" @@ -128,7 +138,12 @@ class OpenAILLMService: ) -> str: import asyncio - from openai import APIConnectionError, APITimeoutError, RateLimitError + from openai import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + RateLimitError, + ) client = self._get_client() messages = [ @@ -163,7 +178,15 @@ class OpenAILLMService: retries += 1 await asyncio.sleep(min(2 ** retries, 10)) continue + if isinstance(exc, RateLimitError): + # 限流耗尽:区分上报,API 层映射为 429(两后端错误码等价) + raise LLMRateLimitError( + f"LLM provider 限流(重试 {self._max_retries} 次后仍失败)" + ) from exc raise LLMAPIError(f"LLM provider 不可用:{type(exc).__name__}") from exc + except AuthenticationError as exc: + # 401 鉴权失败:不可重试,区分上报,API 层映射为 401 + raise LLMAuthError("LLM provider 认证失败") from exc except Exception as exc: # 其他错误(如 4xx 参数错)不可重试 raise LLMAPIError(f"LLM 调用失败:{type(exc).__name__}") from exc diff --git a/src/backends/langgraph.py b/src/backends/langgraph.py index c9ae6a0..d8b9fd2 100644 --- a/src/backends/langgraph.py +++ b/src/backends/langgraph.py @@ -15,8 +15,11 @@ from collections.abc import AsyncIterator from ..agent.graph import GraphDeps, build_graph, workflow_fake_llm +from ..agent.llm import LLMAuthError, LLMRateLimitError from .chat import ( ChatBackend, + ChatBackendAuthenticationError, + ChatBackendRateLimitError, ChatInput, ChatResult, ChatStreamEvent, @@ -49,6 +52,20 @@ class LangGraphBackend(ChatBackend): - 本阶段不处理 ``need_form_update`` 的真实表单差量 (作业 6 起实现)。 """ + result = await self._complete_turn(chat_input) + return ChatResult( + content = result["response_text"], + status_code = None, + form_update = result.get("turn_patch") or {} + ) + + async def _complete_turn(self, chat_input: ChatInput) -> dict: + """执行一轮图,把 LLM 层错误翻译为后端中立错误。 + + 错误码等价(对照 FastGPT 后端): + LLMAuthError -> ChatBackendAuthenticationError(API 层 401); + LLMRateLimitError -> ChatBackendRateLimitError(API 层 429)。 + """ graph_input: dict = { "session_id": chat_input.session_id, "input_text": chat_input.text, @@ -58,16 +75,16 @@ class LangGraphBackend(ChatBackend): # 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。 if chat_input.injected_fields: graph_input.update(chat_input.injected_fields) - result = await self._graph.ainvoke( - graph_input, - config={"configurable": {"thread_id": chat_input.session_id}}, - context=GraphDeps(llm=self._llm), - ) - return ChatResult( - content = result["response_text"], - status_code = None, - form_update = result.get("turn_patch") or {} - ) + try: + return await self._graph.ainvoke( + graph_input, + config={"configurable": {"thread_id": chat_input.session_id}}, + context=GraphDeps(llm=self._llm), + ) + except LLMAuthError as exc: + raise ChatBackendAuthenticationError(str(exc)) from exc + except LLMRateLimitError as exc: + raise ChatBackendRateLimitError(str(exc)) from exc async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]: """流式:产生中立流事件。 diff --git a/test/agent/test_llm_error_mapping.py b/test/agent/test_llm_error_mapping.py new file mode 100644 index 0000000..9b820c8 --- /dev/null +++ b/test/agent/test_llm_error_mapping.py @@ -0,0 +1,134 @@ +"""LLM 错误分类测试:鉴权/限流可区分上报(两后端错误码等价的关键)。 + +对照 FastGPT 后端:401 -> ChatBackendAuthenticationError、 +429 -> ChatBackendRateLimitError(endpoint 已有对应响应分支)。 +LangGraph 链路由 OpenAILLMService 抛出可区分子类、 +LangGraphBackend 翻译为 ChatBackend* 错误。 +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from openai import AuthenticationError, RateLimitError + +from src.agent.llm import ( + LLMAPIError, + LLMAuthError, + LLMRateLimitError, + OpenAILLMService, +) +from src.backends.chat import ( + ChatBackendAuthenticationError, + ChatBackendRateLimitError, + ChatInput, +) +from src.backends.langgraph import LangGraphBackend + + +def _status_error(cls, status: int) -> Exception: + request = httpx.Request("POST", "http://llm.local/v1/chat/completions") + response = httpx.Response(status, request=request) + return cls("error", response=response, body=None) + + +def _service_with_failing_llm(exc: Exception, max_retries: int = 0): + """complete 固定抛 exc 的 OpenAILLMService(client 打桩)。""" + llm = OpenAILLMService(model="test", api_key="test", max_retries=max_retries) + create = AsyncMock(side_effect=exc) + llm._client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=create)) + ) + return llm, create + + +@pytest.mark.asyncio +async def test_authentication_error_raises_llm_auth_error_without_retry(): + llm, create = _service_with_failing_llm( + _status_error(AuthenticationError, 401), max_retries=2 + ) + with pytest.raises(LLMAuthError): + await llm.complete("系统", "输入") + # 401 不可重试:只调用一次 + assert create.call_count == 1 + + +@pytest.mark.asyncio +async def test_rate_limit_error_retries_then_raises_llm_rate_limit_error(): + llm, create = _service_with_failing_llm( + _status_error(RateLimitError, 429), max_retries=2 + ) + with pytest.raises(LLMRateLimitError): + await llm.complete("系统", "输入") + assert create.call_count == 3 # 首次 + 2 次重试 + + +@pytest.mark.asyncio +async def test_llm_error_subclasses_are_api_errors(): + """子类兼容既有 LLMAPIError 捕获(不破坏现有 except 顺序)。""" + assert issubclass(LLMAuthError, LLMAPIError) + assert issubclass(LLMRateLimitError, LLMAPIError) + + +class _GraphRaising: + """ainvoke 固定抛指定异常的图替身。""" + + def __init__(self, exc: Exception): + self._exc = exc + + async def ainvoke(self, *args, **kwargs): + raise self._exc + + +@pytest.mark.asyncio +async def test_langgraph_backend_maps_auth_error_to_backend_error(): + backend = LangGraphBackend( + graph=_GraphRaising(LLMAuthError("bad key")), + llm=SimpleNamespace(), + ) + with pytest.raises(ChatBackendAuthenticationError): + await backend.complete( + ChatInput(session_id="s1", text="你好", client_mode="direct") + ) + + +@pytest.mark.asyncio +async def test_langgraph_backend_maps_rate_limit_to_backend_error(): + backend = LangGraphBackend( + graph=_GraphRaising(LLMRateLimitError("限流")), + llm=SimpleNamespace(), + ) + with pytest.raises(ChatBackendRateLimitError): + await backend.complete( + ChatInput(session_id="s1", text="你好", client_mode="direct") + ) + + +@pytest.mark.asyncio +async def test_langgraph_backend_stream_propagates_mapped_errors(): + """stream 经由 complete 实现,映射自动生效。""" + backend = LangGraphBackend( + graph=_GraphRaising(LLMAuthError("bad key")), + llm=SimpleNamespace(), + ) + with pytest.raises(ChatBackendAuthenticationError): + async for _ in backend.stream( + ChatInput(session_id="s1", text="你好", client_mode="direct") + ): + pass + + +@pytest.mark.asyncio +async def test_langgraph_backend_other_llm_errors_still_raise_api_error(): + """其余 LLM 错误保持原样上抛(endpoint 落 500 通用分支)。""" + from src.agent.llm import LLMAPIError, LLMTimeoutError + + backend = LangGraphBackend( + graph=_GraphRaising(LLMTimeoutError("超时")), + llm=SimpleNamespace(), + ) + with pytest.raises(LLMTimeoutError): + await backend.complete( + ChatInput(session_id="s1", text="你好", client_mode="direct") + )