接口查询实现与依赖组装

API文件可以先把它们分成五层:

层次文件主要职责
入口层 main.py 创建 FastAPI 应用,注册生命周期和路由
HTTP 层 query_router.py 定义 /api/query,接收请求,返回流式响应
业务层 query_service.py 创建 State / Context,调用 LangGraph,并包装 SSE 消息
依赖层 dependencies.py 组装 Service、Repository、Session、Client
生命周期 lifespan.py 应用启动时初始化客户端,应用关闭时释放连接

这几个文件的关系可以记成一句话:

main 挂路由
router 接请求
service 调工作流
dependencies 组装对象
lifespan 管理应用级资源

入口和路由,先让HTTP请求进来

  1. main.py创建应用并挂载路由

  app = FastAPI(lifespan=lifespan)     app.include_router(query_router)

  2. query_router.py 路由层只做HTTP相关的事

  

from typing import Annotated

from fastapi import APIRouter, Depends
from starlette.responses import StreamingResponse

from app.api.dependencies import get_query_service
from app.api.schemas.query_schema import QuerySchema
from app.services.query_service import QueryService


query_router = APIRouter()

@query_router.post("/api/query")
async def query_handler(
    # FastAPI 会把前端 JSON 请求体自动解析成 QuerySchema
    query: QuerySchema,
    # FastAPI 会调用 get_query_service,递归组装它依赖的仓储和客户端
    query_service: Annotated[QueryService, Depends(get_query_service)],
):
    return StreamingResponse(
        # query.query 是用户问题字符串;QueryService.query 返回异步生成器
        query_service.query(query.query),
        media_type="text/event-stream",
    )

  路由层只做三件事:1. 接收 QuerySchema 请求体  2. 通过 Depends 拿到 QueryService   3. 把 QueryService.query(...) 交给 StreamingResponse

  它不直接创建 Qdrant、ES、MySQL Repository,也不直接执行图节点。路由层越薄,后续接口越容易维护

  3. queryService把一次请求变成一次图执行

  QueryService 是这一章的核心。它把 HTTP 层传入的自然语言问题,转换成一次 LangGraph 工作流执行

  query_router.py -> 负责 HTTP 层:接请求、返响应  

  query_service.py -> 负责业务层:调问数智能体、组织流式输出

  4. 从测试脚本转移到API

  迁移到 API 后,变化主要有三处:

测试脚本API 接口版本
用户问题写死在代码里 使用请求体传入的 query
上下文在测试脚本里手动创建 由 QueryService 接收依赖后创建
print(chunk) 输出到控制台 yield data: ...\n\n 流式写给前端

  5. queryService核心代码

  

import json

from langchain_huggingface import HuggingFaceEndpointEmbeddings

from app.agent.context import DataAgentContext
from app.agent.graph import graph
from app.agent.state import DataAgentState
from app.repositories.es.value_es_repository import ValueESRepository
from app.repositories.mysql.dw.dw_mysql_repository import DWMySQLRepository
from app.repositories.mysql.meta.meta_mysql_repository import MetaMySQLRepository
from app.repositories.qdrant.column_qdrant_repository import ColumnQdrantRepository
from app.repositories.qdrant.metric_qdrant_repository import MetricQdrantRepository


class QueryService:
    def __init__(
        self,
        meta_mysql_repository: MetaMySQLRepository,
        embedding_client: HuggingFaceEndpointEmbeddings,
        dw_mysql_repository: DWMySQLRepository,
        column_qdrant_repository: ColumnQdrantRepository,
        metric_qdrant_repository: MetricQdrantRepository,
        value_es_repository: ValueESRepository,
    ):
        # MySQL 仓储分别负责元数据补全和真实数仓环境信息读取
        self.meta_mysql_repository = meta_mysql_repository
        self.dw_mysql_repository = dw_mysql_repository

        # 召回链路依赖的向量检索、Embedding 和全文检索能力由依赖层注入
        self.embedding_client = embedding_client
        self.column_qdrant_repository = column_qdrant_repository
        self.metric_qdrant_repository = metric_qdrant_repository
        self.value_es_repository = value_es_repository

    async def query(self, query: str):
        # State 只放会被图节点读写和合并的业务数据,外部工具对象不塞进 State
        state = DataAgentState(query=query)

        # Context 保存本次图执行需要复用的外部依赖,节点通过 runtime.context 读取
        context = DataAgentContext(
            column_qdrant_repository=self.column_qdrant_repository,
            embedding_client=self.embedding_client,
            metric_qdrant_repository=self.metric_qdrant_repository,
            value_es_repository=self.value_es_repository,
            meta_mysql_repository=self.meta_mysql_repository,
            dw_mysql_repository=self.dw_mysql_repository,
        )

        try:
            # stream_mode="custom" 对应节点内部 writer(...) 写出的进度消息
            async for chunk in graph.astream(
                input=state,
                context=context,
                stream_mode="custom",
            ):
                # SSE 要求每条消息以 data: 开头,并以两个换行符结束
                # ensure_ascii=False 保留中文进度文案,default=str 兜底处理日期等非 JSON 类型
                yield f"data: {json.dumps(chunk, ensure_ascii=False, default=str)}\n\n"
        except Exception as e:
            # 流式接口已经开始返回后不能再改 HTTP 状态码,因此把异常也包装成一条 SSE 消息
            error = {"type": "error", "message": str(e)}
            yield f"data: {json.dumps(error, ensure_ascii=False, default=str)}\n\n"

两个注意点:

  1. state: 保存的是本次任务会不断变化的业务数据

    query + keywords + table_infos + sql + result

  2. context: 保存的是节点运行时需要使用的外部能力

    Embedding Clientn + ColumnQdrantRepository + MetricQdrantRepository + DWMySQLRepository

可以这样记:state:任务数据,图执行过程中会变    context:工具资源,节点执行时拿来用

为什么异常也要包装成SSE

普通接口出错时,可以直接返回 500 状态码。但流式接口不太一样:一旦 StreamingResponse 开始往外写数据,HTTP 响应头通常已经发送出去了,后面就不能再随便改状态码

所以当前版本先把异常包装成一条 SSE 消息:

error = {"type": "error", "message": str(e)}
yield f"data: {json.dumps(error, ensure_ascii=False, default=str)}\n\n"

前端拿到这条消息后,可以按 type=error 展示错误状态。下一章会继续完善节点级异常处理、前后端联调和 request_id 日志追踪

dependencies.py 把对象创建交给依赖层

现在路由里已经声明了:query_service: Annotated[QueryService, Depends(get_query_service)]

这意味着项目必须提供 get_query_service()

如果直接在路由里创建 QueryService,路由就会知道太多底层细节:Qdrant 客户端怎么取 + ES 客户端怎么取 + MySQL Session 怎么创建和释放 

这些不是 HTTP 层该关心的事。所以本项目把依赖组装统一放到:shopkeeper-agent/app/api/dependencies.py

1. 最终依赖树

get_query_service
  -> get_meta_mysql_repository
      -> get_meta_session
  -> get_embedding_client
  -> get_dw_mysql_repository
      -> get_dw_session
  -> get_column_qdrant_repository
  -> get_metric_qdrant_repository
  -> get_value_es_repository

再往底层,这些依赖会使用生命周期中初始化好的客户端管理器: embedding_client_manager.client + qdrant_client_manager.client + meta_mysql_client_manager.session_factory

dependencies.py 和 lifespan.py 是一组配合关系:

lifespan.py -> 应用启动时初始化客户端管理器 

dependencies.py -> 每次请求中取出客户端或 Session,组装 Repository 和 QueryService

2. MySql Session是请求级资源

 MySQL Session 用带 yield 的依赖项管理。

async def get_meta_session():
    """创建一次请求内使用的元数据库 Session"""

    # yield 之后的清理逻辑由 async with 负责,FastAPI 会在请求结束后继续执行退出流程
    async with meta_mysql_client_manager.session_factory() as meta_session:
        yield meta_session


async def get_dw_session():
    """创建一次请求内使用的数仓 Session"""

    async with dw_mysql_client_manager.session_factory() as dw_session:
        yield dw_session

执行顺序可以理解成: 请求需要 Session   -> 创建 Session -> yield 给 Repository 使用 -> 请求结束 -> 退出 async with,释放 Session

这里不要把 Session 做成全局对象。数据库 Session 通常属于一次请求的工作单元,而客户端管理器、连接池这类才适合放到应用生命周期里

3. Repository和session怎么组装

async def get_meta_mysql_repository(
    session: Annotated[AsyncSession, Depends(get_meta_session)],
) -> MetaMySQLRepository:
    """基于请求级 Session 创建元数据仓储"""

    return MetaMySQLRepository(session)


async def get_dw_mysql_repository(
    session: Annotated[AsyncSession, Depends(get_dw_session)],
) -> DWMySQLRepository:
    """基于请求级 Session 创建数仓仓储"""

    return DWMySQLRepository(session)

Qdrant 和 ES Repository 使用的是应用启动阶段已经初始化好的客户端:

async def get_column_qdrant_repository() -> ColumnQdrantRepository:
    """创建字段向量检索仓储"""

    return ColumnQdrantRepository(qdrant_client_manager.client)


async def get_metric_qdrant_repository() -> MetricQdrantRepository:
    """创建指标向量检索仓储"""

    return MetricQdrantRepository(qdrant_client_manager.client)


async def get_value_es_repository() -> ValueESRepository:
    """创建字段取值全文检索仓储"""

    return ValueESRepository(es_client_manager.client)

async def get_embedding_client() -> HuggingFaceEndpointEmbeddings:
    """获取应用启动阶段初始化好的 Embedding 客户端"""

    return embedding_client_manager.client

 

最后组装QueryService

async def get_query_service(
    meta_mysql_repository: Annotated[
        MetaMySQLRepository, Depends(get_meta_mysql_repository)
    ],
    embedding_client: Annotated[
        HuggingFaceEndpointEmbeddings, Depends(get_embedding_client)
    ],
    dw_mysql_repository: Annotated[DWMySQLRepository, Depends(get_dw_mysql_repository)],
    column_qdrant_repository: Annotated[
        ColumnQdrantRepository, Depends(get_column_qdrant_repository)
    ],
    metric_qdrant_repository: Annotated[
        MetricQdrantRepository, Depends(get_metric_qdrant_repository)
    ],
    value_es_repository: Annotated[ValueESRepository, Depends(get_value_es_repository)],
) -> QueryService:
    """组装一次查询所需的业务服务"""

    # QueryService 只接收已经创建好的依赖对象,不关心这些对象具体来自哪里
    return QueryService(
        meta_mysql_repository=meta_mysql_repository,
        embedding_client=embedding_client,
        dw_mysql_repository=dw_mysql_repository,
        column_qdrant_repository=column_qdrant_repository,
        metric_qdrant_repository=metric_qdrant_repository,
        value_es_repository=value_es_repository,
    )

这就是 FastAPI 子依赖的价值:我们只声明依赖关系,FastAPI 会自动从叶子节点往上解析整棵依赖树

lifespan.py 在应用启动时准备外部客户端

dependencies.py 能拿到 qdrant_client_manager.clientes_client_manager.client,前提是这些 manager 已经初始化。

所以需要在应用启动阶段执行:qdrant_client_manager.init()   embedding_client_manager.init()   es_client_manager.init()  meta_mysql_client_manager.init()  dw_mysql_client_manager.init()

from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.clients.embedding_client_manager import embedding_client_manager
from app.clients.es_client_manager import es_client_manager
from app.clients.mysql_client_manager import (
    dw_mysql_client_manager,
    meta_mysql_client_manager,
)
from app.clients.qdrant_client_manager import qdrant_client_manager


@asynccontextmanager
async def lifespan(app: FastAPI):
    """管理应用启动和关闭两个阶段的外部资源"""

    # 启动阶段:先建立各类外部服务客户端,后续依赖函数会从 manager 中取已初始化对象
    qdrant_client_manager.init()
    embedding_client_manager.init()
    es_client_manager.init()
    meta_mysql_client_manager.init()
    dw_mysql_client_manager.init()

    # yield 之前是启动逻辑,yield 之后是关闭逻辑;中间阶段由 FastAPI 正常处理请求
    yield

    # 关闭阶段:统一释放外部连接,避免进程退出前留下未关闭的网络连接
    await qdrant_client_manager.close()
    await es_client_manager.close()
    await meta_mysql_client_manager.close()
    await dw_mysql_client_manager.close()
yield 前:
  FastAPI 应用启动时执行
  初始化 Qdrant、Embedding、ES、MySQL 客户端管理器

yield 处:
  应用进入运行状态
  开始接收请求

yield 后:
  应用关闭前执行
  释放外部客户端连接

本章把 /api/query 从协议验证推进到了真实业务执行。 

main.py
  -> 创建 FastAPI 应用
  -> 注册 lifespan 和 query_router

query_router.py
  -> 接收 QuerySchema
  -> 通过 Depends 获取 QueryService
  -> 用 StreamingResponse 返回 query_service.query(...)

QueryService
  -> 创建 DataAgentState
  -> 创建 DataAgentContext
  -> 调用 graph.astream(..., stream_mode="custom")
  -> 把 chunk 转成 JSON 并包装成 SSE

dependencies.py
  -> 组装 Session、Repository、Client、Service

lifespan.py
  -> 应用启动时初始化客户端
  -> 应用关闭时释放客户端
posted @ 2026-06-11 11:32  幻影之舞  阅读(8)  评论(0)    收藏  举报