环境搭建 & 跑通第一个 Agent
昨天(Day 01)我们翻源码建好了「盖楼」地图:地基→可信底座→各户业务 Agent→物业前台。今天动手把这栋楼真正通水通电——搞懂 uv workspace 怎么把 31 个包串成一个整体(editable 原理),再逐行读 config.py 的 load_config 和 _deep_merge,用一个真实的 base.yaml + dev.yaml 合并例子把配置系统焊死,最后把平台跑起来调第一个 Agent。
_deep_merge 合并);启动 server = 打开楼下大门总闸;调用一个 Agent = 你按了某一户门铃看它应答。下面 9 讲就是这四步,而且每一步都落到真源码。今天的目标
Day 01 我们建立了"地图"。今天把地图落到能跑的环境上,并且读几段真源码。学完你能:
- 讲清 uv workspace + editable 安装为什么能做到"改一处、全平台生效";
- 读懂
config.py里load_config()的完整 5 步流水线,和_deep_merge()的递归逻辑; - 亲手走一个 真实的 base.yaml + dev.yaml 合并,预测出最终
arch_compliance.host的值; - 把
gov-agents-server跑起来,用统一 envelope 调一个 Agent。
monorepo、uv workspace 与 editable 原理
monorepo(单体仓库)就是"把很多相关的包放在同一个 git 仓库里"。本项目就是——一个仓库里同时住着 toolkit、21 个 agent、server、cli、前端门户……它们互相依赖。
ai-trust-toolkit 目录 pip install .,再进 gov-agents-server 装一遍,再进 21 个 agent 各装一遍……而且改了 toolkit 一行源码,还得把它重新打包、其它包重新安装才生效。31 个包手动 pip install,装到怀疑人生,还极易装出「A 包要 pydantic 2.5、B 包锁死 2.7」这种打架版本。uv.lock),保证 3 楼和 18 楼水压一致(版本一致);哪户想换个灯泡(改某个包源码),全楼立刻亮新灯——因为是 editable 安装,装的是「指向源码的软链」而不是拷贝。Day 01 我们已经看过成员清单。今天补上那把"钥匙"——pyproject.toml:44-53 的 [tool.uv.sources],它才是 editable 生效的关键:
[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 时读的就是新源码,无需重装。
pip install .:把 toolkit 源码拷贝进 site-packages。改了源码 → 拷贝的还是旧的 → 必须重新打包 + 逐个重装 21 个 agent 才生效。editable(workspace = true):装的是指向源码目录的软链。改一行立刻全平台生效,正好配合 Day 01 讲的"渐进抽象"——公共逻辑一下沉,所有 agent 当场吃到、当场被测试覆盖。代价:不能在开发机上同时装两个不同版本的 toolkit(但 monorepo 本就只要一个版本),可接受。
一条命令装好全仓
装好 uv 后(curl -LsSf https://astral.sh/uv/install.sh | sh),在仓库根目录只需要一条命令。这条命令和参数来自 README.md:140-141:
参数解释:--all-packages = 装所有 31 个 workspace 成员;--all-extras = 连每个包的可选依赖(如 [postgres]、[observability])也一起装。装完你可以直接 uv run pytest 跑全套测试——因为测试全部走 FakeLLM(假大模型),不联网、不花钱、几秒跑完。
get_llm() —— 永远不要直接 import anthropic,否则测试要联网"。朴素做法:测试里直接调 Claude → 每跑一次 CI 花钱、要网、结果还不确定(模型每次输出不同,断言没法写)。
本仓做法:业务从不直连大模型,全走注入点
get_llm();测试时把它换成返回固定答案的 FakeLLM。收益:测试可复现、零成本、零外网、几秒全绿。这套注入机制是 Day 11 的主角。配置系统:逐行读 load_config 源码
平台要在本地、测试、预发、生产等不同环境跑,每个环境的数据库地址、预算上限都不同。它用一套"基础配置 + 环境覆盖"的 profile 系统解决,文件在 configs/(base/dev/staging/prod/test.yaml 五个)。核心加载器就一个函数——我们把它整段贴出来读:
@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)。找不到就 raiseConfigError,不静默。 - ③ _expand_env_vars(read_text(...)):先把 yaml 文本里的
${VAR}占位符换成真环境变量,再喂给yaml.safe_load。顺序很关键——先展开、后解析。(L06 细讲) - ④ _deep_merge(base_data, env_data):把"公共默认"和"本环境覆盖"逐字段合并。这是全函数的心脏,L05 逐行拆。
- ⑤ Config(**merged):交给 Pydantic 校验成类型安全的对象。字段名/类型不对当场报错。
返回类型 Config 是一个 Pydantic 模型,它的字段结构就是"平台一共有哪些配置块"的数据字典:
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)
extra="forbid"(config.py:218 等)。宽松做法:你把
ratelimit_rpm 手滑写成 ratelimit_rmp → Pydantic 忽略它 → 限速配置静默失效,线上被打爆才发现。本仓做法(forbid):拼错一个 key 当场
ConfigError 崩在启动阶段(fail-fast)。宁可"起不来",不要"带病运行"。配置是安全边界,越早报错越好。_deep_merge 逐行 + 一个真实合并例子
load_config 的第 ④ 步 _deep_merge 是配置系统的心脏。它只有 13 行,但决定了"base 和 env 到底怎么拼"。整段贴出来:
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:
| 递归层 | 此刻发生什么 | host | port |
|---|---|---|---|
顶层 key=database | 两边都是 dict → 命中 ③ 递归下钻 | — | — |
第 2 层 key=arch_compliance | 两边还是 dict → 再递归 | — | — |
第 3 层 key=host | 值是标量 → 走 ④,dev 覆盖 base | RDS 地址 | — |
第 3 层 key=port | dev 没写这个 key → 循环压根不遍历它 → base 原样保留 | — | 3306 |
list / scalar 走 override 覆盖。举个真会踩的例子——dev.yaml 的 gateway.gitlab_instances 是个列表(两个 gitlab 实例)。如果你以为"base 里有 1 个、dev 里加 1 个就变 2 个",那就错了:只要 dev 写了这个 list,base 的整条 list 被完全丢弃、以 dev 的为准。想保留 base 项,必须在 dev 里把它们也重写一遍。这是深度合并里最反直觉的地方。warning 就回退到 dev.yaml,不报错。所以你把 APP_ENV=prod 手滑成 APP_ENV=prd,程序不会崩——它会安安静静用 dev 配置(本地 RDS、预算宽松)跑起来。生产事故常常就藏在这种"看起来起来了、其实用错配置"里。教训:起服务后一定 log 一下实际生效的 app_env。.env 与 ${VAR} 展开:secret 怎么进配置
配置里怎么放密码?不硬编码进 yaml,而是写占位符 ${VAR},运行时从环境变量取。负责展开的是 _expand_env_vars:
# ${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,它有个贴心又关键的保证:
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
if key not in os.environ 是刻意的。如果 .env 覆盖一切:你在 K8s / Vault 里注入的真生产密钥,会被镜像里不小心带进去的
.env 文件盖掉 → 生产用错凭据。本仓做法:真实环境变量优先级高于 .env 文件。
.env 只是"本地开发的兜底默认",一旦运维显式设了环境变量,就以运维为准。这让同一份代码在"本地 .env / K8s 注入"两种场景都安全。APP_ENV=test,test.yaml 里 llm.provider: mock、redis 关闭、预算拉满,平台就用假大模型,不需要真 key 也能启动、看首页。真调用才需要真 key。${VAR}",规范满分。但 configs/dev.yaml:9-12 里 arch_compliance 的 password 却是明文写死的真实数据库密码,gateway.gitlab_instances 里还有明文 signing_secret。这就是"规范写得漂亮、落地打折扣"的活教材——学习时要能识别:"这违反了它自己 base.yaml:2 的声明,是不该抄的写法。"正确姿势永远是 password: ${MYSQL_PASSWORD}。启动 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 的?靠一张写死的注册表,不是自动扫目录:
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:
注意这个返回结构——所有 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)为准。
docker-compose 一键全栈
不想在本机装 Python 依赖?用 Docker。仓库根 docker-compose.yml 定义了本地全栈,关键是它用 APP_ENV 把容器和 L04 的配置系统打通了:
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_PASSWORD、ANTHROPIC_API_KEY等 secret 用${VAR:-}透传——呼应 L06:"secret 只走环境变量,不进镜像"。
| 服务 | 是什么 | 说明 |
|---|---|---|
redis | redis:7-alpine,端口 6379 | Working Memory 断点续跑的后端(Day 09) |
platform | build: .,端口 8080 | 平台主进程,depends_on redis healthy 才起 |
README 说 compose 有 "PG + Redis + Ollama + platform" 四个服务,但实际 docker-compose.yml:10-58 只有 redis + platform 两个。再次印证 Day 01 的原则:以文件为准,别信文档里的数字。今日小结 + 动手
🧠 今天你应该能回答
- 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
docs/learn-langgraph/ 的 4 个真 demo,再对照旗舰 sre-rca 的真实 state.py 和 builder.py,把 State / Node / Edge / Reducer 焊死在源码上。