游戏生成异步链路技术解析:从服务端到扣子,再回到前端
前言
最近把项目里的"生成游戏"功能完整收了一遍,表面上看,这只是一个异步任务场景:前端提交参数,后端调用 AI 工作流,最后把结果返回给用户。
但真正做下来会发现,难点并不在"怎么调 AI",而在于这条链路怎么做稳:
- 前端提交之后,不能一直傻等接口返回
- 用户想实时看到状态变化,而不是只看到"提交成功"
- 服务重启后,执行到一半的任务不能直接丢
- WebSocket 断线后,前端不能彻底失去状态
- 失败任务不能全靠人工重试
- 多个任务同时提交时,服务端还要能并发推进
我最后落地的方案,是一套比较典型但又比较贴业务的组合:
MySQL存任务状态和事件Redis Stream做异步任务调度worker负责消费任务和推进状态WebSocket负责实时把状态推给前端Coze 工作流负责真正生成游戏内容
这篇文章不只讲"用了什么技术",更想把这条链路从头到尾讲清楚:
- 状态是怎么变化的
- S 端怎么把任务发给扣子
- 扣子怎么把结果再回给 S 端
- S 端怎么把状态同步给 C 端
- 服务端怎么实现多个任务并行生成
一、先把几个角色分清楚
要理解这套链路,先把几个角色摆正。
1. C 端
这里的 C 端就是前端页面或者小程序,职责很明确:
- 发起"生成游戏"请求
- 接收任务状态变化
- 在断线或重连后恢复状态
2. S 端
S 端是你的后端服务,负责整条链路的编排:
- 接收生成请求
- 创建任务记录
- 入 Redis 队列
- 启动 worker 消费
- 调扣子工作流
- 接扣子回调
- 把状态推给前端
3. Redis Stream
Redis Stream 在这里不是拿来存业务真相的,它更像一个"任务调度通道"。
前端请求进来后,服务端会先把任务放进 Stream,后面的 worker 再从 Stream 里把任务取出来执行。
4. worker
worker 本质上就是后台干活的人。
它不负责接用户请求,它只负责一件事:
从 Redis Stream 里取出游戏生成任务,然后真正执行它。
5. 扣子端
扣子工作流是外部 AI 生成能力的提供方。
服务端不会自己凭空生成游戏,而是会在任务进入执行阶段后,调用扣子的工作流接口,让扣子去真正产出游戏内容。
二、为什么这块一定要做成异步
如果不用异步,最直接的写法其实很简单:
text
前端请求 -> 服务端同步调用扣子 -> 等几分钟 -> 返回结果
但这种模式在实际业务里很快就会暴露问题:
- 接口耗时太长
- 前端一直 loading,体验很差
- 请求超时风险大
- 服务端容易被长请求拖住
- 中间状态没法实时告诉前端
- 一旦服务重启,执行到一半的任务不好恢复
所以后面我把它拆成了两段:
text
1. 接口负责快速接单
2. worker 负责后台执行
这就是这条链路的出发点。
三、整体链路长什么样
先看一版简化后的整体流程:
text
C端发起生成
->
S端创建任务记录
->
S端写首条 queued 事件
->
S端把任务写入 Redis Stream
->
worker 消费任务
->
任务进入 generating
->
S端异步调用扣子工作流
->
扣子执行完成后回调 S端
->
S端收口为 success / failed
->
S端通过 WebSocket 把状态推给 C端
如果把这条链路按方向拆开,其实就是四件事:
C端 -> S端:提交任务S端 -> 扣子端:异步提交工作流扣子端 -> S端:回调结果S端 -> C端:同步状态和结果
接下来就按这四条线分别讲。
四、第一条线:从 C 端到 S 端
1. 前端发起生成请求
用户在页面上点击"生成游戏"后,前端会把生成参数发给服务端。
这一步服务端做的不是直接生成,而是先接住这笔任务。
2. 服务端创建任务记录
服务端接到请求后,会先在数据库里创建一条任务记录,也就是 game_record。
这个时候任务的初始状态就是:
queued
也就是"已受理,等待执行"。
3. 服务端写首条状态事件
除了任务表本身,服务端还会写一条首个状态事件,表示:
- 任务已入队
这条事件后面会给两个地方用:
- WebSocket 实时推送
- 断线后的事件补拉
4. 服务端写入 Redis Stream
任务记录落库后,服务端再把这条任务写入 Redis Stream。
到这一步,接口层的事情就基本结束了。接口不会继续等任务执行完成,而是快速返回给前端:
- 任务提交成功
- 当前状态是
queued
所以从 C 端视角来看,第一跳只是"提交成功 + 排队中"。
五、第二条线:从 S 端到 C 端
很多时候大家一提状态同步,第一反应就是 WebSocket。这个方向没错,但如果只靠 WebSocket,真实环境里一定会漏。
所以我后面把 S 到 C 端拆成了三条通道。
1. WebSocket:负责实时推送
每次关键状态变化时,服务端都会通过 WebSocket 推送给前端:
queuedgeneratingsuccessfailed
这样前端能第一时间看到:
- 任务已排队
- 任务开始生成
- 任务生成成功
- 任务生成失败
2. /game/pending:负责当前态快照
WebSocket 有一个天然问题:如果用户断线、刷新页面,实时消息就断了。
所以我补了 /game/pending,它只做一件事:
- 返回当前还在进行中的任务快照
也就是只关心:
queuedgenerating
前端在页面进入、断线重连、回前台时,可以先调这个接口,把当前态先对齐。
3. /game/status/events:负责补拉漏消息
只拿当前态还不够,因为前端有时还想知道自己漏掉了哪些状态变化。
所以又补了一张事件表和一个事件补拉接口:
game_status_event/game/status/events
前端带着 last_event_id 过来,就可以把断线期间漏掉的状态事件补回来。
最后 S 到 C 端的完整模型就是:
text
WebSocket 实时推送
+
/game/pending 当前态快照
+
/game/status/events 事件补拉
这也是为什么我后来一直强调:前端不能只信 WebSocket。
六、第三条线:从 S 端到扣子端
1. worker 真正开始接手任务
任务一旦写进 Redis Stream,后面就是 worker 持续监听并消费。
worker 会做两件核心的事:
- 把任务状态从
queued推进到generating - 真正调用扣子工作流
也就是说,generating 这个状态的含义不是"接口已经接到了任务",而是:
后台 worker 已经正式开始执行这条任务了。
2. 服务端调用扣子工作流
状态切到 generating 之后,服务端会去调用扣子的工作流接口。
这里有一个很关键的点:
- 我用的是异步提交,不是同步等待最终结果
也就是说,服务端发给扣子的不是"请你马上返回生成好的游戏",而是:
- "请你开始执行这个工作流"
扣子会先返回一个 execute_id,表示这次工作流执行已经被受理。
3. 为什么这里要异步提交
如果这里同步等扣子完整跑完,会有两个明显问题:
- worker 会长时间阻塞
- 并发能力会变差
而异步提交后,服务端只负责把任务启动起来,真正的结果等扣子后续再回调回来。
这一步其实很像:
text
worker 不是自己做游戏
worker 只是把任务交给扣子开始做
七、第四条线:从扣子端回到 S 端
1. 扣子执行完成后回调服务端
扣子工作流执行完成后,不是让服务端一直去轮询结果,而是由扣子主动回调服务端内部接口。
这样整条链路会更自然:
- S 端发起异步执行
- 扣子执行完成
- 扣子把结果主动回给 S 端
2. 服务端根据回调结果收口任务
服务端收到回调后,会根据回调状态做收口:
- 成功:收口为
success - 失败:收口为
failed
同时还会做三件事:
- 更新
game_record - 写入对应的状态事件
- 推送 WebSocket 给前端
也就是说,扣子端回来的不是"只是一个结果",而是会进一步驱动服务端完成最终状态闭环。
3. 这一步为什么重要
因为前面 S -> 扣子 那一步只是"把任务提交出去",真正让任务结束的,是这里的回调收口。
没有这一步,任务就会一直停留在 generating。
八、状态变化是怎么推进的
把整条链路抽象完之后,状态变化其实并不复杂。
1. 正常链路
正常情况下,状态流转是:
text
queued -> generating -> success
2. 失败链路
如果扣子执行失败,或者服务端收口失败,状态就会走到:
text
queued -> generating -> failed
3. 可重试链路
如果失败是可重试的,任务不会立刻结束,而是会重新回到队列:
text
queued -> generating -> queued -> generating -> success / failed
4. 为什么我只保留这几个状态
异步系统不是状态越多越好。
这块我最后坚持只保留:
queuedgeneratingsuccessfailed
原因很简单:
- 前后端都容易理解
- 状态边界清晰
- 补偿和恢复也更容易收口
真正复杂的部分,不在状态种类,而在状态变化怎么保证可靠。
九、服务端是怎么实现并行生成的
这一块很多人第一反应会问:
你说支持多个任务同时生成,那到底是哪里在并行?
这个问题要分开看。
1. 不是一个任务内部并行
先说清楚,你现在这套"并行生成",不是把一个游戏任务拆成很多线程同时生成不同片段。
它的含义是:
- 多个游戏生成任务可以同时推进
也就是任务级并行,不是单任务内部并行。
2. 第一层并行:多个 worker 并发消费
服务启动后,会按配置启动多个 worker。
这意味着:
- worker1 可以消费任务 A
- worker2 可以消费任务 B
- worker3 可以消费任务 C
于是多个任务可以并行被服务端处理。
3. 第二层并行:任务提交给扣子后,worker 不再阻塞等待
每个 worker 把任务推进到 generating 后,会把任务异步提交给扣子工作流。
这里有一个很容易被说模糊的点:worker 并不是在本地一直等扣子把游戏生成完。
真正发生的事情其实是:
- worker 取到任务
- 把任务推进到
generating - 调用扣子工作流接口
- 扣子先返回一个
execute_id - worker 当前这次处理结束
- worker 继续去消费下一条任务
也就是说,worker 在把任务成功提交给扣子之后就被释放出来了,后续真正继续执行这条任务的是扣子侧的工作流实例,而不是这个 worker 一直卡在那里。
所以从效果上看,会更像这样:
text
任务A -> worker1 提交给扣子 -> worker1 继续处理下一条
任务B -> worker2 提交给扣子 -> worker2 继续处理下一条
任务C -> worker3 提交给扣子 -> worker3 继续处理下一条
如果只有一个 worker,本地也是"快速连续提交多个任务给扣子";如果有多个 worker,就是"多个 worker 并发提交多个任务给扣子"。真正的任务执行并行,主要还是发生在扣子工作流侧。
4. 并行能力真正来自哪里
所以你这套服务端并行生成能力,实际上来自两个组合:
Redis Stream 多消费者扣子异步工作流
前者负责让多个任务可以并发被取出来,后者负责让每个任务提交出去后不阻塞服务端主流程。
十、为什么这套链路比"同步调用扣子"更适合上线
如果只是为了把功能做出来,同步调用扣子当然也不是不能写。
但一旦放到真实业务里,异步链路的价值就很明显了。
1. 接口更轻
前端提交后,接口可以很快返回,不需要等几分钟。
2. 状态更透明
前端不是只看到"成功"或者"失败",而是能看到任务从排队到执行再到完成的全过程。
3. 服务更稳
任务执行和接口处理拆开后,服务端不会因为长耗时生成被同步拖住。
4. 更容易补偿
异步链路一旦拆清楚,就比较容易在每一层加恢复能力,比如:
- pending 恢复
- 失败重试
- 超时恢复
- 事件补拉
这也是为什么这类场景越做越像"异步系统",而不只是"一个 AI 调用接口"。
十一、我在这条链路里最看重的几个点
如果让我把这套方案压缩成几个关键词,我会重点强调下面这些。
1. Redis Stream 不是状态真相,而是调度通道
很多人一看到 Redis 就容易把所有事情都往里面放,但这块我一直比较克制。
- Redis Stream 负责调度任务
- MySQL 负责保存状态真相
这个边界一旦清楚,后面很多问题都更容易定位。
2. worker 才是真正推进任务的人
接口层只是接单,真正推进状态的是 worker。
没有 worker,任务就只会停留在 queued,永远不会真正执行。
3. 扣子回调是整条链路的收口点
S -> 扣子 那一步,只是把任务提交出去。
真正让任务结束的,是:
- 扣子执行完成
- 回调到服务端
- 服务端再更新状态并通知前端
4. 前端不能只依赖 WebSocket
前端真正可靠的状态同步,不是"只要 WS 连着就行",而是:
- WebSocket 负责实时
/game/pending负责当前态恢复/game/status/events负责漏消息补拉
十二、总结
回过头看,这条"生成游戏"的链路,真正难的地方其实不是接 Redis,也不是调扣子,而是把角色边界和数据流向理顺。
我最后比较认同的一种理解是:
- C 端负责发起和展示
- S 端负责编排和收口
- Redis Stream 负责调度
- worker 负责执行
- 扣子负责产出结果
只要这几个角色边界够清楚,状态机就不会乱,补偿机制也更容易往里加。
所以这套方案最后不只是"实现了异步生成游戏",更准确地说,是把一条从:
C端 -> S端 -> 扣子端 -> S端 -> C端
的完整闭环链路真正跑顺了。
这也是我觉得这次实现最有价值的地方。