做 Agent 工程这两年,我最大的感受是:真正难的不是让模型说出正确答案,而是让 Agent 在真实系统里不掉链子地把事情做完。模型能力由大语言模型决定,可一个 Agent 能不能可靠地调用工具、在失败后自我纠错、按照流程跑完多步骤任务,几乎全看工程架构。现在大家聊 Agent,绕不开三个词:Harness、Loop、Graph。有人把它们当三种框架,有人把它们当三个发展阶段,我自己的理解是,它们是 Agent 工程里相互嵌套的三层架构——Harness 是身体,Loop 是心跳,Graph 是大脑里那张地图。这篇文章不打算讲某个特定框架的 API,而是把这三层拆开,讲清楚每层解决什么问题、生产环境里怎么落地、哪些坑我反复踩过。适合正在做 Agent 项目、或者准备把原型推向生产环境的工程师参考。
1. 拆清三层架构:Harness、Loop、Graph 到底在管什么
1.1 三层职责边界:用“开车”这件事来理解
Harness 字面意思是“马具、挽具”,在工程里可以理解成“把模型这匹马套到系统这辆车上”的那套装置。它负责提供工具、执行环境、权限边界、上下文管理。Loop 则是 Agent 自己跑起来的那个循环:模型收到任务,做出推理,决定调用哪个工具,拿到观察结果,再继续推理。Graph 则负责更高层的流程编排:多个 Agent、多个任务步骤之间谁先谁后、什么条件下走哪条分支。
用开车类比:Harness 是车本身,负责油门刹车、仪表盘、安全带;Loop 是驾驶员“看路-打方向-看仪表-再决定”的连续动作;Graph 是导航地图,告诉你从 A 到 B 走哪条路,遇到堵车换哪条。没有车,路线和驾驶动作都无从落地;没有驾驶循环,地图再精确车也不会动;没有地图,车只能漫无目的地转圈。
1.2 Harness 和 Agent 的区别:别再把运行时当成智能体
经常有人问,harness 和 agent 有什么区别?我一般这么回答:Agent 是“策略 + 状态”的概念,它知道该做什么、做到哪一步;Harness 是“运行时”的概念,它负责让策略变成真实动作。同一个 Agent 逻辑,可以跑在普通 Python 进程里,也可以跑在 Docker 沙箱里,还可以跑在带权限隔离的远程执行器里,工具列表、超时策略、日志上报方式都可能不一样,但 Agent 本身的决策代码不变。这就是分层的好处:换 Harness,不重写 Agent;改 Graph,不重写工具。
1.3 为什么不能只靠一套框架解决
我见过不少团队,一开始用某个 Agent 框架把 demo 跑通了,上线后发现要么工具调用权限收不住,要么循环跑飞了没人管,要么多 Agent 协作只能靠硬编码 if-else。根本原因是把三层混在一起。Harness、Loop、Graph 是三个不同尺度的工程问题:Harness 偏基础设施,Loop 偏运行时控制,Graph 偏流程设计。把它们拆开,每个层可以独立演进,出问题也更好定位。我们在生产环境里实际维护的代码,最外面是一个 graph runner,中间是 loop orchestrator,底层是一组通过 harness 注册的 tool adapter,三层之间通过明确的数据结构通信,而不是互相调用彼此的内部方法。
2. Harness 层实战:给 Agent 一副能安全执行动作的“身体”
2.1 Harness 的本质:把模型输出变成真正的系统调用
模型输出是一段文本,无论它说“我要调用 create_order”,还是直接给出 JSON,真实系统只认函数调用。Harness 最核心的工作,就是把这层“文本到调用”的转换做扎实。这看起来简单,实际坑很多:参数类型对不对、字段是否齐全、工具返回值会不会超过模型上下文、调用失败后返回什么错误格式。我们在生产环境里对所有工具入口做了统一包装,模型返回的 tool_call 经过 schema 校验、白名单校验、权限校验之后,才会真正执行。任何一步校验失败,都返回结构化错误,而不是直接抛异常。
2.2 工具注册与参数校验:这是一份需要长期维护的契约
Harness 里的工具注册表就是 Agent 能触达世界的“白名单”。每加一个工具,至少要登记名称、描述、入参 schema、出参 schema、权限级别、超时时间、失败重试策略。用 Python 的话,我习惯用 pydantic 定义入参模型,注册时自动生成给模型看的 JSON Schema。
from pydantic import BaseModel, Field class QueryOrderInput(BaseModel): order_id: str = Field(..., description="订单编号") include_detail: bool = Field(False, description="是否返回明细") @harness.register_tool( "query_order", description="按订单号查询订单基础信息", input_schema=QueryOrderInput, permission="order:read", timeout=5, ) def query_order(order_id: str, include_detail: bool = False) -> dict: # 实际业务逻辑 ...这样做的好处有三个:模型拿到的是标准 schema,不容易编参数;执行前的 pydantic 校验把类型错误挡在业务代码之外;权限字段可以统一走 harness 的鉴权中间件。这段代码里的 register_tool 是抽象写法,换成 LangGraph 的 ToolNode、或者自己写的装饰器都可以,核心是每层职责单一。
注意:工具描述要写“什么条件下该调用、不该调用”,不要只写“能做什么”。模型对工具的选择很大程度依赖描述,描述模糊会出现反复调用错误工具的循环。
2.3 上下文管理:别让 Agent 背着整个会话跑
Harness 层最容易忽略的是上下文生命周期。很多项目把对话历史、工具返回结果、中间状态全部塞进一个数组,模型每次请求都带上全部内容,token 消耗越来越大,最后上下文窗口溢出。我们的做法是三层状态分开:短期记忆只保留最近几轮关键对话;工具结果默认摘要化,只把结构化提取后的结果放回上下文;长期记忆落到外部存储,需要时按语义检索。Harness 负责在每次循环开始前组装“当前模型可见的上下文”,这样可以保证模型看到的信息是克制且相关的,而不是越滚越大的日志。
2.4 自研还是直接用现成 Harness
市面上的 Agent 框架很多,各有各的偏重。有些框架偏 Loop,有些偏 Graph,真正把 Harness 做得完整的反而少。社区里讨论比较多的 deepseek harness(dsh)这类项目,思路就是单独把执行环境、插件加载、工具接入做成一层,方便和不同模型对接。这类项目适合快速搭原型,但生产环境大概率还是要改:要么插件加载方式不满足你的内网部署要求,要么权限模型和公司统一鉴权对不上。我的建议是:不要迷信“开箱即用”,先想清楚你需要的工具白名单、沙箱方式、上下文组装策略,再决定是直接改开源 harness,还是自己写一个两百行的轻量 harness。很多项目其实只需要一个带 schema 校验的工具注册表加一个执行器,没必要引入厚重框架。
3. Loop 层设计:推理-行动循环的终止条件与防死循环
3.1 循环的本质:从 ReAct 到 Loop Engineering
ReAct 是 Agent 最基础的行为模式:Reason(推理)→ Act(行动)→ Observe(观察),然后循环。现在很多人提 Loop Engineering,意思是把这一圈圈的循环当作真正的工程对象来设计,而不是让模型自己一直跑到自然结束。循环设计得好不好,第一个判断标准是:它能不能在多种结果下都正常退出。正常结束、达到最大步数、工具连续报错、用户中途取消、上下文接近上限,每一种情况都要有明确的退出路径。
3.2 终止条件:max_step、超时、置信度与人工确认
我在生产环境里给循环设置了四道闸门:
- 最大迭代步数:默认 15 步,复杂任务 30 步,超过直接进入人工接管流程。
- 单步超时:单次模型调用 60 秒,单次工具调用 10 秒,整体任务 180 秒。
- 静止检测:连续三步没有任何新信息产生(比如重复问同一个问题、重复调用同一个工具),主动终止。
- 人工确认点:涉及写操作、花钱、发消息等动作,必须先暂停等确认。
四道闸门不是简单的“到了就报错”,而是返回一个结构化终止原因。Loop 层记录终止原因,Graph 层根据原因决定是重试、换分支还是交人工。终止原因本身也是可观测性数据的重要来源。
3.3 工具失败后的回退与重试:指数退避必须按层做
工具调用总会失败。网络抖动、依赖服务返回 500、参数被上游拒绝,失败原因不同,重试策略也不同。我们在 harness 层管“单次工具调用的重试”:对可重试错误做最多 3 次指数退避,退避间隔 1s、2s、4s;对不可重试错误直接返回结构化错误。在 loop 层管“整轮重试”:如果 Agent 发现工具调用失败,可以换一个工具、换一个参数、或者改变策略,而不是机械重试同一个调用。这两层重试不能混。否则容易出现“Agent 以为自己重试了,实际底层已经重试过热”的情况,既浪费又难排查。
3.4 防自引用与上下文爆炸:序列化错误和记忆膨胀一起治
Loop 里最常见的一类问题,是状态对象里带了循环引用。比如内存里某个 MemberInfo 对象包含父对象引用,序列化成 JSON 时直接报 self referencing loop detected。我们在 Agent 的每一步状态落地时都要求“可序列化快照”,任何不能 JSON 序列化的对象不允许进入状态存储。做法是定义统一的状态模型,字段只放字符串、数字、列表、字典;模型实例、数据库连接、文件句柄一律不放进状态。这样既避免序列化错误,也方便后续做日志重放和断点恢复。
上下文膨胀比序列化错误更隐蔽。模型看不到被丢弃的信息,就会反复问同样的问题。我们的处理是在每轮观察之后做一个“信息增量判断”:如果本轮工具结果和上一轮相比没有新增关键字段,就压缩成本轮摘要;连续三轮没有增量,就触发静止检测。上下文从源头控制,比事后剪裁有效得多。
4. Graph 层编排:从单 Agent 到多 Agent 的流程抽象
4.1 什么时候必须上 Graph:线性循环解决不了的场景
Loop 层处理的是“一个 Agent 反复推理并行动”,但真实业务往往不是一个 Agent 从头跑到尾。一个典型的客服任务可能要经历:意图识别、信息收集、方案生成、人工审核、执行、结果汇总。每个阶段可能需要不同模型、不同工具集、不同权限。这种场景如果全塞进一个大 Loop,prompt 会越来越臃肿,工具白名单会越来越大,出错之后很难定位是哪一步的问题。Graph 层要解决的就是这种“多阶段、多分支、多 Agent 协作”的流程抽象。
4.2 图的节点、边与状态:一个可以直接落地的定义
Graph 在我们项目里不一定是严格意义的 DAG,有时候是带环的状态机,但核心元素就三类:节点、边、状态。节点表示一个阶段,可以是一个子 Agent、一个工具调用、一段固定逻辑;边表示流转条件,可以是无条件、按状态字段路由、按上一步终止原因路由;状态是全局上下文,所有节点共享,但每个节点只能声明自己可读写的字段。
一个简单的定义可以长这样:
{ "nodes": ["intent", "planner", "executor", "reviewer"], "edges": [ {"from": "intent", "to": "planner", "condition": "intent.confirmed"}, {"from": "planner", "to": "executor", "condition": "plan.approved"}, {"from": "executor", "to": "reviewer", "condition": "executed"}, {"from": "reviewer", "to": "executor", "condition": "review.rejected"}, {"from": "reviewer", "to": "end", "condition": "review.approved"} ] }这段 JSON 不完整,但表达的意思很明确:图和代码互相独立,改流程不用改处理器本身。Graph runner 按边上的 condition 来判断下一步走到哪里,condition 就是一个纯函数,入参是当前状态,出参是布尔值。把条件写成纯函数,最大的好处是方便测试和回放。
4.3 条件路由与动态子图:局部到全局的设计思路
复杂系统不建议一开始就画一张巨大的全局图。我们参考了图分析里 local-to-global 的思路:先为每个子任务构建局部子图,比如“订单查询子图”、“退款审批子图”,每个子图内部自己管理 Loop 和 Harness;再由全局图把子图作为节点拼接起来。这样单个子图可以独立测试、独立复用,全局图只需要关心子图之间的数据流和触发条件。
脑功能网络分析里也常用 local-to-global 的分层建模:先看局部区域的连接模式,再聚合到全局网络。Agent 编排也是一样,局部子图内部的细节不要过早暴露到全局,全局图只保留子图的输入输出契约。否则一旦业务流程调整,你要改的不只是某条边,而是整张图。
4.4 Graph、Loop、Harness 如何在一次任务里配合
实际执行时,Graph 的每个节点通常会启动一个 Loop,Loop 的每一步又通过 Harness 调用工具。也就是说,三层是嵌套关系,不是并列关系。Graph 知道的是阶段和条件,Loop 知道的是“当前阶段内部怎么一步步逼近目标”,Harness 知道的是“这个工具怎么被安全调用”。我们把每层之间的接口严格限定为数据结构:Graph 看到的是节点状态;Loop 看到的是执行步和终止原因;Harness 看到的是工具调用请求和响应。这样就算某一层内部实现完全重写,另外两层不用动。
5. 生产落地:并发控制、链路追踪、权限隔离与内网部署
5.1 AI Agent 扛并发:从线程到信号量的三级限流
Agent 和普通接口不一样,一次任务可能包含多次模型调用和多次工具调用,单次任务耗时动辄几十秒。如果按普通 Web 服务的思路,每来一个请求开一个线程,服务很快会被拖垮。我们常用的做法是三级限流:
- 入口层:按用户或租户做并发配额,比如每个租户最多同时 20 个 Agent 任务,超出的排队。
- 执行层:用信号量控制整个进程内的并发 Agent 实例数量,避免模型 API 和工具 API 被打爆。
- 工具层:对每个外部工具做独立限流,防止某个慢工具占满所有资源。
异步代码里信号量是最直接的手段:
import asyncio sem = asyncio.Semaphore(20) async def run_agent(request): async with sem: async with asyncio.timeout(180): result = await agent.run(request) return result这段代码虽然简单,但解决了核心问题:并发不再由线程数量决定,而是由信号量配额决定。队列长度、等待时间、实际执行时长都记录下来,方便后续调整配额。
5.2 可观测性:给每一次模型调用和工具调用打上 trace
Agent 排错最难的是“不知道它当时在想什么”。我们给整个系统接了 OpenTelemetry 规范,但没接太重的框架,只是约定几个关键埋点:任务开始生成 trace_id;每个 Loop 步生成一个 span,记录 step 序号、模型请求、模型响应摘要;每次工具调用生成子 span,记录工具名、入参、出参、错误信息;Graph 节点切换时记录当前节点名和触发条件。这样在日志系统里输入一个 trace_id,就能看到完整的决策链。没有这套东西,生产环境的 Agent 几乎没法维护。
提示:模型输入的 prompt 可能很大,不建议全量记录,记录“经过脱敏的摘要”就够了。否则日志系统先被 token 撑爆。
5.3 权限隔离与安全红线:Harness 的边界就是 Agent 的边界
Harness 层是权限控制的最后一道关口。工具注册时声明的 permission 字段,到执行前必须经过统一鉴权。我们坚持几条红线:
- Agent 默认无权限,每个工具单独授权;
- 所有外部 API 调用必须经过统一出口,不允许 Agent 直接发起任意网络请求;
- 文件系统读写限制在特定工作目录,必要时用 Docker 沙箱;
- 敏感配置不放在 prompt 里,也不放在工具入参里,运行时从环境注入。
RPA 类的落地尤其要注意:把 Harness 接到 RPA 工具时,RPA 能点的按钮、能访问的系统,Agent 也就能访问。此时权限模型必须比 RPA 用户更严格,而不是直接复用。我们在一个项目里让 Agent 操作内部系统,专门做了一个“最小动作集”的转换层,把模型可能产生的动作限制到预定白名单内。
5.4 内网部署与 Harness 插件加载:离线环境的三个实操细节
不少团队要把 Agent 部署到内网服务器,模型 API 是内网地址,依赖包也只能离线安装。这个过程最常见的两类问题:一是 Harness 启动时插件加载失败,二是附带技能数据没有同步过去。先讲插件加载失败,报错常长这样:Harness failed to load plugins。排查顺序一般是:
- 插件目录是否被正确挂载,Harness 对插件目录有固定路径要求;
- 插件依赖的 Python 包是否已经离线安装到目标环境,直接在启动日志里看 ImportError;
- 插件入口是否在配置中显式声明,很多插件框架要求入口类在配置里注册,而不是靠目录扫描自动发现;
- 如果报错还带着 web boot 字样,说明启动入口被识别成了 Web 模式,检查启动方式是否和环境变量匹配。
内网部署时,插件依赖建议打包成 wheelhouse,一次性离线安装;技能数据(skill 文件)要作为部署产物和代码一起发布,不能假设外网能实时拉取。另一点是我踩过的坑:内网环境的 Python 版本和本地不一致,某些依赖编译不过,直接导致插件加载失败。解决办法是先在内网目标机上用 pyenv 固定版本,再生成 requirements 锁定文件,不要只依赖跨平台的 wheel。
6. 常见报错与排查思路速查
6.1 “Agent execution terminated due to error” 的定位顺序
这个报错信息很泛,出现时先看三层各自的状态。第一,看 Harness 层有没有调用记录,如果工具调用根本没有发生,说明执行环境没起来,重点查插件加载和权限校验。第二,看 Loop 层有没有终止原因,如果是达到最大步数或触发静止检测,说明不是崩溃,是循环设计问题。第三,看模型 API 的原始错误,很多情况是模型端返回了超时或内容校验异常,Agent 执行器把异常包装成了通用错误。不要一上来就怀疑代码逻辑,先看 trace。
6.2 “Harness failed to load plugins” 的常见原因
这个问题在 5.4 节详细说过,这里给个速查表:
| 现象 | 原因 | 排查手段 |
|---|---|---|
| 启动即报 failed to load plugins | 插件目录未挂载 | 检查工作目录和插件路径 |
| 日志里有 ImportError | 依赖包缺失或版本不匹配 | 离线安装 wheel,锁定版本 |
| web boot 时提示 entry 未激活 | Web 模式启动方式错误 | 检查入口配置和环境变量 |
| 换机器后首次能加载、重启后消失 | 动态生成文件未被持久化 | 把生成产物纳入部署流程 |
6.3 “self referencing loop detected” 状态序列化错误
这个错误在 Python 的 JSON 序列化场景里很常见,本质是对象图里有循环引用。Agent 状态里如果放了 ORM 模型、带 parent 指针的树节点、或者共享的可变对象,一序列化就炸。解决思路不是写自定义 serializer,而是从源头保证状态模型只放基础数据类型。所有跨步骤数据必须经过一层 to_state() 转换,把需要保留的字段复制成新 dict,而不是直接引用原对象。
6.4 Graph 节点状态丢失或者走到死路
图形编排里两类问题出现频率很高。一种是节点状态覆盖:两个节点同时写同一个状态字段,后执行的覆盖先执行的,导致前面节点结果丢失。解决办法是每个节点声明 read_fields 和 write_fields,Graph runner 在节点执行前后做校验。另一种是死路:某条 condition 永远不满足,图停在某个节点不往下走。给每个节点加一个“最大驻留时间”,超过就触发默认兜底边,进入人工或重试。这相当于给图也加了一道闸门。
7. 落地后的经验与建议
7.1 先做减法:从 Loop 和 Harness 起步
如果让我给刚开始做 Agent 工程的人一条建议,我会说:不要一开始就追求完美的 Graph。先写一个线性的 Loop,配合一个简单的 Harness,把工具调用、日志、终止条件跑通,再慢慢把重复出现的分支抽成 Graph 节点。三层架构不是让你一次到位,而是让你知道每一层出了问题该去哪个位置找原因。我自己踩过最大的坑,就是前期为了演示效果直接上多 Agent 图编排,结果循环和权限都没做好,一个任务挂在中间节点上,调试了一整晚。后面把每层接口重新梳理成纯数据结构,问题一下子就清楚了。
7.2 边界清单比模型聪明更重要
最后再分享一个小技巧:Harness 的工具描述里,一定要写“这个工具不能做什么”。模型在边界模糊时更容易触发误调用,你明确写出限制,反而能减少很多无效循环。日志里把“模型说的话”和“Harness 实际执行的动作”分开记录,回放问题时会轻松很多。三层架构看着是概念,落到代码里其实就是接口边界,边界画得清楚,Agent 项目才能真正耐得住生产环境的折腾。