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

基于项目真实代码整理,用于后端开发面试准备
目标:读完能 90 秒口述讲清「我们的 harness 怎么做的、解决了什么」


一、先搞懂:什么是 Harness

很多人第一次听到 “Harness” 会以为是某个框架或者库。其实不是,Harness 就是「套在核心逻辑外面的那层保护壳」。

大白话类比:

  • 你写了一个 Agent,核心逻辑是「规划 → 调工具 → 合成答案」
  • 但真实世界里工具会超时、会报错、会卡住,LLM 会抽风,异步任务会 panic
  • 如果直接裸跑,任何一个环节出问题,整个请求甚至整个进程就崩了
  • Harness 就是在核心逻辑外面包一层「安全带」:超时、重试、取消、降级、兜底,让 Agent 在各种异常下还能活着返回结果

所以 Harness 不是某一个函数,而是一套贯穿请求全生命周期的容错策略。


二、项目里的 Harness 长什么样

先说结论:这个项目没有独立的 harness 包。它是横切在 runtime 里的一层容错执行层,配置在 config.yaml 的 harness: 段,执行点分散在三个地方:

  1. 图运行时(runtime_graph.go)—— 步骤级重试、超时、失败 replan
  2. 进程编排(runtime_process.go / runtime_task.go)—— 请求级取消
  3. 异步副作用(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,继续下一轮
}

几个关键设计:

  1. 每次 attempt 都新建 timeout ctx:如果复用同一个 ctx,第一次超时后 ctx 就废了,后面的重试也跑不了。每次新建保证重试是干净的。

  2. 工具调用三级回落([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,无结构化错误
  3. 错误分类重试:只有结构化错误才能区分「4xx 不重试 / 5xx 重试」。老接口路径只要 err != nil 一律认为可重试——这是个缺陷,参数错误也会被重试 3 次。

  4. 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 层:图级 —— 失败传播与恢复

问题:某个节点失败了,下游怎么办?整个任务就废了吗?

做法:

  1. 失败节点标 failed 并写 error,但仍然 MarkDone 推进下游——也就是说,失败不会卡死整图,下游会拿着空的上游结果继续跑。这是个双刃剑:好处是不卡死,坏处是下游可能产出错误结果。

  2. 局部 replan:如果开启了 ReplanOnFailed,节点失败时会让 LLM 基于当前图状态决定要不要追加替代节点(换工具、换参数),受 MaxReplan=2 总额度约束,防止死循环。

  3. 竞速组([raceGroup](file:///d:/agent/AGI-saber/internal/application/chat/runtime_graph.go#L258-L355)):同 race_group 的节点并行跑,首个成功的胜出,其余立即 cancel 并标 skipped 推进下游。整组有 30s 超时,全败才整组标 failed。

  4. 快照:每层执行完 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 次

六、缺陷与改进方向(面试加分项)

主动暴露边界,比被问住强。以下是真实存在的问题:

  1. 无请求级总预算:5s × 3 attempts × N 节点 × replan 2 次,总时长理论上无上限。缺少一个「整个请求最多跑 X 秒 / 花 Y 元」的硬限制。

  2. max_iterations 是死配置:配置写了 5 次,代码里没人读。真正限制迭代的是图结构和 MaxReplan。

  3. 429 被归为 4xx → 不重试:限流场景最该退避重试,恰好被误伤。应该单独处理 429,加指数退避。

  4. 老工具不可取消、错误一律可重试:search_web 只实现了无 ctx 的 Execute,取消只能等它自然返回;参数错误也会被重试 3 次。

  5. 失败节点静默降质:失败节点仍推进下游,且不进最终 Observations,Generator 不知道有步骤失败,可能输出残缺答案还很自信。

  6. 失败 replan 持锁调 LLM:节点失败时在持有 rt.mu 的情况下调用 tryReplan(内部发 LLM 请求),网络 IO 持锁会阻塞同层其他节点写结果。

  7. 中断时竞速组语义丢失:节点已被标 cancelled,但 raceGroup 在「无胜出者」分支会把整组覆盖成 failed。

  8. 并发 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 步」摘要——注意这是「可中断 + 快照留档」,不是断点续跑。

Logo

更多推荐