LLM 应用面对的不只是“成功或失败”:限流可以重试,内容拒绝不该重试,工具异常也不应盲目切换模型。本文用一个可运行的 Node.js 示例,把供应商差异收敛为稳定的业务错误语义,并据此驱动恢复策略与用户提示。

为什么不能把失败都变成 500

接入多个模型后,业务层经常看到这样的代码:

try {
  return await provider.generate(input);
} catch (error) {
  throw new Error('模型调用失败');
}

这段代码抹掉了决定后续行为的关键信息。HTTP 429 表示容量不足,稍后重试或切换模型可能有效;内容策略拒绝通常是确定性结果,自动重试既浪费配额,也可能造成不合规行为;工具执行失败则应定位工具名称和调用阶段,而不是归咎于模型。

需要区分的至少有以下几类:

错误语义是否适合重试是否适合模型降级用户侧表达
RATE_LIMITED是,需退避服务繁忙,请稍后重试
CONTENT_REFUSED通常否请求无法处理,请调整内容
TOOL_FAILED视工具幂等性而定外部能力暂时不可用
MODEL_TIMEOUT是,次数受限响应超时,请重试
USER_CANCELLED请求已取消
UPSTREAM_UNAVAILABLE服务暂时不可用

错误码不是日志文案,而是业务策略的输入。供应商原始错误仍可保留在受控上下文中,但不能直接成为跨模块契约。

设计稳定的错误契约

统一错误对象可以包含五组信息:稳定错误码、发生阶段、是否可重试、对外 HTTP 状态,以及经过筛选的故障上下文。上下文只放模型名、工具名、供应商请求 ID、重试等待时间等低基数字段,不要塞入完整提示词、工具参数或供应商响应体。

class AppFault extends Error {
  constructor(code, message, options = {}) {
    super(message, { cause: options.cause });
    this.name = 'AppFault';
    this.code = code;
    this.stage = options.stage ?? 'unknown';
    this.retryable = options.retryable ?? false;
    this.status = options.status ?? 500;
    this.context = options.context ?? {};
  }

  toJSON() {
    return {
      code: this.code,
      message: this.message,
      stage: this.stage,
      retryable: this.retryable,
      context: this.context
    };
  }
}

cause 供服务端日志追踪原始异常,但 toJSON() 故意不序列化它。这样既能保留诊断链路,也能避免把密钥、请求正文或 SDK 内部结构发送给前端。

错误码应由应用维护,而不是照搬某家供应商的字符串。供应商适配器负责把状态码和原始代码翻译成统一语义;上层工作流只依赖 AppFault

一个可运行的 Node.js 实现

下面的示例只使用 Node.js 20 及以上版本的标准 API。模型适配器用可控场景模拟不同供应商响应,错误层、超时、取消、重试和降级逻辑都可以直接运行。将代码保存为 error-layer.mjs,执行 node error-layer.mjs;也可以设置 SCENARIO=raterefusaltooltimeoutcancel 观察不同结果。

import { setTimeout as sleep } from 'node:timers/promises';

class AppFault extends Error {
  constructor(code, message, options = {}) {
    super(message, { cause: options.cause });
    this.name = 'AppFault';
    this.code = code;
    this.stage = options.stage ?? 'unknown';
    this.retryable = options.retryable ?? false;
    this.status = options.status ?? 500;
    this.context = options.context ?? {};
  }
  toJSON() {
    const { code, message, stage, retryable, context } = this;
    return { code, message, stage, retryable, context };
  }
}

class ProviderError extends Error {
  constructor(status, providerCode, requestId) {
    super(`provider error: ${providerCode}`);
    this.status = status;
    this.providerCode = providerCode;
    this.requestId = requestId;
  }
}

function normalize(error, meta) {
  if (error?.name === 'AbortError' && meta.userSignal.aborted) {
    return new AppFault('USER_CANCELLED', 'request cancelled', {
      stage: 'model', status: 408, context: { model: meta.model }
    });
  }
  if (error?.name === 'AbortError' && meta.signal.reason?.name === 'TimeoutError') {
    return new AppFault('MODEL_TIMEOUT', 'model timed out', {
      stage: 'model', status: 504, retryable: true,
      context: { model: meta.model }
    });
  }
  if (error instanceof AppFault) return error;
  if (error instanceof ProviderError) {
    const context = {
      model: meta.model,
      providerCode: error.providerCode,
      requestId: error.requestId
    };
    if (error.providerCode === 'content_policy') {
      return new AppFault('CONTENT_REFUSED', 'content refused', {
        stage: 'model', status: 422, context, cause: error
      });
    }
    if (error.status === 429) {
      return new AppFault('RATE_LIMITED', 'provider rate limited', {
        stage: 'model', status: 429, retryable: true,
        context: { ...context, retryAfterMs: 50 }, cause: error
      });
    }
    if (error.status >= 500) {
      return new AppFault('UPSTREAM_UNAVAILABLE', 'provider unavailable', {
        stage: 'model', status: 503, retryable: true,
        context, cause: error
      });
    }
  }
  return new AppFault('INTERNAL_ERROR', 'unexpected failure', {
    stage: 'unknown', status: 500, cause: error
  });
}

async function executeTool() {
  throw new AppFault('TOOL_FAILED', 'weather tool failed', {
    stage: 'tool', status: 502, retryable: false,
    context: { tool: 'weather.lookup' }
  });
}

async function callModel(model, scenario, signal) {
  await sleep(20, undefined, { signal });
  if (scenario === 'rate' && model === 'primary') {
    throw new ProviderError(429, 'rate_limit', 'req-demo-1');
  }
  if (scenario === 'refusal') {
    throw new ProviderError(400, 'content_policy', 'req-demo-2');
  }
  if (scenario === 'timeout') {
    await sleep(200, undefined, { signal });
  }
  if (scenario === 'tool') await executeTool();
  return { model, text: '调用成功' };
}

const metrics = new Map();
function count(fault, model) {
  const key = `${fault.code}:${model}`;
  metrics.set(key, (metrics.get(key) ?? 0) + 1);
}

async function invoke(model, scenario, userSignal) {
  const timeoutSignal = AbortSignal.timeout(80);
  const signal = AbortSignal.any([userSignal, timeoutSignal]);
  try {
    return await callModel(model, scenario, signal);
  } catch (error) {
    throw normalize(error, { model, signal, userSignal });
  }
}

async function runWithPolicy(scenario, userSignal) {
  for (const model of ['primary', 'fallback']) {
    for (let attempt = 0; attempt < 2; attempt++) {
      try {
        return await invoke(model, scenario, userSignal);
      } catch (fault) {
        count(fault, model);
        if (!fault.retryable) throw fault;
        if (attempt === 0) {
          await sleep(fault.context.retryAfterMs ?? 30, undefined, {
            signal: userSignal
          });
        }
      }
    }
  }
  throw new AppFault('UPSTREAM_UNAVAILABLE', 'all models unavailable', {
    stage: 'orchestration', status: 503
  });
}

const scenario = process.env.SCENARIO ?? 'ok';
const controller = new AbortController();
if (scenario === 'cancel') {
  setTimeout(() => controller.abort(), 5);
}

try {
  console.log(await runWithPolicy(scenario, controller.signal));
} catch (fault) {
  console.error(JSON.stringify(fault, null, 2));
} finally {
  console.log('metrics:', Object.fromEntries(metrics));
}

这里使用 AbortSignal.timeout() 表示系统超时,用独立的 AbortController 表示用户取消,再通过 AbortSignal.any() 合并。虽然两种情况最终都可能产生 AbortError,归一化函数仍能根据原始信号判断责任来源。

让错误语义驱动恢复策略

安全重试不能只看“调用失败”。至少要同时检查错误码、尝试次数、操作幂等性和剩余时间预算。示例中只重试 retryable 错误,并限制每个模型最多两次;主模型持续失败后才进入备用模型。

真实系统还应加入指数退避和随机抖动,并优先遵守供应商返回的 Retry-After。工作流必须设置总截止时间,否则两次重试加一次模型降级,可能把一次请求拖成数十秒。

工具调用需要更严格。查询天气之类的只读工具通常可以重试;发送邮件、扣减库存或创建工单则不能仅凭 TOOL_FAILED 自动重放。错误上下文可以增加 operationidempotencyKey,由工具适配器明确声明幂等性,再由策略层决定是否重试。

内容拒绝也不适合通过切换模型绕过。不同模型的安全规则虽然不同,但“遇到拒绝就换一家”会让系统行为不可控。更稳妥的做法是停止自动调用,返回稳定错误码,并给用户可操作但不泄露内部审核规则的提示。

前端提示与指标边界

前端不应直接展示 fault.message。服务端可以按错误码维护公开文案,将内部上下文放入日志:

const publicMessages = {
  RATE_LIMITED: '当前请求较多,请稍后重试',
  CONTENT_REFUSED: '该请求无法处理,请调整内容后重试',
  TOOL_FAILED: '外部服务暂时不可用',
  MODEL_TIMEOUT: '响应超时,请重新提交',
  USER_CANCELLED: '请求已取消',
  UPSTREAM_UNAVAILABLE: 'AI 服务暂时不可用',
  INTERNAL_ERROR: '服务发生内部错误'
};

function toHttpResponse(fault) {
  return {
    status: fault.status,
    body: {
      error: fault.code,
      message: publicMessages[fault.code] ?? publicMessages.INTERNAL_ERROR,
      retryable: fault.retryable
    }
  };
}

指标标签应选择 codestagemodeltool 等有限集合。不要把错误消息、请求 ID或用户输入作为指标标签,否则会制造高基数数据。请求 ID适合进入结构化日志和追踪系统,用于关联一次调用的模型请求、工具执行与最终响应。

还要区分“系统失败率”和“用户取消率”。用户主动停止生成不是服务故障;内容拒绝也不应计入模型可用性告警。只有语义层稳定,告警阈值、SLO 和容量判断才不会被混杂数据误导。

总结

统一错误层的目标不是包装一个更漂亮的异常类,而是让每个调用方都能基于同一组事实决策:供应商适配器负责归一化,业务层依据稳定错误码执行有限重试和模型降级,工具层结合幂等性判断是否重放,前端通过公开映射展示提示,监控系统按低基数字段统计。

实现时应保留原始异常作为服务端 cause,只序列化经过筛选的故障上下文;同时明确区分模型超时、用户取消、内容拒绝和工具失败。这样即使底层模型或 SDK 更换,上层策略仍能保持稳定,也不必再把所有失败压缩成一个缺乏行动信息的 500。