1. 从一次翻车说起:为什么 Agent 光有循环还不够
去年冬天我接手了一个内部工具链的自动化改造,目标很朴素:让一个基于大模型的 Agent 自己去读代码仓库、定位一个已知的构建失败、改掉它、跑测试、提交。听起来就是标准的“感知—决策—执行”循环,我一开始也确实是这么搭的:一个 while 循环,把工具调用结果塞回上下文,再让模型决定下一步。第一天跑得挺顺,第二天开始出问题——它开始重复读同一个文件,第三次、第四次,上下文越滚越长,最后在一次工具调用里返回了一个巨大的日志,直接把窗口撑爆,模型开始胡言乱语,改了一个根本不存在的函数。
那次翻车让我意识到一件事:Agent 循环本身只是骨架,真正决定它能不能干活的是循环外面那一圈东西。后来我把这套外围机制统称为 Harness,也就是“挽具”——它不负责思考,它负责让思考这件事能稳定、可验证、可长时间地跑下去。这个标题里的几个词其实是一条完整的链路:Agent 循环是心脏,上下文是血液,沙箱是手术台,验证是术后检查,长时运行是能不能撑过一台大手术。缺任何一环,Agent 都只是个玩具。
这篇东西我想按我自己踩坑的顺序,把这五个部分拆开讲清楚。适合谁看?如果你已经写过最基础的 tool-calling 循环,但发现它一上真实任务就崩,那这篇就是写给你的。如果你还没写过循环,也能看懂,因为我会把每个概念用生活化的方式讲一遍。核心关键词我会反复提到:Agent 循环、上下文管理、沙箱隔离、验证机制、长时运行,这五个是 Harness 工程的五根柱子。
2. Harness 工程的整体设计思路拆解
2.1 为什么我把 Harness 和 Agent 本体分开看
很多人第一次接触 Agent,脑子里想的是“模型 + 工具 = Agent”。我早期也这么认为,直到我发现同一个模型、同一套工具,换一个外围框架,成功率能差出好几倍。这说明能力不全在模型里,很大一部分在外围。我把这个外围叫 Harness,它包含五件事:怎么组织循环、怎么管理上下文、在哪执行、怎么判断对错、怎么撑住长时间。
打个比方,模型是一个很有天赋但记性有限、手还容易抖的实习生。Agent 循环是给他派活的流程,上下文是他的工作台,沙箱是他操作的车间,验证是质检,长时运行是排班制度。你光招个聪明实习生,不给他工作台、不给他车间、不质检、不排班,他干两天就乱了。Harness 工程做的就是这四件“不性感但致命”的事。
我坚持把 Harness 独立出来设计,还有一个很现实的原因:它可测试、可替换、可复用。模型会换、工具会加,但 Harness 的骨架相对稳定。你把循环、上下文、沙箱、验证做成独立模块,换模型的时候只动一小块,而不是推倒重来。这是工程化的基本盘。
2.2 五个模块的职责边界与耦合关系
我画不出图(这里也不打算画),但可以用一张表把职责说清楚。这张表是我实际项目里模块划分的简化版,每个模块我只写它“必须负责”的事,超出范围的一律不接。
| 模块 | 核心职责 | 不该管的事 | 与其他模块的接口 |
|---|---|---|---|
| Agent 循环 | 驱动“决策—执行—观察”的节奏,控制终止条件 | 不负责上下文裁剪策略 | 向上下文模块要历史,向沙箱要执行结果 |
| 上下文管理 | 决定每一步给模型看什么、丢什么、压缩什么 | 不负责判断任务是否完成 | 从循环接收新事件,输出给模型 |
| 沙箱 | 提供隔离、可复现、可回滚的执行环境 | 不负责决定执行什么 | 接收循环下发的动作,返回结构化结果 |
| 验证 | 判断一次动作或整个任务是否达标 | 不负责修复,只负责判定 | 接收沙箱产物,向循环反馈信号 |
| 长时运行 | 处理中断、恢复、状态持久化、资源回收 | 不负责单步逻辑 | 包裹整个循环,管理生命周期 |
这张表最关键的一列是“不该管的事”。我见过太多项目把上下文裁剪逻辑写进循环里,结果循环代码膨胀到几百行,改一处崩三处。边界清晰比功能强大更重要,这是 Harness 工程的第一原则。
2.3 一个反直觉的取舍:宁可循环笨一点
新手容易把循环写得很“聪明”:根据任务类型走不同分支、内置各种启发式规则、提前判断该不该调用某个工具。我早期也这么干,后来全删了。原因是:循环越聪明,越难调试,越难和模型的能力解耦。当模型升级后,你那些启发式规则反而成了拖累。
我现在的做法是循环尽量笨:只做四件事——把当前状态交给模型、拿到动作、交给沙箱执行、把结果交给验证和上下文。所有“聪明”都下沉到上下文管理和验证里,因为那两块是可独立测试的。循环笨了,出问题时我能快速定位是模型的问题、上下文的问题还是沙箱的问题,而不是在一堆分支里猜。
3. Agent 循环的核心细节与实操要点
3.1 循环的四种终止条件,缺一个都会挂
最朴素的循环是“模型不再调用工具就停”。我实测下来,只靠这一条,十次里有三次会卡死或者无限循环。我现在固定用四种终止条件,任何一个触发就停:
- 模型显式给出最终答案:最理想的情况,模型说“我完成了”并给出结论。
- 达到最大步数:我一般设 30 到 50 步,看任务复杂度。超过就强制停,交给人工或上层逻辑。
- 连续无进展检测:如果连续三步的动作和观察结果高度相似(比如反复读同一个文件),判定卡死,停。
- 预算耗尽:token 预算或时间预算,任一超限就停。
这四条里,第三条最容易被忽略,但它是救命的。我踩过的坑就是模型陷入“读文件—发现不对—再读同一个文件”的死循环,光靠最大步数要等 50 步才停,浪费大量预算。无进展检测我用的是动作指纹加结果指纹的相似度,简单但有效。
提示:最大步数不要设太大。我一开始设 100,结果一次卡死烧掉的钱够我跑一周正常任务。后来改成 30,配合无进展检测,反而更稳。
3.2 动作解析:为什么我不让模型直接输出 JSON
早期我让模型直接输出 JSON 格式的工具调用,结果被各种格式错误折磨:少个引号、多个逗号、字段名拼错。后来我改成让模型输出一种更宽松的格式,再由解析层做容错。具体做法是:模型输出“工具名 + 参数块”,参数块允许用自然语言描述,解析层负责把它规整成结构化调用。
这个改动的收益很大。模型在宽松格式下的成功率明显高于严格 JSON,因为严格格式对 token 级错误零容忍,而宽松格式给了容错空间。解析层我写了几条规则:字段名做模糊匹配、参数值做类型推断、缺失的必填参数用默认值兜底并记录警告。这套下来,格式导致的失败率从两位数降到个位数百分比。
3.3 循环里的状态机:别用裸 while
裸 while 循环在简单场景够用,但一旦涉及重试、回滚、人工介入,就会变成一团乱麻。我现在的循环内部是一个显式状态机,状态包括:IDLE、THINKING、ACTING、OBSERVING、VERIFYING、RETRYING、DONE、FAILED。每个状态只做一件事,状态转移有明确条件。
这么做的直接好处是可观测。我可以在任意时刻打印当前状态,知道 Agent 卡在哪一步。另一个好处是可干预:人工介入时,我可以把状态从RETRYING手动推到DONE,或者注入一条修正指令。裸 while 做不到这些,因为你根本不知道它现在处于什么阶段。
4. 上下文管理:Agent 的记性和注意力
4.1 上下文不是越长越好,这是个陷阱
我见过一种说法:“上下文窗口越大,Agent 越强”。这话对了一半。窗口大确实能塞更多信息,但塞进去的信息越多,模型的注意力越分散。我做过对比:同一个任务,把完整历史塞进去 vs 只塞关键历史,后者的成功率反而更高,因为模型不会被无关的中间步骤干扰。
这就像你让一个人干活,桌上堆满了他之前所有的草稿纸,他反而找不到当前要用的那张。上下文管理的核心不是“装得多”,而是“装得准”。我现在的基本原则是:当前任务相关的信息必须全,历史过程信息按需保留,无关信息坚决丢。
4.2 三层上下文结构:我实际用的组织方式
我把上下文分成三层,每层有不同的保留策略:
- 系统层:任务目标、约束条件、可用工具说明。这层几乎不变,始终保留。
- 工作层:当前正在处理的子任务、最近几步的动作和观察。这层滚动更新,只保留最近 N 步。
- 归档层:更早的历史,压缩成摘要后保留。摘要由模型生成,只保留结论和关键决策。
三层之间的信息流动是有规则的:工作层满了,最老的几步压缩进归档层;归档层超过阈值,最老的摘要再压缩一次。这样上下文总量可控,同时关键信息不丢。我实测下来,这套结构能让长任务在固定窗口下稳定跑几十步而不爆。
4.3 压缩的时机和粒度:踩过的坑
压缩最怕两件事:压太早,关键信息还没用上就没了;压太晚,窗口已经爆了。我现在的做法是按 token 占用比例触发,而不是按步数。当上下文占用达到窗口的 70% 时开始压缩,压到 50% 停。这个区间是试出来的,70% 留了缓冲,50% 保证有足够空间继续跑。
粒度上,我不用“每步都压”,而是按子任务边界压。一个子任务完成,就把这个子任务的过程压成一段摘要。这样摘要的语义完整,不会把一件事拆成两半。早期我按固定步数压,结果经常把一次工具调用和它的结果分开,模型看到结果却看不到调用,直接懵了。
注意:压缩摘要一定要让模型自己生成,不要用规则截断。规则截断会丢语义,模型生成的摘要至少保留了“发生了什么、结论是什么”。
4.4 上下文里的“负样本”要不要留
这是个有意思的问题。Agent 走错路的过程要不要留在上下文里?我一开始全留,想着让模型“记住教训”。结果发现模型经常被错误路径带偏,反复回到错误方向。后来我改成:错误路径只保留结论性的“此路不通”,不保留具体过程。比如“尝试用方案 A 修复失败,原因是 X”,而不是把方案 A 的每一步都留着。
这个改动让 Agent 的“回头率”明显下降。模型知道哪些路走不通就够了,不需要重温走错的全过程。这跟人一样,你记住“那条路堵”,不需要记住堵车的每一秒。
5. 沙箱:让 Agent 的手不抖、可回滚
5.1 为什么必须隔离:一次真实的教训
我早期图省事,让 Agent 直接在宿主机上跑命令。有一次它执行了一个清理命令,参数写错,删掉了一个不该删的目录。虽然那个目录有备份,但那次之后我彻底改了:所有 Agent 的执行动作必须在隔离环境里。这不是洁癖,是底线。
隔离的好处不只是安全,还有可复现。同一个任务,在干净的沙箱里跑,结果稳定;在脏的宿主机上跑,结果取决于环境状态,根本没法调试。我现在要求沙箱每次任务开始时是干净状态,任务结束后销毁,中间的所有改动都在沙箱内。
5.2 沙箱的三种隔离级别与选型
我实际用过三种隔离级别,各有适用场景:
| 级别 | 隔离方式 | 启动速度 | 适用场景 | 我的使用建议 |
|---|---|---|---|---|
| 进程级 | 独立进程 + 资源限制 | 快 | 纯计算、无文件系统改动 | 轻量任务首选 |
| 容器级 | 独立文件系统 + 网络隔离 | 中 | 需要装依赖、改文件 | 大多数任务用这个 |
| 虚拟机级 | 完整系统隔离 | 慢 | 高风险、需内核操作 | 谨慎任务才用 |
选型逻辑很简单:按任务的风险和依赖需求选。改代码、跑测试这种,容器级足够;纯文本处理,进程级就行;涉及系统级操作的,才上虚拟机。我见过有人一律用虚拟机,结果启动慢到没法用;也见过一律用进程级,结果文件被改乱。按需选,别一刀切。
5.3 沙箱内的文件系统快照与回滚
回滚是沙箱最被低估的能力。Agent 改错了怎么办?如果沙箱支持快照,直接回滚到上一个检查点,比重跑一遍快得多。我的做法是:在每个子任务开始前打一个快照,子任务失败就回滚,成功就保留。这样即使某个子任务搞砸了,也不会污染前面的成果。
快照的粒度要控制好。太细,快照开销大;太粗,回滚损失大。我按子任务边界打快照,一个子任务通常几步到十几步,这个粒度实测最舒服。回滚时我只回滚文件系统,不回滚上下文——上下文里保留“这次尝试失败了”的记录,让模型知道别再试。
5.4 沙箱与循环的接口设计
沙箱和循环之间的接口,我坚持用结构化结果,而不是裸的 stdout。每次执行返回一个对象,包含:退出码、标准输出、标准错误、耗时、资源占用、是否超时。循环拿到这个对象,再决定下一步。
为什么不用裸 stdout?因为裸文本没法可靠解析。我早期让循环去 grep stdout 里的关键字判断成功失败,结果被各种格式变化搞崩。结构化结果让判断逻辑稳定,也让验证模块有干净的输入。这个接口设计看起来是小事,但它决定了整个 Harness 的可维护性。
6. 验证机制:怎么知道 Agent 干对了
6.1 验证的三个层次:动作、子任务、整体
验证不是一步,是三层。动作级验证看单次工具调用是否成功(退出码、返回格式);子任务级验证看一组动作是否达成子目标(比如“修复了这个函数”);整体级验证看整个任务是否完成(比如“构建通过且测试全绿”)。三层缺一不可。
我早期只做动作级验证,结果 Agent 每一步都“成功”,但整体任务失败——因为它做的每一步都对,方向却错了。加上子任务级和整体级验证后,这种“局部正确、全局错误”的情况大幅减少。验证层次的设计,本质是给 Agent 提供不同粒度的反馈信号。
6.2 用测试作为验证:最可靠但也最贵
如果任务有现成的测试,那测试就是最好的验证。跑一遍测试,通过就是通过,不通过就是不通过,没有歧义。但测试的问题是贵——跑一次可能几十秒到几分钟,Agent 如果每步都跑测试,时间成本爆炸。
我的折中方案是:子任务级用轻量验证,整体级用测试。子任务级用静态检查、语法检查、快速单元测试;整体级才跑完整测试套件。这样既保证了反馈及时,又保证了最终质量。实测下来,这个分层让总耗时降了一半以上,质量没降。
6.3 没有测试怎么办:启发式验证的设计
很多任务没有现成测试,比如写文档、改配置。这时候只能靠启发式验证。我常用的几种:格式校验(JSON 能不能解析、YAML 合不合法)、引用完整性(引用的文件存不存在、链接通不通)、一致性检查(改动前后有没有矛盾)、模型自评(让另一个模型判断结果是否合理)。
模型自评要慎用,因为它容易“自我感觉良好”。我的做法是让自评模型只看结果不看过程,并且给它明确的评判标准,而不是笼统地问“好不好”。标准越具体,自评越可靠。比如“这个配置文件的字段是否都符合给定的 schema”,比“这个配置写得好不好”有用得多。
6.4 验证失败后的反馈怎么给模型
验证失败后,反馈的质量决定了 Agent 能不能自我修正。我踩过的坑是:只告诉模型“失败了”,不告诉它为什么失败。结果模型瞎猜,越改越错。后来我要求验证模块输出结构化的失败原因:哪一层失败、失败的具体位置、期望值 vs 实际值。
比如不是“测试失败”,而是“子任务 3 的单元测试失败,函数 X 的返回值期望 5 实际 3”。这种反馈模型一看就知道往哪改。反馈的粒度越细,修正的成功率越高。这是验证模块和上下文模块的接口,也是整个 Harness 里最影响 Agent 自我修正能力的一环。
7. 长时运行:让 Agent 撑过一场马拉松
7.1 长时运行的真实挑战:不是慢,是断
短任务跑几分钟,长任务可能跑几小时甚至跨天。长时运行最大的挑战不是性能,是中断。网络抖一下、进程被重启、预算临时超限,任何一个小意外都可能让几小时的工作白费。我早期没做持久化,一次任务跑到第 80 步进程崩了,全部重来,那种感觉不想再体验第二次。
长时运行的核心是状态持久化 + 断点恢复。每一步的状态都要落盘,恢复时能从最近的检查点继续。这不是可选项,是长任务的必需品。我现在要求所有可能跑超过 10 分钟的任务,都必须支持断点恢复。
7.2 检查点设计:存什么、多久存一次
检查点存三样东西:上下文状态(三层结构)、沙箱快照引用、循环状态(当前状态机的状态、步数、预算消耗)。这三样齐了,就能完整恢复。
存多久一次?我的经验是按步存,但异步。每步结束后触发一次异步落盘,不阻塞主循环。这样既保证恢复点足够新,又不拖慢执行。同步落盘我试过,每步多几百毫秒,几十步下来就是十几秒的额外开销,不划算。
提示:检查点文件要带版本号。我有一次改了上下文结构,结果旧检查点恢复不了,只能从头跑。加了版本号后,至少能检测到不兼容并给出明确提示。
7.3 资源回收与预算控制
长时运行最容易失控的是资源。token 越用越多、沙箱越开越多、临时文件越堆越大。我现在的做法是每个检查点做一次资源审计:统计当前 token 消耗、活跃沙箱数、磁盘占用,超过阈值就触发清理或告警。
预算控制我分两级:软预算触发告警和降级(比如减少验证频率),硬预算直接终止任务并保存状态。软硬结合,既不会因为一点超支就停,也不会无限烧下去。这个设计让我在长任务里能睡个安稳觉,不用担心一觉醒来账单爆炸。
7.4 长时运行的可观测性:日志、指标、追踪
长任务出问题时,没有可观测性就是抓瞎。我要求三样东西:结构化日志(每步一条,带步数、状态、耗时、结果摘要)、关键指标(步数、token 消耗、成功率、重试次数)、追踪 ID(贯穿整个任务,方便串联)。
日志我坚持结构化,不用纯文本。纯文本日志在长任务里根本没法查,结构化日志可以直接过滤、聚合、告警。指标我接了一个简单的时序存储,能看到任务随时间的健康度。追踪 ID 让我能把一次任务的所有日志串起来看,定位问题快很多。
8. 常见问题与排查技巧实录
8.1 循环卡死:三种典型表现和排查路径
卡死是最高频的问题,我总结成三种表现:
| 表现 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 反复读同一文件 | 无进展检测缺失 | 看动作指纹是否重复 | 加无进展检测 |
| 步数暴涨但无输出 | 上下文爆了,模型乱答 | 看上下文占用比例 | 加压缩触发 |
| 停在某状态不动 | 状态机转移条件缺失 | 打印当前状态 | 补转移条件 |
排查顺序我固定为:先看状态机当前状态,再看上下文占用,最后看最近几步的动作指纹。这个顺序能覆盖八成卡死情况。剩下两成通常是模型本身的问题,换模型或调提示词。
8.2 上下文爆窗口:预防比补救重要
上下文爆窗口的补救很麻烦,因为一旦爆了,模型已经开始乱答,你得回滚重来。所以重点是预防。我的预防措施有三条:压缩阈值设 70%、每步检查占用、子任务边界强制压缩。这三条下来,我最近半年没再遇到爆窗口。
如果真的爆了,补救办法是:回滚到上一个检查点,手动触发一次压缩,再继续。不要试图在爆了的上下文上继续跑,模型的状态已经不可信了。
8.3 沙箱污染:为什么你的结果不可复现
沙箱污染的表现是:同一个任务,跑两次结果不一样。原因通常是沙箱没清理干净,或者任务之间共享了状态。我的排查方法是:跑两次,对比沙箱的文件系统差异。有差异就是污染。
解决很简单:每次任务开始前强制清理沙箱,任务结束后销毁。如果任务需要保留状态,用显式的持久化卷,而不是让沙箱自己留着。我见过有人为了“省启动时间”复用沙箱,结果调试了一整天,最后发现是上一个任务的残留文件在捣乱。
8.4 验证误判:假阳性和假阴性
验证误判有两种:假阳性(明明错了说对了)和假阴性(明明对了说错了)。假阳性更危险,因为它让错误溜过去。假阴性更烦人,因为它让 Agent 反复重试正确的东西。
假阳性通常来自验证太宽松,比如只检查退出码不检查输出。解决是加严验证,多层校验。假阴性通常来自验证太严格或环境问题,比如测试依赖了不稳定的外部服务。解决是隔离验证环境,去掉不稳定依赖。我现在的原则是:宁可假阴性多一点,也不要假阳性,因为假阴性最多浪费点时间,假阳性会交付错误结果。
8.5 长任务恢复失败:检查点不兼容的坑
恢复失败最常见的原因是检查点不兼容——代码改了,旧检查点读不了。我的解决办法是检查点带 schema 版本,恢复时先校验版本,不匹配就明确报错,而不是静默失败。静默失败最坑,你以为恢复了,其实状态是错的,跑出来的结果全是垃圾。
另一个坑是检查点存了但没存全,恢复时缺东西。我的做法是恢复后做一次完整性校验:上下文三层是否齐全、沙箱快照是否可访问、循环状态是否合法。校验不过就不恢复,从头跑,至少结果可信。
9. 我在实际项目里的一些体会
这套 Harness 工程我迭代了大概大半年,从最开始一个两百行的裸循环,到现在五个模块分工明确。最大的体会是:Agent 的能力上限由模型决定,但能力下限由 Harness 决定。模型再强,Harness 不行,任务照样崩;模型一般,Harness 扎实,任务反而能稳定完成。
另一个体会是别追求一步到位。我一开始想设计一个完美的 Harness,结果卡在设计阶段很久。后来改成先用最笨的版本跑起来,遇到问题再加模块,反而进展快。上下文管理是第三个版本才成型的,验证机制是第四个版本,长时运行是最后加的。每个模块都是被真实问题逼出来的,不是设计出来的。
最后分享一个小技巧:给 Harness 本身写测试。循环的终止条件、上下文的压缩逻辑、沙箱的清理、验证的判定,这些都可以单元测试。我一开始觉得 Harness 是“胶水代码”不用测,结果每次改都提心吊胆。后来补了测试,改起来放心多了。Harness 是 Agent 的地基,地基值得测。