你有没有遇到过这种场面:用户提交了一篇几百字的评测文本,页面转圈十几秒,请求还挂在 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 与 Queues
Cloudflare 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?taskId=xxx轮询,拿到评分结果。
整个过程里,模型 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-dlq"max_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]前缀。否则调试时你会分不清一条评分任务到底卡在"入队前"还是"消费中"。我因为这个排查过整整一下午,加了前缀日志之后,问题定位速度直接翻倍。