Agent底层重构实践:从同步脚本到可观测的分布式运行时

发布时间:2026/10/2 22:43:45
Agent底层重构实践:从同步脚本到可观测的分布式运行时 做底层重构这件事凡是碰过 Agent 框架的工程师多少都体会过那种“外立面看着还行一拆墙全是隐患”的焦虑。Orkas 是我们团队维护的一个面向 LLM Agent 的开发与运行平台简单说它想解决的是“Agent 怎么跑、怎么断、怎么续、怎么安全地和外部工具协作”这一整条链路的问题。最近我们完成了一次真正意义上的底层重构不换皮、不改壳直接把执行内核拆了重写。写这篇东西是想把重构前后的思路、踩过的坑、可复现的实践方式摊开来讲给正在设计 Agent 运行时、纠结并发模型或 memory 层的朋友一些参考。这次重构涉及的代码量大牵扯的细节多但它不是那种“改改接口、优化一下性能”的小改动而是把 Agent 从“一个会跑循环的脚本”变成“一个有状态、可观测、能抗并发、带安全边界的分布式运行时”。下面我会从旧架构的问题讲起再到新架构的分层设计和核心模块实现最后是迁移上线的具体操盘方式。1. 为什么必须动“地基”旧架构的积弊与业务压力1.1 旧架构本质上是一个“demo 架构”Orkas 最早的版本并不复杂甚至可以说非常朴素一个进程、一个同步 executor、一个和业务逻辑强耦合的 harness。所谓 harness按当时团队的理解就是“把 LLM 的输入输出、工具调用、上下文拼接打包在一起的执行壳”。这在刚起步时很爽因为调试方便一行代码进去Agent 就带着上下文跑起来了。但问题也随之而来harness 和所有业务逻辑挤在同一层导致几乎每次增加新工具、新策略都要去改执行壳。请求进来是同步阻塞的并发量一上来整个服务就像单车道高速后面堵成一串。最难受的是结构上根本没想清楚“Agent 是什么”。在 Orkas 里一个 Agent 往往就是一个类类里堆了 prompt 模板、tool 调用、上下文对象、缓存逻辑甚至还有日志代码。长期下来Agent 不像一个抽象实体更像一个不断膨胀的杂物间。这个问题的本质在于早期我们只把 Agent 当作“LLM 的封装”没有把它当作“一个有明确生命周期、有持久化状态、需要独立执行环境的运行时对象”。最后的结局很明显加功能越来越慢排查问题越来越难一个工具调用超时能把整个执行线程拖死。1.2 用户压力逼出来的三个核心痛点真正的重构动力不是代码异味而是线上反馈。我们当时盯着监控数据发现用户对 Orkas 的抱怨集中在三个方面。第一个是并发能力接近天花板。多个真实 Agent 任务同时跑的时候线程池饱和请求互相排队。尤其是 Agent 调用外部工具、等待工具返回的过程中线程被白白占用基本是在空转。用过同步模型做 LLM 应用的人都知道LLM 一次推理可能三五秒如果再加上工具往返整个流程几十秒很正常期间线程资源完全锁死这个账怎么算都不划算。第二个是长任务“跑着跑着就丢了”。旧架构里的 Agent 状态全在内存里一旦进程重启、容器调度或代码发布进行中的任务直接变成未知状态。用户拿不到结果也不知道任务到底执行到哪一步。更尴尬的是记忆和上下文都跟着进程走换一台机器就是“陌生人”。第三个是工具调用完全没有审计和防重机制。Agent 可能会因为模型幻觉或 prompt 注入连续触发某个危险工具。我们当时连“这个工具是谁在什么上下文里触发的”都很难回答更别说做细粒度授权和二次审批。就这一点直接推动了安全层的重写。1.3 “底层重构”到底指的是什么这里必须先做一个定义上的澄清。我们说的“底层重构”既不是优化某个库的性能也不是换一门语言重写全部业务代码而是重写“执行内核”——也就是 Agent 从诞生、运行、暂停、续跑、结束时依赖的那套基础设施。重构后的 Orkas理论上应该具备这样几个能力Agent 的执行过程被拆成可持久化的状态事件流执行节点不再持有状态状态全部外置一个 Agent 任务可以暂停和恢复调度器能把不同租户、不同优先级的任务按配额公平调度任何一次工具调用都能回溯到当时的上下文。说白了我们要的是一座地基而不是又一座只盖一层的小平房。这个地基动工前团队内部有过不少争议。最大的分歧点在于要不要引入第三方 Agent 框架市面上主流的 Agent 框架各有优势但我们在评估后还是决定自己掌握核心执行内核。原因是 Orkas 的定位是平台用户的 Agent 会被部署到多租户、多复杂度的环境里我们需要的不是“默认可用”而是“任何一层都能做精细控制”。框架可以借鉴思想地基必须自己打。2. 重构的目标与总体设计核心思路拆解2.1 先分清 harness 和 agent划清边界这次重构里我们做的第一件事就是重新画边界把“harness”和“agent”彻底拆开。用一句话解释清楚两者的区别harness 是“壳”负责执行流程、工具的装载调度、消息的传递、状态的持久化agent 是“策略”负责决定下一步干什么、选哪个工具、怎么解读结果。harness 不关心这份 Agent 是不是聪明它只保证过程可运行agent 不关心底层调度细节它只负责做出决策。这个边界划清楚之后整个系统瞬间清爽了。以前用户为了改一个工具选择的策略不得不去改 harness 的代码——这相当于为了让车转弯更顺直接把方向盘焊在发动机上。现在用户只需要实现自己的策略逻辑harness 会按协议把决策上下文交给他再把决策结果拿回去执行。实际操作中我们为 agent 层提供了非常窄但清晰的接口大致包含四个阶段输入归一化、规划与工具选择、执行反馈、结果输出。harness 负责在这四个阶段外提供重试、超时、观测和状态恢复。你不需要写 harness除非你明确想接管整个执行循环这在特殊场景下是被允许的但默认不会开放。2.2 六层架构带来明确的分工重构后的 Orkas 在逻辑上分成六层这是我比较满意的部分也很适合作为参考设计。接入层只负责处理外部 API 请求、流式响应和客户端连接不承载任何业务逻辑。相当于大楼的门卫只管接人不管住户家的事。编排层负责多 Agent 场景的拆分与合并。用户提交一个复杂的业务目标编排层会判断是否需要走单 Agent 流程还是拆成多个子 Agent 协同处理。它也负责子任务的结果汇总和失败重试。执行层这是新地基最核心的部分。它维护 Agent 运行时的状态机管理工具调用队列调度 worker 消费任务。每个“Agent Run”在这里都是一个独立实体有自己的生命周期。记忆层负责短期会话上下文、长期知识、向量检索和记忆压缩。它不关心 Agent 的业务逻辑只负责把“记忆”变成可访问的服务。安全层负责身份校验、权限判定、工具调用审批、内容过滤和沙箱隔离。可观测层负责埋点、日志、trace、指标聚合把所有层的运行信息结构化输出。这六层没有严格的前后顺序关系执行层和记忆层、安全层是平级协作接入层和编排层只是入口。这样做的好处是每一层都能独立演进。比如安全策略调整不需要碰执行层记忆索引换了新向量库也不影响工具调用的逻辑。2.3 并发模型的选择事件驱动加 Actor 模型并发设计是这次重构的硬骨头。旧架构是线程同步阻塞模型一个 Agent 任务占一个线程工具调用等待时线程挂起完全是在浪费资源。新架构我们采用了两层嵌套的并发模型外层是事件驱动内层是 Actor 模型。先说事件驱动所有业务动作都变成事件写入事件总线。任务开始是一个事件工具调用发起是一个事件工具返回是一个事件状态流转也是一个事件。每个 worker 只关心事件不关心谁调了它所以天然能并行处理不同任务。内层的 Actor 模型解决的是状态隔离问题。每个 Agent Run 是一个 ActorActor 之间不共享可变状态它们通过消息通信。这很符合 Agent 的特点不同的用户、不同的任务之间本来就应该强隔离。把 Actor 的邮箱实现为一个持久化队列我们甚至能做到任务中途把 Actor 迁移到另一个实例继续跑这在旧架构里是不可想象的。并发上限的计算也做了细粒度控制不再依赖线程池的固定值。我们按任务类型估算资源开销LLM 调用是 IO 密集型的计算类工具是 CPU 密集型的两类任务走独立的 worker 池。一个比较保守的配置比如单实例部署 32 个 worker 时IO 型任务可以同时等待数百个外部调用而 CPU 型任务最多并行 8 个。实测下来同样的机器规格新架构的吞吐量比旧架构提升了大约 6 到 8 倍这里的提升主要不是因为机器更快而是因为等待不再阻塞资源。2.4 记忆层的设计原则能外置就外置能压缩就压缩Agent 的记忆问题很多项目和框架都在处理但处理方式差异很大。这次重构中我们把记忆层单独抽出来定下了几个硬原则。第一短期上下文的存储必须和进程解耦。会话的对话记录全部走外部存储推荐组合是 Redis 存热数据PostgreSQL 存冷数据。进程重启不会丢上下文多副本部署也可以共享同一套用户会话。第二长期记忆不存原始文本而是存“可检索的知识片段”。每次对话结束后执行层会把关键结论做摘要化处理更新到向量库。用户再次发起任务时编排层会先做一次语义检索选出相关度最高的记忆片段再拼接进 prompt。这样既控制了 token 成本又避免把无关的陈旧信息一股脑塞给模型。第三记忆更新必须有审计。谁写入的记忆、写入时的上下文是什么、对应的工具调用是什么这些都要能追溯。我们在记忆条目上加了元数据字段包括来源 Agent、任务 ID、时间戳、关联工具。刚开始觉得这个设计有点重后来发现排查问题的时候这个“记忆血缘”帮了大忙。与此同时我们也参照了 LLM Agent 记忆安全防护的思路对记忆的读写做了一圈校验防止恶意提示词通过记忆通道污染后续会话。这一点在判断“记忆内容是否真实来自历史事件”时尤其有用。3. 核心模块的实现与落地实操与细节3.1 Agent 运行时的状态机设计新地基里最基础的实物是 Agent Run 的状态机。我们把一个 Agent 的一生定义为七个状态pending、running、waiting_tool、paused、completed、failed、cancelled。pending 表示任务已创建还没被 worker 捡走。running 表示正在执行推理循环也就是模型交互阶段。waiting_tool 表示模型调用了工具执行层正在等待工具结果。paused 用于人工介入或资源不足时暂停任务。completed、failed、cancelled 是三个终态。这七个状态之间只允许规定的路径转移任何非法转移都会抛出异常。最关键的转移是 waiting_tool 到 running工具结果返回后主循环自动恢复。我们在持久化时把状态记录在事件日志里恢复执行时直接从最近一个快照 事件日志重建避免一次长任务因一次进程重启就前功尽弃。实现状态机的代码结构并不复杂但有一点建议必须强调状态转移核心不要用 if/else 散写而是定义成一张显式的转移表。好处是静态代码审查时一眼能看到每个状态允许哪些路径测试时也容易做穷举。我们的状态转移表就是一张简单的映射表key 是当前状态value 是允许的下一状态集合转移动作统一走一个入口函数。3.2 工具调用的调度与防重Agent 能不能干实事很大程度取决于工具调用链路稳不稳。我们在执行层做了一个工具调度器核心职责有四块。第一块是工具注册表。所有工具在注册时声明自己的超时时间、幂等性、并发限制、权限等级。声明式的好处是调度器不需要知道工具内部实现只按声明做约束。超时处理这一块尤其重要有的工具确实不可设超时那至少要有外部终止机制否则一个失控的脚本能拖死整个 worker。第二块是幂等控制。现实中的工具调用因为网络超时会重试重试就可能导致重复操作。我们在工具请求里加幂等键工具执行方保存幂等键和执行结果重复请求直接返回旧结果。这是电商支付领域的老办法放在 Agent 工具调度里同样适用。第三块是断路器。某个工具连续失败次数超过阈值后调度器会暂时熔断不再把新任务派给它等冷却时间过后再逐步放量。Agent 场景里模型完全可能因为误判反复调用同一个坏工具断路器可以避免大量无效成本。第四块是权限校验。工具调用前调度器必须通过安全层拿到授权结果。授权结果有三种allow、deny、require_human。require_human 会把任务状态切成 paused推送待审批消息给用户用户确认后任务以 waiting_tool 转 running 的逻辑继续执行。实际效果非常好因为管理员不会容忍 Agent 未经确认就删除数据库表。3.3 安全边界沙箱、提示词注入与审批Agent 安全是个常说常新的话题但落到实际实现就三件事隔离执行环境、过滤恶意输入、卡住高危操作。隔离执行环境方面我们给所有外部工具调用设计了沙箱策略。默认情况下工具运行在受管容器内只有白名单端口可以出网文件系统挂载目录只读CPU 和内存都有配额限制。有人可能觉得这样做太重但对于平台型产品沙箱是底线不是可选项。你永远不知道某个第三方工具脚本里藏着什么逻辑尤其是当 Agent 可以动态加载工具的时候。提示词注入防护这块公开讨论很多但我们的实现策略是多层组合不依赖单一模型判定。第一层是系统级检测规则识别常见注入模式比如“忽略之前所有指令”这类句式第二层是模型分类器对用户输入和外部工具返回内容做风险打分第三层是把外部内容放进一个隔离的“不可信上下文区域”主 prompt 明确告知模型该区域的信息只能作为客观数据不能作为指令。这三层合起来不能说绝对免疫但至少能把最粗的几条攻击路径堵住。高危操作的二次审批也值得一提。我们把工具按风险等级分为 normal、sensitive、critical 三级。normal 放行sensitive 需要用户手动确认critical 除了确认之外还要在后台记录完整操作回放。审批界面直接显示 Agent 的计划、将要执行的参数、可能的影响范围。实践中这个设计赢得了很多企业客户的信任因为在敏感场景里“人最后把关”仍然是不可替代的。3.4 记忆与上下文的落地实现记忆层实际操作中主要分两部分短期会话和长期知识。短期会话用的是 Redis Stream每条消息是一个事件包含 role、content、tool_call_id、timestamp。执行层每次构建 Prompt 时从 Redis Stream 拉最近 N 条记录拼到上下文窗口里。我们用窗口长度和 token 预算两个约束共同控制避免无限的上下文膨胀。长期知识用的是向量库加摘要流程。Agent 每完成一个重要步骤会触发一个 Summary Worker把最近这段对话压缩成几百字的结构化摘要再写入向量库。检索阶段用的是混合检索方式关键词检索负责精确命中向量检索负责语义召回两路结果做加权合并。实际上这一步调参比较费时间召回条数、相似度阈值、摘要粒度都会影响最终效果我们没有给通用答案而是按业务域做了可配置项。还有一个容易被忽略的点上下文管理不能只管理“送进模型的文本”还要管理“没送进模型但临时存在的数据”。我们在执行层加了一层中间结果缓存工具返回的大 JSON 不再直接塞进上下文而是先落到缓存再把一个引用塞给模型。只有当模型明确需要完整数据时才通过引用去取。这个设计对长流程 Agent 特别管用因为中间结果往往又大又杂直接全塞进上下文不仅费钱还容易把模型注意力带偏。3.5 一个具体的集成样例Docker 里跑 ROS2 与 micro-ros agent除了通用工具很多用户想把 Orkas 接入物理世界系统我们在这个维度上也做了一些验证。这里分享一个具体的实践路径把 ROS2 Humble 和一个 micro-ros agent 放进 Docker 容器里并封装成 Orkas 的一个工具插件。base 镜像用的是 ubuntu:22.04安装 ros-humble-ros-base 之后再装 micro-ros agent 的依赖。启动容器时需要把宿主机的 ROS_DOMAIN_ID 传给容器并设置相同的网络模式才能让容器内的 nodes 发现宿主机上的 ROS2 节点。micro-ros agent 本身负责把微控制器上的 micro-ros 节点桥接到 ROS2 网络所以容器内至少要有两个进程一个是 ROS2 核心环境一个是 micro-ros agent 进程。封装成 Orkas 工具时我们把容器生命周期管理做成一个 Tool 接口。调用时传入 payloadpayload 里包含机器人控制指令工具内部通过 Docker SDK 执行容器命令实时读取 stdout再将结构化结果返回。整个过程在 Orkas 的视角里只是一个带超时、带审计的工具调用。这个集成案例给出一个心得不要试图让 Agent 直接理解底层通信协议。Agent 只需要知道“odom 数据是什么格式、底盘速度指令有哪些取值”就够了协议转换全部沉淀在工具层。这样既保证 Agent 推理的稳定性也把复杂系统隐藏在可观测的抽象之后。4. 迁移与上线的操盘笔记4.1 兼容层的重要性别让老用户陪我们重构底层重构最担心的不是重构本身而是上线后的兼容性问题。我们的策略是写一个显式兼容层而不是让旧代码继续躺在新代码旁边。兼容层的职责是做协议翻译旧版 API 的请求进来翻译成新版内部事件流新版产生的输出翻译成旧版响应格式。兼容层本身也是一个可配置模块灰度切换时可以随时调整开关。比如旧版有一个字段叫 status新版叫 phase兼容层会统一映射。我们保留了旧消息结构和字段至少一个版本周期在这段时间内持续观察客户端的使用情况等确认没有人再依赖旧字段再决定是否裁掉。注意这里不会因为“代码在仓库里放着又没有坏”就无限期保留计划内删除反而会让系统更健康。4.2 金丝雀发布与数据迁移上线的过程我没有选择一次性切换而是分了四步。第一步新架构和旧架构双跑新架构只接收影子流量也就是拷贝一份请求到新环境但不影响线上结果。这阶段主要验证状态机有没有崩溃、事件流有没有不正确。第二步开放 5% 的真实流量观察错误率与耗时。第三步扩大到 30%此时开始执行任务数据的完整迁移。第四步100% 切流旧架构只保留只读访问用于审计。数据迁移是另一个容易翻车的地方。旧系统里任务状态只在内存迁移不了多少但工具调用日志和会话记录都是结构化的我们把它们分批搬运到新事件流按任务 ID 建立新旧映射。迁移脚本设计成可重入的也就是每一批数据都带幂等键跑一半断了可以接着跑不会重复插入。实际操作中最大的风险点是历史会话的 token 用量口径不一致新旧系统对 token 的统计方式有差异迁移后的数字一时对不上差点吓出冷汗。后来比对后发现不是丢失数据而是口径变化所以迁移前最好先统一度量标准否则排查成本非常高。4.3 可观测性体系的重构新的执行内核如果没有可观测性等于盲人开车。我们为每次 Agent Run 生成了一个全局 trace_id从 API 入口开始一路透传到工具调用、记忆读取、安全审批。事件日志统一格式化成 JSON包含事件类型、时间戳、任务 ID、层名称、耗时、关键字段。指标层重点关注五类数据任务队列长度、worker 利用率、LLM 调用耗时、工具调用失败率、状态机非法转移次数。前三个是容量管理的基础后两个直接反映系统健康度。我们设置了两条比较重要的告警规则一条是 waiting_tool 状态占比超过 40% 且有持续上升趋势说明工具链路可能开始拥堵另一条是队列长度持续超过 worker 数的三倍说明需要扩容了。trace 具体到排查问题时我的做法是先按 trace_id 拉出整个事件链从“任务创建”一直看到“工具返回”逐段找耗时瓶颈。大多数 Agent 慢的问题都不在模型推理本身而是浪费在不必要的工具重试和上下文重传上。5. 实际踩过的坑与排查技巧5.1 高频问题速查表以下这些坑有些是我们线上真实踩过的有些是社区里反复出现的典型问题整理出来供参考。错误现象根因分析处理建议agent execution terminated due to error没有统一异常边界工具异常直接穿透到执行层每个 Agent Run 最外层包 recover 边界工具异常统一包装成 ToolExecutionError沙盒更新失败容器镜像构建过程没做幂等依赖源不稳定镜像构建全部用 lockfile构建过程输出固定版本摘要无法发送消息/请求超时很多情况下是网络代理配置或出口策略变化也有可能是 worker 池耗尽排查顺序先看队列长度再看工具调用超时率最后看网络出口连通性并发一高就状态错乱共享变量被多个协程同时修改严格遵循 Actor 模型任务状态只允许在归属 Actor 内修改上下文过大导致模型报错没有做上下文裁剪工具中间结果全部塞进 prompt使用中间结果缓存 引用传递必要时按 token 预算自动裁剪恢复任务后记忆对不上记忆快照与事件日志不同步恢复顺序必须严格按事件日志重放不能直接用内存快照绕过日志这个表看着轻巧但每一行背后都有两三天排查的代价。比如状态错乱的那种情况我们当时监控里能看到同一任务被两个 worker 同时处理状态被来回覆盖。根因就是订阅事件时没有做 key 级别的分区同一个任务 ID 的消息被分发到了不同实例最终通过按任务 ID 哈希取模分区才解决。5.2 排查 Agent 问题时的五个实际操作习惯我自己的排查习惯经过这次重构后基本稳定在五个步骤上。第一先看事件流不看日志正文。事件流能还原“发生了什么、顺序是什么”日志只是辅助。没有事件流的排查就像看监控回放一样只能靠猜。第二把工具调用重放一遍。很多问题只在特定输入下触发重放时保留原始传入参数把工具实参和返回结果完整记录能快速定位是模型选错工具还是工具执行得不对。第三对比两次相同输入的运行轨迹。如果同一个任务第一次成功第二次失败大概率是状态问题或依赖资源问题而不是模型本身随机性。第四检查记忆层的变化尤其是任务失败前是否有新写入的记忆片段。不少诡异行为是记忆污染导致的。第五保持 trace_id 的完整传递拒绝任何不携带关联 ID 的异常上报。没有这个 ID再多的日志也只是噪音。5.3 给准备做 Agent 底层重构的团队的几点建议如果你们也在准备做一个类似的底层重构我有几个实际操作层面的建议。不要一上来就写并发。先画状态图把每个状态允许的转移路径确认清楚再碰代码。我们就是先花了一周时间在状态图上较劲后面写实现反而很快。并发模型的细节如果不建立在清晰状态机之上最后一定会出现竞态问题。这个顺序不能乱。迁移现有用户时先找出真正的黄金指标。不要盯着“服务不报错”这种低级指标要看“任务完成率”“工具调用成功率”“用户等待时长”。旧系统的任务完成率如果只有 90%新系统上线后如果波动到 89%表面上差不多实际上很可能代表一批任务由成功了变成失败。允许新系统在某些环节比旧系统慢。比如安全审批、持久化、审计这些自然会增加耗时。要分清什么是必要的慢什么是低效的慢。必要的慢说明你补上了应该补的账低效的慢才是要砍掉的部分。如果一味追求所有路径都变快反而容易把安全边界和审计能力给牺牲掉。最后一点代码层面做好随时回滚的准备。回滚不是丢人是为了让下一次切换更稳。我们在发布过程里准备了三条回滚路径配置开关回滚、流量路由回滚、数据库 Schema 回滚。前两条是秒级操作第三条需要预演但必须提前准备好。结尾重构落地到现在我最大的感受倒不是性能提升多少倍而是团队对系统的掌控力完全不一样了。以前排查一个 Agent 故障要翻遍日志猜状态现在拉出事件流每个环节都明明白白连“当时模型为什么会这么选”都能通过工具调用链和记忆片段还原个七八成。做底层的活很多时候是给自己修的修完才知道之前住在危房里有多难受。如果让我只留一句心得我会说Agent 的地基核心不是模型也不是 prompt而是稳定、可恢复、可观测的执行内核。模型可以换prompt 可以调但内核一旦混乱上层再怎么努力都是摇摇晃晃的。最后再分享一个小技巧重构期间我们给每个核心模块都写了一个“混沌场景清单”专门模拟进程被杀、网络闪断、工具超时这类极端情况。这个清单现在成为了每次发版前的保留节目实测下来帮我们挡掉了至少三次潜在的事故。希望这些经验对同样在做 Agent 平台的你有点用。