2.1 分层配置:pydantic-settings 加载与校验
第 1 章我们把 ResearchCopilot 的骨架搭起来了:monorepo、TS 包、能跑 lint/test 的 CI。骨架跑得通,但它还不知道「连哪个数据库、用哪个模型、超时设多久」。这些运行时参数如果靠散落各处的 os.environ.get(...) 拼凑,工程很快会失控:同一个键在三个文件里被读三遍、拼写错了没人发现、默认值各写各的。本节先把它们收成一份可校验的对象。
本节把 ResearchCopilot 推进到「配置有单一来源」:用
pydantic-settings定义Settings,四级来源逐级合并,非法值在启动时就报错,再把这份对象显式注入服务。
2.1.1 配置散落会付出什么代价
先看反面教材。很多 Agent 项目一开始是这样写的:
# 反面写法:配置散落、无校验
import os
MODEL = os.environ.get("MODEL", "claude-sonnet-4-5")
TIMEOUT = float(os.environ.get("TIMEOUT", "30"))
MAX_STEPS = int(os.environ.get("MAX_STEPS", "8"))
DB = os.environ["DATABASE_URL"] # 缺了就在这里 KeyError
这段代码能跑,但它埋了四颗雷:
| 问题 | 表现 | 后果 |
|---|---|---|
| 无单一来源 | 同一个键在多处被读 | 改了环境变量仍有地方读旧值 |
| 无类型约束 | MAX_STEPS="abc" 到用时才崩 | 报错点远离根因 |
| 无范围约束 | MAX_STEPS=0 合法 | 循环一步都不跑,静默出错 |
| 无失败时机 | 缺 DATABASE_URL 在首次查询才炸 | 服务已启动、流量已进来 |
四颗雷的共同解法,是把配置收敛成一个在启动时就被完整校验的对象。校验越早、越集中,线上就越少惊喜。
2.1.2 Settings:一份可校验的配置对象
pydantic-settings(本机实测 2.15.0,配套 pydantic 2.14.0)在 BaseSettings 里把「读来源」和「做校验」合到了一起。我们为 ResearchCopilot 写一个 Settings:
# researchcopilot/config.py
from __future__ import annotations
from typing import Literal
from pydantic import Field
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
class Settings(BaseSettings):
"""ResearchCopilot 的运行时配置。"""
model_config = SettingsConfigDict(
env_prefix="RESEARCH_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# 推理
model_name: str = "claude-sonnet-4-5"
max_steps: int = Field(default=8, ge=1, le=32)
request_timeout_s: float = Field(default=30.0, gt=0)
# 数据层
database_url: str = "postgresql://postgres:postgres@localhost:15435/research"
redis_url: str = "redis://localhost:16380/0"
# 路由
log_level: Literal["debug", "info", "warning", "error"] = "info"
use_fake_model: bool = True
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
# 优先级从高到低:显式参数 > 环境变量 > .env > research.json > 默认值
return (
init_settings,
env_settings,
dotenv_settings,
JsonConfigSettingsSource(settings_cls, json_file="research.json"),
file_secret_settings,
)
这里有三处刻意的设计,值得逐个说清:
Field(default=8, ge=1, le=32)把约束写进类型里。max_steps既是「默认为 8」,也是「必须是 1 到 32 的整数」。约束声明式地贴在字段上,而不是散在业务代码的if里。Literal[...]枚举合法取值。log_level只能是四个值之一,写错就是启动期错误,而不是运行到日志那一行才发现。env_prefix="RESEARCH_"给环境变量加命名空间。同一台机器上可能有多个服务,加前缀避免DATABASE_URL这种通用名互相踩踏。
2.1.3 四级来源,谁覆盖谁
pydantic-settings 的核心模型是来源叠加:同一字段被多个来源提供时,按优先级取最高的那个。settings_customise_sources 返回的元组,顺序即优先级,越靠前越高。我们把 ResearchCopilot 的合并顺序定为四级:
| 级别 | 来源 | 典型用途 | 优先级 |
|---|---|---|---|
| ① | 类字段默认值 | 保证「零配置也能跑」 | 最低 |
| ② | research.json 配置文件 | 项目级默认、团队共享基线 | 低 |
| ③ | 环境变量 / .env | 部署环境差异(端口、密钥) | 高 |
| ④ | 显式构造参数 Settings(...) | 测试注入、单次覆盖 | 最高 |
这条顺序符合直觉:越靠近「具体这次运行」的来源,优先级越高。部署时用环境变量覆盖代码里的默认值,测试时用显式参数覆盖一切——这正是我们想要的方向。
顺带说明 extra="ignore":环境里往往有一堆与我们无关的变量(PATH、HOME),设为 ignore 表示「不认识就跳过」,否则 BaseSettings 会因为多余字段报错。
2.1.4 真跑:四级合并与非法值拒绝
口径讲完,跑一段验证。脚本依次构造四次 Settings,每次只让更高一级的来源生效,打印关键字段:
# run_config_demo.py
import json
import os
import tempfile
from pathlib import Path
from pydantic import ValidationError
from researchcopilot.config import Settings
work = Path(tempfile.mkdtemp(prefix="rc-cfg-"))
os.chdir(work)
def show(tag: str, s: Settings) -> None:
print(f"{tag:<14} model_name={s.model_name:<18} max_steps={s.max_steps:<3} log_level={s.log_level}")
print("== 四级来源合并(优先级 低 -> 高) ==")
show("① 默认值", Settings())
(work / "research.json").write_text(
json.dumps({"model_name": "claude-haiku-4-5", "max_steps": 12}), encoding="utf-8"
)
show("② 配置文件", Settings())
os.environ["RESEARCH_MODEL_NAME"] = "claude-opus-4-1"
os.environ["RESEARCH_LOG_LEVEL"] = "debug"
show("③ 环境变量", Settings())
show("④ 显式参数", Settings(model_name="claude-sonnet-4-5", max_steps=4))
print()
print("== 非法值被拒绝 ==")
try:
Settings(max_steps=0)
except ValidationError as e:
print(e)
用 venv 里的 Python 3.14.6 跑出来(真实输出):
== 四级来源合并(优先级 低 -> 高) ==
① 默认值 model_name=claude-sonnet-4-5 max_steps=8 log_level=info
② 配置文件 model_name=claude-haiku-4-5 max_steps=12 log_level=info
③ 环境变量 model_name=claude-opus-4-1 max_steps=12 log_level=debug
④ 显式参数 model_name=claude-sonnet-4-5 max_steps=4 log_level=debug
== 非法值被拒绝 ==
1 validation error for Settings
max_steps
Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]
For further information visit https://errors.pydantic.dev/2.14/v/greater_than_equal
逐行读这份输出:
- ① → ②:写进
research.json的model_name与max_steps覆盖了默认值,log_level没写所以保持info。只有被显式提供的字段才被覆盖,其余各自回落到下一级。 - ② → ③:环境变量
RESEARCH_MODEL_NAME盖过文件里的haiku,RESEARCH_LOG_LEVEL=debug也生效。注意max_steps仍是文件里的 12——环境里没设它。 - ③ → ④:显式参数只传了
model_name和max_steps,所以log_level还停在环境给的debug。没传的字段不会被动到。 - 最后一段:
max_steps=0触发ge=1约束,报错信息直接点名max_steps、给出约束(greater than or equal to 1)和实际值(0)。这正是「把约束写进类型」的回报——错误在构造那一刻就暴露,还自带修复指引。
一个容易忽略的细节:ValidationError 是在构造时抛出的,不是等到某个业务函数用到该字段才炸。配置在服务启动的第一步就被完整校验,坏配置根本进不了运行态。
2.1.5 注入:不要在模块顶层读环境变量
配置对象造好之后,下一个问题是怎么让它到达需要它的代码。反面做法是在每个模块顶层直接读:
# 反面写法:模块顶层读环境变量
import os
DB = os.environ["DATABASE_URL"] # 导入即执行,无法在测试里替换
class RetrievalService:
def query(self, q: str) -> list[str]:
return _hit(os.environ["DATABASE_URL"], q) # 又一次全局读取
这样写的后果是:导入模块的那一刻配置就被冻结,测试里想换成内存库得改全局环境变量;同一个键被读多次,改一处不生效。正确做法是把 Settings 当参数显式传进去:
# 正面写法:依赖注入
from researchcopilot.config import Settings
class RetrievalService:
def __init__(self, settings: Settings) -> None:
self._dsn = settings.database_url
self._timeout = settings.request_timeout_s
def query(self, q: str) -> list[str]:
return _hit(self._dsn, q, timeout=self._timeout)
区别很实在:RetrievalService 不再关心配置从哪来,它只接收一个已经校验好的对象。测试时传 Settings(database_url="...") 造一个专用实例即可,不用碰全局状态。
在应用的入口处,通常再包一层带缓存的工厂,保证「进程内只解析一次」:
# researchcopilot/config.py(续)
from functools import lru_cache
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
lru_cache 让 get_settings() 在首次调用时构造、之后复用同一个实例。但注意:只有入口处才调它,业务代码一律接收注入进来的 settings 参数。这样既避免了「每处都重新读环境」,又保住了「测试可替换」的灵活性。
2.1.6 验收清单与真实环境
配置这一层的验收,可以收敛成四条:
- 所有运行时参数都来自唯一的
Settings,仓库里搜不到第二处os.environ.get。 - 每个字段都有类型;数值字段有范围约束;枚举字段用
Literal。 - 服务启动的第一步就是构造
Settings,坏配置立即失败。 - 业务模块接收注入的
Settings,不在模块顶层读环境。
真实环境里还有两点差异值得提前知道。其一,来源会更多。 生产上往往还要接密钥管理服务(Vault、云厂商的 Secret Manager),pydantic-settings 为此提供了 AWSSecretsManagerSettingsSource 等现成来源,可以像 research.json 一样插进 settings_customise_sources 的元组里。其二,.env 只用于本地。 它会随代码一起被打包,绝不能在生产镜像里携带真实凭据——下一节 2.2 密钥管理
会把这条线讲透。
本节把配置收成了一份对象;但 database_url、redis_url 这些字段里可能嵌着密码,直接 print(settings) 会泄露。下一节解决密钥的脱敏、注入与缺失检查。想回看骨架是怎么搭的,翻 1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test
。
阅读导航:上一节:1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test · 下一节:2.2 密钥管理:Anthropic key / DB / Redis 本地注入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。