ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cloudflare Durable Objects 实战:构建有状态的 AI 健身计划生成器

Cloudflare Durable Objects 实战:构建有状态的 AI 健身计划生成器 我最近用 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 实例对应一次“计划生成任务”。我在对象内部维护几个关键状态字段字段类型含义statusstringgenerating/done/faileduserInputobject用户提交的基础信息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): PromiseResponse { 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和envstate就是对象的状态容器。我实现了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): PromiseResponse { 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): PromiseResponse { 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 生成任务“断片”的问题刚开始我直接在前端发请求到 WorkerWorker 里同步跑 AI 生成一旦超时或用户断开连接任务就废了。改成 DO 之后问题同样存在DO 不是不会休眠长时间没有请求进来它也会进入休眠状态。所以关键一点是不要依赖对象一直“活着”来记住状态。我的做法是所有进度都写入state.storageAlarm 回调重新启动时第一步先从 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 里键名带上时间戳就行。这样用户可以随时对比“上次生成”和“这次生成”的区别后续做“基于上周计划微调”的功能时这些历史数据就是现成的素材。
返回列表