BodySense 错误处理策略

BodySense 项目的错误处理架构:Go 后端统一错误模型、LLM 错误降级、前端错误处理、用户友好错误信息。

#type / concept #status / growing #tech / dev / backend #tech / ai

[!info] related notes

BodySense 错误处理策略

错误处理架构

┌─────────────────────────────────────────────────────────┐
│                      前端                                │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │  API 错误   │  │  SSE 错误   │  │  网络错误   │      │
│  │  拦截器     │  │  处理器     │  │  重试       │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                    Go 后端                               │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │  Handler    │  │  Service    │  │  Repository │      │
│  │  错误转换   │  │  业务错误   │  │  数据错误   │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
│                           │                              │
│  ┌─────────────────────────────────────────────────┐    │
│  │              统一错误处理中间件                    │    │
│  │  - 错误分类    - 日志记录    - 用户友好信息      │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                    AI 服务                               │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │  LLM 错误   │  │  RAG 错误   │  │  超时错误   │      │
│  │  降级处理   │  │  降级处理   │  │  重试       │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
└─────────────────────────────────────────────────────────┘

1. 统一错误模型

Go 后端错误类型

// errors/types.go
type AppError struct {
    Code       string `json:"code"`        // 业务错误码
    Message    string `json:"message"`     // 用户友好信息
    Detail     string `json:"detail,omitempty"`  // 开发者详情
    HTTPStatus int    `json:"-"`           // HTTP 状态码
    Err        error  `json:"-"`           // 原始错误
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Code, e.Err)
    }
    return e.Code
}

func (e *AppError) Unwrap() error {
    return e.Err
}

错误码定义

// errors/codes.go
var (
    // 认证相关 (1xxx)
    ErrInvalidCredentials = &AppError{
        Code: "AUTH_001", Message: "invalid email or password",
        HTTPStatus: 401,
    }
    ErrTokenExpired = &AppError{
        Code: "AUTH_002", Message: "token expired",
        HTTPStatus: 401,
    }
    ErrTokenRevoked = &AppError{
        Code: "AUTH_003", Message: "token revoked",
        HTTPStatus: 401,
    }
    ErrAccountLocked = &AppError{
        Code: "AUTH_004", Message: "account locked",
        HTTPStatus: 423,
    }

    // 业务相关 (2xxx)
    ErrSessionNotFound = &AppError{
        Code: "SESSION_001", Message: "session not found",
        HTTPStatus: 404,
    }
    ErrSessionCompleted = &AppError{
        Code: "SESSION_002", Message: "session already completed",
        HTTPStatus: 409,
    }
    ErrInsufficientInfo = &AppError{
        Code: "SESSION_003", Message: "insufficient information for analysis",
        HTTPStatus: 400,
    }

    // AI 服务相关 (3xxx)
    ErrLLMUnavailable = &AppError{
        Code: "AI_001", Message: "AI service temporarily unavailable",
        HTTPStatus: 503,
    }
    ErrLLMTimeout = &AppError{
        Code: "AI_002", Message: "AI service timeout",
        HTTPStatus: 504,
    }
    ErrLLMRateLimit = &AppError{
        Code: "AI_003", Message: "too many requests, please try again later",
        HTTPStatus: 429,
    }
    ErrRAGNoResults = &AppError{
        Code: "AI_004", Message: "no relevant knowledge found",
        HTTPStatus: 200,  // 不是错误,返回空结果
    }

    // 通用错误 (9xxx)
    ErrInternal = &AppError{
        Code: "SYSTEM_001", Message: "internal server error",
        HTTPStatus: 500,
    }
    ErrValidation = &AppError{
        Code: "SYSTEM_002", Message: "validation failed",
        HTTPStatus: 400,
    }
    ErrRateLimit = &AppError{
        Code: "SYSTEM_003", Message: "too many requests",
        HTTPStatus: 429,
    }
)

错误构造函数

// errors/errors.go
func NewValidationError(detail string) *AppError {
    return &AppError{
        Code:       ErrValidation.Code,
        Message:    ErrValidation.Message,
        Detail:     detail,
        HTTPStatus: 400,
    }
}

func WrapError(err error, appErr *AppError) *AppError {
    return &AppError{
        Code:       appErr.Code,
        Message:    appErr.Message,
        HTTPStatus: appErr.HTTPStatus,
        Err:        err,
    }
}

func WrapInternal(err error) *AppError {
    return &AppError{
        Code:       ErrInternal.Code,
        Message:    ErrInternal.Message,
        HTTPStatus: 500,
        Err:        err,
    }
}

2. Handler 层错误处理

// handler/errors.go
func handleServiceError(c *gin.Context, err error) {
    var appErr *errors.AppError
    if errors.As(err, &appErr) {
        // 业务错误,返回给用户
        c.JSON(appErr.HTTPStatus, gin.H{
            "error": gin.H{
                "code":    appErr.Code,
                "message": appErr.Message,
            },
        })
        return
    }

    // 未知错误,返回通用错误
    c.JSON(500, gin.H{
        "error": gin.H{
            "code":    "SYSTEM_001",
            "message": "internal server error",
        },
    })
}

Handler 使用示例

func (h *Handler) CreateSession(c *gin.Context) {
    userID := c.GetString("user_id")
    uid, _ := uuid.Parse(userID)

    session, err := h.sessionService.CreateSession(c.Request.Context(), uid)
    if err != nil {
        handleServiceError(c, err)
        return
    }

    c.JSON(201, session)
}

3. Service 层错误处理

// service/session.go
func (s *SessionService) CreateSession(ctx context.Context, userID uuid.UUID) (*Session, error) {
    // 检查是否有进行中的会话
    existing, err := s.repo.GetLastInProgress(ctx, userID)
    if err != nil {
        return nil, errors.WrapInternal(err)
    }
    if existing != nil {
        return existing, nil  // 复用现有会话
    }

    session := &Session{
        ID:     uuid.New(),
        UserID: userID,
        Status: "in_progress",
        Phase:  "collecting",
    }

    if err := s.repo.Create(ctx, session); err != nil {
        return nil, errors.WrapInternal(err)
    }

    return session, nil
}

func (s *SessionService) GenerateDiagnosis(ctx context.Context, sessionID uuid.UUID) (*Session, error) {
    session, err := s.repo.GetByID(ctx, sessionID)
    if err != nil {
        return nil, errors.WrapInternal(err)
    }
    if session == nil {
        return nil, errors.ErrSessionNotFound
    }
    if session.Phase != "analysis_ready" {
        return nil, errors.NewValidationError("session not ready for diagnosis")
    }

    // 调用 AI 服务
    diagnosis, err := s.aiService.GenerateDiagnosis(ctx, session)
    if err != nil {
        return nil, err  // AI 错误向上传播
    }

    session.Diagnosis = diagnosis
    session.Phase = "diagnosis_confirmed"
    if err := s.repo.Update(ctx, session); err != nil {
        return nil, errors.WrapInternal(err)
    }

    return session, nil
}

4. AI 服务错误处理

LLM 错误分类

// ai/errors.go
type LLMError struct {
    Type    string  // 'timeout' | 'rate_limit' | 'invalid_response' | 'api_error'
    Message string
    Err     error
    Retryable bool  // 是否可重试
}

func classifyLLMError(err error) *LLMError {
    errStr := err.Error()

    switch {
    case strings.Contains(errStr, "timeout"):
        return &LLMError{Type: "timeout", Message: "LLM request timeout", Err: err, Retryable: true}
    case strings.Contains(errStr, "rate_limit"):
        return &LLMError{Type: "rate_limit", Message: "LLM rate limit exceeded", Err: err, Retryable: true}
    case strings.Contains(errStr, "invalid JSON"):
        return &LLMError{Type: "invalid_response", Message: "LLM returned invalid JSON", Err: err, Retryable: false}
    default:
        return &LLMError{Type: "api_error", Message: "LLM API error", Err: err, Retryable: false}
    }
}

LLM 重试机制

// ai/retry.go
func WithRetry(ctx context.Context, maxRetries int, fn func() error) error {
    var lastErr error
    for i := 0; i <= maxRetries; i++ {
        err := fn()
        if err == nil {
            return nil
        }

        lastErr = err
        llmErr := classifyLLMError(err)
        if !llmErr.Retryable {
            return err  // 不可重试,直接返回
        }

        // 指数退避
        if i < maxRetries {
            backoff := time.Duration(1<<uint(i)) * time.Second
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(backoff):
                // 继续重试
            }
        }
    }
    return lastErr
}

LLM 降级策略

// ai/provider.go
func (p *Provider) GenerateDiagnosis(ctx context.Context, session *Session) (string, error) {
    var diagnosis string
    var err error

    // 尝试主模型
    err = WithRetry(ctx, 2, func() error {
        diagnosis, err = p.callLLM(ctx, "gpt-4o", session)
        return err
    })

    if err != nil {
        // 主模型失败,尝试备用模型
        err = WithRetry(ctx, 1, func() error {
            diagnosis, err = p.callLLM(ctx, "gpt-4o-mini", session)
            return err
        })
    }

    if err != nil {
        // 所有模型都失败,返回降级响应
        return "", errors.WrapError(err, errors.ErrLLMUnavailable)
    }

    return diagnosis, nil
}

RAG 错误处理

// ai/rag.go
func (r *RAGService) Search(ctx context.Context, query string) ([]SearchResult, error) {
    // 1. 生成 query embedding
    embedding, err := r.embeddingService.Generate(ctx, query)
    if err != nil {
        // embedding 失败,降级到关键词搜索
        return r.keywordSearch(ctx, query)
    }

    // 2. 向量搜索
    results, err := r.vectorStore.Search(ctx, embedding, 10)
    if err != nil {
        return nil, errors.WrapError(err, errors.ErrInternal)
    }

    // 3. 如果没有结果,降级到关键词搜索
    if len(results) == 0 {
        return r.keywordSearch(ctx, query)
    }

    return results, nil
}

5. SSE 错误处理

Go 后端 SSE 错误

// handler/consultation.go
func (h *ConsultationHandler) StreamMessage(c *gin.Context) {
    // 设置 SSE 头
    c.Header("Content-Type", "text/event-stream")
    c.Header("Cache-Control", "no-cache")
    c.Header("Connection", "keep-alive")
    c.Header("X-Accel-Buffering", "no")

    // 创建 SSE writer
    writer := sse.NewWriter(c.Writer)

    // 处理 panic
    defer func() {
        if r := recover(); r != nil {
            writer.WriteEvent("error", gin.H{
                "code":    "SYSTEM_001",
                "message": "internal server error",
            })
            writer.WriteEvent("done", nil)
        }
    }()

    // 处理客户端断开
    ctx := c.Request.Context()
    go func() {
        <-ctx.Done()
        // 客户端断开,清理资源
    }()

    // 处理消息
    err := h.consultationService.ProcessMessage(ctx, sessionID, message, writer)
    if err != nil {
        var appErr *errors.AppError
        if errors.As(err, &appErr) {
            writer.WriteEvent("error", gin.H{
                "code":    appErr.Code,
                "message": appErr.Message,
            })
        } else {
            writer.WriteEvent("error", gin.H{
                "code":    "SYSTEM_001",
                "message": "internal server error",
            })
        }
    }

    writer.WriteEvent("done", nil)
}

SSE 事件类型

// sse/events.go
type SSEEvent struct {
    Type string      `json:"type"`
    Data interface{} `json:"data"`
}

// 事件类型
const (
    EventTypeText         = "text"
    EventTypeExtractedInfo = "extracted_info"
    EventTypePhaseChange  = "phase_changed"
    EventTypeRedFlag      = "red_flag"
    EventTypeCitation     = "citation"
    EventTypeError        = "error"
    EventTypeDone         = "done"
)

6. 前端错误处理

API 错误拦截器

// api/errors.ts
export class ApiError extends Error {
  code: string;
  status: number;

  constructor(code: string, message: string, status: number) {
    super(message);
    this.code = code;
    this.status = status;
  }
}

api.interceptors.response.use(
  (res) => res,
  (error) => {
    if (error.response) {
      const { data, status } = error.response;
      throw new ApiError(
        data.error?.code || 'UNKNOWN',
        data.error?.message || 'An error occurred',
        status,
      );
    }

    if (error.request) {
      throw new ApiError('NETWORK', 'Network error', 0);
    }

    throw error;
  },
);

SSE 错误处理

// hooks/useChatSSE.ts
function useChatSSE(callbacks: SSECallbacks) {
  const [error, setError] = useState<string | null>(null);

  const processEvent = (event: SSEEvent) => {
    switch (event.type) {
      case 'error':
        setError(event.data.message);
        callbacks.onError?.(event.data);
        break;
      case 'done':
        callbacks.onDone?.(event.data);
        break;
      // ... 其他事件
    }
  };

  const sendMessage = async (sessionId: string, content: string) => {
    setError(null);

    try {
      const response = await fetch(`/api/v1/consultation/${sessionId}/message`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new ApiError(error.code, error.message, response.status);
      }

      // 处理 SSE 流
      const reader = response.body?.getReader();
      // ...
    } catch (err) {
      setError(err instanceof ApiError ? err.message : 'Unknown error');
    }
  };

  return { sendMessage, error };
}

用户友好错误信息

// utils/errorMessages.ts
const ERROR_MESSAGES: Record<string, string> = {
  AUTH_001: '邮箱或密码错误',
  AUTH_002: '登录已过期,请重新登录',
  AUTH_003: '登录已失效,请重新登录',
  AUTH_004: '账户已被锁定,请稍后再试',
  SESSION_001: '会话不存在',
  SESSION_002: '会话已结束',
  SESSION_003: '信息不足,请补充更多症状描述',
  AI_001: 'AI 服务暂时不可用,请稍后再试',
  AI_002: '请求超时,请重试',
  AI_003: '请求过于频繁,请稍后再试',
  SYSTEM_001: '系统错误,请稍后再试',
  SYSTEM_002: '输入格式错误',
  SYSTEM_003: '请求过于频繁,请稍后再试',
  NETWORK: '网络连接失败,请检查网络',
};

export function getErrorMessage(code: string): string {
  return ERROR_MESSAGES[code] || '未知错误';
}

错误展示组件

// components/ErrorAlert.tsx
interface ErrorAlertProps {
  error: ApiError | null;
  onRetry?: () => void;
  onDismiss?: () => void;
}

export function ErrorAlert({ error, onRetry, onDismiss }: ErrorAlertProps) {
  if (!error) return null;

  const message = getErrorMessage(error.code);

  return (
    <div className="bg-red-50 border border-red-200 rounded-md p-4">
      <div className="flex">
        <ExclamationCircleIcon className="h-5 w-5 text-red-400" />
        <div className="ml-3">
          <p className="text-sm text-red-800">{message}</p>
          {onRetry && (
            <button
              onClick={onRetry}
              className="mt-2 text-sm text-red-600 underline"
            >
              重试
            </button>
          )}
        </div>
        {onDismiss && (
          <button onClick={onDismiss} className="ml-auto">
            <XMarkIcon className="h-5 w-5 text-red-400" />
          </button>
        )}
      </div>
    </div>
  );
}

7. 日志记录

结构化日志

// logger/logger.go
type Logger struct {
    *slog.Logger
}

func (l *Logger) Error(msg string, err error, attrs ...slog.Attr) {
    attrs = append(attrs, slog.String("error", err.Error()))

    var appErr *errors.AppError
    if errors.As(err, &appErr) {
        attrs = append(attrs,
            slog.String("error_code", appErr.Code),
            slog.Int("http_status", appErr.HTTPStatus),
        )
    }

    l.Logger.Error(msg, attrs...)
}

错误日志示例

// 在 Handler 中记录错误
func (h *Handler) CreateSession(c *gin.Context) {
    session, err := h.sessionService.CreateSession(ctx, userID)
    if err != nil {
        h.logger.Error("failed to create session", err,
            slog.String("user_id", userID),
        )
        handleServiceError(c, err)
        return
    }
    // ...
}

8. 监控与告警

错误率指标

// metrics/errors.go
var (
    errorCounter = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "app_errors_total",
            Help: "Total number of errors",
        },
        []string{"code", "method", "path"},
    )
)

func RecordError(code, method, path string) {
    errorCounter.WithLabelValues(code, method, path).Inc()
}

告警规则

# prometheus/alerts.yml
groups:
  - name: app_errors
    rules:
      - alert: HighErrorRate
        expr: rate(app_errors_total[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate detected"

      - alert: AIServiceDown
        expr: rate(app_errors_total{code="AI_001"}[5m]) > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "AI service is down"

常见面试问题

Q: 你的错误处理策略是什么?

A:

  1. 分层处理:Handler 转换、Service 业务逻辑、Repository 数据访问
  2. 统一模型:所有错误都用 AppError,包含错误码、用户信息、HTTP 状态
  3. 错误分类:业务错误返回给用户,系统错误记录日志
  4. 降级策略:LLM 失败重试,RAG 失败降级到关键词搜索

Q: LLM 返回错误怎么办?

A:

  1. 分类错误:timeout/rate_limit 可重试,invalid_response 不可重试
  2. 指数退避:重试间隔 1s, 2s, 4s…
  3. 备用模型:主模型失败尝试备用模型
  4. 降级响应:所有模型失败返回友好错误信息

Q: 怎么保证用户看到友好的错误信息?

A:

  1. 错误码映射:后端定义错误码,前端映射到中文
  2. 不暴露内部细节:用户看到”系统错误”,开发者看到堆栈
  3. 国际化支持:错误信息支持多语言

常见错误

吞掉错误

// ❌ 吞掉错误,不记录
result, err := doSomething()
if err != nil {
    return nil
}

// ✅ 记录错误
result, err := doSomething()
if err != nil {
    h.logger.Error("doSomething failed", err)
    return nil, errors.WrapInternal(err)
}

暴露内部错误

// ❌ 把数据库错误返回给用户
c.JSON(500, gin.H{"error": err.Error()})

// ✅ 返回友好信息
c.JSON(500, gin.H{"error": gin.H{
    "code":    "SYSTEM_001",
    "message": "internal server error",
}})

不处理 SSE 错误

// ❌ SSE 出错不通知前端
err := processMessage(ctx, sessionID, message, writer)
if err != nil {
    // 只记录日志,前端不知道出错了
    log.Error(err)
}

// ✅ 通知前端
err := processMessage(ctx, sessionID, message, writer)
if err != nil {
    writer.WriteEvent("error", gin.H{"message": "processing failed"})
    writer.WriteEvent("done", nil)
}
创建于 2026/6/25 更新于 2026/6/25