1. 分工
pyproject.toml 已经是 TOML 文件,为什么 FastAPI 项目还需要 config.toml?因为它们描述的对象不同:
1pyproject.toml:描述 Python 项目、依赖和开发工具2config.toml:描述这个应用默认如何运行
例如,下面这些内容属于项目定义:
1[project]2name = "fastapi-langchain"3requires-python = ">=3.13"4dependencies = ["fastapi[standard]", "langchain"]56[tool.fastapi]7entrypoint = "app.main:app"
下面这些内容属于应用运行配置:
01[app]02name = "FastAPI LangChain"03environment = "development"04log_level = "INFO"0506[model]07provider = "deepseek"08name = "deepseek-v4-flash"09base_url = "https://api.deepseek.com"10temperature = 0.211timeout_seconds = 301213[database]14pool_size = 1015pool_timeout_seconds = 30
应用配置不是 Python 标准强制规定的文件。config.toml 的文件名、表结构和字段含义都由我们的项目自己设计,因此项目也必须负责读取、校验和说明它。
对于 FastAPI + LangChain + LangGraph 服务,适合放入 TOML 的通常是:
- 应用名称、日志级别和功能开关;
- 默认模型名称、非敏感接口地址、超时和重试次数;
- 数据库连接池大小、检查点保留策略;
- CORS 来源、批处理大小、并发上限;
- 本地、测试、生产环境都可以公开的默认值。
API Key、数据库密码、JWT 密钥不应放入会提交到 Git 的 TOML 文件。它们应由环境变量或部署平台 Secret 提供。
2. 结构
配置结构应尽量接近应用中的职责边界,而不是把所有字段放在根级:
01[app]02name = "FastAPI LangChain"03environment = "development"04log_level = "INFO"0506[server]07host = "127.0.0.1"08port = 80000910[server.cors]11allow_origins = ["http://localhost:3019"]12allow_methods = ["GET", "POST", "OPTIONS"]1314[model]15provider = "deepseek"16name = "deepseek-v4-flash"17base_url = "https://api.deepseek.com"18temperature = 0.219timeout_seconds = 3020max_retries = 22122[langgraph]23recursion_limit = 5024checkpoint_backend = "memory"2526[database]27pool_size = 1028pool_timeout_seconds = 30
其中,environment 表示当前是开发、测试还是生产环境;server.cors 决定哪些浏览器来源和请求方法可以访问接口;recursion_limit 用来限制 LangGraph 一次运行最多推进多少步;checkpoint_backend 则决定运行状态保存在内存还是持久化存储中。初学时不必一次记住所有字段,先理解每个 TOML 表对应应用中的一类职责即可。
表名应表达稳定的领域,例如 server、model、database。不要按使用文件命名为 factory_config、router_config,否则代码移动后配置名称也被迫变化。
字段名采用一致的 snake_case,与 Python 属性命名一致,可以减少转换:
1model_name = config["model"]["name"]2pool_size = config["database"]["pool_size"]
配置值也要放在正确层级。temperature 是模型调用参数,不属于 [server];pool_size 是数据库连接池设置,不属于 [app]。层级清楚后,后续 Pydantic 模型也更容易拆分。
不要为每个 Python 模块建立一个 TOML 表。配置的目标是表达运行策略,不是复制源代码目录树。
3. 路径
最直接的读取方式如下:
1from pathlib import Path2import tomllib345def load_config() -> dict:6with Path("config.toml").open("rb") as file:7return tomllib.load(file)
tomllib 从 Python 3.11 开始进入标准库,负责读取 TOML,但不负责写入。这里必须用 "rb" 以二进制模式打开文件,因为 tomllib.load() 接收的是二进制文件对象。
这段代码的问题是 Path("config.toml") 相对于当前工作目录,也就是执行启动命令时所在的目录。下面两种启动方式可能得到不同结果:
1cd fastapi-langchain2uv run fastapi dev34cd fastapi-langchain/app5uv run python main.py
更稳妥的方式是从已知 Python 文件定位项目根目录。假设 config.toml 位于仓库根目录,加载器位于 app/core/config_loader.py:
01from pathlib import Path02import tomllib0304PROJECT_ROOT = Path(__file__).resolve().parents[2]05DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config.toml"060708def load_toml(path: Path = DEFAULT_CONFIG_PATH) -> dict:09with path.open("rb") as file:10return tomllib.load(file)
__file__ 是当前模块路径。调用 resolve() 后得到绝对路径。以 app/core/config_loader.py 为起点,parents[0] 是 core,parents[1] 是 app,parents[2] 才是 fastapi-langchain 项目根目录。
这段代码依赖当前目录结构,因此应该集中写在一个配置模块中,不要在各个业务模块中重复计算路径。
parents[2] 只适合上面约定好的源码目录结构,并不是定位配置文件的通用写法。项目安装为 wheel 后,仓库根目录可能根本不存在,根目录下的 config.toml 也不会自动成为包内资源。部署应用时,更常见的做法是通过启动参数或环境变量提供配置文件路径,再把这个路径传给加载器;如果要读取随 Python 包发布的内置资源,则应使用 importlib.resources。
测试时也可以根据测试文件自身的位置定位专用配置:
1from pathlib import Path23from app.core.config_loader import load_toml45TESTS_DIR = Path(__file__).resolve().parent6test_config = load_toml(TESTS_DIR / "fixtures/config.toml")
这样测试不必修改生产配置文件,也不依赖测试进程从哪个目录启动。
4. 校验
tomllib 只验证 TOML 语法,不知道 temperature 应在什么范围,也不知道 pool_size 必须大于零。下面这份文件语法完全正确,却既缺少必填配置,又包含明显不合理的数值:
1[model]2name = ""3base_url = "https://api.deepseek.com"4temperature = 9.55timeout_seconds = -167[database]8pool_size = 0
可以使用 Pydantic BaseModel 描述应用配置结构:
01from typing import Literal0203from pydantic import BaseModel, ConfigDict, Field, HttpUrl040506class StrictConfigModel(BaseModel):07model_config = ConfigDict(extra="forbid")080910class ApplicationConfig(StrictConfigModel):11name: str = Field(min_length=1)12environment: Literal["development", "test", "staging", "production"]13log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]141516class CorsConfig(StrictConfigModel):17allow_origins: list[str]18allow_methods: list[str]192021class ServerConfig(StrictConfigModel):22host: str = Field(min_length=1)23port: int = Field(ge=1, le=65535)24cors: CorsConfig252627class ModelConfig(StrictConfigModel):28provider: str = Field(min_length=1)29name: str = Field(min_length=1)30base_url: HttpUrl31temperature: float = Field(ge=0, le=2)32timeout_seconds: float = Field(gt=0)33max_retries: int = Field(ge=0, le=10)343536class LangGraphConfig(StrictConfigModel):37recursion_limit: int = Field(gt=0)38checkpoint_backend: Literal["memory", "postgres"]394041class DatabaseConfig(StrictConfigModel):42pool_size: int = Field(gt=0)43pool_timeout_seconds: float = Field(gt=0)444546class AppConfig(StrictConfigModel):47app: ApplicationConfig48server: ServerConfig49model: ModelConfig50langgraph: LangGraphConfig51database: DatabaseConfig
这里的模型与第 2 节 TOML 中的每个表一一对应。Literal 把字段限制在列出的字符串中;HttpUrl 要求 base_url 是 HTTP 或 HTTPS 地址;Field 则负责数值范围等规则。示例把 temperature 限制在 0 到 2,这是当前项目制定的配置契约,切换模型供应商时还要确认对方实际支持的范围。
extra="forbid" 很重要。Pydantic 默认会忽略模型中未声明的字段,配置键即使拼错也可能不报错;改为禁止额外字段后,像 timeout_second 这样的拼写错误会直接导致启动失败。
读取 TOML 后,通过 model_validate() 转换:
01from pathlib import Path02import tomllib0304from app.core.config_schema import AppConfig050607def load_config(path: Path) -> AppConfig:08with path.open("rb") as file:09raw_config = tomllib.load(file)1011return AppConfig.model_validate(raw_config)
使用方不再访问任意字典,而是获得有类型提示的对象:
1config = load_config(DEFAULT_CONFIG_PATH)23print(config.model.name)4print(config.database.pool_size)
config.model.base_url 经过校验后是 Pydantic 的 URL 对象。下游库只接受字符串时,可以使用 str(config.model.base_url) 转换。
如果配置缺失或类型错误,应用应在启动阶段失败,并给出字段路径。不要吞掉异常后悄悄使用一套不明确的默认值,否则服务表面启动成功,第一次真实请求才暴露问题。
5. 默认值
默认值可以写在 TOML,也可以写在 Pydantic 模型中。两种方式各有适用场景:
| 默认值位置 | 适合 |
|---|---|
| TOML | 运维和开发者经常调整、需要一眼看到的运行策略 |
| Python 模型 | 很少变化、属于代码契约的安全默认值 |
例如,模型名称和连接池大小适合在 TOML 中显式展示。在本章的完整示例里,所有字段都由 TOML 提供,因此 Pydantic 模型只负责声明必填项和校验规则,没有再重复设置默认值。如果希望由 Python 提供缺省日志级别,就应从 TOML 示例中删掉同一个字段,并在配置文档中说明它的默认行为。
不要在两个地方重复维护同一个值:
1[model]2temperature = 0.2
1class ModelConfig(BaseModel):2temperature: float = 0.7
如果 TOML 一直存在,Python 中的 0.7 永远不会生效;删除 TOML 字段后,行为又突然改变。团队需要明确哪个位置是主要来源。
对必填字段,不应提供看似可用但实际上错误的默认值。例如:
1class ModelConfig(BaseModel):2name: str3base_url: HttpUrl
模型名称或接口地址缺失时,启动失败比默认连接到错误服务更安全。
对于密钥,不能在 TOML 或 Python 代码中提供真实默认值。最多使用 None 表示未配置,并在真正需要创建模型时给出清晰错误。
6. 读取时机
配置通常在应用启动时读取一次,而不是每个请求都重新打开文件:
1进程启动2-> 定位配置文件3-> 解析 TOML4-> Pydantic 校验5-> 创建 AppConfig 配置对象6-> 初始化数据库、HTTP 客户端和模型工厂7-> 开始接收请求
这样做有三个好处:
- 配置错误会在启动阶段暴露;
- 请求不承担重复文件读取和校验成本;
- 同一进程中的组件看到一致配置。
修改 TOML 后,正在运行的进程不会自动获得新值。开发环境的代码重载可能顺带重启进程,但生产环境应把配置变更当作一次明确部署或滚动重启。
动态业务数据不应依赖修改 TOML。例如后台页面切换当前用户的模型、一次请求调整温度,这些值应该来自数据库或请求参数。TOML 更适合进程级默认策略。
如果确实需要运行时热更新,应设计独立配置服务、版本号、校验、并发控制和回滚机制,而不是让多个请求同时改写本地文件。
错误处理
一个可靠加载器至少区分三类错误:文件不存在、TOML 语法错误、字段校验错误。
01from pathlib import Path02import tomllib0304from pydantic import ValidationError0506from app.core.config_schema import AppConfig070809class ConfigError(RuntimeError):10pass111213def load_config(path: Path) -> AppConfig:14try:15with path.open("rb") as file:16raw_config = tomllib.load(file)17except FileNotFoundError as error:18raise ConfigError(f"配置文件不存在:{path}") from error19except tomllib.TOMLDecodeError as error:20raise ConfigError(f"TOML 语法错误:{error}") from error2122try:23return AppConfig.model_validate(raw_config)24except ValidationError as error:25details = "; ".join(26f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}"27for item in error.errors(include_input=False)28)29raise ConfigError(f"配置字段校验失败:{details}") from error
include_input=False 会从错误详情中排除原始输入值,避免以后加入密钥配置时意外把敏感值写进日志。日志可以记录配置文件路径、当前环境和非敏感字段,但不能打印完整原始配置。
配置文件来自不可信来源时还应限制文件大小。tomllib 文档明确提醒,恶意输入可能消耗大量 CPU 或内存。普通随代码部署的小型配置文件风险较低,但上传 TOML 并在线解析属于另一种安全场景。