AI 给一个新页面生成了完整分层:domain 层定接口、data 层落实现、mapper 隔离映射。命名统一、注释齐全,逐行看下去挑不出毛病。
问题是那个接口只有一个实现,可以预见未来也只会有一个。
它难被挑出来的原因不是写得隐蔽,恰恰是因为它完全符合规范------我自己写的规范说"新增 feature 按 domain / data 分层",它就分了,一层不少。你盯着规范逐条对,条条都对。
这类产出不会报错、不会崩、不会被静态检查抓到,只会让项目每加一个功能就重一点。三个月后,你已经想不起来当初为什么要那一层。
这篇不讲 AI 让我快了几倍,也不贴提示词模板。要说的是另一件事:这类问题写进规范文档是拦不住的。 文档要靠人主动遵守、主动检查,而需要检查的那一刻,恰恰是你最赶的时候------要发版、bug 还没修完、下班前想把提交推掉。
拦得住的只有一种东西:会失败的闸门------不通过就走不下去,而且放不放行跟当事人此刻的心态无关。打包脚本不通过就是出不了包,这件事和你今天有多急没有关系。
但先把话说清楚:"闸门"是个目标状态,不是一步到位的。 下面七条里,真正能让流程失败的只有一小部分,其余还停在"工具能扫出来"或者"只能靠纪律"的阶段。七条讲完之后会有一节专门把这三层分开,说明哪几条现在落在哪一层------上来就宣称七条全是硬闸门,那是骗人。
下面是我实际遇到的七种失效。它们不是七件互不相关的事,可以归成三类------AI 会做多、会看少、不会收尾,三类的根因完全不同,所以要用不同的办法拦。
先给一张全表,后面按编号逐条展开:
| # | 它会...... | 对应的办法 | 类别 |
|---|---|---|---|
| 1 | 多包一层抽象 | 新增抽象层必须回答"现在有几个实现" | 做多 |
| 2 | 把你的规范当成油门 | 规范里必须写"什么时候不该做" | 做多 |
| 3 | 用产出量证明自己在干活 | 每个新增文件说不出"现在就有用"就删 | 做多 |
| 4 | 把归档当成现状 | 归档目录默认排除在全文检索之外 | 看少 |
| 5 | 只看得见你给的那个文件 | 迁移符号时,检索必须覆盖测试目录 | 看少 |
| 6 | 顺便优化一下 | 注释里显式写下"与旧逻辑严格等价" | 不收尾 |
| 7 | 不回收自己开的调试出口 | 用编译期门控,不靠运行时判断 | 不收尾 |
前提
方法论脱离约束就是空话,先交代这套东西是在什么条件下长出来的:
- 资产类客户端,业务里有大量「同类实体逐条接入、彼此存在细微差异」的东西
- 发版节奏密集,没有专门的重构窗口
- 出错的代价不可回滚
- AI 参与的改动量,可以轻易超过逐行阅读的容量
最后一条是这套东西存在的理由。人工阅读的容量是恒定的,AI 的产出量不是。 一旦单位时间的改动量超过能认真读完的量,"记得检查"就不再是一个可靠的机制------这跟谁来读、读得多认真都没关系,是量级问题。
一类:它会做多
这一类的根因只有一句话:它不承担维护成本。
那层抽象三个月后谁改、新人要多久看懂、加个字段要动几个文件------这些代价一分不落在模型身上。它给方案时,成本那一侧的权重接近零。
再叠一层:在文本层面,"考虑周全"永远比"够用就行"显得更专业。一个带扩展点、预留接口、写满防御性判断的方案,读起来就是更负责任。
所以它不是偶尔过度设计,它有结构性动机过度设计。三种表现:
1. 它会多包一层抽象
就是开头那个只有一个实现的接口。
办法:任何新增的抽象层,必须回答"现在有几个实现"。
- 两个以上 → 通过
- 只有一个,但需要注入假实现来测试 → 通过,但注释里要写明"这层存在的理由是可测性"
- 只有一个,理由是"以后可能会有别的" → 删掉
第二条不是走过场。写下"为了可测性"之后,下次遇到不需要测的地方你会自己跳过抽象;而如果理由一直空着,你会每次都分层。
2. 它会把你的规范当成油门
这条最反直觉,我付了学费才明白。
规范给了方向,但没给上限。"新增 feature 按 domain / data 分层"------模型会执行到极致,因为满足规范让输出看起来无可挑剔。它不会问"这个 feature 小到不需要分层吗",因为规范里没有这句话。
于是我写的约束,本意是防它乱来,在抽象层数这件事上反而给了它方向、没给它刹车。
办法:规范里必须写「什么时候不该做」,不能只写「该怎么做」。
我后来补的是这类条目:数据源只有一个且短期不会变的,不要建 domain 接口;页面只做展示、没有状态派生的,不要拆 controller。
一条只有"要怎么做"没有"什么时候不做"的规则,对 AI 就是一个单向油门。
3. 它会用产出量证明自己在干活
交付十个文件看起来比交付两个更勤奋。这个倾向人也有,模型更强------它没有"这些东西以后要我维护"的负担。
症状很好认:预建的空目录、只有一行 export 的空 barrel、给空壳实现配的单测、顺手写的说明文档。每一个单独看都无害,甚至像好习惯。
办法:交付前自查改动清单,每个新增文件都要能说出"它现在就有用"的理由,说不出就删。
注意是"现在就有用",不是"将来有用"。空目录和空 barrel 一律不预建,随第一个真实内容一起建;UI 和空壳实现默认不配单测,要写先问。
执行成本几乎是零(git status 看一眼),砍掉的东西相当多。
二类:它会看少
这一类的根因也是一句话:它每次都从零开始,视野完全由你划定。
模型对这个项目没有记忆。每个新会话,它靠检索现场重建认知,而它检索的范围就是你给它的范围。你没圈进来的东西,它看不见;你没排除的东西,它一视同仁。
4. 它会把归档当成现状
三个月前废弃的方案文档、停滞的技术调研、个人的临时笔记------在检索里全是一等公民。它读到了、当成现状,然后按一个已经死掉的方案写代码。
这个失效尤其危险,因为产出看起来完全合理:它确实是"按文档写的"。
办法有两条,都是关于检索面,不是关于提示词:
- 归档目录默认排除在全文检索之外。 已完成或已停滞的方向移进归档目录,在项目约定里写明它默认不参与搜索,只有明确要重启该方向时才查阅。以下划线开头的目录(个人笔记、本地草稿)同样默认排除。
- 入口文档只做分流,不承载规范正文。 项目根目录那份给 AI 读的说明,我刻意保持很短:只有全局协作规则,和"什么任务去看哪份文档"的索引,长期规范全部外置。
第二条管的是注意力预算。入口文件塞两千行细则,模型不会更守规矩,只会在里面挑它觉得相关的看------而你无法预测它挑哪些。短入口加明确分流,比长入口加指望它全读到,可靠得多。
这两条目前都还只是约定,模型可以不遵守。能做成配置的部分优先做成配置:把归档移出主仓库,或者写进检索工具的忽略规则------那样它就不再取决于模型这次听不听话。
5. 它只看得见你给的那个文件
同一个判定在另外五个文件里也有,它不知道,你不说它就不会去找。
结果是:A 处收敛了,B 处的同一份逻辑还在,两套真相继续各自漂移。比重构前更糟,因为你现在以为已经统一了。
办法:迁移或重命名任何全局符号时,检索和静态分析必须覆盖测试目录,不只是源码目录。
这条听起来很基础,但它是我实际漏过的:改完 lib/ 下所有引用、analyze 通过,测试目录里还留着旧符号,跑测试才炸。模型只搜你让它搜的范围,而人的默认心智范围是"源码"。
顺带说,"同一个判定散在互不相干的好几层"本身就是个值得单独治理的问题。我另一篇写多链币种判定收敛的文章讲的就是这件事,那里有个散在三十处的具体例子。
三类:它不会收尾
这一类的根因同样只有一句话:它的任务边界只有这一次对话。
"以后要清理"这件事不在它的时间尺度里。改完这一处、这次对话结束,就没有以后了。而收尾工作恰恰全都发生在"以后"。
6. 它会顺便优化一下
模型倾向于在改一处代码时,把附近看起来不对的地方一起改掉。单独看每一处修改都是改进。
而所谓重构,前提就是行为不能变。一旦顺手改了行为,这次改动就变成混着行为变更的重构------这类改动是最难查的:回归测试拦不住它,测试只覆盖已知的预期行为,而这次变了什么没人知道;事后排查也想不到怀疑它,因为这次改动在记录里写的是"重构"。
人也会犯这个错,区别在频率。人动手改附近的代码前,多少会犹豫一下"这算不算这次的范围";模型没有"这次改动的边界在哪"这个概念,看见不对就一起改了。它通常还会在回复里如实交代自己顺带改了什么------但那一行混在十几条改动说明里,很容易被划过去。
办法:在注释里显式写下"与旧逻辑严格等价",并把旧逻辑原样抄在后面。
格式是固定的,就在改动处补一行:
dart
/// 与旧逻辑「<把原来的判断条件、默认值、边界处理原样抄下来>」严格等价。
填好之后是这样------这是我在一次币种判定收敛里写的,尖括号里换成了被取代的那个真实条件:
dart
/// 是否为 wrap / unwrap 币种入口。默认 false;
/// 仅该链的主币与其对应的 wrap 代币返回 true,排除该链上其它代币。
/// 与旧逻辑「链判定 && (币种符号 == 主币 || wrap 代币)」严格等价。
bool get isWrapEntry => false;
要紧的是后半句。只写"与旧逻辑等价"没有用,必须把旧逻辑抄进来------抄的动作会强迫你把旧代码完整读一遍。这行注释一半的价值在抄的过程里,不在那行文字上。
而写下它就等于承诺这次不改行为。真发现旧逻辑有 bug,另开一次改动处理,说明写清楚。
这条不是我一开始就想到的,是一次差点出事的改动之后补上的,放在「闸门挡不住什么」一节讲。
7. 它不会回收自己开的调试出口
为了排查问题,AI(和人)会加临时调试出口:打开日志开关、挂上抓包面板、注册一个测试路由。
问题不在于加,在于清理它的人是谁。加的时候是为了解决当下的问题,解决完注意力就转移了------而这件事从来没有责任人。
在资产类 App 上,这不是代码整洁问题,是安全问题。一个带着网络抓包面板的正式包,等于把用户请求摊开给任何拿到手机的人看。
思路是:不要依赖任何人记得清理,让没清理的东西在正式包里物理上不存在。
具体做法是双保险,缺一不可------运行时一层,编译期一层:
dart
static bool get canOutput => !kReleaseMode && _enabled;
static void _emit(String message) {
if (!canOutput) return;
assert(() {
// 真正的输出在这里
return true;
}());
}
第一层防"忘了关开关":canOutput 里的 !kReleaseMode 让它在 release 包恒为 false。第二层防"第一层被改坏了":真正的输出整段包在 assert 内,release 编译会把它整体剥离------哪怕有人把开关改成恒为真,正式包里也已经没有那段代码了。
同样的思路用在每一个调试出口上,一律用 assert(() { ...; return true; }()) 包裹,靠编译期剥离,而不是靠运行时判断。运行时判断的代码还在包里,编译期剥离的代码不在。
第 7 条的延伸:让校验去检查防护本身
到这里还差一步。上面那两层保险都是"当时写对了",但谁保证它们后来不被改坏?
所以打包脚本在出包前会跑一组检查。注意它检查的对象是什么:不是业务代码写得对不对,而是上面那两层保险本身还在不在。
这个区别很重要。普通的 lint 问"你有没有违反规则";这类校验问"你有没有为了方便,把规则的执行机制关掉"。
而后者恰恰是最常发生的。为了调试注释掉一个判断、为了省事拆掉一层门控------当时都有充分理由,然后就忘了。规则本身没被违反,规则的执行机制被摘掉了。
对着上一节那段代码,要查的就是三件事:
bash
#!/usr/bin/env bash
# 出包前校验:确认日志出口的两层保险都还在。示例路径与符号名按自己项目替换。
set -uo pipefail
TARGET="lib/.../app_log.dart"
errors=()
# ① 运行时兜底还在,而且还带着 !kReleaseMode。
# 只取行首非注释的定义------否则一段被注释掉的旧定义会让检查误判为"还在"。
def=$(grep -nE '^[[:space:]]*static bool get canOutput' "$TARGET" | head -1)
if [[ -z "$def" ]]; then
errors+=("canOutput 的定义找不到了,是不是被整段注释掉了")
elif [[ "$def" != *'!kReleaseMode'* ]]; then
errors+=("canOutput 里的 !kReleaseMode 兜底被摘掉了:$def")
fi
# ② 本地调试开关没有带着"打开"提交上来。
if grep -qE '^[[:space:]]*static bool _enabled = true' "$TARGET"; then
errors+=("_enabled 被置成 true 提交了,出包前请改回 false")
fi
# ③ 真正的输出仍然包在 assert 内部。
# 这条 grep 做不到:grep 能告诉你 debugPrint 存在,不能告诉你它在哪个块里面。
naked=$(awk '
/^[[:space:]]*\/\// { next } # 注释行不算
/assert[[:space:]]*\(/ { in_assert = 1 } # 进入 assert 块
/debugPrint[a-zA-Z]*\(/ && !in_assert { print NR": "$0 }
in_assert && /\}\(\)\);/ { in_assert = 0 } # assert(() { ... }()); 结束
' "$TARGET")
if [[ -n "$naked" ]]; then
errors+=("这些输出不在 assert 内,release 编译不会剥离它们:\n$naked")
fi
if (( ${#errors[@]} )); then
printf '出包校验未通过:\n'
printf ' - %b\n' "${errors[@]}"
exit 1 # 非零退出,打包中止
fi
前两条是存在性检查,grep 就够。第三条不一样,它是位置相关 的:要判断的不是"某个调用存不存在",而是"它是否落在某个块的内部"。纯文本匹配回答不了后者,只能拿个状态机去跟踪代码结构------上面那段 awk 就是最小可用版本,靠 assert( 和 }()); 这对标记维护一个"我现在在不在 assert 里面"的状态。
它是简化的,真要用还得补几个边界:assert 内部嵌套了别的 }()); 会让状态提前复位;/* */ 块注释和字符串里的括号也没处理。这类检查的成本几乎全花在这些边界上,而且没有更省事的办法------一旦你要问"它在哪儿",就绕不开跟踪结构这件事。
两点关于用法。
报错要写清楚怎么修,不能只说一句"校验失败"。上面每条都带了具体动作("是不是被注释掉了""请改回 false"),因为撞上这个错误的人通常正急着出包,你要让他三秒内知道该动哪一行。
这类校验防的是无心之失,不是防有心绕过。 真想绕开,把定义换个写法就脱靶了------这不是它的目标,它的目标是让"为了调试顺手拆掉、然后忘了装回去"这件事在出包时会失败,而不是等着谁想起来检查。把它当安全边界用是误用;把它当防手滑机制用,它是这套东西里最可靠的一道。
最后一点,比前面都重要:这类校验一定会有例外。 总会有某个出口因为走了另一套机制而不适用于统一检查。真正要紧的不是有没有例外,而是例外必须连同原因一起写下来。
闸门有边界,边界要写下来。没写下来的例外,下次就是漏洞。
三层:会失败的、能机检的、只能靠纪律的
现在回到开头欠的那笔账。这七条并不都是"会失败的闸门",按它靠什么生效分,它们落在三层上:
| 层 | 靠什么生效 | 本文里的 |
|---|---|---|
| 会失败的闸门 | 编译期剥离、打包中止------不依赖任何人的记性 | 第 7 条,以及上一节那组校验 |
| 能机检、但不阻断 | 工具能扫出来,跑不跑仍是人的选择 | 第 3 条(改动清单)、第 5 条(检索覆盖测试目录) |
| 只能靠纪律 | 要靠人主动写、主动想起来 | 第 1、2、4、6 条 |
第三层那几条是过渡态,不是终点。它们现在靠纪律,不是因为纪律更好,而是我还没想出让它们失败的办法:写下"与旧逻辑严格等价",机器判断不了它抄得对不对;"这个 feature 小到不需要分层吗",也没有哪个静态检查能替你回答。
所以方向是:能往上推一层就推一层,推不动的至少先写下来。 而不是假装七条一样硬。
第二层是这里面最值得投入的一层,而且它装得下的东西远不止本文这两条------我那个提交前自检工具里还有一批 UI 规范和分层规范的检查项,下面拿它们当例子。
这一层的检查还要再分一次档。判据不是"问题多严重",而是**"自动改会不会造成破坏"**:
| 档 | 判据 | 处理 | 例子 |
|---|---|---|---|
| 绿 | 机械可修复,改了不会错 | 直接改 | 裸资源路径换成生成的常量、无关格式化 diff 收窄 |
| 黄 | 需要在几个选项里做选择 | 给方案和落点,等人拍板 | 裸色值该用哪个语义色、裸字号配哪个 token、弹框迁到统一容器 |
| 红 | 结构性问题 | 只报告,绝不自动改 | 继承了旧的页面基类、绕过网络层封装直调、直接解析后端返回的 Map |
红档为什么不自动改,理由写在规则里:自动改会破坏旧链路语义。 一个直接解析后端 Map 的地方,改成走模型层看起来更规范,但那个 Map 里可能有个为兼容旧接口而保留的字段,改完当场就炸。
分寸在这里:红档不禁止妥协,但要求妥协留下痕迹。 确实需要兼容旧链路的,必须写下兼容原因、当前的替换点、后续迁移位置,不能只写一句"先这样"。
还有两个设计,决定这类自检工具能不能活过第一周。
只扫新增行,不动存量历史债。 这条几乎是生死线。拿全量扫描去跑一个有年头的项目,命中数会大到没人愿意看,工具会在第一天被关掉。只看本次改动的新增行,等于存量冻结、增量收紧------历史债不会一夜消失,但至少不再增加。
强制约束要滞后于验证期。 第二层的检查一开始都做成自愿运行的:先让它跑一段时间,确认它报出来的东西确实值得改,再谈往第一层推。顺序反了很难走通------一条还没被验证有用的规则先上了强制,被质疑的会是它存在的必要性本身,而绕过强制往往只需要一个命令行参数。
这也是为什么第一层目前只有打包那道:它拦的是安全问题,而且它在流程最末端------绕过它的成本比遵守它更高。
闸门挡不住什么
有一类错误,上面这一整套是失效的。举一个我遇到过的,很有代表性。
那次的工作是把数据实体的 JSON 解析从代码生成改成手写。手写时加了几个类型安全的辅助函数,注释写得很清楚:
dart
// 失败返回 null(调用点用 `?? 默认值` 兜底)。避免服务端字段类型漂移时硬转抛异常。
这个约定完全合理------服务端某天把数字返回成字符串,App 不至于直接崩。然后调用点照着约定,给字段统一加了兜底:
dart
..amountText = _asString(json['amountText']) ?? "0"
..priceValue = _asDouble(json['priceValue']) ?? 0
每一行都在遵守约定。
问题是这批字段里有一部分------上面这两行就都是------它们的 null 不是缺失值,是控制信息。
那个接口的响应并不总是携带全部字段,而落库逻辑靠 null 判断"本次没带这个字段,保留已有的值"。?? "0" 一加,这个守卫就被击穿了------本该保留的值会被 0 覆写。
在这类系统里,null 是"我不知道",0 是"确实没有"。把前者折叠成后者,等于把"没查到"当成"没有"。
这个问题是在测试阶段发现的,没有进正式包。但值得说清楚的是它不是被任何一道检查抓到的:静态分析过了,提交前自检也过了,改动本身干净得挑不出毛病。抓住它的是真机------跑到"接口这次没带这个字段"那个场景时,界面上的数字不对,人看出来的。
这个错误任何闸门都拦不住。 它没违反任何红线:类型是安全的,格式是规范的,它遵守的甚至是一个刚刚写下的、完全正确的约定。
它错在领域语义 ------你必须知道这些字段的 null 在业务上意味着什么。这层知识不在代码里,也不在规范里。
所以边界很清楚:
闸门能挡住"违规",挡不住"理解错了业务"。
后者只能靠领域知识和真机回归------上面那次就是这么拦下来的。这也是我不担心闸门会替代掉判断的原因:它替代的是"记得检查"里能被机器接管的那部分,不是"知道什么是对的"。
顺带说,这件事恰好印证了第 6 条:把生成代码改成手写,本质上就是一次要求严格等价的重构。那次改动里我没有在任何一处写下"与旧逻辑等价",于是也就没想到该逐字段核对"原来是 null 的,现在还是不是 null"。
不建议照抄的部分
项目小的话,这套是过度投入。 三五个页面、没有资产和安全约束,加这么多约束的维护成本高于收益。挑第 3 条(新增文件说不出用处就删)和第 7 条(调试出口编译期门控)两条就够了。
闸门和文档的顺序:先闸门,后文档。 两者的生效条件不同:文档要靠共识才能生效,闸门不需要------一道会失败的检查不依赖任何人认同它。所以能自动化的部分先落成脚本,需要共识的留到后面。顺序反了会两头空。这也是上面那张三层表该有的推进顺序:先把能落到第一层的落下去,剩下的边用边往上抬。
每一条约束都该能说出它是因为哪次返工而存在的。 说不出来的,就是从别处抄来的,删掉。
我这份清单里每条都有具体来由:等价声明那条来自那次 ?? 0 击穿 null 守卫;覆盖测试目录那条来自一次只改了源码、漏掉测试的符号迁移;调试出口那条来自一次对发版前检查清单的复盘。
抄来的约束不但没用,还有害:它会占满入口文档的篇幅,挤掉真正重要的那几条,让模型在一堆无关规则里挑它觉得相关的看。
回到那个接口
最后回到开头那个只有一个实现的接口。
它现在还在代码里,因为测试确实需要注入一个假的数据源。但注释里多了一行,写着这层存在的唯一理由是可测性。
下一个人看到它的时候,不需要猜。