feat: 智能错误检测与 retryable 传播

ChatGPT 适配器:
- 解析 conversation SSE 响应,提取文本内容判断图片生成是否开始
- 早期检测速率限制(RateLimitException)和内容拒绝(policy violation)
- 动态超时:未检测到图片生成迹象时使用 30s 短超时,避免无谓等待
- 超时后尝试正则提取文本作为错误信息

LMArena 适配器:
- 新增 extractError 函数,解析 SSE 中的 a3(模型提供方错误)和 ae(平台错误)
- 支持嵌套 JSON 错误提取(如 OpenAI moderation_blocked)
- 无图片时返回 error 而非 text

Worker:
- enrichedMeta: 自动在 meta 中添加 adapter 和 model 信息
- 故障转移中传播 retryable 标志,不可重试错误立即停止

Failover:
- 优先使用 result.retryable 而非仅依赖 normalizeError 推断
- 不可重试错误直接返回,不再尝试其他候选 worker
This commit is contained in:
daidai
2026-04-05 00:11:42 +08:00
Unverified
parent 5b1c2f1123
commit d1467d943a
5 changed files with 180 additions and 18 deletions
+95 -7
View File
@@ -110,12 +110,79 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
return { error: `API 返回错误: HTTP ${conversationResponse.status()}` };
}
// 5.5 解析 conversation 响应,检查是否是纯文本回复(拒绝/限流场景)
let conversationText = '';
let isImageGenerationStarted = false;
let conversationBody = '';
try {
conversationBody = await conversationResponse.text();
// 检查是否有图片生成相关的内容 (dalle 工具调用或 file_ 文件引用)
// 注意:不使用 'image' 关键词,因为拒绝消息也会包含这个词
isImageGenerationStarted = conversationBody.includes('dalle') || conversationBody.includes('file_');
logger.debug('适配器', `isImageGenerationStarted: ${isImageGenerationStarted}`, meta);
// 提取文本内容
const lines = conversationBody.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const dataStr = line.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
// 提取初始文本 (channel=final 的 assistant 消息)
if (data.v?.message?.channel === 'final' &&
data.v?.message?.author?.role === 'assistant' &&
data.v?.message?.content?.parts?.length > 0) {
const part = data.v.message.content.parts[0];
if (typeof part === 'string') {
conversationText = part;
}
}
// patch 格式累加 (data.v 是 patch 操作数组)
if (Array.isArray(data.v)) {
for (const patch of data.v) {
if (patch.o === 'append' && patch.p === '/message/content/parts/0' && patch.v) {
conversationText += patch.v;
}
}
}
} catch { }
}
logger.debug('适配器', `提取到文本 (${conversationText.length} 字符): ${conversationText.substring(0, 200)}...`, meta);
} catch (e) {
logger.warn('适配器', `解析 conversation 响应失败: ${e.message}`, meta);
}
// 早期检测:如果文本表明是拒绝/限流,立即返回,不等待图片超时
if (conversationText) {
// 检查是否是速率限制错误 (不重试,同账号重试也没用)
const isRateLimit = conversationBody.includes('RateLimitException') ||
conversationBody.includes('rate limit') ||
/limit.*reset/i.test(conversationText);
if (isRateLimit) {
logger.warn('适配器', `早期检测到速率限制: ${conversationText.substring(0, 200)}...`, meta);
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
}
// 如果没有图片生成迹象,检查是否是内容被拒绝
if (!isImageGenerationStarted) {
const isContentRejection = /cannot|can't|unable|sorry|policy|violat/i.test(conversationText);
if (isContentRejection) {
logger.warn('适配器', `早期检测到内容拒绝: ${conversationText.substring(0, 200)}...`, meta);
return { error: `内容被拒绝: ${conversationText.substring(0, 200)}`, retryable: false };
}
}
}
logger.info('适配器', '生成中,等待图片就绪...', meta);
// 6. 监听文件状态接口,等待图片生成完成
// 通过 file_name 是否包含 .part 判断是否生成完成
// 如果 conversation 响应中没有图片生成迹象,使用较短超时
let downloadUrl = null;
let fileName = null;
const imageTimeout = isImageGenerationStarted ? 120000 : 30000;
try {
await page.waitForResponse(async (response) => {
@@ -128,11 +195,6 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const fn = json.file_name;
const dl = json.download_url;
// 检查是否生成完成:
// 1. 必须有 file_name
// 2. file_name 开头必须是 user- (生成的图片)
// 3. file_name 不能包含 .part(表示中间状态)
// 4. 必须有 download_url
if (fn && fn.startsWith('user-') && !fn.includes('.part') && dl) {
fileName = fn;
downloadUrl = dl;
@@ -145,8 +207,34 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
} catch {
return false;
}
}, { timeout: waitTimeout });
}, { timeout: imageTimeout });
} catch (e) {
logger.debug('适配器', `等待图片超时, conversationText长度: ${conversationText.length}, downloadUrl: ${downloadUrl}`, meta);
// 超时时检查是否有 conversation 中的文本内容
if (conversationText && !downloadUrl) {
const isRateLimit = conversationBody.includes('RateLimitException') ||
conversationBody.includes('rate limit') ||
/limit.*reset/i.test(conversationText);
if (isRateLimit) {
logger.warn('适配器', `触发速率限制: ${conversationText.substring(0, 200)}...`, meta);
return { error: `触发速率限制: ${conversationText.substring(0, 200)}`, retryable: false };
}
logger.warn('适配器', `模型返回文本而非图片: ${conversationText.substring(0, 200)}...`, meta);
return { error: `模型返回文本而非图片: ${conversationText.substring(0, 200)}`, retryable: false };
}
// 如果没有提取到文本,但有原始响应体,尝试用简单方式提取
if (!conversationText && conversationBody) {
const partsMatch = conversationBody.match(/"parts":\s*\["([^"]+)"\]/);
if (partsMatch && partsMatch[1]) {
logger.warn('适配器', `通过正则提取到文本: ${partsMatch[1].substring(0, 200)}...`, meta);
return { error: `模型返回文本而非图片: ${partsMatch[1].substring(0, 200)}`, retryable: false };
}
}
const pageError = normalizePageError(e, meta);
if (pageError) return pageError;
throw e;
+56 -2
View File
@@ -40,6 +40,53 @@ function extractImage(text) {
return null;
}
/**
* 从响应文本中提取错误信息
* SSE 错误格式:
* - a3: 模型提供方错误 (如 OpenAI moderation_blocked)
* - ae: Arena 平台错误 (如内容审核拦截)
* @param {string} text - 响应文本内容
* @returns {string|null} 提取到的错误信息,如果未找到则返回 null
*/
function extractError(text) {
if (!text) return null;
const lines = text.split('\n');
for (const line of lines) {
// a3: 模型提供方错误
if (line.startsWith('a3:')) {
try {
const errorMsg = JSON.parse(line.substring(3));
if (typeof errorMsg === 'string') {
// 尝试提取嵌套的 JSON 错误
const jsonMatch = errorMsg.match(/\{[\s\S]*"error"[\s\S]*\}/);
if (jsonMatch) {
try {
const nested = JSON.parse(jsonMatch[0]);
if (nested.error?.message) {
return `[模型错误] ${nested.error.message} (code: ${nested.error.code || 'unknown'})`;
}
} catch { }
}
return `[模型错误] ${errorMsg}`;
}
} catch (e) { }
}
// ae: Arena 平台错误
if (line.startsWith('ae:')) {
try {
const errorData = JSON.parse(line.substring(3));
if (errorData?.message) {
return `[平台错误] ${errorData.message}`;
}
if (typeof errorData === 'string') {
return `[平台错误] ${errorData}`;
}
} catch (e) { }
}
}
return null;
}
/**
* 执行生图任务
@@ -146,7 +193,14 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
const httpError = normalizeHttpError(response, content);
if (httpError) {
logger.error('适配器', `请求生成时返回错误: ${httpError.error}`, meta);
return { error: `请求生成时返回错误: ${httpError.error}` };
return { error: `请求生成时返回错误: ${httpError.error}`, retryable: httpError.retryable };
}
// 8.5 检查 SSE 错误 (a3/ae 行)
const sseError = extractError(content);
if (sseError) {
logger.warn('适配器', `SSE 错误: ${sseError}`, meta);
return { error: sseError, retryable: false };
}
// 9. 提取图片 URL
@@ -170,7 +224,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
return result;
} else {
logger.warn('适配器', '未获得结果,响应中无图片数据', { ...meta, preview: content.substring(0, 150) });
return { text: `未获得结果,响应中无图片数据: ${content}` };
return { error: `未获得结果,响应中无图片数据: ${content.substring(0, 200)}` };
}
} catch (err) {
+13 -2
View File
@@ -405,6 +405,7 @@ export class Worker {
const maxAttempts = maxRetries === 0 ? candidateTypes.length : Math.min(maxRetries + 1, candidateTypes.length);
let lastError = null;
let lastRetryable = undefined;
for (let i = 0; i < maxAttempts; i++) {
const { type, modelId: actualModelId } = candidateTypes[i];
@@ -415,12 +416,19 @@ export class Worker {
}
lastError = result.error;
lastRetryable = result.retryable;
// 如果明确标记为不可重试(如内容安全问题),立即返回
if (result.retryable === false) {
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: false };
}
if (i < maxAttempts - 1) {
logger.warn('工作池', `[${this.name}] ${type} 失败,尝试下一个适配器...`, { error: lastError, ...meta });
}
}
return { error: `所有支持该模型的适配器都无法使用: ${lastError}` };
return { error: `所有支持该模型的适配器都无法使用: ${lastError}`, retryable: lastRetryable };
}
/**
@@ -479,10 +487,13 @@ export class Worker {
userDataDir: this.userDataDir
};
// 扩展 meta,添加 adapter 和 model 信息
const enrichedMeta = { ...meta, adapter: type, model: modelId };
this.busyCount++;
try {
// 传递原始 modelId,由适配器自己解析
return await adapter.generate(subContext, prompt, paths, modelId, meta);
return await adapter.generate(subContext, prompt, paths, modelId, enrichedMeta);
} finally {
this.busyCount--;
}
+9 -5
View File
@@ -66,11 +66,15 @@ export function createFailoverExecutor(options = {}) {
// 记录错误
lastError = result.error;
// 检查是否可重试
const normalized = normalizeError(lastError);
if (!normalized.retryable && i < maxAttempts - 1) {
// 不可重试的错误,但还有候选,继续尝试
logger.debug('故障转移', `不可重试错误,跳过: ${lastError}`, meta);
// 优先使用 result 中的 retryable,否则通过 normalizeError 推断
const retryable = result.retryable !== undefined
? result.retryable
: normalizeError(lastError).retryable;
// 不可重试的错误(如内容安全问题),直接返回,不尝试其他候选
if (!retryable) {
logger.debug('故障转移', `不可重试错误,停止故障转移: ${lastError}`, meta);
return { error: lastError, code: 'NOT_RETRYABLE', retryable: false };
}
// 触发重试回调
+7 -2
View File
@@ -21,8 +21,8 @@ export function isRetryableError(errorMessage) {
const retryablePatterns = [
// 网络错误
/network|net::|econnreset|econnrefused|etimedout/i,
// 超时
/timeout|timed out/i,
// 超时(含中文)
/timeout|timed out|加载超时|请求超时/i,
// 页面崩溃
/crashed|crash/i,
// 5xx 服务端错误
@@ -63,6 +63,11 @@ export function normalizePageError(err, meta = {}) {
logger.error('适配器', timeoutMsg, meta);
return { error: timeoutMsg, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
}
// 页面加载超时 (gotoWithCheck 抛出的中文超时错误)
if (err.message?.includes('页面加载超时') || err.message?.includes('页面加载失败')) {
logger.error('适配器', err.message, meta);
return { error: err.message, code: ADAPTER_ERRORS.TIMEOUT_ERROR, retryable: true };
}
// 兼容原生 TimeoutError (其他地方抛出的)
if (err.name === 'TimeoutError' || err.message?.includes('Timeout')) {
logger.error('适配器', '请求超时', meta);