ARTICLE DETAIL

资讯详情

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

基于Cloudflare Durable Objects的AI健身计划服务架构实践

基于Cloudflare Durable Objects的AI健身计划服务架构实践 1. 先说清楚这个项目到底在做什么我想做的是一个真正的 AI 健身计划服务不是那种“丢给大模型一个 Prompt返回一段文字就完了”的玩具。用户填上自己的训练目标、身体数据、训练水平、可用器械系统生成一份结构化的 7 天训练计划然后用户每天打卡记录完成情况系统还能根据反馈调整后面的训练安排。这里有个很容易被忽略的点AI 生成计划只是最外层的一步真正难的是“状态管理”。今天练完第 3 天了明天应该自动切到第 4 天用户临时说膝盖不舒服后面的深蹲要换成腿举用户同时用手机和电脑登录两边都在打卡不能互相覆盖。这些都不是一句“调用大模型”能解决的它们需要一个能记住每个用户状态、能够并发安全更新、并且部署和运维成本都足够低的后端方案。我最后选了 Cloudflare Workers Durable Objects 来承载这套服务。Durable Objects 是 Cloudflare 提供的一种有状态的分布式对象它把“每个对象同一时刻只处理一个写操作”的语义直接内置在平台里开发者不用自己去搞分布式锁、不用管理数据库连接池对象内部的状态会由平台自动持久化。对于 AI 健身计划这个场景来说这几乎就是为我量身定做的每个用户是一个独立对象数据和操作天然按用户隔离不用担心多租户之间的相互影响。练过的朋友都知道健身计划和“纯内容生成”最大的区别就是它有一条时间线这周练完下周的计划要调整这周状态不好下周的强度要降。这种需要跨请求记住“上次进行到哪里”的服务用传统无状态函数写起来会非常痛苦你得额外搭 Redis、搭数据库、写一堆持久化代码。而 Durable Objects 允许我在对象内部直接维护当前训练日、历史打卡记录、AI 生成的全部计划内容所有读取和更新都像写一个普通类一样简单但是背后又天然具备分布式能力。这篇文章会把完整方案讲清楚从架构选型、状态模型设计、AI 提示词工程、核心代码实现到上线后遇到的坑和排查方法。适合正在做 AI 应用、想了解如何用有状态 Serverless 方案落地一个真实产品的开发者也适合自建健身应用 MVP 的技术负责人参考。我不做多余的铺垫直接上干货。1.1 核心问题拆解表面上这是一个“生成健身计划”的需求拆开看其实是三个问题。第一个问题是 AI 生成的设计能力。用户输入目标、水平、器械模型输出合理的训练计划。这里的关键不是“能不能生成”而是“能不能稳定生成一份结构化、可被程序消费的计划”比如每天有几个动作、每个动作几组几次、组间休息多少秒。这是我的提示词工程和后处理校验要解决的问题算是这套系统里最灵活的部分。第二个问题是状态持久化。计划生成了要在后续几天内反复读取用户打卡要记录用户调整动作要更新。如果后端是无状态的每次请求都要从数据库重新拉取计划并拼装状态逻辑会非常散。Durable Objects 解决了这个痛点我可以把一份计划连同用户当前的进度一起存在对象内部读的时候直接从对象内存拿写的时候由平台保证持久化。第三个问题是多端并发同步。用户不太可能只在一个设备上使用。手机端打卡、网页端查看、智能手表同步同一时间可能有多个请求打到同一个用户数据上。Durable Objects 的对象级别单写者语义避免了我自己去实现复杂的并发控制它保证针对同一个对象的请求是排队处理、串行执行的天然不会出现两个写操作同时修改同一份数据的情况。1.2 选型对比我为什么没有直接搞数据库加定时任务在确定 Durable Objects 之前我按最常规的路子推演过几个方案这里直接把对比结果放出来方便你判断自己的项目是否需要类似的选型。方案无状态 API用户进度存储并发控制部署运维成本传统后端 MySQL Redis容易需要建表、做ORM、维护迁移需要自己处理事务和锁高要管服务器、备份、扩缩容普通 Serverless 函数 对象存储容易每次读写都要访问外部存储容易出现竞态问题中要处理冷启动和连接池Cloudflare Workers Durable Objects容易对象内部直接存取平台内置单写者语义低代码部署即上线我最早想过“PHP/Node 后端 MySQL”这种典中典组合但是算了一笔账用户量不大时光维护一台云服务器、配置数据库、处理半夜宕机的成本就已经高于业务本身了。后来考虑 Serverless 函数但很快发现一个问题健身计划服务里大多数请求其实是在读同一份状态如果每个请求都无状态地连一次外部存储先不说延迟光是数据库连接和流量费用就会让我很不舒服。Durable Objects 的特点是“状态跟着对象走”。当我通过idFromName拿到某个用户的稳定对象 ID 后所有对这个 ID 的请求都会路由到同一个对象实例我直接在这个实例的ctx.storage里读写数据。其他用户的对象互相隔离某个对象出错也不会影响别人。最妙的是我不需要为“并发更新进度”写任何额外的分布式锁代码因为平台已经保证了同一个对象同时只处理一个写操作。当然这套方案也有自己的边界比如不适合做跨用户的全局聚合分析、存储结构更适合按用户隔离的场景这些我放在第 5 章细说。总体而言对于健身计划这种“一个用户一份状态”的典型场景它是我当前能找到的最优解。2. 架构设计与数据流拆解2.1 整体架构Worker 当入口Durable Object 存状态AI Provider 做生成整个系统的架构非常克制只由三个部分组成。Worker是唯一的 API 入口负责鉴权、路由分发、参数校验以及在需要生成计划时调用 AI 服务。它本身不保存任何业务状态是一个纯计算层。所有请求进来之后Worker 根据 userId 解析出对应的 Durable Object 实例然后把具体的读写操作转发给那个对象。Durable Object是每个用户的“私有档案库”。它保存三样东西当前计划元数据、按天拆分的训练内容、用户每天的打卡进度。用户发来 GET 请求时它直接从自身读取数据返回用户发来打卡请求时它验证参数、更新进度、推进当前训练日这些操作都是原子的。AI Provider是外置的生成能力我通过 OpenAI 兼容的chat/completions接口调用把用户信息拼成 Prompt要求模型返回严格 JSON然后在 Worker 层做完整校验后再把结果交给 Durable Object 存储。这里 AI 不直接接触用户状态它只负责“生成”不负责“记忆”。数据流大致是这样的用户发起POST /api/plan→ Worker 解析 body → Worker 调用 AI 接口生成计划 → AI 返回 JSON → Worker 做结构校验和字段兜底 → Worker 拿到 DO stub → 调用savePlan→ 返回成功响应。用户后续每次打卡请求路径会短很多POST /api/check-in→ Worker 只做基础参数校验 → 调用 DO 的更新方法 → 返回最新进度。AI 不会参与每一次打卡这样可以显著降低调用成本。2.2 核心状态模型一个用户的计划到底长什么样因为大模型输出的计划是 JSON我直接用对象结构存储下面是一个精简后的状态模型示例实际代码中字段会更多。{ userId: user_123, goal: fat_loss, fitnessLevel: intermediate, trainingDays: 7, currentDay: 1, plan: { day1: [ { name: 杠铃深蹲, sets: 4, reps: 10, restSec: 90, equipment: barbell, tips: 核心收紧膝盖和脚尖方向一致 }, { name: 哑铃卧推, sets: 3, reps: 12, restSec: 75, equipment: dumbbell } ], day2: [], day3: [] }, progress: { day1: { done: true, completedAt: 2025-01-10T08:30:00Z, rating: 4 }, day2: { done: false } } }这里有一个非常关键的工程细节我把currentDay放在顶层是刻意为之。打卡接口每次只接收“今天完成了哪一天”由服务端来判断是否要推进到下一个训练日而不是让客户端自己传一个第二天的值过来。原因很简单客户端时间不准而且如果我允许客户端随便传就会出现用户从旧设备打卡把新进度覆盖掉的惨剧。在 Durable Object 里我实际上不是把上面这一大坨 JSON 作为一个键存进去的。原因涉及存储限制我后面会在坑的部分详细讲这里先说设计原则小而易变的数据字段单独存大块且只读的内容按天拆分。比如元数据meta存一份每天的完整动作列表用day:1、day:2这样的键分开存打卡进度单独一个progress键。这样每次打卡只需要更新小键不需要把整个大计划读出来再写回去。2.3 提示词工程如何让大模型稳定吐出结构化计划这个项目里的 AI 调用不是随便写一句“帮我生成一个健身计划”就完事的因为下游程序需要解析结果、存库、渲染页面。模型一旦开始自由发挥输出 Markdown 列表、加额外解释、造出不存在或高风险的训练动作整个链路就断了。我把 Prompt 拆成四层这里分享一个亲测有效的结构。首先是角色设定明确告诉模型它是“具备运动科学背景的私人教练”并且要求它只输出计划、不闲聊。其次是输出格式约束必须是 JSON字段名固定类型固定日期用数字编号而不要用“周一”这种容易出乱子的文本。再次是业务规则约束动作名称要来自真实器械训练动作不要自创动作或者把高难度动作放到初学者计划里每个动作必须有组数、次数和休息时间。最后是少样本示例我会在 Prompt 里塞一条完整的“day1 输出示例”让模型照葫芦画瓢。下面是一个精简版的 Prompt 构造示例。const messages [ { role: system, content: [ 你是一名私人教练请根据用户的训练目标、水平和可用器械生成一份 7 天训练计划。, 只输出 JSON不要输出任何解释、Markdown 或代码块标记。, JSON 结构: { \plan\: { \day1\: [{ \name\: \动作名\, \sets\: 3, \reps\: 12, \restSec\: 60, \equipment\: \器械名\, \tips\: \动作要点\ }] } }, 规则: 动作必须真实可执行新手不要安排大重量复合动作每个动作必须有 sets/reps/restSec每天安排 4-6 个动作。 ].join(\n) }, { role: user, content: JSON.stringify({ goal: params.goal, fitnessLevel: params.fitnessLevel, availableEquipment: params.equipment, trainingDays: 7 }) } ];在实际调用 AI 接口时我会尽量使用response_format: { type: json_object }参数如果所用模型或服务商不支持这个参数就得靠系统提示词里的“只输出 JSON”约束同时在后处理阶段做更加严谨的解析兜底。这个过程我在第 4 章展开。3. 从零到一的代码实现3.1 环境准备与配置我使用wrangler初始化项目命令如下。npx wrangler init ai-workout-planner项目创建好之后需要重点配置两样东西Durable Object 的绑定以及 AI 凭据。wrangler.toml的配置片段长这样name ai-workout-planner main src/index.ts compatibility_date 2024-12-18 [durable_objects] bindings [ { name WORKOUT_PLAN, class_name WorkoutPlanDO } ] [[migrations]] tag v1 new_sqlite_classes [WorkoutPlanDO]注意[[migrations]]这一段首次给项目添加 Durable Object 类时必须有对应的 migration否则部署后访问 DO 会直接报错。这个报错非常经典很多第一次接触 DO 的人都会踩到。具体的报错信息和排查方法我放在第 4 章的表格里。然后通过环境变量注入 AI 服务商相关信息wrangler secret put AI_API_KEY wrangler secret put AI_API_ENDPOINT wrangler secret put AI_MODEL把敏感凭据放在 secrets 里不要写进仓库。虽然开发时可以临时写在.dev.vars文件里但那只是本地开发用的千万别提交到 git。3.2 Worker 入口路由与 Durable Object 绑定我在src/index.ts里实现 Worker 的出口。这里只列核心路由逻辑简化了鉴权和参数校验的细节但在注释里标明了生产环境必须补上的地方。export default { async fetch(request: Request, env: Env): PromiseResponse { const url new URL(request.url); const userId url.searchParams.get(userId); if (!userId) { return Response.json({ error: missing userId }, { status: 400 }); } // 生产环境必须从登录态/会话中解析 userId而不是信任客户端传入的参数。 const id env.WORKOUT_PLAN.idFromName(userId); const stub env.WORKOUT_PLAN.get(id); if (url.pathname /api/plan request.method POST) { const body await request.jsonGeneratePlanRequest(); return handleGeneratePlan(env, stub, userId, body); } if (url.pathname /api/plan request.method GET) { const plan await stub.getPlan(); return Response.json(plan ?? { error: plan not found }, { status: plan ? 200 : 404 }); } if (url.pathname /api/check-in request.method POST) { const body await request.jsonCheckInRequest(); const progress await stub.checkIn(body.day, body.done); return Response.json(progress); } return Response.json({ error: not found }, { status: 404 }); } } satisfies ExportedHandlerEnv;env.WORKOUT_PLAN.idFromName(userId)是整套架构的核心。它通过名称生成一个确定性的 ID相同 userId 永远映射到同一个 Durable Object 实例。这样我就不需要维护“哪个用户的数据在哪个节点”这样的路由表底层会自动处理。3.3 Durable Object 核心类实现下面是我实际使用的 DO 类的精简版本。DurableObject类在构造时可以拿到this.ctx和this.env通过this.ctx.storage操作持久化存储。import { DurableObject } from cloudflare:workers; export class WorkoutPlanDO extends DurableObjectEnv { async getPlan() { return (await this.ctx.storage.get(meta)) ?? null; } async savePlan(userId: string, planInput: PlanInput) { const meta { userId, goal: planInput.goal, fitnessLevel: planInput.fitnessLevel, currentDay: 1, createdAt: new Date().toISOString() }; // 每天的训练内容单独存放避免单个 key 过大 const dayEntries Object.entries(planInput.plan); for (const [dayKey, exercises] of dayEntries) { await this.ctx.storage.put(day:${dayKey}, exercises); } await this.ctx.storage.put(meta, meta); await this.ctx.storage.put(progress, {}); return { ok: true, meta }; } async checkIn(day: number, done: boolean) { // 单写者特性保证这里不需要手动加锁 const progress (await this.ctx.storage.getRecordstring, object(progress)) ?? {}; progress[day${day}] { done, completedAt: done ? new Date().toISOString() : null }; await this.ctx.storage.put(progress, progress); if (done) { const meta (await this.ctx.storage.getMeta(meta)) ?? { currentDay: 1 }; // 已完成的天数只会往前推进不会回退 meta.currentDay Math.max(meta.currentDay, day 1); await this.ctx.storage.put(meta, meta); } return progress; } }我一开始看到async方法的时候有个疑惑既然单写者保证“不并发”为什么方法还需要是异步的其实这里的异步是针对 I/O 的Durable Object 会串行执行到达它的请求但每个请求内部依然可以异步读写存储所以并不矛盾。平台会保证同一个对象同一时刻只有一个请求在执行写操作这是 Durable Object 最核心的卖点也是我敢把进度更新逻辑写得这么直白的原因。3.4 AI 生成与结构化校验AI 调用放到 Worker 层不在 DO 内部。这样做有个好处生成计划的耗时不会阻塞 Durable Object 的请求处理队列因为对象只需要接收最终结果。调用 AI 的核心函数是这样的async function callAI(env: Env, messages: Array{ role: string; content: string }): Promisestring { const resp await fetch(env.AI_API_ENDPOINT, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${env.AI_API_KEY} }, body: JSON.stringify({ model: env.AI_MODEL, messages, response_format: { type: json_object }, temperature: 0.7 }) }); if (!resp.ok) { const errText await resp.text(); throw new Error(AI API error: ${resp.status} ${errText}); } const data await resp.json{ choices: Array{ message: { content: string } } }(); return data.choices?.[0]?.message?.content ?? ; }AI 返回的是一段纯文本通常格式是 JSON但偶尔会携带多余的 Markdown 注释或者代码块标记。因此我写了一个extractJson函数做解析兜底function extractJson(text: string): unknown { try { return JSON.parse(text); } catch { // 去掉可能的 json 代码块标记 const cleaned text.replace(/json|/g, ).trim(); try { return JSON.parse(cleaned); } catch { // 尝试截取第一个 { 到最后一个 } 之间的内容 const start cleaned.indexOf({); const end cleaned.lastIndexOf(}); if (start 0 end start) { return JSON.parse(cleaned.slice(start, end 1)); } throw new Error(AI output is not valid JSON); } } }解析完成后还必须做一次结构校验。我自己总结了一套“最小可用校验”检查计划是不是 7 天、每天至少 1 个动作、每个动作必须有name、sets、reps、restSec数字类型对不对。凡是校验失败的情况我会让 AI 带着报错信息重新生成一次。如果两次都失败就回退到一份硬编码的基础计划模板保证用户端始终有东西可用。3.5 把计划推给客户端REST 为主WebSocket 为辅我的第一版只做了 REST 接口客户端通过轮询拿到最新计划和打卡结果用起来也还行。但为了让打卡之后的进度展示更实时我补了一个轻量的 WebSocket 通道。WebSocket 的连接也挂在 Worker 上当客户端连上后Worker 会把请求转发给对应的 Durable Object 实例。DO 中我可以主动把最新状态推送给客户端比如用户当天完成打卡后订阅了 WebSocket 的手机可以立刻收到“进度已更新”的事件。具体实现上需要用到WebSocketPair代码量不多async fetch(request: Request) { const pair new WebSocketPair(); const [client, server] Object.values(pair); server.accept(); this.ctx.storage.get(meta).then((meta) { server.send(JSON.stringify({ type: sync, data: meta })); }); this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); }但是我想提醒一句如果你的客户端数量不多纯轮询就够用了WebSocket 会引入连接管理和断线重连的额外复杂度。我是在“实时性确实影响体验”之后才加的不建议上来就双通道不去重设计我的架构坚持基础设施只做基本面。4. 实测记录我踩过的坑与排查清单4.1 计划数据太大撞上 Durable Object 存储限制Durable Object 的存储不是无限大的单个 value 最大 128KB。我第一版直接把整份 7 天计划塞进一个键里数据生成后发现只要在tips字段里多加几句动作教学一份计划很快就突破 50KB再配合历史打卡记录和备注再过一段时间就会超过限制。虽然短期不出问题但迟早有一天storage.put会抛错。我的解决方案就是把“大字段拆开存”。训练计划改为按天拆键比如day:1、day:2每次只读写当天所需的数据。元数据和打卡进度都放在小键里。读取完整计划时我需要先拿meta再拿所有day:*然后在内存拼装。这里的代价是代码多写几行但换来了存储的稳定性。提示不要等到线上报错再拆键架构设计阶段就要确定“单用户数据在存储中如何分片”这个策略。Durable Objects 不是不能存大 JSON而是你不要让某个键无限增长。4.2 大模型的“自由发挥”让 JSON 解析崩了测试阶段遇到最多的就是这种问题AI 明明收到了“只输出 JSON”的指令仍然给你返回一段带 Markdown 标题和解释文字的混合内容有时候还会把单引号当成 JSON 合法字符用。JSON.parse直接抛异常整个接口 500。后来我用三重兜底解决了大部分情况先直接解析失败后去掉外围的代码块标记再解析再不行就截取首尾大括号之间的内容硬解析。对于仍然解析失败的情况我会把原始输出以及当前的校验失败原因拼进下一次 Prompt要求模型“修正输出格式后重新生成”。重试机制实测能让成功率从 90% 提升到 99% 以上。从根上说能支持response_format的服务一定开启它这比任何 Prompt 兜底都可靠。如果用的是不支持这个参数的服务商就必须下决心把“提取 JSON”函数写得很健壮这是成本最低的保险。4.3 多端同步时出现的并发覆盖问题按道理 Durable Object 的单写者语义会保证同一个用户的写请求串行执行但我在测试手机和网页同时打卡时还是发现了一个问题当两个客户端各自持有过期状态并且把旧状态作为条件提交时新的覆盖了旧的。这不是平台问题而是我的业务逻辑设计问题。根因在于checkIn(day, done)没有做版本控制。手机先读到currentDay 3网页也读到currentDay 3手机完成第 3 天打卡并推进到 4网页随后提交“第 3 天未完成”的状态又把进度改回去了。解决方式就是在检查清单里加入一个“幂等键”或“基础版本号”。客户端调用打卡接口时带上它读取到的currentDay服务端对比当前值不一致就直接拒绝或提示“状态已过期请刷新”。这个改动虽然简单但能最大程度避免用户在多设备上互相打架。4.4 成本失控预警AI 调用得太随意了健身计划里的 AI 调用不由着用户的情绪来。用户可能手滑点了几次“重新生成”或者前端代码在重试时重复调用同个接口每次都按字符数计费累积下来非常可观。我从三个层面控制成本。第一是结果缓存我把每次生成好的计划按“userId 目标 水平”组合作为 key 写入 KV如果用户参数完全一致下次直接复用无需再调 AI。第二是限频限制单个用户每天的重新生成次数超过次数就返回“明天再来”。第三是前端防抖按钮点击后立刻进入 loading 状态从源头减少重复请求。一套组合下来AI 调用量大概下降了七成而用户体验几乎没有感知。4.5 常见问题排查速查表这里我把实测中最常见的几个问题整理成速查表按“现象 → 原因 → 处理方式”排列供你直接对照。现象原因处理方式首次部署后访问 DO 接口报错缺少 migration 配置在wrangler.toml中添加[[migrations]]和new_sqlite_classesAI 返回的内容无法解析模型输出混入了 Markdown 或解释启用response_formatjson_object并实现三段式 JSON 提取兜底打卡后进度回退或错乱客户端提交的是过期状态增加currentDay版本校验拒绝过期请求AI 调用量远超预期缺少缓存和限流接入 KV 结果缓存增加每日重新生成次数限制计划存储越来越大把多天计划存进了同一个键按天拆分键元数据和进度单独存储WebSocket 断开后收不到更新客户端没有处理重连增加断线重连和状态补偿拉取逻辑5. 复盘思考这套方案的边界与进阶玩法5.1 什么情况下该放弃 Durable Objects我做了这个项目之后对整个选型边界有了更清晰的认知。Durable Objects 适合的是“一个用户/一个会话/一个文档”这种天然分片的状态场景但如果你需要的是跨用户聚合分析比如“统计全站所有用户的平均训练完成率”Durable Objects 就会让你很难受。想拿到全局数据你得遍历所有对象再汇总这在数据量大之后既慢又贵远不如结构化数据库的一行 SQL 好用。所以我的判断标准很简单状态是否天然围绕单个实体组织如果是DO 是极佳选择如果状态是要跨实体流动的或者需要全局索引那就别硬用考虑接回传统数据库作为分析底座。此外如果你的应用实际上完全不需要 AI 生成之外的状态比如只是做一次性的文本转换那连 Durable Objects 都不需要写个无状态 Worker 就够了。DO 要解决的问题是“有状态”没有状态问题的时候不要创造问题。5.2 后续可以扩展的几个方向第一用 Durable Object 的 Alarm 能力做训练提醒。给每个用户的 DO 实例注册一个定时器在当地时间每天早上推送当天的训练计划和注意事项。因为 DO 实例已经存在Alarm 天然绑定在每个用户对象上不需要单独建定时任务表。第二用更完整的 Agent 化设计替代单次生成。目前是一次 Prompt 生成 7 天计划这个方案简单可靠但无法进行深度个性化。未来可以用多轮对话的方式先让 AI 询问用户的运动习惯和伤病史再生成计划生成后根据第一次的训练反馈继续微调。Durable Objects 因为能保存对话记忆非常适合做这种 Agent 的长期记忆层。第三接动作识别的多模态能力。用户打卡时上传一段训练视频通过多模态模型判断动作标准度并写入当天的progress。这个扩展可以把纯文字计划服务升级成“教练”产品但对成本和模型能力的要求会高一个量级。5.3 一些个人项目中的设计体会如果让我重做一遍这个项目我一定会在第一天就把数据分片策略和 JSON 兜底解析写进代码而不是等到报错再去补救。踩坑不可怕可怕的是把“临时方案”变成了线上长期运行的核心路径。我在实际测试中发现技术选型其实不是这个项目最难的部分最难的是让 AI 输出稳定、可消费、可存储的数据。Cloudflare Durable Objects 把有状态服务的复杂度收纳得非常好让我可以腾出精力去打磨 AI 提示词、设计状态校验规则、优化多端同步体验。这套组合对于一个需要“以低运维成本实现 AI 个性化状态服务”的独立开发者来说确实是个实用解。如果你也正在做类似方向的产品我建议在动手写第二版路由之前先把单用户的状态模型画清楚想明白“什么数据该存、什么数据该拆开存、什么请求允许覆盖什么请求必须拒绝”。把这些问题想透了后面的代码实现会顺滑很多。
返回列表