1. 从“能跑”到“跑得住”:Harness 工程到底在解决什么
很多人第一次接触 Agent 系统,注意力几乎全放在模型本身——换更强的模型、调更细的提示词、堆更多的工具。结果 demo 阶段惊艳,一旦让它连续跑上几十分钟、处理几十个步骤,就开始出现各种诡异现象:跑到一半忘了最初的目标、工具调用参数越写越离谱、上下文塞满后开始胡言乱语、同一个错误反复重试直到把额度烧光。这些问题的根源,往往不在模型,而在模型外面那层“壳”。
这层壳,就是 Harness。直译是“马具”,很形象——模型是那匹有劲但方向感不稳定的马,Harness 是套在它身上、让它能沿着既定路线稳定拉车的一整套约束装置。它包含 Agent 循环怎么转、上下文怎么组织、工具在什么环境里执行、结果怎么验证、长任务怎么维持状态。模型能力决定上限,Harness 决定这个上限能不能被稳定地兑现出来。
我自己的判断是:在当前的工程实践里,Harness 的质量对最终效果的影响,经常比换一个更强的模型还大。一个设计良好的循环加上克制的上下文管理,能让中等模型跑出接近顶级模型的稳定性;反过来,一个粗糙的 Harness 会把顶级模型拖到及格线以下。这篇文章就围绕 Harness 工程的几个核心模块展开——Agent 循环、上下文、沙箱、验证、长时运行——把每个部分“为什么这么设计”和“实际怎么落地”讲透。
适合读这篇的人:正在做 Agent 应用、被长任务稳定性折磨过的开发者;想从“调提示词”进阶到“做系统”的工程师;以及任何好奇“为什么我的 Agent 跑着跑着就崩了”的实践者。下面不堆概念,直接按工程模块拆。
2. Agent 循环:不是 while(true) 那么简单
2.1 循环的基本形态与它的三个隐藏成本
最朴素的 Agent 循环长这样:把用户输入和工具列表发给模型,模型返回一个工具调用,执行工具,把结果塞回对话,再发给模型,如此往复直到模型返回最终答案。伪代码大概十行就能写完:
while not done: response = model.chat(messages, tools) if response.is_final: break result = execute(response.tool_call) messages.append(response.tool_call) messages.append(result)看起来人畜无害,但真跑起来,这个循环有三个隐藏成本会逐渐暴露。
第一个是轮次成本。每一轮都要把完整历史重新发给模型,token 消耗随轮次线性甚至超线性增长。跑到第 30 轮时,光是把历史读一遍就可能吃掉大量预算。第二个是错误累积成本。某一轮工具返回了格式错误的结果,模型基于错误信息继续推理,错误会像滚雪球一样放大。第三个是收敛成本。模型可能陷入“调用工具—结果不满意—再调用—还是不满意”的死循环,没有明确的退出条件。
所以一个能上生产的循环,必须显式管理这三件事:轮次上限、错误处理策略、收敛判定。我通常会给循环加一个硬性步数上限(比如 25 步),超过就强制进入总结阶段,让模型基于已有信息给出当前最优答案,而不是无限试探。
2.2 循环里的“决策点”设计:什么时候该停、该问、该退
循环真正难的地方不是“转”,而是“什么时候不转”。我把循环里的关键判断归纳成三类决策点。
停止决策:模型说完成了,但它真的完成了吗?这里不能只信模型的自述。常见做法是加一个轻量的校验步骤——比如让模型在声称完成时,必须同时输出它认为满足的验收条件,然后由外部逻辑或另一个模型调用去核对。这一步能挡掉相当一部分“自我感觉良好”的假完成。
求助决策:当模型连续两次拿到相同的错误、或者工具返回的信息明显不足以推进时,应该让它停下来向用户提问,而不是硬编。我见过太多 Agent 在信息缺失时开始“脑补”参数,编出一个看起来合理但完全错误的输入。给循环加一个“连续失败计数”,达到阈值就触发求助分支,是性价比极高的改动。
回退决策:某些操作是不可逆的(删数据、发请求、写文件)。循环里应该区分“可安全重试”和“不可逆”的工具,对后者要么加确认,要么设计成幂等。这个区分必须在工具定义阶段就做好,不能等出事再补。
2.3 把循环写成状态机:比裸循环多出来的那点确定性
裸while循环最大的问题是状态全藏在messages数组里,调试时根本看不出“现在到底走到哪一步了”。我的做法是把循环显式建模成一个状态机,至少包含这几个状态:规划中、执行工具、等待结果、校验中、求助、完成、失败。每次状态转移都记一条日志,包含当前步数、触发转移的原因、消耗的 token。
这么做的直接好处是:出问题时你能一眼看出它卡在哪个状态、卡了几轮。间接好处是,你可以在特定状态上挂钩子——比如在校验中状态强制走一遍验证逻辑,在执行工具状态做参数合法性检查。状态机让“循环”从一个黑盒变成了可观测、可干预的流程,这是长任务能跑住的前提。
提示:不要一上来就把状态机设计得很复杂。先用三五个状态跑通,等真的遇到“分不清卡在哪”的问题时再细化。过度设计的状态机本身就是新的 bug 来源。
3. 上下文工程:决定 Agent 能“记住”多久的关键
3.1 上下文不是越多越好:窗口塞满后的三种典型退化
新手最容易犯的错,是把所有历史一股脑塞进上下文,觉得信息越全模型越聪明。实测恰恰相反。当上下文接近窗口上限时,会出现三种典型退化。
第一种是注意力稀释:关键信息淹没在大量无关历史里,模型抓不住重点。第二种是中间遗忘:很多模型对上下文首尾的信息记得牢,中间部分容易忽略,塞得越满,中间的关键约束越容易被跳过。第三种是格式漂移:历史里混入了各种工具返回的原始格式,模型开始模仿这些格式,输出越来越不规范。
所以上下文管理的核心不是“装更多”,而是“在正确的时刻装正确的东西”。这需要一套主动的裁剪和重组策略,而不是被动地等它溢出。
3.2 分层记忆:把上下文拆成“常驻、工作、归档”三层
我习惯把 Agent 的上下文分成三层来管理,效果比单一历史数组好很多。
常驻层:放那些从头到尾都不能丢的东西——系统指令、核心约束、当前任务的最终目标、关键的用户偏好。这一层永远在上下文里,且放在最前面,保证模型每轮都能看到。
工作层:放当前正在处理的子任务相关的信息——最近几轮的工具调用和结果、当前步骤的中间产物。这一层随任务推进滚动更新,旧的子任务完成后就把它压缩成一句摘要移出。
归档层:放已经完成的历史,不直接进上下文,而是存到外部,需要时通过检索召回。比如“三小时前我们确认过数据库表结构”,这条信息平时不占窗口,只有当模型再次涉及数据库操作时才检索出来注入。
分层的本质是:让上下文里始终只保留“此刻决策真正需要的信息”。常驻层保证方向不偏,工作层保证当前步骤有据可依,归档层保证历史可追溯但不占地方。
3.3 压缩与摘要:什么时候压、压到什么粒度、怎么防止信息丢失
压缩是上下文管理里最容易翻车的环节。压得太狠,关键细节丢了;压得太松,等于没压。我的经验是遵循几条规则。
按子任务边界压缩,而不是按固定轮数。一个子任务(比如“查清楚某个接口的返回格式”)完成后,把整个过程压缩成一条结构化摘要:做了什么、得到什么结论、有什么遗留问题。这样压缩后的信息是自洽的,不会出现“半句话被截断”的情况。
保留可验证的锚点。摘要里一定要保留具体的标识符——文件路径、函数名、参数值、错误码。这些是模型后续推理的“抓手”,丢了它们,摘要就变成了没用的空话。我见过压缩后只剩“之前处理过一个错误”这种摘要,等于白压。
压缩后做一次回读校验。让模型基于压缩后的上下文复述一遍当前任务状态,如果复述出现明显偏差,说明压过头了,需要回滚。这一步多花一点 token,但能避免后面几十步全跑偏。
3.4 上下文污染的识别与清理:那些悄悄带偏模型的脏数据
上下文污染是个隐蔽的杀手。它可能来自一次格式错误的工具返回、一段模型自己编造但被写进历史的“事实”、或者一个已经失效的中间结论。污染一旦进入上下文,后续每一轮都会被它影响。
识别污染的信号有几个:模型开始引用一个从未真实出现过的信息、反复纠结一个已经解决的问题、输出格式突然变得混乱。发现这些信号时,不要试图“说服”模型纠正,直接定位污染源并从上下文里剔除,然后重新推进。
清理的实操建议是:给每条进入上下文的消息打上来源标签(用户输入、工具返回、模型推理、系统注入),当怀疑污染时,按标签快速定位可疑来源。工具返回的消息尤其要检查——很多污染就是从一次异常返回开始的。
4. 沙箱与工具执行:让 Agent 的手“伸得出去又收得回来”
4.1 为什么工具执行必须隔离:一次失控调用的连锁反应
Agent 调工具和人类点按钮有本质区别:人类点之前会看一眼,Agent 是“想到就调”。一旦某个工具调用失控——比如一个删除操作作用到了错误的路径、一个循环调用把外部服务打爆——后果是连锁的。更麻烦的是,Agent 往往意识不到自己闯了祸,还会基于错误结果继续往下推。
所以工具执行必须放在隔离环境里。隔离的目标不是“防止 Agent 做坏事”(它没有主观恶意),而是限制单次错误的爆炸半径。一个调用出错,最多影响它自己的沙箱,不波及主流程、不污染真实数据、不拖垮外部依赖。
4.2 沙箱的几种粒度:进程级、容器级、文件系统级怎么选
沙箱不是只有一种。按隔离粒度从轻到重,常见的有三档。
| 隔离级别 | 隔离对象 | 适用场景 | 代价 |
|---|---|---|---|
| 进程级 | 独立进程 + 资源限额 | 纯计算、无副作用工具 | 低 |
| 容器级 | 独立文件系统 + 网络策略 | 需要读写文件、装依赖 | 中 |
| 文件系统级 | 独立挂载 + 快照回滚 | 高风险写操作、可回滚场景 | 高 |
选哪一档,取决于工具的风险等级。只做字符串处理、数学计算的工具,进程级加个超时和内存上限就够了。需要跑代码、装包、读写文件的,至少容器级。涉及不可逆写操作的,最好上文件系统快照,出问题能一键回滚。
我的默认策略是:所有工具默认跑在容器级沙箱里,只有明确证明无害的才降级到进程级。宁可多花一点启动开销,也不要为了省事把风险敞口留着。
4.3 工具契约设计:参数校验、超时、幂等与返回格式统一
沙箱解决“在哪跑”,工具契约解决“怎么跑得可控”。一个健壮的工具定义至少包含四件事。
参数校验:在调用模型之前就校验参数类型和范围,别等工具执行到一半才发现参数是错的。校验失败直接返回结构化错误给模型,让它重新生成,而不是让错误进到执行层。
超时控制:每个工具都要有独立超时。一个卡住的工具会拖垮整个循环。超时后返回明确的超时错误,让模型决定是重试还是换方案。
幂等设计:对可能被重试的工具,尽量设计成幂等——同样的输入执行多次和一次效果相同。这样循环里的重试逻辑就不用担心副作用叠加。
返回格式统一:所有工具返回统一的结构,比如{status, data, error, meta}。格式统一后,模型处理结果时不用为每个工具写不同的解析逻辑,上下文也更干净。
4.4 执行日志与可回放:出问题时怎么快速定位是哪一步崩的
沙箱和工具执行必须留下完整日志,而且日志要能支撑“回放”。回放的意思是:给定一次失败的运行,你能按时间顺序重放每一步的输入、输出、状态变化,精确定位是哪一步开始偏离。
日志里我必记的字段:步序号、时间戳、工具名、入参、出参、耗时、沙箱标识、上下文摘要哈希。最后那个哈希很关键——它能帮你判断“这一步用的上下文和上一步是不是同一份”,从而发现上下文被意外修改的问题。
有了可回放日志,排查从“猜”变成了“看”。我处理过的一次诡异失败,就是靠回放发现某一步的工具返回被截断了,导致模型基于残缺信息做了错误决策。没有回放,这种问题能查一整天。
5. 验证机制:怎么判断 Agent 这一步到底做对了没有
5.1 自验证的陷阱:模型说“我完成了”为什么不能信
让模型自己检查自己的输出,听起来很合理,实际很不可靠。原因在于:模型的自验证和它的生成用的是同一套“信念”,它倾向于认为自己刚生成的东西是对的。这就像让学生自己批改自己的卷子,还指望他严格扣分。
更糟的是,自验证会消耗额外 token 却给出虚假的安全感。我见过不少系统加了“自我检查”步骤,结果错误率没降,成本翻倍。自验证不是不能用,而是不能作为唯一的验证手段。
5.2 外部验证的三种手段:规则校验、单元测试、交叉模型
真正靠得住的验证来自模型外部。我常用三种手段,按成本从低到高排列。
规则校验:对输出做确定性检查。比如要求返回 JSON,就用解析器验证格式;要求文件存在,就真的去查文件系统;要求数值在范围内,就做区间判断。规则校验便宜、快、可靠,能挡掉大量低级错误,应该作为第一道防线。
单元测试:如果 Agent 的任务是写代码或改代码,直接跑测试。测试通过与否是客观的,不依赖模型判断。这一步能把“看起来对”和“真的对”区分开。
交叉模型验证:用一个不同的模型(或同一模型的不同提示)去审查输出。因为视角不同,它能发现生成模型自己忽略的问题。成本较高,适合用在关键节点,而不是每一步。
5.3 验证失败的反馈闭环:错误信息怎么组织才能让模型真正改对
验证失败后,把错误信息喂回给模型让它修正,这一步的组织方式直接决定修正成功率。常见的错误做法是只丢一句“验证失败”,模型根本不知道错在哪,只能瞎猜。
好的反馈应该包含三要素:期望是什么、实际是什么、差异在哪。比如不要只说“格式错误”,而要说“期望字段count是整数,实际收到字符串'3',请修正类型”。信息越具体,模型一次改对的概率越高。
另外,反馈里要避免指责性语言,保持中性描述。模型对“你错了”这类表述的反应不如对“这里不匹配”稳定。这不是玄学,是实测出来的差异。
5.4 验证的粒度权衡:每步都验还是关键节点验
验证不是越多越好。每步都做重验证,会让循环变得又慢又贵,而且很多中间步骤本来就不需要严格验证。我的做法是分级:低风险步骤只做规则校验,高风险步骤(写操作、外部调用、最终交付)做完整验证。
判断风险等级的标准很简单:这一步错了,能不能低成本回退?能回退的,轻验证;不能回退的,重验证。把验证资源花在刀刃上,整体效率反而更高。
6. 长时运行:让 Agent 从“跑几分钟”到“跑几小时”
6.1 长任务的本质困难:状态漂移、目标遗忘、资源耗尽
短任务里不明显的问题,在长任务里会被放大。长时运行有三个本质困难。
状态漂移:跑了很久之后,Agent 的当前状态和最初的目标之间出现了偏差,而且它自己意识不到。漂移是渐进的,每一步看起来都合理,累积起来就偏了。
目标遗忘:上下文经过多轮压缩后,最初的目标被压没了或者压变形了。Agent 开始为一个“被篡改过的目标”努力。
资源耗尽:token 预算、时间预算、外部调用配额,任何一项耗尽都会让任务中断。长任务必须主动管理这些资源,而不是等它们见底。
6.2 检查点与恢复:把长任务切成可独立验证的小段
应对长任务,最有效的结构是检查点机制。把整个任务切成若干阶段,每个阶段结束时打一个检查点:记录当前进度、已验证的结论、待办事项、资源消耗。检查点存到外部持久化,不依赖上下文。
这样带来两个好处。一是可恢复:任务中断后,从最近的检查点重启,不用从头再来。二是可验证:每个检查点都是一次“阶段性验收”,能及早发现漂移,而不是等到最后才发现全错了。
检查点的粒度需要权衡。太粗,恢复时损失大;太细,管理开销高。我的经验是:每个检查点对应一个可独立验证的子目标,通常 5 到 15 步一个。
6.3 目标锚定:怎么让 Agent 在几十轮之后还记得最初要干什么
对抗目标遗忘,核心手段是目标锚定。具体做法是把最初的目标和验收标准放在常驻层,每轮都注入,且用醒目的格式标记。同时,在每个检查点让 Agent 复述一次目标,和原始目标做比对,偏差超过阈值就触发纠偏。
复述比对这一步很关键。它相当于给长任务装了一个“指南针”,定期校准方向。我实测下来,加了目标锚定之后,长任务跑偏的概率明显下降,而且纠偏成本远低于事后返工。
6.4 资源预算与优雅降级:预算快用完时怎么收尾而不是硬崩
长任务必须带预算跑。预算包括 token 数、墙钟时间、外部调用次数。每消耗一点就扣减,接近阈值时触发降级策略。
降级不是失败,而是在资源受限下给出当前最优结果。比如预算剩 10% 时,停止探索性操作,转入总结阶段,把已有结论整理成可交付的产出。这样即使没跑完,用户也能拿到有价值的东西,而不是一个中断的烂摊子。
我见过太多系统在预算耗尽时直接抛异常,前面的工作全白费。加一个降级收尾逻辑,成本很低,收益很高。
6.5 长时运行的观测:哪些指标能提前预警“要出事了”
长任务不能等崩了才管,要能提前预警。我盯的几个指标:每步平均 token 消耗(突然上升说明上下文在膨胀)、连续失败次数(上升说明卡住了)、目标复述偏差度(上升说明在漂移)、剩余预算比例(下降过快说明效率有问题)。
这些指标不需要多精密,简单的滑动平均加阈值告警就够用。关键是持续观测,让问题在变成事故之前就被看见。观测本身不解决问题,但它给你争取了干预的时间窗口。
7. 把这些模块拼起来:一个可落地的 Harness 骨架
前面拆了五个模块,实际工程里它们是咬合的。一个能跑长任务的 Harness,大致是这样运转的:状态机驱动循环,每轮从分层上下文里取当前需要的信息,工具调用走沙箱执行并记日志,关键节点做外部验证,检查点定期落盘,预算和观测指标全程监控。
拼装时有几个容易忽略的接缝。上下文层和检查点要打通——检查点恢复时,上下文要能从检查点重建,而不是从零开始。验证结果要回写上下文——验证失败的反馈是下一轮的重要输入,不能只记在日志里。沙箱标识要进日志——否则回放时无法还原执行环境。
我自己的体会是,Harness 工程的难点从来不在单个模块有多复杂,而在于模块之间的边界和契约。循环怎么把控制权交给验证、验证怎么把结果交回循环、上下文怎么在检查点处被安全地序列化和恢复——这些接缝处理好了,系统就稳;处理不好,每个模块单独看都没问题,合起来就是跑不住。
最后分享一个我踩过的坑:早期我为了“灵活”,把上下文管理逻辑散落在循环的各个角落,结果每次改一处就影响另一处,调试成本极高。后来把所有上下文操作收敛到一个统一的接口后面,循环只通过这个接口读写上下文,问题立刻少了一大半。Harness 里凡是涉及共享状态的模块,都值得用一个明确的接口把它封起来,这个原则在长任务场景下尤其值钱。