[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f1ej8mjmbfyolx":3},{"_id":4,"slug":5,"title":6,"subtitle":7,"kind":8,"cards":9,"tags":57,"categories":58,"source":59,"lang":62,"author":63,"audioState":66,"stats":67,"publishedAt":70,"renderer":71},"6abb45daca21c797c7e9c0d9","agent-while-agent-1c02c4ab","我把 Agent 的 while 循环拆掉了：一种你可能没想到的 Agent 架构","当 Durable Execution 遇上 Agent，Agent 就该是个微服务 当 Durable Execution 遇上 Agent，Agent 就该是个微服务 去年年底，我们的 Agent 平台遇到一个很朴素的麻烦：容器太贵，进程太脆，网络太乱。","news",[10,12,17,22,27,32,37,42,47,52],{"headline":6,"body":7,"imageUrl":11,"sourceImageUrl":11},"https:\u002F\u002Fp3-xtjj-sign.byteimg.com\u002Ftos-cn-i-73owjymdk6\u002F200789e9b8d54dfa9cb394108dabe792~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgR3JlZW5UZWE=:q75.awebp?rk3s=f64ab15b&x-expires=1791215697&x-signature=V%2BrFDBxJAU4wU02X5z7RUkd%2B3tE%3D",{"headline":13,"body":14,"imageUrl":15,"images":16},"具体来说是三件事叠在一起。线上跑着几千个会话，一个用户一个 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 循环和微服务有一个惊人的同构性，这也是我后来想通的全部起点。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F1.webp",{"local":15},{"headline":18,"body":19,"imageUrl":20,"images":21},"最后一行是灵魂所在。 单体里 orderService.pay() 是一次函数调用，万毫秒级，失败了重试就行。Agent 里 runShell(\"npm…","最后一行是灵魂所在。 单体里 orderService.pay() 是一次函数调用，万毫秒级，失败了重试就行。Agent 里 runShell(\"npm install\") 是一段跨越几千毫秒、可能产生半成品文件、可能烧掉几十美金的外部副作用。这两者的失败语义根本不同，你用同一套代码结构承载它们，必然痛苦。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F2.webp",{"local":20},{"headline":23,"body":24,"imageUrl":25,"images":26},"而这个\"痛\"我给起了个名字：唤醒成本决定休眠策略。因为重建一个容器要秒级，而一个会话空闲期可能有十分钟，于是我们只能拍脑袋定个 30 分钟…","而这个\"痛\"我给起了个名字：唤醒成本决定休眠策略。因为重建一个容器要秒级，而一个会话空闲期可能有十分钟，于是我们只能拍脑袋定个 30 分钟 TTL——不敢睡太久浪费钱，也不敢睡太短把用户等醒了。TTL 是个被迫的启发式妥协，它掩盖了真正的问题：状态被绑在了计算上。 二、Durable Execution 给了我答案的一半","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F3.webp",{"local":25},{"headline":28,"body":29,"imageUrl":30,"images":31},"如果你关注过分布式系统，一定听过 Temporal。它解决的问题是：写一段\"业务逻辑\"，进程随时可能崩溃，但这段逻辑最终会跑完。做法是 event…","如果你关注过分布式系统，一定听过 Temporal。它解决的问题是：写一段\"业务逻辑\"，进程随时可能崩溃，但这段逻辑最终会跑完。做法是 event sourcing——把逻辑执行过程中每一个决策都记成事件，崩溃后从事件流重放出内存状态继续跑。 Durable Execution 最有价值的一句话是：Durable Execution 是 crash-proof execution。 你只写 happy path，故障处理交给平台。 我当时的第一反应是：这就是我要的。但冷静下来后发现，Agent 场景和传统工作流有两个关键差异：","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F4.webp",{"local":30},{"headline":33,"body":34,"imageUrl":35,"images":36},"差异 1：Agent 的\"活动\"是不对称的。 Temporal 里 activity 通常是调一个 API，几百毫秒。Agent","差异 1：Agent 的\"活动\"是不对称的。 Temporal 里 activity 通常是调一个 API，几百毫秒。Agent 的 activity 是 LLM 推理——动辄 30 秒到 5 分钟，token 成本真实存在，而且重跑一次是要花钱的。事件溯源能保证\"不重复执行已完成的决策\"，但保证不了\"崩溃在 LLM 刚返回、结果还没落库那一刻，不重复花这笔钱\"。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F5.webp",{"local":35},{"headline":38,"body":39,"imageUrl":40,"images":41},"差异 2：Agent 的执行体很重。 Temporal 假设 activity 跑在你自己写的 worker","差异 2：Agent 的执行体很重。 Temporal 假设 activity 跑在你自己写的 worker 进程里，毫秒级拉起。但 Agent 需要一个能装下 Node、Python、浏览器甚至完整 Linux 发行版的沙箱。Temporal 不会替你解决这个问题——你可以把 activity 指向任何沙箱，但连接、挂载、生命周期，全得自己搞。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F6.webp",{"local":40},{"headline":43,"body":44,"imageUrl":45,"images":46},"于是我意识到，我需要的不是\"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 表，它同时是：审计日志、\u002Fhistory 接口的数据源、context 重建的原料、SSE 的内容源。一份数据，四个用途——这是把状态外置做干净的最大收益。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F7.webp",{"local":45},{"headline":48,"body":49,"imageUrl":50,"images":51},"防腐层（ACL） 出现在两处：LLM 端点（我只需要\"给我 context，还我一个带 tool_calls 的响应\"，不关心…","防腐层（ACL） 出现在两处：LLM 端点（我只需要\"给我 context，还我一个带 tool_calls 的响应\"，不关心 OpenAI\u002FAnthropic\u002FQwen 的协议差异）和沙箱（工具执行只认 sandbox.Sandbox 接口，将来换 OpenSandbox \u002F CubeSandbox 不用动业务）。 四个进程，各自独立部署、独立扩缩容。真相源是一个 SQLite 文件（WAL 模式）。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F8.webp",{"local":50},{"headline":53,"body":54,"imageUrl":55,"images":56},"插一句：我原本是写了 Kitex RPC 层的（RunStep(session_id) 这样的单步服务），后来在 MVP…","插一句：我原本是写了 Kitex RPC 层的（RunStep(session_id) 这样的单步服务），后来在 MVP 阶段主动删掉了——四个进程共享同一个库，直接调库函数更简单，状态机一行没改。RPC 层是后面加回去的，接口边界早就切干净了。有时候砍掉一层抽象比补一层更划算。","\u002Fapi\u002Fmedia\u002Fposts\u002Fagent-while-agent-1c02c4ab\u002F9.webp",{"local":55},[],[],{"name":60,"url":61},"Juejin","https:\u002F\u002Fjuejin.cn\u002Fpost\u002F7690413193492217856","zh",{"handle":64,"displayName":65},"spots","Spots",null,{"views":68,"likes":69,"saves":69,"shares":69,"completions":69,"opens":69,"skips":69,"depthSum":69},3,0,"2026-09-29T05:00:10.461Z","local"]