如果半年前有人问我,怎样让 AI 长期维护一个项目,我大概会回答:把需求写清楚,先写 PRD 和技术方案,不要只靠一句 Prompt。
现在我的答案会多一半:Spec 很重要,但只有 Spec 还远远不够。
我用 Claude Code 维护了一个自托管的家庭资产管理系统。从 2026 年 5 月 6 日到 8 月 2 日的仓库快照,一共积累了 536 次 commit。技术栈是 Java 21、Spring Boot 3.3、MyBatis、MySQL、Thymeleaf 和 HTMX;目前生产 Java 约 3.07 万行,仓库里有 24 份 PRD、25 份技术设计、52 个数据库迁移和 438 个 JUnit 测试。
文档已经很多,测试也不少。但项目仍然出现过下面这些情况:
- PRD 明确写着"切换显示币种不能改变收益率",同一类 bug 还是修了三轮;
- 守护测试显示"券商入口存在",用户却说功能丢了;
- MySQL 健康检查是绿色,应用却一直因为密码错误重启;
- 备份脚本打印"成功",真正恢复时才发现所谓
.sql.gz根本没有经过 gzip。
它们有一个共同点:实现通过了某个判据,但那个判据没有在验证用户真正需要的结果。
这也是我后来开始认真理解 Harness Engineering 的原因。问题不只是"AI 有没有读懂 Spec",而是整个仓库有没有给 Agent 一条可靠的工作回路:从哪里拿上下文、如何操作环境、用什么证据判断结果、失败后怎样修正,以及走到生产边界时在哪里停下。

Prompt、Spec 和 Harness,不是三个近义词
我现在这样区分它们:
ini
Prompt = 这一次要做什么
Spec = 什么结果才算正确,以及明确不做什么
Harness = Agent 为了完成任务,能够读取、操作、验证和纠错的整个工程环境
换成一个更具体的表达:
markdown
Harness
= 可导航的仓库知识
+ 可复现的运行环境
+ Agent 能直接使用的工具
+ 接近真实结果的分层判据
+ 失败后可继续迭代的反馈
+ 不能越过的权限闸门
Spec 是 Harness 的重要输入,但不是全部。
你可以在 PRD 里写一百遍"移动端必须可用"。如果 Agent 看不到手机布局、不能启动应用、不能截图,也没有任何判据检查遮挡和横向溢出,这句话仍然只能靠人最后发现问题。
你也可以写"备份必须可靠"。如果验收只检查目录中出现了一个 .sql.gz 文件,Agent 很容易得到一个全绿但不可恢复的系统。
OpenAI 在 2026 年发布的 Harness engineering 文章中,把工程师的工作概括为设计环境、表达意图和建立让 Agent 可靠工作的反馈回路;其中一个关键判断是:从 Agent 的视角看,运行时无法读取和验证的东西,实际上就不存在。
Anthropic 在 Demystifying evals for AI agents 中还区分了两种概念:Agent harness 让模型能够接收输入、调用工具并执行任务;evaluation harness 则负责运行任务、记录过程、判定结果和汇总评估。
落到普通业务项目里,我认为不必过分纠结术语边界。真正需要回答的是:Agent 完成一次改动后,凭什么知道自己做对了?
我踩过的四次"假绿",其实都是 Oracle 选错了
测试领域经常把"判断结果对不对的机制"叫作 Test Oracle。AI Coding 把这个问题放大了,因为 Agent 会非常积极地优化到你给它的判据。
判据只检查字符串,它就会让字符串存在;判据只检查文件,它就会让文件生成;判据只问服务有没有响应,它就会让服务返回响应。
至于用户能不能找到入口、文件能不能恢复、凭据能不能真正登录,并不在这个判据里。
1. Spec 写对了,但局部测试没有覆盖系统不变量
这个系统支持 CNY、USD、HKD 三种显示币种。规则并不复杂:
scss
金额类:amount(view) = amount(base) × fx
比值类:ratio(view) = ratio(base)
净资产切成美元显示后数值应该缩放,但收益率、负债率、资产占比和紧急储备月数不能改变。
这条规则一直写在文档里,AI 也能在每次对话里解释得很正确。但实际代码仍然修了三轮:有时只换算了分子,有时多期窗口缺少锚点汇率,有时第三币种缺少经本位币的三角换算。
问题不是 Spec 不够清楚,而是验证总盯着当前页面的几个固定值。后来我把判据改成属性:对同一份业务事实生成三个币种视图,金额必须按因子缩放,所有比值必须完全相等。下面是从仓库真实测试 CurrencyInvarianceTest 中简化出来的核心断言,省略了测试数据构造和其他金额字段:
scss
@Test
void ratiosStayInvariantWhileAmountsScale() {
KpiSnapshot cny = kpisFor(BigDecimal.ONE, "CNY");
KpiSnapshot usd = kpisFor(new BigDecimal("6.774"), "USD");
assertThat(usd.debtToAssetRatio())
.isEqualByComparingTo(cny.debtToAssetRatio());
assertThat(usd.monthlyInvestReturnPct())
.isEqualByComparingTo(cny.monthlyInvestReturnPct());
assertThat(usd.netWorth())
.isEqualByComparingTo(cny.netWorth()
.multiply(new BigDecimal("6.774")));
}
这层 Harness 的变化是:从"记住这个规则",变成"任何新指标都要经过同一个不变量"。AI 不需要重新理解三年前那次讨论,只要改坏就会收到确定的失败反馈。
2. DOM 里有链接,不等于用户看得见
项目曾有一条名为"券商入口在账户页"的静态守护:
bash
grep -q '/broker(id=' accounts/index.html
后来一次 UI 整理把"券商"按钮收进了没有文字的 ⋯ 菜单。链接还在模板中,因此守护一直通过;用户却直接问我:"这个功能是不是丢了?"
对用户来说,"还在但找不到"和"已经删除"没有区别。
这里需要的 Oracle 不是字符串存在,而是双端真实渲染以后,入口能看见、没被遮挡,而且不用先展开隐藏容器。
现在项目用 scripts/entry-points.json 登记能力入口,并在 PC 1440×900 和手机 390×844 两种布局中运行浏览器检查:
ini
a.scrollIntoView({ block: 'center', inline: 'nearest' });
const rect = a.getBoundingClientRect();
const hasArea = rect.width > 1 && rect.height > 1;
const collapsed = !!a.closest('details:not([open])');
const inMoreMenu = !!a.closest('.row-more-pop');
let top = document.elementFromPoint(
Math.round(rect.left + rect.width / 2),
Math.round(rect.top + rect.height / 2)
);
while (top && top !== a) top = top.parentElement;
const visible = hasArea && top === a && !collapsed && !inMoreMenu;
原来的 grep 没有删,它仍然适合快速发现链接被误删。只是它不再冒充用户可见性测试。
这层 Harness 的变化是:静态事实和运行时事实分开验证,并且测试名称不能承诺超出自身能力的东西。
3. 数据库有响应,不等于应用能使用数据库
Docker 安装流程里,我曾经用 mysqladmin ping 检查数据库是否就绪。一次用户重新克隆项目后,新 .env 中的随机密码和旧数据卷中的密码不一致,应用因此不断报 Access denied。
诡异的是,数据库容器一直显示 Healthy,入口脚本也打印"MySQL 就绪"。
复现后才发现,mysqladmin ping 在密码错误时仍然可以返回退出码 0。它回答的问题是"服务端有没有响应",不是"这组凭据能不能完成一次数据库操作"。同一个错误原语被 Compose healthcheck 和入口脚本复用,于是两层一起假绿。
旧判据:
bash
mysqladmin ping -h db -u "$DB_USER" -p"$DB_PASS"
新判据:
bash
mysql -h db -u "$DB_USER" -p"$DB_PASS" \
-Nse 'SELECT 1'
真实查询失败后,脚本不会继续猜"数据库可能还在初始化",而是识别认证错误,在不删除业务数据的前提下进入凭据同步流程;无法安全修复就停下。
这里还有一个容易漏掉的 Harness 设计:判据从宽变严之后,要检查谁把它当作控制流。 我第一次把 healthcheck 改成真实查询后,depends_on: service_healthy 让 docker compose up -d 直接非零退出,而 set -e 又在自愈逻辑之前结束了脚本。Oracle 改对了,调用链却没跟上。
所以一次判据升级至少要检查三件事:它判断的命题是否正确、失败信息是否足够修复、上游和下游会怎样消费这个失败。
4. 备份文件存在,不等于备份可以恢复
另一次更危险。备份脚本生成了一个名为 finance-xxx.sql.gz 的文件,du -h 也能看到大小,于是脚本打印"备份成功"。
真正执行恢复时才出现:
lua
gzip: not in gzip format
原因很朴素:Docker 分支把 mysqldump 输出直接写进了 .sql.gz,文件名有 .gz,中间却根本没有 gzip。更糟的是,恢复前自动创建的 before-restore-* 退路也复用了同一段错误逻辑。
原来的 Oracle 是:文件存在且大小大于 0。
现在至少检查:
perl
mysqldump ... | gzip -9 > "$backup"
gunzip -t "$backup"
gunzip -c "$backup" | grep -q 'CREATE TABLE'
但这仍然只证明压缩包能解开、里面像一份 SQL。真正的验收是闭环往返:
rust
写入标记数据
-> 备份
-> 删除标记
-> 恢复
-> 标记必须回来
-> 服务健康
恢复前生成的退路也要再恢复一次,证明它真的能撤销本次恢复。
这层 Harness 的变化是:对备份、迁移、发布和回滚这种低频高风险路径,不能只验证动作发生,必须验证承诺的结果。
四个事故之后,我才看清 Harness 应该长什么样
入口测试、数据库探针和备份脚本看起来是三个不相关的问题。把它们放在一起后,可以抽象出一条更稳定的工作回路:
rust
给 Agent 一张仓库地图
-> 让它在可复现环境里完成改动
-> 用不同成本的 Oracle 逐层反馈
-> 把失败原因和修复方向重新喂给 Agent
-> 直到最接近用户结果的判据通过
-> 在外部状态变化前等待人工授权
第一层:知识必须可导航,而不是全部塞进上下文
项目现在用 AGENTS.md 记录产品边界、环境拓扑、页面地图、研发流程和跨模块联动。例如改收入来源时,必须同步事实层、家庭现金流、Dashboard 空态和报表;新增比值指标时,必须进入币种不变性测试。
但我也踩到了"大而全说明书"的反面:当前 AGENTS.md 已经有 222 行,早期"不做持仓"的描述和后来已经上线的能力发生过冲突。文档存在不代表它仍然可信。
更合理的结构应该是:短入口只做地图,领域知识分散到有所有者、能交叉链接、能检查新鲜度的文档中。Agent 先看到路线,再按任务逐层读取,而不是开局吞下一本百科全书。
项目下一步需要补的不是更多规则,而是文档结构检查、陈旧规则扫描和定期清理。这一点也与 OpenAI Harness Engineering 文章中"给 Agent 地图,而不是一千页说明书"的经验一致。
第二层:把应用本身变成 Agent 可以读取的对象
只让 Agent 看代码,很多问题永远不可见。因此项目给它的工作面不只有 Maven:
- 可以启动独立 beta,使用合成数据完成登录和用户路径;
- 可以访问数据库真值,而不是只读 Controller 返回值;
- 可以读取日志、健康状态、版本和迁移结果;
- 可以在 PC 和移动视口截图,检查 Canvas、遮挡、横向溢出和隐私模式;
- 可以运行备份、恢复、部署和诊断脚本,并验证副作用。
这不是给 AI 增加几个花哨工具,而是减少人类在中间搬运证据。如果错误只存在于我看到的一张手机截图里,而 Agent 看不到,那张截图对当前执行过程就等于不存在。
第三层:建立有成本梯度的反馈
并不是所有改动都要一上来跑完整端到端。当前项目大致有这样的反馈梯度:
rust
编译 / 类型检查
-> JUnit 公式和领域不变量
-> 静态守护(跨文件联动、禁止项、迁移结构)
-> HTTP + 数据库真值的端到端主线
-> 浏览器双端渲染与截图
-> beta 用户路径
-> 发布、镜像、健康检查与回滚验证
越靠前越快、定位越直接;越靠后越接近用户结果,但成本更高。
Harness 的目标不是用最重的测试包住一切,而是让错误尽可能早地在最便宜且足够真实的一层失败。静态 grep 能判断的,不需要浏览器;但用户可见性绝不能停在 grep。
第四层:失败信息本身也是 Agent 的上下文
传统 CI 里一句 test failed 可能还够人类顺着堆栈排查。Agent 工作时,错误信息应该尽量直接告诉它:哪个不变量被破坏、证据是什么、应该去看哪些文件。
例如入口检查不会只打印 FAIL,而会区分:DOM 中根本没有链接、元素无面积、被其他元素遮挡、藏在 ⋯ 菜单,还是二级页面进入路径不存在。
好的 Harness 不只是把门关上,还要把下一轮修正所需的信息放回循环里。否则 Agent 只会重新猜一次。
第五层:验证通过不等于获得发布权限
项目允许 Agent 自主修改代码、执行测试和提交 commit,但 tag、push 和生产发布是另一类外部状态变化。
发布流程先执行 preflight,检查工作区、版本、迁移、测试和文档联动,然后必须停下。只有维护者返回与目标版本完全一致的确认串:
arduino
release vX.Y.Z
流程才继续打 tag、推送、备份、迁移、部署和验证。版本不一致或者没有回复,就不能继续。
这道闸门不负责判断代码质量,它负责表达责任边界:Agent 可以证明"我认为可以发布",但不能因此推导出"我已经获得修改生产环境的授权"。
怎样把一次事故真正沉淀进 Harness
我现在不会在每次出错后直接加一句"以后注意"。更可复用的处理顺序是:
markdown
1. 找出这次通过但实际说谎的旧判据
2. 写清用户真正依赖的结果
3. 选择当前成本下最接近该结果的可观测证据
4. 先把故障放回去,证明新判据会红
5. 修复后证明它会绿
6. 把失败原因和修复入口写进输出
7. 回头审计:已有守护里还有多少条在犯同一种错
第 4 步非常重要。只在修复后的代码上跑一次绿灯,不能证明测试真的抓得住问题。
第 7 步也很重要。项目在 v1.6.14 已经记录过"显示不等于看得见",但当时只把它当成以后写新测试的提醒,没有回头检查已有的入口守护。结果到 v1.6.23,同一种错误换了个页面再次出现。
教训被写下来,不等于教训已经生效。 只有旧错误重新出现时能自动失败,它才真正进入 Harness。
这套 Harness 目前仍然不够好
写到这里不能假装问题已经解决。
第一,仓库知识仍然偏集中。AGENTS.md 太长,PRD、技术设计、README 和页面工程数字之间仍可能发生漂移。当前不同文档里的测试数量就出现过不同步,这说明"发布前检查文档数字"的 Harness 还没有覆盖所有权威源。
第二,静态守护数量很多,其中一部分仍然基于 grep。它们便宜有效,但也容易被注释、示例文案和相同字符串误导。必须持续把高风险判据升级到语义、数据库或运行时层。
第三,浏览器检查在没有 Chromium、应用没有启动时会返回 SKIP。开发机允许诚实地 SKIP,但正式 CI 如果也接受 SKIP,这道防线就只是装饰。
第四,真实券商同步需要用户自己的账号和凭据,无法在公开 CI 中完整复现。目前只能用 mock 覆盖 reconcile 逻辑,再由维护者在 beta 做真机确认。这是明确的验证缺口,不能用单测数量掩盖。
Harness Engineering 不是"一次搭好测试平台",而是持续寻找系统中还需要人脑补、需要人搬运、或者会产生假绿的地方,再把它们逐步变成 Agent 可读取、可执行、可失败的能力。
最后
Spec Coding 解决的是:不要让 AI 在需求和边界不清楚时直接写代码。
Harness Engineering 继续追问:即使需求已经清楚,Agent 在一个长期项目里怎样拿到正确上下文、怎样验证真实结果、怎样从失败中继续修正,以及怎样避免越过人的授权边界。
模型能力还会继续变强,但模型越能快速生成代码,反馈回路的质量就越重要。因为它不仅放大正确实现,也会放大错误 Oracle、陈旧文档和虚假的绿灯。
本文中的案例来自我维护的开源项目"家庭账房"。它是一套自托管的家庭资产快照、收益归因和风险分析工具。下面这些 Harness 产物都在公开仓库中:
- 项目地图与不变量:github.com/LuoDi-Nate/...
- 功能入口运行时判据:github.com/LuoDi-Nate/...
- QA 用例和事故记录:github.com/LuoDi-Nate/...
- 部署、备份和恢复脚本:github.com/LuoDi-Nate/...
- 完整源码:github.com/LuoDi-Nate/...

利益关系说明:我是项目维护者,项目采用 Apache 2.0,无付费版。文中项目数据基于 2026-08-02 的 master@67deb53;公开页面和截图均为合成演示数据,不是真实家庭资产。