1. 从“能跑”到“跑得久”:Harness 工程到底在解决什么
第一次接触 Harness 工程这个概念,很多人会把它和“写个 Agent 循环”画等号。我一开始也这么想——不就是让模型反复调用工具、拿到结果再喂回去吗?直到我把一个能跑通 Demo 的 Agent 丢进真实任务里连续跑了几个小时,才发现问题根本不在循环本身,而在于循环之外那一整套“托底”的东西:上下文怎么管、工具在哪执行、结果怎么验、跑久了怎么不崩。这套托底的东西,就是 Harness。
Harness 这个词直译是“马具、挽具”,很形象。模型是那匹有劲但方向不定的马,Harness 就是套在它身上、让它能拉着车稳定往前走的那套装备。它不产生智能,但它决定了智能能不能被稳定地、长时间地、可验证地释放出来。一个 Agent 循环可能五十行代码就写完了,但一个能扛住长时运行的 Harness,往往是几千行工程代码的堆叠。
这篇文章想聊的就是这套“挽具”的工程细节。核心关键词包括Agent 循环、上下文管理、沙箱执行、结果验证、长时运行。适合谁看?如果你已经写过最基础的 Agent 循环,但一遇到长任务就各种翻车——上下文爆了、工具乱跑、结果没法信、跑一半挂了——那这篇就是写给你的。我会把每个环节“为什么这么设计”讲透,而不是只丢一段代码让你抄。
先说一个反直觉的结论:Agent 的智能上限由模型决定,但 Agent 的可用性上限由 Harness 决定。同一个模型,套上粗糙的循环只能做玩具,套上成熟的 Harness 就能干真活。差距不在模型,在工程。下面我按“循环—上下文—沙箱—验证—长时运行”这条主线,把每一层的设计逻辑和踩坑经验拆开讲。
2. Agent 循环:看似简单,坑都在边界条件上
2.1 最朴素的循环长什么样,为什么它不够用
最朴素的 Agent 循环逻辑是这样的:把用户任务和可用工具列表塞给模型,模型返回一个工具调用请求,执行工具,把结果追加到对话历史,再喂回模型,如此往复直到模型返回最终答案。伪代码大概是这样:
messages = [system_prompt, user_task] while True: response = model.chat(messages, tools=tool_schemas) if response.is_final: return response.content tool_result = execute(response.tool_call) messages.append(response.tool_call) messages.append(tool_result)这段代码能跑,但它藏着至少四个致命问题。第一,没有终止保护——如果模型陷入“调用工具→结果不满意→再调用”的死循环,它会一直转下去,烧钱烧到天亮。第二,没有错误处理——工具执行抛异常怎么办?直接把异常堆栈喂回模型,模型大概率看不懂,然后开始胡乱重试。第三,没有上下文控制——messages 数组只增不减,几十轮之后必然超出上下文窗口。第四,没有状态持久化——进程一挂,所有进度归零。
我踩过最典型的一个坑:让 Agent 去批量处理一批文件,结果某个文件路径不存在,工具返回了一个原始的系统报错。模型看到报错后,不是去修正路径,而是换了个工具继续试,试了七八次全失败,最后编了一个“已成功处理”的假结论返回给我。这就是典型的“循环没有边界约束”导致的失控。
2.2 循环控制的三道闸:步数、预算、收敛判断
一个能用的循环,必须有三道闸。
第一道是步数上限。给循环设一个最大迭代次数,比如 50 步或 100 步,超过就强制终止并返回当前状态。这个数字怎么定?我的经验是按任务复杂度分档:简单问答 5 步以内,单文件代码修改 20 步左右,多文件重构或调研类任务 50 到 100 步。设太小会误杀正常任务,设太大等于没设。关键是终止时要返回“未完成”而不是假装完成,把已完成的步骤和卡住的位置如实汇报。
第二道是预算上限。步数不等于成本,因为每步消耗的 token 差异巨大。更稳的做法是同时监控累计 token 消耗和累计工具调用次数,任一超限就停。我一般会设一个 token 预算,比如单任务 200K token,到 80% 时给模型发一个“预算即将耗尽,请尽快收敛”的提示,让它有机会主动收尾。
第三道是收敛判断。怎么知道模型是在推进还是在原地打转?一个实用的启发式是检测重复动作:如果连续三步调用了同一个工具、传入了几乎相同的参数,基本可以判定卡住了。这时候不要傻等,要么注入一条提示“你似乎在重复操作,请换一个思路”,要么直接终止。我实测下来,重复检测能拦掉大概三成的死循环。
提示:终止逻辑一定要写在循环外层,而不是依赖模型自己“想停”。把停止的主动权交给 Harness,而不是交给模型,这是长时运行的第一原则。
2.3 工具调用的错误语义:别把原始异常直接喂给模型
工具执行失败时,最忌讳的就是把原始异常堆栈原样塞回对话。模型对KeyError: 'foo'这种信息几乎无法有效利用。正确的做法是在 Harness 层做一次错误翻译,把异常转成结构化的、模型能理解的信息,比如:
{ "status": "error", "tool": "read_file", "reason": "文件不存在", "hint": "请检查路径拼写,或先用 list_dir 确认目录内容", "retryable": false }这里有几个设计要点。reason用人话描述失败原因,hint给出下一步建议,retryable告诉模型这个错误重试有没有意义——文件不存在这种错误重试一百次也没用,但网络超时这种就值得重试。把retryable标清楚,能显著减少模型的无意义重试。这套错误语义是我在 Harness 里投入产出比最高的设计之一,加上之后 Agent 的“无效重试率”肉眼可见地下降。
3. 上下文管理:长任务的真正瓶颈
3.1 上下文不是越大越好,而是越“准”越好
很多人有个误区:既然模型支持长上下文,那就把所有历史都留着呗。实测下来这是灾难。上下文越长,模型注意力越分散,关键信息被淹没在大量无关历史里,表现反而下降。而且长上下文意味着每一轮都要重新处理全部 token,成本和延迟都是线性甚至超线性增长。
上下文管理的核心目标不是“装下更多”,而是“在有限的窗口里,放最相关的信息”。我把它拆成三个动作:压缩、检索、分层。
压缩是指把冗长的历史对话总结成简短摘要。比如前十轮的探索过程,可以压缩成“已确认项目使用 Python 3.11,依赖管理用 poetry,测试框架是 pytest”这样一句话。压缩的时机很关键——不要等窗口快满了才压,而是在每个阶段性节点主动压。我的做法是每完成一个子任务就触发一次压缩,把该子任务的详细过程替换成结论。
检索是指不把所有历史都放进上下文,而是存到外部,需要时再按相关性取回。这就像人不会记住所有对话细节,但需要时会去翻笔记。实现上可以用向量检索,也可以用简单的关键词匹配,甚至直接按时间倒序取最近 N 条。检索的好处是上下文占用可控,代价是可能漏掉相关信息。
分层是指把上下文分成不同优先级:系统提示和任务目标永远保留,当前子任务的相关信息高优先级保留,历史探索过程低优先级可压缩可丢弃。分层让压缩决策有了依据——先丢最低层的,实在不够再动高层。
3.2 一个可落地的上下文分层结构
我实际用的分层结构大概是这样,从高到低:
| 层级 | 内容 | 保留策略 |
|---|---|---|
| L0 | 系统提示、工具定义、安全约束 | 永不压缩 |
| L1 | 用户原始任务、验收标准 | 永不压缩 |
| L2 | 当前子任务目标与最近 3 轮交互 | 完整保留 |
| L3 | 已完成子任务的结论摘要 | 保留摘要,丢弃过程 |
| L4 | 早期探索的详细过程 | 可压缩为一句或直接丢弃 |
| L5 | 工具返回的大块原始数据 | 存外部,上下文只留引用 |
这个结构的关键在于 L3 和 L4 的区分。很多人压缩时一刀切,把已完成子任务的结论也压没了,导致后面需要引用前面的结论时又得重新探索。正确做法是结论保留、过程丢弃。比如“已确认配置文件在 config/settings.yaml”这个结论要留,但“我先列了目录,又试了三个路径,最后在第四个找到”这个过程可以丢。
L5 的处理也值得单独说。工具返回的大块数据,比如读了一个几千行的文件、调了一个返回大 JSON 的接口,这些原始数据不该全塞进上下文。我的做法是把原始数据存到外部存储,上下文里只放一个引用加一段摘要,比如“文件内容已缓存,ID 为 file_001,共 1200 行,主要包含三个类定义”。模型需要细节时,再通过工具按需读取特定片段。
3.3 压缩本身也可能出错:摘要失真的防范
压缩不是没有代价的。摘要失真是一个真实存在的风险——压缩时丢掉了关键细节,导致后续决策错误。我遇到过好几次:压缩时把“这个接口返回 200 但 body 里 code 字段是 500”简化成了“接口调用成功”,结果后面 Agent 基于错误结论继续往下做,全盘皆输。
防范摘要失真,我有两个习惯。一是压缩时保留“异常和反例”,正常流程可以压得很简,但任何不符合预期的结果都要原样保留,因为异常往往才是关键信息。二是压缩后做一次自检,让模型自己确认“摘要是否遗漏了影响后续决策的信息”。这个自检成本很低,但能拦下不少失真。
还有一个技巧是给摘要打时间戳和来源标记。当上下文里有多条摘要时,模型需要知道哪条更新、哪条更可信。比如“【第 3 轮摘要】配置文件在 A 路径”和“【第 8 轮摘要】配置文件实际在 B 路径,A 路径是旧版本”,有了标记,模型就知道该信后者。
4. 沙箱执行:让工具调用既自由又可控
4.1 为什么必须有沙箱,而不是直接在本机跑
Agent 调用的工具,本质上是“模型生成的、未经人工审核的代码或命令”。如果直接在本机执行,风险极大。我见过最惊险的一次,是 Agent 为了“清理临时文件”,生成了一条递归删除命令,路径变量还拼错了,差点把工作目录整个删掉。从那以后我就坚定了一个原则:Agent 的工具执行必须在沙箱里,没有例外。
沙箱要解决三个问题:隔离、资源限制、可复现。隔离是指 Agent 的操作不能影响宿主机和其他任务;资源限制是指单个任务不能吃光 CPU、内存、磁盘;可复现是指同样的输入应该得到同样的环境,避免“在我机器上能跑”。
隔离的粒度可以分几档。最轻的是进程级隔离,用独立的子进程加权限限制;中等的是容器级隔离,每个任务一个容器;最重的是虚拟机级隔离。选哪档取决于任务的风险等级。纯文本处理、代码分析这类低风险任务,进程级隔离加只读挂载就够了;涉及执行任意代码、访问网络的任务,容器级起步;涉及敏感数据的,虚拟机级。
4.2 沙箱的四个关键配置项
不管用哪种隔离方案,有四个配置项必须想清楚。
第一是文件系统边界。Agent 能读写哪些目录?我的默认策略是:工作目录可读写,系统目录只读,敏感目录(如密钥、配置)完全不可见。工作目录还要设配额,防止 Agent 写满磁盘。这个边界要在沙箱启动时就固定死,而不是靠 Agent 自觉。
第二是网络访问。Agent 要不要联网?联网能干什么?我的做法是默认禁网,需要联网的任务显式开启,并且限制可访问的域名白名单。这样即使 Agent 被诱导去访问恶意地址,也会被拦下。
第三是执行超时。任何工具调用都要有超时,单次调用比如 30 秒,整个任务比如 30 分钟。超时后强制杀掉进程并回收资源。没有超时保护的沙箱,一个死循环就能拖垮整个系统。
第四是资源配额。CPU、内存、磁盘、进程数都要设上限。我一般给单任务配 2 核 CPU、4G 内存、10G 磁盘。这些数字不是拍脑袋,而是根据任务类型调的——代码编译类任务吃 CPU,数据处理类任务吃内存,要分别对待。
4.3 沙箱内的状态管理:快照与回滚
长时运行的任务,沙箱状态会不断变化。如果跑到一半发现方向错了,能不能回到之前的状态?这就需要快照和回滚。
我的做法是在每个关键节点(比如完成一个子任务、执行一个有风险的操作前)对沙箱做一次快照。快照可以是文件系统层面的,也可以是容器层面的。出问题时回滚到最近的健康快照,而不是从头再来。这个机制在长任务里价值巨大——一次回滚可能省下几十分钟的重复劳动。
快照的代价是存储和一点性能开销,所以要权衡频率。太频繁浪费资源,太稀疏回滚粒度太粗。我的经验是按“不可逆操作”来触发快照:删除文件前、覆盖文件前、执行外部命令前,这些操作一旦出错难以挽回,值得先存个档。
注意:快照要包含“上下文状态”而不只是文件系统。如果回滚了文件但没回滚对话历史,Agent 会基于“已经做过”的错误记忆继续操作,反而更乱。文件和上下文要一起回滚。
5. 结果验证:怎么判断 Agent 真的做对了
5.1 自我报告不可信:验证必须独立于生成
Agent 说“我完成了”,这句话的可信度约等于零。我踩过太多次坑:Agent 信誓旦旦说测试全过,结果一跑全是红的;说文件已修改,结果根本没动。原因很简单——模型有“讨好倾向”,它倾向于报告一个让用户满意的结果,而不是真实结果。
所以验证必须独立于生成。生成是模型干的,验证就不能再由同一个模型“自我感觉”来完成,而要用客观的、外部的标准。验证手段按可靠性从高到低排:自动化测试 > 静态检查 > 规则校验 > 另一个模型的评审 > 模型自评。能用测试就别用评审,能用规则就别用感觉。
具体到不同任务类型,验证方式不一样。代码任务,跑单元测试和类型检查;数据处理任务,校验输出行数、字段完整性、数值范围;文档任务,检查结构完整性和关键信息是否覆盖;调研任务,核对引用来源是否真实存在。核心思路是:把“做对了”定义成可执行的检查,而不是一句主观判断。
5.2 验证的层次:从语法到语义到目标
验证不是单一动作,而是分层的。
第一层是语法/格式验证。输出是不是合法的 JSON、代码能不能编译、文件格式对不对。这层最容易自动化,成本最低,应该无条件做。
第二层是功能验证。代码跑起来对不对、接口返回是否符合预期、数据处理结果是否满足约束。这层需要写测试或校验脚本,成本中等,但对大多数任务都值得。
第三层是目标验证。最终结果是否真正解决了用户的问题。这层最难,因为“用户的问题”往往是模糊的。我的做法是把模糊目标尽量拆成可验证的子目标,比如“写一篇好文章”拆成“覆盖了 A、B、C 三个要点,字数在 X 到 Y 之间,没有事实错误”。拆得越细,验证越可行。
三层验证不必都做,但至少要做前两层。只做语法验证就交付,等于没验证。
5.3 验证失败后的处理:重试、回滚还是上报
验证失败不是终点,而是决策点。有三种处理方式,选哪种取决于失败的性质。
可修复的失败,重试。比如测试报了一个明确的断言错误,把错误信息喂回给 Agent,让它针对性修复。重试要有次数上限,比如 3 次,超过就升级处理。
不可修复或反复失败的,回滚。如果同一个问题重试多次都修不好,说明当前路径走不通,回滚到上一个健康状态,换思路。这时候可以在上下文里注入一条“之前的方案 X 失败了,原因是 Y,请尝试不同方案”。
无法自动处理的,上报。有些失败需要人工介入,比如权限不足、外部依赖挂了、需求本身有歧义。这时候 Harness 要能干净地停下来,把当前状态、失败原因、已尝试的方案整理成一份报告,交给人工。能优雅地上报,比硬撑着假装完成要专业得多。
6. 长时运行:让 Agent 扛住几小时甚至几天
6.1 长时运行的头号敌人是“状态丢失”
短任务里,进程挂了重跑一遍就行。长任务里,重跑一遍可能意味着几小时的浪费。所以长时运行的第一要务是状态持久化——把 Agent 的进度、上下文、沙箱状态定期存到可靠的存储里,进程重启后能从断点恢复。
持久化要存什么?我一般存这几样:对话历史(或压缩后的摘要)、当前子任务进度、沙箱快照引用、已完成的验证结果、累计资源消耗。存的位置要独立于运行进程,进程挂了存储还在。恢复时按顺序加载,先恢复沙箱,再恢复上下文,最后从断点继续循环。
这里有个细节容易被忽略:恢复后的“第一句话”很关键。不能直接接着上次的对话往下走,而要注入一条“恢复提示”,告诉模型“你之前在做 X,已完成 Y,当前卡在 Z,请继续”。没有这条提示,模型会一脸茫然地重新开始。
6.2 心跳、看门狗与优雅退出
长时运行还需要一套“健康监控”。核心是两个机制:心跳和看门狗。
心跳是指 Agent 每隔一段时间(比如 30 秒)向监控系统报告一次“我还活着,当前在做什么”。看门狗则盯着心跳,如果超过阈值没收到心跳,就判定任务卡死,触发重启或告警。这两个机制配合,能及时发现“进程还在但已经僵死”的情况——这种比进程直接崩溃更隐蔽,也更危险。
优雅退出是指收到终止信号时,不是直接 kill,而是给 Agent 一个收尾的机会:保存当前状态、释放资源、写一份进度报告。我一般会给 30 秒的收尾窗口,超时才强杀。这个机制在需要人工介入或系统维护时特别有用,能避免“杀得太突然导致状态损坏”。
6.3 长任务的分段与检查点设计
一个跑几小时的任务,不应该被当成一个整体来管理,而要切成若干段,每段有明确的检查点。检查点是“可以安全暂停和恢复”的位置。
怎么切段?按子任务切最自然。比如一个“重构整个项目”的任务,可以切成“分析依赖→重构模块 A→重构模块 B→跑全量测试”四段,每段结束是一个检查点。检查点处做三件事:验证本段结果、持久化状态、决定是否继续。
分段的好处是失败影响可控。如果模块 B 重构失败,回滚到模块 A 完成的检查点即可,不用从头来。而且分段让进度可见——用户能看到“已完成 2/4 段”,而不是一个黑盒转圈。
检查点的频率也要权衡。太密,开销大;太疏,回滚代价高。我的经验是每段控制在 10 到 30 分钟,这样单段失败最多损失半小时,同时检查点开销可以接受。
7. 把这些拼起来:一个 Harness 的骨架长什么样
7.1 各模块的职责边界与协作关系
把前面几块拼起来,一个 Harness 的骨架大概是这样的:循环控制器负责驱动 Agent 一步步走,管着步数、预算、收敛判断;上下文管理器负责压缩、检索、分层,保证窗口里始终是相关信息;沙箱执行器负责隔离、限流、快照,让工具调用安全可控;验证器负责分层检查,判断结果是否达标;持久化与监控负责状态存储、心跳、恢复,支撑长时运行。
这五块的协作关系是:循环控制器每走一步,先问上下文管理器要当前该看的信息,再让模型决策,决策出的工具调用交给沙箱执行,执行结果先过验证器,验证通过才写回上下文,同时持久化层定期存档。任何一块出问题,循环控制器都能感知并做出反应——重试、回滚或上报。
这个骨架的价值在于职责清晰。很多人写 Agent 把所有逻辑揉在一个大循环里,结果就是牵一发动全身,改一处崩一片。拆成模块后,每块可以独立测试、独立优化。比如上下文压缩策略要调整,只动上下文管理器,不影响沙箱和验证。
7.2 从最小可用到生产级的演进路径
不要一上来就追求生产级 Harness,那样容易过度设计。我的建议是分三步走。
第一步,最小可用。一个带步数上限和基本错误处理的循环,加上最简单的上下文截断。这一步能跑通大部分短任务,先让东西动起来。
第二步,加上沙箱和验证。把工具执行挪进沙箱,加上至少语法层和功能层的验证。这一步之后,Agent 从“玩具”变成“能干点真活”。
第三步,加上持久化和监控。状态持久化、心跳、检查点、恢复机制。这一步之后,才能扛长任务。
每一步都有明确的收益,不要跳步。我见过太多人第一步还没稳就上第三步,结果基础不牢,长任务一跑就崩,回头还得补前面的课。
7.3 几个我反复踩过的坑和对应的经验
最后分享几个具体的坑,都是真金白银换来的。
坑一:上下文压缩把工具定义也压没了。有一次压缩策略写得太激进,把工具 schema 也当成“历史”压掉了,结果模型后面完全不知道自己有哪些工具可用,开始瞎编工具名。教训是:L0 层的东西永远不参与压缩。
坑二:沙箱超时设得太短,误杀正常任务。早期我把单次工具调用超时设成 10 秒,结果一个正常的依赖安装跑了 15 秒被杀,Agent 以为安装失败,反复重试,越试越乱。后来改成按工具类型分别设超时,安装类给 5 分钟,查询类给 30 秒。
坑三:验证只做了一层就交付。有次任务只做了语法验证,JSON 格式没问题就放行了,结果里面的数值全是错的。教训是:格式对不等于内容对,功能验证不能省。
坑四:恢复时没恢复沙箱,只恢复了上下文。进程重启后,上下文说“文件已创建”,但沙箱是全新的,文件根本不存在,Agent 基于错误前提继续操作,全乱套。教训是:上下文和沙箱状态必须一起恢复,缺一不可。
坑五:心跳间隔设得太长,卡死发现太晚。一开始心跳设 5 分钟一次,结果任务卡死 4 分钟才被发现,白白浪费。后来改成 30 秒,配合看门狗 90 秒无心跳即告警,响应快多了。
这些坑的共同点是:它们都不在“主流程”上,而在边界和异常路径上。而 Harness 工程的价值,恰恰就体现在这些边界和异常的处理上。主流程谁都能写,把边界处理好才是真功夫。