write-tech-design:技术决定只做一次,而且要在写代码之前
这是技能逐个讲系列的一篇。五层约束和五个阶段讲的是系统怎么运转,这个系列把 21 份技能逐份拆开:每份解决什么问题,具体怎么干。母篇是《从能跑到可交付:一套前端 AI 协作系统的设计笔记
》,阶段全貌见澄清阶段
。write-tech-design 是澄清著述链的第四步,排在 write-quality-vision 之后:需求已经编号,“好样子"已经写成锚点,这一步决定这个功能在仓库里怎么搭。我用的系统是开源的 frontend-harness-cli
,文中规则都能在仓库的技能文件里找到原文。
两个上下文,两套架构
先看一个具体的场景。
一个功能分两次做。第一次,接手的 AI 写列表页:它选了查询缓存库拉数据,服务端数据就留在缓存里,不进全局状态。第二次,接手的是一个上下文全新的 AI,写同一个功能的详情页:它自己写了个 useEffect 加 fetch,把数据塞进一个全局 store,顺手还装了一个状态管理库——理由写得通顺:详情页需要跨组件共享。
两边单独看都没毛病。代码能跑,类型能过,各自的测试都是绿的。
合起来就是事故。同一份数据有了两个家:列表页在缓存里改了,详情页从 store 里读,还是旧的;缓存失效有两套策略,谁也说不清哪套生效。仓库里从此并存两个状态方案,后来的人不敢删任何一个,因为不知道哪边在用。评测阶段截图,详情页时不时截到过期数据,还以为是渲染慢。
这个 bug 藏在哪一行代码里?哪一行都不藏。错的是从来没有人把"数据怎么取、状态住哪、用哪个库"做成一次决定,写下来。 每个上下文都被迫临场决定一次,临场决定的方式叫掷硬币——掷两次,相反的概率一点都不低。
write-tech-design 存在的理由就是把这类决定收走:只做一次,在第一行代码出现之前做,落成文件,后到的每个上下文照着执行,不再重新掷。
这份技能干什么
它由规划者执行,产出两份文件:
tech-design.md——技术方案。这个功能用什么栈和依赖、模块怎么分、数据怎么流、状态归谁、接口长什么样、失败了怎么办、在哪一层测、评估时怎么把应用跑起来,全在这里定完。pages.json——页面清单:评测阶段要截哪些页面,路由是什么,等哪个元素出现才算渲染完。
它在著述链里的位置决定了它只回答一个问题:怎么搭。“做什么"由 write-prd 回答(需求编号在那),“什么样算好"由 write-quality-vision 回答(锚点在那)。技术方案不许越界去改这两件事——发现上游有问题,回去改上游文件,不在方案里偷偷修正。下游单方面改上游,澄清阶段覆盖门禁的那笔账就对不上了。
开写之前:门禁
动笔有前置条件,写在技能的 Gate 一节里:
第一,prd.md 和 vision.md 必须已存在,澄清摘要已确认。顺序是硬的:需求和"好样子"没定,技术方案没有可依附的对象,只能空转。
第二,先读三样东西再下笔:.harness/overview/technical.md(项目已有的技术全貌:现有的模块、既定的选型)、.harness/standards.md(栈包规范,五层约束在项目里的落盘形态)、以及这个功能将要碰到的现有代码。
第三,一条总原则,技能原文是:沿用代码库的现有模式;新模式需要一个用户同意过的理由(“Follow the codebase’s existing patterns; a new pattern needs a reason the user agreed to.")。为什么对新模式收费?因为仓库的一致性本身是资产:多一种模式,后面每个读代码的上下文都要多学一套、多猜一次。新模式不是不许,是要有人为它签字——它的成本由所有后来者付,决定就不能由某一个临场上下文免费做。
tech-design.md 的十节
方案固定十节,逐节说清每节钉的是什么:
- 技术栈与关键依赖——用什么,须是澄清里确认过的(“沿用仓库”,或明确的新选择)。这一节有一条硬规则:开发者不得引入这里没写的东西。开场场景里"顺手装个状态库”,就是被这一条堵死的。
- 架构概览——功能住在哪,文字画个图就够,不追求形式。
- 模块与边界——新增和改动的模块、各自负责什么、谁可以 import 谁。这是工程层约束(引用方向)在单个功能上的落盘。
- 数据流与状态——数据从哪来、存哪、状态结构长什么样。状态结构不在这里另起一套,指回
state-schema-first的做法(先列事实,再逐个写字段、类型、初始值、事实源);加载、空、错误、无权限四种状态都要在这里交代去向。 - API / 接口契约——端点或函数签名,含请求、响应、错误结构。语言层说过签名是写给下一个 AI 看的合同,合同文本就产在这里。
- 异常处理——逐个失败模式写:用户看到什么、什么会重试、什么记日志。不许只写"做好异常处理"这种没法执行的话。
- 测试策略——单元、组件、API、E2E 四层,什么在哪一层测,先分好工,免得实现阶段每层都以为别层测过了。
- 运行说明——评估时启动应用的命令、URL、前端工作目录。这一节是写给评测阶段的:评测者是全新上下文,它接手时得知道怎么把应用跑起来。
- 风险与权衡——考虑过哪些别的方案,为什么选了这个。决定背后的理由必须留痕——理由不写,下一个上下文看到一个"奇怪的选型”,第一反应是推翻它重来,决定就等于没做过。
- 概览增量——功能做完后,项目概览(overview)要更新什么,列在这里,留给交付后的
sync-overview执行。本技能不直接写 overview,只登记增量。
pages.json:评测要截哪些页面,现在定
第二份产物容易被当成测试的附属品,其实它是设计的一部分:评测拿什么当证据,在设计时就定,不等测试用例存在。 技能里写明,它依据的是 PRD 的关键页面和应用的路由,“不从测试用例推导——那时它们还不存在”。
一份最小的长这样:
{
"baseUrl": "http://localhost:5173",
"viewport": { "width": 1280, "height": 800 },
"pages": [
{ "name": "notices", "route": "/notices", "waitFor": "[data-testid=notice-list]" },
{ "name": "notices-empty", "route": "/notices?fixture=empty" }
]
}
几条规则都有具体的防事故对象:
name用 kebab-case 且唯一,route以/开头且唯一——清单本身先是一份能对账的账。waitFor必须是"只有页面渲染完成才会出现"的选择器。没有它,截图工具可能在数据回来之前就按下快门,截一张加载中的白屏当证据——证据是假的,后面的评分全建在假证据上。- 覆盖
vision.md里的每个关键页面,加上那些有独立 URL 或 fixture 开关的状态(空、错误)。上面例子里的notices-empty就是用查询参数切出空状态单独成页:空状态不是"列表没有数据"那么一句,它是评测要单独看的一张脸。 - 如果有多个应用、多套路由,分不清该用哪套:问用户一次,不许猜。
- 功能没有 UI:不写这个文件,在方案第 7 节写明
ui-evidence: N/A — <理由>。跳过要有据,不能默默不写。
这份文件不是写完就算:harness capture 会校验它,校验不过,截图这一步根本起不来。
设计一:决定前置,因为现在改最便宜
为什么非要在写代码之前把十节写满?算成本。澄清阶段改一个决定,是改文档里的几行字;实现阶段写到一半改架构,是重写已完成任务加全部重测;交付后发现两个状态方案并存,是没人敢动的长期债。
而且决定只许做一次,不只是省钱,是防漂移。做决定的上下文会消失——这是这套系统的基本前提,每个阶段、每个任务接手的都是新人。会消失的东西做的决定,只有落成文件才算数。 第 9 节要求把权衡和理由一起写下,也是同一个道理:只写结论不写理由的决定,保质期只有一个上下文。
设计二:引用,不重述
注意这份方案的写法:它几乎不发明新约束。规范读 .harness/standards.md(五层约束已经在那里),状态结构指回 state-schema-first,需求指回 PRD 的编号,“好样子"指回 vision 的锚点,项目现状指回 overview。
它在这些既有决定之上只做一件事:具体化。落到这个功能,具体是哪个模块、哪个文件——技能的规则里有一条:“写出将要改动的确切文件;开发计划的任务复用它们。“下一步 write-dev-plan 拆任务时,直接拿这份文件清单当原料。
为什么不把相关约束抄一份到方案里,读起来方便?因为抄一份就多一份真相。standards 改了,方案里的副本不会跟着改,两份文件迟早不一致,后来者不知道听谁的。引用只有一份真相,代价只是多跳一次链接——这笔账很划算。
设计三:文档里不许留 TODO
规则原文很短:不写 TODO / TBD;真正定不了的选择,回去问用户。
理由和澄清阶段不许留开放问题是同一条:文档里的空白不会保持空白,它会被执行者用默认值填上。需求里的空白填的是功能默认值,方案里的空白填的是架构默认值——而架构默认值填错,爆炸半径比功能大得多。开场的两套架构,追根究底就是"状态方案"这个格子在设计时空着,两个实现上下文各填了一个。
配套还有一条选型规则:选能单独测试的最简单边界。 边界划在哪,决定了后面每一层测试的成本:边界内聚,单元测试就能覆盖;边界漏得到处都是,就只能靠最贵的 E2E 兜底。简单不是偷懒,是给评测留一条便宜的路。
它管不了什么
边界照例说清。
第一,它保证的是"决定被做过一次、写下来了、有理由”,保证不了决定本身是对的。技术栈选错了,十节写得再齐也救不回来——判断力不在这份技能的射程里。它消除的是两类事故:没人做决定,和同一个决定被做了两次、两次还不一样。
第二,它不排施工顺序。方案回答"结构是什么”,不回答"先做哪件、后做哪件”。把顺序混进设计,设计就会为了迁就顺序变形;拆任务是下一步的事。
满足完成标准——十节齐全、技术栈一节与澄清一致、pages.json 通过校验并覆盖每个关键页面(或写明 N/A)——这一步才算过。下一篇 write-dev-plan:结构定完了,把它拆成一份有序的施工单。