ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent框架底层重构实战:从状态机到事件驱动的架构升级

Agent框架底层重构实战:从状态机到事件驱动的架构升级 1. 重构的动机为什么我要动 Agent 的“地基”做 Agent 这一年多我最大的一个感受是框架跑得动和跑得稳完全是两码事。Orkas 是我这边从零开始攒的一个 agent 开发框架最初的目标很简单——让团队能用一套代码把 LLM 调用、工具调用、上下文管理串起来快速搭出内部工具。但到了后期我们发现自己每天都在给旧代码打补丁今天修一个 memory 溢出明天补一段线程安全后天又要调 tool 调用的超时逻辑。尤其是当同时跑十几个 agent 实例、每个实例还挂着会话历史的时候旧架构的“地基”已经明显撑不住了。这次“底层重构”不是一次锦上添花的技术升级而是把过去半年积累的问题一次性清账。我给自己定的目标很直接Agent 的任务调度要可控、上下文切换要省钱、工具调用要稳定、出问题要能查。这篇文章就把整个重构的过程、踩过的坑、以及最后稳定下来的方案原原本本写出来给同样在做 agent 框架或准备重构底层代码的工程师一个参考。先交代一下旧版本到底哪里不行因为“重构”最忌讳的就是没想清楚就动手。我们旧版 Orkas 的架构其实很简单一个主循环一个工具注册表一个大字典存会话上下文。单 agent 调试没问题一旦并发上来要么是全局锁把吞吐拖死要么是多个 agent 共享同一份 memory 导致上下文串味。日志全靠 print线上查问题只能靠“重新执行一次看运气”。这些问题在 demo 阶段可以忍但到了要接真实业务流程时就非常痛苦。所以我这次重构定下了几条硬性原则每个 agent 实例必须是独立沙箱上下文、工具状态、运行状态全部隔离任务调度要显式化不能靠隐式的 while 循环临时变量凑合所有关键路径都要有 trace 日志不能光靠 print 找问题并发模型统一不做“这边线程池、那边协程池”的混搭。重构不是炫技是还债。把这几点想清楚之后整个技术选型就顺理成章了。2. 整体设计与架构拆解2.1 从“一个循环”到“状态机事件驱动”旧版 Orkas 最核心的问题是它把所有逻辑塞进了一个大 while 循环里读取用户输入拼接 prompt调用 LLM解析返回匹配 tool执行 tool拼回上下文……每个环节之间靠一堆 flag 和共享变量传递状态。这种写法在单步调试时非常直观但一旦 agent 需要“多轮工具调用后暂停等待用户确认”或者“任务 A 没跑完就要响应一个优先级更高的事件”这套循环就彻底绕不过来了。重构后的架构改成了一个轻量状态机驱动的事件循环。每个 agent 实例都维护自己的一个状态对象状态包括idle、thinking、waiting_tool、waiting_user、done 等。事件则是驱动状态迁移的因子用户消息、LLM 流式输出、工具回调、定时器超时、外部 webhook 通知。这个改动看起来很基础但它带来的效果是质变的不再需要“临时变量”来保存“上次跑到哪儿了”因为状态就写在状态机里多个 agent 实例之间天然互相隔离没有共享的可变状态暂停和恢复变得异常简单——把状态对象序列化存起来下次反序列化恢复即可。举个例子过去我们要实现“agent 调用完数据库工具后把结果发给用户确认再继续下一步”需要在 while 循环里额外维护一个 wait_for_user 的 flag然后手动跳过后续逻辑。重构后状态机在收到工具返回后直接把状态切到 waiting_user把工具结果作为事件内容发给用户订阅端然后这个 agent 实例就“挂起”了。等用户回复后事件循环再把这个实例唤醒从 waiting_user 迁移回 thinking。整个流程清晰、可打断、可恢复。2.2 运行时与调度层分离让 Agent 自己管自己的事旧架构里还有一个很别扭的地方agent 的执行线程和业务调用的线程是混在一起的。你在一个 HTTP 请求里调用了 agent.run()那这个 agent 就在这条请求线程上把整个任务跑完。如果任务里有一个耗时的工具调用整条请求就被死死的占住后续请求只能排队。重构后我做出了一个我自认为本轮最重要的一步把“运行时”和“调度层”拆开。运行时层负责一个 agent 实例的单步推进每次收到一个事件推进一个状态转移返回新的状态和输出调度层负责决定“哪个 agent 实例在什么时机被推进”以及“LLM 调用、工具调用应该用什么并发策略”。这么说可能有点抽象打个比方旧架构里每个 agent 就像是自己开着车车和司机绑死在一条路上新架构里agent 只负责“决定去哪儿”而调度层是总调度中心负责分配车道、控制红绿灯。这样带来的直接好处是如果某条 LLM 调用特别慢调度层可以把这个 agent 挂起先把 CPU 让给其他 agent 实例等响应回来了再把任务接回来。在调度策略上我最终采用了一种混合策略LLM 调用使用异步 IO 信号量限流工具调用里那些纯 CPU 计算的比如代码执行、数据解析放进有界线程池而涉及外部 IO 的HTTP 请求、数据库查询则统一走异步事件循环。这样既避免了“全异步导致 CPU 密集任务把事件循环卡死”的问题也防止了“全线程池导致线程数爆炸”。具体实现时调度层维护一个就绪队列每个 agent 实例注册一个 continuation也就是“下一步该执行哪段逻辑”的回调当外部事件到达时调度层把对应 agent 实例从等待队列挪到就绪队列由 worker 取出来执行一步。这一步执行完如果状态变成 waiting_tool就把执行权交还给调度层继续跑下一个 agent。这个设计的好处是并发规模不再受“线程数”限制而是受“事件吞吐量”限制。在一个 8 核 16G 的容器里旧框架跑 20 个并发 agent 就开始出现明显卡顿重构后跑 100 个 agent 实例依然很稳核心瓶颈反而变成了上游 LLM 服务的响应速度。2.3 上下文构建Token 从哪里来、往哪里去Agent 框架里最容易被忽视但又最能决定成本的地方是上下文构建。旧版 Orkas 的做法非常“野”把整个会话历史、系统提示词、所有工具描述一股脑拼进 prompt然后丢给 LLM。结果就是一旦会话超过十轮Prompt 膨胀严重不仅 token 费用飙升模型响应质量也会下降——因为太多噪声把真正的指令淹没了。重构时我把上下文构建做成了一个可编排的 Pipeline系统层提示词固定不随会话变化动态指令当前任务目标、用户意图工具上下文只注入当前状态可能用到的工具描述而不是全部会话记忆分层短期窗口 长期摘要临时附件当前轮新产生的内容每步之间用工厂方法注册允许不同 agent 类型覆盖默认行为。比如一个“代码助手 agent”可以把工具上下文换成完整的 Python 解释器接口说明而一个“数据分析 agent”可能会把数据库 Schema 的摘要放在动态指令层。关键收益在会话记忆的分层设计上。短期窗口保留最近 N 轮完整消息长期摘要用一次独立的 LLM 调用把更早的历史压缩成几条要点。这个“摘要”不是每次对话都实时生成而是在阈值触发时异步更新一次所以不会在每次请求中额外增加 LLM 调用次数。实测下来在同样的任务集上上下文重构后每次请求的 token 消耗平均下降了 40%而任务完成率反而提升了约 7%。因为 prompt 干净了模型回复的“废话”也少了这算是一个双赢。3. 核心机制Agent 记忆、工具调用与并发模型3.1 记忆机制重做从“一个大字典”到“三级存储”旧版的记忆机制是最让我头疼的部分。之前的做法是每个 session 一个大 dict所有 key-value 都往里塞包括用户信息、中间计算结果、历史消息、缓存对象。结果时间一长这个 dict 变得又大又乱而且多个 agent 共享同一个 session 时会出现互相覆盖的情况。重构后我把记忆拆成了三级存储工作记忆对应 agent 当前任务的运行状态放在内存里生命周期就是一个任务短期记忆对应最近 N 轮会话上下文放在内存或 Redis带过期时间长期记忆对应跨会话的用户偏好、历史摘要、沉淀下来的知识放在结构化数据库里我用的 PostgreSQL JSONB。工作记忆和短期记忆比较好理解重点说长期记忆。长期记忆的写入不是简单“存一个字符串”而是经过一个“提取-验证-沉淀”的流程当一次任务执行完成后框架会调用一次 LLM把这次任务的关键信息提炼成结构化条目比如用户偏好、任务结果、注意事项然后通过检索式召回在下次相关任务启动时自动载入。这个流程不是每个任务都要跑而是有选择性的。只有当一个任务被标记为“需要沉淀”时比如用户确认结果、任务类型属于重复性任务、或者上下文摘要超过阈值框架才触发长期记忆更新。这样既控制了 LLM 调用成本也避免了垃圾信息入库。在做记忆机制重构时我总结了一条很重要的经验记忆系统必须支持“回滚”和“清理”。旧版因为只有一个 dict数据错了没法回滚污染了之后整个 session 都废了。新版本里每次长期记忆写入都会带着一个快照版本号系统管理员或者用户可以通过指令撤回某一次写入。这个功能在调试阶段救了我很多次——有时候 LLM 会把错误的推理过程写进记忆里有了版本回滚就能迅速恢复。另外记忆的读取也要做“相关性过滤”。如果没有过滤长期记忆里存了 100 条用户偏好每次都全部加载进 prompt一方面 token 爆炸一方面真正有用的反而被淹没。我实现了一个简单但实用的召回方案根据当前任务标签和关键词从长期记忆里召回 top K 条K 默认为 5再合并短期记忆窗口中确实存在的引用。这里的 K 值建议不要设太大我试过 10 条效果反而下降因为模型容易在过多上下文里“迷失重点”。3.2 工具调用的重构从注册表到可编排工具链工具调用是 Agent 发挥作用的核心机制也是这次重构中改动最大的部分之一。旧版实现了一个「万能 tool 注册表」——任何函数往里一注册agent 就能调用。听起来很灵活但实际用起来问题很多没有参数校验LLM 传一个非法参数直接让真实业务函数抛异常没有超时控制一个工具卡住了整个 agent 都卡住没有权限隔离一个 agent 能调用所有已注册的工具无法做最小权限控制没有日志工具调用的入参、出参、耗时都没有记录出了错根本不知道是哪一步。新架构引入了“工具链”概念。一个 agent 实例不再面对一个大而全的注册表而是面对一个按任务实例化的工具列表。工具链在 agent 启动时构建可以动态追加、移除、替换。每个工具封装在独立执行单元里带上一组“门卫”参数校验层用 JSON Schema 声明工具参数LLM 给出的工具调用先经过 schema 验证不合法就直接返回错误参数说明而不是把异常抛到业务层。超时控制层每个工具都有独立超时设置默认 10 秒超时后返回一个明确的“工具执行超时”标记同时将 agent 状态迁移回 thinking 并允许重试或换工具。执行沙箱层工具执行发生在专用工作线程池中而不是 agent 主线程上。这样即使工具内部出现死循环或内存泄漏也可以通过隔离手段结束不会拖垮整个 agent。这个设计对并发场景特别重要。旧架构里如果两个 agent 实例同时调用同一个有状态的工具比如一个共享的文件写 handle就会出现竞态。新架构里每个工具的实例状态都是 per-agent 的工具本身如果是无状态的则走共享只读路径如果是 hasState 的则必须声明并自动获得独立副本。3.3 并发模型Agent 框架究竟怎么扛并发“AI Agent 怎么扛并发”是最近社区里讨论热度非常高的一个话题我这次重构对这个问题的回答可以总结成三句话异步化 IO、有界化计算、状态勿外泄。异步化 IO 比较传统凡是对外部服务的调用LLM、HTTP API、数据库全部走异步非阻塞方式通过事件循环调度。这里要特别注意一点在使用 Python 做底层实现时requests这种同步库会把整个线程阻塞住必须换成httpx.AsyncClient或者aiohttp。我们在重构时用了一个不可变的原则——同步 IO 不允许出现在 agent 运行时的主路径上唯一例外是启动时的配置加载。有界化计算意味着凡是 CPU 密集的工具调用不能直接丢进异步事件循环里执行因为会阻塞其他协程。必须是放进一个ThreadPoolExecutor并且设置最大线程数。这个最大值怎么定不是拍脑袋选的而是根据容器 CPU 核数和工具平均执行耗时算出来的。我的经验值是max_workers min(32, cpu_core_count * 2)。这个数值在大多数场景下都能在延迟和吞吐之间取得平衡。状态勿外泄则是一个工程纪律agent 的运行时状态不能被任何工具函数直接握住引用。工具要读取某个状态必须通过框架提供的只读视图工具要修改状态必须通过框架的消息接口。这个约束保证了一个 agent 的内存状态不会被另一个并发执行的任务“误改”。说白了就是把共享内存思维改成消息传递思维。作为参考重构完成后我们在内部做了一次压测在一个 4 核 8G 的容器里模拟 50 个 agent 实例同时运行每个实例平均 8 轮对话、4 次工具调用。旧架构的吞吐约 3.2 请求/秒P99 延迟 12 秒新架构吞吐 11.8 请求/秒P99 延迟 4.5 秒。而且更重要的是新架构几乎没有出现因线程池竞争导致的“假死”现象。4. 实操过程重构的完整时间线和实现要点4.1 阶段划分与每一步的目标很多朋友问我说“重构一个框架应该从哪里开始”我的经验是按依赖关系从内到外推进而不是按代码模块逐个重写。第一阶段是搭建事件循环和状态机骨架。这一阶段的目的很简单——先把执行骨架立起来不接任何真实逻辑。我花了大约三天时间写了一个最小版本一个 EventLoop若干 AgentInstance每个实例维护一个状态字段和一个 step 方法。方法里只需要打印日志返回“已处理”。这个阶段的关键是验证并发模型是否合理让 100 个实例同时在事件循环里空转观察 CPU 和内存是否平稳。第二阶段是接入 LLM 调用。事件循环跑通了以后我把第一步接入了真实 LLM API。这一步要解决的核心问题是“异步调用后如何回到状态机”。我的实现是状态机的 step 方法里如果当前需要调用 LLM就异步发一个请求并注册一个回调然后立刻返回“waiting_llm”状态。等响应到了事件循环触发一个llm_response事件调度层再把实例放回就绪队列。这里要注意 LLM 调用失败和超时的处理必须明确重试策略我当时定的策略是网络错误重试 2 次HTTP 429 用指数退避等待模型侧错误比如 content filter直接返回失败不重试。第三阶段是工具调用与准备环境。LLM 通了之后我开始接工具调用链。这里的难点在于“LLM 返回的 JSON 不一定符合 schema”所以参数校验必须在真正执行前拦截。我把工具注册表重构为工具工厂模式每个工具声明自己的输入 schema、执行函数、超时、权限标记、运行池类型。调用流程变成解析 LLM 返回的 function_call - 查工具工厂 - 参数校验 - 构建执行单元 - 投递到目标运行池 - 监听结果事件 - 把结果注入上下文。第四阶段是记忆系统和持久化。状态机和工具链都跑顺了之后我开始做记忆系统。这一步有个很容易踩的坑如果把记忆持久化直接做在事件循环的每次状态迁移里代价太高。所以我用了“标记-异步刷新”策略工作记忆每次变化后打上一个“脏标记”由后台线程定时把脏数据刷入存储而不是实时写库。这样做的好处是状态迁移过程不会被 IO 阻塞代价是宕机时会丢最近几秒的工作记忆但对大多数 Agent 应用场景来说可以接受。4.2 关键实现一个最小化的状态机代码参考代码不贴太长核心的状态机骨架大概是这个意思。真实项目里细节更多但抽出主干可以让读者更容易理解运行机制class AgentState(Enum): IDLE idle THINKING thinking WAITING_LLM waiting_llm WAITING_TOOL waiting_tool WAITING_USER waiting_user DONE done class AgentInstance: def __init__(self, agent_id, context_builder, tool_chain): self.agent_id agent_id self.state AgentState.IDLE self.context_builder context_builder self.tool_chain tool_chain self.working_memory {} async def step(self, event: dict): if self.state AgentState.IDLE: prompt await self.context_builder.build(...) task self._call_llm_async(prompt) self.state AgentState.WAITING_LLM return self.state, task if self.state AgentState.WAITING_LLM: if event[type] llm_response: tool_calls parse_tool_calls(event[content]) if tool_calls: self.state AgentState.WAITING_TOOL payload await self.tool_chain.invoke(tool_calls[0]) return self.state, payload else: self.working_memory[reply] event[content] self.state AgentState.DONE return self.state, event[content] elif event[type] llm_error: self.state AgentState.THINKING return self.state, {error: event[error]}这里每一步都返回“新状态 事件载荷”由调度层决定下一步投放到哪个队列。这样实例本身不需要持有任何锁所有并发控制收敛到调度层。代码写起来也会觉得每一行都很干净没有“上下文变量散落各处”的焦虑感。第四阶段结束之后就是一个非常稳定、可测试的 Agent 运行时内核了。此时再接应用层业务比如多轮客服、知识库问答、数据分析助手基本上都是“往工具链里加工具”的活儿不用再碰底层。4.3 重构中要避开的几个经典陷阱整个重构过程大约花了三周其中有几次差点让我推倒重来。整理几个最典型的坑供各位参考第一个坑是“事件循环里不要做阻塞操作”。听起来像是废话但真实重构时我一度把日志写入、指标上报这些 IO 操作直接塞进事件循环结果高并发下日志系统成了瓶颈。后来把所有侧写 IO日志、指标、审计全部改成批量异步写入主事件循环只做状态迁移和消息分发性能立刻上了一个台阶。第二个坑是“LLM 的流式输出和状态机的衔接”。一开始我把流式输出直接透传给最终用户但同时还要处理工具调用导致状态机里出现“边流式边等工具”的混乱。后来我把流式输出收拢成“前置输出缓冲策略”如果一次 LLM 响应既包含文本又包含工具调用框架先把文本缓存住待工具调用执行完毕后再统一输出。虽然用户体验上会有短暂的延迟但换来的是状态机稳定性和任务一致性。第三个坑是“工具执行的超时和重试必须分开控制”。旧版本里超时的工具会自动触发一次重试结果导致很多“慢请求”被执行了两次下游数据库里插入了重复数据。新版本中超时不代表要重试——只有标记了 idempotent 的工具才允许自动重试其他工具超时后把选择权交给 LLM让它决定是换工具还是让用户介入。这个语义分离极大地减少了“幽灵副作用”。第四个坑是“记忆持久化不能阻塞主流程”。我一开始尝试在每个状态迁移结束时同步写入记忆存储结果单次迁移耗时从 0.2ms 涨到了 5ms并发一高就出现了明显排队。后来改成上面说的“脏标记异步刷新”策略后主流程耗时几乎无感持久化由后台线程周期性地批量执行。代价是要接受极端情况下最后几秒的记忆丢失但对于非金融级场景这个取舍是完全值得的。5. 可观测性重构让 Agent 的一切行为有迹可循5.1 Trace 贯穿整个 Agent 生命周期底层重构让我体会最深的一点是Agent 框架的排障难度远高于普通 Web 服务因为一条用户请求在 agent 内部会引发多次 LLM 调用、多次工具调用、多次状态转移它们之间不是简单的“同步调用链”而是一棵发散的执行树。如果你没有做埋点出问题时连“责任的链条”都还原不出来。旧版 Orkas 的日志是零散的 print每个函数打印自己的那一段没有任何 request_id 关联。线上 agent 答错了我们只能靠“用户提供了什么输入”去反查但中间 LLM 到底输出了什么、工具返回了什么全是黑盒。重构时我把可观测性提升到了“一等公民”的位置每个 agent 实例启动时生成一个 trace_id每次状态迁移包括事件内容、耗时、状态目标都作为一条 span 记录每次 LLM 调用的入参prompt 摘要、model、temperature和出参响应摘要、token 用量都会随之记录工具调用层单独记录 schema、入参、出参、耗时、超时标记。用的标准工具是 OpenTelemetry Jaeger。最开始我担心 OTEL 的复杂度和性能开销会不会对 agent 主流程产生负面影响实测下来 span 采集的 CPU 开销约在 2%-3%可以接受。这套 Trace 体系在重构后期帮了大忙。举个例子有一次我们发现在某个客户场景下agent 总是在第三步工具调用时“卡住”但业务日志里完全看不出异常。加上 trace 后发现是工具调用返回了一个超长的 JSON 数组序列化存储时占满了内存缓冲导致后续状态机一直阻塞在等待资源释放。这种问题靠 print 一辈子都查不出来。5.2 会话回放与调试沙箱复现 Agent 问题的关键Agent 问题还有一层的特殊性很多 bug 是概率性的依赖上下文、依赖 LLM 的随机性。同一个输入跑十次可能只挂一次。传统调试手段完全无力。为此我在新架构里加入了“会话回放”机制——每轮完整的 trace 数据状态迁移、LLM 响应、工具结果都会持久化可以按 trace_id 还原出整个执行过程然后放入一个调试沙箱中重现。调试沙箱的核心能力是“断点注入”你可以指定某个状态迁移点改写当时 LLM 的响应数据比如模拟一个工具调用参数的边界情况然后继续执行查看后续状态机表现。这相当于给 agent 增加了一个可控的“时间旅行调试”工具。在排查“LLM 偶尔会传入非法工具参数”之类的顽疾时这个机制几乎成了救命稻草。做好可观测性的逻辑其实很简单你只有能完整地看到一次 agent 执行的内部全过程你才有资格去优化它。这句话放在 Agent 框架这种高度不确定的系统上尤为适用。LLM 的输入输出本身是黑盒但框架侧的调度、记忆、工具调用行为应该是可解释的。5.3 从 Trace 中提炼出的性能优化点有了 trace 数据后做性能分析就变得很轻松。我的方法很简单把一次任务的 trace 拉出来按 span 耗时排序。你会非常直观地看到时间都去哪儿了。在我自己的重构中通过 trace 分析发现了三个意外瓶颈第一个是记忆持久化虽然用了异步刷新但刷新时锁粒度太大导致工作记忆读取偶尔会阻塞到主流程。后来把锁拆成读写锁读多写少的问题顺利解决。第二个是工具链中某些工具的 schema 校验开销很大——特别是有嵌套 JSON 的时候。后来我给 schema 校验加了缓存同一个工具的同一份 schema 只编译一次后续直接复用。第三个是 LLM 的 token 计数在 prompt 构建阶段会被重复计算多次构建器算一遍、缓存管理算一遍、token 限制器又算一遍。后来改成在构建器里一次性计算并透传省了大概 20% 的 CPU 时间。这些优化没有一项是“拍脑袋想出来的”都是 trace 数据直接告诉我“这里慢去看”。所以我的建议非常朴素如果你的 agent 框架还没有 trace 体系那你现在最应该做的不是调 prompt而是先把 trace 建起来——磨刀不误砍柴工。6. 安全与稳定性Agent 框架的底线思维6.1 Agent 安全工具调用权限与业务数据隔离“Agent 安全”是今年社区里关注度飙升的领域吴恩达提出 agent 相关课程后安全问题被越来越多的团队摆上桌面。我的理解比较务实不要指望一次架构设计就能解决所有安全问题但作为一个 Agent 框架至少要在底层把“隔离”和“最小权限”这两件事做成默认能力。旧版 Orkas 里任何一个 agent 实例都能调用注册表里的所有工具。这在内部 demo 阶段没问题但一旦 agent 接入了生产环境的数据库、订单接口、内部文档搜索风险就放大了。一个 prompt injection 就可能导致 agent 调用一个它根本不应该调用的敏感接口。重构后的权限模型分为两层工具级权限每个工具声明自己的“可见性标签”agent 实例在声明时只能挂载被授权的标签集合。挂载动作在 agent 构建时完成运行时不可变除非有显式的管理员指令。数据级隔离每个 agent 实例都有一个 tenant 或 scope 标识工具执行时从上下文中读取这个标识并以此过滤数据。比如一个“数据分析 agent 实例”绑定了 scopefinance它就只能在 finance 这个项目空间内查数据。这两层防线的意义是即使 LLM 被 prompt injection 欺骗它也无法调用不在它工具链里的工具更无法通过有权限的工具读取到自己 scope 之外的数据。我给团队定的一个原则是——Agent 可以犯错但框架不能让它犯危险的错。6.2 稳定性保障限流、熔断与优雅降级底层重构里除了功能和性能稳定性是另一个我花了大量时间的维度。Agent 框架的稳定性挑战在于它的调用链路比较稀疏但偶然性极强——几十次工具调用里只要有一次超时、一次 LLM 限流整个任务就可能以失败告终。所以稳定性设计必须贯穿底层而不是留给上层业务处理。我做了三个关键的稳定性组件限流器设置在框架与 LLM 服务之间算是第一道防线。采用令牌桶算法按模型维度控制并发请求数。比如 GPT-4o 这个模型我们的并发上限设的是 8超过这个数的新请求直接排队而不是一股脑打给上游导致上游触发 429。这个限流器的作用不是让响应更快而是保护整体存活率。熔断器则用于工具调用链路。每个工具执行时统计失败率和平均耗时如果某个工具最近一分钟的错误率超过 30%熔断器打开后续调用直接快速失败。快速失败不是结束任务而是把错误信息交给 LLM让它换一种方式实现目标。这个设计很有用——比如一个上游 API 挂了agent 不会傻乎乎地不断重试同一个失败的调用而是会改用其他工具或直接告诉用户“当前网络服务不可用”。优雅降级则体现在“任务失败时的收尾动作”。旧版架构里agent 一旦在中途遇到不可恢复的错误所有中间结果全部丢失用户只能从头再来。新版本中我加了一个“checkpoint”机制每完成一个关键状态转移就把工作记忆的存盘点写入存储。当任务失败后用户可以选择从最近的 checkpoint 继续恢复而不必完全重新开始。这在实际使用中受到很多好评。这项设计让我深刻地认识到Agent 框架做稳定性的目标不是“永远不出错”而是“出错后可恢复、可解释、可绕行”。因为 LLM 的不确定性决定了我们无法制造一个永远不犯错的 agent但我们可以通过底层机制让犯错的影响范围可控。6.3 Checkpoint 与任务恢复机制简单说一下 checkpoint 的实现思路。每个 agent 实例在运行过程中会周期性约每 5 个状态迁移或每隔 30 秒生成一个快照内容包括当前状态、工作记忆内容、工具链上下文、未决事件列表。快照以 JSON 格式写入 Redis 或本地文件。恢复时框架读取该快照重建实例对象注册事件处理器然后从上次的状态继续等待事件。这里有一个细节很关键快照里的 LLM 调用是“已发起但未返回”的状态恢复后不可能真正拿到当时的网络响应。所以我的做法是如果快照时正处于 WAITING_LLM 状态恢复后直接将状态重置为 THINKING并且在上下文中标记“上一次模型响应丢失需要重新生成”。这样虽然偶尔会重复一次 LLM 调用但换来的是状态机永远不会因为“半路丢失的响应”而卡死。这个代价是可以接受的。checkpoint 可能不是一个“华丽”的功能但在真实的生产环境里它确实能让用户的调试焦虑下降一截。每次看到用户在系统里说“刚才那个任务失败了点了恢复居然真的从中间结果继续了”我就觉得这个底层设计值了。7. 重构成效与经验沉淀7.1 数字对比重构到底带来了什么文章写到这里有必要把重构的成效用数字量化一下。我们以同一个内部 benchmark 任务集共 50 个多轮推理任务做前后对比结果如下指标重构前重构后变化平均单任务 token 消耗12.8k7.6k下降 41%任务完成率LLM 正确走完全程78%87%提升 9%并发 30 实例时 P99 延迟9.7 秒4.1 秒下降 58%工具调用失败导致任务中断率16%4%下降 75%可观测覆盖率关键路径 trace 完整率不足 20%95%大幅提升这些数字不绝对精确但趋势是很明确的。最令我意外的是 token 消耗下降得那么明显——它说明之前很多 token 都花在了“重复描述历史”“来回粘贴工具结果”上了而这恰恰是上下文构建优化最大的红利。当然也有一个指标变“差”了单次状态迁移的平均耗时从 0.2ms 涨到了 0.5ms。但这是因为加入了 schema 校验、trace 记录、权限检查等必要的“基础设施成本”。换来的结果是状态迁移具备了可回溯、可验证、可防护的能力这个代价是值得的。7.2 后续还能在哪些方向上扩展重构完成后Orkas 已经能在生产环境里跑两周没有出现阻塞性问题。但作为一个长期维护的项目我心里很清楚很多方面还只是“基本能用离完善还有距离”。我接下来打算推进的方向有三个第一是让 Agent 状态机支持更复杂的子任务编排——不止是“顺序执行”还要支持“并行分支、母任务等待子任务聚合”。第二是把长期记忆的“提取-验证-沉淀”流程做得更智能比如引入记忆重要性评分减少垃圾沉淀。第三是想把工具的权限模型从“静态声明”升级为“动态策略”——根据当前网络的信誉度、操作风险等级实时限制敏感接口的调用。如果你正在做类似的 agent 框架重构我的最大一条建议是不要把重构的重心放在“做出更炫酷的架构”上而是放在“让混乱变得有序、让黑盒变得透明、让失控变得可恢复”上。Agent 本身已经足够不确定了框架的任务就是给它确定性的骨架。这三周的重构让我对 Agent 系统的复杂度有了完全不一样的认识。以前我觉得 Agent 的核心是提示词工程现在我会说 Agent 框架的核心是运行时工程。提示词决定智商上限而运行时决定可靠度下限。没有可靠的地基再聪明的 Agent 也只是沙丘上的碉堡看着好看一推就倒。最后分享一个小的调试技巧如果你的 agent 在高并发下行为变得诡异先别急着怀疑 LLM把它跑在一个单线程沙箱里重复同样的任务五次。如果能稳定复现那大概率是状态机或记忆管理的问题如果五次里只有一两次异常再去翻 trace看是否并发导致上下文污染。这个“先隔离、后归因”的思路帮我省掉了无数个排查深夜。
返回列表