1. 从一次深夜调试说起:为什么我们需要重新理解 Agent 工程
凌晨两点,我盯着屏幕上第无数次中断的 Agent 执行日志,心里只有一个念头:这玩意儿怎么又卡在同一个地方了。任务本身并不复杂——让 Agent 自动完成一份多步骤的数据整理工作,读取文件、分析内容、生成报告、保存结果。单看每一步都不难,但把它们串成一个完整的自动化流程,问题就全冒出来了:上下文窗口爆了、工具调用返回了意料之外的格式、中间某一步失败了整个流程就彻底停摆、跑了一半的会话因为超时被强制中断。
我相信每一个真正动手做过 Agent 系统的人,都经历过类似的夜晚。市面上的教程大多停留在“怎么调 API”“怎么写 Prompt”的层面,但当你真正要把一个 Agent 跑起来、跑稳、跑得久,你会发现真正的挑战根本不在于模型本身,而在于模型之外的那一整套工程体系。这套体系,我把它叫做Harness 工程。
Harness 这个词,原意是“马具”——套在马身上、让马的力量能够被有效引导和控制的整套装备。Agent 领域的 Harness 也是同样的意思:模型是那匹马,有力量、有速度,但如果没有一套合适的“马具”来约束、引导、保护,它要么跑偏,要么累死,要么把车拉翻。Harness 工程要解决的就是这个问题:怎么让一个基于大模型的 Agent 系统,在真实环境中稳定、可靠、长时间地运行下去。
这篇文章适合谁看?如果你正在做 Agent 相关的开发,不管是用现成框架还是自己手搓,只要你遇到过“跑着跑着就崩了”“上下文不够用了”“工具调用老出问题”“长时间运行不稳定”这些情况,那这篇内容就是写给你的。我会从 Agent 循环的核心机制讲起,一路拆解上下文管理、沙箱隔离、结果验证、长时运行这几个关键环节,把每个环节背后的设计逻辑、实操要点、踩坑经验都摊开来讲。不堆术语,不搞玄学,就是实打实的工程经验分享。
2. Agent 循环:整个系统的发动机
2.1 循环的本质:一个不断做决策的状态机
很多人第一次接触 Agent 的时候,会觉得它很“智能”——好像模型自己在思考、在规划、在执行。但如果你把 Agent 的外壳剥开,会发现它的核心就是一个循环,一个非常朴素的循环。
我用一个最简化的伪代码来说明:
while not task_complete: response = model.generate(context) if response.has_tool_call: result = execute_tool(response.tool_call) context.append(result) else: task_complete = True final_answer = response.content就这么简单。Agent 循环的本质就是:把当前上下文喂给模型,模型决定下一步做什么,如果是调用工具就去执行,把结果塞回上下文,然后继续下一轮,直到模型认为任务完成。
但就是这么一个简单的循环,在实际工程中会衍生出无数的问题。因为每一轮循环都涉及几个关键决策:上下文里放什么、模型输出怎么解析、工具调用怎么执行、执行结果怎么处理、什么时候该停下来。每一个决策点做不好,整个系统就会出问题。
我见过很多刚上手的朋友,写出来的 Agent 循环是这样的:把所有历史消息一股脑塞进上下文,模型输出直接用字符串匹配来解析工具调用,工具执行没有任何超时和错误处理,循环终止条件就是“模型说完成了”。这种写法在 Demo 阶段跑几个简单任务没问题,但一旦上真实场景,基本活不过三天。
2.2 循环控制:什么时候该继续,什么时候该停
循环控制是 Harness 工程里最容易被低估的部分。很多人觉得“模型说完成了就停”就行了,但实际情况远比这复杂。
你需要考虑至少这几种终止条件:
- 正常完成:模型明确表示任务已完成,且输出通过了验证。
- 最大轮次限制:防止模型陷入死循环,设置一个硬性的轮次上限。
- 超时终止:整个任务或单轮执行超过了预设的时间限制。
- 错误累积终止:连续多轮出现工具调用失败或解析错误,主动终止避免资源浪费。
- 人工干预终止:在某些场景下,需要支持外部信号来中断执行。
我自己的经验是,最大轮次限制和超时终止是两个必须设置的硬约束。不要相信模型能自己判断什么时候该停,它有时候会陷入一种“我再试一次”的循环里,反复调用同一个工具,每次都得到同样的错误,但就是不停下来。这种情况在工具返回错误信息不够明确的时候特别常见。
实操心得:最大轮次不要设得太宽松。我一般会根据任务的复杂度来定,简单任务 10 轮以内,中等复杂度 20 到 30 轮,复杂任务最多 50 轮。超过这个数还没完成,大概率是卡在某个地方了,继续跑只是浪费 token。
2.3 循环中的状态管理:别让上下文变成垃圾场
每一轮循环都会往上下文里追加内容——模型的输出、工具调用的结果、可能的错误信息。如果不加管理,上下文会迅速膨胀,最终超出模型的窗口限制。
这里有一个很关键的认知转变:上下文不是日志,不是把所有发生过的事情都记下来就好。上下文是模型做决策的依据,它应该只包含对当前决策有用的信息。
我通常会把上下文分成几个逻辑区域来管理:
| 区域 | 内容 | 管理策略 |
|---|---|---|
| 系统提示区 | 角色定义、能力边界、输出格式要求 | 固定不变,放在最前面 |
| 任务描述区 | 当前任务的目标和约束 | 任务开始时写入,中途可更新 |
| 工作记忆区 | 最近几轮的模型输出和工具结果 | 滑动窗口,保留最近 N 轮 |
| 长期记忆区 | 关键信息摘要、已完成步骤的记录 | 定期压缩,用摘要替代原文 |
| 工具定义区 | 可用工具的 schema 描述 | 按需加载,不用的工具不放入 |
这种分区管理的思路,核心目的是让每一轮循环的上下文都保持在一个可控的规模内,同时不丢失关键信息。具体怎么压缩、怎么摘要、怎么决定哪些信息该保留哪些该丢弃,后面讲上下文工程的时候会详细展开。
3. 上下文工程:Agent 的“工作台”怎么摆
3.1 上下文窗口不是越大越好
现在很多模型都支持很长的上下文窗口,动辄几十万 token。这让很多人产生了一个错觉:既然窗口这么大,那把什么东西都塞进去不就行了?
我实测下来的结论是:上下文窗口的大小和 Agent 的实际表现之间,并不是简单的正相关关系。窗口越大,模型在处理信息时的注意力就越分散,关键信息被淹没在大量无关内容中的概率就越高。而且,长上下文带来的推理成本和时间开销也是线性增长的。
你可以把上下文想象成一张工作台。工作台大是好事,但如果上面堆满了乱七八糟的东西,你找一把螺丝刀都要翻半天,那大工作台反而成了负担。真正高效的工作台,是只放当前手头需要的东西,用完就收走,需要的时候再拿出来。
3.2 上下文压缩的几种实用策略
在实际工程中,我常用的上下文压缩策略有这么几种,各有适用场景:
滑动窗口法是最简单的。只保留最近 N 轮的对话记录,更早的直接丢弃。这种方法实现简单,但问题是会丢失早期的重要信息。适合那些任务步骤之间相对独立、不需要回溯太多历史的场景。
摘要压缩法是在上下文快要满的时候,让模型对前面的内容做一个摘要,然后用摘要替换原文。这种方法能保留关键信息,但摘要本身会引入信息损失,而且摘要的质量取决于模型的总结能力。我一般会在上下文中保留最近几轮的完整记录,更早的部分用摘要替代。
关键信息提取法是我个人最推荐的方式。不是让模型自由发挥去摘要,而是提前定义好哪些信息是关键的——比如已完成的步骤列表、当前正在处理的文件路径、已经确认的事实——然后每轮循环结束后,把这些关键信息抽取出来,以结构化的形式保存在上下文的一个固定区域。这样即使丢弃了大量的对话历史,核心状态也不会丢失。
分层记忆法是把上下文分成短期记忆和长期记忆两层。短期记忆就是最近几轮的完整记录,长期记忆是经过压缩和结构化的关键信息。每轮循环开始时,把长期记忆和短期记忆拼接起来喂给模型。这种方式的灵活性最好,但实现复杂度也最高。
注意事项:不管用哪种压缩策略,都要确保压缩后的上下文仍然包含模型做下一步决策所必需的信息。我踩过的一个坑是,压缩得太狠,把工具调用的参数格式说明给压没了,结果模型开始胡乱构造参数。压缩完最好做一次校验,确认关键信息没有丢失。
3.3 上下文中的信息排序也有讲究
除了控制上下文的体积,信息的排列顺序也会影响模型的表现。我实测发现,把最重要的信息放在上下文的开头和结尾,中间放次要信息,模型的表现会更好。这和大模型注意力机制的特点有关——模型对上下文首尾部分的关注度通常高于中间部分。
具体到 Agent 的上下文组织,我一般会这样排列:
- 系统提示和角色定义(开头,必须让模型首先看到)
- 当前任务的核心目标和约束(紧跟系统提示)
- 最近一轮的工具调用结果(结尾附近,模型需要基于这个做下一步决策)
- 更早的历史记录和摘要(中间部分)
- 可用工具的 schema 定义(如果工具很多,放在中间偏后位置)
这个排序不是绝对的,需要根据具体任务来调整。但核心原则是:模型当前最需要用来做决策的信息,要放在它最容易注意到的位置。
4. 沙箱与工具执行:让 Agent 的手伸出去但不闯祸
4.1 为什么必须有沙箱
Agent 和普通的聊天模型最大的区别在于,它能“动手”——能执行代码、能读写文件、能调用外部服务。这个能力很强大,但也很危险。
我见过一个真实的案例:一个 Agent 被要求清理临时文件,结果因为路径拼接的逻辑有问题,把用户的一个重要目录给删了。还有一个案例是 Agent 在执行数据处理脚本时,因为脚本里有死循环,直接把整个容器的 CPU 占满了,导致同机器上的其他服务全部受影响。
这些问题的根源都在于:Agent 的执行环境没有和主系统做隔离。沙箱要解决的就是这个问题——给 Agent 一个独立的、受限的、可随时销毁的执行环境,让它在里面随便折腾,出了问题也不会影响到外面的系统。
4.2 沙箱的隔离级别选择
沙箱的隔离级别有好几档,从轻到重大致是:
| 隔离级别 | 实现方式 | 隔离强度 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| 进程级 | 独立子进程 + 资源限制 | 低 | 很小 | 简单脚本执行 |
| 容器级 | Docker 等容器技术 | 中 | 中等 | 大多数 Agent 任务 |
| 虚拟机级 | 轻量虚拟机 | 高 | 较大 | 高安全要求场景 |
| 物理隔离 | 独立机器 | 最高 | 最大 | 极端安全要求 |
对于大多数 Agent 应用来说,容器级隔离是一个比较平衡的选择。它能提供文件系统、网络、进程空间的隔离,性能开销也可以接受。启动一个容器通常只需要几百毫秒到几秒,对于大多数任务来说这个延迟是可以接受的。
如果任务比较简单,比如只是执行一段计算脚本,进程级隔离加上资源限制(CPU 时间、内存上限、文件大小限制)也够用。但如果任务涉及文件操作、网络请求这些,还是建议上容器。
4.3 工具调用的参数校验与错误处理
沙箱解决了“在哪里执行”的问题,但“执行什么”同样需要严格把控。Agent 构造的工具调用参数,不能直接透传给执行层,中间必须有一层校验。
我一般会在工具执行前做这几层检查:
- Schema 校验:参数的类型、格式、必填项是否符合工具定义。
- 范围校验:数值参数是否在合理范围内,路径参数是否在允许的目录下。
- 权限校验:当前 Agent 是否有权限调用这个工具、访问这个资源。
- 频率校验:是否超过了调用频率限制,防止 Agent 疯狂调用同一个工具。
任何一层校验不通过,都应该返回一个明确的错误信息给模型,让模型知道哪里出了问题、应该怎么修正。这里有一个关键点:错误信息要具体、可操作。不要只返回“参数错误”,而要返回“参数 file_path 的值 /etc/passwd 不在允许的目录 /workspace 下,请使用 /workspace 下的路径”。模型看到具体的错误信息,才有可能在下一轮修正。
实操心得:工具执行一定要设超时。我一般会给每个工具调用设一个 30 秒到 2 分钟的超时,超时后强制终止并返回超时错误。没有超时机制的话,一个卡住的工具调用能把整个 Agent 循环拖死。
5. 验证与纠错:怎么知道 Agent 做对了
5.1 验证不是可选项,是必选项
Agent 执行完一个步骤后,怎么知道它做对了?很多人的做法是:看模型说“完成了”就认为完成了。但模型说完成了,不代表真的完成了。
我遇到过太多次这种情况:模型信誓旦旦地说“报告已生成并保存到指定路径”,结果去检查发现文件根本不存在,或者文件内容是空的。模型有时候会“幻觉”出一个成功的结果,尤其是在它没有真正执行工具、只是“想象”自己执行了的情况下。
所以,每一步关键操作之后都必须有验证。验证的方式可以分几种:
- 存在性验证:文件是否真的创建了,目录是否真的存在。
- 内容验证:生成的内容是否符合预期的格式和结构。
- 逻辑验证:执行结果是否满足任务的约束条件。
- 交叉验证:用不同的方式或不同的工具来确认同一个结果。
5.2 验证失败后的纠错策略
验证失败之后怎么办?直接把错误扔回给模型让它重试,是最简单的做法,但效果往往不好。因为模型可能不知道自己为什么错了,重试一次大概率还是同样的错误。
我一般会采用分级纠错的策略:
第一级是自动重试。如果错误是偶发的、非确定性的(比如网络超时),直接重试一到两次,很多时候就能成功。
第二级是带反馈的重试。把具体的错误信息和验证结果反馈给模型,让它基于这些信息来修正。比如告诉它“文件不存在,请检查路径是否正确,当前工作目录是 /workspace”。
第三级是策略切换。如果同一个步骤连续失败多次,说明当前的方法可能根本行不通,需要让模型换一种思路。这时候可以在反馈中加上“你之前尝试的方法连续失败了 N 次,请换一种完全不同的方法”。
第四级是人工介入。如果所有自动纠错都失败了,就把任务挂起,通知人工来处理。这在生产环境中是必要的兜底机制。
5.3 验证器的设计原则
验证器本身的设计也很关键。一个好的验证器应该满足这几个条件:
快速。验证不应该成为整个流程的瓶颈。如果验证一个结果需要跑一个很重的检查,那整个 Agent 的响应速度就会被拖慢。尽量用轻量级的验证方式,比如检查文件是否存在、检查 JSON 是否能解析、检查关键字段是否存在。
明确。验证结果要明确告诉模型“哪里不对”,而不是笼统地说“验证失败”。比如不要只说“输出格式错误”,而要说“输出的 JSON 中缺少 required 字段 ‘summary’”。
可组合。不同的任务需要不同的验证规则,验证器应该设计成可组合的。比如有一个基础的“文件存在性验证器”,一个“JSON 格式验证器”,一个“字段完整性验证器”,根据任务需要把它们组合起来使用。
6. 长时运行:让 Agent 跑得久还不崩
6.1 长时运行面临的核心挑战
让 Agent 跑几分钟、完成一个简单任务,和让它跑几个小时、完成一个复杂任务,是完全不同的两件事。长时运行面临的挑战主要有这么几个:
状态持久化。Agent 跑了一半,进程挂了或者被重启了,怎么恢复?如果状态只存在内存里,那就全丢了。必须把关键状态定期持久化到外部存储,支持断点续跑。
资源泄漏。长时间运行的过程中,如果每一轮循环都创建新的连接、打开新的文件句柄、申请新的内存,而不释放旧的,很快就会把资源耗尽。必须确保每一轮循环结束后,该释放的资源都释放了。
上下文膨胀。前面讲过的上下文管理问题,在长时运行场景下会更加突出。跑了几百轮之后,上下文如果不做压缩,早就爆了。
错误累积。单次错误可能不影响大局,但如果错误处理不当,小错误会累积成大问题。比如每一轮都产生一点数据偏差,几百轮下来偏差就大得离谱了。
外部依赖的不稳定性。长时运行意味着要长时间依赖外部服务——模型 API、数据库、文件存储。这些服务任何一个出现抖动,都可能影响 Agent 的运行。
6.2 检查点与恢复机制
检查点机制是长时运行的基础。核心思路是:定期把 Agent 的完整状态保存下来,出问题的时候可以从最近的检查点恢复,而不是从头开始。
需要保存的状态包括:
- 当前的任务描述和目标
- 已完成的步骤列表和每步的结果
- 当前的上下文(或上下文的压缩版本)
- 当前的循环轮次和已消耗的资源统计
- 待处理的工具调用(如果有的话)
保存的频率需要权衡。太频繁会影响性能,太稀疏会丢失太多进度。我一般会根据任务的粒度来定:如果每个步骤耗时较长(比如几分钟),那每完成一个步骤就保存一次;如果步骤很快(几秒钟),那就每 N 个步骤或每隔几分钟保存一次。
恢复的时候,从检查点加载状态,重建上下文,然后从断点继续执行。这里有一个细节要注意:恢复后的第一轮循环,最好让模型先确认一下当前状态,比如把已完成步骤的摘要和当前待办事项展示给模型,让它确认从哪里继续。这样可以避免模型在恢复后重复执行已经完成的步骤。
6.3 心跳与健康检查
长时运行的 Agent 需要一套健康检查机制,用来判断它是不是还“活着”、是不是还在正常推进。
我通常会在两个层面做健康检查:
进程层面,定期发送心跳信号,确认 Agent 进程没有挂掉。如果连续多个心跳周期没有收到信号,就认为进程异常,触发重启和恢复流程。
任务层面,监控任务的推进速度。如果发现长时间没有新的步骤完成,或者连续多轮循环都在做重复的事情,就认为任务卡住了,需要介入。介入的方式可以是给模型发送一个“你似乎卡住了,请重新审视当前状态并尝试不同的方法”的提示,也可以是直接终止并回滚到上一个检查点。
实操心得:心跳间隔不要设得太短,否则会产生大量无意义的检查开销;也不要太长,否则出问题后要等很久才能发现。我一般用 30 秒到 1 分钟作为心跳间隔,连续 3 次心跳丢失就触发告警。
6.4 优雅退出与资源清理
长时运行的 Agent 最终总是要停下来的——可能是任务完成了,可能是被人工终止了,也可能是遇到了无法恢复的错误。不管哪种情况,都需要有一套优雅退出的机制。
优雅退出要做的事情包括:
- 保存当前状态到检查点
- 关闭所有打开的文件句柄和网络连接
- 释放沙箱资源
- 记录退出原因和最终的统计信息
- 如果有未完成的任务,标记为“中断”状态,方便后续恢复
这些操作看起来琐碎,但在长时运行场景下非常重要。我见过因为没做资源清理,导致沙箱容器越积越多,最后把宿主机磁盘撑爆的情况。也见过因为没保存状态,任务中断后只能从头再来,浪费了大量已经完成的工作。
7. 常见问题与排查技巧实录
7.1 工具调用相关的问题
问题一:模型构造的工具调用参数格式不对
这是最常见的问题之一。模型输出的参数 JSON 可能缺少引号、多了逗号、类型不对。排查思路是:先检查工具的 schema 定义是否清晰,参数类型和格式要求是否在描述中写明白了。如果 schema 没问题,那就是模型的问题,可以在系统提示中加一些示例,展示正确的参数格式。
问题二:模型调用了不存在的工具
有时候模型会“发明”一个工具出来,或者把工具名字记错了。这种情况通常是因为工具列表太长,模型记混了。解决办法是精简工具列表,只保留当前任务真正需要的工具,并且在系统提示中明确列出可用工具的名称。
问题三:工具执行成功但模型不理解结果
工具返回了结果,但模型在下一轮循环中似乎没有正确理解这个结果,导致后续决策出错。这通常是因为工具返回的结果格式太复杂,或者包含了模型不熟悉的字段。解决办法是让工具返回结构清晰、字段命名语义化的结果,必要时在结果中加上简短的说明。
7.2 上下文相关的问题
问题一:上下文超出窗口限制
这个问题的排查比较简单,就是看上下文的 token 数是不是超过了模型的限制。解决办法就是前面讲的压缩策略。但要注意,压缩之后要验证关键信息没有丢失。
问题二:模型“忘记”了之前的指令
模型在后续轮次中不再遵守最初设定的规则或约束。这通常是因为这些指令被埋在了上下文的中间位置,模型的注意力没有覆盖到。解决办法是把关键指令放在系统提示中,或者每一轮都在上下文的开头重复一遍核心约束。
问题三:上下文中的错误信息导致模型反复犯错
如果上下文中保留了之前的错误记录,模型可能会被这些错误“带偏”,反复犯同样的错误。解决办法是在错误被修正后,把相关的错误记录从上下文中清理掉,只保留正确的状态。
7.3 长时运行相关的问题
问题一:跑着跑着就卡住了
表现是 Agent 不再产生新的输出,也不报错,就是停在那里。排查思路是:先看是不是某个工具调用卡住了(检查工具执行的超时机制),再看是不是模型 API 调用超时了(检查网络和 API 状态),最后看是不是 Agent 进程本身挂了(检查进程状态和心跳)。
问题二:内存持续增长
长时运行过程中内存占用越来越高,最终 OOM。这通常是资源泄漏导致的。排查方法是定期打印内存快照,对比不同时间点的对象数量和大小,找出持续增长的对象。常见的泄漏点包括:未关闭的连接、未释放的缓存、不断累积的日志。
问题三:恢复后行为异常
从检查点恢复后,Agent 的行为和中断前不一致。这通常是因为恢复的状态不完整,或者恢复后的上下文和中断前有差异。解决办法是确保检查点保存了完整的状态,并且在恢复后给模型一个明确的状态说明。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 模型反复调用同一工具 | 工具返回结果不明确 | 检查工具返回内容 | 优化返回格式,增加明确的状态标识 |
| 上下文迅速膨胀 | 未做压缩或压缩策略不当 | 检查每轮上下文增量 | 引入滑动窗口或摘要压缩 |
| 任务执行到一半停止 | 超时或轮次限制触发 | 检查循环控制参数 | 调整限制阈值或优化任务拆分 |
| 工具调用参数错误 | Schema 不清晰或模型理解偏差 | 检查工具定义 | 补充示例,简化参数结构 |
| 恢复后重复执行已完成步骤 | 检查点状态不完整 | 检查保存的状态字段 | 补全状态,恢复时明确告知模型 |
| 沙箱资源未释放 | 缺少清理逻辑 | 检查退出流程 | 增加资源清理钩子 |
8. 我个人的一些工程体会
做 Agent 系统这段时间,最大的感受是:模型的能力决定了 Agent 的上限,但 Harness 工程决定了 Agent 的下限。一个 Harness 做得好的系统,即使模型能力一般,也能稳定完成很多任务;而一个 Harness 做得差的系统,即使模型再强,也会在各种边界情况下崩溃。
另一个体会是,不要试图让模型解决所有问题。有些问题适合模型来解决(比如理解意图、生成内容、做决策),有些问题适合用传统工程手段来解决(比如格式校验、超时控制、资源管理)。把这两者混在一起,让模型去处理本该由代码处理的逻辑,只会让系统变得脆弱。
最后分享一个我一直在用的原则:每一轮循环,都要假设模型可能会犯错。基于这个假设来设计你的校验、纠错、恢复机制,你的 Agent 系统就会稳健很多。反过来,如果你假设模型总是对的,那你的系统就只是在等待第一次崩溃而已。