一个客户项目的 435 个测试:AI 编码怎么验收

一个客户项目的 435 个测试:AI 编码怎么验收

最近在一个客户项目里加一项学习规划能力。用户填教材名称、每天可投入时间和任务截止日期,后台几分钟后交付一份 PDF:里面有三阶段起止日期、每周章节、每日时间块、阶段验收标准和资料搭配。用户在对话里说「我想规划这本书」时,助手只调出填写入口,不在聊天里直接编一份计划。

这个功能的链条比入口看起来长:章节目录要核实,日期要按服务端时钟排,模型只负责写其中一部分文字,报告要渲染成 PDF,文件要做水印和大小校验,任务失败时还得说清楚是哪一步失败了。

蓝图工程线稿:用户填写教材与时长后,任务排队、核实目录、生成骨架、模型补叙事、渲染 PDF,最后下载后删除或七天过期

这类任务交给 agent 时,最容易写坏的是任务票本身。如果票上只写「实现规划功能」,agent 很快能交出一份看起来完整的实现:接口有了,页面能调,模型也能返回文字。问题会留在后面。日期可能被模型算错,目录可能被模型编出来,任务状态可能在服务重启后丢失,PDF 可能根本不是合法文件。每一处都等到用户点开报告时再发现,验收就成了看运气。

我们最后把这个需求拆成四张小票:一张只管入口,一张只管任务受理和异步执行,一张只管报告文件的后处理、一次性下载和超期清理,一张只管删除、导出和权限边界。每张票都写清楚输入、输出、不能做什么、跑什么命令、交付时拿什么证据。这样拆完,agent 做的事情反而简单了:按票实现,按票验收。

一张任务票先写验收,再写实现

第一张票看起来很小:用户在对话里表达想做规划时,助手只调出表单入口,不在聊天里直接生成规划。这个边界之所以要单独成票,是因为它很容易被实现偏。模型很擅长顺着用户的话往下写,给几个阶段、排几周计划、再补几句鼓励,表面上像完成了需求,实际上把产品的责任边界弄混了。完整报告是异步生成的,聊天里多生成一次,就多一份不受状态机和文件校验约束的产物。

这张票的验收因此写得很具体:对话里只返回入口信号,接口文档同步更新,相关意图枚举同步更新,全量测试和编译检查都要过。它没有涉及复杂算法,但它把「规划从哪里开始」钉住了。

第二张票才开始碰执行链路:新增任务接口,建任务表,写异步执行器,封装托管智能体调用,处理重试和超时。这张票有一个关键限制:外部服务只允许从一个客户端边界进出,测试里用假的传输层把整个流程跑通。这样一来,执行器的逻辑不需要依赖真实上游才能验收;上游返回什么状态、事件历史什么时候出现、任务什么时候算失败,都可以在本地写进测试。

我们后来把一张合格任务票固定成几项内容:输入和输出写到字段级,禁止动作写清楚,验证命令写清楚,交付证据写清楚,涉及线上行为时再补回滚方式。少了这些东西,agent 不是不能写代码,是写完以后没人能快速判断它做对了没有。

蓝图工程线稿:一张任务票被拆成输入、输出、禁止动作、验证命令、交付证据、回滚方式六个栏位

把模型不该做的事从任务里拿走

规划报告里最容易出错的是日期和章节。模型可以写得像模像样,但它不应该负责计算今天离考试还有几天,也不应该凭印象补一本书的目录。这个项目把这些内容全部放到了确定性代码里:服务端按权威时钟计算阶段起止日期,按周期截止日拆分基础、强化、冲刺阶段,把章节摊到每周,再生成每日时间块。

这份 PDF 面向读者的部分包括教材与考试对应关系、关键认知、总体节奏、分阶段详案、每周时间模板、资料与工具搭配、三条红线、适用变体和免责说明。读者看到的是一份完整报告,工程上它被切成两半:骨架由代码生成,叙事由模型补全。

模型拿到的是已经排好的骨架,只负责补叙事。输出必须是固定 JSON,字段缺一不行,关键认知少于两条不行,红线少于三条也不行;解析不出合法 JSON 时,系统会先要求同会话重出一轮,仍然不合法才把任务判为内容校验失败。

报告渲染也不让模型碰表格。周计划、阶段日期、每日时间块直接来自骨架 JSON,PDF 由服务端渲染。模型生成完文字后,还要过文件校验:产物必须是合法 PDF,页数和大小不能超过上限,水印处理失败就直接判定内容不合格。这个结构的作用很具体:把模型可能编错的地方尽量从它的任务里拿掉。

蓝图工程线稿:教材、每日时长、截止日期进入确定性骨架,模型只补叙事 JSON,日期与周表绕过模型进入 PDF

验收要能分清是谁错了

异步任务最常见的假完成,是状态看起来对了,事情还没开始。这个项目里就遇到过一种情况:消息发给托管智能体之后,很短时间内会话状态仍然是空闲,事件历史里也只有用户消息。如果验收逻辑只看「状态空闲」和「还没有 agent 事件」,会立刻把任务判成空回复失败。真实情况是上游刚受理,agent 还没开始写。后来这条行为被写进等待逻辑:没有 agent 事件时继续等,不能提前判败。

蓝图工程线稿:发送消息后状态仍空闲且只有用户消息,正确动作是继续等待 agent 事件出现,不能提前判败

另一次问题出在错误映射。新凭证没有目标模型权限,上游返回的是请求无效;第一版实现把它归成了「目录找不到」。这两个错误对用户来说都表现为失败,但对运维来说完全是两条路:一个要改凭证和权限,一个要补书名或版本。验收如果只确认「会失败」,这种错就能混过去;验收要求错误码准确,它就必须被修掉。

这也是我们把外部服务包成唯一边界的原因。实现错误、上游协议错误、配额不足、目录核实失败、内容校验失败,要在测试里分别出现,分别映射。agent 可以写这些代码,但验收标准不能让 agent 自己解释「我觉得它失败了」。每种失败都要有名字,有状态,有可复现的输入。

蓝图工程线稿:任务入口分流到配额不足、服务不可用、目录未核实、任务超时、内容未通过、权限无效六张故障卡

435 个测试对应的是具体边界

截至 2026 年 9 月 26 日,这个仓库里有 264 次提交、21 个活跃开发日、435 个测试函数。测试数字本身不值得炫耀,它既不代表没有 bug,也不代表每张票都拆得好。它值得写下来的原因,是这些测试大多对应一次具体边界:日期由谁算,输出缺字段怎么办,任务超时怎么落库,用户删除任务后本地文件和远端会话怎么清理,无权限用户为什么不能看见别人的任务。

下载和清理也有明确功能边界:报告下载一次后服务端删除副本,七天未下载自动过期;用户删除任务时,本地文件和远端会话一起清理;跨用户访问返回不存在。这些行为都在验收范围内,不靠运维记忆里的一句「应该会删」。

这个数量是一路加上去的。9 月 1 日连续几张票关闭时,测试数从 301 涨到 307,再到 308;9 月 11 日一次思考流改动合并时,全量测试是 424;到今天再数,是 435。中间新增的很多测试并不聪明,只是把一次真实翻车固定下来:某个错误不能映射错,某个状态不能判断早,某个字段不能缺。

蓝图工程线稿:测试数量从 9 月 1 日的 301、307、308 增长到 9 月 11 日的 424,再到 9 月 26 日的 435

AI 编码改变了测试在评审里的位置。人手写代码时,测试当然重要,但人还能靠上下文记忆和局部推断补一些漏洞。agent 写代码时,任务边界和测试就是它能拿到的主要上下文。票写得越含糊,它越容易把实现写宽;测试越少,人越只能逐行读代码。把验收写进测试以后,评审可以先看失败列表和新增测试,再回到真正需要判断的地方。

人守住不能委托的门口

这个项目的仓库规则很硬:任何改动都从任务开始,在独立 worktree 和分支里完成,验收跑全量 pytest 和 compileall,合回常驻分支时用 --no-ff 保留来路。发布另有一整套门槛:发布前确认工作区干净、测试和编译通过,部署后做健康检查,针对本次新行为跑真实冒烟,再看日志;验证失败就回滚,问题当场修或登记,不能只在对话里说一句「下次处理」。

这些门口都可以由 agent 准备材料,但不适合由 agent 签字。生产凭证、数据库结构变化、最终合并、发布和回滚,仍然由人负责。agent 可以说测试通过了,也可以给出日志和冒烟结果,人不能只看这一句结论。我们要看的是命令、输出、改动范围和失败记录。

蓝图工程线稿:任务票经过独立 worktree、全量测试和编译检查后到达人工门,门后是人工合并、发布冒烟和失败回滚

这套流程会让派活变慢。写票、补验收、跑全量测试,都要时间。它换来的是后面的时间:agent 写偏了,测试会先红;上游协议变了,边界测试会先红;真机冒烟失败,发布门口会拦住。对一家小团队来说,这比事后靠记忆追问题便宜。

这个项目接下来还会继续加测试。新增测试大多会来自一次具体失败:哪个错误映射错了,哪个状态判断早了,哪个输出字段漏了。下一篇如果继续写这条线,应该写多张任务票并行之后,合并和评审怎么处理。

这个号会继续记录我们把 AI 用进真实交付时留下的规矩。我们之前从方法论讲过 AI 编码的门槛,这一篇写的是门槛落到一张任务票上的样子。

相关阅读:

返回博客