ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent框架底层重构:执行引擎、记忆与编排的彻底解耦

Agent框架底层重构:执行引擎、记忆与编排的彻底解耦

做 Agent 框架时间长了,你会对“重构”两个字产生条件反射式的警惕。Orkas 这次底层重构,我从立项到灰度上线盯了快两个月,核心就一句话:把执行引擎、记忆系统和编排器之间的耦合彻底拆开。干这一行最清楚,Agent 项目前几个月全是“跑通 demo”的兴奋感,等到真上生产、接真实业务,那些藏在地基里的问题才会一个个浮出来。这篇东西不是教科书,是我踩完坑之后的复盘,希望能给正在折腾同类框架的人省点弯路。

要理解这次重构的价值,得先明白 Orkas 之前是怎么个活法。它本来是个 Agent 运行时框架,让大模型驱动的智能体能按既定流程调用工具、读写记忆、做多步推理。功能做了一年多,看起来啥都能干,实际每次加能力都像在旧房子上接新房间,越接越摇。某次线上长任务连续崩了三回,日志里连断点都找不到,我终于下定决心:地基必须重写。

1. 重构前的痛:旧地基为什么撑不住

1.1 Orkas 是什么、底层重构到底重在哪

先交代背景。Orkas 最初的设计目标很简单:让开发者用声明式配置定义一个 Agent,配置里写明它有哪些工具、用什么模型、按什么顺序执行,框架负责把配置跑起来。听起来很像现在市面上那些低代码 Agent 平台,但 Orkas 更偏运行时而非界面,核心是给对执行流程有强控制需求的业务用。

这次“底层重构”不是换个命名规范、整理下目录结构,而是把运行时的三大支柱全拆了重做:执行引擎、记忆系统、编排层。旧版本里这三块是搅在一起的:工具调用直接写在某个回调里,记忆读写散落在各个 Agent 节点中,编排逻辑则靠一堆 if-else 串起来。早期这种“能跑就行”的做法很常见,但问题在于,三块逻辑一旦互相渗入,任何一处的修改都会引发连锁反应。重构的本质,就是把它们之间的边界重新划清楚,各自变成独立的子系统。

1.2 旧架构的五个硬伤

我对着线上日志和旧代码做了整整三周的复盘,把过去几个月反复出现的问题归成了五类,这几类问题直接决定了新架构的设计方向:

  • 执行链路是“串行面条”。旧版把 Agent 执行建模成一个线性步骤列表,第一步成功才走第二步,任何一步因为模型输出格式异常或工具超时挂掉,整条链就断。更麻烦的是断点不明确,日志里根本看不出是哪一步先出的问题。
  • 状态存储是全局单例。所有 Agent 实例共享同一个全局状态对象,并行跑多个任务时,中间变量互相覆盖。有次线上 12 个任务并行,最后 5 个任务的变量全串了,排查了两天才定位到是状态污染。
  • 记忆只有“近因窗口”。旧实现只是在每次对话里拼接最近 N 轮消息,没有真正的短期、中期、长期分层。用户问“三天前我设置的那个参数还在吗”,Agent 直接傻眼。
  • 工具调用没有统一生命周期。每个工具自己管超时、重试、错误上报,同一个 HTTP 工具在 A 场景超时 5 秒,在 B 场景能卡 40 秒,行为完全不可控,线上表现全凭运气。
  • 编排逻辑硬编码在 Agent 内部。子任务拆解、多 Agent 协作这些高级能力,全写在主 Agent 的判断分支里,每加一种协作方式就得动主逻辑,回归成本越来越高。

这五个问题单独看都能忍,凑在一起就是灾难。重构与其说是技术升级,不如说是给项目续命。很多人在决定重构前会纠结:到底是修补还是重写?我的判断标准特别朴素——如果修补成本已经超过重写核心模块的成本,而且模块边界模糊到没人能说清楚,那就别犹豫。Orkas 当时两条都满足。

2. 新地基的设计:事件驱动加有向图执行加分层记忆

2.1 执行引擎:从线性状态机到事件驱动的有向图

重构后 Orkas 的执行引擎不再是一长串步骤,而是一张有向图。图上每个节点是一个原子操作,节点之间的边表示依赖关系与数据流向。Agent 的一次完整运行,就是在这张图上做遍历:从入口节点出发,按拓扑顺序执行,遇到分支走条件边,遇到并行块就触发并发执行。

这个设计最大的收益,是执行流与业务逻辑解耦。以前想给 Agent 加一个“第一次工具调用失败就换备选工具”的逻辑,得写一大段 if-else 分支;现在只需要在图中多画两条边,一条指向失败分支,一条指向成功分支,引擎本身不关心业务语义,它只负责按规则推进。做框架的都知道,一旦执行器和业务逻辑解耦,后续扩展的边际成本会急剧下降。

为了让执行过程可观测,我同步引入了一个全局事件总线。每个节点在执行前、执行后、出错时都会发出事件,比如 node_started、node_completed、node_failed。可观测层订阅这些事件,把耗时、输入输出、上下文变化全部记录下来。后来排查线上问题时,我基本是开着上帝视角在查:某个节点卡了 8 秒是因为模型重试了三次才拿到合法 JSON 输出,某个分支走了异常边是因为上游返回了空数组。这些信息旧版里根本不可能拿到。

2.2 记忆系统:短期、中期、长期三层

记忆是 Agent 从“玩具”走向“工具”的分水岭。旧版只有一个近因窗口,重构后我把记忆拆成了三层,各管一摊:

  • 短期记忆:维持当前对话的上下文,通常是最近 N 轮消息,放内存里,随会话销毁,读写都极快。
  • 中期记忆:跨会话但有时效性的信息,比如用户最近的偏好、某次任务里临时生成的配置参数,落库到向量数据库,带时间戳和衰减权重。
  • 长期记忆:经过摘要化处理的稳定事实,比如用户的身份信息、项目的历史决策,由 LLM 定期从短期和中期记忆里抽取出来,写入 KV 或关系库。

这三层不是各自为政,而是通过“记忆写入管线和检索管线”串起来。写入时,新信息先进短期,再通过异步任务做向量化和摘要,沉淀到中期和长期;检索时,先查短期,再按相关性和时间衰减去中期、长期补全。实测下来,这种分层让 Agent 回答“老问题”时的准确率提升非常明显,用户连续追问“我昨天让你改的那个设置还在吗”这类问题时不再断片。

2.3 工具层:统一的工具生命周期

工具调用这块,重构时我定了一条铁律:所有工具调用必须走同一个执行器。超时、重试、并发、错误归一化,全部在统一执行器里处理,工具本身只负责定义入参、出参和副作用。

配合 MCP 协议,Orkas 的工具不再强制要求内部实现,只要符合 MCP 规范的服务都能接入。这直接改变了工具扩展的成本结构:以前新增一个工具要动框架代码,现在只要写一个描述文件,配置好输入输出 schema,框架就能在运行时完成校验、调用和结果回传。我还特意在 schema 校验上加了“宽松模式”,允许模型输出带多余字段,避免因为一个格式小问题导致整轮对话失败。这个细节看着不起眼,实际上能省下大量因为模型输出抖动带来的无效报错。

3. 实操:核心模块是怎么一步步落地的

3.1 事件驱动内核的最小实现

这段代码是按“能跑通的最小骨架”标准写的,生产版本在此基础上加了更多钩子,但骨架逻辑没变:

class EventBus: def __init__(self): self._handlers = defaultdict(list) def subscribe(self, event_type, handler): self._handlers[event_type].append(handler) def emit(self, event): for handler in self._handlers.get(event.type, []): handler(event) class AgentNode: def __init__(self, node_id, fn, retries=2): self.id = node_id self.fn = fn self.retries = retries def run(self, ctx): for attempt in range(self.retries + 1): try: result = self.fn(ctx) return result except Exception as e: ctx.events.emit(NodeFailed(node_id=self.id, error=str(e), attempt=attempt)) if attempt == self.retries: raise return None class GraphExecutor: def __init__(self, events): self.events = events def execute(self, entry, ctx): queue = [entry] while queue: node = queue.pop(0) self.events.emit(NodeStarted(node_id=node.id)) node.run(ctx) self.events.emit(NodeCompleted(node_id=node.id)) queue.extend(ctx.next_nodes(node.id))

骨架看着简单,但有几个细节值得单独拎出来:

  • 事件总线的 handler 最好是异步注册、同步派发,避免 handler 内部耗时阻塞执行主流程。我自己踩过坑,某个可观测 handler 里写了数据库落库,结果拖慢了整个 Agent 推理链路。
  • retries 粒度应该是节点级而不是任务级。模型输出格式异常这类暂时性问题,在小范围重试就可以,没必要把整张图回滚重来,那样代价太大。
  • ctx.next_nodes 是图拓扑的查询入口,后续要支持动态变更,比如根据某步失败调整后续路径,也是在这个方法里做文章。

3.2 记忆检索与压缩的实现细节

记忆的写入与检索,是重构里改动最大的一块。我之前犯过一个典型错误:把“记忆”简单理解成“把历史消息全塞进上下文”。结果上下文窗口越塞越满,模型越到后面越糊涂,成本还翻倍。

重构后的实现走“检索加压缩”的组合路线:

  1. 先把当前会话的短期记忆完整放入上下文,保证对话连贯性;
  2. 再把用户最近的问题向量化,去中期记忆里做近似检索,取 Top-K 条相关记录;
  3. 长期记忆不全量灌入,而是用 LLM 做一次摘要任务,只把最相关的事实抽出来;
  4. 所有取回的记忆在写入上下文前,先经过一个压缩器,合并重复项、丢弃过期项、把长段落压缩成要点。

压缩器我用了独立的轻量模型,专门做抽取式摘要,速度比主模型快不少。这样主模型看到的上下文永远是“精炼版”。我在某个销售场景里实测,同一预算下准确率提升约一成半,回复延迟也明显下降。

这里必须提醒一句:压缩别每次对话都做。频繁压缩会丢掉细节,模型反而变笨。我的策略是设触发条件,比如短期记忆条数超过阈值,或者当前上下文估算超过窗口的 60%,才触发一次压缩。低于阈值时让原始信息多保留一会儿,模型表现反而更好。

3.3 多 Agent 协作与编排层

编排层是最能体现这次重构成败的部分。旧版的多 Agent 协作是主 Agent 自己判断“要不要找人帮忙”,新版 Orkas 把协作抽成了一个独立编排器,主 Agent 只需要输出一个“协作意图”,编排器负责具体调度。

我把协作模式固化成了三种:

  • 委派模式:主 Agent 把子任务交给子 Agent,等结果返回后继续主流程。适合子任务相对独立的场景,比如“把这份文档翻译成三种语言”。
  • 竞争模式:多个子 Agent 同时尝试解决同一个问题,取置信度最高的结果。适合答案质量波动大的开放性问题,比如给文案起标题。
  • 汇聚模式:多个子 Agent 从不同角度产出中间结果,由主 Agent 汇总决策。适合需要综合分析的任务,比如从多个渠道收集信息后给出建议。

实现上,这三种模式都是图中预置好的子图模板,运行时由编排器实例化。子 Agent 之间不直接通信,只和自己的父节点交换消息,这是为了避免“Agent 之间开小会”导致的失控。我见过有些开源项目让子 Agent 直接互聊,demo 看起来惊艳,实际调试时根本不知道是谁影响了谁,出了问题也没法归因。

3.4 上下文管理的实时监控

上下文用量在 Agent 系统里是个隐形杀手。Orkas 重构后加了一个“上下文预算器”,每条消息进来时估算 token,实时维护一张预算表:主模型占用多少、记忆检索占用多少、工具返回占用多少。超过预算就触发三种处理之一:截断最不重要的历史、压缩记忆块、把部分上下文转存到中期记忆。

估算方法很朴素:英文按字符数除以 4,中文按字符数直接计,精度够用。关键在于把它做成一条流水线,而不是某个节点里的临时判断,这样任何模块的超预算行为都会被统一拦截和处理。上线第二天就抓住了一个“工具返回 20 万字符导致溢出”的隐藏 bug,这在旧版里根本发现不了。

4. 踩过的坑:重构过程中那些“不能说的秘密”

4.1 状态管理的重入问题

事件驱动模型带来一个以前没有的问题:事件 handler 可能被并发触发。比如同一个节点被多个上游节点同时引用,或者重试逻辑和并行分支同时作用,就有可能出现重复写状态、重复扣减资源的情况。

我的解决方案是给每个节点实例分配一个唯一 execution_id,所有状态写入都带上这个 ID 做原子性检查。同时把全局状态从单例改成“作用域隔离”,每个 Agent 实例拥有独立的 Context 对象,只有显式声明的共享字段才会被写入全局共享区。这个改动不大,但直接掐断了“状态串扰”的根。

4.2 工具调用超时的颗粒度

工具超时不能一刀切。我最初给所有工具设了统一的 15 秒超时,结果本地工具 5 秒就够,外部 API 高峰期要 30 秒。一刀切的结果轻则误杀慢请求,重则让 Agent 在高峰期频繁失败。

后来我把超时配置下沉到了工具 schema 级别,每个工具声明自己期望的超时区间,执行器再根据任务优先级动态调整。核心交易流程用硬超时 10 秒,普通信息查询用 30 秒软超时,软超时触发后允许重试一次。既保证了关键路径的响应速度,又不至于让长工具完全不可用。

4.3 记忆污染问题

记忆系统上线后遇到一个特别坑的问题:记忆里存了过时信息,导致 Agent 坚信错误事实。比如用户某天改了偏好,但长期记忆里还留着旧版本,Agent 会按旧偏好在多个步骤里持续输出错误建议。

排查下来,根因是长期记忆的更新策略只做了“追加”,没做“失效”。修复方式是在记忆写入管线里加了一个“冲突检测”步骤:新写入的记忆如果和旧记忆在同一主题下冲突,先把旧记录标记为待确认,下次检索时不再直接使用,而是交给模型做一次取舍判断。这个方法避免了大量误更新,也保留了历史脉络。

4.4 常见问题排查速查表

现象可能原因排查建议
Agent 中途无响应节点陷入重试循环或等待工具返回查事件总线日志,看 node_started 后是否长时间没有 node_completed
上下文 token 很快耗尽工具返回过大且未被压缩检查“上下文预算器”日志,定位哪个模块贡献最多 token
记忆检索结果答非所问向量召回不精准换混合检索:关键词匹配加向量检索加时间衰减加权
多 Agent 协作结果不一致子 Agent 之间消息串扰确认子 Agent 只与父节点交换消息,关闭直接互聊
重构后老接口报错状态上下文对象不兼容检查是否还依赖全局单例,改用显式的 Context 传参

5. 迁移与灰度:老用户怎么平滑过渡

5.1 接口兼容层

重构不意味着老代码全部推翻。Orkas 这次保留了对外的主 API 形态,变化主要发生在运行时内部。我在新旧接口之间加了一层适配器,老版本的 Agent 声明文件会被自动转换成语义等价的图描述,用户无感。

这个决策是有取舍的:完全推倒重来会让老用户全部重写接入代码,代价太大;保留接口兼容、重写内核,则能用老业务来验证新内核的正确性。灰度期间我就是这么干的:同一批业务请求,一半走老内核,一半走新内核,对比成功率、延迟、成本,再逐步切流量。

5.2 回归测试与压测

重构最怕的是“跑起来好好的,一压测就崩”。我给 Orkas 搭了一套三层测试体系:

  • 单元层:每个节点的入参出参校验,重点测异常分支。
  • 场景层:用典型业务案例做全链路回归,比如多工具协作任务、长对话记忆召回。
  • 压力层:用并发任务模拟生产负载,重点关注状态隔离和事件总线吞吐。

压力测试中我发现了一个意想不到的性能瓶颈:事件总线的 handler 数量多了之后,单次 emit 的派发时间从零点几毫秒涨到了几十毫秒。原因是 handler 列表里有不少耗时的同步操作。优化方式有两个:一是把可观测类 handler 全部异步化,二是给 handler 加优先级,关键的推进逻辑先进先出,非关键的往后靠。

做完这轮优化,新内核在同等负载下 P95 延迟比旧内核降低了约四成,成功率稳定在 99.5% 以上。数据不算夸张,但对业务来说已经是实打实的质变了。

6. 重构以后的思考:什么才是 Agent 的“地基”

6.1 三层聚焦:执行、记忆、编排

回顾这次重构,我最大的体会是:Agent 的地基不是“模型选型”,也不是“提示词工程”,而是执行、记忆、编排三件事。模型和提示词决定了一个 Agent 的能力上限,但执行引擎、记忆系统、编排层才决定它在生产环境里能不能稳定跑起来。

执行引擎解决的是“怎么把一次次 LLM 调用串成可控流程”;记忆系统解决的是“怎么让 Agent 记得住、找得到、忘得掉”;编排层解决的是“多个 Agent 之间怎么协作而不失控”。这三件事任何一个没做好,Agent 都只能在 demo 里好看,一上生产就现原形。

6.2 给正在做同类重构的人几个建议

根据我这次的实际操作经验,如果你们也正打算重构 Agent 框架,有几点真的值得先想清楚:

  • 先把可观测性做进去。没有事件总线和全链路日志,重构后的 Bug 只会更难查,因为新架构的并发路径更多。
  • 记住记忆系统一定要有“遗忘机制”。只增不改的记忆系统一定会污染后续决策,冲突检测和失效标记必须作为一等公民来设计。
  • 公共 API 兼容层值得投入。它可能让你多写一两周代码,但换来的是一条平滑的灰度验证路径。
  • 不要迷信某一个框架或某一种编排模式,你自己的业务场景才是最终裁判。

这次 Orkas 的底层重构做完,我看待 Agent 的方式也变了。以前我总觉得 Agent 就是“模型加工具”,现在我会下意识地问一句:你的事件总线在哪里?你的记忆分层了吗?你的协作边界画清楚了吗?这几个问题,恰恰是决定一个 Agent 项目能不能从玩具走向生产的关键。

RELATED READING

延伸阅读

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