Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

wuyin 异步任务 5 分钟超时是写死的吗?扒一下轮询循环里那 4 个真实差异

2026年8月26日47 min read
wuyin 异步任务轮询与超时

提交返回的 task_id 拿不到图/视频,是 wuyin 没跑完还是代理提前放弃?源码逐行:同步轮询(图片)vs 异步分步(视频)的两套 client 模型、GET→POST 405 兜底、status 枚举没有常量、submit 阶段双鉴权。

跑视频生成的同学大概率撞过这个循环:

POST /v1/videos  →  {"id": "vk_abc123", "status": "queued"}
GET  /v1/videos/vk_abc123        →  status: "in_progress"
GET  /v1/videos/vk_abc123        →  status: "in_progress"
GET  /v1/videos/vk_abc123        →  status: "completed"
GET  /v1/videos/vk_abc123/content  →  mp4 二进制

但跑图片生成的同学看到的却是另一个样子——POST /v1/images 之后代理自己轮询到结束才把 data[] 整包塞回来,没有"中途再看一眼"这一步。

为什么一个供应商、两个端点,行为差这么多?这一篇扒一下 src/proxy/llm-proxy.service.ts 里 wuyin 异步任务从提交到收尾的 4 个真实差异,每个差异都对应一个生产环境里被踩过的坑。

一、同步轮询 vs 异步分步:图和视频根本是两套模型

文件 src/proxy/llm-proxy.service.ts:898-931(图片路径 pollWuyinImageResult):

while (Date.now() - startedAt < timeoutMs) {
  lastPayload = await this.fetchWuyinAsyncDetail(baseUrl, apiKey, taskId, timeoutMs);
  const status = Number(lastPayload?.data?.status);
  if (status === 2) return lastPayload;             // 成功
  if (status === 3) {
    throw new HttpException(... '无印图片生成失败', HttpStatus.BAD_GATEWAY);
  }
  await new Promise((resolve) => setTimeout(resolve, this.wuyinPollIntervalMs));
}
throw new HttpException(`无印图片生成超时,任务ID: ${taskId}`, HttpStatus.GATEWAY_TIMEOUT);

图片端 POST /v1/images 一直挂着不返回,代理内部跑这个 while 循环,每 2 秒(:35wuyinPollIntervalMs = 2000)打一次 /detail,最多撞 5 分钟(:36wuyinDefaultTimeoutMs = 5 * 60 * 1000)。status === 2 才回包。

视频端完全不同——文件 src/proxy/llm-proxy.service.ts:1082-1094

return {
  data: this.buildNormalizedVideoTaskResponse({
    videoId: taskId,
    status: 'queued',                              // 直接返回 queued
    ...
  }),
  headers: (submitResponse.headers || {}) as Record<string, any>,
  status: submitResponse.status,
};

POST /v1/videos 不轮询,拿到 taskId 就立刻回 status: "queued"。客户端要自己再 GET /v1/videos/:id 查进度,GET /v1/videos/:id/content 拉二进制(:1101-1126)。

一个供应商、两个端点、一个同步轮询、一个客户端多步——这才是为什么 99% 的"图片视频代理"教程照搬过来都跑不通的根因。OpenClaw 这一层做的是"图代理帮你等、视频代理只转交"。

二、status 字段是数字 0/1/2/3,文档却写字符串

文件 src/proxy/llm-proxy.service.ts:603-624normalizeWuyinAsyncTaskStatus

switch (numericStatus) {
  case 0: return 'queued';
  case 1: return 'in_progress';
  case 2: return 'completed';
  case 3: return 'failed';
}

wuyin 上游吐的是数字 0/1/2/3:

上游数字归一化后含义
0queued已入队
1in_progress正在跑
2completed完成
3failed失败

但这个映射只在视频路径:1133)调一次,图片路径里写死的是 status === 2status === 3 两个 magic number,没有走 normalize 函数:911-921)。意味着:

  1. 如果 wuyin 哪天在 0/1 之外新增一个状态码(比如 -1 取消),图片端会把它当成"还在跑"继续轮询到超时;视频端 Number.isNaN 兜底成 failed
  2. 文档里"status 取值 0/1/2/3"是和供应商对齐的契约,不是 OpenClaw 自己的语义层。
  3. 命名空间冲突:exec_time 是视频路径专属(:1147),图片路径根本没回这字段。

三、GET → POST 405 自动兜底,是一个真值得记的细节

文件 src/proxy/llm-proxy.service.ts:745-781fetchWuyinAsyncDetail

try {
  const response = await firstValueFrom(
    this.httpService.get(`${baseUrl}/detail`, {
      headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
      params: { key: apiKey, id: taskId },
      timeout: Math.min(30000, timeoutMs),
    }),
  );
  return response.data;
} catch (error) {
  const axiosError = error as AxiosError;
  if (axiosError?.response?.status !== 405) {
    throw error;
  }
  // 405 才回退到 POST
  const response = await firstValueFrom(
    this.httpService.post(`${baseUrl}/detail?key=${encodeURIComponent(apiKey)}`, { id: taskId }, ...),
  );
  return response.data;
}

注释里没写为什么要这么干,但代码逻辑很清楚:先用 GET 试,碰到 405 才回退 POST。这是 wuyin 历史上 /detail 端点允许方法变过的痕迹——有些镜像只接 POST,有些只接 GET,OpenClaw 不挑,能 GET 就 GET,被拒就 POST,POST 也失败才把错误原样抛出去(throw error 在非 405 分支里)。

对外契约:调用方不用关心这次拉详情走的是哪个 HTTP method,response.data 形状一样。

四、超时时间是双层的:Math.min(30000, timeoutMs) vs 完整 timeoutMs

同一个 fetchWuyinAsyncDetail 里:

timeout: Math.min(30000, timeoutMs),  // 单次 HTTP 请求:30 秒硬上限

但外层 pollWuyinImageResult 跑的:

while (Date.now() - startedAt < timeoutMs) {  // 整体轮询:5 分钟
  ...
  await new Promise((resolve) => setTimeout(resolve, this.wuyinPollIntervalMs));  // 2 秒间隔
}

意思是:

  1. 每一次 /detail 请求最多等 30 秒——哪怕你 provider.config.timeoutMs = 600000,单次拉详情也只给你 30 秒。
  2. 整轮轮询 5 分钟——超出就 GATEWAY_TIMEOUT 抛回去,taskId 也回给你,但代理已经放弃,你用这个 taskId 再去查还得自己打 wuyin。
  3. submit 阶段(/image_gpt:959/video_grok_imagine:1045)也是 Math.min(30000, timeoutMs)

provider.config.timeoutMs 影响的是"轮询坚持多久",不影响"单次 HTTP 抗慢"。如果你看到 502 总是恰好 30 秒准时挂——就是这个 30 秒硬上限在压你,不是代理在轮询,submit 阶段就被打回了。

五、submit 阶段的双重鉴权:?key= + Authorization

文件 src/proxy/llm-proxy.service.ts:958-968(图片)和 :1043-1054(视频):

await firstValueFrom(
  this.httpService.post(
    `${wuyinBaseUrl}/image_gpt?key=${queryKey}`,  // URL query 鉴权
    upstreamBody,
    {
      headers: {
        Authorization: apiKey,                    // Header 鉴权
        'Content-Type': 'application/json',
      },
      timeout: Math.min(30000, timeoutMs),
    },
  ),
);

/detail 路径(:752-758)也是:

this.httpService.get(`${baseUrl}/detail`, {
  headers: { Authorization: apiKey, ... },
  params: { key: apiKey, id: taskId },           // GET 走 query 走 params
  ...
});

但 POST 兜底路径(:775-778)是手动拼到 URL:

this.httpService.post(
  `${baseUrl}/detail?key=${encodeURIComponent(apiKey)}`,
  { id: taskId },
  ...
);

也就是说:同一次调用,query string 带了一份 apiKey、Authorization header 又带了一份。这是 wuyin 上游要求双鉴权的历史包袱——漏一个就 401。encodeURIComponent 在 query 上是必须的(apiKey 里可能有特殊字符),在 header 里 axios 自己会处理。

调用方不需要传这两份:OpenClaw 拿你传进来的 apiKey 字段在 submit 阶段自己复制成两份发出去。

六、extractWuyinTaskId 三层兜底,但顺序说明上游响应漂过

文件 src/proxy/llm-proxy.service.ts:589-600

private extractWuyinTaskId(payload: any): string | undefined {
  const data = payload?.data;
  if (typeof data === 'string' && data.trim()) {
    return data.trim();                           // 1) data 直接是字符串
  }
  if (typeof data?.id === 'string' && data.id.trim()) {
    return data.id.trim();                        // 2) data.id 字符串
  }
  if (typeof payload?.id === 'string' && payload.id.trim()) {
    return payload.id.trim();                     // 3) 顶层 id
  }
  return undefined;
}

按顺序吃三种结构:data 是字符串 → data.id → 顶层 id。三层都没命中就抛 无印图片生成响应缺少任务ID:986)或 无印视频生成响应缺少任务ID:1071)。

buildWuyinImageGenerationResponse:869-893)里回包结构是 { created, data, usage, task_id, provider },但这是 OpenClaw 自己构造的不是 wuyin 的原始响应。wuyin 原始的 submit 响应可能三种结构漂过,所以你看到 taskId 提取按"从最里到最外"逐层退。

七、buildNormalizedVideoTaskResponse 把数字 status 翻成字符串

视频路径在 :1133 把数字 status 过了一次 normalizeWuyinAsyncTaskStatussrc/proxy/llm-proxy.service.ts:603-624),所以 GET /v1/videos/:id 回的是字符串 queued/in_progress/completed/failed——这是给客户端看的统一语义层,调用方不用再自己 Number() 一次。

但图片路径(:911)只 Number(lastPayload?.data?.status) 就直接 === 2/3 比较——没有归一化层。这就是为什么图和视频的"完成判定"在客户端 SDK 看起来不一致:图代理不暴露 status(只回 data[]),视频代理把 status 翻译成字符串后回给你。

八、extractWuyinTaskId 用 payload 而不是 payload.data

注意 :594payload?.data vs :598payload?.id——前者是 wuyin 标准外层(绝大多数端点都把数据塞 data),后者是兜底(比如早期镜像直接顶层 id)。如果你用 mock server 测 OpenClaw,响应里没 data 字段、只顶层有 id,也能跑通

九、image 和 video 的差异不仅是"轮询不轮询"

把上面散的拼一下:

维度图片 /v1/images视频 /v1/videos
submit 后行为内部轮询到完成立即返回 queued
客户端何时拿到 datasubmit 响应里GET /:id/content
状态字段语义不暴露,代理内消化暴露 queued/in_progress/completed/failed
失败状态码502(HttpStatus.BAD_GATEWAY502 失败 / 409 未完成
整体超时5 分钟(wuyinDefaultTimeoutMs客户端自己控制
单次 HTTP 超时30 秒30 秒(/detail 阶段)
content 拉取超时不适用Math.min(180000, timeoutMs),3 分钟
鉴权submit 双鉴权 + 每次 detail 双鉴权submit 双鉴权 + 每次 detail 双鉴权

content 拉取那个 3 分钟(:1118Math.min(180000, timeoutMs))是 videos/:id/content 单独给的,比图片代理的 30 秒松得多——因为 mp4 文件通常比图片大,且 wuyin 视频 CDN 偶尔有冷启动。

十、你自己代理的第二条路

如果你要在自己的业务里挂 wuyin:

  1. :直接抄 OpenClaw 的 pollWuyinImageResult——while (Date.now() - startedAt < timeoutMs) + await sleep(pollIntervalMs) + magic number 2/3。90% 的人在这一步写出 for (let i = 0; i < 150; i++)——别这么写,setTimeout 的实际间隔会漂,长任务会从 5 分钟变成 7 分钟。
  2. 视频:别在代理里轮询。直接照 buildNormalizedVideoTaskResponse 把上游数字 status 翻成 queued/in_progress/completed/failed 给前端,前端用 setIntervalGET /:id5000ms 间隔足够),拿到 completed 再去拉 /:id/content前端控制节奏比代理里再起一层轮询简单太多。
  3. submit 失败:submit 阶段的 Math.min(30000, timeoutMs)单次 HTTP 抗慢上限,不是整体超时。如果你配置 provider.config.timeoutMs = 600000 然后看到 502 总是恰好 30 秒准时挂——把这个值调小到 30000 也能复现相同行为,调大到 90000 也没用,因为有 30 秒硬上限。要绕过这个上限,得在 fetchWuyinAsyncDetail 那一层自己改 timeout 公式。
  4. GET 405:如果你的 wuyin 镜像把 /detail 的方法改了,OpenClaw 会自动回退 POST,不需要改业务代码。但如果 POST 也 405(比如某些企业内网代理连 GET/POST 都不让、只让 PUT),那 fetchWuyinAsyncDetail 不会兜——会原样抛 405。
  5. 双鉴权:所有 wuyin 调用都走 ?key= + Authorization别只发一个。如果你只发 Authorization 不发 ?key=,绝大多数镜像会 401;反过来也 401。

写在最后

wuyin 异步任务这套实现的核心是一个朴素的观察:图片生成足够快(秒级),同步轮询到结束对客户端简单;视频生成是分钟级,让客户端自己分步查能避免代理长连接被打爆

status === 2 / status === 3 这两个 magic number 看着糙,但比起"加一个 status 常量文件"更省事——只要 extractWuyinTaskIdnormalizeWuyinAsyncTaskStatus 把契约守住,魔法数字留在内部是 OK 的。真正会出问题的不是 magic number,是"图片不归一化、视频归一化"这种——这就是为什么客户端 SDK 调图和调视频的代码长得完全不一样。

下一篇会聊 /api/async 的 baseUrl 归一化(resolveWuyinApiBaseUrl:465-510)——那个是真正的"随便你怎么配 baseUrl,OpenClaw 都能把路径补对"的小型 DSL,规则比这一篇还绕。