过去半年,Claude Sonnet 5、GPT-4.1、Gemini 2.5 Pro 等模型在 SWE-bench 和 Terminal-Bench 上的成绩一路飙升,Cursor、Claude Code 等工具也让"AI 写 Flutter 代码"从玩具变成了生产力。但几乎所有深度使用者都会遇到同一个问题:AI 生成的代码越来越强,也越来越容易"跑偏" ------你让它改一个 ListTile,它顺手重构了整个 LoginPage;你让它修一个 bug,它自作主张帮你换了状态管理方案。
这不是模型变笨了,而是约束没跟上能力的增长。本文结合 Cursor + Sonnet 5 + Flutter 的实际场景,拆解跑偏的根因,并给出一套经过验证的防跑偏体系。
一、跑偏的四大根因
| 类型 | 典型表现 | 根本原因 |
|---|---|---|
| 范围漂移 | 改 A 文件时顺手改了 B/C/D,甚至重构了无关模块 | 指令边界模糊 + Sonnet 5 的 agentic 倾向(微软实测 5 比 4.6 更爱"顺手优化") |
| 范式摇摆 | 一会儿用 Riverpod,一会儿用 Bloc;find.text 和 find.byKey 混用 | 项目约定没有锚定在 AI 可见的位置,或 rules 太长模型没读完 |
| 上下文中毒 | 第 10 轮以后,模型开始忘记"禁止重写整个 build",又整段重贴 | 长会话未及时 compact/新开,早期约束被后续对话稀释 |
| 自检幻觉 | 模型声称"跑了一下 test 全部通过",实际上没跑或跑错了文件 | Sonnet 5 的 agentic 自检 + 终端回包噪声,导致它误以为执行成功 |
这四个根因在 Flutter 项目中尤其突出------widget 嵌套深、build 方法长、状态管理方案多、生成文件(.g.dart)与手写文件混杂,模型稍不留神就会越界。
二、防跑偏体系:从硬约束到软习惯
1. 用 AGENTS.md + .cursor/rules/*.mdc 双层锚定范式
很多开发者把几十条规则塞进一个 .cursorrules 文件,结果 alwaysApply 太长,Sonnet 5 的新 tokenizer 吃掉 30% 容量后模型根本没读完。正确的做法是分层锚定:
第一层:AGENTS.md(项目根,不超过 50 行)
这个文件是所有 AI 工具(Cursor、Claude Code、Codex、Windsurf)的通用入口,放在项目根即可生效。只放不可妥协的红线:
markdown
# Flutter 项目范式(不可妥协)
- 状态管理:Riverpod(AsyncNotifier + freezed sealed)
- 禁止:Bloc / Provider / setState 混用
- Widget 查找:必须用 WidgetKeys 常量(`find.byKey(WidgetKeys.xxx)`),禁止 `find.text()`
- 代码生成:freezed / json_serializable / riverpod_generator,只改注解,*.g.dart 禁止编辑
- 改 UI 必须 @ 文件+行号,禁止"改登录页"这种模糊指令
- 禁止重写整个 build 方法,只改指定子树 ±5 行
第二层:.cursor/rules/*.mdc(多文件,按 globs 按需加载)
Cursor 0.45+ 推荐的多文件规则体系,用 globs 匹配不同文件类型,只有当前文件匹配的规则才会注入:
bash
.cursor/rules/
├── 000-core.mdc # alwaysApply: true,≤500 字,通用省 token
├── 010-effort.mdc # alwaysApply: true,控 Sonnet 5 thinking
├── 100-flutter-style.mdc # globs: **/*.dart,Flutter 编码约定
├── 101-state.mdc # globs: lib/**,状态管理范式
├── 102-test.mdc # globs: **/*_test.dart,测试范式
└── 103-assets.mdc # globs: pubspec.yaml,护 pubspec
关键:alwaysApply 的总字数一定要控制在 500 字以内(Sonnet 5 的新 tokenizer 会让相同文本多出 30% token),超出部分用 globs 拆到专用文件里。这样模型每轮都能读到核心红线,又不会被海量规则冲昏头脑。
2. 指令从"开放意图"改为"约束型 Prompt"
跑偏的第一诱因是指令太开放。你只说"帮我优化登录页",模型当然会按它的理解去"优化"------包括重构路由、调整主题、重写状态管理。正确的做法是在指令里画好边界:
| ❌ 跑偏高发 | ✅ 约束型 |
|---|---|
| "帮我把登录页的按钮改成圆角" | "@lib/pages/login_page.dart L78,把 ElevatedButton 的 shape 改成 RoundedRectangleBorder(borderRadius: 12),只改这一个 widget,不改 build 其余部分,不改 AuthBloc" |
| "给网络请求加错误处理" | "在 login notifier 的 signInWithPassword 里 catch DioException,emit LoginState.error(message),不新增 provider,不改 UI" |
| "跑一下测试" | "跑 flutter test test/features/login/,只修失败的 case,不新增 test,不碰 test/shared/ " |
三个要素缺一不可:改哪(@文件+L号)+ 改什么 + 不改什么。"不改什么"往往比"改什么"更重要------Sonnet 5 的 agentic 天性决定了你不画边界它就会越界。
3. 复杂任务先出 Plan,再执行
跨文件重构、新 feature、Router 结构调整这类任务,不要让模型直接写代码。在 Cursor Composer 或 Claude Code 里先让它:
"先出改动计划:列出需要修改的文件、每个文件的改动范围、涉及的验证命令。我确认后再执行。"
Plan 阶段你只需要盯三件事:
- 涉及文件是否真的需要改(模型常列 8 个文件,其实 3 个就够了,砍掉多余的)
- 有没有偷偷加"顺便优化"项("顺便抽一下 Theme""顺便加个 golden test"------全部砍掉)
- 验证命令是否正确 (
flutter test的范围、build_runner 是否需要跑)
一轮 Plan 通常只需 200-300 token,却能省掉后面 3-5 轮返工。Plan 不是浪费 token,是买保险。
4. 会话管理:一个会话一件事,及时 compact / 新开
跑偏的高发时段是第 10-20 轮------早期约束被后续对话稀释,模型开始"自由发挥"。
- 一个会话只干一件事:修登录、改 UI、写测试,三个会话分开
- 关注 context ring:Cursor 聊天框左侧的色环变黄就该开新 Chat 了,别硬撑
- 用好 Checkpoint 回滚:Composer 改歪了直接回滚到上一个 checkpoint,不要在错误基础上追问------追问只会叠 token 和叠跑偏
- /compact 带引导:在 Claude Code 里 compact 时加一句"保留 WidgetKeys 约定和 Riverpod 范式,丢弃具体改了哪几行",否则 compact 后红线也可能被压丢
5. Flutter 专属的两个防跑偏钩子
WidgetKeys 常量化
Flutter 测试和 UI 中最常见的跑偏是模型在 find.text('登录') 和 find.byKey(WidgetKeys.loginSubmit) 之间反复横跳。解决方案是在 lib/core/keys/ 下统一维护 key 常量:
vbnet
abstract class LoginKeys {
static const emailField = Key('login_email_field');
static const passwordField = Key('login_password_field');
static const submit = Key('login_submit');
}
然后在 100-flutter-style.mdc 里写死:"新增交互元素必须加 WidgetKeys,改 existing 的 find 必须用 WidgetKeys.xxx"。模型一旦看到常量引用,就不再敢擅自改成 find.text。
build_runner 护城河
Flutter 项目中模型手搓 .g.dart 是灾难------它觉得"帮你省一步",结果手写的 generated code 和注解对不上,编译失败。在 AGENTS.md 里写死:
"改 model 只改注解,改完提示开发者跑
flutter pub run build_runner build --delete-conflicting-outputs,禁止手写 *.g.dart"
同时 .cursorignore 里把 *.g.dart、*.freezed.dart、*.config.dart 全部排除,让模型连读都读不到这些文件,从根本上杜绝手搓。
三、跑偏的早期信号:第一轮就掐掉
别等到第 5 轮才发现方向错了。模型第一轮回复中出现以下任何一条,立即打断并重给指令:
- 开始复述你的需求("好的,我来帮你优化登录页...")→ 它在寒暄,大概率接下来要越权
- 开始改你没有 @ 的文件 → 范围漂移,立刻喊停:"只改 @ 的这个文件"
- 开始写你没要求的 test / docs / README → agentic 自检溢出,检查 rules 里"写完不必主动跑 test"那条是否生效
- 开始重构不相关代码("顺便把 Theme 也抽一下")→ Sonnet 5 经典病,指令里缺"不改 UI 其余部分"
- 回复中 thinking_blocks 很长但结论飘 → L1 任务不该触发 thinking,检查 010-effort.mdc 是否压住了
四、总结
AI 驱动的 Flutter 工程,核心矛盾不是"模型能不能写",而是 "模型能不能只写你让它写的" 。防跑偏不是靠"更聪明的模型",而是靠一套可执行的约束体系:
| 层级 | 工具 | 作用 |
|---|---|---|
| 红线锚定 | AGENTS.md(<50 行) |
跨工具通用,不可妥协 |
| 细则分层 | .cursor/rules/*.mdc(globs 按需加载) |
按文件类型注入,alwaysApply ≤500 字 |
| 指令设计 | 约束型 prompt(改哪 + 改什么 + 不改什么) | 给模型画边界 |
| 任务流程 | Plan → 确认 → 执行 | 复杂任务先对齐 |
| 会话卫生 | 单会话单事 + 及时 compact/新开 | 防止约束稀释 |
| Flutter 特化 | WidgetKeys 常量 + build_runner 护城河 | 堵住两个最高频的跑偏点 |
这套体系已经在多个 Flutter 生产项目中验证:同样的 Cursor Pro $20 额度,配之前半个月见底,配之后撑满一个月,且代码质量明显提升------不是因为模型变强了,而是因为它终于知道哪些事不该做。
AI 编码的未来不是"模型全能",而是 "模型在你画的圈子里全能" 。圈子画好了,它才不会跑偏。