提交返回的 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 秒(:35 的 wuyinPollIntervalMs = 2000)打一次 /detail,最多撞 5 分钟(:36 的 wuyinDefaultTimeoutMs = 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-624 的 normalizeWuyinAsyncTaskStatus:
switch (numericStatus) {
case 0: return 'queued';
case 1: return 'in_progress';
case 2: return 'completed';
case 3: return 'failed';
}
wuyin 上游吐的是数字 0/1/2/3:
| 上游数字 | 归一化后 | 含义 |
|---|---|---|
| 0 | queued | 已入队 |
| 1 | in_progress | 正在跑 |
| 2 | completed | 完成 |
| 3 | failed | 失败 |
但这个映射只在视频路径(:1133)调一次,图片路径里写死的是 status === 2 和 status === 3 两个 magic number,没有走 normalize 函数(:911-921)。意味着:
- 如果 wuyin 哪天在 0/1 之外新增一个状态码(比如
-1取消),图片端会把它当成"还在跑"继续轮询到超时;视频端Number.isNaN兜底成failed。 - 文档里"status 取值 0/1/2/3"是和供应商对齐的契约,不是 OpenClaw 自己的语义层。
- 命名空间冲突:
exec_time是视频路径专属(:1147),图片路径根本没回这字段。
三、GET → POST 405 自动兜底,是一个真值得记的细节
文件 src/proxy/llm-proxy.service.ts:745-781 的 fetchWuyinAsyncDetail:
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 秒间隔
}
意思是:
- 每一次
/detail请求最多等 30 秒——哪怕你provider.config.timeoutMs = 600000,单次拉详情也只给你 30 秒。 - 整轮轮询 5 分钟——超出就
GATEWAY_TIMEOUT抛回去,taskId 也回给你,但代理已经放弃,你用这个 taskId 再去查还得自己打 wuyin。 - 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 过了一次 normalizeWuyinAsyncTaskStatus(src/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
注意 :594 的 payload?.data vs :598 的 payload?.id——前者是 wuyin 标准外层(绝大多数端点都把数据塞 data),后者是兜底(比如早期镜像直接顶层 id)。如果你用 mock server 测 OpenClaw,响应里没 data 字段、只顶层有 id,也能跑通。
九、image 和 video 的差异不仅是"轮询不轮询"
把上面散的拼一下:
| 维度 | 图片 /v1/images | 视频 /v1/videos |
|---|---|---|
| submit 后行为 | 内部轮询到完成 | 立即返回 queued |
| 客户端何时拿到 data | submit 响应里 | GET /:id/content 时 |
| 状态字段语义 | 不暴露,代理内消化 | 暴露 queued/in_progress/completed/failed |
| 失败状态码 | 502(HttpStatus.BAD_GATEWAY) | 502 失败 / 409 未完成 |
| 整体超时 | 5 分钟(wuyinDefaultTimeoutMs) | 客户端自己控制 |
| 单次 HTTP 超时 | 30 秒 | 30 秒(/detail 阶段) |
| content 拉取超时 | 不适用 | Math.min(180000, timeoutMs),3 分钟 |
| 鉴权 | submit 双鉴权 + 每次 detail 双鉴权 | submit 双鉴权 + 每次 detail 双鉴权 |
content 拉取那个 3 分钟(:1118 的 Math.min(180000, timeoutMs))是 videos/:id/content 单独给的,比图片代理的 30 秒松得多——因为 mp4 文件通常比图片大,且 wuyin 视频 CDN 偶尔有冷启动。
十、你自己代理的第二条路
如果你要在自己的业务里挂 wuyin:
- 图:直接抄 OpenClaw 的
pollWuyinImageResult——while (Date.now() - startedAt < timeoutMs)+await sleep(pollIntervalMs)+ magic number2/3。90% 的人在这一步写出for (let i = 0; i < 150; i++)——别这么写,setTimeout的实际间隔会漂,长任务会从 5 分钟变成 7 分钟。 - 视频:别在代理里轮询。直接照
buildNormalizedVideoTaskResponse把上游数字 status 翻成queued/in_progress/completed/failed给前端,前端用setInterval查GET /:id(5000ms间隔足够),拿到completed再去拉/:id/content。前端控制节奏比代理里再起一层轮询简单太多。 - submit 失败:submit 阶段的
Math.min(30000, timeoutMs)是单次 HTTP 抗慢上限,不是整体超时。如果你配置provider.config.timeoutMs = 600000然后看到 502 总是恰好 30 秒准时挂——把这个值调小到 30000 也能复现相同行为,调大到 90000 也没用,因为有 30 秒硬上限。要绕过这个上限,得在fetchWuyinAsyncDetail那一层自己改 timeout 公式。 - GET 405:如果你的 wuyin 镜像把
/detail的方法改了,OpenClaw 会自动回退 POST,不需要改业务代码。但如果 POST 也 405(比如某些企业内网代理连 GET/POST 都不让、只让 PUT),那fetchWuyinAsyncDetail不会兜——会原样抛 405。 - 双鉴权:所有 wuyin 调用都走
?key=+Authorization,别只发一个。如果你只发Authorization不发?key=,绝大多数镜像会 401;反过来也 401。
写在最后
wuyin 异步任务这套实现的核心是一个朴素的观察:图片生成足够快(秒级),同步轮询到结束对客户端简单;视频生成是分钟级,让客户端自己分步查能避免代理长连接被打爆。
status === 2 / status === 3 这两个 magic number 看着糙,但比起"加一个 status 常量文件"更省事——只要 extractWuyinTaskId 和 normalizeWuyinAsyncTaskStatus 把契约守住,魔法数字留在内部是 OK 的。真正会出问题的不是 magic number,是"图片不归一化、视频归一化"这种——这就是为什么客户端 SDK 调图和调视频的代码长得完全不一样。
下一篇会聊 /api/async 的 baseUrl 归一化(resolveWuyinApiBaseUrl,:465-510)——那个是真正的"随便你怎么配 baseUrl,OpenClaw 都能把路径补对"的小型 DSL,规则比这一篇还绕。