接口查询实现与依赖组装
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.client、es_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
-> 应用启动时初始化客户端
-> 应用关闭时释放客户端

浙公网安备 33010602011771号