别把新版本一把梭哈:vibe 项目的灰度发布与回滚实战
vibe 项目最常见的死法:新版本一把梭哈全量用户,一出 bug 全站陪葬。这篇指南给一人开发者一套可落地的灰度发布与回滚打法:自研 TypeScript feature flag 实现、按百分比与按用户分组的灰度策略、健康指标与自动回滚触发线、一键回滚 SOP(含数据库 expand-migrate-contract 三步)、发布前中后检查清单,以及让灰度变成虚假安全感的反模式。一个周末 7 小时,把爆炸半径从 100% 降到 5%。

那个让全站陪葬的下午
我认识一个独立开发者,姑且叫他阿 K。他的产品是个 AI 简历工具,跑了小半年,好不容易攒到 2000 个注册用户,付费转化刚刚起势。某个周五下午,他用 AI 重写了简历解析模块——提示词里说"重构解析逻辑,提高准确率",AI 吭哧吭哧吐出 400 行新代码,他看了一眼,本地跑通了,点了部署。
两小时后,邮箱炸了。所有新上传的简历解析出来都是乱码。回滚花了 40 分钟——因为他记不清上一个稳定版本是哪个 commit,数据库迁移还把旧字段删了,回滚代码之后旧代码读新库直接报错。最后他硬着头皮修 bug 修到凌晨三点,丢了十几个付费用户,App Store 评论区多了两条一星。
这不是阿 K 一个人的故事。这是 vibe 项目最常见的死法:新版本一把梭哈全量用户,一出 bug,全站陪葬。
传统团队有 QA、有 staging 环境、有值班工程师。vibe 项目有什么?你,加上 AI。你常常是唯一的测试员,而 AI 写的代码有个致命特点:它看起来永远是对的——命名规范、注释齐全、测试文件都给你生成好了,但边界条件、并发、真实数据的脏数据,它一概没见过。
灰度发布,就是给这种局面准备的保命技能。它的核心思想一句话:永远不要让 100% 的用户同时成为你的小白鼠。先让 5% 的用户试试水,指标正常再放量;出问题,一键切回,全站无感。
为什么 vibe 项目更需要灰度
先算一笔账。大厂的发布流程里,灰度是标准动作,因为他们有 SRE 团队撑着。独立开发者反而更需要它,理由有三条,每一条都扎心。
理由一:AI 写的代码,测试覆盖是纸糊的
你让 AI 写功能,它顺手给你生成测试,这很美好。但 AI 生成的测试有个通病:它测试的是"代码按我理解的需求工作",而不是"代码按真实世界工作"。它会给你写"输入合法 JSON 返回 200"的测试,不会写"用户上传了一个 87MB 的扫描版 PDF,OCR 返回半中文半乱码"的测试。真实世界的脏数据、怪异的浏览器、慢 3 秒的网络,这些才是生产环境的日常。
更要命的是 vibe 式开发的迭代速度。你一天能发三版,AI 一小时能重构一个模块。速度越快,回归测试的缺口越大。灰度就是用真实流量做最后的测试——但只拿 5% 的流量做,炸了也只炸 5%。
理由二:你是唯一的 QA,而你会犯困
传统流程里,测试团队会在 staging 环境点点点。你的 staging 环境是什么?是你自己的浏览器。你点了三下"看起来没问题",就上线了。但你测的永远是 happy path:你自己的账号、你自己的数据、你的高速网络。
灰度发布把"真实用户"变成了你的 QA 团队——但它是无痛的。5% 的用户先用新版本,你的监控看板替你盯着指标,你该睡觉睡觉。这比你自己熬夜点点点靠谱一万倍。
理由三:全量发布的 blast radius 是 100%
Blast radius(爆炸半径)是个 SRE 术语,指一次故障最多能影响多少用户。全量发布,blast radius 就是 100%。你的 2000 个用户、你的付费客户、你的口碑,一次全押上。
灰度 5%,blast radius 就是 5%。100 个人遇到 bug,你发个公告、退个款,基本能摆平;2000 个人同时遇到,你的客服邮箱(也就是你本人)会直接瘫痪。这个数学题,小学生都会算。
一句话总结:AI 让你写代码的速度翻了 10 倍,但没让你的代码质量翻 10 倍。灰度是把"速度"和"安全"解耦的唯一便宜办法。
四档灰度策略:选型表
灰度不是只有一种做法。从轻到重有四档,选哪档看你的项目阶段和团队规模(团队规模=1 还是 =1,自己对号入座)。
- 第一档:Feature Flag(功能开关)——代码里埋个开关,新功能默认关闭,按条件打开。同一套代码、同一台服务器,只是部分用户走新逻辑。
- 第二档:按用户百分比灰度——用用户 ID 做一致性 hash,5% 的用户命中新版本。用户无感,分布均匀。
- 第三档:按用户分组灰度——内测名单先行,或者付费用户先行(也可以反过来:免费用户先当小白鼠,付费用户最后升级)。
- 第四档:双版本并行——preview.example.com 跑新版本,主域名跑旧版本,手动引流或让用户自选。
选型表如下,成本按"一个独立开发者"为基准估算:
┌──────────────┬──────────┬──────────┬────────────────────────────────┐
│ 策略 │ 接入成本 │ 运维复杂度 │ 适用场景 │
├──────────────┼──────────┼──────────┼────────────────────────────────┤
│ Feature Flag │ 半天 │ 低 │ 功能级灰度,想精确控制 │
│ │ │ │ 谁能用新功能 │
├──────────────┼──────────┼──────────┼────────────────────────────────┤
│ 用户百分比 │ 1 天 │ 低 │ 版本级灰度,最通用 │
│ (hash) │ │ │ 的"先放 5% 试试水" │
├──────────────┼──────────┼──────────┼────────────────────────────────┤
│ 用户分组 │ 半天 │ 中 │ 有内测群/付费分层, │
│ │ │ │ 想定向放量 │
├──────────────┼──────────┼──────────┼────────────────────────────────┤
│ 双版本并行 │ 1-2 天 │ 高 │ 大改版、数据库结构 │
│ (preview 域) │ │ │ 大变,需要人工验收 │
└──────────────┴──────────┴──────────┴────────────────────────────────┘
我的建议:默认从 Feature Flag + 用户百分比组合起步。这两档可以叠:flag 控制"新功能开不开",百分比控制"开给多少人"。等你有了付费分层,再加用户分组。双版本并行是最后的手段,留给那种"重写了核心链路,不敢直接上"的改版。
一个判断标准:如果这次发布你心里有一丝"希望别出事"的念头,就用灰度。直觉是便宜的,故障是贵的。
Feature Flag 实战:自研最小实现
很多人一听 feature flag 就想到 LaunchDarkly,先别急着掏钱。独立开发者的体量,自研一个 100 行不到的最小实现完全够用,等用户量到几十万、flag 数量到几十个再考虑上服务。
核心代码:基于用户 ID 的一致性 hash 开关
需求很简单:给定用户 ID 和 flag 名,稳定地返回开/关——同一个用户每次结果一致,不同用户按比例分布。用 Node.js + TypeScript 写:
// flags.ts
import { createHash } from "crypto";
type FlagRule =
| { type: "boolean"; enabled: boolean }
| { type: "percent"; percent: number } // 0-100
| { type: "allowlist"; userIds: string[] }; // 内测名单
// 存在数据库里的一张小表,或最简单的:一个 JSON 文件 / 环境变量
const FLAGS: Record<string, FlagRule> = {
"new-parser": { type: "percent", percent: 5 },
"beta-dashboard": { type: "allowlist", userIds: ["u_001", "u_007"] },
"kill-switch-ads": { type: "boolean", enabled: true },
};
function hashToPercent(key: string): number {
const h = createHash("sha256").update(key).digest("hex");
// 取前 8 位 hex 转成 0-100 的数字
return parseInt(h.slice(0, 8), 16) % 100;
}
export function isEnabled(flag: string, userId: string): boolean {
const rule = FLAGS[flag];
if (!rule) return false;
switch (rule.type) {
case "boolean":
return rule.enabled;
case "allowlist":
return rule.userIds.includes(userId);
case "percent":
// 同一个用户对同一个 flag 结果永远一致
return hashToPercent(`${flag}:${userId}`) < rule.percent;
}
}
注意 hashToPercent(`${flag}:${userId}`) 这个细节:hash 的输入里带上 flag 名,这样同一个用户在 flag A 是 5% 命中、在 flag B 也是独立随机的,不会"中了一个就全中"。这是从 LaunchDarkly 的文档里学来的小技巧,免费送你。
在业务代码里使用
// 解析简历的入口
import { isEnabled } from "./flags";
async function parseResume(userId: string, file: Buffer) {
if (isEnabled("new-parser", userId)) {
return parseResumeV2(file); // 灰度中的新版本
}
return parseResumeV1(file); // 稳定的老版本
}
灰度放量的过程,就是把 "new-parser" 的 percent 从 5 → 25 → 50 → 100 改个数字。改 JSON、重启(或者做成读数据库,实时生效),不需要重新部署。这就是 feature flag 的最大价值:发布代码和发布功能解耦了。代码可以大胆合并上线,功能可以小心翼翼地开。
管理后台:一个最简开关页
flag 存在 JSON 里,每次改都要 SSH 上服务器,这很蠢。花一小时做个最简管理页,挂在 /admin/flags 后面(记得加管理员鉴权,别把开关页暴露给全世界):
// admin/flags 页面逻辑(伪代码,任何框架都一样)
// GET /admin/flags → 列出所有 flag:名称、类型、当前值、命中人数
// POST /admin/flags/:name → 更新规则 { type, percent | enabled | userIds }
// 每次变更写一条审计日志:谁、什么时候、把什么从多少改到多少
// 审计日志表 flags_audit:
// id | flag_name | changed_by | old_value | new_value | changed_at
// 1 | new-parser | lee | 5% | 25% | 2026-10-09 14:02
审计日志别省。凌晨三点出问题,你最想知道的就是"刚才谁动了开关"。没有审计日志的 flag 系统,等于没有刹车的汽车——你能开,但不敢开快。
什么时候换 LaunchDarkly
自研方案有三个天花板,碰到任意一个就该换了:
- flag 数量超过 20 个——JSON 文件开始变成"谁也不敢动"的祖传配置,改一个数字要对着文档看半天。
- 需要按复杂条件定向——比如"过去 30 天付过费、且用 iOS App、且不在欧盟的用户",自研的条件引擎写起来就是个无底洞。
- 需要审计、审批流、多人协作——你开始有合伙人或外包,开关不能再是"谁 SSH 上去改都行"。
LaunchDarkly 这类服务的免费额度对独立开发者很友好(通常每月几万次 flag 评估免费),迁移成本主要是把 isEnabled 换成他们的 SDK 调用。我的判断是:年收入没到能覆盖 100 美元/月之前,自研够用了。把钱花在获客上,别花在 flag 上。
健康指标与自动回滚线
灰度放出去之后,盯什么?不能靠"感觉好像没人骂"。你需要三个硬指标,每个都有明确的回滚线。指标从哪来?Sentry(错误)、你的 APM(延迟)、你的数据库/支付回调(转化)。独立开发者标配:Sentry 免费版 + Vercel Analytics 或自建 Prometheus,够了。
指标一:错误率
新版本 5 分钟窗口内的服务端错误率(5xx / 总请求)。回滚线:错误率超过 1%,且是过去 7 天同时段基线的 3 倍。
为什么是"基线的 3 倍"而不是固定值?因为每个产品的基线不一样。你的 side project 基线可能是 0.1%,3 倍就是 0.3%;别人的可能是 0.8%,3 倍就是 2.4%。固定阈值要么太松(漏报),要么太紧(误报把你半夜叫醒)。相对阈值是独立开发者的省心选择。
指标二:p95 延迟
别看平均延迟,看 p95——最慢的那 5% 请求有多慢。AI 重构最常见的坑就是"功能对了,慢了 10 倍":比如新解析逻辑里多了个串行的 embedding 调用,平均延迟只涨了 200ms,但 p95 从 800ms 涨到 8 秒。
回滚线:p95 延迟超过基线 2 倍,持续 10 分钟。延迟问题不像错误率那样致命(用户只是觉得慢),所以给 10 分钟观察窗口,排除偶发的抖动。
指标三:核心转化 / 支付成功率
这是最重要、也最容易被忽略的一个。错误率没涨、延迟没涨,但新版的支付按钮被 AI 移到了折叠区域下面,支付成功率掉了 30%。服务器指标全绿,收入在流血。
回滚线:核心转化事件(注册/下单/支付)的成功率,低于过去 7 天均值 20%,持续 15 分钟。转化数据有滞后性,所以窗口给到 15 分钟。
回滚触发条件模板
把上面的线写成一份贴在墙上的模板(也可以写成告警规则,直接配在 Sentry / Uptime Kuma / 自建脚本里):
# canary-alerts.yaml —— 灰度发布自动回滚触发条件
# 任何一条命中 → 立即把 flag percent 调回 0(或切回旧版本),再慢慢查原因
alerts:
- name: error-rate-spike
condition: "5 分钟窗口错误率 > 1% 且 > 7 天基线 3 倍"
action: 自动回滚(flag 置 0)+ 短信/邮件通知
- name: latency-degradation
condition: "p95 延迟 > 基线 2 倍,持续 10 分钟"
action: 自动回滚 + 通知
- name: conversion-drop
condition: "核心转化成功率 < 7 天均值 80%,持续 15 分钟"
action: 自动回滚 + 通知
- name: manual-kill-switch
condition: "人工判定:用户投诉 / 数据异常 / 就是觉得不对劲"
action: 管理后台一键把所有灰度 flag 置 0
最后一条 manual-kill-switch 是给你的直觉留的后门。指标是滞后的,直觉有时候更快。给自己留一个"一键全关"按钮,凌晨三点你会感谢现在的自己。
一键回滚 SOP
回滚不是"把代码 revert 一下重新部署"。真正的回滚 SOP 要回答三个问题:切到哪、数据怎么办、多久能切完。目标:从发现问题到恢复服务,不超过 5 分钟,且不需要你清醒地写代码。
保持上一个稳定版本可即时切换
不同平台做法不同,核心思想一样:旧版本别删,留着当救生艇。
- Vercel:每次部署都有 immutable 的 URL 和一键 Promote。回滚 = 进 dashboard,找到上一个稳定部署,点 Promote to Production,10 秒生效。建议:每次灰度放量前,给当前生产部署打个备注"stable-2026-10-09"。
- Railway / Render / Fly.io:保留上一个 release。Railway 可以直接 redeploy 之前的部署;Fly.io 用
fly deploy --image指定上一个镜像 tag。关键习惯:给每个生产镜像打语义化 tag(prod-20261009-1430),别用latest——latest在回滚时会让你分不清哪个是哪个。 - 自建 Docker:部署脚本里永远保留上一个容器。
docker-compose up新版本前,先docker tag app:current app:previous。回滚就是docker tag app:previous app:current && docker-compose up -d,一行命令。
# 最简回滚脚本 rollback.sh —— 提前写好,别等出事再想
#!/bin/bash
set -e
PREV_TAG="app:previous" # 部署脚本每次发布前自动打这个 tag
docker tag $PREV_TAG app:current
docker-compose up -d
echo "已回滚到上一个版本,当前镜像:"
docker inspect app:current --format='{{.Config.Image}} {{.Created}}'
数据库迁移:只加不改的兼容窗口
回滚最难的部分永远是数据库。代码可以秒切,数据不行。如果你这次发布删了个字段、改了个字段类型,回滚旧代码会直接连不上新库——阿 K 当年就是死在这。
解法是 expand-migrate-contract(扩展-迁移-收缩)三步,这是大厂用了十几年的老办法,独立开发者照抄就行:
- Expand(扩展):只加不改。加新字段、新表,不删旧字段,不改旧字段类型。新代码同时写新旧两份(双写),读的时候优先读新的,兜底读旧的。
- Migrate(迁移):写个一次性脚本,把旧数据搬到新结构。这一步可以慢慢跑,不 blocking。
- Contract(收缩):等新版本稳定运行一到两周,确认没人再读旧字段了,再发一版把旧字段删掉、把双写代码清掉。
关键点:Expand 和 Contract 之间必须留一个"兼容窗口"——至少一次完整灰度周期的长度。窗口期内,新旧代码都能读写数据库,回滚才有意义。违反"只加不改"的迁移,等于亲手烧掉了自己的救生艇。
-- 反面教材:直接改字段类型,回滚即死亡
ALTER TABLE resumes ALTER COLUMN parsed_data TYPE jsonb;
-- 旧代码还在读 text 格式,直接报错
-- 正确做法:加新字段,双写,旧字段两周后再删
ALTER TABLE resumes ADD COLUMN parsed_data_v2 jsonb;
-- 新代码:写 parsed_data 和 parsed_data_v2,读优先 parsed_data_v2
-- 两周后,确认无回滚需求,再执行:
-- ALTER TABLE resumes DROP COLUMN parsed_data;
回滚演练清单
没演练过的 SOP 等于没有 SOP。消防演习不会等到真着火才做,回滚也一样。
- 多久演练一次:每季度一次,或者每次大改版灰度之前。一个人也要演,演给未来的自己看。
- 演练什么:① 从告警响起到服务恢复,计时,看能不能进 5 分钟;② 回滚脚本能不能一键跑通(依赖的 tag、镜像还在不在);③ 数据库处在兼容窗口的哪个阶段(旧字段删了没?);④ 审计日志能不能查到是谁动的开关。
- 演练记录:简单记一笔——日期、用时、发现的问题。第一次演练大概率会发现回滚脚本早就坏了(镜像被清掉了、tag 没打),这就是演练的价值。
回滚演练的最低成本版本:每季度挑一个周五下午,假装新版本出 bug 了,实际跑一遍 rollback.sh,看着监控恢复正常。全程 15 分钟,买的是一季度的安心。
发布检查清单(Pre-flight Checklist)
飞行员起飞前要对着清单逐项打勾,不是因为他们记性差,是因为人靠不住。发布也一样。下面三张清单,打印出来(或者存成模板),每次灰度对着勾。
灰度前(发布前一天)
- ☐ 上一个稳定版本的部署/镜像已打 tag,回滚脚本本地跑通过一次(dry-run)
- ☐ 数据库迁移符合"只加不改",兼容窗口已确认(旧字段保留至少两周)
- ☐ feature flag 已创建,初始 percent 设为 5(或 allowlist 只有内测账号),审计日志开启
- ☐ 三个健康指标的基线已确认(过去 7 天同时段数据截图存档),告警规则已启用
- ☐ 回滚负责人就是你——手机不静音,电脑不合盖,发布后 2 小时内能响应
灰度中(放量期间)
- ☐ 5% 跑满 30 分钟,三个指标全绿,才放到 25%
- ☐ 每次放量(5→25→50→100)间隔至少 1 小时,给转化指标留出滞后窗口
- ☐ 放量期间不合并其他代码、不改 flag 之外的任何配置(一次只变一个变量)
- ☐ 用户反馈渠道有人看(邮箱/评论区/Discord),技术指标和用户体感两条腿走路
- ☐ 每次放量在审计日志/发布记录里记一笔:时间、百分比、当时指标截图
灰度后(全量 24 小时后)
- ☐ 全量 24 小时指标平稳,flag 可以"退役":代码里的 if/else 合并成新逻辑,flag 删除
- ☐ 审计日志归档:这次灰度的时间线(几点放的量、有没有告警、谁操作的)
- ☐ 数据库进入 Contract 阶段排期:旧字段/双写代码的删除时间定下来(两周后)
- ☐ 复盘 10 分钟:这次灰度有没有误报/漏报?告警阈值要不要调?记下来
- ☐ 回滚脚本和稳定版 tag 更新到最新(救生艇永远要是"上一个"版本,不是"上上个")
反模式:灰度做错比不做好更危险
灰度是个好工具,但用错了会给你虚假的安全感。下面三个反模式,我见过不止一个人踩过。
反模式一:灰度 1%,但 1% 全是付费大客户
按用户 ID hash 灰度,理论上分布均匀。但如果你只有 200 个付费用户、18000 个免费用户,5% 的 hash 会命中约 10 个付费用户——而你的收入 80% 来自这 200 人。更惨的是,付费用户通常用得最深、最先踩到 bug。
解法:灰度策略要和用户分层正交。要么付费用户走独立的 allowlist(最后才升级),要么灰度时按"免费用户先行"定向。记住:灰度的目的是控制 blast radius,blast radius 要按"损失"算,不按"人数"算。10 个付费大客户的 blast radius,可能比 1000 个免费用户还大。
反模式二:flag 堆积成技术债
flag 的诞生永远比死亡容易。发三个版本,埋五个 flag,全量之后"改天再清理",然后就没有然后了。半年后代码里躺着 30 个永远为 true 的 flag,新来的 AI(或者三个月后的你)读代码时一脸懵:这个 if 分支到底走哪边?
解法:flag 退役规则写死。每个 flag 创建时就定好退役日期(比如全量后 14 天),到期要么删、要么延期(延期要写理由)。管理后台给每个 flag 显示"年龄",超过 30 天的标红。我的个人规则:同一时间活着的 flag 不超过 5 个,超了就先还债再发新版。
反模式三:"观察一下"式人工盯盘
"先放 5%,我盯一会儿看看。"然后你去刷了会儿手机,回来发现错误率已经飙了 40 分钟。人工盯盘有两个问题:一是你会困、会分心,二是你的"觉得没问题"没有量化标准——错误率 0.8% 算不算问题?你盯着看的时候永远觉得"再观察一下"。
解法:告警规则代替眼睛,kill switch 代替犹豫。阈值写进配置,命中自动回滚;拿不准的时候,默认动作是回滚而不是观察。回滚的成本是一次重新放量,观察的成本可能是全站故障。记住阿 K 的那个下午:他要是 5 分钟内切回去了,损失的就是 5% 用户的两小时,而不是全部用户的一整晚。
最小可用方案:一个周末搭起来
说了这么多,收个尾。如果你是一个人、一个周末的时间,想给项目加上灰度+回滚,按这个顺序做,做完就有 80% 的效果:
- 周六上午(2 小时):把上面的
flags.ts抄进项目,接上你最常用的一个高频功能(比如新解析逻辑、新落地页)。管理后台先不做,flag 配置放环境变量,改配置重启生效。 - 周六下午(2 小时):写
rollback.sh,给部署流程加上"发布前自动打 previous tag"的步骤。在 Vercel/Railway 后台找到上一个稳定部署,确认你知道回滚按钮在哪。 - 周六晚上(1 小时):Sentry(或你已有的监控)加上三条告警规则:错误率、p95、转化。阈值先按模板里的相对值设,跑两周再调。
- 周日上午(1 小时):把 Pre-flight Checklist 存成模板,下次发布直接用。顺手做一次回滚演练:跑一遍 rollback.sh,看服务能不能在 5 分钟内恢复。
- 周日下午(1 小时):检查你最近的数据库迁移,有没有违反"只加不改"的。给下一次迁移定下 expand-migrate-contract 的规矩。
总共 7 小时,一个周末。做完之后,你的发布流程就从"梭哈"变成了"先派 5% 的侦察兵"。AI 还是会写出 bug——它永远会。但 bug 的 blast radius 从 100% 降到了 5%,回滚从 40 分钟的冷汗变成了 5 分钟的一键操作。
阿 K 后来怎么样了?他花了一个周末搭了这套东西。三个月后又有一次 AI 重构翻车——新版本把 5% 用户的导出功能搞挂了。Sentry 告警 3 分钟后响起,他点了 kill switch,5% 切回 0%,剩下 95% 的用户什么都不知道。他修好 bug,第二天重新灰度,全量。那天晚上他 11 点就睡了。
这就是灰度发布的全部意义:让翻车变成一件小事。独立开发者的命也是命,别拿全站给 AI 的幻觉陪葬。
相关文章

每个 vibe 项目都会经历同一个黑色幽默时刻:网站白屏了,朋友比你的监控先告诉你。这篇实战为一人团队搭建完整错误监控体系:5 分钟 Sentry 最小闭环、错误边界、上报上下文设计、后端结构化日志、AI 调用专项防护、告警分级降噪,最后附上线检查清单。

每个 vibe 项目迟早需要定时任务:每日数据同步、过期订单清理、账单对账、定时报告。AI 给你的第一个版本通常是 setInterval——开发够用,生产必死。这篇实战给出四种跑法的选型地图(应用内/Vercel Cron/GitHub Actions/Cloudflare),cron 表达式速查与时区坑,幂等性、防重叠分布式锁、失败重试与告警、可观测性 run log,以及 cron 接口的鉴权,最后附上线清单。

Zephos 团队在一个 Agent 搭起来的 Next.js + Supabase + Stripe 笔记应用里埋了 16 个上线杀手级问题,其中 2 个和支付有关:webhook 没验签、拿客户端传的 plan 直接开 pro。这篇指南把这两个坑拆成一套可落地的实战方法:webhook 签名三件套、服务端唯一真相源、订阅状态机、测试时钟和沙盒到生产 checklist。核心判断:和钱相关的逻辑必须手写或逐行审计,agent 只能写样板。