
我先说一个真实场景去年我在做一个 AI 健身计划生成的小产品用户填完目标、训练天数、器械条件点一下“生成计划”前端就开始转圈。最开始我只写了几个 Worker 接口调用大模型返回 JSON本以为很简单结果上线第一天就被打脸——用户生成一个四天分化训练计划LLM 平均要跑 30 到 40 秒而 Worker 的请求有超时限制请求一断用户看到的不是“生成失败”而是“一直转圈最后什么都没发生”。更惨的是同一个用户因为没拿到结果又点了几次生成LLM 那边重复计费了好几次。后来我把整套逻辑迁移到 Cloudflare Durable Objects 上这个标题里看起来“重”的技术反而成了整个服务里最省心的一环。这篇不是泛泛聊 AI也不打算从 Durable Objects 的文档概念讲起而是把它放进“AI 健身计划生成”这个真实业务里拆清楚它到底解决了什么问题、怎么设计状态机、怎么处理超时和重复请求以及我上线后撞到的四个坑。适合正在用 Workers 做 AI 应用、又觉得长任务和状态管理不好搞的开发者参考。1. 为什么AI健身计划不能只靠一个Worker跑完1.1 一个普通生成请求的三道坎一个“生成训练计划”的请求表面上只是调一次大模型实际拆开看是这样的先把用户的年龄、性别、训练目标、可用器械、每周天数、是否有伤病等十几个参数拼成提示词然后调用 LLM 生成一份包含每周动作安排、组数、次数、休息时间的计划最后解析 JSON 并落库。这个过程有三个绕不开的问题。第一LLM 本身慢尤其是生成复杂 JSON 结构时动辄二三十秒有些推理模型甚至要一分钟第二普通 Worker 请求的生命周期是有上限的你没法让一个 HTTP 请求挂在那里等一分钟第三也是最容易被忽略的——生成过程中如果用户关掉页面、断网、或者重复点击服务端必须知道这件事进行到哪一步了而普通 Worker 是无状态的请求结束就什么都不记得了。我当时的第一反应是“用队列不就行了”把生成任务丢到队列里后台慢慢跑。但仔细一想队列方案还得分服务器而且一个训练计划的生成结果要回传给用户还得额外维护一套 job 状态表。整个复杂度一点没少反而多了一个基础设施要管。1.2 为什么选中 Durable Objects而不是别的方案先给没接触过的朋友一个简单类比Durable Objects以下简称 DO就像你给每个用户请了一个专属管家每个用户有一个固定的房间实例管家有长期记忆持久化存储并且同一时间只处理一件事单线程事件循环。不管用户什么时候回来问“我的计划生成好了吗”管家都记得这件事做到哪一步了。我整理过三种方案的对比当时写进设计文档里这里直接放出来方案长任务支持状态存储并发控制维护成本适合场景普通 Worker KV 存状态弱超时即断需要自己写状态轮询弱每个请求独立中短请求状态简单外部队列 任务表强但要自建服务需要单独数据库需要额外实现高已有基础设施Cloudflare Durable Objects强事件驱动自带持久化 storage天然单实例串行低长任务 用户级状态DO 最打动我的不是某个单一功能而是“状态、并发、长任务”这三件事它天然都占了。再加上我本来就在 Workers 上不需要再引入新的后端服务整个技术栈还是只有一个 Cloudflare 边缘平台。1.3 先理解 DO 的三个底层机制再动手用 DO 之前我建议你先搞懂三个机制不然写起来很容易踩坑。第一个是实例寻址。通过idFromName(userId)可以生成一个确定性的 ID同一个 userId 永远对应同一个 DO 实例。这就实现了“每个用户一个管家”不同用户之间的状态天然隔离不会出现 A 的训练计划写到 B 名下这种低级事故。第二个是单线程事件循环。DO 的每个实例在同一时刻只处理一个事件这意味着你在代码里不需要考虑“两个请求同时改同一个状态”的竞态问题。看似限制其实是巨大的便利——做状态机的时候读到状态、判断逻辑、写入新状态整个流程是原子的。第三个是持久化 storage。state.storage是 DO 自带的键值存储写入后即使实例被冻结、重启数据也还在。对于训练计划这种低频突变、需要稳定保存的数据比 KV 更合适因为不需要额外的缓存一致性逻辑。这三件事理解透了后面的代码就顺理成章。2. 架构拆解状态协调交给Durable ObjectsAI生成交给LLM2.1 从请求到生成的完整数据流整个服务我拆成了三个文件入口 Worker、DO 类、AI 调用模块。用户点的“生成计划”实际走的是这样一条链路客户端 POST/api/plans带用户信息和一个幂等键。入口 Worker 根据 userId 拿到对应的 DO 实例把请求转交给 DO 的fetch方法。DO 先检查是否已经存在生成中的计划如果有就直接返回“已存在”防止用户连点。DO 写入状态PLAN_GENERATING设置一个立即触发的 alarm然后立刻向客户端返回202 Accepted和轮询地址。alarm 触发后DO 内部调用 LLM API把返回的 JSON 校验、清洗、落库。客户端轮询/api/plans/{userId}DO 根据状态返回PENDING、GENERATING、READY或FAILED拿到READY后再取计划内容。这里最关键的设计是第 4 步请求先返回AI 生成放在 alarm 里异步执行。如果你在 DO 的fetch里直接 await LLM问题会和普通 Worker 一模一样——HTTP 连接等着大模型慢慢吐字。把真正的生成塞进 alarm相当于把“接收请求”和“干活”彻底解耦这也是 DO 能扛长任务的核心玩法。2.2 核心代码入口 Worker入口 Worker 的代码很简单主要就是拿到 DO 实例并转发请求// src/index.ts import { WorkoutPlanDO } from ./workout-do; export interface Env { WORKOUT_PLAN_DO: DurableObjectNamespace; OPENAI_API_KEY: string; } export default { async fetch(request: Request, env: Env): PromiseResponse { const url new URL(request.url); const userId url.searchParams.get(userId) || default-user; // 每个用户一个 DO 实例按 userId 确定性寻址 const id env.WORKOUT_PLAN_DO.idFromName(userId); const stub env.WORKOUT_PLAN_DO.get(id); if (url.pathname /api/plans request.method POST) { // 创建/触发生成计划DO 内部会做幂等处理 return stub.fetch(request); } if (url.pathname /api/plans request.method GET) { // 查询当前用户计划状态任意 Worker 都可以查询不一定走 DO return stub.fetch(request); } return new Response(Not Found, { status: 404 }); }, };注意入口 Worker 不需要自己写任何生成逻辑它只做路由和转发。所有状态读写都收拢在 DO 内部。2.3 核心代码DO 类DO 类是核心状态机和 alarm 都写在里面我简化后的版本长这样// src/workout-do.ts import { generateWorkoutPlan } from ./ai; export class WorkoutPlanDO { private state: DurableObjectState; private env: Env; constructor(state: DurableObjectState, env: Env) { this.state state; this.env env; } async fetch(request: Request): PromiseResponse { const method request.method; if (method POST) { return this.handleCreate(request); } if (method GET) { return this.handleQuery(); } return new Response(Method Not Allowed, { status: 405 }); } async handleCreate(request: Request): PromiseResponse { const body await request.json(); const idempotencyKey body.idempotencyKey || plan-${Date.now()}; // 幂等检查同一幂等键下已经有状态直接返回当前状态不重复触发 const existing await this.state.storage.getany(plan); const status await this.state.storage.getstring(status); if (existing status READY) { return new Response(JSON.stringify(existing), { headers: { content-type: application/json }, }); } // 写入初始状态并保存请求体供 alarm 阶段使用 await this.state.storage.put(status, PLAN_GENERATING); await this.state.storage.put(request, JSON.stringify(body)); await this.state.storage.put(idempotencyKey, idempotencyKey); // 设置 0 毫秒后的 alarm异步触发真正的 AI 生成 await this.state.storage.setAlarm(Date.now()); // 立即返回 202 轮询地址避免请求超时 return new Response( JSON.stringify({ status: PLAN_GENERATING, pollingUrl: /api/plans?userId${body.userId}, }), { status: 202, headers: { content-type: application/json }, } ); } async alarm(): Promisevoid { const request JSON.parse(await this.state.storage.getany(request)); try { const plan await generateWorkoutPlan(request, this.env); await this.state.storage.put(plan, plan); await this.state.storage.put(status, READY); } catch (err) { await this.state.storage.put(error, String(err)); await this.state.storage.put(status, FAILED); } } async handleQuery(): PromiseResponse { const status await this.state.storage.getstring(status); const plan await this.state.storage.getany(plan); const error await this.state.storage.getstring(error); if (status READY) { return Response.json({ status, plan }, { status: 200 }); } if (status FAILED) { return Response.json({ status, error }, { status: 500 }); } return Response.json({ status }, { status: 200 }); } }这段代码里有个容易被忽略但很重要的细节alarm里如果调用 LLM 抛错一定要把FAILED状态写回去并且把错误信息存下来。不然用户打死也不知道自己的计划为什么永远停在GENERATING。我早期没写 catchdebug 的时候全靠猜浪费了一整个下午。2.4 数据存储的分层什么数据放哪里DO 自带 storage 并不是唯一的数据存储我最终把数据分成三层DO storage存训练计划的状态机数据和最终计划 JSON因为要跟着生成流程走天然适合放在 DO 里D1 数据库存用户档案、历史生成记录、每个计划的使用反馈这些需要按条件查询比如“找出所有这个月生成过计划的人”DO 的 KV 存储不适合做查询KV存静态资源和 AI 生成的提示词模板读多写少边缘缓存友好。这样分不是为了炫技而是每种存储的能力边界不一样。你要是图省事全塞 DO storage后期会发现“按日期筛选计划”这种查询根本写不出来。反过来把状态机这种需要强一致性的数据放 D1又会在并发更新时遇到各种冲突。3. 提示词与结构化输出让生成结果可校验、可落库3.1 提示词的第一原则强制输出 JSON很多人写 AI 健身计划提示词让模型“返回一份周计划”结果模型给你一段 Markdown 表格甚至夹杂“祝您训练愉快”这种废话。我的做法是在系统提示词里直接声明只输出 JSON不要输出任何解释性文字。并且在 JSON 里嵌入一个可被程序直接识别的schema_version字段为以后迭代留余地。我目前线上用的提示词核心段落长这样你可以直接抄你是一位拥有 CSCS 认证的健身教练。请根据以下用户档案和训练条件生成一份一周训练计划。 用户档案 - 性别、年龄、身高体重略 - 训练目标增肌 - 每周可训练天数4 - 可用器械杠铃、哑铃、可调节凳 - 伤病限制无 输出要求 1. 只输出一个 JSON 对象不要包含任何文字说明。 2. JSON 结构必须符合以下 Schema { schema_version: 1.0, goal: muscle_gain, days_per_week: 4, weeks: [ { week: 1, sessions: [ { day: Monday, focus: Push, exercises: [ { name: Barbell Bench Press, sets: 4, reps: 8-10, rest_seconds: 90, notes: 控制离心胸大肌充分拉伸 } ] } ] } ] } 3. 动作要具体不要写「肩部练习」这种模糊描述必须写出具体动作名。 4. 组数和次数要符合渐进超负荷原则相邻两周要有合理调整。给模型一个 Schema 模板而不是一段描述是结构化输出成功率最高的办法。实测下来按这个方式LLM 返回可解析 JSON 的成功率能到 95% 以上剩下 5% 交给代码兜底。3.2 拿回来先校验再落库LLM 返回的 JSON 不能直接信。我遇到过模型把reps: 8-10写成reps: 8-10没引号导致 JSON 解析失败也遇到过sets: 0这种业务上完全没意义的数值。所以我加了两个层面的校验。第一层用 JSON Schema 校验器我用的是 Ajv验证结构完整性字段类型对不对必填字段有没有缺失。第二层是业务规则校验所有动作必须出现在我的动作库里组数必须在 1 到 6 之间每次训练时间估算不能超过 90 分钟一周内同一肌群的训练频率不能超过安全上限。业务校验不过会触发一次重试重试时把失败原因拼进提示词让模型修正。// src/schema.ts import Ajv from ajv; const planSchema { type: object, required: [schema_version, goal, days_per_week, weeks], properties: { schema_version: { type: string }, goal: { type: string }, days_per_week: { type: number }, weeks: { type: array, minItems: 4, maxItems: 12, items: { type: object, required: [week, sessions], properties: { week: { type: number }, sessions: { type: array, minItems: 1 }, }, }, }, }, }; export function validatePlan(plan: unknown): { ok: boolean; errors?: string[] } { const ajv new Ajv(); const validate ajv.compile(planSchema); const valid validate(plan); return valid ? { ok: true } : { ok: false, errors: validate.errors?.map((e) e.message || ) }; }3.3 生成后的后处理动作映射与渐进负荷校验通过不等于可以直接用我还会做一轮后处理。首先是动作映射大模型有时会编出一个根本不存在的动作名或者同一个动作在不同器械下的变体名称不一致需要映射到我动作库里对应的标准名和肌群标签。其次是强度折算根据用户档案里的训练水平把固定组次范围换算成对应的 RPE训练自觉强度让计划对新手和进阶用户都可用。最后一步是渐进超负荷检查。AI 生成的周计划经常出现训练量完全一样的情况后处理会把每周总组数、总次数算出来对比上一周如果下周没有增加 2% 到 5% 左右的训练量就自动调整最后一组的建议次数或组间休息时间保证“增肌计划”在原理上站得住脚。这一步做完用户才会觉得这不是一份“模板套娃”计划。4. Durable Objects的三件脏活状态机、幂等与限流4.1 状态机设计用户和管家都看得懂的流转训练计划从创建到可用我定义了五个状态状态含义用户看到的提示PLAN_PENDING已收到请求还没开始生成排队中PLAN_GENERATINGAI 正在生成生成中约需 30 秒PLAN_READY生成完成计划可查看查看计划PLAN_FAILED生成失败可重试生成失败点击重试PLAN_EXPIRED超时未完成请重新生成这个状态机放在 DO 里用state.storage保存当前状态流转规则只有四种创建时从PENDING到GENERATINGalarm 成功后从GENERATING到READY失败时从GENERATING到FAILED用户重试时从FAILED回到GENERATING。为什么要有PENDING这个状态因为我要区分“请求刚进来”和“alarm 正在生成”。有些极端情况下 alarm 可能延迟几秒这段时间内用户来轮询如果直接返回GENERATING语义上也没错但从可观测性角度你没法判断是“即将生成”还是“已经卡住”。多一个状态后面接监控和告警都方便。4.2 幂等键解决用户连点的“重复扣费”这是整个服务里最现实的坑。用户点一次生成前端看到没反应又点一次如果后端不做幂等LLM 就会被调用两次账单上就多一笔钱。我的做法见前面代码客户端在 POST 时带上一个idempotencyKey通常是 UUIDDO 处理创建请求前先检查 storage 中是否已有同一个 key 对应的状态。如果有直接返回当前状态不再触发新的 alarm。还有个细节值得提醒幂等键的作用域要收紧到“用户级 DO 实例”内。也就是说同一个用户的不同计划请求要生成不同 key而同一个计划的重复点击要复用同一个 key。我的方案是前端在用户第一次点击时生成 UUID后续重试都带着这个 UUID 走后端据此判断是“重试”还是“新计划”。4.3 限流利用 DO 的天然串行而不是靠额外的限流器AI 生成服务最怕的不是流量大而是多个请求同时在短时间内打到 LLM 上游触发上游的 Rate Limit。我原来在普通 Worker 里做过一个“请求发起前检查是否在冷却期”的开关结果分布式环境下根本不可靠——多个 Worker 实例同时检查发现都没人访问然后一起发出去照样打爆。换到 DO 以后这个问题被架构层面解决了同一个用户的所有请求都进同一个 DO 实例而 DO 实例是单线程事件循环同一时刻只处理一个事件。这意味着一万个用户同时点生成LLM 上游看到的请求依旧来自不同 DO 实例而每个用户之间的请求不会自相残杀。当然如果你有“一个用户同时生成多个计划”的需求限流粒度就变成了用户维度在 DO 内部用一个lastRequestAt时间戳判断两次请求间隔小于 5 秒直接拒绝。因为都在同一个 DO 实例里这个判断没有竞态问题写起来非常简单。4.4 异常补偿LLM 挂了也不能让用户干等AI 生成一定会失败网络抖动、上游超时、上下文超长、JSON 解析失败任何一环都可能挂。我的 alarm 里做了两层补偿。第一层是重试同一个任务最多重试 2 次每次重试前先修改提示词把上次失败的原因写进去。比如“上次返回的 JSON 缺少 required 字段 sessions请确保每个 week 对象都有 sessions 数组”。第二次重试如果还失败状态置为FAILED不继续耗。第二层是降级如果连续两次失败我会用一份本地存储的“通用模板计划”兜底标记为TEMPLATE用户能看到完整计划同时界面提示“AI 生成暂时不可用已为您提供专业模板”。这个兜底不赚钱但保住了用户的留存率。用户不会因为你生成失败就流失但会因为生成失败后“什么也得不到”而流失这两件事差别很大。5. 上线后撞上的四个坑超时、重复扣费、JSON校验和状态丢失5.1 第一个坑返回了 202但用户永远拿不到结果我最初的设计没有用 alarm而是想当然地在fetch里直接 await LLM然后返回结果。结果就像开头说的用户疯狂转圈。改成202 alarm之后又踩了一个新坑alarm 虽然触发了但 DO 实例里没有对应的状态查出来永远是空的。排查后才发现我当时在 alarm 里没有先读request存储而是直接用了this.request——但 DO 的 alarm 回调里根本没有 request 上下文。修复方案就是代码里展示的那样创建请求时把用户参数完整存到 storage 里alarm 触发时再去读。DO 的 fetch 和 alarm 之间不能共享内存只能靠 storage 传数据这个认知如果不建立起来后面还会踩更多类似的坑。5.2 第二个坑重复请求导致 LLM 重复计费这个坑我在第 4 章的幂等设计里已经详细说了但上线时还是出过一次事。原因是前端在用户点击后先调用了一次 POST又因为一个 beforeunload 事件触发了一次 POST两个请求几乎同时到达 DO——第一个走到了“写状态、设置 alarm”第二个进来时发现状态已经是PLAN_GENERATING了就直接返回了看起来是安全的。但因为我早期代码里用Date.now()当幂等键两个请求生成了不同的幂等键导致第二个请求绕过了幂等检查。最后我把幂等键生成完全挪到客户端用 crypto.randomUUID()并且在后端把“创建新计划”和“幂等重试”两个逻辑分开才彻底解决。经验是幂等这件事必须从客户端到服务端一起设计单独靠一端做总有缝隙漏过去。5.3 第三个坑LLM 返回合法 JSON但业务上完全不可用Ajv 校验只能保证结构合法不能保证内容合理。我遇到过一次 LLM 返回的 JSON 结构完全正确但里面的动作名是“Arnold Press”我的动作库里没有直接存进去之后用户在前端看到动作列表是空的。修复方案是增加一个“业务语义校验”层把动作名映射到动作库 ID映射失败就返回重试信号。而且重试提示词里要写得足够具体“你使用了不在动作库中的动作 Arnold Press请从以下动作列表中选择...”实测发现把动作列表塞进提示词后模型很少再乱编动作名了。不是因为它记性好而是因为它不需要猜了。5.4 第四个坑训练计划突然“消失”了这个坑发生在我开始改动 DO 的 storage 结构之后。我原来把整个计划 JSON 存在plan这个 key 下后来想改成按周拆分的plan_1、plan_2就在代码里加了读取plan_1的逻辑。结果老用户的所有计划全部读不出来了因为他们的数据还是存在plankey 下。这不是 DO 的问题而是我在设计存储结构时没有做迁移兼容。后来我在读取逻辑里写了一个 fallback先读新 key读不到就读旧 key读到旧 key 后异步做一次数据迁移。这里也提醒各位DO 的 storage 结构一旦上线就会积累真实数据改结构前一定要想好兼容策略不然线上事故就是这样发生的。5.5 排查链路用 wrangler tail 定位 DO 内部状态DO 的调试比普通 Worker 麻烦一点因为你有两个层面要查入口 Worker 的行为和 DO 实例内部的行为。我最常用的排查链路是三条命令配合# 查看入口 Worker 的请求日志 wrangler tail --format pretty # 在 DO 代码里主动打日志定位状态流转 console.log([DO] alarm triggered, current status:, await this.state.storage.get(status)); # 本地模拟 DO alarm 触发 wrangler dev --test-scheduled提醒一句wrangler tail默认能看到console.log输出但 DO 实例的日志会带有durableObject字段从那里可以确认是哪个 userId 的实例在报错。我排查问题时的习惯是在 alarm 的入口和出口各打一条日志把状态打出来基本能在两分钟内定位是 LLM 问题、存储问题还是状态机问题。6. 部署、鉴权与成本一个真实小项目的落地清单6.1 wrangler 配置与发布DO 的配置集中在wrangler.toml第一次配置时要注意两点一个是durable_objects.bindings一个是migrations。迁移配置尤其容易漏没有它你会一直收到“Durable Object class not found”之类的错误。name workout-forge main src/index.ts compatibility_date 2024-11-01 [[durable_objects.bindings]] name WORKOUT_PLAN_DO class_name WorkoutPlanDO [[migrations]] tag v1 new_sqlite_classes [WorkoutPlanDO]发布流程就三步wrangler login登录wrangler deploy发布wrangler tail看日志。这一步没什么弯弯绕但一定要把 migration 写在最前面不然以后想给 DO 加新的方法会发现实例调度不生效。6.2 鉴权别让你的 AI 计费接口裸奔这个服务只要上线就会有人扫你的端点。我的 AI 生成接口一开始用的是 userId 参数直接寻址没有鉴权结果被刷了三天账单多了几十美元。后来统一改成前端通过一个独立的登录服务换取短期 token所有 /api/plans 请求都必须带Authorization: Bearer tokenWorker 入口在转给 DO 之前先做 JWT 校验。如果你不想引入认证服务至少也要做 IP 维度的限流或者在请求里加上客户端生成的匿名 ID 并配合签名校验。裸奔的 AI 接口不是技术问题是钱的问题。6.3 成本估算DO 不贵LLM 才是大头很多人一听 Durable Objects 就觉得是“企业级大厂才用得起的架构”实际上对于一个 5000 用户规模的小产品DO 的成本完全可以量化。项目估算方式月成本估算DO 请求数假设每人每月生成 4 次计划 轮询 10 次约 7 万次请求免费额度内或小几十元DO 存储每个计划约 20KB5000 人 × 历史 10 条计划 ≈ 1GB几元到十几元DO 耗电量alarm 每次执行约 5 秒每月约 1 万秒执行时长可控个位数美元LLM API每次生成消耗约 6 万 token按中等模型算单次 0.1 美元左右每月数千元算完你就明白这类产品的大头永远是模型调用费DO 的基础设施成本几乎可以忽略不计。所以我在设计上反复强调幂等和防刷省下的都是真金白银。6.4 后续扩展缓存、定时调整与共享模板当用户量上来之后有三个明显的优化方向。第一个是热门参数组合的缓存很多人都是“增肌、每周 4 天、杠铃哑铃”同一份计划模板可以复用不需要每次都调 LLM用 KV 缓存命中即可。第二个是周期性调整DO 的 alarm 可以用来做计划到期提醒和每周训练完成后的 AI 微调用户不需要每次手动重新生成。第三个是多用户共享一个教练用户生成一份计划批量分发给他的学员每个学员还是各自独立的 DO 实例但计划内容指向同一个模板能大幅减少 LLM 调用。这几个扩展我都试过一部分最直观的收益是 LLM 调用次数直接降了 40%。从开始踩超时的坑到最终把整套状态逻辑收进 DO我最大的体会是所谓“用 Durable Objects 做 AI 应用”并不是因为 DO 是时髦的技术名词而是它恰好补上了 AI 长任务里最难受的那块拼图——异步协调和持久状态。如果你也在 Workers 上做 AI 产品第二天的计划千万别让用户傻等把生成逻辑丢进 alarm把状态机放进 DO你会回来感谢这个设计。