AI API错误码规范

要点

  • AI API 调用链路长,认证失败、参数不合法、模型超时、配额耗尽、上游崩溃,每种错误的处理方式不同
  • 只靠 HTTP 状态码无法区分——一个 400 可能是 Key 过期,也可能是 prompt 超长,前端不知道该跳登录页还是提示用户缩短输入
  • 错误码在 HTTP 状态码之下加一层业务级标识,让前端能根据 code 走不同处理分支
  • 错误码按出错位置分五类:认证(AUTH)、参数(PARAM)、模型(MODEL)、限流(RATE_LIMIT)、系统(SYSTEM)
  • 字符串编码比数字编码可读性好,适合面向开发者的 API
  • 错误码需要文档、国际化、版本管理三件事一起跟上,否则会变成没人敢改的遗留代码

1. 从一个真实的错误响应开始

调用 AI 接口时,前端收到的错误响应通常长这样:

// POST /api/ai/chat 返回 400
{ "error": "Bad Request" }

前端只知道「请求出错了」。但原因可能是 API Key 没传、model 名称不存在、prompt 超出上下文限制、额度用完、模型服务不可用。这五种情况前端的处理方式完全不同。

一个 400 Bad Request 无法区分这些场景。HTTP 状态码描述的是协议层面的结果,不是业务语义。

加上错误码之后,同一个 400 可以拆成不同的业务含义:

// 场景一:API Key 缺失
{ "error": { "code": "AUTH_API_KEY_MISSING", "message": "缺少 API Key" } }
 
// 场景二:模型名称不合法
{ "error": { "code": "PARAM_INVALID_MODEL", "message": "model 参数不合法" } }
 
// 场景三:超过上下文长度,附带结构化数据
{ "error": { "code": "PARAM_PROMPT_TOO_LONG", "message": "prompt 超出上下文限制", "details": { "maxTokens": 128000, "actualTokens": 156000 } } }

前端拿到 error.code 就能直接 switch 走不同分支,不需要解析 message 文本。

2. AI API 的错误分类

按出错位置分五类:

认证错误(AUTH) — 请求到达业务逻辑之前就出了问题:API Key 缺失/过期/被吊销、权限不足、签名校验失败。

参数错误(PARAM) — 请求到了业务层,但参数不合法:必填字段缺失、类型不匹配、值超出范围、prompt 超长、messages 格式错误。

模型错误(MODEL) — 参数没问题,模型调用出了问题:模型不存在或已下线、上游超时、输出触发安全审核、流式响应中途断开。

限流错误(RATE_LIMIT) — 频率或用量超配额:RPM 超限、TPM 超限、月度额度用完、并发数超限。

系统错误(SYSTEM) — 后端自身问题:数据库连接失败、内部服务超时、未捕获异常、配置缺失。

分类确定之后,下一步是设计编码格式。

3. 错误码格式:数字编码 vs 字符串编码

数字编码

{ "error": { "code": 40101, "message": "API Key 缺失" } }

按位分段,第一位表示类别,后四位表示具体错误。1xxxx 是认证,2xxxx 是参数,3xxxx 是模型,4xxxx 是限流,5xxxx 是系统。优点是短,缺点是看到 30002 需要查表才知道含义。

字符串编码

{ "error": { "code": "MODEL_SERVICE_UNAVAILABLE", "message": "模型服务暂时不可用" } }

直接用英文大写命名,前缀表示分类。看到 RATE_LIMIT_TPM_EXCEEDED 就知道是 Token 用量超限,日志排查时也能直接读懂。

怎么选

面向外部的公开 API 用字符串编码更友好,开发者看到错误码就能理解。内部高频微服务调用可以考虑数字编码的体积优势。大多数 Hono 项目选字符串编码更省心。

4. 错误码与 HTTP 状态码的配合

错误码不是替代 HTTP 状态码,而是在它之上补充业务语义。HTTP 状态码告诉客户端和基础设施请求的结果类别,错误码告诉应用层具体是哪种业务错误。

一个 400 下可以挂多种错误码,而不同分类的错误码也可能对应不同的 HTTP 状态码:

// src/errors/error-mapping.ts
// 错误码 -> HTTP 状态码映射
export const codeToHttpStatus: Record<string, number> = {
  // 认证 -> 401/403
  AUTH_API_KEY_MISSING: 401,
  AUTH_API_KEY_INVALID: 401,
  AUTH_API_KEY_EXPIRED: 401,
  AUTH_PERMISSION_DENIED: 403,
  // 参数 -> 400
  PARAM_REQUIRED_FIELD_MISSING: 400,
  PARAM_INVALID_TYPE: 400,
  PARAM_VALUE_OUT_OF_RANGE: 400,
  PARAM_PROMPT_TOO_LONG: 400,
  // 模型 -> 视情况而定
  MODEL_NOT_FOUND: 404,
  MODEL_SERVICE_UNAVAILABLE: 503,
  MODEL_CONTENT_FILTERED: 422,
  // 限流 -> 429
  RATE_LIMIT_RPM_EXCEEDED: 429,
  RATE_LIMIT_TPM_EXCEEDED: 429,
  RATE_LIMIT_QUOTA_EXHAUSTED: 429,
  // 系统 -> 500/504
  SYSTEM_INTERNAL_ERROR: 500,
  SYSTEM_DATABASE_ERROR: 500,
  SYSTEM_UPSTREAM_TIMEOUT: 504,
}

MODEL_NOT_FOUND 对应 404MODEL_SERVICE_UNAVAILABLE 对应 503——同样模型错误,HTTP 状态码不同。限流统一 429,网关和 CDN 能识别并做相应处理。

有了映射表,自定义错误类和统一处理函数可以这样写:

// src/errors/ai-error.ts
import type { ErrorCodes } from './ai-error-codes'
 
export class AIError extends Error {
  constructor(
    public code: keyof typeof ErrorCodes,
    message: string,
    public details?: Record<string, unknown>
  ) {
    super(message)
    this.name = 'AIError'
  }
}
// src/errors/error-handler.ts
import type { Context } from 'hono'
import { AIError } from './ai-error'
import { codeToHttpStatus } from './error-mapping'
 
export function handleError(err: unknown, c: Context) {
  if (err instanceof AIError) {
    const status = codeToHttpStatus[err.code] ?? 500
    return c.json(
      { error: { code: err.code, message: err.message, ...(err.details ? { details: err.details } : {}) } },
      status
    )
  }
  // 未预期的异常:不暴露内部细节
  console.error('[UnhandledError]', err)
  return c.json({ error: { code: 'SYSTEM_INTERNAL_ERROR', message: '服务器内部错误' } }, 500)
}

在 Hono 里用 app.onError() 注册:

// src/index.ts
import { Hono } from 'hono'
import { handleError } from './errors/error-handler'
 
const app = new Hono()
app.onError((err, c) => handleError(err, c))
export default app

5. 在路由里使用错误码

路由里的错误抛出变得很直接,每个 throw new AIError(...) 携带错误码、可读描述和可选的上下文数据:

// src/routes/ai-chat.ts
import { Hono } from 'hono'
import { AIError } from '../errors/ai-error'
 
const chatApp = new Hono()
 
chatApp.post('/chat', async (c) => {
  const body = await c.req.json()
 
  const apiKey = c.req.header('X-API-Key')
  if (!apiKey) {
    throw new AIError('AUTH_API_KEY_MISSING', '缺少 API Key')
  }
  if (!body.model) {
    throw new AIError('PARAM_REQUIRED_FIELD_MISSING', 'model 参数不能为空')
  }
  if (!body.messages || !Array.isArray(body.messages)) {
    throw new AIError('PARAM_INVALID_MESSAGES_FORMAT', 'messages 必须是数组')
  }
 
  // 校验 prompt 长度,附带 details
  const totalLength = body.messages.reduce((sum: number, m: { content: string }) => sum + m.content.length, 0)
  if (totalLength > 128000) {
    throw new AIError('PARAM_PROMPT_TOO_LONG', 'prompt 超出上下文限制', { maxTokens: 128000, actualLength: totalLength })
  }
 
  // 检查配额
  const quota = await checkUserQuota(apiKey)
  if (quota.remaining <= 0) {
    throw new AIError('RATE_LIMIT_QUOTA_EXHAUSTED', '本月额度已用完', { plan: quota.plan, resetAt: quota.resetAt })
  }
 
  try {
    return c.json(await callModel(body.model, body.messages))
  } catch (err) {
    if (err instanceof TimeoutError) {
      throw new AIError('MODEL_SERVICE_UNAVAILABLE', '模型响应超时,请稍后重试')
    }
    throw err
  }
})

6. 错误码文档

错误码需要文档让调用方知道含义和处理方式。实用的做法是用 TypeScript JSDoc 注释作为文档的单一来源:

// src/errors/ai-error-codes.ts
export const ErrorCodes = {
  /** API Key 未在请求头中传入 */
  AUTH_API_KEY_MISSING: 'AUTH_API_KEY_MISSING',
  /** API Key 格式不合法或已被吊销 */
  AUTH_API_KEY_INVALID: 'AUTH_API_KEY_INVALID',
  /** API Key 已过期 */
  AUTH_API_KEY_EXPIRED: 'AUTH_API_KEY_EXPIRED',
  /** 无权调用目标模型或接口 */
  AUTH_PERMISSION_DENIED: 'AUTH_PERMISSION_DENIED',
  /** 必填字段未传入 */
  PARAM_REQUIRED_FIELD_MISSING: 'PARAM_REQUIRED_FIELD_MISSING',
  /** 字段类型与预期不符 */
  PARAM_INVALID_TYPE: 'PARAM_INVALID_TYPE',
  /** 字段值超出合法范围 */
  PARAM_VALUE_OUT_OF_RANGE: 'PARAM_VALUE_OUT_OF_RANGE',
  /** prompt 长度超出模型上下文窗口 */
  PARAM_PROMPT_TOO_LONG: 'PARAM_PROMPT_TOO_LONG',
  /** messages 数组结构不合法 */
  PARAM_INVALID_MESSAGES_FORMAT: 'PARAM_INVALID_MESSAGES_FORMAT',
  /** 请求的模型不存在或已下线 */
  MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
  /** 上游模型服务不可用或超时 */
  MODEL_SERVICE_UNAVAILABLE: 'MODEL_SERVICE_UNAVAILABLE',
  /** 输出内容触发安全审核策略 */
  MODEL_CONTENT_FILTERED: 'MODEL_CONTENT_FILTERED',
  /** 流式响应中途断开 */
  MODEL_STREAM_INTERRUPTED: 'MODEL_STREAM_INTERRUPTED',
  /** 每分钟请求次数超限 */
  RATE_LIMIT_RPM_EXCEEDED: 'RATE_LIMIT_RPM_EXCEEDED',
  /** 每分钟 Token 消耗超限 */
  RATE_LIMIT_TPM_EXCEEDED: 'RATE_LIMIT_TPM_EXCEEDED',
  /** 月度调用额度用完 */
  RATE_LIMIT_QUOTA_EXHAUSTED: 'RATE_LIMIT_QUOTA_EXHAUSTED',
  /** 并发请求数超限 */
  RATE_LIMIT_CONCURRENT_EXCEEDED: 'RATE_LIMIT_CONCURRENT_EXCEEDED',
  /** 未预期的内部异常 */
  SYSTEM_INTERNAL_ERROR: 'SYSTEM_INTERNAL_ERROR',
  /** 数据库连接或查询失败 */
  SYSTEM_DATABASE_ERROR: 'SYSTEM_DATABASE_ERROR',
  /** 上游服务调用超时 */
  SYSTEM_UPSTREAM_TIMEOUT: 'SYSTEM_UPSTREAM_TIMEOUT',
  /** 环境变量或配置项缺失 */
  SYSTEM_CONFIG_MISSING: 'SYSTEM_CONFIG_MISSING',
} as const
 
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]

JSDoc 注释会被编辑器识别,悬停就能看到含义。对外发布的文档按分类整理成表格,每个错误码附带 HTTP 状态码和处理建议:

错误码HTTP含义处理建议
AUTH_API_KEY_MISSING401Key 未传入在请求头中传入 X-API-Key
PARAM_PROMPT_TOO_LONG400prompt 超长缩短输入或换更大上下文的模型
RATE_LIMIT_QUOTA_EXHAUSTED429月度额度用完升级套餐或等待下月重置

7. 错误码国际化

面向多语言用户的 API,message 字段需要做国际化。把翻译放在独立的 i18n 文件里,错误码本身不携带文案:

// src/i18n/error-messages.ts
export const errorMessages: Record<string, Record<string, string>> = {
  zh: {
    AUTH_API_KEY_MISSING: '缺少 API Key,请在请求头中传入 X-API-Key',
    PARAM_PROMPT_TOO_LONG: 'prompt 长度超出模型上下文限制',
    RATE_LIMIT_QUOTA_EXHAUSTED: '本月调用额度已用完',
    MODEL_SERVICE_UNAVAILABLE: '模型服务暂时不可用,请稍后重试',
    // ...
  },
  en: {
    AUTH_API_KEY_MISSING: 'API Key is missing. Please provide it in the X-API-Key header.',
    PARAM_PROMPT_TOO_LONG: 'Prompt length exceeds the model context limit.',
    RATE_LIMIT_QUOTA_EXHAUSTED: 'Monthly quota exhausted.',
    MODEL_SERVICE_UNAVAILABLE: 'Model service is temporarily unavailable.',
    // ...
  },
}

错误处理函数根据 Accept-Language 选择文案:

// src/errors/error-handler.ts
import { errorMessages } from '../i18n/error-messages'
 
function getLocale(c: Context): string {
  const lang = (c.req.header('Accept-Language') ?? 'en').split(',')[0]?.split('-')[0] ?? 'en'
  return lang in errorMessages ? lang : 'en'
}
 
// 在 handleError 里:
const locale = getLocale(c)
const message = errorMessages[locale]?.[err.code] ?? errorMessages['en']?.[err.code] ?? err.message

前端不需要做翻译,后端直接返回对应语言的 message。如果前端想自己做翻译,也可以通过 error.code 匹配前端 i18n 资源。details 字段是结构化数据,不参与翻译。

8. 错误码版本管理

错误码发布给外部调用方后,不能随便删除或修改含义。处理演进需要几条规则:

新增 — 随时可以加,不影响已有调用方。

废弃 — 标记 @deprecated,在下一个大版本前不删除:

// src/errors/ai-error-codes.ts
export const ErrorCodes = {
  RATE_LIMIT_RPM_EXCEEDED: 'RATE_LIMIT_RPM_EXCEEDED',
  RATE_LIMIT_TPM_EXCEEDED: 'RATE_LIMIT_TPM_EXCEEDED',
  /** @deprecated 已拆分为 RPM/TPM 两个,将在 v2.0 移除 */
  RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
  // ...
} as const

修改含义 — 不允许。语义需要调整时新建错误码,旧的标记废弃。

版本传递 — 在响应头里带版本号,让调用方知道当前用的是哪套错误码:

// src/middleware/api-version.ts
import type { MiddlewareHandler } from 'hono'
 
export const apiVersion: MiddlewareHandler = async (c, next) => {
  await next()
  c.res.headers.set('X-API-Version', '1.2.0')
}

错误码文档按版本维护,changelog 记录每次新增或废弃了哪些码。调用方升级时可以对照 changelog 知道改了什么。

总结

回顾一下这篇的要点:

  • 错误码在 HTTP 状态码之下提供业务级标识,让前端按 code 走不同处理逻辑
  • 五类错误:认证(AUTH)、参数(PARAM)、模型(MODEL)、限流(RATE_LIMIT)、系统(SYSTEM)
  • 字符串编码可读性好,适合面向开发者的 API;数字编码适合内部高频微服务
  • 错误码与 HTTP 状态码通过映射表关联,app.onError() 注册统一处理
  • JSDoc 注释作为文档单一来源,对外整理成带处理建议的表格
  • 国际化通过独立 i18n 文件实现,后端按 Accept-Language 返回对应语言 message
  • 版本管理:可以新增、可以废弃、不能改含义,用 X-API-Version 响应头和 changelog 同步变更

下一篇我们把这套错误码规范用到 AI Chat 接口的完整实现里。

所属分组

12-Hono开发AI API服务