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。
一次模型调用只会产生回答或候选动作。执行权在 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 # 副作用已发出,但结果未确认
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 是当前决策所需信息的集合,不负责保存永久历史。系统指令、工具定义、文件内容、命令输出和检索结果都会占用窗口。
一个实用的保留顺序是:
- 当前目标与用户约束;
- 已确认的决策、理由与关键证据;
- 最近的操作与结果;
- 可以重新读取的旧工具输出。
系统最好同时保存两份信息:完整记录用于追溯,本轮 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 很容易变得臃肿。新增组件最好对应一次真实失败,并记录它要改善的问题、引入的成本、验证方式和复查时间。
可以用下面的流程评测改动:
- 从真实失败中整理固定任务集;
- 冻结旧版与新版 Harness;
- 固定模型、任务输入和主要环境;
- 每次只修改一个组件;
- 对同一道题多次运行,做逐题比较;
- 记录失败轨迹,而不只看最终总分。
评测时同时关注这些指标:
| 指标 | 说明 |
|---|---|
| 任务成功率 | 端到端是否交付 |
| 错误完成率 | 没做成却报告成功的比例 |
| 证据支持率 | 结论是否真的被引用证据支持 |
| 自我恢复率 | 收到有效反馈后能否纠正 |
| 人工介入次数 | 哪些地方仍需要人处理 |
| 单任务耗时与费用 | 资源效率与每个成功任务成本 |
| 权限违规与近失事件 | 是否突破执行边界 |
成功率不变但成本下降,改动仍可能有价值。成功率上升却伴随更多越权,则说明改动没有达到目标。
消融实验会暂时关闭某个组件,观察效果怎样变化。一次分数不变,只能说明当前样本没有检出收益,不能证明组件永远无用。权限和安全机制往往低频但损失很高,也不能因为小样本里“没有出事”就删除。
评测本身也会失真,例如题面泄露答案、模型能读取标准答案、外部服务状态变化、执行环境不一致或裁判标准不稳定。修改 Harness 前,先检查考卷和验证器。
六、从最小实现到可维护系统
个人项目没必要一次搭齐所有机制,可以根据风险和遇到的故障逐步增加。
阶段一:先跑通,并看清失败
先建立最小循环、少量工具、执行日志和资源预算,记录模型请求、实际执行、工具结果与退出原因。这一阶段先保证失败可以重现,不急着把系统做得完整。
阶段二:补工具契约与任务验收
区分参数错误、查无数据、权限不足、超时和结果未知,限制工具输入输出,并给关键任务写清验收条件。有副作用的动作还要同时建立授权与幂等机制。
阶段三:让长任务能够恢复
把任务状态和证据放到模型上下文之外,增加检查点、在途调用核对和 Context 裁剪。可以主动模拟程序在几个关键位置崩溃,检查恢复后是否重复执行、丢失证据或错误宣告完成。
阶段四:根据瓶颈增加分工
出现独立且耗时的调查分支时,再引入子 Agent;需要长期协作时,再增加消息与调度。每个新角色都要明确输入、输出、权限、预算、超时和失败归属。
阶段五:持续评估与清理
把真实失败整理成回归用例,记录每次组件调整的收益与代价。模型或项目升级后,重新检查旧假设,删除已经被模型能力或其他可靠机制覆盖的规则。
从后端工程的视角看,Harness 处理的许多问题并不陌生:
| Agent 机制 | 后端工程中的对应问题 |
|---|---|
| 工具调用超时 | 分布式调用的结果未知、幂等与补偿 |
| 检查点恢复 | 持久化、崩溃窗口和状态重放 |
| 多 Agent 通信 | 消息重复、乱序与消费确认 |
| 工具并发 | 数据依赖、锁、限流与资源调度 |
| 权限检查 | 服务端鉴权、资源范围和审计 |
| 完成验证 | 业务验收、质量门禁与基线对比 |
新的难点来自模型本身:动作和解释由概率系统动态生成,所以协议、状态和反馈需要比传统程序更明确。
七、什么时候不需要复杂 Harness
原型、一次性脚本和低风险小改动通常不值得搭建重流程。判断一层机制是否值得保留,可以看同一种错误再次出现时,它能否自动挡住问题。
模型升级后,一部分“教模型如何做事”的提示会失去价值。状态持久化、权限隔离、外部验证和不可逆操作前的确认则更稳定。Harness 应当允许裁剪,每个组件都能独立关闭、比较和删除。
规则放进 Prompt 还是变成程序机制,取决于它需不需要模型灵活判断。需要判断的规则适合写进文档和 Skill;模型不应有机会违反的规则,更适合交给类型系统、Linter、权限、Hook 与 CI。
持续改进 Harness 的方式,是把重复出现的问题变成工程资产:
- 缺失的信息变成项目文档与观察接口;
- 反复出现的 Review 意见变成静态检查;
- 看不见的失败变成结构化信号;
- 口头提醒变成真正的执行边界;
- 线上事故变成可以重复运行的评测用例。
Agent 出错后,除了修正当前产物,还要回到运行环境中寻找缺失的能力、边界或反馈。随着这些机制逐渐齐全,工程师可以少参与具体步骤,把精力放在目标、协议、约束和验证回路上。
我目前对 Harness Engineering 的理解是:让模型负责需要创造和判断的部分,把权限、状态和验收尽可能交给确定性的系统。
如果这篇文章对你有帮助