Closes the error-code gap between LangGraph and FastGPT backends: the endpoint's 401/429 branches previously could never trigger under the LangGraph backend because all provider failures collapsed into LLMAPIError (500). - agent/llm: LLMAuthError / LLMRateLimitError as LLMAPIError subclasses; OpenAILLMService raises them from openai AuthenticationError (401, no retry) and RateLimitError (429, after bounded retries exhausted) - backends/langgraph: _complete_turn translates LLMAuthError -> ChatBackendAuthenticationError and LLMRateLimitError -> ChatBackendRateLimitError; stream reuses complete() so the mapping applies to both paths; other errors (timeout etc.) propagate unchanged - endpoint untouched: existing 401/429 response branches now fire for both backends Verified: 212 unit tests (7 new error-mapping tests)
108 lines
4.4 KiB
Python
108 lines
4.4 KiB
Python
"""LangGraph implementation of the backend-neutral chat contract(作业 2)。
|
||
|
||
实现 ``ChatBackend`` Protocol,把图执行结果翻译为公共契约:
|
||
- ``complete()`` 返回 ``ChatResult``,``content`` 保留
|
||
``<state>XXXX</state>`` 前缀(API 层负责解析);
|
||
- ``stream()`` 产生 ``TextDelta`` / ``FormUpdate``;初期允许把固定完整答案
|
||
切成若干片段,真实增量输出在作业 8 接入。
|
||
|
||
通过 ``build_graph()`` 编译的图构造;会话用
|
||
``configurable.thread_id = chat_input.session_id`` 绑定。
|
||
|
||
对应测试:test/backends/test_langgraph_backend.py
|
||
"""
|
||
|
||
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,
|
||
FormUpdate,
|
||
TextDelta,
|
||
)
|
||
|
||
|
||
class LangGraphBackend(ChatBackend):
|
||
"""Adapt a compiled LangGraph to the neutral chat contract."""
|
||
|
||
def __init__(self, graph=None, llm=None):
|
||
"""参数:
|
||
graph: 编译后的图;缺省调用 ``build_graph()``。
|
||
llm: LLM 服务(运行时依赖);缺省为离线 workflow fake。
|
||
生产环境由 lifespan 注入 ``build_llm_service()`` 的实例。
|
||
"""
|
||
self._graph = graph if graph is not None else build_graph()
|
||
self._llm = llm if llm is not None else workflow_fake_llm()
|
||
|
||
async def complete(self, chat_input: ChatInput) -> ChatResult:
|
||
"""非流式:执行一轮图并翻译结果。
|
||
|
||
TODO(作业 2): 实现该方法。要求:
|
||
- 用 ``ainvoke`` 执行图,``thread_id`` 取
|
||
``chat_input.session_id``;
|
||
- 初始 state 至少包含 ``session_id``、``input_text``;
|
||
- 返回 ``ChatResult(content=<响应文本(含 state 前缀)>,
|
||
status_code=None, form_update={})``;
|
||
- 本阶段不处理 ``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,
|
||
"client_mode": chat_input.client_mode,
|
||
}
|
||
# 作业 6:应用服务注入的最新业务字段覆盖本轮输入,
|
||
# 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。
|
||
if chat_input.injected_fields:
|
||
graph_input.update(chat_input.injected_fields)
|
||
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]:
|
||
"""流式:产生中立流事件。
|
||
|
||
TODO(作业 2): 实现该方法。最低要求:
|
||
- 先产出含完整 ``<state>...</state>`` 前缀的 ``TextDelta``,
|
||
再产出正文片段(保证 API 层的 stage 先于正文约束);
|
||
- 本阶段允许“完整答案切片段”,提交说明须标注
|
||
“尚未接入模型增量输出”(作业 8 改为真实增量)。
|
||
"""
|
||
result = await self.complete(chat_input)
|
||
prefix, sep, body = result.content.partition("</state>")
|
||
if not sep:
|
||
yield TextDelta(result.content)
|
||
return
|
||
yield TextDelta(prefix + "</state>")
|
||
if result.form_update:
|
||
yield FormUpdate(result.form_update)
|
||
if body:
|
||
yield TextDelta(body)
|