Cloudflare Workers 异步任务改造:让 LLM 评分不再超时 你有没有遇到过这种场面用户提交了一篇几百字的评测文本页面转圈十几秒请求还挂在 Network 面板里迟迟不回来。这个卡点其实特别典型——HTTP 请求天然是请求-响应模型而一次基于大模型的长文本评分本质上却是提交-处理-完成的长任务。把后者硬塞进前者的等待窗口就会撞上 Cloudflare Workers 这类 Serverless 平台的执行时长限制最终等不到这一次评分结果。这篇文章想把这些年的一个真实改动记录下来把一个跑在 Cloudflare Workers 上的 LLM 长任务内容评分从同步等待改造成异步任务流。如果你现在正被接口超时、请求打满、一个评分任务把 Worker 整个拖垮这类问题困扰或者正准备在 Serverless 环境里接入大模型能力这篇文章应该能帮你少走不少弯路。1. 现象复盘一次评分请求如何一步步拖到超时1.1 我当时写的同步评分接口长什么样先说最原始的形态。当时的需求很简单用户提交一段文本后端调用大模型 API 对文本做质量评分返回一个分数和几句针对性评语。我自然选择了最直接的方式——在 Worker 的fetch处理器里同步调用模型接口export default { async fetch(request, env, ctx) { if (request.method POST new URL(request.url).pathname /score) { const { text } await request.json(); // 这里同步等待大模型返回平均耗时 3~15 秒 const score await callLLMForScore(text, env); return Response.json({ score }); } return new Response(ok); } }本地测试时一切正常因为我的测试文本只有一两句话模型一两秒就返回了。于是我很自然地以为这个接口已经能用了。等到真正接入到某个内部演示环境把真实的文章段落丢进去的时候问题立刻暴露出来。页面端发起评分请求接口迟迟不给响应再试一次等了半分钟直接 502隔了一会儿再试居然又好了。这种时好时坏的状态比一直报错更让人头疼。1.2 等不到响应的几种典型表现我把当时的现象归纳了一下大致有四种你如果也遇到过可以对照着看输入文本规模模型推理耗时用户感受到的结果一两句话1~3 秒勉强能接受偶尔转圈几百字段落5~15 秒页面长期转圈请求悬空长文/多次评分30 秒以上浏览器侧可能直接断连并发多个评分请求单个变慢超时Worker 实例被占满整套服务不可用这里最迷惑的地方在于接口时间一长报错并不统一。有时候是网关的 502有时候是浏览器直接取消请求有时候是模型 API 自己超时。你会觉得问题好像在到处跳动一会儿怪网络、一会儿怪模型、一会儿怪 Serverless 平台。其实根本原因是同一个你正在用同步 HTTP 响应去承载一个异步长任务。想通这一点之后后面的改造方向就非常清晰了。2. 根因剖析LLM 推理的长耗时与 Worker 执行限制的冲突2.1 Serverless 运行时的时间账本CPU 时间与挂起时间Cloudflare Workers 对单个请求的执行时长限制远不像本地 Node 服务那样想跑多久跑多久。它区分两个概念CPU 时间你的代码实际在执行计算所消耗的时间。免费套餐通常只有 10ms 左右的 CPU 时间即使是付费套餐一般也是 30s 级别的 CPU 配额。挂起时间代码在等待 I/O比如等待外部 API 返回、等待数据库响应的时间这部分不计入 CPU 配额。乍一听好像等待模型返回的时间不计入 CPU那不就无所谓了吗问题在于虽然挂起时间不烧 CPU但它仍然占据一次 HTTP 请求的真实生命期。服务器的连接、网关的转发、客户端的耐心都不会无限等待。Cloudflare 的免费套餐单次请求的墙钟时间有较严格上限付费套餐环境下如果等待时间过长同样会碰到平台层的超时限制。更关键的是你的 Worker 在等待外部模型 API 的这段时间里并没有真正休息。如果是常规的同步请求模式一个实例同时只能处理一个请求链路一旦请求挂住后续请求就得排队。用户那边看到的就是评分按钮点了没反应其他人也一起卡死。2.2 大模型评分为什么天生就慢很多人以为内容评分这种活儿很简单模型一眼就能看完。但实际用过大模型 API 就会发现评分任务的延时经常远超预期原因有几个LLM 生成是逐 token 的不是一次出结果。即使输入只有几百字输出端要想给出合理评分和理由可能也要生成几百个 token这个生成过程是串行的无法像普通函数调用那样算完就回。评分任务往往带着复杂的指令上下文。为了拿到结构稳定的评分我通常会在 prompt 里塞评分维度、评分标准、示例样本这些上下文越多模型每次计算的负担就越重。失败重试会放大耗时。模型 API 偶尔会返回 429、500或者因为输出超长被截断。你加了重试逻辑之后一个评分任务的真实耗时可能从 5 秒膨胀到 30 秒甚至更久。这不是某一个模型服务商的问题而是大模型推理本身的特点。你很难通过换一个更快的模型彻底解决因为模型更快也快不过一次网络往返加生成完整输出所需的时间下限。2.3 打电话 vs 留言信箱两种任务的本质差异我在一次排查时想通了一个类比同步 HTTP 请求就像打电话你得等在电话这头等对方说完才能挂机。而 LLM 评分任务更像是一个留言信箱——你把文本投递进去系统处理完把结果放到信箱里你过段时间来取就行。问题就出在我非要用打电话的方式去等一个留言信箱才愿意处理的任务。用户请求进来我在电话里干等模型慢慢生成既占着线路又拦着后面的电话打进来。这显然不是技术不行而是任务模型选错了。把思维转过来之后方案非常明确HTTP 请求只负责收件和取件真正的模型推理交给后台队列去跑。3. 改造思路把同步评分请求改造成异步任务流3.1 三个候选方案waitUntil、Durable Objects 与 QueuesCloudflare Workers 生态里能承担后台长任务的方案主要有三个我逐个试过先说说它们各自的特点方案适合场景优点缺点ctx.waitUntil()发送日志、统计、轻量异步通知无需新增构件代码改动小不保证运行时长任务重时可能被中断没有重试和持久化Durable Objects需要强一致状态、实时 WebSocket 推送、定时任务状态存活周期长支持 Alarm 定时器有单点执行能力要理解 actor 模型需要处理实例生命周期对简单任务有点重Queues KV任务异步化、消息可靠投递、失败重试托管队列天然支持批量、重试、死信队列结合 KV 做结果存储代码量小消费者同样受 CPU 时长限制不适合单个任务超过数分钟的场景对我这个提交一段文本后台评分用户查询结果的需求来说Durable Objects 其实也能做但有点高射炮打蚊子。我需要的是一个能稳定承载任务投递—后台执行—结果查询三段的容器Queues 加 KV 的组合正好覆盖而且每个环节都是 Cloudflare 官方托管我不用自己维护任务表。3.2 我为什么选了 Queues KV 而不是 Durable Objects当时也问了自己一个问题既然 Durable Objects 能把状态保存在内存里查询不更快吗最终决定用 Queues KV 主要基于三点考虑任务状态非常简单。一个 taskId 对应status和result两个字段KV 的最终一致性在这个场景下完全够用。我既不依赖内存对象做复杂状态机也不需要 WebSocket 实时推送。Queues 自带可靠投递和重试。send()之后消息不会丢消费失败会自动重试还能绑定死信队列。这些能力如果用 Durable Objects 自己做等于把消息队列重新发明了一遍。成本更低、心智负担更小。KV 读写按次计费Durable Objects 按实际运行时长计费。对一个个人项目或中小团队工具来说KV 的计费模式更平滑不需要关心活跃连接数量。当然如果你的业务后续需要多个客户端实时看到任务进度或者任务状态频繁更新Durable Objects 会是更好的选择。这是后话了。4. 跑通异步评分任务队列、状态存储与管理端的代码落地4.1 整体架构提交、执行、查询三段式改造后的流程长这样客户端POST /score请求体里带要评分的文本。Worker 生成一个taskId把文本塞进score-queue同时把初始状态写入 KV。Worker 立刻返回{ taskId }这次请求就算完成了耗时通常不到 50ms。后台的 Queue Consumer 取出消息真正调用大模型 API 评分。评分完成后把结果写入 KV状态改成completed。客户端用GET /result?taskIdxxx轮询拿到评分结果。整个过程里模型 API 再慢也不会拖住用户的 HTTP 请求因为它已经彻底脱离了用户请求的生命周期。4.2 生产端 Worker请求进来立即返回 taskId先看入口 Worker 的代码。我在同一个 Worker 里同时挂fetch和queue两个入口这样不用单独再起一个服务export default { async fetch(request, env, ctx) { const url new URL(request.url); // 提交评分任务 if (request.method POST url.pathname /score) { const { text } await request.json(); if (!text || text.length 0) { return Response.json({ error: text is required }, { status: 400 }); } const taskId crypto.randomUUID(); // 先写入 pending 状态再入队 await env.SCORE_RESULTS.put( task:${taskId}, JSON.stringify({ status: pending, taskId }), { expirationTtl: 3600 } ); await env.SCORE_QUEUE.send({ taskId, text }); return Response.json({ taskId }); } // 查询评分结果 if (request.method GET url.pathname /result) { const taskId url.searchParams.get(taskId); if (!taskId) { return Response.json({ error: taskId is required }, { status: 400 }); } const raw await env.SCORE_RESULTS.get(task:${taskId}); if (!raw) { return Response.json({ error: task not found }, { status: 404 }); } return Response.json(JSON.parse(raw)); } return new Response(ok); }, async queue(batch, env) { // 队列消费者后面会展开 } };几个细节值得说明一下。KV 的 key 我加了task:前缀避免以后和其他业务数据冲突。写入 KV 时设置了expirationTtl: 3600任务提交 1 小时后自动过期既保证用户有充足时间轮询又不会让 KV 里堆满历史垃圾数据。先写 pending 状态再入队是为了防止消息已经在队列里、状态还没写进去导致查询端 404。这一点很重要后面我还会提到它带来的一个小坑。4.3 消费端 Worker真正跑 LLM 长任务的地方队列消费者才是整个后台执行的核心。当score-queue里有消息时Cloudflare 会调度这个queue函数来处理async queue(batch, env) { for (const msg of batch.messages) { const { taskId, text } msg.body; // 标记为处理中 await env.SCORE_RESULTS.put( task:${taskId}, JSON.stringify({ status: processing, taskId }) ); try { // 调用大模型 API并设置 30 秒超时 const score await callLLMForScore(text, env, { signal: AbortSignal.timeout(30000) }); await env.SCORE_RESULTS.put( task:${taskId}, JSON.stringify({ status: completed, taskId, score }) ); } catch (err) { // 失败也落库防止用户永久轮询 await env.SCORE_RESULTS.put( task:${taskId}, JSON.stringify({ status: failed, taskId, error: err.message }) ); } } }callLLMForScore是一个封装好的函数负责拼接 prompt、调用模型 API、解析返回的 JSON。核心点在于每一个消息处理前都会先把状态从pending改成processing用户查询时能感知到任务正在执行而不是一直停在已提交。模型调用加了AbortSignal.timeout(30000)。这是很多同步超时事故的元凶——如果不给大模型 API 加超时某个坏请求可能让消费者卡到天荒地老。加了超时之后最坏情况也就是 30 秒后进入失败分支不会无限拉长。失败同样落库为failed附带错误信息。这样前端轮询到failed状态时可以直接给用户提示评分失败请重试而不是让用户永远看着 loading 转圈。4.4 队列与 KV 的绑定配置wrangler 里怎么做上面代码涉及两个绑定队列和 KV。如果对 Cloudflare 配置不熟可以照这份wrangler.toml抄name score-worker main src/index.js compatibility_flags [nodejs_compat] [[kv_namespaces]] binding SCORE_RESULTS id your-kv-namespace-id [[queues.producers]] binding SCORE_QUEUE queue score-queue [[queues.consumers]] queue score-queue max_batch_size 1 max_retries 3 dead_letter_queue score-dlqmax_batch_size设成 1 是个有意的选择。默认值可能会一次拉多条消息但消费者里的大模型调用是串行的而且单条消息就可能要等十几秒。如果批量拉取 10 条最后一条的等待时间会被前面的任务无限拉长。设成 1每条消息独立消费某一批失败也不会影响其他消息。max_retries设为 3意味着同一消息最多重试三次。超过三次会进入score-dlq死信队列方便我后续手动排查。大模型接口偶发 500、429 很正常三次重试通常能缓解大部分问题。4.5 查询端补充轮询节奏与异常兜底客户端轮询很简单async function fetchScore(taskId) { for (let attempt 0; attempt 10; attempt) { const res await fetch(/result?taskId${taskId}); const data await res.json(); if (data.status completed) return data.score; if (data.status failed) throw new Error(data.error); // 指数退避3s、5s、10s、15s…… await sleep(Math.min(3000 * Math.pow(1.5, attempt), 15000)); } throw new Error(timeout); }后端强制每个任务在 1 小时后过期前端则控制最多轮询 10 次、单次最长间隔 15 秒两边都有兜底不会出现任务没了前端还在轮询的死循环。5. 实战中绕不开的坑状态一致性、慢请求与并发控制5.1 坑一KV 的最终一致性会把已完成读成进行中这是我在联调时第一个踩到的坑。流程跑通后我提交了一个任务等了几秒去查结果居然还是pending。再查一次又变成processing。又等了一下才终于看到completed。原因在于 KV 是最终一致性的存储。生产端写入pending后消费端立刻改写成processing但 KV 的某个读取节点可能还没同步到最新的processing于是用户查询时读到了旧值。这个问题的严重程度取决于你的业务。对于用户多等几秒刷新能看到结果的场景其实完全可接受。如果你需要更精确的状态流转有两个升级思路把状态存储从 KV 换成 Durable Objects利用它的强一致特性每次读取都能拿到最新值。或者不在 KV 里做pending - processing的中间态覆盖而是用不同的 key 分别表示状态和结果查询端合并读取。我最终的选择比较务实保留 KV但把状态流转改成覆盖写并接受最终一致带来的秒级延迟。反正用户看到pending的时候只是在等待不会看到错误结果体验不受影响。5.2 坑二大模型 API 的慢请求会把整条队列堵死又一次线上事故某个模型 API 突然变慢原本 5 秒返回的请求硬是拖到 45 秒。我虽然给消费者设置了AbortSignal.timeout(30000)但队列里的消息还是在前面积压越积越多。排查后发现问题出在max_batch_size 1虽然保证了单条消息独立但消费者的循环是串行的——一批消息里只有一条处理完一条才能处理下一条如果这条消息卡了 30 秒队列里的下一条消息就得等 30 秒。这里的改进有两个方向在消费端做并发控制一次跑几个并发任务。但要注意不要把所有消息一次性拉进来并发跑否则容易打爆模型 API 的限流。给模型 API 加更积极的超时设置比如 10 秒。评分任务如果 10 秒没返回大概率服务端已经出问题了重试一次比硬等更划算。我最后把消费者改成了一个小并发池async queue(batch, env) { const concurrency 3; let index 0; async function worker() { while (index batch.messages.length) { const msg batch.messages[index]; await processMessage(msg, env); } } await Promise.all(Array.from({ length: concurrency }, worker)); }这样既不会并发跑到爆又能让慢请求不再单点阻塞全局。concurrency根据模型 API 的速率限制酌情调整我这边 3 个并发比较稳。5.3 坑三文本内容过大容易撞上请求体限制和模型上下文限制评分对象是长文本时除了耗时还要考虑塞不塞得进去的问题。Worker 的请求体限制虽然很宽松但大模型 API 通常有自己的上下文窗口限制。我一开始没做截断直接把 8000 字的文章丢给模型结果 API 返回了 context length 超限错误。处理方式是在入队之前做预处理function normalizeText(text, maxLen 6000) { const trimmed text.trim(); if (trimmed.length maxLen) return trimmed; // 简单截断也可以在这里做分段摘要 return trimmed.slice(0, maxLen); }如果你想评分的内容远远超过上下文窗口更合理的做法是先做分段摘要再把摘要合并起来评分。这个展开又是一个话题但至少先做到入队前截断到模型允许范围能避免大量低级报错。5.4 坑四轮询体验太差的补救思路异步化之后接口不再超时但用户仍然需要等待几秒才能拿到评分。如果产品上要求评分结果尽快展示轮询方案有几个补强手段前端做乐观提示正在评分通常需要 5-15 秒先把用户预期拉长焦虑感会明显降低。对高频提交的相同文本做结果缓存写入 KV 时顺便记一个内容的哈希值。下次相同内容直接命中历史结果评分秒回。如果后面真的需要评分完成主动推送可以引入 Durable Objects 加 WebSocket但那是另一个量级的改造我暂时没有做。5.5 成本观察异步化之后一个月多了多少钱有一说一异步化之后代码和运维复杂度并没有显著增加成本控制反而更舒服。队列本身的计价很低KV 读写按次计费一个评分任务从提交到完成大约产生 3 次 KV 写pending、processing、completed和若干次读。相比同步方案下请求超时重试造成的大量无效调用异步化省下来的模型 API 费用可能比平台费用还多。6. 迁移之后我的真实体感与后续扩展6.1 迁移之后第一次觉得长任务这件事理顺了改造完成之后我特意模拟了一次最坏场景提交一篇 5000 字的长文然后立刻打开 Network 面板盯着请求。POST /score在 40ms 左右返回状态为processing。大约 8 秒后GET /result拿到completed和完整评分。没有超时、没有 502、没有转圈到怀疑人生。那种终于把大象装进冰箱的顺畅感很难用语言形容。之前总觉得 Cloudflare Workers 不适合跑长任务现在明白了——不是平台不适合是我用错了姿势。平台提供的队列、KV、死信队列这些积木本来就是给这种场景准备的。6.2 将来还能往哪里扩展这次改造留下了一个很好的骨架后续想加能力都很顺手评分失败告警消费者里捕获异常时往另一个通知队列发消息再绑定一个发站内信/通知的消费者就能实现评分失败自动通知管理员。多模型兜底callLLMForScore内部可以做成策略模式主模型超时后切换备用模型或者取多个模型评分的平均值。人工复核队列评分低于某个阈值时不直接返回结果而是把任务流转到人工审核队列。这本质上就是给现有架构加一个判断分支不需要改动主链路。我个人最推荐的做法是先把提交—执行—查询这套骨架稳定运行一段时间积累真实的任务量、失败率、平均耗时数据再决定要不要上更重的 Durable Objects 或 WebSocket 推送。不要一上来就把架构做得太重异步化本身已经解决了 90% 的问题。最后再分享一个小技巧如果你在 Cloudflare Workers 上同时处理 HTTP 请求和队列消费记得给两套逻辑各自加日志标记比如[fetch]和[queue]前缀。否则调试时你会分不清一条评分任务到底卡在入队前还是消费中。我因为这个排查过整整一下午加了前缀日志之后问题定位速度直接翻倍。