@realfxw: 在分布式架构里跑多智能体(Multi-Agent),最怕的其实不是模型推理慢,而是“状态丢失”和“协同死锁”。 最近很多做 Agent 的团队都在讨论把编排逻辑迁移到边缘。Pi 配合 Cloudflare Durable Objects(…

X AI KOLs Timeline 工具

摘要

文章介绍 Pi Durable 配合 Cloudflare Durable Objects 的边缘多智能体编排思路:每个会话映射为一个有状态 Durable Object,实现状态持久化、休眠唤醒与子代理并行调度,摆脱传统 Redis/消息队列的重后台架构。

在分布式架构里跑多智能体(Multi-Agent),最怕的其实不是模型推理慢,而是“状态丢失”和“协同死锁”。 最近很多做 Agent 的团队都在讨论把编排逻辑迁移到边缘。Pi 配合 Cloudflare Durable Objects(DO)的这个落地思路很值得细品。传统做多人协作系统或者带子代理(Subagent)的架构,通常要堆一整套 Redis/PubSub、消息队列加常驻后台服务,链路拉得极长,光是处理 WebSocket 断连重试和状态对齐就能把人搞崩。 而 DO 这种 Actor 模型的妙处,在于每个协同房间或 Agent 实体都自带天然的唯一性与有状态内存: 就地计算与持久化合一:每个会话由独立的 Durable Object 托管,消息收发、状态转移直接在内存里跑,同时利用底层内置的存储自动落盘。不用在每一步推理前后频繁去外部数据库存取上下文,省去了大量胶水代码。 Multiplayer 天然支持:多个用户和多个 Subagent 挂在同一个长连接会话中,路由和广播变得极其纯粹,没有复杂的多机同步心跳机制,并发冲突处理直观很多。 子代理生命周期解耦:主代理负责统筹调度,拆分出的专业子任务(比如查资料、代码执行)可以直接派生出独立的 Actor/DO 实例并行处理,算完把结构化结果交回主实例,架构逻辑干净利落。 对于想要摆脱传统沉重后台、做一个轻量又抗打的协同 Agent 框架的开发者来说,这种把计算、连接和持久化融在边缘的范式,确实提供了完全不同的解题思路。完整实现和样例可以直接去翻项目仓库,拿来搭原型省掉不少踩坑时间。
查看原文
查看缓存全文

缓存时间: 2026/10/17 08:55

在分布式架构里跑多智能体(Multi-Agent),最怕的其实不是模型推理慢,而是“状态丢失”和“协同死锁”。

最近很多做 Agent 的团队都在讨论把编排逻辑迁移到边缘。Pi 配合 Cloudflare Durable Objects(DO)的这个落地思路很值得细品。传统做多人协作系统或者带子代理(Subagent)的架构,通常要堆一整套 Redis/PubSub、消息队列加常驻后台服务,链路拉得极长,光是处理 WebSocket 断连重试和状态对齐就能把人搞崩。

而 DO 这种 Actor 模型的妙处,在于每个协同房间或 Agent 实体都自带天然的唯一性与有状态内存:

就地计算与持久化合一:每个会话由独立的 Durable Object 托管,消息收发、状态转移直接在内存里跑,同时利用底层内置的存储自动落盘。不用在每一步推理前后频繁去外部数据库存取上下文,省去了大量胶水代码。

Multiplayer 天然支持:多个用户和多个 Subagent 挂在同一个长连接会话中,路由和广播变得极其纯粹,没有复杂的多机同步心跳机制,并发冲突处理直观很多。

子代理生命周期解耦:主代理负责统筹调度,拆分出的专业子任务(比如查资料、代码执行)可以直接派生出独立的 Actor/DO 实例并行处理,算完把结构化结果交回主实例,架构逻辑干净利落。

对于想要摆脱传统沉重后台、做一个轻量又抗打的协同 Agent 框架的开发者来说,这种把计算、连接和持久化融在边缘的范式,确实提供了完全不同的解题思路。完整实现和样例可以直接去翻项目仓库,拿来搭原型省掉不少踩坑时间。


Pi Durable on Cloudflare | Earendil

Source: https://earendil.com/posts/pi-durable-on-cloudflare/ Many AI agents respond to user requests or other external events, do their work and then sit idle for long periods. Keeping a machine running around the clock wastes compute and quickly gets expensive.

Pi Durable is a harness designed for long-running agents. Read theannouncementexplaining its concepts and storage model in detail. And Cloudflare Durable Objects offer a stateful objects that shut down when idle and start up again when a request, WebSocket message, or alarm arrives.

In this post we will build a chat agent that stores every session in its own Durable Object, manages multiple conversations per session, survives hibernation, wakes up for scheduled work, and uses a subagent to search across conversations.

We’ll run with 90s vibes for the interface but make it multiplayer because it’s 2026: multiple participants connected to the same chat session.

Download the multiplayer demonstration.Multiplayer chat agent built with Pi Durable. Both conversation events and application-defined presence updates share the same WebSockets.## One Pi session per Durable Object

A Pi Durable harness is scoped to one session. That maps naturally to one Durable Object instance addressed by the session ID. Its SQLite-backed storage persists session data, the Durable Object can hibernate while idle, and its alarm can wake it for pending work.

React client
    │ PartySocket
    ▼
Worker
    │ route by session ID
    ▼
SQLite-backed Durable Object instance
    ├── Hibernatable WebSockets
    ├── embedded SQLite database
    │       └── conversations and tasks
    └── Pi Durable Harness ──▶ Model provider

The Worker creates a session ID and returns a shareable URL that clients connect to over WebSockets. Everyone who opens the same session URL reaches the same object. Different session IDs address different objects with isolated state.

Persist Pi session data in Durable Object storage

Pi Durable makes no assumption about where sessions are stored. You can bring your own storage backend if the built-in memory, JSONL or SQLite support is not exactly what you need. The SQLite implementation defines a runtime-neutral, asynchronous database interface and includes an adapter for Node.js, as well as an adapter for Cloudflare Durable Objects using the synchronous storageSqlStorageAPI throughctx\.storage\.sql.

await openDurableObjectSqliteStorage(ctx.storage)

If your implementation requires rolling your own SQLite adapter or a different storage backend, use Pi Durable’s conformance suite to check that it behaves correctly. It works with any Vitest- or Jest-compatible test runner. For a Durable Object, use it with theCloudflare Workers Vitest integration:

registerStorageConformance(
  { describe, expect, it },
  "Cloudflare Durable Object SQLite",
  async (use) => {
    const stub = env.SESSIONS.get(env.SESSIONS.newUniqueId());
    await runInDurableObject(stub, async (_instance, state) => {
      await use(await openDurableObjectSqliteStorage(state.storage));
    });
  },
);

Open Pi when the Durable Object starts

The harness gets initialized with theSessionDurable Object. Cloudflare runs the constructor every time the object starts: when it gets first created, and again after hibernation or eviction when a request, alarm, or WebSocket message arrives. All events are held back until the initialization is complete.

export class Session extends DurableObject<Env> {
  #harness!: Harness;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    void ctx.blockConcurrencyWhile(() => this.#initialize());
  }

  async #initialize(): Promise<void> {
    const storage = await openDurableObjectSqliteStorage(this.ctx.storage);
    const chatOptions = createChatHarnessOptions((timestamp) =>
      this.#setAlarmNoLaterThan(timestamp),
    );
    const harness = await Harness.open(
      storage,
      {
        ...chatOptions,
        onReport: (error) => reportError("Harness background task failed",
 error),
      },
      BACKGROUND_CONTEXT,
    );

    // A storage error closes the harness. Reset the object so the next event reopens it.
    void harness.closed.then((end) => {
      if (end.reason === "failed") this.ctx.abort("Harness failed");
    });

    try {
      this.#harness = harness;
      await this.#restoreEventStreams();
      await this.#refreshAlarm();
    } catch (error) {
      await harness.close(BACKGROUND_CONTEXT);
      throw error;
    }
  }
}

This is the minimum integration of the harness. The full implementation also streams live task activity to clients and tells them when a subagent conversation is created.

Run multiple conversations in one shared session

The session is the durable boundary, but it is not one giant transcript. Chat participants can create independent conversations within the same session, or fork them from a committed user or assistant entry.

Download the conversation forking demonstration.A session can contain independent conversations and forks that share history up to a selected message.To avoid creating duplicates, for instance after dropping the WebSocket connection, the harness stores a receipt with the request ID in the same commit that creates the conversation:

const conversationId = await this.#harness.commit(async (tx) => {
  const doc = await tx.doc(ConversationCommandReceipts, requestId, null);
  if (doc.receipt !== undefined) return doc.receipt.conversationId;

  const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
  doc.receipt = { command: "conversation.create", conversationId: conversation.id };
  return conversation.id;
}, BACKGROUND_CONTEXT);

Creating a fork is similar, only that it receives the parent’s history up to the selected entry:

const fork = await tx.forkConversation(parentConversationId, entryId, {
  ownership: { kind: "ownerless" },
});

When a chat participant sends a message, the session attributes it with the display name and submits it as a follow-up prompt.

const conversation = await this.#harness.conversation(conversationId, BACKGROUND_CONTEXT);
await conversation?.submit(
  {
    type: "input",
    content: attributedPrompt(username, content),
    requestId,
    whenBusy: "followUp",
  },
  BACKGROUND_CONTEXT,
);

If several people send messages while the model or a tool is running, the messages enter Pi’s durable inbox and are answered in order.

Stream durable conversation state

For each connected client, the session opens Pi’s event stream for the selected conversation. The stream begins with an atomic snapshot and then emits events for committed transcript changes, streaming output, tools, compaction, resets, and inbox changes.

const stream = await watchEvents(
  this.#harness,
  conversationId,
  BACKGROUND_CONTEXT,
);

socket.serializeAttachment({ username, conversationId });
socket.send(JSON.stringify({
  type: "conversation.events",
  conversationId,
  events: [stream.snapshot],
}));

stream.start((events) => {
  socket.send(JSON.stringify({
    type: "conversation.events",
    conversationId,
    events,
  }));
});

The React client updates its selected conversation state with these events and uses a separate session snapshot to populate the sidebar.

Resume after hibernation and wake for work

Persisting messages is only one part of durability. The application must also reconstruct live views, recover interrupted Pi tasks, and wake at future deadlines.

Cloudflare keeps the WebSockets open while the object sleeps. Each socket stores the participant’s name and selected conversation, so when the object wakes, it reopens the matching Pi event streams. If a connection drops, the client resends anything the server hasn’t confirmed under the same request ID, so nothing runs twice.

After restoring the live views, the session inspects the persisted task set. Future deadlines are projected onto the Durable Object alarm. For tasks that can run immediately,waitForTask\(\)enables Pi’s scheduler and watches for settlement, after which the session recalculates its next alarm.

Map Pi deadlines to the Durable Object alarm

This agent is given ascheduletool for it to act later, for example to check the weather after a couple of minutes. Behind the scenes, this is turned into a durable Pi task that sleeps until the task deadline.

Download the scheduled task demonstration.A scheduled instruction is persisted and later resumed through the Durable Object alarm.Pi can track any number of deadlines, but a Durable Object has only one alarm. A newly scheduled task brings the alarm forward if its deadline comes first. Each time the object wakes, it rebuilds the alarm from Pi’s tasks. Work still in progress gets a short recovery alarm, so an evicted object comes back to finish it:

override async alarm(): Promise<void> {
  await this.#refreshAlarm();
}

#refreshAlarm(): Promise<void> {
  return this.#serializeAlarmUpdate(async () => {
    const { tasks } = await this.#harness.inspect(BACKGROUND_CONTEXT);
    const now = Date.now();
    let nextAlarm: number | undefined;
    const tasksToResume: TaskId[] = [];

    for (const task of tasks) {
      if (task.state.kind === "blocked") continue;
      const scheduled = scheduledDeadline(task);
      const waitsForFuture =
        scheduled !== undefined && scheduled > now && !task.record.abortRequested;
      const deadline = waitsForFuture ? scheduled : now + RECOVERY_ALARM_DELAY_MS;
      nextAlarm = nextAlarm === undefined ? deadline : Math.min(nextAlarm, deadline);
      if (!waitsForFuture) tasksToResume.push(task.record.id);
    }

    await this.#replaceAlarm(nextAlarm);
    // waitForTask starts the scheduler, so persist its recovery alarm first.
    for (const taskId of tasksToResume) this.#refreshAlarmWhenTaskSettles(taskId);
  });
}

Because alarms have at-least-once execution, the handler must be idempotent.\#refreshAlarm\(\)recalculates everything from the stored tasks, and the stable schedule request ID stops the instruction from being submitted twice.

Search conversation history with a durable subagent

With several conversations in one session, a participant may ask about something outside the selected conversation’s context. Rather than loading every transcript into the parent conversation, thesearch\_historytool delegates retrieval to a subagent. In Pi Durable, a subagent is an ordinary conversation owned by the tool task that created it.

It searches user and assistant messages from top-level conversations, including earlier messages in the conversation that initiated the search. It cannot search its own conversation or messages created by previous searches, preventing generated search results from becoming evidence for future searches.

Parent conversation
    │ search_history(question)
    ▼
Durable tool task
    │ owns
    ▼
Child conversation
    │ search_conversations
    ▼
Final answer returned to parent

Download the conversation search demonstration.A subagent gets its own conversation and searches across all other conversations within the session.The tool is marked as replay-safe, so if the Durable Object is evicted mid-call, Pi reruns it on the next start instead of reporting it as interrupted:

const childId = await api.commit(async (tx) => {
  // Reuse the original child if this tool task is replayed.
  const existing = (
    await tx.scanConversations(
      { ownerTaskId: api.taskId },
      1,
    )
  ).items[0];

  if (existing !== undefined) return existing.id;

  const child = await tx.createConversation({
    ownership: {
      kind: "task",
      taskId: api.taskId,
    },
  });

  await configure(tx, child.id, {
    extensions: [conversationSearchExtension],
    tools: [searchConversationsTool],
  });

  return child.id;
}, context);

// Let the interface link from the parent tool call to its subagent.
await api.details({ conversationId: childId }, context);

const child = await api.conversation(childId, context);
if (child === undefined) {
  throw new Error(`Search conversation ${childId} is missing`);
}

const submission = await child.submit(
  {
    type: "input",
    content:
      `Answer this question using search_conversations: ${question}\n\n` +
      "Try a few relevant terms. Reply briefly, cite conversation IDs, " +
      "and say so if nothing relevant was found.",
    requestId: `search-history:${api.taskId}`,
  },
  context,
);

const settled = await submission.wait(context);
if (settled.status !== "done" || settled.type !== "input") {
  throw new Error(`History search did not answer: ${settled.status}`);
}

const answer = await api.commit(
  (tx) => tx.entry(AssistantEntry, settled.answer),
  context,
);

return {
  output: [{
    type: "text",
    text: messageText(answer?.model?.[0]),
  }],
  details: { conversationId: childId },
};

Ownership gives the child a durable identity. If the tool is interrupted and replayed, it finds the conversation created by the original task and continues.

Thesearch\_conversationstool, the only tool given to the subagent, uses a deliberately simple substring search in this sample implementation. Raw matches and intermediate tool calls remain isolated in the subagent conversation. The parent conversation receives only the final answer.

Run locally

You need Node.js 22.19 or newer, pnpm 11, and an OpenAI API key. Clone the sample repository, install its dependencies, and copy the example environment file:

git clone https://github.com/earendil-works/pi-durable-cloudflare.git
cd pi-durable-cloudflare
pnpm install
cp .dev.vars.example .dev.vars
OPENAI_API_KEY=your-key
pnpm dev

Create a session, open its URL in a second browser, and try this sequence:

  1. In the first conversation, say: “We decided to use PostgreSQL for the event store.”
  2. Create another conversation.
  3. Ask: “What did we decide about the event store?”
  4. Open the search subagent from the sidebar and inspect its searches.
  5. Ask the agent to check the weather in two minutes, then close the browser and return after the deadline.

Note that the demo intentionally has no authentication.

Build with Pi Durable

This example is deliberately small, but it shows the key capabilities of Pi Durable: durable sessions, pluggable storage, long-running and scheduled work, and subagents.

Start building your agents on Pi Durable. We’d love to hear what you think and how you use it.

相似文章

@jiangkoumo_: Pi 官方发布了一篇在 Cloudflare Durable Objects 上构建多人 Agent 的教程。单个 Durable Object 对应一个会话,状态保存在内置的 SQLite 里。多人共享同一个链接就能进入同一会话协同聊天…

X AI KOLs Timeline

Pi 官方发布在 Cloudflare Durable Objects 上构建多人 Agent 的教程:单个 Durable Object 对应一个会话,状态存于内置 SQLite,支持多人共享链接协同聊天及定时唤醒任务。教程还展示了会话检索的隔离设计、请求幂等处理与多任务共享闹钟的机制。

@realfxw: 做 Agent 工作流或自主智能体循环时,一个极度影响体验的瓶颈在于:每一个细小的分支判断、工具路由、任务完成度检查或安全拦截,如果全部塞给昂贵的推理大模型(Reasoning LLM),不仅每次调用带来数秒的延迟,Token 成本也会呈…

X AI KOLs Timeline

这篇文章介绍了开源清单awesome-jev-projects,它通过解耦System 1快速决策模型和System 2推理大模型来优化AI代理工作流,降低延迟和成本,并提供代码级参考。

@JoshARosen: https://x.com/JoshARosen/status/2107840212915077350

X AI KOLs Following

一篇架构对比文章,分析 Pi Durable 与 OpenCode 的设计差异:Pi Durable 将模型生成、工具调用等序列化为可调度的持久任务,使工作不再绑定于单个进程,而传统会话式 harness(如 OpenCode、Claude Code、Codex)仍依赖进程内的活动 agent 循环。

@vintcessun: 多智能体系统开发门槛太高,没学过Agent理论的人根本不敢碰。结果就是项目落地难,团队只能依赖少数专家。这篇论文直接把分布式系统的成熟架构模式(发布-订阅、消息队列等)拿过来,定义一套最小Agent概念映射上去。学生连DS经验都没有也能用…

X AI KOLs Timeline

这篇论文提出将分布式系统的成熟架构模式(如发布-订阅、消息队列)直接映射到多智能体系统,以降低开发门槛,并在课程中验证:即使无分布式系统经验的学生也能用gRPC、RabbitMQ上手,平均分超过80%。