折腾了大半个月,我把手头所有跟 AI 结对编程相关的脚本、规则、钩子统一塞进了一个叫t3code的目录。刚开始它只是我本地的一个临时文件夹,用来装各种给 AI 代码“挑毛病”的脚本,后来慢慢长成了一个完整的验收流水线。今天想把这套东西完整拆开聊聊——不聊大道理,只说实际怎么让 AI 生成的代码别把线上环境炸了。
如果你正在每天用 Copilot、ChatGPT、Claude 之类的工具写业务代码,大概率会遇到一种情况:代码跑起来没问题,但 review 的时候觉得哪里不对劲,又说不清哪里不对劲。其实这种“不对劲”并不是玄学,它通常集中在几类固定问题上。t3code 的核心思路也很简单:把 AI 生成的代码按三道关卡依次过一遍,分别是Type(类型约束)、Test(独立测试)、Tidy(结构规整),名字里三个 T 就是这么来的。这篇文章我会从一次真实的翻车现场开始,讲清楚每一关到底在做什么、怎么做,以及最后怎么把这些东西串成一条能日常跑的流水线。
1. 从一次90分钟的排查说起:AI代码为何总在边界条件上翻车
1.1 事故现场:一个看起来没毛病但总在最后一页出错的分页接口
先讲个真实事故。我们有个管理后台需要用户列表分页,于是我让 AI 按常规写法生成一个接口:
async function getUserPaging(page: number, pageSize: number) { const rows = await db.query('SELECT * FROM users LIMIT ? OFFSET ?', [pageSize, (page - 1) * pageSize]); return { list: rows, page, pageSize, total: rows.length, }; }这段代码在单元测试里能过,在前 5 页里也都正常,但等到翻到最后一页,前端突然发现“总共只有一页”。问题出在total: rows.length——当最后一页的数据条数小于pageSize时,rows.length返回的是当前页的行数,而不是全表总数。正确写法应该先SELECT COUNT(*)得到总记录数,或者用窗口函数把总数带出来。
这个 bug 在 review 时非常容易漏掉,因为它只在特定翻页场景下才暴露。如果你盯着代码只看逻辑流,很难从“返回了一个 number”这件事里发现问题。但如果你把这个函数放在一个分页契约里,明确total必须是全量总数,那么这个 bug 在类型和测试两层都应该被揪出来。
1.2 根因:AI 生成代码的依据是“模式频率”,不是“当前库的约束”
为什么 AI 老在这种地方翻车?我的理解是:大模型写代码的时候,本质是在已经见过的海量代码里按概率组合出最像样的结果。训练语料里,一万个分页函数可能有一万个都直接返回rows.length,因为它“看起来”是对的。但 AI 不知道你这个项目里total字段在接口文档里的精确含义,也不知道前端是根据total来算总页数的。
人写代码的时候,脑子里通常带着“这一页的 total 到底代表什么”的上下文,这是业务语义。AI 没有这个上下文,它只有统计规律。所以你不能指望 AI 一上来就写出完全符合你当前项目契约的代码,你能做的是在它生成完之后,用机器可执行的规则去验证。
这就是 t3code 立项的起点:代码生成靠 AI,代码验收靠流水线。我们要把“AI 写得对不对”从人的主观 review,变成一组可重复、可自动运行的关卡。
1.3 t3code 的三关设计原则
t3code 的三关不是想当然拍出来的,是我把过去两年里踩过的坑分类归拢之后得出的三个最小必要环节:
| 关卡 | 动作 | 解决的核心问题 |
|---|---|---|
| Type | 让 AI 先写类型、再写实现,然后跑类型检查 | 结构和契约错位 |
| Test | 用独立的另一路 AI 生成测试用例,跑真实断言 | 边界行为和语义错误 |
| Tidy | 用格式化器加自定义静态规则,对新增代码做结构约束 | 可读性和长期维护成本 |
这三关互相独立,又各有专攻。类型检查管“形状”,测试管“行为”,tidy 管“维护”。接下来我按流水线顺序,把每一关的细节展开写清楚。
2. 第一关:用类型系统把 AI 的“自由发挥”锁进笼子
2.1 为什么类型检查要放在最前面
我在实际项目里最深的体会是:类型检查是成本最低、反馈最快的代码验收方式。编译器就像一个永远不会累的 review 机器人,只要类型对不上,它立刻告诉你具体位置。
很多 AI 生成的代码,肉眼看上去挺规整,但一旦放到真实工程里,就会因为某个接口定义变了导致大量红色波浪线。类型不对,后面的测试和结构规整都没有意义——你连编译都过不了,还谈什么行为对不对。
所以 t3code 的第一阶段强制做一件事:在让 AI 写实现代码之前,先让 AI 把涉及的数据结构、函数签名、对外契约完整声明出来,人类 review 确认之后再生成实现。这个顺序很重要,不能反。
2.2 契约先行:两个阶段的 Prompt 设计
拿刚才的分页接口举例。第一阶段我会给 AI 发这样的 prompt:
请为“获取用户分页列表”功能设计类型契约: - 定义 Page<T> 泛型,包含 items、total、page、pageSize 四个字段 - 定义 User 类型,字段至少包含 id、nickname、avatar、createdAt - 定义 getUserPaging 的函数签名,入参 page 和 pageSize,返回 Promise<Page<User>> - 只输出 TypeScript 类型声明,不要写任何实现等它返回types.ts之后,人是第一道审查者。这里我额外加一条要求:任何字段如果不是某个业务属性的直接映射,都必须写注释说明预期语义。比如total必须注明它是“全量记录数,不是当前页记录数”。这一步看着多余,但 AI 在第二阶段生成实现时,会参考类型声明里的注释,命中率明显提升。
第二阶段 prompt 是:
现在根据 types.ts 中的契约,实现 getUserPaging。 约束:不得修改任何已声明的类型;total 必须来自独立的总数查询;不得使用 any;不得使用 @ts-ignore 或 @ts-expect-error。生成实现后,t3code 会立刻执行类型检查,命令类似这样:
t3code type --project tsconfig.json --strict实际后台调用的是tsc --noEmit加上一些额外检查。--strict模式会强制检查:
- 不允许
any或unknown出现在函数参数和返回类型里 - 不允许使用非空断言
! - 不允许出现
@ts-ignore、@ts-expect-error - 严格空值检查:
null和undefined不能随意赋值给非可选字段
2.3 用类型锁定返回值,把“语义错误”转成“类型错误”
回到开头那个分页 bug。如果我们把total的类型定义为业务对象而不是普通数字,API 设计上是办不到的,因为数据库返回的total就是数字。但我们可以用品牌类型(branded type)来强化它:
type TotalCount = number & { __brand: 'TotalCount' }; interface Page<T> { items: T[]; total: TotalCount; page: number; pageSize: number; }这样 AI 如果在实现里返回rows.length,类型系统会报错——因为number不兼容TotalCount。这个技巧不是 t3code 发明的,但我用下来觉得它是锁定 AI 行为最有效的手段。你不需要给每个数字都搞品牌类型,只要给那些“看起来像数字但语义完全不同”的字段加一层标记,AI 就不容易跑偏。
当然,品牌类型也有代价,数据从数据库或其他服务进来时你得手动断言,代码会多一些。我的建议是只在关键契约字段上做,比如总额、总价、唯一 ID 这种经常被 AI 搞混的东西。
2.4 增量检查配置,别让全量编译拖垮体验
刚把 t3code 接入项目时,大家都担心引入 AI 之后编译时间变长。实测下来,一个中等规模的 monorepo 全量tsc --noEmit大概要 40 秒,确实不能忍受。所以我在 t3code 里加了增量编译:
t3code type --project tsconfig.json --incremental --tsbuildinfo .t3code/cache/tsconfig.tsbuildinfoTypeScript 官方支持--incremental,第一次全量构建后会生成.tsbuildinfo缓存,之后每次只检查变更文件。在我们的仓库里,增量检查基本可以压到 3~5 秒。这对 CI 场景不太重要,但对本地开发者的体感影响非常大——如果每次都要等半分钟,再好的流程也没人愿意用。
3. 第二关:让测试成为 AI 代码的“独立验收员”
3.1 反直觉的设计:测试用例必须由第二路独立生成
类型关解决的是“形状对不上”,但很多 bug 形状完全没问题,比如那个total错误,类型上依然是一个数字。这时候需要测试来抓行为。
t3code 在这里做了一个反直觉的设计:如果 AI 负责写实现,那就不要再让同一个 AI 写测试。原因很简单,同一个模型生成实现和测试时,会共享一套隐含假设。它写一个排序函数如果只测正序数组,那大概率是它压根没考虑逆序场景;你让它自己写测试,他会继续忽略这个场景。
所以 t3code 的做法是:把实现代码发到一个独立的会话里,用一个完全不一样的 prompt 让第二个模型专门“找茬”。这招在只有一种模型可用时也能用——你只要把两次请求放进不同的会话,甚至手动把实现代码 copy 出去再重新粘贴进来,效果都比“用同一个上下文同时生成实现和测试”强得多。
具体 prompt 模板长这样:
下面是一段函数实现代码。你的任务是设计能暴露它缺陷的测试用例。 要求: 1. 不要修改实现代码 2. 必须覆盖空输入、逆序输入、全等重复值、超大输入、浮点数边界 3. 对每个用例,说明它可能暴露什么问题 4. 输出 Vitest 测试文件,只用标准 assert3.2 t3code test 的完整执行链路
t3code test这一个命令,内部会依次做:
- 从当前 git diff 中提取新增或被修改的函数签名
- 根据这些签名构建测试目标清单
- 调用独立模型,按上面的模板生成测试文件
- 把测试文件写到临时目录
.t3code/tests/ - 执行 Vitest,收集通过/失败结果
- 读取覆盖率报告,对照阈值判定是否通过
实际执行看起来是这样:
t3code test --target src/sort.ts --threshold branch@0.75 --timeout 60这里我用branch@0.75而不是行覆盖率,是因为分支覆盖更能反映边界场景。一个函数杀一行覆盖率达到 100%,但只有一个分支被测试过的情况太常见了。分支覆盖至少要 75%,才说明主要输入路径都跑过了。
3.3 真实案例:快速排序的栈溢出是怎么被第二路测试发现的
有次我让 AI 写一个快速排序。第一版实现是这样的:
function quickSort(arr: number[]): number[] { if (arr.length <= 1) return arr; const pivot = arr[0]; const left = arr.filter((x) => x < pivot); const right = arr.filter((x) => x > pivot); return [...quickSort(left), pivot, ...quickSort(right)]; }这段代码在数组大部分元素不重复时没问题,但如果输入是[1, 1, 1, 1, 1],那么left和right都是空数组,pivot 只有第一个 1,递归无法收敛?其实因为每次只取一个 pivot,剩下的 1 都丢了。最后结果变成[1],而不是 5 个 1。这是典型的不稳定分区问题。
第一路 AI 生成的测试全用不同数字的正序数组,跑得飞快,全绿。第二路模型生成的测试里有一条[1, 1, 1, 1, 1],一次就拉爆了结果。后来换成了经典的三路快排才通过。
这个案例给我们的启发是:测试的“独立性”比“覆盖率数字”更值钱。让同一路 AI 自己写实现再自己验证,基本等于让学生自己出卷子自己批改,很难发现问题。
3.4 覆盖率阈值定多少合理
在 t3code 里,我默认的阈值是:
- 核心业务函数:分支覆盖 ≥ 80%
- 工具函数:分支覆盖 ≥ 70%
- 纯 I/O 胶水层:行覆盖 ≥ 60%,分支不强制
阈值不是越高越好。有些组件是“数据进来,数据出去”,内部逻辑简单,硬抠分支覆盖会造成大量无意义测试。而纯函数、算法、状态机这种几乎没有外部依赖的逻辑,分支覆盖率就应该拉满。t3code 支持不同规则对应不同目录:
thresholds: src/domains/**: branch: 0.80 src/lib/**: branch: 0.75 src/api/**: branch: 0.60这套配置跑了一个月后,我发现 AI 生成的算法类代码质量明显提升,因为一旦分支覆盖不达标,流水线就会把代码打回去重写。它逐渐学会“多考虑几种输入”。
4. 第三关:用静态规则和 codemod 消除代码里的“AI味”
4.1 一眼看穿 AI 代码的通病
前两关解决的是正确性和行为,但 AI 生成的代码还有个更磨人的问题:风格和质量。我把它们统称为“AI味”。典型症状有:
- 一个函数能写到 80 行,从上到下全是顺序逻辑,中间塞三个 if 和两个循环
- 魔法数字满天飞:
86400、1000、60到处都是,没有命名 - 嵌套层级过深,
if (a) { if (b) { if (c) { ... } } } - 变量命名极其偷懒:
data、result、temp、res - 大量重复代码,经常能看到同一段过滤逻辑在两三处出现
这些代码在功能上没问题,测试也全绿,但三个月后将成为维护噩梦。t3code 的第三关tidy就是用来处理这类问题的。
4.2 先格式化,再跑自定义规则
t3code tidy 第一步是无脑跑 Prettier 和 ESLint,先把格式问题清掉。第二步才是自定义规则。这些自定义规则不是单纯的风格偏好,而是针对“AI 生成特征”设计的结构性约束。
每条规则固定有三级判定:
- warning:只是提醒,不打回
- error:必须修复,否则阻断合并
- autofix:能自动修的直接用 codemod 改
比如,我自定义了三条核心规则:
规则一:函数不得超过 40 行。
这条规则对 AI 特别有效,因为它经常把多个步骤塞进一个大函数。函数一旦太长,人就难以理解它的上下文边界。t3code 会在新版 diff 里定位超过 40 行的函数,报error,并自动调用拆函数器——把连续段落提取成命名清晰的小函数。
规则二:魔法数字必须命名。
if (timestamp > 86400)会报 error。AI 生成的代码里,大量时间戳、重试间隔、分页大小都是裸数字。这条规则会扫描新增代码中的所有数字字面量,如果它出现在比较表达式或赋值语句中,就要求提取为const。
规则三:嵌套深度最多 3 层。
AI 写循环里套 if 套条件非常顺手,4 层嵌套让人想砸键盘。t3code 用 AST 检查新增代码的最大嵌套深度,超过 3 层直接报 error。
4.3 自定义 ESLint rule 的落地写法
这里分享一个最小可用的自定义规则骨架,用来检查函数行数:
// eslint-plugin-t3code/rules/max-function-lines.js module.exports = { meta: { type: 'layout', docs: { description: 'AI generated function should not exceed 40 lines' }, fixable: 'code', }, create(context) { return { FunctionDeclaration(node) { const start = node.loc.start.line; const end = node.loc.end.line; const length = end - start + 1; if (length > 40) { context.report({ node, message: `Function length ${length} exceeds 40 lines.`, }); } }, }; }, };要注意的是,ESLint 的检查范围默认包含整个历史代码。如果对整个历史代码跑规则,存量旧代码能产生几百个 error,大家都会骂娘。所以 t3code 的做法是:只对 git diff 中新增的行运行规则。可以用context.getFilename()结合本地 git 信息做过滤,或者在调用 ESLint 时直接用--staged参数限定在暂存文件上。
4.4 用 codemod 而不是直接让 AI 改
当规则报错后,最偷懒的方式是把错误信息塞回 AI,让它重写。但我实测下来,AI 重写经常引入新的风格问题和边界遗漏,因为它在修改一段代码时的“局部视野”比重新生成时还要窄。
更稳的方式是用 codemod 自动改。比如魔法数字,我们写一个 jscodeshift 脚本,自动扫描 diff 里的数字字面量,把它提升为文件顶部的const。函数拆分也能用 codemod 做基础步骤,把连续的块抽成一个新函数,但这里建议先自动提取,再让人润色命名。自动化的力度刚好到“不会破坏行为”为止,剩下的主观判断交给人。
下面是一个简化版魔法数字提取脚本:
// codemod/extract-magic-numbers.js module.exports = function transformer(file, api) { const j = api.jscodeshift; const root = j(file.source); const magic = []; root.find(j.NumericLiteral).forEach((p) => { const value = p.node.value; if (!Number.isInteger(value) || value < 0) return; if (value === 0 || value === 1) return; if (p.parentPath.node.type === 'VariableDeclarator') return; magic.push(p); }); if (magic.length === 0) return file.source; const lines = []; magic.forEach((p, i) => { const name = `MAGIC_${i}`; lines.push(`const ${name} = ${p.node.value};`); j(p).replaceWith(j.identifier(name)); }); return lines.join('\n') + '\n' + root.toSource(); };实际跑下来,这个 tidy 阶段把 AI 提交代码的整体结构问题降低了七成左右。自动拆函数和提取命名这一步,虽然不是所有场景都完美,但至少在进 review 前把大部分“AI味”都清掉了。
5. 把三关接成一条流水线:t3code 的持续运转方式
5.1 一条命令完成“类型 + 测试 + 规整”
前面的三关单独都好说,真正有价值的是把它们用一个命令串起来。t3code 提供一个顶层命令:
t3code run --staged--staged表示只处理当前git diff中暂存的新增代码。这个命令会按顺序执行:
t3code type:增量类型检查t3code test:读取 diff 函数,生成独立测试并跑分支覆盖t3code tidy:格式化 + 自定义规则 + 可选的 codemod 修复
任何一关失败,命令立即返回非零退出码,并输出一份报告:
t3code report for staged changes Type: PASS (3.2s) Test: FAIL (coverage 0.62 < 0.75) missing branch: src/payment/calc.ts:18 missing branch: src/payment/calc.ts:24 Tidy: WARN (2 warnings) max-function-lines: src/user/list.ts:22 (52 lines)我把这个命令挂到了pre-commit钩子上。如果钩子时间超过 20 秒,我会切到pre-push——推送时才开始检查。实际用下来,增量类型检查 3~5 秒,测试生成加执行 10~15 秒,tidy 2~3 秒,总体还能接受。
5.2 性能调优的几个必做项
流水线很理想,但如果不能控制耗时,开发者会用脚投票。我踩过的性能坑主要有三个:
第一个坑:tsc 全量编译。解决方式前面提过,用--incremental和.tsbuildinfo。这里再补充一个细节——tsc --noEmit在增量模式下也可能不生效,需要配合composite项目配置。我们最后是把类型检查改成了tsc -b --pretty false,构建模式天然支持增量,速度快很多。
第二个坑:测试每次都生成全量文件。如果 diff 里只有两个文件,其实只需要对那两个文件生成测试。t3code 默认使用--related参数拿到变更文件列表,再按这个列表生成测试。不要让 AI 生成没必要的全局测试。
第三个坑:tidy 阶段对全量项目跑 ESLint。必须限制在新增代码上。我们是用一个自定义脚本来包裹 ESLint Linter API,先拿 git diff 里的行号范围,再过滤context.report里的位置,只保留命中新增行的告警。
下面是流水线的时间预算表:
| 阶段 | 通常耗时 | 优化手段 |
|---|---|---|
| type | 3~5s | 增量编译、build mode |
| test | 10~15s | 只测 diff 相关文件、并行跑 |
| tidy | 2~3s | 只扫新增行、禁用无关规则 |
5.3 跑了一个月后,数据发生了什么变化
t3code 在我们一个前端 monorepo 里试点了一个月,参与者 12 人。统计结果挺有意思:
- AI 代码一次通过率(即无需人工改动直接进入 commit)从 31% 提升到 68%
- 被流水线拦截的问题分布如下:
| 问题类型 | 每月拦截次数 | 典型场景 |
|---|---|---|
| 类型契约错位 | 22 | 改了入参类型但实现还在用旧字段 |
| 边界行为错误 | 31 | 空数组、全重复数据、null |
| 性能隐患 | 9 | 循环内重复查询、不必要的 state 更新 |
| 结构问题 | 35 | 超大函数、魔法数字、三层以上嵌套 |
其中“性能隐患”不是 t3code 的三关直接发现的,而是第二路生成测试时,测试超时暴露出来的。那次是 AI 写了一个O(n²)的数组去重,输入是 10 万条数据时直接卡死。
最让我意外的是,这些被拦截的问题里,有将近一半是即使人工 review 也大概率漏掉的。比如某个接口返回的total错误、排序函数遇到全相同值丢元素,这些都是“看代码看不出问题”的类型,只有机器能抓到。
5.4 人在这个流程里的角色变化
很多人担心引入流水线后,人会越变越懒。我实际体会恰恰相反。有了 t3code 自动拦截低级问题,人在 code review 时就能把注意力从“这块语法对不对”挪到“这个设计满足需求吗”“这个边界策略合理吗”“这个抽象和团队现有架构是否一致”。
t3code 不是用来替代代码评审的,它是把评审里最机械、最耗神的部分自动化,让评审回到真正需要人的判断力的地方。现在我们的流程是:
- 人写需求和关键约束
- AI 产出候选实现
- 流水线按三关自动验证
- 人只看流水线报告加变更 diff,做最终裁决
这套流程跑顺了以后,我的体感是:AI 从“偶尔靠谱的助手”变成了“产能很高但需要严格质检的生产线”。t3code 这个名字对我来说不是一个工具,而是一种和 AI 协作的边界意识——给它再大的自由,也要守住“类型、行为、可维护性”这三条底线。
如果你也想在团队里试试类似的做法,我的建议很简单:别一开始就在整个仓库铺开。先挑一个变更频率高、纯逻辑占比大的模块,比如一个工具库目录或者一个独立微服务,把t3code run --staged挂在 pre-push 钩子上跑两周,收集真实命中数据后再决定要不要扩大范围。工具本身不是重点,重点是你愿意在 AI 产出的代码上建立一个稳定的反馈闭环——这个闭环越早建立,后面维护代码的人就越感激你。