把算力换到成本时踩过的坑

3000 多美元的 SageMaker 账单,而我们的服务日活还不到 5 万。

用的是 GPT-4 兼容的 7B 模型,部署在 SageMaker,用了 4 个 ml.g5.xlarge 实例。

问题出在哪儿?

先做个成本拆解。用的是 GPT-4 兼容的 7B 模型,部署在 SageMaker,用了 4 个 ml.g5.xlarge 实例。平均每个请求 200 tokens,响应时间 800ms 左右。

# 账单拆解时的关键数据
aws ce get-cost-and-usage \
  --time-period Start=2026-06-01,End=2026-06-30 \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --filter '{
    "Dimensions": {
      "Key": "SERVICE",
      "Values": ["Amazon SageMaker"]
    }
  }' \
  --query 'ResultsByTime[0].Total.BlendedCost'
# 输出: {"Amount": "3142.87", "Unit": "USD"}

问题很快就出来了:

  1. 实例利用率低:监控显示 CPU 长期在 20-30% 之间徘徊
  2. 冷启动频繁:自动扩缩容策略设得太激进,导致频繁启停实例
  3. 模型没有量化:直接用 FP16 部署,推理延迟偏高
  4. 没有请求聚合:每条用户消息都单独跑一次推理,即使可以通过缓存命中

更糟糕的是,团队还在讨论要不要换更大的模型来"提升用户体验"。

第一步:把模型量化和减小

先从最直接的下手。模型用的是 Meta 的 Llama-2-7B-chat-hf,原始 FP16 版本占用约 14GB 显存。我们用 AutoGPTQ 做了 4-bit 量化:

from transformers import AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig

# 量化配置
quantize_config = BaseQuantizeConfig(
    bits=4,  # 4-bit 量化
    group_size=128,
    damp_percent=0.01,
    desc_act=False,
)

# 加载原始模型
model = AutoGPTQForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-chat-hf",
    quantize_config=quantize_config,
)

# 准备校准数据
calibration_data = [
    "用户: 你好\n助手: 你好!有什么我可以帮助你的吗?",
    "用户: 解释一下什么是机器学习\n助手: 机器学习是一种人工智能...",
    # ... 准备 100-200 条代表性的对话数据
]

# 执行量化
model.quantize(calibration_data, cache_examples_on_gpu=True)

# 保存量化模型
model.save_quantized("./llama-2-7b-4bit")

量化后模型大小从 14GB 降到约 4.5GB,推理显存占用从 18GB 降到 8GB 左右。这意味我们可以把部署实例从 ml.g5.xlarge(16GB 显存)换成更便宜的 ml.g5.2xlarge,然后在一个实例上跑 2 个模型副本。

但这事儿没那么简单

量化后第一个坑就来了:某些长文本生成任务偶尔出现"胡言乱语"的现象。用户投诉说"AI 有时会突然说一些完全不通的话"。

查了半天才发现,4-bit 量化在极端长度(超过 2000 tokens)的序列上会出现精度问题。最后做了个妥协:

def get_model_for_request(request_length):
    if request_length > 2000:
        # 长文本用 FP16 模型
        return fp16_model_path
    else:
        # 短文本用 4-bit 量化模型
        return quantized_model_path

这样虽然复杂了点,但确实解决了问题。更重要的是,这个"复杂度"带来了立竿见影的成本下降:月度账单从 3142 美元降到了 1860 美元。

第二步:优化实例调度和扩缩容

接下来处理实例利用率低的问题。之前用的是默认的 SageMaker 自动扩缩容,目标是保持 30% 的 CPU 利用率。问题在于:

# 之前的扩缩容配置(有问题)
ScalingPolicies:
  - TargetTrackingScalingPolicyConfiguration:
      TargetValue: 30.0  # CPU 利用率目标
      PredefinedMetricSpecification:
        PredefinedMetricType: SageMakerVariantInvocationsPerInstance
      ScaleOutCooldown: 300  # 5 分钟
      ScaleInCooldown: 300

这个配置有个致命问题:它只看"每实例调用数",但没考虑"调用耗时"。而我们的调用耗时波动很大(200ms-1500ms),导致扩缩容判断失真。

改用自定义指标后情况好多了:

# 自定义 CloudWatch 指标:计算每个实例的"待处理请求数"
def calculate_backlog_metric(instance_id):
    # 获取实例当前处理的请求数
    active_requests = get_active_requests(instance_id)
    # 获取平均处理时间
    avg_process_time = get_avg_process_time(instance_id)
    # 估算待处理积压
    backlog = active_requests * (avg_process_time / 1000)
    return backlog

# 发送到 CloudWatch
cloudwatch.put_metric_data(
    Namespace='SageMaker/Custom',
    MetricData=[{
        'MetricName': 'InstanceBacklog',
        'Dimensions': [{'Name': 'InstanceId', 'Value': instance_id}],
        'Value': backlog,
        'Unit': 'Count'
    }]
)
# 优化后的扩缩容配置
ScalingPolicies:
  - TargetTrackingScalingPolicyConfiguration:
      TargetValue: 5.0  # 目标是每个实例最多积压 5 个请求
      CustomizedMetricSpecification:
        MetricName: InstanceBacklog
        Namespace: SageMaker/Custom
        Statistic: Average
      ScaleOutCooldown: 60   # 快速扩容
      ScaleInCooldown: 600  # 慢速缩容,避免频繁启停

这一步的效果非常直接:实例平均 CPU 利用率从 25% 提升到了 65%,同时实例数量从平均 4 个降到了 2.5 个。

第三步:加个请求缓存层

当时有个很荒谬的现象:我们 30% 的请求其实是"重复的"。用户经常问类似的问题,但我们每次都重新跑一遍推理。

加了个简单的缓存层:

import hashlib
import json
from functools import lru_cache

# 缓存配置
CACHE_TTL = 3600  # 1 小时
CACHE_KEY_PREFIX = "ai_response:"

def generate_cache_key(prompt, model_version, temperature):
    """生成缓存键"""
    cache_data = {
        "prompt": prompt,
        "model": model_version,
        "temperature": temperature,
    }
    return hashlib.sha256(
        json.dumps(cache_data, sort_keys=True).encode()
    ).hexdigest()

async def get_cached_response(cache_key):
    """从 Redis 获取缓存"""
    try:
        cached = await redis.get(f"{CACHE_KEY_PREFIX}{cache_key}")
        if cached:
            return json.loads(cached)
    except Exception as e:
        # 缓存失败不影响主流程
        logger.warning(f"Cache get failed: {e}")
    return None

async def cache_response(cache_key, response):
    """缓存响应"""
    try:
        await redis.setex(
            f"{CACHE_KEY_PREFIX}{cache_key}",
            CACHE_TTL,
            json.dumps(response)
        )
    except Exception as e:
        logger.warning(f"Cache set failed: {e}")

async def generate_with_cache(prompt, model_version, temperature=0.7):
    """带缓存的推理"""
    cache_key = generate_cache_key(prompt, model_version, temperature)

    # 尝试从缓存获取
    cached = await get_cached_response(cache_key)
    if cached:
        logger.info(f"Cache hit: {cache_key[:8]}...")
        return cached

    # 缓存未命中,执行推理
    response = await model.generate(prompt, temperature=temperature)

    # 缓存结果
    await cache_response(cache_key, response)

    return response

这个简单的缓存层带来了 28% 的缓存命中率,直接减少了近三分之一的推理调用。

但这里也有个坑:有些用户反馈"AI 回答太死板了"。后来发现是我们的缓存键太严格,甚至连标点符号的微小差异都会导致缓存未命中。

调整了缓存策略:

def normalize_prompt(prompt):
    """标准化 prompt 以提高缓存命中率"""
    # 去除多余空格
    prompt = ' '.join(prompt.split())
    # 统一标点符号
    prompt = prompt.replace(',', ',').replace('。', '.')
    # 转小写(仅对英文部分)
    import re
    prompt = re.sub(
        r'[a-zA-Z]+',
        lambda m: m.group(0).lower(),
        prompt
    )
    return prompt

调整后缓存命中率提升到了 35%,同时用户投诉也少了。

第四步:混合云部署

做到这一步,账单已经从 3142 美元降到了 980 美元。但团队还是觉得"能不能再低点"。

这时候开始考虑混合云部署:把大部分推理流量放到更便宜的 GPU 实例上,只把关键请求留在 SageMaker。

我们选了 RunPod,因为它的 GPU 实例比 AWS 便宜约 40-50%。但有个问题:RunPod 不支持 SageMaker 那种开箱即用的模型部署,需要自己处理很多运维细节。

最终采用了这样的架构:

graph TD A[用户请求] --> B{请求类型判断} B -->|关键业务请求| C[AWS SageMaker] B -->|普通请求| D[RunPod GPU 实例] C --> E[FP16 模型] D --> F[4-bit 量化模型] E --> G[响应] F --> G A --> H{缓存检查} H -->|缓存命中| I[直接返回] H -->|缓存未命中| B
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential

class MixedInferenceClient:
    def __init__(self):
        self.sagemaker_endpoint = "https://xxx.execute-api.us-east-1.amazonaws.com/prod"
        self.runpod_endpoint = "https://xxx.runpod.io"
        self.cache_client = RedisClient()

    def is_critical_request(self, prompt):
        """判断是否为关键请求"""
        critical_keywords = [
            "支付", "退款", "账户", "密码",
            "legal", "payment", "account"
        ]
        return any(keyword in prompt.lower() for keyword in critical_keywords)

    @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
    async def call_sagemaker(self, payload):
        """调用 SageMaker 端点"""
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.sagemaker_endpoint,
                json=payload,
                timeout=30.0
            )
            response.raise_for_status()
            return response.json()

    @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
    async def call_runpod(self, payload):
        """调用 RunPod 端点"""
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.runpod_endpoint,
                json=payload,
                timeout=30.0
            )
            response.raise_for_status()
            return response.json()

    async def generate(self, prompt, temperature=0.7):
        # 检查缓存
        cached = await self.cache_client.get(prompt)
        if cached:
            return cached

        # 判断请求类型
        if self.is_critical_request(prompt):
            # 关键请求用 SageMaker
            response = await self.call_sagemaker({
                "prompt": prompt,
                "temperature": temperature
            })
        else:
            # 普通请求用 RunPod
            response = await self.call_runpod({
                "prompt": prompt,
                "temperature": temperature,
                "max_tokens": 512
            })

        # 缓存结果
        await self.cache_client.set(prompt, response, ttl=3600)

        return response

这个混合部署的策略是:约 15% 的关键请求(涉及支付、账户等)走 SageMaker,其余 85% 的普通请求走 RunPod。

效果立竿见影:月度账单从 980 美元降到了 780 美元。但这里也有坑:

  1. RunPod 稳定性不如 AWS:偶尔会出现实例不可用的情况,需要更好的重试策略
  2. 延迟稍微增加:跨云调用增加了约 100-200ms 的延迟
  3. 运维复杂度上升:需要同时管理两套基础设施

针对这些问题,我们做了调整:

# 改进的健康检查和回退策略
async def generate_with_fallback(self, prompt, temperature=0.7):
    """带回退策略的推理"""
    # 检查缓存
    cached = await self.cache_client.get(prompt)
    if cached:
        return cached

    # 判断请求类型
    if self.is_critical_request(prompt):
        # 关键请求直接用 SageMaker
        response = await self.call_sagemaker({
            "prompt": prompt,
            "temperature": temperature
        })
    else:
        # 普通请求先尝试 RunPod
        try:
            response = await asyncio.wait_for(
                self.call_runpod({
                    "prompt": prompt,
                    "temperature": temperature,
                    "max_tokens": 512
                }),
                timeout=5.0  # 5 秒超时
            )
        except (asyncio.TimeoutError, httpx.HTTPError) as e:
            # RunPod 失败,回退到 SageMaker
            logger.warning(f"RunPod failed, fallback to SageMaker: {e}")
            response = await self.call_sagemaker({
                "prompt": prompt,
                "temperature": temperature
            })

    # 缓存结果
    await self.cache_client.set(prompt, response, ttl=3600)

    return response

这个回退策略保证了即使 RunPod 出问题,核心服务也不会受影响。

最后的账单

经过这几个月的折腾,最终的月度账单大概是这样:

项目优化前优化后降幅
SageMaker 实例$2,850$38087%
RunPod 实例$0$320-
CloudWatch/日志$180$6067%
其他 AWS 服务$113$2082%
总计$3,143$78075%

量化、缓存与混合云三轮优化后,账单结构发生了明显变化——下图按分项对比优化前后的月度费用。

AI 推理服务月度账单分项对比:SageMaker、RunPod、CloudWatch/日志与其他 AWS 服务(USD)

SageMaker 实例费用从 $2,850 降到 $380,RunPod 承接了大部分普通流量,总账单降幅达 75%。

关键指标变化:

  • 平均推理延迟:800ms → 650ms(反而提升了)
  • 缓存命中率:0% → 35%
  • 实例平均 CPU 利用率:25% → 68%
  • P99 响应时间:2.3s → 1.8s

经验总结

回顾整个过程,有些经验值得分享:

  1. 先监控再优化:很多团队一上来就想"用什么技术降成本",但连钱花在哪儿都不知道。先做详细的成本拆解和监控,找到真正的浪费点。

  2. 量化是最直接的下手点:模型量化虽然不是银弹,但确实是成本优化中最直接的手段。4-bit 量化在我们的场景下几乎没有明显的质量损失。

  3. 缓存不是万能的:缓存能显著降低推理调用,但要注意缓存失效策略和用户体验的平衡。我们踩过"缓存太严格导致用户体验变差"的坑。

  4. 混合云要慎重:混合部署确实能降低成本,但会增加运维复杂度。小团队在考虑前要评估是否有足够的人力维护多套基础设施。

  5. 不要过度优化:从 3143 美元降到 780 美元后,再往下优化的边际收益就很低了。与其继续抠那几十美元,不如把精力放在产品功能上。

这次优化的本质不是"技术多牛",而是"账单逼出来的务实选择"。很多时候,成本优化的第一步不是选什么框架或算法,而是先搞清楚钱到底花在哪儿。


最后留个问题:如果你的 AI 服务月度账单突然翻倍,你会先查哪儿?

版权声明: 本文首发于 指尖魔法屋-把算力换到成本时踩过的坑https://blog.thinkmoon.cn/post/191-ai-cost-optimization-computing-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!