让 Agent 只读该读的——文档路由设计(二)

2 阅读22分钟

本文是「Agent-first 大型项目文档分层」系列的第二篇。第①篇给出了 L0–L3 四层总模型:L0 是每次会话自动加载的入口文件,L1 是按工作类型划分的规范,L2 是模块与变更文档,L3 是执行证据。本篇只回答其中一个问题:Agent 接到一件事,该读哪几份。素材仍来自某企业级支付审批系统。

一、规范早就有,只是没有路

第①篇开头讲过那次封板(冻结版本、跑全量测试、打 tag、出包):9 月 16 日同一天打了两个版本的 tag,两版的更新日志和交接说明直到两天后的立规提交才补上;同一个提交新建了封板规范,并在入口文件里加了一张「操作 → 必读」表。本篇从那张表往下挖。

先把「找不到」说准确:第①篇讲过,立规前「六套全量」的定义早已写在项目测试规范里,可在当时的入口文件里搜「全量」「封板」,结果为空。

这里要区分两种失败,它们的修法完全不同:

  • 被淹没:路在,只是被几百行叙述盖住了。修法是精简、加粗、挪到显眼处。
  • 不可达:从手头这件事出发,入口里没有一个路标指向它。修法只有一个:修一条路。

我们的情况是后者,而且比「没有引用」更隐蔽。严格说,引用图上有一条两跳的路:入口文件的开发流程一节把详细规范指向开发工作流文档(项目的开发规范),它的相关文档表里有一行链到项目测试规范,那一格写的是「五层测试金字塔」;全量定义在那份规范的第 154 行。路径是连通的,可对一个接到封板任务的 Agent,沿途没有一个路标写着「全量」或「封板」。另一条提到了封板却走错终点:入口提到变更索引的路径,索引里三行「v2.0.x 本地封板与出包」链到那几次封板自己的文档,其中一份需求逐项列了六套——那是一次历史实例,不是当前定义,也不链向测试规范。对没有跨会话记忆的读者来说,从手头的任务出发走不到的文档,等于不存在,写得再好也一样。

立规提交修路的方式值得细看。它没有把六套全量的定义抄进入口文件,而是加了一张表,第一行把「封板 / 出包 / 打 tag / 说「全量绿」」指向新的封板规范。入口文件从 410 行变成 424 行:新增的一节连标题、引言共 14 行,表里 7 行就是 7 条路,封板那条只占一行。

之后可以逐版核对:截至 10 月 14 日,立规后发布的 v2.0.5 到 v2.3.1 共 9 个版本,更新日志与交接说明两件齐全;封板测试报告从 v2.1.0 起每版都有(v2.0.5 没有)。我不把这归功于一张表——同一个提交还写了封板 checklist 和 release 两件的必含项;但这些内容要起作用,前提是被读到,而把它们接到入口上的,是路由表那一行。

所以本篇要回答的不是「怎么让 Agent 记住规范」,而是:怎么保证从入口到每一份该读的规范都有路,而且 Agent 只走该走的那几条。

二、路由失败的三种形态

把视角从那一次封板放大到整个文档体系,路由失败有三种形态,我们项目里都出现过:

  1. 没有路。 规范在,入口不可达。开场就是。
  2. 路太宽。 入口文件把各类工作的细则直接装进来,Agent 每次会话都要吞下全部内容,真正相关的那条被稀释。这是第①篇讲过的 L0 膨胀。
  3. 路断在半途。 路由表把 Agent 送到了项目层规范,项目层却不提自己继承哪份通用规范,Agent 读完项目层就以为读全了。第十节细说。

三种形态有同一个根因:文档是按「写的人怎么分」组织的,而 Agent 进门时手里只有一个问题——我现在要干什么。 写的人按目录、文档类型、时间来组织,这对熟悉仓库的人很自然;可 Agent 每次会话都是新人,不知道仓库长什么样,只知道用户让它封板、改表结构,或者审一个改动。路由的键,必须是 Agent 进门时手里已经有的东西。

三、三条原则

原则 1:入口文件只路由,不承载。(对应「上下文有限且有成本」)第①篇从「无跨会话记忆」推出过同一句话:入口是 Agent 唯一确定会读到的东西,所以必须有。这里补另一半:入口文件每次会话都整份加载,装进去的每一行,所有会话都要付费。六套全量的定义如果抄进入口,每一次与封板无关的会话都要读一遍;做成路由,只有封板的会话才付这份成本。

原则 2:按工作类型路由,不按目录路由。(对应「无跨会话记忆」)目录树回答「东西放在哪」,前提是读者知道自己要找的东西叫什么、属于哪一类——这本身就是记忆。Agent 进门时没有这种记忆,只有用户交给它的任务。开场那条两跳的引用就是反例:目录和引用都连通,路标却是按「这里有什么」写的,不是按「你要干什么」写的。所以路由表第一列写的是动作:封板、改表结构、审查代码,而不是目录名。

原则 3:每一跳都声明下一跳和自己的上层。(对应「按字面执行、不会意会」)人读到一份「项目数据库规范」,会自然想到「大概还有一份通用的」;Agent 不会。一份文档没写「我是谁的子集」,Agent 就把它当成全部规则;没写「读完去哪」,Agent 就停在这里。路由不只是入口的事,路上每一份文档都要回答这两个问题。

四、机制 1:「操作 → 必读」路由表

下面是按推荐形态整理的片段。底稿是我们项目入口文件里现在那张表,已脱敏、节选;原表只有两列,要点写在括注里,这里把要点单独成列。

| 操作 | 必读规范 | 一句话要点 |
|---|---|---|
| 封板 / 出包 / 打 tag / 说「全量绿」 | handbook/engineering/release-process.md | 六套全量全部按口径通过才可说「全量绿」 |
| DB 结构 / 跨环境数据变更 | handbook/database/project-database-changes.md | 改表结构必写增量脚本;脚本以生产执行器兼容为准 |
| 写 / 改 / 审查任何测试 | testing/guides/project-testing.md | 先按「工作类型 → 测试层级」表选层,不为界面契约上升到端到端 |
| 用浏览器验证前端行为 | 入口文件「选择器不许靠猜」节 | 浏览器自动化只用于定位,不构成验收证据 |
| 代码审查 / 复审 | 对应变更的 handoff.md + test-cases.md | 结论必须带「路径:行号」证据 |
| 改工作流引擎 | tracks/refactor/roadmap.md + 当前变更 | 先读历史教训再动引擎行为 |
| 改移动端 | 入口文件「真机兼容硬约束」节 | 新依赖先核 WebView 版本地板 |

**分层阅读原则**:L0 = 入口文件顶部硬约定,始终生效;L1 = 上表按工作类型定址,开工前只读对应行的文档;L2 = 模块的变更计划与交接文档,进入该模块时再读。不要为一件任务通读全库,也不要没读 L1 就凭印象开工。

这张表是原则 2 的落点(对应「无跨会话记忆」):每次进门的新人都能凭手里的任务查到下一跳。值得展开的是两件事:收什么,和第三列。

收录标准:高风险、低频、凭印象易错。

  • 高风险:做错了代价大或不可逆,比如生产库、发版、跨环境数据。
  • 低频:一个月才做一两次的事,相关规范不会碰巧出现在最近的上下文里。
  • 凭印象易错:Agent 的常识会给出一个看似合理、其实不对的答案。「全量」最典型:常识里全量就是「把测试全跑一遍」,项目里的定义却是固定六套、缺一不可。

日常编码不收。「写一个列表页」没有一行,它不满足任何一条;高频操作的规则归开发规范,入口里一个指向开发规范的链接就够了。表一旦开始收日常操作,就会长成第二本开发规范。

「一句话要点」列保证没跳转也拿到最关键的约束。 路由表在 L0,每次都被读到;目标文档在 L1,要多跳一次。Agent 可能因为上下文紧张、或判断「这次改动很小」而没跳过去。要点列是保底:哪怕没跳,「改表结构必写增量脚本」这一句也已经在它眼前。它还有个副作用:一份规范的要点如果写不成一句话,往往说明那份规范本身没有核心规则,该先治理目标文档。

表下那段分层阅读原则同时堵两个方向:「不要为一件任务通读全库」防读太多,「不要没读 L1 就凭印象开工」防读太少。它是第①篇总模型在 L0 里的一段话版本,让 Agent 每次进门都知道自己在哪一层。

再看代码审查那一行的要点:结论必须带「路径:行号」。审查结论最终是给人裁决用的,所以路由表不只规定 Agent 读什么,也规定它交给裁决者的证据长什么样。另外,表里有两行的必读目标是「入口文件某某节」,这是个信号,第十节再说。

五、机制 2:多 Agent 统一入口

入口文件 3 月 4 日是 128 行,8 月 20 日涨到 406 行,名字一直是 CLAUDE.md,也就是 Claude Code 约定的文件名。可协作的还有 Codex 和 Kimi,按某个工具约定命名的入口,换个工具未必会被自动加载。9 月 3 日的文档重整把规则统一收进 AGENTS.md,CLAUDE.md 缩成转发文件,现在一共 5 行,只说三件事。意译后的骨架如下:

# <工具名> 兼容入口

唯一的项目规则在 AGENTS.md,开工前完整阅读。
不要在本文件重复任何规则;需要新增或修改,改 AGENTS.md,
让每个 Agent 用同一个事实源。

转发文件里一条规则都不能有。只要有一条,两份入口就会各自生长,不同的 Agent 看到不同的项目规则。

统一入口还有另一半:个人记忆。有的 Agent 工具会在本机为用户保存记忆文件,跨会话自动加载;它不在仓库里,别的 Agent、别人的机器都看不到——第①篇症状 6 那批项目级教训,就困在这里。

判断一条经验放哪,只问一句:换一个 Agent、换一台机器,这条还该生效吗?(对应「多个 Agent 并存」)该生效,就进仓库,按层级放进入口或 L1 规范;不该,才留在个人记忆里。留下来的应该只是协作偏好,比如回复用中文、提交前先给人看 diff。

六、机制 3:通用 → 项目两层继承

L1 规范分两层。通用层写跨项目成立的规则,并留出占位项,也就是每个项目必须自己回答的空,比如用哪种数据库方言、迁移脚本放在哪、全量包含哪几套。项目层只写四样东西:占位项的填充、本项目现状、对通用规则的例外、本项目踩过的坑。

两层靠一句继承声明连起来,这句声明必须放在项目层开头。原因在路由:路由表指向项目层(那里才有能直接执行的东西),Agent 落在项目层;项目层不提上层,通用层对它就不可达——和开场同一个形状,只是发生在 L1 内部。(对应「按字面执行、不会意会」)

正面样板是我们移动端子工程的测试落地说明,标题下第一句就是继承声明(链接已换成文档名):

本文是《通用测试规范》在本项目的落地填充(§10)。通用内容不重复,只填本项目的占位项 + 现状 + 例外。活文档:执行中发现新坑回写本文。

一句话答了三个问题:上层是谁;本文写什么、不写什么;本文会更新。上层也有对应的接口:通用测试规范第 10 节「落地与例外」用一张「占位项|项目需明确」表列出留白。测试中心 README 的放置规则再补一道:子工程可以有自己的落地说明,「但必须从本页可达,且不得复制通用规范形成第二事实源」。

项目层的骨架:

# 数据库变更规范(项目层)

> 继承:<通用数据库变更规范>。本文只写占位项填充、现状、例外和本项目的坑;
> 未写到的以通用层为准;与通用层冲突时,以本文「例外」节为准。

## 占位项填充
## 现状
## 例外
## 本项目的坑

「冲突时以谁为准」必须写出来。两层说法不一致时,按字面执行的 Agent 不会揣摩作者意图,只会在两句里挑一句信。我们自己的反面案例放在第十节。

七、机制 4:逐级路由链

路由表只解决第一跳。从入口到正文,我们的设计是一条链,每一层只回答「下一步去哪」:

入口文件 → 文档总入口(按职责)→ 一级目录 README(二级路由 + 放置规则)→ 文档开头的 AI Quick Path → 正文

  • 文档总入口按职责分流。我们的总入口开头一段写着「查资料时先判断文档的职责,不再按文件名在根目录猜位置」,下面的「快速入口」表只有两列:想了解什么|入口。
  • 一级目录 README 做二级路由,同时写放置规则。测试中心的 README 只有两节:「导航」告诉读的 Agent 去哪份,「放置规则」告诉写的 Agent 新内容放哪。读和写走同一张图,新文档才不会绕开路由(放置规则是第③篇的主题)。
  • AI Quick Path 是文档开头的一节有序阅读清单:处理这类任务时先读本文哪一节,再读哪份。它是链的最后一跳,把 Agent 从文档门口直接送到需要的那一节。
## AI Quick Path

处理本类任务时按顺序读:
1. 本文「核心规则」节;
2. <上层通用规范>:本文未写到的规则;
3. <相关变更的需求与设计>:为什么改、改什么;
4. <对应索引>:取号、查历史。

为什么是一条链,而不是一张大表?(对应「上下文有限且有成本」)每一层只装下一跳,Agent 每走一步只付一小段 token;新增一份文档只改它所在目录的 README,不碰入口文件,路由表也就不会随文档变多而变长。

最后一跳我们做得最少:治理前,文档目录下带 AI Quick Path 的文档只有 2 份。链的前几层都在,最后一跳大多缺席,Agent 到了文档门口仍要从头读起。

八、机制 5:交接文档是一次性路由

路由表服务所有会话,交接文档(handoff.md)只服务一次:一个变更从这个 Agent 交到下一个 Agent 手里。我们一份跨层变更的交接文档,「给编码执行者的任务说明」一节开头就是阅读顺序:

先读本目录 requirements、design、implementation、test-cases、impact-map,再读根 AGENTS.md 与开发工作流。

「本目录」指这次变更的 bundle:一次变更的需求、设计、实现记录、用例同放一个目录,称为 bundle。后面一节叫「每次交审需要提供」,列了 5 项:分支与提交及计划偏差;新增用例的编号、实际命令、结果与报告位置;本阶段关键证据;涉及迁移时的编号防冲突与首跑、复跑证据;更新实际执行记录。再往后是一张「审核阻断清单」,每个检查项写明「不通过的具体情形」,给负责审核的那个 Agent 用。

这是为这次变更、这个接手者定制的路由,方向有两个(对应「无跨会话记忆」,接手的 Agent 不知道上一个会话做到了哪):

  • 进:先读什么、按什么顺序。和路由表同构,只是范围缩到一个 bundle。
  • 出:每次交审必须附哪些证据。它规定了 Agent 交给下一环的东西长什么样。

审核阻断清单和路由表是接上的:路由表「代码审查」那一行指向的正是它。一个审查任务的完整路径因此是:入口路由表 → 该变更的交接文档 → 阻断清单与用例矩阵 → 代码。人在链的末端,读的是 Agent 按清单整理出的证据,做的是放行或打回。

交接文档是一次性的:变更完成后,它就成为 L2 的历史,不再承担路由职责。

九、每类工作怎么走

把五个机制合起来,按工作类型列出完整路径。下表是推荐形态,不是我们项目现状的照搬——现状离它还有距离,见下一节。「项目层」一列写的是项目层要填什么。

工作类型L1 入口通用层项目层下一跳人的角色
需求文档模型规范需求模板与必含章节编号、目录约定该变更 bundle 的需求文档对范围拍板
开发项目开发规范通用开发规范分支模型、本项目硬约定该变更的设计与交接文档批准 plan
重构项目开发规范通用开发规范重构专题的历史教训当前变更 bundle对专题优先级与收口拍板
测试项目测试规范通用测试规范占位项填充、子工程落地说明用例索引、该变更的用例拍板测试口径
封板封板规范通用发版流程全量口径、封板 checklistrelease 两件(写入);L3 封板测试报告封板验收:按 checklist 放行
用户手册手册维护约定—哪些改动必须更新手册手册对应章节(只写入,不作知识来源)抽查后确认交付物
DB 变更项目数据库规范通用数据库变更规范方言、迁移目录、执行工具、坑迁移索引(取号)对兼容口径与例外拍板
部署部署 runbook(操作手册)通用部署规范环境分册、包名规则本次迁移执行清单批准上生产
代码审查审查检查项通用审查清单本项目检查项交接文档的阻断清单与用例矩阵按证据拍板放行或打回
浏览器调试浏览器调试约定—选择器约定固化成测试用例验收:看固化后的用例结果放行

先看「下一跳」:没有一格指回入口文件,路由只往下走,终点是 L2、L3、交付物或代码本身;标着「写入」的是交付物,Agent 维护它们,但不从中获取工作知识。「通用层」有空格,是因为不是每类工作都要拆两层,只有规则能跨项目复用时才拆。「人的角色」每格都是一个裁决点,人裁决时读的,正是「下一跳」那一列产出的报告、证据和执行清单。

十、路由会怎么坏

坏法一:路由表长成入口文件本身

机制 1 的片段里有两行,必读目标是「入口文件某某节」。原表更明显:立规时 7 行里有 3 行指回入口文件自身;10 月 14 日扩到 12 行,指回自身的变成 5 行。表下的分层阅读原则自己列了四项 L0 硬约定,可这 5 行里只有两行指向它们;新增业务类型同步清单、选择器约定、移动端真机兼容这 3 行,是只有特定工作才用得上的细则,却住在每次都加载的入口文件里。

另一个征兆在单元格里。原表的要点写在括注里,浏览器那一格的括注写了规则正文、证据口径,还有立规日期。一格长成了一段,路由表就开始承载内容。

与此同时,入口文件从立规后的 424 行涨到了 457 行(治理后降到 242 行,见第④篇)。路由表本该替入口文件减负,照这样写下去,反而会给它加厚。

对策有三条:

  • 一行的目标如果是「入口文件某节」,先问它在不在 L0 硬约定清单里;不在,就把那一节下沉为 L1 文档,路由指向它。
  • 要点列只许一句话,写不下的内容属于目标文档。
  • 新增一行之前,先过一遍收录标准:高风险、低频、凭印象易错。

坏法二:指向已被替代的文档

断链至少会暴露:Agent 一打开就报错。我们入口层目前的断链数是 0。真正危险的是链接活着、目标已经不是当前事实——按字面执行的 Agent 走到了就信。「已被替代」有两种:一种是正式被新版取代;一种是本来只代表某一次的历史实例,却被当成当前定义来用。

后一种我们的封板路由就有,出在链的末端。立规时的封板规范关于交接说明那一条写的是「模板照 v2.0.2-delivery.md」,后面跟一张必含项清单:路由链的最后一跳落在一份历史交付文档上,而不是一份当前定义。开场第二条两跳的路是同一个形状:路标提到封板,终点却是某一次封板的需求。指向历史实例,等于把「照上次那样做」写成了规定;我们的交接说明后来逐代走样,从 v2.0.3 起就缺了规范明文要求的 SHA-256 校验值。模板怎么做,第③篇细说。

前一种的问题在字段:文档被新版取代,却没在头部写 Replaced by。第①篇数过,我们多数 bundle 没写这个字段,「读到已替代就先跳转」的规则因此空转。

对策:路由只指向「当前定义」类文档(规范、模板、索引),或写成「对应变更的交接文档」这种随任务而变的指针;不写死某一次的实例(某版交付文档、某个历史变更计划)。可能被替代的目标必须带 Replaced by。脚本能查「链接活着」,查不了「目标还有效」,后者要靠元信息字段,怎么强制留给第④篇。

坏法三:项目层不声明继承

这是我们项目里最完整的反例。治理前,项目测试规范、项目数据库规范对各自通用规范的引用次数都是 0;通用数据库规范还标着 draft v0.1(草稿)。当时我们的项目层文档里,正确声明继承的只有机制 3 那份移动端测试落地说明。

讽刺的是,通用数据库规范开头当时就有 AI Quick Path,第 1 步读通用规范,第 2 步读项目适配文档——上层指向了下层。可这条路只从上往下:路由表把 Agent 直接送到项目层,而项目层没有一句话让它往上走一层。沿路由表进来的 Agent 读完项目数据库规范,通用层的规则对它就不存在。两层实际上成了两份互不相识的文档。

对策:项目层开头的继承声明是必填项,而且最容易机器检查(对应「能 grep / glob / 跑脚本」)——这个缺陷本身就是一次 grep 查出来的:项目层文档里有没有指向通用层的链接,一条命令就知道。通用层若还是草稿,项目层要么推动它定稿,要么声明「本文自足」,别让 Agent 去猜一份草稿算不算数。治理时两份项目层规范开头都补上了继承声明,通用数据库规范定稿,缺继承声明由 doc-lint 报错(见第④篇)。

十一、小结

  1. 路由失败常常不是「被淹没」,而是「没有路」:从手头的任务出发走不到的文档,对 Agent 等于不存在;引用连通不等于有路。修路比加粗有效:开场那条路在入口里只占一行。
  2. 三条原则:入口文件只路由不承载(上下文有限且有成本);按工作类型路由,不按目录(无跨会话记忆);每一跳声明下一跳和自己的上层(按字面执行、不会意会)。
  3. 路由表只收高风险、低频、凭印象易错的操作;一句话要点保证没跳转也拿到最关键的约束。
  4. 一个入口,其余转发;一条经验换个 Agent 还该生效,就进仓库,不留在个人记忆。
  5. 路由是一条链:入口 → 总入口 → 目录 README → AI Quick Path → 正文;交接文档是给下一个 Agent 的一次性路由。它会坏在三处:表长成入口、链尾指向历史、项目层不认上层。

系列导航

  1. Agent 不是失忆,是找不到入口——大型项目文档分层设计(一)
  2. 让 Agent 只读该读的——文档路由设计(二)(本篇)
  3. 让每份文档都有唯一的位置——文档寻址设计(三)
  4. 让文档不烂——Agent 时代的文档生命周期(四)