我最近用 Cloudflare Durable Objects 搭了一个 AI 健身计划生成器,整个项目跑在边缘网络上,没有自建服务器,也没有 Redis 之类的第三方状态存储。用户提交身高体重、训练目标和可用时间之后,系统会调用 Workers AI 生成一份按周拆分的训练计划,同时通过 WebSocket 把生成进度一帧一帧推给前端。核心调度逻辑全部落在 Durable Objects 上,这一个对象同时承担了状态保存、长任务调度和实时通信三件事。
这个场景非常适合拿来聊 Durable Objects 的设计边界。AI 工作流和普通 API 不一样,它不是一次请求就能结束的事:要调用大模型、要解析输出、要做结构化校验、要给用户反馈进度,任何一个环节出问题都不能让整个任务“断片”。传统 Worker 天然无状态、有超时限制,硬做也能做,但做出来的代码全是补丁。我这次换个思路,把任务状态机放进 Durable Objects 里,让对象自己记住“活干到哪一步了”,体验完全不同。
如果你也在 Cloudflare 生态里做 AI 应用,或者想搞清楚 Durable Objects 到底适合什么场景,这篇内容应该能给你一个完整参考。我从需求拆解、数据模型、核心代码到踩坑实录都写出来,代码是简化可用版,你换成自己的业务场景也成立。
1. 为什么选 Durable Objects 当 AI 工作流的“调度中枢”
1.1 传统 Worker 处理 AI 请求的三个硬伤
先说我一开始是怎么想的:直接用一个 Worker 接住用户请求,调用env.AI.run(),把结果返给前端,完事。听起来很简单,但实际一跑就发现三个问题。
第一个问题是 Worker 的执行时长限制。AI 生成一篇完整的训练计划不是秒回的,模型推理加流式输出,动辄十秒以上。虽然 Cloudflare Worker 的 CPU 限制比很多人以为的宽松,但长任务还是得考虑超时和重试成本。就算解决了超时,下一个问题更致命:Worker 默认是无状态的。用户提交请求后,如果前端刷新了页面、断了网络,任务状态直接就丢了。用户看着“生成中”的页面,背后其实什么也没发生。
第三个问题是多步骤协调的复杂度。一个合格的健身计划生成流程,不是一次 LLM 调用就结束的:需要先确认基础信息,再拆分训练阶段,生成每周计划,最后还要做格式校验和降级兜底。每一步之间都依赖上一个结果,而且状态需要持续更新。这种“有状态的长流程”,放在无状态的 Worker 里,要么用外部存储硬扛,要么干脆写出一个谁都维护不了的巨型函数。
这三个问题凑在一起,我基本可以确定:健身计划生成这个项目,不能用最简单的 Worker + 全局变量方案。
1.2 Durable Objects 提供了什么不一样的能力
Durable Objects(下称 DO)本质上是一个“有身份、有状态、有唯一地址”的对象实例。它和普通 Worker 最大的区别在于:每个 DO 都有一个确定的对象 ID,同一时间只有一个实例在运行。你不需要考虑多个副本同步问题,DO 天然就是一个单实例锁。
我这次用得最爽的是这几条能力。
一是内部状态持久化。state.storage就像一个挂在对象身上的嵌入式数据库,你可以随时put、get、delete,数据会持久化落盘。生成计划做到一半,用户关了页面,没关系,状态存在 DO 里,用户回来之后还能接着查。
二是单线程执行模型。DO 保证同时只有一个实例在跑,所有来自外部请求、Alarm 回调、WebSocket 事件都串行进入对象内部。这意味着你不会在并发下踩到“两个请求同时改同一份计划”的脏读问题。这一点对健身计划这种“一份数据反复改”的场景非常适用。
三是 Alarm 定时器能力。DO 可以设置一个alarm()回调,到点之后即使对象休眠了也会被唤醒执行。这正好用来做那种“用户提交后,后台慢慢生成”的长任务:先把请求返回给用户,再在 Alarm 里慢慢跑 AI,跑完存结果,用户回来拿计划就行了。
四是 WebSocket 处理能力。DO 原生支持 WebSocket Hibernation API,可以在对象内部维护连接状态。AI 生成过程中,我可以随时把进度事件推给已经连接的用户,前端实时显示“正在生成第一周计划 30%”,体验比干等轮询好太多。
1.3 对比:KV、D1 和 Queue 能不能替代 DO
我在选型的时候也认真对比过平台上的其他存储和调度组件,这里直接放到一张表里说明白。
| 组件 | 适合场景 | 为什么不直接用它 |
|---|---|---|
| KV | 读多写少、全局缓存 | 最终一致性,写入后立刻读不到;不适合记录任务状态变更 |
| D1 | 关系型查询、SQL 分析 | 能做存储,但不解决长任务调度和实时通信问题,还要自己写事务 |
| Queue | 异步消息、削峰 | 消息队列适合解耦,但队列消费者还是无状态 Worker,状态仍然得存别处 |
| DO | 有状态实体、长流程、实时交互 | 状态、调度、通信三合一,天然契合 |
如果你只是想存一份数据,KV 或 D1 都够了。但我这个项目需要的是一整个“任务实体”:它有生命周期,有中间状态,还要能跟用户实时交互。DO 正好把这三件事收在一处,省掉了我在多个服务之间写胶水代码的时间。
2. 需求拆解与数据模型设计
2.1 产品层面怎么拆需求
做健身计划生成器之前,我先整理了产品流程。用户从进入页面到拿到计划,至少要经历五个阶段:
- 填写基础信息(性别、年龄、身高体重、目标、每周训练天数、单次时长、可用器械、训练经验)。
- 系统创建一次“计划生成任务”,返回给用户一个任务 ID。
- 后台调用模型生成结构化训练计划。
- 前端实时展示生成进度。
- 用户查看最终计划,也可以重新生成。
这五个阶段里的每一步,都不是一次普通 HTTP 请求能闭环的。尤其是第 3 和第 4 步,生成时间长且需要状态追踪,所以我让“任务”成为一个一等公民对象,而不是把数据散落在各种存储里。
2.2 训练计划的数据结构
我让模型输出的是一份完整的 JSON 计划,而不是一段散文式的建议。原因很简单:结构化数据方便前端渲染,方便用户按周、按天切换,也方便后续做“调整计划”这类迭代操作。
我设计的核心结构长这样:
{ "userProfile": { "heightCm": 175, "weightKg": 70, "goal": "减脂增肌", "experience": "beginner" }, "weeks": [ { "weekNumber": 1, "focus": "全身激活与动作适应", "days": [ { "day": "周一", "type": "力量训练", "exercises": [ { "name": "高脚杯深蹲", "sets": 3, "reps": "10-12", "restSeconds": 90, "notes": "保持躯干直立,膝盖与脚尖方向一致" } ] } ] } ] }为什么分两层?weeks和days虽然结构多了一层,但它的作用是给用户一个清晰的周期感。减脂增肌的人看计划,首先关心的是“这周练什么”,其次才是“今天练什么”。如果模型输出平铺的一堆动作,用户很难建立训练节奏。
2.3 Durable Object 内部状态设计
一个 DO 实例对应一次“计划生成任务”。我在对象内部维护几个关键状态字段:
| 字段 | 类型 | 含义 |
|---|---|---|
status | string | generating/done/failed |
userInput | object | 用户提交的基础信息 |
plan | object | 最终生成的训练计划 |
progress | number | 0 到 100 的进度值 |
errorMsg | string | 失败原因,便于页面展示 |
这个设计有两个好处。第一,所有状态都集中在一个 DO 的 storage 里,我查状态只需要拿到对象 ID,不用去联合查询各种表。第二,DO 的单实例模型天然避开了“两个人同时改同一任务”的冲突。即使用户手滑点了两次提交,同一个任务 ID 对应的 DO 内部也是按顺序处理请求的。
3. 核心代码实现:从 Worker 到 Durable Object 再到前端
3.1 Worker 入口路由
先是入口文件。这里定义了 Worker 的接口、环境绑定,以及对外的三个路由:创建任务、查询状态、WebSocket 连接。
export interface Env { AI: Ai; WORKOUT: DurableObjectNamespace; } export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/api/plan" && request.method === "POST") { const id = env.WORKOUT.newUniqueId(); const stub = env.WORKOUT.get(id); const result = await stub.fetch("https://do.internal/start", { method: "POST", body: request.body, }); return result; } if (url.pathname === "/api/plan/status" && request.method === "GET") { const id = env.WORKOUT.idFromString(url.searchParams.get("id") ?? ""); const stub = env.WORKOUT.get(id); return stub.fetch("https://do.internal/status"); } if (url.pathname.startsWith("/api/plan/ws")) { const id = env.WORKOUT.idFromString(url.searchParams.get("id") ?? ""); const stub = env.WORKOUT.get(id); return stub.fetch(request); } return new Response("Not Found", { status: 404 }); }, };这里有几个细节需要注意。第一,WORKOUT.newUniqueId()会生成一个新的对象 ID,这个 ID 就是任务的唯一标识,返回给前端后,前端通过它查询状态。第二,从 Worker 调用stub.fetch()时,用的 URL 是占位符,真正重要的是路径和请求体,这个路径只在 DO 内部路由时用到。
3.2 Durable Object 的启动与状态机
接下来是核心的 DO 类。构造时接收state和env,state就是对象的状态容器。我实现了fetch方法处理外部请求,也用 Alarm 做了一个后台生成任务。
export class WorkoutEngine { private state: DurableObjectState; private env: Env; constructor(state: DurableObjectState, env: Env) { this.state = state; this.env = env; } async fetch(request: Request): Promise<Response> { const url = new URL(request.url); if (url.pathname === "/start" && request.method === "POST") { const userInput = await request.json(); await this.state.storage.put({ status: "generating", userInput, progress: 0, plan: null, errorMsg: null, }); await this.state.storage.setAlarm(Date.now() + 100); return Response.json({ id: this.state.id.toString(), status: "generating", }); } if (url.pathname === "/status") { const status = await this.state.storage.get("status"); const progress = await this.state.storage.get("progress"); const errorMsg = await this.state.storage.get("errorMsg"); return Response.json({ status, progress, errorMsg }); } if (url.pathname === "/plan") { const plan = await this.state.storage.get("plan"); return Response.json({ plan }); } if (url.pathname === "/ws") { return this.handleWebSocket(request); } return new Response("Not Found", { status: 404 }); } async alarm() { const userInput = await this.state.storage.get("userInput"); await this.runGeneration(userInput); } }为什么用setAlarm而不是在/start里直接await生成?因为这样/start接口能立刻返回任务 ID,用户那边马上能看到“任务已创建”,生成流程在 Alarm 回调里慢慢跑。这个过程完全符合 DO 的设计哲学:对外快速响应,对内慢慢作业。
3.3 核心生成逻辑与 AI 调用
runGeneration是真正的核心。它要把用户输入变成一份可用的训练计划,这里我做了两件事:一是精心设计的提示词,二是强制 JSON 格式化输出。
async runGeneration(userInput: any) { try { const prompt = this.buildPrompt(userInput); const result = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", { messages: [ { role: "system", content: "你是一名持有认证的专业健身教练。你的任务是输出一份可执行的训练计划。计划必须严格按JSON格式返回,不允许包含任何解释性文字。只输出JSON对象本身。", }, { role: "user", content: prompt, }, ], response_format: { type: "json_object" }, }); const rawText = result.response; const plan = this.safeParseJson(rawText); if (!plan || !this.validatePlan(plan)) { throw new Error("模型输出格式校验失败"); } await this.state.storage.put({ plan, status: "done", progress: 100, errorMsg: null, }); } catch (err: any) { await this.state.storage.put({ status: "failed", errorMsg: err.message ?? "生成失败", }); throw err; } }提示词我单独抽了一个方法,因为它是决定计划质量的关键。我的经验是:不要在提示词里写一堆“请帮忙”“谢谢”这种客套话,直接把约束条件说清楚,让模型知道你只要 JSON。
private buildPrompt(input: any): string { return ` 请根据以下用户信息生成一份训练计划,输出 JSON,不要输出其他内容。 用户信息: - 年龄:${input.age} - 身高:${input.heightCm} cm - 体重:${input.weightKg} kg - 目标:${input.goal} - 每周训练天数:${input.daysPerWeek} - 单次训练时长:${input.minutesPerSession} 分钟 - 可用器械:${input.equipment ?? "无器械"} 要求: 1. 生成 ${input.daysPerWeek} 周计划,每周 1 个 focus。 2. 每 7 天一个周期,安排 ${input.daysPerWeek} 个训练日。 3. 每个训练日包含 3-5 个动作,每个动作给出名称、组数、次数、组间休息、注意事项。 4. 如果用户没有可用的器械,优先安排自重训练动作。 5. 输出的 JSON 结构必须包含 weeks 和 days 字段。 `; }safeParseJson也很重要。LLM 输出不稳定的问题很常见,所以我在这个函数里做了两重兜底:先直接JSON.parse,如果失败就尝试提取字符串里第一个{到最后一个}之间的片段再解析。
private safeParseJson(raw: string): any | null { try { return JSON.parse(raw); } catch { const start = raw.indexOf("{"); const end = raw.lastIndexOf("}"); if (start === -1 || end === -1) return null; try { return JSON.parse(raw.slice(start, end + 1)); } catch { return null; } } }3.4 用 WebSocket 把进度推给前端
有了状态机还不够,用户是要看进度的。我最初用轮询,每两秒查一次/status,能用但体验一般。后来直接上了 WebSocket,生成过程中的每一步都实时推到前端。
DO 的handleWebSocket是这样实现的:
async handleWebSocket(request: Request): Promise<Response> { if (request.headers.get("Upgrade") !== "websocket") { return new Response("Expected Upgrade: websocket", { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); this.state.acceptWebSocket(server); const progress = await this.state.storage.get("progress"); server.send(JSON.stringify({ type: "progress", value: progress ?? 0 })); return new Response(null, { status: 101, webSocket: client, }); }在runGeneration里,我增加了阶段性进度推送:
private async sendProgress(message: any) { const sockets = this.state.getWebSockets(); for (const socket of sockets) { socket.send(JSON.stringify(message)); } }生成过程中,每完成一个大阶段就发一条消息,比如“预热结束”“第一周计划已生成”“整体计划校准完成”。前端拿到这些事件后,更新进度条和文案,用户就不会对着空白页面干等。
3.5 前端消费与渲染
前端我用原生 JavaScript 写了一个简单的状态机:
const ws = new WebSocket(`wss://your-worker.workers.dev/api/plan/ws?id=${taskId}`); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "progress") { renderProgress(data.value); } if (data.type === "done") { fetch(`/api/plan?id=${taskId}`) .then((res) => res.json()) .then((json) => renderPlan(json.plan)); } };这里有一个小坑:WebSocket 是独立通道,如果页面刷新,会断开连接。所以我的前端在onload时先发起一次/api/plan/status请求,拿到当前进度后再决定是继续连 WebSocket 还是直接展示结果。这样用户中途刷新页面也不丢状态。
4. 踩坑实录与调优心得
4.1 AI 输出不稳定,尤其是 JSON 解析失败
这是我在整个项目里踩得最深的一个坑。模型输出的 JSON 偶尔会有多余的前缀、截断的内容,甚至干脆是中文散文。最开始我的JSON.parse直接抛异常,任务直接标记失败,用户只能重新生成。
后来我做了三层防护:
- 提示词里强制要求“只输出 JSON,不要输出其他内容”。
response_format指定json_object,让部分支持该能力的模型直接走 JSON 输出。safeParseJson做兜底,尝试从任意文本中提取最可能的 JSON 片段。
我建议你自己也做一层校验函数,不要信任模型输出是绝对规范的。校验内容包括:是否有weeks数组、每week是否有days、每day是否有exercises,以及每个动作字段是否完整。只要有一层不满足,就触发重试或降级。
4.2 生成任务“断片”的问题
刚开始我直接在前端发请求到 Worker,Worker 里同步跑 AI 生成,一旦超时或用户断开连接,任务就废了。改成 DO 之后,问题同样存在:DO 不是不会休眠,长时间没有请求进来,它也会进入休眠状态。
所以关键一点是:不要依赖对象一直“活着”来记住状态。我的做法是,所有进度都写入state.storage,Alarm 回调重新启动时,第一步先从 storage 读状态,而不是依赖内存变量。这样即使对象休眠再唤醒,生成任务也能从上次的位置继续推进。
另外提醒一下:Alarm 里的任务如果抛异常,对象会进入失败状态。我建议在alarm()方法里加一个 try/catch,把错误信息存到errorMsg,这样用户至少能看到“生成失败:模型响应超时”,而不是一个莫名的 500。
4.3 进度推送的实时性与连接管理
WebSocket 连接一旦建立,DO 会把它托管在对象上。但连接数是有上限的,而且用户可能打开多个标签页,同一任务 ID 下会建立多条 WebSocket 连接。每次sendProgress时,我都要遍历getWebSockets()给所有连接发送消息。
这里推荐一个习惯:在 WebSocket 的close事件里做清理。虽然 DO 的 Hibernation 内部会自动处理连接生命周期,但你自己的业务数据(比如“当前在线设备数”)需要手动清理。另外,如果前端用了重连机制,你最好在消息里带一个递增的seq,避免前端重复渲染旧消息。
4.4 AI 生成时长的现实考量
实测下来,llama-3.1-8b-instruct生成一份完整计划要几秒到十几秒,取决于输出长度和模型负载。这个时长用户其实可以接受,但前提是界面有反馈。我建议把生成流程拆成多个阶段:先快速验证用户输入,再启动生成,最后做格式化。这样用户等待的总时长虽然没变,但感知上“每一步都有反应”。
另外,如果你觉得单次生成长 JSON 容易截断,可以把提示词拆成两部分:先生成训练大纲(一周的 focus 和动作分配),再基于大纲逐周生成详细计划。这样单次输出长度变短,成功率明显提升,代价是要多一次模型调用。
结尾:一些个人的体会
做这个项目,我最大的体会是:Durable Objects 不是让你“把服务器搬进 Worker”,而是帮你换一种组织代码的思路。以前我写后端,总要先考虑数据库表、缓存、定时任务、消息队列,这些基础设施堆在一起,代码还没写,环境先折腾半天。用 DO 之后,一个对象就是一个小系统,状态、调度、实时通信都在里面,开发体验非常直接。
如果你也想复刻这个方案,我建议从最小的闭环开始:先只做“提交信息 -> 生成任务 -> 轮询状态 -> 展示计划”这一条线,跑通之后再加 WebSocket 进度推送和重试机制。另外,构建提示词的时候多试几个模型,不同模型对 JSON 格式的理解差异很大,选一个输出稳定的能让后续所有代码省心很多。
最后分享一个细节:用户输入的历史版本,我也都存进了同一个 DO 的 storage 里,键名带上时间戳就行。这样用户可以随时对比“上次生成”和“这次生成”的区别,后续做“基于上周计划微调”的功能时,这些历史数据就是现成的素材。