☰
多Agent串行流水线:节点链设计、重试与续跑实战
2026/10/22 5:31:29 网站建设 项目流程

1. 为什么要把一个任务拆成 Pipeline 节点链

1.1 从"一把梭"到"分步走"的认知转变

最早做自动化任务编排的时候,我的做法很粗暴:一个函数从头写到尾,中间调模型、读文件、写结果全塞在一起。跑通了很开心,但只要中间任何一步出问题——比如模型返回格式不对、某个文件读不到、网络抖了一下——整个任务就得从头再来。最要命的是,前面那些已经成功、耗时又长的步骤,全部白跑。

后来任务越来越复杂,一次完整流程可能要串七八个环节:先解析输入、再检索资料、然后分段处理、接着逐段生成、最后汇总校验。这种规模下,"一把梭"的写法基本等于自杀。于是我开始把任务拆成一个个独立的Pipeline 节点,每个节点只干一件事,节点之间用明确的数据结构传递结果。这就是标题里说的"串行流水线"。

所谓多 Agent 串行流水线,本质上是把一个大任务分解成若干个职责单一的 Agent 节点,按固定顺序依次执行,每个节点的输出作为下一个节点的输入。它解决的核心问题是三个:可重试(某一步失败只重跑这一步)、可续跑(中断后从断点继续,不用从头来)、可观测(每一步的输入输出都留痕,出问题能定位)。

这套东西适合谁?如果你正在做需要多步骤协作的自动化任务——比如内容生成流水线、数据处理管道、多阶段审核流程——并且已经被"一崩全崩"折磨过,那这套思路就是给你准备的。哪怕你之前没接触过 Agent 编排,只要会写基本的函数和数据结构,也能跟着落地。

1.2 串行流水线到底解决了哪些真实痛点

我先说几个我踩过的具体场景,你对号入座一下。

第一个场景:一个内容处理任务,前面检索资料花了三分钟,中间生成花了五分钟,最后格式化输出时因为一个字段类型不对直接抛异常。整个任务挂了,前面八分钟全废。如果拆成节点,格式化那步失败,我只需要重跑格式化,前面八分钟的结果都在。

第二个场景:任务跑到一半,机器重启或者进程被 kill 了。没有续跑机制的话,只能从头再来。有了节点链和状态持久化,重启后读一下进度文件,从最后一个成功的节点继续。

第三个场景:某个节点偶尔抽风,返回的结果时好时坏。如果是一整块逻辑,你根本不知道是哪一步的问题。拆成节点后,每个节点的输入输出都存着,一眼就能看出是检索环节召回不准,还是生成环节跑偏了。

这三个痛点,本质上都指向同一个设计原则:把不确定性隔离在最小的执行单元里。节点越小,失败的影响面越小,重试的成本越低,定位问题越容易。这也是为什么我后来越来越倾向于"细粒度节点"而不是"大块逻辑"。

2. 节点链的核心设计思路拆解

2.1 节点粒度怎么定:太粗和太细都是坑

拆节点第一个要面对的问题就是:拆多细合适?

我一开始走极端,恨不得每个函数调用都做成一个节点,结果节点数量爆炸,状态管理、序列化、日志全部变成负担,维护成本比收益还高。后来我又试过另一个极端,把整个流程只拆成"准备"和"执行"两个大节点,结果又回到了"一崩全崩"的老路。

经过几轮调整,我总结出一个比较实用的判断标准:一个节点应该对应一个"语义上完整、且失败后可以独立重试"的最小单元。具体来说,满足下面几条就可以考虑独立成节点:

  • 这一步有明确的输入和输出,输入输出都能被序列化
  • 这一步失败后,重试它不需要依赖前面步骤的"副作用"(比如不需要重新建立某个临时连接)
  • 这一步的耗时或者失败率明显高于其他步骤,值得单独监控
  • 这一步的逻辑相对独立,改动它不会牵连其他步骤

反过来,如果两个操作必须原子性地一起成功或一起失败,那就别拆,放同一个节点里。比如"读取配置并校验",读和校验拆开没意义,因为校验失败了你还是得重读。

我一般会把一个中等复杂度的任务拆成 5 到 10 个节点。少于 5 个,说明拆得不够,重试粒度太粗;多于 15 个,就要警惕是不是过度设计了,很多节点其实可以合并。

2.2 串行 vs 并行:为什么这里坚持串行

标题里明确说了"串行",这不是偷懒,是有意为之。

串行流水线最大的好处是状态简单。每个节点只需要关心"上一个节点给了我什么",不需要处理并发写入、竞态条件、部分失败这些烧脑问题。对于大多数任务编排场景,步骤之间本来就有依赖关系——你不先检索,怎么生成?不先生成,怎么校验?强行并行反而要引入复杂的同步机制。

当然,串行也有代价,就是总耗时是各节点耗时之和。如果你的任务里有大量可以并行的独立子任务,那确实应该考虑在某个节点内部做并行,而不是把整个流水线改成并行。我的做法是:节点之间串行,节点内部按需并行。比如"批量处理"这个节点,内部可以开多个 worker 并行处理,但对外的接口还是一个节点,输出一份汇总结果。这样既拿到了并行的速度,又保住了串行的简单。

2.3 状态持久化:续跑能力的根基

可续跑的关键在于状态持久化。你得把每个节点的执行状态、输入、输出都存下来,才能在中断后恢复。

我试过几种方案。最简单的是存 JSON 文件,一个任务一个目录,每个节点一个文件,文件名带上节点序号和状态。这种方案零依赖,调试的时候直接打开文件看,非常直观。缺点是并发写的时候要小心,以及任务量大了之后文件管理会乱。

稍微正式一点的是用 SQLite,一张表存任务,一张表存节点执行记录。SQLite 的好处是支持事务,状态更新是原子的,不会出现"写了一半崩了"的脏数据。而且查询方便,想看哪个任务卡在哪个节点,一条 SQL 就出来了。

再往上就是 Redis 或者各种数据库,适合分布式场景。但说实话,如果你只是单机跑任务,SQLite 完全够用,别一上来就上重型方案。

我现在的默认选择是:小任务用 JSON 文件,稍复杂的用 SQLite。核心原则是状态存储要足够简单可靠,因为它是整个续跑机制的地基,地基不稳,上面全白搭。

2.4 重试策略:不是所有失败都值得重试

重试这件事,最忌讳无脑重试。有些错误重试一百次也没用,比如输入数据格式错误、必填字段缺失,这种是"确定性失败",重试纯属浪费。真正值得重试的是"瞬时性失败",比如网络超时、临时资源不可用、模型偶发的格式抖动。

我的做法是给每个节点配一个重试策略,包含三个参数:最大重试次数、重试间隔、以及一个判断函数(决定这个错误要不要重试)。

class RetryPolicy: def __init__(self, max_retries=3, base_delay=2.0, retryable_exceptions=(TimeoutError,)): self.max_retries = max_retries self.base_delay = base_delay self.retryable_exceptions = retryable_exceptions def should_retry(self, attempt, error): if attempt >= self.max_retries: return False return isinstance(error, self.retryable_exceptions) def get_delay(self, attempt): # 指数退避,避免雪崩 return self.base_delay * (2 ** attempt)

重试间隔我用的是指数退避,第一次等 2 秒,第二次 4 秒,第三次 8 秒。这样做的原因是,如果失败是因为下游服务过载,密集重试只会雪上加霜,拉开间隔反而更容易成功。这个细节很多人忽略,但对稳定性影响很大。

3. 核心细节解析与实操要点

3.1 节点接口设计:统一契约是协作的前提

节点之间要能串起来,前提是它们遵守同一套接口契约。我定义的节点基类大概长这样:

from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Dict @dataclass class NodeResult: success: bool data: Dict[str, Any] error: str = "" meta: Dict[str, Any] = None class BaseNode(ABC): name: str = "base_node" @abstractmethod def run(self, context: Dict[str, Any]) -> NodeResult: """执行节点逻辑,context 是上游传来的上下文""" pass def validate_input(self, context: Dict[str, Any]) -> bool: """可选的输入校验,默认通过""" return True

这里有几个设计决策值得说。

第一,输入用统一的 context 字典,而不是每个节点定义自己的参数列表。这样做的好处是节点之间解耦,新增字段不影响已有节点,坏处是类型不安全,容易写错 key。我的折中是:context 里放一个约定好的核心字段集合,节点内部再做一次显式的字段提取和校验。

第二,输出统一用 NodeResult 包装,包含 success、data、error、meta 四个字段。success 决定流程是否继续,data 是给下游的数据,error 是失败原因,meta 放一些辅助信息(比如耗时、token 消耗)。统一包装让上层的调度器可以用同一套逻辑处理所有节点。

第三,validate_input 单独抽出来,因为输入校验失败和运行失败的处理方式不同。校验失败通常意味着上游数据有问题,不该重试当前节点,而应该回溯上游;运行失败才考虑重试当前节点。

3.2 上下文传递:只传必要的东西

context 里传什么,是个容易出问题的地方。我见过两种极端:一种是所有中间结果都往 context 里塞,跑着跑着 context 变成几百 MB 的巨型字典;另一种是每个节点只传一个 ID,其他数据都去数据库查,导致节点内部逻辑复杂。

我的经验是:context 只传下游真正需要的字段,大块数据用引用(比如文件路径、数据库 ID)而不是直接塞内容。比如检索节点召回了 100 篇文档,不要把 100 篇全文塞进 context,而是存到临时文件或者数据库,context 里只放一个引用 ID 和必要的元信息(数量、来源等)。下游节点需要全文时,拿着 ID 去取。

这样做的好处是 context 保持轻量,序列化和持久化的成本低,日志也好看。代价是节点内部多了一次数据读取,但这个开销相比 context 膨胀带来的问题,完全可以接受。

3.3 幂等性:重试不产生副作用

重试的前提是幂等——同一个节点用同样的输入跑多次,结果应该一致,且不产生额外的副作用。

这个要求听起来简单,做起来容易翻车。比如一个"写入结果"的节点,如果第一次写成功了但返回时超时,重试就会写两次,产生重复数据。解决办法是给写入操作加一个幂等键,比如用任务 ID 加节点名做唯一约束,重复写入时直接覆盖或者忽略。

再比如一个"调用外部服务"的节点,如果外部服务本身不幂等(比如每次调用都扣费),那重试就要格外小心。这种情况我会在节点内部记录"已调用"标记,重试前先检查,避免重复调用。

注意:幂等性不是节点自己就能保证的,很多时候需要下游系统配合。设计节点时一定要问自己一句:这个节点重试三次,会不会产生三份副作用?如果会,就得想办法加幂等保护。

3.4 超时控制:别让一个节点拖死整条链

串行流水线有个天然风险:任何一个节点卡住,整条链就停了。所以每个节点都必须有超时控制。

我的做法是在调度器层面给每个节点设一个超时时间,节点执行超过这个时间就强制中断,标记为失败,然后走重试或者跳过逻辑。超时时间怎么定?我的经验是取该节点正常耗时的 3 到 5 倍。比如一个节点平时跑 10 秒,超时设 30 到 50 秒。设太短会误杀正常的长尾请求,设太长则失去保护意义。

import signal class TimeoutError(Exception): pass def run_with_timeout(func, args, timeout_seconds): def handler(signum, frame): raise TimeoutError(f"节点执行超过 {timeout_seconds} 秒") signal.signal(signal.SIGALRM, handler) signal.alarm(timeout_seconds) try: result = func(*args) finally: signal.alarm(0) return result

上面这个用 signal 的实现只适合 Unix 单线程环境,如果你在多线程或者 Windows 下跑,得换成基于线程或者进程的方案。核心思路是一样的:给节点执行加一个硬性的时间上限。

4. 实操过程与核心环节实现

4.1 调度器:整条链的大脑

调度器负责按顺序执行节点、处理重试、持久化状态、支持续跑。我写了一个简化版的调度器,核心逻辑如下:

import json import os import time class PipelineScheduler: def __init__(self, nodes, state_dir, retry_policy=None): self.nodes = nodes self.state_dir = state_dir self.retry_policy = retry_policy or RetryPolicy() os.makedirs(state_dir, exist_ok=True) def _state_path(self, task_id): return os.path.join(self.state_dir, f"{task_id}.json") def _load_state(self, task_id): path = self._state_path(task_id) if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) return {"task_id": task_id, "completed": [], "context": {}, "status": "pending"} def _save_state(self, task_id, state): path = self._state_path(task_id) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) os.replace(tmp, path) # 原子替换,避免写一半崩了 def run(self, task_id, initial_context=None): state = self._load_state(task_id) if state["status"] == "done": return state["context"] context = state["context"] or (initial_context or {}) completed = set(state["completed"]) for node in self.nodes: if node.name in completed: continue # 续跑:跳过已完成的节点 if not node.validate_input(context): state["status"] = "failed" state["error"] = f"{node.name} 输入校验失败" self._save_state(task_id, state) raise ValueError(state["error"]) result = self._run_with_retry(node, context) if not result.success: state["status"] = "failed" state["error"] = f"{node.name}: {result.error}" self._save_state(task_id, state) raise RuntimeError(state["error"]) context.update(result.data) completed.add(node.name) state["completed"] = list(completed) state["context"] = context state["status"] = "running" self._save_state(task_id, state) state["status"] = "done" self._save_state(task_id, state) return context def _run_with_retry(self, node, context): attempt = 0 while True: try: return node.run(context) except Exception as e: if not self.retry_policy.should_retry(attempt, e): return NodeResult(success=False, data={}, error=str(e)) delay = self.retry_policy.get_delay(attempt) time.sleep(delay) attempt += 1

这段代码有几个关键点。

状态原子写入:用临时文件加os.replace的方式,保证状态文件要么是旧的完整版本,要么是新的完整版本,不会出现写一半的损坏文件。这个细节在续跑场景下特别重要,因为状态文件损坏意味着整个任务无法恢复。

续跑逻辑:每次启动先加载状态,把已完成的节点放进completed集合,循环时直接跳过。这样即使进程被杀,重启后也能从断点继续。

状态更新时机:每个节点成功后立即持久化,而不是等所有节点跑完再存。这样中断时最多丢失当前节点的进度,前面的成果都保住了。

4.2 一个具体的节点实现示例

光看调度器可能还是抽象,我拿一个"文本分段"节点举例,看看具体节点怎么写:

class TextSplitNode(BaseNode): name = "text_split" def __init__(self, max_chunk_size=1000, overlap=100): self.max_chunk_size = max_chunk_size self.overlap = overlap def validate_input(self, context): return "raw_text" in context and isinstance(context["raw_text"], str) def run(self, context): text = context["raw_text"] if not text.strip(): return NodeResult(success=False, data={}, error="输入文本为空") chunks = [] start = 0 while start < len(text): end = min(start + self.max_chunk_size, len(text)) chunks.append(text[start:end]) if end == len(text): break start = end - self.overlap # 保留重叠,避免语义断裂 return NodeResult( success=True, data={"chunks": chunks, "chunk_count": len(chunks)}, meta={"original_length": len(text)} )

这个节点做了几件事:校验输入、按固定长度切分、保留重叠区、返回结构化结果。重叠区的作用是避免在句子中间硬切导致语义断裂,下游处理时能拿到更完整的上下文。这个参数overlap设多少合适?我的经验是取max_chunk_size的 10% 到 20%,太小起不到衔接作用,太大则冗余严重。

4.3 参数选择背后的计算逻辑

节点里很多参数不是拍脑袋定的,背后有计算逻辑。我拿几个常见的说说。

分段大小:这个取决于下游模型或者处理器的上下文窗口。假设下游模型能处理 2000 个 token,那分段大小就要留出余量,设成 1500 左右,因为还要算上提示词和输出的空间。粗略估算 token 和字符的关系,中文大概 1 个 token 对应 1.5 到 2 个字符,英文大概 1 个 token 对应 4 个字符。所以 1500 token 大约对应 2000 到 3000 个中文字符。这个换算不是精确的,但足够用来定初始值。

重试次数:不是越多越好。假设单次成功率 90%,重试 3 次的总成功率是 1 - 0.1³ = 99.9%。重试 5 次是 1 - 0.1⁵ = 99.999%。从 3 次到 5 次,成功率提升微乎其微,但最坏情况下的耗时翻倍。所以 3 次是个比较平衡的选择。如果单次成功率只有 50%,那重试 3 次也只有 87.5%,这时候该做的是排查为什么成功率这么低,而不是加次数。

超时时间:前面说了取正常耗时的 3 到 5 倍。更严谨一点,可以统计该节点历史耗时的 P99 分位数,超时设成 P99 的 1.5 倍。这样既能覆盖绝大多数正常请求,又能及时掐掉异常的长尾。

4.4 日志与可观测性:出问题能定位

节点链跑起来之后,最怕的就是出问题找不到原因。所以每个节点执行时都要打日志,记录:节点名、开始时间、结束时间、耗时、输入摘要、输出摘要、是否成功、失败原因。

我习惯把日志和状态分开存。状态是给调度器用的,日志是给人看的。日志用结构化的 JSON 格式,方便后续用工具分析。比如:

import logging import json logger = logging.getLogger("pipeline") def log_node_execution(node_name, task_id, start_time, end_time, success, error=""): logger.info(json.dumps({ "event": "node_execution", "task_id": task_id, "node": node_name, "start": start_time, "end": end_time, "duration": round(end_time - start_time, 3), "success": success, "error": error }, ensure_ascii=False))

有了这些日志,任务失败时我可以快速回答几个问题:卡在哪个节点?那个节点跑了多久?失败原因是什么?是第一次就失败还是重试后失败?这些信息对定位问题至关重要。

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

5.1 续跑时状态不一致怎么办

这是续跑机制最容易出问题的地方。典型场景:节点执行成功了,但状态还没持久化,进程就崩了。重启后调度器以为这个节点没跑,重新跑一遍,如果节点不幂等,就产生重复副作用。

我的解决办法是先持久化"执行中"状态,再执行节点,成功后更新为"已完成"。这样即使崩在中间,重启后看到"执行中"状态,就知道这个节点可能跑了一半,需要特殊处理——要么重跑(如果幂等),要么人工介入检查。

# 执行前标记 state["current_node"] = node.name state["node_status"] = "running" self._save_state(task_id, state) # 执行节点 result = self._run_with_retry(node, context) # 执行后更新 state["node_status"] = "done" if result.success else "failed" self._save_state(task_id, state)

多一次写状态的开销,换来的是崩溃后能准确判断节点状态,这个买卖很划算。

5.2 节点间数据格式对不上

这个问题的根源是节点之间没有强制的 schema 约束。上游返回的字段名和下游期望的不一致,跑起来就报 KeyError。

我的应对策略是在每个节点的 validate_input 里做显式校验,缺字段、类型不对,直接在校验阶段报错,而不是等到 run 里面才崩。报错信息要写清楚:期望什么字段、实际拿到什么、是哪个上游节点产生的。这样排查起来一目了然。

更进一步,可以给 context 定义一个 schema,用类似 JSON Schema 的方式描述每个字段的类型和来源。不过这个投入比较大,小项目用显式校验就够了。

5.3 重试风暴:一个节点拖垮下游

如果某个节点失败后疯狂重试,而它的下游是共享资源(比如某个 API),就可能把下游打挂。这就是重试风暴。

防范措施有三个。第一,指数退避,前面说过了,拉开重试间隔。第二,加抖动,在退避时间上加一个随机量,避免多个任务同时重试形成脉冲。第三,熔断,如果某个节点连续失败超过阈值,直接停止重试,标记任务失败,等人工介入。

import random def get_delay_with_jitter(base_delay, attempt): delay = base_delay * (2 ** attempt) jitter = random.uniform(0, delay * 0.3) # 加 30% 以内的随机抖动 return delay + jitter

抖动这个技巧看起来不起眼,但在多任务并发场景下能显著削峰。

5.4 常见问题速查表

问题现象可能原因排查方向解决思路
任务卡住不动某节点超时未设或设太长看日志最后一条是哪个节点加超时控制,缩短超时时间
续跑后重复执行状态持久化时机不对检查状态更新和执行顺序先标记执行中,再执行
重试后数据重复节点不幂等检查节点是否有写操作加幂等键或去重逻辑
节点间字段对不上缺少输入校验看报错的字段名在 validate_input 里显式校验
重试风暴打挂下游无退避或退避太短看重试时间间隔指数退避加抖动加熔断
状态文件损坏非原子写入检查文件内容是否完整用临时文件加原子替换

5.5 几个我踩过的坑

第一个坑:context 里塞了不可序列化的对象。有一次我在 context 里放了一个数据库连接对象,结果持久化的时候直接报错。教训是 context 里只放能被 JSON 序列化的东西,连接、句柄这类资源在节点内部创建和销毁,不要跨节点传递。

第二个坑:节点顺序硬编码。一开始我把节点顺序写死在代码里,后来想调整顺序或者插入新节点,就得改代码。后来改成用配置文件定义节点顺序,灵活多了。节点链的顺序应该是数据,不是代码。

第三个坑:忽略节点的副作用清理。有些节点会创建临时文件或者临时资源,失败重试时如果不清理,会越积越多。解决办法是在节点里用 try/finally 保证清理,或者给每个任务分配独立的临时目录,任务结束后统一清理。

第四个坑:日志打太多导致性能下降。有一次我把每个节点的完整输入输出都打进日志,结果日志文件几分钟就几百 MB,IO 成了瓶颈。后来改成只打摘要和关键字段,完整数据存到单独的文件里按需查看。

6. 节点链的扩展方向

6.1 从串行到带分支的流程

纯串行适合线性任务,但真实场景里经常有分支:某个节点根据条件走不同的后续路径。这时候可以在串行基础上加一个"路由节点",它不处理数据,只决定下一步走哪个节点。

class RouterNode(BaseNode): name = "router" def __init__(self, routes): # routes: {条件值: 下一个节点名} self.routes = routes def run(self, context): key = context.get("route_key") next_node = self.routes.get(key) if not next_node: return NodeResult(success=False, data={}, error=f"未知路由: {key}") return NodeResult(success=True, data={"next_node": next_node})

调度器遇到路由节点时,不按固定顺序走,而是跳到next_node指定的节点。这样就在串行框架里实现了分支,复杂度增加有限。

6.2 节点复用与组合

节点设计得好,是可以复用的。比如"文本分段"这个节点,在内容生成流水线里用,在数据处理流水线里也能用。复用的关键是节点要足够通用,不要和具体业务耦合。

我现在的做法是把通用节点抽成一个库,业务流水线从库里挑节点组装。这样新任务来了,很多时候不用写新节点,拼装现成的就行。节点库的积累是个长期过程,但回报很高。

6.3 人工介入节点

有些环节机器搞不定,需要人工确认。这时候可以加一个"人工审核节点",它把待审核内容展示出来,等人工输入结果后再继续。这个节点在自动化流程里开了一个口子,让流程能处理需要判断力的环节。

实现上,人工节点会把任务状态标记为"等待人工",然后挂起。人工处理完后,通过一个接口更新状态,调度器检测到状态变化后继续执行。这个模式在审核、标注类任务里特别有用。

7. 我个人的一些实操体会

这套节点链方案我用了一年多,最大的感受是:前期多花时间设计,后期省下大量救火时间。一开始我也觉得拆节点麻烦,不如一把梭来得快。但任务一旦复杂起来,一把梭的维护成本是指数级增长的,而节点链的成本是线性增长的。

另一个体会是:状态持久化是重中之重,值得反复打磨。我见过太多任务因为状态管理不严谨,导致续跑时数据错乱。宁可多写几次状态,也不要为了省一次 IO 埋下隐患。

还有一点:别过度设计。节点链是为了解决问题,不是为了炫技。如果任务本身很简单,三步就能跑完,那就别硬拆成十个节点。工具要服务于目标,而不是反过来。

最后分享一个小技巧:给每个节点加一个"干跑"模式,只校验输入输出、不执行实际逻辑。这样在调试流水线结构时,可以快速验证节点之间的数据流是否通畅,而不用等每个节点真正跑完。这个模式在开发阶段能省下大量时间。

这套东西后续还能往很多方向扩展,比如加节点级别的缓存(相同输入直接复用上次输出)、加节点级别的限流(控制对下游的调用频率)、加节点级别的指标采集(统计每个节点的成功率、耗时分布)。但这些都是锦上添花,核心的串行、重试、续跑三件事做扎实了,就已经能解决绝大多数问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询