别把 Claude Code 当聊天框:一套「确定性工程」落地手册

0 阅读9分钟

写在前面

我用 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/ 下有没有 requireAuthallowAnonymous → 任一失败写 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,你只能让它做随时能检查的小事;配好三层之后,你才敢让它做那种要跑二十分钟但你敢去接杯咖啡的任务。

规则层管"愿意怎么做",分工层管"被允许怎么做",反馈层才管"做不到就走不掉"。