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 实例”绑定了 scope=finance,它就只能在 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.8k | 7.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,看是否并发导致上下文污染。这个“先隔离、后归因”的思路,帮我省掉了无数个排查深夜。