导航菜单

审核流程设计

一次误判引发的”事故”

审核系统上线后的第三天,摄影师老张给我发了一封长长的邮件。

他上传了一组人体艺术摄影——黑白光影、专业布光、构图讲究。AI 审核直接判了”reject”,理由是”porn score 92%”。

老张很愤怒:“这是获得过国际摄影奖的作品,你的 AI 说它是色情?”

我手动恢复了老张的图片,但这件事让我意识到:AI 审核不是非黑即白的——它有置信度,有灰色地带。 一个好的审核流程不是”AI 说了算”,而是机器和人的协作。

审核流程架构

我重新设计了审核流程,核心思路是分级审核

用户上传图片


┌─────────────────────┐
│  第一级:AI 快速审核   │  < 300ms
│  (鉴黄 + 二维码)     │
└──────┬──────────────┘

   ┌───┴────┬──────────┐
   │        │          │
   ▼        ▼          ▼
reject    review      pass
   │        │          │
   │        ▼          ▼
   │   ┌──────────┐   异步发起精审
   │   │ 第二级:   │   (OCR + 暴力 + 水印)
   │   │ AI 精审   │
   │   └────┬─────┘
   │        │
   │   ┌────┴─────┐
   │   │          │
   │   ▼          ▼
   │ reject    review
   │   │        │
   │   │        ▼
   │   │   ┌──────────┐
   │   │   │ 第三级:   │
   │   │   │ 人工审核   │  人工审核后台
   │   │   └────┬─────┘
   │   │        │
   │   │   ┌────┴─────┐
   │   │   │          │
   │   │   ▼          ▼
   │   │ reject      pass
   │   │   │          │
   ▼   ▼   ▼          ▼
 拦截通知          用户申诉通道

置信度阈值设计

from dataclasses import dataclass
from typing import Optional

@dataclass
class AuditThreshold:
    """审核阈值配置"""
    # 鉴黄阈值
    porn_reject: float = 90.0     # > 90% 直接拒绝
    porn_review: float = 60.0     # 60~90% 人工审核
    
    # 暴力阈值
    violence_reject: float = 85.0
    violence_review: float = 50.0
    
    # 性感内容
    sexy_reject: float = 95.0     # 性感内容不轻易拒绝(泳装等)
    sexy_review: float = 70.0
    
    # 文字风险
    text_reject: str = 'high'     # high/medium/low
    text_review: str = 'medium'
    
    # 是否需要人工确认 AI 的 reject 决定
    human_confirm_reject: bool = False  # 摄影社区建议开启


class AuditDecisionEngine:
    """审核决策引擎"""
    
    def __init__(self, thresholds: AuditThreshold = None):
        self.thresholds = thresholds or AuditThreshold()
    
    def decide(self, audit_results: dict) -> dict:
        """综合所有审核结果,做出最终决策"""
        decisions = []
        
        for dimension, result in audit_results.items():
            decision = self._decide_dimension(dimension, result)
            decisions.append(decision)
        
        # 取最严格的决定
        action_priority = {'reject': 3, 'review': 2, 'pass': 1}
        final = max(decisions, key=lambda d: action_priority.get(d['action'], 0))
        
        return {
            'action': final['action'],
            'dimensions': decisions,
            'primary_reason': final.get('reason'),
            'confidence': final.get('confidence', 0),
        }
    
    def _decide_dimension(self, dimension: str, result: dict) -> dict:
        """单个维度的决策"""
        confidence = result.get('confidence', 0) or result.get('rate', 0)
        label = result.get('label', 'normal')
        
        if dimension == 'nsfw':
            if label == 'porn':
                if confidence >= self.thresholds.porn_reject:
                    return {'action': 'reject', 'confidence': confidence,
                            'reason': f'色情内容 (置信度 {confidence:.0f}%)'}
                elif confidence >= self.thresholds.porn_review:
                    return {'action': 'review', 'confidence': confidence,
                            'reason': f'疑似色情 (置信度 {confidence:.0f}%)'}
            elif label == 'sexy':
                if confidence >= self.thresholds.sexy_reject:
                    return {'action': 'reject', 'confidence': confidence,
                            'reason': f'性感内容 (置信度 {confidence:.0f}%)'}
                elif confidence >= self.thresholds.sexy_review:
                    return {'action': 'review', 'confidence': confidence,
                            'reason': f'疑似性感 (置信度 {confidence:.0f}%)'}
        
        elif dimension == 'violence':
            if confidence >= self.thresholds.violence_reject:
                return {'action': 'reject', 'confidence': confidence,
                        'reason': f'暴力内容 (置信度 {confidence:.0f}%)'}
            elif confidence >= self.thresholds.violence_review:
                return {'action': 'review', 'confidence': confidence,
                        'reason': f'疑似暴力 (置信度 {confidence:.0f}%)'}
        
        elif dimension == 'text':
            severity = result.get('severity', 'none')
            if severity == 'high':
                return {'action': 'reject', 'confidence': 90,
                        'reason': f'敏感文字: {result.get("word", "")}'}
            elif severity == 'medium':
                return {'action': 'review', 'confidence': 70,
                        'reason': f'可疑文字: {result.get("word", "")}'}
        
        return {'action': 'pass', 'confidence': confidence, 'reason': None}

人工审核后台

AI 审核不了的图片需要转人工。我设计了一个简单的人工审核后台:

# 人工审核后台 API
from flask import Blueprint, request, jsonify

audit_bp = Blueprint('audit_admin', __name__)

@audit_bp.route('/api/admin/audit/pending', methods=['GET'])
def get_pending_reviews():
    """获取待人工审核的图片列表"""
    page = request.args.get('page', 1, type=int)
    per_page = 20
    
    photos = db.query_paginated('photos', {
        'status': 'pending_review',
    }, page=page, per_page=per_page, order_by='created_at ASC')
    
    results = []
    for photo in photos:
        audit = db.query('photo_audits', {'photo_id': photo['id']})
        results.append({
            'photo_id': photo['id'],
            'image_url': f"https://cdn.guangying.com/{photo['object_key']}",
            'ai_decision': audit['action'],
            'ai_reason': audit.get('review_reason', ''),
            'ai_scores': audit.get('scores', {}),
            'uploader': {
                'id': photo['user_id'],
                'name': photo.get('username', ''),
                'is_verified': photo.get('is_verified', False),
            },
            'uploaded_at': photo['created_at'].isoformat(),
        })
    
    return jsonify({
        'items': results,
        'total': db.count('photos', {'status': 'pending_review'}),
        'page': page,
    })


@audit_bp.route('/api/admin/audit/<photo_id>/decision', methods=['POST'])
def human_decision(photo_id):
    """人工审核决定"""
    data = request.json
    decision = data['decision']  # 'approve' or 'reject'
    reason = data.get('reason', '')
    reviewer_id = get_current_admin_id()
    
    photo = db.query('photos', {'id': photo_id})
    if not photo:
        return jsonify({'error': 'not found'}), 404
    
    if decision == 'approve':
        # 放行
        db.update('photos', {'status': 'approved'}, {'id': photo_id})
        
        # 触发后续处理(压缩、CDN 等)
        queue.publish('image:process', {
            'photo_id': photo_id,
            'object_key': photo['object_key'],
            'bypass_audit': True,
        })
        
        action = 'human_approve'
    
    elif decision == 'reject':
        # 拒绝
        db.update('photos', {'status': 'rejected'}, {'id': photo_id})
        
        # 从 OSS 和 CDN 移除
        oss_client.delete_object(photo['object_key'])
        cdn_client.refresh_object_caches(
            f"https://cdn.guangying.com/{photo['object_key']}"
        )
        
        action = 'human_reject'
    
    # 记录审核日志
    db.insert('audit_logs', {
        'photo_id': photo_id,
        'action': action,
        'reason': reason,
        'reviewer_id': reviewer_id,
        'reviewed_at': datetime.now(),
    })
    
    # 通知用户
    notify_user(photo['user_id'], photo_id, action, reason)
    
    return jsonify({'status': 'ok'})

前端审核界面:

// 人工审核后台界面
interface AuditItem {
  photo_id: string;
  image_url: string;
  ai_decision: string;
  ai_reason: string;
  uploader: { id: number; name: string; is_verified: boolean };
  uploaded_at: string;
}

function AuditDashboard() {
  const [items, setItems] = useState<AuditItem[]>([]);
  const [currentIndex, setCurrentIndex] = useState(0);

  // 加载待审核列表
  useEffect(() => {
    fetch('/api/admin/audit/pending')
      .then(r => r.json())
      .then(data => setItems(data.items));
  }, []);

  const handleDecision = async (decision: 'approve' | 'reject') => {
    const item = items[currentIndex];
    
    await fetch(`/api/admin/audit/${item.photo_id}/decision`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        decision,
        reason: decision === 'reject' ? prompt('请输入拒绝原因') : '',
      }),
    });
    
    // 下一个
    setCurrentIndex(prev => prev + 1);
  };

  const item = items[currentIndex];
  if (!item) return <div>暂无待审核图片</div>;

  return (
    <div className="audit-dashboard">
      <div className="image-preview">
        <img src={item.image_url} alt="审核图片" />
      </div>
      
      <div className="audit-info">
        <p>AI 判定: {item.ai_decision} - {item.ai_reason}</p>
        <p>上传者: {item.uploader.name} 
          {item.uploader.is_verified && ' ✓认证'}
        </p>
      </div>
      
      <div className="audit-actions">
        <button onClick={() => handleDecision('approve')}>
通过
        </button>
        <button onClick={() => handleDecision('reject')}>
拒绝
        </button>
      </div>
      
      {/* 键盘快捷键 */}
      <KeyboardHandler
        onKeyA={() => handleDecision('approve')}
        onKeyR={() => handleDecision('reject')}
      />
    </div>
  );
}

用户申诉机制

被拒绝的图片应该有申诉通道:

@audit_bp.route('/api/appeal', methods=['POST'])
def submit_appeal():
    """用户提交申诉"""
    data = request.json
    photo_id = data['photo_id']
    reason = data['reason']
    user_id = get_current_user_id()
    
    photo = db.query('photos', {'id': photo_id, 'user_id': user_id})
    if not photo:
        return jsonify({'error': 'not found'}), 404
    
    if photo['status'] != 'rejected':
        return jsonify({'error': '只能申诉被拒绝的图片'}), 400
    
    # 检查申诉频率(每用户每天最多 3 次)
    today = datetime.now().date()
    appeal_count = db.count('appeals', {
        'user_id': user_id,
        'created_at__gte': today,
    })
    if appeal_count >= 3:
        return jsonify({'error': '今日申诉次数已达上限'}), 429
    
    # 创建申诉
    appeal_id = db.insert('appeals', {
        'photo_id': photo_id,
        'user_id': user_id,
        'reason': reason,
        'status': 'pending',
        'created_at': datetime.now(),
    })
    
    # 通知高级审核员(申诉由更高级别的审核员处理)
    queue.publish('appeal:review', {
        'appeal_id': appeal_id,
        'photo_id': photo_id,
    })
    
    return jsonify({'appeal_id': appeal_id, 'status': 'pending'})


@audit_bp.route('/api/appeal/<appeal_id>/resolve', methods=['POST'])
def resolve_appeal(appeal_id):
    """高级审核员处理申诉"""
    data = request.json
    decision = data['decision']  # 'uphold' or 'overturn'
    reason = data.get('reason', '')
    
    appeal = db.query('appeals', {'id': appeal_id})
    photo_id = appeal['photo_id']
    
    if decision == 'overturn':
        # 翻案:恢复图片
        db.update('photos', {'status': 'approved'}, {'id': photo_id})
        db.update('appeals', {'status': 'overturn', 'resolved_at': datetime.now()}, {'id': appeal_id})
        
        # 触发图片处理
        photo = db.query('photos', {'id': photo_id})
        queue.publish('image:process', {
            'photo_id': photo_id,
            'object_key': photo['object_key'],
            'bypass_audit': True,
        })
        
        # 用这次申诉的样本反馈给 AI 模型(提升准确率)
        feedback_to_model(photo_id, 'should_pass')
    
    else:
        # 维持原判
        db.update('appeals', {'status': 'uphold', 'resolved_at': datetime.now()}, {'id': appeal_id})
        
        feedback_to_model(photo_id, 'should_reject')
    
    return jsonify({'status': 'ok'})

灰度发布审核规则

审核规则和阈值的调整需要灰度发布——先在小比例图片上验证效果,再全量上线:

class AuditABTest:
    """审核规则 A/B 测试"""
    
    def __init__(self):
        self.experiments = {
            'new_porn_threshold': {
                'control': {'porn_reject': 90.0, 'porn_review': 60.0},
                'treatment': {'porn_reject': 85.0, 'porn_review': 55.0},
                'traffic_pct': 10,  # 10% 流量走新规则
                'enabled': True,
            },
        }
    
    def get_thresholds(self, photo_id: str) -> AuditThreshold:
        """获取当前图片应该使用的阈值"""
        for exp_name, config in self.experiments.items():
            if not config['enabled']:
                continue
            
            # 基于 photo_id 做流量分配
            bucket = int(hashlib.md5(photo_id.encode()).hexdigest()[:4], 16) % 100
            
            if bucket < config['traffic_pct']:
                # 走实验组
                return AuditThreshold(**config['treatment'])
            else:
                # 走对照组
                return AuditThreshold(**config['control'])
        
        return AuditThreshold()  # 默认阈值
    
    def record_result(self, photo_id: str, ai_action: str, human_action: str):
        """记录 AI 和人工判定结果,用于评估实验效果"""
        for exp_name, config in self.experiments.items():
            if not config['enabled']:
                continue
            
            bucket = int(hashlib.md5(photo_id.encode()).hexdigest()[:4], 16) % 100
            group = 'treatment' if bucket < config['traffic_pct'] else 'control'
            
            db.insert('audit_experiments', {
                'experiment': exp_name,
                'photo_id': photo_id,
                'group': group,
                'ai_action': ai_action,
                'human_action': human_action,
                'agree': ai_action == human_action,
                'created_at': datetime.now(),
            })
    
    def evaluate(self, experiment_name: str):
        """评估实验效果"""
        results = db.query_all('audit_experiments', {'experiment': experiment_name})
        
        groups = {'control': [], 'treatment': []}
        for r in results:
            groups[r['group']].append(r)
        
        for group_name, data in groups.items():
            total = len(data)
            agreed = sum(1 for d in data if d['agree'])
            false_positives = sum(1 for d in data 
                                 if d['ai_action'] == 'reject' and d['human_action'] != 'reject')
            false_negatives = sum(1 for d in data 
                                 if d['ai_action'] != 'reject' and d['human_action'] == 'reject')
            
            print(f"\n{group_name}:")
            print(f"  AI与人工一致率: {agreed/total*100:.1f}%")
            print(f"  误报率: {false_positives/total*100:.1f}%")
            print(f"  漏报率: {false_negatives/total*100:.1f}%")

审核数据看板

class AuditDashboard:
    """审核数据统计"""
    
    def get_stats(self, date_range: tuple) -> dict:
        """获取审核统计数据"""
        start, end = date_range
        
        total = db.count('photos', {'created_at__between': (start, end)})
        
        by_status = {}
        for status in ['approved', 'rejected', 'pending_review', 'processing']:
            by_status[status] = db.count('photos', {
                'status': status,
                'created_at__between': (start, end),
            })
        
        by_dimension = {}
        for dim in ['nsfw', 'violence', 'text', 'qrcode', 'watermark']:
            by_dimension[dim] = db.count('photo_audits', {
                'primary_dimension': dim,
                'action': 'reject',
                'created_at__between': (start, end),
            })
        
        # 人工审核统计
        human_stats = {
            'total_reviewed': db.count('audit_logs', {'created_at__between': (start, end)}),
            'overturn_rate': self._overturn_rate(start, end),
            'avg_review_time': self._avg_review_time(start, end),
        }
        
        return {
            'period': {'start': start.isoformat(), 'end': end.isoformat()},
            'total_uploads': total,
            'status_breakdown': by_status,
            'rejection_by_dimension': by_dimension,
            'human_review': human_stats,
            'auto_reject_rate': by_status.get('rejected', 0) / max(total, 1) * 100,
        }

本节小结

我学到了什么

  • 审核流程不是”AI 一刀切”,而是机审→人审的分级体系
  • 置信度阈值是平衡误报和漏报的关键——摄影社区应该偏保守
  • 灰度发布审核规则,避免一次调参影响全量用户
  • 用户申诉机制是纠错的最后保障,申诉数据可以反哺 AI 模型

⚠️ 踩过的坑

  • AI 审核对人体艺术的误判率很高——这类内容必须走人工审核
  • 审核阈值不能太严格,15% 的误报率会让摄影师愤怒地离开平台
  • 人工审核的效率瓶颈——高峰期可能积压大量待审核图片

🎯 下一步:审核流程完善了,图片处理也搞定了。但用户访问图片还是从我的一台服务器上下载。是时候上 CDN 了。

我的思考

思考 1

如果人工审核员也有主观偏差(比如对同一张图,A 审核员通过了,B 审核员拒绝了),怎么保证审核标准的一致性?

参考答案

审核标准不一致是人工审核的固有问题。解决策略:

1. 审核指南 + 标注数据集

建立详细的审核指南文档:
- 什么是色情?什么是艺术?
- 具体案例 + 判定结果
- 边界案例的处理原则

定期用标注数据集测试审核员:
- 准备 100 张标准答案已知的图片
- 审核员判定结果与标准答案对比
- 一致率低于 90% 的需要培训

2. 双人审核 + 仲裁

# 高风险内容由两个审核员独立审核
def dual_review(photo_id):
    # 两个审核员独立给出判定
    review_1 = get_review_from_reviewer(photo_id, reviewer_pool[0])
    review_2 = get_review_from_reviewer(photo_id, reviewer_pool[1])
    
    if review_1.decision == review_2.decision:
        # 一致,采用该判定
        return review_1.decision
    else:
        # 不一致,升级到高级审核员仲裁
        return escalate_to_senior(photo_id, review_1, review_2)

3. 定期校准会议

每周一次审核校准会议:
- 回顾本周的争议案例
- 讨论审核标准的边界
- 更新审核指南
- 统一团队认知

4. 用 AI 做一致性检查

# 检测审核员的偏差
def detect_reviewer_bias(reviewer_id, period='30d'):
    decisions = get_reviewer_decisions(reviewer_id, period)
    
    # 对比该审核员与团队平均的偏差
    team_avg_reject_rate = get_team_avg('reject_rate')
    my_reject_rate = decisions['reject'] / decisions['total']
    
    if abs(my_reject_rate - team_avg_reject_rate) > 0.15:
        # 偏差超过 15%,标记为异常
        alert(f"审核员 {reviewer_id} 的拒绝率偏差过大")

思考 2

对于”先审后发”(审核通过才展示)和”先发后审”(先展示,发现问题再删除),你会怎么选择?各有什么利弊?

参考答案

两种策略各有利弊,摄影社区建议混合使用

先审后发(审核通过才展示):
  优点:
    - 平台零违规风险
    - 用户体验一致(看到的都是合规内容)
  
  缺点:
    - 上传到展示有延迟(等待审核,10秒~数分钟)
    - 审核高峰期可能积压
    - 摄影师即时分享的体验被破坏

先发后审(先展示后审核):
  优点:
    - 即时展示,用户体验好
    - 审核压力小(可以慢慢审)
  
  缺点:
    - 违规内容可能短暂可见
    - 法律风险
    - 用户可能看到不当内容

混合策略

def choose_audit_strategy(user):
    """根据用户风险等级选择审核策略"""
    
    risk = calculate_user_risk(user)
    
    if risk == 'high':
        # 新用户、曾被拒绝过的用户 → 先审后发
        return 'pre_audit'
    
    elif risk == 'medium':
        # 普通用户 → 先发后审,但降低展示范围
        return 'post_audit_limited'
    
    else:
        # 认证摄影师、长期无违规的用户 → 先发后审
        return 'post_audit'


def calculate_user_risk(user):
    days_since_register = (datetime.now() - user.created_at).days
    total_uploads = user.total_uploads
    rejected_count = user.rejected_count
    reject_rate = rejected_count / max(total_uploads, 1)
    
    if days_since_register < 7:
        return 'high'
    if reject_rate > 0.1:
        return 'high'
    if reject_rate > 0.02:
        return 'medium'
    if user.is_verified and reject_rate == 0:
        return 'low'
    return 'medium'

对于”光影”,我选择了:

  • 新用户先审后发
  • 认证摄影师先发后审
  • AI 快审通过的图片即时展示,精审在后台异步进行

这样既保证了安全性,又不影响核心用户的体验。

搜索