ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Orkas框架底层重构实战:状态管理、事件驱动与幂等性设计的工程复盘

Orkas框架底层重构实战:状态管理、事件驱动与幂等性设计的工程复盘

搞底层重构这种事,平时没人愿意碰,但只要动过手的人都知道:改地基的工程量往往比在上面盖楼还大,而且一步踏错,整栋房子都得跟着塌。我这次对 Orkas 动刀,就是典型的“自己埋的雷自己拆”。Orkas 是一个面向复杂 Agent 场景的编排框架,核心职责是调度多步骤任务、管理工具调用、串联大模型推理,听起来不算复杂,但一旦要承载真实的多 Agent 协作和长周期任务,底层的状态管理、执行队列、上下文传递就会像雪球一样越滚越大。过去三周,我把它的执行内核几乎重写了一遍,从状态存储到调度模型全部换了思路。这篇就当作一次工程复盘,把重写前后踩过的坑、想过的方案、最后落地的取舍都摊开来讲,希望对准备重构 Agent 框架底层、或者正在纠结要不要重构的朋友有点参考价值。

1. 为什么非重写不可:旧地基的三个慢性病

先说结论:Orkas 上一代版本能跑,能交付,但所有问题都藏在“能跑”的背后。我用了几个月时间观察它的运行表现,把故障、超时、状态错乱这些现象逐步往回追,最后定位到三个根因。

1.1 病一:状态散落在各个回调里

旧版 Orkas 的每个任务都是一个递归链:Planner 生成下一步,执行函数处理一步,把结果塞回上下文,再递归生成下一步。这种写法看着直白,调试也方便,但问题在于——状态全部散落在闭包和回调参数里,没有一个地方能回答“这个 Agent 此时此刻到底在做什么”。

有一次生产环境里一个 Agent 中途停掉了,日志只显示模型推理返回正常,工具调用结果也写进去了,但任务就是不再往下走。排查了半天,发现是某一层回调里对一个可选字段做了空值判断,判断失败后直接 return,但上层根本感知不到这个“提前结束”,整个任务就悬挂在了一个既不是完成也不是失败的中间态。

这种“隐式终止”是回调式状态管理最典型的坑。任务执行的进度只有一条隐形的调用链知道,外部想知道,只能靠自己在每个入口埋日志。而一旦链条里任何一环异常,整条链的状态就失明了。

1.2 病二:编排逻辑长在业务代码里

旧架构里,Agent 的编排逻辑——就是“下一步该做什么”——并非由框架统一调度,而是由每个业务方在自己的代码里通过 if-else、for 循环自己写。Orkas 只提供一个很薄的执行壳,谁调谁写流程。

在单个 Agent 任务里这样还能忍,但在多 Agent 协作场景下就完全失控了。多个 Agent 之间的任务交接、重试策略、错误恢复,全部散落在不同的业务代码里。统一改一个超时策略,要翻好几个仓库;想全局统计“工具调用失败重试率”,根本无从下手,因为重试逻辑每家公司写得都不一样。

说白了,旧 Orkas 干的是“执行器”的活,不是“编排器”的活。这就导致它表面上是框架,实际上只是个执行工具,真正的编排复杂度全被推给了使用方。这次重构的第一个决心,就是把编排这个职责从业务层收回到框架层。

1.3 病三:工具调用是“开盲盒”

旧版对工具调用的处理堪称原始:发起一个工具请求,然后阻塞等待结果,工具崩了就在 catch 里记一条错误日志,没了。超时、重试、重入保护、调用审计这些全是空白。

最让我无法接受的是重试的问题。某些 Agent 场景下工具是外部 API,不具备幂等性,比如发通知、写记录这类操作。旧架构没有幂等保护机制,一旦网络超时触发重试,就可能造成同一个操作被执行两次。这在生产环境里不是“概率问题”,而是“何时发生”的问题——我亲眼见过重复发放通知导致的事故工单。

这三个慢性病相互叠加,造成的结果是:Orkas 能应付 demo,撑不住真实流量。重构的动机很简单——我不想再靠补丁过日子了,与其在外围反复打补丁,不如把核心换掉,让问题在架构层面彻底消失。

2. 重构总纲:先分层,再动手

底层重构最忌讳的事情就是一上来就改代码。没有任何架构规划的“重写”不是重构,是重造轮子。我把前四天完全花在方案设计上,期间产出的并非一行代码,而是一份分层模型文档和一份接口契约草案。

2.1 三平面拆分:控制平面、数据平面、开发平面

我把 Orkas 的新架构拆成三个逻辑平面,每个平面有各自独立的关注点:

  • 控制平面:负责任务调度、生命周期管理、重试、超时、恢复策略,是整个框架的大脑;
  • 数据平面:负责状态存储、上下文访问、记忆读写的统一入口,是整个框架的血液循环系统;
  • 开发平面:面向业务开发者的 API 层,提供装饰器、基类、回调钩子,尽量保持旧版的使用习惯不变。

这个拆法借鉴了服务网格里的控制/数据平面概念。控制平面和数据平面分离的最大好处是:二者可以独立演进。比如未来想换掉底层的存储引擎,数据平面内部改动就行,控制平面和开发平面完全不受影响;同理,调度策略调整也碰不到状态存储的代码。

2.2 新架构的三层执行模型:Planner / Executor / Tool Context

在核心执行链路上,我最终选定了三层模型:

  1. Planner:负责决定“做什么”,接收当前状态 + 目标描述,输出下一步计划;
  2. Executor:负责把计划变成实际动作,包括调用大模型推理、触发工具执行、处理返回结果;
  3. Tool Context:负责把工具调用的入参、出参、错误、元数据全部标准化,形成一条可审计的调用记录。

这三层之间通过事件总线通信,而不是直接函数调用。为什么选事件驱动?因为 Agent 任务天然是异步的:大模型推理要几秒到几十秒,工具调用也随时可能挂起。事件驱动的模式能让各部分并发推进,而不是像旧版那样一路阻塞到底。同时,事件本身可以作为审计日志的天然载体,每个环节发生了什么都有据可查。

2.3 状态分区:Agent 记忆体系的工程化落地

关于 Agent 记忆体系,网上讨论很多,但工程落地的教程极少。Orkas 重构时这件事必须一并解决,因为状态的本质就是记忆。我按时间维度和作用域把状态分成了三区:

  • 短期记忆区(会话级):保存当前任务的上下文窗口,包括每一轮的输入输出、中间推理结果,生命周期和任务绑定,任务结束就清理;
  • 长期记忆区(租户级):跨任务的持久化状态,比如用户的偏好设置、历史任务的摘要,存 Redis 这类缓存存储,设置过期时间;
  • 永久记忆区(知识库级):经过提炼沉淀的领域知识,需要结构化入库,一般是向量库加工后的结果,生命周期几乎无限。

三层记忆在代码层面统一走一个MemoryStore接口,读写都不直接打存储引擎。这样上层开发永远不需要关心“这个数据该放 Redis 还是 MySQL”,框架根据配置自动分流。

这个设计后来验证是对的:因为统一了接口,多个数据源之间的迁移变得极其丝滑。我甚至在某次本地调试时直接把 Redis 换成内存实现,只改了一行依赖配置,上层零改动。如果你在自建 Agent 框架,强烈建议从一开始就把记忆接口抽象出来,否则后面每换一种存储方案都要伤筋动骨。

3. 重写过程的核心机制拆解

方案定完之后进入实现阶段。这段时间最大的感受是:无论方案设计得多周密,真正动手时还是会有新的边界情况冒出来。这一节我把几个最核心的机制实现拆开讲,每一个都在生产环境里被操练过。

3.1 执行上下文贯穿全程

重构后的 Orkas 把“上下文”从隐形的闭包变量提升为显式的一等对象。当前任务的所有状态——计划列表、工具结果、模型返回、变量集合、运行标识——全部存进ExecutionContext,每一步执行器都从这个对象里读取和写入。

这个设计看似粗暴,实则极其好用。它能直接解决旧版“状态散落回调里”的顽疾:只要拿到上下文,整个任务当前的运行位置、历史记录、变量状态一目了然。更重要的是,它为断点续跑提供了基础——上下文可以被序列化落盘,Agent 进程崩溃后可以从最近的检查点恢复执行。

3.2 状态机取代递归:每一步都有明确归属

新内核里,任务流转从递归链改成了显式状态机。状态分五档:PENDING(等待开始)、RUNNING(执行中)、WAITING_TOOL(等待工具返回)、COMPLETED(完成)、FAILED(失败)。每次状态迁移都会发布一个事件,并写入状态存储。

这样做的直接收益是:任务再也不存在“既不是完成也不是失败”的悬挂态了。任何一步出问题,状态机要么往FAILED走,要么转入同名重试分支,绝不允许任务卡在RUNNING里无人问津。针对这一点我还专门写了一个异常兜底守卫:在执行器每轮循环末尾检查状态一致性,发现状态和实际动作不匹配时直接强制归档任务并告警。

3.3 工具调用的幂等与超时治理

这次重构里我最满意的一块,是工具调用的“安全护栏”。新框架给每个工具请求生成一个全局唯一的RequestID,同一业务操作可以携带同一个幂等键发起多次请求,框架层会对已完成的幂等键做去重,直接拦截重复执行。

超时方面,不再用同步阻塞等待,而是为每个工具请求挂一个异步监视线程,到时未返回就自动把任务标记为可重试,并记录一条“timeout”事件。为了应对特殊情况,还支持配置自定义重试次数和退避策略。所有参数都可以在任务级别覆盖,框架提供默认值:超时 30 秒,重试 3 次,指数退避的初始间隔 1 秒。

带幂等保护重试和裸奔重试的区别,好比一个是带安全绳的攀岩,一个是徒手攀岩——后者可能有更刺激的体验,但出事后没有重来的机会。

3.4 事件总线与可观测性

新架构里所有关键动作都向事件总线发事件。事件类型覆盖:任务创建、计划生成、模型调用开始/结束、工具调用开始/结束、状态迁移、重试触发、失败归档。每条事件都带trace_id,把同一个任务在一条链路里串起来。

可观测性的收益非常直接。以前排查问题要看业务日志、框架日志、工具调用日志三份记录手工对齐时间线,现在打开追踪面板,整个任务的每个环节耗时、状态变化、失败原因整整齐齐排好,哪里慢、哪里错一眼可见。这个变化让测试同学也省心了不少,提 bug 时直接附带trace_id,开发拿到就能定位,再也不用反复问“在哪个环境复现的”。

4. 兼容层与迁移:别让历史包袱拖着新框架走

底层重构最现实的问题永远是兼容。框架是给业务用的,业务上一堆跑着的任务不可能停摆等你重写。我在规划重构目标时就明确了一条:新的执行内核和旧的执行内核并行,通过一个兼容层逐步过渡,而不是一次性切换。

4.1 兼容旧接口而不是迁移旧任务

我的做法是编写一个适配器层,让旧的调用风格可以映射到新框架的 API 上。旧任务无需改动自身逻辑,只需要在启动配置里切换执行模式,底层自动走到新内核上。

这一步的成本不容小觑。新旧接口之间存在“模型差异”,旧版里开发者习惯直接操作上下文里的一个小字典,新框架改成了结构化对象,适配层得负责把它们互相转换。例如旧版的任务注册函数现在会被包裹一层,自动套上 Plan-Execute-Tool 的三层模型。因为兼容层兜住了差异,存量业务方可以自由选择迁移时间点,完全不用“周六凌晨强制升级”。

4.2 灰度策略:三阶段推进

兼容层写好后,我没有直接放开全部流量,而是按三阶段切:

  • 第一阶段,放开测试环境和 staging 环境的所有流量,观察一星期稳定性指标;
  • 第二阶段,在生产环境放 10% 的内部工具类任务,重点观察工具调用超时率、任务完成率、状态迁移是否正常;
  • 第三阶段,确认前两阶段稳定后逐渐提高到 50%,然后再冲刺全量。

每个阶段都设了自动回滚标记,任何指标出现明显劣化就自动切回旧内核。这套灰度方案要特别感谢兼容层的存在——如果不是新旧内核可以并存,切流量就没有退路,风险会成倍上升。

4.3 迁移过程中遇到的兼容问题

最典型的兼容问题出在 Tool Context 标准化上。旧版工具函数的返回值没有任何约定,返回字符串、字典、列表都行,新内核要求统一为ToolResult对象,包含status、data、error三个字段。

为了不让所有业务方都改代码,我在适配层加了一个“智能包装器”:检测到返回的是原始类型时自动转成标准结构。这个操作看起来只是格式转换,实际上深有讲究——只有数据统一,上层才能做统一的错误判断、重试分类和数据统计,否则每个工具都得单独适配。经过一个多星期的灰度观察,存量任务在适配层下运行稳定,兼容方案通过了实测验证。

5. 常见问题与排查心得

重构过程中踩坑的数量绝对不少,而且很多坑是设计阶段无法预见的,只能靠线上反馈来逼出来。整理几个最典型的问题,给后来人做个路标。

5.1 高频踩坑记录

第一个坑是事件风暴。事件总线上线后,每个工具调用完成会抛出一条事件,原本这没问题,但异步监视线程和主执行线程同时操作同一个上下文对象,“写入隔离”没做好,导致上下文变量偶尔被覆盖。排查后加上线程锁和不可变快照机制解决。

第二个坑是重试导致的死循环。因为状态机把“工具超时”标记为可重试,某次一个外部 API 持续超时,重试逻辑不断触发,每次都会新建一条事件,事件消费者反复处理,CPU 飙到接近打满。后来才意识到应该加上全局限流器,单任务重试次数无论配置多少,都必须同时受全局并发限制约束。

第三个坑是幂等键的过期问题。为了拦截重复执行,幂等键会保存在存储里,但如果不设置过期时间,存储会被历史键塞满;设置太短,真正的重复请求可能在键过期之后又进来,拦截失效。最后权衡后设置了 24 小时 TTL,并且在大流量工具上单独加大键保留时间。

5.2 快速排查速查表

我把这次重构过程中最常遇到的问题汇总成一张速查表,方便运维和开发快速定位。

症状可能原因排查路径修复建议
任务卡死不动,状态一直 RUNNING上下文死锁或监视线程空转看 trace 里最近一条事件是否入栈加超时兜底,超时自动归档
工具返回正常但任务失败返回格式不兼容,包装器转换异常看工具调用事件里的原始返回结构修正适配层转换逻辑
重试过多导致 CPU 高全局限流配置失效检查限流器日志和事件频率降低并发限制,增加退避时间
同一操作被重复执行幂等键过期或未携带查工具调用事件中 RequestID 是否一致调整 TTL 并检查调用方传参
内存持续上涨上下文快照未释放查看对象存活数量增加结束任务的强制回收
迁移切换到新内核后偶发超时新旧内核连接池未分开管理检查连接池配置独立连接池并做预热测试

5.3 独家避坑技巧

最后分享两个我在重写过程中形成的小习惯,虽然不起眼,但实战效果显著。

第一个习惯是重构期间给所有核心模块加“逻辑开关”。每个新架构特性都做成可配置项,比如状态机是否启用、事件总线是否开关、幂等检查是否开启。这样调试时可以根据需要灵活关闭某些特性来定位问题,不必每次都要改代码重新发布。

第二个习惯是每次状态迁移都输出一条包含前后状态的日志。执行从 RUNNING -> COMPLETED这样的迁移日志多了之后,逐渐就能在脑海里形成一张动态流转图。任何人只要看到一串状态迁移日志,就能判断任务执行路径是否偏离预期。这种细颗粒度的日志对排查问题太有帮助了,我已经把它写成团队的开发规范,后续所有模块都要求遵守。

做底层重构最大的体会,其实是克制。一开始我也动过“趁重写把 API 统统换掉”的念头,但冷静下来后还是选择了最保守的兼容路线。事实证明这个克制是对的——直到全量切换完成,存量业务几乎没有感知。框架代码量翻了将近一倍,但运行却更稳了。我觉得真正有价值的重构,从来不是推倒一切重来,而是把过去的教训沉淀到新的设计里,让以后少走弯路。

RELATED READING

延伸阅读

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