ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

视频生成免排队:本地转发层与请求去重优化

视频生成免排队:本地转发层与请求去重优化

排队这件事,做过视频生成类工具集成的工程师大概都有一肚子话。同一个提示词,用户手一抖连点三下,后台就多了三条一模一样的任务,排队号一个比一个靠后,最后三份结果几乎同时吐出来——多出来的两份纯粹是自己跟自己抢位置。更别提弱网下的超时重试、切后台被杀掉后重新提交、轮询频率太高被服务端直接掐掉连接。这些等待里,真正"算力不够"造成的只占一部分,剩下相当比例是客户端自己制造的。这篇就围绕视频生成模型的免排队调用思路,把本地转发层(Local Relay)这套东西拆开讲清楚:它到底解决了哪些等待、各个模块怎么设计、参数怎么算、代码怎么写,以及在移动端落地时会撞上哪些坑。适合正在做 AI 工具客户端集成的工程师、想给自己的创作流程提速的重度用户,以及单纯好奇"为什么我点了生成却要等十分钟"的读者。全文讲的所有手段都落在平台规则允许的范围内,靠的是客户端侧工程优化,不涉及任何绕过限制的做法。

1. 把"排队"拆开看:等待到底发生在哪一层

1.1 一次视频生成请求的完整链路

想把等待砍掉,第一步得知道时间花在哪。用户点下"生成"到看到成片,中间至少要穿过七个环节,每个环节都可能塞进几秒到几分钟的延迟。第一环是客户端参数组装:提示词、画面比例、时长、分辨率、随机种子、参考图、风格预设,这些字段拼成一个提交体。第二环是网关鉴权与配额校验,服务端确认这个账号今天还剩几次额度、当前等级允许几个并发任务,这一步通常在百毫秒级,但鉴权服务抖动时会明显变慢。第三环是入队,任务拿到一个唯一标识,进入调度队列等待算力空闲,这一环的耗时完全取决于全局负载,是用户感知最强的那段等待。第四环是客户端轮询或长连接推送,持续询问"我的任务到哪一步了"。第五环是推理集群执行,模型加载、参考图编码、逐帧生成、后处理编码,这段是真正的算力消耗。第六环是结果回传,通常给一个带时效的下载地址而不是直接塞二进制。第七环是客户端下载、解码、缓存、渲染到列表。

链路上任何一环出问题,用户看到的都是同一句话——"还在排队中"。所以排查优化的第一步,是把"排队"这两个字拆成可观测的分段耗时:提交耗时、入队耗时、执行耗时、回传耗时、下载耗时。本地转发层之所以有价值,是因为它能在第二环到第七环之间插进一层可编程的中间件,把散落在各处的请求收敛起来统一管理,而不是把这一切交给 UI 层的散装代码去各管各的。

我见过不少客户端把提交、轮询、下载三个动作写在三个不同的页面里,各自维护自己的重试逻辑和网络配置,结果就是同一份参考图被上传三次、同一个任务被轮询出两套节奏。这种结构性混乱,靠改几行代码是修不干净的,得靠架构。

1.2 排队的五个真实成因

把成因归归类,实际项目里最常见的就五种,而且比重往往和直觉相反。

第一种是服务端并发闸门。平台按账号等级、当日额度、任务类型设置并发上限,比如普通账号同时只能跑两个任务,第三个必须等前面任意一个结束。这是硬规则,改不了也不该去改,但它带来一个副产品:本地如果不做窗口控制,一股脑发五个请求上去,后三个直接被拒,客户端再重试,重试又触发新的拒绝,用户体验比老老实实排队还差。

第二种是全局任务队列。算力池满的时候,任务按优先级和提交时间排队,这段等待跟客户端行为完全无关,唯一能优化的是"让用户知道自己大概还要等多久"。很多工具在这里只给一个转圈动画,用户不知道是卡死了还是正常排队,于是退出重进,重进又触发新一轮提交。

第三种是客户端重复提交,也是性价比最高的优化点。用户连点、网络超时后自动重试、页面切换时生命周期回调重复触发,任何一个都可能让同一个任务进队两次。一次生成动辄占用几分钟算力,重复任务就是实打实地把别人的位置占掉,同时也把自己的位置往后推。

第四种是轮询风暴。为了"看起来更快",很多实现把轮询间隔设成 500 毫秒甚至更短,结果是每秒两次请求全部打在网关的限流器上,触发限流后返回 429,客户端又立刻重试,越重试越糟。正确的做法是退避加抖动,后面会给出具体参数。

第五种是冷启动与资源预检。首次请求要拉取配置、校验参考图格式、转换尺寸,这些动作如果每次都做一遍,累积起来也是可观的开销。把它们前置到本地做一次并缓存,能省掉不少。

1.3 客户端侧能动的边界在哪

这里必须把边界说清楚,否则很容易滑向不该碰的地方。客户端能做的,是减少无效请求、降低请求成本、提升可观测性这三件事:去重让每个任务只提交一次,连接复用让每次往返少花几个 RTT,退避让轮询不打爆限流器,缓存让重复的创作需求不用重新算一遍,队列位置的解析让用户知道真实进度。

客户端不能做、也不该做的,是伪造身份信息、绕开配额校验、用多实例刷额度、篡改请求签名。这些行为一旦被风控识别,账号直接进小黑屋,得不偿失。我做这套东西的时候给自己定了条铁律:本地转发层的每一个优化动作,都必须能在"服务端完全可见"的前提下解释得通——服务端看到的请求数变少了、频率变平缓了、重复变没了,这是正向优化;服务端看到的请求特征被伪装了,那就是另一回事了。

2. 本地转发层的整体设计:为什么要多架这一层

2.1 直连模式在移动端遇到的三个硬问题

不做中转、客户端直接调服务端,在桌面环境通常没问题,但在移动端会撞上三个很实际的问题。

第一是生命周期不可控。移动端页面随时可能被切走、被系统回收、被用户划掉。如果提交任务和轮询状态的逻辑绑在页面生命周期上,页面一销毁,任务就失联了——任务其实还在服务端跑,但客户端不知道结果在哪,用户回来只能重新提交,又是一次无谓的排队。

第二是代码重复。一个稍具规模的工具类应用,生成、编辑、批量处理、历史记录四五个入口都要发起生成请求,每个入口都会写一遍重试、加密、上报逻辑。等到要改一处重试策略,得改五个地方,改漏一个就出现行为不一致。

第三是缺乏全局视角。直连模式下,每个请求是独立的,没有一个地方知道"当前还有三个任务在跑、本地配额还剩一次、缓存里已经有相似结果"。没有全局视角就做不了全局调度,做不了全局调度就只能眼睁睁看着请求互相打架。

本地转发层本质上是把这三件事收拢:一个常驻(或半常驻)的服务进程接管所有出站请求,页面只和本机回环地址上的这个服务通信。页面死了不影响到传输层,代码改一处全生效,全局状态有了统一的账本。

2.2 本地中转的架构分层

我这套东西大致分四层,从上往下说。

最上面是接口层,暴露给 UI 和业务代码的是一组本地 HTTP 接口,比如提交任务、查询进度、获取结果、取消任务。业务代码不关心底层怎么重试,只发一个请求、等一个回调,心智负担降到最低。

中间是调度层,这是整套设计的核心。它维护三张表:任务表(每个任务的唯一标识、指纹、当前状态、服务端任务 ID)、指纹表(指纹到任务的映射,用于去重)、缓存表(指纹到本地结果文件路径的映射)。调度层还管着并发窗口——一个信号量,容量等于账号允许的并发数,任务进队后先在这里等位,拿到令牌才真正发到服务端。

再往下是传输层,负责连接复用、超时控制、退避重试、错误分类。这一层对上层屏蔽掉"网络抖动"和"服务端限流"的区别,统一转成可重试和不可重试两类。

最下面是持久化层,任务状态、指纹表、缓存索引都落盘,应用被杀掉重开后能恢复现场。这一层最容易被忽略,但它决定了"用户切后台五分钟回来"这个高频场景下,体验是丝滑还是灾难。

2.3 移动端实现方案的选型对比

在移动端跑一个本地服务,可选的路子不多,各有取舍。我把当时评估的几套方案列成表,方便对照。

方案典型实现优点明显短板适用场景
内嵌轻量 HTTP 服务移动端常见的小型 HTTP 服务框架随应用启动,无需额外进程,代码量小应用被回收即终止,后台不可靠前台为主的单向任务
系统级后台服务组件平台提供的常驻服务机制生命周期相对长,可跨页面共享各平台能力差异大,功耗与合规审核压力大长任务、批量任务
独立本地进程打包一个可执行体,回环通信跨端复用,语言自由移动端限制多,包体变大,启动慢桌面端优先、移动端次要
纯内存单例,不监听端口进程内事件总线零网络开销,最简单无法跨进程,页面销毁即丢极轻量场景

我最后选的是第一种,理由是移动端的重心在前台:绝大多数用户是打开应用、输入、等结果、拿走,这段时间应用在前台,内嵌服务完全够用;真正需要长时间后台跑的场景(比如批量排队二十个任务),用系统级后台机制配合持久化恢复来兜底,而不是强求一个永远活着的进程。这个取舍很关键,因为很多团队一上来就追求"后台常驻",结果耗电、被系统杀、审核不过,三头挨打。

端口选择上有个小细节:不要写死。固定端口最常见的失败原因是和本机其他应用冲突,其次是上次进程没退干净导致绑定失败。我的做法是从一个高位区间(比如 49152 以上)随机起选,绑定失败就加一重试,最多试十次,成功后把端口写进应用私有存储,供其他模块读取。

2.4 必须守住的合规与安全边界

本地服务监听回环地址,听起来安全,实际上有几个必须堵的洞。

第一,只绑回环地址,不要绑 0.0.0.0。绑到通配地址意味着同一局域网内的其他设备也能访问这个端口,等于把用户的账号额度开放给整个网段。这一条是红线,写代码时把绑定地址硬编码成回环,不要留配置项。

第二,加一个本地令牌。应用启动时生成一个随机串写入私有存储,所有本地接口校验这个串,防止同机其他应用扫端口发起请求。成本极低,收益很大。

第三,请求白名单。本地服务的出站目标只允许服务端域名,用一个显式的域名列表做校验,避免这套机制被当成通用的转发出口使用。这一点在代码评审时经常被问到,提前写好注释和校验逻辑,省得后面解释。

3. 核心机制拆解:去重、合并、轮询节奏与缓存

3.1 请求指纹:幂等去重的地基

去重的前提是能判断"这两次请求是不是同一件事",靠的就是请求指纹。指纹的生成有两个坑,踩过的人不少。

第一个坑是把无关字段算进去了。时间戳、客户端生成的请求 ID、UI 来源、按钮位置这些字段每次都不一样,如果直接对整个提交体做哈希,永远算不出相同的指纹,去重等于没做。正确做法是显式定义一个参与哈希的字段白名单,只保留影响生成结果的参数:提示词、画面比例、时长、分辨率、随机种子、模型版本、参考图内容哈希。

第二个坑是序列化不稳定。同一个对象,键的顺序不同,字符串化出来的结果就不同,哈希自然也不同。JavaScript 里JSON.stringify按插入顺序输出,嵌套对象还要递归排序。必须做深度排序再序列化。

const crypto = require('crypto'); // 递归排序对象键,保证序列化结果稳定 function deepSort(obj) { if (Array.isArray(obj)) return obj.map(deepSort); if (obj && typeof obj === 'object') { return Object.keys(obj).sort().reduce((acc, k) => { acc[k] = deepSort(obj[k]); return acc; }, {}); } return obj; } // 只保留影响生成结果的字段 const FINGERPRINT_FIELDS = [ 'prompt', 'aspectRatio', 'duration', 'resolution', 'seed', 'modelVersion', 'stylePreset' ]; function buildFingerprint(payload, refImageHash) { const picked = {}; for (const k of FINGERPRINT_FIELDS) { if (payload[k] !== undefined && payload[k] !== null) picked[k] = payload[k]; } // 提示词做规范化:合并连续空白、去首尾空格 if (typeof picked.prompt === 'string') { picked.prompt = picked.prompt.replace(/\s+/g, ' ').trim(); } const base = JSON.stringify(deepSort(picked)) + '|' + (refImageHash || ''); return crypto.createHash('sha256').update(base, 'utf8').digest('hex'); }

参考图单独处理,因为它通常是二进制。做法是先算图片内容的哈希,再把哈希拼进指纹。这里有个实用技巧:图片上传前先做尺寸归一化(比如长边缩到固定值)再算哈希,这样用户从相册里选同一张图、系统给出不同 EXIF 的副本时,也能命中同一个指纹。

指纹算出来之后,去重策略还得配一个时间窗口。不能无限期去重,因为用户可能就是想用同一组参数再生成一版不同的结果(随机种子相同但服务端有微小扰动)。我的做法是设一个 30 秒的窗口:窗口内相同指纹只提交一次,窗口外允许重新提交。同时提供一个"强制重新生成"的入口,用户主动点击时绕过去重。

3.2 并发窗口与连接复用

并发窗口的容量,直接取服务端给这个账号的并发上限,不要加一,也不要减一。加一的结果是多出来的那个请求必然被拒,然后进重试循环,反而拖慢整体;减一则是浪费额度。这个值可以在启动时通过一个轻量的配额查询接口拿到,拿不到时给一个保守默认值(比如 1),宁可慢一点也别报错。

本地排队的任务要有明确的队列语义:先进先出,同时把"前面还有几个"这个信息透传给 UI。用户看到"前面还有 2 个任务"和看到一个转圈,心理感受完全不同——前者是有预期的等待,后者是不确定的煎熬。

class ConcurrencyGate { constructor(limit) { this.limit = Math.max(1, limit); this.running = 0; this.queue = []; } acquire() { return new Promise((resolve) => { const task = () => { this.running++; resolve(() => { this.running--; const next = this.queue.shift(); if (next) next(); }); }; if (this.running < this.limit) task(); else this.queue.push(task); }); } pendingCount() { return this.queue.length; } }

连接复用这块,重点是别每次请求都新建连接。HTTPS 完整握手在弱网下要 2 到 4 个往返,按单次往返 100 到 300 毫秒算,光建连就要 0.3 到 1.2 秒。移动网络频繁切换基站时,建连失败的概会显著上升,客户端看到的是"偶发提交失败",用户看到的是"点了没反应"。启用 HTTP/2 多路复用后,一个连接上并发跑多个请求,握手成本被摊薄到接近零;再配上连接保活,让长任务轮询复用同一条通道,整体感知延迟会明显下降。

实测下来,同一个任务从提交到拿到第一次状态回执,走连接复用的路径比每次新建连接平均省 0.4 到 0.8 秒。单看不多,但一次创作流程里往往有十几二十次往返,累积就是一串可感知的卡顿。

3.3 轮询退避的参数计算

轮询是最容易做错的一环。所有轮询请求都打在一个限流器后面,频率高了必然触发限制,触发限制后立刻重试更是雪上加霜。

我的参数是这样定的:首次间隔 2 秒,之后每次乘 1.5,上限 15 秒,每次间隔加正负 20% 的随机抖动。基础值 2 秒的考虑是,绝大多数任务不可能在 2 秒内完成,更短的首次间隔纯属浪费;倍率 1.5 是经验和限流阈值之间的折中,2 的倍率会让长任务的感知延迟太大,1.2 又太平缓起不到降频作用;上限 15 秒是因为超过这个间隔,即使任务完成了用户也会觉得"怎么还没出来";抖动的目的是避免多个客户端在同一时刻对齐轮询,形成周期性尖峰。

按这个参数算下来,各轮的实际等待和累计耗时如下(抖动取中值):

轮次基础间隔抖动后范围累计耗时
第 1 次2.0 s1.6 ~ 2.4 s约 2.0 s
第 2 次3.0 s2.4 ~ 3.6 s约 5.0 s
第 3 次4.5 s3.6 ~ 5.4 s约 9.5 s
第 4 次6.75 s5.4 ~ 8.1 s约 16.3 s
第 5 次10.1 s8.1 ~ 12.1 s约 26.4 s
第 6 次起15.0 s(封顶)12.0 ~ 18.0 s每轮 +15 s

对比一下就知道差别有多大:如果按固定 1 秒轮询,一个耗时 5 分钟的任务要发 300 次请求;按上面的退避策略,同样 5 分钟大约只发 25 次,请求量降到十二分之一。这不只是省流量的问题——请求少了,被限流的概率就低,被限流后任务状态更新不及时导致的"假卡死"也就没了。

还有一个细节值得单拎出来:读 Retry-After 头。服务端限流时通常会告诉客户端"多少秒后再来",客户端如果无视这个头按自己的节奏重试,就是在跟服务端对着干。我的实现里,只要收到限流响应,立即把当前任务的轮询间隔临时切换到服务端给的值,不管这个值比自己的退避间隔大多少。

3.4 结果缓存与本地文件治理

缓存是"免排队"效果最直观的一块。同一组提示词加参数,用户经常会重复调用——调了三次才满意的提示词、模板化生产的固定风格、给客户批量出图的同一套设定。这些场景下命中缓存,等于零排队、零等待。

缓存键就是前面算出的指纹,缓存值是本地文件路径加元信息。策略用 LRU 加过期时间:总容量上限设 2 GB,单条记录 7 天过期。容量和时效这两个值的取法有讲究——容量太小,LRU 频繁淘汰,命中率上不去;太大,用户手机存储吃紧,容易招来差评。2 GB 是移动端比较安全的量级,桌面端可以调到 10 GB。

文件治理有几件事必须在实现时就做好,不能等到用户投诉存储爆了再补。一是命名规则用指纹本身,天然唯一且不会冲突;二是清理时机放在应用启动后和每次写入后各扫一次,避免只写不扫;三是索引落盘,别只存在内存里,否则应用一重启缓存就成了孤儿文件,占着空间但再也命中不了。

注意:缓存键里千万不要包含随机种子以外的随机元素。见过有实现把"请求发起时刻"揉进缓存键,结果缓存命中率永远是 0,白白吃了几百 MB 存储。

4. 动手实现:一个能跑起来的本地中转层

4.1 环境准备与依赖

先把依赖说清楚,别一上来就陷入选型泥潭。运行环境用常见的 Node.js 长期支持版本即可,不需要额外框架,内置的 HTTP 模块足够应付本地回环场景。核心依赖只有两个:一个是哈希,用内置 crypto;另一个是本地存储,桌面端用文件系统,移动端用平台提供的键值存储。如果要做图片尺寸归一化,再引一个图像处理库。

目录结构建议按职责拆开,别一股脑塞在一个文件里:

relay/ server.js # 本地 HTTP 接口 scheduler.js # 任务表、指纹表、并发窗口 transport.js # 连接复用、超时、退避重试 store.js # 持久化:任务状态与缓存索引 fingerprint.js # 指纹计算

这套结构的价值在于,后面要换传输实现(比如换成平台原生网络库)、要加新的接口,改动范围都能控制在单个文件里。

4.2 最小可运行骨架

先给出骨架,能跑通"提交—查询"这条最小闭环,其他能力再往上叠。

// server.js const http = require('http'); const crypto = require('crypto'); const LOCAL_TOKEN = crypto.randomBytes(16).toString('hex'); // 写入应用私有存储 const HOST = '127.0.0.1'; function pickPort(start = 49152, tries = 10) { return new Promise((resolve, reject) => { let port = start + Math.floor(Math.random() * 2000); let left = tries; const attempt = () => { const probe = http.createServer(); probe.once('error', () => { if (--left <= 0) return reject(new Error('no free port')); port++; attempt(); }); probe.once('listening', () => probe.close(() => resolve(port))); probe.listen(port, HOST); }; attempt(); }); } function readBody(req) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (c) => { size += c.length; if (size > 1 << 20) { // 1MB 上限,防止本地请求被滥用 reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } async function start(scheduler) { const port = await pickPort(); const server = http.createServer(async (req, res) => { // 本地令牌校验,挡掉同机其他应用 if (req.headers['x-local-token'] !== LOCAL_TOKEN) { res.writeHead(403).end('forbidden'); return; } try { if (req.method === 'POST' && req.url === '/generate') { const payload = JSON.parse(await readBody(req)); const task = await scheduler.submit(payload); res.writeHead(200, { 'content-type': 'application/json' }) .end(JSON.stringify({ taskId: task.id, state: task.state, ahead: task.ahead })); } else if (req.method === 'GET' && req.url.startsWith('/task/')) { const id = req.url.slice('/task/'.length); const task = scheduler.get(id); if (!task) return res.writeHead(404).end('not found'); res.writeHead(200, { 'content-type': 'application/json' }) .end(JSON.stringify(task)); } else { res.writeHead(404).end('not found'); } } catch (e) { res.writeHead(500, { 'content-type': 'application/json' }) .end(JSON.stringify({ error: String(e.message || e) })); } }); server.listen(port, HOST); return { port, server, token: LOCAL_TOKEN }; } module.exports = { start };

这段骨架里有两个关键设计。一是端口动态选择,绑不上就换一个,最多试十次,彻底避开端口冲突带来的启动失败。二是本地令牌校验,每个请求都要带这个串,挡住同机其他应用扫端口。这两条看起来是小细节,实际上是这套机制能不能长期安全运行的分水岭。

4.3 指纹与去重模块

调度层把前面的指纹逻辑和并发窗口串起来,核心是一个submit方法。

// scheduler.js const { buildFingerprint } = require('./fingerprint'); const crypto = require('crypto'); const DEDUP_WINDOW_MS = 30 * 1000; class Scheduler { constructor({ gate, store, transport }) { this.gate = gate; this.store = store; this.transport = transport; this.tasks = new Map(); // taskId -> task this.fingerprints = new Map(); // fingerprint -> { taskId, at } } async submit(payload, opts = {}) { const fp = buildFingerprint(payload, opts.refImageHash); // 1. 命中本地缓存,直接返回结果 const cached = this.store.getCachedResult(fp); if (cached && !opts.forceRefresh) { return { id: 'cache-' + fp.slice(0, 8), state: 'done', fromCache: true, result: cached }; } // 2. 窗口内重复提交,复用已有任务 const hit = this.fingerprints.get(fp); if (hit && Date.now() - hit.at < DEDUP_WINDOW_MS && !opts.forceRefresh) { const existing = this.tasks.get(hit.taskId); return { ...existing, deduped: true }; } // 3. 新建任务,进入本地并发队列 const id = crypto.randomUUID(); const task = { id, fingerprint: fp, state: 'queued', payload, createdAt: Date.now(), attempts: 0 }; this.tasks.set(id, task); this.fingerprints.set(fp, { taskId: id, at: Date.now() }); this.store.persistTask(task); // 4. 后台推进,不阻塞本地接口返回 this.run(task).catch((e) => { task.state = 'failed'; task.error = String(e.message || e); this.store.persistTask(task); }); task.ahead = this.gate.pendingCount(); return task; } async run(task) { const release = await this.gate.acquire(); try { task.state = 'submitting'; this.store.persistTask(task); const remoteId = await this.transport.submit(task.payload); task.remoteId = remoteId; task.state = 'running'; this.store.persistTask(task); const result = await this.transport.waitForDone(remoteId, { onTick: (s) => { task.state = s; this.store.persistTask(task); } }); task.state = 'done'; task.result = result; this.store.cacheResult(task.fingerprint, result); this.store.persistTask(task); } finally { release(); } } get(id) { return this.tasks.get(id) || this.store.loadTask(id); } } module.exports = { Scheduler };

几个容易写错的地方值得点出来。第一,run必须异步起、立即返回,本地接口不能等着整个生成过程结束,否则 UI 线程会被一个 HTTP 请求拖住几十秒。第二,release()必须放在finally里,任何异常路径都要归还并发令牌,否则窗口会越用越小,最后卡死在零。第三,"强制刷新"的开关要一路透传,用户明确要求重来的时候不能又被去重挡住。

4.4 移动端落地:端口、权限与保活

从桌面搬到移动端,有三件事必须处理。

权限声明。本地监听端口在多数平台上不需要特殊权限,但访问网络需要,读写缓存文件需要。这些权限在应用配置里显式声明,别依赖默认授予。

前台服务与通知。如果任务需要在中途继续跑,把中转层挂到平台的常驻服务机制上,配一条低优先级通知。这条通知不是形式主义,它同时解决两个问题:系统不会轻易回收,用户也知道"后台还在处理"。通知文案写清楚进度,比如"正在生成第 2 个任务,已完成 1 个",比一句"正在处理"有用得多。

进程被杀后的状态恢复。这是最考验设计的一环。任务状态落盘之后,应用重启时做一次对账:遍历本地任务表,把所有处于"运行中"的任务拿出来,用远端任务 ID 查一次真实状态。已经完成的,直接把结果拉回来标记为完成;不存在的,标记为失败并清理;还在跑的,重新挂上轮询。这套对账逻辑写起来不到一百行,但能把"切后台五分钟回来发现任务丢了"这个高频投诉直接消灭。

注意:状态落盘要写"最后更新时间",重启对账时如果发现某个任务超过一个合理时长(比如 2 小时)还挂着"运行中",直接判定为失效。不然任务表会随着时间越积越多,最后拖慢启动。

4.5 联调验证清单

功能跑通只是第一步,上线前我会过一遍这几项。

验证项方法期望结果
去重生效同一组参数连续提交 3 次只产生 1 个远端任务,后两次返回 deduped
强制刷新带 forceRefresh 提交产生新的远端任务,不受窗口限制
并发窗口一次性提交 5 个不同任务同时在跑的数量不超过配额,其余排队
退避生效模拟限流响应轮询间隔跳到服务端给的值,不出现密集重试
缓存命中已完成的参数再次提交直接返回结果,无远端请求
重启恢复任务运行中强杀应用,重新启动任务状态被正确对账,不出现幽灵任务
越权访问不带本地令牌访问接口返回 403

这套清单我每次大改都会跑一遍,平均能抓到一到两个回归问题。别嫌麻烦,这些问题漏到线上,排查成本是验证成本的十倍以上。

5. 常见问题与排查实录

5.1 问题速查表

实际跑起来会遇到的问题,集中程度比我预想的高。整理成表,方便对着症状找原因。

现象可能原因排查手段处理方式
本地服务启动失败端口被占、上次进程残留看启动日志的绑定错误码动态端口重试,启动前清理残留监听
提交成功但状态永远 running远端任务已完成,本地轮询被限流比对远端查询结果与本地上次更新时间读限流响应给的等待时间,加长间隔
去重完全不起作用指纹字段白名单混入时间戳或随机值打印两次请求的指纹看是否一致收紧白名单,序列化前做深度排序
缓存命中率为零缓存键含时间元素,或文件被提前清理统计命中日志键只用指纹,清理策略改为 LRU 加过期
切后台回来任务消失状态只存在内存检查落盘逻辑每个状态变更都持久化,启动对账
同一张参考图被重复上传图片二进制哈希不稳定比对不同来源同一图片的哈希上传前做尺寸归一化,再算内容哈希
用户连点产生多份结果UI 层没有防抖,本地去重窗口太短看提交时间戳分布UI 防抖 800 毫秒,配合本地 30 秒窗口
应用重启后存储暴涨孤儿文件未清理统计缓存目录与索引条目数是否匹配启动时扫描,索引里没有的文件直接删

5.2 几个印象比较深的坑

第一个坑跟提示词的规范化有关。同一个提示词,用户在输入框里打了两个空格,或者从别处复制过来带了不可见的零宽字符,指纹就完全不同,缓存永远命不中。这个问题在测试阶段完全发现不了,因为我们自己测的时候都是手敲的干净字符串。上线后有用户反馈"同样的设置有时候快有时候慢",才顺着日志找出来。解决办法是在指纹计算前做一次深度规范化:合并连续空白、去掉首尾空格、过滤零宽字符、统一全角半角。做完这一步,缓存命中率提升了接近三成。

第二个坑是并发窗口和远端配额的错位。我们一开始把本地窗口设成远端配额加一,想着"多用一点是一点"。结果那个多出来的请求每次都被远端拒绝,客户端进重试,重试又占着本地窗口不放,导致后面排队的任务迟迟发不出去。现象是"明明只有一个任务在跑,其他任务全都卡着不动"。把窗口改成严格等于配额之后,问题立刻消失。这件事给我一个很实在的教训:本地调度器的作用是忠实反映远端约束,不是去试探它。

第三个坑是长任务的状态落盘频率。一开始为了"实时",每次轮询回执都写一次磁盘,一个五分钟的任务写了几十次。移动端的闪存写入有寿命,也有功耗代价,用户反馈"手机发烫"。后来改成只在状态发生跃迁时落盘(queued 到 submitting、submitting 到 running、running 到 done),进度百分比这类高频变化的字段只放内存,配合定时合并写入,写入次数降到原来的十分之一,发热问题消失,状态恢复的准确性也没受影响。

第四个坑比较隐蔽,跟端口选择有关。动态端口解决了冲突,但没解决"上次启动的监听没释放"。移动端应用被系统强杀时,监听套接字可能还处于半关闭状态,新进程绑同一个端口会失败,重试几次后才绑上。表象是"偶尔启动慢两秒"。解决方式是在启动前先探测一次已知端口,能连上就说明有残留,主动发一个关闭指令再重新绑定。这段代码只有十来行,但把冷启动的偶发延迟抹平了。

5.3 关于"能不能再快一点"的取舍

做完上面这些,等待时间能压缩多少?按我们的实测数据,一次创作流程从点击到看到结果,主观感知的等待下降了大约四成,其中去重贡献最多,缓存次之,连接复用和退避再往后排。剩下那六成,大部分是真实的推理耗时和全局队列等待,客户端再怎么优化也碰不到。

这时候要有清醒的判断:继续往客户端堆优化的边际收益会快速递减。我见过有团队为了再抠出几百毫秒,把轮询间隔压到极短,结果触发限流,整体反而更慢;也见过把缓存窗口拉得过大,用户想重新生成却拿到旧结果,投诉量上升。优化的边界感比优化本身更重要——把无效请求和无效等待清干净,剩下的时间老老实实交给算力,这才是合理的分工。

我个人在实际项目里的体会是,本地转发层这套东西,真正难的不是写代码,而是定义清楚哪些事归客户端管。归客户端管的,做到底;不归客户端管的,做好观测和预期管理就够了。用户能接受"还要等三分钟",接受不了"不知道还要等多久"。把队列位置、预计耗时、当前阶段这三样东西透出去,体验的提升往往比再优化几百毫秒更明显。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进