OpenAI-Codex-CLI-智能体编程实战指南-五-
OpenAI Codex CLI 智能体编程实战指南(五)
原文:Agentic Coding with OpenAI Codex CLI
译者:飞龙
请注意其中缺失的内容:风格规则、导入顺序、格式化。这些属于你的 linter 配置。将它们混入审查指南会降低信噪比,并训练审查者(无论是人类还是 AI)跳过发现问题。
对于具有特定合规义务的包,在不修改根配置的情况下分层添加额外规则:
<!-- payments/AGENTS.md -->
#### Review guidelines
- Flag any hardcoded card numbers, CVVs, or full PANs as P0 immediately.
- All payment amounts must use the Money type from `lib/money`. Flag raw floating-point arithmetic on monetary values as P0.
- Every new payment flow must have a corresponding integration test. Flag additions to `payments/flows/` without a matching test file as P1.
人机协作审查模式
目标不是取代人工审查,而是使其更有效。三种模式构建了交接流程。
AI 首轮审查,人工裁决争议。 Codex CLI 在 PR 打开时自动审查。人工审查者阅读 AI 发现的问题,将其标记为已解决或有争议,然后在 AI 标记的问题已标注的情况下审查差异。这压缩了审查周期:审查者不再需要从零开始发现问题。有争议的发现是宝贵的数据:将解决方案添加到 AGENTS.md 中,以便未来的审查能更好地校准。
按关注点分离审查轮次。 人工审查者擅长架构意图;AI 擅长机械正确性。并行运行它们。人工专注于“这是否是要构建的正确事物?”,而 Codex CLI 处理“这是否构建正确?”。当审查者稀缺时,此模式效果最佳:它防止机械性审查工作消耗有限的人工注意力。
审查即对话。 在交互式会话中,/review 是起点,而非定论。首轮审查后,继续追问:“关于第 84 行缺少认证检查的发现,展示一个不需要重构处理程序的最简单修复方案。” Codex CLI 提出针对性补丁。你应用它,在未提交的更改上再次运行 /review,并迭代。审查变成了一个增量改进循环,而非阻塞式关卡。
当人工审查者和 AI 对某个发现存在分歧时,记录原因。如果 AI 错了,在 AGENTS.md 中添加说明例外的注释,以便未来审查不重复该误报。如果 AI 是对的但人工覆盖了它,追踪这些案例。某一特定类别上出现覆盖模式,通常意味着该类别需要专门讨论,而非逐个 PR 驳回。
审查-修复循环:三级成熟度模型
一个更深层的问题是:在人类看到代码之前,有多少审查-修复循环是自主发生的?团队分阶段采用自动化审查;理解这一演进有助于你规划基础设施投资,避免过早过度设计。
Level 1:人工审查
Agent 生成代码;人工使用 /review 或在 PR 中阅读差异进行手动审查。这是大多数团队的起点。Agent 是生产力工具,代码质量仍取决于审查者的关注度和专业知识。没有自动化反馈循环:每个问题都需要人工去发现、请求修复并验证解决。
Level 2:自主止损钩子
审查在代码离开 Agent 会话前自动发生。Codex CLI 的 PostToolUse 钩子在每次修改文件的工具调用后触发审查。一个独立的 codex exec 进程充当审查关卡,根据 AGENTS.md 指南和项目的审查 Schema 检查输出,阻止包含 P0 发现的提交,而无需等待人工。
Agent 编写代码,审查关卡检查它,若标记出问题则 Agent 尝试修复,所有这些都在同一会话内完成。人类看到的代码已通过自动化关卡。第 9 章 的钩子基础设施使这变得简单:
{
"hooks": {
"PostToolUse": [
{
"matcher": { "tool_name": "apply_patch" },
"command": "codex exec --sandbox=read-only --output-schema review-schema.json 'Review the last patch for P0 issues'",
"timeout_ms": 30000
}
]
}
}
钩子在每次 apply_patch 调用后,以只读模式调用独立的 codex exec 进程。若审查返回 P0 发现,钩子的输出会注入一条系统消息,指示会话在继续前修复该问题。将审查者与生成者分离是关键设计决策:审查进程没有动机为自己未编写的代码辩护。
Level 3:多 AI 治理
Level 3 在审查关卡钩子之上增加了独立的跨提供商审查。两个插件使这变得切实可行。
CodeRabbit 作为 Codex 插件提供,在 Agent 会话内提供独立的第三方代码审查。Agent 在工作流中调用 CodeRabbit,获得应用 CodeRabbit 自身审查规则、安全检查和模式检测的视角。因为它维护自己的模型和规则引擎,能捕捉 Codex 原生审查者可能系统性遗漏的问题。
Codex plugin for Claude Code 通过在 Claude Code 会话中运行 Codex,实现跨提供商审查,暴露 /codex:review 和 /codex:adversarial-review 等命令。在同一会话中结合两个提供商,能捕捉源于共享训练数据或相似架构的盲点。对抗式 CI 模式从并行运行中合并发现,而此插件将跨提供商审查带入 Agent 会话本身。
在 Level 3,单个会话调用三个独立审查者:PostToolUse 审查钩子(同提供商,只读沙箱)、CodeRabbit(第三方引擎)和用于 Claude Code 的跨提供商 Codex 插件。审查钩子擅长 AGENTS.md 中的项目特定约定。CodeRabbit 应用广泛的安全和正确性规则。跨提供商配对基于不同的模型架构,识别同提供商模型共享的逻辑和架构盲点。
多 AI 治理增加了延迟和成本。在 Level 3,每个值得提交的更改在人类看到前都要通过三个审查系统。这适用于安全敏感库和受监管代码库。对于内部工具或早期原型,Level 2 以极小的开销提供了大部分安全收益。根据风险概况匹配级别,并随着项目成熟重新评估选择。
总结
-
AI 代码审查可靠地捕捉安全反模式、正确性问题以及编码在
AGENTS.md中的自定义团队约定。它不替代人类在架构、意图或机构权衡上的判断。表 20-1 总结了按项目类型推荐的配置。 -
交互式会话中的
/review命令提供四种模式(分支比较、未提交变更、提交审查、自定义指令),始终为只读,并遵循会话范围内的AGENTS.md指南。
3. AGENTS.md 文件中的 ## Review guidelines 可将您团队的标准自动传播到每一个审查界面。在目录树深处为敏感包分层制定专用规则。
4. 校准严格度:将 AI 审查限制在您的代码检查工具未覆盖的类别上,并将自动 PR 评论保持在 P0 和 P1 级别,以维护信号质量。
5. 使用 sandbox: read-only 的 openai/codex-action 可为您提供结构化的 JSON 输出,并在任何 SCM 平台上实现 CI 级别的审查。优先级值从 0(关键)到 3(建议);在 P1 级别过滤以用于 PR 评论。
6. 使用 codex exec --sandbox=read-only 的预提交钩子可在 P0 级别发现到达远程端之前将其拦截,提供快速的本地反馈,无需等待 CI。
7. 人机协作模式(AI 首轮审查、按关注点并行审查、将审查视为对话)让人类判断保持在回路中,同时将其集中于只有人类能做出的决策上。
8. 三级审查-修复循环成熟度模型从手动审查(第 1 级),进阶到带有 PostToolUse 审查门禁的自主停止钩子(第 2 级),再到结合 CodeRabbit、审查门禁钩子以及通过 Codex 插件实现的 Claude Code 跨提供商审查的多 AI 治理(第 3 级)。根据项目的风险概况选择匹配的级别。
9. 代理 PR 信任赤字。 对 23,247 个 PR 的研究显示,1.7% 的代理 PR 存在高消息-代码不一致性,导致接受率降低 51.7%,合并时间延长 3.5 倍。CodeRabbit 数据显示,AI PR 在逻辑、质量、安全性和可读性类别中包含的问题多 1.7 倍。请使用 codex exec --output-schema 从差异而非计划生成 PR 描述,并使用 PostToolUse 钩子验证描述与代码的一致性。
10. Pull Request 中的代理指纹。 从业者观察表明,AI 生成的代码会留下可检测的模式(特征性的变量命名、一致的格式化选择、特定的习语偏好、独特的注释风格),通常可在 Pull Request 级别识别出来。^(¹⁹⁰) 对于专业团队,这有两层含义。首先,Git 卫生:代理撰写的提交应明确归属(例如通过 Co-Authored-By 尾行),而非混入人类提交历史,这既关乎知识诚实,也关乎审计追踪的完整性。其次,审查校准:知道变更由代理撰写的审查者,可将关注点调整至代理持续表现不佳的类别(架构权衡、边缘情况推理、设计选择的安全隐患)。
练习
1. 预提交钩子设置。
编写一个 .git/hooks/pre-commit 脚本,在暂存的变更上以只读模式运行 codex exec。如果返回任何 P0 级别发现,该钩子应阻止提交,并将发现标题打印到 stderr。通过暂存一个包含硬编码密码的文件来测试它。
2. AGENTS.md 审查指南。
选择一个您正在参与的真实项目。确定您的代码审查历史中反复出现但现有代码检查工具未捕获的三类问题。编写一个 AGENTS.md ## Review guidelines 部分来编码这些规则,并为每类问题包含适当的优先级。
3. codex-action 工作流。
向仓库添加一个 GitHub Actions 工作流,在每个 PR 上运行 Codex 审查。仅将 P0 级别发现作为阻塞性审查评论发布,将 P1 级别发现作为非阻塞性建议发布。在测试 PR 上验证工作流运行正确。
4. 校准审计。
在您代码库的近期 PR 上运行 /review,并将其发现与实际的人工审查评论进行比较。将每个发现分类为真阳性、假阳性或漏报(人工审查中存在但 AI 审查中缺失)。利用假阳性改进您的 AGENTS.md 指南,利用漏报识别值得添加的类别。
5. 协作模式设计。
为共享高流量仓库(每周 20+ 个 PR)的四人工程团队设计一个审查工作流。指定哪个审查界面处理哪类问题,如何对 AI 发现进行分诊,以及有争议发现的升级路径是什么。将其写成一份适合团队 Wiki 的单页工程流程文档。
6. 多 AI 治理设置。
为一个小型安全敏感服务配置第 3 级审查流水线。安装 CodeRabbit 插件和用于 Claude Code 的 Codex 插件,使用 codex exec --sandbox=read-only 配置 PostToolUse 审查钩子,并在一个包含故意 SQL 注入漏洞和架构反模式的测试提交上运行这三个审查者。比较哪个审查者捕获了哪个问题,并记录重叠部分。
表 20-1. 按项目类型划分的审查配置
| 项目类型 | 推荐界面 | AGENTS.md 优先级 | 严格度阈值 | 人工审查重点 |
| --- | --- | --- | --- | --- |
| 公共 API 服务 | 预提交 + 自动 PR 审查 | P0:认证绕过、注入、数据泄露。P1:缺失输入验证、未记录的端点 | P0 阻止提交;P1 合并前必需 | API 契约正确性、向后兼容性 |
| 内部工具 | 交互式 /review + 敏感 PR 上的手动 @codex review | P0:凭据泄露、无确认的破坏性操作。P1:缺失错误处理 | P0 仅建议;无自动合并门禁 | 易用性、运维安全 |
| 数据管道 | 自动 PR 审查 + CI codex-action | P0:无迁移的 Schema 变更、日志中的 PII。P1:外部数据缺失空值检查、硬编码批次大小 | P0 阻止 CI;P1 以评论形式浮现 | 数据质量契约、上下游影响 |
| 前端应用 | 预提交 + 自动 PR 审查 | P0:XSS 攻击向量、令牌不安全存储。P1:无障碍倒退、缺失加载/错误状态 | P0 阻止提交;P1 合并前审查 | UX 一致性、交互设计 |
| 安全敏感库 | 预提交 + 自动 PR 审查 + 手动专家审查 | P0:包含其他项目类型的所有类别及加密误用。P1:任何偏离既定模式的情况 | P0 阻止提交和 CI;P1 需显式签署 | 威胁模型一致性、侧信道暴露 |
| 遗留现代化 | 重构期间的交互式 /review | P0:破坏行为的变更、丢失的错误路径。P1:重新引入遗留反模式的新代码 | P0 阻止提交;P1 记录以便追踪 | 与原始行为等价、增量范围 |
第二十一章:代码库迁移与现代化
手动进行迁移工作既繁琐又容易出错。您重命名一个文件,却忘记了三个目录外的一个引用,结果花费一个下午来理清后果。大规模迁移需要 Codex CLI 为特性开发和代码审查带来的那种持续、多步、自主工作的能力。智能工具不会疲劳,不会忘记测试文件中的导入,并能在您审查计划时并行跨数十个模块运行。本章涵盖为什么迁移是 Codex CLI 的高杠杆应用,如何在动手前理解遗留系统,如何构建迁移以使代理保持正轨,以及如何验证结果——从单个批次到单体分解,再到整个语言的重写。
学习目标
您将能够:
-
运行一次只读发现阶段,在更改遗留系统之前生成现代化报告和风险登记册
-
运行一次 Codex CLI 规划会话,为真实代码库生成一个经过优先级排序、验证过的迁移计划
-
实现一个批次验证循环,阻止前向推进,直到当前批次通过其测试套件
-
使用耦合分析、契约优先提取和功能标志路由来分解单体
分层验证大规模重写(对等性、合理性、惯用法、安全性、性能),而不仅仅依赖测试
为什么 Codex CLI 擅长迁移工作
迁移工作具有四个特性,使其非常适合代理工具。
机械且定义明确
迁移由明确的转换规则定义:将 React.Component 替换为函数组件,将 CLAUDE.md 重命名为 AGENTS.md,将每个 print 语句更新为 print()。代理在大面积代码库中一致地应用已知规则。模式匹配是优势;创造性即兴发挥则是隐患。精心编写的迁移提示词能约束创造力:“应用此转换;不要重构其他任何内容。”
范围明确
迁移有天然的工作单元:一个文件、一个模块、一个包。该单元完美映射到 Codex CLI 会话。批量处理二十个文件,运行代理,运行测试,提交,继续下一批。有限的范围使上下文窗口保持可控,并为审查和回滚提供了天然的检查点。
可测试
几乎所有有意义的迁移都可以通过运行现有测试套件来验证。如果每批迁移前后测试均通过,则说明转换保留了行为。此原则支撑着下文的验证策略。
可并行化
当模块间不共享可变状态时,可同时迁移它们。Codex CLI 子代理(见第 11 章) 使此具体化:每包生成一个代理,收集结果,验证,合并。两周细致的串行工作压缩为数小时的并行执行,后接聚焦的人工审查。
迁移极少需要新颖性。你决定如何迁移;代理大规模执行且不遗漏边缘情况。
先理解遗留系统
破坏遗留系统最快的方式,是在弄清它实际做什么之前就开始修改。文档会撒谎,注释会腐烂,而真正重要的行为往往是某下游系统现依赖的未文档化怪癖。在任何转换前,先花一个会话进行纯发现,让代理处于只读模式,以便它在学习时无法修改任何东西。
将 Codex CLI 指向代码库并使用 -s read-only,要求其生成现代化报告:用通俗语言描述系统功能,按模块记录实际行为而非设计意图,列出包含真实字段类型的数据模型,并编制技术风险登记册。风险登记册是最有价值的部分。明确要求列出重写易搞错的关键怪癖:日期与时区处理、数值舍入与精度、字符编码、隐式排序、全局可变状态。测试套件极少捕获这些行为,天真重写会悄无声息地改变它们。
codex -s read-only "读取此服务并生成现代化报告:
1. 它的业务功能是什么,面向从未见过它的人
2. 按模块行为:每个模块实际做什么,而非其名称暗示什么
3. 数据模型:每个持久化字段、其真实类型及其不变量
4. 风险登记册:日期与时区处理、舍入、编码、排序、全局状态,
以及每项所在的文件和行号"
在此阶段运行现有测试,即使你不信任它们。通过的套件告诉你哪些行为被钉死;单薄的套件告诉你在哪里盲目飞行,需在动手前添加特性化测试。
将所学记录在代理会再次读取的位置:仓库根目录下的 AGENTS.md(见第 4 章)。记录约定、可安全修改的模块、不可修改的模块,以及风险登记册中的怪癖。这将部落知识转化为代理每次会话重读的持久指令,使理解在上下文压缩中存活,而非在第十二批次中被重新学习或遗忘。此处也应放置一条直白的安全规则,例如:未经明确批准,不得修改 src/legacy/billing/ 下的文件。
对于你真正不理解的系统,要求一张图。Codex CLI 可输出模块、数据存储和外部依赖的 Mermaid 架构图,直接在拉取请求中渲染,在第一行代码变更前给团队一张共享地图。
codex --profile read-only "将此服务绘制为 Mermaid 流程图。
展示顶层模块、它们读写的数据存储,及任何外部依赖(HTTP API、队列、第三方 SDK)。
标注每条边上的流向。仅输出单个围栏式 mermaid 代码块。"
代理读取的是代码而非 README,所以图表反映系统实际行为。典型结果如下:
将该代码块粘贴到拉取请求描述或议题中,GitHub 会将其作为图表内联渲染,无需任何工具。视其为待修正的草稿而非绝对真理:代理从导入和调用点推断图谱,可能遗漏通过动态配置或配置连接的依赖。与了解系统的人一起阅读它,往往是迁移计划中第一个真实缺口浮现的地方。
使用 Codex CLI 规划迁移
最差的开始方式是将代理指向代码库并说“迁移所有东西。”代理缺乏关于哪些模块高风险、哪些测试存在、哪些文件是生成而非手写的上下文。
从只读模式的规划会话开始(sandbox_mode = "read-only"),要求代理在任何更改前分析代码库。
codex --profile read-only "分析此代码库以进行 React 类组件到 Hooks 的迁移。
生成包含以下章节的迁移计划:
1. 文件清单:包含类组件的每个文件,附带行数和测试覆盖率标记
2. 依赖图:哪些组件继承自其他组件,哪些组件在多少处被使用
3. 复杂度分级:高/中/低,附带你的判据
4. 建议批次顺序:按什么顺序迁移哪些文件,从叶子节点开始
5. 风险登记册:转换模棱两可或需要人工决策的任何文件"
该单一提示词生成一份可在任何代码变更前审查的文档。它在棘手情况变成 Bug 前将其暴露:使用 getSnapshotBeforeUpdate 的类组件,或调用 API 不兼容的第三方库的组件。
将迁移计划保存为仓库根目录下的 MIGRATION_PLAN.md 并在任何转换工作开始前提交。它作为范围的权威记录,并可在批次完成时更新作为进度追踪器。
规划会话还验证代理的理解。如果清单遗漏一半组件或包含生成文件,你会在代码变更前发现。
编码转换规则
对计划满意后,将转换规则编码为技能。技能使规则在每个批次中保持一致,防止代理在第十二批次以不同于第一批次的方式解读提示词。
name: react-class-to-hooks
description: 将 React 类组件文件迁移为带有 Hooks 的函数组件
allowed-tools:
-
Read
-
Edit
-
Bash
将 $ARGUMENTS 处的文件从 React 类组件迁移为函数组件。
规则:
-
将 state 转换为
useStateHooks -
转换生命周期方法:
componentDidMount→useEffect(fn, []),componentDidUpdate→ 带有依赖项的useEffect,componentWillUnmount→useEffect清理函数 -
不要转换使用
getSnapshotBeforeUpdate的组件——在注释中标记它们并保持原样 -
不要重构与组件生命周期无关的逻辑
-
完全保留所有 prop types 和默认 props
-
编辑后,运行:
npx tsc --noEmit src/$ARGUMENTS以验证类型正确性
将此保存到 .agents/skills/react-class-to-hooks/SKILL.md。每个批次会话调用 /react-class-to-hooks src/components/Button.tsx,而不是重新解释规则。
增量迁移模式
表 21-1 总结了涵盖大多数现实场景的三种模式。你的选择取决于模块耦合度以及你对新旧代码共存期的容忍度。
逐文件 是最简单的。将每个文件作为一个单独的会话处理,运行测试,通过后提交。速度较慢但可审计性最高:每次提交对应一个转换后的文件,二分查找总能定位到具体的变更。
逐模块 将一个连贯单元(一个包、一个目录、一个功能切片)批量放入单个会话。代理一次性看到模块中的所有文件,有助于在无需猜测的情况下解决跨文件引用。权衡是:失败的批次更难二分查找。当文件紧密耦合且代理需要读取多个文件才能安全转换任意一个时使用此模式。
绞杀者模式 适用于无法一次性迁移所有内容的场景,因为代码库太大、迁移破坏性太强,或业务无法接受功能冻结。^(¹⁹¹) 在旧实现旁引入新实现,将新代码路由到新实现,并逐步淘汰旧路径。Codex CLI 在每个阶段都能提供帮助:生成新实现、更新调用点、审计剩余用法。
并行子代理 在时间紧迫且模块真正独立时,同时运行多个独立模块。
表 21-1. 迁移模式决策指南
| 模式 | 适用场景 | 批次大小 | 回滚成本 |
| --- | --- | --- | --- |
| 逐文件 | 低耦合,高可审计性要求 | 1 个文件 | 最小;还原一次提交 |
| 逐模块 | 高模块内耦合,清晰的模块边界 | 5 到 30 个文件 | 中等;还原一个批次 |
| 绞杀者模式 | 大型代码库,无法冻结功能开发,或可接受部分迁移 | 持续进行 | 低;旧路径始终可用 |
| 并行子代理 | 独立模块,时间压力 | 多个模块同时进行 | 中等;合并前验证 |
绞杀者模式的名字源自绞杀榕,它环绕寄主树生长,最终完全取代寄主,而寄主在过渡期提供结构支撑。Martin Fowler 在 2004 年的一篇文章^(¹⁹²)中推广了该术语;它仍然是软件工程中最有用的迁移隐喻之一。
图 21.1 - 选择迁移模式
用于选择增量迁移模式的决策树。第一个决策询问代码库是否可以冻结功能开发;如果不能,选择绞杀者模式。如果可以,下一个决策询问模块是否独立且面临时间压力;如果是,使用并行子代理。否则,最终根据高模块内耦合决定:耦合高则走向逐模块,追求最大可审计性则走向逐文件。
迁移过程中的验证策略
核心原则:在前一批次通过测试之前,下一批次不得进行。跳过这条规则,让代理在运行测试套件前迁移十个模块,你就失去了将失败归因于特定变更的能力。迁移就会变成那种已毁掉不止几个工程团队的“大爆炸式”重写。
验证循环如下所示:
#!/usr/bin/env bash
#### migrate-batch.sh — 运行迁移批次并以测试为门控
set -euo pipefail
BATCH_FILE="$1" # 列出本批次要迁移路径的文件
while IFS= read -r filepath; do
echo "迁移中: $filepath"
codex --profile migration "/react-class-to-hooks $filepath"
done < "$BATCH_FILE"
echo "运行测试套件..."
npm test -- --passWithNoTests
if [ $? -eq 0 ]; then
git add -p
git commit -m "chore: migrate batch $(basename "$BATCH_FILE" .txt) to hooks"
echo "批次已提交。"
else
echo "测试失败。回滚批次。"
git checkout -- .
exit 1
fi
有三个细节值得注意。脚本使用 git checkout -- . 进行回滚;如果你想在丢弃前检查失败状态,请改用 git stash。它以 npm test 为门控,所以你需要在开始前拥有一个全绿的测试套件:预先存在的失败会使新的回归变得不可见。它在批次边界而非文件边界提交;调整粒度以匹配你的可审计性需求。
不要在测试套件失败的代码库上运行迁移批次。你需要一个绿色基线来检测回归。如果套件不全绿,先修复预先存在的失败并单独提交该修复,以便清晰地归因。
对于类型检查的代码库,在测试套件前添加类型检查。TypeScript 迁移常产生通过运行时测试但无法通过编译器的代码:
npx tsc --noEmit && npm test
对于有 lint 的代码库,添加 linter。匹配你的 CI 流水线强制执行的相同门控;如果 CI 能捕获,你的本地循环应先捕获。
快照测试与迁移安全
快照测试值得特别关注。捕获渲染 HTML 的快照在类转 Hooks 迁移后会失败,即使行为完全相同,因为内部表示发生了变化。在开始前决定是自动更新快照 (jest --updateSnapshot) 还是将失败视为阻断项。答案取决于你的快照是行为契约还是附带的实现工件。在迁移计划中明确这一区别。
框架与语言版本迁移
同样的模式适用于框架和语言版本迁移,以下三例说明这一点。
React 类组件到 Hooks。 本章的激励示例。转换规则定义明确,测试套件提供强有力的验证,叶子优先的批次顺序使工作易于管理。主要的人工决策是处理没有直接 Hooks 等价物的生命周期方法(getSnapshotBeforeUpdate、getDerivedStateFromError)。代理会标记这些而不是猜测;上述技能定义编码了该约束。
Python 2 到 Python 3。 2to3 工具^(¹⁹³) 处理机械转换。Codex CLI 在周边工作中增值:分析哪些包缺乏 Python 3 支持、生成更新后的 requirements.txt 条目、更新 CI 配置,并重写 2to3 处理不当或过于保守的惯用语。先运行 2to3,然后用 Codex CLI 处理残留情况。使用 python -m pytest 验证,对于有类型的代码库,使用 mypy。
从 AngularJS 迁移到 Angular
从 AngularJS 迁移到 Angular。 由于两个框架在架构上不兼容^(¹⁹⁴),因此必须采用“绞杀无花果”迁移模式;大规模重写一个庞大的 AngularJS 应用程序极少可行。智能体为每个 AngularJS 组件生成 Angular 的等效实现,更新路由器以将新 URL 导向 Angular,而旧版 URL 保留在 AngularJS 上,并审核旧版代码库以查找剩余依赖项。验证较少依赖单元测试(旧版 AngularJS 应用程序往往缺乏测试),而更多依赖针对正在运行的应用程序进行的端到端测试。请在开始前投资构建这些测试。
对于语言版本迁移,请将目标版本的静态分析工具(如 pylint、tsc --strict 或 eslint)作为验证循环的一部分运行。这些工具能捕获一类完整的错误,而仅测试“快乐路径”的测试套件会漏掉这些错误。
共同点在于:智能体处理规模与一致性;你处理歧义与判断。定义规则,审查标记出的案例,做出规则无法处理的决策。
解构单体架构
将单体架构拆分为服务是“绞杀无花果”模式在最大规模上的应用,同样的原则适用:先理解,再增量提取,并保持旧路径存活,直到新路径被证明可行。Codex CLI 在人类觉得繁琐的两端帮助最大:寻找接缝和证明一致性;在中间环节帮助最小,那里需要你的架构判断。
通过依赖分析寻找接缝
通过依赖分析寻找接缝。 让智能体以只读模式映射模块级依赖,并按耦合度对候选服务进行排序:入站和出站依赖计数、共享数据表以及任何循环依赖。
最佳试点是低耦合且具有清晰数据边界的模块,而不是导致痛苦最多的模块。一个读写另外三个模块也会触及的表的模块,不是服务边界;它是个陷阱。数据库所有权是最严格的测试:如果两个候选服务都需要写入同一张表,那么边界划分就是错的。
先定义契约,后实现代码
先定义契约,后实现代码。 让智能体在编写任何服务代码之前,先生成被提取服务的 API 契约——一份 OpenAPI 文档。可审查的契约能在错误代价最低时捕获边界错误,并成为实现代码及其测试所依据的规范。这是本章前面提到的规划原则在服务层面的应用:契约即计划。
通过可切换的接缝进行路由
通过可切换的接缝进行路由。 在路由层(API 网关或反向代理)后提取服务,该层将试点的端点发送给新服务,将其他所有内容发送给单体架构。将开关置于功能标志后面,以便你可以切换一小部分流量过去,观察其表现,并能在无需重新部署的情况下即时回滚。在新服务赢得流量之前,单体架构仍是事实来源。
验证原则不变;只是强度升级。每个被提取的端点在接管真实流量前,必须针对新服务运行并通过单体架构现有的集成测试。解构增加了活动部件;测试关卡正是阻止活动部件增加事故的关键。
超越测试通过:验证大规模重写
对于迁移到新语言或平台的场景,测试套件全部通过是必要但不充分的条件。智能体可以忠实地复现行为,却仍可能让目标代码库充斥着“逃生舱口”:破坏类型系统的类型转换、破坏借用检查器的 unsafe 代码块、破坏失败模型的被吞没的错误。测试通过是因为行为正确;代码不健全是因为翻译过于字面化。大规模机器辅助重写的公开案例清晰地展示了这一模式:一个几乎逐行复现源代码的重写发布了数千个 unsafe 代码块,而一个为目标语言重新设计的同类工具则将其控制在极少数。^(¹⁹⁵) 测试同样通过,代码却截然不同。
这种差距源于两种策略的区别,你应在开始前慎重选择。音译 保留原始架构并逐语句翻译;速度快、易于与原代码对比,但会整体引入源语言的惯用语,并以不健全为代价。重新设计 以目标语言的惯用方式重建每个模块;速度较慢、难以与原代码对比,但产出的代码能受目标语言工具链的保护。将该选择编码为智能体遵循的规则,并为“逃生舱口”设定显式预算,例如每千行代码 unsafe 或 any 的上限,使策略成为可度量的指标而非愿景。
分层验证,每一层捕获其下一层无法捕获的问题:
-
行为一致性。 现有测试套件针对重写代码通过。这是底线,而非上限。
-
类型与内存健全性。 运行目标语言提供的最严格类型检查和 lint 设置,并根据预算统计“逃生舱口”数量。一个运行类型检查器的
PostToolUse钩子(第 9 章)可在写入后立即捕获回归,而非在最后才发现。 -
惯用性。 让一个单独的只读审查智能体根据目标语言惯用语检查翻译后的文件:错误处理、标准库使用,以及标记代码为原生而非音译的模式。
-
安全与供应链。 审计新的依赖集、信任边界,以及每个“逃生舱口”的辩护性注释。重写也是全新的依赖树,该树需要 第 16 章 级别的审视。
-
性能。 将基准测试套件与原版对比。功能正确性可能掩盖严重退化,“用更快的语言重写”不等于“它更快”。
这一原则推广到语言翻译之外:每当智能体产生大量看似合理、测试通过的变更时,一致性是最廉价但也最不充分的验证属性。从健全性到性能的其余层级,才是将“可信赖的重写”与“仅仅能跑通的重写”区分开来的关键。
总结
-
迁移适合智能体执行。 它们具有机械性、边界明确、可测试、可并行化等特性,这些特性完美契合 Codex CLI 管理上下文和执行任务的方式。
-
先理解,后变更。 运行只读发现阶段,产出现代化报告和风险登记表,记录承载重任的怪癖(日期、舍入、编码、排序、全局状态),并将发现记录在
AGENTS.md中,以便跨会话保留。 -
从只读规划会话开始。 智能体对代码库的分析(文件清单、依赖图、复杂度分级和批次顺序)是你在任何代码变更前审查和批准的文档。
-
三种模式覆盖大多数迁移。 逐文件迁移以实现最大可审计性,逐模块迁移以处理紧耦合模块,绞杀无花果模式适用于无法完全冻结功能开发的大型代码库。
-
以测试为门控前向进度。 在前一批次通过测试前,下一批次不得进行。将此门控编码在 Shell 脚本中,而非寄希望于美好意愿。
-
框架迁移遵循相同模式。 React 类组件到 Hooks、Python 2 到 3、AngularJS 到 Angular 皆可遵循。定义转换规则,将其编码为技能,通过完整的 CI 关卡验证,并将人类注意力保留给智能体正确标记出的模糊案例,而非猜测。
7. 分解单体应用即大规模扼杀无花果
通过耦合分析寻找切分缝隙,在实现之前先定义每个服务的契约,通过可切换的功能开关网关进行路由,并将每个抽取出的端点保持在单体应用现有的集成测试之下。
8. 通过的测试套件并非合理重写的证明
有意识地选择音译或重新设计,为 unsafe 和 any 等“逃生舱”预算,并分层验证:功能对等、类型与内存安全、语言习惯用法、安全与供应链、以及性能。
另请参阅: 第 8 章 (MCP)。 本章将 Codex CLI 应用于其擅长处理的高杠杆工程问题之一。第 8 章 探讨模型上下文协议连接,Codex CLI 在此既消费又暴露 MCP 端点,从而实现与您迁移后的代码库所依赖的工具和服务的更丰富集成。
练习
-
在真实代码库上运行规划会话。 在您组织中寻找一个包含类组件、使用旧版 API 或存在其他明确迁移机会的 JavaScript 或 TypeScript 项目。以只读模式运行 Codex CLI,提示词要求提供文件清单、依赖图、复杂度分级和建议的批次顺序。审查输出:代理的分析在哪些地方与您的直觉相符?哪些地方让您感到意外?提交生成的
MIGRATION_PLAN.md。 -
实现批次验证循环。 将“迁移过程中的验证策略”部分中的
migrate-batch.sh脚本适配到您项目的测试运行器和提交规范中。针对迁移计划中复杂度最低的批次运行它。验证故意引入的错误(重命名函数以破坏测试)会导致脚本回滚而非提交。 -
为您的代码库设计复杂度分级分析。 在运行任何迁移之前,您需要了解要迁移什么。编写一个以只读模式运行的 Codex CLI 提示词,为与您的代码库相关的特定迁移类型(例如 API 版本升级、依赖替换、编码模式现代化)生成复杂度分级评估。评估应将文件分为至少三个等级(简单、中等、复杂),并为每个等级提供明确的标准。通过人工审查每个等级中的五个文件来评估分级分配的质量。
-
验证迁移批次回滚。 为单个迁移批次实现本章中的“测试失败时回滚”模式。设置带有故意测试失败(在批次中破坏一个测试)的验证循环,并验证脚本会回滚整个批次而非提交部分更改。然后修复测试并重新运行,确认批次成功提交。记录该回滚模式所防范的具体故障模式。
-
运行遗留代码考古排查。 选取一个不熟悉的或遗留的模块。在
-s read-only模式下,让 Codex CLI 生成一份现代化报告,涵盖通俗易懂的行为描述、逐模块行为、数据模型,以及日期、舍入、编码、顺序和全局状态怪癖的风险登记册。将排名前三的风险作为约束添加到AGENTS.md中,然后勾勒出如果您重写该模块将运行的分层验证(对等性、安全性、习惯用法、安全、性能)。
第二十二章:后端工程
前几章处理的是已有代码:审查、迁移、现代化。接下来三章从零构建并一直延伸到生产环境。本章编写后端服务及其 API,第 23 章 为其添加前端,第 24 章 使用基础设施即代码部署整个系统。一个贯穿始终的示例,一个核心问题:当后端、前端和基础设施各自发挥代理不同的优势和劣势时,如何让 Codex CLI 提供帮助?每章针对各自层面给出答案;合在一起,展示了同一个代理如何适应三种截然不同的工作。
示例刻意保持适度,并带点自娱自乐的意味。它是一块从 Audley End 到 Liverpool Street 的发车牌,即本书开篇提到的 07:02 列车路线。询问我是可以悠闲出门还是必须冲出门,它会给出答案:给定当前时间和时刻表,返回未来几班发车信息及裁决,RUN 或 relax。它足够小以装进脑海,又足够真实以锻炼让后端工作对代理而言棘手的一切:数据模型、带棘手边界的业务逻辑(何时算太晚出发?)、测试,以及 第 23 章 前端将依赖、第 24 章 部署将托管的 HTTP 契约。
我们使用 Python 和 FastAPI 构建后端,主线贯穿全书:问题不是“如何编写 FastAPI 服务”,而是“如何配置 Codex CLI 使其按团队方式编写服务”。Python 让这个问题更尖锐,因为它给代理提供的反馈循环比编译型静态类型语言更弱:构建时能捕获的错误迟至运行时或代码审查时才浮现。本章大部分配置旨在弥补这一差距,并在过程中构建 Run or Relax API。
学习目标
您将能够:
-
编写完整的 Python
AGENTS.md,消除最常见的“工具选型错误” -
配置钩子,在每次文件写入后强制执行代码检查,在会话结束前运行完整测试套件
-
遵循本章构建端到端的 Run or Relax 服务:带有经过测试裁决规则的类型化 FastAPI 端点,准备好供前端消费
-
将
pyproject.toml配置为 uv、ruff 和 mypy 工具链的单一事实来源,使生成的代码在首次尝试时即通过代码检查和类型检查 -
识别导致代理出错的 Python 特有失败模式,并为每种模式配置防御措施
我们要构建的服务
在进行任何配置之前,详细说明我们要构建的内容,因为本章每个配方都为此服务。目标塑造设计,在交给代理之前使其显性化很有价值。裁决是核心,因此位于一个小巧、纯净、可测试的函数中,而非埋在请求处理器里:它接收时间、计划发车时间和延误,返回两个词之一。规则本身有两个棘手边界。“太晚无法悠闲”不是零分钟,而是从家门走到车站所需时间;延误会推迟发车,从而为您争取时间。“太晚无法奔跑”是第二个更严格的阈值:即使冲刺也赶不上的车次根本不应出现在发车牌上。时刻表和延误是数据而非代码,因此存放在测试可控的种子 JSON 文件中。服务仅暴露一个只读端点,因为发车牌只读。
契约即这一个端点:
GET /departures?station=audley-end&now=<ISO 8601, optional, defaults to now>
200 OK
[
{"service": "07:02", "platform": "2", "delayMinutes": 0,
"minutesUntil": 10, "verdict": "RUN"},
{"service": "07:32", "platform": "2", "delayMinutes": 4,
"minutesUntil": 44, "verdict": "relax"}
]
合同里的两个决策
合同里隐藏着两个决策,文本将其全部明确,这样智能体就不用猜测了。延误会将实际发车时间往后推,因此一列计划时间已过的列车,只要其延误后的发车时间距离现在足够远以供奔赴,仍应保留在列表中:按延误后的实际发车时间而非计划时间进行筛选,并剔除距离小于奔赴阈值的车次。而 minutesUntil 也是从同一延误后的发车时间开始计算,这样读者看到的数字就与他们实际能赶上的那趟列车相符。
计划项目布局
以下是计划的项目布局,下一节的 AGENTS.md 会向智能体描述该布局:
run-or-relax/
├── pyproject.toml
├── AGENTS.md
├── .codex/
│ ├── hooks.json # PostToolUse, Stop, UserPromptSubmit 钩子
│ └── scripts/
│ └── block-legacy-pip.sh
├── data/
│ ├── timetable.json # 每站的种子发车数据
│ └── delays.json # 模拟延误数据源
├── src/departures/
│ ├── models.py # Pydantic 响应模型
│ ├── timetable.py # 加载时刻表 + 延误,计算下一班发车
│ ├── verdict.py # RUN/relax 规则(纯函数)
│ └── app.py # FastAPI 应用与 /departures 路由
└── tests/
├── conftest.py # 时刻表固件
├── test_verdict.py
├── test_timetable.py # 时刻表 + 延误源行为
└── test_api.py
data/ 下的两个文件是运行时种子数据,服务从检出目录运行时会从项目根目录读取它们,而非作为打包进 wheel 的应用资产。这对教学服务尚可接受,但若不加说明,这类决策部署后会悄无声息地导致故障,所以现在就给它定性:第 24 章 决定这些文件如何、或是否随部署服务一同分发。
响应结构
响应结构是一个 Pydantic 模型,它是第 23 章 前端将要镜像的合同的单一事实来源:
#### src/departures/models.py
from typing import Literal
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class Departure(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
service: str # 计划时间,例如 "07:02"
platform: str
delay_minutes: int
minutes_until: int
verdict: Literal["RUN", "relax"]
别名生成器会输出 camelCase JSON(delayMinutes、minutesUntil),使合同在第 23 章 的 TypeScript 端读起来自然,而 Python 端保持 snake_case。Literal["RUN", "relax"] 也在暗中发挥作用:它是前端将要切换的同一个双状态联合类型,因此第三种判定永远不可能意外泄露出 API。
你可以直通本章构建此服务。AGENTS.md 部分编写智能体遵循的规则;pytest 与类型检查部分产出判定逻辑及其测试;工具链部分锁定环境;最后一部分组装 FastAPI 路由并运行。最终你将获得一个在 localhost 上响应 GET /departures 的服务,已为第 23 章 的前端和第 24 章的部署做好准备。
为什么 Python 对智能体来说很难
在给出配置方案前,先说明为什么 Python 比静态类型语言需要更多方案。智能体真正的反馈回路是那组能快速、自动地告诉它“上一次修改是否正确”的信号。Python 给智能体的回路比 TypeScript 或 Rust 弱,本章大部分配置都是为了强化它。语言及其生态系统的五个特性几乎解释了所有麻烦。
错误晚暴露
错误晚暴露。 在编译型静态类型语言中,智能体能获得快速、免费的正确性信号:构建失败。Python 将大多数此类信号推迟到运行时,或代码审查时,除非你接入了 mypy 或 ty。看不见类型错误的智能体会自信地把错误发布出去。对策是将类型检查纳入回路而非事后诸葛亮:在完成门禁中加入严格类型检查,让智能体像面对编译器一样感受到失败。
环境是环境变量且会漂移
环境是环境变量且会漂移。 Python 根据当前活动环境中碰巧安装的包来解析导入,而智能体的会话、CI 与各开发者的笔记本环境各不相同。在环境中运行 pip install 的智能体写出的代码在自己的会话能跑,在别人那里却会坏。对策是单一的、有锁文件支撑的环境,所有调用都经过它,这也是本章将一切通过 uv run 路由的根本原因。
导入时副作用隐藏行为
导入时副作用隐藏行为。 Python 在导入时执行代码:模块级常量、装饰器、注册表注册,有时甚至包括网络或文件系统访问。孤立推理某个函数的智能体看不见仅因导入模块就触发的效果,因而编写的测试和重构会悄悄改变启动行为。对策是智能体可遵循的约定,记录在 AGENTS.md 中:保持模块导入时无副作用,将副作用推到函数背后,把模块顶层视为新工作的禁区。
Mock 太容易
Mock 太容易。 动态类型让打补丁变得轻而易举,追求快速绿灯的智能体会 unittest.mock.patch 整个世界,而不是构建真实的固件。结果就是第 19 章 记载的过度模拟模式:测试通过却什么也没证明。Python 是这种失效模式最严重的地方,这正是为什么模拟策略专门要写在 Python 的 AGENTS.md 里。
Async 有锋利且无声的边缘
Async 有锋利且无声的边缘。 async 函数里的阻塞调用不会报错;它会卡住事件循环,症状表现为负载下的延迟,远离病因。不了解你并发模型的智能体会以通过所有测试却在生产环境退化的方式混用阻塞与异步代码。对策是在 AGENTS.md 中制定显式的异步约定,并在同步与异步代码的边界上加强审查。
透过这个透镜阅读本章余下内容。每个方案并非随意的代码风格;它堵住了这五个缺口之一,把智能体看不见的特性变成它无法忽视的信号。
Python 专用 AGENTS.md
搞对 AGENTS.md 是 Python 项目杠杆率最高的配置决策。没有它,Codex CLI 会做出看似合理实则错误的选择:用 pip install 而非 uv add,用 python -m pytest 而非 uv run pytest,用 setup.cfg 或 .flake8 而你的项目用的是 pyproject.toml。每个错误单独看都能恢复,但累积起来会侵蚀信任并造成环境漂移。
根目录的 AGENTS.md 应回答四个问题:用哪个包管理器、用哪条测试命令、用哪条 lint 与格式化命令、什么算作完成任务。
AGENTS.md
环境
-
Python 包管理器:
uv,切勿直接使用pip -
所有工具调用必须通过
uv run(例如:uv run pytest、uv run ruff) -
添加依赖:
uv add <package>;开发依赖:uv add --dev <package> -
Python 版本:3.14(参见
.python-version或pyproject.toml)
测试
-
运行测试:
uv run pytest tests/ -v -
运行单个测试文件:
uv run pytest tests/test_foo.py -v -
带覆盖率运行:
uv run pytest --cov=src --cov-report=term-missing -
所有新代码必须有 pytest 覆盖率,无例外
代码检查与格式化
-
代码检查:
uv run ruff check . --fix -
格式化:
uv run ruff format . -
类型检查:
uv run mypy src/(稳定的默认选择;ty离开 beta 并加入开发依赖后可用uv run ty check) -
标记任务完成前运行所有检查:代码检查 → 格式化 → 类型检查 → 测试
提交约定
-
规范化提交:
feat:、fix:、refactor:、test:、docs: -
除非依赖版本被有意更新,否则不要提交
uv.lock变更
此处的特定工具——uv、ruff、mypy、pytest——是本书的示例技术栈,而非强制规定。其价值在于明确团队实际使用的工具,以便代理停止猜测:替换为 poetry 或 hatch、black、pyright 或 unittest,文件的结构完全相同。这正是 AGENTS.md 的意义:它适配你的环境和技术栈,而本章其余部分恰好使用上述技术栈。
“在标记任务完成前”这句话至关重要。没有它,代理满足字面提示后就会停止,而不会验证任何东西是否工作。有了它,Codex CLI 就将代码检查、格式化、类型检查和测试内化为完成的定义。
表 22-1 总结了关键配置字段及其背后的理由。
表 22-1. Python AGENTS.md 配置参考
| 字段 | 推荐值 | 重要性 |
| --- | --- | --- |
| 包管理器 | uv | 从 uv.lock 解析并安装;消除开发者与 CI 之间的环境漂移 |
| 测试命令 | uv run pytest tests/ -v | 通过 uv 管理的环境路由;-v 显示单个测试名称,便于诊断 |
| 覆盖率命令 | uv run pytest --cov=src --cov-report=term-missing --cov-fail-under=90 | 硬性阈值防止覆盖率回归;term-missing 内联显示未覆盖的行 |
| 代码检查命令 | uv run ruff check . --fix | 自动修复消除机械性审查评论;--fix 仅应用安全转换 |
| 格式化命令 | uv run ruff format . | 替代 Black;相同的确定性输出,在大型代码库上亚秒级完成 |
| 类型检查 | uv run mypy src/(或 uv run ty check) | mypy 是稳定的默认选择,也是完成门控运行的工具;ty 是 Astral 基于 Rust 的检查器,写作时处于 beta 阶段 |
| 完成门控 | 代码检查、格式化、类型检查、测试 | 显式顺序防止部分完成 |
| 提交风格 | 规范化提交 | 启用自动变更日志生成和语义化版本控制 |
随服务增长的嵌套覆盖
目前 Run or Relax 是单一服务。随着增长,假设出现一个单独的 worker 轮询真实延迟源而非种子源,根 AGENTS.md 保留共享约定,而嵌套文件承载差异。Codex CLI 按作用域顺序合并它们,深层文件优先。
#### src/delay_feed/AGENTS.md
##### 服务特定覆盖
- 这是一个后台 worker,而非 HTTP 服务:此处无路由处理器
- 它写入 `data/delays.json`;绝不可触及 `src/departures/`
- 集成测试轮询存根源:`uv run pytest tests/integration/ -m integration`
- 仅单元测试:`uv run pytest tests/unit/`
保持嵌套文件专注于差异。在每个文件中重复完整工具链会在根约定变更时产生漂移。
在 Codex CLI 中运行 /init 可根据实际项目结构生成初始 AGENTS.md 脚手架,再将其与你编写的文件对比。生成的初始文件在测试路径、工具配置或目录上与你的不同之处,即是 Codex 会以不同方式推断的约定,你在会话中期被咬之前就捕捉到了。
Pytest 集成与测试生成
Pytest 是事实上的 Python 测试运行器,但其约定对未被告知 fixture 位置或标记工作方式的代理来说并不明显。在 AGENTS.md 中编码这些细节,区别在于产出语法有效但无用的测试的测试编写器,还是能与现有套件集成的测试编写器。
Fixture 发现
最常见的错误是 Codex CLI 创建了一个已存在于 conftest.py 中的 fixture。一条指令即可消除它:
#### pytest 约定
- 共享 fixture 在 `tests/conftest.py`:创建新 fixture 前请先检查那里
- `tests/conftest.py` 中的 `timetable` fixture 加载种子 JSON;请复用它,而不是在测试中读取文件或调用时钟
- 服务级 fixture 位于 `tests/<service>/conftest.py`
- 使用 pytest-asyncio 并设置 `asyncio_mode = "auto"`:无需手动添加 `@pytest.mark.asyncio`
- HTTP 客户端 fixture 使用带 ASGI 传输的 `httpx.AsyncClient`,而非 `requests`
使用 FastAPI 或任何异步框架的团队通常在 pyproject.toml 中设置此项:
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "--tb=short -q"
如果没有在 AGENTS.md 中注明这一点,Codex CLI 会给每个异步测试添加 @pytest.mark.asyncio,产生一个看似正确但违反项目约定的差异。
覆盖率阈值与测试标记
指定覆盖率基线和测试类别:
##### 覆盖率
- 最低覆盖率:总体 90%,`src/departures/verdict.py` 为 100%(规则绝不能回归)
- 不要在无注释解释的情况下添加 `# pragma: no cover`
##### 测试标记
- @pytest.mark.unit:无 I/O,每次推送在 CI 运行
- @pytest.mark.integration:需要 Docker 服务,仅在 PR 时在 CI 运行
- @pytest.mark.slow:长时间运行;本地用 `-m "not slow"` 跳过
- 默认本地命令:`uv run pytest -m "unit" -q`
有了这些指令,Codex CLI 在生成新测试文件时会应用正确的标记。被意外标记为 unit 的集成测试会在每次推送时引入数据库调用,造成那种侵蚀 CI 信心的缓慢、易变的失败。
自动化测试扩展
Codex CLI 最可靠的 pytest 贡献是为现有代码扩展覆盖率。当 AGENTS.md 精确时,像“为 src/departures/timetable.py 中尚无覆盖的每个公共函数添加 pytest 测试,使用项目的 timetable fixture”这样的提示能产生良好结果。代理读取 tests/conftest.py 获取 fixture,检查 pyproject.toml 获取标记定义,并生成能融入现有结构的测试。
大规模测试生成可能产生立即通过但断言错误的测试。它们验证当前行为可复现,而非行为正确。审查生成的测试要像审查生成的源代码一样仔细。一个在 None 是 Bug 而非 Feature 时断言 result == None 的测试,比没有测试更糟。
类型提示与文档字符串自动化
Python 的类型提示生态系统在 3.10 和 3.12 版本中成熟。语法更简洁(str | None 而非 Optional[str])[¹⁹⁶],工具更快(ty 和 mypy)[¹⁹⁷] 严格模式下),交付类型化代码的团队报告生产环境运行时错误更少。Codex CLI 可加速类型提示采用,但前提是有一致的风格指南。
AGENTS.md 中的类型风格
类型提示
-
使用 Python 3.10+ 的联合类型语法:
str | None,而非Optional[str] -
在每个模块顶部使用
from __future__ import annotations以支持前向引用 -
所有公开函数必须具有完整的类型注解(参数和返回类型)
-
对于领域概念,优先使用
TypeAlias和NewType而非裸类型别名 -
在函数间传递的结构化字典使用
TypedDict -
除非有注释解释原因,否则不要使用
Any
严格执行禁止裸 Any 的规定。Codex CLI 在不确定类型时默认使用 Any,而这正是显式声明能产生最大价值的场景。
文档字符串生成
针对文档字符串的 AGENTS.md 指令应指定格式、必需章节和范围:
#### 文档字符串
- 格式:Google 风格(Args、Returns、Raises 章节)
- 所有公开函数、方法和类都需要文档字符串
- 每个公开模块也需要单行模块文档字符串:Ruff 的 `D` 规则集包含 D100
- 包含单行摘要,然后空一行,如有需要再添加详细说明
- `Args` 章节:每个参数一条,包含类型和描述
- `Raises` 章节:仅针对调用者预期处理的异常
模块文档字符串行很容易被遗漏。在 Ruff 中启用 D 规则集后,首次对 app.py、models.py、timetable.py 和 verdict.py 运行 lint 时,会因 D100 Missing docstring in public module 报错,直到每个文件都添加了简短的模块文档字符串,因此最好预先声明,而不是一次次通过 --fix 发现。
有了这个设置,像“为 src/departures/timetable.py 中缺少文档字符串的所有公开函数添加 Google 风格文档字符串”这样的提示词,会生成一致的输出,且无需手动编辑即可通过文档字符串风格检查(pydocstyle 或 Ruff 的 D 规则集)。
uv、ruff 与现代 Python 工具链
现代 Python 工具链将历史上分离的工具合并为两个:
-
uv 替代了 pip、venv、pyenv、pipx 和 poetry。它由 Rust 编写,能在毫秒级解析依赖,并生成固定完整依赖图的
uv.lock文件。[1] -
Ruff 替代了 flake8、Black、isort、pyupgrade 以及 900 多条 lint 规则背后的插件。同样基于 Rust,在大型代码库上能在亚秒级完成运行。[2]
一条原则贯穿整个工具链:每次工具调用都通过 uv run。这保证了在任何工具运行前,项目的虚拟环境处于激活且最新状态。
pyproject.toml 作为单一事实来源
所有工具配置都位于 pyproject.toml 中。没有 .flake8,没有 setup.cfg,没有 mypy.ini。一个完整的基线配置如下:
[project]
name = "run-or-relax"
version = "0.1.0"
requires-python = ">=3.14,<3.15"
dependencies = [
"fastapi>=0.115",
"pydantic>=2.9",
"uvicorn[standard]>=0.30",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/departures"]
[dependency-groups]
dev = [
"httpx>=0.27",
"mypy>=1.13",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"pytest-cov>=6.0",
"ruff>=0.9",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "--tb=short -q"
pythonpath = ["src"]
[tool.ruff]
target-version = "py314"
line-length = 100
exclude = [".uv-cache"]
[tool.ruff.lint]
select = [
"D",
"E",
"F",
"I",
"UP",
"B",
"SIM",
]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["D"]
[tool.ruff.lint.isort]
known-first-party = ["departures"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.mypy]
strict = true
ignore_missing_imports = true
mypy_path = "src"
有了这个文件,Codex CLI 就能从单一位置发现你的项目约定。有几个条目虽不起眼但至关重要:
-
[tool.ruff.lint]中的select列出了激活的 lint 规则,因此生成的代码能在首次尝试时就通过 lint 检查。 -
exclude = [".uv-cache"]让 Ruff 忽略项目本地的 uv 缓存。下面的钩子将UV_CACHE_DIR=.uv-cache设置为将缓存写入项目内部,但如果不排除它,uv run ruff check .会扫描该目录并报出数千个来自第三方包的 lint 错误。排除它才使得整个树.目标变得安全;否则你必须将每个 lint 命令限定为src tests。 -
[build-system]加上uvicorn[standard]使得uv run uvicorn departures.app:app能正常解析。声明了构建后端后,uv run会以可编辑模式安装项目,这样src/布局下的departures模块就能通过名称导入,而不仅限于路径导入。 -
[tool.hatch.build.targets.wheel]告诉 Hatchling 包的位置。当项目名为run-or-relax时,它无法推断出departures位于src/下,如果没有packages = ["src/departures"],首次构建会失败并提示Unable to determine which files to ship inside the wheel。 -
pythonpath = ["src"]让 pytest 能在无需安装步骤的情况下导入同一个包。 -
完成门控运行的是 mypy,而非
ty。ty是备选的 beta 版本;可运行的基线仍使用稳定的检查器。
requires-python = ">=3.14,<3.15" 的上限是刻意设定的:固定次版本号能保持工具链的可复现性,避免 uv 在新解释器一安装就跳转过去。在项目根目录下的 .python-version 文件中记录相同的固定版本,这样 uv 无需询问即可选择 3.14:
3.14
跳出个别条目来看,模式才是本章的重点:pyproject.toml 和 .python-version 中的每一行,都是 Codex CLI 不再需要猜测的一项决策。包管理器、lint 规则、类型检查器、包布局、解释器版本,每一项都是代理本会从不完整信号中推断(有时错误推断)的决策。将它们一次性写下来,将一系列猜测变为查阅得到的事实,这也是生成的代码能在第一次尝试而非第三次尝试时就正确落地的原因。
用于自动执行的钩子
Codex CLI 的钩子系统(见 第 9 章)在会话层面强制执行工具链纪律,而不依赖代理的记忆。三个钩子覆盖了关键的 Python 护栏:
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "uv run ruff check . --fix --quiet && uv run ruff format . --quiet",
"statusMessage": "正在进行 Lint 和格式化…",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "if UV_CACHE_DIR=.uv-cache uv run --no-sync pytest tests/ -q --tb=short >&2; then printf '{}\\n'; else hook_status=$?; printf '{}\\n'; exit \"$hook_status\"; fi",
"statusMessage": "正在运行测试套件…",
"timeout": 120
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash .codex/scripts/block-legacy-pip.sh",
"statusMessage": "正在检查提示词…",
"timeout": 5
}
]
}
]
}
}
脚本 block-legacy-pip.sh 会检查传统工具调用,若发现则以代码 2 退出并显示消息:
#!/usr/bin/env bash
##### .codex/scripts/block-legacy-pip.sh
##### 阻止包含传统 Python 工具调用的提示词
set -euo pipefail
##### UserPromptSubmit 通过标准输入传递 JSON 对象;文本在 `.prompt` 中。
prompt="$(jq -r '.prompt // empty')"
if printf '%s' "$prompt" | grep -qE 'pip install|pip add|python -m pytest|python -m ruff'; then
echo "请对所有 Python 操作使用 uv:uv add <pkg>、uv run pytest、uv run ruff" >&2
exit 2
fi
Hooks
正如第 9 章所述,UserPromptSubmit hook 在标准输入 (stdin) 上接收一个 JSON 对象形式的提交提示词,文本位于 prompt 字段中,因此脚本使用 jq -r '.prompt' 读取它,然后再进行 grep 操作。如果你针对的是较旧的 Codex 版本,请在依赖此机制前确认负载确实通过 stdin 到达了 hook,但 stdin JSON 是文档规定的通道。
PostToolUse hook 在每次工具调用后运行 ruff,因此每个写入的文件都会立即被检查和格式化。Stop hook 使测试门禁显性化:在完成前保持测试套件通过(绿色)。UserPromptSubmit hook 在 Codex CLI 处理遗留调用前将其拦截,这是主动强制执行,而非被动响应。
Stop 命令看起来比 'run pytest' 复杂得多,主要有两个原因。第一是 JSON 包装器:Stop hook 会在标准输出 (stdout) 上解析一个 JSON 控制对象,而原始的 pytest 输出不是有效的 JSON,因此该命令将 pytest 的报告发送到标准错误输出 (stderr) (>&2),它仍能到达你这里,在 stdout 上打印一个空的 {} 对象以满足解析器,并以 pytest 自身的状态码重新退出,这样失败的测试套件(红色)仍会阻止停止操作。裸露的 uv run pytest tests/ -q --tb=short 即使在所有测试通过时也会导致 hook 失败,因为其测试输出的第一行会被解析为格式错误的 JSON。
第二个原因是 uv run 本身的两个标志。默认情况下,uv run 在运行前会同步项目环境,从锁文件解析并安装依赖,这可能会访问网络并写入 uv 的全局缓存。在 hook 中进行这种同步既缓慢,又容易因 hook 无权写入的目录而失败,从而报告一个与你的测试无关的失败。--no-sync 跳过此步骤,因为环境已在会话期间就绪,而 UV_CACHE_DIR=.uv-cache 将任何缓存写入保留在项目内部,而不是 hook 可能不拥有的全局位置。它们共同使门禁变得快速且无副作用;遗漏它们正是导致此次更改的失败原因。
撰写本文时,PostToolUse 匹配器过滤支持 Bash、apply_patch(也匹配 Edit 和 Write)以及 MCP 工具名称。它尚不拦截 WebSearch 或其他非 shell、非 MCP 工具调用。上述 PostToolUse hook 因未指定匹配器而在每次工具调用后触发;无条件运行 ruff 足够快,在实践中这并非显著开销。
服务与异步约定
Run or Relax API 是一项单一服务,但使其对智能体友好的约定同样适用于多服务扩展。FastAPI 和异步 Python 各自带有 Codex CLI 无法自行推断的规则,两者都应置于 AGENTS.md 中,以便智能体默认应用它们,而非逐个路由即兴发挥。
FastAPI 服务约定
FastAPI 是新建 Python 服务的常见选择。关键约定:
#### FastAPI 约定
- 路由处理器必须为异步;出站 HTTP 调用使用 `httpx.AsyncClient`
- Pydantic 响应模型位于 `src/departures/models.py`(每个服务一个模块;增长时拆分为包)
- 通过 FastAPI 的 `Depends()` 进行依赖注入:禁止全局状态
- 错误处理:在路由处理器中抛出 `HTTPException`;绝不让领域异常向上传播
- OpenAPI 模式:在每个路由上保持 `response_model` 显性化
- 后台任务:即发即弃型使用 `BackgroundTasks`;持久化工作使用任务队列(Celery 或 ARQ)
异步模式
异步 Python 有 Codex CLI 无指示无法推断的约定:
#### 异步模式
- 对于并发独立操作,使用 `asyncio.gather()`
- 数据库会话:使用异步上下文管理器(`async with session_factory() as session`)
- 切勿在异步函数中调用阻塞式 I/O;如必需,请使用 `asyncio.run_in_executor`
- HTTP 客户端:在启动时创建 `httpx.AsyncClient` 实例并通过依赖注入传递
- 避免将 `asyncio.sleep(0)` 用作让出点;仅在真正需要时使用 `asyncio.sleep(0.001)`
构建 Run or Relax API
有了 AGENTS.md 中的这些约定和章节开头的 Departure 模型,服务的其余部分只需一个简短提示词,因为契约已敲定:
使用 FastAPI 构建 Run or Relax API,遵循 AGENTS.md。
- GET /departures?station=audley-end&now=<ISO 8601,可选,默认为当前时间>
- 从 data/timetable.json(已植入)加载时刻表;应用来自 data/delays.json 的模拟延误源
- 对每个即将到来的发车返回:服务时间、站台、延误分钟数、距离发车分钟数,以及 RUN 或 relax 的判定
- 如果距离发车分钟数(含任何延误)至少为 WALK_MINUTES(14 分钟,步行至车站所需时间),判定为 relax;如果至少为 RUN_MINUTES(8 分钟,跑步所需时间),判定为 RUN;看板从不显示比 RUN_MINUTES 更近的列车,因为即使跑也赶不上
- 添加 CORSMiddleware,允许来自 RUN_OR_RELAX_CORS_ORIGIN 环境变量的看板源;未设置时,默认允许两个本地看板源 http://localhost:3000 和 http://127.0.0.1:3000 ,以便第 23 章的前端无论在 localhost 还是 127.0.0.1 上打开,都能从浏览器调用此服务
- Pydantic 响应模型;pytest 测试以时刻表作为 fixture 注入,无真实时钟且无网络
CORS 这一条是在浏览器介入前不可见的要点:所有后端测试不加它也能通过,随后第 23 章的看板因从不同源提供服务,其跨域请求被阻塞。将其写入提示词而非事后发现,能保持两章之间的接缝诚实。
默认值还藏着第二个陷阱,仅在浏览器中显现:根据浏览器的同源策略,http://localhost:3000 和 http://127.0.0.1:3000 是不同源,尽管它们解析到同一台机器。仅允许 localhost 的默认值会在读者用 127.0.0.1 打开看板的那一刻将其阻塞,UI 只会显示 Could not reach the departure service. 默认允许两者可移除陷阱;在生产环境设置 RUN_OR_RELAX_CORS_ORIGIN 则将其收窄为唯一交付的源:
DEFAULT_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
def cors_origins() -> list[str]:
configured = os.getenv("RUN_OR_RELAX_CORS_ORIGIN")
return [configured] if configured else DEFAULT_CORS_ORIGINS
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins(),
allow_credentials=False,
allow_methods=["GET"],
allow_headers=["*"],
)
CORS 正是那种单元测试全过却在浏览器中崩坏的接缝,所以用一个像浏览器一样发送 Origin 头并断言响应将其回显的测试将其钉死:
import httpx
from departures.app import app
async def test_cors_allows_127_0_0_1() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/departures", headers={"Origin": "http://127.0.0.1:3000"})
assert resp.headers["access-control-allow-origin"] == "http://127.0.0.1:3000"
当智能体编写路径操作时,FastAPI 惯用的 station: str = Query(default="audley-end") 会触发 Ruff 的 B008(函数调用在默认参数中),因为 B (flake8-bugbear) 在选定的规则集中。请改用 FastAPI 现在推荐的 Annotated 形式,即 station: Annotated[str, Query()] = "audley-end",它将默认值保持为普通字面量,从而干净地通过 B008。在 AGENTS.md 中命名此约定,以便生成的路由首次尝试即通过 lint 检查,而非第二次。
根据约定,以及前文给出的 Departure 模型,Agent 填充了时刻表加载器、异步路由和判决规则,后者作为一个小型纯函数位于 src/departures/verdict.py。该函数值得重点审查,因为它包含了目标所警告的棘手边界情况:
from datetime import datetime
WALK_MINUTES = 14 # Audley End: 前门到车站步行所需时间
RUN_MINUTES = 8 # 前门到车站跑步所需时间
def verdict(now: datetime, departs_at: datetime, delay_min: int) -> str:
minutes_until = (departs_at - now).total_seconds() / 60 + delay_min
return "relax" if minutes_until >= WALK_MINUTES else "RUN"
verdict 函数掌握第一个边界:“太晚无法从容” 不是零分钟,而是 WALK_MINUTES;延误会让发车时间推迟,反而为你争取时间,而非消耗时间。如果 Agent 未被告知此规则,很可能会拿零分钟做比较,建议你从容走出家门,去赶一班实际上需要你冲刺才能赶上的列车。第二个边界 RUN_MINUTES 是加载器的职责(连跑步都赶不上的列车永远不会出现在发车板上),我们稍后再回到那里。通过命名边界的测试来固定 verdict 的行为,沿用前文的 fixture 约定,避免依赖真实时钟:
from datetime import datetime
def test_ten_minutes_out_means_run() -> None:
now = datetime(2026, 6, 15, 6, 52) # 07:02 发车的列车,无延误(10 分钟)
assert verdict(now, datetime(2026, 6, 15, 7, 2), 0) == "RUN"
def test_a_delay_turns_a_run_into_a_relax() -> None:
now = datetime(2026, 6, 15, 6, 52) # 同样是 10 分钟,但晚点 6 分钟
assert verdict(now, datetime(2026, 6, 15, 7, 2), 6) == "relax"
喂给 verdict 的加载器留给 Agent 实现,但它掌管第二个边界 RUN_MINUTES,以及前述已定的延误与过滤决策,因此了解你将要审查的代码结构很有必要。next_departures 很精简:读取种子数据,将各延误应用到计划时间,丢弃即使跑步也赶不上的车次,返回前几个作为 Departure 模型。
##### src/departures/timetable.py (参考结构)
from datetime import datetime, timedelta
from pathlib import Path
from typing import List
##### 假设 Departure, load_timetable, load_delays, RUN_MINUTES, verdict 在别处定义/导入
def next_departures(
station: str, now: datetime, limit: int = 5
) -> list[Departure]:
scheduled = load_timetable()[station] # data/timetable.json
delays = load_delays() # data/delays.json
board = []
for train in scheduled:
delay_min = delays.get(train.id, 0)
departs_at = train.scheduled + timedelta(minutes=delay_min)
minutes_until = round((departs_at - now).total_seconds() / 60)
if minutes_until < RUN_MINUTES: # 即使跑步也赶不上
continue
board.append(Departure(
service=train.service, platform=train.platform,
delay_minutes=delay_min, minutes_until=minutes_until,
verdict=verdict(now, train.scheduled, delay_min),
))
return sorted(board, key=lambda d: d.minutes_until)[:limit]
这只是一个参考结构,而非成品模块:确切的加载器签名和时刻表记录取决于 Agent 如何建模种子 JSON。它固定的是决策顺序:先应用延误,再过滤,最后限制数量,以便你检查 Agent 是否按此顺序决策,而非(例如)在应用延误前先按计划时间过滤。
播种时刻表与延误数据
加载器的优劣取决于背后的数据,第一版种子数据通过惨痛教训证明了这一点:仅含上午班次的时刻表会导致发车板在下午显示为空,即使 API 运行完美。一个过滤“即将出发”班次的发车板,若没有全天候的班次数据,高峰过后就无车可显。必须播种全天数据。相对于包而非硬编码路径来解析数据目录,以便无论仓库签出到何处都能找到文件,并提供可选的环境变量覆盖以指向备用数据集:
DEFAULT_DATA_DIR = Path(__file__).resolve().parents[2] / "data"
DATA_DIR = Path(os.getenv("RUN_OR_RELAX_DATA_DIR", DEFAULT_DATA_DIR))
parents[2] 从 src/departures/timetable.py 向上走两级到达项目根目录,因此 DATA_DIR 默认落在仓库的 data/ 目录,且从不烘焙入特定机器的路径。若要针对不同数据集运行,将该变量指向任何包含同名两个文件 timetable.json 和 delays.json 的目录即可:
RUN_OR_RELAX_DATA_DIR=/path/to/alternate/data \
uv run uvicorn departures.app:app --reload --host 127.0.0.1 --port 8000
创建 data/timetable.json,包含该车站全天的班次服务,每个服务拥有稳定的 id,供延误数据源关联:
{
"audley-end": [
{ "id": "ae-lst-0505", "service": "05:05", "platform": "2" },
{ "id": "ae-lst-0538", "service": "05:38", "platform": "2" },
{ "id": "ae-lst-0554", "service": "05:54", "platform": "2" },
{ "id": "ae-lst-0606", "service": "06:06", "platform": "2" },
{ "id": "ae-lst-0632", "service": "06:32", "platform": "2" },
{ "id": "ae-lst-0638", "service": "06:38", "platform": "2" },
{ "id": "ae-lst-0702", "service": "07:02", "platform": "2" },
{ "id": "ae-lst-0708", "service": "07:08", "platform": "2" },
{ "id": "ae-lst-0732", "service": "07:32", "platform": "2" },
{ "id": "ae-lst-0738", "service": "07:38", "platform": "2" },
{ "id": "ae-lst-0802", "service": "08:02", "platform": "2" },
{ "id": "ae-lst-0808", "service": "08:08", "platform": "2" },
{ "id": "ae-lst-0828", "service": "08:28", "platform": "2" },
{ "id": "ae-lst-0842", "service": "08:42", "platform": "2" },
{ "id": "ae-lst-0909", "service": "09:09", "platform": "2" },
{ "id": "ae-lst-0939", "service": "09:39", "platform": "2" },
{ "id": "ae-lst-1017", "service": "10:17", "platform": "2" },
{ "id": "ae-lst-1039", "service": "10:39", "platform": "2" },
{ "id": "ae-lst-1117", "service": "11:17", "platform": "2" },
{ "id": "ae-lst-1139", "service": "11:39", "platform": "2" },
{ "id": "ae-lst-1217", "service": "12:17", "platform": "2" },
{ "id": "ae-lst-1239", "service": "12:39", "platform": "2" },
{ "id": "ae-lst-1317", "service": "13:17", "platform": "2" },
{ "id": "ae-lst-1339", "service": "13:39", "platform": "2" },
{ "id": "ae-lst-1417", "service": "14:17", "platform": "2" },
{ "id": "ae-lst-1439", "service": "14:39", "platform": "2" },
{ "id": "ae-lst-1517", "service": "15:17", "platform": "2" },
{ "id": "ae-lst-1539", "service": "15:39", "platform": "2" },
{ "id": "ae-lst-1557", "service": "15:57", "platform": "2" },
{ "id": "ae-lst-1614", "service": "16:14", "platform": "2" },
{ "id": "ae-lst-1639", "service": "16:39", "platform": "2" },
{ "id": "ae-lst-1656", "service": "16:56", "platform": "2" },
{ "id": "ae-lst-1717", "service": "17:17", "platform": "2" },
{ "id": "ae-lst-1738", "service": "17:38", "platform": "2" },
{ "id": "ae-lst-1806", "service": "18:06", "platform": "2" },
{ "id": "ae-lst-1838", "service": "18:38", "platform": "2" },
{ "id": "ae-lst-1907", "service": "19:07", "platform": "2" },
{ "id": "ae-lst-1938", "service": "19:38", "platform": "2" },
{ "id": "ae-lst-2017", "service": "20:17", "platform": "2" },
{ "id": "ae-lst-2039", "service": "20:39", "platform": "2" },
{ "id": "ae-lst-2117", "service": "21:17", "platform": "2" },
{ "id": "ae-lst-2139", "service": "21:39", "platform": "2" },
{ "id": "ae-lst-2208", "service": "22:08", "platform": "2" },
{ "id": "ae-lst-2239", "service": "22:39", "platform": "2" },
{ "id": "ae-lst-2308", "service": "23:08", "platform": "2" }
]
}
接着创建 data/delays.json,以每个服务的 id 为键,值为延误分钟数,模拟真实发车板会轮询的实时数据源。只有少数班次晚点;其余缺省即为准点:
{
"ae-lst-0702": 6,
"ae-lst-0738": 3,
"ae-lst-0808": 10,
"ae-lst-1717": 5,
"ae-lst-1806": 2
}
总结
-
Python 削弱了反馈回路。 其正确性信号弱于静态类型语言:后期才浮现的类型错误、环境变量依赖、导入时的副作用、易于模拟以及静默的 async 边缘情况是这五大鸿沟,本章中的每个秘诀都是为了填补其中之一。
-
在
AGENTS.md中命名工具。 一份写明uv run pytest、uv run ruff check --fix和uv run ruff format的 PythonAGENTS.md能在错误发生前消除绝大多数工具误用。完整质量关卡(lint → format → type check → tests)将完整的质量周期变成了 Agent “完成”定义的一部分。 -
Pytest 需要命名 fixtures 和 markers。 当 Codex CLI 知道 fixtures 位于何处、存在哪些 markers 以及覆盖率阈值是多少时,集成效果会显著提升。Fixture 发现和 marker 规范是配置不足的项目中最常缺失的两个约定。
-
类型生成需要风格规范。 当风格被精确指定时,类型提示和文档字符串适合自动化生成。禁止裸露的
Any以及选择 Google 风格文档字符串是产出最一致输出的两条指令。 -
一套工具链,一个配置文件。
uv和ruff合并了历史上半打独立的工具,mypy作为类型检查关卡。将pyproject.toml编码为单一事实来源,并通过uv run路由所有调用,可生成一个 Codex CLI、CI 和团队每位开发者完全共享的可复现环境。 -
FastAPI 和 async 需明确约定。 Async 路由处理器、
Depends()注入、显式的response_model以及 async 函数内无阻塞 I/O,所有这些都应写入AGENTS.md,否则 Codex CLI 会在不同路由间不一致地即兴发挥。 -
“运行或放松”的回报。 有了这些约定,服务只需一个简短提示,而值得审查的部分仅是裁决规则和钉住其棘手边缘情况的测试。其
GET /departures契约即是 第 23 章 所渲染、第 24 章 所部署的那个贯穿三章的单一示例。
练习
-
基线审计。 选取一个你正在从事的现有 Python 项目,使用本章约定编写一份根目录下的
AGENTS.md。在 Codex CLI 中运行/init并将其生成内容与你编写的内容对比。它们在哪些地方出现分歧?这说明了 Codex CLI 能推断出哪些约定,又有哪些必须显式声明? -
覆盖率扩展。 找出你项目中测试覆盖率低于 80% 的模块。编写一个 Codex CLI 提示词,利用
AGENTS.md中的 fixture 约定将覆盖率扩展至 95%。审查生成的测试:它们是断言行为还是仅断言当前输出?修改那些断言附带行为而非预期契约的测试。 -
钩子管道。 实现本章中的三钩子配置(PostToolUse lint、Stop 测试关卡、UserPromptSubmit 遗留命令拦截)到
.codex/hooks.json文件中。运行一个向模块添加新函数的 Codex CLI 会话,观察哪些钩子触发以及触发时机。修改 Stop 钩子命令使其在测试失败时非零退出,观察 Codex CLI 如何报告错误。 -
类型注解扩展。 找出代码库中至少 30% 函数缺少类型注解的 Python 模块。编写一个 Codex 提示词,为每个未注解的函数添加注解,遵循
AGENTS.md中的约定(例如 Python 3.10+ 使用str | None而非Optional[str])。Codex CLI 完成注解后,在严格模式下运行mypy或ty并审查浮现的任何类型错误。注意这些错误源于新增注解,还是注解暴露出的预存类型问题。 -
构建 Run or Relax API。 使用包含本章 FastAPI 和 async 约定的 Python
AGENTS.md,提示 Codex CLI 构建上述GET /departures端点,对接种子时刻表,将裁决实现为纯函数,并编写注入时钟而非读取时钟的测试。然后重新生成,但刻意在提示词中省略“站台进站规则”:Agent 是否会与零比较并让你冲向一班你赶不上的列车?将该规则加入AGENTS.md,确认有测试钉住它,并保留该契约;你将在 第 23 章 中针对它构建前端。
第二十三章:前端工程
第 22 章 构建了 Run or Relax 后端:一个 API,给定时间和时刻表,返回 Audley End 的下一班发车信息及一个裁决结果——RUN 或 relax。一个没有面孔的服务。本章构建这张“面孔”:调用 API 并渲染看板的 React 与 TypeScript 前端,与后端一样,代码由 Codex CLI 编写。
难点在于前端工作是 Agent 错误最难被捕获的领域,这改变了你引导它的方式。核心在于静默失败。生成错误 SQL 的后端 Agent 会立即在测试中失败;而选错包管理器、在 Hooks 代码库中拿类组件、或针对 Vitest 运行器编写 Jest 断言的前端 Agent,代码编译通过,随后却以难以追踪的方式出错。如何让它“守轨行驶”是本章剩余内容的主题,伴随的挑战包括:工具链不匹配、测试通过却无实证、无障碍技术债,以及将设计转化为代码。
我们以 React 和 TypeScript 为具体落地目标,但本章不针对某个特定库:它关乎如何为前端工作配置 Codex CLI,使那些静默失败尽早浮现。
学习目标
你将能够:
-
编写一份前端
AGENTS.md,预防最常见的工具链不匹配。 -
使用单个 Codex CLI 提示词,脚手架出带有同目录 Vitest 测试套件的类型化 React 组件。
-
在 React 前端消费类型化后端契约,通过判别联合类型切换,使 API 形态在两端均得到强制约束。
-
生成 Vitest 和 Testing Library 测试,以锻炼真实用户交互而非合成事件。
-
应用探索者/工作者子 Agent 模式处理跨越 UI 和 API 的特性。
-
将无障碍审计自动化为可复用的 Codex CLI 技能。
-
利用视觉输入和 Figma 的 MCP 服务器实现设计转代码工作流。
-
判断何时浏览器验证循环值得其延迟和 Token 成本。
前端专用 AGENTS.md 配置
第 22 章 与前端 AGENTS.md
第 22 章 论证了 AGENTS.md 是阻止 Agent 盲目猜测的最高杠杆手段;前端将风险放大了。这不仅仅是因为约定更多,更因为错误的约定更隐蔽:当你的 Monorepo 使用 pnpm 时却执行 npm install,当 Barrel 文件期望命名导出时却写了默认导出,或者迁移到 Vitest 的 vi.fn() 后仍使用 jest.fn()。每个错误单独看都微不足道,但合在一起却具有腐蚀性。它们拖慢 Code Review,导致约定分歧,并侵蚀自主运行所依赖的信任。
前端 AGENTS.md 需要回答后端配置通常可隐式忽略的五个问题:
-
用哪个包管理器?确切命令是什么?
-
用哪个 React 版本?它启用或禁止哪些模式?
-
用哪个组件库?基础组件如何安装与扩展?
-
用哪个测试框架?单元测试与 E2E 测试的边界在哪里?
-
Agent 绝对不能跨越哪些文件系统边界?
以下配置为使用 React 19 编译器的 Next.js 15 项目回答了上述全部五个问题:^(²⁰¹)
#### AGENTS.md — Frontend (React/TypeScript)
##### Stack
- React 19 with TypeScript strict mode
- Next.js 15 (App Router), built as a static export (`output: "export"`): the board ships as static assets, with no server runtime
- Tailwind CSS v4
- shadcn/ui for component primitives
- Vitest + Testing Library for unit/integration tests
- Playwright for browser automation and visual verification
- pnpm (never npm install or yarn)
##### Backend
- The Chapter 22 FastAPI service exposes `GET /departures`; the board calls it directly from the browser
- Base URL: `NEXT_PUBLIC_DEPARTURES_API_URL`, defaulting to `http://localhost:8000`
- Calls are cross-origin, so the backend must send CORS headers for the board's origin
##### Commands
- `pnpm dev` — development server (port 3000)
- `pnpm test` — Vitest unit tests
- `pnpm test:e2e` — Playwright end-to-end tests
- `pnpm build` — production build
- `pnpm lint` — ESLint + Prettier check
- `pnpm tsc --noEmit` — TypeScript type check
##### Architecture
- `/app` — Next.js App Router pages (static export, no server runtime)
- `/components/ui` — shadcn/ui primitives (regenerate with CLI, never edit directly)
- `/components` — application components
- `/lib` — utilities and data fetching (calls the backend API directly)
- `/tests` — Vitest unit tests
- `/e2e` — Playwright tests
##### Component conventions
- Functional components only (no class components)
- Do NOT add useMemo/useCallback by default; trust the React Compiler
- Use `startTransition` for non-urgent state updates
- Prefer server components; use `"use client"` only when interactivity requires it
- shadcn/ui components install via `pnpm dlx shadcn@latest add <component>`
- Named exports only (no default exports from component files)
##### TypeScript
- Strict mode is on — no `any`, no `@ts-ignore`
- Use `satisfies` for type narrowing rather than casting
- All API response shapes must have explicit types
##### Constraints
- Never modify files in `/components/ui` directly
- Never add `console.log` to production code
- Tailwind utility classes only — no inline styles, no CSS-in-JS
##### Completion criteria
- `pnpm build` passes with no errors
- `pnpm test` all pass
- `pnpm tsc --noEmit` reports zero errors
有两处注释值得重点关注。React 19 编译器备注“不要默认添加 useMemo/useCallback”,防止 Codex CLI 应用 React 17 时代的性能模式——该模式现由编译器自动处理。^(²⁰²) 没有它,Codex CLI 会防御性地包装每一个昂贵计算,产出虽能工作但不必要冗余的代码。shadcn/ui 约束同样具体:这些是由 shadcn CLI 管理的生成文件,而非你拥有的源代码,直接修改会破坏再生成工作流。将其编码为硬约束,可防止 Codex CLI 在“改进”基础组件时造成无声破坏。
completion criteria(完成标准)部分将 AGENTS.md 从风格指南转变为反馈循环。Codex CLI 在任务结束时运行列出的命令,并迭代直到全部通过。没有显式标准,你只能依赖 Agent 自行推断何时完成,它确实会推断,但并不总是正确。
创建前端项目
第 22 章 留下了一个 run-or-relax/ 检出,FastAPI 服务位于 src/ 下。前端与其并存于 frontend/ 目录,这是一个 Node 项目而非 Python 项目,因此两套工具链永不冲突。首先确认工具链在 PATH 上,因为最新的 Node 安装可能在启用 Corepack 前不暴露 pnpm:
node --version
corepack enable pnpm
pnpm --version
然后从仓库根目录脚手架项目:
cd run-or-relax
pnpm create next-app@latest frontend --typescript --app --tailwind --eslint --use-pnpm
cd frontend
pnpm dlx shadcn@latest init --defaults --yes
pnpm add -D vitest @testing-library/react @testing-library/user-event jsdom @vitejs/plugin-react
pnpm add -D @playwright/test jest-axe @axe-core/playwright
这些命令追踪快速迭代的工具,预期会有漂移。@latest 可能安装比 AGENTS.md 技术栈行声明更新的 Next 主版本;保留脚手架安装的版本并更新技术栈行以匹配,而不是固定版本,因为本章的约定不依赖确切版本。使 shadcn init 非交互的标志也随版本变化,如果仍提示交互,请添加 --force 或查看当前的 --help。create-next-app 会将自己的 AGENTS.md 和 CLAUDE.md 放入项目:将其版本特定的警告合并到前述配置中,而非盲目覆盖。
如果完全无法运行 create-next-app(例如离线或受限会话),转而手动构建项目结构,请添加脚手架本会安装的类型包:pnpm add -D @types/react @types/node。没有它们 pnpm tsc --noEmit 会立即失败,且让 next build 在构建中途自动安装它们会与 pnpm 存储配置冲突。脚手架隐藏了此依赖;手动路径必须显式声明。
将合并后的 AGENTS.md 放置在 frontend/AGENTS.md,使 Codex CLI 从此处运行的每条命令都继承约定,然后在同一会话中让它生成测试配置,明确运行器边界,确保两个测试工具永不冲突:
Configure the test toolchain following AGENTS.md:
- vitest.config.ts: jsdom environment, a tests/setup.ts setup file, and
exclude e2e/**, node_modules/**, .next/** and out/** so Vitest never
loads a Playwright spec
- playwright.config.ts: run only e2e/**
- package.json: "test" runs Vitest, "test:e2e" runs Playwright
前端无服务器运行时发布,因此在同一会话中配置 Next.js 进行无构建时网络调用的自包含静态导出:
Configure the build for a static export with no build-time network calls:
- next.config.ts: set output: "export" so `pnpm build` emits a static
out/ directory, and set turbopack.root to this frontend/ directory so
the build does not infer a parent lockfile as the workspace root
- app/layout.tsx: replace the scaffold's next/font/google imports
(Geist) with a system font stack, so the build never fetches fonts
from Google at build time
这使 pnpm build 将前端写入 out/,即 第 24 章 作为静态资源服务的目录。生成的目录树即 AGENTS.md 架构章节所描述的结构:
run-or-relax/
├── src/departures/ # 第 22 章 FastAPI 服务
├── data/ # 预置时刻表 + 延误数据
└── frontend/
├── AGENTS.md
├── app/
│ └── departures/page.tsx # 发车页面
├── components/
│ ├── DepartureRow.tsx
│ ├── DepartureBoard.tsx # 容器组件:获取 + 轮询
│ └── DepartureBoardView.tsx # 展示组件
├── lib/departures.ts # 类型契约 + 获取逻辑
├── tests/ # Vitest
└── e2e/ # Playwright
这些提示词指示智能体启用 Corepack,将 e2e/** 从 Vitest 中排除,设置 turbopack.root,并替换脚手架的字体,因此遵循这些提示词的读者应该能在无需人工干预的情况下获得绿色的 pnpm build。前端脚手架是该技术栈中最容易受工具链漂移影响的部分,因此某个步骤仍可能落地失败:发布版本间标志发生变化,或命令在先决条件就绪前运行。表 23-1 是此类情况的恢复参考,将你实际看到的症状映射到要交给 Codex CLI 的单行修复命令。
表 23-1. 当前工具链下的设置障碍及修复方法
| 症状 | 原因 | 修复 |
| --- | --- | --- |
| pnpm: command not found | Corepack 尚未为 pnpm 创建 shim | 在第一条命令前运行 corepack enable pnpm |
| shadcn init 仍然弹出交互提示 | 其非交互式标志在不同版本间发生漂移 | 添加 --force,或查看 --help 确认当前标志 |
| pnpm test 失败并提示 "Playwright Test did not expect test() to be called here" | Vitest 正在加载 e2e/ 下的测试规格 | 在 vitest.config.ts 中排除 e2e/** |
| 构建警告推断父级 lockfile 作为工作区根目录 | 多个 lockfile 位于 frontend/ 上层 | 在 next.config.ts 中将 turbopack.root 设置为 frontend/ 目录 |
脚手架配置完成,约定已落笔成文,已知障碍要么被提示词预先规避,要么可查表解决,项目此时处于绿色 pnpm build 状态,智能体拥有无需猜测即可工作的一切条件。本章其余内容均通过后续提示词生成到此目录树中,首先从看板渲染所依赖的契约开始。
运行后端并定义契约
看板直接调用 第 22 章 的后端,因此请先启动后端并确认其返回的数据结构。从 run-or-relax/ 检出目录出发,在一个独立的终端中(区别于前端终端):
uv run uvicorn departures.app:app --reload # 在 localhost:8000 提供服务
curl 'http://localhost:8000/departures?station=audley-end'
[
{"service": "07:02", "platform": "2", "delayMinutes": 0,
"minutesUntil": 10, "verdict": "RUN"}
]
看板运行在 localhost:3000,而 API 运行在 localhost:8000,因此浏览器的 fetch 请求是跨域的,后端必须发送 CORS 响应头,否则浏览器会拦截响应。这是在 第 22 章 服务中只需添加的一行代码:
在
src/departures/app.py中,添加 FastAPI 的 CORSMiddleware,允许看板的源:开发环境下为http://localhost:3000,生产环境下为部署后看板的源(从环境变量读取)。遵循 AGENTS.md。
后端可达后,前端代码库中第一个引入的内容是契约类型,AGENTS.md 规则“所有 API 响应结构必须具有显式类型”强制要求这一点。它完全镜像 第 22 章 的 camelCase JSON 结构:
// lib/departures.ts
const API_BASE =
process.env.NEXT_PUBLIC_DEPARTURES_API_URL ?? "http://localhost:8000"
export interface Departure {
service: string // 例如 "07:02"
platform: string
delayMinutes: number
minutesUntil: number
verdict: "RUN" | "relax"
}
export async function fetchDepartures(
station: string,
now?: string,
): Promise<Departure[]> {
const params = new URLSearchParams({ station })
if (now) params.set("now", now)
const res = await fetch(`${API_BASE}/departures?${params}`)
if (!res.ok) throw new Error(`departures: ${res.status}`)
return res.json()
}
URLSearchParams 对 station 和可选的 now 进行编码,因此棘手的车站 ID 不会破坏 URL;基础 URL 来源于 NEXT_PUBLIC_DEPARTURES_API_URL,使得同一次构建既可在开发环境指向 localhost:8000,也可在生产环境指向已部署的 API。now 覆盖参数是 第 22 章 暴露的同一 ISO 8601 参数:生产环境看板省略它,但测试和演示传入固定值,使看板表现具有确定性。
一个诚实的警示:Departure 接口是后端契约的镜像,而非运行时保证。TypeScript 仅检查代码期望的结构,而非线上的字节,因此类型检查器无法捕获漂移或错误的响应。共享的 verdict: "RUN" | "relax" 联合类型依然发挥作用:基于它进行分支的组件在构造上是穷尽的,若出现第三种 verdict 将导致 pnpm tsc --noEmit 失败。若想在运行时强制校验边界,可请 Codex 在返回前用 Zod schema(或手写的 isDeparture 守卫)验证响应。
组件生成与脚手架
有了 AGENTS.md,组件脚手架搭建成为 Codex CLI 最可靠的操作之一。构建提示词时应包含所有上下文,避免智能体被迫做假设:文件位置、props、state 及配套测试。
在
/components/DepartureRow.tsx创建<DepartureRow>组件。
要求:
- Props:单个
departure: Departure(类型定义于lib/departures.ts)
- 渲染发车时间、站台、延误(为零时隐藏)及距发车分钟数
- verdict 以徽章形式展示:verdict 为 "RUN" 时变体为 "run"(醒目、高对比度),为 "relax" 时变体为 "calm"
- verdict 为 "RUN" 时高亮整行(后端已编码门到站台规则,因此行组件不得重新计算阈值)
- 使用 shadcn/ui 基础组件
- 完全键盘可访问;verdict 徽章携带无障碍标签
- TypeScript strict 模式 —— 禁止
any
在
/tests/DepartureRow.test.tsx编写 Vitest 测试,覆盖:
RUN 行、relax 行、零延误情况、无障碍标签。
具体性至关重要。“展示 verdict”与“基于 verdict: "RUN" | "relax" 的 'run' 或 'calm' 变体徽章”产出截然不同。后者移除了一个决策点,若猜错则需后续修正;且因该 prop 正是来自 第 22 章 的判别联合类型,组件在构造上即穷尽:第三种 verdict 会在此处导致类型检查失败。
对于反复脚手架同类组件形状的团队(表单字段、模态框、数据获取封装),将模式编码为技能可消除逐次提示词中对约定的重复说明。创建 .agents/skills/new-component/SKILL.md:
---
name: new-component
description: Scaffold a new React component with co-located test file
---
#### 新组件技能
按项目约定创建新组件:
1. `/components/<Name>.tsx` — 含 TypeScript props 接口、Tailwind 样式、具名导出的组件
2. `/tests/<Name>.test.tsx` — 覆盖核心渲染行为及交互状态的 Vitest + Testing Library 测试
3. 向 `/components/index.ts` 添加具名导出
4. 遵循所有 AGENTS.md 约定
除非用户显式要求,否则不要创建 Storybook 故事。
以极简提示词调用技能:
codex "$new-component — BirthdayBanner 组件,接收 name 与 age,渲染庆祝语,如 'Happy 4th Birthday Alex'"
技能的最后一行(“除非被询问,否则不要创建 Storybook 故事”)并非吹毛求疵。没有它,Codex CLI 会因训练数据中该模式频繁出现,而偶尔在未被要求时生成故事。技能中的显式否定指令可防止这类好心办坏事的范围蔓延。
构建看板
利用 lib/departures.ts 中的契约,将发车板构建为两个组件:一个渲染发车信息的纯视图组件,以及一个负责获取数据并轮询的容器组件。这种拆分使视图组件极易测试,并将棘手的异步逻辑、加载态、错误处理和轮询计时器隔离在同一位置。
为发车板创建两个组件,遵循 AGENTS.md。
<DepartureBoardView> 位于 /components/DepartureBoardView.tsx:
- 接收 departures: Departure[]
- 每个发车信息渲染一个 <DepartureRow>
- 列表为空时渲染空状态(“今日无更多列车”)
- 纯粹且为展示型:无数据获取,无状态
<DepartureBoard> 位于 /components/DepartureBoard.tsx ("use client"):
- 接收 station: string
- 调用 fetchDepartures(station) 并每 30 秒重新轮询一次
- 先显示加载骨架屏,获取抛出错误则显示错误状态,数据到达后显示可见的最后更新时间
- 用结果渲染 <DepartureBoardView>
- 使用 shadcn/ui 基础组件;TypeScript 严格模式;完全支持键盘访问
编写 Vitest 测试:
- DepartureBoardView:一个 RUN 行、一个 relax 行和空状态
- DepartureBoard:使用模拟的 fetchDepartures 测试加载-然后加载完成的路径、获取被拒绝时的错误状态,以及轮询间隔推进时最后更新时间的前进
使用 Testing Library 的 userEvent 和假计时器,而非 fireEvent。传递固定的 now 时间,使测试不依赖真实时钟。
容器组件拥有所有可能出错的逻辑(加载、轮询、失败),而视图组件保持为其 departures prop 的纯函数,因此证明 RUN 徽章能正确渲染的测试,永远不需要搭建 fetch mock。容器组件刻意设计为客户端组件,因此有 "use client":静态导出没有服务端运行时,所以轮询和读取 NEXT_PUBLIC_DEPARTURES_API_URL 都在浏览器中发生。因为裁决类型与第 22 章共享,视图组件的分支情况源于其两种状态,未来后端若新增第三种裁决,会在此处导致 pnpm tsc --noEmit 失败,而不是渲染空内容。这就是契约在前端发挥作用:一种形态,镜像在通信线的两端。
挂载发车板的页面是一个轻量包装器:
创建 /app/departures/page.tsx 渲染
<DepartureBoard station="audley-end" />,遵循 AGENTS.md。
同时运行两端:在后端检出目录运行 uv run uvicorn departures.app:app --reload,在 frontend/ 运行 pnpm dev,localhost:3000/departures 将显示奥德利恩德(Audley End)发车板,每趟列车标记 RUN 或 relax,每三十秒刷新一次。
预置时刻表仅包含早班车,因此下午运行发车板会正确显示“今日无更多列车”,除非你了解时刻表,否则这看起来像是数据通路损坏。若要在任何时刻看到 RUN 和 relax 行,请传递数据层已支持的确定性 now 覆盖参数(测试使用的同一个 ISO 8601 钩子),并将发车板指向一个上午的时间点。
React 组件的测试生成
Vitest 和 Testing Library 为 React 项目定义了惯用的单元测试界面。Codex CLI 在拥有组件源码时能准确生成这些测试,但质量随提示词的具体程度而异。“为该组件编写测试”能产出覆盖率。指定场景则能产出有用的测试。
为发车板编写 Vitest 测试,遵循 AGENTS.md。
针对 <DepartureBoardView>(纯渲染):
1. 每个发车信息渲染一行
2. RUN 发车显示 RUN 徽章,relax 发车显示 relax 徽章
3. 列表为空时渲染空状态(“今日无更多列车”)
针对 <DepartureBoard>(容器,使用模拟的 fetchDepartures):
4. 先显示加载骨架屏,Promise resolve 后显示行
5. fetchDepartures 拒绝时显示错误状态
6. 轮询间隔到达时重新获取并推进最后更新时间
使用 Testing Library 的 userEvent 进行交互,使用假计时器处理轮询。传递固定的 now 时间,使测试不读取真实时钟。不要使用 fireEvent。
userEvent 指令至关重要,因为 fireEvent 触发合成 DOM 事件,而 userEvent 模拟真实浏览器行为,包括聚焦、失焦和键盘序列。^²⁰³ 使用 fireEvent 编写的测试可能在真实交互会失败时却通过了。Codex CLI 了解这两套 API,因此你必须告诉它你的代码库使用哪一种。
对于端到端测试,Playwright 技能提供了浏览器验证循环。创建 .agents/skills/playwright-check/SKILL.md:
---
name: playwright-check
description: 通过运行 Playwright 浏览器测试验证 UI 变更
---
### Playwright 检查技能
在真实浏览器中验证当前 UI 变更工作正常:
1. 启动开发服务器:`pnpm dev &`
2. 等待就绪:`npx wait-on http://localhost:3000`
3. 运行相关的 Playwright 测试:`pnpm test:e2e --grep "<组件名称>"`
4. 若该组件无测试,创建一个最小测试:
- 导航到受影响页面
- 断言关键元素渲染
- 检查无控制台错误
- 测试移动端(375px)和桌面端(1280px)视口
5. 报告结果及任何失败
在组件或页面变更后、标记任务完成前,始终运行此步骤。
在视觉正确性至关重要的提示词中引用该技能:
在页头添加搜索栏。实现后,使用 $playwright-check 验证其在移动端和桌面端断点下渲染正确。
Playwright 测试需要运行中的开发服务器,会在会话期间占用一个端口和一个进程。在 CI 中,确保端口 3000 可用,且 pnpm dev 在会话结束时能干净退出。步骤 2 中的 wait-on 工具必不可少:没有它,Playwright 会对尚未启动完成的服务器发起请求导致 404,产生虚假失败。
探索者/工作者子代理模式
第 11 章 全面介绍了子代理。^²⁰⁴ 对于前端工作,探索者/工作者模式适用于那些在生成新代码前需要理解现有约定的功能。
问题在于上下文污染。当 Codex CLI 在陌生代码库中构建新设置页面时,它在同一上下文窗口中一边阅读现有文件以理解布局、认证模型、API 形态和组件约定,一边生成新代码。阅读阶段留下的噪音减少了可用于生成的有效上下文。
探索者/工作者模式将这些阶段分离到不同的代理中:
构建发车板功能:一个显示车站下一班列车的页面,每趟带有 RUN 或 relax 裁决,并轮询更新。
按如下方式使用子代理:
探索者:
阅读 API 契约(第 22 章的 Departure 类型和 GET /departures)、/lib/departures.ts 和现有的 /components/DepartureRow.tsx。
报告:响应形态、裁决联合类型和已有的行 props。
工作者 1(探索者之后运行):
利用探索者的发现,构建位于 /app/departures/page.tsx 的板页面、轮询 fetchDepartures 的 <DepartureBoard> 容器,以及组合 <DepartureRow> 的 <DepartureBoardView>。
工作者 2(与工作者 1 并行运行):
利用探索者的发现,在 /lib/departures.ts 中编写类型化数据层:Departure 类型、API 基础 URL,以及带查询编码和可选 now 覆盖的 fetchDepartures。
工作者 3(工作者 1 和 2 之后运行):
编写覆盖板流程的 Playwright e2e 测试:RUN 状态、relax 状态、API 不可达时的错误状态,以及空的“今日无更多列车”状态。
探索者产出结构化报告(响应形态、裁决联合类型、现有行 props),工作者 1 和 2 将其作为干净的输入接收。工作者 1 和 2 并行运行,减少了具有明显 UI 和数据层关注点分离的功能的墙钟时间。工作者 3 最后运行,因为它需要实际的实现来编写有意义的测试。
图 23.1 - Explorer/Worker 子代理模式
Explorer/Worker 子代理模式的流程图。Explorer 代理读取 GET /departures 契约、数据层和现有的 DepartureRow 组件,并将响应结构、判决联合类型和可用的行属性作为结构化报告输出。该报告供两个并行运行的 Worker 使用:Worker 1 构建看板页面、轮询的 DepartureBoard 容器以及组合 DepartureRow 的 DepartureBoardView;Worker 2 编写类型化数据层(Departure 类型和 fetchDepartures)。Worker 3 在两者完成后运行,为看板流程编写 Playwright 端到端测试。
此模式对于简单的组件添加属于过度设计。当任务跨越多个子系统(UI、API、数据库模式)、阅读约定消耗大量上下文,或并行执行能显著缩短生成可测试结果的时间时,请使用它。
Explorer 的输出质量取决于你提出问题的质量。“读取代码库并告诉我你发现了什么”只会产生摘要。“读取认证布局和用户类型,并报告数据结构和布局约束”则会产生规范。后者为 Worker 提供了它们生成正确代码所需的结构化输入。
无障碍审计自动化
前端工程会悄无声息地积累无障碍技术债。视觉检查能发现明显问题,自动化 Lint 工具能发现另一组不同但有重叠的问题。两者都无法面面俱到,也都无法产生可复现的审计追踪。将 axe-core 与 Codex CLI 集成可提供广泛的覆盖面、可重复性和适合 Diff 对比的报告。
axe-core 是由 Deque Systems 维护的无障碍测试引擎,它将违规项映射到 WCAG 2.1 和 2.2 标准。^(²⁰⁵) 可通过 jest-axe 进行集成,尽管名字带有 jest,但它适用于 Vitest;也可通过 @axe-core/playwright 进行浏览器级审计。
基于 Vitest 的审计如下所示:
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
import { expect } from 'vitest'
import { DepartureBoard } from '../components/DepartureBoard'
expect.extend(toHaveNoViolations)
it('DepartureBoard has no accessibility violations', async () => {
const { container } = render(
<DepartureBoard departures={mockDepartures} /> )
const results = await axe(container)
expect(results).toHaveNoViolations()
})
创建 .agents/skills/accessibility-audit/SKILL.md 以使此模式可复用:
---
name: accessibility-audit
description: Run axe-core accessibility audit on specified components
---
#### Accessibility Audit Skill
Run an accessibility audit on the specified component(s):
1. For each component file named in the request, check whether an
axe-core test already exists in /tests/.
2. If not, create one following the pattern in /tests/DepartureBoard.test.tsx.
3. Run `pnpm test --reporter=verbose` and capture any axe violations.
4. For each violation, report:
- The WCAG criterion violated
- The specific element causing the violation
- The recommended fix
5. Implement fixes for violations rated "critical" or "serious".
6. Re-run the audit to confirm violations are resolved.
针对组件或目录调用它:
codex "$accessibility-audit — audit all components in /components/forms/"
对于浏览器级审计,它能捕捉基于 jsdom 的测试遗漏的问题(如颜色对比度和焦点陷阱行为),请使用 @axe-core/playwright 向技能添加 Playwright 步骤:
// e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('settings page has no critical accessibility violations', async ({ page }) => {
await page.goto('/settings')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
expect(results.violations.filter(v => v.impact === 'critical')).toHaveLength(0)
})
在 CI 中针对发布中包含的每个页面运行 axe-core Playwright 审计。Codex CLI 只需一个提示词即可为整个路由树生成初始测试套件:“使用 $accessibility-audit,为 /app/ 下的每个页面编写 Playwright axe 测试并在开发服务器上运行它们。”
设计到代码工作流
撰写本文时,Figma 的 MCP 服务器处于测试阶段,^(²⁰⁶) 它向兼容 MCP 的客户端暴露设计文件数据,以实现令牌级精准的组件生成。在该集成稳定之前,实用的方法是基于图像的:将设计导出为 PNG 并使用 Codex CLI 的视觉输入。
codex "Implement this design as a React component with Tailwind.
The image is at /tmp/designs/checkout-flow.png.
Match the layout as closely as possible. Flag any interactive states
(hover, focus, disabled) that aren't visible in the static image."
基于图像的提示词能准确处理结构布局;间距令牌需要人工验证。
表 23-2. 前端任务类型与推荐的 Codex CLI 模式
| 任务类型 | 推荐模式 | 技能或标志 | 备注 |
| --- | --- | --- | --- |
| 带测试的新组件 | 包含完整需求的单一提示词 | $new-component 技能 | 显式包含 Props 接口、状态和测试场景 |
| 跨越 UI + API 的功能 | Explorer/Worker 子代理 | 子代理提示块 | Explorer 先运行;Worker 并行运行 |
| 无障碍审计 | 专用审计技能 | $accessibility-audit 技能 | 同时针对 Vitest (jsdom) 和 Playwright (浏览器) |
| 视觉验证 | 每次 UI 变更后运行 Playwright 技能 | $playwright-check 技能 | 测试移动端和桌面端视口 |
| 设计到代码 | Figma MCP(若可用)或图像提示词 | MCP 或视觉 | 手动验证间距令牌;MCP 状态为测试版 |
| TypeScript 迁移 | 以 tsc 完成为标准的单文件作用域 | 无需额外技能 | 将 pnpm tsc --noEmit 添加到完成标准 |
| 组件库升级 | Explorer 读取破坏性变更;Worker 应用修复 | 子代理提示块 | 将每个 Worker 的作用域限定为单个组件文件 |
| CI 质量门禁 | 带有 Codex Action 的 GitHub Actions 工作流 | codex-action | 在匹配 components/** 的 PR 路径上运行 |
Codex 浏览器使用:视觉验证
撰写本文时,浏览器使用(Computer Use)功能在 Codex 桌面应用中可用,但在 Codex CLI GA 发布流中不可用。在依赖它用于生产工作流前,请验证你的版本中的可用性。
Codex 浏览器使用 允许代理在无头浏览器中渲染前端输出、点击浏览界面并直观验证结果,从而闭合了原本需要人工开发者检查的环路。^(²⁰⁷)
构建并验证循环
启用浏览器使用后,代理工作流变为一个闭环:
-
生成:代理编写或修改组件
-
构建:代理运行
pnpm build和pnpm dev以编译和服务 -
渲染:代理在无头浏览器中导航到服务的页面
-
验证:代理截图,将其与预期设计对比,并识别差异
-
修复:代理修改代码以解决视觉问题并返回步骤 2
这将验证从人类职责转移给了代理:它自主迭代视觉保真度,在无需开发者干预的情况下捕捉 CSS 问题、布局回归和响应式断点问题。
与 Claude Computer Use 和 Devin 的对比
撰写本文时,存在三种基于浏览器的验证竞争方案:
| 功能 | Codex 浏览器使用 | Claude Computer Use | Devin 沙箱浏览器 |
| --- | --- | --- | --- |
| 环境 | 无头,代理控制 | 前台,锁定开发者桌面 | 沙箱虚拟机 |
| --- | --- | --- | --- |
| 与钩子集成 | | 完整:审批钩子和 PostToolUse 钩子均适用 | 部分:受限钩子模型 | 不适用(专有) |
| 成本模型 | | 包含在会话中 | Vision API 按截图收费 | 包含在 Devin 订阅中 |
| 适用场景 | | 迭代式前端验证循环 | 跨任意应用的 GUI 自动化 | 自包含的项目执行 |
Codex 的方案与现有的钩子和审批基础设施集成。每个浏览器操作都受制于管控 Shell 命令和文件写入的相同 PreToolUse 和 PostToolUse 钩子。企业团队可使用管控所有其他代理操作的相同治理模式,对浏览器交互进行审计、应用策略,并记录截图以供合规审查。
何时使用浏览器验证
浏览器使用会增加延迟和 Token 成本。请有选择地使用:
-
推荐: 完成视觉组件后的最终验证、响应式布局测试、提交前的视觉回归检查
-
不推荐: 业务逻辑的快速迭代、纯 API 变更、无视觉组件的后端工作
完成标准模式可自然延伸:将 pnpm dev && screenshot localhost:3000 && verify-visual 添加到标准链中,只有当视觉输出符合预期时,代理才会将任务标记为完成。
总结
-
前端 AGENTS.md 解答五个问题。 包管理器、React 版本及允许的模式、组件库安装方式、测试框架和文件系统边界。缺少这些,Codex 会默认采用看似合理但实则错误的假设。
-
完成标准使代理具备自我修正能力。 列出
pnpm build、pnpm test和pnpm tsc --noEmit,Codex CLI 会迭代直到三者全部通过。 -
将重复模式编码为技能。 诸如
$new-component和$playwright-check之类的技能,消除了逐提示词重新指定约定的需要,并减少了范围蔓延。 -
探索者/工作者模式将发现与生成分离。 对于跨越多个子系统的功能,或上下文污染会降低生成质量的场景,请使用该模式。
-
axe-core 使无障碍审计可复现。 通过
jest-axe在 Vitest 中集成,通过@axe-core/playwright在端到端测试中集成,它生成映射 WCAG 的审计报告,Codex CLI 可在单个会话中生成、运行并修复这些报告。 -
设计转代码有两条路径。 编写本文时,Figma 的 MCP 服务器处于测试阶段;基于视觉的工作流可准确覆盖结构布局,但间距 Token 需人工核验。表 23-2 按任务类型总结了推荐模式。
-
Codex 浏览器使用闭合了构建-验证循环。 它实现了自主视觉 QA,但请有选择地使用:对于纯后端工作,其延迟和 Token 成本无法得到证明是合理的。
-
前端是 第 22 章 后端的门面。
<DepartureBoard>容器直接调用GET /departures(基础 URL 来自NEXT_PUBLIC_DEPARTURES_API_URL,后端已启用 CORS),纯<DepartureBoardView>依据共享的"RUN" | "relax"联合类型进行切换,因此契约在两端均被镜像。看板作为静态导出生成,由 第 24 章 与 API 一同部署。
练习
-
前端 AGENTS.md。 选取一个你拥有的 React 项目。编写一个
AGENTS.md,回答本章的五个问题。添加包含至少三条命令的完成标准部分。用 Codex 运行一个脚手架任务,记录AGENTS.md阻止了哪些约定违规,哪些遗漏了。 -
探索者/工作者模式。 找出一个同时涉及新 UI 界面和新 API 端点的功能。使用探索者/工作者模式编写一个子代理提示词:一个探索者报告数据结构和约束,两个工作者并行实现前端和后端,一个工作者编写 e2e 测试。在 Codex 会话中运行它,或分析每个工作者需要何种上下文以及探索者报告应如何构建。
-
无障碍审计流水线。 创建本章的
$accessibility-audit技能并针对三个组件运行。对于每个组件,记录axe-core在 Vitest (jsdom) 运行与 Playwright (浏览器) 运行中分别捕获了哪些违规。找出一个仅在浏览器层面被捕获的违规并解释为何 jsdom 未能捕获。 -
设计转代码工作流。 对需要构建的 UI 组件截图。使用 Codex CLI 的视觉输入生成初始实现。在三个维度上将结果与原图对比:结构准确性(层级与嵌套)、视觉保真度(间距、颜色、排版)和无障碍性(ARIA 属性、键盘导航)。找出哪个维度最准确,哪个需要修正最多。
-
配置 TypeScript 专用护栏。 添加一个匹配文件编辑工具的
PostToolUse钩子,在 TypeScript 项目的每次文件写入后运行pnpm tsc --noEmit。通过让 Codex 编写一个故意包含类型错误的函数来测试它。验证钩子触发、错误浮现,且 Codex 在完成任务前修正了它。记录钩子配置及其防止的失败模式。
第二十四章:基础设施即代码
前两章构建了 Run or Relax:第 22 章 的后端,第 23 章 的前端。两者均在笔记本电脑上运行。要对通勤者有用,它们需要一个栖身之所,而搭建它本身就是一项工作:基础设施。本章将技术栈推向生产环境,且同其他每一章一样,Codex CLI 协助编写。
问题在于基础设施非应用代码,这种差异改变了你与代理协作的方式。核心在于影响范围。糟糕的应用代码行导致测试失败;糟糕的基础设施代码行可能拖垮生产环境,或删除其原本旨在托管的资源。因此代理的角色收窄:它规划、起草并提议,人类审查并应用。让代理停留在该界线的“提议侧”是本章剩余内容的主题,随之而来的挑战包括:幻觉资源、未经审查变更的代价,以及守住底线的安全配置。
我们以 Google Cloud Run 为具体部署目标,采用云原生 GCP 项目相同的技术。^(²⁰⁸) 但本章不关于 Google Cloud 配置;而是关于为基础设施工作配置 Codex CLI,以使该边界得以守住。
学习目标
你将能够:
-
编写适配基础设施仓库的
AGENTS.md文件 -
配置适合基础设施变更的审批模式和沙箱设置
-
使用 Codex CLI 生成、审查和重构部署服务到 Cloud Run 和静态网站到 Cloud Storage 的 Terraform 代码
-
使用确定性工具(
tflint、checkov)闭环,并将其包装在/goal中 (第 12 章) -
应用安全护栏,防止未经审查的基础设施变更
-
将 Codex CLI 集成到基础设施 CI/CD 流水线中
-
通过 Terraform MCP 服务器,让代理建立在最新的提供商文档之上
-
使用 GitOps,使应用边理由架构而非仅策略来强制执行
-
以只读
codex exec流水线运行漂移检测等第二天运维操作
为何基础设施代码抗拒代理
在配置任何内容之前,了解为何基础设施代码比应用代码更难驾驭代理会有所帮助,理由不止影响范围。
大型语言模型以令人担忧的速率幻觉出 Terraform 资源类型和属性名称,这在 Python 中可能只是个小麻烦,但在资源定义中却具有破坏性。^(²⁰⁹) 原因是结构性的。Terraform 所使用的 HashiCorp 配置语言 HCL,其训练语料库与 Python 或 TypeScript 相比非常小,而且提供商模式随每个版本发布而变化:Google Provider 暴露了数百种资源类型并经常修订其参数,因此六个月前训练的模型会自信地生成已不存在的属性。
Cloud Run 资源就是一个典型例子:它已从 google_cloud_run_service 重命名并重构为 google_cloud_run_v2_service,而学习了旧结构的模型会尝试访问新资源并不具备的字段。关于 Terraform 合成的研究发现,即使是强模型也难以驾驭它,经常直接凭空捏造资源类型和属性名称。^(²¹⁰)
如果放任不管,Agent 编写的基础设施会以可预测的方式失败:本应是变量的值被硬编码、本应是模块的代码却成了整体式文件、过度宽松的 IAM 绑定、缺少加密,以及没有自动化测试或策略检查。^(²¹¹) terraform validate 无法捕捉其中任何一项,而且其中几项正是你最不希望进入生产环境的变更。
对策不是避免使用 Agent,而是对其加以约束。基础工作优先:一份编码你的约定与禁令的 AGENTS.md,配合 Agent 与提交之间的确定性检查。这足以构建并交付技术栈。然后,一旦它站稳脚跟,模型上下文协议(MCP)服务器通过向 Agent 提供实时、经过验证的提供商文档,而非陈旧的训练数据,进一步强化生成能力。本章剩余部分将按此顺序,基于这三者展开。
面向基础设施仓库的 AGENTS.md
基础设施仓库的约定与应用代码不同。AGENTS.md 文件应显式编码这些差异:
#### AGENTS.md
##### 测试命令
- `terraform fmt -recursive` 用于格式化
- `terraform init -backend=false` 然后 `terraform validate` 用于语法检查
- `terraform plan -out=tfplan` 用于变更预览
- `tflint --init` 然后 `tflint` 用于静态分析(Google 规则集;`--init` 写入 TFLint 插件缓存)
- `checkov -d . --compact` 用于安全扫描
##### 约定
- 提供商:google。目标运行时:Cloud Run;静态资产:Cloud Storage
- 所有资源使用命名约定:`{project}-{env}-{resource}`
- 变量定义在 `variables.tf` 中,输出定义在 `outputs.tf` 中
- 远程状态在 `backend.tf` 中配置(GCS 后端):切勿修改它
- 使用 `locals` 块定义计算值,而非内联表达式
- 每个服务一个服务账号;切勿使用默认 Compute SA
- 提交 `.terraform.lock.hcl`;切勿将其加入 gitignore(它固定提供商版本)
##### 禁区
- 切勿运行 `terraform apply`、`terraform destroy` 或 `gcloud run deploy`
- 切勿直接修改 `.terraform.lock.hcl`
- 未经明确批准,切勿修改 `modules/shared/` 下的文件
- 切勿提交凭据、令牌或密钥值
关键约束在于禁止 terraform apply,此处将其延伸至 gcloud run deploy,因为后者跨越了相同的边界,直接将版本推送到生产环境。与应用代码不同(运行测试是安全的),这些命令可能产生即时且不可逆的影响。Agent 应负责规划、验证和提议;由人工审核并应用。
但仅写在 AGENTS.md 中,该禁令只是建议:Agent 会读取它,通常遵守它,但在状态不佳时仍会运行 terraform apply。对于影响范围达生产环境的命令,“通常”是不够的。将该指令与钩子(第 9 章)配对,直接阻断这些命令,将请求变成一堵墙。PreToolUse 钩子在 Shell 命令运行前触发,可通过非零退出来拒绝执行:
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash .codex/scripts/block-apply.sh",
"statusMessage": "Checking for apply-boundary violations…",
"timeout": 5
}
]
}
]
}
}
该脚本读取待执行命令,并拒绝任何会更改实时基础设施的操作:
#!/usr/bin/env bash
#### .codex/scripts/block-apply.sh
##### 无论提示词要求什么,阻断跨越应用边界的命令。
set -euo pipefail
payload="$(cat)" # PreToolUse JSON,包含正在运行的命令
if printf '%s' "$payload" \
| grep -qE 'terraform[[:space:]]+(apply|destroy)|gcloud[[:space:]]+run[[:space:]]+deploy'; then
echo "Blocked: apply/destroy/deploy cross the apply boundary. A human runs these." >&2
exit 2
fi
现在,即使指令失效,边界依然坚守。Agent 仍可随意 plan 和 validate;唯一那类触达生产环境的命令被拒之门外,由人工在 Agent 之外刻意执行。同一个钩子可推广到团队想要管控的任何不可逆命令,如 kubectl delete 和 gcloud sql。
只读沙箱模式(-s read-only)是基础设施工作最安全的起点。Agent 可以读取你的模块、生成计划并提议变更,但无法写入文件或执行命令。仅当你准备好让 Agent 编写 Terraform 文件时,才升级至 workspace-write。PreToolUse 钩子是该 workspace-write 阶段的兜底保障:当 Agent 能运行命令时,禁令必须被强制执行,而非寄希望于信任。
基础设施配置在仓库中的位置
在单语言仓库中,此 AGENTS.md 是根文件。Run or Relax 不是单语言仓库。到目前为止,仓库根目录已有来自第 22 章的 Python AGENTS.md,frontend/ 下有来自第 23 章的 React AGENTS.md,它们各自限定自己的工具链。基础设施文件是同类的第三个,它属于自己的目录,而非根目录:
run-or-relax/
├── AGENTS.md # Python: uv, pytest, ruff (第 22 章)
├── src/departures/ # FastAPI 服务
├── frontend/
│ └── AGENTS.md # React: pnpm, vitest (第 23 章)
└── infra/
├── AGENTS.md # Terraform: validate, tflint, checkov, never apply
└── modules/ # cloud_run, site, ...
Codex CLI 按作用域顺序合并 AGENTS.md 文件,最深层优先,即第 22 章中的嵌套覆盖行为,因此 infra/ 下的文件仅在 Agent 处理基础设施时生效。这种作用域划分并非表面功夫。根文件规定所有工具通过 uv run 运行,且任务在 pytest 通过时完成,这对 Terraform 完全错误:若将基础设施约定放在根目录,它们将与 Python 约定在三分之二的仓库范围内相互矛盾。将它们放在 infra/ 中,Agent 就在编写 HCL 的地方准确获得 terraform validate 和应用禁令,而 Python 和 React 规则则完全不在其列。
保持该文件仅包含差异部分。真正共享的约定——规范化提交和绝不提交密钥——从根文件继承而来,因此基础设施 AGENTS.md 仅携带基础设施特有的内容:Terraform 与扫描器命令,以及最重要的是对 apply、destroy 和 gcloud run deploy 的禁止。重复其余内容只会在根文件变更时造成配置漂移。
通过确定性工具闭环
AGENTS.md 能够减少 Agent 起步阶段的错误,但无法完全消除。互补的做法是:在 Agent 与提交之间加入一道确定性检查,并将检查结果反馈给 Agent。Linter 和安全扫描器正是判断 Agent 编写基础设施代码的最佳“裁判”:它们速度快,每次返回相同的裁决,且每条发现都指向具体的文件和行号。Agent 是概率性的,而这些工具不是,这种反差正是它们的价值所在。
有两款工具几乎在每个 Terraform 仓库中都占有一席之地:
-
tflint能捕捉terraform validate遗漏的问题:弃用语法、无效参数及其他 Provider 特有的错误、未使用的声明,以及AGENTS.md规定的命名约定。启用 Google 规则集,使其了解 Cloud Run 和 Cloud Storage 的特定细节。 -
checkov能从本章一开始就扫描出安全误配置:未加密存储、公共入口、过度权限的 IAM —— 这些terraform validate会直接放行。其基于图的检查能跨资源进行推理,而非逐个检查,因此能捕捉单资源扫描器遗漏的关系性错误;且在检查失败时返回非零退出码,这使其可作为质量门禁使用。
提升来自于在会话内部运行这些工具,而非事后。因为工具是确定性的,“退出码为零”是一个明确无歧的目标,Agent 可以针对它反复迭代;这不像“使其安全”这种目标,Agent 只能大致逼近:
codex --profile infra-write "生成用于作为静态网站托管 Run or Relax 看板的 Cloud Storage 存储桶模块,需求见 README.md。随后在结果上运行 tflint 和 checkov,修复它们报告的每一个问题。重复此过程,直到两条命令均退出码为零,然后展示最终的 diff。"
每一轮迭代都在缩小“看似合理的 HCL”与“能通过检查的 HCL”之间的差距。典型流程是:Agent 生成模块,收到 checkov 警告存储桶缺少统一存储桶级访问权限或版本控制,添加两者,重新运行,仅在工具不再报警时停止。
有一条发现结果不应被静默,且极具指导意义:网站存储桶必须向 allUsers 授予 roles/storage.objectViewer,而 checkov 每次都会标记该公共读取权限。存储桶旨在公开,因此正确的做法是添加带有文档说明的行内抑制,而非修改代码。你将看到一个除了这条发现外已经干净的 diff,你可以阅读并批准其抑制理由。
这也是 Goal Mode(第 12 章)大显身手的地方。目标需要一个位于 Agent 之外的验证界面,一个其退出码能回答“完成了吗?”的命令,而一次干净的 tflint 和 checkov 运行正是如此。用 /goal 包装循环,可使停止条件显性化,并以 Token 预算限定工作量。在会话内设置它,/goal 是在 Codex TUI 中输入的斜杠命令,而非 Shell 参数:
/goal "根据 README.md 为 Run or Relax 看板生成静态网站存储桶模块。当 terraform validate、tflint 和 checkov 均退出码为零,且公共读取发现已通过行内抑制并附带理由时视为完成。仅在 modules/site/ 下编写文件。"
Agent 会迭代直到检查通过或预算耗尽,因为完成标准是真实的命令而非其自我判断,所以它无法在基础设施仍未通过扫描器时宣称胜利。在启动前,请将 第 12 章 的五问评估量表应用于目标陈述。应用边界依然存在:目标负责编写和验证;绝不运行 terraform apply。
同样的工具也应作为不可商量的质量门禁存在于 CI 中,这样人工审查 Pull Request 时,看到的仅是已经通过检查的变更:
#### .github/workflows/terraform-review.yml (lint and scan step)
- name: Lint and scan
run: |
tflint --format compact
checkov -d . --compact
界限清晰:确定性工具捕捉可编目录举的问题,而非意图。它们会告诉你存储桶是全球可读的;但不会告诉你这是静态网站(没问题)还是数据存储桶(泄露)。它们不会告诉你模块抽象是否错误,或执行计划是否会删除服务。这种判断留待本章后面的计划审查,以及执行 apply 的人类。Linter 提高了底线;它们无法替代天花板。
Terraform 工作流
模块生成
Codex CLI 根据自然语言描述生成 Terraform 模块。看板的存储桶是 Run or Relax 部署中最小的组件,因此是一个很好的首个模块:
创建一个用于作为静态网站托管 Run or Relax 看板的 Cloud Storage 存储桶 Terraform 模块,要求:
- 启用统一存储桶级访问权限
- public_access_prevention 设为 "inherited" 而非 "enforced":公共网站存储桶需要此设置,因此添加一条行内 checkov 抑制并解释该例外
- 网站配置(索引页 index.html,404 页 404.html)
- 对象版本控制(以便不良的看板部署可回滚)
- 通过授予 allUsers roles/storage.objectViewer 的 IAM 绑定实现公共读取,附带一条行内 checkov 抑制说明这是有意公开的站点
- 存储桶名称由项目和环境构建(例如 ${project}-${env}-rr-board),因为存储桶名称全局唯一且上限为 63 字符
- 包含项目、环境和位置的变量
- 输出存储桶名称、站点 URL(index.html 地址)以及源(仅协议和主机),供 API 的 CORS 配置使用
Agent 将按照 AGENTS.md 中的约定生成 main.tf、variables.tf 和 outputs.tf。在提交前运行 terraform validate、tflint 和 checkov 验证输出,或让 Agent 自行运行并迭代直到通过,如前文所述的确定性工具循环。
计划审查
计划审查是基础设施团队最高价值的模式,因为它完全位于 apply 边界的安全一侧:
-
运行
terraform plan -out=tfplan并捕获输出 -
请求 Codex CLI 审查计划中的风险、非预期变更和安全顾虑
-
Agent 识别正在被销毁、修改或创建的资源,并标记任何异常情况
terraform plan -out=tfplan -no-color > plan-output.txt codex exec -a untrusted -s read-only "审查 plan-output.txt 中的 Terraform 计划,找出风险和非预期变更。标记任何资源删除、IAM 绑定变更,或会导致流量切换的 Cloud Run 修订版本。"
重构
基础设施重构遵循与 第 21 章 中应用重构相同的模式,但约束更严格。工作内容很熟悉:将单体配置拆分为模块、升级 Provider 版本、从弃用资源迁移,正是让未落地的 Agent 翻车的 google_cloud_run_service 到 google_cloud_run_v2_service 的迁移。
将 `main.tf` 中的 Cloud Run 配置重构为 `modules/cloud_run/` 下的独立模块。
保留所有资源地址,以便重构后 `terraform plan` 显示无变更。
每次变更后运行 `terraform validate` 以验证语法。
零 diff 约束至关重要:在生产环境中触发资源重建的重构不算重构。重建而非更新 Cloud Run 服务会导致其 URL 和流量丢失;保留资源地址可使计划为空。
部署到 Cloud Run
部署的核心
这是“运行还是放松”部署的核心,也是三章内容汇聚之处。API 是一个容器;Cloud Run 运行容器,并在没人询问是否为 07:02 班次发车时将其缩容至零。看板是静态文件;Cloud Storage 存储桶以运行服务器成本的一小部分来托管它们。Terraform 供应这两者,而 Codex CLI 编写 Terraform 代码。
在开始这一切之前,先确定 Chapter 24 和 #n 留下了什么,因为部署直接连接到它们:
-
FastAPI 应用可作为
departures.app:app导入,这是容器启动命令所需的路径。 -
Cloud Run 将流量路由到
$PORT中命名的端口(默认为 8080),因此服务器必须绑定0.0.0.0:$PORT,而不是开发环境的默认值。 -
看板构建输出到静态
out/目录(来自 Chapter 23 的output: "export");该目录即上传到存储桶的内容。 -
看板从
NEXT_PUBLIC_DEPARTURES_API_URL读取 API 地址,该地址在构建时烘焙进去,而 API 通过RUN_OR_RELAX_CORS_ORIGIN允许看板访问。这两个值是接缝处:搞错它们,部署后的看板将渲染空白,而所有测试依然显示通过。
容器入口点
构建包会检测 Python 项目并构建镜像,无需 Dockerfile,但它无法猜测如何在 Cloud Run 的端口上启动 Web 服务器。单行 Procfile 消除了歧义:
###### Procfile
web: uvicorn departures.app:app --host 0.0.0.0 --port ${PORT:-8080}
${PORT:-8080} 在生产环境中采用 Cloud Run 注入的 $PORT,在本地则回退到 8080。如果省略它,镜像仍能构建且部署仍会成功,但容器永远不会响应,Cloud Run 会在健康检查中将其终止——这看起来像基础设施故障,实则是缺少启动命令。
API 发布前还有一个问题需要回答:其数据从何而来。真正的“运行还是放松”会从火车时刻 API 读取实时发车和延误数据;timetable.json 和 delays.json 只是占位符,以便本章有内容可部署。作为一次性示例数据,它们随镜像 内部 搭载,无需额外供应。生产系统则会将服务指向实时 API,其基础 URL 作为配置,密钥存放在 Secret Manager 中,但这项工作超出了本章范围,也不影响后续的 Codex 工作流。
认证与引导远程状态
Agent 编写 Terraform;它不持有你的云凭据,也无法创建存放自身状态的存储桶。两者均为人工、一次性的设置,必须在任何模块应用之前完成,跳过它们是首次部署死于 terraform init 的最常见方式。首先进行认证,将应用程序默认凭据指向项目:
gcloud auth login
gcloud auth application-default login
gcloud config set project "$PROJECT_ID"
gcloud auth application-default set-quota-project "$PROJECT_ID"
随后在 Terraform 之外创建状态存储桶,因为后端必须先存在,Terraform 才能用它来存储状态,这是一个真正的“鸡生蛋、蛋生鸡”问题,无法通过任何 terraform apply 解决:
gcloud storage buckets create "gs://${PROJECT_ID}-terraform-state" \
--project="$PROJECT_ID" --location="$REGION" --uniform-bucket-level-access
gcloud storage buckets update "gs://${PROJECT_ID}-terraform-state" --versioning
仓库附带一个带有占位符存储桶名的 backend.hcl.example;它无法直接使用。将其复制为 backend.hcl 并填入你真实的、全局唯一的存储桶名。状态存储桶名称在整个 Google Cloud 中必须唯一,因此 PROJECT-terraform-state 必须改为特定于项目的名称:
###### infra/backend.hcl
bucket = "your-project-id-terraform-state"
prefix = "run-or-relax/dev"
现在初始化后端,每当 backend.hcl 变更时重新运行并加上 -reconfigure:
terraform -chdir=infra init -reconfigure -backend-config=backend.hcl
即使 terraform init 因后端失败,terraform validate 仍可能打印 Success! The configuration is valid.,因为它仅检查本地文件的语法和提供商模式。绿色的 validate 并非后端初始化成功、状态存储桶存在或凭据健康的证据。请看 init 的结果,而非 validate 的结果,以判断部署就绪情况。
如果 init 因 auth: "invalid_grant" 失败,问题出在认证而非你的 HCL:凭据过期、账号错误,或环境变量强制覆盖了服务账号密钥。重新运行前述的 ADC 登录,若有密钥被强制设置,则清除它:
gcloud auth list # 哪个账号处于活动状态
echo "$GOOGLE_APPLICATION_CREDENTIALS" # 是否有密钥被强制设置?
unset GOOGLE_APPLICATION_CREDENTIALS
terraform -chdir=infra init -reconfigure -backend-config=backend.hcl
本小节从人工视角诠释了本章主题:Agent 始终处于边界的“提议方”一侧,而凭据和状态存储桶——此处仅有的两个真正能接触账户的东西——由人设置。
引导:API 与镜像仓库
首次镜像推送前还需两样东西就绪,两者均由 Terraform 一次性创建:堆栈使用的 Google API,以及镜像落地的 Artifact Registry 仓库。将它们作为引导模块生成:
创建一个 Terraform 引导模块,要求:
- 使用
google_project_service启用所需的 Google API:Cloud Run、Cloud Build、Artifact Registry、IAM 和 Cloud Storage
- 在目标区域创建名为
run-or-relax的google_artifact_registry_repository(Docker 格式)。Google 托管加密对教学仓库足矣,因此为 customer-managed-key 发现添加内联 checkov 抑制
- 项目和区域的变量;输出仓库路径
该模块必须在其余部分 之前 应用,原因在于一个真实的首部署顺序陷阱。对整个堆栈进行单次 terraform apply 会同时规划 Cloud Run 服务和 Artifact Registry 仓库,但服务引用的镜像在仓库创建前无法存在,导致应用失败。单独先应用引导模块(或将其作为拥有独立状态的单独 Terraform 根模块,长远来看更整洁),然后构建,最后应用其余部分:
图 24.1 - 部署到 Cloud Run
部署作为有序序列运行:人工认证并在 Terraform 外创建状态存储桶,然后运行 init,第一阶段应用引导模块,构建并推送镜像,规划,Agent 评审规划,第二阶段应用,上传看板。虚线标记鸡生蛋依赖:仓库必须在镜像构建前存在,镜像必须在第二次应用前存在。
###### 阶段 1:仅 API 和仓库
terraform -chdir=infra apply -target=module.bootstrap
构建前,添加 .gcloudignore,以便 Cloud Build 上传源代码而非虚拟环境、node 模块、缓存和本地 Terraform 状态,这关乎几百个文件与一吉字节的区别:
###### .gcloudignore
.git
.venv/
.uv-cache/
.mypy_cache/
.pytest_cache/
.ruff_cache/
**/__pycache__/
frontend/node_modules/
frontend/.next/
frontend/out/
infra/.terraform/
infra/backend.hcl
infra/*.tfvars
infra/tfplan
infra/plan-output.txt
现在构建并推送。标签不仅仅是装饰:它必须与 Cloud Run 模块读取的 image_tag 变量匹配,否则 Terraform 将部署一个不存在的标签。
gcloud builds submit --pack image=REGION-docker.pkg.dev/PROJECT/run-or-relax/api:v1 .
默认构建包从 Chapter 22 拾取项目的 3.14 版本锁定并无需额外配置即可构建;保持运行时最新正是让这一步保持平淡无奇的关键。
Cloud Run 服务
后续部署会更改正在运行的基础设施,因此这是 Terraform 的工作,而 Agent 的工作是编写它:
创建一个 Terraform 模块,将 Run or Relax API 部署到 Cloud Run,要求:
- 一个运行镜像 `REGION-docker.pkg.dev/PROJECT/run-or-relax/api` 的
`google_cloud_run_v2_service`,镜像标签为变量 `image_tag`
- 容器监听端口 `8080`
- 一个专用服务账号(最小权限),而非默认的 Compute SA
- 实例数从 0 扩缩容至最多 4 个
- 将环境变量 `RUN_OR_RELAX_CORS_ORIGIN` 设置为看板的源站(仅 scheme 和 host,
例如 `https://BUCKET.storage.googleapis.com`,不含路径)
- 公开的 invoker 绑定(向 `allUsers` 授予 `roles/run.invoker`):看板直接从浏览器
调用 API,因此该服务有意设为公开
- `project`、`region`、`image_tag` 和 `cors_origin` 变量
- 一个输出服务 URL 的 output
该提示词中的两行正在进行跨章节连线,值得注意,因为这正是部署通常出故障的地方。RUN_OR_RELAX_CORS_ORIGIN 是 FastAPI 服务为其 CORS 中间件读取的环境变量(第 22 章),它需要一个源站——仅包含 scheme 和 host,不含路径(https://BUCKET.storage.googleapis.com),而不是人类访问的 index.html URL。将其设置为看板存储桶的源站,浏览器的跨域 fetch 就会被允许;如果留空,或设置为完整的页面 URL,来自已部署看板的每个请求都会被拦截,而每个后端测试仍会通过。看板存储桶的模块正因如此输出这两个值:给人的 site_url 和给 API 的源站。而该模块输出的 Cloud Run 服务 URL,正是看板在构建时作为 NEXT_PUBLIC_DEPARTURES_API_URL(第 23 章)注入的值。两个服务通过 Terraform 的输出和变量互相发现,而非硬编码字符串,这才使得同一配置能同时支撑仅 .tfvars 不同的开发环境和生产环境副本。
公开的 invoker 绑定是你有意保留的几个 checkov 发现之一,表 24-1 列出了它们。向 allUsers 开放 Cloud Run 服务通常是危险信号;但这是一个公开的只读发车看板,因此该抑制是诚实的,审阅者也能明白原因。与存储桶一样,工具捕获可编目的问题,人类提供意图。
计划前先验证
首先按 Google 规则集要求的顺序,对 infra/ 运行确定性工具(tflint --init 会在 tflint 使用前安装插件):
terraform fmt -recursive infra
terraform -chdir=infra init -backend=false
terraform -chdir=infra validate
tflint --chdir=infra --init
tflint --chdir=infra
checkov -d infra --compact
这种 init -backend=false 是离线路径:它在无凭据、无状态后端的情况下验证语法和 Provider 模式,这正是 Agent 循环所需的。带后端配置的真实 init 来自 bootstrap 步骤,是部署时使用的那个。无论哪种方式,terraform init 都会下载 Provider 插件并写入 .terraform.lock.hcl;请提交该锁文件,以便每台机器和 CI 运行都解析到相同的 Provider 版本。
某些沙箱 Agent 环境会阻止 Provider 下载和插件安装,因此 terraform init、terraform validate、tflint --init 和 checkov 的元数据查询可能在受限沙箱中停滞或失败。若出现这种情况,请批准网络访问或在普通终端运行验证步骤;那是工具在访问注册表,而非你的配置有误。
计划、审阅、应用、再上传
在任何内容发布前先计划和审阅,沿用之前同一个安全侧边界循环:
terraform -chdir=infra plan -out=tfplan -no-color > plan-output.txt
codex exec -a untrusted -s read-only "Review plan-output.txt. Confirm the Cloud Run service, its service account, the IAM invoker binding, the Artifact Registry repository, and the static-site bucket are created as expected, and flag anything else."
人类阅读审阅结果并运行 terraform apply,这是第二阶段的 apply,此时 Cloud Run 服务引用的镜像已存在。这会创建存储桶,但不会填充内容。上传已构建的看板是一个单独的内容部署步骤,有意与预置分离,这也是 第 23 章 中静态 out/ 目录与 Terraform 刚创建的存储桶相遇的唯一地方:
gcloud storage cp -r frontend/out/* gs://$(terraform -chdir=infra output -raw board_bucket)/
现在 Run or Relax 在 Cloud Run URL 后回答 GET /departures,而看板从存储桶渲染它。始于纯判定函数的线程终于部署服务而告终,且 Agent 从未跨越 apply 边界。
预期的 Checkov 发现
此栈上干净的 checkov 运行并非零发现,而是零未解释发现。少数发现每次都会触发,且保留它们是正确的,因为对于一个小型公开教学部署,被标记的行为是有意为之。纪律要求在行内用审阅者可读的注释逐个抑制,而非整体静默扫描器。表 24-1 是预期的集合。
表 24-1. 预期的 Checkov 发现及对应决策
| 发现 | 资源 | 决策 |
| --- | --- | --- |
| 公开读取(allUsers → roles/storage.objectViewer) | 看板存储桶 | 跳过,行内论证:有意为公开网站 |
| 未强制执行公开访问防护 | 看板存储桶 | 跳过,行内论证:公开网站需要例外;日志存储桶保持强制执行 |
| 未配置访问日志接收器 | 看板存储桶 | 教学部署跳过;生产环境启用存储桶日志记录 |
| 无客户管理加密密钥 | Artifact Registry 仓库 | 跳过:Google 托管加密在此足矣;策略要求时使用 CMEK |
| 公开调用者(allUsers → roles/run.invoker) | Cloud Run 服务 | 跳过,行内论证:看板是公开的只读 API |
除此表之外的任何发现都是需修复的真实问题,而非已知例外,这正是该门禁存在的意义:噪音已编目,因此下一个新的失败会凸显出来。
位置比看起来更重要。#checkov:skip 注释必须位于其适用的资源块内部,作为块主体的第一行,而不能在 resource 声明的上一行。Checkov 3.3.0 仅遵循块内发现的抑制;块上方的注释会被忽略,发现依然会触发:
resource "google_storage_bucket_iam_member" "board_public_read" {
#checkov:skip=CKV_GCP_28:Intentional public website; the board is read-only static content
bucket = google_storage_bucket.board.name
role = "roles/storage.objectViewer"
member = "allUsers"
}
Check ID 后的文本即为论证,这是审阅者阅读的部分。一条抑制、一个资源、一个可批准的理由,这种纪律让表 24-1 的编目保持诚实。
当 Cloud Run 不够用时:GKE
Cloud Run 完美契合 Run or Relax:一个可缩容至零的无状态 HTTP 服务。当服务超出其承载范围时——持续流量使缩容至零不再有帮助、需要 sidecar、细粒度网络、或非请求-响应工作负载——目标变为 Kubernetes,产物变为清单而非 Cloud Run 资源。Codex 模式不变;仅验证命令改变。将它们添加到 AGENTS.md:
##### 测试命令
- `kubectl apply --dry-run=client -f .` 用于语法验证
- `kubeconform -strict .` 用于模式验证
代理会从与 Cloud Run 模块相同类型的需求提示词中生成 Deployment、Service 和 HorizontalPodAutoscaler,包括副本数量、资源限制、针对 /healthz 的探针以及 ConfigMap 引用,随后你可在提交前使用 kubectl 和 kubeconform 命令验证输出结果。这一逃生通道真实存在,但极少需要;只有当 Cloud Run 的限制(而非你的习惯)强制你迁移时,才应采用它。
通过 Terraform MCP 服务器强化生成
技术栈现已就绪,但仍有一种故障模式仅能在事后被发现,而无法被预防:过时的提供商知识。AGENTS.md 编码了你的规则,扫描器能捕获不安全的输出,但二者都无法阻止代理编造一个已不存在的资源参数。一旦主干路径建立,这就是对其进行强化的增强措施。这项工作由 HashiCorp 官方的 Terraform MCP 服务器承担,它赋予任何模型上下文协议客户端(包括 Codex CLI)实时访问 Terraform Registry 的能力,而非依赖模型的训练快照。^(²¹²) 第 8 章 概述了 MCP 的通用设置;基础设施特有的决策在于你暴露哪些工具。
这正是 Google 提供商频繁变更造成麻烦的地方,因此也是实时查询大显身手之处:代理确认 google_cloud_run_v2_service 的当前参数,而不是去猜测它在本章早些时候绊倒的那个已重命名的资源。将该服务器添加到项目的 .codex/config.toml 中,并通过 enabled_tools 限定其作用域,以便代理仅能看到只读的注册表查询工具:
[mcp_servers.terraform]
command = "docker"
args = ["run", "-i", "--rm", "hashicorp/terraform-mcp-server"]
enabled_tools = [
"search_providers", "get_provider_details", "get_latest_provider_version",
"search_modules", "get_module_details", "get_latest_module_version",
"search_policies", "get_policy_details",
]
这些工具能回答那些原本会导致幻觉的问题:资源实际接受哪些参数、提供商或模块的当前版本、模块是否经过验证,以及适用哪些策略。^(²¹³) 代理会查阅这些信息,而不是靠猜测。
该服务器还附带用于 HCP Terraform 和 Terraform Enterprise 的工作区管理工具,包括创建运行、应用或丢弃操作。^(²¹⁴) 这些工具跨越了应用边界,因此请将它们排除在无人值守运行的代理配置文件的 enabled_tools 列表之外。此模式贯穿全章:暴露读取操作,保留应用操作。注册表查询可安全自动化;触发运行则不然。
GitOps:将应用边界结构化
截至目前,所有措施皆通过策略来强制执行应用边界:AGENTS.md 禁令、沙箱模式、人工把关的流水线步骤。GitOps 则通过架构来强制执行,这更为稳健,因为它不依赖于代理遵守指令。
在 GitOps 工作流中,Git 是单一事实来源,协调控制器持续应用提交的任何内容。^(²¹⁵) 代理绝不接触集群或项目。它的唯一写入操作是提交或拉取请求;控制器负责应用。在 Google Cloud 上,这可以是协调 Kubernetes 状态的 Config Sync,或是在合并到 main 分支时运行 terraform apply 的 Cloud Build 触发器,从而使基础设施变更的动作变为合并行为,而非提示行为。无论哪种方式,本章其余部分通过配置捍卫的边界都成为系统的一个属性:一个没有凭证且仅能输出提交的代理,无论被如何提示,都无法应用变更。
这使得 GitOps 非常适合代理生成的基础设施。其产出物——Terraform 变更、Kubernetes 覆盖层、环境晋升编辑——具有重复性、模式约束性,且在合并前即可验证。为了一次性引入多个环境,带有输出模式的 codex exec 可生成结构化输出,供流水线拆分为文件并提交:
codex exec \
--sandbox workspace-write \
--output-schema envs-schema.json \
"Read environments.yaml, which lists each environment with its project id and \
region. Generate the per-environment tfvars and backend config for the Run or \
Relax stack, following the GitOps conventions in AGENTS.md."
代理将文件写入工作副本;CI 验证它们并打开拉取请求。人工审核合并,随后控制器或由合并触发的 terraform apply 协调变更。需要审慎对待的唯一之处是同步策略:允许非生产环境自动自我修复和修剪,但将任何可能删除生产资源的变更视为独立的受审核变更,而非附带的编辑。^(²¹⁶)
安全护栏
审批模式配置
对于基础设施工作,推荐的配置比应用代码更严格。approval_policy 和 sandbox_mode 是顶层配置键,因此捕获两种姿态最简洁的方式是使用两个配置文件,Codex CLI 通过 --profile 将它们叠加在你的基础配置之上:
##### ~/.codex/infra-review.config.toml — 计划审查:仅读取
approval_policy = "untrusted"
sandbox_mode = "read-only"
##### ~/.codex/infra-write.config.toml — 编写创作:写入需审批
approval_policy = "untrusted"
sandbox_mode = "workspace-write"
使用 codex --profile infra-review 进行计划分析和代码审查,使用 codex --profile infra-write 生成或重构模块。untrusted 审批策略意味着代理在运行命令前会暂停并等待显式审批,因此每次文件写入都被管控。
CI/CD 集成
Codex CLI 在基础设施流水线中最明确的定位是对每个拉取请求进行计划审查。示例使用 GitHub Actions,但在 Cloud Build 上结构相同;关键在于代理仅审查,从不应用。
拉取请求中的计划审查:
#### .github/workflows/terraform-review.yml
- name: Generate Terraform plan
run: terraform plan -out=tfplan -no-color > plan-output.txt
- name: AI plan review
run: |
codex exec -a never -s read-only \
"Review the Terraform plan in plan-output.txt. Flag resource deletions, IAM changes, and cost implications." \
-o review-comment.md
- name: Post review comment
uses: actions/github-script@v8
with:
script: |
const review = require('fs').readFileSync('review-comment.md', 'utf8');
github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body: review });
这是本章前文所述计划审查模式的 CI 集成:它在每个涉及基础设施的拉取请求上运行,生成计划,让 Codex CLI 以 read-only 模式审查并将发现写入文件,随后将该文件作为 PR 评论发布,使计划及其风险与变更一同呈现在人工审查者面前。代理从不应用;它仅做标注。
Codex CLI 在流水线中的另一种计划用法——漂移检测,也符合同一只读、仅报告的模式。它属于第二天运维而非部署流水线,因此在“第二天运维”部分详细介绍,此处不再赘述。
应用边界
基础设施代理的基本法则:绝不自动应用。代理负责生成、验证和提议。人工审查计划并运行 terraform apply。此边界通过以下方式强制执行:
-
AGENTS.md指令禁止 apply、destroy 和直接gcloud run deploy(建议性:代理通常遵守) -
一个直接阻止这些命令的
PreToolUse钩子,将指令从“建议”变为“强制” -
沙箱模式限制阻止命令执行(
read-only)或对其加以限制(无网络的workspace-write)
4. CI/CD 流水线设计:Agent 生成计划,人工审批步骤执行 Apply
这四层设计是有意为之:AGENTS.md 行声明意图,Hook 使其具有约束力,沙箱从根本上限制 Agent 可运行的内容,而流水线在 CI 中保持相同的边界。
Day-Two 运维:漂移检测与 Runbook
资源供应是“第一天”的工作。更长久的工作是“第二天”:健康检查、漂移检测、证书续期和合规扫描,这些持续不断的琐碎工作让生产环境持续运转。因为这些任务读取状态并汇报,而非改变状态,它们天然处于 apply 边界的安全一侧,而 codex exec 将它们转化为可复现、可审计的流水线。^(²¹⁷)
其模式是一致的。调度器(cron 或 CI 作业)携带一项运维技能和一个输出 Schema 运行 codex exec。Agent 通过沙箱中的只读工具(gcloud、terraform、kubectl)读取基础设施状态,推理异常情况,并输出结构化报告。下游工具解析 JSON 并将其路由至 Slack、PagerDuty 或仪表盘。^(²¹⁸)
漂移检测是典型用例:对比声明式配置与实际状态并标记差异。输出 Schema 使结果可被机器解析:
codex exec \
--sandbox read-only \
--output-schema ./schemas/drift-report.json \
-o /tmp/drift-report.json \
"Run the drift-check skill against the staging environment."
read-only 沙箱是关键所在:运维读取任务绝不应改变状态。^(²¹⁹) 当真正需要修复时,应拆分工作流而非提升检测器的权限。检测技能在 read-only 模式下运行并生成报告;一个单独的、经人工批准的步骤执行修复。管控 terraform apply 的禁令同样管控运维:Agent 观察并提议,由人工或协调控制器执行。
其他基础设施工具
以 Terraform 为中心的模式可迁移至基础设施工具箱中的其他工具,每个工具都有其对应的安全预览命令,相当于 terraform plan 的角色。
对于 Ansible,AGENTS.md 规定使用 ansible-lint 进行代码检查,使用 ansible-playbook --check --diff 进行干运行;干运行在不应用的情况下预览变更,扮演与 terraform plan 相同的角色。对于 Pulumi,程序是通用代码(TypeScript、Python、Go),因此 Codex CLI 将其视为具有基础设施语义的应用代码;关键约束在于 pulumi preview 是安全的,而 pulumi up 需要人工批准。在所有情况下,规则皆相同:存在一个展示拟变更的命令,和一个执行变更的命令,Agent 仅被允许使用前者。
记录技术栈:项目 README
三章下来,检出的代码库已包含一个完整系统:src/ 下的 FastAPI 服务,frontend/ 下的 Next.js 看板,以及两者的 Terraform 代码在 infra/ 下。它尚缺的,是那个告诉下一位阅读者(或六个月后的你)各部分如何契合、如何运行的单一入口文件。这三章努力维护的接缝——CORS 源、两个 URL 输出、apply 边界——散落在三章的文本中;根目录下的 README.md 正是将它们汇聚为单一操作手册之处。生成它是最后的构建步骤,而 Agent 已从会话中掌握所需的所有事实:
Write a root README.md for the Run or Relax project. Cover:
- Architecture: the FastAPI departures API, the static Next.js board, and the
Terraform stack, and how the board reaches the API (NEXT_PUBLIC_DEPARTURES_API_URL)
and the API allows the board's origin (RUN_OR_RELAX_CORS_ORIGIN)
- Local setup for both toolchains: uv for the backend, pnpm + Corepack for the frontend
- Backend commands: uv run uvicorn, uv run pytest, uv run ruff check/format
- Frontend commands: pnpm dev, pnpm build (static export to out/), pnpm test, pnpm test:e2e
- Environment variables for each service, with their defaults
- The seeded data files (data/timetable.json, data/delays.json) and what they control
- The Terraform validation workflow: fmt, init -backend=false, validate, tflint, checkov
- The infrastructure boundary in bold: the agent authors and validates, a human runs
terraform apply and the gcloud upload; nothing in this repo applies on its own
在 README 本身中显式保留 no-apply 边界,而不仅是在 AGENTS.md 中。AGENTS.md 告诉 Agent 其不可为何;README 告诉人工操作员其双手何处被需,这是同一边界从相反两侧的解读。
总结
-
基础设施需要更严格的约束。 基础设施仓库比应用代码需要更严格的
AGENTS.md,首要的是显式禁止 apply、destroy 和直接执行gcloud run deploy。 -
默认只读。 计划审查使用
read-only沙箱模式,编写代码使用带有untrusted审批的workspace-write模式,从而让每一次文件写入都受到把关。 -
计划审查是最高价值模式。 生成计划,让 Agent 审查风险,再由人工执行 apply。这能让 Agent 始终处于 apply 边界的安全一侧。
-
重构目标为零差异。 在生产环境中触发资源重建的重组不算重构;保留资源地址,使
terraform plan显示无变更,Cloud Run URL 亦然。 -
部署有 Agent 无法推断的前置条件。 人工完成认证并在首次
terraform init前创建 Terraform 状态存储桶(后端无法创建存放自身状态的存储桶)。Cloud Run 需要绑定$PORT的启动命令(单行Procfile);首次镜像推送需启用相关 API 和 Artifact Registry 仓库,因此引导阶段在构建镜像和应用其余资源前单独应用;.gcloudignore防止构建上下文膨胀;且 buildpack 构建器必须与项目的 Python 版本匹配。绿色的terraform validate不代表init成功,干净的checkov运行意味着零未解释发现,而非零发现(见表 24-1)。 -
CI/CD 止步于计划。 流水线集成聚焦于 PR 中的计划审查,绝不进行自动化 apply;漂移检测作为计划的 Day-Two 作业单独运行。
-
Agent 提议,人工执行。 这是全章捍卫的边界。
AGENTS.md中的禁止声明了这一点,但提示指令仅具建议性;阻止terraform apply、terraform destroy和gcloud run deploy的PreToolUseHook 使其具有约束力,沙箱模式和人工审批的流水线步层层叠加在其后。 -
用确定性工具闭环。 让 Agent 在会话中运行
tflint和checkov并迭代至干净退出,仅保留你能辩护的抑制项(公共网站存储桶和公共看板 API 合法需要的那些)。干净的扫描运行也是 Goal Mode 所需的外部验证面,因此循环自然地包裹在/goal中(第 12 章)。 -
路径稳定后,强化生成以对抗 Provider 漂移。 基础设施代码是 Agent 幻觉最重之处,Google Provider 的频繁变更(包括
google_cloud_run_v2_service重命名)是典型案例。在工作路径跑通后引入 Terraform MCP 服务器,提供经过验证的 Provider、模块和策略文档,enabled_tools将无人值守的 Agent 限制为只读注册表查找。 -
GitOps 使边界结构化。 当 Agent 的唯一产出是 Git 提交,而控制器或合并触发的
terraform apply执行应用时,无论提示如何,没有凭证的 Agent 都无法执行 apply。 -
第二天运维操作是只读汇报。 漂移检测、证书校验和合规扫描作为计划内的只读
codex exec流水线运行,产出结构化报告;修复是一个独立的、需经人工批准的步骤。 -
以 README 收尾。 完整的技术栈仍需一个根目录
README.md,记录架构、两套工具链的本地搭建与运行命令、环境变量、种子数据、Terraform 验证工作流以及禁止应用的边界,以便会话结束后项目仍可运行。将其生成为最后一个构建步骤。 -
线程在应用边界处结束。 第 22 章 至第 #n 章构建了 Run or Relax API,为其配置了看板,并将其部署到 Cloud Run。智能体能为技术栈编写每一行 Terraform 代码,涵盖 Cloud Run 服务、静态网站存储桶、服务账号与 IAM,以及将看板源站连接到 API CORS 的部署环境变量,但同一边界依然有效:智能体提议,人工应用。
练习
-
编写基础设施 AGENTS.md。 为现有的基础设施仓库编写一个
AGENTS.md。包含测试命令、命名约定,以及对 apply 和 destroy 的显式禁止。使用-a untrusted -s read-only运行 Codex CLI,并请其审查你现有的 Terraform 代码是否存在最佳实践违规。 -
生成并验证看板存储桶。 为 Run or Relax 看板生成 Cloud Storage 静态网站模块,然后运行完整序列:
terraform fmt、terraform init -backend=false、terraform validate、tflint --init、tflint和checkov -d infra --compact。确认每个剩余的checkov发现都与表 24-1 中的某一行匹配,并为每个发现添加合理的行内抑制注释;将表中未列出的任何发现视为真正的问题进行修复。 -
构建计划审查流水线。 设置一个 CI/CD 流水线,使用
codex exec在拉取请求中审查 Terraform 计划。跨五个拉取请求,将智能体的审查与团队的手动审查流程进行对比。 -
规划 Terraform MCP 服务器。 在项目的
.codex/config.toml中配置 Terraform MCP 服务器,将enabled_tools限制为仅注册表查找。请 Codex CLI 编写 Cloud Run 服务模块,然后通过会话日志确认它是查阅了注册表获取google_cloud_run_v2_service的信息,而非猜测属性名称。 -
串联两个服务。 利用 Terraform 输出和变量,连接已部署的组件:Cloud Run 模块输出服务 URL,看板构建时将其作为
NEXT_PUBLIC_DEPARTURES_API_URL,API 的RUN_OR_RELAX_CORS_ORIGIN设置为看板的存储桶 URL。运行一次 plan,请 Codex 审查,并确认两个模块间没有任何值是硬编码的。
第六部分:
第二十五章:基准测试与真实世界性能
第 1 章 至 第 3 章 涵盖了 Codex CLI 是什么、如何运行它以及如何有效地提示它。后续章节演示了工具链的主要特性:AGENTS.md 约定、审批与沙箱模型、模型名单以及钩子系统。你已了解该工具在理论上提供的能力。现在迎来更棘手的问题:它到底有多好?
2026 年,每个 AI 实验室的营销文案都以基准测试百分比开篇。这些数字看起来令人印象深刻,直到你弄清它们在衡量什么,以及它们刻意不衡量什么。基准测试之所以重要,是因为它们提供了一致、可复现的信号。它们之所以会误导人,是因为一旦成为衡量成功的首要指标,实验室就会为优化指标本身而非底层能力而努力。
本章将为你提供批判性词汇以审慎解读基准测试宣称,提供背景知识以理解 Codex CLI 在当前格局中的定位,并提供一套在自有代码库上运行评估的具体方法论:唯一真正对你团队重要的基准测试。
学习目标
你将能够:
-
解读基准测试宣称,识别决定其能否与其他宣称相比较的变量
-
基于当前评估数据,针对不同任务类型在 Codex CLI 中选择合适的模型配置
-
使用 Terminal-Bench 任务格式,设计并运行针对自有代码库的轻量级基准测试套件
基准测试格局
三大评测主导了 2026 年初关于 AI 编码智能体的讨论。每项测试衡量不同的能力切片,基准测试的选择往往反映了谁的模型在该测试上表现最佳。
表 25-1. 三大主流 AI 编码基准测试(2026 年 3 月)
| 基准测试 | 任务 | 范围 | 抗污染能力 |
| --- | --- | --- | --- |
| SWE-bench Verified | 500 个 Python 任务 | Bug 修复补丁 | 低:已知存在污染担忧 |
| SWE-bench Pro | 1,865 个多语言任务 | 多文件变更 | 高:包含 GPL 和专有代码库 |
| Terminal-Bench 2.0 | 89 个端到端任务 | 完整终端工作流 | 高:Docker 环境,众包构建 |
理解这三者的区别是解读产品发布、大会演讲或供应商对比中引用的任何分数的前提条件。
基准测试演进:编码模型五年进化史
在详细考察每个基准测试之前,回顾模型发展的历程将大有裨益。从 OpenAI 最初的 Codex(2021)到 GPT-5.4(2026)的轨迹,展示了快速提升后随之而来的基准测试饱和模式——这是社区需要更难评测的一个反复出现的信号。
HumanEval(函数级代码生成)
| 模型 | 年份 | 得分 |
| --- | --- | --- |
| Codex-12B | 2021 | 28.8% |
| GPT-3.5 Turbo | 2023 | 67.0% |
| GPT-4 | 2023 | 87.0% |
| GPT-4o | 2024 | 90.2% |
| GPT-5 / Claude 4 Opus+ | 2025+ | 95%+(饱和) |
HumanEval 实际上已饱和:每个前沿模型得分均超 90%,剩余差距属于噪声而非信号。社区已主要转向 SWE-bench 和 Terminal-Bench 作为区分度更高的评测。
SWE-bench Verified(真实 GitHub 问题解决)
| 模型/智能体 | 年份 | 得分 |
| --- | --- | --- |
| GPT-4o (baseline) | 2024 | 33.2% |
| o1-preview | 2024 | 41.3% |
| o3 | 2025 | 71.7% |
| GPT-5.2 Codex | 2025 | 75.2% |
| Claude Opus 4.5 | 2026 | 80.9% |
| GPT-5.5 | 2026 | 88.7% |
鉴于污染问题(见下节),前沿模型得分超过 ~60% 的情况应持怀疑态度。进步是真实的,但被夸大了。
Codeforces Elo(竞赛编程)
| 模型 | 年份 | Elo |
| --- | --- | --- |
| o1 | 2024 | 1,258 |
| o3 | 2025 | 2,727 |
o3 从 1,258 跃升至 2,727 Elo,是任何编码基准测试中单代际改进最显著的一次,使该模型跻身人类竞赛程序员的顶尖行列。这种能力转化为算法密集型任务的优势,但无法预测其在 SWE-bench 和 Terminal-Bench 所衡量的多文件工程工作中的表现。
该演进的关键启示:基准测试的改进是真实的,但在任务类型间分布不均。在竞赛编程中占主导地位的模型,可能在真实仓库导航中表现不佳。正如本章后节「数字对你的团队意味着什么」所述,始终将基准测试与你的工作负载相匹配。
SWE-bench:从金标准到警示录
SWE-bench 实际衡量的内容
SWE-bench 由普林斯顿大学创建,旨在评估语言模型解决从 12 个热门 Python 仓库中抽取的真实 GitHub 问题的能力。^([220)] 智能体接收问题描述以及问题提出时对应提交点的仓库快照,必须生成一个补丁。成功与否是二元的:应用补丁,运行现有测试套件,通过或失败,不给予部分分数。

浙公网安备 33010602011771号