Spots

我把 Agent 的 while 循环拆掉了:一种你可能没想到的 Agent 架构

当 Durable Execution 遇上 Agent,Agent 就该是个微服务 当 Durable Execution 遇上 Agent,Agent 就该是个微服务 去年年底,我们的 Agent 平台遇到一个很朴素的麻烦:容器太贵,进程太脆,网络太乱。

具体来说是三件事叠在一起。线上跑着几千个会话,一个用户一个 Docker 容器,agent 的 loop(while 一直转,调 LLM、跑工具、再调…

具体来说是三件事叠在一起。线上跑着几千个会话,一个用户一个 Docker 容器,agent 的 loop(while 一直转,调 LLM、跑工具、再调 LLM)整个塞在这个容器里。第一个月我们发现,容器 CPU 平均利用率不到 5%——因为 agent 九成时间在等 LLM 返回、等工具执行、等用户点确认,但我们为这 5% 付了 100% 的钱。第二个问题,某次宿主机抖动,几十个正在跑工具的容器同时挂了,用户会话的对话历史、已经跑了一半的代码、装好的依赖,全没了,因为它们只存在于那个容器的内存里。第三个问题是网络:每个容器要开自己的网关转发用户流量、要接 Consul 做服务发现、还要打通内网调用业务系统,运维同学每天在处理本不该存在的问题。 那天晚上我盯着那段 while 看了很久。它没有 bug,它只是长错了——它是一个用微服务时代的架构范式(一个进程干完一整件事)去承载一个本质上属于分布式系统的东西。 于是我决定做一个当时看起来有点疯的决定:把 while 循环拆掉,让 Agent 变成一堆互相调用的服务。 这篇文章讲我踩的坑、想明白的事,以及最终那套跑通的架构。 一、先说清楚:为什么 while 循环是个"单体" 我们习惯用"单体 vs 微服务"来描述大系统,但 Agent 循环和微服务有一个惊人的同构性,这也是我后来想通的全部起点。

最后一行是灵魂所在。 单体里 orderService.pay() 是一次函数调用,万毫秒级,失败了重试就行。Agent 里 runShell("npm…

最后一行是灵魂所在。 单体里 orderService.pay() 是一次函数调用,万毫秒级,失败了重试就行。Agent 里 runShell("npm install") 是一段跨越几千毫秒、可能产生半成品文件、可能烧掉几十美金的外部副作用。这两者的失败语义根本不同,你用同一套代码结构承载它们,必然痛苦。

而这个"痛"我给起了个名字:唤醒成本决定休眠策略。因为重建一个容器要秒级,而一个会话空闲期可能有十分钟,于是我们只能拍脑袋定个 30 分钟…

而这个"痛"我给起了个名字:唤醒成本决定休眠策略。因为重建一个容器要秒级,而一个会话空闲期可能有十分钟,于是我们只能拍脑袋定个 30 分钟 TTL——不敢睡太久浪费钱,也不敢睡太短把用户等醒了。TTL 是个被迫的启发式妥协,它掩盖了真正的问题:状态被绑在了计算上。 二、Durable Execution 给了我答案的一半

如果你关注过分布式系统,一定听过 Temporal。它解决的问题是:写一段"业务逻辑",进程随时可能崩溃,但这段逻辑最终会跑完。做法是 event…

如果你关注过分布式系统,一定听过 Temporal。它解决的问题是:写一段"业务逻辑",进程随时可能崩溃,但这段逻辑最终会跑完。做法是 event sourcing——把逻辑执行过程中每一个决策都记成事件,崩溃后从事件流重放出内存状态继续跑。 Durable Execution 最有价值的一句话是:Durable Execution 是 crash-proof execution。 你只写 happy path,故障处理交给平台。 我当时的第一反应是:这就是我要的。但冷静下来后发现,Agent 场景和传统工作流有两个关键差异:

差异 1:Agent 的"活动"是不对称的。 Temporal 里 activity 通常是调一个 API,几百毫秒。Agent

差异 1:Agent 的"活动"是不对称的。 Temporal 里 activity 通常是调一个 API,几百毫秒。Agent 的 activity 是 LLM 推理——动辄 30 秒到 5 分钟,token 成本真实存在,而且重跑一次是要花钱的。事件溯源能保证"不重复执行已完成的决策",但保证不了"崩溃在 LLM 刚返回、结果还没落库那一刻,不重复花这笔钱"。

差异 2:Agent 的执行体很重。 Temporal 假设 activity 跑在你自己写的 worker

差异 2:Agent 的执行体很重。 Temporal 假设 activity 跑在你自己写的 worker 进程里,毫秒级拉起。但 Agent 需要一个能装下 Node、Python、浏览器甚至完整 Linux 发行版的沙箱。Temporal 不会替你解决这个问题——你可以把 activity 指向任何沙箱,但连接、挂载、生命周期,全得自己搞。

于是我意识到,我需要的不是"Temporal 的事件溯源",而是一个专门为 Agent 定制的、单步级别的、可休眠可恢复的运行时。我最后的选择是:借事件溯源的思…

于是我意识到,我需要的不是"Temporal 的事件溯源",而是一个专门为 Agent 定制的、单步级别的、可休眠可恢复的运行时。我最后的选择是:借事件溯源的思想(journal 是唯一真相源),但自己实现执行层。回头看,这个决定是对的——不是因为 Temporal 不好,而是因为把 LLM 调用当成 activity,会有一个"崩溃在写库前"的窗口,Temporal 的 event sourcing 解决不了(它只保证不重跑已记录的步骤,不保证不重跑正在执行的步骤)。这个窗口只能靠意图(intent)前置 + 幂等键来堵,这正是我后文 I4 不变量的由来。 三、核心洞察:把 Agent 当成"只有一步"的东西 Agent 的最小可恢复单位不是"会话",而是"一次推理"。 Agent 的最小可恢复单位不是"会话",而是"一次推理"。 整个系统只做一件事:拿到一次推理机会,把当前状态喂给 LLM,把结果落库,然后决定下一步谁来。 "tool-use" 和 "纯文本回复" 两种结局: 进程退出后,什么都不占着。 下一步的触发者有两个:工具执行完回推(gateway 回调),或者用户又发了一句话。两个触发都通过队列回到"再跑一次 Step"。中间无论多久,无论哪个进程挂了,状态都在 journal 里躺着。 限界上下文(Bounded Context) 划成三个: 聚合根(Aggregate Root) 是 session。所有写状态的操作都必须经过它,而且——这是我觉得最值钱的一条设计——所有状态迁移都必须带来源状态白名单: 代码层面我强制了"from 列表不允许为空"——系统里不存在一条能无条件改状态的 SQL 路径。听起来像洁癖,但它一次性消灭了一整类 bug(下面第八节有实例)。 领域事件(Domain Event) 就是 journal 里的 event 表,它同时是:审计日志、/history 接口的数据源、context 重建的原料、SSE 的内容源。一份数据,四个用途——这是把状态外置做干净的最大收益。

防腐层(ACL) 出现在两处:LLM 端点(我只需要"给我 context,还我一个带 tool_calls 的响应",不关心…

防腐层(ACL) 出现在两处:LLM 端点(我只需要"给我 context,还我一个带 tool_calls 的响应",不关心 OpenAI/Anthropic/Qwen 的协议差异)和沙箱(工具执行只认 sandbox.Sandbox 接口,将来换 OpenSandbox / CubeSandbox 不用动业务)。 四个进程,各自独立部署、独立扩缩容。真相源是一个 SQLite 文件(WAL 模式)。

插一句:我原本是写了 Kitex RPC 层的(RunStep(session_id) 这样的单步服务),后来在 MVP…

插一句:我原本是写了 Kitex RPC 层的(RunStep(session_id) 这样的单步服务),后来在 MVP 阶段主动删掉了——四个进程共享同一个库,直接调库函数更简单,状态机一行没改。RPC 层是后面加回去的,接口边界早就切干净了。有时候砍掉一层抽象比补一层更划算。

News

我把 Agent 的 while 循环拆掉了:一种你可能没想到的 Agent 架构

当 Durable Execution 遇上 Agent,Agent 就该是个微服务 当 Durable Execution 遇上 Agent,Agent 就该是个微服务 去年年底,我们的 Agent 平台遇到一个很朴素的麻烦:容器太贵,进程太脆,网络太乱。

@spots
Source: Juejin
See more like this