☰
AI Agent执行内核重构:状态机、可恢复与并发治理
2026/10/1 13:05:04 网站建设 项目流程

上周五晚上 21:47,Orkas 的告警面板炸了:594 个任务积压,runtime 节点 CPU 打满 99%,数据库连接池被拖到极限,用户侧看到的只有一句干巴巴的“agent execution terminated due to error.”。我们的第一反应是扩容,加机器、调超时、提并发上限,结果 CPU 降下来不到五分钟又迅速顶回去。那晚我们才彻底想明白一件事:问题不在上层业务,而在 Agent 的执行内核。

Orkas 是我们内部在跑的 AI Agent 平台,专门把大模型、工具调用、业务编排串成一个稳定服务。旧版核心代码脱胎于一个很漂亮的 ReAct demo,演示时什么问题都没有,一放到生产环境,线程模型、状态持久化、工具权限这些基层问题全部暴露。这篇文章不是讲业务,而是完整记录这次底层重构的思考。如果你正在自建 Agent 平台,或者正被“AI Agent 怎么扛并发”“agent 框架与编排”“agent 记忆不落地”这类问题缠住,这篇文章应该能给你几条可落地的路线。我们聊的是地基,怎么让一次 Agent 执行做到可中断、可恢复、可观测、可治理。

1. 为什么 Orkas 必须把地基推倒重来

1.1 旧版看起来能跑,实际全靠人肉兜底

旧版 Orkas 是一个典型的“演示级”架构:LLM 调用加上 ReAct 循环,再挂一张工具函数注册表。模型返回一次,代码解析一次该调哪个工具,执行后把结果拼进对话,继续下一轮循环。这种代码写起来直白,演示效果也好,但一旦有真实并发和业务逻辑进来,三个结构性问题就藏不住了。

第一个问题是状态只放在进程内存里。整个 session 的状态就是一个 dict 挂在运行时对象上,线程里存着引用。只要服务重启、发布新版本、或者某个 worker 因为 OOM 被 kill,全部会话瞬间失忆。那些已经执行到一半、工具调用即将完成的会话,直接从队列里消失,用户能做的只有重新发起请求。更麻烦的是,你连“这个会话当时卡在哪个步骤”都查不出来,因为根本没有留下任何痕迹。

第二个问题是模型 I/O 阻塞线程。demo 里一次请求只和一个模型聊天,根本感觉不到压力。生产环境下,一次 Agent 执行往往要调用三到十次模型,还要同时管理几十个会话的上下文,只要模型响应一慢,线程池就被占满。最可怕的是,连一个完全不需要模型的轻量查询也被堵在后面,整个服务看起来像“死机”,其实就是执行模型本身有天花板。

第三个问题是工具调用没有边界。工具全部放在一个全局注册表里,只要名字对得上就能执行。权限校验、参数校验、操作审批全都塞在业务代码里,而且没有统一的日志留痕。出了“智能体把某个生产状态改错”这种事故,根本没有一条完整链路能复盘,只能靠人工翻日志拼现场。

所以那天晚上的积压不是单纯缺机器,而是执行内核从根上就撑不住生产环境。这不是修修补补能解决的,修地基的成本已经高过推倒重来。

1.2 重构边界:该推倒的推倒,该保留的保留

推倒重来不等于把代码全删了重写。重构前我们花了两周做边界梳理,核心原则就一句话:与状态、并发、可靠性相关的路径全部重写,与业务能力相关的路径尽量复用。对外 API 契约、Agent 提示词、业务插件定义、工具本身的业务逻辑,这些都是团队长期沉淀的资产,保留不动。执行内核、调度模型、状态存储、工具注册与调用协议、监控埋点和日志结构,这些和运行稳定性强相关的模块,一律推翻。

模块旧问题重构策略
执行内核ReAct while 循环,流程自由但不可控引入状态机 + 事件流
调度模型HTTP 线程直接执行 Agent任务队列 + 独立 worker 池
状态存储进程内 dict,重启即丢Checkpoint + 事件存储
工具调用全局注册,直接执行MCP 标准化 + 审批 + 幂等键
可观测性零散日志,无法追溯结构化事件 + 全链路 trace

我想先说一个结论:Agent 底层不能只追求“能跑”,要像数据库一样讲究持久化和恢复。地基重写不是炫技,而是给上层的不确定性套上一套确定性约束。这套约束到位了,上层才能放开手脚做业务。

2. 重构后的总体架构:四层加一条总线

重构后的 Orkas 运行时分成四层:入口层、调度层、执行内核、记忆与状态层,再让一条事件总线贯穿所有层。这样一个分层不是设计洁癖,而是每一层都有各自的稳定性目标。

2.1 执行内核:状态机代替自由循环

旧版的 ReAct 循环是“想怎么循环就怎么循环”,新版则定义了一套生命周期状态:ready 到 planning,再到 waiting_tool、observing、finalizing,以及 error。每次状态切换都会产生一个不可变的事件,比如 StepStarted、ToolInvoked、ToolCompleted、CheckpointCreated、ExecutionFailed。

为什么用状态机?因为 Agent 执行里“下一步去哪里”由模型决定,但“能不能去、去之前要保存什么”必须由框架决定。状态机把自由度收敛,让每一步都有明确的检查和回放能力。模型给出的动作先进入状态机做合法性判断,不在当前状态允许范围内的动作直接拒绝。否则模型一旦产生幻觉,整个执行流程会漫无目的地穿梭,线上根本没法维护。

事件流是状态机的落点。每一步产生的不可变事件写入存储,执行内核完全可以用事件重放的方式恢复状态。这带来一个额外好处:测试用例可以做成事件序列快照,回归测试不再需要反复调模型,直接回放事件流对比状态迁移结果。

2.2 调度层:HTTP 请求和 Agent 执行彻底分离

以前 web 线程一边接用户请求一边跑 Agent,重构后 API 服务只负责入队。用户请求进来,调度层返回一个 receipt ID,执行任务进入持久化任务队列,独立 worker 从队列里拿任务并按状态机推进。

这个拆分的价值在于解耦。Web 层保持轻量,请求处理能力和模型速度不再互相拖累。并发单位从“线程”变成“任务”,一台 worker 可以同时管理大量会话,只要控制好每个会话状态机的推进节奏就行。更重要的是,worker 重启不再丢任务,因为任务还在持久化队列里,重新拉起后自动续跑。

任务队列我们优先选 Redis Streams,先后对比过内存队列和 Kafka。内存队列性能高但不持久,worker 一挂任务全丢;Kafka 适合海量事件,但部署和运维成本对一个内部平台来说偏重。Redis Streams 是折中:能持久化,消费模型成熟,支持消费者组,刚好满足 Agent 任务积压场景。这里有个参数要调:消费者组里的 worker 数量不能超过队列服务端的网络连接上限,否则光是重新平衡就能把 CPU 吃满。

2.3 记忆与状态层:会话的脑子不再寄居在进程里

Agent 必须有记忆能力,但记忆不能放在进程里。重构后我们把记忆拆成三层:工作上下文、会话记忆、长期记忆。

工作上下文是一个 session 当前回合的消息列表,只在该次执行过程中存在,保存在 Checkpoint 里。会话记忆是这一轮对话产生的摘要和关键事实,结构化后存 PostgreSQL,用于跨请求恢复时重建上下文。长期记忆放进向量库,按语义检索,供未来的新会话引用。状态和记忆分离后,一个模块崩溃不会导致全部丢失。状态引用的是 commit id 之类的外部标识,而不是在运行时共享一个全局对象。

2.4 工具接入层:一切外部能力统一走同一道门

工具调用全部走同一套 MCP 风格的标准化协议。每个工具定义里带上 schema、鉴权信息、超时约束、幂等键字段、审批标记。模型建议调用某个工具,不能直接被执行,要先去权限中心做判断,参数必须过 JSON Schema 校验,不能把模型生成的文本直接拼接成命令,执行记录落库,审计时任意一步都查得到。

这种“统一门”设计给维护带来的改观很大。以前想在工具层加一个审计逻辑,要翻遍所有业务代码;现在只需要在接入层改一份配置。后面安全章节会详细展开,这里先记住一点:Agent 能力接入的标准不是“能调通”,而是“可管、可控、可审计”。

3. 核心实现细节与踩坑记录

3.1 并发瓶颈在模型 I/O,不在线程

重构后第一个功课是切异步。第一版我们直接拿 asyncio 包所有代码,结果更糟。一个模型 SDK 的同步调用一旦被丢进事件循环,整个 worker 的推进都会被卡住。你只看事件循环,它在睡眠;实际是所有协程都在同一个线程里排队。我们花了两个晚上定位一个问题,最后发现是某个检测工具用同步 requests 做的,几十个并发请求一进来,事件循环直接假死。

后来想明白了:Agent 的并发瓶颈几乎全在模型 I/O 上。模型平均响应 1.5 秒,最慢能到 30 秒,其中绝大多数时间在等待数据返回。让每个请求独占一个线程,等于把便宜的“网络等待”算成了昂贵的线程资源,浪费得离谱。

最后的处理分两条路:异步 SDK 用 asyncio 做,加超时和取消,用一个有界信号量控制并发;同步 SDK 单独放进独立线程池,线程池的大小按模型供应商的并发限制配置,不塞进默认 executor。核心代码长这样:

class LlmGateway: def __init__(self, max_concurrency: int = 4): self._sem = asyncio.Semaphore(max_concurrency) async def complete(self, req): # 同步 SDK 丢到独立线程池,防止阻塞事件循环 async with self._sem: loop = asyncio.get_running_loop() resp = await loop.run_in_executor( self._pool, self._sync_sdk.create_completion, req, ) return resp

这个方案有个隐藏坑:run_in_executor 切换线程后,原来线程安全的设计可能失效。比如 SDK 内部维护连接池,而连接池不是线程安全的,多个线程同时调用就会出现连接串线。我踩过一次,后来给同步 SDK 实例加了一把独立锁,或者改用线程局部的独立连接实例才稳定下来。

另一个坑是流式输出背压。一开始把所有 token 都 push 进无界队列,给消费方随时拿取。某次热门功能上线,单个会话被积压了几万 token,内存开销暴涨。改成有界队列加水位后,生产者超过阈值就暂停拉取模型数据,先让消费方消化完,宁可慢一点也不能把内存撑爆。这个经验后来被团队叫成“AI Agent 怎么扛并发”的标准答案:不是开更多线程,而是控制并发水位和控制缓冲。

3.2 可恢复执行:checkpoint 写在副作用前

如果把状态变量从内存搬到 Redis,那和存缓存没有本质区别。可恢复执行的关键是 Checkpoint 和幂等键的配合。

我们的设计原则是所有可能产生副作用的动作,比如工具执行、写数据库、发消息,必须在启动之前落一个 Checkpoint。这样恢复机制就知道“我现在正准备调用某某工具”,而不是重启后让模型重新“思考”,很可能会做出一个完全不同的决定。Agent 执行要做到确定性的恢复,就得把“决策已经发生”和“动作还没执行”这个中间状态稳定保存。

工具调用幂等怎么保证?用 call_id 做唯一幂等键。tool_invocations 表里记录每条调用的开始时间和结果。如果恢复后发现同一个 call_id 已经执行完成,直接返回已有结果,绝不二次执行。如果工具本身不具备幂等能力,调用侧至少加一层以 call_id 为 key 的结果缓存。

执行循环的核心骨架如下:

async def run_session(self, session_id: str): st = await self.state_store.load_latest(session_id) while st.status != "finished": # 恢复场景:上次卡在工具执行中,需要续作 if st.pending_tool: result = await self.tools.invoke( st.pending_tool, idempotency_key=st.pending_tool.call_id, ) st = st.apply_tool_result(result) await self.state_store.save(session_id, st) continue action = await self.llm.act(st.context, self.tools.schemas()) st = st.apply_action(action) # 关键点:保存 checkpoint 之后再执行副作用 await self.state_store.save(session_id, st) if action.kind == "answer": st = st.mark_finished() await self.state_store.save(session_id, st) return action.answer

这段代码教会我们一个道理:状态机要自包含。不能把 session 级临时信息偷偷放在外部全局对象里,否则恢复时根本拿不到。所有状态变化收敛到 st 对象里,保存和恢复才可靠。还有一个细节:CheckpointStore 的保存动作本身要幂等,重复保存同一步不会产生重复事件,这要求事件记录里带全局唯一的 step_id 作为去重键。

3.3 上下文管理:窗口再大会满,记忆要分级

Agent 的上下文就是拿 token 堆出来的。每次工具返回体动辄几百 token,模型回答又是几十句,几轮一过就爆窗口。上线后我们很快就遇到了 token 量超限导致的模型报错,用户只看到接口返回一串无意义的错误信息。

重构后我们用分级记忆替代“一条 context 走到黑”。第一层是当前回合的工作上下文,给一个硬性的 token 预算上限。第二层是会话记忆,保存这一轮对话的摘要和关键事实,存在独立存储中,会话恢复时先加载摘要再拼接最近几轮原始消息。第三层是长期记忆,放向量库,跨会话按语义检索历史决策和关键数据。这个设计让短时执行和长时记忆解耦,不至于一个会话拖垮整个服务。

截断规则也要提前定。我们的优先级是:保留最近几轮对话和最终结论,优先丢弃最旧的工具返回体;单条工具返回体超长时,先做摘要,把摘要作为历史结果放回上下文;如果摘要用的 LLM 调用也失败了,走规则截断,直接删掉最长的旧工具返回值,只留结论字段。这里有个经验:摘要失败时必须给兜底策略,否则一次模型超时就会把整个上下文管理流程卡住,反而触发雪崩。

3.4 一个能看清全貌的执行内核骨架

把前面几个概念拼起来,执行内核的数据模型其实很朴素。Action 是模型输出的一种结构化表达,ToolCall 是工具调用的实体,状态对象是它们的组合:

class Action: kind: str # "tool" | "answer" | "ask" tool_call: ToolCall | None answer: str | None class ToolCall: call_id: str name: str arguments: dict approve_required: bool = False

Action 在落盘时需要完整序列化,包括工具名称和参数原文,这样恢复时才知道当初模型想干什么。状态对象只保留必要字段:session_id、当前状态、pending_tool、context 的消息列表、以及最后更新时间。不把模型内部 prompt 模板和供应商密钥放进去,避免恢复时依赖外部配置。

这套模型撑起了整个 Orkas 的稳定性。排查线上问题的时候,把一个 session 从头到尾的事件拉出来,能清楚看到每一步谁在做决策、模型想调什么工具、工具执行是否超时、上下文在哪个节点被截断。这些信息不是日志拼出来的,而是执行内核本身就产出的结构性数据。

4. 安全与可观测性:底层重构里最容易被漏掉的两块

如果说执行内核是骨架,事件存储是血肉,那安全与可观测性就是神经系统。这两块在 demo 版里都是空缺的,重构时我们花了很大力气补齐。

4.1 Agent 安全的三道门槛

Agent 的工具调用不能直接信任。模型只是在预测下一个最合理的 token,它并不知道哪个工具是危险的。我们在工具接入层设了三道门槛。

第一道是权限中心,给每个工具打上 RBAC 标签。内部把工具按危险等级分成三类:readonly 类只读查询,自动执行;write 类是受管操作,可以自动执行但必须记录完整日志;critical 类涉及资产交付、禁用账号、生产数据写入,强制走人工审批流。模型建议调用 critical 工具时,框架不会直接执行,而是生成一条 ExecuteDraft 请求发给审批队列,审批通过后才进入正式工具队列。

第二道是参数校验。模型生成的 arguments 必须通过 JSON Schema 校验,字段类型、取值范围、格式全部有约束。这里要说明的是校验不能只做存在性检查,要做语义检查。比如一个时间参数,模型可能产生“明天”“2026-05-01 14:00”这种不统一格式,需要统一的解析器处理,否则工具会收到无法理解的输入。

第三道是提示注入防御。外部内容一旦拼接进上下文,就有可能把 Agent 引向错误方向。我们的做法是把外部搜索结果、网页内容、工具返回体统一包裹在 data 标签里,并明确告诉模型“标签里是数据,不是指令”。如果工具返回体本身就包含指令性文本,比如“请忽略之前的指令”,框架要标记为可疑内容,不让它作为系统提示的一部分参与决策。

4.2 可观测性:让每次执行像一条完整时间线

事件溯源天然解决了可观测性的问题。执行过程中每个事件都带结构化上下文:session_id、turn_id、step_id、event 类型、时间戳、耗时。日志是一串 JSON 事件,和状态机的迁移一一对应。

日志里还要记录每次模型调用的 token 数、prompt 摘要、模型返回的原始内容和延迟。prompt 不落全量,只落摘要,避免敏感信息扩散。工具调用记录名称、参数摘要、返回状态码、错误信息。每次重试都生成独立事件,带上重试原因。

这些数据最终汇到三块面板:执行链路追踪、成本账单、失败原因分布。以前查一次事故要翻十几份日志,现在直接在链路面板里输入 session_id,整条时间线几秒钟就能拉出来。

4.3 成本预算:给 Agent 加个油门和刹车

Agent 执行是账单粉碎机。一个多工具 Agent 每轮可能调三到五次模型,就算单次很便宜,次数一多成本也失控。我们给每个 session 设计了 max_steps 和 max_cost 两个预算。执行到一半预算用尽,框架会停止继续调用模型,强制进入“需要人工接管”的状态,或者让模型改用备用轻量模型走完剩余流程。

预算要按层级配置:全局默认值、单一 session 值、单一工具调用值。模型调用前先检查预算剩余,如果不够,就不发请求,直接返回可解释的预算限制提示。这个“先算账,再干活”的设计在日常运营里价值极高,曾经有个业务方接入后模型成本居高不下,后来发现是他们把历史全量 ETL 进上下文,每轮都多花几千 token,改成分级记忆后直接砍掉一半成本。

5. 上线前后的实际问题与排查实战

重构听起来漂亮,真正上线时踩的坑一个比一个暗。这里挑三个最典型的真实问题,附上排查思路。

5.1 重试风暴:一次超时被放大成整站雪崩

上线第二天,某个上游工具服务因为数据库慢查询开始超时。正常情况下这只是局部故障,结果整个 Orkas 被拖崩。复盘发现是重试策略失控:模型供应商 SDK 默认重试三次,工具调用方也重试,HTTP 网关还重试,三个重试叠加,把一次超时放大成了几十倍请求量。几秒钟内队列深度暴涨,数据库连接被打满。

修复措施有三条:把所有 SDK 默认重试关掉,统一由调度层控制;重试使用指数退避加抖动,第一次 1 秒、第二次 2 秒、第三次 4 秒,并限制最多三次;队列堆积到一定水位直接快速失败,返回 429,让客户端稍后再试,而不是把任务全部排进积压队列。重试在 Agent 框架里不是免费的,每一次重试都可能带来模型费用和工具副作用,必须当作资源管控,而不是当作兜底措施。

5.2 用户只看到一句“agent execution terminated due to error”

这个错误是用户最常见到的“废话式错误”。它的来源很简单:顶层 while 循环里某个异常没有被捕获,异常一路抛到用户接口,触发了框架最后兜底的这条统一错误消息。对用户来说,这句话完全没有信息量,无法指导任何操作。

修复方案是在执行内核外层包一个 supervisor,统一捕获所有异常,先把异常现场序列化成一条 ExecutionFailed 事件,再根据错误类型判断可恢复性。可恢复错误在当前步骤的安全边界内重试;不可恢复错误则标记 session 为需要人工介入,并用可读语言解释失败原因,比如“工具查询超时,请稍后重试”而不是“agent execution terminated due to error”。这里的关键是把异常从“崩溃”变成“数据”,用户得到的是一份状态报告,而不是一个退出码。

5.3 一个 MCP 工具超时拖死整条主流程

工具统一走 MCP 协议后,曾出现过一个隐蔽问题:某个工具偶发 hang,基础默认超时时间设成了 300 秒,结果这个工具一旦被调用,整条 session 被卡在等待状态快五分钟。用户以为服务挂了,实际上是一个工具在拖住整条链。

修复方法是在工具元数据里给每个工具配置独立的超时时间。数据库查询类工具 10 秒,外部 API 调用 5 秒,发送通知类 3 秒。同时给每个工具建独立熔断器,失败率超过 30% 就自动熔断。更重要的思路变化是:工具超时应该返回一个结构化错误结果,让模型拿到这个信息后自行决策,换一条路径继续执行或者停止。把异常变成可处理的信息,而不是直接把整条流程掐断。

5.4 问题速查表

症状常见原因第一步排查动作
任务积压,CPU 99%模型 I/O 阻塞占满线程池,或重试风暴看任务队列深度和 worker 线程栈
重启后会话全部丢失状态只放在进程内存,没落盘检查 checkpoint 存储是否已接入
用户收到 terminated due to error顶层异常未被捕获查 supervisor 日志里的 ExecutionFailed 事件
工具执行了两次缺少幂等键,恢复后直接重放查 tool_invocations 表同一 call_id 的记录
上下文总是被截断每轮把全部历史塞进 prompt看 context manager 的截断触发次数

上线后的周末,我们把这些问题全过了一遍,整个平台从“靠人肉续命”变成“靠数据驱动”。效果最直观的一个数字:线上故障定位时间从小时级降到分钟级,用户会话恢复率从几乎为 0 提升到 98% 以上,而且每次恢复都能拿到哪一步失败的精确证据。

最后说几句体己话

这次重构之后,团队最大的变化是不再靠猜排查问题。以前线上出故障,只能堆日志、翻内存快照、凭感觉推原因;现在任意一个 session 拖出来就是一条完整时间线,每步动作、每个工具调用、每次重试、每次截断都摆在那里,证据链清晰可查。

如果你也在维护一个 Agent 项目,我建议从一段完整会话开始梳理:中间状态存在哪里?工具调用有没有幂等键?模型 I/O 会不会把请求线程堵死?先解决这三个问题,再谈功能丰富度,否则上层堆得越高,底层塌得越快。

最后分享一个实操经验:不要在 Agent 执行的主路径里直接修改生产数据。把关键操作拆成“预执行、审批、再执行”三步之后,我们再也没有出现过“智能体自己把状态改错却无法回滚”的事故。地基踏实之后,上面做业务才敢放手。

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

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

立即咨询