ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

t3code工作流:用Type、Test、Trace三道防线告别线上事故

t3code工作流:用Type、Test、Trace三道防线告别线上事故

从那次线上事故说起吧。

那天晚上十一点,我正在给一个新功能做最后的联调。前端页面点开之后,用户头像区域直接白屏,控制台报了一个Cannot read properties of undefined (reading 'name')。我第一反应是后端字段没返回,打开日志一看,字段明明有,只是名字叫displayName,前端代码里拿的是name。最讽刺的是,这段前端代码是前一周用 AI 补丁生成的,当时测试跑过、类型检查也过了,没人会觉得这里会炸。

后来我跟团队复盘,发现这次事故的根子不在 AI,也不在某个人的粗心,而在我们的代码没有任何"约束":接口边界没有类型定义、行为没有测试兜底、出问题时没有一条能顺着查下去的链路。也是从那次之后,我开始认真研究社区里被反复讨论的 t3code 工作流,并且把项目按这套方式重构了一遍。今天这篇就把我对 t3code 的理解、落地顺序、以及和 AI 编程搭档配合的完整经验写出来。

1. t3code 是什么:Type、Test、Trace 三道防线的编码协议

1.1 三个 T 的职责分工

t3code 这个叫法在不同语境里出现过,但社区里讨论最多的解释是三个以 T 开头的关键词:Type(类型)、Test(测试)、Trace(追踪)。三个词单独看都不新鲜,新鲜的是它们被组合成了一个有明确顺序的编码协议。

  • Type over Guess:先定义类型,让数据结构的约定变成编译器和 IDE 都能检查的"硬合同"。
  • Test over Memory:先写测试,把代码应该表现出的行为钉死在用例里,而不是靠人脑记住"当初这里是这样写的"。
  • Trace over Trust:先接可观测性,让每一个请求、每一次数据库查询都有迹可循,出问题时不需要靠猜。

这三者不是平行的可选项,而是一条单向的防线顺序。类型守住编译期,测试守住开发期,Trace 守住运行期。任何一层被跳过,问题都会往下一层漏。如果把代码比作一座建筑,Type 是结构图纸,Test 是验收标准,Trace 是水电管线的走向图——图纸错了还能改,验收标准缺失就会带着问题交付,没有管线图纸的话,哪漏水都得砸墙。

1.2 一次没按协议来造成的连环事故

回到开头那次事故。如果把当时的代码按 t3code 的视角重新审视一遍,链条上的每一个环节其实都是可以提前拦下来的:

  • 后端返回的数据没有经过 Schema 校验,字段名靠口头约定,前端和后端各写各的,这就是Type 层缺失。
  • 前端渲染头像的组件没有任何测试,甚至没有人写过"当用户对象包含 displayName 时应该显示这个名字"这样的用例,这就是Test 层缺失。
  • 前端日志只在 catch 里打了一个笼统的console.error('加载失败'),没有上下文信息,后端日志也没有 requestId 可以关联,排查时只能靠肉眼比对时间戳,这就是Trace 层缺失。

三层防线全都没设,出事只是时间问题。那次之后我意识到,在 AI 补丁越来越多、代码变更频率越来越快的环境下,写代码的速度已经不是瓶颈,约束代码的能力才是。t3code 本质上就是一套给人和 AI 共同遵守的"防呆协议"。

2. Type over Guess:让错误停在编译期的第一道闸门

2.1 类型边界的意义:从"口头约定"变成"机器可检查的合同"

很多人觉得写类型是给编译器找活干,实际上类型最大的受益者是协作者——包括未来的你自己、你的队友、以及你接进来的 AI 助手。没有类型边界的时候,一个函数的输入输出全靠函数名和注释暗示,这种"口头约定"在代码规模小的时候还能忍,一旦超过几千行、调用链超过两层,就开始出现各种猜谜现场。

我之前在一个中台项目里接手过一个fetchUser函数,返回值是被any包裹的。调用方拿到的数据到底是什么形状,完全取决于当时写代码的人心情。后来我数了一下,光这一个函数就有十一种不同的调用方式,有人取user.name,有人取res.data.name,还有人取res.user.name。这种代码没有 bug 才奇怪,因为每一次调用都在做一次没有依据的猜测。

Type 层的核心工作,就是把这些"猜测"全部替换成"检查"。你不必追求 100% 的类型覆盖,但凡是跨模块、跨服务、跨团队的数据边界,都必须有明确的类型定义。

2.2 边界上的 Schema 实战:zod + tRPC

在 Next.js 全栈项目里,我最常用的组合是 zod 加 tRPC。zod 负责在运行时校验数据结构,tRPC 负责在编译期把类型安全从后端一路带到前端。

简单说一下这个组合的用法。先在服务端定义一个 zod Schema:

import { z } from 'zod'; export const UserSchema = z.object({ id: z.string().uuid(), displayName: z.string().min(1), email: z.string().email(), avatarUrl: z.string().url().optional(), }); export type User = z.infer<typeof UserSchema>;

然后在 tRPC 的路由里使用这个 Schema:

import { router, publicProcedure } from './trpc'; import { UserSchema } from './schemas/user'; export const appRouter = router({ getUser: publicProcedure .input(z.object({ id: z.string().uuid() })) .output(UserSchema) .query(async ({ input }) => { const user = await db.query.users.findFirst({ where: (users, { eq }) => eq(users.id, input.id), }); // 即使数据库返回的数据不完整,这里也会被 zod 拦截 return UserSchema.parse(user); }), });

这样做的效果是:后端在返回数据前先校验一次,保证出去的形状一定是 Schema 承诺的形状;前端在收到数据时,TypeScript 会自动推导出 User 类型,IDE 补全、字段名检查全都生效了。

我在实际项目中遇到过一个问题:数据库表里存的是created_at,前端代码里写的是createdAt,如果不经过 Schema 转换,这个字段名问题会一直潜伏到某个组件真正渲染它才爆炸。用了 zod 之后,可以直接在 Schema 层做字段映射:

export const UserSchema = z.object({ id: z.string().uuid(), displayName: z.string().min(1), email: z.string().email(), createdAt: z.coerce.date(), });

这种转换放在边界上做,既干净又集中。所有字段的形状、命名、类型都在这一个文件里可见,后续要改也好排查。

2.3 防 any 与 as 的团队规则

类型层的防线再完整,也挡不住有人用as any亲手拆掉它。这是我踩过最深的一个坑。

有次我重构一个老模块,看到一行const data = response.data as any;,当时赶进度没细想,结果后面所有人的代码都以这个any为起点往下写类型。三个月后这个模块成了一个"类型黑洞",任何改动都会引起一片连锁报错,最后只能花一天时间重写整段逻辑。

所以我在团队里定了两条硬规则,写进 eslint 配置里强制执行:

  • 禁止在公共函数签名、组件 props、API 返回类型中使用any。
  • 禁止使用as any和as unknown as X绕过类型检查,特殊情况必须写注释说明原因。

具体配置可以在.eslintrc里加上类型感知规则:

{ "rules": { "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-unnecessary-type-assertion": "error" } }

我在实际执行中遇到过同事的反对,认为这样太严格、不灵活。我的回应是:**灵活性应该放在 Schema 层,而不是类型断言层。**如果后端返回的数据确实有两种形态,就用 zod 的 union 类型明确表达出来,而不是用一个any把问题甩给下游。模糊的灵活本质上是在转移认知负担,下游的每个人都得替你猜。

3. Test over Memory:把行为钉死在用例里

3.1 为什么"测试全绿"还是翻车:测实现与测行为的区别

团队里经常听到一句话:"测试都过了,为什么上线还是出问题?"这个问题的答案往往不是测试太少了,而是测试在测实现,不在测行为。

什么意思?假如你写了一个排序函数,然后让 AI 生成了几个测试用例,这些用例会照着 AI 自己写的那套实现逻辑来断言。如果实现逻辑本身就是错的——比如比较器写反了方向——测试照样会全绿,因为它在验证"代码跑起来没有抛异常",而不是验证"输出的数组确实按升序排列"。

举个我自己踩过的例子。我给一个数据转换函数写测试,第一版断言是:

test('should convert raw data to view model', () => { const result = convertData(rawData); expect(result).toBeDefined(); });

这个测试相当于什么都没测。它只证明了函数没有当场崩溃,至于result的字段是否齐全、值是否正确、异常输入时怎么处理,一概不知道。后来我把这个测试删了,重写成了行为导向的版本:

test('should map raw fields to view model fields', () => { const rawData = { id: 'user-1', full_name: '张三', preferences: { theme: 'dark' }, }; const result = convertToViewModel(rawData); expect(result).toEqual({ id: 'user-1', displayName: '张三', theme: 'dark', }); }); test('should handle missing preferences with default theme', () => { const rawData = { id: 'user-1', full_name: '张三' }; const result = convertToViewModel(rawData); expect(result).toEqual({ id: 'user-1', displayName: '张三', theme: 'light', }); });

前后对比一下,差别非常明显。第一个测试只关心"有没有返回值",第二个测试关心"返回值的每一种行为是否符合预期"。这才是 t3code 里 Test 这一层的真正意义:把行为钉死,让未来的改动必须显式地打破旧行为才能引入新行为。

3.2 最小可用的 TDD 循环:先红后绿再重构

很多开发者觉得 TDD 麻烦,是因为把"先写测试"理解成了"要先设计完整的测试架构"。其实最小可用的 TDD 循环只有三步:

  1. 写一个描述期望行为的失败用例。
  2. 写最少量的实现代码让这个用例通过。
  3. 重构实现代码,保持测试仍然通过。

这个循环每一步都不要贪大。一个用例只描述一个行为,对应一段最小实现。

我在写日期格式化工具时试过这个流程。先写了一个失败用例:

import { describe, expect, test } from 'vitest'; import { formatShortDate } from './date'; describe('formatShortDate', () => { test('formats ISO date string as YYYY-MM-DD', () => { expect(formatShortDate('2025-06-15T10:30:00Z')).toBe('2025-06-15'); }); });

跑测试,预期失败——因为formatShortDate还没实现。然后写最小实现:

export function formatShortDate(isoString: string): string { return isoString.slice(0, 10); }

测试变绿。这时再考虑边界情况:如果传入的字符串不是 ISO 格式怎么办?如果带时区偏移怎么办?每发现一个边界情况,就新增一个用例,再扩展实现。整个过程像给函数画安全区,一步一个脚印。

这种方式的额外好处是,测试本身就是文档。新同事接手代码时,不需要翻 README,直接看测试文件就能知道这个函数的"性格":它接受什么、拒绝什么、边界在哪里。

3.3 该测什么、不该测什么

不是所有代码都值得写测试,尤其在项目节奏快的时候,要把测试精力花在刀刃上。我在 t3code 落地时定了这么一套优先级:

值得写测试的:

  • 数据转换逻辑(字段映射、日期格式化、金额计算)
  • 权限判断逻辑(角色、租户、资源归属)
  • 复杂的 UI 状态切换(多步骤表单、异步加载状态)
  • 任何被多个模块共用的工具函数

不值得写测试的:

  • 简单的 prop 透传组件
  • 第三方库的封装层(如果库本身有测试,你只需要一个冒烟测试)
  • 纯展示型 UI(除非它包含逻辑)

以权限判断为例,这类逻辑出错往往不是技术问题而是业务问题,测试的价值在于把业务规则显式化:

describe('canEditDocument', () => { test('owner can edit', () => { expect(canEditDocument('owner', 'admin')).toBe(true); }); test('editor can edit', () => { expect(canEditDocument('editor', 'admin')).toBe(true); }); test('viewer cannot edit', () => { expect(canEditDocument('viewer', 'admin')).toBe(false); }); test('viewer can edit if granted temporary permission', () => { expect(canEditDocument('viewer', 'admin', { temporaryEdit: true })).toBe(true); }); });

这些测试写起来很快,但每次业务规则变更时,它们会是第一个告诉你"这里被改坏了"的信号。

4. Trace over Trust:线上问题别再靠猜

4.1 可观测性的最小基础设施:requestId 加结构化日志

类型和测试解决的是"开发期"的问题,线上运行时的故障仍然无法完全避免。这时候最可怕的不是报错,而是报错之后你不知道从哪里开始查。

t3code 里的 Trace 层,核心要解决的就是"可定位"。我在项目里最先做的一件事,是给每一个请求一个唯一的requestId,让前端、后端、数据库日志通过这个 ID 串联起来。

后端可以用一个中间件在请求入口处生成 requestId:

import { randomUUID } from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import { requestStore } from './request-store'; export function withRequestId(handler: (req: NextRequest) => Promise<NextResponse>) { return async (req: NextRequest) => { const requestId = randomUUID(); // 用 AsyncLocalStorage 让整个请求链路共享 requestId return requestStore.run({ requestId }, async () => { console.log({ level: 'info', message: 'request started', requestId, path: req.nextUrl.pathname, method: req.method, }); const response = await handler(req); response.headers.set('x-request-id', requestId); return response; }); }; }

前端在发起请求时把这个requestId带上,出问题时看网络面板就能拿到:

const res = await fetch('/api/user', { headers: { 'x-request-id': crypto.randomUUID(), }, }); if (!res.ok) { // 这里的 requestId 就是后端日志里的同一个值 console.error('API call failed', { requestId: res.headers.get('x-request-id'), status: res.status, }); }

这样做的效果立竿见影。之前排查一个跨前后端的问题,平均要花 20 分钟,因为要从前端日志、后端日志、数据库慢查询日志三个地方猜哪条记录是同一个请求。加上 requestId 之后,直接搜索这一个 ID,整条链路的时间线就齐了。

4.2 一个 N+1 查询的排查实录

Trace 层不仅能帮你定位报错,还能帮你在性能问题变成事故之前发现它。分享一个真实的排查过程。

当时有个接口返回用户列表,线上监控显示 P95 响应时间从 80ms 涨到了 1.5s。我一开始怀疑是数据库硬件问题,但看 CPU 和内存指标都正常。后来我把接口的日志从"单条消息"改进为"链路事件流"——每个数据库查询都输出一条带耗时和表名的日志:

{ "requestId": "a3f9c1e2", "event": "db.query", "table": "users", "durationMs": 2 } { "requestId": "a3f9c1e2", "event": "db.query", "table": "orders", "durationMs": 45 } { "requestId": "a3f9c1e2", "event": "db.query", "table": "orders", "durationMs": 38 }

看到这个输出,问题瞬间清楚了——同一个 requestId 下出现了几十条orders表的查询,每条耗时差不多,典型的 N+1 问题:先查了 30 个用户,再对每个用户循环查一次订单表,总共 31 条查询。

修复方案用 SQL 关联查询替代循环查询,或者用 ORM 的 eager loading。我的项目里用的 Drizzle ORM,一行代码解决:

const usersWithOrders = await db.query.users.findMany({ with: { orders: true, }, });

响应时间从 1.5s 降回 90ms。如果没有结构化日志把每个查询都暴露出来,这个性能问题可能还要潜伏几周。

4.3 日志分级与敏感信息规避

Trace 层做深之后,要小心一个反作用:日志太多变成噪音,甚至把敏感信息泄露出去。我在项目里踩过一次,调试时把整个用户对象塞进日志,包括手机号和邮箱。后来日志系统做审计发现数据合规问题,紧急清理了一轮。

现在的规则很简单:

  • 日志只记录必要字段,不记录完整对象。
  • 敏感字段(手机号、邮箱、身份证号)一律脱敏再打。
  • 用日志级别区分噪音:DEBUG 记录详细但不进生产,INFO 记录关键事件,ERROR 只记录异常和上下文。
console.log({ level: 'error', message: 'user lookup failed', requestId, userId: user.id, // 记录 ID,不记录完整用户对象 error: err.message, });

配合上一套日志采集工具,用pino这类库还能自动把 JSON 日志转换成多维度的监控指标。这是 t3code 的 Trace 层和普通"到处打 console.log"最大的区别:打日志不是为了让开发者在出事时蹲在终端里看,而是为了让检索和分析成为可能。

5. t3code 的落地顺序:先贴边界,再钉行为,最后加观测

5.1 三步走:从最疼的地方开始

聊完三个 T 的理论,很多人会问:我现在的项目已经几万行代码了,难道要从头开始补吗?

我自己的经验是:不要全面铺开,按痛点优先级分三步走。

**第一步:给数据边界贴类型。**找出你的项目里跨模块、跨服务、跨组件的数据流,在边界上加好 Schema。不需要一开始就覆盖全部接口,先挑最常改、最容易出错的那几个。我的经验是,凡是最近三个月出过线上 bug 的接口,都值得优先加上类型校验。

**第二步:为核心行为写测试。**按前面说的优先级,先把数据转换、权限判断、复杂状态切换这些逻辑钉住。优先挑你重构时会心虚的那部分——如果你觉得"这块我改完只能靠上线验证",那它就是最该写测试的地方。

**第三步:补齐可观测性。**先加 requestId 和结构化日志,再针对慢接口加查询耗时埋点。这一步见效最快,通常加完第二个星期你就能感受到排查问题的效率变化。

5.2 团队评审时的 t3code 检查清单

在团队协作中,光靠个人自觉很难长期坚持一套规范。我把 t3code 总结成一张代码评审检查清单,合并到团队的 PR 模板里:

检查项说明类型
新接口定义是否经过 zod Schema 校验数据进出不许裸奔Type
公共函数签名是否避免 any不能让类型黑洞蔓延Type
新逻辑是否包含失败用例至少一个用例先描述行为Test
断言是否验证具体值而非只验证存在拒绝toBeDefined()糊弄Test
跨模块调用是否有 requestId 传递链路要能串起来Trace
日志是否记录关键上下文ERROR 级日志必须有排查线索Trace
是否避免在日志中输出敏感字段合规是底线Trace

这张表贴在 PR 模板里之后,我明显感觉到团队的代码质量在变化。以前评审会上争论的是"我觉得这里应该这样写",现在争论的是"这个行为是不是不符合用例"——话题从主观偏好转移到了客观行为约束上。

5.3 渐进切换的心态与方法

最后说一句可能会得罪同行的话:不要试图一晚上把项目全部改造成 t3code。我见过太多"周六花一天重写所有类型定义"的人,结果周一回来发现周日改的代码让 CI 炸了十几次,然后又花三天回滚。

渐进式迁移的正确姿势是:**选一个新的小模块作为试点,完整走一遍 Type、Test、Trace 三个步骤,跑两个星期,对比一下线上故障率和排查时间的变化。**有了真实数据的支撑,再慢慢扩展到其他模块。如果团队有人反对,把数据摆出来比讲道理有用一百倍。

我自己在试点模块上观察到的最明显变化是:在线故障的平均排查时间从一小时以上降到了二十分钟以内,而且有几个问题在出现之前就被测试拦住了。数据不会说谎。

6. 和 AI 编程搭档一起执行 t3code

6.1 为什么 AI 生成代码总是绕过规则

现在大部分团队的日常开发都离不开 AI 补全和代码生成工具,但不知道你有没有发现一个现象:AI 生成的代码特别容易"抄近道"。

它倾向于不写类型——因为类型定义需要更长上下文;它倾向于跳过测试——因为没有哪个编程助手会在你写"实现一个排序函数"时主动补一个边界用例;它更不会主动添加可观测性——因为代码评审时没人检查 AI 生成的日志是否带 requestId。

这不是 AI 坏,而是默认提示词没有给它立规矩。AI 的行为高度依赖于你给它设定的约束,这正好和 t3code 的理念是一致的:你希望它怎么工作,就要显式地告诉它边界在哪里。

6.2 我给 AI 的提示词协议(可直接复制)

我现在用的 AI 编程提示词模板长这样:

你在这个项目里必须遵循以下编码协议: 1. Type 规则 - 所有新的 API 返回类型必须基于项目现有的 zod Schema 定义。 - 禁止在公共函数签名中使用 any。如果参数类型不确定,先搜索现有类型定义,不要自己造。 - 修改数据结构时,必须同步更新对应的 zod Schema。 2. Test 规则 - 每实现一个新的行为逻辑,必须同时提供一个描述该行为的测试用例。 - 测试断言必须验证具体的值和边界条件,禁止只写"结果存在"的空断言。 - 如果修改了已有函数的实现,先运行它的测试,确保旧行为没有被意外打破。 3. Trace 规则 - 所有跨模块调用必须携带已有的 requestId 上下文。 - 错误处理代码必须输出结构化日志,包含错误信息、当前操作的对象 ID 和 requestId。 - 禁止在日志中输出用户的敏感字段,如手机号、邮箱、家庭住址。 4. 通用规则 - 在修改任何公共函数之前,先搜索并列出所有调用方,评估影响范围。 - 不生成与现有代码风格不一致的代码。 - 不进行超出当前任务范围的破坏性重构。

把这段提示词放在项目的AGENTS.md或者 AI 工具的 system prompt 里,效果非常明显。我最直观的体验是,AI 生成的 Pull Request 不再动辄引入三个类型错误,而且每次生成新逻辑都会主动带上测试文件——虽然有一部分测试写得还是不够好,但至少门槛已经立起来了。

6.3 把硬闸门交给 CI:类型检查加测试加 lint

提示词只能约束自觉,真正让规则不可绕过的工具是 CI。我在项目里把三个检查全部设为强制门禁:

# 第一步:类型检查 npx tsc --noEmit # 第二步:lint npx eslint . --ext .ts,.tsx # 第三步:测试 npx vitest run

任何一步失败,PR 都不能合并。这套组合拳从实际效果来看,比任何 code review 的嘴仗都管用。有一次 AI 生成的代码在 eslint 阶段就被拦下来了,原因是它在一个地方用了any。虽然后续人工 review 也会发现,但让它 CI 阶段直接失败,省掉的来回沟通时间非常可观。

如果你还想更进一步,可以把测试覆盖率也纳入门禁。我目前设置了核心模块的覆盖率红线——关键的边界逻辑必须达到 90% 以上的行覆盖率,其他模块不强制。这个红线的目的不是追求数字,而是防止 AI 在"通过测试"的压力下生成一堆空断言来凑数。

最后再分享一条我在项目里反复用来校准团队认知的经验:**t3code 不是阻碍你写代码的流程,它是给混乱的 AI 时代立的一根护栏。**Type 让数据不再胡说,Test 让行为不再失控,Trace 让故障不再隐身。哪怕你只先捡起其中一条,把它和 AI 搭档的协作配合起来,效果也会给你明显的反馈——至少下一次线上故障来临时,你不会再像我开头那次一样,对着一个undefined干瞪眼半小时了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进