第 12 章 质量门禁

CI、测试、监控闭环——这些东西不性感,但"能跑的 demo"和"可靠的生产系统"之间,差的恰恰就是它们。这一章讲我以前怎么忽略它们,后来又怎么补上、补的时候修正了哪些认知。
开篇:一个尴尬的事实
先说一个让我有点不好意思的事实。
我做了好几个 Agent 项目,代码量加起来好几万行。但在很长一段时间里,没有一个是配了 CI 的。
没有自动跑测试,没有 lint 强制检查,没有提交时自动验证。测试要靠人肉想起来才跑一次,代码风格靠自觉。我知道这不对,但就是这么过来的。
为什么会这样?因为在做 Agent 项目的过程中,兴奋点永远在"让 Agent 做新事情"上。每次有了新想法,我打开编辑器就开始写,写完在本地跑一遍——"诶,能用了"——就提交了。配 CI?下次再说吧。加测试?先让功能跑起来再说。
这个"下次再说"一拖就是好几个项目。回头看,这是 Agent 开发中最容易掉进去的坑——新功能的正反馈太即时了(跑一遍就能看到效果),而质量基础设施的正反馈太延迟了(不出事你感觉不到它的价值)。
这一章,就是讲我后来终于把这些补上、补的过程中又踩了哪些坑、修正了哪些想当然的认知。
第一关:配 CI——以及它给我上的几课
终于下定决心配 CI,是在一个项目里。我打算用最务实的方式:先跑一遍 lint 和测试,看现状如何。
结果第一次跑 lint,炸了。四百多个错误。 四百多个。
我当时有点懵——这项目我一直在用,功能也跑得好好的,怎么 lint 一跑出来这么多问题?因为我从来没真正跑过 lint。工具一直在配置文件里声明着,但从没在环境里装过、从没跑过。这就是"配了不跑"的真实代价:你以为你有 lint,其实你没有。
第一课:CI 的真实成本,不只是"配半天"
清这四百多个错误,花了我远超半天的时间。自动能修的(比如没用的 import、格式问题)让工具自动修,修了一百多处;剩下得人工判断的一个个改。
但真正值钱的,是清债过程中翻出来的东西。其中有几个错误是"引用了未定义的变量"——这种错误在普通代码里早就崩了,但这几个偏偏藏在一个 except Exception: pass 里。什么意思呢?代码本来想用一个变量,结果那个变量没定义,抛了 NameError,然后被宽泛的 except 一口吞掉,程序静默地继续跑,什么错都没报。
这意味着一个功能——一个我以为是好的功能——其实从来没有真正工作过。它在每次调用时都安静地失败,因为异常被吞了,没人知道。我和任何 reviewer 都看不到这个问题,因为它不报错、不崩溃、只是默默不工作。只有静态分析能把它揪出来。
这件事给我上了一课,让我修正了一个想当然的认知。我以前以为 CI 最大的价值是"挡住有问题的提交"——这没错,但我低估了它另一个价值:它是唯一不带"我对这段代码的记忆和偏好"的客观反馈源。 人看自己的代码会带着盲区,reviewer(如果你有的话)也会累、会漏,但 lint 不会。它冷冰冰地把每一个违规都摆出来,包括那些被异常吞掉、肉眼永远看不见的静默 bug。
一个反复出现的模式
配 CI 这事,我也撞见了"统一原则 vs 分层落地"那个老问题——下面会讲,单人项目的 CI 和团队项目的 CI,最小可行形态根本不是一回事。
第二课:单人项目的 CI,最小可行形态是什么
我配 CI 之前,脑子里默认的 CI 长这样:代码 push 到远程仓库,触发一个云端流水线,跑测试、跑 lint,结果挂在网页上,红了就阻止合并。这是 GitHub Actions、GitLab CI 那一套——团队协作的标准配置。
但我这个项目是单人主干开发——没有 PR、没有 code review、直接推主干。团队 CI 的核心价值(挡住别人的代码、互相 review)对我来说根本不存在。那我的 CI 该长什么样?
我试了两种形态,对比之后有了结论:
- 云端流水线(push 后触发):反馈是延迟的——你 push 完,过一会儿才看到结果。等你看到红的的时候,你可能已经在写下一段代码了。
- 本地 pre-commit(commit 前触发):反馈是即时的——你想提交,它先拦住跑一遍,过了才让你提交。不过,提交都提交不上去。
我专门做了个行为测试验证后者:故意写一个必然失败的测试,git add 之后 git commit——结果被 pre-commit 拦住了,提交没进历史,零污染。这一刻我意识到,对单人项目来说,pre-commit 才是最小可行形态,而不是云端流水线。
不是说云端流水线没用——它是个"额外的客观环境"(干净的 CI 机器,没有你本地的各种配置),能抓出"在我电脑上能跑"这类问题。但它的核心价值(延迟的、远端的第二双眼睛)对单人项目是锦上添花,不是雪中送炭。真正能改变你开发习惯的,是那个在你指尖、commit 前就拦住你的 pre-commit。
这里还有一个意外收获。我把代码 push 到云端流水线后,原本预期它会红——因为 CI 机器上没有我本地的配置文件和密钥,我以为代码会因为这个隐式依赖而跑挂。结果它绿了。这反过来证明了一件事:我的代码并没有隐式依赖本地环境,它在没有配置时也有合理的默认行为。 CI 不只帮我抓 bug,还帮我证伪了一个悲观的假设,给了我"这代码比我想的更干净"的信心。
第二关:测试覆盖的失衡
配完 CI,下一个问题是:跑哪些测试?
这一关我没踩大坑,但它暴露了一个一直存在的失衡:我的测试精力,全花在了"好测"的部分上。
Agent 项目里,有些东西是好测的——核心算法、数据处理、各种边界和精度,用固定随机种子一钉,确定性测试写得又快又有成就感。但那些"难测"的部分——Agent 的行为、LLM 的输出质量、整条调用链——我基本没测。
为什么失衡?因为好测的部分有即时正反馈(写一个、过一个、爽一下),难测的部分连"怎么测"都要想半天(第三部分讲 eval 才解决了这个问题)。人自然会往有成就感的地方倾斜。
但没测的那一层,恰恰是用户直接打交道、最容易出问题的一层。你把最难测的东西留给了线上用户去测——这不是好策略。 第三部分(第 6、7 章)讲的 eval 方法论,本质上就是在补这一层:让"概率性的、难测的 Agent 行为"也变得可测。配 CI 之后,这套 eval 也应该接进流水线,每次提交自动跑——不只是跑确定性测试,也跑 eval 回归基线。
第三关:空壳闭环——以及一个比"空壳"更隐蔽的问题
第三关是个特别坑的问题:代码写了,但从来没被调用过。
我以前踩过这种坑:写了一个告警推送的功能,代码完整,本地调试也过了,但它从来没有被接进定时任务。也就是说,告警能检测到问题,但永远不会推送出来,因为没人触发它。还写过一个记录决策的函数,功能齐全,但业务代码从来没调用过它——表是空的,功能是空壳。
这种问题之所以坑,是因为它不报错。代码在那里,import 也正常,lint 也不报,测试(如果你写了的话)也是绿的——因为它自己内部是自洽的。唯一的"错"是它没被接进主干流程,而没有任何工具会主动告诉你这件事。
我以前发现这种问题,全靠偶然——哪天突然想起来"诶那个功能好像没接",去查一下,果然没接。靠偶然发现,显然不可靠。所以这次我试着提炼一套系统识别法。
第一步:grep 调用点,抓"完全空壳"
最直接的方法——对每个功能,grep 一下它在整个项目里被调用的地方。如果一个功能 grep 出来的调用点是 0,那它就是"完全空壳",铁板钉钉。
这一步很有效,但也让我发现一个意外:我用这个方法查了一圈,一个完全空壳都没找到。
第二步:列举"该用的所有路径",抓"部分接通"
完全空壳没有,但问题真的不存在吗?我把每个功能"语义上应该在哪些路径出现"逐一列出来核实。这一查,查出来两个问题——它们不是"完全没人用",而是**"主路径用了,边缘路径漏了"**:
- 一个功能在定时任务里用了,但在 Web 接口那条路径里没接——所以你从网页上操作,它不生效。
- 另一个功能在后台生成报告时走了,但在前端查询报告时没走——所以同一份数据,两条路径行为不一致。
这种"部分接通"比"完全空壳"隐蔽得多。完全空壳,grep 一下调用点为 0 就抓到了;部分接通,调用点是大于 0 的,grep 根本分辨不出"这个调用点该有却没有"。它需要人去判断"这个功能语义上还该在哪些路径出现"——这一步 grep 替代不了。
这让我修正了对"空壳"的理解。我以前以为空壳是个二元的概念——"写了没接通"就是空壳。实际上它是个光谱:
- 完全空壳:一个调用点都没有。grep 能抓,好发现。
- 部分接通:主路径用了,边缘路径漏了。grep 抓不到,要靠人列举路径逐一核实,难发现。
- 完全接通:该用的路径都用了。
真实项目里,最危险的不是完全空壳(它太明显,迟早被发现),而是那个"部分接通"——它在大部分时候工作正常,让你以为没问题,唯独在某个你没覆盖到的边缘场景里悄悄失效。
所以系统识别法是两步,缺一不可:
- grep 总调用点——抓完全空壳(容易)。
- 列举"该功能语义上该出现的所有路径",逐一核实——抓部分接通(难,要人判断)。
该补什么,什么优先级
讲了这么多,如果现在要给一个 Agent 项目补质量基础设施,我会按这个顺序:
P0:本地 pre-commit(CI 的最小可行形态)。 对单人项目,这是性价比最高的一步。commit 前自动跑测试和 lint,把"想到才手动跑"变成"绕不过的自动跑"。半天配好(前提是项目没有历史债务),立刻改变后续每一次提交的安全感。
P1:接通空壳闭环。 用上面那两步识别法,把"写了但没接通"的功能揪出来接上。这一步往往不写新代码,只是把已有功能串进主干流程。
P2:补难测那一层的测试。 确定性部分早测了,现在该补 Agent 行为层——用 eval(第三部分讲的方法)。接进 pre-commit 或 CI,每次提交自动跑回归。
P3:云端 CI(可选)。 如果你需要"干净环境"的第二双眼睛,或者以后会有协作,再上云端流水线。单人不一定需要。
这个优先级的逻辑是:先解决"不知道改坏了什么"(pre-commit),再解决"写了没用"(闭环),再解决"漏测"(eval),最后才是"额外的客观环境"(云端 CI)。
一个更深的问题:为什么总是排在后面?
诚实地说,上面这些我都知道该做,但很长一段时间没做。为什么?
不是不知道怎么做,也不是做不了。是Agent 开发的节奏天然排斥防御性工程。
你用 Claude Code 写代码,让它配一个 CI——它可以配,但配完之后你没有"跑一遍看看"的即时快感。CI 的价值在"下次提交时自动验证",而那种延迟的、隐性的价值,和"让 Agent 学会一个新技能"的即时正反馈比起来,太弱了。
所以防御性工程总是排在后面。不是它不重要,是新功能的诱惑太强。这个心理陷阱,我自己到现在还在跟它搏斗。
我能给的建议只有一个:别等。 别等"功能做完了再补 CI"——功能永远做不完。在你写第三个功能之前,花半天把 pre-commit 配上。它会改变你后续所有开发的安全感——而且,像我这回清债时翻出那几个静默 bug 一样,它很可能会帮你发现一些你以为"一直好好的"功能,其实从来没真正工作过。
这一章的工具:质量门禁检查清单
🔧 质量门禁检查清单
对照你当前的 Agent 项目,过一遍:
CI(最基本)
- [ ] 提交代码时,有没有自动跑测试?
- [ ] 有没有自动跑 lint 和类型检查?
- [ ] 你是单人项目吗?如果是——pre-commit 配了吗?(它比云端流水线更改变你的习惯)
- [ ] 测试不通过时,能不能阻止提交?
测试覆盖
- [ ] 你的测试覆盖了哪些层?(算法层?HTTP 层?数据层?Agent 行为层?)
- [ ] 有没有"测了好测的部分,难测的 Agent 行为层完全没测"的失衡?
- [ ] eval 用例跑完的结果,接进 CI 自动比对了,还是靠肉眼判断?
闭环
- [ ] 有没有"代码写了但从没被调用"的功能?(告警、日志、审计……)
- [ ] 第一步:grep 每个功能的调用点,0 个的就是完全空壳
- [ ] 第二步:列举每个功能"语义上该出现的所有路径",逐一核实——抓部分接通
- [ ] 你的告警能真的推送到你手里吗?还是写完了但没接通?
危险信号
- [ ] 你做的所有项目都没有 CI → 你和上面那些坑之间只差一个提交
- [ ] 你的测试全集中在某个层 → 未覆盖的层就是下一个 bug 的来源
- [ ] 你有"写了但没接通"的功能 → 不接通等于没写
- [ ] 你的项目从没跑过 lint,但配置文件里声明着 → 你以为你有 lint,其实你没有
小结
这一章的核心就一句话:"能跑的 demo"和"可靠的生产系统"之间,差的就是这些不性感但不能没有的东西。
补它们的过程,修正了我好几个想当然的认知:CI 的成本不只是配半天,还有清债(而且清债能翻出静默 bug);单人项目的 CI 最小可行形态是 pre-commit 不是云端流水线;"空壳"不是二元的,"部分接通"比"完全空壳"更危险也更难发现。
这些东西之所以总被忽略,不是不知道重要,是新功能的即时正反馈太强了,防御性工程永远排在后面。但只要开始补,你就会发现它带来的不只是"少出 bug",还有一种"我知道我的代码是什么状态"的踏实感——这种踏实感,是"跑一遍看看"永远给不了的。
下一章
第 13 章 · 驾驭曲线 —— 你和智能体的协作关系,经历了什么变化?四个阶段,每阶段的标志是什么?