Day 02 / 共 20 天 · 第 1 周 建立心智

环境搭建 & 跑通第一个 Agent

昨天(Day 01)我们翻源码建好了「盖楼」地图:地基→可信底座→各户业务 Agent→物业前台。今天动手把这栋楼真正通水通电——搞懂 uv workspace 怎么把 31 个包串成一个整体(editable 原理),再逐行读 config.pyload_config_deep_merge,用一个真实的 base.yaml + dev.yaml 合并例子把配置系统焊死,最后把平台跑起来调第一个 Agent。

📍 你在 20 天里的位置(第 1 周:建立心智)
D01 全景架构 D02 跑起来 D03 LangGraph 基础 D05 一个 Agent 结构 D06-11 可信底座 D14 旗舰精读
💡 用一个类比先兜住今天(延续昨天的「盖楼/物业」世界观) 如果说 Day 01 是拿到楼盘的设计图纸,今天就是交房入住那天uv sync = 物业一次性把全楼水电煤气一起开通(一条命令装好 31 个包);configs/ profile = 物业的「公共守则 + 各单元特批」(公共默认 + 本环境覆盖,靠 _deep_merge 合并);启动 server = 打开楼下大门总闸;调用一个 Agent = 你按了某一户门铃看它应答。下面 9 讲就是这四步,而且每一步都落到真源码。
L01

今天的目标

Day 01 我们建立了"地图"。今天把地图落到能跑的环境上,并且读几段真源码。学完你能:

  • 讲清 uv workspace + editable 安装为什么能做到"改一处、全平台生效";
  • 读懂 config.pyload_config() 的完整 5 步流水线,和 _deep_merge() 的递归逻辑;
  • 亲手走一个 真实的 base.yaml + dev.yaml 合并,预测出最终 arch_compliance.host 的值;
  • gov-agents-server 跑起来,用统一 envelope 调一个 Agent。
没有环境也没关系:今天所有命令我都会把"预期输出"写出来,源码也直接贴出来,你光看也能理解。真机操作是加分项,不是必须。
L02

monorepo、uv workspace 与 editable 原理

monorepo(单体仓库)就是"把很多相关的包放在同一个 git 仓库里"。本项目就是——一个仓库里同时住着 toolkit、21 个 agent、server、cli、前端门户……它们互相依赖。

🤔 痛点:没有 workspace 你会怎样?你得进 ai-trust-toolkit 目录 pip install .,再进 gov-agents-server 装一遍,再进 21 个 agent 各装一遍……而且改了 toolkit 一行源码,还得把它重新打包、其它包重新安装才生效。31 个包手动 pip install,装到怀疑人生,还极易装出「A 包要 pydantic 2.5、B 包锁死 2.7」这种打架版本。
💡 本质:workspace = 物业统一开通全楼水电workspace 就像入住时物业一次性给全楼所有单元同时通水通电:你不用挨家挨户开阀门。而且用的是同一套管网(一个 uv.lock),保证 3 楼和 18 楼水压一致(版本一致);哪户想换个灯泡(改某个包源码),全楼立刻亮新灯——因为是 editable 安装,装的是「指向源码的软链」而不是拷贝。

Day 01 我们已经看过成员清单。今天补上那把"钥匙"——pyproject.toml:44-53[tool.uv.sources],它才是 editable 生效的关键:

真源码 · pyproject.toml:7-53(裁剪)
[tool.uv.workspace]
members = [
    "packages/ai-trust-toolkit",
    "packages/gov-agents-server",
    "apps/sre-rca-agent",
    # ... 共 31 条
]

[tool.uv.sources]
ai-trust-toolkit = { workspace = true }   # ← 当谁依赖它,就用本地这份源码,不去 PyPI
gov-agents-mcp   = { workspace = true }
  • members:8-42)= "要一起装的 31 个子包"。uv sync --all-packages 会把它们全部装进同一个 .venv
  • [tool.uv.sources]:44-53)里 ai-trust-toolkit = { workspace = true } 是精髓:某个 app 的 pyproject 写"我依赖 ai-trust-toolkit"时,uv 不去 PyPI 下载同名包,而是把它指向本地 packages/ai-trust-toolkit/ 的源码目录(生成一个 .pth 软链)。
  • 这就是 editable(可编辑安装):装的是"指路牌"不是"复印件"。你改 toolkit 一行,Python 下次 import 时读的就是新源码,无需重装
根 pyproject.toml [tool.uv.workspace] · 31 members uv.lock (890KB) 锁死所有版本 ai-trust-toolkit 可信底座(被依赖) gov-agents-server 运行时 sre-rca-agent 业务 agent apps/… ×20 其余业务 agent ↑ 大家都 { workspace = true } 从本地取 toolkit,不去 PyPI 下载 uv sync --all-packages:一条命令全部 editable 装好
图注:一个根 pyproject 管着 31 个成员,共享一个 uv.lock;业务包都通过 { workspace = true } 依赖同一份本地 toolkit 源码。
⚖️ 设计取舍①:editable 软链 vs 传统拷贝安装 传统 pip install .:把 toolkit 源码拷贝site-packages。改了源码 → 拷贝的还是旧的 → 必须重新打包 + 逐个重装 21 个 agent 才生效。
editable(workspace = true):装的是指向源码目录的软链。改一行立刻全平台生效,正好配合 Day 01 讲的"渐进抽象"——公共逻辑一下沉,所有 agent 当场吃到、当场被测试覆盖。代价:不能在开发机上同时装两个不同版本的 toolkit(但 monorepo 本就只要一个版本),可接受。
L03

一条命令装好全仓

装好 uv 后(curl -LsSf https://astral.sh/uv/install.sh | sh),在仓库根目录只需要一条命令。这条命令和参数来自 README.md:140-141

terminal
uv sync --all-extras --all-packages
Resolved 400+ packages in 1.2s
Installed ai-trust-toolkit (editable)
Installed gov-agents-server (editable)
Installed sre-rca-agent (editable) ...
✓ 全部 31 个 workspace 包 + 依赖 + dev 工具装好了

参数解释:--all-packages = 装所有 31 个 workspace 成员;--all-extras = 连每个包的可选依赖(如 [postgres][observability])也一起装。装完你可以直接 uv run pytest 跑全套测试——因为测试全部走 FakeLLM(假大模型),不联网、不花钱、几秒跑完。

⚖️ 设计取舍②:为什么测试坚决不用真大模型? AGENTS.md:263 立了条规矩:"LLM 调用走 get_llm() —— 永远不要直接 import anthropic,否则测试要联网"。
朴素做法:测试里直接调 Claude → 每跑一次 CI 花钱、要网、结果还不确定(模型每次输出不同,断言没法写)。
本仓做法:业务从不直连大模型,全走注入点 get_llm();测试时把它换成返回固定答案的 FakeLLM。收益:测试可复现、零成本、零外网、几秒全绿。这套注入机制是 Day 11 的主角。
L04

配置系统:逐行读 load_config 源码

平台要在本地、测试、预发、生产等不同环境跑,每个环境的数据库地址、预算上限都不同。它用一套"基础配置 + 环境覆盖"的 profile 系统解决,文件在 configs/base/dev/staging/prod/test.yaml 五个)。核心加载器就一个函数——我们把它整段贴出来读:

真源码 · config.py:290-339 load_config()
@lru_cache(maxsize=4)
def load_config(env: str | None = None) -> Config:
    app_env = env or os.environ.get("APP_ENV", "dev")          # ① 定 profile
    configs_dir = _find_configs_dir()                          # ② 找 configs/ 目录
    base_path = configs_dir / "base.yaml"
    env_path = configs_dir / f"{app_env}.yaml"

    if not env_path.exists():                                  # 找不到就退回 dev(坑!见下)
        log.warning("profile %s.yaml not found ... falling back to dev", app_env)
        env_path = configs_dir / "dev.yaml"
        ...
    try:
        base_data = yaml.safe_load(_expand_env_vars(base_path.read_text(...))) or {}   # ③ 读+展开
        env_data  = yaml.safe_load(_expand_env_vars(env_path.read_text(...)))  or {}
    except yaml.YAMLError as e:
        raise ConfigError(f"yaml syntax error · {e}") from e

    merged = _deep_merge(base_data, env_data)                  # ④ 深度合并
    merged["app_env"] = app_env                                #   app_env 以传入为准(SSOT)
    try:
        return Config(**merged)                                # ⑤ Pydantic 校验
    except Exception as e:
        raise ConfigError(f"config validation failed for {app_env}: {e}") from e
  • ① app_env = env or os.environ["APP_ENV"](默 dev):先定"我是哪个环境"。这决定第二个 yaml 读谁。
  • ② _find_configs_dir():从当前目录一路往上找到含 configs/base.yaml 的目录(config.py:270-287)。找不到就 raise ConfigError,不静默。
  • ③ _expand_env_vars(read_text(...)):先把 yaml 文本里的 ${VAR} 占位符换成真环境变量,喂给 yaml.safe_load。顺序很关键——先展开、后解析。(L06 细讲)
  • ④ _deep_merge(base_data, env_data):把"公共默认"和"本环境覆盖"逐字段合并。这是全函数的心脏,L05 逐行拆。
  • ⑤ Config(**merged):交给 Pydantic 校验成类型安全的对象。字段名/类型不对当场报错。

返回类型 Config 是一个 Pydantic 模型,它的字段结构就是"平台一共有哪些配置块"的数据字典:

真源码 · config.py:215-228 class Config
class Config(BaseModel):
    model_config = ConfigDict(extra="forbid")           # ← 多写一个 key 直接报错

    app_env: str = "dev"
    database: DatabaseConfig = Field(default_factory=DatabaseConfig)
    redis: RedisConfig = Field(default_factory=RedisConfig)
    llm: LLMConfig = Field(default_factory=LLMConfig)
    cost: CostConfig = Field(default_factory=CostConfig)
    quota: QuotaConfig = Field(default_factory=QuotaConfig)
    budget: BudgetConfig = Field(default_factory=BudgetConfig)
    observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig)
    gateway: GatewayConfig = Field(default_factory=GatewayConfig)
Config (root) databaseDB pool redischeckpoint llmtier costUSD cap quotarpm budgetsqlite observ.OTel gateway (5 上游集成) lark / gitlab / jira / crm / confluence extra="forbid" · yaml 里拼错任何一个 key → 直接 ConfigError
图注(数据结构):Config 这棵 Pydantic 树就是"平台配置的全貌"。每个 section 是一个子模型,都开了 extra="forbid"。
⚖️ 设计取舍③:为什么每个模型都开 extra="forbid"(严格禁止多余字段)? Pydantic 默认是"多写的字段悄悄忽略"。本仓反其道,全部设 extra="forbid"config.py:218 等)。
宽松做法:你把 ratelimit_rpm 手滑写成 ratelimit_rmp → Pydantic 忽略它 → 限速配置静默失效,线上被打爆才发现。
本仓做法(forbid):拼错一个 key 当场 ConfigError 崩在启动阶段(fail-fast)。宁可"起不来",不要"带病运行"。配置是安全边界,越早报错越好。
L05

_deep_merge 逐行 + 一个真实合并例子

load_config 的第 ④ 步 _deep_merge 是配置系统的心脏。它只有 13 行,但决定了"base 和 env 到底怎么拼"。整段贴出来:

真源码 · config.py:255-267 _deep_merge()
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    """deep merge · dict 递归 · list / scalar 走 override 覆盖。"""
    result = dict(base)                      # ① 先拷一份 base 当底
    for key, value in override.items():      # ② 遍历 override 的每个 key
        if (
            key in result
            and isinstance(result[key], dict)
            and isinstance(value, dict)      # ③ 两边都是 dict → 递归往下合并
        ):
            result[key] = _deep_merge(result[key], value)
        else:
            result[key] = value              # ④ 否则(标量/列表/新key) → override 直接盖
    return result
  • ① result = dict(base):以"公共默认"为底稿拷一份,不改原对象。
  • ② for key, value in override.items():只遍历"本环境覆盖"里写了的字段——没写的字段压根不碰,天然保留 base 的值。这就是"env 只写差异"的实现。
  • ③ 两边都是 dict → 递归:像 database.arch_compliance.host 这种嵌套,会一层层钻进去合并,而不是把整个 database 块替换掉。
  • ④ else → override 直接盖:注意 docstring 第一行——标量和 list 都走"整体覆盖",不合并。这个细节是最大的坑,下面 pitfall 专门说。
💡 本质:配置合并 = 公司的报销制度base.yaml 就像公司统一报销总则<APP_ENV>.yaml 就像某部门特批文件,只写"和总则不一样的那几条"。你实际能报多少,是总则打底、部门特批覆盖同名条目后的结果——这正是 _deep_merge 在做的事。

现在用仓库里真实的两个文件走一遍。看数据库那块,base.yaml 给的是 docker 默认,dev.yaml 覆盖成了真实的 RDS 地址:

base.yaml:10-16(公共默认)
database: arch_compliance:  host: ${MYSQL_HOST:-mysql}  port: 3306  user: ${MYSQL_USER:-app}
+
dev.yaml:4-9(只写差异)
database: arch_compliance:  host: bmdevdb...rds.amazonaws.com  user: dev  database: obelisk
=
_deep_merge 结果
host: bmdevdb...(dev 覆盖)port: 3306 (base 保留)user: dev (dev 覆盖)database: obelisk

_deep_merge 当调试器单步走一遍,跟踪 arch_compliance 这个嵌套 dict:

递归层此刻发生什么hostport
顶层 key=database两边都是 dict → 命中 ③ 递归下钻
第 2 层 key=arch_compliance两边还是 dict → 再递归
第 3 层 key=host值是标量 → 走 ④,dev 覆盖 baseRDS 地址
第 3 层 key=portdev 没写这个 key → 循环压根不遍历它 → base 原样保留3306
🚧 边界/易错点①:_deep_merge 对 list 是"整体替换",不是"追加"config.py:256 docstring:list / scalar 走 override 覆盖。举个真会踩的例子——dev.yamlgateway.gitlab_instances 是个列表(两个 gitlab 实例)。如果你以为"base 里有 1 个、dev 里加 1 个就变 2 个",那就错了:只要 dev 写了这个 list,base 的整条 list 被完全丢弃、以 dev 的为准。想保留 base 项,必须在 dev 里把它们也重写一遍。这是深度合并里最反直觉的地方。
🚧 边界/易错点②:APP_ENV 拼错会静默降级到 dev 回看 L04 的 config.py:308-314:目标 profile 文件不存在时,只打一条 warning 就回退到 dev.yaml不报错。所以你把 APP_ENV=prod 手滑成 APP_ENV=prd,程序不会崩——它会安安静静用 dev 配置(本地 RDS、预算宽松)跑起来。生产事故常常就藏在这种"看起来起来了、其实用错配置"里。教训:起服务后一定 log 一下实际生效的 app_env
L06

.env 与 ${VAR} 展开:secret 怎么进配置

配置里怎么放密码?不硬编码进 yaml,而是写占位符 ${VAR},运行时从环境变量取。负责展开的是 _expand_env_vars

真源码 · config.py:236-252 _expand_env_vars() + _VAR_PATTERN
# ${VAR} 或 ${VAR:-default} · 锁 var 名走 UPPER_SNAKE
_VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)(:-(.*?))?\}")

def _expand_env_vars(text: str) -> str:
    def replace(m: re.Match[str]) -> str:
        var = m.group(1)
        default = m.group(3) if m.group(3) is not None else ""
        return os.environ.get(var, default)      # env 有→用 env;没有→用默认;再没有→空串
    return _VAR_PATTERN.sub(replace, text)
  • _VAR_PATTERN 只认 ${大写下划线名},可带 :-默认值。例如 base.yaml:31${REDIS_URL:-redis://redis:6379}
  • replace():env 里有这个变量就用 env 值;没有但写了默认值就用默认;两者都没有就替换成空串(交给后面 Pydantic 决定要不要报错)。
  • 关键顺序(对照 L04 第③步):先展开文本、再 yaml 解析。所以 secret 永远只活在环境变量里,yaml 文件里只有占位符。

还有个旧的 .env 读取器 load_dotenv_if_present,它有个贴心又关键的保证:

真源码 · config.py:83-93 _apply_env_file()
def _apply_env_file(path: Path) -> None:
    """解析 KEY=VALUE 格式,不覆盖已有 env vars。引号去掉,# 当注释。"""
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip(); val = val.strip().strip('"').strip("'")
        if key and key not in os.environ:        # ← 关键:已存在的 env 绝不覆盖
            os.environ[key] = val
⚖️ 设计取舍④:.env 为什么"不覆盖已存在的环境变量"? config.py:92if key not in os.environ 是刻意的。
如果 .env 覆盖一切:你在 K8s / Vault 里注入的真生产密钥,会被镜像里不小心带进去的 .env 文件盖掉 → 生产用错凭据。
本仓做法:真实环境变量优先级高于 .env 文件。.env 只是"本地开发的兜底默认",一旦运维显式设了环境变量,就以运维为准。这让同一份代码在"本地 .env / K8s 注入"两种场景都安全。
只想先跑通、不想花钱?APP_ENV=testtest.yamlllm.provider: mock、redis 关闭、预算拉满,平台就用假大模型,不需要真 key 也能启动、看首页。真调用才需要真 key。
🚧 反面教材:configs 里的明文 secret(Day 01 埋的坑,现在看原文) configs/base.yaml:2 明明写着"secret SHALL NOT hardcode·走 ${VAR}",规范满分。但 configs/dev.yaml:9-12arch_compliancepassword 却是明文写死的真实数据库密码gateway.gitlab_instances 里还有明文 signing_secret。这就是"规范写得漂亮、落地打折扣"的活教材——学习时要能识别:"这违反了它自己 base.yaml:2 的声明,是不该抄的写法。"正确姿势永远是 password: ${MYSQL_PASSWORD}
L07

启动 server + 调用第一个 Agent

平台主进程是 gov-agents-server(FastAPI 服务),按环境变量 ENABLED_AGENTS 决定挂载哪些 agent:

# 方式 A:脚本一键起
./scripts/start_server.sh                       # 默认 8080

# 方式 B:直接 uvicorn(看清底层)
ENABLED_AGENTS=sre-rca,doc-checker \
  uv run uvicorn gov_agents_server.main:app --host 0.0.0.0 --port 8080

它是怎么按名字找到 agent 的?靠一张写死的注册表,不是自动扫目录:

真源码 · packages/gov-agents-server/src/gov_agents_server/main.py:62-68
AGENT_REGISTRY: dict[str, tuple[str, str]] = {
    "sre-rca":      ("sre_rca.server", "build_router"),
    "risk-reviewer":("risk_reviewer.server", "build_router"),
    "alert-triage": ("alert_triage.server", "build_router"),
    "doc-checker":  ("doc_checker.server", "build_router"),
    "biz-link-gov": ("biz_link_gov.server", "build_router"),
    # ...
}
  • 这是个字典:key = agent 名(ENABLED_AGENTS 里写的)value = (模块路径, 工厂函数名)
  • 启动时按名字 importlib.import_module("sre_rca.server") 懒加载,再调它的 build_router(pool) 挂到 FastAPI 上。
  • 不是自动扫描文件夹——加新 agent 必须手动往这张表加一行(main.py:19-22 的注释就是这么教的)。Day 12 细讲这套机制。

server 起来后访问 http://localhost:8080/ 有自带首页;也可用命令行打 HTTP,每个 agent 端点都是 POST /v1/agent/<name>/invoke

调用 doc-checker
curl -s http://localhost:8080/v1/agent/doc-check/invoke \
-H 'X-Anthropic-Api-Key: sk-ant-xxx' \
-d '{"content": "# 上线方案\n本次变更..."}'
 
{ "case_id": "01HXXX...", "agent": "doc-check",
  "status": "success", "result": {...},
  "metrics": {"elapsed_ms": 3200, "estimated_cost_usd": 0.04},
  "trace_id": "..." }

注意这个返回结构——所有 agent 返回的格式都长一样,叫"统一 envelope(信封)":case_id(ULID 唯一 ID)/ status / result(业务结果)/ metrics(耗时/token/花钱)/ trace_id。这套外壳是 toolkit 的 build_v1_router() 自动给每个 agent 装上的(Day 12 精讲)。

👶 小白:为什么每个 agent 都要长成一样的信封?各写各的不省事吗?

👨‍🏫 老师:还记得昨天「盖楼」里的物业前台吗?统一 envelope 就是全楼共用的一张「访客登记表」——不管你按哪户门铃,前台都能用同一张表记下"谁来的(case_id)、成没成功(status)、花了多少(metrics)"。格式统一,前台才能统一计费、审计、限流。

👶 小白:doc-checker 为什么调用路径是 /v1/agent/doc-check/invoke?名字不一样?

👨‍🏫 老师:好眼力。注册名 doc-checker 和挂载 URL 前缀 doc-check 不一定同名,一切以 main.py:62 那张 AGENT_REGISTRY(以及各 agent server.py 里的 prefix)为准。

L08

docker-compose 一键全栈

不想在本机装 Python 依赖?用 Docker。仓库根 docker-compose.yml 定义了本地全栈,关键是它用 APP_ENV 把容器和 L04 的配置系统打通了

真源码 · docker-compose.yml:28-31
    environment:
      # APP_ENV 决定读哪个 yaml profile:dev → configs/dev.yaml,prod → configs/prod.yaml
      APP_ENV: ${APP_ENV:-dev}
      ENABLED_AGENTS: sre-rca,risk-reviewer,alert-triage,doc-checker,biz-link-gov,...
  • APP_ENV: ${APP_ENV:-dev}:compose 把宿主机的 APP_ENV 透传进容器,没设就默认 dev。于是APP_ENV=prod docker compose up 就切成生产配置——同一个镜像,靠环境变量切 profile,正好复用 L04/L06 那套 ${VAR} 展开。
  • ENABLED_AGENTS 这里写死了一长串 agent 名,对应 L07 的注册表 key。
  • docker-compose.yml:33-48 还把 DB_PASSWORDANTHROPIC_API_KEY 等 secret 用 ${VAR:-} 透传——呼应 L06:"secret 只走环境变量,不进镜像"。
服务是什么说明
redisredis:7-alpine,端口 6379Working Memory 断点续跑的后端(Day 09)
platformbuild: .,端口 8080平台主进程,depends_on redis healthy 才起
🚧 又一个文档漂移README 说 compose 有 "PG + Redis + Ollama + platform" 四个服务,但实际 docker-compose.yml:10-58 只有 redis + platform 两个。再次印证 Day 01 的原则:以文件为准,别信文档里的数字
L09

今日小结 + 动手

🧠 今天你应该能回答

  • editable 安装为什么能"改一处、全平台生效"?({ workspace = true } 装的是指向源码的软链,不是拷贝)
  • load_config 的 5 步是什么?(定 profile → 找 configs → 展开 ${VAR} → _deep_merge → Pydantic 校验)
  • _deep_merge 对 dict / list 分别怎么处理?(dict 递归合并;list 和标量整体覆盖)
  • secret 怎么进配置又不泄露?(yaml 写 ${VAR} 占位符,运行时从 env 展开;.env 不覆盖已设 env)
  • server 怎么按名字找 agent?(写死的 AGENT_REGISTRY 字典 + importlib 懒加载,不自动扫目录)

✋ 动手

# 1. 装好全仓(需先装 uv)
uv sync --all-extras --all-packages

# 2. 跑全套测试,感受"几秒跑完、不联网"
uv run pytest -q

# 3. 亲手读今天的核心源码
sed -n '255,267p' packages/ai-trust-toolkit/src/ai_trust_toolkit/config.py   # _deep_merge
sed -n '290,339p' packages/ai-trust-toolkit/src/ai_trust_toolkit/config.py   # load_config

# 4. 对比 base 与 dev 的 database 段,验证 L05 的合并例子
sed -n '7,16p'  configs/base.yaml
sed -n '4,12p'  configs/dev.yaml

# 5.(有 key 的话)起服务、开浏览器
ENABLED_AGENTS=doc-checker uv run uvicorn gov_agents_server.main:app --port 8080
明天预告 · Day 03:平台跑起来了,该搞懂"一个 Agent 内部怎么运转"。Day 03 从零讲 LangGraph,直接读 docs/learn-langgraph/ 的 4 个真 demo,再对照旗舰 sre-rca 的真实 state.pybuilder.py,把 State / Node / Edge / Reducer 焊死在源码上。
← Day 01 项目全景 Day 03 · LangGraph 零基础入门 →