AI 会做多、看少、不收尾:七种失效和拦住它们的办法

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. 它会把归档当成现状

三个月前废弃的方案文档、停滞的技术调研、个人的临时笔记------在检索里全是一等公民。它读到了、当成现状,然后按一个已经死掉的方案写代码。

这个失效尤其危险,因为产出看起来完全合理:它确实是"按文档写的"。

办法有两条,都是关于检索面,不是关于提示词:

  1. 归档目录默认排除在全文检索之外。 已完成或已停滞的方向移进归档目录,在项目约定里写明它默认不参与搜索,只有明确要重启该方向时才查阅。以下划线开头的目录(个人笔记、本地草稿)同样默认排除。
  2. 入口文档只做分流,不承载规范正文。 项目根目录那份给 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 守卫;覆盖测试目录那条来自一次只改了源码、漏掉测试的符号迁移;调试出口那条来自一次对发版前检查清单的复盘。

抄来的约束不但没用,还有害:它会占满入口文档的篇幅,挤掉真正重要的那几条,让模型在一堆无关规则里挑它觉得相关的看。

回到那个接口

最后回到开头那个只有一个实现的接口。

它现在还在代码里,因为测试确实需要注入一个假的数据源。但注释里多了一行,写着这层存在的唯一理由是可测性。

下一个人看到它的时候,不需要猜。

相关推荐
happyprince1 小时前
03_OpenCodeReview 深刻不忘观:硬约束 × 动态决策的设计哲学
人工智能
持力行2 小时前
AI编程CRUD类项目实操
大数据·人工智能·ai编程
beibeix20152 小时前
3D Slicer 架构分析
架构·slicer
hzcj8882 小时前
汇正财经:AI重构信创,算力安全战略升级
人工智能·安全·重构
程序员-李俞2 小时前
从主题到可编辑 PPT:AI 演示文稿生成系统的任务编排、异步队列与质量验收
人工智能·gpt·ai作画·大模型·aigc·ppt·ai api
怪兽学LLM2 小时前
AI 应用中的高并发处理:从限流、异步到缓存与队列
人工智能·缓存
IT_陈寒2 小时前
Vue的这个响应式陷阱,我调试了一整天才爬出来
前端·人工智能·后端
大模型搬砖师2 小时前
不同任务该用哪个模型?企业 AI 网关的智能路由策略手册
人工智能
今夕资源网2 小时前
SayIt语音输入+AI 润色 Typeless 替代品 GitHub开源语音输入法,可本地部署LLM亦可接入deepseek 或者其他大模型。
人工智能·开源·github·输入法·开源输入法·语音输入法