Engineering

错误系统设计:保留原始错误并补充业务语义

好的错误系统不是把底层错误替换成新字符串,而是在保留 cause 的基础上逐层补充业务语义。

一个好的错误系统,不应该把底层错误替换成一段新的字符串,而应该在保留原始错误的基础上,逐层补充业务语义。

底层错误回答的是“具体哪里坏了”,中间层错误回答的是“哪个能力因此失败了”,最外层错误回答的是“这个请求应该如何响应”。这三件事不应该互相覆盖。

不要丢掉原始错误

最常见的错误处理坏味道是:

try {
  await provider.generate();
} catch (err) {
  throw new Error('Generate failed');
}

这段代码虽然给了一个更干净的错误信息,但也丢掉了原始错误里的状态码、provider message、request id、stack 和 cause。未来排查时,只能看到“Generate failed”,却不知道真正失败的是鉴权、限流、网络、参数,还是模型服务内部错误。

更好的做法是把原始错误作为 cause 保留下来:

try {
  await provider.generate();
} catch (err) {
  throw new AppError(
    {
      code: 'LLM_PROVIDER_GENERATE_FAILED',
      domain: 'LLM',
      category: 'THIRD_PARTY',
      details: {
        provider: 'openai',
        modelId,
        runId,
      },
    },
    err,
  );
}

这样外层得到的是一个带业务语义的错误,但底层真实错误仍然还在。

错误应该分层表达语义

错误上抛时,不是每一层都必须重新包装。只有当当前层能增加新的语义时,才值得包一层。

例如一次模型调用失败,底层可能先生成 LLM 语义:

LLM_PROVIDER_GENERATE_FAILED
domain: LLM
category: THIRD_PARTY
cause: 原始 provider error

到了 Agent 层,如果这个错误意味着 agent.generate() 整体失败,可以再补一层 Agent 语义:

AGENT_GENERATE_FAILED
domain: AGENT
category: THIRD_PARTY
cause: LLM_PROVIDER_GENERATE_FAILED

这里的 domain 变了,因为失败已经影响到 Agent 能力;但 category 仍然可以保留第三方失败性质,因为真正的根因来自 provider。外层不应该为了表达“Agent 失败了”,就把责任归类改得更模糊。

最后到了 HTTP 层,不应该再发明新的业务语义,只需要把错误转换成协议响应:

HTTP 502
body.error.code: AGENT_GENERATE_FAILED

这时错误链大致是:

HTTPException
  cause:
    AppError: AGENT_GENERATE_FAILED
      cause:
        AppError: LLM_PROVIDER_GENERATE_FAILED
          cause:
            ProviderError

这里每一层都在回答不同问题:

  • ProviderError:外部服务具体返回了什么错误。
  • LLM_PROVIDER_GENERATE_FAILED:模型调用这个动作失败。
  • AGENT_GENERATE_FAILED:Agent 的生成能力失败。
  • HTTPException:这个失败应该以什么 HTTP 状态返回。

cause 是错误链,不是字符串拼接

现代 JavaScript 的 Error 支持 cause

const wrapped = new Error('Agent generate failed', { cause: originalError });

cause 的价值是保存结构化错误链,而不是把所有 message 拼成一条长字符串。字符串拼接虽然容易展示,但会破坏可读性和机器可处理性。

错误系统应该保留两个层面的信息:

  • 当前层的 messagecodedomaincategorydetails
  • 下层错误的 cause,包括下层自己的 message、stack、cause 和自定义字段。

这样日志、trace 和调试工具可以沿着 cause 链向下看,而面向用户的响应可以只展示最合适的一层。

用 code 区分具体错误

codeid 应该是稳定、唯一、可搜索的机器标识。

好的 code:

LLM_PROVIDER_GENERATE_FAILED
AGENT_MEMORY_SAVE_FAILED
WORKFLOW_STEP_TIMEOUT
AUTH_SESSION_EXPIRED

不好的 code:

ERROR
FAILED
BAD_REQUEST
GENERATE_FAILED

code 不应该承担所有分类职责。它适合精确识别某个错误,但不适合回答“这是哪个模块的问题”或“这类错误应该怎么响应”。这些应该交给 domaincategory

用 domain 表达错误所属模块

domain 用来回答:错误发生在哪个功能域?

常见 domain 可以是:

LLM
AGENT
TOOL
WORKFLOW
AUTH
STORAGE
BILLING

domain 的价值是横向归类。它能帮助日志、监控和源码阅读快速判断:这不是一个孤立异常,而是某个系统边界上的失败。

用 category 表达错误性质

category 用来回答:这个错误大致属于谁的责任,应该用什么策略处理?

一个实用分类是:

USER
SYSTEM
THIRD_PARTY
UNKNOWN

含义可以这样理解:

  • USER:用户输入、配置、权限、调用方式有问题。
  • SYSTEM:系统内部状态、代码逻辑或基础设施有问题。
  • THIRD_PARTY:外部服务、SDK、provider 失败。
  • UNKNOWN:兜底分类,说明当前层还不能判断。

HTTP 状态不应该直接替代 category。HTTP 是协议层表达,category 是工程判断。比如外部模型服务失败,在内部可以是 THIRD_PARTY,到了 HTTP 层可能映射成 502

用 details 保存排查上下文

details 应该保存机器可读的上下文:

details: {
  agentId,
  runId,
  modelProvider,
  modelId,
  storageTable,
}

details 的边界是:帮助排查,但不泄露敏感信息。

不要放:

  • API key。
  • token。
  • 用户隐私。
  • 完整 prompt。
  • 过大的请求或响应对象。

如果确实需要关联原始请求,优先放 requestIdrunIdtraceId 这类可回查标识。

HTTP 层只做协议映射

HTTP 层最好不要重新判断业务失败原因。它应该只做两件事:

  1. 把内部错误映射成合适的 HTTP status。
  2. 选择可以暴露给调用方的响应体。

例如:

function toHttp(error: unknown) {
  if (error instanceof AppError) {
    const status =
      error.category === 'USER'
        ? 400
        : error.category === 'THIRD_PARTY'
          ? 502
          : 500;

    return {
      status,
      body: {
        error: {
          code: error.code,
          message: error.message,
        },
      },
    };
  }

  return {
    status: 500,
    body: {
      error: {
        code: 'INTERNAL_ERROR',
        message: 'Internal error',
      },
    },
  };
}

真正完整的错误链留给日志、trace 和内部诊断,不一定直接暴露给外部 API。

什么时候重新包装错误

重新包装错误的判断标准是:当前层是否能提供新的稳定语义。

值得包装:

  • 第三方 SDK 错误进入系统边界,需要标记 provider、model、runId。
  • 工具执行错误进入 Tool 层,需要标记 toolId、args 摘要。
  • 工作流 step 失败,需要标记 workflowId、runId、stepId。
  • Agent 调用失败,需要标记 agentId 和调用方法。

不值得包装:

  • 只是为了改 message。
  • 当前层没有新增上下文。
  • 会把已有 code/domain/category 覆盖得更模糊。
  • 会导致同一个错误被重复记录多次。

如果已有错误已经足够表达当前语义,直接上抛更好。

设计一个 AppError

一个最小可用的错误类可以长这样:

type ErrorCategory = 'USER' | 'SYSTEM' | 'THIRD_PARTY' | 'UNKNOWN';

class AppError extends Error {
  readonly code: string;
  readonly domain: string;
  readonly category: ErrorCategory;
  readonly details?: Record<string, unknown>;

  constructor(
    definition: {
      code: string;
      domain: string;
      category: ErrorCategory;
      message?: string;
      details?: Record<string, unknown>;
    },
    cause?: unknown,
  ) {
    const normalizedCause =
      cause instanceof Error
        ? cause
        : cause !== undefined
          ? new Error(String(cause))
          : undefined;

    super(definition.message ?? normalizedCause?.message ?? 'Unknown error', {
      cause: normalizedCause,
    });

    this.name = 'AppError';
    this.code = definition.code;
    this.domain = definition.domain;
    this.category = definition.category;
    this.details = definition.details;
  }

  toJSON() {
    return {
      message: this.message,
      code: this.code,
      domain: this.domain,
      category: this.category,
      details: this.details,
      cause:
        this.cause instanceof Error
          ? {
              name: this.cause.name,
              message: this.cause.message,
            }
          : undefined,
    };
  }
}

这个类的重点不是代码复杂,而是约束清晰:

  • code 精确识别错误。
  • domain 标记错误所在模块。
  • category 标记错误性质。
  • details 提供排查上下文。
  • cause 保留底层真实错误。

错误系统的核心设计原则是:保留底层真相,补充当前层语义,最后再做协议转换。

不要只设计一组错误 message,而要设计一套错误信息结构:

code       精确识别
domain     模块归类
category   责任与处理策略
details    排查上下文
cause      原始错误链

这样系统既能给用户一个干净的响应,也能给工程师留下足够完整的诊断线索。

Back to Blog

Related Posts

View All Posts »