☰
Cloudflare Durable Objects 实战:构建有状态的 AI 健身计划生成器
2026/10/1 12:07:23 网站建设 项目流程

我最近用 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 产品层面怎么拆需求

做健身计划生成器之前,我先整理了产品流程。用户从进入页面到拿到计划,至少要经历五个阶段:

  1. 填写基础信息(性别、年龄、身高体重、目标、每周训练天数、单次时长、可用器械、训练经验)。
  2. 系统创建一次“计划生成任务”,返回给用户一个任务 ID。
  3. 后台调用模型生成结构化训练计划。
  4. 前端实时展示生成进度。
  5. 用户查看最终计划,也可以重新生成。

这五个阶段里的每一步,都不是一次普通 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 实例对应一次“计划生成任务”。我在对象内部维护几个关键状态字段:

字段类型含义
statusstringgenerating/done/failed
userInputobject用户提交的基础信息
planobject最终生成的训练计划
progressnumber0 到 100 的进度值
errorMsgstring失败原因,便于页面展示

这个设计有两个好处。第一,所有状态都集中在一个 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直接抛异常,任务直接标记失败,用户只能重新生成。

后来我做了三层防护:

  1. 提示词里强制要求“只输出 JSON,不要输出其他内容”。
  2. response_format指定json_object,让部分支持该能力的模型直接走 JSON 输出。
  3. 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 里,键名带上时间戳就行。这样用户可以随时对比“上次生成”和“这次生成”的区别,后续做“基于上周计划微调”的功能时,这些历史数据就是现成的素材。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询