Agent工程三层架构:Harness、Loop与Graph的分层设计与生产落地
1. 从三个词说起:为什么Agent工程需要分层思考
第一次接触Agent开发的人,往往会被各种概念砸晕。Harness、Loop、Graph这三个词,单独看都不难理解,但放在一起就让人犯迷糊——它们到底是什么关系?是三个独立的工具,还是同一套体系的不同层面?我在过去一年多的Agent项目实践中,踩过不少坑,也逐步摸索出一套相对清晰的分层认知框架。这篇文章就把我对这三层架构的理解和实际落地经验完整地分享出来。
先说结论:Harness是执行外壳,Loop是运转机制,Graph是编排逻辑。三者不是并列关系,而是从外到内、从底层到上层的递进关系。你可以把它们想象成一家餐厅的运营:Harness是厨房的硬件设备和工位布局,Loop是厨师接单、做菜、出餐的循环流程,Graph则是整个后厨的协作网络——谁负责切菜、谁负责炒菜、谁负责摆盘,以及这些角色之间怎么传递信息。
这套分层思路解决的核心问题是:当Agent从Demo走向生产环境时,复杂度会急剧上升。如果不在架构层面做好切分,代码会迅速变成一团乱麻——工具调用逻辑和流程控制混在一起,状态管理和任务编排纠缠不清,最后连自己都改不动。我见过太多项目在原型阶段跑得挺好,一到生产环境就各种超时、死循环、状态丢失,根子往往不在模型能力上,而在架构分层没做好。
这篇文章适合三类人看:一是正在做Agent开发但感觉代码越写越乱的工程师;二是准备把Agent项目从原型推向生产的技术负责人;三是对Agent架构感兴趣、想了解工程实践细节的开发者。不管你是用Python还是其他语言,不管你是基于哪个模型做开发,这套分层思路都是通用的。
接下来我会逐层拆解,每一层都会讲清楚它解决什么问题、核心设计要点是什么、实际写代码时怎么落地,以及我在实践中踩过的那些坑。
2. Harness层:Agent的执行外壳与工具基座
2.1 Harness到底解决什么问题
Harness这个词在工程领域出现得很早,原本指的是测试脚手架或者执行框架。放到Agent语境下,它的角色是:为Agent提供一个标准化的执行环境,让模型能够安全、可控地调用外部工具和资源。
你可以把Harness理解成Agent的“手和脚”。模型本身只是一个大脑,它能思考、能推理,但没法直接读文件、发请求、查数据库。Harness就是把这些能力封装成标准接口,让模型可以通过统一的协议来调用。没有Harness,Agent就是一个只会说话的摆设。
我刚开始做Agent的时候,图省事,直接在业务代码里写工具调用逻辑。结果就是每加一个工具就要改一遍主流程,工具之间的错误处理各写各的,日志格式五花八门。后来把这些逻辑抽出来做成统一的Harness层,代码量少了三分之一,可维护性提升了好几个档次。
Harness层的核心职责包括:工具注册与发现、参数校验与转换、执行隔离与超时控制、结果格式化与错误封装。这五件事看起来简单,但每一件都有不少细节要处理。
2.2 工具注册与发现的设计要点
工具注册看起来就是维护一个字典,但实际上要考虑的问题不少。首先是工具的元信息描述——模型需要知道每个工具叫什么、干什么用、需要什么参数。这个描述的质量直接影响模型选择工具的准确率。
我的经验是,工具描述要写得像给新人看的API文档:一句话说清楚功能,参数说明要具体到格式和取值范围,最好附上一两个调用示例。我试过同一个工具用不同详细程度的描述,模型选择正确率能差出20%以上。
# 工具注册的典型结构 tool_registry = { "search_database": { "description": "根据关键词搜索产品数据库,返回匹配的产品列表", "parameters": { "keyword": {"type": "string", "required": True, "description": "搜索关键词"}, "limit": {"type": "integer", "required": False, "default": 10, "description": "返回结果数量上限"} }, "handler": search_database_impl } }工具发现机制则决定了模型在运行时怎么找到合适的工具。最简单的做法是全量注入——把所有工具描述都塞进提示词里。但工具一多,提示词就会爆炸,不仅浪费token,还会降低模型的选择准确率。更好的做法是分层发现:先根据任务类型筛选出候选工具集,再让模型从中选择。我在一个项目里把工具按领域分成五组,每组不超过八个工具,模型的选择准确率从67%提升到了89%。
2.3 执行隔离与超时控制
这是Harness层最容易被忽视但最不能省的部分。Agent调用的工具可能涉及网络请求、文件读写、数据库查询,任何一个环节出问题都可能拖垮整个流程。
执行隔离的核心思路是:每个工具调用都在独立的上下文中执行,一个工具挂了不能影响其他工具。具体实现上,可以用进程池、线程池或者异步任务来实现隔离。我一般用异步方式,配合超时控制,效果比较稳。
超时控制要分层设置:单个工具调用有超时,整个任务有总超时,重试次数也要有上限。我踩过的一个坑是只设了单次超时没设总超时,结果一个任务里调了二十次工具,每次都卡在超时边缘,整个任务跑了十几分钟才结束。后来加了总超时控制,超过阈值直接中断并返回部分结果,用户体验好了很多。
注意:超时时间不要设得太短。我见过有人把工具超时设成500毫秒,结果稍微慢一点的数据库查询全部失败。建议根据实际工具的P99耗时来定,一般留2到3倍余量。
2.4 结果格式化与错误封装
工具返回的结果需要经过标准化处理才能交给模型。原始结果可能是JSON、XML、纯文本甚至二进制数据,模型需要的是结构清晰、语义明确的文本描述。
我的做法是定义一个统一的结果格式,包含状态码、数据体和错误信息三个部分。成功时返回数据体,失败时返回错误码和可读的错误描述。这样模型能清楚地知道工具调用是成功了还是失败了,失败的原因是什么,从而决定下一步怎么做。
错误封装要特别注意不要暴露敏感信息。数据库连接字符串、内部IP地址、堆栈跟踪这些内容绝对不能直接返回给模型。我一般会做一层错误映射,把技术错误转换成业务语义的错误描述。比如“Connection refused”转换成“数据库暂时不可用,请稍后重试”。
3. Loop层:Agent的运转机制与循环控制
3.1 Loop的本质:让Agent动起来
如果说Harness给了Agent手脚,那Loop就是让这些手脚动起来的神经系统。Loop的核心是一个循环:模型思考、选择工具、执行工具、观察结果、继续思考,直到任务完成或者达到终止条件。
这个循环看起来简单,但实际写起来要考虑的事情很多。什么时候该继续循环?什么时候该停下来?循环次数上限设多少?中间结果怎么管理?这些问题不处理好,Agent要么陷入死循环烧钱,要么过早停止任务没完成。
我刚开始写Loop的时候,最朴素的做法就是设一个最大轮次,比如10轮,跑完就停。结果发现有些简单任务3轮就完成了,白白浪费了7轮;有些复杂任务10轮根本不够,跑到一半就被迫中断。后来改成动态判断:模型输出中包含明确的完成信号就停止,否则继续,同时设一个硬性上限兜底。
3.2 循环终止条件的精细设计
终止条件是Loop层最核心的设计点。我总结了几种常见的终止信号:
- 显式完成信号:模型明确输出“任务完成”或者返回最终答案
- 无工具调用:模型这一轮没有选择任何工具,说明它认为不需要更多信息了
- 达到最大轮次:硬性上限,防止无限循环
- 总超时触发:整个任务的时间预算耗尽
- 连续错误:连续多轮工具调用失败,继续下去没有意义
这几种条件要组合使用,不能只依赖一种。我一般会同时设置显式完成信号、最大轮次和总超时三个条件,任何一个满足就终止循环。
这里有个细节:最大轮次设多少合适?我的经验值是,简单问答类任务3到5轮,中等复杂度的任务8到12轮,需要多步推理的复杂任务15到20轮。当然这只是一个参考,具体要根据实际场景调整。我建议在开发阶段把轮次上限设得宽松一些,观察实际任务的轮次分布,再根据P95值来设定生产环境的上限。
3.3 中间结果的管理策略
Loop每跑一轮都会产生中间结果——模型的思考过程、工具调用的输入输出、观察到的信息。这些结果怎么管理,直接影响Agent的上下文质量和token消耗。
最简单的做法是全量保留,每一轮都把之前所有结果拼进提示词。这样做的问题是token消耗随轮次线性增长,跑到后面提示词长得吓人,不仅贵而且模型注意力容易分散。
我的做法是分层管理:最近两轮的完整结果保留,更早的结果做摘要压缩。摘要只保留关键信息——调用了什么工具、得到了什么核心结论、有没有遇到错误。这样既保留了必要的上下文,又控制了token消耗。实测下来,同样的任务,分层管理比全量保留能省40%左右的token,而且任务完成质量没有明显下降。
还有一种情况需要特别注意:当工具返回的结果特别长时,比如查询数据库返回了几百条记录,直接塞进上下文会瞬间撑爆token限制。这时候需要在Harness层做预处理,只返回最相关的部分,或者在Loop层做截断和摘要。
3.4 循环中的状态传递
Loop的每一轮之间需要传递状态。这个状态包括:当前的任务目标、已经收集到的信息、已经尝试过的操作、当前的进展阶段。状态管理做得好,Agent就能在正确的轨道上推进;做得不好,Agent就会反复做同样的事情,或者丢失关键信息。
我用过几种状态管理方案,比较下来最稳妥的是显式状态对象。定义一个状态结构,包含任务描述、已完成步骤、待办事项、关键发现等字段,每一轮循环开始时把状态注入提示词,循环结束时更新状态。这样状态的变化是可控的、可追踪的,出问题了也容易排查。
实操心得:在状态对象里加一个“已尝试操作”列表,记录每一轮调用了什么工具、参数是什么、结果如何。这样当Agent想重复调用同一个工具时,可以在提示词里提醒它“你已经尝试过这个操作,结果是XXX,请考虑其他方案”。这个小技巧能有效减少无效循环。
3.5 循环中的错误恢复
工具调用失败是常态,网络抖动、服务限流、参数错误都可能发生。Loop层需要有一套错误恢复机制,不能一遇到错误就整个任务失败。
我的策略是分级处理:可重试的错误(如网络超时)自动重试,重试次数不超过3次;不可重试的错误(如参数格式错误)把错误信息返回给模型,让它调整参数后重试;连续多轮出现同类错误则终止循环,返回当前已获得的结果和错误说明。
这里有个容易忽略的点:错误信息要足够具体,模型才能做出正确的调整。比如“参数错误”这种模糊的描述,模型看了也不知道怎么改。要具体到“参数limit的值必须是正整数,你传入的是-1”。
4. Graph层:Agent的编排逻辑与多角色协作
4.1 从Loop到Graph:为什么需要编排层
单个Loop能处理的任务是有限的。当任务需要多个角色协作、需要并行处理、需要条件分支时,单靠一个Loop就力不从心了。Graph层就是来解决这个问题的——它把多个Loop组织成一张有向图,每个节点是一个处理单元,边定义了流转规则。
举个例子:一个客服Agent需要先理解用户意图,然后根据意图分流到不同的处理流程——查订单走订单查询流程,退换货走售后流程,投诉走投诉处理流程。每个流程内部可能又是一个或多个Loop。这种结构用Graph来表达就非常清晰。
Graph层的核心价值在于:把复杂的任务拆解成可管理的节点,每个节点职责单一,节点之间的流转规则明确。这样不仅代码好维护,而且每个节点可以独立测试和优化。
4.2 节点设计:单一职责与可组合性
Graph中的每个节点应该只做一件事,并且把这件事做好。我见过有人把意图识别、信息提取、回复生成全塞在一个节点里,结果这个节点又大又难改,改一处影响一片。
好的节点设计应该像乐高积木:每个积木形状简单、接口标准,但组合起来能搭出各种东西。具体来说,每个节点要有明确的输入输出定义,输入是什么格式、输出是什么格式,都要约定清楚。节点内部可以是一个Loop,也可以是一次简单的模型调用,甚至是一个纯规则判断。
节点的粒度怎么把握?我的经验是:如果一个节点的代码超过200行,或者需要修改时经常影响到其他节点,那就说明粒度太粗了,应该拆分。反过来,如果两个节点总是一起出现、从不单独使用,那可能应该合并。
4.3 边与流转规则的设计
边定义了节点之间的流转关系。最简单的边是无条件流转——A节点执行完直接到B节点。但实际场景中更多的是条件流转:根据A节点的输出结果决定下一步走B还是走C。
条件流转的关键是条件表达式的设计。条件要写得清晰、无歧义,而且要考虑所有可能的情况。我踩过的一个坑是条件覆盖不全,某个边界情况没有对应的分支,导致流程卡住。后来养成了习惯:每写一个条件分支,都要问自己“如果条件不满足且没有其他分支匹配,会发生什么”。
还有一种特殊的边是循环边——从后面的节点回到前面的节点。这种边要特别小心,必须配合明确的退出条件,否则容易形成死循环。我一般会在循环边上加一个计数器,超过阈值就强制跳出。
4.4 并行与汇聚的处理
有些任务可以并行处理,比如同时查询多个数据源然后汇总结果。Graph层需要支持并行节点和汇聚节点。
并行节点的实现方式取决于你的运行时环境。如果是异步框架,可以用asyncio.gather同时发起多个任务;如果是多线程环境,可以用线程池。关键是要处理好超时和错误——并行任务中有一个失败了怎么办?是等所有任务完成还是快速失败?
我的做法是:对于相互独立的任务,等所有任务完成,失败的任务返回错误信息,成功的任务返回结果,最后在汇聚节点统一处理。对于有依赖关系的任务,则用快速失败策略,一个失败就取消其他任务。
汇聚节点要把多个并行结果合并成一个。合并逻辑要根据业务需求来定:有时候是简单拼接,有时候需要去重、排序、筛选。我一般会在汇聚节点做一层数据清洗,把并行结果整理成统一的格式再往下传。
4.5 状态在Graph中的传递
Graph中的状态传递比Loop中更复杂,因为涉及多个节点和分支。我的做法是定义一个全局状态对象,所有节点都从这个对象读取输入、向这个对象写入输出。这样节点之间不需要直接通信,通过状态对象解耦。
全局状态对象要设计好结构,按功能分区:任务元信息、各节点的输出、全局变量、错误记录等。每个节点只读写自己关心的部分,避免相互干扰。
注意:全局状态对象不要无限增长。每个节点写入的数据要有清理机制,否则跑长流程时内存会持续上涨。我一般会在节点执行完成后,把不再需要的中间数据标记为可回收,定期清理。
5. 三层架构的协作与生产环境落地
5.1 三层之间的接口设计
Harness、Loop、Graph三层之间的接口要清晰定义,这样才能独立演进。Harness对Loop暴露的是统一的工具调用接口,Loop不需要知道工具内部怎么实现;Loop对Graph暴露的是任务执行接口,Graph不需要知道Loop内部怎么循环。
接口设计的关键是抽象要稳定。我一般会定义几个核心接口:工具调用接口、任务执行接口、状态读写接口。只要这些接口不变,各层的内部实现可以自由调整。
实际写代码时,我习惯用依赖注入的方式把各层组装起来。Graph层持有Loop层的引用,Loop层持有Harness层的引用,但都是通过接口引用而不是具体实现。这样测试的时候可以方便地替换mock实现。
5.2 可观测性建设
生产环境的Agent系统,可观测性是生命线。三层架构每一层都需要有日志、指标和追踪。
Harness层记录每次工具调用的耗时、成功率、错误类型;Loop层记录每轮循环的轮次、token消耗、终止原因;Graph层记录每个节点的执行时间、流转路径、整体任务耗时。这些数据汇总起来,才能定位性能瓶颈和异常点。
我用过的方案是:在每层的关键位置埋点,输出结构化日志,然后用统一的日志收集系统聚合分析。追踪ID贯穿三层,这样从一次任务执行可以追溯到每个工具调用的细节。
5.3 性能优化实践
三层架构的性能优化要分层进行。Harness层的优化重点是减少工具调用的开销——连接池复用、结果缓存、批量调用;Loop层的优化重点是控制循环轮次和token消耗——提示词精简、中间结果压缩、提前终止;Graph层的优化重点是并行化和路径优化——能并行的不串行,能跳过的节点不执行。
我做过一个对比测试:同样的任务,优化前平均耗时12秒、消耗8000 token;优化后平均耗时4秒、消耗3500 token。优化手段主要是:Harness层加了结果缓存,Loop层做了中间结果摘要,Graph层把两个串行节点改成了并行。
5.4 安全与权限控制
Agent系统能调用工具、访问数据,安全问题不能忽视。我的做法是在Harness层做统一的权限校验:每个工具调用都要检查当前上下文是否有权限,敏感操作需要额外的确认步骤。
权限模型我一般用基于角色的访问控制:定义几种角色,每种角色能调用的工具集合不同。Agent在执行任务时携带角色信息,Harness层根据角色决定是否放行。
还有一个容易被忽视的点是输入输出的内容安全。工具返回的结果可能包含敏感信息,需要在Harness层做过滤。模型的输出也可能包含不当内容,需要在返回给用户前做检查。
5.5 常见问题速查
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| Agent陷入死循环 | 终止条件未触发 | 检查循环计数和终止信号 | 增加硬性轮次上限和超时 |
| 工具调用总是失败 | 参数格式不匹配 | 查看工具调用的入参日志 | 在Harness层加参数校验和转换 |
| 任务执行到一半卡住 | 某个节点等待超时 | 检查各节点执行时间分布 | 设置节点级超时和降级策略 |
| token消耗异常高 | 中间结果未压缩 | 分析每轮提示词长度 | 启用中间结果摘要和截断 |
| 并行任务结果丢失 | 汇聚逻辑有bug | 检查汇聚节点的合并代码 | 增加结果校验和补偿机制 |
| 状态数据不一致 | 并发写入冲突 | 检查状态读写时序 | 加锁或改用不可变状态 |
6. 一些踩坑之后的个人体会
这套三层架构不是一开始就设计出来的,而是在实际项目中逐步演化出来的。最开始我只用了一个大Loop,所有逻辑都塞在里面,跑简单任务没问题,稍微复杂一点就各种问题。后来把工具调用抽出来做成Harness,代码清晰了不少。再后来任务复杂到需要多角色协作,才引入了Graph层。
我的体会是:架构分层要适度,不要为了分层而分层。如果你的Agent只做简单的问答,一个Loop加一个轻量Harness就够了,没必要上Graph。但如果你的Agent需要处理多步骤、多角色、有条件分支的复杂任务,那三层架构能帮你省很多事。
还有一个很实际的建议:在开发阶段就把可观测性做好。我早期为了赶进度,日志打得很少,结果线上出问题的时候两眼一抹黑,只能靠猜。后来补日志花的时间,比一开始就好好打日志多得多。
最后分享一个小技巧:在Loop层的提示词里加一句“如果你已经连续两次尝试同一个操作都没有成功,请换一种思路”。这句话看起来不起眼,但能有效减少Agent在同一个坑里反复摔跤的情况。我在多个项目里验证过,加上这句话之后,无效循环的比例明显下降。