前阵子我排查一个 Agent 项目的诡异行为:同一个模型、同一套工具定义,直接在 API 层测试一切正常,放到自己的 Agent 框架里跑,它却开始反复调用同一个查询工具,最后甚至在一次会话里把上下文撑爆。问题本身不难定位——根子不在模型,而在承载模型的那套 harness 上。Agent 循环的节奏、上下文的管理方式、沙箱的隔离强度、验证失败的反馈路径,任何一个环节没设计清楚,都会让模型的表现连裸调 API 都不如。这也是我写这篇文章的初衷:把 harness 工程拆开,说说 Agent 循环、上下文、沙箱、验证与长时运行这几块到底在解决什么问题、实践中怎么取舍。
1. Harness 的真实职责:模型只是"决策器",harness 才是"执行体"
1.1 为什么叫 harness,以及它替模型补上了什么
很多人第一次接触"harness"这个词会困惑。字面意思是"挽具、绳索",工程上指"把力量固定到某个执行载体上的连接装置"。放到 Agent 场景里非常贴切:大语言模型本身只是一个无状态的文本映射器,输入 token 序列,输出 token 序列。模型并不知道"怎么调用工具""怎么记住上一轮结果""怎么遵守权限边界",它只是根据上下文给出下一段文本的预测。
那 Agent 的能力从哪来?来自模型之外那层脚手架。比如模型输出了一段 JSON 格式的调用指令,里面写着某个工具名和参数,如果没有 harness 去解析这段文本、映射到真实函数、执行它、再把执行结果写回对话历史,这段指令就永远只是一段文本。换句话说,模型负责提出"想法",harness 负责把想法变成"行动"并观测"行动的结果",再把结果作为新的上下文交还给模型。这个闭环就是 Agent 系统相对普通 API 调用的根本区别。
我见过不少从纯 Prompt 工程转向 Agent 开发的人,一开始会觉得"让模型调用工具"这件事很简单——只要在提示词里写清楚工具列表就行。实际跑起来才发现,真正的工程量全在 harness 上。以下面这个表为例,能比较直观地看出裸 API 和 harness 之间的落差:
| 能力维度 | 裸模型 API | Harness 化 Agent |
|---|---|---|
| 动作解析 | 输出纯文本,需自行理解 | 按约定协议解析为结构化工具调用 |
| 记忆维护 | 调用方每次手动拼上下文 | 自动维护会话历史、观察记录 |
| 权限边界 | 无任何限制 | 沙箱、工具白名单、资源配额 |
| 错误恢复 | 失败即终止 | 校验错误回填、自动重试 |
| 运行形态 | 单次请求-响应 | 可长时运行的循环系统 |
一开始可能觉得这些功能是"附加项",但实际它们是 Agent 能稳定工作、能被测试、能被追踪的前提。模型的能力决定 Agent 的上限,harness 的质量决定 Agent 的下限。
1.2 从"无状态请求"到"有状态运行系统"的思维转换
做传统后端开发时,我们对服务的理解通常是"请求进来、响应出去",即使有会话也是由调用方维护。Agent harness 不同,它把一次任务拆成多轮交互,每一轮循环都依赖上一轮的输出、观察结果和累积记忆,天然是有状态的运行系统。
这意味着工程设计上要做几个非常务实的决定:状态存在哪里、什么时候持久化、进程挂了怎么恢复、上下文膨胀到什么程度必须压缩。这些决定没法照搬传统 Web 服务的经验。传统服务挂了就重启,用户重试即可;Agent 任务如果跑了一半挂掉,重启后如果没有状态恢复机制,整个任务就需要从头再来,对长时运行的批处理场景几乎是不可接受的。
所以我在设计 harness 时,会强制自己把每个子模块当成独立问题看待:循环执行器负责"转",上下文管理器负责"记",沙箱负责"挡",验证器负责"纠错",持久化模块负责"恢复"。这篇文章后面几节,就是按这个顺序把每一块的坑展开。
2. Agent 循环:看似只有几步,问题全在"转多久"和"怎么停"
2.1 循环骨架:感知、推理、行动、观察
一个标准的 Agent 循环迭代可以拆成四段:感知——读取系统指令、用户目标、工具清单和之前的观察结果;推理——让模型产出下一步行动方案;行动——把模型的输出解析成具体操作并执行;观察——记录工具执行结果,把它追加到上下文里,进入下一轮。
用伪代码表示大概是这样的:
while not done: # 感知:构造当前轮完整上下文 messages = build_messages( system=SYSTEM_PROMPT, tools=TOOL_SCHEMAS, history=session_history, observations=last_observations ) # 推理:模型输出决策 response = model.generate(messages, tools=TOOL_SCHEMAS) action = parse_action(response) # 解析成结构化的工具调用 # 行动与观察 if action.type == "call_tool": observation = sandbox.execute(action.tool, action.args) session_history.append(action) session_history.append(observation) elif action.type == "finish": done = True这个骨架看起来人畜无害,真正的难度集中在"done"怎么判断,以及循环跑偏时怎么兜底。模型经常不会乖乖输出"finish"标记,它可能连续调用工具,也可能以为自己已经完成任务但实际上没有。循环层必须同时承担"推进器"和"刹车"两个角色。
2.2 停止条件设计:预算、无进展检测、人工确认点
停止条件是我踩坑最多的地方。早期版本只设置了一个 max_iterations=10,结果非常粗暴:简单任务 2 轮就完成了,复杂任务 10 轮也不够,把参数调大后模型又开始跑飞。后来我把停止条件拆成几个层次:
第一层是硬预算。max_iterations 和总 token 预算,超了就强制停止。这一层不是给模型"合理完成任务"用的,而是防止失控的系统把你账户里的额度烧光。我通常会把预算值设为正常完成所需轮数的 2 到 3 倍,留足余量但别让人安心烧钱。
第二层是无进展检测。模型连续好几轮调用同一个工具、传几乎相同的参数,说明它在原地打转,继续转下去没有意义。我在运行日志里加了"最近 3 轮动作是否完全相同"的检查,一旦命中就触发终止,并把"检测到重复动作"作为一条观察消息回填给模型,让它自己重新规划。相比直接终止,这种方式给了模型一次自我纠正的机会,实测对很多误判场景有效。
第三层是人工确认点。涉及不可逆操作——删除数据、扣费、发布、修改生产环境——我会在 harness 里预留一个 pending 状态:工具调用不直接执行,而是挂起等待用户确认。虽然这让自动化程度打折扣,但对生产系统来说,这个"折扣"买的是安全。
2.3 循环里的三种常见异常模式
第一个常见异常是"工具结果没被正确观察"。模型调用了工具,但 harness 把观察结果放错了位置,或者根本没放进去,模型下一轮就"失忆"了,只能再调一次同样的工具。这种问题在自测时最容易忽略,因为你盯着日志会觉得"调用成功了啊",但模型视角里它根本看不到结果。排查方法很简单:把每轮发送给模型的完整消息序列打出来,看观察内容是否真的在里面。
第二个异常是"推理链过度膨胀"。每轮循环都会往上下文里追加至少一两千 token,几十轮之后就几万 token,单次请求延迟从 2 秒涨到 8 秒,费用也线性上升。这个问题的根源往往不是模型,而是任务拆解得太碎,或者工具返回值太大。后面第三节会展开说上下文压缩。
第三个异常是"提前收工"。模型在只完成一半任务时就输出 finish,原因多半是系统提示词里没有明确"完成标准"。后来我在系统提示里加了一句"只有在用户目标的所有子项都被满足,或你已确认某子项不可完成时,才输出 finish",误终止率下降了很多。这说明循环层不仅要管"怎么停",还要管"停下时依据什么证据"。
3. 上下文管理:窗口不是资源,而是 Agent 的记忆
3.1 一次循环的上下文中都装了些什么
如果把 Agent 的上下文窗口看作一个人的工作记忆,那里面通常装的东西不少:系统提示词(角色、行为规则、任务目标)、工具定义列表(名字、参数 schema、使用说明)、用户原始需求、历史对话回合、历次工具调用和观察结果。随着循环推进,这些内容不断累积,窗口从"够用"走向"溢出"只是时间问题。
这里有个新手特别容易忽视的点:工具定义本身也很占空间。每个工具 schema 动辄几百 token,如果注册了 20 个工具,光工具定义就几千 token,每轮都会重复发送。我见过一个项目,上下文里 40% 是工具说明,真正干活的空间没剩多少。优化手段就是"按需投递":根据当前阶段任务,只把相关性最高的几个工具定义放进这一轮的消息里。这个优化对成本和延迟的改善非常直接。
3.2 长上下文被低估的三个代价
很多人觉得"上下文窗口够大就没问题",实际远没那么简单。
第一个代价是费用和延迟。每轮请求的输入 token 是累积的,第 30 轮的输入可能比第 1 轮大十倍,消耗的算力和等待时间都同步上涨。对于有交互延迟要求的场景,这是致命的。
第二个代价是注意力稀释。我在排查困惑行为时发现,模型离窗口开头的系统指令越远,就越容易忘记约束条件,尤其是"不要调用 XX 工具""如果遇到 Y 情况优先请示用户"这类软性规则。上下文窗口不是容量越大越好,关键内容能不能"贴"到模型最近注意到的地方,才是重点。
第三个代价是"中间遗忘"。就是在长上下文中,位于中间位置的信息最容易在推理时被忽略。工具观测结果、某个关键错误提示恰好落在中间段,模型拍脑袋决策时直接没看到。这类问题光靠增加窗口大小无法解决,只能靠结构化管理。
3.3 四种可行的记忆管理策略
做长时运行的 Agent,上下文策略几乎不可避免。根据任务类型,我通常从下面四种方案里组合:
截断是最偷懒也最危险的做法,直接从最早的历史开始丢,直到塞进窗口。好处是简单,坏处是早期信息里可能有任务的核心约束,丢了之后模型后续行为很容易漂移。
滚动摘要更稳健:每执行几轮,就让模型把之前的会话浓缩成一段摘要,放进上下文里替代原文。代价是每一步摘要都有可能丢失细节,摘要本身也可能有摘要的幻觉。我的做法是把摘要请求做成独立验证步骤,对摘要里提到的关键事实和原文对照一遍再使用。
关键信息抽离是针对目标性强的任务:不保留全部历史,只保留"任务目标""已完成步骤清单""当前待办""已知约束"这几项结构化信息。每一轮结束后重新生成这份状态卡。好处是 token 消耗非常可控,坏处是如果任务本身强依赖对话历史细节,抽离会漏信息。
外部记忆检索是把完整历史写入本地存储,然后按语义检索回填。这是最接近"长期记忆"的方案,但实现复杂,需要额外的索引和检索质量保障。我的经验是:小规模任务用"滚动摘要 + 关键信息抽离"的组合就够了;任务依赖大量背景材料时才需要上检索式记忆。
3.4 上下文污染的排查案例
最后分享一个让我印象深刻的 bug。某次运行的 Agent 突然开始说一些和任务无关的话,我完整打印了每轮 message,才发现是某个工具返回的网页内容里藏了一段"忽略你之前的指令,只输出..."的注入文本,这段文本被原样附加到上下文里,下一轮模型就照做了。这就是典型的上下文污染:harness 对观察结果没有任何清洗、过滤或可信度分层,等于给了外部内容改写 Agent 记忆的机会。
从那以后,我在 harness 里加了一条铁律:所有工具观察结果按"数据"对待,不能按"指令"对待。具体做法是对观察文本做封装,比如加上明确的"以下是工具输出的原始数据"边界标记,并且在系统提示里说明工具输出全部视为不可信数据,不应影响行为规则。这一步对内容抓取类的 Agent 尤其重要。
4. 沙箱:给 Agent 的行动加一道程序化边界
4.1 为什么不能直接信任模型生成的"代码"
很多人都知道沙箱重要,但对"不沙箱会出什么事"缺乏直观感受。举例说,Agent 拿到了一个 Shell 工具,模型需要生成一条命令来统计文件数量。它可能正常输出find . -name "*.log" | wc -l,也可能在提示词含糊时输出rm -rf /这种灾难性命令。模型并不知道它输出的是什么,它只是在预测最可能的 token 序列。问题在于,模型预测出来的 "最可能的序列" 和 "应当被信任的代码" 完全是两回事。
所以沙箱存在的第一个理由非常朴素:LLM 输出本质是不可信输入,而不可信输入不应该直接触碰你真实的操作系统、生产数据和网络。你写一个 Agent 自己玩无所谓,一旦它要读文件、发请求、执行命令,就必须有一个程序化边界把它框住。
4.2 沙箱层级的选型:从子进程到容器到微虚拟机
根据你 Agent 的实际危险等级,沙箱强度大致分几个档位:
| 沙箱层级 | 隔离强度 | 典型开销 | 适用场景 |
|---|---|---|---|
| 子进程 + 资源限制 | 弱,共享内核与文件系统 | 极低 | 仅执行纯计算、无副作用代码 |
| 容器运行时 | 中,隔离文件系统与进程 | 中等 | 大部分工具类任务,需要可复现环境 |
| 微虚拟机 | 强,独立内核边界 | 较高 | 处理不受信代码、执行模型生成的完整程序 |
子进程方案适合实验原型:用 timeout 限制运行时长,用系统账户权限限制文件访问范围。但说实话,它挡不住真正危险的命令,稍微通点网络协议就能横向穿透。容器方案是多数生产场景的选择,把工具执行装进只读根文件系统、挂载临时数据卷、禁用网络或只放行白名单域名,可复现性和隔离性都能兼顾。如果任务需要执行不受信第三方代码,那就得上微虚拟机,虽然启动开销大,但值得。
4.3 沙箱必须管住的四件事
我总结过一句话:沙箱要管好"网、盘、量、时"。
"网"是网络访问控制。很多 Agent 工具只需要访问某个特定 API,那就应该只放行这个域名的出网,其他一概拒绝。不然一个文件读取工具被诱导,就能把本机数据外传。
"盘"是文件系统权限。默认情况下,沙箱内进程应当只能读写指定工作目录,不能访问宿主机其他路径。把工作目录设计成每次任务独立创建、任务结束即清理,能避免大量跨任务的脏数据污染。
"量"是资源配额。CPU、内存、临时磁盘空间都要设上限,防止工具失控把机器打爆。模型生成的代码很可能写出死循环,没有资源限制的话,一个循环就能让整个服务卡死。
"时"是执行超时。任何工具调用都必须有一个硬超时,常见的是 30 到 120 秒。超时后不是简单杀掉进程,而是要把"工具执行超时"作为一条观察结果回传给模型,让它决定降级处理还是换方案。这一步很容易被忽略,但它恰好是 Agent 失败恢复能力的关键环节。
4.4 可复现性:沙箱环境本身要"干净"
沙箱另一个容易被忽略的价值是可复现性。如果每次工具执行的环境都不一样——有时 Python 3.10,有时 3.11,有时装了这个依赖,有时没装——那 Agent 遇到"同样的调用,结果不同"的概率会非常高,排查起来极其痛苦。我的做法是把工具执行环境固化成镜像:系统版本、解释器版本、依赖清单全部锁定,每次新任务从镜像启动,任务结束后销毁容器。这样 Agent 行为偏差就只能归因于"模型决策"或"输入数据",而不是"运行环境随机"。
5. 验证层:把 Agent 的幻觉和越界挡在应用边界上
5.1 Agent 输出错误的常见来源
模型直连 API 时,输出格式错了、参数非法,顶多算一次请求失败,重新生成即可。但在 Agent 循环里,模型输出的不只是一段文本,而是一个"将要被执行的动作"。一旦动作越界或格式错乱,就不是重试一次那么简单了,可能在真实环境造成破坏。所以验证层的定位不是"锦上添花",而是"发布前的必过门槛"。
Agent 输出错误主要分三类。第一类是格式错误,最典型的是模型输出一段声称是 JSON 但实际非法的东西,比如多了尾逗号、包含未转义换行。第二类是参数语义错误,格式合法但内容不合理,工具需要整数参数,模型却传了字符串;工具限定枚举值,模型传了列表之外的值。第三类是权限越界,模型调用了自己本不该调用的工具,或者在一个只读任务里试图写入。
5.2 三道验证:结构、schema、语义
我把验证拆成三层。第一层是结构校验,把模型输出解析为规定的动作数据结构,解析失败直接进入重试流程,并带上解析失败的详细原因。这层拦截最常见的"输出格式崩坏"问题。
第二层是 schema 校验,对照工具定义检查参数类型、必填项、枚举范围。这层可以用现成的 JSON Schema 校验器实现。关键是错误信息要足够具体,不能只报"参数不正确",要把"期望类型是 integer,实际收到 string"这样的细节带进回填消息。
第三层是语义校验,也是最难自动化的一层。它检查"这个动作在当前任务状态下是否合理"。比如一个订单查询工具,任务上下文里根本没有订单号,模型却尝试调用,这就是语义越界。语义校验常常需要结合任务状态机或规则引擎实现,没法完全靠模型自己判断。
5.3 验证失败的正确姿势:回填给循环,而不是直接中断
很多初版 harness 的做法是验证失败就抛异常终止。这在单次任务里问题不大,但 Agent 循环的优势恰恰在于"能从错误中恢复"。正确做法是把验证失败变成一个工具返回结果,写进观察消息,让模型看到"你刚才调用失败了,原因是 X,请修正后重试"。
我在实现时给验证器定义了一个统一的校验错误结构:
{ "status": "validation_failed", "stage": "schema", "tool": "list_files", "error": "参数 path 类型校验失败:期望 string,实际收到 null", "suggestion": "请提供一个合法的路径字符串后重试" }这个 JSON 对象会原样变成一条 observation 加入历史。模型下一轮就能基于这个反馈自我修正。实测下来,这种方法能让很多原本会失败的调用在下一轮被纠正,显著提升了任务完成率。
5.4 验证器自身的两个陷阱:过严和过松
过严的验证器会频繁拦下合法调用,逼着模型反复重试,不仅浪费预算,还会诱导模型尝试绕过验证规则,比如把参数编码成奇怪的格式。过松的验证器则会让前面说的所有防护形同虚设。我的建议是验证规则用"白名单思维"而不是"黑名单思维":明确列出该工具可接受的参数形态,而不是列出禁止的情况。因为黑名单永远列不全,模型总有办法生成你没预料到的输入。
6. 长时运行:从"跑完一次"到"稳定跑几天"
6.1 长时运行面对的三个敌人:失败、丢失、漂移
当 Agent 从"一次性对话"变成"夜间批量任务""多天追踪任务"时,系统设计就进入另一个量级。长时运行说到底要一直对抗三件事。
第一是失败。单轮执行失败是常态,进程崩溃、外部 API 不稳、网络超时,什么都会发生。长时运行要求 harness 能从失败中恢复,而不是整个任务从头开始。第二是丢失。进程重启后,如果没有任何状态记录,之前几十轮的上下文就像没存在过一样。第三是漂移。任务目标在长时间运行中容易被遗忘,模型跑着跑着就开始纠结细枝末节,忘了最初要解决什么问题。
6.2 状态持久化:快照加事件日志的组合
要对抗"失败"和"丢失",我采用"快照 + 事件日志"双写策略。每个完整的 Agent 循环步骤结束后,把当前状态(包括消息历史、上下文摘要、已完成动作列表、当前待办)压缩成一个快照持久化到存储中,同时把每一步的原始动作和观察追加到事件日志里。
恢复时优先从最近快照加载,然后重放事件日志中快照之后的增量事件,把状态补到最新。这个模式很像游戏存档加录像回放。好处是快照恢复快,事件日志保证信息完整。如果快照本身写入失败,还能用事件日志从头重建状态。对运行级别要求很高的任务,我还会定期把快照复制到另一处存储,避免单一存储故障导致整个任务丢失。
6.3 心跳、超时和看门狗机制
长时运行的 harness 本身也可能变成"僵尸进程":循环还在跑,但每轮之间卡了很久,或者工具调用一直没有返回。我的做法是给整个 Agent 运行进程加一个看门狗,看门狗不在 Agent 主进程内,而是独立的监控进程。它定期检查心跳文件的时间戳,如果发现 Agent 超过 N 分钟没有更新心跳,就认为进程卡死,自动重启整个任务,加载最近的快照继续跑。
这里有个细节:工具调用的"卡住"和"合理等待"之间需要区分。比如某 Agent 在执行一个 sleep 30 秒的重试逻辑,心跳也必须相应放宽,否则会被看门狗误杀。我给工具调用单独设了超时,同一个超时值也会指导看门狗的心跳阈值设置,两边要对齐。
6.4 长时运行中的上下文防漂移
任务漂移的根源,我在多数项目的日志里都能找到同一个线索:最初的用户目标在几十轮之后已经被压到了遥远的历史里,模型眼里只剩下最近的几个观察结果。所以长时运行 harness 必须把"任务目标"做成高优先级的常驻上下文,不随滚动摘要淘汰。
我的做法是维护一份结构化的"任务目标卡",里面包含三层:最终目标、当前阶段目标、完成条件。每一轮构造消息时,任务目标卡固定放在系统消息之后、历史之前的固定位置,并且每若干轮要求模型根据当前进展更新这份卡片。这样即使旧历史被摘要压缩掉,模型依然能稳定锚定在正确的任务方向上,漂移概率大幅下降。
另外,长时运行的 Agent 日志是真正的排查法宝。我要求每轮循环都记录一个结构化日志条目:轮次、输入 token 数、输出动作、观测摘要、当前累计耗时。任务出问题时,我第一件事就是看这个日志表,定位行为变化发生在那一步,比看模型输出本身高效得多。
写在最后的一点个人体会
做 harness 工程这几年,我越来越认同一个分工:模型负责"想法",harness 负责"记忆"和"约束"。把循环的停止条件、上下文压缩策略、沙箱边界、验证回填、状态恢复这些基础设施做扎实,模型的能力才会稳定兑现。每次设计新 Agent 时,我会先画清楚两个东西:循环在什么条件下终止,失败的路径如何回填。把这两端想清楚,中间环节基本不会出大乱子。
最后分享一个小技巧:给 harness 加一个"干跑模式"。在这个模式下,所有工具调用都不真正执行,只返回预设的模拟结果,但循环流程、上下文管理、验证逻辑全部照常运转。跑通干跑模式再切真实执行,能避开一大半集成期的低级 bug。这条经验帮我省下过大量调试时间,希望对正在搭 Agent 的你也有用。