☰
Orkas底层重构实践:AI Agent框架的状态管理与并发调度优化
2026/10/1 5:21:57 网站建设 项目流程

先说个背景:Orkas 是我一直在维护的一个 AI Agent 开发框架,最早它只是给内部项目用的一个“半成品脚手架”,后来慢慢被团队推着往前走,成了好几个业务线的公共底座。这次动它,不是心血来潮,也不是为了“重构而重构”,而是它的底层设计已经明显撑不住当前的业务形态了。所以我决定把最核心的那几层重写一遍,包括执行引擎、会话状态管理、工具调用链路和并发调度策略。这篇文章就把这次重构的前因后果、方案取舍、实操细节和踩坑实录完整记录下来。

1. 为什么会走到“底层重构”这一步

1.1 从业务增长聊起:Orkas 当初是怎么诞生的

Orkas 最早立项是在 2023 年年底,当时我们团队在做一批偏自动化的业务机器人,需要把大模型能力和业务系统打通。大家都清楚,那会儿市面上的 Agent 框架还比较初级,要么过于学术化,要么绑定特定厂商的模型服务,拿到真实业务里用总是差口气。于是我们自己撸了一个轻量执行内核,核心就三件事:定义 Agent 的任务循环、封装模型调用、提供工具注册机制。

初版设计非常简单,甚至可以说是“朴素”:一个全局的任务队列,一组常驻 Worker,再加上一个用字典存状态的 Memory 模块。每个 Agent 本质上就是一个带有 system prompt 和工具表配置的对象,跑到完成任务或者到达 max_iterations 为止。当时这套东西跑得挺顺的,因为业务量小,并发请求基本两位数,模型调用频率也不高,偶尔挂一个任务重试一下就能恢复。

也正是因为它够简单,团队接手成本很低,后来几个新项目都是直接把 Orkas 拿过去改一改就上线。最多的时候,内部同时有六个业务线在跑基于 Orkas 的 Agent 服务,每天处理的任务量从几万涨到几十万。也就是从那个阶段开始,我陆续收到来自不同业务方的“奇怪”反馈:有的任务莫名卡死,有的 Agent 上下文串了,有的工具调用返回结果张冠李戴。这些问题的根源,几乎都指向同一个方向——初版架构虽然能跑,但它根本没有为一个“多业务、多租户、高并发”的规模化场景做设计。

1.2 当“能用”变成“能扛”:哪些信号逼我必须动地基

真正让我下定决心重写底层,是连续出现的几个生产事故叠加在一起。

第一个信号是会话状态错乱。Orkas 早期的 Memory 是全局单例的,每个会话通过 session_id 去读写上下文。理论上只要 key 设计得够严谨就不会串,但现实是有些业务方在 tool 回调里直接往 Memory 里塞了自定义字段,又没有做 session_id 隔离,结果两个不同用户的 Agent 在极端时序下读到了彼此的历史消息。这个问题定位了整整一个下午,最后翻日志才发现是全局字典的 key 冲突。能从日志里查出来算运气好,再往后数据量一大,这种问题几乎不可能根因定位。

第二个信号是并发能力见顶。我们的压测环境里,单个 Orkas 实例在 50 路并发时还能勉强维持,一旦超过 100 路,模型调用的超时率就开始飙升,任务队列积压,CPU 被无谓的轮询占满。我仔细看过火焰图,相当一部分开销花在了“等锁”和“空转扫描”上,说明调度层的设计完全不适合这种高吞吐场景。

第三个信号是最致命的:工具调用的可靠性。Agent 业务里工具调用是灵魂,但我们的工具执行器是同步阻塞的,一次 HTTP 调用如果对端响应慢,会把整个 Worker 线程占住,后续所有任务都在排队等它。更麻烦的是,工具返回的数据没有统一的 schema 校验,模型经常把字段类型解析错,导致下游逻辑报错。

这三个信号加在一起,我基本可以确定,Orkas 需要的不是“打补丁”,而是从执行模型、状态存储、工具链路到调度策略的系统性重写。换句话说,地基的承重结构已经出了隐患,局部加固已经没有意义了。

2. 重构前的全局评估与方案选型

2.1 先盘点家底:现有架构到底烂在哪

动手之前,我把 Orkas 的代码完整读了一遍,画了一张现状图。整个系统可以分成四层:

  • 接入层:提供 HTTP 接口,接收外部请求,创建会话、触发 Agent 运行。
  • 调度层:维护全局任务队列,Worker 从队列里取任务执行。
  • 执行层:负责 Agent 的循环推理,调用 LLM、解析输出、触发工具。
  • 支撑层:包含 Memory、工具注册表、配置中心、日志与追踪。

看着分层挺清晰,但实际耦合非常严重。最典型的问题是执行层和调度层之间没有明确的接口边界,Worker 里直接 new 了一个 AgentRuntime,这意味着调度器想干预执行过程(比如超时取消、优先级抢占)根本做不到。再比如 Memory 虽然是独立模块,但它的读写散布在各个业务代码里,想替换存储后端就得把所有调用点翻一遍。

这种“伪分层”架构带来的直接后果是:任何一层想动,都得连带改其他层,回归测试成本极高。所以,重构的第一步不是写代码,而是先把边界划清楚:哪些属于内核,哪些属于扩展,哪些必须由框架保证,哪些应该开放给业务方自定义。

2.2 重写 vs 改造:我为什么最终选择重构

团队里有人提过,既然能跑,不如在上层加一些补偿机制,比如重试、熔断、降级,这样改动面小,上线风险也可控。这个方案我认真考虑过,但最后还是否决了,原因有三点。

第一,补偿机制解决不了“根因类问题”。以会话状态错乱为例,如果根因是存储层的隔离设计缺陷,那么加再多的重试和熔断也只能降低故障概率,不能消除故障。一旦数据量再上几个量级,问题必然以更恶性的方式复现。

第二,旧架构的复杂度已经接近“修改不动”的临界点。有些函数动辄几百行,内部同时处理模型调用、工具执行和异常归一化,任何一处微小改动都可能引发连锁反应。在这种代码上做增量改造,开发效率很低,而且每次上线都要全量回归,成本远超预期。

第三,新需求已经明确指向“多租户隔离、动态扩缩容、可观测性增强”这三个方向,而这些在旧架构里几乎没有落点。与其花三个月把旧架构补成一个四不像,不如花同样的时间把核心层重写干净,留下一个清晰、可扩展的底子。

最终我的选择是:保留对外 API 语义不变,重写内部实现。业务方不需要改调用方式,但底层的执行、存储、调度全部换成新方案。

2.3 新架构的三大设计原则

重写不是推倒重来,而是带着约束去做。我给新架构定了三条铁律。

原则一:内核与扩展彻底分离。内核只负责 Agent 执行的最小闭环:接收任务、调用模型、执行工具、产出结果。其余一切能力,包括记忆管理、权限校验、流量控制、观测上报,都通过扩展点接入。这样两边的演进可以独立进行,框架升级不必等业务适配。

原则二:一切状态皆可追溯。每个会话的每一步执行都产生不可变的事件记录,从任务接收、模型请求发出、工具调用开始、工具返回结果,到最终答案生成,全部落盘。这样不仅能解决状态串扰的问题,还能让定位问题变得像看回放一样简单。

原则三:并发模型显式化。我们不再依赖隐式的线程池和全局锁,而是引入基于协程的调度模型,每个会话有独立的执行上下文,任务之间的资源争用通过调度器统一管理。高并发的场景下,系统可以明确知道每个 Agent 正在跑什么、等待什么、占用了多少资源。

这三个原则看起来平淡无奇,但真正贯穿到每个模块的设计里,工作量并不小。接下来的几个小节,我会把重写过程中最关键的部分逐一拆开讲。

3. 地基重写的具体实施过程

3.1 核心网关:从单体路由到按需调度的拆分

旧版的接入层是一个简单的 HTTP Handler,收到请求就创建一个 Agent 实例开始跑,没有任何前置检查。新版本我把这个环节重构成“网关 + 路由 + 调度器”三段式结构。

网关层负责做统一鉴权、流量染色和协议适配,这里不涉及任何业务逻辑,入口流量先在这里被标准化成一个内部调用请求。路由层根据请求的 app_id、agent_type 和 session_id 决定把任务送到哪个执行单元,这一步是实现多租户隔离的基础。调度器则负责真正的资源分配,它维护一个优先级队列,每个任务都带着权重和超时时间,Worker 不是“抢任务”,而是由调度器“派任务”。

我举一个具体的例子来说明这层拆分的意义。旧架构里,如果两个业务方同时提交大批量任务,它们共享同一个队列,一个业务方的慢任务会拖慢所有业务方。新架构里,每个业务方在调度器里拥有独立的队列和配额,慢任务只影响它自己的队列,其他业务方完全无感。这在实现上其实不复杂,就是在任务入队时打上 namespace 标签,出队时按 namespace 进行配额控制。

3.2 会话与记忆层:状态管理的一次彻底换代

这一层是重写中改动最大的部分,也是收获最明显的部分。旧版全局字典式的 Memory 被我彻底废弃,取而代之的是一个基于事件溯源的会话存储模型。

所谓事件溯源,简单说就是:不存“当前状态是什么”,而是存“状态是如何一步步变成这样的”。每个会话都维护一个有序事件流,例如user_message、assistant_start、tool_call、tool_result、assistant_end。要恢复会话的当前上下文时,只需从事件流的起点回放,或者按需生成一个快照,然后把增量事件叠加到快照上。

这个设计带来的直接好处有两个。

第一,状态串扰问题从根上消失。每个会话的事件流是独立的 Append-Only 记录,物理隔离,不存在一个共享容器里多个 session 互相覆盖的可能。第二,排查问题变得极其直观。以前出问题只能靠打日志猜,现在直接可以把这个会话的事件流拉出来看,哪一步的 tool 返回了异常,哪一步模型的输出和预期不符,一目了然。

实际存储上,我没有引入特别重的中间件,而是用了一个支持追加写入和范围查询的 KV 存储,以 session_id + seq 作为复合键。每个事件写入时还会附带一个服务端时间戳,方便后续做时序分析。这里有一个具体的参数选择:事件快照我设置成每 20 条增量自动生成一次,快照内容的序列化格式用 MessagePack,相比 JSON 能省差不多 40% 的存储空间,回放时的反序列化速度也更快。

3.3 工具调用与 MCP 集成层的重构

工具调用链路是整个 Agent 系统里最容易出问题的地方,也是这次重构里我花心思最多的一块。旧版的工具执行器是同步调用,一个工具被拉起后,整个 Worker 就被占住了,哪怕对端响应需要 30 秒,其他任务也只能等着。新架构我把工具执行器改造成异步调用模型,配合信号量机制控制并发上限。

具体实现上,每个工具在注册时都要声明自己的执行类型:sync、async或者stream。sync类型适合内部函数计算,比如算个哈希、查一下本地配置,执行时间通常在毫秒级。async类型适合外部 API 调用,执行器会把它包装成一个 Future,交给独立的事件循环去轮询完成状态。stream类型则专门为流式输出设计,比如模型生成过程中逐步返回内容。

不仅如此,新版本里我把工具调用的结果做了一个强制 schema 校验。每个工具必须声明自己的输出结构,比如字段名、类型、是否可空,模型在决定调用工具之前,系统会先把工具的描述和参数说明注入到上下文中,工具返回后,执行器还会对结果做一次结构校验,不合格就直接丢弃并触发一次重新规划。

MCP 集成层的重构同样重要。老版本里 MCP 服务的接入方式是写死的,新增一个 MCP Server 就要改代码。新版本我把它抽象成“连接器”接口,任何 MCP Server 只需要提供一份标准化的协议描述文件,就能动态接入。连接器的生命周期也做了统一管理:空闲超过 5 分钟的连接自动释放,下次使用时重新建立,避免长期占着连接资源。

3.4 并发控制与资源隔离

并发这块,我引入了一个基于令牌桶的全局流量控制模块。每个 Agent 会话在执行前都必须向调度器申请令牌,令牌的数量取决于当前实例的 CPU 核数、可用内存以及模型服务的配额。

这里我给出一个具体的计算参考。假设一台实例配置是 8 核 16G,模型服务的并发上限是 40,那么我们设置的基础令牌池就是 40。每个 Agent 任务在执行时,如果它需要调用外部工具,还会额外申请一个“工具调用令牌”,这个令牌池单独设置,上限为 20。这样设计的原因是,工具调用会占用额外的网络 I/O 和等待时间,如果不单独控制,模型并发和工具并发叠加起来,很容易把实例的资源打满。

资源隔离方面,我按 namespace 做了四级隔离:连接数隔离、令牌配额隔离、队列深度隔离和日志存储隔离。每一级都有独立的监控指标,任何一个业务方出现异常流量,都能在监控面板上第一时间定位到具体 namespace,而不是整个系统一起遭殃。

4. 重构期间的踩坑与排查实录

4.1 迁移数据时冒出来的隐性问题

任何重构都绕不开数据迁移,Orkas 也不例外。我们当时面临的情况是:旧系统的会话状态全部存在 Redis 的 Hash 结构里,需要迁移到新的事件流存储。一开始我写了个简单的扫描任务,把每个 session 的 Hash 读出来,拼装成初始事件写入新库。结果跑到一半发现,有些会话的上下文在迁移过程中丢失了最后几轮消息。

查了半天原因,发现问题出在“读快照和写事件不是原子的”。旧系统在持续写入,我的迁移脚本读到的 Hash 可能是一个中间状态,读完成之后又有新的写入,但那条新写入没有被包含进迁移事件流里。这直接导致迁移后的会话上下文不完整,尤其是在活跃会话上,丢消息的概率更高。

解决方案分两步走:第一步,迁移脚本只处理超过 24 小时没有更新的“冷会话”,这些会话不会被写入干扰,迁移结果可靠。第二步,对于活跃会话,采用双写策略,新写入同时写到旧 Hash 和新事件流,持续一段时间后再整体切换读流量。这个方案看起来笨,但在保证数据不丢的前提下是最稳的。

4.2 并发压测里反复出现的幽灵 Bug

新架构上线前,我做了一轮比较狠的压测:一次性拉起 300 个并发会话,每个会话 10 轮模型调用、每轮带 2 个工具调用,模拟线上真实流量。压测一跑,立刻暴露了一个很有意思的问题——某些任务的响应时间偶尔会突然飙到几十秒,然后又恢复正常。

刚开始我怀疑是模型服务的问题,但去查模型服务端的监控,发现它的响应时间非常平稳,P99 只有 800 毫秒。问题显然出在我们自己的链路里。我开启了全链路追踪,逐个事件排查,最后发现耗时的根源在网络库的连接池设置上。

我们的工具调用执行器使用的是一个共享 HTTP 连接池,默认最大连接数是 50。压测场景里,工具调用频率极高,连接池被瞬间打满,新的请求只能排队等待空闲连接,这就造成了“响应时间尖刺”。问题是,这个尖刺看起来像是偶发的,因为只有连接池耗尽的那一刻才会出现。

修复办法很简单,把连接池的上限从 50 调到 200,并增加一个“等待超时快速失败”的策略,连接排队超过 3 秒直接返回错误,让 Agent 走重规划路径,而不是傻等。修复之后,同样的压测场景下 P99 降到了 900 毫秒以内。

4.3 兼容性灰度方案的设计

底层重构最怕的就是“一次性切换,出了事全完”。我们的做法是设计了一个比较保守的灰度策略。

第一阶段,新老两套架构并行运行,线上流量按 1% 的比例切到新架构,持续观察两天,重点看错误率、延迟和会话状态完整性。第二阶段,把切换比例提升到 10%,同时对新架构的会话事件流做抽样检查,人工比对部分任务的执行结果是否和老架构一致。第三阶段,双跑验证没问题后,把流量切到 50%,并且让新老架构共享同一份会话事件流,这样即使新架构执行异常,还能用老架构兜底。

这个灰度方案总共跑了大概两周时间。过程中发现过一个问题:老架构里某些工具调用的参数封装方式和新的 schema 校验规则不匹配,导致 10% 灰度阶段偶尔出现工具调用失败的告警。解决方式是在工具执行器里增加一套“兼容模式”,当 schema 校验失败时,尝试用宽松模式解析一次,解析成功就记一条告警日志,提示业务方尽快修正工具定义。

5. 验证与运营结果反馈

5.1 性能指标变化

重写完成并全量上线一个月后,我拉了一组指标做对比,数据变化还是比较明显的。

  • 相同压测规模(300 并发),P95 响应时间从 8.2 秒降到 2.1 秒,P99 从 15 秒降到 3.4 秒。
  • 单实例支持的最大并发会话数从约 80 提升到 350,峰值时 CPU 使用率反而下降了约 25%。这要归功于协程调度替代了线程池阻塞等待,减少了无谓的上下文切换。
  • 工具调用成功率从 96.2% 提升到 99.8%,失败的场景主要集中在对端服务真正不可用的情况,不再有因连接池耗尽或超时设置不合理导致的内部错误。
  • 线上会话数据错乱、串记忆的工单数量降为零,之前平均每周至少能收到两起相关投诉。

5.2 稳定性与服务治理改善

除了性能数字,稳定性方面的改善更让我觉得这次重构值了。最直观的一个变化是,故障定位的时间从“小时级”缩到了“分钟级”。

以前排查一个线上 Agent 行为异常,需要在日志里翻各种上下文,还不一定能拼出完整链路。现在直接打开会话事件流,从任务创建到每一步工具调用、模型返回、状态变更全部可视化,基本上一眼就能看出问题出在哪个环节。

另外,新的资源隔离机制让多业务方共享同一套 Orkas 集群变得非常省心。以前是“谁都怕被别人拖垮”,现在是每个业务方都看得到自己的配额和实时水位,出了问题也影响不到别人。已经有业务方主动要求接入新的可观测面板,定期检查自己 Agent 的工具调用效率和令牌消耗情况。

6. 一点后记

重构这一路走下来,我的一个很深的体会是:Agent 框架的底层设计,拼的不是某一个炫酷的技术点,而是“在复杂业务场景下还能不能保持简单和可控”。事件溯源、协程调度、连接器抽象这些概念,单独拎出来都不新鲜,但真正把它们组织成一个自洽的系统,并且经受住真实流量的考验,还是有很多细节需要打磨。

如果你也在维护一个 Agent 框架,或者准备从零搭一个,我建议你从一开始就把状态隔离和可观测性放在最高优先级。这两个东西在业务量小的时候显得“没有必要”,但一旦规模上来,它们就是你的救命稻草。还有一个实用的小建议:重构时尽量保持对外接口不变,哪怕内部改得面目全非,也先让业务方无感迁移。技术上的重构已经够累了,别再让业务方陪着一起折腾。

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

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

立即咨询