diff --git a/src/backends/langgraph.py b/src/backends/langgraph.py
new file mode 100644
index 0000000..15c9e9a
--- /dev/null
+++ b/src/backends/langgraph.py
@@ -0,0 +1,65 @@
+"""LangGraph implementation of the backend-neutral chat contract(作业 2)。
+
+实现 ``ChatBackend`` Protocol,把图执行结果翻译为公共契约:
+- ``complete()`` 返回 ``ChatResult``,``content`` 保留
+ ``XXXX`` 前缀(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): 实现该方法。最低要求:
+ - 先产出含完整 ``...`` 前缀的 ``TextDelta``,
+ 再产出正文片段(保证 API 层的 stage 先于正文约束);
+ - 本阶段允许“完整答案切片段”,提交说明须标注
+ “尚未接入模型增量输出”(作业 8 改为真实增量)。
+ """
+ result = await self.complete(chat_input)
+ prefix, _, body = result.content.partition("")
+ yield TextDelta(prefix + "")
+ if body:
+ yield TextDelta(body)
diff --git a/src/core/config.py b/src/core/config.py
index 4bc2476..6c06984 100644
--- a/src/core/config.py
+++ b/src/core/config.py
@@ -15,9 +15,21 @@ class Config:
FASTGPT_BASE_URL = os.getenv("ANALYSIS_SERVICE_URL")
FASTGPT_APP_ID = os.getenv("APP_ID")
+ # Chat backend switch(作业 2)
+ # "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。
+ AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt")
+
@classmethod
def validate(cls):
"""Validate required configuration."""
+ # TODO(作业 2): 语音管线仍依赖 FastGPT。当前仅按文本后端开关校验;
+ # 接入语音开关后,需要单独校验语音路径所需配置。
+ if cls.AGENT_BACKEND not in ("fastgpt", "langgraph"):
+ raise ValueError(
+ f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}"
+ )
+ if cls.AGENT_BACKEND != "fastgpt":
+ return
required = [
("FASTGPT_BASE_URL (ANALYSIS_SERVICE_URL)", cls.FASTGPT_BASE_URL),
("FASTGPT_API_KEY (ANALYSIS_AUTH_TOKEN)", cls.FASTGPT_API_KEY),
diff --git a/src/core/fastgpt_client.py b/src/core/fastgpt_client.py
index 3ce0c4c..4879baf 100644
--- a/src/core/fastgpt_client.py
+++ b/src/core/fastgpt_client.py
@@ -2,8 +2,10 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastgpt_client import AsyncChatClient
+from ..agent.graph import build_graph
from ..backends.chat import ChatBackend
from ..backends.fastgpt import FastGPTBackend
+from ..backends.langgraph import LangGraphBackend
from .config import Config
@@ -13,20 +15,25 @@ _chat_backend: ChatBackend | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
- """Manage FastGPT client lifecycle."""
+ """Manage chat backend resource lifecycle."""
global _chat_backend, _fastgpt_client
Config.validate()
- _fastgpt_client = AsyncChatClient(
- api_key=Config.FASTGPT_API_KEY,
- base_url=Config.FASTGPT_BASE_URL,
- timeout=60.0,
- max_retries=3,
- retry_delay=1.0,
- enable_logging=Config.DEBUG,
- )
- await _fastgpt_client.__aenter__()
- _chat_backend = FastGPTBackend(_fastgpt_client)
+ if Config.AGENT_BACKEND == "langgraph":
+ # 作业 2:LangGraph fake 模式不要求 FastGPT 密钥。
+ # TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。
+ _chat_backend = LangGraphBackend(build_graph())
+ else:
+ _fastgpt_client = AsyncChatClient(
+ api_key=Config.FASTGPT_API_KEY,
+ base_url=Config.FASTGPT_BASE_URL,
+ timeout=60.0,
+ max_retries=3,
+ retry_delay=1.0,
+ enable_logging=Config.DEBUG,
+ )
+ await _fastgpt_client.__aenter__()
+ _chat_backend = FastGPTBackend(_fastgpt_client)
try:
yield
diff --git a/test/backends/test_langgraph_backend.py b/test/backends/test_langgraph_backend.py
new file mode 100644
index 0000000..bb7e668
--- /dev/null
+++ b/test/backends/test_langgraph_backend.py
@@ -0,0 +1,62 @@
+"""作业 2 验收测试:LangGraph 后端适配器。
+
+通过标准(对应迁移计划作业 2):
+- ``complete()`` 返回带 ``1002`` 前缀的 ``ChatResult``,
+ API 层据此解析阶段码;
+- ``stream()`` 先产出含完整 state 前缀的 ``TextDelta``,再产出正文,
+ 保证 ``stage_code`` 事件先于正文;
+- 会话通过 ``thread_id = session_id`` 绑定(图 checkpoint 语义)。
+
+注意:``src/backends/langgraph.py`` 中的 TODO 完成前,
+本文件的测试会失败,属于预期状态。
+"""
+
+import pytest
+
+from src.backends.chat import ChatInput, TextDelta
+from src.backends.langgraph import LangGraphBackend
+
+
+def make_input() -> ChatInput:
+ return ChatInput(
+ session_id="session-001",
+ text="发生了交通事故",
+ client_mode="direct",
+ )
+
+
+@pytest.mark.asyncio
+async def test_complete_returns_state_prefixed_result() -> None:
+ backend = LangGraphBackend()
+
+ result = await backend.complete(make_input())
+
+ assert result.content.startswith("1002")
+ assert "发生了交通事故" in result.content
+
+
+@pytest.mark.asyncio
+async def test_stream_emits_prefix_before_body() -> None:
+ backend = LangGraphBackend()
+
+ events = [event async for event in backend.stream(make_input())]
+
+ assert events, "stream 至少应产生一个 TextDelta"
+ assert all(isinstance(event, TextDelta) for event in events)
+ joined = "".join(event.text for event in events)
+ assert joined.startswith("1002")
+ assert "发生了交通事故" in joined
+
+
+@pytest.mark.asyncio
+async def test_sessions_are_isolated_by_thread_id() -> None:
+ backend = LangGraphBackend()
+
+ first = await backend.complete(make_input())
+ second = await backend.complete(make_input())
+ other = await backend.complete(
+ ChatInput(session_id="session-002", text="你好", client_mode="direct")
+ )
+
+ assert first.content != "" and second.content != ""
+ assert "你好" in other.content