应用系统体系架构-07:控制流与 Harness
Agent 能根据观察调整下一步行动,但自由选择并不等于流程可靠。生成代码、执行测试、分析错误和重新修改之间存在反馈环;多个 Agent 协作还会增加状态合并、交接和资源管理的复杂度。Loop Engineering、Graph Engineering 与 Harness Engineering分别控制循环、表达状态转换,并验证真实执行结果。
完整系统需要同时回答:当前在哪个状态,哪些操作已经完成,为什么进入下一节点,失败后怎样恢复,以及如何验证任务成功。Prompt 提供任务要求,运行时则把这些要求落实为路由、预算、持久化和测试。
Graph 结构
DAG是有向无环图,适合表达具有依赖关系的固定任务。带环的有向图允许边返回已执行过的节点,能够直接表达“生成—测试—修正”的反馈过程。

DAG 调度器也可以为某个节点设置重试,不能把 DAG 理解成只能在全部步骤成功时运行。区别在于:任务重试属于执行策略,而回到另一个业务节点、更新状态并重新决策,需要显式控制流程。简单链路可以保持 DAG,只有需要反馈的部分才引入环。
状态图的三个组成部分是 State、Node 和 Transition:
| 组成 | 职责 | 设计重点 |
|---|---|---|
| State | 保存业务数据与运行进展 | 字段语义、版本、合并规则 |
| Node | 读取状态并完成有限操作 | 单一职责、输入输出、超时 |
| Transition | 决定下一步执行位置 | 条件完整、目标合法、失败出口 |
节点通常只返回发生变化的字段,即 Partial State Update。框架根据相应 Reducer 合并,而不是让每个节点自行复制并修改全部历史。
节点分工
把模型调用、检索、写入和校验全部放进一个 run_agent(),会使测试、权限和故障定位纠缠。可以按职责分成四类节点。
| 节点 | 工作 | 典型结果 |
|---|---|---|
| LLM Node | 分类、规划、生成候选代码 | 意图、方案、Patch |
| Data Node | 读取文档、AST、向量库和业务数据 | 证据、符号、对象状态 |
| Action Node | 应用补丁、运行工具或提交业务变化 | 执行状态与副作用记录 |
| Guard Node | 检查权限、参数、预算和敏感数据 | 允许、拒绝、待审批 |
LLM Node 可以访问模型服务,因此仍涉及网络和计算成本;“不直接修改业务对象”比“完全没有副作用”更准确。Data Node 也需要限制读取范围,Action Node 需要明确影响目标,Guard 则应由确定性规则处理不可绕过的条件。
节点接口越清楚,越容易注入模拟依赖、设置独立超时和定位失败。Graph 负责协调这些能力,而不是替代各能力内部的验证。
条件路由
Conditional Edge读取状态,返回有限路由键,再映射到已注册节点。模型可以给出候选意图,但不能随意生成一个节点名并跳过必需的校验。
1 | from typing import Literal, TypedDict |
每个返回值都应出现在 Path Map 中。未知状态应进入明确的停止或诊断分支,不能默默当成成功,也不能返回 None 后把错误留给后续步骤。
路由函数宜保持简单、可测试,不在判断过程中调用数据库或产生写入。需要外部资料时,先由节点取得数据,再依据状态路由。
循环边界
循环需要步数、时间、费用和进展四类限制。recursion_limit 限制一次图执行的计算步数,不是某个函数的超时,也不能终止已经卡住的外部进程。
1 | from langgraph.errors import GraphRecursionError |
这里的 25 是示例预算,需要依据流程长度设置。节点本身还需超时,长工具任务应支持取消,并确认子进程或容器真的结束。
Action Fingerprint根据工具名和规范化参数识别重复动作。相同错误参数反复出现而没有新观察时,可以提前转入降级或人工处理。但合法轮询也会重复调用,所以不能只看到相同哈希就认为死循环。
1 | import hashlib |
指纹计数还可以结合目标版本、最新观察摘要和持续时间。多 Agent 交接需要额外检查 A→B→A 的往返是否产生进展,避免仅限制单个 Agent 后仍形成全局无限循环。
终止条件
显式 END 表达运行终点,但存在终点不等于一定能到达终点。要说明有限终止,需要保证每条路线有出口,循环消耗有限预算,节点能够完成或被超时终止。
可以用剩余尝试次数建立排名函数:
若每次返回修复环都使 减少,且 时不再回到修复节点,则这一重试环具有有限次数。对于多个嵌套循环,还需要全局预算或更完整的排名函数。
到达 END 可以是成功、失败、取消或待外部处理,状态中应保存原因。它不自动保证所有句柄都已回收,也不证明任务满足业务要求;执行器仍需清理连接、临时文件和容器,并验证最终产物。
状态归约
Reducer决定某字段如何处理节点返回的更新。普通覆盖适合当前状态,列表追加适合事件记录,按标识合并适合多个分支的产物。
1 | from operator import add |
add 对列表执行 old + new。因此返回 {"messages": []} 不会清空旧消息,只会追加空列表。消息更新还可以采用按消息 ID 合并的规则,避免修改同一消息时产生重复。
并发节点更新未配置归约的同一字段时,LangGraph 可能报告冲突,不能笼统解释为任何情况下都由最后完成者覆盖。对并发结果要明确合并与排序规则;列表追加也不自动成为具有幂等、交换律和冲突解决的 CRDT。
重试与降级
Retry Policy适合临时网络故障、限流或短暂不可用。指数退避可以表达为:
并可加入抖动,避免多个请求同时再次访问。参数错误、权限拒绝和确定性业务失败不应无限重试。
代码测试失败通常应转换成状态中的 Observation,再由条件边回到修复节点;模型服务短暂限流则可以由节点级 Retry Policy 处理。二者分别是业务反馈环和调用重试。
重试没有成功时不更新共享状态,并不能证明外部副作用没有发生。请求可能已完成写入,却在回传时超时,因此写操作仍需幂等标识、状态查询和补偿机制。
Checkpoint
长任务可能跨越 HTTP 超时、服务重启和人工等待。Checkpointer保存运行状态与后续位置,使任务不必依赖一个长期存活的请求或内存对象。
需要保存的内容包括业务输入、节点结果、待执行动作、状态版本以及与外部操作的关联标识。检查点通常按运行引擎的步骤边界保存,不应假定每一个内部函数调用都有一个独立快照。
| 后端 | 适用情况 | 需要考虑 |
|---|---|---|
| 内存保存器 | 单进程测试和演示 | 重启后丢失 |
| SQLite | 本地调试、小规模服务 | 文件锁、连接方式、并发写入 |
| PostgreSQL | 多实例服务与长期状态 | 连接池、迁移、事务和备份 |
| Redis | 高频或短生命周期状态 | 持久化配置、TTL 和淘汰策略 |
SQLite、PostgreSQL、Redis 的保存器通常来自相应集成包,需要固定依赖并按要求初始化。选用了数据库,并不自动完成多租户授权、线程安全或全局业务事务。
会话标识
thread_id 标识一条状态历史,checkpoint_id 定位其中的具体版本,checkpoint_ns 区分嵌套图的检查点命名空间。它们用于定位状态,不是访问权限凭证。
1 | config = { |
用户输入的 HTTP Header 不能未经核对就用于读取任意会话。入口应从受信身份取得租户,验证任务归属,再构造 Config。checkpoint_ns 通常由图运行时管理,不能把手工填一个命名空间当成完整隔离实现。
持久化需要 TTL、保留期限、PII 脱敏、Schema 版本与迁移。凭证、数据库连接和沙箱客户端宜放在运行依赖中,不直接序列化进历史状态。
中断恢复
静态断点可在编译时通过 interrupt_before 或 interrupt_after 放到指定节点边界,适合调试固定位置。动态 interrupt() 则在节点内依据具体状态挂起,适合业务审批。
1 | from langgraph.types import Command, interrupt |
Command(resume=...) 由受信审批入口提交,不能把模型自己的“批准”文字作为授权。恢复时中断所在节点可能从开头重跑,所以中断前应避免不可重复副作用,或提供幂等保护。
持久化挂起可以释放正在等待的执行资源,但状态存储、队列和监控仍占用资源。客户端断开也不一定表示任务取消,需要独立任务状态与取消操作。
历史与分支
Time Travel从历史 Checkpoint 继续执行,Fork则在历史位置注入不同状态,形成另一条后续路线。它们用于调试、策略比较和失败分析。
1 | def fork_with_strategy(graph, config: dict, strategy: str) -> dict: |
示例针对同步图;异步节点应使用相应异步接口。update_state 创建新的状态版本,原历史仍可保留,不能把它解释成擦除旧轨迹。分支可以存在于同一 thread 的检查点树中,并非必须更换 thread_id。
更换 thread ID 本身不会复制完整执行位置、待办与历史,跨 thread 迁移需要显式处理。What-If 实验还应固定 Base Commit、依赖、输入和测试环境,并把写操作限制在独立工作区或 Mock 中。
状态回放不等于外部世界回滚。 重放后的节点可能再次调用模型、运行工具或写入业务系统;真实副作用必须单独隔离、去重或补偿。
人工编辑
人工可以修改草稿、参数或 Patch,再恢复任务。update_state() 会按相应 Reducer 应用更新,as_node 说明该更新应视为哪个节点产生,从而影响后续位置。
1 | updated_config = graph.update_state( |
补丁被修改后,旧测试报告和旧审批应失效。不能用 as_node 假装安全审查已经完成,从而跳过真正校验。审计记录应保存操作者、修改前后摘要、原因和父检查点。
长期工作流
LangGraph 适合 AI 状态与反馈环,LlamaIndex Workflows 以事件驱动方式组织检索任务,Temporal/Cadence 适合跨服务、定时等待和持久调度。它们可以结合,而不是必须选择一个框架承包所有层次。
将模型或图运行封装为 Temporal Activity,可以由外层工作流管理重试、定时器和任务恢复。Workflow 的确定性部分读取事件历史,模型与网络等非确定性操作由 Activity 执行并记录结果。
Saga 补偿需要业务定义,例如创建资源失败后删除已创建部分。调度框架不会凭空推导撤销动作,也不自动提供跨所有系统的原子事务。重复 Activity 执行仍需幂等和副作用状态核对。
多 Agent
复杂任务可以把 Triage、Coder、Tester、Reviewer 分开,分别限制上下文、工具和输出。这样能减少单一 Agent 中过多规则与能力的混杂,也更容易独立评测。
但多个 Agent 可能共享同一种模型错误,辩论一致不等于结论正确。应根据任务复杂度、成本和可验证性决定是否拆分,不必把所有流程都改成 Multi-Agent Swarm。
| 拓扑 | 组织方式 | 主要权衡 |
|---|---|---|
| Supervisor | 中央节点分派并汇总 | 控制清楚,主管可能成为瓶颈 |
| Hierarchical | 多级主管分别管理领域 | 适合大任务,增加层级与传递成本 |
| Peer-to-Peer | Agent 直接 Handoff | 交接灵活,需要全局环路和权限限制 |
中央调度不能自动杜绝死锁,对等拓扑也不必然失控。关键是明确谁拥有当前任务、怎样汇总、怎样结束以及怎样限制往返。
Handoff
Handoff把当前控制权交给另一 Agent,区别于“调用专家工具后由主管继续回答”。交接需要目标身份、任务、允许工具、上下文范围和返回约定。
Swarm 中返回另一 Agent 对象的工具函数可以说明这一抽象。下面用与框架无关的数据结构表达相同职责:
1 | from dataclasses import dataclass |
Host 识别交接请求后检查目标是否注册、权限是否允许,以及交接次数是否超限,再切换活动 Agent。OpenAI Agents SDK 等实现提供相应编排与 Handoff 能力,具体 API 与教学原型需要区分。
状态树
主状态保存用户目标、任务 ID、Base Commit 和已核准产物;子状态保存局部检索、AST、草稿和内部循环。子 Agent 结束时,通过 Mapper 只把约定结果写回主图。
直接把所有 Agent 的长消息数组共享,会传播无关信息、过时方案和敏感数据。子状态隔离既减少上下文,也使组件测试具有明确输入输出。
编译后的 Subgraph 可以作为父图节点。状态一致时可直接组合;状态不同则需要输入、输出映射。子图可以继承或配置检查点,但私有状态是否保留、清理以及如何访问,仍由运行与存储策略决定。
协商与通信
Coder 提出补丁,Tester 返回运行证据,Reviewer 检查漏洞和架构约束。遇到 Critical 问题可以退回修复,超过讨论轮数则进入人工裁决。共识只能作为信号,测试和业务断言仍是独立依据。
跨语言或跨服务协作需要统一消息 Schema:
1 | { |
RabbitMQ、NATS、Redis 等可用于消息协作,但能力不同:Redis Pub/Sub 不能直接视为具有持久重投能力的工作队列。需要定义确认、重试、去重、顺序与过期,避免同一消息重复触发操作。
LangGraph 适合显式图状态与恢复,AutoGen 常围绕 Agent 消息与协作组织,CrewAI 提供角色、任务和流程抽象。实际控制能力取决于版本及实现,不能用固定排名替代系统需求分析。
Sandbox
Sandbox限制代码能访问的资源,Harness组织任务、依赖、判分和回放。它们解决不同问题:隔离环境可以降低执行风险,但不证明算法正确;测试通过也不证明代码具有无限制执行权。
| 方案 | 架构特点 | 使用时核对 |
|---|---|---|
| Docker | 容器化环境,通常共享内核 | 用户、挂载、Capability、网络和配额 |
| Firecracker | MicroVM 虚拟化隔离 | 虚拟化支持、镜像与生命周期 |
| E2B | 面向代码执行的云沙箱服务 | SDK、网络策略、超时与数据位置 |
| Modal | 托管计算与执行环境 | 权限、镜像、资源和清理配置 |
| gVisor | 用户态内核接口隔离 | 兼容性、开销及具体部署 |
不能把 gVisor 直接等同于 KVM MicroVM,也不能假设一个 with 语句在所有 SDK 版本中都自动销毁远端资源。执行器应明确创建、准备、运行、采集与清理,并记录清理失败。
执行边界
生成代码应进入独立工作目录或临时环境。源代码可只读挂载,输出使用专门可写目录,默认限制出站网络,环境变量只传必要项。
1 | services: |
这段配置表达隔离思路,并非所有项目都能不加调整直接运行。依赖需要预先准备,测试可能有特定写目录;限制网络不应通过继承宿主凭证来补救。Capability 与只读配置也不构成绝对安全证明。
超时要确认真实进程或容器已停止,输出需要流式预算,测试结束后恢复或销毁环境。临时快照应带项目版本,避免不同任务共享污染状态。
Harness 回放
确定性测试优先用 Mock 替换外部副作用,并录制模型、工具和环境中的非确定性响应。只固定 temperature 或 seed,不能保证跨模型服务和运行环境逐字重现。
1 | from collections import deque |
Fixture 可记录输入 State、模型响应、Tool Call、Patch、Base Commit、环境镜像与测试报告。真实模型质量评估仍需另行调用模型,不能把纯回放的稳定性分数当成新模型生成能力。
评估指标
Agent 评估包含正确性、轨迹效率、成本和工具选择。若共采样 次、正确 次,选取 个候选至少有一个成功的估计为:
1 | from math import comb |
Pass@1 对应 。这是采样成功率估计,不是对单个补丁的确定性证明。测试集、环境和判分规则需要固定,并报告样本量与失败类别。
轨迹效率记录成功任务的步骤、重试和 Handoff;成本区分输入、输出、缓存及工具资源;Tool Precision/Recall/F1 判断能力选择,不能只看最终回复。费用应使用实际模型单价和用量,不能给全部 Token 乘一个永久固定价格。
重构案例
一个完整流程可以由 Triage、Coder、Tester、Reviewer 四类 Agent 组成:先确定范围,Coder 生成受限 Patch,Tester 在独立环境运行测试,Reviewer 核对规则,再由主管选择提交、修复或停止。
连续失败后可以从历史分支选择其他策略,但剩余全局预算仍需减少,不能通过 Time Travel 重置计数继续无限尝试。回放控制层应在当前运行之外协调,避免节点递归调用整条图形成另一层失控循环。
Trace 记录节点、参数摘要、版本、耗时和结果,Fixture 支持诊断回放;CI 检查任务质量、安全、轨迹与成本。是否自动提交或合并,还需要仓库规则、真实授权和针对当前 Commit 的验证。
Graph 表达状态与路径,Loop Guard 限制无进展循环,Checkpoint 保存恢复位置,Sandbox 限制执行范围,Harness 验证任务行为。它们共同构成可测试、可恢复的 Agent 运行体系。

