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=rate、refusal、tool、timeout 或 cancel 观察不同结果。
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 自动重放。错误上下文可以增加 operation 和 idempotencyKey,由工具适配器明确声明幂等性,再由策略层决定是否重试。
内容拒绝也不适合通过切换模型绕过。不同模型的安全规则虽然不同,但“遇到拒绝就换一家”会让系统行为不可控。更稳妥的做法是停止自动调用,返回稳定错误码,并给用户可操作但不泄露内部审核规则的提示。
前端提示与指标边界
前端不应直接展示 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
}
};
}
指标标签应选择 code、stage、model、tool 等有限集合。不要把错误消息、请求 ID或用户输入作为指标标签,否则会制造高基数数据。请求 ID适合进入结构化日志和追踪系统,用于关联一次调用的模型请求、工具执行与最终响应。
还要区分“系统失败率”和“用户取消率”。用户主动停止生成不是服务故障;内容拒绝也不应计入模型可用性告警。只有语义层稳定,告警阈值、SLO 和容量判断才不会被混杂数据误导。
总结
统一错误层的目标不是包装一个更漂亮的异常类,而是让每个调用方都能基于同一组事实决策:供应商适配器负责归一化,业务层依据稳定错误码执行有限重试和模型降级,工具层结合幂等性判断是否重放,前端通过公开映射展示提示,监控系统按低基数字段统计。
实现时应保留原始异常作为服务端 cause,只序列化经过筛选的故障上下文;同时明确区分模型超时、用户取消、内容拒绝和工具失败。这样即使底层模型或 SDK 更换,上层策略仍能保持稳定,也不必再把所有失败压缩成一个缺乏行动信息的 500。
评论