最近我让 Claude Code 直接给 Flutter 项目写一个“消息通知中心”模块,功能要求列得很细:列表展示、下拉刷新、未读角标、点击已读、本地缓存。它几分钟就把代码跑通了,我也觉得挺顺利。结果 PR 一发出去,同事只回了三个字:“重写吧。”
问题不在功能,而在代码的“气质”跟整个项目完全不是一回事。全局单例满天飞、Repository 在 View 里直接 new、一个页面同时混了 Provider 和 setState、文件命名半折半驼。功能是对的,但它不像我们项目的代码——评审根本没法过。
这事让我重新想了很久:AI 写代码翻车,真的不能怪 AI 能力不够,是我没把“规矩”交给它。后来我花了一个周末,把项目里的工程规范整理成 AI 可以读取、执行的规则文件,同时用 Claude Code 和 Codex 两套工具协作,跑了两周,AI 生成的代码质量基本拉齐,PR 评审意见也从“重写”变成了“小改”。
这篇文章就是把整套方法拆开讲清楚:AI 为什么会翻车、工程规范体系怎么设计、Claude Code 和 Codex 怎么配置、实际案例长什么样、以及我踩过的坑。适合所有在用 AI 辅助写 Flutter 的开发者,哪怕你之前没用过这两个工具,照着这篇文章配置也能看到明显效果。
1. AI 写代码“翻车”的根因:不是能力问题,是没立规矩
1.1 先看两个典型的翻车现场
我见过的 AI 生成代码问题大致能分四类。
第一类是架构型翻车。AI 把新页面直接丢到lib/pages下面,根本不理你项目里 feature-first 的目录结构。你不说它不知道,但它也不主动去翻你已有的目录去“学习”风格。
第二类是风格型翻车。命名不一致就算了,最典型的是喜欢写全局静态方法、随手 new 依赖、跳过错误处理。比如下面这种代码,在 AI 生成结果里太常见了:
class Api { static Future<dynamic> fetchData() async { final client = Dio(); final res = await client.get('https://xxx'); return res.data; } }这段代码本身没病,但它完全不符合我们项目的规矩。它的特点就是“大众写法”,网上搜教程基本全是这种。可是真实项目里,网络层需要统一超时、鉴权、日志拦截,数据要包成Result<T, Failure>,Repository 要抽象、要注入、要可 mock。AI 不知道这些。
第三类是副作用型翻车。它给你加功能的同时,顺手改了main.dart、给pubspec.yaml加了一个版本极随意的依赖,或者把另一个文件的格式重新撸了一遍。这种“顺手优化”在 PR 里是最头疼的。
第四类是质量型翻车。功能代码写得很快,但测试文件完全不生成,或者异常捕获之后打一行日志就完了。这种代码过 CI 没问题,一上线就炸。
1.2 根因:模型只学会了“大众写法”
为什么 AI 会这样?说到底,大模型是在海量公共代码上训练的。它见过的 Flutter 代码大多来自教程、Demo、开源小项目,这些项目的特点是简化、直白、不考虑团队协作。所以当你只说一句“写个页面”,模型会按它见过的“最大公约数”来生成代码,而不是按你公司内部的隐性约定。
这就像一位新同事能力很强,但你没给他看团队文档模板,他第一天就按自己习惯的排版交了一份文档。你不会觉得他能力不行,你只会觉得他不懂规矩。AI 也一样,它不缺能力,缺的是“项目上下文”。
而项目上下文这种东西,散落在你的大脑里、代码评审意见里、团队 Wiki 里。如果不主动提取出来交给 AI,那翻车真的就是必然。
1.3 核心解法:把工程规范变成 AI 的“上岗培训”
所以正确思路不是每次写提示词时都手打一遍规则,也不是“少用 AI”,而是把工程规范前置成 AI 的输入条件。
具体做法是建立一套规则文件,让 Claude Code 和 Codex 在启动任务前自动读取。这套文件相当于给 AI 做“上岗培训”:把项目的架构、命名、状态管理方案、禁止事项一次性讲清楚。AI 动手前先理解规则,生成的代码自然就会收敛到项目风格里。
这个思路跟传统工程文档的差别在于:传统文档是写给人看的,夹叙夹议、有背景介绍、有取舍讨论;而给 AI 看的规则文件必须是可以直接执行的“约束集合”。后面我会详细讲怎么写,关键是要让它“可验证、可执行、无歧义”。
2. 为什么我会选 Claude Code + Codex 组合
2.1 Claude Code:适合当“大局控”
Claude Code 是我主力用的命令行编程智能体。它最擅长的是长任务和多文件操作。你给它一个完整目标,比如“在 xxx feature 下实现一套完整的离线缓存机制”,它会自己去翻代码、理解现有依赖、设计接口、生成实现,再帮你跑测试和看 diff。
它处理复杂上下文的能力比较强,能在一次会话里同时记住目录结构、状态管理约定、错误处理风格等多条规则。这对做架构型任务特别重要。
代价是它相对更重、更慢,单次消耗的上下文也更贵。不是所有小任务都值得启动它,尤其是那种改一个方法签名、补一个测试用例的小事。
2.2 Codex:适合当“轻量快手”
Codex 更像是编辑器里随叫随到的助手,响应快,特别适合写单个文件、修 bug、做代码补全、快速答疑。比如我写代码时突然忘了某个 Riverpod API 的准确写法,或者想快速把一段逻辑抽成一个方法,直接叫它处理就很顺手。
但它也有短板:长任务做到后半程,容易丢前面的上下文;跨多个文件做大规模重构时,稳定性不如 Claude Code。所以我对它的定位是“流水线上的快手”,不是“总架构师”。
2.3 组合打法:大活用 Claude Code,小活用 Codex
我实践的协作模式大概是下面这组分工:
| 场景 | 推荐工具 | 原因 |
|---|---|---|
| 新功能模块、多文件创建 | Claude Code | 长上下文,能整体规划 |
| 跨层重构、依赖调整 | Claude Code | 需要全局视角 |
| 单个方法修改、补测试 | Codex | 响应快,改动小 |
| 编辑中代码补全 | Codex | 延迟低,集成体验好 |
| 复杂 UI 页面 | Claude Code + 人工复查 | UI 细节多,AI 容易失控 |
两个工具需要共享同一套规则,否则行为就会不一致。这也是我为什么在文件设计上把规范主文件统一成一个,工具各自只保留少量补充配置。这一点是后面所有配置的核心。
3. 规范体系怎么设计:给 AI 立规矩的四层法
3.1 第一层:项目级通用底线
第一层规则面向“任何任务”,它是不可商量的底线。内容包括:
- 所有 Dart 代码必须通过
dart format,不允许提交格式混乱的代码 - 只允许修改与当前任务直接相关的文件,禁止顺手改无关文件
- 新增功能必须附带对应测试文件
- 禁止提交注释掉的死代码、临时调试输出、无意义 TODO
- 禁止修改
pubspec.yaml里的依赖版本而不说明理由
为什么要先立“底线”?因为 AI 默认是“讨好的”,它希望尽可能帮你完成得全面,所以经常顺手多做,而这些“顺手”恰恰是代码评审里最让人血压升高的地方。底线规则要放在规则文件最前面,用“禁止”“必须”这些强约束词。
3.2 第二层:Flutter 架构规则
第二层是项目架构层面的规则,这层直接决定 AI 生成的代码“长在什么骨架里”。
拿我现在这个 Flutter 项目举例,核心规则是:
- 目录结构:feature-first,
lib/features/<feature>/按业务功能划分,每个功能内部再分data/、domain/、presentation/三个子目录。 - 依赖方向:严格单向,
presentation -> domain -> data。UI 层只能依赖 domain 抽象,不能直接碰 data 实现。 - 状态管理:统一使用 Riverpod。禁止在 Widget 内直接创建状态对象,禁止用全局单例配合 setState 的全家桶模式。
- 网络层:所有 HTTP 请求必须通过
lib/core/network/api_client.dart里封装的 ApiClient 发出。禁止直接创建 Dio 实例,禁止绕开统一拦截器。 - 数据模型:DTO 使用
freezed+json_serializable生成,禁止手写容易出错的fromJson解析逻辑。 - 路由:统一注册到
go_router的 RouterConfig 中,禁止在页面里用Navigator.push硬编码路由名。
每一条都要讲清楚“为什么”。比如路由为什么必须统一走go_router?因为我们需要 deep link、需要路由守卫、需要嵌套导航,这些不是Navigator.push能简单覆盖的。AI 不会自动理解这些取舍,它只会选择一个最通用的方案,而通用方案通常不符合我们这类已经成熟的项目。
3.3 第三层:模块级契约
第三层针对具体模块的契约。不同 feature 的细节千差万别,不可能全塞进全局规则。我建议每个 feature 目录下单独放一份AGENTS.md,描述这个模块特有的约定。
拿消息通知模块来说,我会写这些契约:
data/里的 Repository 实现必须依赖 ApiClient,不能自己发 HTTPdomain/里的 Entity 必须是不可变对象domain/必须包含 Repository 的抽象接口和核心 UseCasepresentation/的页面只负责组装,业务逻辑全部下沉到 Controller/Notifier- 页面里的单个 Widget 的
build方法超过 60 行必须拆子组件
模块级契约的好处是:AI 任务会明确落在某个 feature 内,它读到的上下文里直接包含这个模块的约定,生成就非常聚焦。
3.4 第四层:负面清单
第四层是“禁区列表”,专门列 AI 最容易犯、且破坏力最大的行为。我的规矩是:
- 禁止在 domain 层 import Flutter 相关库
- 禁止在 View 里 new Repository、new Controller
- 禁止以全局单例替代依赖注入
- 禁止 catch 到异常后只打日志不处理
- 禁止硬编码文案到界面,必须走本地化资源
- 禁止在
build方法里做耗时操作或网络请求
负面清单放在规则文件的末尾,作为强约束出现。这比在正文里反复强调“请遵循分层架构”要有效得多。AI 对“禁止”这个词的遵循程度,通常好过对“请尽量”“建议”这类软性表述。
3.5 规则写多细合适?一个平衡原则
给 AI 看的规范不是越细越好。规则文件要进模型上下文,你每写一行它都占 Token,写太长不仅贵,还会分散模型注意力。
我的经验是“约束最小集合”:
- 每条规则都必须可以直接检查,能落到
git diff或代码审查里 - 用代码示例代替描述性文字,比如直接给一段 Repository 的规范写法
- 超过 200 行的规则文件就该砍。真正重要的约束,往往不超过 30 条
- 介于“给人看”和“给 AI 看”之间的大文档别塞进 AGENTS.md,放
docs/engineering-guide.md
4. 实操:把工程规范部署到 Claude Code 和 Codex
4.1 建立规则文件结构
我的项目根目录下最终是这么组织的:
项目根/ ├── AGENTS.md ├── CLAUDE.md ├── docs/ │ └── engineering-guide.md └── lib/ ├── core/ └── features/.md文件各有分工:
AGENTS.md是主规则源,Codex 原生支持读取,Claude Code 也兼容这个标准。放在根目录,两个工具都会主动加载。CLAUDE.md是 Claude Code 的补充文件,放一些只有在长会话里才需要强调的内容,比如“如果任务涉及跨模块改动,先输出改动计划再动手”。docs/engineering-guide.md是给人读的完整工程文档,包含背景讨论、取舍原因、历史决策。AI 默认不读这个,只是在规则里提示它需要时可查阅。
这样设计的原因很简单:规则只保留强制执行的约束,人读文档保留解释细节。避免把整个工程文档压进上下文。
4.2 AGENTS.md 的内容模板
下面这份是我在实际项目里精简后的AGENTS.md模板,你可以直接拿来改:
# AGENTS.md - Flutter 项目 AI 编程规范 ## 项目概览 MVP 阶段的电商类 Flutter 应用,目标平台 Android / iOS。当前架构采用 feature-first 分层模式,禁止把新增代码堆进 lib/pages 或 lib/models。 ## 目录结构 - lib/features/<feature>/ 每个业务功能独立目录 - data/ 数据源、Repository 实现、DTO - domain/ Entity、Repository 抽象、UseCase - presentation/ 页面、Widget、Controller/Notifier - lib/core/ 全局基础能力 - network/ 统一网络客户端 - theme/ 主题与样式 - utils/ 纯函数工具 ## 依赖方向 严格的单向依赖:presentation -> domain -> data。 - 禁止 domain 层依赖 Flutter SDK - 禁止 presentation 层直接创建 Repository 实现 - Repository 抽象放 domain,实现放 data ## 状态管理 - 统一使用 Riverpod - 页面状态使用 NotifierProvider / AsyncNotifierProvider - 禁止在 Widget 内 new 状态对象,禁止全局单例 + setState 模式 - 异步数据使用 AsyncValue,禁止在 build 方法里直接触发网络请求 ## 网络层 - 必须通过 lib/core/network/api_client.dart 的 ApiClient 发送请求 - 禁止直接创建 Dio 实例 - 所有请求必须返回 Result<T, Failure>,禁止裸抛异常 ## 数据模型 - Entity 使用手写不可变类,重写 == 和 hashCode - DTO 使用 freezed + json_serializable,禁止手写 fromJson - DTO 与 Entity 之间的转换写在 data 层映射器里 ## 命名规范 - 文件:snake_case.dart - 类名:PascalCase - 方法、变量:camelCase - 页面组件统一以 Page 结尾,列表项组件以 Item 结尾 ## 测试 - Repository 必须提供 Fake 实现 - 页面至少有一个 Widget Test - 核心业务逻辑必须有 Unit Test ## 禁止事项 - 禁止在 domain 层 import Flutter - 禁止修改 pubspec.yaml 而不说明理由 - 禁止在 PR 中提交与任务无关的文件 - 禁止 catch 后只打日志不处理错误 - 禁止硬编码界面文案这份模板每条都尽量可检查。比如“禁止直接创建 Dio 实例”,在 code review 里搜Dio()就能查;“页面组件以 Page 结尾”,看文件命名就能核对。
4.3 接入 Claude Code
Claude Code 会在会话启动时主动读取项目根目录下的规则文件。我这里的做法是:
- 把精简版规则直接放进
CLAUDE.md,第一行就写明:“动手前必须阅读 AGENTS.md 并遵守其中全部约定。” - 对于需要跨模块的大型任务,我会在
CLAUDE.md里额外要求它:“先输出改动计划和影响文件清单,再开始写代码。” - 如果用不到
CLAUDE.md也可以直接复用根目录的AGENTS.md,Claude Code 同样支持。
操作顺序上,我通常会新建会话,先提出任务,紧接着补一句:
先读项目根目录的 AGENTS.md,理解目录结构、状态管理选型和禁止事项, 然后按这个规范实现任务。这一句不是多余,它是在强化规则文件的存在感。只要输入任务里带上这个指令,AI 就会把规则文件放到更高优先级。
4.4 接入 Codex
Codex 对AGENTS.md的支持比较标准,你把规范文件放在项目根目录,启动会话时它就会把它当作项目规则。
我常用的方式有两种。一种是在 Codex 对话里直接说“按照 AGENTS.md 的约定修改 xxx”,它会主动去读文件。另一种是在 IDE 里用 Codex 时,向它输入问题前先带上指令“请先查看项目根目录的 AGENTS.md”。
如果你担心它没读,可以直接在对话里引用文件内容,或要求它先复述一遍规则:“先用 50 字概括 AGENTS.md 里的目录结构要求。”它答对了,就说明规则真正进入了上下文;答不对,说明加载路径有问题。
4.5 验证规范有没有真正生效
配置完之后,一定要验证一次。我的三步验证法:
第一步,问它一个项目认知问题:“这个项目里,状态管理统一用什么?网络请求必须走哪个类?”AI 能答出来,说明规则已经加载。
第二步,让它实现一个最小例子,例如“在 feature 下新建一个 Repository 骨架”。然后肉眼检查它生成的内容里,data目录有没有出现、Repository 有没有注入 ApiClient、命名是否符合规则、有没有新增测试。
第三步,跑git diff --stat。看它到底改了几个文件,是不是只有任务要求的文件。如果多了pubspec.yaml、main.dart这类不相关文件,规则就没有完全生效。
这套验证流程每次更换工具版本、调整规则文件之后都要重新走一遍。规则文件改了之后不是自动保证生效,工具版本的读取逻辑可能会有变化。
5. 实测记录:一个消息通知模块的“前后对比”
5.1 任务描述
我拿一个具体任务来演示规范的效果。
任务原文:
在 lib/features/notification 下实现消息通知列表页,支持下拉刷新、 加载更多、未读标记、点击已读。数据从远端接口获取,使用 Riverpod 管理状态,所有网络请求走 ApiClient,需要补 Repository 和页面测试。任务里已经指定了 feature、Riverpod、ApiClient,但“怎么组织内部目录”“Repository 抽象怎么写”“DTO 和 Entity 怎么转换”这些细节,仍然要由规则文件来补充。
5.2 没有规范时 AI 的“自由发挥”
我故意用一个没有规则文件的空项目目录跑同一任务,结果如下:
- 页面文件直接生成在
lib/pages/NotificationScreen.dart,根本不进 feature 目录 NotificationController里直接final _dio = Dio(),绕过 ApiClient- 页面状态用
setState加上一个手写的ChangeNotifier,没有走 Riverpod - 没有 DTO,直接
json['data'] as List转实体 - 顺手改了
main.dart,加了一个旧的MaterialPageRoute - 完全没生成测试文件
其中最有代表性的是这段:
class NotificationController extends ChangeNotifier { List<NotificationItem> items = []; final _dio = Dio(); Future<void> fetch() async { final res = await _dio.get('https://api.example.com/notifications'); items = (res.data as List) .map((e) => NotificationItem(...)) .toList(); notifyListeners(); } }功能逻辑没问题,但换到真实项目里,这个实现基本推倒重来。
5.3 有规范后 AI 的“受控输出”
同一任务,在完整AGENTS.md的约束下,AI 生成的目录是这样的:
lib/features/notification/ ├── data/ │ ├── notification_repository_impl.dart │ ├── notification_dto.dart │ └── notification_mapper.dart ├── domain/ │ ├── notification_entity.dart │ ├── notification_repository.dart │ └── fetch_notifications_use_case.dart └── presentation/ ├── notification_page.dart ├── notification_controller.dart └── widgets/ └── notification_list_item.dartcontroller 部分也自动收敛成了 Riverpod 的写法:
final notificationControllerProvider = NotifierProvider<NotificationController, AsyncValue<List<NotificationEntity>>>( NotificationController.new, ); class NotificationController extends Notifier<AsyncValue<List<NotificationEntity>>> { @override AsyncValue<List<NotificationEntity>> build() { return const AsyncValue.data([]); } Future<void> fetch() async { state = const AsyncValue.loading(); final repository = ref.read(notificationRepositoryProvider); state = await AsyncValue.guard(repository.fetchNotifications); } }Repository 实现里没有出现Dio(),它直接接收 ApiClient 参数并返回统一封装的Result<List<NotificationEntity>>。测试文件也随之生成:Repository 用 Fake 实现,页面有 Widget Test。整个 diff 里没有改动任何无关文件。
5.4 两者的差异到底有多大
| 维度 | 没有规范 | 有规范 |
|---|---|---|
| 目录归属 | lib/pages 下,游离在架构外 | feature 内 data/domain/presentation 三层 |
| 网络层 | 新建 Dio 实例 | 统一 ApiClient 注入 |
| 状态管理 | ChangeNotifier + setState | Riverpod NotifierProvider |
| 数据转换 | 手写 json 转实体 | DTO + Mapper,结构清晰 |
| 测试 | 无 | Fake Repository + Widget Test |
| 全局影响 | 改了 main.dart | 零副作用 |
两轮生成相差不到 10 分钟,唯一区别就是第一轮没有规则文件,第二轮有。可见“规则前置”对产出质量的影响,比我预想得大得多。
6. 常见问题与避坑技巧
6.1 AI 不遵守规则,先自查三个地方
发现 AI 没按规范生成时,先别急着骂它。按这个顺序排查:
第一,确认规则文件是否真的加载了。直接在对话里问:“这个项目状态管理用什么?”要是答错,说明文件没进上下文,先解决加载问题。
第二,确认规则是不是太笼统。写“请保持高内聚低耦合”,AI 大概率不会纠正自己的做法。但如果写“页面 build 方法超过 60 行必须拆子组件”,这就是可执行的硬约束。
第三,确认任务描述里有没有冲突指令。比如规则里写着统一 Riverpod,但任务里说“参考 xxx 页面用 ChangeNotifier”,那 AI 会无所适从。任务指令与规则冲突时,AI 往往会优先听最新的指令。
6.2 规则文件太长,上下文被撑爆
规则文件一长,每次对话都会消耗大量 Token,成本涨得快,而且模型抓不住重点。
我的解法是分档:
AGENTS.md只放 30 条以内的核心约束,尽量用短句- 详细解释放到
docs/engineering-guide.md,AI 用不到时不会去读 - 对于大而全的规则,按模块拆分,只让对应模块的 AI 读对应部分
- 定期重读规则文件,删掉那些“根本无法检查”的口水话
6.3 Claude Code 和 Codex 行为不一致
两个工具对规则文件的理解和执行风格会有差异,这是正常的。比如 Codex 更依赖 AGENTS.md 明确指令,Claude Code 对自然语言的理解更强,但在长任务里也可能“创造性地偏离规则”。
我的方法是:
- 主规则统一放在
AGENTS.md,谁先读都不冲突 - Claude Code 的额外补充放
CLAUDE.md,不让它干扰 Codex - 在验证阶段,同一个任务分别让两个工具各跑一次,对比差异点
- 如果发现某条规则只在单工具上生效,就把它写得更“无脑”一些,比如加代码示例
6.4 UI 页面是最容易失控的重灾区
AI 生成 UI 的时候,问题不是不会写,而是容易“过度堆叠”。一个页面能把 Row、Column、Stack、Padding、Container 全部叠满,几千行的列表项能写得让人崩溃。
我给它定了几条硬规则:
- 单个 Widget 的
build方法不超过 60 行,超过就拆子组件 - 复杂页面必须先描述“组件树”,让 AI 告诉我们它打算怎么拆,再生成代码
- 列表的 item 必须独立成
Widget,不能写成itemBuilder里的内联代码块 - 任何 UI 代码都要遵循主题系统,禁止硬编码颜色、字号、间距
这条经验特别适用,因为 UI 一旦失控,后面维护成本极高。规范里提前锁死组件粒度,比事后 review 改来改去省事得多。
6.5 老代码与新规范并存,怎么渐进落地
团队项目通常有一堆历史代码,规范和现状不一致。如果规则里写着“所有代码必须符合新规范”,AI 在遇到老代码时会陷入两难:改老代码容易引发回归,不改又不符合规则。
我的处理方式是:
- 在规则里明确写“新增代码必须符合本规范,旧代码不主动重构”
- 对于旧文件,AI 只做最小改动,不顺手调整格式
- 如果有大规模老代码改造需求,单独开一个重构任务,并明确允许 AI 扩大影响面
- 让 AI 在 PR 描述里标注“本次改动是新增还是重构”,方便评审人判断
这套渐进式策略用了之后,AI 不会再因为“觉得旧代码不规范”而擅自重写整个文件了,PR 的可读性提升非常明显。
我个人在最开始建规则文件的时候,一直拿不准要不要用 AI 工具来自动生成这些规范,后来试了一圈还是决定手写。原因很简单:规范这东西的本质是记录团队的真实取舍,你只能自己梳理,AI 无法替你做决策,但它可以帮你检查遗漏。
关于这套方案,我最后再分享一个小技巧:让 AI 每完成一个任务,在回复末尾用 2 到 3 句话自检一遍——改了哪些文件、有没有违反禁止事项、测试跑没跑过。这个自检机制比任何外置 lint 都管用,因为 AI 在反思环节会从自己的输出里挑毛病,而且通常能自己修掉大部分问题。工具会一直升级,规则文件也会改版,但这个“让 AI 先自检”的习惯我从没变过。如果你现在还在为 AI 生成的代码闹心,不妨从一张 30 行的规范文件开始试起,两天之内你就能看到差别。