1. 引言:纯对话式编程终将失控,AI 编码需要工程化的约束
AI 编程最令人困扰的并非它无法生成代码,而是它缺乏稳定可靠的工程边界。今天它还记得用 intl.get() 处理国际化,明天可能就直接嵌入中文硬编码;上一轮还能规规矩矩地拆分组件,下一轮就把页面、状态、事件和样式全部塞进一个 index.tsx 里。更糟糕的是,你让它修复一个 bug,它顺手就把周围代码的风格一并改了。

单独来看,每个问题都不算致命,但累积起来会持续消耗你在 Code Review 上的精力。原本 Review 应该聚焦在业务逻辑、交互边界和异常处理上,结果却变成了替 AI 擦屁股:检查有没有 any 类型逃逸、有没有硬编码的中文、列表的 key 是否用了 index、有没有乱改配置文件。
在我们的项目中,恰恰遇到了同样的困境:仅仅停留在对话编程层面,缺乏一个强制性的机制来约束 AI 行为。
Prompt 可以提醒 AI,但提醒不等于约束。README 里写得再清楚,AI 也不一定会每次去读。真正需要的是一套工程化的 Harness:在 AI 编辑代码的关键动作之前,插入一道检查关卡,把“建议”变成“机制”。
这套体系的核心可以浓缩为一句话:
2. 原理速览:有 AI 规范驱动与无约束的差异
2.1 没有 Harness:AI 直接冲进代码库
没有 Harness 时,AI 的工作流短得可怜:用户提出需求,AI 直接上手编辑文件,然后进入 Review 环节。
sequenceDiagramparticipant User as 开发者participant AI as AI 编码助手participant Repo as 代码仓库participant Review as Code ReviewUser->>AI: 实现一个 TODOLIST 页面AI->>Repo: 直接编辑 index.tsxAI->>Repo: 顺手补样式和状态逻辑AI->>Review: 提交结果Review-->>User: 发现 any、硬编码中文、key=index、组件过重User->>AI: 继续返工
这个流程的问题不在于 AI 不够聪明,而是中间缺少任何强制性的检查点。AI 是否读取了规范、是否遵守了 i18n 规则、有没有限制修改范围,完全依赖模型当时的上下文和运气。
2.2 有 Harness:关键动作前先过闸
AgentFlow Web 的 Harness 基于 Claude Code Hooks 实现。核心是两个 Hook:
PreToolUse:在 AI 调用 Edit / Write 前执行PostToolUse:在 AI 调用 Skill 后执行
sequenceDiagramparticipant User as 开发者participant AI as AI 编码助手participant Hook as PreToolUse Hookparticipant Skill as Skill 规范participant State as Session Stateparticipant Repo as 代码仓库participant Snapshot as Snapshot / AuditUser->>AI: 实现一个 TODOLIST 页面AI->>Hook: 请求编辑 .tsx 文件Hook->>Hook: 匹配文件规则Hook->>State: 检查 coding-standards 是否已加载State-->>Hook: 未加载Hook-->>AI: 阻断,提示先加载 coding-standardsAI->>Skill: 加载 coding-standardsAI->>Hook: 再次请求编辑Hook->>Snapshot: 保存原文件快照 + 写审计日志Hook-->>AI: 放行AI->>Repo: 编辑文件
这个机制的关键点在于:AI 不是被禁止写代码,而是必须先带着项目规范写代码。
3. AI 规范驱动工程的设计与实现
我们的 Harness 不是单一脚本,而是一组互相配合的机制。
当前目录结构如下:
.claude/├── README.md# Harness 工程说明文档├── settings.json# Claude Code Hook 注册与权限配置├── hooks/│ ├── pre-edit-check.js# Edit/Write 前置检查:规则匹配、Skill Gate、快照、执行边界│ ├── post-skill-mark.js # Skill 调用后置处理:标记已加载、重置计数、i18n 提醒│ └── lib/│ ├── match-rules.js # 文件路径到 Skill 的映射规则│ ├── session-state.js # 会话状态、编辑计数、TTL 清理、审计日志│ └── snapshot.js# 文件修改前快照、列表、恢复、清理├── session-state/│ ├── .gitignore # 忽略运行期状态文件│ └── audit.log# 追加写入的审计日志├── snapshots/│ └── .gitignore # 忽略运行期快照文件└── skills/├── coding-standards/# 编码规范:React、状态、API、TypeScript、Monorepo├── i18n-coding/ # 国际化规范:intl.get、locale 文件、common key 复用├── ui-design/ # UI 规范:design token、页面骨架、组件模式├── code-review/ # 代码审查编排:按文件类型分发 checklist
3.1 先定义 AI 能在哪些地方动手
不是所有文件都会被拦截。测试文件、类型声明、第三方依赖,甚至是 Harness 自身的文件,都不应该触发规范检查。所以第一层是动作空间注册表:只对关键业务文件启用强约束。
// .claude/hooks/lib/match-rules.jsconst RULES = [{name: 'React 组件',// 组件文件最容易出现 props、memo、i18n、样式等规范问题pattern: /.(tsx|jsx)$/i,skill: 'coding-standards',},{name: 'Zustand store',// store 影响跨页面状态,必须遵守状态拆分和订阅规范pattern: /[/]src[/]store[/].*.ts$/i,skill: 'coding-standards',},{name: 'API 层',// API 函数需要统一命名、泛型和错误处理方式pattern: /[/]src[/]api[/].*.ts$/i,skill: 'coding-standards',},{name: '自定义 Hook',// Hook 需要遵守依赖数组、命名、请求封装等规则pattern: /[/](src[/]hooks|src[/]pages[/][^/]+[/]hooks)[/].*.ts$/i,skill: 'coding-standards',},];
这里的设计边界很明确:src/utils/ 没有被纳入规则。工具函数通常是纯逻辑,不涉及 React 生命周期、组件设计和 i18n。Harness 不应该为了“看起来严格”而随意扩大拦截范围,否则会让 AI 和开发者都感到被打扰。
3.2 没有加载规范,不允许编辑
第二层是 Skill Gate。一旦命中文件规则,Hook 就会检查当前 session 是否已经加载过 coding-standards。如果没有,直接阻断。
// .claude/hooks/pre-edit-check.jsif (!isSkillLoaded(sessionId, rule.skill)) {audit({session: sessionId,tool: toolName,file: filePath,rule: rule.name,skill: rule.skill,action: 'blocked',});// exit 2 是 Claude Code Hook 的阻断信号// stderr 会反馈给 AI,指导它下一步该加载哪个 Skillprocess.stderr.write(`请先执行: Skill(skill="${rule.skill}")`);process.exit(2);}
这个设计的核心意图很明确:把“请遵守规范”从提示词变成执行前置条件。它的适用边界也很清晰:只拦截 Edit 和 Write,不拦截 Read、Grep 或 Glob。AI 可以自由阅读代码和规范,但真正动手前,必须过闸。
3.3 防止 AI 陷入循环修改
AI 有一种典型的失控模式:在同一个文件上改来改去,越改越偏。为此,我们加入了执行边界。
| 指标 | 当前值 | 行为 |
|---|---|---|
| 单 session 编辑次数 | 15 次/openspec 工程40次 | 超过后阻断 |
| 同文件编辑次数 | 5 次 | 警告,不阻断 |
| session TTL | 7 天 | 自动清理过期状态 |
// .claude/hooks/lib/session-state.jsconst DEFAULT_EDIT_LIMIT = 15;
function incrementEditCount(sessionId) { const state = loadState(sessionId); // 每次 Edit / Write 放行前计数 +1 state.editCount = (state.editCount || 0) + 1; sa veState(sessionId, state); return state.editCount; }
为什么是 15 次?这是一个经验阈值,不是数学真理。判断依据是:一个明确的小任务如果需要超过 15 次编辑,通常意味着需求理解、类型设计或者上下文已经出现了偏差。这时候,与其让 AI 继续自动修改,不如停下来重新确认方向。
3.4 让 AI 行为可追踪
每次放行、阻断、加载 Skill,都会追加一条审计日志。
{
"ts": "2026-06-12T10:30:00.000Z",
"tool": "Edit",
"file": "src/pages/todo/index.tsx",
"action": "pass-skill-loaded"
}
审计日志的边界也需要说清楚:它不替代 Git 历史,也不记录文件内容,只记录操作的元信息。它能很好地回答这些问题:AI 是什么时候开始改这个文件的?有没有被阻断过?是不是在未加载规范时就尝试编辑了?
3.5 给 AI 修改加上回滚保险
最后一层是快照。每次 Edit 或 Write 放行前,Harness 会把原文件内容保存到 .claude/snapshots/{session_id}/ 下。
// .claude/hooks/lib/snapshot.js
function sa veSnapshot(sessionId, filePath) {
if (!filePath || !fs.existsSync(filePath)) {
// 新建文件没有原始内容,不需要快照
return false;
}
const content = fs.readFileSync(filePath, 'utf8');
const snapshot = {
ts: new Date().toISOString(),
file: filePath,
content,
};
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
return true;
}
它的适用边界是“局部误改恢复”,不是替代 Git。如果 AI 把某个文件改坏了,快照能快速找回编辑前的状态;如果是跨分支、跨提交的大规模回滚,依然应该交给 Git。
4. 实战迁移:用 Harness 实现一个 TODOLIST 页面
为了验证这套 Harness 的实际效果,我们做了一次小实验:让 AI 实现同一个 TODOLIST 页面,一组模拟没有 Harness 的输出,另一组按 Harness 规范输出。实验产物保存在:
docs/harness-todolist-experiment/
├── no-harness/
└── with-harness/
任务要求完全一致:需要实现任务列表、新增、完成切换、删除、空状态、用户可见文本、样式和类型。
4.1 无 Harness:代码能跑,但 Review 压力转嫁给人
没有 Harness 的版本,就是典型的“一把梭”实现:全部逻辑都塞进 index.tsx 里,状态、渲染、事件全在同一个组件中。
{/* 问题 2:用户可见中文直接硬编码 */}
{item.text}
// docs/harness-todolist-experiment/no-harness/index.tsx
export default function TodoPage() {
const [text, setText] = useState('');
// 问题 1:any[] 逃逸,后续 item 字段完全失去类型保护
const [todos, setTodos] = useState任务列表
setText(e.target.value)} />
{todos.map((item, index) => (
// 问题 3:列表 key 使用 index,删除/排序后可能导致渲染错位
这类代码的问题在于:它看起来完成了需求,但把所有的规范成本,都一股脑甩给了 Review。
4.2 有 Harness:先加载规范,再写代码
有 Harness 的时候,AI 第一次尝试写 .tsx 文件就会被拦截:
[harness] 该文件命中规则「React 组件」
要求:动手前必须先调用 Skill 工具加载 `coding-standards`。
如涉及国际化文案:Skill(skill="i18n-coding")
随后 AI 会加载:
coding-standards:组件设计、类型、状态、React 模式i18n-coding:用户可见文本国际化
最终输出会自然地拆分成多个文件:
with-harness/
├── types.ts
├── TodoItem.tsx
├── TodoList.tsx
├── index.tsx
└── index.less
核心代码如下:
// docs/harness-todolist-experiment/with-harness/types.ts
export interface TodoItem {
// 使用稳定 id,避免列表 key 使用 index
id: string;
title: string;
completed: boolean;
}
{items.map(item => (
// docs/harness-todolist-experiment/with-harness/TodoList.tsx
export interface TodoListProps {
// 只传子组件需要的数据,不传整个页面状态
items: TodoItemType[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export default function TodoList({ items, onToggle, onDelete }: TodoListProps) {
if (items.length === 0) {
// 用户可见文案统一走 intl.get
return
// docs/harness-todolist-experiment/with-harness/index.tsx
export default function TodoPage() {
const [inputValue, setInputValue] = useState('');
const [items, setItems] = useState
const handleAdd = () => { const title = inputValue.trim(); if (!title) return; // 使用函数式 setState,避免依赖旧闭包状态 setItems(prevItems => [ ...prevItems, { id: `${Date.now()}`, title, completed: false, }, ]); setInputValue(''); };
return (
{intl.get('todo.page.title')}
setInputValue(event.target.value)} />
样式也从硬编码色值切换为 token:
.page {
/* 使用 design token,避免散落的 #fff / #eee / #999 */
padding: var(--spacing-6);
background: var(--color-bg-container);
}
.completedTitle {
flex: 1;
color: var(--color-text-tertiary);
text-decoration: line-through;
}
5. AI 规范驱动带来的实际提升效果
这次实验的统计脚本对两组代码做了静态扫描,统计项包括文件数、tsx 组件数、代码行数、any、key={index}、intl.get、硬编码中文、硬编码色值和 Review 问题数。
5.1 实验结果对比
| 指标 | 无 Harness | 有 Harness | 变化 |
|---|---|---|---|
| 文件数 | 2 | 5 | 组件和类型拆分更清晰 |
| TSX 组件数 | 1 | 3 | 从单文件页面变成页面 + 列表 + 项 |
| 有效代码行数 | 60 | 124 | 代码更多,但职责边界更明确 |
any 次数 | 1 | 0 | 类型逃逸消除 |
key={index} 次数 | 1 | 0 | 列表渲染风险消除 |
intl.get 次数 | 0 | 5 | 用户可见文案全部国际化 |
| 硬编码中文数量 | 5 | 0 | i18n 漏项消除 |
| 硬编码色值数量 | 3 | 0 | 样式 token 化 |
| Review 问题数 | 6 | 0 | 基础规范问题前置消化 |
无 Harness 版本被统计出的 6 个 Review 问题是:
- 存在
any类型逃逸 - 列表 key 使用 index
- 存在硬编码中文文案
- 存在硬编码色值
- 未使用
intl.get - 组件拆分不足
5.2 如何理解这些数据
有 Harness 版本的代码行数更多,这完全在预期之中。工程规范的目标不是让代码更短,而是让代码更容易维护。types.ts、TodoList.tsx、TodoItem.tsx 的拆分增加了文件数量,却实实在在地降低了页面组件的认知负担。
真正值得关注的是,Review 问题从 6 个直接降到了 0 个。这里的 0 不是说代码完美无缺了,而是说那些基础的规范类问题,被前置消化在了编码阶段。Review 工作得以回归到它更有价值的轨道上:交互是否符合产品预期?空输入是否需要提示?是否需要持久化?是否要接 API?
6. 团队落地的三个阶段
第一阶段:先把规范写清楚。 只做 CLAUDE.md 和 Skill,不做阻断。目标是让 AI 有规则可依,让团队先统一语言。
第二阶段:关键文件启用阻断。 只拦截 .tsx、src/api、src/store、src/hooks 这类高风险文件。不要一开始就试图全仓库管控。
第三阶段:接入审计和回滚。 等 AI 参与的任务变多之后,再启用 audit log、snapshot 和 execution boundary。到那时候,这些机制的价值会体现得非常明显。
7. 总结:Prompt 是建议,Harness 是机制
AI 编码的核心矛盾,不在于模型能不能写代码,而在于团队能不能承接它带来的不确定性。
Prompt 解决的是“告诉 AI 怎么做”;Skill 解决的是“把规范模块化注入上下文”;Hook 解决的是“在关键动作前强制检查”;Snapshot 和 Audit 解决的是“出问题后可追踪、可恢复”。
