写在前面
我用 Claude Code 做生产项目一年多,走过一段典型弯路:一开始以为瓶颈在模型,不断换更强的模型,该出的 bug 还是出。后来我把注意力从"让模型更聪明"转到"让约束更硬",提升比换三次模型加起来都明显。
原因不复杂:Claude Code 不是问答系统,而是概率性的执行系统——同一个需求,每次产物都不一样。你要做的是把概率压缩成确定性。我把它拆成三层:
| 层次 | 回答的问题 | 手段 | 强制力 |
|---|---|---|---|
| 规则层 | 它知不知道项目规矩 | CLAUDE.md、.claude/rules/、Skills、Commands | 弱(建议) |
| 分工层 | 有没有人出来唱反调 | SubAgent、工具白名单 | 中(结构约束) |
| 反馈层 | "改好了"三个字谁来验证 | Hooks、MCP、Checkpoint、验收脚本 | 强(运行时强制) |
规则层管"愿意怎么做",分工层管"被允许怎么做",反馈层管"做不到就走不掉"。 只有第三层是真正的强制力。
一、它为什么会"越用越难用"
裸用时你会稳定撞上三堵墙——不是"有时候发生",而是必然随时间发生。
上下文漂移。 规划与中途的约定只存在于对话历史里,历史被压缩后"刚才说好用 A 方案"就丢了,它按重新推断的方案继续写,而你毫无察觉。
自我宽容。 同一个 Agent 先写代码再自己审查,等于让考生给自己阅卷。
口头验收。 "改好了"的真实命中率取决于它有多想收尾,而不是代码的真实状态。
共同点:缺的不是模型能力,是工程约束。
二、规则层:把口头约定写成文件
CLAUDE.md 不是备忘录,是编译期约束:每一条都必须可被判定——要么通过,要么违反。大多数人的版本是「注意事项」下挂三条——高质量、规范、最佳实践,这类内容对模型的约束力接近于零,因为它无法判定自己是否违反了"高质量"。
判断标准只有一条:能不能被 grep 出来、被脚本检查? 不能,就重写:
# Project: acme-api
## 这个仓库是什么
acme-api 是对外提供订单查询的 Node.js 服务。上游网关,下游 MySQL + Redis。
## 必须遵守(改代码前先读这段)
- 运行时:Node.js 20 LTS + TypeScript 5.x **ESM**,禁止出现 `require()`
- 包管理:只用 pnpm。`npm install` / `yarn add` 一律视为错误
- 目录约定:`src/routes/` 只放路由声明,**业务逻辑下沉到 `src/services/`**
- 错误码:必须引用 `ErrorCode` 枚举,**不得**手写 `res.status(400).json({...})`
- 鉴权:新增 HTTP 端点必须挂 `requireAuth()`;确需放开写 `allowAnonymous('原因')`
## 不要做的事
- 不改 `src/legacy/**`;不改测试断言去迁就实现;不做计划外重构
## 验收标准(必须同时满足)
1. `pnpm typecheck` 通过 2. `pnpm test` 全绿 3. `pnpm lint` 无 error
4. 接口类改动必须用真实请求打过一次,贴出响应
一个观察:禁止条款的效果明显好过倡导条款——有没有挂 requireAuth(),grep 一下就知道;符不符合"高质量",谁也判不了。
超过两百行后,每条规则被"注意到"的概率明显下降。按领域拆到 .claude/rules/,用 frontmatter 的 globs 限定生效范围。规则加载量直接影响模型表现,这不是文档洁癖。
再往上是两个容易混淆的机制:
- Skill(
.claude/skills/<name>/SKILL.md)**自动触发**,管"做事的规矩"。核心是 frontmatter 的description,写得含糊这个 Skill 就等于不存在。我的api-guard` 跑一张六项检查清单(鉴权、schema、统一包装、幂等、埋点、测试)。其中「需你决策」一段是我每个 Skill 都保留的设计——让 Skill 学会承认自己不知道,否则它会用看似合理的猜测把业务语义堵上。
- Slash Command(
.claude/commands/xxx.md)手动/调用,管"做事的顺序"。最常用的ship.md:只读调研 → 出方案(停下等我确认)→ 实现 → 验证(真实执行并贴结果)→ 自审。
三、分工层:让批判与创造分离
规则是静态的,代码质量问题却是动态的。靠结构解决:让 critic 和 author 不处于同一个上下文。
SubAgent 就是 .claude/agents/ 下的 Markdown,frontmatter 定义身份与能力边界,子 Agent 在隔离上下文里运行——这是整个设计的核心。三个角色足矣:
| 角色 | 模型定位 | tools | 关键约束 |
|---|---|---|---|
| planner | 最强档 | Read, Grep, Glob, WebSearch | 只给计划不给代码;说不清的列成问题清单反问 |
| coder | 中档 | Read, Write, Edit, Bash, Grep, Glob | 计划的作者不是你;只改计划内文件 |
| reviewer | 小档(换系列) | Read, Grep, Glob, Bash(pnpm test:*, pnpm typecheck, pnpm lint) | 只读;每条问题带行号 + 置信度 + 后果 |
reviewer 的提示词关键在最后两行:
## 输出要求
每条问题必须带三样东西,缺一不可:
- 文件 + 行号
- 置信度 0-100
- **具体后果**:会导致什么。不接受"这里可能有点问题"这种废话
**置信度低于 70 的一律不输出。** 宁可漏,不要吵。
三个容易被忽略的决策:reviewer 必须只读——能改代码的 reviewer 会自己动手,从"审查者"退化成"第二个 coder";reviewer 用小模型反而更好——同模型自我审查的最大问题是过度认同,换个不同系列的小模型能打破这种共鸣,还便宜;置信度阈值比评分更重要——AI 审查让人放弃使用的原因不是漏报,是误报淹没有效信息,报 30 条、28 条是废话等于报 0 条。
编成一条 /pipeline:调 @planner 拆解(拿到待确认问题后转问我,不许替我回答)→ 交给 @coder 实现 → 调 @reviewer 独立验证(让它真的跑命令)→ 高于 70 分的问题交回 @coder 修复,最多两轮。需要说明的是,这种编排的可靠性来自 prompt 写得够硬,不是引擎级保证。要更强的确定性,得上 Hook。
四、反馈层:把"改好了"翻译成"跑过了"
三层里最重要、也是最少人做的一层。前面所有东西本质上都是提示,模型可以在某次采样里忘了遵守;只有 Hook 是确定会执行的——挂在工具调用事件上,由运行时触发,不由模型决定。
rules 是"它应该这样做",hooks 是"它不得不这样做"。
最有用的事件是 Stop:每当 Claude 想结束回合,先跑你的脚本。退出码 0 放行,2 拦截并把 stderr 原样喂回给 Claude——这不是"报错",是"把反馈传回去",它读到后会接着干活。
{
"permissions": {
"allow": ["Read", "Edit", "Write", "Glob", "Grep", "Bash(pnpm:*)"],
"deny": ["Bash(rm:*)", "Bash(git push:*)", "Read(./.env)", "Read(./.env.*)"]
},
"hooks": {
"PostToolUse": [
{ "matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "pnpm typecheck 2>&1 | tail -15" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "node scripts/verify-app.mjs" }] }
]
}
}
四个要点:git push 必须禁掉——让 Agent 拥有推送权限是迟早出事的决定;Bash(pnpm:*) 是白名单粒度控制,比 Bash(*) 安全得多;PostToolUse 防错误累积,Stop 防提前收工;个人偏好放 .claude/settings.local.json 并加进 .gitignore,团队规则放 settings.json 提交 Git。
verify-app.mjs 四步:跑 typecheck / test / lint 收集失败 → git diff --name-only HEAD 取改动文件 → 检查 src/routes/ 下有没有 requireAuth 或 allowAnonymous → 任一失败写 stderr 并 process.exit(2)。
三个 pnpm 命令不是重点,最后那段项目专属检查才是——它把"新增端点必须挂鉴权"从软约定变成会拦截的硬约束。这就是确定性工程的本质:把软约定翻译成可执行检查,交给运行时强制执行。
MCP 解决另一半问题——"它能看到什么":常驻 context7(治过时的 API 写法)、playwright(自己开浏览器验证)、filesystem(看见项目全貌)、数据库 MCP(查真实数据)。
三个坑:数据库一律只读账号(给一个能 DROP TABLE 的连接串,性质等同于把生产库 root 密码贴出来);filesystem 路径必须绝对;改完配置必须重启。
五、实战:给一个 Express 服务做鉴权收口
选它是因为很典型:改动范围中等、涉及安全、容易出隐蔽 bug。
起点。 每个路由都自己写 jwt 校验:缺 header 返回 401 { msg: 'no auth' },jwt.verify 失败返回 403 { msg: 'bad token' }——另一个路由同样情况却返回 401。参数校验写成 res.status(500).json({ msg: 'amount?' }),退款逻辑没有任何幂等保护。更要命的是,同样的逻辑在另外两个路由里是另外两个版本。
规划。 planner 会 grep 出全部 jwt.verify 调用点。真正的价值是它会反问:三个路由角色要求不同,是否需要按角色收口;能否一次性替换;token 过期与非法是否返回同一个错误(返回不同可能泄漏用户是否存在)。这三个问题我不回答它就不该动手。
抽出中间件。 目标是一支 requireAuth(...roles),四个刻意取舍:
export const requireAuth =
(...roles: string[]): RequestHandler =>
(req, _res, next) => {
try {
const token = parseBearer(req.headers.authorization);
const payload = jwt.verify(token, secret) as JwtPayload & { roles?: string[] };
if (!payload.sub) throw new AppError(ErrorCode.UNAUTHORIZED, 'token 缺少 sub');
const user: AuthUser = { sub: payload.sub, roles: payload.roles ?? [] };
if (roles.length && !roles.some(r => user.roles.includes(r))) {
throw new AppError(ErrorCode.FORBIDDEN, `需要以下任一角色:${roles.join(', ')}`);
}
req.user = user;
next();
} catch (err) {
next(err); // 统一交给 error handler,中间件自己不写响应
}
};
① 中间件自己不写响应,错误统一走 next(err),"错误长什么样"只有一个地方定义,且只对 status >= 500 打 error 日志。② JWT_SECRET 缺失时启动即失败。③ token 过期与非法返回同一错误,避免通过错误信息枚举用户是否存在。④ 职责单一,只做认证。
覆盖失败路径。 不止 happy path,六条:无 Authorization 头 → 401;scheme 写成 Basic → 401;token 过期 → 401 且响应体结构与无 token 时完全一致(这条防的不是 bug 是信息泄漏);角色不足 → 403;缺 amount → 400 而非 500;相同幂等键重复提交 → 两次 refundId 相同。第三条只有 reviewer 视角想得到。
验收并固化到 CI。 别问"你确定没问题吗",直接下指令:「用 playwright 走一遍登录,验证 401 时前端跳登录页而不是白屏,把截图给我。」从"我说通过了"到"截图在这儿",这是反馈层想要的全部效果。最后把同一份 verify-app.mjs 放进 CI——本地 Hook 可以绕过,CI 绕不过。
复盘。 退款要不要幂等、幂等键什么粒度、能否一次性切过去——这三件业务语义的事它猜不中。流程能保证"已知的正确被稳定执行",不能保证"未知的语义被正确猜中"。
结语
这套东西真正改变的不是"代码写得快不快",而是你敢不敢把活交给它。裸用 Claude Code,你只能让它做随时能检查的小事;配好三层之后,你才敢让它做那种要跑二十分钟但你敢去接杯咖啡的任务。
规则层管"愿意怎么做",分工层管"被允许怎么做",反馈层才管"做不到就走不掉"。