没有把“子代理工作流”做成持久化、可追踪之前,我的项目基本是跑一次看一次,任务多了之后根本说不清某个子任务到底卡在哪、是重试过还是彻底失败。最近团队把普通 subagent 工作流转成了带持久化状态和事件追踪的方式之后,才真正解决了“跑起来容易,维护起来难受”的问题。这篇就围绕“让普通 subagent 工作流持久化、可追踪”展开,把我在实测和落地中整理的思路、步骤、参数和排查经验完整拆一遍。
先说结论:如果你只是单机、单条、几分钟跑完的演示型工作流,不持久化也没什么问题。可一旦涉及多轮子代理、多文件输入、失败重试、断点恢复或跨进程调度,就必须给工作流加一个可写可查的持久化层。这篇文章适合正在用 subagent 编排多步任务、想从一次性脚本升级到可观察工作流的开发者。最值得关注的不是某个现成框架的 API,而是“任务状态怎么存、事件怎么记、失败怎么恢复、链路怎么追踪”这四件事怎么设计。
1. 先搞清楚 subagent 工作流为什么容易“跑着跑着就断了”
很多人第一次用 subagent 跑任务,会觉得这东西和普通函数调用差不多:主代理拆几个子任务,每个子任务交给 subagent 执行,最后拼结果。实际跑下来才会发现,一个真正多步骤的 subagent 工作流,往往包含多次模型调用、工具调用、文件读写、条件判断和分支跳转。任何一步超时、断连、输入格式变化或者返回结构异常,都会导致整条链路中断。
1.1 普通工作流和持久化工作流的核心差异
普通工作流默认生命期是“进程活着,任务就在;进程死了,任务就没了”。它最大的问题是不能在异常中断后接着跑。比如主代理已经派发了 5 个子任务,其中 4 个成功,第 5 个因为超时失败,如果你没有把前 4 个结果落到某种存储里,整个任务只能从头再来。更麻烦的是,从头再来不一定能复现,因为模型输出有随机性,工具调用的副作用也可能已经发生了。
持久化工作流则把“任务本身”和“进程生命周期”解耦。它至少保存三样东西:
- 每个子任务的当前状态,比如 pending、running、success、failed、timeout、canceled。
- 每个子任务的输入、输出和错误信息。
- 任务之间的父子关系和触发顺序。
有这三个基础信息,工作流才能在进程重启后读取状态,跳过已完成节点,只重跑失败节点。这也是“持久化”真正解决的核心问题:不是让运行变快,而是让运行变得可恢复、可审计。
1.2 从“可运行”到“可追踪”还有多大距离
我在实际项目里发现,“可运行”和“可追踪”之间隔着整个调试体验。可运行的工作流只保证输入能到输出,中间过程全靠日志猜。可追踪的工作流则要求每一次子任务调用都有记录,包括:
- 谁发起的这次子任务,是主代理还是另一个 subagent。
- 输入是什么,当时用了哪些参数。
- 输出长什么样,有没有缺失字段。
- 失败时错误栈和上下文是什么。
- 重试了几次,最终结果如何。
听起来像是多记日志,但真正落地时,难点在于这些信息要能和任务 ID 关联起来,并且能按时间线回放。否则日志再多,也只能在出问题时打开文件人肉搜索。
2. 设计方案前,先定义清楚任务模型和事件模型
这一步不做扎实,后面写代码会被一堆临时的字段和状态绕晕。我建议先用一张表把基础模型定义出来,再开始写存储和追踪代码。
2.1 任务模型:状态、父子关系、输入输出
一个可持续追踪的 subagent 工作流,任务模型至少要包含这些字段:
| 字段 | 含义 | 说明 |
|---|---|---|
| task_id | 任务唯一 ID | 建议用 UUID,不要用自增整数 |
| parent_task_id | 父任务 ID | 根任务为空,体现父子层级 |
| workflow_id | 工作流实例 ID | 一次完整的外部请求对应一个 workflow |
| agent_name | 执行该任务的 subagent 名称 | 用于定位是哪个节点出问题 |
| status | 任务状态 | pending / running / success / failed / timeout / canceled |
| input_snapshot | 输入快照 | 记录当前子任务收到的参数,推荐 JSON |
| output_snapshot | 输出快照 | 记录成功后的输出 |
| error_snapshot | 错误快照 | 记录失败时的异常信息 |
| retry_count | 重试次数 | 当前任务已经重试过几次 |
| max_retry | 最大重试次数 | 超过后标记为 failed |
| created_at / updated_at | 创建时间 / 更新时间 | 用于排查超时和性能 |
| attempts | 尝试列表 | 每次运行独立记录,方便查看每次差异 |
注意,attempts和retry_count是两回事。retry_count是一个聚合数字,attempts是每次执行的具体记录。只记录聚合数字,你就看不到“第一次在哪个工具调用失败、第二次为什么换了路径”这些细节了。
2.2 事件模型:每个关键节点都产生一条不可变事件
任务模型解决“当前在哪”的问题,事件模型解决“中间发生了什么”的问题。两条可以同时存在。最简单的做法是设计一张事件表,每条事件都带 task_id、event_type、event_data、created_at。
事件类型建议包括:
- task_created:任务刚创建。
- task_started:任务开始执行。
- tool_call_started:某个工具开始调用。
- tool_call_finished:某个工具调用结束。
- subagent_dispatched:派发了一个子代理任务。
- subagent_received:子代理返回结果。
- task_retrying:任务失败并准备重试。
- task_succeeded:任务成功。
- task_failed:任务失败。
- task_timeout:任务超时。
每一条事件都应该是只追加的,不要更新已经写入的事件。因为追踪工作需要保留现场,如果直接改原有事件,后面排查时看到的都是“改写后的历史”,没法确认真实顺序。追加式事件表虽然会越积越多,但可以按 workflow_id 定期归档。
# 伪代码示例:记录一次子代理派发 def dispatch_subagent(parent_task, sub_agent_name, input_payload): child_task = Task.create( workflow_id=parent_task.workflow_id, parent_task_id=parent_task.task_id, agent_name=sub_agent_name, status="pending", input_snapshot=input_payload, ) Event.append( task_id=child_task.task_id, event_type="subagent_dispatched", event_data={"parent": parent_task.task_id, "payload": input_payload} ) return child_task上面这段只是示例,真正的实现要结合你的存储方案。核心思路是:创建子任务时马上写事件,不要等执行完再补。这样即使子任务还没启动就挂了,也能在事件表里看到它曾经被派发过。
3. 持久化层怎么选:从 SQLite 到 PostgreSQL 的取舍
存储是持久化的基础。选择很简单:少量任务、单机调试用 SQLite;多实例并发、生产环境用 PostgreSQL。不要一上来就上重型分布式存储,大多数 subagent 工作流的瓶颈根本不在存储。
3.1 单机调试:SQLite 足够
如果只是本地验证,SQLite 是最省事的选择。它不需要额外服务,一个文件就能存所有任务和事件。需要注意两点:
- 把
journal_mode设为WAL,减少读写锁冲突。 - 连接时设置超时,避免多个进程同时写入时报 “database is locked”。
SQLite 适合任务量在千级、没有跨主机并发写入的场景。要是你的工作流已经部署在多台机器上,再用 SQLite 文件,写冲突和文件锁问题会非常难受。
3.2 生产环境:先接 PostgreSQL,再考虑附加组件
生产环境建议直接 PostgreSQL。它的事务、索引和并发控制能支撑大多数业务场景。需要重点设计三张表:tasks、attempts、events。索引建议在workflow_id、parent_task_id、status和updated_at上建,因为排查时最常见的查询条件就是按 workflow 查事件、按状态查待重试任务。
不要把事件和任务状态混在一张表里。状态表可以随时更新,事件表只追加。两者混用会导致“想追踪历史的时候,状态已经被覆盖了”的尴尬局面。
I/O 层不建议直接裸露 SQL 到处写,最好封装一个TaskStore接口,至少提供这些方法:
class TaskStore: def create_task(...): ... def update_status(...): ... def append_event(...): ... def get_workflow(workflow_id): ... def list_pending_retries(...): ... def mark_timeout(...): ...接口后面接 SQLite 还是 PostgreSQL,可以后面再替换。先保证调用方只和接口打交道。
3.3 事件归档和清理策略
事件表会越积越大。每个子任务在重试时会产生重复的tool_call_started、tool_call_finished记录。我之前遇到过一个跑了 2000 个任务的工作流,事件表涨到几十万行,查询变慢。
解决办法是分层:热数据保留最近 7 天或最近 100 个 workflow,更早的数据归档到冷表或对象存储。归档时保留完整 JSON,不要只保留聚合指标。
4. 可追踪的核心:任务 ID 链路、上下文透传和重试策略
可追踪不只是记日志,而是让任何一条任务记录都能还原出完整调用链。这里最容易踩坑的是只记录任务自身的状态,没有记录父子关系。
4.1 用 workflow_id + task_id + parent_task_id 建立链路
一旦子任务嵌套超过两层,parent_task_id就变得极其重要。比如主代理派发一个“网页信息提取”子代理,这个子代理又调用了一个“文本清洗”子代理,如果只有 task_id,你只能看到两个孤立的任务,没法知道谁是谁的上游。加上parent_task_id之后,可以递归查出一整棵树。
排查时我一般会先查根任务的 workflow_id,然后用一条 SQL 把整棵任务树拉出来:
SELECT task_id, parent_task_id, agent_name, status, retry_count FROM tasks WHERE workflow_id = ? ORDER BY created_at;拿到列表后,再结合事件表看时间线。先看哪个子任务出现了失败状态,再点开它的 attempts 列表看具体错误。不要在状态表上死磕,事件表里往往有更完整的现场。
4.2 上下文透传:子任务的 output 必须带父任务的标识
subagent 调用中很容易忽略一个问题:子任务的输出要能被父任务正确接收,同时还要能回写到子任务记录里。如果子任务输出的是一个很大的 JSON,建议把它按原始内容存到output_snapshot,不要只存“成功”或者“已调用”。因为后续排查时,只有知道“子任务到底输出了什么”,才能判断父任务为什么对这个结果处理失败。
举一个实际例子:某个子代理返回了一个 Markdown 表格,父代理需要解析表格列数。如果子任务输出被截断或只保存了“成功”标志,父代理解析失败后你压根不知道原始输出长什么样。把完整输出留给未来排查,花费不多但价值很大。
{ "task_id": "task-123", "parent_task_id": "task-001", "input_snapshot": { "source": "https://example.com/page", "format": "markdown" }, "output_snapshot": { "status": "success", "content": "| Name | Age |\n| --- | --- |" } }看这个例子就能明白,追踪不能只看状态,还要看内容。状态可以自动化判断,内容需要人来看。
4.3 重试策略:全局重试和节点重试要分开配置
很多人的做法是给整个工作流设置一个总重试次数,某一步失败就整条链路重跑。这种粗粒度重试在 subagent 工作流里代价极高,因为会导致已经成功的子任务被重复执行,还可能重复调用外部工具,产生重复数据或费用翻倍。
更合理的策略是“节点级重试”:
- 每个子任务单独配置
max_retry。 - 重试只在失败节点上执行。
- 已经成功的同级子任务跳过。
- 父任务只有在必要情况下才重新计算分支。
还要设定“重试间隔”。模型调用超时后的立即重试往往没有意义,太频繁还可能触发限流。我一般先用指数退避:第一次重试间隔 1 秒,第二次 2 秒,第三次 4 秒。具体间隔根据你的实际任务耗时调整,比如一个子任务本身要跑 30 秒,那 1 秒的重试间隔就太短了。
重试时还必须保存attempts信息。每次尝试单独成行,记录开始时间、结束时间、错误消息、是否成功。这样你在事后才能看到“第一次是网络超时,第二次是输出格式错误,第三次才成功”,排查时这种信息比任何日志都管用。
# 伪代码:带尝试记录的节点执行 def run_task_with_retry(task, store): for attempt_number in range(1, task.max_retry + 1): attempt_id = store.create_attempt(task, attempt_number) try: result = execute_subagent(task.input_snapshot) store.mark_attempt_success(attempt_id, result) store.update_task_status(task, "success", output=result) return result except Exception as exc: store.mark_attempt_failed(attempt_id, exc) if attempt_number >= task.max_retry: store.update_task_status(task, "failed", error=exc) else: store.update_task_status(task, "retrying", error=exc) time.sleep(compute_backoff(attempt_number)) raise RuntimeError("unreachable")需要注意的是,update_task_status里如果传了error,要尽量把当前任务的完整错误上下文写入error_snapshot。不要只在日志里打印。
5. 从最小 Demo 到批量生产:环境、参数和验证指标
很多项目在设计时只考虑“能不能跑通”,经常忽略“能不能一直稳定跑通”。一旦要批量化、要部署成服务,就必须按新的标准来验证。
5.1 最小 Demo 应该验证的四个点
我建议先做一个小规模验证,不需要复杂框架,只需验证四个点:
- 创建任务后能通过 task_id 查到任务状态。
- 子任务失败后,父任务能根据持久化数据恢复,并且只重跑失败节点。
- 重启进程后,正在运行或 pending 的任务能恢复到正确状态。
- 事件表能按时间顺序重放出每个关键节点。
这四个点全部通过,再考虑并发、调度和界面化。
5.2 生产环境需要重点关注的参数
批量落地时,下面这些参数需要单独调:
| 参数 | 建议调整方向 | 说明 |
|---|---|---|
| 最大并发数 | 不要一开始就拉满 | 并发太高会导致模型接口限流、外部工具压力过大 |
| 单任务超时时间 | 按实际任务耗时的 1.5 到 2 倍设置 | 太短会误杀正常任务,太长会让故障卡住 |
| 重试次数 | 先设 2 到 3 次 | 重试过多会放大副作用 |
| 队列长度 | 根据内存和存储容量控制 | 批量排队时不要无限堆积 |
| 事件批量写入 | 每 10 到 50 条写一次 | 减少频繁写入压力 |
| 任务输出快照大小 | 限制最大长度 | 防止超大输出撑爆数据库 |
我在实际测试中发现,最容易出错的是“超时时间”和“重试次数”。很多人喜欢把超时设得很短,比如 10 秒,但真实的 subagent 调用经常要 30 秒以上。建议先记录一批真实任务的耗时分布,再设置合理阈值。
5.3 验证成功的标准是什么
判断一个持久化、可追踪的 subagent 工作流是否合格,不能用“今天跑通了”这样的模糊标准。要看这几条:
- 随机杀掉进程后,重启工作流能够恢复到中断位置。
- 手动把某个子任务标记为失败后,再次触发流程,只重跑这个失败节点。
- 事件表可以还原任意一个 workflow 的完整执行时间线。
- 在中等规模数据下,查询单次 workflow 任务的耗时不超过秒级。
- 所有子任务输出都有快照,不依赖日志文件。
如果这些都能满足,这个系统才算真正具备“持久化和可追踪”能力。
6. 常见问题排查链路和边界条件
持久化工作流真正落地时,并不会因为加了数据库就万事大吉。很多问题看起来像存储问题,实际是任务设计问题。
6.1 现象:任务状态一直是 running,但没有实际执行
这种问题最常见,也最容易误判。第一反应是看任务是不是真的在跑,很多情况是进程已经重启了,内存里的执行器没了,但数据库里状态还停留在 running。解决方法是执行器启动时扫描所有status='running'且updated_at超过一定时间的任务,统一标记为interrupted,再决定是重试还是失败。
排查顺序:
- 查看事件表,确认最后一次事件是什么。
- 如果最后一次事件是
task_started,但时间已经超过超时阈值,基本可以判定执行器失联。 - 不要手动把状态改回来,先把执行器日志拉出来,确认是不是进程崩溃、OOM 或外部接口超时。
6.2 现象:重试后结果和第一次不一致
subagent 工作流的重试和普通接口重试不一样。普通接口重试如果幂等,结果基本一致;subagent 的模型调用、工具调用、外部数据采集都可能产生副作用,重试结果会变化。
解决办法是:在重试前检查输入快照是否完整。如果第一次调用时输入里包含了外部临时状态,重试时最好使用同一个输入快照。不要重新从上游拉数据,否则输入变了,失败原因可能也会变。
如果重试后仍然失败,不要无限重试。标记为failed,走人工排查流程。不要让机器一直消耗 token 或外部资源。
6.3 现象:事件表太大,查询变慢
事件表膨胀后,最直接的问题是排查某一次 workflow 时加载太慢。建议建索引时覆盖workflow_id + created_at,并且每次查询只取最近 1000 条事件。历史事件可以通过归档解决。
千万注意,不要在事件表上做大范围更新。事件是追加的,更新会破坏历史。
6.4 边界条件:持久化不是万能兜底
很多人以为加了持久化,任务就不会丢了。实际上,持久化只保证“状态可以被读取”,并不保证“数据不会损坏”。如果子任务已经把结果写入外部系统,但还没来得及更新任务状态,这时进程崩溃,重试就可能造成外部系统的重复写入。这个场景在真实业务里非常常见。
应对办法是给外部操作设计幂等键。比如写文件时带上 task_id,发消息时带上 attempt_id。这样即使重试,也能在外部系统里判断是否已经被执行过。
7. 我落地时踩过的几个坑
最后分享几个我自己在实现持久化 subagent 工作流时踩过的坑,不一定多高级,但确实容易把开发时间耗进去。
第一个坑是“太早做并发”。刚开始我给工作流加了 10 个并发,一跑就大量报错。后来把并发降到 2,先去解决超时和重试问题,稳定之后再逐步提升并发数。结论是:并发问题要等单节点稳定性验证完再碰。
第二个坑是“事件记录写得太少”。最初我只记录 task_succeeded 和 task_failed,排查问题时发现根本不知道中途发生了什么。后来补上了 tool_call 级别的记录,问题定位速度快了很多。记录得细,代价是存储量变大,但这些成本远小于人肉排障的时间成本。
第三个坑是“没有给外部调用做幂等”。一个子代理负责写数据库,重试时重复插入,最终数据出现重复记录。后来给这个子代理加了 request_id 参数,重试时带上同一个 request_id,数据库层做唯一约束,问题才解决。
如果只是学习用途,最简方案是 SQLite + 两张表 + 一个简单的重试循环,就能达到“可恢复、可追踪”的七成效果。要是进入生产环境,建议尽早切换 PostgreSQL,并把事件归档、任务队列、幂等键这些因素前置设计。
真正让 subagent 工作流能长期跑下去的关键,不是某个框架有多智能,而是它失败之后你能不能快速定位、准确重试、安全恢复。先把这个地基打好,再谈复杂的编排和智能调度,会顺很多。