1. 边界
在工程实践中,需要根据业务需求和团队规模来确定分层边界。通常可以参考以下原则:
| 层 | 负责 |
|---|---|
| API | 路径、参数、状态码和响应 |
| Schema | 请求与响应的数据结构 |
| Service | 编排聊天业务 |
| Core | 配置、生命周期等应用基础设施 |
| Persistence | 管理数据库连接、Session 和查询 |
| Factory | 组装应用 |
这样的划分不追求“层数越多越好”。它只是让同一类变化尽量停留在同一处:接口路径变化时修改 API,响应结构变化时修改 Schema,业务流程变化时修改 Service。
2. 结构
我们可以把项目按照如下的结构进行整理:
routes 按业务领域拆分接口,router.py 再把这些路由聚合起来。这样新增一个订单或知识库模块时,应用工厂不需要了解该模块中的每条路径。
目录图省略了部分子目录中的 __init__.py。实际项目可以在 api、routes、core、schemas 和 services 中保留它,让包边界更直观。
数据库访问在这里不是“以后有需求再增加”的可选项,而是当前业务的一部分。database.py 负责连接池和 Session 工厂,repositories 负责查询语句,Service 负责决定一次业务操作需要查询什么、写入什么以及何时提交事务。
这套结构适合已经出现多个接口、模型资源和数据库请求的项目。只有两三个非常简单的接口时,可以合并 services 或 dependencies,但不要把数据库查询重新塞回路由函数。
3. 流程
拆分之后,一次请求并没有变得更神秘。它只是沿着明确的方向移动:
01POST /api/v1/chat02-> FastAPI 应用03-> api/router.py04-> api/routes/chat.py05-> ChatRequest 校验输入06-> get_db_session() 提供请求级数据库 Session07-> get_chat_service() 组装服务和 Repository08-> Repository 查询或写入数据库09-> ChatService 调用 LangChain 模型10-> ChatResponse 组织输出11-> FastAPI 序列化为 JSON
这里发生了三次重要转换:HTTP 请求先变成 Python 数据,Python 数据再进入业务服务,业务结果最后变成 HTTP 响应。路由位于 HTTP 世界和业务世界的交界处,因此它应该保持简短,而不是承载全部逻辑。
理解这条路径之后,再看后面的文件就不会觉得它们只是被随意拆散的代码。
4. 配置
先安装 Pydantic Settings。读取 .env 还需要 python-dotenv:
1uv add pydantic-settings python-dotenv
配置模块只负责读取、转换和校验配置:
01from functools import lru_cache0203from pydantic import SecretStr04from pydantic_settings import BaseSettings, SettingsConfigDict050607class Settings(BaseSettings):08"""集中管理应用配置,并负责从环境变量和 .env 读取值。"""0910model_config = SettingsConfigDict(11env_file=".env",12env_file_encoding="utf-8",13extra="ignore",14)1516app_name: str = "FastAPI LangChain"17deepseek_model: str = "deepseek-v4-flash"18deepseek_base_url: str = "https://api.deepseek.com"19deepseek_api_key: SecretStr | None = None20cors_allow_origins: str = "*"21cors_allow_methods: list[str] = [22"GET",23"POST",24"PUT",25"DELETE",26"OPTIONS",27]28cors_allow_headers: list[str] = [29"Content-Type",30"Authorization",31"X-Request-Id",32]33cors_expose_headers: list[str] = [34"X-Request-Id",35"X-Process-Time",36]3738@property39def allowed_origins(self) -> list[str]:40"""把教学环境中的逗号分隔配置转换成 CORS 需要的列表。"""4142origins = [43origin.strip()44for origin in self.cors_allow_origins.split(",")45if origin.strip()46]47return origins or ["*"]4849@property50def allow_credentials(self) -> bool:51"""通配来源时关闭凭证,避免违反浏览器 CORS 规则。"""5253return "*" not in self.allowed_origins545556@lru_cache57def get_settings() -> Settings:58"""在进程内复用配置对象,避免每个请求重复读取 .env。"""5960return Settings()
仓库中提交 .env.example,用于告诉其他开发者需要哪些配置:
1DEEPSEEK_MODEL=deepseek-v4-flash2DEEPSEEK_BASE_URL=https://api.deepseek.com3DEEPSEEK_API_KEY=your-api-key4CORS_ALLOW_ORIGINS=*
开发者在本地复制出 .env,再填入真实密钥。.env 必须加入 .gitignore,不能提交到仓库。
Pydantic Settings 会按照字段名读取同名环境变量,并把字符串转换成对应的 Python 类型。当前项目用逗号分隔的 CORS_ALLOW_ORIGINS 保存多个来源,再由 allowed_origins 转换成 CORS 中间件需要的列表。
SecretStr 主要用于降低密钥被日志和调试输出意外打印的风险,它并不会加密 .env 文件。真正使用密钥时仍要显式调用 get_secret_value()。
系统环境变量的优先级高于 .env,生产环境可以通过部署平台注入配置,而不必修改代码。env_file=".env" 默认相对于进程的当前工作目录解析,不是相对于 config.py;从其他目录启动服务时,要确认 .env 的位置仍然正确。
lru_cache 让同一进程复用一个配置对象,避免每个请求都重新读取文件。稍后应用工厂仍然允许显式传入 Settings,这样测试就不必反复修改全局环境和清理缓存。
5. 契约
请求和响应模型放在 schemas:
1from pydantic import BaseModel, Field23class ChatRequest(BaseModel):4question: str = Field(min_length=1, max_length=4000)56class ChatResponse(BaseModel):7answer: str8model: str
Schema 是 API 对外的数据契约。它负责说明客户端可以传入什么、服务会返回什么,却不负责调用模型。
请求模型和响应模型分开,即使它们暂时包含相同字段也不要复用。客户端能够提交的字段与服务允许返回的字段,往往会随着项目发展而产生不同的权限和校验规则。
6. 服务
业务层只关心“如何回答问题”:
01from langchain_core.language_models import BaseChatModel0203from app.repositories.chat import ChatRepository0405class ChatService:06def __init__(07self,08model: BaseChatModel,09model_name: str,10repository: ChatRepository,11):12self._model = model13self.model_name = model_name14self._repository = repository1516async def answer(self, question: str) -> str:17message = await self._model.ainvoke(question)18await self._repository.save_turn(question, message.text)19await self._repository.commit()20return message.text
ChatService 不导入 FastAPI,也不知道 URL、状态码和请求头。它接收 LangChain 的 BaseChatModel 抽象,而不是把类型写死成 ChatOpenAI。以后更换模型实现时,聊天业务通常不需要跟着改变。
ainvoke() 是一次异步模型调用。等待远程模型响应时,事件循环可以继续处理其他请求;它不会把远程模型推理变成本机的并行 CPU 计算。
LangChain 消息的原始 content 既可能是字符串,也可能是内容块列表。这里使用当前消息接口提供的 message.text 读取文本,避免用 str(message.content) 把列表直接转换成不适合展示的 Python 字符串。
模型名称也由构造函数显式传入,路由不再访问 service.model.model_name 这类具体实现的内部属性。Service 对外暴露什么,由自己的接口决定。
上面的 save_turn() 和 commit() 只是最小示意。Repository 负责根据用户和会话读取历史消息、写入消息记录,并处理分页、唯一约束等数据库细节;它可以提供提交能力,但不应自行决定整个业务何时提交。查询语句放在 Repository,业务先后顺序和事务边界放在 Service,不能让路由直接拼接 SQL。
7. 生命周期
模型客户端会复用 HTTP 连接等资源,适合在应用 lifespan 中创建一次:
01from collections.abc import AsyncIterator02from contextlib import asynccontextmanager0304from fastapi import FastAPI05from langchain_openai import ChatOpenAI06from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine0708from app.core.config import Settings0910@asynccontextmanager11async def lifespan(app: FastAPI) -> AsyncIterator[None]:12settings: Settings = app.state.settings1314app.state.model = ChatOpenAI(15model=settings.deepseek_model,16api_key=settings.deepseek_api_key.get_secret_value(),17base_url=settings.deepseek_base_url,18)1920app.state.db_engine = create_async_engine(21settings.database_url,22pool_pre_ping=True,23)24app.state.session_factory = async_sessionmaker(25app.state.db_engine,26expire_on_commit=False,27)2829yield3031await app.state.db_engine.dispose()
yield 之前是启动阶段:配置准备好后创建模型客户端、数据库引擎和连接池。yield 之后是关闭阶段:数据库引擎需要调用 dispose() 释放连接池,其他提供关闭方法的 HTTP 客户端也应在这里清理。
FastAPI 当前推荐使用 lifespan 管理应用启动和关闭逻辑。它与请求级 yield 依赖解决的是不同生命周期:lifespan 管理“每个应用进程一次”的资源,请求依赖管理“每个请求一次”的资源。
如果启动多个 worker,每个 worker 都是独立进程,也会分别创建自己的模型客户端和连接池。“在 lifespan 中创建一次”指的是每个进程一次,不是整台服务器只有一个全局实例。
这里延续前文,使用 ChatOpenAI 连接兼容 OpenAI Chat Completions 的 DeepSeek 地址,适合课程中的基础文本调用。LangChain 目前也提供专门的 langchain-deepseek 集成;需要读取 DeepSeek 独有响应字段或使用供应商特有能力时,应优先选择 ChatDeepSeek,不要假设兼容接口能保留所有扩展信息。
8. 依赖
长期资源保存在 app.state 中,依赖函数为每个请求创建数据库 Session,再把 Session 和模型组装成路由需要的 Service:
01from collections.abc import AsyncIterator02from typing import Annotated0304from fastapi import Depends, Request05from langchain_core.language_models import BaseChatModel06from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker0708from app.core.config import Settings09from app.repositories.chat import ChatRepository10from app.services.chat import ChatService1112async def get_db_session(13request: Request,14) -> AsyncIterator[AsyncSession]:15session_factory: async_sessionmaker[AsyncSession] = (16request.app.state.session_factory17)1819async with session_factory() as session:20yield session2122SessionDep = Annotated[AsyncSession, Depends(get_db_session)]2324def get_chat_service(25request: Request,26session: SessionDep,27) -> ChatService:28settings: Settings = request.app.state.settings29model: BaseChatModel = request.app.state.model3031return ChatService(32model=model,33model_name=settings.deepseek_model,34repository=ChatRepository(session),35)3637Chat = Annotated[ChatService, Depends(get_chat_service)]
每个请求会创建一个很轻量的 AsyncSession、Repository 和 ChatService,但不会重新创建模型客户端或数据库连接池。async with 保证请求结束后 Session 被关闭并把连接归还给连接池;一个 AsyncSession 只属于当前请求,不应跨请求或跨并发任务共享。
Chat 是一个依赖类型别名。路径函数只要声明 service: Chat,FastAPI 就会执行 get_chat_service(),并把返回值注入参数。依赖组装集中在这里,路由不用知道模型对象存放在哪里。
数据库 Session 使用请求级依赖,是因为它通常对应一次事务。引擎和 Session 工厂是应用级资源,放在 lifespan;具体 Session 是请求级资源,放在带 yield 的依赖。不要在每个请求中重新创建整个数据库引擎,也不要把同一个 AsyncSession 放进全局变量。
9. 路由
具体接口使用 APIRouter,不再创建第二个 FastAPI 应用:
01from fastapi import APIRouter0203from app.api.dependencies import Chat04from app.schemas.chat import ChatRequest, ChatResponse0506router = APIRouter(prefix="/chat", tags=["chat"])0708@router.post("", response_model=ChatResponse)09async def chat(10payload: ChatRequest,11service: Chat,12) -> ChatResponse:13answer = await service.answer(payload.question)1415return ChatResponse(16answer=answer,17model=service.model_name,18)
路由函数只做三件事:接收已经校验的数据、调用 Service、组装响应。模型密钥、客户端创建和提示词流程都不应该重新塞回这里。
当前路由自己的前缀是 /chat,接口装饰器使用空字符串,所以它在这个路由组中的路径就是 /chat。下一步统一路由还会增加 /api/v1,最终地址才会变成 /api/v1/chat。
10. 聚合
当前项目的章节案例继续在 src/fastapi/api/router.py 中统一聚合:
01from fastapi import APIRouter0203from . import deps, req, res, validation04from .routes import health0506# 需要版本管理的业务接口统一挂载在 /api/v1 下。07api_v1_router = APIRouter(prefix="/api/v1")08api_v1_router.include_router(health.router)09api_v1_router.include_router(deps.router)10api_v1_router.include_router(req.router)11api_v1_router.include_router(res.router)12api_v1_router.include_router(validation.router)1314# 根路由负责聚合不同版本的 API,供应用入口一次性接入。15router = APIRouter()16router.include_router(api_v1_router)
这个文件只关心“应用有哪些业务路由”,不创建应用,也不处理请求。以后新增章节领域模块,只需要在这里挂载它的 router。
为什么不让 factory.py 直接导入所有路由?独立聚合层可以让应用工厂保持稳定。Factory 负责组装 FastAPI,API Router 负责组装 API,它们的变化原因不同。
11. 工厂
应用工厂负责把配置、生命周期、中间件和路由组装成一个 FastAPI 应用:
01from fastapi import FastAPI0203from app.core.config import Settings, get_settings04from app.middleware import register_middleware05from src.fastapi.api.router import router as api_router060708async def root() -> dict[str, str]:09return {"message": "Hello FastAPI"}101112def create_app(settings: Settings | None = None) -> FastAPI:13"""组装 FastAPI 应用,允许测试传入独立配置。"""1415current_settings = settings or get_settings()16app = FastAPI(17title=current_settings.app_name,18version="0.1.0",19)20app.state.settings = current_settings2122register_middleware(app, current_settings)2324app.include_router(api_router)25app.add_api_route("/", root, methods=["GET"])26return app
前面配置了 allowed_origins,这里才真正把它交给 CORS 中间件。只声明配置字段但不使用,并不会让浏览器自动获得跨域权限。
允许携带凭证时,应明确列出可信来源,不要为了省事把来源配置成 *。本地前端端口变化后,也要把新的 origin 加入配置;origin 包含协议、主机名和端口,三者任意一个变化都会被视为不同来源。
create_app() 接受可选的 Settings。正常启动时使用缓存配置,测试时可以直接传入测试配置。这样应用工厂的价值不只是“多包一层函数”,而是让组装过程拥有清楚、可控制的输入。
当前项目还没有需要长期维护的数据库连接池或模型客户端,因此工厂暂时不接入 lifespan。未来真正加入这类资源时,再把 lifespan 作为工厂参数传入,并在其中负责启动和清理。
12. 入口
启动模块保持足够简单:
1from app.factory import create_app23app = create_app()
main.py 的职责只是暴露 FastAPI CLI 能找到的 app 对象。业务代码不应该反过来导入 app.main.app,否则很容易让模块导入变成一圈。
在 pyproject.toml 中声明入口:
1[tool.fastapi]2entrypoint = "app.main:app"
开发环境可以直接执行:
1uv run fastapi dev
app.main:app 表示导入 app/main.py 模块中的 app 对象。fastapi dev 面向本地开发,会启用自动重载;部署 Python 服务时应使用 fastapi run,而不是把开发服务器当成生产服务器。
13. 依赖方向
现在可以重新观察模块关系:
01main02-> factory03-> api router04-> routes05-> schemas06-> dependencies07-> services08-> application resources09-> core config / lifespan1011services -> LangChain BaseChatModel12services -> repositories -> database
上层负责组装,下层提供能力。services/chat.py 可以依赖 Repository 抽象,但不应反过来让 Repository 导入路由;repositories 可以依赖数据库 Session 和 ORM 模型,但不应读取 HTTP Request。config.py 也不应导入应用对象。出现循环导入时,通常不是再换一种导入写法就能解决,而是对象创建位置或职责边界需要重新考虑。
有些代码会横跨多个业务,例如认证、异常转换、请求日志和追踪。它们也不应该全部进入 utils.py:
- 认证上下文适合放在依赖中;
- HTTP 异常转换适合放在异常处理器中;
- 请求日志、CORS 和追踪适合放在中间件中;
- 纯粹且稳定的无状态函数,才适合进入工具模块。
依赖方向清楚之后,单元测试可以直接测试 Service,接口测试则通过应用工厂创建应用。两类测试不必承担彼此的成本。
14. 演进
当前项目还没有数据库请求业务,因此 database.py、请求级 AsyncSession 和 repositories 不会作为空壳提前加入。下面讨论的是未来出现真实持久化需求时,数据库访问如何保持清晰。
一个请求通常会经历下面的协作:
- 依赖创建当前请求的
AsyncSession; - Service 根据业务规则调用一个或多个 Repository;
- Repository 使用 Session 执行查询和写入,但不决定整个业务流程;
- Service 在需要时统一提交或回滚事务;
- 请求结束后依赖关闭 Session,连接回到连接池。
可以按下面的变化继续拆分:
| 变化 | 可以增加的边界 |
|---|---|
| 数据库连接、Session 工厂和迁移配置混在应用入口 | core/database.py、迁移目录和独立配置 |
| Service 开始直接编写大量 SQL 或 ORM 查询 | 按资源拆分 repositories,例如 conversation.py、message.py |
| 一个业务操作需要跨多个 Repository 保持一致 | 由 Service 统一事务边界,必要时增加 Unit of Work |
| 多个 Service 重复调用同一个外部平台 | clients 或基础设施适配层 |
| 业务规则脱离 FastAPI、数据库和 LangChain 也能独立成立 | domain 领域层 |
| 聊天、知识库、账号模块都变得很大 | 按业务功能组织独立模块 |
当项目只有少数业务时,本文这种“按技术职责横向分层”的结构很直观。业务模块继续增加后,也可以把 chat 的 router、schema、service 和 repository 放进同一个 features/chat 目录,同时保留共享的数据库 Session 工厂。两种组织方式没有绝对胜负,判断标准是一次需求修改需要跨越多少无关目录。
架构的目标不是预见所有未来,而是让当前代码容易理解,并为下一次真实变化保留清楚的落点。