ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Agent Loop到图编排:智能体应用的生产级重构之路

从Agent Loop到图编排:智能体应用的生产级重构之路

1. 从单智能体循环说起:一个看似能跑却走不远的架构

先交代一下背景。前阵子在某个在线课程平台的实战项目里,我完整经历了一个智能体应用的演进过程:一开始只是个简单的 Agent Loop,跑通了基本对话和工具调用,后来因为业务复杂度上升,不得不把它重构成了基于图编排(Graph Engineer)的生产级系统。这个过程中踩了不少坑,也积累了一些值得分享的经验,整理出来给正在做类似项目的同学参考。

1.1 Agent Loop 的基本形态:就是不断重复“想、做、看”

先说说单智能体循环是什么。很多初学大模型应用的人,第一条路径几乎都会走到 Agent Loop 上。它的本质就是一个循环:

while not done: reasoning = llm.think(context) # 模型根据当前上下文做推理 action = llm.choose_action(reasoning) # 模型决定调用哪个工具 result = execute_tool(action) # 执行工具调用 context.append(result) # 把工具结果追加回上下文

这个循环非常直观,就像一个人接到任务后反复“想一想、动手做、看结果、再想一想”。它支撑起了最早一批智能体原型:让模型自己决定该搜索、该查数据库、该调用 API,然后基于返回结果继续推理,直到它认为任务已完成。

我在项目初期就是这么写的。借助一个简单的外部模型接口,配合几个函数作为工具,就搭出了能回答复杂问题的系统。当时的感觉是:这东西真牛,模型自己规划任务,自己调用工具,完全不需要人参与。

1.2 单循环的致命弱点:一锅炖的上下文与失控的状态

但很快,问题就暴露出来了。最关键的一个问题是:上下文越来越臃肿,模型越来越“迷茫”。

你可以把单 Agent Loop 的上下文想象成一个不断膨胀的聊天记录。每多一次工具调用,就把指令、参数、结果、中间推理全部塞进同一个上下文窗口。刚开始窗口够用,一旦任务链变长(比如一次需要调 5 个以上的工具),整个上下文里全是中间过程,模型极容易丢失核心目标,开始绕圈子、重复调用、甚至编造工具结果。

第二个问题是不可观测、不可干预。所有逻辑都藏在模型的自由发挥里,你只能看到一个循环日志,却很难知道当前走到哪一步、哪一步出了问题。想人为注入一条约束?没有明确的入口。想跳过某一步直接走另一条分支?更做不到。

第三个问题是无法复用。你写了一个能处理“查天气并推荐穿衣”的循环,想再复用一个“查订单并处理退款”的场景,基本等于重写。工具替换、流程变更带来的修改成本极高。

我把这个阶段称为“能跑但不负责任的原型”。

1.3 从循环到图的思维转换:把“模型自由发挥”变成“结构化的流程”

在项目进入到必须面向真实生产环境的时候,我开始意识到一个核心问题:Agent Loop 的本质是“用模型的自由意志驱动流程”,而生产级系统要的是“流程约束模型的自由意志”。

图编排的思路由此引入。简单说,就是把原来那个单循环拆成一个个独立的节点(Node),每个节点只负责一件事,节点之间通过有向边(Edge)连接,形成一张计算图。模型不再是那个从头跑到尾的“独行侠”,而是这张图里的若干个“执行者”之一——在某些节点上发挥推理能力,而在另一些节点上则变成简单的规则判断或 API 调用。

这个思维转换是决定性的,它带来三个变化:

  • 每一步都有了明确的身份。
  • 每一步之间的数据传递是显式的。
  • 整体流程是可以被绘制、检查、局部修改和测试的。

于是,我的改造目标明确了:从“一个会思考的循环”走向“一张可编排的图”,也就是标题里说的 Graph Engineer 工程化路线。

2. 图编排系统的整体设计:核心概念与选型思路

2.1 核心抽象:节点、边、状态容器

图编排系统里,我首先要定义清楚四个基本抽象。

第一个是节点(Node)。每个节点是一个独立的执行单元,输入是结构化的数据,输出也是结构化的数据。节点内部可以是调用大模型、查数据库、调外部 API、跑一段 Python 逻辑,或者仅仅做数据格式转换。关键在于:所有节点对系统来说都是黑盒,只遵循同一个协议。

第二个是边(Edge)。边定义了控制流走向,也就是“这个节点执行完之后,下一步应该去哪个节点”。边可以带条件:满足某个条件走这条边,否则走另一条。

第三个是状态容器(State)。它相当于整张图运行期间的“共享白板”,节点读取自己需要的部分,写入自己产出的部分。状态是图编排的核心,也是和单 Agent Loop 差异最大的地方。

第四个是入口与出口(Entry / Exit),即图从哪里开始执行、在哪里结束。

这四个抽象合在一起,足以表达绝大多数真实业务流程。比如一个普通的订单处理流程,入口是“接收用户问题”,然后走向“意图识别”节点,根据意图结果选择走“订单查询”还是“闲聊应答”,最后汇总结果输出。

2.2 状态管理的两种模式:共享白板与消息传递

状态管理是图编排系统的灵魂。我在设计过程中仔细比较过两种模式。

共享白板(Shared State)模式下,全局只有一个大的状态对象,所有节点都能读写。优点是简单直观,但缺点是节点之间耦合度高——一个节点写错字段,影响的是整张图。另一个难点是并发控制:如果后续引入并行节点,多个节点同时写同一个字段,很容易互相覆盖。

消息传递(Message Passing)模式下,节点只能通过边的数据传递来交换信息,下游节点拿到的数据必须有明确的来源。这样耦合度低、便于追踪,但实现成本稍高,每个节点的输入输出 schema 必须设计得很严谨。

我最终的方案是折中:全局状态容器采用“命名空间隔离”,每个节点只能写入自己专属的namespace,读取时可以读全局,但建议只读自己依赖的那部分。实际效果相当于既保留了白板的便利,又避免了写冲突。

提示:如果你也在设计图编排的状态结构,建议一开始就设计成命名空间隔离,而不是一个扁平的字典。扁平字典在节点一多的时候一定会出问题,别问我怎么知道的。

2.3 控制流与数据流分离的本意

这个设计原则是我在重构中体会最深的。

控制流管的是“顺序与分支”,对应的是图的边和节点之间的跳转逻辑;数据流管的是“数据怎么传、传给谁”,对应的是状态的读写。两者混在一起时,典型症状就是:为了控制分支,你在数据里塞了一个next_step字段;为了传数据,又在控制逻辑里到处判断字段存在与否。最后代码变成一锅粥。

正确的做法是:

  • 每个节点的执行结果返回一个标准结构:状态数据(data)和路由信息(route)。
  • 路由信息交给图执行引擎处理,决定下一步去哪。
  • 状态数据交给状态容器管理,写入对应的命名空间。

这样一来,节点代码里不需要出现任何“下一个节点是谁”的逻辑,完全由图的拓扑结构决定。节点的可复用性瞬间提升了一大截。

2.4 为什么不用传统流程引擎,而要自建图编排

这个问题是面试里经常被问到的,我在做技术方案评审时也被问过:为什么不用现成的消息队列流程编排组件,非要自己写一套图引擎?

我的回答是:传统流程引擎处理的是“确定性的工作流”,节点之间完全是明确的规则,比如审批流程、订单状态机。但智能体场景不同,它有一个环节是模型决策,是概率性的,甚至需要“动态选择下一步”。这本质上不是工作流,而计算图的概念,中间可以有环(循环)、有条件路由、有动态生成的节点序列。

现成的流程引擎通常会在这几块卡住:不支持图结构(只支持线性或树状)、不支持运行时动态路由、对 LLM 这种无确定延迟的外部调用缺乏灵活的重试与降级控制。所以最后我选择了轻量自建,只保留需要的四种抽象,把复杂度控制在合理范围内。

3. 工程化落地:关键模块的设计与实现细节

3.1 节点协议规范化:一张统一的输入输出契约

确定图模型之后,第一件具体的事就是定义节点协议。我最终定下的节点执行接口长这样:

@dataclass class NodeInput: state: GraphState # 当前全图状态 node_id: str # 当前节点ID config: Mapping[str, Any] # 节点自带配置 @dataclass class NodeOutput: data: Mapping[str, Any] # 写入当前节点命名空间的数据 route: str | None # 路由目标,None表示沿用默认边 end: bool = False # 是否结束整图

每个节点继承一个基类,实现execute(input) -> NodeOutput方法即可。这个方法签名我会建议你尽量保持不变,因为它是整个系统稳定的地基。

在设计时我特别强调了两点。第一,节点内部的异常必须自行捕获,不能让异常直接冒泡到执行引擎上层,否则状态容器里缺失的部分会非常难以排查。第二,每个节点的输出数据必须明确声明 namespace,不能随意改全局状态。

3.2 条件路由与循环退出:防止图跑飞的最后防线

有了协议,接下来是路由逻辑。我的实现里,每条边自带一个路由函数,函数输入是当前节点的输出,输出是布尔值或者目标节点名。

# 伪代码示意:条件边 def route_refund_approved(node_output): if node_output.data.get("approved"): return "execute_refund" return "manual_review"

真正复杂的地方在于:图里存在循环(比如“生成内容 → 质量评估 → 不通过则重新生成”),如果不加限制,图会无限循环。我加了三个保险:

  • 最大迭代次数限制(如整个图最多执行 50 个节点,超时就强制结束并报警)。
  • 单节点最大重试次数(一般在 2~3 次以内)。
  • 分支循环内的终止条件,比如质量评估分数达到某个阈值或调用模型次数用尽。

这层逻辑看起来简单,但实际运行起来就是能不能“生产级”的分水岭。在单 Agent Loop 里,模型会在几轮之后自然结束(因为上下文满了),但在图引擎里,循环是显式的,你如果不设退出条件,它会一直空转到预算耗尽。

3.3 可观测性设计:没有链路追踪就谈不上生产级

很多人一开始没有意识到可观测性的重要性,我也是在被生产事故教育之后才补上的。

我在每个节点执行前和执行后都会生成一条结构化日志,用统一的 trace_id 串联;日志里记录:

  • 节点 ID、图 ID、执行轮次。
  • 输入状态的关键字段(可能有敏感数据,必须先脱敏)。
  • 节点执行的耗时。
  • 输出摘要。
  • 路由结果。

更重要的是状态快照机制:每执行完一个节点,就把当前 GraphState 的一个深度拷贝持久化下来。这意味着任意时刻都可以回溯到图中任意步骤,重现现场。我用一个本地存储目录来放快照,文件名格式很简单,直接按trace_id + step_counts组织。

没有这套机制的时候,线上出了诡异问题你只能靠猜;加完之后,基本可以做到每个错误都能精准定位到“某个节点、某次输入、某次输出”上。对排查效率的提升,是几何级别的。

3.4 恢复与重试机制:如何优雅处理模型接口的高延迟与偶发失败

生产环境中,模型接口和外部 API 的失败是家常便饭。我在设计实现时主要做了三件事。

第一,超时控制。每个节点的执行都设置超时时间,模型的单次调用我设在 60 秒,外部 API 视场景而定。超过时间就当作失败处理,而不是无限等待。

第二,重试策略。对于可以重试的错误(如模型接口返回 5xx、超时),采用指数退避 + 抖动的方式重试,最多重试 2 次。不做重试的场景是参数错误、业务逻辑错误,这类错误重试也没用。

第三,节点级容错声明。每个节点可以声明自己是“关键节点”还是“可降级节点”。关键节点失败直接终止整个图;可降级节点失败则返回一个默认结果继续跑,只在日志里记录一条 warning。

def execute_with_retry(node, input, max_retries=2): for attempt in range(max_retries + 1): try: return node.execute(input) except RetryableError: if attempt == max_retries: raise time.sleep(2 ** attempt + random.uniform(0, 1))

按这个思路,在一张包含 12 个节点的图里,模型调用偶发失败对整体成功率的拖累能控制在 2% 以内,这个数字在生产上是可以接受的。

3.5 执行引擎设计:并行与嵌套如何取舍

图引擎的外层实现,我采用的是一个非常朴素的调度循环:维护一个“待执行节点集合”,每轮从中取出可以执行的节点(所有前置节点已执行完成),执行它们,再更新依赖关系,直到没有可执行的节点为止。

def run_graph(graph, initial_state): ready = [graph.entry] executed = set() while ready: node = ready.pop(0) output = execute_node_with_guard(node, initial_state) executed.add(node.id) for edge in graph.get_edges_from(node.id): if edge.is_enabled(initial_state) and edge.target not in executed: ready.append(edge.target) return initial_state

并行方面,我没有一开始就上多线程/多进程,因为并行会把状态管理、日志关联、重试逻辑的复杂度拉高一个量级。第一版我采用单线程顺序执行,先保证正确性;后续如果需要,再做分组并行(把互不依赖的节点放一起并发执行)。

这里有一个关键教训:图的拓扑可以支持并行,但你的状态设计先要能扛住并行。如果状态容器不是线程安全的,并行执行的结果会非常不可预测。所以工程化的顺序一定串行优先,再考虑并行优化。

4. 实操过程:从单 Agent 循环到图编排的完整改造记录

4.1 用案例驱动改造:一个订单处理智能体的故事

空谈设计没有说服力,这里用一个贯穿始终的案例来说明改造过程:某电商平台的订单处理智能体。

最初的单循环版本,模型拿到用户问题后,自己决定调用哪个工具,然后不断循环。典型交互过程是这样的:

用户说:“我要退掉昨天买的那个蓝色耳机,钱什么时候到账?”

模型需要:先查询订单,确认订单里有蓝色耳机,判断是否符合退款条件,如果符合则发起退款,然后告知用户预计到账时间。

单循环版本的问题非常典型。模型查完订单后,把整段订单信息塞进上下文;接着判断退款条件,又塞了一段;执行退款后,再把退款回执塞进去。几轮之后上下文已经很长,模型在后期总结时经常记错订单金额,甚至在重复调用退款接口的边缘试探。

4.2 图拓扑设计:拆成 6 个节点

我的第一步是把流程画成一幅图。这里以结果说明:

  • 节点 A:意图识别(LLM 节点),识别用户是想退货、查物流还是闲聊。
  • 节点 B:订单查询(工具节点),根据用户提到的订单 ID 查出订单信息。
  • 节点 C:退款条件判断(规则节点),根据订单状态和商品品类判断是否允许退款。
  • 节点 D:退款执行(工具节点),调用退款接口。
  • 节点 E:人工审核(人工节点),条件不满足时转入。
  • 节点 F:结果生成(LLM 节点),汇总所有信息生成最终答复。

边把流程串成图:入口 → A → 若意图是退款则走 B;B → C;C 若通过则走 D,否则走 E;D → F;E → F;F → 出口。

这张图看起来并不复杂,但它已经把“模型自由发挥”的范围大大收窄了。模型的判断只发生在意图识别和结果生成两个节点上,其余节点全是规则和工具调用,确定性大大提高。

4.3 分步实施:先把协议定死,再逐个节点落地

有了图拓扑之后,落地顺序是这样的。

先搭节点协议和执行引擎外壳,这一步不依赖任何具体业务。具体而言:

  • 实现 GraphState 状态容器。
  • 实现节点注册机制(装饰器注册或者字典注册都可以)。
  • 实现最基础的无条件边的调度器。

然后逐个实现节点。这里重点是测试策略:每个节点单独执行时需要能脱离整图运行,所以我给每个节点写了一组独立的测试样例输入,断言其输出。只有所有节点都能单测通过时,才把整图组装起来做集成测试。

再接着处理条件路由和循环退出配置。这一步属于引擎的进阶能力,建议不要在第一版就做,先跑通一条主线(无分支、无环),再逐步加分支。

4.3.1 节点实现示例:退款条件判断

这里以退款条件判断节点为例,说明一个“规则节点”长什么样:

class RefundEligibilityNode(BaseNode): def execute(self, input: NodeInput) -> NodeOutput: order = input.state.get("order_service", "order_info") product = order.get("product_info", {}) # 规则判断 is_eligible = ( order.get("status") == "completed" and product.get("category") == "electronics" and order.get("created_at") < some_time ) reason = "" if is_eligible else "订单状态不符合退款条件" return NodeOutput( data={"eligible": is_eligible, "reason": reason}, route="approve" if is_eligible else "reject" )

这个节点的核心价值在于把原本模型自由判断的业务规则,变成了可测试、可审计的确定逻辑。业务方要改规则,直接改这个节点就行,不需要去调整提示词。

4.4 集成调试:从串行主线到完整分支

集成测试阶段我遇到的第一个问题是节点输出与路由函数对不上。比如 C 节点先返回了{"eligible": "yes"},但路由函数里判断的是data.get("eligible") is True,类型不一致导致路由走进了意料之外的分支。

这个问题的教训是:节点输出字段的类型在最初设计时就必须精确到“字段名 + 类型 + 允许的取值”,并在节点基类的 docstring 里写清楚。光靠约定不够,类型检查能让这个问题在测试期暴露。

另一个问题是图引擎的执行顺序假设。我最初的调度器版本假设一个节点只有一个前置依赖,但实际图里存在“汇合”节点(比如 F 节点,要等 D 或 E 完成后才执行)。调度器如果没有处理“多前置依赖都完成才能执行”,汇合节点就会在第一个前置完成后就立刻被执行,导致数据缺失。

修正方式很简单:调度器为一个节点维护一个前置完成计数器,只有全部前置都完成后,节点才进入待执行队列。

4.5 验收指标与效果对比

改造完成后,我用同一批测试用例对比了单循环版本和图编排版本,结论非常直观:

  • 任务完成率从 78% 升到了 94%,关键提升来自“退款判断”从模型决策变成规则决策。
  • 平均响应时间下降了约 40%,因为不再需要模型反复大段推理,很多节点是规则节点,毫秒级完成。
  • 可解释性完全改善:每个请求都能导出一条清晰的节点执行链路。

注意:如果目标是学习研究,单循环完全够用;如果目标是让系统稳定服务,图编排的工程收益远大于初始的开发成本。

5. 常见问题与排查技巧实录

5.1 状态错乱:一个字段被多个节点误修改

这是图引擎最容易出的问题之一。症状是:某个节点的输出数据在后续节点中读出来是对的,但到下下个节点时突然变成了另一个值。

排查思路:先看状态快照。因为我在每一步都做了快照,所以能直接对比两个节点之间是哪个节点改了这个字段。后来发现是一个“通用数据清洗节点”把整个全局状态里所有字符串都做了一遍格式化,导致订单号被改写了。

解决方式也很简单:通用节点只能操作自己命名空间内的字段,不允许遍历全局状态。

5.2 图进入死循环:日志看半天也没发现退出条件

这个问题发生在“内容质量评估”场景,评估节点打分低于阈值就重新生成,但打分偶发波动,导致同一个节点反复执行。

第一版实现里,我只设置了最大节点总次数 50,但没有针对单节点设上限,所以一个生成+评估的环最多可以各跑 25 次,费用烧得很厉害。

正确的做法是:给每个循环环设置独立的“环内最大迭代次数”,并且把“停止条件”和数据更新绑定。比如评估分数连续两次没有提升,就强制终止,转走降级分支。

5.3 模型接口偶发超时导致整个图失败

早期版本里,模型节点超时会直接抛出异常,导致整图终止。排查时发现,用户请求里往往只有一次模型调用失败,但整条链路的 10 个节点不得不全部重跑。

改进方案就是前面提到的节点级容错声明。我建议把模型节点统一标记为“可重试”,外部 API 节点视业务而定,规则节点默认不重试。同时,呼叫模型接口时做一个短暂的等待重试而不是立刻失败,实际效果会好很多。

5.4 调试图编排的通用方法论:找最小复现路径

最后分享一个我从这次改造里学到、也非常通用的排障方法论:不管问题看起来多复杂,都要先构造一个能稳定复现的最小路径。

具体做法是:

  • 只保留出问题的节点链路,把其他节点全部注释掉。
  • 使用固定输入,确保不是随机因素导致。
  • 逐步补回其他节点,观察从哪个节点开始行为发生偏移。

这个方法帮我定位了不少“单独跑没问题,组合起来就出问题”的隐蔽 bug,核心在于把组合爆炸问题降解为单点搜索问题。

6. 生产级系统的最后一块拼图:安全、限流与扩展方向

6.1 从课设到生产:离不开的安全与限流机制

如果你已经走到这一步,很快会发现图编排本身只是骨架,生产级系统还需要加上几层防护。

第一层是输入输出过滤。用户输入和模型输出都要经过一个敏感信息过滤层,防止提示词注入、敏感信息外泄。这个过滤层作为图里的一个前置节点和一个后置节点接入比接入在代码里更优雅:你可以临时增删,甚至根据业务需要配置多个过滤版本。

第二层是限流。图编排系统很消耗模型调用,必须做好两个维度的限流:一是用户维度的 QPS 限制,二是模型接口维度的 token 预算限制。我用了一个简单的令牌桶算法,在入口节点之前统一控制。

第三层是审计。图执行日志、状态快照必须保留一定周期,便于事后审计和追溯。我建议快照最少保留 7 天,日志最少保留 30 天,具体时长按业务合规要求来。

6.2 扩展方向:子图嵌套与可视化编排

图编排的优势在于它的扩展性。我目前已经在探索两个方向。

第一个是子图嵌套。就是把一张已经稳定的图当作一个节点嵌入到更大的图里,比如“订单退款图”作为“客服全流程图”的一个子图。这让系统能够在不同粒度上复用已经验证的流程。

第二个是可视化编排。图结构天然适合可视化:节点拖拽、连线、配置参数。目前很多平台都提供这种交互方式。把图定义序列化成结构化描述,配合一个简单的 Web 编辑器,就能让非技术人员参与流程编排,而不是每次都要改代码。

6.3 个人使用体会:何时该继续用 Loop,何时该上 Graph

最后一个话题,也是很多人真正纠结的问题:我的场景到底需要用图编排吗?

根据这次改造的经验,我的判断标准是这样的:

  • 如果你的智能体只是单轮工具调用,比如写个“查天气 + 推荐出行”的 Demo,单循环完全够了。
  • 如果你的智能体是多轮任务,例如“查订单 → 判断 → 执行 → 反馈”,且涉及多个工具、多个决策点,或者业务要求可解释、可干预、可复用,那么图编排的价值就非常明显了。

我见过不少人在单循环里硬塞复杂业务,最后提示词写了上千字,模型行为还是不稳定,这就是架构选型出了问题。真正需要做的不是优化提示词,而是换一个更适合的架构。

至于我自己,做完这次重构后再回头看,最深的体会是:工程化的本质不是把简单的东西变复杂,而是让复杂的东西变可控。图编排换来的是每一环都可验证、可回溯、可替换,这份确定性,是生产系统最需要的东西。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进