一个好的错误系统,不应该把底层错误替换成一段新的字符串,而应该在保留原始错误的基础上,逐层补充业务语义。
底层错误回答的是“具体哪里坏了”,中间层错误回答的是“哪个能力因此失败了”,最外层错误回答的是“这个请求应该如何响应”。这三件事不应该互相覆盖。
不要丢掉原始错误
最常见的错误处理坏味道是:
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 拼成一条长字符串。字符串拼接虽然容易展示,但会破坏可读性和机器可处理性。
错误系统应该保留两个层面的信息:
- 当前层的
message、code、domain、category、details。 - 下层错误的
cause,包括下层自己的 message、stack、cause 和自定义字段。
这样日志、trace 和调试工具可以沿着 cause 链向下看,而面向用户的响应可以只展示最合适的一层。
用 code 区分具体错误
code 或 id 应该是稳定、唯一、可搜索的机器标识。
好的 code:
LLM_PROVIDER_GENERATE_FAILED
AGENT_MEMORY_SAVE_FAILED
WORKFLOW_STEP_TIMEOUT
AUTH_SESSION_EXPIRED不好的 code:
ERROR
FAILED
BAD_REQUEST
GENERATE_FAILEDcode 不应该承担所有分类职责。它适合精确识别某个错误,但不适合回答“这是哪个模块的问题”或“这类错误应该怎么响应”。这些应该交给 domain 和 category。
用 domain 表达错误所属模块
domain 用来回答:错误发生在哪个功能域?
常见 domain 可以是:
LLM
AGENT
TOOL
WORKFLOW
AUTH
STORAGE
BILLINGdomain 的价值是横向归类。它能帮助日志、监控和源码阅读快速判断:这不是一个孤立异常,而是某个系统边界上的失败。
用 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。
- 过大的请求或响应对象。
如果确实需要关联原始请求,优先放 requestId、runId、traceId 这类可回查标识。
HTTP 层只做协议映射
HTTP 层最好不要重新判断业务失败原因。它应该只做两件事:
- 把内部错误映射成合适的 HTTP status。
- 选择可以暴露给调用方的响应体。
例如:
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 原始错误链这样系统既能给用户一个干净的响应,也能给工程师留下足够完整的诊断线索。