记一次给 Claude Code 装护栏的全过程
写在最前面:这篇不是教程,是一份实践流水账。里面的判断带很强的个人偏好,未必适合你的项目,欢迎评论区拍砖。
起因
用 Claude Code 有一段时间了,功能都能跑,但一直有个说不清的别扭:我不敢把稍微大一点的任务交给它。
写单个函数、改一处小逻辑这类事我很放心;但要动七八个文件的任务,我全程得盯着,每隔几分钟去看一眼它改了什么。盯到最后我自己也累,有时候算下来,还不如自己写效率高。
一度我以为这是模型不够强的问题。换过几次更强的模型,症状缓解了一点,但没根治——它每次在"做事方式"上出的问题,都指向同一件事:我把太多东西交给了概率。
同一个需求让它做两遍,产出大概率不一样。这不是 bug,是它的工作方式。而我之前的用法,等于每次都在赌这次采样结果好不好。
于是开始一层层补护栏。下面按时间顺序记录,每一步都是因为踩到了具体的坑才往前走的——没有一步是提前设计出来的,全是被事故推着走的。
第一处:约定会丢
一个跨两天的重构任务。第一天聊了很久,定了一条自认为很重要的约束:"不要一次性替换所有调用点,逐个替换,每换一个跑一次测试。"
第二天新开会话,它上来就把八个调用点全改了。八个里面有一个出错,但我完全不知道是哪个,只能一个一个回滚排查。
翻回去看,第一天的约定在对话历史里——而对话历史超限之后会被自动压缩。压完之后,那句"逐个替换,每换一个跑一次测试"就只剩"我们讨论过替换策略"。约束还在,但可执行的部分被磨掉了。
从那之后,长任务我强制要求它写两个文件到磁盘:
.claude/plan/current-task.md——目标、步骤、已完成、待办.claude/plan/knowledge.md——查明的事实:表结构、接口约定、踩过的坑
开工前先读一遍,每完成一步就更新。核心逻辑很简单:文件在磁盘上,不会因为上下文压缩而消失。
第二个文件是后来才加的。当时遇到的情况是:它每次开工都要重新 grep 一遍表结构、重新推断某个字段的含义,推断错了还会沿着错的往下写。把这些"查明的事实"落盘之后,重复调研基本消失了,而且事实和推理分开存放,它能清楚知道哪些是确认过的、哪些是它自己猜的。
顺带学会了 /compact 的时机:以前等它自动触发,后来改成准备切到跟前半小时无关的新话题时手动压缩。等自动触发再压,你想留的东西往往已经被挤掉了。另一个习惯是长任务中途偶尔 /context 看一眼占用,超过七成就主动收尾一次对话。
第二处:它提前下班
一个要改四五个文件的任务,它干到第三个文件就停了,然后说"我已经完成了基础框架的实现,你可以在此基础上继续"。
不,你没完成。
一开始我的反应是追问"真的完成了吗",后来发现这没用——那依然是一次概率采样,不是一次检查。它大概率会重新审视一遍,然后自信地告诉你完成得差不多了。我在用一次更贵的采样去验证上一次采样,本质上是同一件事做两遍。
解决办法是 Stop Hook。它是 Claude Code 里唯一不由模型决定是否执行的东西,挂在工具调用事件上,由运行时触发:每当 Claude 想要结束回合,先跑你的脚本。
退出码的语义是精髓:0 放行;2 拦截,并且把 stderr 原样喂回给 Claude。它不是"报错",是"把反馈传回去"——Claude 读到你的 stderr 会接着干活。这个设计把 Hook 从"拦截器"变成了"对话的一部分",是我觉得整个机制里最巧妙的地方。
我的验收脚本核心是这样一段:
// scripts/verify-app.mjs —— 三步常规检查之后,是项目专属规则
const touched = execSync('git diff --name-only HEAD', { stdio: 'pipe' })
.toString().trim().split('\n').filter(Boolean);
for (const f of touched.filter(f => f.startsWith('src/routes/'))) {
if (!existsSync(f)) continue;
if (/requireAuth|allowAnonymous/.test(readFileSync(f, 'utf8'))) {
console.log(`✓ ${f} 鉴权声明存在`);
} else {
failures.push(`✗ ${f} 路由缺少 requireAuth() 或 allowAnonymous(),请补齐再收工`);
}
}
if (failures.length) {
console.error('\n【验收未通过】\n' + failures.join('\n\n'));
process.exit(2); // 拦截,stderr 会喂回给 Claude
}
前面跑 pnpm typecheck / pnpm test / pnpm lint 三项是常规部分,真正值钱的是这段项目专属检查——它把 CLAUDE.md 里那句"新增端点必须挂鉴权",从一句软建议变成会拦住它的硬约束。
配好之后最明显的体感变化:它不再说"我完成了",而是先跑一遍、发现没过、自己改、再试。我把"是否完成"的判定权,从它的主观判断搬到了我的客观脚本里。
两个踩过的坑:一是脚本本身要快,早期版本里我塞了完整的 e2e,跑一次两分钟,它每轮都被拦一次,交互体验非常糟——后来只留 typecheck 和 lint 这类秒级检查,慢的测试放到 CI;二是拦截信息必须写清楚"该怎么改",只报"缺少 requireAuth"它会瞎猜,写上"请补齐再收工"它就知道下一步是什么。
第三处:我的 code review 是假的
同一个会话里让它 review 自己刚写完的代码,给了六条建议,全是命名风格、注释位置,真正出问题的那个分支一条没提。
想明白了:让考生给自己阅卷。 同一个上下文里,它为这段代码保留了完整的"我当初为什么这么写"的理由,天然倾向于证明自己是对的。这不是不诚实,是上下文决定的——你让它换个视角,但它的视角依赖的就是那段上下文。
解法是 SubAgent——.claude/agents/ 下一个 Markdown 就是一个独立上下文的角色。planner 只读做方案、coder 按计划实现、reviewer 负责挑刺。三个设计点:
- reviewer 必须只读。
tools里刻意没给 Write / Edit。能改代码的 reviewer 遇到看不顺眼的地方会自己去改,然后就退化成第二个 coder,隔离带来的客观性当场消失。 - 用小模型,最好换不同系列。这条最反直觉也最有价值:同系列模型对自己的产物天然宽容,换个便宜的小模型反而更敢挑刺,而且跑得动高频审查。
- 置信度低于 70 不许输出。AI 审查真正让人弃用的原因是误报淹没有效信息:报 30 条、28 条是废话,等于报 0 条。没这道过滤,这个环节两周内一定被自己人弃用。
planner 这一侧我加了一条硬要求:说不清的地方列成问题清单反问,不许替我回答。 加上这条之后,它在动手前会把业务语义上的空白摆出来(比如"退款是否需要幂等,需要你确认"),而不是自己猜一个看起来合理的答案往下写。猜错的业务语义,是这整套流程里唯一拦不住的错误类型。
第四处:它看得见的范围,是我给的
前三次补的都是"做事方式",这一处是"视野"。
有段时间它写出来的前端代码总带着过时的 API 用法,一问才知道它凭记忆在写。后来接了 context7,让它写之前先查一遍当前版本的真实签名,这类问题基本消失。
MCP 我最终只留了四个:context7(查最新 API)、playwright(自己开浏览器验证)、filesystem(看见项目全貌)、数据库 MCP(调试时查真实数据)。踩过的坑有三个:
- 数据库一律给只读账号。曾经图省事给了个有写权限的连接串,它在排查问题时顺手改了一条数据。给一个能
DROP TABLE的连接串,性质等同于把生产库 root 密码贴在工位上。 filesystem的路径必须是绝对路径。相对路径时好时坏,排查这种随机失败非常浪费时间。- 改完配置必须重启。这条的低级程度跟实际踩坑频率严重不成比例。
另外有个反直觉的观察:MCP 不是越多越好。 我曾经堆到九个,结果它开始"忘记"其中几个工具的存在,该用的时候不用。常驻 4~6 个是舒服区间。
现在的配置
acme-api/
├── CLAUDE.md 全局约定(团队共享,提交 Git)
├── CLAUDE.local.md 个人偏好(.gitignore)
├── .mcp.json MCP(团队共享)
├── .claude/
│ ├── settings.json 权限 + Hook(团队共享)
│ ├── settings.local.json 个人覆盖(.gitignore)
│ ├── agents/ planner / coder / reviewer
│ ├── commands/ ship / pipeline
│ ├── rules/ api.md + testing.md(带 globs)
│ ├── skills/api-guard/ SKILL.md
│ └── plan/ current-task.md + knowledge.md
└── scripts/
├── verify-app.mjs Stop Hook 验收
└── guard-bash.mjs PreToolUse 命令守卫
几个我自己用得最频繁的:
| 文件 | 什么时候改它 | 我的经验 |
|---|---|---|
CLAUDE.md |
团队约定变了 | 只写可判定的规则,能被 grep 出来的那种;超过两百行就拆 |
.claude/rules/ |
某个领域的规矩变多 | 用 frontmatter 的 globs 限定生效范围,别一次性全塞进上下文 |
.claude/commands/ship.md |
交付流程调整 | 五阶段:调研 → 方案(停下等我确认)→ 实现 → 验证 → 自审 |
.claude/skills/api-guard/ |
加接口时自动触发 | 检查清单里我固定留一段「需你决策」,让它承认自己不知道 |
权限这块三条原则:git push 永远禁掉(审查权在人手里);.env 永远禁读(需要值就让它读 .env.example 拿变量名);rm 交给版本控制(有 Git 就几乎用不到,要回滚用 /rewind)。
另外别忘了分流:团队规则提交 Git,个人偏好放 .local 文件加进 .gitignore,否则全组会被某个人的本机路径污染。这条是协作时最容易吵起来的一件事。
最后
整个折腾下来最大的收获不是"代码写得更快了",而是我敢把活交给它的范围变大了。现在能放心让它跑需要十几分钟的任务,自己去接杯咖啡,回来再看结果。
回头看每层收益来源很清晰:
CLAUDE.md/ rules 管它愿意怎么做——软约束,会被概率绕过去- permissions / SubAgent 管它被允许怎么做——结构约束,绕不过去但也不保证做对
- Hook / CI 管它做不到就走不掉——只有这一层是真正的强制力
如果只让我留一件事,我留 Stop Hook。它是唯一一个"我说完成不算完成,脚本说完成才算"的机制。
还有一件流程保证不了的事:业务语义它猜不中。 退款要不要幂等、幂等键什么粒度、能不能一次性切换——这些它给出的答案看着都对,但只有一个符合真实规则。所以我的习惯是让它把不确定的地方明确列出来,而不是替我决定。
如果重新来一遍,我会按这个顺序装,每一步都能独立见效:
- 先写一份能 grep 的
CLAUDE.md,只收禁止条款和验收命令——半天就能做完,收益立竿见影 - 再上 Stop Hook,从"跑三条命令"开始,别一上来就设计复杂规则
- 长任务强制写 plan 文件,只在跨天任务上用,短任务别折腾
- 最后拆 SubAgent,先只加 reviewer,跑顺了再加 planner
不建议一上来就配齐一整套。我当时是一次性堆完的,结果出问题分不清是哪层的锅,又花了两天一层层拆回去验证。
浙公网安备 33010602011771号