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

文章来源声明: 原文作者:GreenTea; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690413193492217856; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

对正被容器成本、会话易失与运维复杂度困扰的 Agent 平台团队很有参考价值,意图前置与单飞租约两条经验可直接落地。

![dc-aerial-panorama-refined-cinematic-anime_1790481642.png](https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/200789e9b8d54dfa9cb394108dabe792~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgR3JlZW5UZWE=:q75.awebp?rk3s=f64ab15b&x-expires=1791215697&x-signature=V%2BrFDBxJAU4wU02X5z7RUkd%2B3tE%3D)

当 Durable Execution 遇上 Agent,Agent 就该是个微服务

去年年底,我们的 Agent 平台遇到一个很朴素的麻烦:容器太贵,进程太脆,网络太乱。

具体来说是三件事叠在一起。线上跑着几千个会话,一个用户一个 Docker 容器,agent 的 loop(while 一直转,调 LLM、跑工具、再调 LLM)整个塞在这个容器里。第一个月我们发现,容器 CPU 平均利用率不到 5%——因为 agent 九成时间在等 LLM 返回、等工具执行、等用户点确认,但我们为这 5% 付了 100% 的钱。第二个问题,某次宿主机抖动,几十个正在跑工具的容器同时挂了,用户会话的对话历史、已经跑了一半的代码、装好的依赖,全没了,因为它们只存在于那个容器的内存里。第三个问题是网络:每个容器要开自己的网关转发用户流量、要接 Consul 做服务发现、还要打通内网调用业务系统,运维同学每天在处理本不该存在的问题。

那天晚上我盯着那段 while 看了很久。它没有 bug,它只是长错了——它是一个用微服务时代的架构范式(一个进程干完一整件事)去承载一个本质上属于分布式系统的东西。

于是我决定做一个当时看起来有点疯的决定:把 while 循环拆掉,让 Agent 变成一堆互相调用的服务。

这篇文章讲我踩的坑、想明白的事,以及最终那套跑通的架构。


一、先说清楚:为什么 while 循环是个"单体"

我们习惯用"单体 vs 微服务"来描述大系统,但 Agent 循环和微服务有一个惊人的同构性,这也是我后来想通的全部起点。

一个典型的 Agent 循环长这样:

while (没结束) {
    响应 = <span>LLM</span>(历史消息)
    if (响应里有工具调用) {
        结果 = 执行工具(...)
        历史消息<span>.push</span>(结果)
        continue
    }
    返回给用户
}

把它和传统单体对比:

单体应用Agent 循环
状态在进程内存里状态在容器内存里
一次调用内完成一次会话跨越几十分钟到几小时
进程常驻,等下一个请求进程常驻,等下一个工具调用(可能等十分钟)
扩缩容 = 加机器扩缩容 = 加容器(1:1,没有杠杆)
进程挂了 = 重新接请求进程挂了 = 会话状态全丢
依赖 = 函数内调用依赖 = 容器网络 + 网关 + 服务发现

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

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


二、Durable Execution 给了我答案的一半

如果你关注过分布式系统,一定听过 Temporal。它解决的问题是:写一段"业务逻辑",进程随时可能崩溃,但这段逻辑最终会跑完。做法是 event sourcing——把逻辑执行过程中每一个决策都记成事件,崩溃后从事件流重放出内存状态继续跑。

Durable Execution 最有价值的一句话是:Durable Execution 是 crash-proof execution。 你只写 happy path,故障处理交给平台。

我当时的第一反应是:这就是我要的。但冷静下来后发现,Agent 场景和传统工作流有两个关键差异:

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

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

于是我意识到,我需要的不是"Temporal 的事件溯源",而是一个专门为 Agent 定制的、单步级别的、可休眠可恢复的运行时。我最后的选择是:借事件溯源的思想(journal 是唯一真相源),但自己实现执行层。回头看,这个决定是对的——不是因为 Temporal 不好,而是因为把 LLM 调用当成 activity,会有一个"崩溃在写库前"的窗口,Temporal 的 event sourcing 解决不了(它只保证不重跑已记录的步骤,不保证不重跑正在执行的步骤)。这个窗口只能靠意图(intent)前置 + 幂等键来堵,这正是我后文 I4 不变量的由来。


三、核心洞察:把 Agent 当成"只有一步"的东西

一句话概括我的设计:

Agent 的最小可恢复单位不是"会话",而是"一次推理"。

整个系统只做一件事:拿到一次推理机会,把当前状态喂给 LLM,把结果落库,然后决定下一步谁来。

伪代码就这么点:

<span>Step</span>(session):
    拿租约(防并发)
    读 journal → 重建 context
    调 LLM
    单事务写:journal + step 记账 + 工具意图 + 唤醒信号
    释放租约
    退出进程

"tool-use" 和 "纯文本回复" 两种结局:

    <span>if</span> 响应有 tool_calls:
        写 tool_call 意图 → 投递「执行工具」信号 → 我退出
    <span>else</span>:
        写 assistant.message + <span>done</span> → 我退出

进程退出后,什么都不占着。 下一步的触发者有两个:工具执行完回推(gateway 回调),或者用户又发了一句话。两个触发都通过队列回到"再跑一次 Step"。中间无论多久,无论哪个进程挂了,状态都在 journal 里躺着。

3.1 用 DDD 的话来说这套设计

如果你喜欢领域建模,这套东西其实很自然:

限界上下文(Bounded Context) 划成三个:

上下文职责语汇
**Control**(控制面)会话状态机、journal、决策status、event、step、tool\_call、outbox
**Execution**(执行面)跑一步推理RunStep、context、lease、fencing
**Side-effect**(副作用面)跑工具、连沙箱tool\_call、sandbox、lease、attach

聚合根(Aggregate Root) 是 session。所有写状态的操作都必须经过它,而且——这是我觉得最值钱的一条设计——所有状态迁移都必须带来源状态白名单:

<span>-- worker 写:必须持租约 + fencing 匹配</span>
<span>UPDATE</span> session <span>SET</span> status <span>=</span> :<span>to</span>, version <span>=</span> version <span>+</span> <span>1</span>
<span>WHERE</span> session_id <span>=</span> :sid
  <span>AND</span> lease_owner <span>=</span> :me
  <span>AND</span> fencing <span>=</span> :my_fencing
  <span>AND</span> cancel <span>=</span> <span>0</span>
  <span>AND</span> status <span>IN</span> (:from_whitelist);   <span>-- ← 关键:不允许无条件覆盖</span>

<span>-- 控制面写:只允许窄条件迁移</span>
<span>UPDATE</span> session <span>SET</span> status <span>=</span> :<span>to</span>
<span>WHERE</span> session_id <span>=</span> :sid <span>AND</span> status <span>IN</span> (:from_whitelist);

代码层面我强制了"from 列表不允许为空"——系统里不存在一条能无条件改状态的 SQL 路径。听起来像洁癖,但它一次性消灭了一整类 bug(下面第八节有实例)。

领域事件(Domain Event) 就是 journal 里的 event 表,它同时是:审计日志、/history 接口的数据源、context 重建的原料、SSE 的内容源。一份数据,四个用途——这是把状态外置做干净的最大收益。

防腐层(ACL) 出现在两处:LLM 端点(我只需要"给我 context,还我一个带 tool_calls 的响应",不关心 OpenAI/Anthropic/Qwen 的协议差异)和沙箱(工具执行只认 sandbox.Sandbox 接口,将来换 OpenSandbox / CubeSandbox 不用动业务)。

3.2 整体架构

                       ┌──────────────┐
 POST /task ──────────▶│     api      │  HTTP/SSE 边缘(无状态)
 GET  /message (SSE) ◀─│              │  写 journal + outbox(单事务)
 GET /history          └──────┬───────┘
 POST /answer /cancel         │ outbox 轮询发布
                       ┌──────▼───────┐
                       │  dispatcher  │  ① outbox 发布器(DB→Redis,at-least-once)
                       │              │  ② reconciler(检测 stall 会话并补投,自愈)
                       └──────┬───────┘
                              │ XADD
   ┌──────────────────────────┼───────────────────────────┐
   │ agentmicro:work (组:agents)│   agentmicro:tool (组:mcp) │
   ▼                          ▼                           ▼
┌─────────────┐  XREADGROUP ▶┌─────────────┐
│  agentstep  │               │   mcpgw     │──▶ sandbox(可换 OpenSandbox/CubeSandbox)
│ 单步执行器   │  tool_call ──▶│ 工具执行     │
│ 租约→LLM→落库│ ◀─tool.result └─────────────┘
└──────┬──────┘    (journal + outbox(STEP))
       │ journal 事件提交后精确镜像
       ▼
  agentmicro:events:<session_id> ◀── SSE 客户端(offset = Redis entry ID)

四个进程,各自独立部署、独立扩缩容。真相源是一个 SQLite 文件(WAL 模式)。

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


四、数据模型:五张表,讲的其实只有一件事

<span>-- 会话:状态机 + 单飞凭据</span>
<span>CREATE</span> <span>TABLE</span> session (
  session_id      TEXT <span>PRIMARY</span> KEY,
  status          TEXT <span>NOT</span> <span>NULL</span>,           <span>-- 7 态状态机,见第五节</span>
  next_step_seq   <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  lease_owner     TEXT,                    <span>-- 单飞:谁在跑</span>
  lease_expire_at <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  fencing         <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,        <span>-- 单飞:第几次接管</span>
  cancel          <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,        <span>-- 取消标记</span>
  fail_count      <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  ctx_digest      TEXT <span>NOT</span> <span>NULL</span>            <span>-- memo 校验用</span>
);

<span>-- journal:消息级真相源</span>
<span>CREATE</span> <span>TABLE</span> event (
  session_id TEXT <span>NOT</span> <span>NULL</span>,
  seq        <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  type       TEXT <span>NOT</span> <span>NULL</span>,   <span>-- user.message / assistant.tool_call /</span>
                             <span>-- assistant.message / tool.call /</span>
                             <span>-- tool.result / ask_user / error / done</span>
  payload    TEXT <span>NOT</span> <span>NULL</span>,
  <span>PRIMARY</span> KEY (session_id, seq)             <span>-- 会话内单调递增</span>
);

<span>-- LLM 调用的幂等账本</span>
<span>CREATE</span> <span>TABLE</span> step (
  session_id TEXT <span>NOT</span> <span>NULL</span>,
  step_seq   <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  status     TEXT <span>NOT</span> <span>NULL</span>,   <span>-- inflight / done / failed</span>
  request_id TEXT <span>NOT</span> <span>NULL</span>,   <span>-- sid|seq|ctx_digest|attempt</span>
  ctx_digest TEXT <span>NOT</span> <span>NULL</span>,
  attempt    <span>INTEGER</span> <span>NOT</span> <span>NULL</span>,
  <span>PRIMARY</span> KEY (session_id, step_seq)
);

<span>-- 工具调用:意图 → 核销(对账层)</span>
<span>CREATE</span> <span>TABLE</span> tool_call (
  tool_call_id TEXT <span>PRIMARY</span> KEY,
  session_id   TEXT <span>NOT</span> <span>NULL</span>,
  status       TEXT <span>NOT</span> <span>NULL</span>,   <span>-- dispatched/running/waiting_user/done/failed</span>
  runner       TEXT,            <span>-- 执行租约:谁在跑这个工具</span>
  run_expires_at <span>INTEGER</span>,
  is_error     <span>INTEGER</span> <span>NOT</span> <span>NULL</span>
);

<span>-- 事务性 outbox:状态与"要发什么信号"同事务落盘</span>
<span>CREATE</span> <span>TABLE</span> outbox (
  id        <span>INTEGER</span> <span>PRIMARY</span> KEY AUTOINCREMENT,
  session_id TEXT <span>NOT</span> <span>NULL</span>,
  kind      TEXT <span>NOT</span> <span>NULL</span>,    <span>-- STEP / TOOL</span>
  payload   TEXT <span>NOT</span> <span>NULL</span>,
  published <span>INTEGER</span> <span>NOT</span> <span>NULL</span>
);

这五张表有个共同点:没有一张存"当前对话状态"这种聚合字段。 状态是算出来的——rebuild_context(journal)。这是整个设计的立足点:只要真相源是追加写的,恢复就是免费的;一旦你开始 UPDATE 一个 conversation 字段,恢复逻辑立刻开始腐烂。

tool_call 是我最想强调的一张。它是意图表,不是任务表:LLM 决定调工具的那一刻就先落一行 dispatched,然后才投递信号。如果这时候 gateway 挂了、工具压根没跑,reconciler 看得见这个"有去无回"的意图并重新投递;如果工具跑了但结果没写回来,状态还停在 running,靠执行租约超时终结。意图和结果分离,是副作用可对账的唯一前提。


五、两个状态机

5.1 会话状态机(7 态)

                    ┌─────────┐
      POST /task ──▶│ PENDING │
                    └────┬────┘
                         ▼
       ┌──────────▶ RUNNING ◀──────────┐
       │            │  │  │            │
       │  工具全终态 │  │  │ LLM 出终答  │ 新一轮输入(revive)
       │            │  │  ▼            │
  (新工具意图)      │  │ DONE           │
       │            │  │               │
       │            │  ▼               │
       │      WAITING_TOOL ───────────┤
       │            │  (gateway 停泊)
       │            ▼                  │
       └─────── WAITING_USER          │
                    │                  │
                    ▼                  │
                 CANCELLED ◀───────────┤ 任意状态可取消
                    │                  │
                    ▼                  │
                 FAILED ──────────────┘ (可被新一轮输入复活)

迁移规则全部走白名单,没有例外。两个"反直觉但必要"的细节:

  • RUNNING → WAITING_TOOL 之后不能直接回 RUNNING。 必须等所有 tool_call 都进终态(WakeIfToolsSettled)。一个响应里有 3 个工具调用,第 1 个先回来就唤醒模型,模型看到的 context 里 2 个 tool.result 缺失——真实模型端点会直接报 400。
  • 取消必须对所有状态可达,包括 RUNNING 中。 我第一版在拿租约的 SQL 里加了 AND cancel = 0,看起来很合理,结果是:RUNNING 中点的取消,因为 worker 一直续租、下次拿租约永远拿不到,这个会话永远卡在 RUNNING。一个谓词,废掉整个取消功能。

5.2 工具调用状态机

dispatched ──(gateway 认领)──▶ running ──(执行成功)──▶ done
     │                            │
     │                            ├──(执行失败)──▶ failed
     │                            │
     │                       (ask_user 工具)
     ▼                            ▼
  waiting_user ──(用户回答)──▶ done

终态不可变:done / failed 只能被读,不能被再次改写(重复投递时直接短路返回)


六、关键难点

难点 1:单飞——两个事件同时到,只能有一个跑

tool 的回调和用户新的一句话,可能同时触发一次 Step。如果不管,模型会基于同一份历史跑两次:烧两倍的钱,journal 里写进两条互相矛盾的 assistant.message。

解法是老朋友:条件 UPDATE 抢租约。

<span>UPDATE</span> session
   <span>SET</span> lease_owner <span>=</span> :me, lease_expire_at <span>=</span> :now <span>+</span> TTL, fencing <span>=</span> fencing <span>+</span> <span>1</span>
 <span>WHERE</span> session_id <span>=</span> :sid
   <span>AND</span> (lease_owner <span>IS</span> <span>NULL</span> <span>OR</span> lease_owner <span>=</span> <span>''</span> <span>OR</span> lease_expire_at <span><</span> :now);

影响行数为 1 才算抢到,同时拿到新的 fencing token。注意这里不能加 cancel = 0 谓词(见上)。

难点 2:fencing——僵尸 worker 的写必须无效

租约过期后,worker B 接管了会话。此时 worker A(可能只是 GC 停顿了几秒)醒过来继续写状态。如果不加防护,A 的写会覆盖 B 的结果。

标准解法是 fencing token:每次接管 fencing + 1,所有 worker 写库时必须带上 fencing = :my_fencing:

<span><span>func</span> <span>(w *Worker)</span></span> submit(ctx context.Context, to Status) <span>error</span> {
    n, err := w.store.TransitionSessionWorker(ctx, w.sid, to, w.owner, w.fencing)
    <span>if</span> err != <span>nil</span> { <span>return</span> err }
    <span>if</span> n == <span>0</span> {
        <span>return</span> ErrZombie   <span>// 条件不匹配:有人接管了我,直接放弃</span>
    }
    <span>return</span> <span>nil</span>
}

但这里有个我一开始想错的语义边界,值得单独说。 fencing token 的经典语义是"新 holder 的 token 更大,旧 token 全部作废"。那如果租约过期了、还没人接管,旧 worker 这时才提交,算不算僵尸?

我一开始写了个 TestFencingRejectsZombie 就以为完事了,后来单独想清楚:只要没人接管过,fencing 值没变,单飞事实上仍然成立,这次提交是安全的。测试最终拆成两个:

  • TestFencingRejectsZombie——B 接管后 A 的写必须失败;
  • TestLateCommitWithoutTakeoverSucceeds——A 迟到但无人接管时提交必须成功。

这两种行为在测试里长得几乎一样,但语义完全相反。如果只测前者,我的系统会在"租约超时但网络抖动没人接管"的场景下丢失一整轮 LLM 结果。

难点 3:LLM 幂等——唯一真正花钱的地方

这是整套系统里我最小心的地方。崩溃窗口长这样:

<span>worker</span> <span>A</span>:  调 <span>LLM</span> ✅(花了 <span>3</span> 块钱)  →  进程被 <span>kill</span>  ✗
<span>worker</span> <span>B</span>:  重新调 <span>LLM</span> ❌(再花 <span>3</span> 块)

只在 journal 落库是防不住的,因为崩在"LLM 已返回"和"结果已落库"之间那几毫秒里。我的解法是把意图前置:

调 LLM 之前:  INSERT step(session, seq, status=<span>'inflight'</span>, request_id=sid|se<span>q|digest|</span>attempt, attempt=n)
调 LLM 之后:  单事务写 journal + step.status=<span>'done'</span> + session.ctx_digest

于是恢复时的判断变得很清晰:

step 记录含义动作
`done` 且 `ctx_digest` 命中上次已经成功,这次是重复唤醒**NOOP**,绝不重发唤醒(否则死循环)
`inflight`上次崩在写库前`attempt + 1`,重调(并打点告警——这说明有窗口)
无记录首次执行正常调

attempt 字段让"重复调用"变成可观测的而不是隐形的。生产化时把 request_id 透传给支持幂等键的 LLM 网关,这个窗口就能彻底关掉——但在那之前,attempt > 0 的计数就是我的告警信号。

顺带说个细节:delta(token 增量)事件是乐观推送的,提交前就发出去了。如果这次 step 崩了重跑,用户会看到两遍流式输出。我不打算解决它——因为 message 事件(最终文本)才是对账基准,客户端拿它去重即可。用一条"非真相源"的事件换取首 token 延迟,这个交易很划算。

难点 4:提前唤醒防御

我加了一条看起来很傻的前置检查:

<span><span>func</span> <span>(s *Step)</span></span> RunStep(ctx context.Context, sid <span>string</span>) <span>error</span> {
    sess, _ := s.store.GetSession(ctx, sid)
    <span>switch</span> sess.Status {
    <span>case</span> store.WaitingUser:
        <span>return</span> <span>nil</span>   <span>// 等人,别动</span>
    <span>case</span> store.WaitingTool:
        pend, _ := s.store.PendingToolCalls(ctx, sid)
        <span>if</span> <span>len</span>(pend) > <span>0</span> {
            <span>return</span> <span>nil</span>  <span>// 工具没跑完,绝不调 LLM</span>
        }
    }
    ...
}

没有这条,mcpgw 因为任何原因提前唤醒(消息重复、reconciler 补投、运维手动重放),agent 就会拿着残缺的 context 去推理。这条检查是防御不变量,而非优化。

难点 5:SSE 断线重连

SSE 推送用 Redis Stream,每会话一条,客户端断线重连要能续读。我第一版的做法是自己维护一个 offset 字段,然后……立刻遇到了裁剪后的 offset 悬空问题。

正确做法是直接用 Redis Stream 原生 entry ID(ms-seq)当 SSE 的 id: 字段,重连时用 Last-Event-ID 头传回来,服务端 XREAD 从该 ID 之后继续:

<span>id:</span> <span>1727</span>...-<span>0</span>
<span>event:</span> tool.result
<span>data:</span> {<span>"tool_call_id"</span>:<span>"tc_1"</span>,<span>"content"</span>:<span>"echo:hello"</span>}

零维护成本,精确续读,不需要任何 offset 映射表。唯一补的兜底是:Stream 有 MAXLEN 裁剪,客户端要完整历史时走 /history(journal 永不裁剪)。

难点 6:超时常量之间的关系

这种系统最怕的就是"改一个参数炸三个地方"。我把这些约束全部显式化:

参数默认关系
`LEASE_TTL`15s
`RENEW_INTERVAL`5s< TTL/2,续租失败立即中止 LLM 调用
`LLM_TIMEOUT`5m
`STEP_MAX_ATTEMPTS`3烧钱上限 = 3 × 单价
`WORK_MIN_IDLE`60s**> LLM\_TIMEOUT**:正在推理的消息不会被误回收
`TOOL_TIMEOUT`60s
`TOOL_MIN_IDLE`90s**> TOOL\_TIMEOUT**:重投时在途工具必然已终结,不会二次执行
`STALL_AFTER`30s< WORK\_MIN\_IDLE(reconciler 是主恢复路径,消息回收是兜底)

两条加粗的约束是我踩出来的。TOOL_MIN_IDLE < TOOL_TIMEOUT 时,一个还在跑的工具会被重复投递 → 重复执行副作用(重复扣款、重复发邮件)。这类 bug 在低并发测试里几乎抓不到,只在生产环境的抖动里现形。


七、恢复分层:四道防线

一个"任意时刻任意进程可能死"的系统,恢复不能只有一条路。我叠了四层,全部幂等:

① outbox 发布      状态事务提交时同时写 outbox → 发布器轮询投递
<span>                   覆盖:99.9% 的正常路径
        ↓ 消息丢了 / 发布器崩了
② XAUTOCLAIM       消费者组回收超过 MinIdle 未 ACK 的消息
                   覆盖:worker 崩溃,消息还挂在 PEL 上
        ↓ 消息被 ack 了但处理失败 / 消息压根没产生
③ reconciler       检测 stall 会话(无待发 outbox、无在途工具、
                   租约已过期、updated_at 过旧)→ 补投唤醒
                   覆盖:任何静默失败,兜底
        ↓ 以上都失效(理论上)
④ 租约 + memoize   重复投递本身不产生副作用
</span>

第 ③ 层是实践中最有价值的一层,它的判定条件我调了好几轮——一开始的 SQL 引用了一个不存在的列,错误被 err != nil 吞掉,reconciler 静静地什么都没干。这种"静默失效的兜底"比没有兜底更危险,因为它给了你虚假的安全感。现在我给每一层都配了独立的测试,并且故意用故障注入验证(见第十节)。


八、第一版根本没跑起来(这段是全文我最想写的)

我把这个单独拎出来。上面那些"难点",听起来像是理论推演,实际上我 v1 写完之后一行都没跑起来。逐段评审后列了 14 个问题,选三个最有代表性的:

8.1 P0:消费端只有 XAUTOCLAIM,没有 XREADGROUP

XAUTOCLAIM 的语义是"回收已经进 PEL(Pending Entries List)的消息",也就是消费者拿过但没 ACK 的。而一条全新发布的消息根本不在 PEL 里——XAUTOCLAIM 永远看不见它。

结果就是:整条管道饿死。outbox 发布器在拼命 XADD,消费者在拼命回收空气。而单测会绿,因为 mock 的 Redis 返回空列表,代码路径没报错。

修复:XREADGROUP > 取新消息是主路径,XAUTOCLAIM 只做兜底回收。测试名就叫 TestReadNewDeliversFreshMessages——专门验证"新消息能被立刻拿到"。

8.2 P0:SSE 永远收不到事件

我把事件写进了 journal,然后……忘了推流。SSE 端点一直在那里转圈。

这个 bug 的恶劣之处在于它不会失败,只会沉默。HTTP 200,连接保持,什么都收不到。修复方式是在 journal 事务提交后,由 relay 精确镜像到 events:<session_id> 流,并且镜像的字段要和 journal 事件一一对应(assistant.tool_call 只进 journal 不下发,因为它是内部事件)。

8.3 P1:一个谓词废掉整个取消功能

前面提过:AcquireLease 里写了 AND cancel = 0。

从直觉上看这很对——都取消了你还抢什么租约。但它造成了死锁:RUNNING 状态下的会话取消后,worker 的续租会持续成功(因为 cancel 不影响续租路径),于是再也没有人能拿到租约完成这次 step,会话永远停在 RUNNING。用户点了停止,状态条还在转。

修正是让取消走"控制面直接迁移":只要不是 worker 正在写(租约有效且 RUNNING),控制面直接把状态推到 CANCELLED;worker 提交时撞 cancel = 1 条件失败,通过 reconcile 收敛到 CANCELLED。

教训:状态机的可达性需要显式验证。我现在为每个状态都有一条"从任意状态可达终态"的测试。

这可能是我整个项目里最贵的一课:mock 如果比真实端点宽容,测试就是在给错误的实现背书。 后来我让 mock 也走完整协议(读 tools、校验消息交替合法性),绿灯才有意义。


九、这个方案不做什么

技术文章只讲好话是耍流氓,说几个边界:

  1. 不解决多租户鉴权与配额。 tenant_id 已经贯穿 session 表,但鉴权没做。gateway 是天然的安全咽喉(所有副作用都从它出去),按 session 限流和配额是下一步最该补的。
  2. 不解决真沙箱隔离。 本地实现是"命令白名单 + 工作区目录",这是演示级,不是安全边界。真上线必须接 OpenSandbox / CubeSandbox 这类,sandbox.Sandbox 接口已经留好了。
  3. SQLite 只适合单机 MVP。 四个进程共享一个 DB 文件靠 WAL 撑着,能跑但不能扛并发。生产换 MySQL/PG,租约逻辑换成 SELECT ... FOR UPDATE 或 UPDATE ... WHERE,语义完全不变。
  4. 事件流与 journal 非原子。 SSE 是通知层,journal 是真相源。这个缝隙是有意留的——为了首 token 延迟。客户端必须以 message/done 事件对账,/history 兜底。
  5. 不适合的场景。 如果你的 agent 交互是"一次请求一次响应"(无状态问答),这套东西纯属自找麻烦。它的价值只在"会话足够长、等待足够多"时才显现。如果是本地单机工具(Claude Code 那种),Codex App Server 那种"常驻进程 + 事件历史"的形态更合适。

第 5 点我想多说一句:不是所有 agent 都该被微服务化。拆分的代价是每一次工具调用多两次队列往返和一次状态重放。如果你的工具调用是亚秒级的、交互是同步的,这笔账算不过来。


十、回头看这个设计

做完了,我最想记下来的不是某个技术点,是三个认知:

第一,while 循环是边界,不是实现细节。 当你的循环体里出现了"跨分钟的外部副作用"和"需要在任意点崩溃后恢复的状态",这个循环就已经是一个分布式系统了,只是它还穿着单体的衣服。识别出这个边界,比选任何技术栈都重要。

第二,状态外置的最大收益不是"能恢复",是"能审计"。 我原本只想要恢复,做完发现收益最大的反而是 journal——它同时是 SSE 的内容源、context 的原料、/history 的 API、排障时的唯一线索。一份数据四个用途,这种复利是单体架构给不了的。

第三,把不变量写进代码结构,而不是写进文档。 "状态迁移必须带来源白名单""fencing 必须校验""inflight 必须前置"——这些如果只写在 wiki 里,三个月后必然有人写出一条绕过路径。但如果它们是 TransitionSessionWorker 这一个函数里不可绕过的条件,那就永远不会被破坏。架构的稳健性来自于把约束收敛到少数几个 choke point。

至于 Durable Execution 这个方向,我的结论可能有点反主流:Temporal 解决的是"业务逻辑的持久化执行",Agent 需要的是"认知状态持久化 + 副作用对账",这两件事只有一半重合。 前者可以复用,后者必须自己写——因为 LLM 调用花钱、工具调用有真实副作用,这两件事的幂等性无法从通用的事件溯源里免费得到。