选部署形态时最容易被忽略的一点:它不是技术选型,是成本结构选型。同一套模型,自建付出的是固定成本和人的时间,托管付出的是单价乘 token 数,混合付出的是两套系统的复杂度。
先算清这三种成本曲线在什么流量下交叉,再谈运维能力。凭"我们有 GPU"或"云上更省事"拍板,通常会在半年后以一次账单或一次事故的形式还回来。
1. 三种形态的成本结构
| 维度 | 自建 | 托管 | 混合 |
|---|---|---|---|
| 成本性质 | 固定(GPU 租金/折旧 + 人力) | 变动(按 token 或按调用) | 固定底载 + 变动峰值 |
| 边际成本 | 接近零(未饱和时) | 恒定单价 | 分段 |
| 前期投入 | 高(采购、部署、调优) | 近零 | 中 |
| 运维负担 | 驱动、CUDA、显存、扩容全要人 | 供应商承担 | 两套都要会 |
| 弹性上限 | 受限于已有卡数 | 受限于供应商配额 | 高 |
| 数据流向 | 完全自控 | 出域到供应商 | 按分流决定 |
| 出问题时的锅 | 自己的 | 供应商 + 自己的调用层 | 定位最难 |
月成本(自建) ≈ GPU 卡数 × 单卡月租(或折旧+电) + 人力(人月 × 人月成本) + 带宽/存储
月成本(托管) ≈ 输入 token × 输入单价 + 输出 token × 输出单价 + 平台费
交叉点:使两式相等的月 token 量,即"自建更划算"的流量门槛
交叉点必须用自己的单价与人力成本算,别人的基准值只能当量级参考。有一点几乎总成立:流量低于某个门槛时,人力成本那一项就足以让自建不划算——招一个能处理驱动、OOM、调度、扩容的人,成本常常高于托管的账单。
2. 自建的适用条件
| 条件 | 判断标准 | 不满足时的后果 |
|---|---|---|
| 流量稳定且高 | 底载长期高于交叉点,GPU 常年饱和 | 卡空转,单位成本比托管贵 |
| 数据不能出域 | 合规或合同明确要求 | 走托管即违规 |
| 需要定制模型 | 自训权重、私有 tokenizer、特殊采样 | 托管多数不让你换 kernel |
| 需要极低延迟 | 同机房、无跨网、可 pin 住版本 | 公网托管多一层不可控 |
| 有 GPU 运维能力 | 有人能接 OOM、驱动升级、故障 | 事故恢复时间远长于托管 |
"数据不能出域"是硬约束,"延迟更低"是软约束——后者常常被低估:托管服务如果就在同一 region,网络延迟可能只差几毫秒,而这层差距在端到端体验里往往被前端渲染淹没。
3. 托管的适用条件
| 条件 | 说明 |
|---|---|
| 流量波动大 | 白天峰值是夜间数倍,按量计费天然贴合 |
| 团队没有 GPU 运维 | 没人值夜班处理 GPU 掉卡 |
| 试错期 | 模型换代快,不想为每个模型买卡 |
| 需要多模型并存 | 按请求选模型,供应商侧一起解决 |
| 长尾需求 | 偶尔的超大上下文请求,托管按次付费更合理 |
托管要提前确认三件事:配额上限(并发与 RPM/TPM)、限流行为(429 还是排队)、版本稳定性(模型是否会被静默更新)。这三件都直接影响客户端设计。
4. 混合的分流判据
| 判据 | 分流规则 | 优点 | 风险 |
|---|---|---|---|
| 敏感级别 | 含隐私字段 → 自建;公开内容 → 托管 | 合规清晰 | 分类错了就是事故 |
| 延迟要求 | 交互式 → 自建;离线批处理 → 托管 | 体验与成本兼顾 | 两套链路都要维护 |
| 成本阈值 | 月累计 token 超阈值后切自建 | 直接省钱 | 切换抖动、缓存不共享 |
| 可用性兜底 | 自建为主,托管做溢出与故障接管 | 抗峰值 | 契约不一致导致行为差异 |
| 模型能力 | 简单任务自建小模型,复杂任务托管大模型 | 单位成本最优 | 路由判定本身要准 |
def route(req):
if req.contains_pii or req.tenant.region == "cn":
return "self_hosted"
if req.latency_budget_ms < 800 and req.stream:
return "self_hosted"
if req.prompt_tokens > 32000 or req.task in HEAVY_TASKS:
return "managed" # 长上下文不占本地 KV
return "self_hosted" if local_pool.has_capacity() else "managed"
路由要落日志:每次分流都记录判据与结果,否则"为什么这个请求走了托管"永远查不清。
5. 自建的关键运维项
| 运维项 | 具体内容 | 常见失手 |
|---|---|---|
| 驱动与 CUDA | 驱动版本、CUDA runtime、框架 wheel 三者匹配 | 升驱动导致 kernel 不兼容 |
| 显存碎片 | 长跑进程的缓存分配器碎片 | 重启才恢复,需定期滚动 |
| 模型热更新 | 新权重加载、双缓冲、灰度 | 直接替换文件导致半加载 |
| 多副本负载均衡 | 会话粘性或共享 KV 前缀 | 轮询把长请求分到已饱和副本 |
| 健康检查 | 测真实推理,不测端口 | 端口通但队列排到超时 |
| 日志与指标 | TTFT、TPOT、队列长度、抢占计数 | 只看 GPU 利用率 |
健康检查这一项值得单独说:/health 返回 200 只证明进程活着。真实推理健康检查要发一个小请求并限超时:
# 真实推理探针:小 prompt + 严格超时,失败即摘流量
curl -s -m 3 http://127.0.0.1:8000/v1/completions \
-H 'Content-Type: application/json' \
-d '{"model":"MODEL","prompt":"ping","max_tokens":1,"temperature":0}' \
| grep -q '"choices"' || exit 1
# Kubernetes 探针示例:readiness 用真实推理,liveness 只查进程
readinessProbe:
exec:
command: ["/bin/sh", "-c", "curl -s -m 3 localhost:8000/health && curl -s -m 3 -X POST localhost:8000/v1/completions -d '{\"model\":\"MODEL\",\"prompt\":\"ping\",\"max_tokens\":1}' | grep -q choices"]
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
startupProbe:
tcpSocket: {port: 8000}
failureThreshold: 60
periodSeconds: 10
6. 弹性与冷启动
扩容的最小粒度不是"一个 Pod",而是一次模型加载所需的时间。7B 从磁盘到可服务通常是几十秒到几分钟量级,70B 或网络存储上更久——具体按你的存储带宽、模型大小、是否启用权重预取核对:
加载时间 ≈ 权重体积 / 存储带宽 + 引擎初始化(编译 kernel、建 CUDA 图)
扩容有效时刻 = 触发时刻 + 加载时间 + 预热请求时间
如果这个时刻晚于客户端超时,扩容等于没扩。三种缓解:
| 做法 | 效果 | 代价 |
|---|---|---|
| 预热池(常驻备用副本) | 秒级接管 | 常驻卡成本 |
| 权重放本地 NVMe / 内存盘 | 缩短加载 | 存储成本 |
| 提前扩容(按历史曲线定时) | 峰值前就位 | 闲时浪费 |
7. 成本可观测:三个口径
| 口径 | 计算方式 | 用在哪 |
|---|---|---|
| 按请求 | 请求数 × 平均单价 | 对外报价、按租户分摊 |
| 按 token | 输入/输出 token 分别统计 | 与托管账单对账 |
| 按 GPU 小时 | 卡数 × 时长 × 单价 | 自建真实成本、容量决策 |
# 每次调用落一条成本记录,字段与账单口径对齐
rec = {"request_id": rid, "model": m, "in_tok": it, "out_tok": ot,
"gpu_hours": gpu_seconds / 3600, "route": route_name,
"cost_est": it * PIN + ot * POUT if route_name == "managed" else gpu_seconds / 3600 * GPU_HOUR}
三个口径必须能相互对上,否则"按 token 算很便宜、按 GPU 小时算很贵"的争论永远没有结论。句柄里最小可用的指标是每千 token 的 GPU 秒数,它同时反映调度效率与模型大小。
8. 容量规划
副本数 ≈ ceil(峰值并发 × 单请求显存 / (单卡可用显存 × 安全系数)) × 冗余系数
import math
def replicas(peak_concurrency, kv_per_req_gib, weights_gib, card_gib,
util=0.9, overhead=1.5, redundancy=1.3):
per_replica = int((card_gib * util - weights_gib - overhead) // kv_per_req_gib)
per_replica = max(1, per_replica)
return math.ceil(peak_concurrency / per_replica * redundancy), per_replica
print(replicas(200, 1.0, 3.5, 24)) # 峰值并发 200、单请求 KV 1 GiB、INT4 7B、24G 卡
冗余系数 不是拍脑袋:单副本故障、滚动升级、以及"峰值比预估高 20%"都要靠它吸收。冗余 1.3 意味着常态利用率约 77%,这个水平对 GPU 服务是可接受的。
9. 合规与数据流向
| 落点 | 可能留下什么 | 处理 |
|---|---|---|
| 应用日志 | 完整 prompt 与输出 | 脱敏、截断、按级别采样 |
| 引擎日志 | 请求 ID、token 数 | 一般不含内容,核对确认 |
| 前缀缓存 | 提示词前缀的 KV 与哈希 | 跨租户共享要隔离 |
| 监控与追踪 | span 属性里常带 prompt | 属性白名单 |
| 托管侧 | 供应商的日志与留存策略 | 按合同与开关配置 |
前缀缓存这一项最容易被忘:混合部署时若多个租户共用一份前缀缓存,缓存键里必须带租户维度,否则等于把别人的上下文块挂在自己名下。
10. 排错:五个高频坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 自建单位成本反而更高 | 只算 GPU 租金,没算人力与闲置 | 用第 1 节公式重算,含人力 |
| 扩容了还是超时 | 冷启动时间 > 客户端超时 | 预热池或提前扩容 |
| 健康检查全绿但请求排队 | 探针只测端口//health |
改真实推理探针 + 队列长度指标 |
| 托管 429 后大面积失败 | 客户端无退避、无重试预算 | 指数退避 + 抖动 + 降级路由 |
| 灰度新模型后指标突变 | 参数、模板、量化版本未同步 | 发布单元带完整配置快照 |
11. 边界:形态之外的变量
| 变量 | 影响 | 说明 |
|---|---|---|
| 网络位置 | 端到端延迟 | 同 region 与跨地域差一个量级 |
| 模型许可证 | 能否商用/托管 | 先看许可,再谈成本 |
| 供应商锁定 | 迁移成本 | 接口抽象层能缓解,但采样行为难对齐 |
| 峰值倍率 | 弹性需求 | 峰值/均值比越高,越偏向托管 |
| 团队规模 | 隐性成本 | 一人团队的自建风险被严重低估 |
12. 自测
- 自建与托管的成本交叉点由哪几项决定?漏掉哪一项最常导致误判?
- 混合部署里,"数据敏感级别"这条判据为什么优先级高于成本判据?
- 健康检查通过但用户仍在等待,问题最可能出在哪个环节?
- 为什么说扩容的最小粒度是"一次模型加载时间",而不是"一个副本"?
- 成本核算的三种口径如何互相对账?哪个指标最能反映调度效率?
- 多个租户共用前缀缓存时,缓存键为什么必须带租户维度?