AI影子部署:测试不够用了之后

我们之前采用的是这样的流程:

  1. 开发环境本地测试
  2. 测试环境跑测试集
  3. 小流量灰度发布
  4. 观察指标
  5. 全量发布

这个流程看似合理,但在实际操作中遇到了几个问题:

为什么写这篇文章

上个月我们团队在做 AI 模型灰度升级的时候,遇到了一个很现实的问题:新模型在测试环境表现很好,但上线后指标异常。当时我们花了三天时间排查,最后才发现是线上数据分布和测试环境差异太大导致的。

这让我意识到,传统的测试环境验证已经不够用了。我们需要一种能够在真实流量下、但不影响业务的方式验证新模型。这就是我深入研究影子部署的原因。

本文会分享我们团队在实践中如何从零搭建影子部署系统,包括踩过的坑和最终的解决方案。

背景:传统部署验证的困境

在 AI 应用中,模型升级是个高风险操作。我们之前采用的是这样的流程:

  1. 开发环境本地测试
  2. 测试环境跑测试集
  3. 小流量灰度发布
  4. 观察指标
  5. 全量发布

这个流程看似合理,但在实际操作中遇到了几个问题:

  • 测试环境和线上数据分布不一致:我们的测试集是历史数据,但线上用户的 query 模式每天都在变化
  • 小流量影响业务:即使是 1% 的灰度,如果模型有问题,也会影响这部分用户
  • 反馈周期长:要等足够多的流量进来才能做出统计判断,通常需要 2-3 天
  • 无法评估模型差异:只能看整体指标,不知道新模型在哪些场景下表现更好或更差

最严重的一次,我们的新模型在测试环境准确率提升了 3%,但上线后发现对话长度显著增加,用户体验反而变差了。

影子部署:概念与价值

影⼦部署(Shadow Deployment)是一种部署验证技术,它的核心思想是:

在真实流量下并行运行新旧版本,但只将旧版本的响应返回给用户。

简单来说,用户请求进来后,会同时发给新旧两个版本。新版本的响应会被记录下来用于分析,但不会真正返回给用户。

影子部署的优势

相比传统的测试方式,影子部署有这些好处:

  • 真实数据验证:使用真实的线上流量和用户行为
  • 零业务影响:用户看到的永远是旧版本的结果
  • 快速反馈:可以实时收集新模型的表现数据
  • 细粒度分析:可以针对不同场景对比模型差异

当然,影子部署也不是银弹。它有自己的限制,我会在后面详细说。

实现方案

我们最终选择了一个相对简单的架构,用 Python + Flask 实现了一个影子部署网关。

架构设计

graph TD A[用户请求] --> B[影子部署网关] B --> C[旧版本服务] B --> D[新版本服务] C --> E[返回结果给用户] D --> F[结果记录到分析系统] B --> G[记录请求上下文] E --> H[用户] F --> I[后续分析对比] G --> I

核心代码实现

首先是网关的主体部分:

from flask import Flask, request, jsonify
import requests
import json
import time
from threading import Thread

app = Flask(__name__)

OLD_SERVICE_URL = "http://old-model-service:8000"
NEW_SERVICE_URL = "http://new-model-service:8000"
ANALYSIS_URL = "http://analysis-service:9000"

def shadow_call(prompt, request_id):
    """异步调用新版本服务"""
    try:
        start_time = time.time()
        response = requests.post(
            NEW_SERVICE_URL + "/predict",
            json={"prompt": prompt},
            timeout=30
        )
        latency = time.time() - start_time

        if response.status_code == 200:
            # 发送结果到分析系统
            requests.post(
                ANALYSIS_URL + "/record",
                json={
                    "request_id": request_id,
                    "version": "new",
                    "result": response.json(),
                    "latency": latency,
                    "timestamp": time.time()
                }
            )
    except Exception as e:
        # 影子部署失败不影响主流程
        print(f"Shadow call failed: {e}")

@app.route('/predict', methods=['POST'])
def predict():
    data = request.json
    prompt = data.get('prompt')
    request_id = data.get('request_id', str(time.time()))

    # 异步调用新版本(影子)
    Thread(target=shadow_call, args=(prompt, request_id)).start()

    # 同步调用旧版本(实际返回)
    try:
        start_time = time.time()
        response = requests.post(
            OLD_SERVICE_URL + "/predict",
            json={"prompt": prompt},
            timeout=30
        )
        latency = time.time() - start_time

        if response.status_code == 200:
            result = response.json()

            # 记录旧版本结果到分析系统
            Thread(target=lambda: requests.post(
                ANALYSIS_URL + "/record",
                json={
                    "request_id": request_id,
                    "version": "old",
                    "result": result,
                    "latency": latency,
                    "timestamp": time.time()
                }
            )).start()

            return jsonify(result)
        else:
            return jsonify({"error": "Service unavailable"}), 503

    except Exception as e:
        return jsonify({"error": str(e)}), 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)

这个实现有几个关键点:

  1. 异步处理影子调用:使用 Thread 将新版本调用放到后台,不阻塞主流程
  2. 异常隔离:影子调用失败不影响旧版本的正常响应
  3. 请求追踪:使用 request_id 关联同一请求的多个版本结果
  4. 性能监控:记录每个版本的响应延迟

分析系统

我们用一个简单的服务来收集和对比数据:

from flask import Flask, request, jsonify
from collections import defaultdict
import json

app = Flask(__name__)

# 存储请求结果
results = defaultdict(lambda: {"old": None, "new": None})

@app.route('/record', methods=['POST'])
def record():
    data = request.json
    request_id = data['request_id']
    version = data['version']

    results[request_id][version] = {
        "result": data['result'],
        "latency": data['latency'],
        "timestamp": data['timestamp']
    }

    return jsonify({"status": "success"})

@app.route('/compare', methods=['GET'])
def compare():
    """获取新旧版本对比数据"""
    comparison_data = []

    for request_id, versions in results.items():
        if versions['old'] and versions['new']:
            old_result = versions['old']['result']
            new_result = versions['new']['result']

            comparison_data.append({
                "request_id": request_id,
                "old_latency": versions['old']['latency'],
                "new_latency": versions['new']['latency'],
                "old_tokens": len(old_result.get('text', '')),
                "new_tokens": len(new_result.get('text', '')),
                "latency_diff": versions['new']['latency'] - versions['old']['latency'],
                "tokens_diff": len(new_result.get('text', '')) - len(old_result.get('text', ''))
            })

    return jsonify({
        "total_requests": len(comparison_data),
        "data": comparison_data[:100]  # 返回前100条作为示例
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9000)

踩坑记录

在实现和使用影子部署的过程中,我们遇到了不少坑。这里分享几个最典型的。

坑一:内存爆炸

刚开始我们直接把所有请求结果都存在内存里,运行了一天就 OOM 了。问题在于:

  • 每小时大约有 10000 个请求
  • 每个请求的结果平均 2KB
  • 一天就是 480MB,很快就爆了

解决方案:改成流式处理,定期持久化到数据库,内存中只保留最近 1 小时的数据。

import time
from datetime import datetime, timedelta

# 定期清理旧数据
def cleanup_old_data():
    while True:
        current_time = time.time()
        cutoff_time = current_time - 3600  # 1小时前

        expired_keys = [
            key for key, value in results.items()
            if all(v['timestamp'] < cutoff_time for v in value.values() if v)
        ]

        for key in expired_keys:
            # 先持久化到数据库
            persist_to_db(results[key])
            del results[key]

        time.sleep(300)  # 每5分钟清理一次

# 启动清理线程
Thread(target=cleanup_old_data, daemon=True).start()

坑二:延迟累积

影子调用虽然是异步的,但大量的后台线程也会造成资源竞争。特别是在高峰期,影子部署网关本身的延迟增加了 20-30ms。

解决方案:引入请求采样和限流。

from threading import Semaphore

# 限制并发影子调用数
shadow_semaphore = Semaphore(50)  # 最多50个并发影子调用

def shadow_call_with_limit(prompt, request_id):
    # 采样率 80%
    if random.random() > 0.8:
        return

    shadow_semaphore.acquire()
    try:
        shadow_call(prompt, request_id)
    finally:
        shadow_semaphore.release()

坑三:数据一致性问题

我们发现有些请求的影子数据一直收不到,导致无法对比。排查后发现是网络波动导致的。

解决方案:增加重试机制和超时检测。

def shadow_call_with_retry(prompt, request_id, max_retries=2):
    for attempt in range(max_retries):
        try:
            response = requests.post(
                NEW_SERVICE_URL + "/predict",
                json={"prompt": prompt},
                timeout=15
            )
            if response.status_code == 200:
                # 记录到分析系统
                requests.post(
                    ANALYSIS_URL + "/record",
                    json={
                        "request_id": request_id,
                        "version": "new",
                        "result": response.json(),
                        "latency": time.time() - start_time,
                        "timestamp": time.time()
                    }
                )
                return
        except Exception as e:
            if attempt == max_retries - 1:
                print(f"Shadow call failed after {max_retries} retries: {e}")
            time.sleep(0.5 * (attempt + 1))

坑四:成本控制

影子部署意味着每个请求都要跑两遍,成本直接翻倍。在 QPS 高的时候,这个成本很可观。

解决方案:智能采样策略。

  • 对低风险请求 100% 影子(如简单查询)
  • 对高风险请求 50% 影子(如复杂推理)
  • 对已验证场景 20% 影子(如常见对话)
def get_shadow_risk_level(prompt):
    """根据 prompt 评估风险等级"""
    # 简单规则,实际可以用分类模型
    if len(prompt) < 50:
        return "low"
    elif "code" in prompt or "debug" in prompt:
        return "high"
    else:
        return "medium"

SHADOW_RATES = {
    "low": 1.0,
    "medium": 0.5,
    "high": 0.2
}

def should_shadow(prompt):
    risk_level = get_shadow_risk_level(prompt)
    return random.random() < SHADOW_RATES[risk_level]

结果与效果

经过一个月的优化,我们的影子部署系统现在运行得比较稳定。

实际数据

以最近的模型升级为例:

指标测试环境影子部署线上线后
准确率提升+3.2%+2.8%+2.5%
平均延迟120ms145ms135ms
成本-+85%+0%
发现问题数271

可以看到,影子部署比测试环境更能反映真实情况,帮助我们提前发现了 5 个潜在问题。

主要收获

  1. 提前发现问题:在正式上线前就发现了新模型在特定场景下性能下降的问题
  2. 快速决策:有了真实数据支撑,决策周期从 3 天缩短到 4 小时
  3. 降低风险:避免了 2 次可能影响用户的故障
  4. 成本可控:通过智能采样,将额外成本控制在 85% 左右

影子部署的限制

虽然影子部署很有用,但它也有自己的局限性。我们在实践中发现这些限制很重要:

1. 不可用于有状态服务

如果你的服务有状态(比如推荐系统的用户画像),影子部署就无法真正模拟线上效果。因为影子版本和旧版本看到的状态可能不一致。

2. 无法验证用户体验

影子部署只能看技术指标(延迟、准确率等),但无法验证用户体验(满意度、留存等)。这些还需要通过其他方式收集。

3. 写操作需要特别处理

如果是写操作(如创建订单),影子部署会产生脏数据。需要配置影子数据库或特殊处理。

def shadow_call_with_db_isolation(prompt, request_id):
    """针对写操作的影子部署"""
    # 切换到影子数据库
    db_session = get_shadow_db_session()

    try:
        response = requests.post(
            NEW_SERVICE_URL + "/predict",
            json={"prompt": prompt, "db_session": db_session},
            timeout=30
        )
        # 清理影子数据
        cleanup_shadow_data(db_session)
    finally:
        db_session.close()

4. 资源开销

虽然可以通过采样降低成本,但影子部署本质上还是需要额外的资源。在资源紧张时可能需要权衡。

最佳实践

基于我们的经验,这里分享一些最佳实践:

1. 分阶段使用

不要一上来就用影子部署,建议按照这个顺序:

graph LR A[单元测试] --> B[集成测试] B --> C[测试环境验证] C --> D[影子部署] D --> E[小流量灰度] E --> F[全量发布]

2. 关注关键指标

不要看太多指标,关注核心的几个:

  • 技术指标:延迟、错误率、吞吐量
  • 业务指标:准确率、相关性、安全性
  • 成本指标:计算资源消耗、token 使用量

3. 设置预警阈值

在影子部署时设置合理的预警阈值:

ALERT_THRESHOLDS = {
    "latency_increase": 50,  # 延迟增加50ms
    "error_rate_increase": 0.01,  # 错误率增加1%
    "accuracy_drop": 0.02  # 准确率下降2%
}

def check_alerts(comparison_data):
    alerts = []

    if comparison_data['avg_latency_diff'] > ALERT_THRESHOLDS['latency_increase']:
        alerts.append("新版本延迟增加过多")

    if comparison_data['error_rate_diff'] > ALERT_THRESHOLDS['error_rate_increase']:
        alerts.append("新版本错误率增加过多")

    if comparison_data['accuracy_diff'] < -ALERT_THRESHOLDS['accuracy_drop']:
        alerts.append("新版本准确率下降过多")

    return alerts

4. 建立回滚机制

一旦发现异常,要能快速回滚:

def auto_rollback_if_needed():
    comparison = get_comparison_data()
    alerts = check_alerts(comparison)

    if alerts:
        print(f"发现异常: {alerts}")
        print("自动回滚到旧版本")
        rollback_to_old_version()
        notify_team(alerts)
        return False

    return True

结语

影子部署不是万能的,但在 AI 模型升级这个高风险场景下,它确实提供了很好的安全网。

我们的经验是:在能承受的成本范围内,尽可能多地进行影子验证。 因为一旦出问题,修复成本远高于验证成本。

当然,影子部署的真正价值不在于工具本身,而在于它提供的真实数据。这些数据能让你在做出技术决策时更有底气,而不是靠猜。

希望这篇文章能帮助你在自己的项目中更好地应用影子部署。如果有任何问题或不同的实践,欢迎交流。

最后说一句:部署验证是个持续优化的过程,没有完美的方案,只有更适合自己团队的方案。

版权声明: 本文首发于 指尖魔法屋-AI影子部署:测试不够用了之后https://blog.thinkmoon.cn/post/291-ai-shadow-deployment-test-validation-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!