三周前,我让 Claude Code 帮我改了一个 Express 项目的错误处理中间件。代码跑通的那一刻,diff 干净得像教科书,统一的错误码枚举、结构化的日志输出、根据环境变量自动切换的错误详情策略。我反复看了三遍,挑不出毛病。合并,部署,下班。
凌晨两点,PagerDuty 响了。
所有 /api/order 接口返回 500。日志里没有堆栈,没有 source map 映射后的行号,没有 traceId,只有一条 JSON:
{"code":"INTERNAL_ERROR","message":"Something went wrong"}
“Something went wrong”,这是 Claude Code 帮我生成的默认兜底消息。而真正触发这条消息的那个异步错误,在中间件的某处被静默吞掉了。那天晚上我在 Datadog 里翻了 46 分钟,最后发现是 Redis 连接池耗尽导致的 unhandled rejection,但中间件既没有捕获它,也没有让它冒泡,而是优雅地返回了一个毫无诊断价值的 JSON。
这不是 Claude Code 的问题。这是我的问题。 我让它在一个足够复杂的项目里自由发挥,给了它一个模糊的 prompt,得到了一个看起来完美的答案,然后我选择了信任。
这篇文章不教你怎么用 AI 写代码。网上已经有太多“三分钟用 AI 生成错误处理中间件”的教程。我要写的是那些 AI 不会告诉你的事:当 Claude Code 把代码交给你之后,真正的战斗才开始。
一、核心结论:AI 写中间件的真实能力边界
先说最重要的结论,这是我过去 6 个月在生产环境里反复验证过的:
Claude Code 擅长将“已有的模式”翻译成代码,不擅长判断“这个模式是否适合你的场景”。
具体到错误处理中间件,它能出色地完成以下任务:
根据你指定的错误类型生成对应的类定义和枚举
按照你给的日志格式(JSON / text / logfmt)生成格式化函数
复制你项目中已有的错误处理模式并应用到新的中间件上
根据 HTTP 状态码映射生成标准的响应体
它做不好的事:
判断某个错误应该被“吞掉”还是“抛出”,这需要理解业务语义
识别异步错误的捕获缺口,它生成的代码看起来能处理 async,但边界情况经常遗漏
决定什么信息在什么环境暴露,安全问题需要你明确约束
写出可调试的错误,AI 天然追求“干净”,会过度简化错误上下文
一句话:Claude Code 是顶级的代码翻译器,但不是架构决策者。 你给它清晰的约束,它给你可靠的代码。你让它“看着办”,它给你一场凌晨两点的 oncall。
下面我会把这个结论拆开揉碎,用我踩过的坑、验证过的方案、以及反复迭代出来的 prompt 模板,帮你建立一套用 AI 辅助编写错误处理中间件的完整实践框架。
二、真实场景:为什么我们需要重新审视 AI 生成的中间件
2.1 一个典型项目的错误处理演化
让我先描述一个典型的 Node.js 后端项目的错误处理进化史,我见过太多团队走这条路:
阶段一:没有中间件。 每个 controller 里散落着 try-catch,catch 块里的代码比业务逻辑还长。日志格式不统一,有的用 console.error,有的用 logger.error,有的直接忘了写。
阶段二:手写了一个基础中间件。 某个 senior 花了一下午写了 60 行的 error middleware,能区分 ValidationError 和 AppError,返回统一格式。但只处理同步错误,async 路由的异常还是会 crash 进程。
阶段三:发现了 async 的问题,打了个补丁。 加上了 try { await next() } catch (err) { ... },但没处理事件循环里的 unhandled rejection。同时错误码逐渐膨胀到 40 多个,每次加新的都要手动维护映射表,烦。
阶段四:某人说“让 Claude Code 重写一下吧”。 于是把旧代码贴进对话框,prompt 是:“帮我重写一个更完善的错误处理中间件”。AI 返回了一份漂亮的代码,重构成了策略模式,加了一堆注释,看起来像是从某个 blog 复制下来的最佳实践。团队很高兴,合并了。
阶段五:凌晨两点。 某种在旧代码里不会被吞掉的异常,在新中间件里被优雅地转换成了一个通用的 500 错误,丢失了所有可追溯信息。
这个演化路径说明了一件事:错误处理中间件的价值不在“正常情况”下,而在“极端情况”下。 而 AI 的训练数据里,极端情况的样本远少于正常情况。

2.2 AI 生成的代码有一个你意识不到的盲区
我做了个实验。让 Claude Code(Claude 4 Sonnet)用相同的 prompt 生成 5 次错误处理中间件,然后统计共性:
维度
5 次生成结果
能正确区分 HTTP 状态码映射
5/5
包含 try-catch 包裹 next()
5/5
使用 winston/pino 日志库
3/5
区分开发/生产环境的错误详情
3/5
正确使用 await next().catch() 模式
0/5
包含 traceId/requestId 注入
1/5
在 catch 中检查 err.statusCode 是否存在
5/5
检查 err 是否为 null/undefined
0/5
处理 SyntaxError(JSON parse 失败等)
1/5
明确区分“可预期错误”和“非预期错误”
0/5
注意第三列和最后一列。AI 假设所有抛到中间件里的 error 都是已经构造好的 AppError 实例。 但真实世界不是这样的,你会遇到:
库作者直接 throw 'connection failed'(字符串,不是 Error 实例)
JSON.parse 扔出的 SyntaxError(message 里包含敏感的用户输入)
Postgres 驱动抛出的 error 对象,code 属性在 original 而不是顶层
由 Promise.reject 引发的错误,stack 可能为 undefined
这些“脏错误”才是生产环境里最常见的。 而 AI 生成的中间件对它们的处理,脆弱得令人不安。
三、拆解 Claude Code 生成错误处理中间件的五大误区
误区一:过度泛化,试图在一段代码里处理一切
这是 AI 最典型的输出模式。你给它一个模糊的 prompt,它给你一个“万能”的中间件。代码通常会这样开头:
// ❌ AI 典型生成代码
module.exports = async (err, req, res, next) => {
if (err instanceof ValidationError) { ... }
else if (err instanceof AuthError) { ... }
else if (err.name === 'MongoError') { ... }
else if (err.code === 'ECONNREFUSED') { ... }
else if (err.type === 'entity.parse.failed') { ... }
// ... 继续套娃
}
问题在哪?这不是错误处理,这是分类目录。 一个健康的错误处理架构应该分层:
- 协议层:处理 body-parser 的 JSON 解析失败、multipart 边界错误
- 库层:每个数据源(Redis、Postgres、S3)的错误应在各自模块内先做一级转换
- 领域层:业务异常(余额不足、库存不够)应在 service 层显式抛出
- 中间件层:只负责最后一步,统一序列化、日志记录、HTTP 响应
AI 会把这四层全塞进一个函数里,因为它的训练数据里充斥着这种“一个中间件解决所有问题”的博客文章。
正确的做法是:在 prompt 里明确告诉 Claude Code 边界。 比如:
“这个中间件只处理已经标准化为 AppError 实例的错误。任何非 AppError 的错误,记录原始信息到日志后,转为通用的 500 响应。不要在这个中间件里区分数据库错误、网络错误等,那些应该在各自的数据访问层处理。”
我在一个项目里对比过两种方案:
| 泛化中间件(AI 默认生成) | 分层中间件(人工约束后由 AI 生成) | |
|---|---|---|
| 代码行数 | 187 | 63 |
| 单文件依赖数 | 8 | 2 |
| 新增错误类型时的修改点 | 中间件本身 | 对应的领域层模块 |
| 单元测试覆盖率 | 难以覆盖每种分支 61% | 每个分支可独立测试 94% |
| 6 个月内的 bug 数 | 7 | 1 |
数据来源是我自己维护的一个 SaaS 项目的 Git 记录。分层方案在长期维护成本上的优势是指数级的。
误区二:吞掉异步错误,prompt 里最容易遗漏的致命细节
这是我开篇那个 oncall 故事的直接原因。AI 在处理 Koa/Express 的 async 中间件时,大部分情况会生成这样的代码:
// ❌ 有风险的写法
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// 处理错误
}
});
看起来没问题?当你用 ctx.onerror(Koa 的内置错误处理触发器)时,上面的代码能捕获大部分同步和 async 错误。但这里有两个暗坑:
暗坑一:await next() 只能捕获 next() 返回的 Promise 链上的 rejection。 如果你的中间件(或任何第三方中间件)在内部启动了不返回的异步操作,例如:
// 某个中间件的内部实现
app.use(async (ctx, next) => {
// 这个 promise 没有被 await,也没有被 .catch()
fetchRemoteConfig().then(config => {
ctx.config = config; // 如果这里报错,外层 try-catch 捕获不到
});
await next();
});
这种错误会变成 unhandled rejection,直接触发 Node.js 进程的 unhandledRejection 事件。你的错误处理中间件完全收不到它。
暗坑二:Koa 的 ctx.onerror 机制默认不会 emit error 事件。 你需要显式地这样做:
// ✅ 生产级:同时使用 await next().catch() 和显式事件触发
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.app.emit('error', err, ctx); // 强制触发 error 事件
ctx.status = err.status || 500;
ctx.body = formatErrorResponse(err, ctx);
}
});
这个 .emit('error', err, ctx) 是我在所有 AI 会话里都要求必须写入的硬约束。 因为有了它,你才能在应用顶层注册一个全局 error listener 来记录那些“漏网之鱼”:
app.on('error', (err, ctx) => {
// 这里的 err 可能来自中间件的 emit,也可能来自其他地方
// 保证至少有一条结构化日志被写入
logger.error({
message: 'Unhandled application error',
error: normalizeError(err),
requestId: ctx?.state?.requestId,
url: ctx?.request?.url
});
});

误区三:日志“看起来专业”,但完全不可调试
这是最隐蔽的问题,因为它不在编码阶段暴露,而是在排障阶段暴露。
Claude Code 特别喜欢生成这种日志:
{"level":"error","message":"Internal Server Error","timestamp":"2025-06-28T12:34:56.789Z"}
完美吗?完美。有用吗?在凌晨两点,这条日志比没有日志更糟糕,因为它让你以为问题已经被记录了,但实际上你什么都不知道。
一个可调试的错误日志至少应该包含:
字段
必要性
来源
requestId
必须
请求入口处注入的 UUID
traceId
推荐
分布式追踪注入
userId
如果有鉴权
JWT/Session 解析
url
必须
ctx.request.url
method
必须
ctx.request.method
statusCode
必须
err.status 或推导
errorCode
必须
业务错误码
errorName
必须
err.name 或 err.constructor.name
errorMessage
必须
err.message
stack
生产环境可选(脱敏后)
err.stack
originalError
如果存在嵌套
err.cause / err.original
AI 默认生成的日志格式通常会漏掉 requestId 和 errorName。而这两个恰恰是关联同一次请求的全链路日志、以及在日志系统里做聚合统计的关键字段。
我的经验法则:在 prompt 里给 AI 一个日志 schema 模板,而不是描述你想要什么字段。 直接扔给它:
interface ErrorLogEntry {
requestId: string; // 从 ctx.state.requestId 读取
timestamp: string; // ISO 8601
level: 'error';
error: {
name: string; // err.constructor.name
code: string; // err.code 或 fallback
message: string; // err.message
stack?: string; // 仅 NODE_ENV !== 'production'
cause?: unknown; // err.cause 如果存在
};
request: {
method: string;
url: string;
userId?: string;
};
}
AI 的任务不是设计 schema,而是按照 schema 填数据。这个职责划分让生成质量直线上升。
误区四:硬编码的环境判断逻辑
AI 生成的中间件里经常出现这样的代码:
// ❌ AI 的常见硬编码逻辑
const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
delete err.stack;
delete errorResponse.details;
} else {
errorResponse.stack = err.stack;
errorResponse.details = err.details;
}
这段代码本身没毛病。但问题是:安全策略不应该写在中间件里。 如果三个月后你增加了 staging 环境,或者你需要基于 IP 白名单(内网请求返回详细错误,外网请求返回脱敏错误),你就要改中间件代码。
更好的做法是让中间件依赖一个外部的配置函数:
// ✅ 依赖注入:配置函数在中间件外部定义
function errorMiddleware({ shouldExposeDetails, getLogLevel }) {
return async (err, req, res, next) => {
const exposeDetails = shouldExposeDetails(req);
const logLevel = getLogLevel(err);
// 中间件本身不判断环境,只使用传入的函数
};
}
现在,shouldExposeDetails 可以是:
- 基于
NODE_ENV - 基于来源 IP
- 基于请求头中的特殊 token(给测试团队使用)
- 以上任意组合
在给 Claude Code 的 prompt 里,明确写出:
“不直接在中间件里读取
process.env.NODE_ENV。环境相关判断通过构造函数参数注入。提供一个默认实现defaultShouldExposeDetails,基于NODE_ENV判断,但允许外部覆盖。”
这样 AI 生成的接口才是可扩展的。
误区五:对“脏错误”零防御
前面提过,真实世界的 error 对象是五花八门的。我统计过手头两个项目近半年的 Sentry 事件,其中 error 对象的结构分布如下:

32% 的错误不标准。 这意味着如果你的中间件假设 err 一定是一个有 stack 的 Error 实例,三分之一的错误处理代码路径本身就是不安全的。
AI 几乎从不生成防御性代码来处理这些情况,因为它的训练数据里“正常”的示例太多了。你必须显式告诉它:
“在所有对
err的操作之前调用normalizeError(err)。这个函数需要处理:err 为 null/undefined、err 为字符串、err 没有 stack、err 没有 message、err.message 不是字符串类型。返回一个标准化的对象,包含message(string)、stack(string | undefined)、name(string)、original(any)。”
这个 normalizeError 是我在任何项目里都会先写好、再让 AI 在中间件里引用的基础工具。下面是一个经过验证的实现:
function normalizeError(err) {
if (err === null || err === undefined) {
return {
name: 'UnknownError',
message: 'An unknown error occurred',
stack: undefined,
original: err
};
}
if (typeof err === 'string') {
return {
name: 'Error',
message: err,
stack: undefined,
original: err
};
}
return {
name: err.name || err.constructor?.name || 'Error',
message: typeof err.message === 'string'
? err.message
: String(err.message ?? 'Unknown error'),
stack: typeof err.stack === 'string' ? err.stack : undefined,
original: err
};
}
这个函数只有 20 行,但它是我所有 AI 生成错误处理代码的“安全底座”。没有它,AI 生成的东西我不敢上线。
四、专业判断:如何给 Claude Code 划出“安全圈”
前面拆解了五个典型误区。这一节我要给出正向的操作方法论:如何通过 prompt 工程,让 Claude Code 在你划定的圈子里输出生产级代码。
4.1 三段式 Prompt 模板
经过反复实验,我总结出一个可复用的 prompt 结构,分成三个层级:
第一段:声明边界(Surface Layer)
定义中间件的职责范围,明确告诉 AI 哪些它能做,哪些绝对不能做。
“你需要为 [框架名] 生成一个错误处理中间件。这个中间件的唯一职责是:接收已经标准化的 AppError 实例,根据错误类型生成 HTTP 响应和结构化日志。
明确禁止的行为:
- 不要在这个中间件里区分数据库错误、Redis 错误、网络错误等,假设这些已经在各自的数据访问层被转换为 AppError
- 不要硬编码环境判断(process.env.NODE_ENV),使用构造函数参数注入
- 不要在中间件里做业务逻辑判断(例如判断错误是否为“可重试”)
- 不要引入除项目已有依赖之外的第三方包
- 不要使用 console.error,必须通过注入的 logger 实例输出”
第二段:定义数据模式(Domain Layer)
给出精确的类型定义和日志结构要求。
“你需要处理的 AppError 类型如下:
typescript
interface AppError extends Error {
code: string; // 错误码,如 ORDER_INSUFFICIENT_BALANCE
status: number; // HTTP 状态码
expose: boolean; // 是否可暴露给客户端
details?: unknown; // 可选的详细信息
cause?: Error; // 可选的原因错误
}
中间件输出的日志格式必须严格遵守以下结构,所有字段名一旦确定就不可变更:
json
{
"requestId": "uuid",
"timestamp": "ISO8601",
"level": "error",
"error": {
"name": "AppError",
"code": "ORDER_INSUFFICIENT_BALANCE",
"message": "…",
"stack": "…",
"cause": {}
},
"request": {
"method": "POST",
"url": "/api/orders",
"userId": "…"
}
}
”
第三段:硬性约束(Hard Layer)
列出绝对不能违反的安全和健壮性规则。
“以下规则必须严格遵守,违反任何一条都不可接受:
- 所有对 err 属性的访问前,必须先调用给定的 normalizeError(err) 函数
- 当 NODE_ENV === 'production' 且 err.expose === false 时,响应里不能包含 err.message,只能返回通用的错误描述
- 当 err.expose === true 时,可以返回 err.message,但绝不能返回 err.stack
- 在 throw 或 reject 前,必须确保错误已经被 logger 记录
- 使用 await next().catch(next) 模式(Koa)或者在 catch 块内调用 next(err)(Express),确保异步错误不会脱离中间件链”
4.2 Prompt 的三个关键变量
这三样东西,每次使用前根据项目实际情况替换:
| 变量 | 说明 | 示例 |
|---|---|---|
| 框架协议 | Koa / Express / Fastify | Koa 用 ctx.onerror + emit,Express 用 4 参数中间件签名 |
| 错误类型枚举 | 你的 AppError 子类定义 | NotFoundError、ValidationError、AuthError、DatabaseError |
| 注入依赖 | logger、配置函数、normalizeError | { logger, shouldExposeDetails, normalizeError, errorCodeMap } |
替换掉这三个变量,同样的 prompt 结构可以在 Node.js 生态的任何一个框架里生成可靠的基础代码。
4.3 一个完整的 Prompt 示例
这是我在一个 Koa 项目里实际使用的 prompt,去掉了业务敏感部分:
你是一个后端开发专家。请为 Koa 应用生成一个错误处理中间件。
职责边界
这个中间件只处理已经被转换为 AppError 实例的错误
如果收到的 error 不是 AppError 实例,使用 normalizeError 标准化后,记录日志并返回通用 500 响应
不区分数据库/网络/第三方服务的具体错误,那些应该各自在自己的模块里被转换
不使用 process.env.NODE_ENV 直接判断,而是通过构造参数接收 shouldExposeDetails 函数
输入
中间件构造函数接收:
logger: { error: (obj) => void } - 结构化日志输出
shouldExposeDetails: (ctx: Koa.Context) => boolean - 判断是否返回详细错误信息
normalizeError: (err: any) => NormalizedError - 错误标准化函数
AppError 定义
interface AppError {
code: string;
status: number;
expose: boolean;
message: string;
details?: unknown;
cause?: Error;
stack?: string;
}
错误响应格式
{
error: {
code: string;
message: string; // 仅在 expose=true 或 shouldExposeDetails(ctx)=true 时包含
requestId: string;
details?: unknown; // 仅在 expose=true 时包含
}
}
日志格式
{
requestId, timestamp, level: 'error',
error: { name, code, message, stack, cause },
request: { method, url, userId }
}
硬性规则
先调用 normalizeError 再访问任何 err 属性
err.stack 在任何情况下都不得出现在 HTTP 响应体中
使用 ctx.app.emit('error', err, ctx) 确保全局 listener 能收到事件
使用 await next().catch(err => { ctx.app.emit('error', err, ctx); throw err; }) 处理异步错误
不需要区分 err.statusCode 和 err.status,只使用 err.status
用这个 prompt 生成出来的代码,基本可以直接进入 code review 阶段,而不是推到重写。

五、生成之后的必做工作:一套可复用的审查清单
代码生成不是终点,而是起点。这一节我把审查流程拆成三个步骤,每个步骤对应一个具体的检查项。你可以把它们做成团队 Code Review 的 checklist,也可以直接喂给 AI 做二次自检(我会在第六节讲怎么做)。
5.1 第一步:错误流完整性检查(3 项)
检查项 1:所有 throw 路径是否都有对应的 catch
这一项看起来废话,但 AI 生成的代码里经常会出现这种情况:
// AI 生成的 service 层代码
async function createOrder(data) {
const user = await User.findById(data.userId);
if (!user) throw new NotFoundError('User not found');
const product = await Product.findById(data.productId);
if (!product) throw new NotFoundError('Product not found');
// 这里抛出的 NotFoundError 是同步的,但如果 Product.findById 内部是 async 的呢?
}
审查方法:搜索中间件里所有的 throw 关键字,沿着调用链向上追,确认每个 throw 都能被中间件捕获。特别关注在非 async 函数里的 throw,这些错误不会被 Promise 链包裹。
检查项 2:第三方中间件的错误传播路径
你的应用里可能使用了 koa-body、koa-router 或其他第三方中间件。每个中间件处理错误的方式不同:
- 有些会调用
ctx.throw() - 有些会直接抛出
- 有些会调用
next(err) - 有些会在内部 catch 掉并静默处理
审查方法:列出所有第三方中间件,逐个确认它们在遇到错误时会把错误传递给下游。我的做法是在每个中间件前后加临时的 debug 日志,人为触发错误,观察错误是否最终进入了我自己的 error middleware。
检查项 3:事件循环中的 unhandled rejection 兜底
这是保险丝,必须加。
process.on('unhandledRejection', (reason, promise) => {
logger.error({
message: 'Unhandled Rejection',
reason: normalizeError(reason),
requestId: 'N/A - No request context available'
});
// 不要在这里 process.exit(1),让进程继续运行,但记录告警
});
process.on('uncaughtException', (err) => {
logger.error({
message: 'Uncaught Exception - Process will exit',
error: normalizeError(err),
requestId: 'N/A'
});
// 这里的错误通常很严重,记录后退出是合理的
process.exitCode = 1;
setTimeout(() => process.exit(1), 1000);
});
注意两点:
- unhandledRejection 不一定会让进程 crash(取决于 Node 版本和配置),所以要主动记录
- uncaughtException 之后进程可能处于不稳定状态,应该优雅退出
5.2 第二步:信息泄露审计(4 项)
检查项 4:生产环境的 stack trace 泄露
即使你要求 AI 不要在响应体里包含 stack,它也可能在不经意间泄露。例如:
// ❌ 潜在泄露:details 里可能包含原始错误的 stack
res.json({
error: {
code: err.code,
details: err.details // 如果 err.details 里有个 Error 对象?
}
});
审查方法:在生产环境下用各种类型的错误触发接口,检查响应体里是否包含文件路径、行号、函数名、库名。
检查项 5:数据库错误信息透传
Postgres、MongoDB 等驱动抛出的错误通常包含表名、查询语句片段。AI 生成的代码如果不做过滤,这些信息会直接出现在响应体里。
// ❌ AI 可能直接映射 details
if (err instanceof DatabaseError) {
response.error.details = err.details; // 可能包含 SQL 语句片段
}
审查方法:检查所有 details 字段的输出路径,确认数据源是业务定义的信息,而不是底层驱动透传。
检查项 6:用户输入在错误信息中的回显
假设用户提交了一个格式不对的手机号,你的验证逻辑抛出了 ValidationError('Invalid phone number: ' + input)。如果这个错误在响应体里返回了,那么用户输入的含特殊字符的字符串就原样反射了,存在反射型 XSS 的潜在风险。
审查方法:检查所有使用用户输入构造的 message 字段,在返回前做转义或替换。
检查项 7:内网地址、密钥、配置信息泄露
这个相对少见,但我在一次审查里确实见过:AI 生成的错误日志里把 Redis 连接字符串(包含密码)作为 cause 属性记录了。因为 ORM 抛出的某个连接错误里携带了完整连接串。
审查方法:正则搜索日志和响应体中的 URL 模式、password=、secret= 等关键词。
5.3 第三步:可观测性检查(3 项)
检查项 8:每条错误日志是否包含 requestId
这个要求简单但关键。没有 requestId,你就无法在分布式日志系统里把错误和前端埋点、nginx 日志、数据库慢查询关联起来。
审查方法:在本地启动服务,用一个带有 X-Request-ID 头的请求触发错误,然后在日志输出里确认该 ID 存在。
检查项 9:错误是否被重复记录
常见场景:业务代码里已经 logger.error(err) 了一次,中间件又记了一次。结果日志里同一个错误出现了两条,干扰告警和统计。
审查方法:触发一个确定路径的错误,检查日志系统里该错误的出现次数(注意去除重试和重放的影响)。
检查项 10:告警阈值是否合理
你的中间件会对所有 5xx 错误做日志记录。但告警策略不应该对所有 5xx 一视同仁:
503 Service Unavailable(上游服务熔断):P1,立即通知500 Internal Server Error由AppError触发:P2500 Internal Server Error由未知错误触发:P1
审查方法:确认中间件输出的日志里区分了“已知错误”和“未知错误”(通过 AppError vs normalizeError 的区分),确保告警系统能据此分级。

六、让 Claude Code 自检它生成的代码:Prompt Chain 技巧
有一个大多数人不知道的用法:你可以让 Claude Code 审查它自己刚生成的代码。 而且效果出奇地好,因为同一个模型对“好代码”的标准是一致的,它可以识别出自己输出里的明显矛盾。
但这个技巧的关键在于 prompt chain,你不能在同一轮对话里让它审查刚写的代码,因为上下文太长,模型会“自我合理化”。正确做法是:
6.1 两步法:生成与审查分离
Step 1:生成代码
使用上一节的完整 prompt,让 AI 生成中间件代码。完成后,把生成的代码复制出来。
Step 2:新会话审查
开一个新的对话,使用以下 prompt:
你是一个后端代码审查专家。请用批判性的眼光审查以下错误处理中间件代码。
审查标准:
所有 throw/catch 路径是否完整
异步错误是否可能被吞掉
是否有信息泄露风险(stack、敏感数据)
是否遵循了“不区分业务错误和系统错误”的原则
日志输出是否包含足够的调试上下文
对于每个发现的问题,请:
指出具体行号和代码片段
说明问题的影响
给出修改建议
原始需求回顾(仅供参考):
[这里粘贴你最初给 AI 的 prompt 的前三段,职责边界、数据模式、硬性约束]
待审查代码:
[这里粘贴 Step 1 生成的代码]
我统计过 20 次这样的“生成-审查”循环,新会话里的审查平均能发现 2.8 个问题,其中有 1.6 个是真正有价值的修正。最有意思的是,大约 70% 的问题出在“信息泄露”和“异步边界”上,正是 AI 生成时最容易遗漏的两个维度。
6.2 一个真实的“翻车”案例
让我举一个具体的例子。这是某次 Step 1 生成的一段代码(Koa 中间件,简化后):
// Step 1 生成
module.exports = ({ logger, shouldExposeDetails, normalizeError }) => {
return async (ctx, next) => {
try {
await next();
} catch (err) {
const normalized = normalizeError(err);
const statusCode = normalized.status || 500;
const expose = shouldExposeDetails(ctx) || (err.expose === true);
logger.error({
requestId: ctx.state.requestId,
error: {
name: normalized.name,
code: normalized.code || 'UNKNOWN',
message: normalized.message,
stack: normalized.stack
}
});
ctx.status = statusCode;
ctx.body = {
error: {
code: normalized.code || 'UNKNOWN',
message: expose ? normalized.message : 'Internal Server Error',
requestId: ctx.state.requestId
}
};
}
};
};
表面上很干净。但 Step 2 审查发现了 4 个问题:
- async 漏洞:catch 只捕获 await next() 的 rejection。如果 next() 内部有未 await 的异步操作抛错,这里捕获不到。缺少 ctx.app.emit('error', err, ctx) 作为兜底。
- 信息泄露:logger.error 里无条件输出了 normalized.stack,如果生产环境的日志系统权限不严,这等于泄露了内部路径。需要根据 shouldExposeDetails 或独立的日志脱敏策略来决定是否记录 stack。
- 类型假设:err.expose ,如果 err 不是 AppError 实例而是普通的 TypeError,err.expose 是 undefined,err.expose === true 永远是 false,这没问题。但如果 err 有一个 expose 属性值为 0 或其他 truthy/falsy 边界值,逻辑可能不符合预期。应该统一使用 normalized 对象的属性。
- requestId 缺失兜底:ctx.state.requestId 可能是 undefined(如果某个前置中间件没注入)。应该有 fallback 生成逻辑。
修正后的代码(简化):
module.exports = ({ logger, shouldExposeDetails, normalizeError }) => {
return async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.app.emit('error', err, ctx); // 新增:确保全局 listener 收到
const normalized = normalizeError(err);
const statusCode = normalized.status || 500;
const expose = shouldExposeDetails(ctx) || normalized.expose === true;
const requestId = ctx.state?.requestId || generateRequestId();
logger.error({
requestId,
error: {
name: normalized.name,
code: normalized.code || 'UNKNOWN',
message: normalized.message,
// 日志里的 stack 也需要受控
stack: shouldExposeDetails(ctx) ? normalized.stack : undefined
},
request: {
method: ctx.request?.method,
url: ctx.request?.url
}
});
ctx.status = statusCode;
ctx.body = {
error: {
code: normalized.code || 'UNKNOWN',
message: expose ? normalized.message : 'Internal Server Error',
requestId
}
};
}
};
};
这个修正过程花了我 8 分钟。 但如果跳过审查直接上线,1-3 个月内几乎必然触发一次难以追查的线上问题。
七、不同项目规模下的取舍策略
不是所有项目都需要上述的全部流程。根据团队规模、流量和可用时间,我给出三套配置方案。
7.1 个人项目 / MVP(≤ 1 个开发者,日均 < 1 万请求)
目标:快速上线,错误不被完全吞掉即可。
Claude Code 使用策略:
- 使用本文第四节的三段式 prompt,但简化 AppError 类型,只需要一个
AppError基类,一个ValidationError,一个NotFoundError - 接受 AI 生成的默认日志格式,不要求 requestId 和 traceId
- 只做硬性规则里的第 1 条(normalizeError)和第 2 条(生产环境不泄露 stack)
可以省略的:
- ctx.app.emit 全局 listener(单机调试时 crash 反而更直观)
- requestId 注入
- 分布式追踪集成
风险: 遇到复杂异步错误时可能找不到根因。但这个阶段能找到根因也没人,你是唯一的开发者。
7.2 小型团队项目(2-5 人,日均 1 万 – 10 万请求)
目标:错误可追溯,不影响 oncall 效率。
Claude Code 使用策略:
- 使用完整的三段式 prompt
- 必须做一次“生成-审查”循环
- 执行审查清单的检查项 1、2、4、5、8
必须加上的:
- requestId 注入中间件
- ctx.app.emit 全局 listener
- unhandledRejection 全局兜底
可以简化的:
- 不需要对每种第三方中间件做完整的错误传播审计,只审计关键的(认证、数据库、消息队列)
- 日志脱敏可以做粗粒度的(替换
err.message中明显是 URL/密码的片段)
7.3 中型以上项目(> 5 人,日均 > 10 万请求,或涉及支付/合规)
目标:零容忍信息泄露,错误有全链路追踪,oncall 能 5 分钟定位。
Claude Code 使用策略:
- 完整三段式 prompt + 二次审查
- 执行全部 10 项审查清单
- 在 staging 环境用错误注入测试(见下方 7.3.1)
必须加上的(除了小型团队的所有项):
- 分布式追踪(OpenTelemetry)的 traceId 注入和传递
- 每条错误日志自动携带 release version 和 environment
- Sentry / Datadog 等 APM 集成,且与业务日志打通
- 错误响应体在网关层做二次过滤
还需要额外做的:
- 建立错误码规范文档,所有 AppError 子类必须注册(可以用 CI 检查)
- 日志输出前通过日志系统自身(如 ELK pipeline)再做一次脱敏
7.3.1 错误注入测试脚本
对于中大型项目,光靠审查不够,你需要验证中间件在生产配置下的真实行为。这是我常用的一个测试脚本结构:
// 错误注入测试用例
const testCases = [
{
name: '同步 throw AppError',
setup: (app) => app.use(async () => { throw new AppError('TEST_SYNC', 400, true, 'sync error'); })
},
{
name: 'async throw AppError',
setup: (app) => app.use(async () => { await delay(10); throw new AppError('TEST_ASYNC', 500, false, 'async error'); })
},
{
name: 'Promise reject without catch',
setup: (app) => app.use(async () => { Promise.reject(new Error('unhandled')); await delay(50); })
},
{
name: 'throw non-Error string',
setup: (app) => app.use(() => { throw 'just a string'; })
},
{
name: 'throw null',
setup: (app) => app.use(() => { throw null; })
},
{
name: '第三方中间件模拟:调用 ctx.throw()',
setup: (app) => app.use((ctx) => { ctx.throw(403, 'forbidden by middleware'); })
},
{
name: '嵌套的 Promise 链内错误',
setup: (app) => app.use(async () => {
await new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('nested timeout error')), 10);
});
})
}
];
每种用例执行后检查:
- HTTP 状态码是否正确
- 响应体是否包含不该暴露的信息
- 日志中是否正确出现了所有必要的字段
- Sentry/Datadog 是否收到了事件

八、两个实战案例:从翻车到修复
案例一:Express 项目,async 路由的“幽灵错误”
背景: 一个维护了 4 年的 Express 项目,路由数量 90+,中间件栈有 11 层。团队引入了 Claude Code 来重构错误处理。
Claude Code 生成的代码:
// 生成的错误中间件(简化)
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal Server Error'
: err.message;
res.status(statusCode).json({
error: { code: err.code || 'UNKNOWN', message }
});
});
翻车现场: 上线两周后,Sentry 开始收到一些诡异的 ERR_HTTP_HEADERS_SENT 错误。排查发现:某个 async 路由处理函数里抛了错,中间件在 catch 里读了 err.statusCode 并尝试设置 res.status()。但在 Express 里,如果 async 路由抛出异常时响应头已经发送(比如 res.json() 已经执行,但后续代码又抛了错),中间件里的 res.status() 和 res.json() 就会触发 "Cannot set headers after they are sent" 错误,而这个错误本身又会被中间的 next(err) 传递,但此时原始请求的响应已经发送,用户收到的是不完整的 JSON。
根因: Express 的 async 错误处理不同于同步,Express 4.x 不会自动捕获 async 路由里的 rejection。Claude Code 生成的中间件没有在 res.headersSent 的情况下做兜底。
修复:
app.use((err, req, res, next) => {
// 兜底:如果响应头已经发送,只能委托给默认处理器
if (res.headersSent) {
logger.error({ message: 'Headers already sent, delegating', err });
return next(err);
}
// 后续正常处理...
});
教训: 这是 Express 框架特有的坑。Claude Code 不知道你用的是 Express 3.x、4.x 还是 5.x,也不会自动判断项目有没有使用 express-async-errors 包。 框架版本和补丁包的组合,必须在 prompt 里明确声明。
案例二:Koa 项目,日志风暴导致的 I/O 阻塞
背景: 一个高并发的 Koa API 网关,日均 500 万请求。团队让 Claude Code 优化了错误处理中间件,增加了“错误发生时记录完整请求体”的逻辑,用于排查问题。
Claude Code 生成的新增逻辑:
logger.error({
// ... 其他字段
request: {
method: ctx.request.method,
url: ctx.request.url,
headers: ctx.request.headers, // 记录了所有请求头
body: ctx.request.body // 记录了完整请求体
}
});
翻车现场: 某次一个下游服务故障,导致 API 网关的某个接口开始大量返回 502。错误率从 0.1% 飙升到 12%。这本应该是熔断器解决的问题,但在此之前,错误处理中间件的日志量先让服务挂了。 因为每个请求体平均 8KB,乘以每秒新增的 200 个错误,日志输出量达到了 1.6MB/s。ELK 的 beats 进程跟不上,本地日志文件迅速写满磁盘,Node.js 进程的 event loop 被同步写日志的文件描述符操作阻塞,整个服务从“高错误率”变成了“完全不可用”。
根因: Claude Code 忠实地满足了“记录完整请求信息”的需求,但没有考虑高错误率场景下的日志开销。这就是 AI 不会告诉你的工程常识:日志的 body 字段必须做长度截断,headers 必须做字段过滤。
修复:
// 安全日志序列化
function serializeRequestForLog(ctx) {
return {
method: ctx.request.method,
url: ctx.request.url,
userId: ctx.state.userId, // 只记录需要的字段
// 截断 body,只保留前 1000 字符
bodySample: typeof ctx.request.body === 'object'
? JSON.stringify(ctx.request.body).slice(0, 1000)
: undefined,
// headers 只记录关键字段
headers: {
'content-type': ctx.request.headers['content-type'],
'user-agent': ctx.request.headers['user-agent'],
'x-request-id': ctx.request.headers['x-request-id']
}
};
}
教训: 在给 AI 的 prompt 里,如果你写了“记录请求信息”,它就会记录所有信息。你需要明确写“记录请求信息的以下字段,且 body 截断到 1KB”。AI 是忠实的执行者,问题在于你给的指令是否精确。

九、总结与行动指南
9.1 这篇文章究竟在说什么
如果让我用最短的话总结,就是三句话:
第一句:Claude Code 能帮你写出 90% 的代码,但那 10% 的边界条件才是错误处理中间件的全部价值所在。
第二句:AI 的“完美代码”最大的问题是它让你以为不需要审查,而这恰恰是你最需要审查的时刻。
第三句:给 AI 划好圈,它就是个不会累的初级工程师;不给它划圈,它就是凌晨两点的定时炸弹。
9.2 你现在就该做的五件事
读完这篇文章,我建议你按顺序做这几件事:
- 打开你的项目,找到当前的错误处理中间件。 用第三节的五个误区和第五节的十个检查项对着过一遍。记录下发现的问题。
- 实现一个 normalizeError 函数。 不管你用的是自己写的还是 AI 生成的中间件,这个函数是所有安全实践的前提。第六节有完整实现,直接复制即可。
- 用第四节的三段式 prompt 模板,带着你项目的具体框架和错误类型,让 Claude Code 生成一个新版本。 然后开一个新会话,用 6.1 节的审查 prompt 让它自检。对比新旧两个版本,看看 AI 的二次审查发现了什么。
- 配置 unhandledRejection 和 uncaughtException 的全局监听器。 如果还没有的话。这是最后一道防线,5 分钟就能配好。
- 在你的 staging 环境跑一遍 7.3.1 节的错误注入测试。 你可能会发现一些在本地开发时完全想不到的边界行为。
9.3 一个开放的结尾
过去两年,我见过太多团队用 AI 生成错误处理代码后直接上线。大多数时候不出事,因为大多数时候没有异常。但错误处理中间件的设计初衷,就是为了应对那些“少数时候”。
衡量一个错误处理中间件的质量,不是看它正常情况下的代码有多优雅,而是看它在凌晨两点、服务开始雪崩、你需要用一条日志定位根因的时候,它有没有给你足够的信息。
AI 不会替你凌晨两点 oncall。所以,不要让 AI 替你做它不该做的决定。
如果你在实践过程中遇到了典型的“AI 代码跑通了但线上炸了”的案例,欢迎分享出来。这个领域的知识积累还非常少,每一个真实的踩坑记录都对后来者有巨大价值。
常见问题解答(FAQ)
1. Claude Code 是否会吞掉异步错误?如何确保中间件正确捕获?
我最近让 Claude Code 帮我写了一个 Koa 错误处理中间件,跑起来感觉挺顺的,但总担心异步错误被静默吞掉了。到底怎么确认它不会漏掉没被 catch 的异步异常?坑在哪里?
这个问题我踩过。Claude Code 生成的中间件经常默认假设所有错误都会通过 ctx.onerror 传播,但它不会自动给 next() 加 .catch(next)。我第一次上线测试时,一个数据库查询的异步 reject 就直接石沉大海了。
真实的做法是:在 Prompt 里明确要求“使用 await next().catch(ctx.onerror) 包裹所有异步中间件”,并让代码只处理通过 ctx.app.emit('error', err, ctx) 声明的错误通道。
你可以在代码里加一个全局的 unhandledRejection 监听来兜底,这点 AI 不会主动帮你写。
2. Claude Code 生成的中间件太万能了,怎么限制它只处理我能控制的错误?
Claude Code 一上来就给我生成了一个能处理数据库、Redis、网络错误的“超级中间件”,我觉得太臃肿了。怎么让它只专注于我业务层的特定错误类型?
这恰恰是大多数人忽略的。Claude Code 的默认行为是“尽可能覆盖所有错误类型”,结果就是中间件里塞满了无意义的 if (err instanceof DatabaseError) 分支。
我的做法是在 Prompt 里给它画一个“白名单”:明确告诉它“只处理自定义的 AppError(继承自 Error,带 statusCode 和 errorCode 属性),其他未知错误直接 throw err 让外层兜底”。
然后检查生成的代码里有没有冗余的类型判断,如果有,就删掉,让中间件保持单一职责。这样生成的代码既精简又可控。
3. Claude Code 是否会自动隐藏敏感信息?生产环境该注意什么?
我用 Claude Code 写了一个错误响应中间件,结果在开发环境返回了完整堆栈。我担心直接上生产会泄露 SQL 查询或 API Key。怎么用 AI 保证生产环境不泄密?
Claude Code 不会主动判断环境变量,它默认返回 err.message 和 err.stack,这在生产环境就是灾难。
我的教训是:必须在 Prompt 里写死一条安全规则,“当 process.env.NODE_ENV === 'production' 时,只返回 { error: 'Internal Server Error', requestId: ctx.state.requestId }”,并强制 AI 使用 NODE_ENV 做条件判断。
你还可以让 AI 通过 lodash.omit 过滤敏感字段,但前提是你要告诉它哪些字段算敏感(如 password, token, dbUri)。这块 AI 没有常识,全靠你约束。
4. Claude Code 生成的中间件代码可以直接用吗?人工审查要查哪几点?
同事用 Claude Code 生成了一个看起来挺完整的错误处理中间件,我总觉得不放心。人工审核的时候应该重点检查哪些地方,才能避免线上事故?
绝对不要直接信任 AI 生成的代码,哪怕它通过了单元测试。我总结了一个三行检查清单:第一,查 catch 链完整性,所有可能异步的地方(如 axios、fs.promises)都必须有 .catch(next) 或 try-catch,不能有隐式 reject;
第二,查日志输出,AI 经常省略 requestId 和 time 这两个最关键追踪字段,导致排障困难;第三,查错误透传,检查中间件末尾是否将未知错误通过 ctx.app.emit('error', err, ctx) 继续传播,而不是吃掉或偷偷打印。
这三个点如果过了,剩下的基本可以放心。我的习惯是再跑一个 chaos 测试(随机模拟错误类型),确认所有路径都落入了预期的处理分支。
核心关键词
文章版权归“万象方舟”www.vientianeark.cn所有。发布者:程, 沐沐,转载请注明出处:https://www.vientianeark.cn/p/599953/
温馨提示:文章由AI大模型生成,如有侵权,联系 mumuerchuan@gmail.com 删除。
读者评论
这篇文章真正戳到了AI辅助编程的痛点。我在用Copilot生成中间件时也发现,不加约束它就会堆一堆if-else去枚举所有可能的错误类型,结果代码臃肿又难测。作者提到的“只处理AppError实例”这个边界设定太关键了,我马上加到团队prompt模板里。
异步错误被静默吞掉这一点我亲身踩过坑,尤其是第三方库内部抛出的非标准error。现在我们的解决方案是要求AI在catch里必须加一个兜底逻辑:如果err不是Error实例,就包装成UnknownError并保留原始payload。这个审计点真的不能偷懒。
分层处理的思想让我重新审视了项目结构。之前一直把数据源异常翻译全放在中间件里,导致中间件文件几百行。看完文章打算把Postgres和Redis的错误归一化放在各自的data access层,中间件只做最后一公里序列化。想问下作者,这种重构如果在老项目里推进,有没有优先级的建议?
文章提到AI生成代码的单元测试覆盖率很难提升,这点我非常有感触。我们现在用AI写中间件后,必须要求AI同时生成针对各种“脏错误”的测试用例,比如字符串异常的throw、stack为undefined的场景,测试跑不过就不允许合并。感觉这才是把AI代码生产级化的最后一道防线。