Agent 容错执行框架(Harness)
🥰个人主页:会编程的土豆(欢迎来访)
💎作者简介:后端学习者
❄️个人专栏:数据结构与算法,数据库,leetcode
✨那些你一个人走过的夜路,终将化作照亮未来的光



基于项目真实代码整理,用于后端开发面试准备
目标:读完能 90 秒口述讲清「我们的 harness 怎么做的、解决了什么」
一、先搞懂:什么是 Harness
很多人第一次听到 “Harness” 会以为是某个框架或者库。其实不是,Harness 就是「套在核心逻辑外面的那层保护壳」。
大白话类比:
- 你写了一个 Agent,核心逻辑是「规划 → 调工具 → 合成答案」
- 但真实世界里工具会超时、会报错、会卡住,LLM 会抽风,异步任务会 panic
- 如果直接裸跑,任何一个环节出问题,整个请求甚至整个进程就崩了
- Harness 就是在核心逻辑外面包一层「安全带」:超时、重试、取消、降级、兜底,让 Agent 在各种异常下还能活着返回结果
所以 Harness 不是某一个函数,而是一套贯穿请求全生命周期的容错策略。
二、项目里的 Harness 长什么样
先说结论:这个项目没有独立的 harness 包。它是横切在 runtime 里的一层容错执行层,配置在 config.yaml 的 harness: 段,执行点分散在三个地方:
- 图运行时(
runtime_graph.go)—— 步骤级重试、超时、失败 replan - 进程编排(
runtime_process.go/runtime_task.go)—— 请求级取消 - 异步副作用(
infra_cancel.go)—— panic 兜底
数据结构定义在 [core_types.go](file:///d:/agent/AGI-saber/internal/application/chat/core_types.go#L32-L75):
TaskStep:可重试的原子执行单元(id、tool、params、status、retry_count)TaskState:一次任务的完整状态(task_id、status、phase、steps、interrupted_at)Snapshot:某一时刻的任务状态快照(用于排查和中断摘要)
三、四个配置旋钮
[config.yaml#L73-L77](file:///d:/agent/AGI-saber/config/config.yaml#L73-L77):
harness:
max_retries: 3 # 总尝试次数(1 原始 + 2 重试),注意不是「重试 3 次」
retry_delay_ms: 200 # 重试间隔,固定 sleep,无指数退避
step_timeout_ms: 5000 # 单次工具调用超时
max_iterations: 5 # ⚠️ 死配置,代码里没人读
重要纠正:max_retries: 3 不是重试 3 次,而是最多尝试 3 次(1 次原始调用 + 2 次重试)。代码逻辑是 for attempt := 0; attempt < maxRetries; attempt++。
另外 max_iterations 写在配置里但没有任何代码读取它,真正限制迭代次数的是图结构本身和 MaxReplan=2。这是个面试可以主动提的「配置与实现不一致」问题。
四、四层防护(核心)
整个 Harness 按请求生命周期分成四层,从上到下逐层兜底。
第 1 层:请求级 —— 取消
问题:用户点了「停止」,或者前端断开连接,怎么让正在执行的 Agent 停下来?
做法:
每个入口(Process / ProcessContext / ProcessStream)都做两件事:
ctx, cancel := context.WithCancel(ctx)
unregister := a.registerCancel(cancel)
defer unregister()
registerCancel 把这个 cancel 函数按递增 id 存进 cancelFns map([runtime_task.go#L33-L60](file:///d:/agent/AGI-saber/internal/application/chat/runtime_task.go#L33-L60))。
关键点:用 map 而不是单个字段,是因为多请求并发。旧实现把 cancel 存在 Agent 的单字段上,后来的请求会覆盖前面的,导致只能取消最近一次。现在每个请求一个 id,CancelAll() 遍历 map 逐个触发,互不影响。
runOnce 在 dispatch 前后各检查一次 ctx.Err(),一旦取消就直接返回 [已中断] 摘要,不再继续往下跑。
第 2 层:步骤级 —— 重试 + 超时
问题:工具调用超时了怎么办?工具报 5xx 怎么办?报 4xx 呢?
做法:核心在 [executeSingleNode](file:///d:/agent/AGI-saber/internal/application/chat/runtime_graph.go#L460-L541),逻辑是一个 attempt 循环:
for attempt := 0; attempt < maxRetries; attempt++ {
1. 先检查 ctx 是否已取消 → 取消就标 cancelled 退出
2. 为本次 attempt 派生一个独立的 WithTimeout(ctx, stepTimeout)
3. 调工具(三级回落:ExecuteStructured → ExecuteCtx → Execute)
4. 成功 → break
5. 失败 → 看 Retryable:
- 不可重试(4xx / 参数错)→ 立刻 break
- 可重试(5xx / 网络 / 超时)→ sleep 200ms,继续下一轮
}
几个关键设计:
-
每次 attempt 都新建 timeout ctx:如果复用同一个 ctx,第一次超时后 ctx 就废了,后面的重试也跑不了。每次新建保证重试是干净的。
-
工具调用三级回落([tool.go#L31-L39](file:///d:/agent/AGI-saber/internal/domain/tool/tool.go#L31-L39)):
ExecuteStructured:返回ToolResult,带ToolError.Retryable字段,能精确判断要不要重试(目前只有 MCP 工具实现了)ExecuteCtx:带 ctx 的字符串接口Execute:老接口,无 ctx,无结构化错误
-
错误分类重试:只有结构化错误才能区分「4xx 不重试 / 5xx 重试」。老接口路径只要
err != nil一律认为可重试——这是个缺陷,参数错误也会被重试 3 次。 -
LLM 重节点特判([runtime_graph.go#L466-L476](file:///d:/agent/AGI-saber/internal/application/chat/runtime_graph.go#L466-L476)):
sub_agent和skill_*节点内部会串行做多次 LLM 调用,秒级的stepTimeout会把它们误杀。所以特判:超时下限抬到 5 分钟,并且maxRetries = 1(只跑一次,不重试)。
第 3 层:图级 —— 失败传播与恢复
问题:某个节点失败了,下游怎么办?整个任务就废了吗?
做法:
-
失败节点标
failed并写error,但仍然MarkDone推进下游——也就是说,失败不会卡死整图,下游会拿着空的上游结果继续跑。这是个双刃剑:好处是不卡死,坏处是下游可能产出错误结果。 -
局部 replan:如果开启了
ReplanOnFailed,节点失败时会让 LLM 基于当前图状态决定要不要追加替代节点(换工具、换参数),受MaxReplan=2总额度约束,防止死循环。 -
竞速组([raceGroup](file:///d:/agent/AGI-saber/internal/application/chat/runtime_graph.go#L258-L355)):同
race_group的节点并行跑,首个成功的胜出,其余立即 cancel 并标skipped推进下游。整组有 30s 超时,全败才整组标failed。 -
快照:每层执行完
saveSnapshot(task),同时落内存和 PostgreSQL,方便事后排查和中断时生成摘要。
第 4 层:副作用级 —— panic 兜底
问题:偏好抽取、记忆抽取、consolidate 这些异步任务如果 panic 了,会拖崩整个进程。
做法:所有 fire-and-forget 任务统一走 goSafe([infra_cancel.go#L38-L61](file:///d:/agent/AGI-saber/internal/application/chat/infra_cancel.go#L38-L61)):
func (a *UnifiedAgent) goSafe(name string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
logger.L().Error("goroutine panic", "task", name, "panic", r, "stack", string(debug.Stack()))
}
}()
fn()
}()
}
捕获 panic、打日志(带 stack trace 方便排查),但不影响主流程返回。
五、解决了什么问题
| 原问题 | 解决方式 |
|---|---|
| 工具/LLM 抖动导致全链路失败 | 按错误可重试性分类重试,5xx/网络/超时自动重试 |
| 工具无超时,单节点卡死整图 | 每次 attempt 独立 WithTimeout,超时立即释放 |
| 多请求并发时 cancel 互相覆盖 | cancelFns map 按 id 隔离,互不影响 |
| 失败了只能放弃 | 局部 replan 换方案;竞速组多源首胜 |
| 异步任务 panic 拖崩进程 | goSafe 统一 recover |
| 执行中途崩溃无法排查 | TaskState + Snapshot 每层落盘 |
| LLM 重节点被秒级超时误杀 | 特判 timeout 下限 5 分钟、只跑 1 次 |
六、缺陷与改进方向(面试加分项)
主动暴露边界,比被问住强。以下是真实存在的问题:
-
无请求级总预算:5s × 3 attempts × N 节点 × replan 2 次,总时长理论上无上限。缺少一个「整个请求最多跑 X 秒 / 花 Y 元」的硬限制。
-
max_iterations是死配置:配置写了 5 次,代码里没人读。真正限制迭代的是图结构和MaxReplan。 -
429 被归为 4xx → 不重试:限流场景最该退避重试,恰好被误伤。应该单独处理 429,加指数退避。
-
老工具不可取消、错误一律可重试:
search_web只实现了无 ctx 的Execute,取消只能等它自然返回;参数错误也会被重试 3 次。 -
失败节点静默降质:失败节点仍推进下游,且不进最终 Observations,Generator 不知道有步骤失败,可能输出残缺答案还很自信。
-
失败 replan 持锁调 LLM:节点失败时在持有
rt.mu的情况下调用tryReplan(内部发 LLM 请求),网络 IO 持锁会阻塞同层其他节点写结果。 -
中断时竞速组语义丢失:节点已被标
cancelled,但raceGroup在「无胜出者」分支会把整组覆盖成failed。 -
并发 map 读写风险:
tryReplan里的AddNodes写tg.Nodes,同时其他 goroutine 在无锁读该 map,建议用go test -race验证。
七、90 秒口述版(面试直接用)
我们的 harness 不是独立框架,是嵌在图运行时里的容错执行层,分四层防护。
第一层请求级:每个请求派生独立 ctx 和 cancel 句柄,按 id 存进 map,多请求并发时取消互不覆盖。
第二层步骤级:每次工具调用 attempt 都派生独立的 timeout ctx,避免上次超时污染重试;按结构化 ToolError 的 Retryable 字段区分——4xx/参数错不重试,5xx/网络/超时才重试,上限 3 次、间隔 200ms。
第三图层级:失败节点标 failed 但仍推进下游,避免卡死;开启 ReplanOnFailed 时让 LLM 追加替代节点,总额度 MaxReplan=2 防死循环;竞速组 30 秒整组超时、首胜取消其余。
第四层副作用级:所有异步任务走 goSafe,recover 后打日志不拖崩进程。
中断时落 TaskState 快照并返回「已完成 x/y 步」摘要——注意这是「可中断 + 快照留档」,不是断点续跑。
更多推荐

所有评论(0)