用结构传达意图:分层与维度
意图的文档化
上一节诊断了三种 vibe coding 的失败模式:早期指令被挤出注意力范围,前后矛盾的指令没有优先级,compaction 主动删除信息。这三个问题有一个共同的结构性原因:意图活在对话里。
把意图写成文档,这三个问题同时得到缓解。
你改了主意,不再需要在对话里追加一条新指令然后祈祷 Agent 能正确判断新旧指令的优先级。你直接改文档里的那一行。文档的当前版本永远只有一个立场,没有需要调和的矛盾。
文档独立于对话存在,不跟聊天内容争抢 context 空间。你的对话可以讨论各种探索性的想法、调试过程、临时的尝试。但最终的意图沉淀在文档里。Agent 需要知道你的意图时,读文档就行,不需要从几十轮对话中提取和推理。
文档持久化在文件系统里。对话被 compaction 压缩了,文档不会受影响。Agent 可以随时重新读取完整的原始内容。你在对话前期花二十分钟讲清楚的架构约束,只要它被写进了文档,就不会因为 compaction 而消失。
很多开发者从纯对话切换到维护一份 spec 文档之后,立刻感觉到产出质量的变化。Agent 不再丢约束了,因为约束写在文档里,每次都被加载。这个方向是对的。
但自然语言文档引入了一个新的问题:歧义。你写了一段描述,你觉得意思很清楚。Agent 也觉得意思很清楚。但你们理解的可能不是同一个东西。这种差异你发现不了,直到你看到 Agent 生成的代码。
解决歧义的方式不是写更多的自然语言。三页的描述可能比三行的描述歧义更多,因为更多的句子意味着更多可以被不同方式解读的地方。真正有效的方式是写对结构。要理解什么结构是对的,需要先理解信息本身的性质。
四层信息与按需加载
想一下人类团队是怎么协作的。
一个五人的开发团队在做一个博客系统。程序员 A 在写文章编辑器,程序员 B 在写搜索功能。A 不知道 B 给搜索索引用了什么字段名,B 不知道 A 的编辑器用了什么富文本库。但最后他们的代码合在一起,搜索能搜到编辑器发布的文章,编辑器保存的内容能被搜索正确索引。
这是怎么做到的?A 和 B 共享了足够高层的 context。他们都知道这个产品是给非技术用户用的,所以交互要简单。他们都知道系统是前后端分离的,API 用 REST,数据库用 PostgreSQL。他们都知道文章存在 posts 表里,有 title、body、published_at 这些字段。他们不需要知道对方代码的每一个细节,因为这些共享的高层决策已经足够约束他们各自的实现方向。
如果缺少这些高层共识会怎样?A 可能自己定义了一套文章数据结构,B 也定义了一套,两套不兼容。A 可能把文章内容存成 HTML,B 的搜索引擎期望纯文本。两个人各自的代码都能跑,合在一起什么都不对。
这个观察揭示了一个关于信息的基本事实:信息天然有层级,不同层级的性质完全不同。
用这个博客系统做例子,从上到下可以看到至少四层。
最顶层是产品的 vision。"让非技术用户也能轻松发布和管理内容。"这句话非常抽象,几乎不包含任何实现细节。但它极少变化,可能在产品的整个生命周期内都不会改。而且它的适用范围最广,团队的每一个人都需要知道它,每一个决策都应该跟它一致。
下一层是架构决策。"前后端分离,API 用 REST,数据库用 PostgreSQL,前端用 React。"这些决策比 vision 具体,但仍然是系统级的。它们偶尔会变(比如从 REST 迁到 GraphQL),但变化的频率远低于具体功能。它们的适用范围是整个开发团队,所有人都需要遵守。
再下一层是 feature spec。"文章搜索功能:支持关键词全文搜索,结果按相关度排序,无结果时显示提示信息。"这比架构决策具体得多,而且随着产品迭代会频繁变化。这个月搜索只支持标题,下个月可能要加上全文。它的适用范围也窄了,只有负责做搜索功能的人需要了解它的全部细节。
最底层是 task 细节。"在 search.ts 的 buildQuery 函数里调用 PostgreSQL 的 tsvector 全文搜索,结果取 relevance 降序的前 20 条。"这是最具体的信息,它可能随着一次 code review 的反馈就改变。它的适用范围最窄,只在执行这一个 task 的时候需要。
四层信息的规律很清楚:从上到下,越来越具体,变得越来越快,需要知道的人越来越少。
这个分层不是一种组织偏好。它反映的是信息的内在性质。Vision 之所以抽象,因为它描述的是目标而不是路径,而达成同一个目标可以有很多条路径。Task 之所以具体,因为它描述的是一条路径上的一个步骤,必须精确到可执行。这两类信息的生命周期、变化频率和适用范围天然不同,把它们混在一起管理是不合理的。
这个分层跟 Agent 的 context 限制直接相关。
如果你把四层信息全部塞进 Agent 的 context,大部分信息对当前任务来说是噪声。Agent 在做搜索功能的时候,不需要知道评论系统的 spec,也不需要知道用户认证模块的 task 细节。这些信息占了 context 的空间,分走了注意力,但对当前任务的对齐没有任何帮助。
合理的做法是按层加载。高层信息(vision、架构决策)稳定且适用范围广,每次都加载。低层信息(当前 feature 的 spec、当前 task 的细节)只在需要的时候加载,完成后就可以从 context 中移除。这就是为什么你需要把信息拆分到不同的文档里,而不是维护一个包含所有内容的大文件。
Ryan 的实践是这个原则的一个具体体现。他的项目上下文(project-context.md)承载高层信息:技术栈、架构、目录结构、模块索引、代码模式。这份文档限制在 200 行以内,Agent 每次开始工作时都加载它。每个 feature 有自己独立的 spec、task list 和 checklist,只在 Agent 执行该 feature 时才被加载。另外他用 CLAUDE.md 存放跨层的强制约束(编码规范、禁止直接操作数据库之类的规则)。三类文档对应不同的层级和不同的加载策略。
判断一条信息应该放在哪一层,可以看两个指标:变化频率和适用范围。如果一条信息在整个项目生命周期内基本不变,所有任务都需要它,它属于高层,应该放在每次都加载的项目上下文里。如果一条信息只跟某个 feature 相关,feature 完成后就过时了,它属于任务层,应该放在那个 feature 的 spec 里。
有两个信号可以帮你判断分层是否合理。如果你发现自己在每个 feature 的 spec 里都重复写同样的信息,比如每次都要提醒 Agent "我们用 REST API,不要用 GraphQL",说明这条信息应该上提到项目上下文层,写一次就够了。反过来,如果你的项目上下文膨胀到了上千行,Agent 每次加载都要花大量 context 空间,说明里面有太多低层细节应该下沉到具体的 feature spec 里。
意图、验收、约束
分层回答了"信息为什么要拆分"和"每条信息该放在哪一层"。下一个问题是:每一层里面该写什么内容?
考察社区里的主流 spec 框架,模板的具体设计差异很大。BMAD 的 spec 有十几个字段,OpenSpec 相对精简,Spec Kit 有自己的结构,AILock-Step 又是另一套。但如果忽略字段名称和组织方式上的差异,观察它们实际上在要求你回答什么问题,会发现一个一致的模式:在每一层,你都需要从三个维度描述信息。
意图:这一层的目的是什么。
意图维度回答"做什么"和"为什么做"。它的内容在每一层都不同,但功能相同:确立这一层的方向。
在 vision 层,意图是产品要解决的根本问题。"让非技术用户也能轻松发布和管理内容。"在架构层,意图是系统为什么这样组织。"前后端分离,因为产品需要同时支持 Web 和移动端,共享同一套 API 可以减少重复开发。"在 feature 层,意图通常用用户故事来表达。"作为一个博客作者,我希望能通过关键词搜索已发布的文章,这样我可以快速找到之前写过的内容。"这句话包含了三个信息:谁(博客作者),要什么(关键词搜索已发布文章),为什么(快速找到旧内容)。缺少任何一个,Agent 的理解都可能跑偏。不知道"谁",它分不清这是给作者用的搜索还是给读者用的搜索,两者的需求完全不同。不知道"为什么",它不知道搜索的优先级应该是精确度还是速度。
用户故事来自产品管理领域,这里不展开它的方法论。它在 Agent 开发中有一个特殊的价值:以用户价值描述的需求天然具有可验证性。"用户能通过关键词搜索文章吗?"有明确的 yes/no 答案。这个特性在后面讲实操的时候会变得很重要。
验收:怎么知道这一层是对的。
验收维度是三个维度中最关键的一个。它在每一层检查的是一个核心问题:这一层的内容是否忠实地实现了上一层的意图。
在 vision 层,验收是检查用户故事和用户旅程能否反映 vision。如果 vision 说"让非技术用户轻松发布内容",但你的用户旅程里有一步要求用户手动配置 Markdown 渲染引擎,说明旅程跟 vision 产生了偏差。在架构层,验收是检查技术决策能否支撑 PRD 里的需求。如果 PRD 要求实时协作编辑,但架构选了一个不支持 WebSocket 的框架,说明架构跟需求之间有 gap。
在 feature 层,验收通常用 Given/When/Then 格式的场景来表达。比如:Given 数据库中有一篇标题包含"性能优化"的文章,When 用户在搜索框输入"性能"并点击搜索按钮,Then 搜索结果中显示这篇文章,并且匹配的关键词被高亮。
一个 feature 通常需要多个验收场景来覆盖不同的情况。正常路径是一个场景。边界情况是另一个:搜索词为空的时候怎么办?异常情况又是一个:没有匹配结果的时候显示什么?每个场景都在测试 feature 的行为是否跟意图一致。
验收场景的价值在于它把"做对了"从一个主观印象变成了一组可检查的条件。没有验收场景的时候,你和 Agent 对"完成"的定义可能完全不同。Agent 觉得搜索功能做完了:能搜,有结果。你觉得没完成:结果没有高亮,没有分页,搜索词为空时直接报错了。你们各自的"完成"标准都是合理的,只是从来没有对齐过。验收场景就是把这种隐含的期望差异提前暴露出来的工具。
Ryan 在实践中发现,验收场景也是他 review spec 时最高效的检查手段。他只需要看一眼用户故事和对应的 Gherkin 场景,就能判断 Agent 对需求的理解是否跟自己一致。他的原话是:"一个 user story 加上几个场景描述,就已经能判断出 AI 跟我的理解了。"
约束:这一层的边界在哪。
约束维度回答"不该做什么"和"已经有什么可以用"。它在每一层防止的是同一件事:在意图范围之外制造意料之外的改动。
在 vision 层,约束可能是市场限制、合规要求或商业模型的边界。在架构层,约束是技术能力的边界、性能要求、跟已有系统的兼容性。在 feature 层,约束回答的是:这次改动的边界在哪里?不能碰哪些模块?有什么已经存在的组件可以复用?
Feature 层的约束特别容易被忽略。你让 Agent 加搜索功能,它可能顺手重构了文章列表页的排序逻辑,因为它觉得搜索结果的排序和列表的排序应该统一。从技术角度看这可能是合理的。但这个改动不在你的预期内,可能破坏了现有用户的使用习惯,可能影响了其他依赖列表排序逻辑的功能。约束维度的作用就是明确告诉 Agent 改动的边界:只做搜索功能,列表页的排序不要动。
Ryan 的 spec 模板里有一个 Context Analysis 字段,包含三部分内容:参考代码(项目里已有的可以复用的模块),相关文档(跟这个 feature 有关的设计文档和历史讨论),历史 feature(之前做过的相关功能和它们的改动记录)。这个字段帮助 Agent 了解已有的东西,有效防止它重复造轮子。
空字段的代价值得强调。Ryan 观察到一个模式:当 spec 里技术方案相关的字段为空时,后续负责编码的 Agent 会开始自己编造方案。编造出来的方案可能跟项目已有的架构不兼容,可能引入了不必要的依赖,可能重复实现了已有的功能。空字段传递的信号很明确:那个维度的意图对齐没有发生。Agent 在那个维度上没有收到任何指引,只能靠自己猜测。
分解过程中的漂移
三个维度描述的是每一层该有什么内容。但在实际工作中,你不是一次性写好所有层的。你先写 feature spec,然后让 Agent 从 spec 生成 task list,再从 task list 推导出 checklist。每一步都在生成新的、更低层级的信息。
这个生成过程本身就会引入漂移。
漂移有两个来源。第一个是 Agent 在"翻译"的过程中加入了自己的理解。你的 spec 说"用户能搜索文章",Agent 把它翻译成具体的执行步骤:"在 search.ts 里加一个 buildQuery 函数,调用 PostgreSQL 的全文搜索。"这个翻译过程中 Agent 做了一系列决策:用什么函数名,调用什么数据库接口,返回结果的格式是什么。这些决策可能跟你的意图一致,也可能不一致。你在 spec 里没有说用 tsvector 还是 LIKE 查询,Agent 自己选了一个。如果它选的方向跟你的性能预期不符,这就是漂移。
第二个来源更隐蔽。Agent 在生成 task list 的时候,context 里已经堆了大量信息:project context、feature spec、对话历史、之前生成的代码片段。spec 里某条约束可能因为注意力分配不足而被忽略了。这跟前面讲的注意力衰减是同一个机制,只是这次发生在层级生成的过程中而不是对话的过程中。Agent 不是故意忽略了你的约束,而是 context 里的信息太多,那条约束在注意力竞争中落败了。
两种漂移的后果相同:下层信息跟上层意图不一致。而且漂移会逐层叠加。Spec 到 task 偏了一点,task 到代码可能在偏的基础上又偏了一点。如果你只在最终产出的时候检查对齐,你面对的可能是一个跟原始意图差了很远的结果,而且你很难定位到底是在哪个环节开始偏的。
所以验收不是最后做一次就够了。每次生成新层级的信息,都需要检查跟上层的一致性。
用交叉验证检测漂移
一种有效的方法是让 Agent 从不同角度看同一份意图,然后比较多次产出之间是否一致。
Ryan 的做法是三轮思考。第一轮,从需求出发生成实现方案和影响分析,形成 spec 文档。这是 Agent 对需求的第一次理解。第二轮,从 spec 出发拆成具体的执行步骤,形成 task list。这是第二次理解。拆步骤的过程本身就是一种检验:如果某个步骤写不清楚,往往是因为 spec 在那个地方还不够具体,方案有漏洞。第三轮,从 task list 出发反推验收标准,形成 checklist。这是第三次理解,方向跟前两次相反:不是从"要做什么"推"怎么做",而是从"怎么做"反推"怎么算做对了"。
三份文档之间的一致性就是对齐的证据。矛盾就是漂移的信号。
如果 checklist 里出现了一个检查项,但 spec 里完全没有提到对应的功能,说明 Agent 在生成 task 的过程中自己加了一些东西。这些东西可能是合理的补充(你确实漏了这个情况),也可能是 Agent 自己加戏了(它误解了需求的范围)。不管是哪种,矛盾的存在本身就值得你去看一眼。
如果某个 checklist 项跟 spec 里的描述直接矛盾,比如 spec 说"搜索结果按相关度排序",但 checklist 的验收标准写的是"按时间排序",说明意图在 spec 到 task 再到 checklist 的传递过程中发生了偏移。如果你不做这个交叉检查,这个偏移会一直带到最终的代码里。
Ryan 把第三轮称为"对抗性测试"。这个名字很贴切:它从一个跟前两轮完全不同的角度(验收而非实现)重新审视了同一份需求。如果三个不同角度看到的是同一个东西,你有理由相信对齐没有丢。如果三个角度看到了不同的东西,你在代码写出来之前就发现了问题。
这个方法的本质不依赖于"三份文档"这个具体形式。核心思想是:当你从一个层级的信息生成下一个层级时,用一个不同的角度来检查两者是否一致。具体实现可以是 Ryan 的 spec/task/checklist 三文档体系,可以是让另一个独立的 Agent 来 review 第一个 Agent 的产出,也可以是自动化的一致性检查工具。Spec Kit 的 analyze 命令做的就是这件事:它以只读的方式扫描所有已生成的文档,检查文档之间有没有重复、矛盾、遗漏或术语不一致。
形式可以不同,原则是一样的:每次生成新层级的信息都有漂移风险,你需要一个机制在那个边界上检测它。
用结构消除歧义
前面的内容可能给人一种印象:要消除歧义,就要写更多的内容,覆盖更多的字段。值得专门澄清一下这个误解。
歧义的根源不是信息量不够,而是关键维度的问题没有被回答。你可以把搜索需求写成三页的自然语言描述,详细说明搜索框应该放在什么位置,用什么颜色,什么字体大小。但如果你没有回答"搜索结果为空时怎么办""搜索范围包含文章正文吗""是输入即搜还是点按钮触发"这些问题,三页描述跟一句话的 prompt 歧义程度是一样的。区别只是歧义藏在了更多的文字里,更难被发现。
结构化的维度(意图、验收、约束)的价值不在于让你写更多,而在于把你必须回答的问题摆在面前,让你无法跳过。一个只有五行的 spec,如果回答了"为谁做""做什么""怎么算对""不该动什么"这几个问题,比一个十页但全是实现细节的文档有效得多。
Spec Kit 的 clarify 工作流做了一个有意思的设计。它用 10 个类别的结构化问题来检测 spec 里的歧义:功能范围、领域模型、交互设计、非功能性要求、集成点、边界情况、约束条件、术语定义、完成信号、占位符标记。每轮最多问 5 个针对性的问题。这些问题问的是写 spec 的人,帮你发现自己还没想清楚的地方。一个 spec 经过一轮 clarify 之后,长度可能只增加了几行,但歧义大幅减少,因为该做的决策被做出来了。