对抗AI Slop:用“写更少代码”守住代码质量底线
最近技术圈有人在聊一个非常实际的问题AI slop 不止出现在图文内容里也开始出现在代码仓库里。典型症状是——每个函数单独看都能看懂放到一起没人敢改功能的增加速度远远赶不上代码量的膨胀速度。最近 Dex Horthy 呼应 Matt Pocock 关于 AI 代码生成的讨论时给了一个非主流但很值得实操的观点减少 AI slop 的办法是写更少的代码。这句话不是劝你别用 AI也不是让你把现有代码乱删一遍。我的理解是真正的工程问题不是“AI 写了多少代码”而是“有多少代码根本不该存在”。代码越少AI 出错的空间就越小评审和测试的覆盖面就越可控模型下一次改动时需要重写和猜的部分也会更少。这篇文章会把这句话拆开讲先看什么是代码里的 AI slop再讲“写更少代码”解决问题的机制然后用一套完整的实操思路——提示词怎么写、类型怎么约束、死代码怎么清理、PR 怎么卡——帮你把 AI 生成代码的质量曲线拉回来。1. AI slop 到底是什么代码库里正在堆积的无意图代码“slop”最早常被用来形容 AI 生成的批量低质量内容看起来逻辑通顺实际上没有观点、没有信息增量。代码领域的 AI slop 也差不多只是更隐蔽它不一定报错甚至测试能过但缺少“人设计出来的意图”。换句话说传统烂代码是人的能力或时间不够导致的AI slop 是模型根据概率平滑生成的“合理代码”。模型不觉得自己在堆复杂度它只是把训练数据里最常见的写法拼了出来。于是代码库里会出现下面这些特征特征表现背后的成本防御性过度一个字段判空、一个返回值包两层 Optional每次调用链都变长读代码要翻更多层大量相似片段一样的逻辑在不同文件里复制多次后续模型生成新代码时只会继续复制同类写法伪抽象为了分层建了很多 Service/Manager但每个类里几乎没有行为改动一个需求要连带改四五个文件命名没有语义变量名是data、result、tempList函数名是泛化的process人类评审时无法判断它到底承担什么职责注释解释“怎么做”而不是“为什么”每行都有注释拿掉注释代码反而更好读注释和代码会出现二次维护成本很多人第一次意识到 AI slop是在代码评审里看到模型生成的代码“太顺滑了”。它不会给你明显的语法错误也不会写一行完全跑不通的伪代码它最大的问题在于新增了一个函数但系统里已经有更合适的函数存在新增了一个分支但上游已经保证了状态不可能出现新增了一个类型但只是为了少写一次as。模型的生成逻辑决定了这个问题会被放大语言模型最大化的是“下一个 token 出现的概率”不是“整个系统的设计一致性”。把它丢进一个没有本地上下文的仓库它不知道哪个模块已经有等价函数也不会因为你刚删过一段重复代码就避免生成风格相似的重复代码。所以只靠提示词里写“请写出高质量代码”基本治不了本。2. 为什么“写更少代码”能直接减少 AI slop先给一个直观对比。假设一次需求评审里你手上有一个 200 行的 PR和一个 10 行的 PR。200 行的 PR 即使全部由资深工程师手写评审者也很难每一行都看出隐藏在数据流里的边界问题。10 行的 PR你可以把每个分支都推演一遍甚至可以立刻追问这 10 行里是否有 3 行根本不需要AI slop 之所以能混进主干靠的就是“一次性生成超出人类评审能力的大代码块”。你 review 不过来就只能信它。所以写更少的代码本质上是把一个大型生成任务拆成人类可以完全理解的微小型验证任务。更关键的是代码量减少会带来四个连锁反馈第一错误面变小。每一行新增代码都是一个 potential bug。少写 100 行不是“节省了 100 行打字时间”是少给系统放了 100 个隐藏炸弹。第二评审成本可控。AI 生成代码合入前最重要的一道防线是人工评审。代码块越小评审者越容易对比“原有实现”和“新实现”的差异也越容易发现模型把某个判断写反了。第三模型下一次生成更准确。模型生成代码时会参考上下文。仓库里重复代码越少、命名越一致、结构越清晰模型在下一次生成时就越容易命中正确模式。反过来说一个被历史代码塞满的仓库模型总会从历史代码里学到坏习惯。第四测试更容易覆盖。代码量不等于测试量但代码路径少了状态分支少了测试用例的边界自然就少。模型要跑通测试的成本也会降低你不用再为了一个纯装饰性抽象层补齐一大堆测试。所以可以把“写更少代码”理解为不断减少系统的表面积。你的接口更小、依赖更少、分支更直接AI 即使想写出一堆没有业务必要性的代码它也没有地方可以落笔。3. 实操第一环契约先行让 AI 只填一个函数很多开发者在让 AI 写代码时习惯性输入的是这种提示词“帮我写一个用户管理模块要支持登录、注册、修改密码、刷新 token”然后把 AI 生成的 300 行代码直接复制进去。这个方式最大的问题不是模型写得慢而是模型在缺少边界的情况下会替你决定所有设计细节它会把 token 存到哪个表里、它会自己脑补一个中间件、它会默认两边 API 的使用方式一致。这些“自定义决策”叠加起来就是你代码库里真正冗余的部分。要减少 AI slop第一步不是让模型“多写”而是让上下文先把边界锁死。3.1 把任务拆成“人写契约 模型写实现”最稳的模式是人先定义好函数签名、输入类型、输出类型。人写好失败测试或验收条件。人指定模型只能修改某一个文件最好只修改某一个函数。模型生成实现后运行测试看是否满足要求。下面是一个最精简可复制的契约。注意看这份代码里人类的职责是画边界模型的职责是填空// src/charge-stage.ts // 这部分由人先写好明确表达“合法状态有哪些、事件怎么流动” export type ChargeStage draft | pending | paid | refunded; export type PaymentEvent | { type: mark_paid; paidAt: string } | { type: refund; reason: string }; /** * 根据当前状态和支付事件返回下一个合法状态。 * 如果不允许发生该转移请返回 null。 * * TODO: 让 AI 填充下面这个函数的函数体。 */ export function nextChargeStage( current: ChargeStage, event: PaymentEvent ): ChargeStage | null { throw new Error(not implemented yet); }接着人再补一条失败测试// test/charge-stage.test.ts import { describe, expect, it } from vitest; import { nextChargeStage } from ../src/charge-stage; describe(nextChargeStage, () { it(draft 状态收到 mark_paid 后进入 paid, () { expect( nextChargeStage(draft, { type: mark_paid, paidAt: 2025-01-01 }) ).toBe(paid); }); it(refunded 状态下不能再次 mark_paid, () { expect( nextChargeStage(refunded, { type: mark_paid, paidAt: 2025-01-01 }) ).toBeNull(); }); });然后给模型的任务描述就非常简单了请实现 src/charge-stage.ts 中的 nextChargeStage 函数。 要求 1. 不要修改这个文件中的类型定义。 2. 不要新增文件。 3. 不要改动测试文件。 4. 补全函数体后运行 pnpm test确保全部测试通过。 5. 如果分支很多请先用状态转移表表达逻辑再写成代码。这个 prompt 和“帮我写用户管理”最大的区别是模型这次不需要做系统设计不需要伪造接口不需要决定测试策略。它唯一能输出的就是这个函数体内部几十行甚至十几行代码。哪怕模型生成得不够好人类 review 的成本也极低——因为红线已经确定了。3.2 复杂任务继续拆而不是让 AI 一步生成如果需求足够复杂比如需要新增一个 API、一个 service、一个 repository建议也不要让 AI 一次性生成全部。你可以把流程拆成几个小步第一轮人定义请求/响应类型让 AI 生成 schema 或 DTO。第二轮人定义 service 的函数签名和错误码让 AI 实现业务逻辑。第三轮人准备好外部依赖接口让 AI 写一个 adapter。这样做看起来“多花了几轮对话”实际上每次模型改动都被约束在一个可验证的局部。模型生成的行数越少AI slop 的生存空间就越小。如果你连续三四轮都发现模型在疯狂新增文件那大概率不是模型的错而是你给的任务边界太宽了。4. 实操第二环用类型和测试收窄生成空间很多代码写得多不是因为需求复杂而是因为类型表达得不够精确。一旦类型里有大量“可空字段”或“任意对象”模型就会觉得每个字段都有可能不存在于是生成大量判空分支和层层防御逻辑。TypeScript 是最容易看到这个效果的场景。举个例子下面是容易诱发 AI 写出大量if (!xxx)的类型type Charge { status: pending | paid; amount: number; paidAt?: string; // paid 时有值pending 时可能是 undefined };这时候让 AI 实现“描述一笔订单”它第一反应就是每写一个分支都判一下paidAt。代码看起来非常严谨实际上因为类型系统根本没有表达清楚“pending 状态下不可能有 paidAt”所以模型只能靠运行时判断来兜底。更好的做法是把类型改成交互式联合把非法状态直接排除在编译层之外type PendingCharge { status: pending; amount: number; }; type PaidCharge { status: paid; amount: number; paidAt: string; }; type Charge PendingCharge | PaidCharge; function describeCharge(charge: Charge): string { // 模型在这个函数里几乎不需要判 undefined switch (charge.status) { case pending: return 等待支付${charge.amount}; case paid: return 已支付${charge.amount}时间${charge.paidAt}; } }在第二个版本里模型进入case paid分支后可以放心访问paidAt因为 TypeScript 编译器已经收窄了类型。它不会再写charge.paidAt ?? 这种为了通过编译而存在的代码。所以当你发现 AI 一直生成防御性代码时优先怀疑类型设计而不是要求 AI “少做防御”。强类型是给模型的止损线让它在编译层面就知道哪些分支根本不可能到达。测试同理。测试描述的不是实现细节而是“合法输入 / 非法输入 / 边界输入”的验收单。模型如果能看到清晰的测试它就不用自己创建一套“我认为应该这样”的判断逻辑。建议项目和人的验收习惯保持一致先写功能测试再让模型补实现先写接口类型再让模型补调用代码。但这里要提醒一句约束强不等于类型体操。不要为了“让 AI 好写”去堆叠大量泛型、重载或 builder 模式。如果约束本身变成了另一种复杂度那只是把 AI slop 换成人手写的类型 slop总代码量没有下降。5. 实操第三环真正删掉多余代码“写更少代码”不只是控制 AI 未来生成量还包括清理存量代码库。存量代码里的重复逻辑、死代码、伪抽象会持续污染模型上下文——只要这些历史代码还在AI 总会学着它们的样子继续生长。5.1 先跑一轮未使用代码检查先用静态工具把明显没人用的代码找出来。不同语言有不同工具下面以 TypeScript 项目为例# 检查是否开启严格未使用检查通常配置在 tsconfig.json 中 npx tsc --noEmit --noUnusedLocals --noUnusedParameters # 用 ESLint 查未使用变量、无意义的 console.log npx eslint . --ext .ts,.tsx # 统计当前分支相对主分支新增/删除行数观察 PR 规模 git diff --numstat origin/main...HEAD | awk { add $1; del $2 } END { printf add: %d, del: %d\n, add, del }如果你的项目规模较大可以用knip、ts-prune这类工具扫描未被导出的模块、未被引用的文件。输出结果通常会是一张“可疑文件清单”然后你逐个确认能否删除。对于 Java、Python、Go 等项目分别去找对应的未使用依赖检查与代码覆盖率工具即可核心思路一致如果一个函数、模块、字段在整个系统里没有任何调用路径那么它大概率是历史包袱不再是有效资产。留着它只会让模型的上下文更乱让后来的 AI 更分不清“什么可以复用”。5.2 让 AI 生成“删除/精简 diff”而不是“重构重写”清理存量代码时很多人习惯让 AI“重构一下这个文件”。这个 prompt 太危险了因为模型会倾向于把所有东西重写一遍最后 diff 巨大你根本没法 review。更安全的做法是让 AI 产出一个只减不增的补丁在 src/payment/v1/order.ts 中删除无用代码具体要求如下 1. 只做删除和合并不要引入新函数。 2. 如果你发现两个函数行为完全相同保留其中一个删掉另一个。 3. 删除任何未被当前模块导出的私有函数。 4. 不要在文件顶部新增 import。 5. 直接输出 git diff不要粘贴整个文件。这时候你要重点检查的是这个 diff 是否真的在减少代码量。它有没有为了“减少行数”而把两个职责完全不同的函数粗暴合并有没有把一个复杂但正确的判断逻辑删成语义丢失的空壳5.3 同类分支合并成数据表还有一种常见的“代码多但信息量低”的情况是模型生成了大量if / else if分支每个分支只是返回一个固定结果。与其让模型继续抄一份新的 switch不如人先给出一个数据表方向。仍以下面的状态迁移为例。一个完整的迁移表用代码写出来可以是状态映射而不是几十行条件判断// 状态迁移表key 是“当前状态 事件”value 是下一状态 const TRANSITION_TABLE: Recordstring, ChargeStage | null { draft:submit: pending, pending:mark_paid: paid, paid:request_refund: refunded, }; export function nextStage(current: ChargeStage, event: string): ChargeStage | null { return TRANSITION_TABLE[${current}:${event}] ?? null; }这种情况下新增一个状态或者新增一个事件不需要修改函数逻辑只需要在表里加一行。模型的输入历史越“表驱动”它后续生成的代码就越少依赖散落的 if 分支。6. 评审与合入门禁把净增代码量当成检查点光是让开发者写少还不够。只要 PR 没有门槛人们为了快速交付很容易让 AI 一次性生成大段代码然后合并进主干。所以在流程层面需要把“代码净增长”变成显式的评审指标。这里说的不是简单地从制度上规定“每次 PR 不能超过 200 行”。如果你第一次就让 AI 做一个大功能并生成 800 行代码你很难把它拆成 200 行以内。更合理的做法是在 PR 描述里填写“新增行数和删除行数”并说明为什么这次必须净增这么多。推荐在代码评审模板里加这几项检查项说明本次需求的净增行数如果只加了功能没加业务复杂度行数应该不高是否新增了文件新增文件是强信号开发者在评审时重点看这个文件能不能被合并是否重复了现有逻辑评审者是否能在仓库里找到相似函数是否出现防御式判空是否能通过类型设计消除这些分支是否有调试残留console.log、临时写死的if (false)等是否带失败测试没有测试情况下AI 生成代码的可信度很低在自动化层面可以先写一个脚本把“PR 是不是太大了”这件事暴露出来。比如每次 Push 之后输出新增和删除行数把它加入 CI 日志#!/usr/bin/env bash # 在 CI 中统计 PR 新增/删除行数辅助 reviewer 判断是否需要拆 PR BASE${GITHUB_BASE_REF:-main} git fetch origin $BASE --quiet git diff --numstat origin/$BASE...HEAD | awk { add $1; del $2 } END { printf total_additions%d total_deletions%d\n, add, del }如果某次 PR 的总新增行数超过预期比如 600 行以上CI 日志里给一个 warning提醒仓库维护者优先拆解。这个门槛值需要根据团队习惯调整。强硬规则可能产生“为了少改行而故意把代码压成一长行”的负优化因此比较好的做法是先暴露、再人工决策。还有一个非常简单的实践要求 AI 生成代码后在提交信息里写清楚是由 AI 辅助完成还是完全自动生成并在 PR 描述里标注涉及范围。这样 reviewer 看到不合适的抽象时可以直接问一句“这段只有这一处调用为什么不直接内联”这个问题的潜台词正是“写更少代码”。7. 怎么观察 AI slop 是不是真的在减少如果你的团队已经开始按上面的方式控制 AI 代码生成下一步需要验证效果。不能只看“我们用了 AI效率提高了”还得看仓库是否变得更难维护。建议关注下面几个信号信号一单次需求平均净增行数是否在下降。这是一个过程指标。如果新需求多次都只增加了 20~50 行说明团队拆解需求和复用逻辑的能力在变强。如果每个需求动不动新增 300 行通常不是业务真的复杂而是又没有找到旧的复用点。信号二重复代码率是否稳定。你不需要直接看“重复代码率数字”只要在 review 时定期搜索相同模式的出现次数。比如一个订单状态的转换逻辑如果被复制到了三个文件里这就是 AI slop 的一种典型残留。信号三评审评论的有效性。如果 review 里经常出现“这里不需要这么防御”“这个文件是不是和旧文件重复了”说明 AI 生成的大块代码开始成为团队负担。反过来如果 review 能集中讨论业务规则和测试边界说明系统的“代码表面积”变小了。信号四改动一个已有小功能时需要动几个文件。如果本来只需要改一个函数但实际要连带改接口类型、两个 service、三个 mock那这套代码里有相当一部分“伪结构”正在增加维护成本。信号五留下了一段代码后三个月内是否真的被修改过。不活跃代码不一定是死代码但一个系统和团队成员长期都没有触达的抽象层大概率只有解释成本没有复用价值。另外可以做定期“砍半实验”。每个月挑一个相对稳定的模块要求组内成员挑战在保持行为不变、测试全绿的前提下代码行数能不能降一半这个实验不一定真能降一半但执行过程中会暴露大量隐藏的重复分支、无意义中间变量和不必要封装。它比任何指标都能更直接地训练出“写更少代码”的直觉。8. 适用边界与常见认知误区讲到这里必须补充一些边界避免“写更少代码”被误解成“少写代码就行了”。AI 有它擅长的工作一次性脚手架、数据格式转换、正则表达式、单元测试样例、把一段 Python 翻译成 TypeScript、生成一个模拟接口的假数据文件。这些代码的特点是生命周期短、重复度高、不需要深层领域知识、就算写得不好也容易替换。让 AI 在这些场景快速生成反而是降低开发者负担的有效方式。但下面的场景要非常谨慎场景谨慎原因建议核心业务状态机状态之间隐含业务约束模型只能看到邻近代码看不到全局规则人工实现并用类型/测试锁死权限与计费逻辑安全与资损风险高错误分支可能不会立刻暴露强制人工对每个分支 review大规模重构模型生成新结构时会假设很多旧调用点存在容易形成影子 API先由人设计目标结构再让 AI 执行小步 diff跨模块数据一致性需要同时理解多个服务的数据流不适合让 AI 一次性新增大段调用链同时针对“写更少代码”本身也有一些常见误区。误区一认为 AI slop 就是因为 AI “写太快”所以限制输出长度就行。实际上简单地在提示词里加一句“不要写太多代码”不会起作用。模型不知道系统里有哪些可以复用的函数它只会降低自己的输出量结果可能是把该写的边界处理也省了。真正解决问题的是缩小任务边界和增强编译/测试约束。误区二认为减少 AI slop 只能等下一代模型。模型能力当然会影响生成质量但代码库本身的混乱程度会影响任何一代模型。你给一个塞满重复代码和伪抽象的仓库模型下一次生成时最好的策略就是继续复制现有风格。仓库干净了模型的输出会立刻变干净。误区三认为删除代码就是删“AI 写出来的代码”。人工写的历史烂代码同样会产生 AI slop 的土壤。很多“祖传代码”本身就很冗余AI 只是延续了那种写法。因此清理目标应该是所有非必要代码而不是看代码是人写的还是 AI 写的。误区四以为“代码量少”是最终目标。有些场景下 50 行代码优于 10 行“聪明”代码比如可读性更好、边界更明确。写更少代码的最终追求是降低系统维护成本而不是强行追求最短实现。另外要注意合规边界。生成式 AI 的代码版权与训练数据来源仍在讨论期公司项目使用前先确认企业内部政策和授权范围。涉及私有仓库、用户数据、密钥的代码不要随意发送到不被允许的外部模型服务。你可以在本地安装私有化模型或用企业内部批准的编码助手但不能因为“AI 生成了代码”就默认代码没有版权或合规风险。9. 最容易立刻见效的三个动作如果你现在就想开始实践“减少 AI slop写更少代码”不需要一次性推动整个团队改革。从下面三个动作开始花半小时就能产生可感知的变化。动作一把下一个 AI 任务改写成“填空任务”下次再让 AI 生成代码时不要直接说“帮我写一个 XX 功能”。先花五分钟写清楚函数签名、输入类型、输出类型再写一条失败测试然后让 AI 只补实现。如果你的任务太难拆就继续把函数拆小。这样跑一次后你会直观感受到 AI 产出的“废话代码”变少了。一个可以直接复制的提示词模板在 src/fetch-user.ts 中实现 fetchUser 函数。 现有签名 function fetchUser(id: string): PromiseUser | null 要求 1. 只修改该函数内部实现。 2. 不要新增文件不要修改其它函数。 3. 接口返回 404 时应返回 null其它错误继续抛出。 4. 运行 pnpm test 确保测试通过。动作二给你的代码库做一次“未使用代码”初筛针对当前项目跑一遍未使用代码检查。把扫描结果里明显没有调用者的私有函数删掉。不要试图一次删光所有内容先挑那些“你凭直觉就能判断没人用”的部分。删除之后再跑一遍全量测试你会发现测试全绿往往意味着那些代码在很长一段时间里根本没有参与系统行为。动作三下一次提交 PR 前主动在描述里写净增行数净增行数是一个很好的元认知信号。写这个数字时你大概率会想这个需求是不是非得新增 300 行中间有没有一段其实是在重复旧的工具函数这段代码能不能直接放到调用方文件里而不用新增一个新文件无论最后是否调整这个动作已经强迫你站在“代码总量”的角度重新审视自己的提交。回到 Dex Horthy 和 Matt Pocock 的讨论。减少 AI slop 其实不需要一套复杂的“AI 治理平台”也不需要禁止模型参与开发。把它做成一件具体的事让每个新功能都以更小的代码增量落地让每个新函数都能找到明确的存在理由让每次模型生成都发生在人已经画好的边界内。当代码总量持续下降时AI 生成的垃圾自然就没有地方落脚了。