🛠️ 第9章 场景一:接手陌生代码库
📄 一句话概括

AGENTS.md 不是介绍文档,是给 Agent 看的约束清单。 每一条都得能被一条命令或一次扫描验证,验证不了的就别写。 Demo 里凭印象写的八条挂了五条,只写已验证事实的六条一条没挂。

📝 本章和前面几章的关系

上一篇把仓库读成了一张地图。这一篇把地图落成文件。 为什么这份文件叫 AGENTS.md 而不是 README? 因为读者不一样。 README 给人看,讲「这是什么」;AGENTS.md 给 Agent 看,讲「在这儿干活要守什么」。 这两个问题的答案经常不重合。 Demo 先写一份「凭印象」的八条,再拿校验器逐条核—— 结果五条站不住:命令不存在、文件不存在、版本不对。 然后换成只写已验证事实的六条,一条没挂。 最后让扫描器自己提候选,八条里留六条。

一、陌生仓库的第一份 AGENTS.md

第一次进陌生仓库,人最容易干的事是「照着印象写」。 因为大部分 Python 项目的结构是像的—— 所以「测试用 pytest」「格式化用 black」这些话写起来毫不费力。 问题是这些话在具体这个仓库里可能是假的。 Demo 里八条凭印象的断言,五条站不住: make 没装、入口不在 src/index.ts、black 没装、Python 不是 3.11、没有 CI 配置。 这些错误的危害不是「错」,是「错得像对的」。 Agent 每次进仓库都会先信它一次——跑一条不存在的命令,拿一份不存在的路径去读。 一轮白跑,你还得回头想「它为什么这么干」。 所以第一份 AGENTS.md 的正确写法是「少而准」,不是「全而像」。 六条真的,胜过八条像的。

二、只写已验证的事实

怎么算「已验证」?分四类,各有各的验法。

类型 例子 怎么验
命令 跑测试用 unittest 真跑一次,看退出码
路径 入口是 app/main.py 看文件在不在
约定 配置读 APP_ENV 在代码里搜得到
版本 Python 3.13 问解释器自己

四类里最容易出错的是「命令」。 因为它看起来最不需要验——但 make、black、pytest 这些装没装,跟项目写得多规范没关系。 「版本」这一类更隐蔽。 写「Python 3.11」的人多半是从 pyproject 里抄的,而机器上装的是 3.13。 这种错不会立刻炸,但会让 Agent 用错语法糖—— 3.11 不支持的写法它不敢用,3.13 支持的它不知道能用。 所以校验器不是形式主义:它把「我以为」换成「我量过」。 Demo 里那个校验器一共四十行,四类各一个分支——真跑命令、真看文件、真搜代码、真问解释器。

三、让它自己补

光靠人写会漏。 Demo 里第二步是让扫描器把「能扫出来的事实」先列一遍。 扫描器能给出这些:文件数、最大的模块、配置项、有没有 CI、用了哪些库、缩进几个空格。 这些都不是猜的,是数出来的。 但扫描器有两个边界,Demo 里都碰到了。 第一个:扫不出来的,它不能说。 没有 package.json,就判不了前端用的什么框架——这时候只能剔掉,不能写「大概是原生 JS」。 第二个:扫出来的,也不一定能写。 「文件 28 个」是真的,但对 Agent 干活没用——它不改变任何决策。 判断标准是:这条事实会不会让 Agent 少走一步弯路。 会,就留;不会,就是噪音。 所以流程是三步:扫描器提候选、校验器逐条核、人来定去留。 三步都不能省。 扫描器省了人的翻找,校验器挡住了扫描器的瞎猜,人负责判断「有没有用」。

四、三条规矩

规矩 为什么 反例
只写能跑通的命令 挂了就是每轮多一次失败 make test
只写扫得到的路径 编一个不存在的目录最坑 src/index.ts
拿不准就别写 空着比写错强 Python 3.11

第三行值得多说一句。 AGENTS.md 空着,Agent 会自己去看——它读文件、跑命令、试出来。 写错了,它会信你,而且不再自己看。 一份错的说明书比没有说明书更糟。 这也是为什么「拿不准就别写」是三条里的最后一条,却最重要。 前两条防的是「写了假话」,这一条防的是「为了写满而写」。

五、动手 Demo:AGENTS.md 里每句话都得能被验证

先写一份凭印象的八条,再拿校验器逐条核; 然后换成只写已验证事实的六条,核一遍; 最后让扫描器自己提候选,看哪几条该留。 校验器是真跑的:命令真执行看退出码,文件真看在不在,配置项真在代码里搜,版本真问解释器。

def check_claim(root, kind, val):
    if kind == "cmd":
        exe = val.split()[0]
        if shutil.which(exe) is None:
            return False, "命令不存在"
        c, _ = run(root, val.split())
        return c == 0, "退出码 %d" % c
    if kind == "file":
        ok = os.path.isfile(os.path.join(root, *val.split("/")))
        return ok, "文件存在" if ok else "文件不存在"
    if kind == "pyver":
        real = "%d.%d" % sys.version_info[:2]
        return real == val, "实际 %s" % real

实跑输出:

--------------------------------------------------------------------
AGENTS.md 里每句话都得能被验证
--------------------------------------------------------------------


  ① 先看一份「凭印象写」的
  --------------------------------------------------------------------
  第一次进陌生仓库,最容易干的事就是照着「通用 Python 项目」的印象写一份。

  [凭印象写的 · 未经验证]
  | # AGENTS.md(凭印象版)
  | - 测试用 `make test`
  | - 入口在 `src/index.ts`
  | - 数据存在 `data/app.db`
  | - 格式化成 `black .`
  | - 运行时是 Python 3.11
  | - 测试都在 `tests/` 下
  | - 配置读环境变量 `APP_ENV`
  | - CI 在 `.github/workflows/ci.yml`
  这八条读起来都挺像回事。


  ② 拿校验器逐条核
  --------------------------------------------------------------------
| # | 断言 | 核出来的 | 判定 |
|---|---|---|---|
| 1 | 测试用 make test | 命令不存在 | 挂 |
| 2 | 入口是 src/index.ts | 文件不存在 | 挂 |
| 3 | 数据在 data/app.db | 文件存在 | 过 |
| 4 | 格式化用 black . | 命令不存在 | 挂 |
| 5 | 运行时 Python 3.11 | 实际 3.13 | 挂 |
| 6 | 测试都在 tests/ 下 | 目录存在 | 过 |
| 7 | 配置读环境变量 APP_ENV | 代码里能找到 | 过 |
| 8 | CI 在 .github/workflows/ci.yml | 文件不存在 | 挂 |

  八条里挂 5 条。这份文件要是直接放进仓库,Agent 每次进来都会先跑一条不存在的命令。
  ! 最坑的不是它错,是它错得像对的——「测试用 make test」在九成 Python 项目里都对。

  ③ 换成只写已验证事实的
  --------------------------------------------------------------------
| # | 断言 | 核出来的 | 判定 |
|---|---|---|---|
| 1 | 入口是 app/main.py | 文件存在 | 过 |
| 2 | 用 python -m app.main 启动 | 退出码 0 | 过 |
| 3 | 数据在 data/app.db | 文件存在 | 过 |
| 4 | 测试跑 python -m unittest discover -s tests -t . | 退出码 0 | 过 |
| 5 | 配置读环境变量 APP_ENV | 代码里能找到 | 过 |
| 6 | 测试都在 tests/ 下 | 目录存在 | 过 |

  六条,挂 0 条。少了两条,但没有一条会把 Agent 带沟里。

  ④ 让它自己补:扫描器提候选
  --------------------------------------------------------------------
| 候选事实 | 从哪来 | 处置 |
|---|---|---|
| Python 文件 28 个 | walk 计数 | 留 |
| 最大的模块是 app/models/order.py(31 行) | 行数排序 | 留 |
| 配置项 APP_ENV | 正则抓 environ | 留 |
| CI 配置 | 扫 .github/workflows | 剔(没有就别编) |
| 数据库 用 sqlite3 | 扫 import | 留 |
| 前端是原生 JS 无框架 | 扫 package.json | 剔(没有 package.json,判不了) |
| 依赖只有标准库 | 扫 import 比对 stdlib | 留 |
| 缩进是 4 空格 | 扫行首空白 | 留 |

  八条候选,留 6 条、剔 2 条。剔掉的那几条共同点:扫不出证据,只能靠猜。
  ! 扫描器能替你找事实,不能替你担保事实。它给出的每条,仍然得过一遍校验器。

  ⑤ 三条规矩
  --------------------------------------------------------------------
| 规矩 | 为什么 | 反例 |
|---|---|---|
| 只写能跑通的命令 | 挂了就是每次任务多一轮失败 | make test |
| 只写扫得到的路径 | 编一个不存在的目录最坑 | src/index.ts |
| 拿不准就别写 | 空着比写错强 | Python 3.11 |

  第三行值得多说一句:AGENTS.md 空着,Agent 会自己去看;写错了,它会信你。

八条挂五条,六条挂零条。 这组对比不是为了说「我写得好」——是为了说明「凭印象写」的默认命中率有多低:超过一半。 注意挂掉的那五条里,没有一条是「明显荒谬」的。 make test、black .、Python 3.11,都是你在别的项目里见过一百遍的东西。 这就是它危险的地方:错误长得很像经验。 扫描器那张表要看最后一列。 八条候选剔了两条,都是「扫不出证据」的—— 没有 package.json 就判不了框架,没有 .github/workflows 就说没有 CI。 而「文件 28 个」这种能扫出来、但没用的,也在被剔的边缘。 最后一句话要记住:扫描器能替你找事实,不能替你担保事实。 它给出的每一条,仍然得过一遍校验器——因为它扫的是「文本里有什么」,不是「跑起来对不对」。

30 秒实验

  1. 给你最熟的那个仓库写三条 AGENTS.md,然后逐条验一遍。 三条里有一条站不住,就说明你对它的印象已经旧了。
  2. 查一下你项目里「测试命令」到底是什么。 真跑一次,看退出码是不是零。 很多项目写着 pytest,其实只装了 unittest。
  3. 把 AGENTS.md 里所有带版本号的行圈出来。 每一条都去问一次真实环境——这一条最容易过时。

常见误区

  1. 「AGENTS.md 写得越全越好。」 全不是目标,准才是。 一条假的会让 Agent 少看一圈文件,还照错的做。
  2. 「照着通用项目模板抄一遍就行。」 Demo 里这么抄的八条挂了五条。 模板描述的是「平均项目」,不是「这个项目」。
  3. 「扫描器扫出来的都能直接写进去。」 不能。 扫得出来只说明它是真的,不说明它对干活有用。
自测三问(点开对答案) 1. 为什么第一份 AGENTS.md 要「少而准」? —— **因为错的比没有更糟。** **空着 Agent 会自己看;写错了它会信,而且不再自己看。** 2. 四类断言里哪类最容易出错?为什么? —— **「命令」这一类。** **因为它看起来最不需要验,但 `make`、`black` 装没装跟项目规范无关。** 3. 扫描器的两个边界是什么? —— **扫不出来的不能说,扫出来的不一定有用。** **所以要校验器核一遍,再让人定去留。**

有了地图和规矩,下一篇讲最难忍的那一步:什么都不改,先把理解验一遍。

相关笔记