AI 编程真正进入团队生产,不是因为模型更强,而是因为工程体系开始接住它的不确定性。
1. 引言:只靠对话编程, AI 很快会失控
AI 编码最麻烦的地方,不是它不会写代码,而是它没有稳定的工程边界 。它可能这次记得用 intl.get(),下次又硬编码中文;这次遵守组件拆分,下次把页面、状态、事件、样式全塞进一个 index.tsx;你让它修一个 bug,它顺手把旁边的代码风格也改了。
这些问题单独看都不严重,但会持续消耗 Code Review 的注意力。原本 Review 应该关注业务逻辑、交互边界、异常处理,结果变成了帮 AI 检查:有没有 any、有没有中文硬编码、列表 key用了 index、有没有乱改配置。
在 我们项目 项目里遇到的问题:只停留在对话编程,没有强制机制。
Prompt 可以提醒 AI,但 Prompt 不是约束。README 可以写规范,但 AI 不一定每次都会读。我们真正需要的是一套工程化 Harness:在 AI 编辑代码的关键动作前插入检查点,把"建议"变成"机制"。
一句话概括这套体系:
编辑前检查规则,命中规则必须先加载规范;编辑前自动快照,编辑过程记录审计;超过边界就停下来。
2. 原理速览:有 AI 规范驱动的差异
2.1 没有 Harness: AI 直接冲进 代码 库
没有 Harness 时,AI 的工作流非常短:用户提出需求,AI 直接编辑文件,然后进入 Review。
rust
sequenceDiagram
participant User as 开发者
participant AI as AI 编码助手
participant Repo as 代码仓库
participant Review as Code Review
User->>AI: 实现一个 TODOLIST 页面
AI->>Repo: 直接编辑 index.tsx
AI->>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 后执行
rust
sequenceDiagram
participant User as 开发者
participant AI as AI 编码助手
participant Hook as PreToolUse Hook
participant Skill as Skill 规范
participant State as Session State
participant Repo as 代码仓库
participant Snapshot as Snapshot / Audit
User->>AI: 实现一个 TODOLIST 页面
AI->>Hook: 请求编辑 .tsx 文件
Hook->>Hook: 匹配文件规则
Hook->>State: 检查 coding-standards 是否已加载
State-->>Hook: 未加载
Hook-->>AI: 阻断,提示先加载 coding-standards
AI->>Skill: 加载 coding-standards
AI->>Hook: 再次请求编辑
Hook->>Snapshot: 保存原文件快照 + 写审计日志
Hook-->>AI: 放行
AI->>Repo: 编辑文件
这个机制的关键点在于:AI 不是被禁止写 代码 ,而是必须先带着项目规范写代码。
3. AI 规范驱动工程设计
我们 的 Harness 不是单一脚本,而是一组互相配合的机制。
当前目录结构如下:
perl
.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 自身文件都不应该触发规范检查。因此第一层是动作空间注册表:只对关键业务文件启用强约束。
javascript
// .claude/hooks/lib/match-rules.js
const 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。如果没有,直接阻断。
php
// .claude/hooks/pre-edit-check.js
if (!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,指导它下一步该加载哪个 Skill
process.stderr.write(`请先执行: Skill(skill="${rule.skill}")`);
process.exit(2);
}
这个 API 的设计意图是:把"请遵守规范"从提示词变成执行前置条件。适用边界也很清晰:它只拦截 Edit / Write,不拦截 Read / Grep / Glob。AI 可以自由阅读代码和规范,但真正动手前必须过闸。
3.3 防止 AI 陷入 循环 修改
AI 有一种典型失控模式:同一个文件改来改去,越改越偏。为此我们加了执行边界。
| 指标 | 当前值 | 行为 |
|---|---|---|
| 单 session 编辑次数 | 15 次/openspec 工程40次 | 超过后阻断 |
| 同文件编辑次数 | 5 次 | 警告,不阻断 |
| session TTL | 7 天 | 自动清理过期状态 |
ini
// .claude/hooks/lib/session-state.js
const DEFAULT_EDIT_LIMIT = 15;
function incrementEditCount(sessionId) {
const state = loadState(sessionId);
// 每次 Edit / Write 放行前计数 +1
state.editCount = (state.editCount || 0) + 1;
saveState(sessionId, state);
return state.editCount;
}
为什么是 15 次?这是一个经验阈值,不是数学真理。我们的判断是:一个明确的小任务如果需要超过 15 次编辑,通常意味着需求理解、类型设计或上下文已经出现偏差。此时继续让 AI 自动修改,不如停下来重新确认方向。
3.4 让 AI 行为可追踪
每次放行、阻断、加载 Skill,都会追加一条审计日志。
json
{"ts":"2026-06-12T10:30:00.000Z","tool":"Edit","file":"src/pages/todo/index.tsx","action":"pass-skill-loaded"}
Audit Log 的边界也要说清楚:它不替代 Git 历史,也不记录文件内容,只记录操作元信息。它适合回答这些问题:AI 是什么时候开始改这个文件的?有没有被阻断过?是不是在未加载规范时尝试编辑?
3.5 给 AI 修改加上回滚保险
最后一层是快照。每次 Edit / Write 放行前,Harness 会把原文件内容保存到 .claude/snapshots/{session_id}/。
javascript
// .claude/hooks/lib/snapshot.js
function saveSnapshot(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. 实战迁移:实现一个 TODOLIST 页面
为了验证 Harness 的实际效果,我们做了一个小实验:让 AI 实现同一个 TODOLIST 页面,一组模拟无 Harness 输出,一组按 Harness 规范输出。实验产物保存在:
bash
docs/harness-todolist-experiment/
├── no-harness/
└── with-harness/
任务要求一致:实现任务列表、新增、完成切换、删除、空状态、用户可见文本、样式和类型。
4.1 无 Harness: 代码 能跑,但 Review 压力转移给人
无 Harness 版本是一个典型的"一把梭"实现:全部逻辑塞进 index.tsx,状态、渲染、事件都在同一个组件里。
javascript
// docs/harness-todolist-experiment/no-harness/index.tsx
export default function TodoPage() {
const [text, setText] = useState('');
// 问题 1:any[] 逃逸,后续 item 字段完全失去类型保护
const [todos, setTodos] = useState<any[]>([]);
return (
<div className={styles.page}>
{/* 问题 2:用户可见中文直接硬编码 */}
<h1>任务列表</h1>
<Input placeholder="请输入任务" value={text} onChange={e => setText(e.target.value)} />
<Button type="primary" onClick={addTodo}>添加</Button>
{todos.map((item, index) => (
// 问题 3:列表 key 使用 index,删除/排序后可能导致渲染错位
<div className={styles.item} key={index}>
<span>{item.text}</span>
<Button danger onClick={() => deleteTodo(item.id)}>删除</Button>
</div>
))}
</div>
);
}
这类代码的问题在于:它看起来完成了需求,但把规范成本全部留给了 Review。
4.2 有 Harness:先加载规范,再写 代码
有 Harness 时,AI 第一次尝试写 .tsx 会被拦截:
ini
[harness] 该文件命中规则「React 组件」
要求:动手前必须先调用 Skill 工具加载 `coding-standards`。
如涉及国际化文案:Skill(skill="i18n-coding")
随后 AI 会加载:
coding-standards:组件设计、类型、状态、React 模式i18n-coding:用户可见文本国际化
最终输出会自然拆成多个文件:
csharp
with-harness/
├── types.ts
├── TodoItem.tsx
├── TodoList.tsx
├── index.tsx
└── index.less
核心代码如下:
typescript
// docs/harness-todolist-experiment/with-harness/types.ts
export interface TodoItem {
// 使用稳定 id,避免列表 key 使用 index
id: string;
title: string;
completed: boolean;
}
typescript
// 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 <Empty description={intl.get('todo.empty.description')} />;
}
return (
<div className={styles.list}>
{items.map(item => (
<TodoItem key={item.id} item={item} onToggle={onToggle} onDelete={onDelete} />
))}
</div>
);
}
ini
// docs/harness-todolist-experiment/with-harness/index.tsx
export default function TodoPage() {
const [inputValue, setInputValue] = useState('');
const [items, setItems] = useState<TodoItem[]>([]);
const handleAdd = () => {
const title = inputValue.trim();
if (!title) return;
// 使用函数式 setState,避免依赖旧闭包状态
setItems(prevItems => [
...prevItems,
{
id: `${Date.now()}`,
title,
completed: false,
},
]);
setInputValue('');
};
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>{intl.get('todo.page.title')}</h1>
<div className={styles.form}>
<Input
value={inputValue}
placeholder={intl.get('todo.input.placeholder')}
onChange={event => setInputValue(event.target.value)}
/>
<Button type="primary" onClick={handleAdd}>
{intl.get('todo.action.add')}
</Button>
</div>
<TodoList items={items} onToggle={handleToggle} onDelete={handleDelete} />
</div>
);
}
样式也从硬编码色值切换为 token:
css
.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 解决的是"出问题后可追踪、可恢复"。