创建时间: 2026-08-25最后更新: 2026-08-27

1. 边界

在工程实践中,需要根据业务需求和团队规模来确定分层边界。通常可以参考以下原则:

层负责
API路径、参数、状态码和响应
Schema请求与响应的数据结构
Service编排聊天业务
Core配置、生命周期等应用基础设施
Persistence管理数据库连接、Session 和查询
Factory组装应用

这样的划分不追求“层数越多越好”。它只是让同一类变化尽量停留在同一处:接口路径变化时修改 API,响应结构变化时修改 Schema,业务流程变化时修改 Service。

2. 结构

我们可以把项目按照如下的结构进行整理:

chat.py聊天接口
health.py健康检查
dependencies.py把应用资源转换为请求依赖
router.py聚合全部业务路由
config.py读取并校验配置
database.py创建数据库引擎和 Session 工厂
lifespan.py管理长期资源
chat.py会话和消息查询
chat.py聊天数据契约
chat.py聊天服务
__init__.py声明 Python 包
factory.py创建并组装 FastAPI 应用
main.py服务启动入口
.env本地配置,不提交到仓库
.env.example可提交的配置模板
pyproject.toml项目和依赖配置
uv.lock锁定依赖版本

routes 按业务领域拆分接口,router.py 再把这些路由聚合起来。这样新增一个订单或知识库模块时,应用工厂不需要了解该模块中的每条路径。

目录图省略了部分子目录中的 __init__.py。实际项目可以在 api、routes、core、schemas 和 services 中保留它,让包边界更直观。

数据库访问在这里不是“以后有需求再增加”的可选项,而是当前业务的一部分。database.py 负责连接池和 Session 工厂,repositories 负责查询语句,Service 负责决定一次业务操作需要查询什么、写入什么以及何时提交事务。

这套结构适合已经出现多个接口、模型资源和数据库请求的项目。只有两三个非常简单的接口时,可以合并 services 或 dependencies,但不要把数据库查询重新塞回路由函数。

3. 流程

拆分之后,一次请求并没有变得更神秘。它只是沿着明确的方向移动:

code.ts
01
POST /api/v1/chat
02
-> FastAPI 应用
03
-> api/router.py
04
-> api/routes/chat.py
05
-> ChatRequest 校验输入
06
-> get_db_session() 提供请求级数据库 Session
07
-> get_chat_service() 组装服务和 Repository
08
-> Repository 查询或写入数据库
09
-> ChatService 调用 LangChain 模型
10
-> ChatResponse 组织输出
11
-> FastAPI 序列化为 JSON

这里发生了三次重要转换:HTTP 请求先变成 Python 数据,Python 数据再进入业务服务,业务结果最后变成 HTTP 响应。路由位于 HTTP 世界和业务世界的交界处,因此它应该保持简短,而不是承载全部逻辑。

理解这条路径之后,再看后面的文件就不会觉得它们只是被随意拆散的代码。

4. 配置

先安装 Pydantic Settings。读取 .env 还需要 python-dotenv:

settings.sh
1
uv add pydantic-settings python-dotenv

配置模块只负责读取、转换和校验配置:

app/core/config.py
01
from functools import lru_cache
02
03
from pydantic import SecretStr
04
from pydantic_settings import BaseSettings, SettingsConfigDict
05
06
07
class Settings(BaseSettings):
08
"""集中管理应用配置,并负责从环境变量和 .env 读取值。"""
09
10
model_config = SettingsConfigDict(
11
env_file=".env",
12
env_file_encoding="utf-8",
13
extra="ignore",
14
)
15
16
app_name: str = "FastAPI LangChain"
17
deepseek_model: str = "deepseek-v4-flash"
18
deepseek_base_url: str = "https://api.deepseek.com"
19
deepseek_api_key: SecretStr | None = None
20
cors_allow_origins: str = "*"
21
cors_allow_methods: list[str] = [
22
"GET",
23
"POST",
24
"PUT",
25
"DELETE",
26
"OPTIONS",
27
]
28
cors_allow_headers: list[str] = [
29
"Content-Type",
30
"Authorization",
31
"X-Request-Id",
32
]
33
cors_expose_headers: list[str] = [
34
"X-Request-Id",
35
"X-Process-Time",
36
]
37
38
@property
39
def allowed_origins(self) -> list[str]:
40
"""把教学环境中的逗号分隔配置转换成 CORS 需要的列表。"""
41
42
origins = [
43
origin.strip()
44
for origin in self.cors_allow_origins.split(",")
45
if origin.strip()
46
]
47
return origins or ["*"]
48
49
@property
50
def allow_credentials(self) -> bool:
51
"""通配来源时关闭凭证,避免违反浏览器 CORS 规则。"""
52
53
return "*" not in self.allowed_origins
54
55
56
@lru_cache
57
def get_settings() -> Settings:
58
"""在进程内复用配置对象,避免每个请求重复读取 .env。"""
59
60
return Settings()

仓库中提交 .env.example,用于告诉其他开发者需要哪些配置:

.env.example
1
DEEPSEEK_MODEL=deepseek-v4-flash
2
DEEPSEEK_BASE_URL=https://api.deepseek.com
3
DEEPSEEK_API_KEY=your-api-key
4
CORS_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:

app/schemas/chat.py
1
from pydantic import BaseModel, Field
2
3
class ChatRequest(BaseModel):
4
question: str = Field(min_length=1, max_length=4000)
5
6
class ChatResponse(BaseModel):
7
answer: str
8
model: str

Schema 是 API 对外的数据契约。它负责说明客户端可以传入什么、服务会返回什么,却不负责调用模型。

请求模型和响应模型分开,即使它们暂时包含相同字段也不要复用。客户端能够提交的字段与服务允许返回的字段,往往会随着项目发展而产生不同的权限和校验规则。

6. 服务

业务层只关心“如何回答问题”:

app/services/chat.py
01
from langchain_core.language_models import BaseChatModel
02
03
from app.repositories.chat import ChatRepository
04
05
class ChatService:
06
def __init__(
07
self,
08
model: BaseChatModel,
09
model_name: str,
10
repository: ChatRepository,
11
):
12
self._model = model
13
self.model_name = model_name
14
self._repository = repository
15
16
async def answer(self, question: str) -> str:
17
message = await self._model.ainvoke(question)
18
await self._repository.save_turn(question, message.text)
19
await self._repository.commit()
20
return 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 中创建一次:

app/core/lifespan.py
01
from collections.abc import AsyncIterator
02
from contextlib import asynccontextmanager
03
04
from fastapi import FastAPI
05
from langchain_openai import ChatOpenAI
06
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
07
08
from app.core.config import Settings
09
10
@asynccontextmanager
11
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
12
settings: Settings = app.state.settings
13
14
app.state.model = ChatOpenAI(
15
model=settings.deepseek_model,
16
api_key=settings.deepseek_api_key.get_secret_value(),
17
base_url=settings.deepseek_base_url,
18
)
19
20
app.state.db_engine = create_async_engine(
21
settings.database_url,
22
pool_pre_ping=True,
23
)
24
app.state.session_factory = async_sessionmaker(
25
app.state.db_engine,
26
expire_on_commit=False,
27
)
28
29
yield
30
31
await 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:

app/api/dependencies.py
01
from collections.abc import AsyncIterator
02
from typing import Annotated
03
04
from fastapi import Depends, Request
05
from langchain_core.language_models import BaseChatModel
06
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
07
08
from app.core.config import Settings
09
from app.repositories.chat import ChatRepository
10
from app.services.chat import ChatService
11
12
async def get_db_session(
13
request: Request,
14
) -> AsyncIterator[AsyncSession]:
15
session_factory: async_sessionmaker[AsyncSession] = (
16
request.app.state.session_factory
17
)
18
19
async with session_factory() as session:
20
yield session
21
22
SessionDep = Annotated[AsyncSession, Depends(get_db_session)]
23
24
def get_chat_service(
25
request: Request,
26
session: SessionDep,
27
) -> ChatService:
28
settings: Settings = request.app.state.settings
29
model: BaseChatModel = request.app.state.model
30
31
return ChatService(
32
model=model,
33
model_name=settings.deepseek_model,
34
repository=ChatRepository(session),
35
)
36
37
Chat = 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 应用:

app/api/routes/chat.py
01
from fastapi import APIRouter
02
03
from app.api.dependencies import Chat
04
from app.schemas.chat import ChatRequest, ChatResponse
05
06
router = APIRouter(prefix="/chat", tags=["chat"])
07
08
@router.post("", response_model=ChatResponse)
09
async def chat(
10
payload: ChatRequest,
11
service: Chat,
12
) -> ChatResponse:
13
answer = await service.answer(payload.question)
14
15
return ChatResponse(
16
answer=answer,
17
model=service.model_name,
18
)

路由函数只做三件事:接收已经校验的数据、调用 Service、组装响应。模型密钥、客户端创建和提示词流程都不应该重新塞回这里。

当前路由自己的前缀是 /chat,接口装饰器使用空字符串,所以它在这个路由组中的路径就是 /chat。下一步统一路由还会增加 /api/v1,最终地址才会变成 /api/v1/chat。

10. 聚合

当前项目的章节案例继续在 src/fastapi/api/router.py 中统一聚合:

src/fastapi/api/router.py
01
from fastapi import APIRouter
02
03
from . import deps, req, res, validation
04
from .routes import health
05
06
# 需要版本管理的业务接口统一挂载在 /api/v1 下。
07
api_v1_router = APIRouter(prefix="/api/v1")
08
api_v1_router.include_router(health.router)
09
api_v1_router.include_router(deps.router)
10
api_v1_router.include_router(req.router)
11
api_v1_router.include_router(res.router)
12
api_v1_router.include_router(validation.router)
13
14
# 根路由负责聚合不同版本的 API,供应用入口一次性接入。
15
router = APIRouter()
16
router.include_router(api_v1_router)

这个文件只关心“应用有哪些业务路由”,不创建应用,也不处理请求。以后新增章节领域模块,只需要在这里挂载它的 router。

为什么不让 factory.py 直接导入所有路由?独立聚合层可以让应用工厂保持稳定。Factory 负责组装 FastAPI,API Router 负责组装 API,它们的变化原因不同。

11. 工厂

应用工厂负责把配置、生命周期、中间件和路由组装成一个 FastAPI 应用:

app/factory.py
01
from fastapi import FastAPI
02
03
from app.core.config import Settings, get_settings
04
from app.middleware import register_middleware
05
from src.fastapi.api.router import router as api_router
06
07
08
async def root() -> dict[str, str]:
09
return {"message": "Hello FastAPI"}
10
11
12
def create_app(settings: Settings | None = None) -> FastAPI:
13
"""组装 FastAPI 应用,允许测试传入独立配置。"""
14
15
current_settings = settings or get_settings()
16
app = FastAPI(
17
title=current_settings.app_name,
18
version="0.1.0",
19
)
20
app.state.settings = current_settings
21
22
register_middleware(app, current_settings)
23
24
app.include_router(api_router)
25
app.add_api_route("/", root, methods=["GET"])
26
return app

前面配置了 allowed_origins,这里才真正把它交给 CORS 中间件。只声明配置字段但不使用,并不会让浏览器自动获得跨域权限。

允许携带凭证时,应明确列出可信来源,不要为了省事把来源配置成 *。本地前端端口变化后,也要把新的 origin 加入配置;origin 包含协议、主机名和端口,三者任意一个变化都会被视为不同来源。

create_app() 接受可选的 Settings。正常启动时使用缓存配置,测试时可以直接传入测试配置。这样应用工厂的价值不只是“多包一层函数”,而是让组装过程拥有清楚、可控制的输入。

当前项目还没有需要长期维护的数据库连接池或模型客户端,因此工厂暂时不接入 lifespan。未来真正加入这类资源时,再把 lifespan 作为工厂参数传入,并在其中负责启动和清理。

12. 入口

启动模块保持足够简单:

app/main.py
1
from app.factory import create_app
2
3
app = create_app()

main.py 的职责只是暴露 FastAPI CLI 能找到的 app 对象。业务代码不应该反过来导入 app.main.app,否则很容易让模块导入变成一圈。

在 pyproject.toml 中声明入口:

pyproject.toml
1
[tool.fastapi]
2
entrypoint = "app.main:app"

开发环境可以直接执行:

dev.sh
1
uv run fastapi dev

app.main:app 表示导入 app/main.py 模块中的 app 对象。fastapi dev 面向本地开发,会启用自动重载;部署 Python 服务时应使用 fastapi run,而不是把开发服务器当成生产服务器。

13. 依赖方向

现在可以重新观察模块关系:

code.ts
01
main
02
-> factory
03
-> api router
04
-> routes
05
-> schemas
06
-> dependencies
07
-> services
08
-> application resources
09
-> core config / lifespan
10
11
services -> LangChain BaseChatModel
12
services -> repositories -> database

上层负责组装,下层提供能力。services/chat.py 可以依赖 Repository 抽象,但不应反过来让 Repository 导入路由;repositories 可以依赖数据库 Session 和 ORM 模型,但不应读取 HTTP Request。config.py 也不应导入应用对象。出现循环导入时,通常不是再换一种导入写法就能解决,而是对象创建位置或职责边界需要重新考虑。

有些代码会横跨多个业务,例如认证、异常转换、请求日志和追踪。它们也不应该全部进入 utils.py:

  • 认证上下文适合放在依赖中;
  • HTTP 异常转换适合放在异常处理器中;
  • 请求日志、CORS 和追踪适合放在中间件中;
  • 纯粹且稳定的无状态函数,才适合进入工具模块。

依赖方向清楚之后,单元测试可以直接测试 Service,接口测试则通过应用工厂创建应用。两类测试不必承担彼此的成本。

14. 演进

当前项目还没有数据库请求业务,因此 database.py、请求级 AsyncSession 和 repositories 不会作为空壳提前加入。下面讨论的是未来出现真实持久化需求时,数据库访问如何保持清晰。

一个请求通常会经历下面的协作:

  1. 依赖创建当前请求的 AsyncSession;
  2. Service 根据业务规则调用一个或多个 Repository;
  3. Repository 使用 Session 执行查询和写入,但不决定整个业务流程;
  4. Service 在需要时统一提交或回滚事务;
  5. 请求结束后依赖关闭 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 工厂。两种组织方式没有绝对胜负,判断标准是一次需求修改需要跨越多少无关目录。

架构的目标不是预见所有未来,而是让当前代码容易理解,并为下一次真实变化保留清楚的落点。

正在验证登录状态
请稍候,验证完成后将继续显示文章内容