用 CI 检查阻止智能体规范失效
文章指出,精简 AGENTS.md 只能改善上下文组织,无法保证代码持续遵守约定。作者介绍通过漂移检查与 CI,将文档中的规范转为可执行约束。
AI 深度解读
AGENTS.md 写得再清楚,也可能在几次合并后变成过期说明。作者此前主张把它压缩成约定索引,把细节移到专题文档;做了不到两周真实的 Agent 开发后,他发现:减少上下文负担,并不能保证代码遵守约定。
他的补法是给规则加 ID,绑定检查函数,再交给 CI 执行。文章值得看的不是多了一套脚本,而是如何判断检查有没有真正生效,以及规则、代码和检查怎样一起维护。
写进 AGENTS.md,为什么还会悄悄失效
作者盘点项目时发现,规则的约束力并不一样。Lint 边界和类型检查有 CI 兜底;「新场景 UI 放在 src/features/<feature>/ui/,不能放进 app/」只是文字建议;「只能使用 pnpm」也一样,直到仓库悄悄出现第二份锁文件。
「不能直接提交 main」稍有不同:分支保护可以执行它,但这层保障在文档中并不可见。
问题不在于 Agent 有没有读过规则,而在于违规之后有没有确定的反馈。没有检查,偏差就会随着合并累积,直到一个月后,文档描述的已经是另一个代码库。
AGENTS.md 负责告诉 Agent 应该怎么做,CI 负责证明提交有没有做到。
作者给每条需要强制执行的规则加上 HTML 注释标记,再用 ID 找到对应检查。下面这段解决的是规则如何与检查关联:
## Package manager
<!--check: single-lockfile -->
This repo uses pnpm ONLY. pnpm-lock.yaml is the single lockfile.
A second lockfile (package-lock.json, yarn.lock) in the repo = CI red.
## Data
<!--check: no-ui-fetch -->
src/features/**/ui/** and app/** must NOT call fetch/axios directly.
Data access belongs to feature logic.
要看的是 <!--check: single-lockfile --> 这一行。它在 Markdown 渲染后不可见,但脚本可以读取;人读规则,机器读地址。
这里也有边界:标记不会自动理解自然语言。规则到底禁止什么,仍然需要开发者写成检查逻辑。没有标记的句子,按照作者的约定,仍然只是建议。
双向绑定,才能让删规则也有反馈
检查器需要防住两个方向:文档新增了标记,却没有实现检查;检查函数还在,文档里的对应标记却被删了。
作者用纯 Node 实现,避免依赖 shell 工具,让 Windows、Linux 和 CI 使用相同验证方式。脚本通过 readFileSync 读取 AGENTS.md,再用 matchAll 和正则提取 ID,去重并保留文档顺序。
下面是原文中负责双向核对的片段,前提是已经准备好 ids、checks 和 problems:
// 双向绑定:文档到映射,以及映射到文档。
for (const id of ids)
if (!(id in checks))
problems.push(`rule ${id} has a marker but no check is mapped`);
for (const id of Object.keys(checks))
if (!ids.includes(id))
problems.push(`check ${id} exists in the map but no marker in AGENTS.md`);
不能漏掉第二个循环。只有第一个循环时,删除文档标记就可能让一项检查失去入口。
完整检查器还会执行规则函数:返回 true 表示通过,false 表示一般失败,string[] 用来报告违规文件。任何失败都以退出码 1 结束。作者报告,11 条规则在正常仓库中运行不到一秒,示例输出为 11 rules verified · 0.0s。
原文还给了约 25 行的 Python 对应版本,用 re、pathlib.Path 和 raise SystemExit(bool(problems)) 完成绑定检查。不过,这个简版只验证标记与映射是否对应,没有执行规则函数。空 checks 遇到真实的 11 条规则,会输出 11 条缺失提示并退出 1,不能把它当成完整检查器。
在 fsaa-next 中,检查由独立的 .github/workflows/agents-md.yml 执行,在 [pull_request, push] 时触发,与 ci.yml 并行。
最容易写错的,是检查究竟检查了什么
作者记录的四次故障,比通过日志更能说明这套方法的成本。
第一次,解析器把说明文字当成规则。作者在文档里直接写了标记示例 <!--check: my-rule -->,正则把它识别为第 12 条规则,随后因为没有映射而报错。他的处理方式是:解释标记机制时,不在文档里放可被识别的完整标记。扫描的是原始文本,代码块和示例不会天然得到豁免。
第二次,导入边界检查放行得太宽。最初脚本只确认 eslint.config.mjs 包含边界配置文字。即使 ESLint 规则被改坏,检查仍可能通过。
后来作者改为扫描真实导入,包括 src/shared/ 中不该出现的 @features/ 导入,以及文中所说的向上导入,同时保留配置检查作为独立防线。原文没有展开「向上导入」的完整判定方式,照搬前还需要明确自己项目的边界。
配置中写着禁止,不等于违规代码一定会被拦住。
第三次,npm 迁移到 pnpm,牵出了三个地方。作者遇到 pnpm/action-setup@v4 的显式 version: 输入与 package.json 中固定的 packageManager 冲突,每次 push 后 4 秒就失败;另一个工作流 ci.yml 还留着 npm ci;检查器的规则 ID 仍叫 cmd-npm-*。
最终,迁移 PR 同步更新了 single-lockfile、cmd-pnpm-* 等契约规则。文章前面展示的 Node 核心片段仍是 npm 时期的形态,含 cmd-npm-install、cmd-npm-lint,阅读时要区分迁移前后。
第四次,重构改了 app/layout.tsx 的名字,file-paths 检查随即报错。此时失败可能说明规则过期,需要更新文档和检查,而不一定是把代码改回去。作者给出的原则是:修代码或者修规则,都可以;绕过检查不可以。
我的判断
我认同把容易遗忘、又能客观判断的约定接进 CI。锁文件、必需脚本、固定路径、明确的层间依赖,都适合这样处理。它还能让规则变更进入 PR:修改约定时,检查逻辑也需要一起解释。
但文章里的「每个剩余句子都应可证伪」要有取舍。目录结构可以检查,「保持代码易读」却没有同样清晰的判定标准。强行为后者写关键词扫描,容易制造虚假的确定感。
这套方案也不能证明自然语言和函数含义完全一致。文档写着「UI 不能直接请求数据」,检查若只搜索 fetch、axios,是否识别别名、封装后的客户端和其他调用形式,原文没有说清。双向绑定保证的是双方都有登记,语义是否匹配仍需评审。
作者确实报告了首周复现的失败情况、11 条规则和不到一秒的运行时间,但没有提供与单纯使用 ESLint、类型检查、分支保护相比的对照数据。所谓普通文字能覆盖 80%、剩余 20% 是 Agent 容易出问题的区域,以及成本低于一次锁文件事故,都是经验判断。
我的取舍是:已有成熟工具能表达的规则,先让成熟工具执行;额外脚本负责补足项目特有约定,并确认文档与执行入口仍然对应。作者自己也承认,周末项目可能用简洁文字就够了。
能拿来做什么
- 先从仓库最容易遗漏的硬约定选起,例如只允许
pnpm-lock.yaml、必须存在指定脚本,以及 UI 层禁止直接访问数据。把判定条件写清楚,再为它们分配检查 ID。 - 对前端分层规则,先构造一份明确违规的代码改动,确认现有 ESLint 或扫描器真的会失败。只看到配置文件里有规则名称,还不足以验收。
- 给 Node 检查器加入文档与函数映射的双向核对,再接入 PR 的 CI。新增标记没有实现、已有实现失去标记,都应产生失败反馈。
- 在包管理器迁移、目录重构和日常 AI 编程任务中,把 AGENTS.md、检查器、工作流一起纳入变更范围。失败时先判断代码违约还是规则过期,再修改对应部分。