一次实时验证运行了两分钟,最后只留下一行:timeout。

这行结果足以让验证门禁(gate)变红,却不足以支持下一轮开发。接手的智能体不知道系统走到了哪里:模型是否选择了工具,工具是否开始执行,外部请求是否返回,结果是否已经保存,还是应用只差最后一个终态事件。下一轮修改因此只能从重新翻日志和猜测故障位置开始。

这时,我们很容易选择增加超时时间、补一次重试,或者修改一个看起来最可疑的组件。但这些改动也许与真正的问题无关。更糟的是,它们可能改变原本正确的产品行为,只为了让下一次测试更容易通过。

我后来意识到,这道门禁只完成了一半工作。它阻止了未经证明的结果继续发布,却没有为下一轮开发留下足够证据。

在大语言模型智能体(LLM agent)参与的开发循环中,后一半工作越来越重要。智能体可以快速阅读代码、修改实现并重新运行测试,但下一轮修改是否准确,很大程度上取决于上一道门禁除了 PASS 或 FAIL,还留下了什么证据。

一个更完整的循环应该是:

实现
→ 门禁执行
→ 契约判决 + 过程观察
→ 确定最后可证阶段
→ 找到下一个调查边界
→ 下一轮窄修改

这套方法可以压缩成一句话:

Observe semantics; assert contracts; diagnose trajectories.

门禁不只是判定器

这套方法并不减少必须通过的硬性门禁(hard gate)。接口不允许出现的内容仍然必须拒绝,要求完成的生命周期仍然必须到达终态;对发布源码、制品和校验和的检查,也不能因为系统存在不确定性就放松。

首先要明确什么信息有资格决定成败,也就是判定权威(authority source)。这里的 authority 指契约或事实的权威来源,不是权限系统里的操作授权。成熟的门禁至少要区分三项职责:

职责 权威来源 结果
契约断言 定义该行为的产品、协议、发布或测试契约 违反时直接判定失败(hard fail)
诊断观察 运行时事件和已有生命周期接口 描述次数、顺序、耗时、阶段和相关状态
判定充分性 验证器自身的证据契约 证据不足时不得给出超出证据范围的根因结论

契约断言决定 GREEN 或 RED。诊断观察(observation)记录这次运行中能够被证明的过程事实。判定充分性则约束验证器能够把结论说到多具体:一个超时或意外计数可以判定契约没有得到证明,却未必足以把根因归给某个组件。

三者可以放进同一条证据流:

                  ┌─────────────────────────┐
                  │ owning product contract │
                  └────────────┬────────────┘
                               │
                               ▼
runtime events → observations → semantic assertions
      │               │                │
      │               │                └─ GREEN / RED
      │               │
      │               └─ counts / timings / ordering
      │
      └─ existing lifecycle/read surfaces

on RED:
observations
   ↓
oracle sufficient?
   ├─ no  → root cause INCONCLUSIVE
   └─ yes → classify narrow failure boundary

release:
only semantic GREEN evidence is authoritative
diagnostic RED evidence is retained but never promoted to release proof

这张图也说明了过程观察的位置。Observation 不是要求更低的 assertion,也不等于日志越多越好。原始事件需要先去重、按身份和时间关联,再映射到有明确语义的阶段,才能成为下一轮开发真正可用的证据。失败(RED)时的诊断依据可以保留,但正式发布证据必须来自语义验收通过的结果(semantic GREEN)。

协作系统需要生命周期,而不只是计数

设想一个父智能体可以创建子任务。代表性测试要求它创建一个目标子任务,取得结果,然后完成父任务。

为了让模拟脚本容易编写,测试可能预设一条理想轨迹:创建请求只能出现一次,父任务产生三次模型响应,子任务产生一次响应。真实运行如果出现两个创建请求和第四次父任务响应,门禁就报告数量不符。

这个结果对下一轮开发的帮助很小。两个创建请求只能证明模型提出了两次请求,不能证明运行时实际创建了两个子任务。第四次响应可能包含重复创建请求,也可能是合法的工具续写,或模型在后续一轮中的生成结果。只有继续观察生命周期,才能区分这些情况。

模型请求创建
    < 运行时完成创建调用
    < 子任务获得身份并开始运行
    < 子任务进入终态
    < 父任务取得结果
    < 父任务完成目标

在同一个任务身份和明确的生命周期契约下,越靠后的阶段证据通常能够证明更多前置条件已经满足。单纯的次数只说明某类事件出现了多少次;要判断这些事件是否属于同一次任务、必要的前置关系是否成立,还需要关联任务身份并追踪生命周期。

因此,协作门禁的硬断言应关注目标子任务是否实际创建、是否完成,以及父任务是否取得结果并完成目标。同时,它可以输出创建请求数、实际子任务数、等待次数、父子任务状态和相对时间。

根据这些观察,下一轮需要检查的位置也会不同。如果模型请求两次,但运行时只创建一个子任务,产品语义可能正确;若要减少成本,应检查提示或模型规划。如果创建请求已经出现,但运行时没有完成创建,应检查工具适配。如果子任务已经完成,父任务却没有继续,应检查等待结果或父任务续写。

即使本次语义验收为绿色,这些观察也有价值。额外请求和多余续写可以成为下一轮效率优化的依据,但不应据此判定当前产品行为不正确。

并发顺序也是同一类问题。不同任务的请求可能合法交错。如果系统只保证每个任务内部的顺序,测试就不应要求所有请求按照一个全局脚本到达。门禁可以在内部使用任务身份做关联,对外报告必要的局部先后约束是否成立。这样,智能体看到的不是“第 4 个请求不符合预期”,而是“两个任务各自的局部顺序正确,但测试匹配器把两条轨迹混在了一起”。修复位置自然落在测试,而不是产品调度器。

超时应该留下阶段快照

再看开头的长耗时操作。一次完整执行可能经过这些阶段:

模型选择工具
→ 工具请求出现
→ 操作开始
→ 外部请求返回
→ 结果完成处理和保存
→ 模型根据工具结果继续生成
→ 应用发布最终终态

外层截止时间到达,只能说明在这段验证时间内,验证器尚未取得足以证明预期结果的证据。这个事实本身既不能证明外部服务卡住,也不能证明工具已经或尚未启动。

截止时间到达时,门禁应停止等待,读取已有状态,并保存一个不泄露敏感信息、且范围和大小受限的阶段快照。例如:

semantic_acceptance: not_proven
last_proven_stage: operation_completed
next_unproven_stage: post_tool_continuation
root_cause: inconclusive
oracle_completeness: sufficient

这个结果仍然是红灯,但它已经把调查范围缩小:操作本身有完成证据,下一步更应该检查工具后的模型续写和终态发布,而不是先重写外部客户端。这里的 root_cause: inconclusive 并不与阶段证据冲突;它表示边界已经缩小,但现有证据仍不足以确定唯一根因。

相对时间也可以帮助判断:工具请求何时出现,操作何时开始和完成,父任务回复与最终终态是否出现。这些时间默认是诊断数据,不是产品不变量。它们的价值在于显示停滞发生在哪一段。

对超时的命名会直接影响后续判断。无论外层窗口是 120 秒还是 180 秒,它首先只是验证器记录的一次超时,用来限制本次实验的时长。除非定义该行为的产品契约明确给出服务等级目标(SLA),否则不能把这个数字写成产品必须遵守的响应时间。

还要为诊断收尾留出时间。if: always() 只有在 runner 能继续执行后续步骤时才有作用;如果 job 自己先被强制终止,证据仍然无法上传。更安全的关系是:

scenario deadline
+ state snapshot / evidence flush / upload margin
< job timeout

同理,验证器可以把 runner_turn_submission_count = 1 设为硬断言,因为它自己控制提交次数;一次交互回合(Turn)内模型调用多少次后端、产生多少次续写,则属于产品执行轨迹。含义模糊的 operation_count 很容易把两者混为一谈。

如果门禁除了“截止时间已经到达”以外没有可靠阶段证据,就应诚实输出 oracle_completeness: insufficient。语义验收仍然失败,但现有证据不能支持更具体的根因分类。产品是否通过,与验证器是否知道为什么失败,是两个不同问题。

诊断能力本身也可以成为验证基础设施的契约。观察值通常不直接决定产品成败,但关键门禁可以要求:失败以后必须留下规定范围内的阶段证据。完整诊断不能把红灯变成绿灯,它只是减少团队在证据不足时修改错误组件的概率。

实践中需要检查哪些关注点

不是每个测试都需要阶段观察。编译错误、格式错误和直接的纯函数断言通常已经能给出足够局部的失败信息。过程观察更适合跨越多个组件、且失败可能发生在不同责任边界的关键门禁,例如模型决策、工具调用、并发任务、外部服务、长耗时操作和发布状态变更(mutation)。

实际实施时,我会检查下面这些关注点。

第一,在代码和证据格式中分开断言与观察。 可以分别维护 required_assertions 和 diagnostic_observations,或为每个场景编写显式的语义验证函数。前者决定成败;后者作为诊断数据,需要检查 schema、范围、大小和保密性,但不应因为某个观察值与理想轨迹不同就自动判定产品失败。不要用一次字典精确相等比较,把产品契约、运行预算和轨迹计数混成一个判断。

第二,确保失败运行也会留下证据。 GitHub Actions 最容易出现的反模式是:场景成功后才上传证据,上传步骤没有 if: always()。需要诊断的 RED 运行反而没有构建产物(artifact)。更稳妥的顺序是:

run scenario
      ↓
helper writes diagnostic envelope on GREEN / RED
      ↓
upload diagnostics                    if: always()
      ↓
explicitly require semantic GREEN
      ↓
all semantic groups GREEN → build authoritative release evidence

诊断包(diagnostic envelope)应足够小,只记录场景、语义状态、判定充分性、失败类型、最后阶段,以及范围和大小受限的观察数据。同时,它需要用 schema_version、source_revision、validator_revision 和 artifact_digest 绑定被验证对象。否则,下一轮智能体可能用旧验证器产生的观察结果解释新实现。诊断包不是发布证明;只有全部语义断言为 GREEN,聚合器才生成可以随发布制品提供的正式证据。

第三,只拆相互独立的语义组(semantic group)。 如果供应商投影、默认控制、智能体协作和媒体路径之间没有执行依赖,可以让它们分别运行,再由最后一道聚合门禁统一判断。在 GitHub Actions 中,continue-on-error 只是实现这种“先收集各组结果,再统一失败”的一种方式,前提是最终聚合步骤仍然明确检查每个预期组是否产出状态。缺少整个语义组的结果也应视为证据不完整,而不能被误读成没有失败。与此同时,不要为了收集更多结果走向“一项测试一个 job”,更不要重复运行相同的构建和检查。

第四,记录超时发生在哪个阶段,而不只记录秒数。 外层 180 秒可以限制实验,却不能自动成为产品 SLA。长耗时请求至少应区分请求已发出、响应头已收到、首字节已收到、正文完成、结果已规范化和制品已保存。只有在异常发生时也能保存这些阶段的观察结果,下一轮智能体才知道应检查传输、解析还是后续处理。

第五,区分验证器控制的计数与产品内部轨迹。 runner_turn_submission_count 可以是硬断言,因为验证器控制它;后端调用次数、轮询次数和模型续写次数通常只是观察。相反,如果调用者明确请求返回 n 个结果,那么结果数量属于 API 语义契约,可以硬断言。关键不在于“计数能不能断言”,而在于谁拥有这个计数的语义。

第六,用身份和局部依赖代替全局顺序。 内部标识可以用于去重和关联,但公开证据只保留必要的关联检查结果。并发场景应验证产品真正保证的前置关系,不应把一次运行中偶然出现的事件到达顺序冻结成产品契约。

第七,把发布证据与 RED 诊断分开。 发布只能采用语义验收通过的证据。RED 的诊断产物应保留,但不能在后续文档中被提升为发布通过的证明。发布动作发生后如果上传中断,可以用 if: always() 执行只读检查,记录标签、发布目标和已有制品;不要自动删除、重试或补传。如果状态变更只完成了一部分,需要由人明确决定如何恢复。

第八,让观察不干扰执行,并保护敏感信息。 验证器不应为了得到完整数据而重试模型操作、重新提交交互回合,或在客户端取消后继续读取(drain)。缺失值应保持 unknown。提示词、回复、参数、凭据、原始流量和内部身份也不应进入公开证据。

最后,应先组合已有的工具开始、工具完成、任务终态和状态读取接口,再考虑增加新埋点。判断一项 observation 是否值得加入,可以问:它能否缩小下一轮调查范围,或区分两个原本无法区分的故障边界?如果不能,它大概率只是增加证据体积。

这些规则约束的是实验如何进行、结果如何判定,不应因此缩小产品能力。可以限制任务范围、外层时间、证据保留和外部副作用;不能因为测试需要确定性,就擅自要求产品只能调用一次工具、只能产生三次响应,或只能使用一个并发任务。

把方法写进编程智能体 Skill

如果这套方法只存在于一次代码审查或一个测试文件中,后续智能体很容易重新回到“看到异常计数就修改产品”的旧路径。一个编程智能体技能(coding-agent Skill)可以把它变成可重复的开发习惯。

这个 Skill 不应定义新的产品契约,也不应成为产品语义的权威来源。它的职责是把已有契约、门禁证据和下一轮调查连接起来,并让智能体能够重复执行同一套审计过程。对每个独立的语义组,Skill 至少应明确下面这组关系:

semantic group
→ owning authority
→ hard assertions
→ diagnostic observations
→ RED evidence path
→ next investigation boundary

执行时,智能体需要依次确认:实现是否区分断言和观察;失败时是否仍会生成并上传诊断包;超时属于运行器预算还是产品 SLA;并发关系是否通过身份和生命周期证明;状态变更失败后是否有只读状态快照;最终发布是否只采用语义验收通过的证据。

Skill 的结果不应只有一份测试结论。它还应整理一个小型证据包:哪些契约已经满足,哪些仍未证明,最后可证阶段是什么,当前根因结论的证据是否充分,以及下一步应调查哪里。对于 GitHub Actions,还应检查失败路径是否真的能够执行诊断上传,以及聚合器是否可能把 RED 诊断误当成正式发布证据;if: always() 只是其中一个实现细节,不是充分条件。

Skill 的作用是让智能体把改动限制在证据支持的最小范围内;具体如何修改,仍需要工程判断。如果一次超时没有阶段信息,下一步应先补门禁观察,而不是直接修改产品。如果一个语义组失败后,其他独立组因此未能运行,下一步应调整门禁之间的执行依赖,而不是盲目扩大测试矩阵。如果局部因果正确,只有全局顺序不符,下一步应修复测试匹配器。

在文档组织上,根级智能体指引只需提供触发词和路由。完整方法可以放进 Skill;具体断言仍以产品、协议和验证文档为依据。Skill 连接这些信息,但不成为产品语义的新来源。

门禁是开发循环的传感器

在 Harness Engineering:模型之外的工程 中,我把 harness 描述为模型行动的外部结构。门禁是这套结构中的判定器,也是传感器。

作为判定器,gate 只对明确契约做硬判决;作为传感器,它保留系统自然运行时的语义阶段、次数、时序和终态。两种职责必须分开实现,却可以共同组成面向下一轮开发的反馈接口。

Observe semantics:让门禁看见系统完成了什么。

Assert contracts:只让明确契约决定红灯和绿灯。

Diagnose trajectories:让过程证据指导下一轮窄修改。

只会给出 PASS / FAIL 的门禁只能决定能不能继续;能够同时说明系统走到哪里、哪些仍然未知,以及下一步应检查什么,门禁才真正成为开发循环中的反馈机制。