Harness Engineering:从 Agent Loop 到可靠执行

从最小循环出发,系统梳理工具契约、状态管理、上下文、权限、记忆、验证与多 Agent 编排。

大模型擅长生成文字。要让 Agent 完成一个跨越多轮操作的任务,程序还得帮它读取环境、调用工具、保存状态、处理失败,并在达到验收条件时停下来。

这套位于模型之外的程序与配置通常被称为 Agent Harness。可以把模型看作发动机,把 Harness 看作方向盘、仪表盘、刹车和道路规则。模型决定能力上限,Harness 决定系统能不能稳定地用好这份能力。

我从一个最小 Agent Loop 开始,沿着实际会遇到的故障逐步补齐 Harness。分析时只追三个问题:失败发生在哪里,哪一层负责处理,以及怎样验证处理确实有效。

一、先分清模型、Prompt、Context 和 Harness

模型、Prompt、Context 和 Harness 处在不同层次,解决的问题也不同。

层次 关注的问题 典型故障
Prompt 当前指令如何表达 回答偏离目标、格式不符合要求
Context 本次调用实际能看到什么 信息缺失、噪声过多、关键证据被淹没
Harness 整个任务如何持续执行并接受约束 越权、无限重试、状态丢失、假完成

Prompt 是 Context 的一部分。Context 还包括对话历史、工具定义、检索结果、文件内容和环境反馈。Harness 位于更外层,负责筛选进入 Context 的信息、执行模型提出的动作,并把结果组织成下一轮 Context。

Prompt、Context 与 Harness 的层次关系

一次模型调用只会产生回答或候选动作。执行权在 Harness 手里:动作能否执行、使用什么权限、结果怎样保存、任务是否完成,都由它判断。

可以把一个 Agent 粗略写成:

Agent = Model + Harness

这个表达式能把许多看似“模型不够聪明”的现象拆成具体的工程问题:

  • 模型没有看到图片,需要补观察通道,继续改 Prompt 没有用;
  • 工具只返回 failed,需要改错误契约,让模型多猜几次也得不到新信息;
  • 模型说“已完成”却没有测试证据,需要增加验收回路;
  • 新会话忘记进度,需要持久化状态。

二、从最小 Agent Loop 开始

用聊天模型解决问题时,人往往就在充当 Harness:复制报错、执行代码、把结果贴回对话框,再判断是否继续。把这套往返过程搬进程序,就得到了最小 Agent Loop。

def run_agent(task, tools):
    messages = [make_user_message(task)]

    for step in range(MAX_STEPS):
        response = ask_model(messages, tools)
        messages.append(response)

        calls = read_tool_calls(response)
        if not calls:
            return read_final_text(response)

        for call in calls:
            name, args, call_id = validate_call(call)
            result = TOOL_IMPL[name](**args)
            messages.append(make_tool_result(call_id, result))

    raise RuntimeError("Agent 超过最大步数,已停止")

这段代码里,模型只出现在 ask_model。其余工作都属于 Harness:保存消息、校验参数、执行工具、回传结果以及限制最大步数。

这个循环能运行,但还有一批问题没有处理:

  • 模型拒答、输出截断或接口超时时怎么办;
  • 工具参数不合法、工具不存在或权限不足怎么办;
  • 写操作超时后,远端究竟有没有执行成功;
  • 模型不再调用工具,是已经完成还是需要用户补充信息;
  • 进程崩溃后,如何知道上次执行到了哪里;
  • 模型说测试通过,依据来自哪里。

生产系统除了维护 messages,还需要一份独立的任务状态。

RUNNING → WAITING_TOOL → RUNNING
RUNNING → VERIFYING → COMPLETED
                   → RUNNING      # 验证失败,仍可修复
RUNNING → NEEDS_INPUT
RUNNING → CANCELLED
RUNNING → BUDGET_EXHAUSTED
WAITING_TOOL → RESULT_UNKNOWN     # 副作用已发出,但结果未确认

Agent Harness 运行循环

RESULT_UNKNOWN 很容易被忽略。只读查询超时后通常可以有限重试;发送通知、支付、部署等操作超时后,远端却可能已经成功。直接重放会产生重复副作用。此时要用业务幂等键、执行回执或状态查询核对结果。模型协议里的 call_id 只负责关联消息,代替不了业务幂等键。

三、Harness 的八层机制

下面八层分别对应一类常见故障。项目遇到哪类问题,就补哪一层,不需要照着清单堆满组件。

层 防止的问题 最小机制
运行循环 人工搬运、无限执行、错误重试 模型与工具交替执行,预算与退出状态
工具契约 参数乱传、结果难懂、输出撑爆窗口 Schema、分页、结构化错误、权限声明
规划 做着做着偏离原目标 当前步骤、任务边界、验收条件
上下文 关键信息被历史和日志淹没 按需读取、结果落盘、裁剪与摘要
记忆 新会话失忆、旧结论被误当事实 检查点、来源、适用范围、失效机制
Hook 与权限 规则只靠模型自觉 执行前拦截、最小权限、沙箱
验证回路 “执行过”被当成“完成了” 外部检查、证据要求、失败反馈
隔离与编排 子任务污染上下文或互相覆盖 独立上下文、结果契约、资源归属

1. 运行循环:把失败送到正确的处理层

“再试一次”只适合少数异常。Harness 要先区分失败类型,再选择处理策略:

故障 Harness 的动作 模型应该看到什么
模型接口限流 有次数上限的退避重试 重试耗尽后的明确原因
工具参数不合法 不执行,返回字段错误 如何修正参数
权限不足 保持权限边界并记录拒绝 被拒绝的动作与替代路径
只读查询超时 在预算内有限重试 超时,而不是“没有数据”
写操作超时 查询状态或核对幂等键 结果未知,不能声称成功或失败
连续重复动作 检查是否产生新证据,必要时熔断 已尝试范围和停止原因

除最大步数外,还要限制单次调用时长、任务总时间、工具调用次数、Token 和费用。任一预算耗尽时,Harness 都要写下明确的退出状态并保存进度。

2. 工具契约:给下一步提供可用信息

工具是 Harness 提供给模型的一份合约,不能简单地把底层函数原样暴露出去。合约至少要说明:

  • 什么时候应该使用;
  • 参数格式与允许范围是什么;
  • 会不会修改外部状态;
  • 能否并发;
  • 输出过大时如何分页或落盘;
  • 失败属于哪一类,是否值得重试。

例如,日志查询工具不应只返回一段字符串:

{
  "status": "ok",
  "query_scope": {
    "service": "order-service",
    "window": "2026-09-30T10:00:00+08:00/2026-09-30T10:10:00+08:00"
  },
  "items": [
    {"evidence_id": "E1", "message": "downstream timeout"}
  ],
  "truncated": true,
  "next_cursor": "opaque-cursor",
  "artifact_ref": "artifact:logs-001"
}

truncated: true 会改变结论能说到什么程度。模型可以说“当前返回记录里出现了下游超时”,却没有依据声称“全部日志只有这一类问题”。

错误也需要结构化。not_found、permission_denied、timeout 和 unknown_result 对应不同的后续动作。如果把权限不足包装成空数组,模型很可能把“无法读取”误解成“业务对象不存在”。

文件工具同样需要契约:

  • read 支持 offset 和 limit,超长时返回下一段位置;
  • bash 限制输出量,完整内容写入临时文件;
  • edit 使用精确旧文本与新文本,修改后返回 diff;
  • 写入前核对文件版本,防止覆盖并发修改;
  • 写入过程采用原子替换,避免留下半个文件。

3. 规划:保存目标与边界

复杂任务经常在执行过程中偏离原目标。工具结果不断进入 Context,最新的报错会吸走注意力,调查范围也会跟着扩大。

计划用来固定目标、边界和验收条件:

goal: 定位指定接口在指定时间段超时的原因
scope:
  allowed: 读取日志、Trace 和指标
  forbidden: 修改线上配置或重启服务
acceptance:
  - 结论关联到可复查证据
  - 区分已验证事实、推测和缺失信息
budget:
  max_tool_calls: 30
  deadline_minutes: 15
stop_when:
  - 达成验收
  - 缺少必要权限或定位标识
  - 预算耗尽

重规划需要说明新证据是什么、旧计划哪里不再成立,以及下一步怎样接近验收。没有新证据却持续扩大范围,通常意味着任务已经漂移。

4. 上下文:它是预算,也是状态的一份视图

Context 是当前决策所需信息的集合,不负责保存永久历史。系统指令、工具定义、文件内容、命令输出和检索结果都会占用窗口。

一个实用的保留顺序是:

  1. 当前目标与用户约束;
  2. 已确认的决策、理由与关键证据;
  3. 最近的操作与结果;
  4. 可以重新读取的旧工具输出。

系统最好同时保存两份信息:完整记录用于追溯,本轮 Context 用于决策。

事件记录 + 证据文件 + 任务状态 + 项目知识
                    ↓ 按当前目标筛选
Context = 目标 + 约束 + 当前步骤 + 关键证据 + 必要历史

大结果优先落盘,Context 只保留摘要、截断信息和引用。需要压缩时,要留下架构决策及理由、已修改文件、验证状态、待办和精确标识符。模型可以生成摘要,程序仍需校验结构和引用是否存在。

“可重新获取”不等于所有操作都能重跑。历史日志可能过期,环境可能变化,带副作用的命令也不能为了恢复上下文再次执行。

5. 记忆:事实、解释与规则不能混在一起

模型不会自行保留跨会话状态。需要长期保存的信息应写进任务检查点、项目文档或 Git。

{
  "task_id": "incident-20260930",
  "revision": 7,
  "status": "waiting_tool",
  "goal": "定位接口超时",
  "current_step": "核对下游耗时",
  "completed_steps": ["确认时间范围", "获取样本调用链"],
  "pending_actions": [
    {"action_id": "action-3", "kind": "read_metrics", "status": "running"}
  ],
  "evidence_refs": ["artifact:trace-001"],
  "open_questions": ["是否存在同时间窗的服务端排队"],
  "next_action": "读取指标结果并更新假设"
}

恢复任务时,不能只把这段 JSON 塞回模型。运行时要先核对在途动作是否完成,尤其要处理这种崩溃窗口:工具已经执行成功,结果却还没来得及落盘。

记忆中还要区分:

  • “接口返回 403”是观察事实;
  • “可能是权限不足”是解释;
  • “以后永远不用这个接口”是一条规则。

这三类信息不能在摘要里合并成一句话。重要记忆最好记录来源、时间、适用范围和验证状态;信息过期后需要重新核对。

6. Hook 与权限:把口头规则变成执行检查

写在 Prompt 里的“不要删除文件”仍要靠模型遵守。能由程序判断的规则,应放到模型之外:

  • 权限规则决定动作是 allow、deny 还是 ask;
  • PreTool Hook 在动作发生前检查参数与风险;
  • PostTool Hook 在修改后立即运行格式、语法或类型检查;
  • 沙箱限制动作真正执行后能够影响的范围;
  • CI 在合并前执行最终门禁。

决定一条规则放在哪里时,可以检查四件事:能否写成代码判定、违反一次的代价、出现频率和反馈速度。

规则 更合适的位置
命名与解释风格 项目指令或 Skill
模块 A 不得依赖模块 B 静态检查与 CI
不得读取特定目录 文件权限或沙箱
某类动作必须得到批准 工具执行前的权限检查
结果必须包含证据 输出校验与验收器

授权必须来自可信的运行时记录。网页、文档和工具结果里即使写着“用户已经批准”,也只是外部数据;模型总结出的“已获授权”同样无效。执行前要核对获批的动作、目标和关键参数是否与即将执行的操作一致。

7. 验证回路:在验证前,“完成”只是一句声明

可靠验收要同时检查结果、执行过程和交付质量:

维度 代码任务 排障任务
结果 原问题是否消失,相关测试是否通过 是否定位原因,或明确证据为何不足
过程 是否越过授权范围、修改无关文件 是否查错环境、扩大时间范围
质量 兼容性、性能、可维护性 证据是否支持结论,是否过度推断

确定性检查优先交给测试、类型系统、Linter 和契约。需要主观判断的部分,可以交给独立上下文中的评审 Agent 或人工处理。分开实现者与评审者,能减少“自己写、自己判”带来的宽松倾向,独立评审仍代替不了可执行测试。

验收失败后的反馈要能指导下一步,只有一个总分没有多少帮助:

{
  "accepted": false,
  "issues": [
    {
      "criterion": "结论有对应证据",
      "finding": "报告称连接池耗尽,但引用证据只显示下游超时",
      "required_action": "补充直接证据,或将该结论降为待验证假设"
    }
  ]
}

如果 Agent 可以修改验收标准、删除失败测试或降低阈值,它就可能通过改考卷让自己通过。验收基线需要独立保存,修改基线也要单独审核。

8. 子 Agent 与编排:先隔离上下文

子 Agent 适合独立搜索、读取大量文件或执行专项检查。它在自己的 Context 中处理细节,主任务只接收结论、证据位置、覆盖范围和不确定项。

subtask: 核查下游服务是否出现排队
input:
  window: 与主任务一致的时间窗
  evidence: [E1, E2]
  constraints: 只读,不得扩大环境范围
output:
  status: completed / blocked / failed
  findings: 结论与证据引用
  coverage: 实际检查范围
  uncertainties: 尚未排除的问题
  artifacts: 可复查结果位置

上下文隔离并不会自动隔离文件系统。多个 Agent 仍可能同时修改一个文件,因此需要明确写入归属、使用独立工作区,或让单一执行者统一落盘。

增加子 Agent 前,先看它能否带回主模型当前拿不到的信息。如果只是让几个相同模型换角色讨论,没有新的工具结果、界面观察或独立证据,通常只会增加 Token 和协调成本。

四、用一次排障任务串起来

用一个排障任务把前面的机制连起来。用户给出的任务是:“某接口在十分钟内大量超时,帮我定位原因。”

第一步:建立任务边界

Harness 保存接口、环境、时间范围、只读权限和最大预算。缺少必要定位信息时,状态进入 needs_input,模型不应自行猜测环境。

第二步:取得现象证据

日志工具返回 E1,显示错误率上升。Trace 工具返回 E2,请求耗时主要集中在下游调用。此时只能确认“下游阶段贡献了较多耗时”,还不足以判断根因来自数据库、网络还是连接池。

第三步:把结论拆成假设

模型提出网络等待、下游排队和数据库访问变慢这几个候选原因。Harness 将假设与待查证据写入状态文件。这样即使上下文被压缩或会话重启,系统仍知道每一步在查什么。

第四步:正确解释工具失败

数据库指标查询返回 permission_denied,说明这个分支还没有被查证,不能据此得出“数据库没有异常”。如果工具只返回空数组,后面的推理很可能从这里开始跑偏。

第五步:形成有边界的结论

新证据 E3 显示,同一时间窗内的下游排队时间明显上升。报告可以写“现有证据支持超时主要发生在下游排队阶段”。若要继续追到“某次发布导致排队”,还需要变更时间、实例对照或其他因果证据。

第六步:交给验收器

验收器核对每个结论对应的环境、时间和证据,检查是否把无权限误写成正常,以及执行过程有没有越过只读范围。如果证据不足,结果应写明“尚无法定位”并列出缺失信息,不必为了给出唯一答案而编造根因。

这类任务可以没有确定答案,但结论强度必须与证据强度一致。

五、如何判断 Harness 改动是否有效

规则不断累积后,Harness 很容易变得臃肿。新增组件最好对应一次真实失败,并记录它要改善的问题、引入的成本、验证方式和复查时间。

可以用下面的流程评测改动:

  1. 从真实失败中整理固定任务集;
  2. 冻结旧版与新版 Harness;
  3. 固定模型、任务输入和主要环境;
  4. 每次只修改一个组件;
  5. 对同一道题多次运行,做逐题比较;
  6. 记录失败轨迹,而不只看最终总分。

评测时同时关注这些指标:

指标 说明
任务成功率 端到端是否交付
错误完成率 没做成却报告成功的比例
证据支持率 结论是否真的被引用证据支持
自我恢复率 收到有效反馈后能否纠正
人工介入次数 哪些地方仍需要人处理
单任务耗时与费用 资源效率与每个成功任务成本
权限违规与近失事件 是否突破执行边界

成功率不变但成本下降,改动仍可能有价值。成功率上升却伴随更多越权,则说明改动没有达到目标。

消融实验会暂时关闭某个组件,观察效果怎样变化。一次分数不变,只能说明当前样本没有检出收益,不能证明组件永远无用。权限和安全机制往往低频但损失很高,也不能因为小样本里“没有出事”就删除。

评测本身也会失真,例如题面泄露答案、模型能读取标准答案、外部服务状态变化、执行环境不一致或裁判标准不稳定。修改 Harness 前,先检查考卷和验证器。

六、从最小实现到可维护系统

个人项目没必要一次搭齐所有机制,可以根据风险和遇到的故障逐步增加。

阶段一:先跑通,并看清失败

先建立最小循环、少量工具、执行日志和资源预算,记录模型请求、实际执行、工具结果与退出原因。这一阶段先保证失败可以重现,不急着把系统做得完整。

阶段二:补工具契约与任务验收

区分参数错误、查无数据、权限不足、超时和结果未知,限制工具输入输出,并给关键任务写清验收条件。有副作用的动作还要同时建立授权与幂等机制。

阶段三:让长任务能够恢复

把任务状态和证据放到模型上下文之外,增加检查点、在途调用核对和 Context 裁剪。可以主动模拟程序在几个关键位置崩溃,检查恢复后是否重复执行、丢失证据或错误宣告完成。

阶段四:根据瓶颈增加分工

出现独立且耗时的调查分支时,再引入子 Agent;需要长期协作时,再增加消息与调度。每个新角色都要明确输入、输出、权限、预算、超时和失败归属。

阶段五:持续评估与清理

把真实失败整理成回归用例,记录每次组件调整的收益与代价。模型或项目升级后,重新检查旧假设,删除已经被模型能力或其他可靠机制覆盖的规则。

从后端工程的视角看,Harness 处理的许多问题并不陌生:

Agent 机制 后端工程中的对应问题
工具调用超时 分布式调用的结果未知、幂等与补偿
检查点恢复 持久化、崩溃窗口和状态重放
多 Agent 通信 消息重复、乱序与消费确认
工具并发 数据依赖、锁、限流与资源调度
权限检查 服务端鉴权、资源范围和审计
完成验证 业务验收、质量门禁与基线对比

新的难点来自模型本身:动作和解释由概率系统动态生成,所以协议、状态和反馈需要比传统程序更明确。

七、什么时候不需要复杂 Harness

原型、一次性脚本和低风险小改动通常不值得搭建重流程。判断一层机制是否值得保留,可以看同一种错误再次出现时,它能否自动挡住问题。

模型升级后,一部分“教模型如何做事”的提示会失去价值。状态持久化、权限隔离、外部验证和不可逆操作前的确认则更稳定。Harness 应当允许裁剪,每个组件都能独立关闭、比较和删除。

规则放进 Prompt 还是变成程序机制,取决于它需不需要模型灵活判断。需要判断的规则适合写进文档和 Skill;模型不应有机会违反的规则,更适合交给类型系统、Linter、权限、Hook 与 CI。

持续改进 Harness 的方式,是把重复出现的问题变成工程资产:

  • 缺失的信息变成项目文档与观察接口;
  • 反复出现的 Review 意见变成静态检查;
  • 看不见的失败变成结构化信号;
  • 口头提醒变成真正的执行边界;
  • 线上事故变成可以重复运行的评测用例。

Agent 出错后,除了修正当前产物,还要回到运行环境中寻找缺失的能力、边界或反馈。随着这些机制逐渐齐全,工程师可以少参与具体步骤,把精力放在目标、协议、约束和验证回路上。

我目前对 Harness Engineering 的理解是:让模型负责需要创造和判断的部分,把权限、状态和验收尽可能交给确定性的系统。