《AI Agent 工程实战》2.2 密钥管理:Anthropic key / DB / Redis 本地注入

上一节的 Settings 里还嵌着明文密码。本节给 ResearchCopilot 补上密钥治理:用 SecretStr 让密钥在打印、日志、序列化时自动脱敏,用 .env 与 .gitignore 配合完成本地注入,并在启动时对必需密钥做 fail-fast 检查。我们真跑两段代码展示脱敏效果与缺失报错,最后给出一份密钥清单表。

2.2 密钥管理:Anthropic key / DB / Redis 本地注入

上一节的 Settings 有一处没解决:database_url 这类字段里往往嵌着密码,而 anthropic_api_key 更是实打实的凭据。如果 print(settings) 或者某个异常堆栈把它带出去,密钥就泄露了。Agent 服务尤其危险——它要同时持有模型厂商的 key、数据库口令、缓存口令,任何一处打日志都可能把一串凭据送进日志系统。

本节把 ResearchCopilot 推进到「凭据可安全流转」:用 SecretStr 让密钥自带脱敏,用 .env + .gitignore 完成本地注入,启动时对必需密钥做 fail-fast,并给出一份密钥清单表。

2.2.1 三条铁律:不落库、不进镜像、不进日志

在写任何代码之前,先把目标钉死。密钥治理的一切手段,都是为了守住这三条:

铁律含义违反的后果
不落库密钥不进业务数据库、不进事件表、不进会话历史一次误查库就泄露全部租户凭据
不进镜像密钥不写进 Dockerfile、不 COPY .env、不烤进层镜像被推送到 registry 即全员可见
不进日志密钥不出现在 print、日志、异常栈、trace 里日志系统成为最大的凭据泄露面

三条线里,「不进日志」最容易在无意识中被打破。原因很简单:调试时打印一个配置对象太自然了,而 dataclass 或 BaseModel 的默认 __repr__ 会把每个字段原样吐出来。要堵住这个口子,得让密钥字段自己知道要脱敏。

2.2.2 SecretStr:让密钥自己会脱敏

pydantic 提供了 SecretStr 类型。它把真实值包在里面,repr / str 一律显示 **********,只有显式调用 get_secret_value() 才拿得到明文。把上一节的密钥字段换成它:

# researchcopilot/config.py(密钥相关部分)
from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Secrets(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="RESEARCH_", env_file=".env", extra="ignore"
    )

    use_fake_model: bool = True
    anthropic_api_key: SecretStr | None = None
    database_url: str = "postgresql://postgres:postgres@localhost:15435/research"
    redis_url: str = "redis://localhost:16380/0"

    @model_validator(mode="after")
    def check_required(self) -> "Secrets":
        if not self.use_fake_model and self.anthropic_api_key is None:
            raise ValueError(
                "use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY"
            )
        return self

关键就一行:anthropic_api_key: SecretStr | None。类型一变,脱敏就是自动的——不需要在每处打印前记得手动打码,因为打码逻辑被焊死在类型里。model_validator(mode="after") 则负责跨字段检查,它的内容留到 2.2.5 展开。

2.2.3 真跑:repr 里的密钥是 **********

光说没用,跑一段看效果。给环境变量塞一个占位符(真实项目里是真实 key),然后从几个常见角度观察它会不会泄露:

# run_secret_demo.py
import os

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Secrets(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")

    anthropic_api_key: SecretStr | None = None
    database_url: str = "postgresql://postgres:postgres@localhost:15435/research"
    redis_url: str = "redis://localhost:16380/0"


os.environ["RESEARCH_ANTHROPIC_API_KEY"] = "sk-ant-***"
s = Secrets()

print("repr(settings) :", repr(s))
print("str(settings)  :", str(s))
print("字段 repr      :", repr(s.anthropic_api_key))
print("model_dump(json):", s.model_dump(mode="json"))

真实输出:

repr(settings) : Secrets(anthropic_api_key=SecretStr('**********'), database_url='postgresql://postgres:postgres@localhost:15435/research', redis_url='redis://localhost:16380/0')
str(settings)  : anthropic_api_key=SecretStr('**********') database_url='postgresql://postgres:postgres@localhost:15435/research' redis_url='redis://localhost:16380/0'
字段 repr      : SecretStr('**********')
model_dump(json): {'anthropic_api_key': '**********', 'database_url': 'postgresql://postgres:postgres@localhost:15435/research', 'redis_url': 'redis://localhost:16380/0'}

四种常见的「意外泄露」路径,全被挡住了:

路径不用 SecretStr用了 SecretStr
repr(settings)明文SecretStr('**********')
str(settings)明文SecretStr('**********')
f-string 插值明文**********
model_dump(mode="json")明文'**********'

要拿明文只有一条路:s.anthropic_api_key.get_secret_value()。把「读明文」变成一个显式动作,正是安全设计该有的样子——代码审查时,grep get_secret_value 就能定位所有真正接触明文的位置。

有一点要如实说明:SecretStr 挡的是「意外泄露」,挡不住恶意代码。任何拿到该对象的代码都能调 get_secret_value()。它的价值在于把泄露从「默认行为」变成「刻意行为」,而不是提供加密。

2.2.4 .env 本地注入与 .gitignore 的配合

密钥从哪来?本地开发最方便的是 .env:一个纯文本键值文件,pydantic-settings 通过 env_file=".env" 自动读取。上一节 SettingsConfigDict 里已经配好,新建一个:

# .env(本地专用,绝不提交)
RESEARCH_ANTHROPIC_API_KEY=sk-ant-***
RESEARCH_DATABASE_URL=postgresql://postgres:postgres@localhost:15435/research
RESEARCH_REDIS_URL=redis://localhost:16380/0
RESEARCH_USE_FAKE_MODEL=false

与它必须成对出现的,是 .gitignore:

# .gitignore
.env
research.json
__pycache__/

.env 和 .gitignore 是一个机制的两半,缺一不可。.env 负责「本地注入」,.gitignore 负责「永不提交」。只要 .env 进了 git 历史,即使后来删掉,凭据也已经在历史里躺着了——唯一的补救是轮换密钥。所以规则是:先写 .gitignore,再建 .env。仓库里应该提交的是一个 .env.example(只有键名和占位符,没有真值),让新同事知道要配哪些键。

读一遍 .env 验证它确实被载入:

# run_env_demo.py
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Secrets(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")

    anthropic_api_key: SecretStr | None = None
    use_fake_model: bool = True


s = Secrets()
print("从 .env 载入:", repr(s.anthropic_api_key), "| fake =", s.use_fake_model)
从 .env 载入: SecretStr('**********') | fake = False

密钥从文件读进来了(use_fake_model 被设成 False 证明 .env 生效),但它依然以脱敏形态存在。「从文件注入」和「打印时脱敏」是两件正交的事,前者管来源,后者管出口,两者叠加才完整。

2.2.5 启动时 fail-fast:缺必需密钥就拒绝启动

最后一环是时机。密钥缺失应该在服务启动时就发现,而不是等第一个用户请求进来、跑到调用模型那一步才崩。pydantic-settings 的 model_validator(mode="after") 正好能做这件事——它在所有字段解析完成后运行,适合做跨字段的完整性检查。

规则是:离线模式(use_fake_model=True)零凭据可跑,真模型模式(False)必须有 key。跑两段验证:

# run_failfast_demo.py
from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Secrets(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")

    use_fake_model: bool = True
    anthropic_api_key: SecretStr | None = None

    @model_validator(mode="after")
    def check_required(self) -> "Secrets":
        if not self.use_fake_model and self.anthropic_api_key is None:
            raise ValueError(
                "use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY"
            )
        return self


print("== 离线模式:缺密钥也放行 ==")
s1 = Secrets(use_fake_model=True)
print("OK  use_fake_model =", s1.use_fake_model, " key =", s1.anthropic_api_key)

print()
print("== 真模型模式:缺密钥 fail-fast ==")
try:
    Secrets(use_fake_model=False)
except Exception as e:
    print(type(e).__name__)
    print(e)

真实输出:

== 离线模式:缺密钥也放行 ==
OK  use_fake_model = True  key = None

== 真模型模式:缺密钥 fail-fast ==
ValidationError
1 validation error for Secrets
  Value error, use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY [type=value_error, input_value={'use_fake_model': False}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.14/v/value_error

注意这里的设计张力:离线模式刻意不要求 key。这保证了任何人在没密钥的机器上都能 git clone 后直接跑测试——这正是本卷「假模型是默认路径」的落地方式。而一旦有人把 use_fake_model 关掉,启动立刻失败,报错信息直白地点名缺哪个环境变量。该宽松时宽松,该严格时严格,界线由 use_fake_model 这个开关划清。

2.2.6 密钥清单表

把 ResearchCopilot 需要的凭据整理成一张表,团队交接时按它逐项核对:

键名必需性缺失时行为本地来源生产来源
RESEARCH_ANTHROPIC_API_KEY真模型模式必需启动 fail-fast.envSecret Manager
RESEARCH_DATABASE_URL必需启动 fail-fast(无默认).envSecret Manager
RESEARCH_REDIS_URL可选回落到内存缓存.envSecret Manager
RESEARCH_USE_FAKE_MODEL可选默认 True(离线).env环境变量
RESEARCH_JWT_SECRET第 4 章起必需启动 fail-fast.envSecret Manager

两条读表规则:「必需」一律对应启动期 fail-fast,绝不拖到运行时;「可选」必须有明确降级路径(比如 Redis 缺失回落到进程内缓存),而不是留一个 None 到处判断。凡是没有降级路径又标成可选的字段,都是潜在的线上事故。

2.2.7 验收清单与真实环境

密钥治理的验收:

  • 所有密钥字段用 SecretStr,仓库里搜不到明文 sk-ant-。
  • .env 在 .gitignore 里,仓库只提交 .env.example。
  • 必需密钥缺失时服务启动即失败,报错点名具体环境变量。
  • 密钥不出现在任何日志、trace、异常栈里。

真实环境里的差异有三点。其一,密钥不再来自 .env,而是来自 Vault 或云厂商 Secret Manager,pydantic-settings 有对应的 SettingsSource 可以无缝接入;.env 只服务于本地。其二,密钥要能轮换:凭据泄露时得能立刻换新,所以业务代码应当每次从 Settings 取、而不是把它缓存进某个长生命周期的对象。其三,要防日志:即便用了 SecretStr,也应在日志配置里对已知键名做一次脱敏兜底(第 15 章的审计与脱敏会展开)。

密钥管好了,接下来要决定「用真模型还是假模型」。下一节 2.3 模型路由与降级 就把这个开关接成一条可降级的链路。想回看配置是怎么分层的,翻 2.1 分层配置:pydantic-settings 加载与校验 。

阅读导航:上一节:2.1 分层配置:pydantic-settings 加载与校验 · 下一节:2.3 模型路由与降级:真/假模型开关,离线默认假模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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