四层防护墙
Retry → Fallback → Circuit → Recovery 由内向外,每一层只解决一类问题,组合形成自修复闭环
跨 Run 巡检
中间态停留超阈值的 Job 不再"挂在那儿",由巡检任务强制收敛到 TIMEOUT / CLOSED_FAILED 终态
灰度一票否决
失败率异常或 Trace 写入失败触发 feature flag 回切旧路径,所有动作进 Audit 留痕
详细设计说明
把异常从"人工救火"收敛为"可控自恢复流程"
这份文档定义 Harness 上线后的自修复能力:错误分类后选择重试、降级、熔断、终止,再加上跨 Run 巡检和灰度一票否决回滚。评审重点是异常发生后系统如何自动止损、留痕和恢复,而不是无限重试。
设计主张
自修复不是无限重试,而是按错误类型选择 Retry / Fallback / Circuit / Recovery 中的一档,并把每一次恢复动作写入 Trace。Run 内即时自愈、Run 间巡检收敛、灰度一票否决回滚——三层职责互补,不能互相替代。
评审关注点
- 哪些错误允许重试,哪些立即失败
- 熔断打开期间高优先级流量是否受影响
- Fallback 路径是否仍满足业务目标
- 僵尸 Run 是否一定收敛到终态
- 灰度回滚的触发条件与回切边界
第一章 · 定位与概念
说明自修复在 Harness 中的边界、四层防护墙的关系、僵尸 Run 巡检和灰度回滚作为外围补丁的角色,回答"为什么不只是 try/except"。
状态:v1 草案 · 日期:2026-05-07 · 定位:Harness 内核运行期的稳态保障层;不替代 Policy 与 HITL,但为它们提供可恢复的执行底座 · 关联:harness-kernel-design(Run 状态机)、observability-spec(Trace / 告警)、testing-strategy(混沌测试与故障注入)
1.1 总体架构图
下图把自修复机制画成由内向外的四层防护墙,加上跨 Run 巡检和灰度回滚两条外围补丁。中间柱是 Harness 一次 Run 的执行路径,每一层防护墙拦住一类问题;越内层粒度越细(Tool 级),越外层范围越大(Run 级 / 灰度级)。所有恢复动作必须经 Trace 收口。
1.2 时序图(2 个典型恢复场景)
下面 2 张时序图对应自修复最常见的两条路径,分别可视化 Run 内即时自愈与跨 Run 巡检收敛的执行顺序。点击 lifeline 头部色块跳到对应章节。
点击下方任意场景卡片跳到对应时序图。
一次 Tool 调用从失败到走 fallback、到触发熔断、到 HALF_OPEN 探测恢复的完整路径。红色箭头 = 失败 / 拒绝路径,绿色 = 恢复路径。
中间态停留超阈值的 Run 没有自然终态。Celery beat 巡检任务每 60s 扫一次 Run 表,超阈值的强制写入 TIMEOUT,再经 TRACING 收口到 CLOSED_FAILED。
1.3 为什么自修复
不做自修复时常见的 4 类痛点:
| 痛点 | 不做自修复时的表现 | 做了自修复后的表现 |
|---|---|---|
| 瞬时网络抖动 | 一次 503 直接 RunFailed,用户看到红色错误 | 指数退避自动重试 2~3 次,对用户完全不可见 |
| 外部依赖故障 | 故障期间所有 Run 全部失败,雪崩拖垮整个 Harness | 熔断 OPEN 隔离故障依赖,其他 Skill 正常服务 |
| Run 卡中间态 | 消费者放着不管,DB 中堆积大量 PREPARING / EXECUTING | 巡检定时强制 TIMEOUT,终态收敛,监控指标干净 |
| 灰度路径异常 | 新路径失败率突涨但未发现,业务受损扩大 | 失败率超阈值即一票否决回切旧路径,损失锁定在分钟级 |
1.4 三层职责
自修复的三层职责按"颗粒度从细到粗"排列;每一层只解决自己擅长的问题,不能用一层替代另一层。
| 层 | 谁负责 | 颗粒度 | 关键能力 |
|---|---|---|---|
| T1 · 健康看板 + 告警 | Prometheus / Grafana / 飞书 oncall | 平台级 | 失败率 / 熔断 OPEN 时长 / Trace 写入失败率持续超阈值即告警;为灰度一票否决提供触发信号 |
| T2 · 跨 Run 巡检 + 灰度回滚 | Celery beat 巡检任务 / Feature Flag 控制器 | Run 级 / 路径级 | 中间态停留超阈值的 Run 强制收敛;灰度路径失败率异常一票否决回切 |
| T3 · Run 内重试 + Skill 熔断 | Tool 调用控制器 / LLM Router | Tool 调用级 | 错误分类后选择重试、降级或熔断;HALF_OPEN 探测自动恢复 |
三层互为补充:T3 是第一道防线(毫秒~秒级自愈),T2 是兜底巡检(分钟级收敛),T1 是异常的最后红线(人工介入 / 全局回滚)。任何一层失效,下一层接管。
第二章 · 重试策略
说明哪些错误允许重试、哪些必须立即失败、退避算法、重试上限,并把每一次重试都写进 Trace。
2.1 错误分类与重试决策
所有外部 IO(Tool / LLM / Skill / 沙箱)的错误必须先经过错误分类,再决定是否重试。原则是:瞬时性 / 服务侧问题可重试,语义性 / 调用方问题立即失败。
| 类型 | 判断依据 | 动作 | 原因 |
|---|---|---|---|
| 可重试 | 网络错误(DNS / TCP reset / 连接超时) | 指数退避重试 | 瞬时性故障,重试通常成功 |
| 可重试 | HTTP 5xx · 上游 unavailable | 指数退避重试 | 服务侧问题,常自愈 |
| 可重试 | HTTP 429 · rate_limit | 退避后重试(按 Retry-After) | 限流是瞬时拒绝,退避后通常放行 |
| 不可重试 | HTTP 4xx(除 429)· 参数错误 | 立即失败 | 语义错误,重试无意义 |
| 不可重试 | auth / billing 错误 | 立即失败 | 账号 / 配额问题,重试只会重复触发 |
| 不可重试 | context_length_exceeded | 立即失败 · 上抛 Plan | 需要走压缩降级而非重试 |
2.2 退避算法与上限
| 维度 | 策略 |
|---|---|
| 退避公式 | 指数退避 1s → 2s → 4s(基础值乘 2 的 retry_count 次方) |
| 抖动(jitter) | 退避值上叠加 ±20% 随机抖动,避免重试雪崩 |
| Tool 调用上限 | 3 次(含首次);超过 → CLOSED_FAILED |
| LLM 调用上限 | 3 次 + 主备切换(下一次切到备模型,仍可继续 2 次重试) |
| 退避总耗时上限 | 不超过 Skill manifest 声明的 timeout_per_call |
2.3 Trace 留痕
每次重试都在 ToolInvocationTrace 上累加 retry_count,并记录每次失败的错误码与退避耗时。Trace 写入失败本身不阻塞 Run,但会触发 P0 告警(见第七章灰度回滚的触发条件)。
第三章 · Fallback
说明 Fallback 触发条件、目标路径来源、与重试 / 熔断的协同关系,以及 Fallback 路径自身的退化处理。
3.1 三类 Fallback
| 类型 | 触发 | 目标 | 来源 |
|---|---|---|---|
| 模型 Fallback | 主模型 5xx / timeout / 配额耗尽 / 重试上限耗尽 | 切到备模型继续推理 | LLM Router 配置(按 endpoint 优先级) |
| Skill Fallback | 主 Skill 重试上限耗尽 / 熔断 OPEN | 切到 manifest 声明的备 Skill | Skill manifest 中的 fallback_skill_id 字段 |
| 功能降级 | 全局压力高(队列积压 / 成本接近上限) | 关闭非核心 Skill,只保留核心能力 | Skill manifest 中的 priority 字段 |
3.2 Fallback 与重试 / 熔断的协同
- 触发顺序:先重试(同一目标 N 次)→ 重试上限耗尽再 Fallback(切备目标)→ 备目标也连续失败累计到熔断阈值再 OPEN。
- Fallback 自身可重试:备路径也按 §2 重试策略走指数退避。
- Fallback 不递归:备 Skill 的 manifest 即使再声明 fallback_skill_id,也只展开一层,避免循环 fallback。
- 必须可观测:所有 Fallback 必须在 Trace 上写
fallback_used=true与fallback_target_id,便于评估业务影响。
3.3 Fallback 路径退化处理
当备路径也失败时分两种情形:
- 非核心场景:进 CLOSED_FAILED,Trace 写明已尝试主备两条路径。
- 核心场景:在 manifest 标记
requires_human_fallback=true,Run 进 AWAITING_HITL 等人工兜底,而非直接失败。
第四章 · 熔断恢复
说明熔断器三态状态机、触发与恢复阈值、HALF_OPEN 探测策略,以及熔断与重试 / Fallback 的边界。
4.1 三态状态机
| 状态 | 语义 | 请求处理 |
|---|---|---|
| CLOSED | 正常态 | 所有请求放行;累计失败计数 |
| OPEN | 熔断态 | 所有请求立即拒绝(短路);不再打到目标依赖 |
| HALF_OPEN | 探测态 | 放 1 个探测请求;成功 → CLOSED,失败 → OPEN 续时 |
4.2 触发与恢复阈值
| 事件 | 阈值 | 转移 |
|---|---|---|
| 累计失败 | 连续 5 次失败(同一 endpoint / Skill 维度) | CLOSED → OPEN |
| OPEN 时长 | 初始 30s · 连续触发指数退避到上限 5min | OPEN → HALF_OPEN(计时到) |
| HALF_OPEN 探测 | 单请求成功 | HALF_OPEN → CLOSED · 重置计数 |
| HALF_OPEN 探测 | 单请求失败 | HALF_OPEN → OPEN · 续时 |
4.3 熔断与重试 / Fallback 的边界
- 重试在熔断之内:单次调用失败先走重试;重试上限耗尽才把"调用失败"事件累加到熔断计数。
- OPEN 时直接 Fallback:熔断 OPEN 期间命中此依赖的请求不再重试,立刻切 Fallback 路径。
- Fallback 路径也熔断:备路径有自己独立的熔断器,主备同时 OPEN 时按 §3.3 退化处理。
- 持久化:熔断状态存 Redis(key + TTL),跨 worker 共享,避免单 worker 视角下"假性恢复"。
4.4 熔断与 RunState 的关系
熔断不直接改变 RunState;它只决定单次外部调用是否放行。Run 内即使遇到熔断 OPEN,仍按 Fallback / 拒绝执行的常规路径走,最终 Run 仍由 Run 管理器写入 SUCCEEDED / CLOSED_FAILED。
第五章 · 失败 Run 恢复
说明已经写入失败终态的 Run 在什么条件下可以重新发起,避免"失败即终结"也避免"无限自动重试"。
5.1 恢复决策依据
已经写入终态的 Run 不能简单"原地重启",必须基于 RunState 与 Trace 重新评估:
| 判据 | 来源 | 用途 |
|---|---|---|
| 失败原因分类 | RunTrace.close_reason · ErrorTrace.error_class | 判断是瞬时还是语义错误 |
| 已产生的副作用 | ToolInvocationTrace 中已写库 / 已外发的 Tool 调用 | 避免重复执行不可重入的 Tool |
| HITL 状态 | HITLTrace | 人工已拒绝的不能自动重启 |
| 成本已消耗 | CostTrace | 判断是否在用户预算范围内重试 |
5.2 四档恢复处置
| 档 | 条件 | 动作 |
|---|---|---|
| 自动恢复 | 瞬时错误(5xx / network) · 无副作用 · 成本未超预算 | 新建 child Run,复用原 input + ctx;旧 Run 保留终态 |
| 降级恢复 | 主路径连续失败 · 业务允许降级 | 新建 child Run 走 Fallback 路径 |
| 拒绝恢复 | 语义错误 / HITL 拒绝 / 成本超限 | 不重启;返回原失败结果给调用方 |
| 人工恢复 | 核心场景但所有自动路径已尝试 | 挂起到工单 / oncall 群,等待运维介入 |
5.3 恢复路径的不变量
- 不重写旧 Run:原 Run 的终态保持不变,恢复必经新建 child Run,保留可追溯链。
- parent_run_id 留痕:child Run 在 RunTrace 上记录
parent_run_id与recovery_reason。 - 幂等键透传:避免下游 Skill 重复执行已成功部分(依赖 ToolInvocation 幂等键)。
第六章 · 僵尸 Run 检测
说明哪些 Run 算"僵尸"、巡检如何发现、强制收敛的状态转移路径,以及补偿动作的边界。
6.1 僵尸 Run 定义
停留在非终态中间态且updated_at 超过阈值的 Run 即为僵尸 Run。可能的原因:worker 崩溃、网络分区、外部依赖死锁、HITL 长期无人响应。
| 中间态 | 停留阈值(默认) | 处置 |
|---|---|---|
| PREPARING | 5min | 强制 TIMEOUT |
| PLANNING | 10min | 强制 TIMEOUT |
| EXECUTING | 1h(受 Skill 上限影响) | 强制 TIMEOUT |
| AWAITING_HITL | HITL request 自带 timeout(默认 24h) | 到时即超时(不算僵尸,走 HITL 超时路径) |
| TRACING | 5min | P0 告警 + 强制 CLOSED_FAILED(Trace 写入失败时的最后兜底) |
6.2 巡检机制
| 维度 | 策略 |
|---|---|
| 触发器 | Celery beat · 每 60s 一次 |
| 查询条件 | state IN (中间态) AND updated_at < now - 阈值 |
| 分级处置 | warn 档(接近阈值)只告警;kill 档(超过 1.5×阈值)强制收敛 |
| 批量上限 | 单次巡检最多处理 100 条,避免巡检自身打挂数据库 |
| 幂等 | 巡检任务用 Redis 锁防止重复执行 |
6.3 强制收敛路径
强制收敛必须遵循 RunState 的"必经 TRACING"约束:
- Worker 调 Run 管理器的
force_timeout(run_id, reason=ZOMBIE)接口。 - Run 管理器原子转移:当前中间态 → TIMEOUT → TRACING。
- Trace 写入
close_reason=ZOMBIE与停留时长。 - TRACING → CLOSED_FAILED(最终关闭态)。
- P1 告警(Job 卡 running > 1h)。
6.4 补偿动作的边界
- 不回滚已发生的副作用:巡检只关闭 Run 状态,不撤销已外发的 Tool 调用(这是设计选择,避免"补偿撤销"自身出错)。
- 不重启原 Run:是否恢复由第五章的失败 Run 恢复策略决定。
- 批量阈值告警:单次巡检发现 > 10 条僵尸 → P0 告警,疑似系统性故障。
第七章 · 灰度回滚
说明灰度路径异常的判定信号、一票否决回滚的触发与执行、回切到旧路径后的留痕与复盘。
7.1 触发信号(一票否决)
灰度路径上线后,下列任一信号超阈值即立即一票否决,无需多指标复合判定:
| 信号 | 阈值 | 原因 |
|---|---|---|
| 新路径 Run 失败率 | 5min 滑窗 > 旧路径 × 1.5 | 新路径明显劣化 |
| Trace 写入失败率 | > 1%(持续 5min) | 审计完整性受损,不可接受 |
| 熔断器 OPEN 数 | > 历史均值 × 3 | 新路径触发依赖故障扩散 |
| 首字延迟 P95 | > 旧路径 × 2 | 用户体验显著退化 |
| 成本超限触发频率 | > 10 次 / hour | 新路径成本失控 |
7.2 回滚动作
| 步骤 | 动作 |
|---|---|
| 1 | Feature Flag 控制器读告警事件,把 USE_HARNESS_PIPELINE 切回 false(或灰度百分比归零) |
| 2 | 新进入的 Run 全部走旧路径;已在新路径执行的 Run 不强制中断(让其自然完成或失败) |
| 3 | 飞书 oncall 群 P0 告警,附本次触发的具体信号与阈值 |
| 4 | 把回滚事件写入 AuditLog(actor=system · action=canary_rollback · reason=信号名称) |
| 5 | 触发自动复盘:拉过去 30min 的关键 Trace 与告警,生成报告挂到事件单 |
7.3 回切边界
- 不可被忽略:一票否决信号触发后必须执行回滚,不允许"等等看"。
- 回切粒度:默认全量回切;如配置了 session 白名单灰度,则只清空白名单。
- 不自动重新放量:回切后的二次放量必须由人工评估并复盘后手动触发,避免抖动。
- 已在新路径的 Run:让其自然完成。强制中断会损坏已产生的 Trace / Tool 调用幂等性。
7.4 与僵尸 Run 巡检的关系
灰度回滚发生后,新路径上仍有少量在飞 Run。如果这些 Run 因新路径已下线而无法继续推进,会变成僵尸 Run,由第六章的巡检兜底强制收敛。两层互补:灰度回滚阻止新增问题,僵尸巡检收敛存量问题。