ARTICLE DETAIL

资讯详情

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

Agent运行时框架重构实战:状态机、工具网关与记忆分级

Agent运行时框架重构实战:状态机、工具网关与记忆分级 Orkas 是我从去年年底开始一直在维护的 Agent 运行时框架这次“重写 Agent 的地基”的底层重构基本上把我大半年前写的核心代码推翻了一半。做 Agent 开发的人应该都有同感刚搭出来的 Demo 能跑通一两步感觉很爽一旦要支持十几个工具、多个子 Agent 协作、还要稳定跑上几个月原来那套“写死 prompt 串 function calling”的方案就会全面崩盘。Orkas 原来也是一样所以我花了两个多月时间把它从“能跑的脚本集合”重构成“有边界的执行内核”。这篇文章把整个重构过程、关键设计和踩过的坑完整记录下来希望能给正在重构 Agent 框架或者准备从零搭 Agent 平台的同行一些参考。1. 为什么必须动地基旧架构的三处硬伤1.1 状态散落在业务代码里Agent 没有任何“记忆体”Orkas 的第一版其实很简单每个 Agent 任务就是一个 prompt 模板加一串工具函数模型每轮输出的 function call 直接进入 Python 的 if-else 分发逻辑。这样做 Demo 非常快但一旦任务步骤变多bug 就开始密集出现。最难受的是状态管理。一个多步骤任务执行到一半比如“查库、写中间文件、调用外部 API、汇总结果”第 4 步突然失败你根本不知道前 3 步产生了什么上下文。所有中间结果都散落在局部变量、全局变量和各种临时文件里。重新执行只能从头开始但有些外部 API 调用是有副作用的——比如已经发了一封邮件你没法简单重来。我一直觉得 Agent 的“记忆”应该是运行时的一部分而不是靠开发者在代码里拼一个 context 数组。旧版把记忆和业务逻辑完全耦合在一起导致两个直接后果第一任何状态恢复、重试、回放都做不了第二上下文一长模型就开始乱。举个生活化的例子这就像记账不是记在一个账本里而是写在桌面便利贴上哪天便利贴被风吹走了你只能凭脑子回忆记错了还不知道错在哪。这次重构里我做的第一件事就是把“状态”从业务流程中抽出来给每个任务一个显式的执行快照。状态不再由各模块自己维护而是全部收敛到一个可序列化的 ExecutionState 里记录当前执行到哪一步、已经拿到哪些观察结果、还有哪些 pending 的工具调用。这样无论崩溃、暂停、人工介入还是回滚都有据可查。1.2 工具调用是黑盒模型说什么就执行什么旧版 Orkas 在工具调用上几乎没有防御。模型返回一个 function call框架就拿着这个参数直接执行底层函数。这在 demo 阶段没毛病但到了生产环境就是灾难。第一个问题是参数可靠性。模型返回的 JSON 经常会出现 enum 写错、日期格式不对、字段多一个少一个的情况。旧版不管这些直接把参数塞给函数然后函数内部抛异常异常再返回给模型模型再猜一次。如果猜不对任务就原地打转。我见过一个很典型的例子模型把asc写成ascending我们的排序工具直接炸而模型并不知道自己写错了。第二个问题是工具返回结果不可控。很多工具返回的内容又长又杂旧版的做法是原封不动塞回 context。比如调用一个数据库查询返回 8 万字的 JSON模型在下一步里根本不知道哪些是重点反而被噪声干扰。更麻烦的是如果工具执行有副作用写文件、发消息框架完全没有审计记录——哪天 Agent 干了什么坏事你都没法追溯。所以这次重构我把工具调用单独抽成一个“工具网关”所有外部函数必须先注册、再校验、最后执行。校验规则用 JSON Schema 表达模型返回的参数先做归一化再决定是否放行。工具返回的结果也不是直接进 context而是进入一个结果缓冲区经过截断、摘要、缓存三步处理后才交给模型。一句话总结模型只负责“决策”不再负责“直接操作世界”。1.3 多 Agent 协作像两个不会挂断电话的人Orkas 早期支持多 Agent 协作但设计方式是最简单的“点对点调用”Agent A 直接调用 Agent B 的接口B 执行完再返回给 A。这种设计在只有两个 Agent 的时候勉强能跑但一多就出问题。最典型的问题是阻塞等待。A 调用 BB 又调用 CC 又要等 A 的某个结果但 A 已经阻塞在 B 那里了。整个调用链卡死而且日志里只看到超时完全看不到是谁在等谁。有一次线上任务积压了上千条原因就是两个 Agent 互相等待对方先释放资源形成死锁。另一个问题是没法重放和审计。点对点调用没有中心化的消息记录一个任务失败后你很难回答“A 到底给 B 发了什么B 又是怎么回应的”这种基础问题。所以重构时我把协作模式从“函数调用”改成了“事件驱动”。所有 Agent 之间的消息都变成事件进入统一的通信总线消费者订阅自己关心的事件类型。这样既解耦了调用链又天然支持消息持久化、回溯和重放。事件总线也让人类介入变得容易——需要人工确认时任务可以发一个NeedHumanReview事件挂在队列里等人处理完再继续。2. 重构的核心思路把 Agent 从“提示词壳”变成“状态机”2.1 执行内核把 ReAct 变成显式状态机Agent 领域现在聊得很多的一个模式叫 ReAct也就是“思考-行动-观察”的循环。老版 Orkas 的实现方式是把 ReAct 写死在 prompt 里让模型自己按这个模式输出文字和工具调用。问题是模型有时候遵守、有时候不遵守你还拿它没办法。重构后的 Orkas 不再依赖模型自觉维护循环而是把 ReAct 变成了运行时的显式状态机。每个 Agent 任务在任意时刻都处于一个确定的状态PLANNING、ACTING、OBSERVING、RETRYING、TERMINATED。模型每一轮输出后引擎根据输出内容决定要迁移到哪个状态。这样做的好处非常直接状态转换是代码控制的不是模型嘴上说了算。比如模型说要调用工具引擎不会立刻执行而是先进入ACTING状态做参数校验然后再执行工具返回后进入OBSERVING做结果处理再回到PLANNING让模型决定下一步。整个过程每一步都会发射事件、写日志排查问题的时候可以精确看到任务卡在哪一个状态上。状态机也让“重试”和“回退”变得非常容易。原来工具调用失败要重新拼接 prompt、让模型再来一遍。现在只需要把状态从ACTING回退到RETRYING带上失败原因重新触发一次决策。这个设计看似简单却是我这次重构里收益最大的一块。2.2 记忆分级别让上下文无限膨胀Agent 记忆是个特别容易被低估的问题。最开始我以为把对话历史、工具返回、中间结论全塞进 context 就行毕竟大模型的上下文窗口越来越大。但实际跑下来发现两个矛盾上下文太长模型注意力开始涣散指令遵从度直线下降上下文太短任务需要的信息又覆盖不住。Orkas 重构后的记忆体系分成了三层短期记忆当前任务最近的对话和动作序列相当于一个滑动窗口保留最近 N 条消息。中期记忆这个任务过去执行过的关键结论、工具返回摘要存到向量库里按需检索。长期记忆用户偏好、领域知识、历史任务的经验规则以结构化配置或知识条目形式存储。这三层记忆有不同的读写策略。短期记忆随任务结束就释放中期记忆会被压缩并写入向量库供后续类似任务检索长期记忆则需要人工或程序审核后才能写入避免模型自己污染自己的知识库。这个设计直接解决了两个痛点第一模型不会因为上下文太长而“迷失”因为每次进入 context 的都是针对当前任务检索出来的高相关片段第二Agent 在跨任务场景下有了延续性。比如在处理“每周自动生成周报”这类重复任务时它能记住上一次报表的格式偏好而不是每次都重新摸索。2.3 工具网关注册、校验、执行、审计四位一体我把工具调用从原来的“即调即走”改成了注册制。每个工具在 Orkas 里都有一个元信息描述包括名称、描述、参数 Schema、执行方式、权限等级、副作用声明。工具不经过网关就不具备执行能力网关是工具与模型之间唯一的通道。注册之后每次调用都走同一套流程解析模型返回的 function call 参数。用 JSON Schema 做校验和归一化。检查该工具在任务上下文里是否允许被调用权限控制。执行工具支持同步、异步、超时、重试策略。记录调用日志包括入参、出参、耗时、token 消耗。将返回结果截断或摘要后放入观察缓冲区。这个网关模型还天然引出了“安全 Agent”的概念。以前总说 Agent 安全就是“别让模型输出危险指令”但真正落地上很粗放。网关让每个工具都有明确的权限边界比如“只读工具”和“写操作工具”分开管理未经允许不能调用具备副作用的工具。有一个常见场景Agent 在尝试修 bug 时可以读日志、读配置但写文件前必须先申请写权限。这在重构前根本做不到。3. 关键模块的落地实现与参数细节3.1 步进执行器与工作流调度Orkas 的执行内核现在是一个步进式的异步执行器用 Python asyncio 实现。核心是一个run_step()方法每次调用只做一件事让模型产出一个决策然后根据决策执行相应动作。动作执行完更新 ExecutionState记录事件然后进入下一轮。我贴一段简化后的核心代码方便理解状态机是怎么实现的class AgentRuntime: def __init__(self, state: ExecutionState, tool_gateway: ToolGateway, memory: MemoryClient): self.state state self.tool_gateway tool_gateway self.memory memory async def run_step(self) - StepResult: if self.state.status TERMINATED: return StepResult(statusdone) # 1. 从记忆服务组装本轮 prompt prompt self.memory.assemble_context(self.state.task_id, self.state.tool_result_buffer) # 2. 让模型决策 decision await self.llm.decide(prompt, toolsself.tool_gateway.list_enabled_tools()) # 3. 根据决策迁移状态 if decision.type finish: self.state.status TERMINATED self.state.answer decision.output return StepResult(statusdone) if decision.type tool_call: self.state.status ACTING validated_args await self.tool_gateway.validate_and_normalize( decision.tool_name, decision.arguments ) result await self.tool_gateway.execute_with_retry( decision.tool_name, validated_args, max_retries2, timeout30, ) self.state.status OBSERVING self.state.tool_result_buffer.append( await self.tool_gateway.summarize_result(result) ) return StepResult(statuscontinue)这里有几个参数是我反复调过的。max_retries2是经验值重试一次能解决不少瞬时错误但超过两次基本说明方向错了不如把错误反馈给模型重新规划。timeout30也要看工具类型外部 HTTP API 我一般设 25 到 40 秒本地工具可以设 10 秒内超时就标记为失败不让单次慢工具卡住整个任务。调度器的另一个关键参数是最大步数限制。这个不能拍脑袋需要根据任务复杂度动态设置。普通单工具任务我设 10 步多工具编排任务设 20 到 30 步多 Agent 协作的编排任务可以到 60 步。但单纯靠步数限制还不够后面在踩坑部分我会详细说“循环检测”。3.2 记忆服务的读写策略记忆服务是这次重构独立出来的一个模块用 gRPC 接口供运行时调用。它在概念上不复杂但实现细节特别多我直接把核心配置列出来。短期记忆我用一个滑动窗口参数是capacity_tokens4000和window_size20。意思是最多保留最近 20 条消息总 token 超过 4000 时自动丢弃最老的非关键消息。这里有一个经验不是直接丢弃而是先用摘要模型把较老的消息压成一小段摘要再把摘要保留在 context 顶部作为“前置背景”。这样既不会无限膨胀又能保留关键历史。中期记忆用的是向量数据库存储维度是 1536 的 embedding。写入时机不是每步都写而是在任务完成、或者任务运行超过 10 步且中途有明确结论时才做一个“经验切片”。每个切片包含目标、关键决策、执行步骤摘要和最终结论。检索时用当前任务的描述做 query取top_k5条相关经验再把相关度低于threshold0.75的结果过滤掉。这个阈值很关键太低会把无关经验塞进去误导模型太高又什么都查不到。长期记忆我做得比较保守主要是防止模型自己给自己“洗脑”。它只存两类内容一类是人工配置的领域知识比如公司内部 API 的调用规范、数据格式说明另一类是经过审核的任务级经验只有由工具输出或人工确认过的结论才能进入长期记忆。所以长期记忆的写入接口是受限的默认只读。记忆服务的接入让我看到了一个明显变化同样一个“生成客户周报”的任务重构前每次都是 7000 到 9000 token重构后因为有长期格式记忆和中期历史数据模型不需要再从头理解需求平均只要 4500 token 左右。token 开销差不多降了三分之一这笔账算下来非常划算。3.3 Agent 间通信总线与事件驱动多 Agent 协作在重构后彻底改成事件驱动。我选择用 Redis Streams 作为底层消息队列不是因为它最强而是因为它足够轻、运维成本低、还支持消费组。每个 Agent 实例有一个独立的消息循环启动时订阅两个 channel一个是自己专属的agent.{id}.commands另一个是共享的agent.broadcast。任务创建者通过把事件写入目标 Agent 的专属 channel 来派发任务子 Agent 跑完以后把结果事件写回到父 Agent 的 channel。事件结构统一带trace_id、parent_trace_id、agent_id、event_type、payload和timestamp。这个设计最大的好处是审计我可以把任意一个业务任务的完整事件流回放出来精确到每次工具调用、每个 Agent 之间的消息传递。调试多 Agent 协作问题时我不再需要靠猜直接查事件流就能定位是哪个环节断了。事件总线也顺带解决了“排队”和“优先级”问题。以前多个任务同时要调用同一个子 Agent直接并发打过去子 Agent 扛不住。现在所有请求都进 Stream消费者按配置的优先级处理。比如priorityhigh的交互式任务可以插队prioritylow的批量任务慢慢跑互不干扰。4. 重构过程中踩过的坑与排查实录4.1 死循环最大步数限制远远不够我一开始以为有最大步数限制就万事大吉结果还是遇到一个让我头皮发麻的问题模型不停调用同一个工具且每次参数完全一样。比如它应该查询“周一的销售数据”但每次都传{date: 2024-01-15}查询到结果后不总结又去查一次如此反复直到步数用尽。最大步数限制只是最后一道保险它的问题是烧钱且浪费时间。任务跑了 30 步才发现是重复调用token 消耗早就爆了。所以我后来加了一层“循环检测”给每次工具调用生成一个指纹指纹包含工具名和归一化后的参数。执行前先查一张最近调用表如果在最近 5 步内出现过相同指纹超过 2 次就判定为疑似循环不再执行工具而是把“你已经多次调用同一个工具但未推进任务”作为新的观察结果返回给模型让它重新规划。这个策略救了我很多次。还有一个变体问题是“参数不同但语义相同”比如日期格式从2024-01-15变成January 15, 2024本质是在查同一个东西。我暂时没有做复杂的语义指纹因为成本太高但我在归一化阶段把日期、金额、时间都转成标准格式很大程度上缓解了这个问题。如果你想彻底解决可以用 embedding 对调用参数做相似度聚类但优先建议先把格式归一化做好。4.2 参数校验Schema 是对的但模型还是会传错工具 Schema 用 JSON Schema 定义后理论上能拦住所有非法参数。但实际操作中我发现一个问题模型经常返回类型不对的值。比如 Schema 里写了type: number模型返回的是字符串1000写了enum: [low, medium, high]模型返回的是very high。一开始我的做法是校验失败就报错把错误信息重新丢给模型让它改。实验下来发现重试成功率只有 40% 左右而且模型经常被同样的错误反复困住。后来我加了一层“参数归一化器”在校验之前先做类型转换和枚举映射。数字字段如果收到字符串先尝试float()转换枚举字段如果出现未知值用文本相似度匹配最接近的合法选项实在没法归一化才把错误返回给模型。这里我贴一个简化后的归一化逻辑def normalize_parameter(value, schema): if schema.get(type) number and isinstance(value, str): try: return float(value.replace(,, )) except ValueError: return value if enum in schema and value not in schema[enum]: # 用字符串距离匹配最接近的枚举值 candidates schema[enum] closest min(candidates, keylambda c: edit_distance(c.lower(), value.lower())) if edit_distance(closest.lower(), value.lower()) / len(value) 0.3: return closest return value但我也要提醒归一化不能做得太激进否则会掩盖模型的真实意图。我设了一个原则——只有“格式错误”才做自动修正“语义错误”必须返回给模型重新决策。比如用户本来想查询 A 城市的天气模型填成 B 城市这种归一化救不了必须靠上下文里的校验规则发现。4.3 多 Agent 协作时的资源争抢与优先级反转多 Agent 同时运行后我第一次遇到的问题不是逻辑而是资源争抢。几个子 Agent 同时调用同一个“写临时文件”工具互相覆盖导致后续读取的全是脏数据。查了半天才发现是并发写冲突。解决办法是给工具加锁。工具网关里内置一个分布式锁管理器对于声明为sharedFalse的工具比如写文件、修改状态同一时间只允许一个任务持有锁其他任务排队等待。对于只读工具则允许共享访问不设锁。资源争抢的另一个维度是 token 配额。如果同时跑 20 个 Agent每个 Agent 都调用一个大模型 API费用会直线上升。我做了全局配额管理按任务优先级分配模型调用并发数。高优先级任务最多同时占用 5 个并发低优先级任务则进入等待队列。这个配额不是固定的我根据历史任务平均耗时算了一个“每秒可用并发”的指标动态调整尽量不让低优先级任务饿死。4.4 可观测性没有 trace_id 的排查就是灾难重构前我最痛苦的一点是日志一大堆但都是孤立的。一条工具调用日志一条模型输入日志一条结果输出日志很难把它们串成一条完整的链路。一旦任务失败只能靠时间戳猜。重构时我把 OpenTelemetry 的 trace 概念引了进来但没用完整 sdk而是自己实现了一套轻量版每个任务从头到尾生成一个trace_id每次事件、每次工具调用都把这个字段带进去。日志系统里按trace_id聚合能直接看到任务每一步的耗时、状态、输入输出摘要。然后我做了一个简单但有效的 dashboard统计每个状态的平均停留时间、工具调用成功率、模型决策分布多少轮是直接结束、多少轮是调工具、多少轮是重复。这些指标让我能很快判断“任务卡在哪”。比如如果OBSERVING状态耗时特别长说明工具返回结果太大或摘要逻辑太慢如果PLANNING状态反复迁移说明模型在当前 prompt 下没有足够信息做决策。5. 重构后的效果与理性评估5.1 压测与真实任务数据我用一组内部测试集对重构前后做了对比。测试集包括 10 个类型共 2000 个任务覆盖数据库查询、外部 API 调用、文件处理、多 Agent 协作生成报告等常见场景。结果我直接放出来指标重构前重构后变化任务整体成功率67.3%94.6%27.3%因工具参数错误导致的失败13.2%2.1%-11.1%因上下文超长导致的失败8.9%1.4%-7.5%单任务平均 token 消耗81205480-32.5%P95 任务完成时间18.2s7.6s-58.2%多 Agent 协作任务死锁次数周均 7 次0清零这些数据不是好看而已背后都是可解释的。工具参数错误率下降是因为有了 Schema 校验和归一化上下文超长失败减少是因为记忆分级和结果摘要token 消耗下降是因为模型不需要再反复读一长串无用历史任务完成时间变短是因为不再有循环调用和点对点等待。当然我也要说清楚重构不是银弹。成功率从 67% 涨到 94% 以后剩下的 5.4% 失败任务主要来自三类外部工具本身不可用、模型推理能力不足、以及一些无法通过自动化和规则解决的领域问题。这三类问题靠框架解决不了需要靠工具降级、人机协作和更优模型来兜底。5.2 稳定性带来的连锁收益底层重构最直接的好处当然是稳定性但后续连锁收益更值钱。稳定之后我开始敢把一些真正有价值的任务交给 Orkas 去跑比如日常报表生成、跨系统数据校验、客服工单分类。之前不敢自动化不是模型不够聪明而是框架不够稳——失败、死循环、参数错误这些事每一样都会让我被运维后台的告警烦死。稳定性还带来了审计能力的提升。工具网关落地后每一个工具调用的出入参都有完整记录谁在什么时间让 Agent 执行了什么操作全部可查。这在企业场景里几乎是刚需。现在如果有人问“Agent 怎么管理权限和审计”我会直接建议把工具网关作为整个平台的地基而不是后补。6. 给同行的一句话底层重构不值得轻易做但晚了更难受6.1 什么时候你该考虑重构 Agent 框架我复盘这次重构觉得并不是所有 Agent 项目都需要动地基。如果你只是写几个链式 prompt跑通一个垂直场景保持简单反而是优势。但出现下面几个信号时就该认真考虑重构了第一每加一个新技能工具都要改动核心链路改动面越来越大第二调试多步任务时你必须靠翻日志拼凑上下文才能理解发生了什么第三模型返回的工具调用你不敢直接执行因为你不知道它会传什么参数第四多 Agent 协作任务频繁超时、死锁而且复现不稳定。这些信号出现任意两三条就说明 Agent 的执行内核已经撑不住了。不要等到线上事故频发再做那时候重构的代价会大得多。6.2 我重构过程中反复提醒自己的几件事最后分享几条最个人化的经验。第一条重构前先把当前行为“冻结”成基线用录制回放的方式跑一遍旧版任务记录每类任务的输入输出。重构不是重写你要保证新框架至少在相同任务集上不比旧版差否则改完连回归都没法做。第二条模块边界一定要清晰。我一开始重构时把记忆、调度、工具、通信全揉在一个大模块里结果改了 A 就崩 B。后来狠下心把它们拆成四个独立服务靠接口通信改起来就顺畅多了。边界清晰不是过度设计它让你每次只动一小块风险可控。第三条不要追求一步到位。我原本还想在重构时顺手支持超长上下文、加多模态、做复杂的 Agent 市场最后都砍掉了。重构应该聚焦在“执行可控、状态可恢复、工具可监管、协作可追踪”这四件事上其他都是锦上添花。跑稳了再慢慢加也不迟。现在 Orkas 还在持续迭代我也没有觉得这次重构一劳永逸。但至少我敢说接下来无论往哪个方向扩展地基都是稳的。如果你也打算动手重写自己的 Agent 底层希望这篇记录能帮你绕过我踩过的那些坑。
返回列表