[控场AI]
· 19 分钟阅读· 9,652 字

给每份文档唯一的地址:AI Agent 时代的文档寻址体系

给每份文档唯一的地址:AI Agent 时代的文档寻址体系

给每份文档唯一稳定的地址,让 Agent 不靠猜就能找到并信任文档。

本文是"Agent-first Documentation Layers for Large Projects"系列的第三篇,聚焦 Agent 应去哪里找文档、找到后该信哪一份。作者从一个真实支付审批系统项目出发,指出目录只回答"哪个区域",要让 Agent 找到并信任文档还需四层寻址:目录定区域、ID 定身份、状态与替代链定有效性、模板定章节位置。文中提出三条原则——目录按责任划分且完成不搬家、一个事实一个源其余为派生视图、状态写在文档字段而非目录名中——并给出稳定 ID 方案、薄索引与派生视图、状态机与替代链、模板即 lint 规则等具体机制。清理前后用同一统计脚本测量,索引行宽、状态拼写、重复 ID、未登记包、模板数量等指标均显著改善。文章还分析了寻址会再次坏掉的四种失败模式及其解法。

给每份文档唯一的地址:AI Agent 时代的文档寻址体系

文档寻址体系

当一个大型企业级支付审批系统的文档树被 Agent 反复读错、写错、引用错时,问题往往不来自模型能力,而来自文档本身没有"地址"。本文是 "Agent-first Documentation Layers for Large Projects" 系列第三篇,聚焦一个具体问题:Agent 该去哪里找文档,以及找到之后该信哪一份。素材来自一个真实项目——该项目已经按这套方法论做过一轮文档清理,前后数据由同一个统计脚本在两棵目录树上测量。

搬完 235 个文件之后,问题并没有结束

项目在 9 月 3 日重排文档架构,动了 235 个文件。此前测试材料散落在四处,计划根目录下并存着好几种拆分方式,Agent 找文档基本靠猜文件名。重排之后,测试材料收拢到一处,计划被归成三类:结构化变更包(一个变更的需求、设计、实施记录、测试用例放在同一目录)、长期轨道(track)、早期单文件计划。

位置是有了。问题没完。

  • ID 撞车:两个变更都编号 F-20260909-005,两个目录共享一个号,当时取号既没规则也没检查。
  • 迁移撞车:9 月 29 日,两个同编号的数据库迁移脚本都已在生产执行过,只能挪走重做,事后才补上规则:"取号前先扫所有并行分支和 worktree"。
  • 包名分不清:9 月 30 日一个同名旧包被误部署,之后包名强制带时分秒。
  • 同一事实多份叙述:变更目录 README 声明"我们不维护第二份状态表",然后自己维护了一份状态列表,停在 9 月 22 日。

目录只回答"哪个区域"。要让 Agent 找到并信任一份文档,它还需要知道是哪一份(ID)、是否仍然有效(状态与替代关系)、信息在哪一节(模板)。这四层合起来,作者称之为 addressing(寻址)。

五类症状与一个根因

寻址在这个项目里以五种形态失效:

  1. 靠猜。迁移、变更包、测试报告按"日期 + 英文 slug"命名;早期单文件计划和事故报告用长中文标题。没有一种模式能唯一通配一类文档。
  2. 同号。两个目录一个 ID,Agent 从 ID 出发不知道进哪个目录。
  3. 一个事实多处存放。状态写在包头部、索引、目录 README 三处;用例索引旁还有两份手工同步的视图。不一致时 Agent 只能随便挑一个信。
  4. 目录名被读成状态。早期计划放在 legacy/ 下,字面意思是"遗留",很容易被读成"已完成归档"。
  5. 文档内找不到节。同类报告每一版的小节都不同,想找"回滚步骤"要从头读到尾。

根因只有一个:位置、身份、状态、结构这四类信息,要么只是惯例,要么写在多处,要么借别的东西表达。目录名被用来表达状态,上一份文档被用来表达结构。人会脑补,按字面执行的 Agent 不会:目录叫什么就是什么,这份文档长得像上一份就像上一份。

三条原则

原则一:目录按责任划分,不按类型、不按状态;活儿干完不要搬家。 目录只回答"这份文档是干什么用的"。类型和状态都是字段。一旦按状态划目录,一个完成的变更就得搬家,所有指向它的引用——其他文档、提交信息、迁移脚本里的注释——一起断。路径稳定,Agent 才能靠模式而不是靠记忆找文档。

原则二:一个事实一个源,其余都是派生视图。 两份手工维护的副本一定会漂移。这次会话改了包头部,下次会话可能忘了改索引。所以指定唯一源,其他位置要么只放链接,要么由脚本生成。

原则三:状态写在文档里,不用目录表达。 状态是字段,词表固定;替代关系是指向新 ID 的字段。目录名只意味着一种责任。

责任目录 + 放置规则

稳态目录结构如下(节选)。"稳态"指一个新项目第一天就该有的形态:

docs/
├── README.md                    文档首页:按责任划分 + 放置规则
├── overview.md                  系统是什么、边界在哪(当前事实)
├── roadmap.md                   全局路线图
├── handbook/                    长期有效的规范
│   └── templates/               每种文档一份模板
├── testing/
│   ├── guides/                  测试规范
│   └── cases/                   全局用例索引及其派生视图
├── deployment/                  当前可执行的部署手册
├── plans/
│   ├── index.md                 变更索引
│   ├── YYYYMMDD-<t>NNN-<slug>/  一个变更一个包,平铺;完成后原地不动
│   └── tracks/<topic>/          长期轨道的导航
├── reports/                     执行证据:测试、审计、事故、迁移
└── releases/                    交付物:变更日志、交接说明

reports/ 是给 Agent 下钻的证据,releases/ 是给业务和运维的交付物,分开的理由就在这里。plans/ 下没有 done/ 或 bugfix/ 子目录,类型和状态都是字段。每个顶层目录的 README 有两节:给读的 Agent 的"导航",给写的 Agent 的"放置规则"。

稳定 ID 方案

稳定 ID 是文档的固定编号,创建时取号,之后不随标题、目录、类型变化。方案最值得直接抄的部分在这张表:

对象ID / 文件名机械关系
变更<T>-YYYYMMDD-NNN ↔ 目录 YYYYMMDD-<t>NNN-<slug>互相可推导;首字母由创建时的类型固定
数据库迁移NNNN-YYYYMMDD-<slug>.sql全局序号决定执行顺序
测试用例TC-<module>-NNN从 ID 能读出模块
报告及其他记录YYYYMMDD-<slug>.md按日期排序;发布封版测试报告的 slug 带版本号,可按版本 grep
发布文档vX.Y.Z.md / vX.Y.Z-delivery.md变更日志与交接说明按版本成对出现
发布包vX.Y.Z-YYYYMMDD-HHMMSS-dist.zip同一天多个包可区分

取号规则是最容易被低估的一块。多个 Agent 在不同分支、不同 worktree 上并行工作,各自看自己目录取"下一个号",撞号几乎必然。规则是:取号前扫描每个本地分支、远程分支和 worktree,取全局第一个空号;发生冲突时后申请者移动——目录、ID 和所有引用一起改,内容不动,并在移出记录里写明原号。迁移脚本撞号代价更高,因为序号就是执行顺序。发布包加上时分秒是同一个道理。

worktree 是 Git 提供的多工作目录机制:同一个仓库可以同时检出到多个物理目录,各自处于不同分支或提交,共享同一份对象库。它解决的问题是:开发者(或 AI Agent)想在不切换当前分支、不打断编译或测试进程的前提下,在另一条分支上并行工作。git worktree add 会新建一个目录并绑定一个分支。这与 git clone 有本质区别——clone 是复制整个仓库,产生互不相干的历史;worktree 里各工作目录共用同一 .git 和引用空间,分支、标签、提交在它们之间即时可见。对本文的取号场景而言,关键在于:每个 worktree 有自己的文件系统视图,Agent 若只在自己目录里扫描文件,看不到其他 worktree 中已取走的编号,因此取号前必须把所有 worktree 路径都纳入扫描范围。

分支(branch) 是 Git 中指向某次提交的可移动指针,让开发线可以分叉、并行、再合并。它本身只是一个轻量引用,创建成本近乎为零,因此常被用于为每个功能、每次修复开辟独立的开发线。多个分支同时存在时,各自可能修改同一批文件。当两个分支都新增了编号为 F-20260909-005 的变更包时,合并时就会冲突——这不是文件内容冲突,而是两个不同文件争用同一个 ID。本地分支之外还有远程分支(origin/xxx),它们代表其他机器或其他人已经推进的编号状态。因此本文要求的"扫描每个本地分支、远程分支和 worktree",本质是让取号动作基于整个仓库的全局编号空间,而非某个局部视图。

变更包与轻量形态

变更包的四件套各回答一个问题:需求回答"要什么",设计回答"打算怎么做",实施记录回答"实际怎么做、哪里偏离",测试用例回答"怎么证明"。只定义四件,现实会自己长出别的文件——清理前包里有 7 份手写用例、5 份影响分析、4 份交接文档、十余份阶段小册子,规范里一个都没定义。清理后变成可选文件白名单:影响地图、交接文档、人工测试用例、独立复审记录、阶段小册子,各有需要条件和读取时机。白名单之外的新文件一律算错,脚本放 scripts/。

另一端是小变更。清理前 20 个包只有需求文件,18 个已完成的包没有实施记录,写回循环断在这里。清理后定义轻量形态:小修、文档改动或发布型变更只需需求,状态推进到"已实施"时文档里必须含一节"实施记录"。区分的依据是一个可判断的条件——有没有设计。

长期与短期也必须分开。变更包是短期;轨道是长期,只写为什么、顺序、边界,以及指向相关包的链接,不放计划、不写状态。在轨道里写状态会制造第二个状态源,违反原则二。

薄索引 + 派生视图

索引只回答"去哪读",不回答"是什么"。清理后表头固定六列:Stable ID | Type | Title | Status | Next step | Plan。标题最多 50 字,下一步最多 80 字,终态的下一步写 -,Plan 列只放包入口链接,索引里禁止日期横幅。

更重要的是源与投影的关系:包头部元数据块是状态的唯一真源,索引是它的投影,不一致就是检查器的错误。用例索引同理:主表是唯一源,按层和按模块两份视图由脚本从主表生成,禁止手改。清理前主表上堆着一批批叙述,叙述里很多 ID 不在主表中,两份视图各自也有一批主表没有的 ID——从不同门进来的 Agent 看到不同的"全部用例"。

状态机、替代链与归档

状态词表里每个值都要对应 Agent 的用法,不能只写"含义"。draft 只是背景不能作为实施依据;approved 可以作为实施依据;implemented 要读实施记录和测试记录;verified 才能当当前事实用;superseded 必须跳到 Replaced by。清理前索引里 completed、done、in_progress 和自由中文并存,类型列里 bug 和 fix 混用,还有 feature/fix 这种组合。允许近义词就等于邀请 Agent 去推断 done 是不是 verified。

替代链是一对字段:旧文档写 Replaced by: <新 ID>,新文档写 Replaces: <旧 ID>,读到 superseded 必须跳转。清理后每个包都写 Replaced by,没有就写 none——显式的"无"也是信息。

最后是归档这个词。归档只指已被替代的历史,比如被新部署手册取代的旧手册,移出当前目录,在原位置留一个指向新文档的链接。完成的变更不归档,它留在原地,状态写在元数据里。理由回到原则一:完成的变更是被引用最多的,搬家会断掉所有引用。

状态机(state machine) 在这里指一组预先定义、互斥且可迁移的状态值,以及它们之间允许的转换。文档状态通常包括 draft、approved、implemented、verified、superseded 等。它的价值在于:每个状态不仅是一个标签,还对应"读者可以据此做什么"的明确语义,例如 approved 才允许作为实施依据、verified 才允许作为当前事实引用。状态机的迁移路径也需要约束,例如未 approved 的文档不应跳到 implemented,verified 的文档被替代后才进入 superseded。如果允许自由赋值,done、completed、finished 这类近义词就会同时出现,Agent 只能自行推断它们的等价关系,而推断标准并不存在。因此本文强调词表固定、每个值都必须写明 Agent 用法,本质是把状态定义成一个可机械判读的状态机,而不是一份标题词库。

派生视图(derived view) 指从唯一真源经过确定性转换(通常是脚本)生成、不承载独立事实的副本。例如用例索引的主表是唯一源,按层、按模块的视图都是派生视图:它们的内容完全由主表决定,一旦主表变化,重新生成即可保持一致。派生视图的判定标准是"能否从源重新推得"——可以,就是派生视图;不可以,就是第二个源。派生视图的正确使用方式是只读,禁止手工编辑,因为手改会让它与源产生分歧,而分歧一旦出现,阅读者就无法判断哪一份可信。本文中索引是包头部的投影、模块视图是主表的投影,都属于这一类。把它们与"一个事实一个源"原则配合使用,才能既保持多角度查询的便利,又不制造第二个真相。

稳态规则 vs 存量接入

从一个项目抽象共享规范时最容易犯的错,是把那个项目的历史当成共享结构。源码项目的共享规范里画出 legacy/YYYYMM/,评审指出 legacy/ 和 changes/ 都是接入的产物。按日期画线的例外更隐蔽,它们散在规则文本和检查器代码里。

所以规范要分两种情境写。稳态:新项目第一天就按规则归档,没有冻结区、没有日期例外、豁免基线为零。plans/ 下只有索引、平铺的包和 tracks/,靠名字模式区分,不需要再套一层 changes/——那种额外层级只为把包和冻结区分开。而且"变更"按字面读就是"修改",新项目的第一个功能不修改任何东西,按字面执行的 Agent 会被这个名字误导。

存量接入需要一个接入配置,里面放四样东西:冻结区、规则生效日期、豁免基线(只降不升,归零删除)、历史目录层。该项目的 changes/ 属于第四种,被 73 个文件引用 564 处,13 个已执行迁移脚本里的注释改不了,所以保留。新项目没有这个文件,检查器零豁免全量检查。

冻结区的命名要格外小心。冻结区意味着旧格式,不意味着已完成。还要推进的工作必须登记在索引里。legacy 这个词天然把人拉向"已完成归档"——该项目 legacy/202504/ 下只有一个文件,文件名结尾是"以后再做",一件明确未完成的工作躺在看起来已归档的目录里。人一眼能看出错位,按字面执行的 Agent 会把它当成做完了。

接入(onboarding,此处指存量接入) 指的是把一个已经运行了一段时间、积累了历史结构与历史文件的项目,逐步纳入新规范的过程。它与"新项目从第一天就按规范建立"是两种完全不同的情境:新项目没有历史包袱,可以做到零豁免、无冻结区、规则全量生效;老项目必然存在早于规则生效日就已存在的文件、目录和引用,不能一刀切,否则会破坏大量外部引用或让检查器长期报错。因此存量接入需要一份显式的配置,把无法立即满足规范的部分隔离出来,包括冻结区(规则暂不适用的历史目录)、规则生效日期(此日期之前创建的内容按旧规处理)、豁免基线(允许存在的违规数量,只降不升,清零后删除该条豁免)、历史目录层(如 changes/、legacy/YYYYMM/ 这类仅为兼容历史而保留的层级)。这种配置的本质,是把"历史"与"规范"之间的差异显式记录下来,让规范可以对新内容硬性执行、对旧内容渐进收拢,而不至于为了照顾历史而把例外散落在规则文本和检查器代码里,最终模糊掉规则本身。

模板是文档内部的寻址

目录和 ID 回答"文档在哪",模板回答"信息在哪一节"。节名固定,Agent 就能直接跳到"回滚与验收边界",不必通读全文。

没有模板会发生什么,素材里有一个完整的反面案例。发布封版规范当时对交接说明写的是"模板参照 v2.0.2-delivery.md"——把一份历史文档当样例,等于把模仿写进规则。从 v2.0.3 起交接说明缺 SHA-256;回滚一节从 v2.0.1 到 v2.2.0 连续 8 个版本缺失,到 v2.2.1 才回来;从 v2.0.3 起变更日志丢掉"本版测试"和"构建可追溯"两节;发布封版测试报告的小节数走出 6→3→5→5→5→4→8→6→6→6;5 份事故报告、13 份审计报告、10 份迁移交接几乎各自发明结构。唯一稳定的小节是交接说明里的"交付结果",14 份里 14 份都有。作者称之为模仿漂移:每一代只做"照着上一份写",偏差代代相传。

模板的规则是:一种文档一份模板,全部放在同一模板目录,README 用一张表登记;模板头部注释声明适用路径和必含字段;模板的 H2 标题就是必需小节,顺序固定;模板本身就是 lint 规则,检查器直接读模板,不另存一份规则列表;新文档从模板复制再删掉头部注释填写,禁止复制上一份同类文档。

清理后,文档模型规范从 1123 行的混合体(其中约 460 行是模板)拆成 252 行项目层、独立的共享层和 31 份模板,覆盖仓库中 338 份适用文档,没有一份无主,也没有一份被两份模板同时认领。

lint 原指源代码的静态检查工具,最初用于在编译前发现 C 语言中可疑的、可能出错的写法,后来泛指所有不执行程序、仅通过扫描文本或语法结构来报告问题的检查。在本文语境中,doc-lint 是把同一思路应用到文档上:检查器读取文档的路径、头部元数据、H2 标题乃至正文短语,验证其是否符合已定义规则,例如状态是否在词表内、必填小节是否齐全、ID 是否唯一、模板要求的字段是否存在。lint 的关键特点是与运行时解耦——它不关心文档内容"对不对",只关心结构"合不合规"。这也解释了为什么"模板本身就是 lint 规则"是个重要论点:如果模板和检查器各自维护一份规则列表,两者必然缓慢漂移,最终出现检查器放行、模板却要求补充的情况。让检查器直接读模板文件,规则就只有一份,修改模板即修改规则,模板与检查天然同步,且新增一种文档类型时无需改动检查器代码。

清理前后

"前"是清理分支起点(10 月 14 日),"后"是清理合并后的状态(10 月 15 日),同一统计脚本逐项测量,可复现。与寻址相关的几行:

指标前后
计划索引列/行8 列 / 56 行,4 行列数错位6 列 / 62 行,0 错位
索引行宽(字符)平均/最大394 / 1128157 / 227
状态不同拼写9 种实际使用 6 种(词表 9 个)
类型不同拼写13 种6 种
重复 Stable ID10
未登记进索引的包1(共 51)0(共 53)
无 Stable ID / 无 Replaced by 的包16 / 350 / 0
元数据格式3 种并存1
用例索引457 行;主表 86 个 ID;叙述另有 83 个 ID 不在主表169 行;主表 86 个不变;叙述 ID 归零
文档模板031

索引变薄不等于删信息,行数没缩,缩的是每行的宽度,进度叙述回到包里。清理前检查器的 ERROR 里,前四名全是寻址问题:用例叙述混在主表前 102 个、类型不在词表 39 个、包缺元数据 35 个、状态不在词表 28 个。

寻址会怎样再次坏掉

失败一:豁免让模板只治理新文档。 大多数存量文档写在接入之前,不匹配模板,只能进豁免清单。模板上线后,它管的其实主要是新文档,存量等被碰触时再迁移。40 个存量文件名不合规,按原则一不主动移动。解法是:一旦碰到冻结区或豁免文件,顺手迁到标准形态,让清单真的往下走。

失败二:规则落地前就已开工的工作,只在合并时才撞上新规则。 v2.3.2 的变更日志和交接说明在 10 月 15 日早上提交到主开发分支,约 30 分钟后清理被合并进来。写的时候主开发分支还没有 doc-lint 和模板,唯一可抄的"上一份"就是旧格式,检查在合并时才报出来。这不是有人违规,而是规则落地前就已开工的工作,只在合并时才撞上新规则。解法是把"新文档从模板开始复制,不要模仿上一份"写进路由表和规范顶部的 AI Quick Path,并在合并时重跑检查作为最后一道闸。

失败三:模板太严会逼 Agent 填废话。 如果每节都必填,Agent 为过检查会塞样板文,而样板文和真内容长得一样,裁决者分不出来。解法是把必填节压到最少,其余标"可选",用不上就删;没内容就写"无",比一段样板更诚实。模板规定的是去哪找,不是写多少。

失败四:按内容判断适用范围。 模板检查原本靠正文里的短语判断一份文档是不是生成视图、要不要跳过,结果一份提到生成命令的设计文档被整个跳过,直到对清理计划自己的文档跑检查时才被发现。适用范围是寻址问题,应该按路径判断——模板头部注释里的适用路径比任何正文特征都可靠。

小结

寻址有四层:目录回答哪个区域,ID 回答哪一份,状态与替代链回答是否仍然有效,模板回答信息在哪一节。少一层,Agent 就得猜。三条原则撑起全部机制:目录按责任划分且完成不搬家,一个事实一个源其余皆派生,状态写在文档里而非目录名里。稳定 ID 要能双向机械推导,取号要扫遍分支和 worktree,冲突时后申请者移动并记录原号,已发放的 ID 只豁免不改名。稳态规则和存量接入分开写,新项目零豁免无冻结区,老项目的冻结区、生效日期、豁免基线、历史目录层全部放进一份接入配置——名字要按字面读,legacy 和 changes 都是。模板是文档内部的寻址,也是检查规则本身;没有模板,Agent 只会模仿上一份,而模仿会漂移。

#AI Agent 文档管理#文档寻址#稳定 ID 方案#Agent-first 文档架构#文档模板漂移#大型项目文档规范#文档生命周期
分享:

相关推荐