Chainlink flaky-test 深度调查协议:三角色 Agent 辩论机制与 JSON 记录规范

原创2026-09-15 10:00:171,339 阅读
文章标签:区块链Web3后端

Chainlink flaky-test 深度调查协议:三角色 Agent 辩论机制与 JSON 记录规范

本文围绕 Chainlink 仓库 tools/test 测试修复技能中的“复杂调查协议”(complex investigation protocol)展开:当一次常规排查不足以定位复杂 flaky test 的根因时,如何通过 Proposer / Challenger / Arbiter 三个独立 Agent 角色、最多 3 轮辩论,配合 CI 失败日志与 diagnose 运行数据,产出可执行的结构化修复结论。读完本文,你将掌握该协议的激活时机、各角色职责与读取预算、五种 JSON 记录的字段规范、结果路由(PROCEED / INCONCLUSIVE / ABANDONED)的完整流程,以及它与 make test ARGS="diagnose ..." 测试夹具(harness)的衔接方式。

1. 协议定位:何时需要“复杂调查”

该协议是 fix-flaky-tests 技能 的一个子模块,服务于 Chainlink 这类大型 Go 仓库中“系统测试和部分集成测试”级别的疑难 flake。其设计前提是:单次排查(single pass)可能不足以理解复杂测试不稳定的成因,此时需要让辩论发生,并补充更多数据点(CI 失败日志、Docker/应用日志等)。

协议本身的全文见 complex-investigation-protocol.md。它与主技能的衔接关系在 SKILL.md 中定义:

  • 初始化阶段(第 2 步):开始前必须询问用户该 flake 属于“相对简单自包含”还是“复杂、需要深入理解应用”(例如系统测试和部分集成测试);若是后者,在形成任何假设之前先激活复杂调查协议;
  • 排查循环(第 5 步):如果判定为复杂测试,则按本协议执行,否则走常规“形成假设 → 修复 → 验证”的循环。

主技能还给出了一组与本流程强相关的全局约束(<absolute_constraints>),直接决定了协议运行时的操作边界:

  • 禁止使用裸 go test 命令,只能从仓库根目录执行 make test ARGS="diagnose ...",单次运行用 --iterations 1;
  • 不得修改测试的核心目标来让它通过;除非用更好的断言替换或删除确认的死代码,否则不得删除测试/断言;
  • 不得为了让局部测试通过而修改包级公共 helper;
  • 不得修复第三方库;若 flakiness 源于三方库,告知用户并停止(这条直接对应本协议的 ABANDONED 出口);
  • 写新工具代码前必须检查 go.mod,优先复用已有库;
  • 预期运行超过 2 分钟的 diagnose 要在后台执行、只做一次 30 秒崩溃检查,然后等待 report.json 通知,不得轮询。

2. 证据收集:CI 失败链接与日志分析

辩论开始前,协议要求先补齐证据链:

  1. 询问 CI 失败链接。如果用户尚未提供,必须向其索取 CI failure link;只接受 GitHub 或其他 CI 提供商的链接,明确拒绝 Trunk 链接(与 SKILL.md 中“不要打开 JIRA 中指向 Trunk.io 的链接”一致)。
  2. 用子 Agent 分析 CI 失败。链接要交给 github-failure-analyzer 子 Agent 分析。该子 Agent 被定义为一个“无头、只读的 GitHub workflow 解析器”,温度设为 0.0,只允许 gh、grep、find、wc、cat、sed 等只读工具,要求它从日志末尾开始读取、下载失败相关的 workflow artifacts,并且只输出固定结构的原始 JSON(含 urls_read、artifact_errors、artifact_locations、failure_diagnosis 四个字段)。
  3. 寻找堆栈、测试日志与应用日志。重点找可能包含应用日志的 stack trace、test logs 和 workflow artifacts。
  4. 应用日志 / Docker 日志。若可获得应用或 Docker 日志,需逐行梳(comb)出与观察到的测试失败可能相关的错误。

这些证据后续会进入 discussion_record.evidence 字段,来源标记为 user | ci | diagnose 三类之一。

3. 三角色定义:Proposer、Challenger、Arbiter

协议的核心是三个必须分别调用的 Agent 角色。文档明确警告:“绝不能把多个角色合并进一次 Agent 调用——单模型的自我批评违背了该机制的初衷”(Never collapse multiple roles into one agent call — self-critique by a single model defeats the purpose)。

3.1 Proposer(提案者,standard 模型)

职责:读取测试及其 helper 代码,从以下典型 flakiness 源中分析:

  • 时序依赖(timing dependencies)
  • 共享全局状态(shared global state)
  • 顺序假设(ordering assumptions)
  • 缺失的清理/拆除逻辑(missing cleanup/teardown)
  • 非确定性数据(non-deterministic data)
  • 其他常见 flakiness 源

分析后提出最可能的根因和一个带文件与行号引用的具体修复,返回 proposed_fix_record。

两条关键规则:

  • 调查必须参考此前被否决的方案。这些方案不得被回避——除非出现支持它们的新决定性证据。这防止多轮辩论原地打转。
  • 读取预算无上限(unbounded),但代价是:每一次文件/代码范围读取必须追加到 discussion_record.code_snippets 缓存中,供后续轮次和其他角色复用,避免重复读取。

proposed_fix_record 的 JSON 结构:

{
    "root_cause": "string",
    "fixes": [
        {
            "id": "integer",
            "fix_file": "string",
            "fix_line": "integer",
            "fix_description": "string"
        }
    ],
    "excluded_approaches": ["string"]
}

3.2 Challenger(挑战者,reasoning 模型)

职责:接收 Proposer 的完整输出和 discussion_record(含 code_snippets),对提案发起挑战——替代成因、边界情况、破坏其他测试的风险。它必须明确表态:提案所主张的因果机制本身是否成立(必须显式给出立场,而非泛泛质疑)。挑战结果返回 fix_evaluation_record。

读取预算为 0(默认):Challenger 只能基于 code_snippets 推理。例外是它至多可以执行 2 次定向读取来验证某个具体论断——但每次读取必须写明 file:start_line-end_line 以及它要验证的论断内容,且读取内容同样必须追加进 code_snippets。

fix_evaluation_record 的 JSON 结构:

[
    {
        "fix_id": "integer",
        "challenges": ["string"]
    }
]

3.3 Arbiter(仲裁者,reasoning 模型)

职责:同时接收 Proposer 与 Challenger 的产物,决定停止(信心已足够)还是再跑一轮(总共最多 3 轮),并给出最终裁决,返回 verdict_record。

读取预算为 0:Arbiter 只对两份记录本身做推理,不读取任何文件。

verdict_record 的 JSON 结构:

{
    "decision": "PROCEED | ANOTHER_ROUND | GIVE_UP",
    "rationale": "string"
}

4. discussion_record:辩论的共享状态

三个角色之间传递的不是自由文本,而是一份结构化的 discussion_record。它由编排方(orchestrator)构建,随轮次推进不断填充:

{
  "evidence": [
    {
        "source": "user | ci | diagnose",
        "content": "string"
    }
  ],
  "code_snippets": [
    {
        "file": "string",
        "start_line": "integer",
        "end_line": "integer",
        "snippet": "string",
        "why_relevant": "string"
    }
  ],
  "investigation_history": {
    "excluded_approaches": ["string"],
    "rejection_reasons": ["string"]
  }
}

字段规则(field-rules):

  1. 历史种子化:如果存在与该测试/包相关的 Jira 历史修复评论,或者存在 diagnose-attempted-fixes-*.jsonl 文件,构建记录时必须从这些来源初始化 investigation_history。这与主技能排查循环第 9 步形成闭环——每次尝试修复后都要向该 jsonl 文件追加 {"timestamp", "model", "hypothesis", "experiment", "result", "next"} 记录,因此跨会话的调查记忆由此文件承载;
  2. code_snippets 共享缓存:初始为空,各角色读取代码时追加;按 (file, start_line, end_line) 三元组去重。

缓存的粒度规则(来自绝对约束第 5 条):code_snippets 缓存是按工单(per-ticket)隔离的。两个工单只有在“一个测试名是另一个的子测试”(如 TestFoo 与 TestFoo/Subcase)且合并后的缓存保持在约 50 条以内时,才允许共享缓存。

5. 运行逻辑:一轮辩论的完整数据流

协议给出的 <logic> 定义了编排方的六步流程:

  1. 汇集证据:整合用户提供的信息与 diagnose 运行产物;读取 diagnose-attempted-fixes-*.jsonl 中的既往尝试并种子化 investigation_history;如可获得应用或 Docker 日志,梳理其中可能与失败相关的错误。
  2. 构建空的 discussion_record(code_snippets 为空),交给 Proposer,启动第 1 轮。
  3. Proposer 完成后,把 proposed_fix_record 和(此时已填充的)discussion_record 交给 Challenger。
  4. Challenger 完成后,把 proposed_fix_record、fix_evaluation_record 和 discussion_record 一并交给 Arbiter。
  5. 按 Arbiter 裁决分派:
    • PROCEED → 进入第 6 步;
    • ANOTHER_ROUND(仅当当前轮次 < 3)→ 把被否决的修复追加进 discussion_record.investigation_history.excluded_approaches,把 Challenger 的推理追加进 rejection_reasons,保留 code_snippets,丢弃本轮的 proposed_fix_record 与 fix_evaluation_record,开启下一轮;
    • GIVE_UP,或达到 3 轮上限仍未 PROCEED → 结果为 INCONCLUSIVE。
  6. 构建并返回 discussion_result_record,然后按 <outcome_routing> 路由后续动作。

注意第 5 步的状态管理细节:跨轮保留的是证据与历史(code_snippets + investigation_history),丢弃的是本轮观点(两份 record)。这正是“被否决的方案会约束后续轮次”这一设计的落地点——Proposer 在第 2、3 轮会带着 excluded_approaches 与 rejection_reasons 重新出发,而不是重复已被证伪的假设。

6. 最终产物与结果路由

discussion_result_record 是协议对外输出的最终 JSON,字段按结果类型条件性置值:

{
  "jira_key": "KEY-NNN | null",
  "test_name": "string",
  "outcome": "PROCEED | INCONCLUSIVE | ABANDONED",
  "evidence": [
      {
          "source": "user | ci | diagnose",
          "content": "string"
      }
  ],
  "fixes": [{
    "fix_file": "string (PROCEED only, null otherwise)",
    "fix_line": "integer (PROCEED only, null otherwise)",
    "fix_description": "string (PROCEED only, null otherwise)"
  }],
  "proposer_root_cause": "string (PROCEED or INCONCLUSIVE only, null otherwise)",
  "excluded_approaches": ["string (PROCEED only, null otherwise)"],
  "inconclusive_reason": "string (INCONCLUSIVE only, null otherwise)",
  "recommended_next_step": "string | null (INCONCLUSIVE only)",
  "abandoned_reason": "string (ABANDONED only, null otherwise)"
}

<outcome_routing> 定义了三种出口各自的后续动作:

  • PROCEED:带着 fixes 回到主技能 <loop> 的第 7 步(Implement the fix),随后按标准流程跑 diagnose 验证修复有效性;
  • INCONCLUSIVE:先向 jsonl 文件写入一条记录(hypothesis = proposer_root_cause,experiment = "complex protocol, N rounds, INCONCLUSIVE",result = inconclusive_reason,next = recommended_next_step),然后分三种情况:
    • 该工单第一次 INCONCLUSIVE → 按 recommended_next_step 以更宽的信号重跑 diagnose(更多迭代次数 / -race / -trace),再重新进入协议一次。没有第二次延期(No second extension);
    • 批量/自治模式(batch/autonomous mode)→ 执行 abandon-ticket.md 流程;
    • 交互模式(interactive mode)→ 询问用户三选一:把 Proposer 的猜测当作单次修复尝试 / 放弃工单 / 携带新证据重新定向;
  • ABANDONED:执行 abandon-ticket.md。当某个角色判定根因位于第三方库代码时发出此结果——这与主技能“不要修复第三方库,告知用户并停止”的绝对约束一致。abandon-ticket.md 本身规定:已认领的工单绝不能停留在 "In Progress",需解除 assignee、把工单流转回 Open,并写入一条 Outcome 为 ABANDONED 的调查评论。

7. 绝对约束:保证协议不退化的五条硬规则

协议用 <absolute_constraints> 固化了五条不可违反的约束,理解它们能看清整个设计的安全边界:

  1. 最多 3 轮;
  2. 每个角色是独立的 Agent 调用(使用 Agent tool);
  3. 绝不允许把多个角色合并到一次调用——单模型自我批评违背机制初衷;
  4. 不同工单的调查可以并行;但同一调查内部的 3 个角色必须串行;
  5. code_snippets 缓存按工单隔离,共享仅限子测试关系且合并后约 50 条以内。

这些约束共同解决了多 Agent 辩论机制的三个典型失效模式:轮数失控(约束 1)、辩论形式化(约束 2、3 强制真实的外部质疑)、以及并行调查间的上下文串扰(约束 4、5)。

8. 与 Chainlink 测试夹具的衔接

协议中的证据源 diagnose 来自仓库自带的测试 harness:tools/test/README.md 说明它是基于 testrig 的 Go 测试运行器,make test 目标(见 GNUmakefile 第 312 行 test: $(TOOLS_TEST_BIN))会构建 harness 二进制并转发参数。协议与之的衔接点有:

  • 运行命令:只使用 make test ARGS="diagnose [harness_flags] -- [go_test_flags] ./path",其中 --ai-output 必须位于 -- 之前,禁止 -count,重复运行严格通过 --iterations 实现(harness flags 还有 --fail-fast-on=(timeout|slow)、--parallel-iterations N;Go flags 如 --run '^TestName$'、--timeout 10m、--race);
  • 迭代次数与漏检概率:SKILL.md 给出 --iterations 与漏检 flake 概率的对照——5 次约 50% 漏检、30 次约 10%、60 次约 5%、150 次约 2%、300 次约 1%、500 次以上 <1%。因此 INCONCLUSIVE 后“以更多迭代次数重跑”是有量化依据的;
  • 日志产物结构:diagnose 运行会产出 iteration-n.log.jsonl(完整输出,按需读取)、postgres-state-n.md(数据库错误/挂起时查看最终状态)、report.json(摘要,可用 jq .run 提取参数)和 logs/pkg_TestName_iter-n.log(特定测试失败日志)。协议逻辑第 1 步“读取 diagnose 的产物”即针对这些文件;
  • 数据库隔离背景:从 SKILL.md 的 <tests-context> 可知,Chainlink 测试共享同一个 Postgres 数据库、diagnose 循环会创建新 DB——“共享全局状态”正是该仓库 flake 的典型来源,也是 Proposer 分析清单里的高优先项。

9. 小结:这套协议可迁移的方法论

剥离 Chainlink 的具体上下文,complex-investigation-protocol.md 展示了一套可复用的疑难问题排查范式:

  1. 先证据后假设:辩论前必须汇集 CI 失败链接、堆栈、应用/Docker 日志,并种子化历史尝试,避免重复走死路;
  2. 三角色分离 + 读取预算分层:提案者可充分读码(unbounded),挑战者默认零读取、仅允许 2 次定向验证,仲裁者纯记录推理。预算差异迫使每个角色承担不同认知成本,而 code_snippets 缓存用 (file, start_line, end_line) 去重保证证据只读一次、全角色可见;
  3. 状态与观点分离:跨轮保留的是证据与历史(code_snippets、investigation_history),丢弃的是单轮观点(两份 record),使“被否决方案进入下一轮约束集”成为机制性保证;
  4. 有限轮次 + 条件性出口:3 轮上限、无第二次延期、INCONCLUSIVE 后按模式分流(重试 / 放弃 / 询问用户)、ABANDONED 强制走工单回滚流程,保证自治模式下的资源与工单状态收敛;
  5. 全链路结构化 JSON:从 proposed_fix_record、fix_evaluation_record、verdict_record 到最终的 discussion_result_record,每个阶段的产物都有严格 schema,可被上游技能循环、jsonl 历史文件与工单评论直接消费。

对维护大型 Go 测试套件的团队而言,这套协议的价值不仅在于它如何修 flaky test,更在于它示范了如何用“角色隔离、预算约束、共享证据缓存和有限轮次”四个机制,让多 Agent 协作排查保持可控、可审计、可复现。

登录后查看全文
chainlink