feat(backends): wire LangGraph backend behind AGENT_BACKEND switch (assignment 2)

- src/backends/langgraph.py: LangGraphBackend implements ChatBackend;
  complete() runs one graph turn per session (thread_id=sessionId) and
  returns state-prefixed content; stream() emits the full state prefix
  before body fragments (full-answer slicing, not yet real incremental
  output - assignment 8)
- src/core/config.py: AGENT_BACKEND=fastgpt|langgraph, langgraph mode
  no longer requires FastGPT credentials at startup
- src/core/fastgpt_client.py: lifespan builds the backend by switch
- test/backends/test_langgraph_backend.py: adapter acceptance tests

Verified:
- pytest: 48 passed
- AGENT_BACKEND=langgraph uvicorn: POST /chat -> 1002;
  browser_addon -> browser_addon.1002; SSE = stage_code, text_delta,
  single done

Known limit: stream slices a complete answer; no formUpdate deltas yet
(assignment 6).
This commit is contained in:
Eric Wang
2026-10-19 14:29:31 +08:00
parent 5020903791
commit 5d68b731d4
4 changed files with 157 additions and 11 deletions
+65
View File
@@ -0,0 +1,65 @@
"""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 build_graph
from .chat import ChatInput, ChatResult, ChatStreamEvent, ChatBackend, TextDelta
class LangGraphBackend(ChatBackend):
"""Adapt a compiled LangGraph to the neutral chat contract."""
def __init__(self, graph=None):
"""参数:
graph: 编译后的图;缺省调用 ``build_graph()``(内存 checkpoint)。
"""
self._graph = graph if graph is not None else build_graph()
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._graph.ainvoke(
{"session_id": chat_input.session_id, "input_text": chat_input.text},
config={"configurable": {"thread_id": chat_input.session_id}},
)
return ChatResult(
content = result["response_text"],
status_code = None,
form_update = {}
)
async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]:
"""流式:产生中立流事件。
TODO(作业 2): 实现该方法。最低要求:
- 先产出含完整 ``<state>...</state>`` 前缀的 ``TextDelta``,
再产出正文片段(保证 API 层的 stage 先于正文约束);
- 本阶段允许“完整答案切片段”,提交说明须标注
“尚未接入模型增量输出”(作业 8 改为真实增量)。
"""
result = await self.complete(chat_input)
prefix, _, body = result.content.partition("</state>")
yield TextDelta(prefix + "</state>")
if body:
yield TextDelta(body)