《AI Agent 工程实战》2.1 分层配置:pydantic-settings 加载与校验

本节用 pydantic-settings 2.15.0 把 ResearchCopilot 散落的配置收成一个可校验的 Settings 对象,演示「默认值 → 配置文件 → 环境变量 → 显式参数」四级来源合并与 Field 校验。我们真跑两段代码:一段展示高层来源逐级覆盖低层,一段展示非法值被拒绝并给出清晰报错,并说明配置对象该如何注入服务。

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 本地注入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距