视频生成免排队:本地转发层与请求去重优化 排队这件事做过视频生成类工具集成的工程师大概都有一肚子话。同一个提示词用户手一抖连点三下后台就多了三条一模一样的任务排队号一个比一个靠后最后三份结果几乎同时吐出来——多出来的两份纯粹是自己跟自己抢位置。更别提弱网下的超时重试、切后台被杀掉后重新提交、轮询频率太高被服务端直接掐掉连接。这些等待里真正算力不够造成的只占一部分剩下相当比例是客户端自己制造的。这篇就围绕视频生成模型的免排队调用思路把本地转发层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 关于能不能再快一点的取舍做完上面这些等待时间能压缩多少按我们的实测数据一次创作流程从点击到看到结果主观感知的等待下降了大约四成其中去重贡献最多缓存次之连接复用和退避再往后排。剩下那六成大部分是真实的推理耗时和全局队列等待客户端再怎么优化也碰不到。这时候要有清醒的判断继续往客户端堆优化的边际收益会快速递减。我见过有团队为了再抠出几百毫秒把轮询间隔压到极短结果触发限流整体反而更慢也见过把缓存窗口拉得过大用户想重新生成却拿到旧结果投诉量上升。优化的边界感比优化本身更重要——把无效请求和无效等待清干净剩下的时间老老实实交给算力这才是合理的分工。我个人在实际项目里的体会是本地转发层这套东西真正难的不是写代码而是定义清楚哪些事归客户端管。归客户端管的做到底不归客户端管的做好观测和预期管理就够了。用户能接受还要等三分钟接受不了不知道还要等多久。把队列位置、预计耗时、当前阶段这三样东西透出去体验的提升往往比再优化几百毫秒更明显。