私有化部署选型:把模型搬回自己的机器

私有化部署要回答的不是「能不能跑起来」,而是「这台机器的显存,配得上哪个尺寸的模型」。

30″30 秒看懂私有化部署

调云端大模型 API,像是把食材寄到城里的中央厨房:厨子手艺一流、设备顶级,但你的食材必须先离开自己家,交到别人手上,做好了再寄回来。

私有化部署就是在自家后厨支一口灶:厨子可能没那位名厨那么强,但食材从头到尾没出过家门,你想什么时候开火就什么时候开火,也不按次收费。企业愿意折腾这件事,几乎都不是因为模型更聪明,而是因为那袋食材见不得人——病历、合同、工资表、代码库。

图① 30 秒看懂:中央厨房与自家后厨
图① 30 秒看懂:中央厨房与自家后厨
比喻里的角色对应的技术概念它到底意味着什么
中央厨房云端大模型 API模型在厂商机房,你发请求、按 token 付费,数据必然出网
自家后厨私有化部署模型权重落在你自己的磁盘上,推理在你自己的机器上发生
灶台面积显存(VRAM)决定你能炒多大的锅——也就是能跑多大参数量的模型
菜谱的厚薄量化档位(Q4 / Q8 / FP16)同一道菜的精简版,占地小了,细节也丢了一些
傻瓜料理机Ollama把「下载权重、装推理引擎、起服务」压成几条命令
食材你的业务数据私有化真正要守住的东西,也是整件事唯一的出发点
⛔ 这一讲只有一条铁律 模型跑在哪台机器上,数据就流到哪台机器上。私有化不会让模型变聪明,它只改变一件事——数据的流向。所以选型的第一个问题永远是「这袋食材能不能出门」,而不是「哪个模型跑分高」。

01概念:私有化部署到底私有了什么

先把「私有」这两个字拆清楚,再谈怎么装

1.1 什么是私有化部署

一句话定义:把大模型的权重文件放在你自己控制的机器上,推理过程完全在这台机器内部完成,不依赖任何外部服务。

注意「你自己控制」这个限定——它不等于「本地」。下面三种都算私有化部署:

  • 开发机本地跑。笔记本插着显卡,模型在自己电脑上,最典型的入门形态。
  • 公司内网服务器跑。模型在机房的一台 GPU 服务器上,全公司通过内网访问。企业里最常见的就是这种。
  • 自己租的云服务器跑。机器是租来的,但操作系统权限、模型文件、日志都归你,厂商拿不到推理内容。

而调 OpenAI、智谱、通义这些厂商提供的 API,无论包装成什么形态,推理都发生在厂商的机器上,都不是私有化。

三个常被混为一谈的词 私有化部署解决「模型跑在哪」;微调解决「模型会不会干你这行的活」;RAG解决「模型知不知道你公司的资料」。三件事互相独立:私有化的模型照样可以不懂业务,云端的模型照样能接 RAG。别把它们当成一条路上的三个台阶。

1.2 六个维度对比云端 API

这张表是选型的主要依据。没有哪一列全是优势——真做决策时,往往只有一两行是决定性的,剩下的都是次要因素。

维度云端 API私有化部署
数据流向必然出网始终在内网,这是绝大多数项目选私有化的唯一真实理由
模型能力明显更强同等预算下能跑的模型小得多,复杂推理、长文本写作差距很明显
成本结构按 token 付费,用多少付多少一次性买卡 + 长期电费运维,低频使用反而更贵
响应速度受公网和厂商限流影响内网延迟低,但生成速度取决于你的卡,小显存机器可能更慢
可用性厂商负责,但会限流、会改价、会下线模型版本你自己钉死,代价是挂了没人管,得自己值班
合规审计要看厂商的资质和协议日志、访问记录全在自己手上,好过审
⚠️ 成本这一行最容易算错 很多人只比「token 单价 vs 电费」,结论必然是私有化便宜。但真实账单里还有:显卡采购、机房或托管、运维人力、模型升级的回归测试。每天只有几百次调用的场景,私有化几乎一定比调 API 贵——这时候还坚持私有化,理由只能是数据不能出网,不能是省钱。

这笔账口算容易漏项,干脆写成脚本。关键设计是把私有化的月支出和调用量解耦——卡买了就在那儿,跑不跑都要交电费和运维工时,这正是低频场景吃亏的根源:

cost_compare.py —— 私有化与云端 API 的回本测算成本测算
"""成本测算:私有化部署 vs 调云端 API,到底几个月才回本。

写这个脚本的原因:
    立项会上最常见的说法是「自己部署省钱」。
    但真实账单里除了电费,还有显卡采购、机房托管、运维人力、升级回归测试。
    很多低频场景算下来,私有化比调 API 贵好几倍 —— 只是没人算过。

这个脚本不替你做决定,它只做一件事:
    把两条路的钱都摊到「每月」,然后告诉你回本点在第几个月。
    如果回本点超过显卡的服役年限,那「省钱」这个理由就不成立,
    该老实承认真正的理由是「数据不能出网」。

用法:
    python3 cost_compare.py                      # 用内置的默认参数跑一遍
    python3 cost_compare.py --calls-per-day 5000 # 换成你自己的调用量
"""
import argparse
import sys

# --------------------------------------------------------------------------
# 默认参数:全部按 TODO 换成你们自己的真实数字,别直接拿去汇报
# --------------------------------------------------------------------------
DEFAULTS = {
    # ---- 业务量 ----
    "calls_per_day": 500,          # 每天调用次数
    "tokens_in": 800,              # 单次输入 token 数(含提示词和历史)
    "tokens_out": 400,             # 单次输出 token 数

    # ---- 云端 API 侧(单位:元 / 百万 token)----
    # 各家价格差别很大,且经常调整,务必以当天官网报价为准
    "api_price_in": 2.0,
    "api_price_out": 8.0,

    # ---- 私有化侧:一次性投入(元)----
    "gpu_cost": 16000,             # 显卡采购
    "server_cost": 8000,           # 主机其余部分
    "setup_hours": 24,             # 首次部署投入的人力工时

    # ---- 私有化侧:每月固定支出(元)----
    "power_watt": 450,             # 整机平均功耗(瓦)
    "power_price": 0.8,            # 电价,元 / 度
    "hosting_month": 0,            # 机房托管费;放办公室就是 0
    "ops_hours_month": 8,          # 每月运维工时

    # ---- 人力单价与服役年限 ----
    "hourly_rate": 150,            # 工程师时薪(元)
    "service_months": 36,          # 显卡预计服役月数
}


# --------------------------------------------------------------------------
def api_monthly(p):
    """云端 API 的每月支出:纯粹按量计费,用多少付多少。"""
    calls_month = p["calls_per_day"] * 30
    tin = calls_month * p["tokens_in"] / 1e6
    tout = calls_month * p["tokens_out"] / 1e6
    return tin * p["api_price_in"] + tout * p["api_price_out"]


def private_upfront(p):
    """私有化的一次性投入:硬件 + 首次部署人力。"""
    return p["gpu_cost"] + p["server_cost"] + p["setup_hours"] * p["hourly_rate"]


def private_monthly(p):
    """私有化的每月固定支出。

    注意这里和调用量无关:卡买了就在那儿,跑不跑都要交电费和运维。
    这正是低频场景吃亏的根源 —— 调用量再低,月支出也降不下来。
    """
    # 按 7×24 全天开机估电费:功率(W) ÷ 1000 × 24 × 30 = 每月度数
    power = p["power_watt"] / 1000 * 24 * 30 * p["power_price"]
    ops = p["ops_hours_month"] * p["hourly_rate"]
    return power + p["hosting_month"] + ops


def breakeven_month(p):
    """回本点:第几个月开始,私有化的累计成本低于 API 的累计成本。

    返回 None 表示在服役期内始终不回本。
    """
    up = private_upfront(p)
    pm = private_monthly(p)
    am = api_monthly(p)
    # 每月省下来的钱;不为正说明连月支出都打不过 API,永远回不了本
    saved = am - pm
    if saved <= 0:
        return None
    months = up / saved
    return months if months <= p["service_months"] else None


# --------------------------------------------------------------------------
def report(p):
    am = api_monthly(p)
    up = private_upfront(p)
    pm = private_monthly(p)
    calls_month = p["calls_per_day"] * 30

    print("=" * 58)
    print("业务量:每天 %d 次,每月约 %d 次" % (p["calls_per_day"], calls_month))
    print("        单次 %d token 进 / %d token 出"
          % (p["tokens_in"], p["tokens_out"]))
    print("-" * 58)
    print("【云端 API】")
    print("  一次性投入      0 元")
    print("  每月支出        %8.0f 元(随调用量涨落)" % am)
    print("【私有化部署】")
    print("  一次性投入      %8.0f 元(显卡 %d + 主机 %d + 部署人力 %d)"
          % (up, p["gpu_cost"], p["server_cost"],
             p["setup_hours"] * p["hourly_rate"]))
    print("  每月支出        %8.0f 元(电费 + 托管 + 运维,与调用量无关)" % pm)
    print("-" * 58)

    be = breakeven_month(p)
    if be is None:
        if am - pm <= 0:
            print("结论:私有化【永远不回本】。")
            print("      光是每月固定支出(%.0f 元)就已经超过 API 的月账单(%.0f 元)。"
                  % (pm, am))
        else:
            print("结论:私有化在 %d 个月服役期内【回不了本】。" % p["service_months"])
        print()
        print("  ⚠️ 这不代表不该私有化,只代表【省钱】这个理由不成立。")
        print("     如果数据确实不能出网,那就用这个理由立项,别用省钱。")
    else:
        print("结论:约第 %.1f 个月回本(服役期 %d 个月)。"
              % (be, p["service_months"]))
        total_saved = (am - pm) * p["service_months"] - up
        print("      整个服役期预计净省 %.0f 元。" % total_saved)
        print()
        print("  ⚠️ 前提是调用量能稳定维持。量掉下来,回本点会立刻往后推。")

    print("=" * 58)
    print("没有计入的成本,评估时请自行补上:")
    print("  · 模型升级时的回归测试人力")
    print("  · 服务故障期间的业务损失")
    print("  · 显卡残值与更换周期")
    print("  · 云端 API 侧的限流重试、以及涨价风险")


def main():
    ap = argparse.ArgumentParser()
    for k, v in DEFAULTS.items():
        ap.add_argument("--" + k.replace("_", "-"), type=type(v), default=v)
    args = ap.parse_args()
    report({k: getattr(args, k) for k in DEFAULTS})
    return 0


if __name__ == "__main__":
    sys.exit(main())

按脚本内置的默认参数(每天 500 次调用、单卡 1.6 万、每月 8 小时运维)跑出来的结论是:

路线一次性投入每月支出回本点
云端 API0 元约 72 元
私有化部署约 27600 元约 1459 元永远不回本

光是每月固定支出就已经是 API 月账单的二十倍,一次性投入根本摊不回来。这个量级的业务,选私有化的唯一正当理由就是数据不能出网——把「省钱」写进立项报告,第一轮评审就会被问穿。

什么时候私有化才真的省钱--calls-per-day 往上调,结论会翻转:调用量足够大时,API 的月账单随量线性上涨,而私有化的月支出基本不动,回本点就出现了。自己拿真实调用量跑一遍,别用别人的结论。

1.3 什么时候不该私有化

说清楚反面比说正面更有用。在此之前,先看一眼本地部署常用的两个模型家族有哪些尺寸可选——尺寸决定了你需要多大的卡,也就决定了这件事划不划算

模型家族可选尺寸特点与适用
qwen30.6b / 1.7b / 4b / 8b / 14b / 30b / 32b / 235b中文场景的常规选择,尺寸梯度密,容易找到贴合显存的那一档
deepseek-r11.5b / 7b / 8b / 14b / 32b / 70b / 671b偏推理链条的模型,回答里会带思考过程,输出更长、更吃生成时间
qwen20.5b / 1.5b / 7b / 72b课程演示常用,体积小、拉取快,本讲的示例都用它
尺寸标号要连版本一起记 命令里必须写全名带冒号版本,例如 qwen2:1.5b。只写 qwen2 会拉到仓库的默认标签,具体是哪一档取决于仓库当时的设置——团队里两个人各拉一次,很可能拿到不同的模型,后面对不上性能数据就会互相扯皮。

说清楚反面比说正面更有用。下面几种情况,建议直接用云端 API

  • 处理的是公开数据。写营销文案、翻译公开资料、做公开知识问答——食材本来就不怕出门,没必要支灶。
  • 任务需要很强的推理能力。复杂代码生成、长链条逻辑推理,本地能跑的小模型和顶级云端模型差距是数量级的,不是调参能补上的。
  • 调用量很小且不稳定。一天几十次的内部工具,租一张卡全天空转,纯属浪费。
  • 没人负责运维。私有化是一个需要长期值班的系统:服务会挂、磁盘会满、显存会泄漏。没人管的私有化部署,三个月后一定是一台没人敢重启的僵尸机器。
✅ 一个实用的折中方案 很多企业最后走的是混合路线:涉密数据走内网私有模型,公开且要求高质量的任务走云端 API,中间用一层统一的接口做路由。这样既守住了数据,又没把能力上限压死。后面第 04 节的 API 接入方式,正好为这种路由打好了基础。

02原理:显存决定你能跑多大的模型

选型的全部算术,就集中在这一节

2.1 参数量怎么换算成显存

模型名字里的 0.5B7B32B 指的是参数个数:B 是 billion,十亿。7B 就是七十亿个参数。每个参数都要占显存,占多少取决于每个参数用几个字节存

精度每参数字节7B 模型权重约占
FP32 单精度4 字节约 28 GB,本地部署基本不用
FP16 半精度2 字节约 14 GB,训练和高精度推理的常见档
Q8 八位量化1 字节约 7 GB
Q4 四位量化约 0.5 字节约 3.5~4.5 GB,Ollama 默认档位

换算口诀很简单:参数量(B)× 每参数字节数 = 权重占用(GB)。7B 的 Q4 模型约 4GB,32B 的 Q4 约 18GB,把这两个数字记住,一眼就能判断一台机器跑不跑得动。

⚠️ 权重不是全部开销 实际显存占用 = 权重 + KV 缓存 + 运行时开销。KV 缓存随上下文长度和并发数增长,是最容易被忽略的一块:同一个模型,num_ctx 从 4096 拉到 32768,缓存能翻好几倍。所以选型时要在权重之上留 20%~30% 余量,卡得刚刚好的配置上线就会 OOM。

把这条口诀和手上的卡对一遍,选型范围立刻就收窄了:

显存常见卡型Q4 下的甜点尺寸说明
6 GB入门独显1.5B 左右只够跑通流程,回答质量有限
8 GBRTX 3060 / 40607B约占 5 GB,留得出上下文余量,学习阶段的主流配置
16 GBRTX 4060 Ti 16G / A400014B约占 10 GB,可以把并发开到 2~4
24 GBRTX 4090 / A1014B(推荐)或 32B(吃紧)多人共用时选 14B,把余量让给并发和上下文
48 GB 以上A6000 / 双卡32B 及以上到这一档才谈得上「本地跑大模型」,但成本也另一个量级了
⚠️ 这张表是起点,不是结论 表里给的是单人、中等上下文下的经验值。并发数、num_ctx、是否同时加载向量模型,都会把甜点尺寸往下压一档。拿它筛掉明显不可能的选项,别拿它当验收标准。

2.2 量化:把菜谱压薄

量化就是用更少的比特去存每一个参数。原本每个参数用 16 位浮点数记录,量化后压到 8 位甚至 4 位整数——像把一本厚厚的精装菜谱缩印成便携本:菜还是那些菜,步骤还在,但一些细微的火候描述被四舍五入掉了。

图② 显存等于灶台面积,量化等于把菜谱压缩成便携本
图② 显存等于灶台面积,量化等于把菜谱压缩成便携本
档位体积质量什么时候选
FP16最大最高显存充裕、对输出质量极敏感,或者要做后续微调
Q8约一半几乎无损显存够用时的稳妥选择,质量损失一般感知不到
Q4约四分之一有可感损失本地部署默认档,同显存下能跑更大的模型,性价比最高
同样的显存,选大模型的低量化还是小模型的高量化? 经验上优先选参数量更大的低量化版本:14B 的 Q4 通常比 7B 的 Q8 表现更好,虽然两者占的显存差不多。参数量带来的能力增益,一般压得过量化带来的损失。但这条不是绝对的,业务上线前务必用自己的真实样本各测一遍。

2.3 显存装不下会怎样

不会直接报错崩掉,而是悄悄变慢——这比报错更坑,因为你不看监控根本不知道发生了什么。

① 模型全进显存速度最快,理想状态
② 装不下,部分溢到内存CPU 和 GPU 混合推理,速度掉几倍
③ 完全用 CPU能跑,但每秒几个 token,体验很差

判断当前落在哪一档,方法只有一个:跑起来以后看进程实际占用。Ollama 提供了 ollama ps,会直接告诉你这个模型是 100% GPU、还是 GPU/CPU 混合。不要凭感觉判断,一定要看这个输出。

2.4 并发怎么吃显存

前面反复提到「并发上去就 OOM」,这里把账算清楚。模型权重是所有请求共用的一份,不会因为来了两个人就加载两份;但 KV 缓存是每个请求一份,来一个人就多一份。

显存构成随并发变化吗说明
模型权重不变只加载一份,所有并发请求共用
KV 缓存成倍增长每个并发请求一份,且随上下文长度线性上涨
运行时开销小幅增长激活值、中间结果等,量级比前两项小

所以那个经常被问的问题——「14B 占 10G,我 24G 的卡是不是能跑两个」——答案是不需要跑两个:同一个模型支持多路并发,只要把 OLLAMA_NUM_PARALLEL 调上去就行,多出来的开销只是 KV 缓存,而不是再来一份 10G 的权重。

⚠️ 并发超过配置值不会报错,只会排队 请求数超过 OLLAMA_NUM_PARALLEL 时,多出来的会进队列等着,表现是单次耗时线性变长,而不是报错。直到队列也满了(OLLAMA_MAX_QUEUE,默认 512),服务端才会直接返回 503。排查「变慢」时记得先看是不是排队,而不是一上来就怀疑模型。

2.5 Ollama 在技术栈里的位置

把模型跑起来,本来要做四件事:下载权重、装推理引擎、做格式转换、写一个服务把它包成接口。Ollama 把这四件事全包了,对外只留几条命令。

图③ Ollama 上手四步:装、起服务、拉模型、对话
图③ Ollama 上手四步:装、起服务、拉模型、对话
方案上手难度适用场景
Ollama最低个人开发、中小规模内部服务;本讲全程用它
vLLM高并发线上服务,吞吐量明显优于 Ollama,配置也复杂得多
LM Studio最低纯桌面图形界面,适合完全不碰命令行的使用者
原生 transformers要改模型结构、做研究或微调时才直接用
✅ 为什么这一讲选 Ollama 它把「跑起来」的门槛压到了三条命令,而且对外暴露的是一套 HTTP 接口——这意味着你今天用 Ollama 写的客户端代码,将来换成 vLLM 也只需要改地址。先用它把整条链路打通,比一上来就啃高性能框架务实得多。

03最小代码:先算清楚这台机器能跑什么

选型不是拍脑袋,三十行就能把账算明白

装 Ollama 之前先做一次估算,能省掉「拉了 20GB 模型才发现跑不动」的整晚时间。下面这个脚本不需要联网,也不需要装任何依赖,纯算术:输入显存大小,输出这块卡放得下哪些模型。

vram_plan.py —— 按显存反推能跑多大的模型选型估算
"""选型估算:这块卡到底放得下多大的模型。

估算口径(务必理解,别当成精确值):
    权重占用 ≈ 参数量 × 每参数字节数
    每参数字节数由量化等级决定:FP16 约 2.0,Q8 约 1.0,Q4 约 0.55
    实际还要再加 KV cache 与运行时开销,这里按经验留 1.2 倍余量

真实占用请以 `ollama ps` 的 SIZE 列和 PROCESSOR 列为准:
PROCESSOR 显示 100% GPU 才是全部装进显存,出现 CPU 就说明溢出了。
"""

# 每个参数在不同量化等级下大致占多少字节
BYTES_PER_PARAM = {
    "FP16": 2.0,
    "Q8": 1.0,
    "Q4": 0.55,
}

# 运行时还要放 KV cache、激活值等,按 1.2 倍留余量
OVERHEAD = 1.2


def estimate_gb(params_b, quant):
    """params_b:参数量,单位 B(十亿)。返回预估显存占用 GB。"""
    per = BYTES_PER_PARAM[quant]
    # params_b × 1e9 个参数 × per 字节 ÷ 1024^3 换成 GiB
    weights = params_b * 1e9 * per / 1024 ** 3
    return weights * OVERHEAD


def fits(vram_gb, params_b, quant):
    need = estimate_gb(params_b, quant)
    return need <= vram_gb, need


def main():
    # 常见显卡显存档位
    cards = [("笔记本核显/无卡(纯CPU)", 0), ("8GB 显卡", 8), ("16GB 显卡", 16), ("24GB 显卡", 24)]
    # 常见模型规模
    models = [("0.5B", 0.5), ("1.5B", 1.5), ("7B", 7), ("14B", 14), ("32B", 32)]

    for quant in ("FP16", "Q4"):
        print("\n量化等级:%s(每参数约 %.2f 字节)" % (quant, BYTES_PER_PARAM[quant]))
        header = "%-12s" % "模型" + "".join("%-22s" % c[0] for c in cards)
        print(header)
        for name, pb in models:
            row = "%-12s" % name
            for _cname, vram in cards:
                ok, need = fits(vram, pb, quant)
                if vram == 0:
                    # 没有独立显卡时跑在内存里,能跑但速度是数量级的差距
                    row += "%-22s" % ("约%.1fGB 内存/慢" % need)
                else:
                    row += "%-22s" % ("%.1fGB %s" % (need, "放得下" if ok else "放不下"))
            print(row)

    print("\n同一个模型,Q4 相对 FP16 大约省下:")
    for name, pb in models:
        a = estimate_gb(pb, "FP16")
        b = estimate_gb(pb, "Q4")
        print("  %-6s %5.1fGB → %5.1fGB  省 %.0f%%" % (name, a, b, (1 - b / a) * 100))


if __name__ == "__main__":
    main()
这段算术怎么来的 核心就一行:参数量 × 1e9 × 每参数字节数 ÷ 1024³BYTES_PER_PARAM 里 Q4 取 0.55 而不是 0.5,是因为量化模型里还有一部分层通常保持较高精度,实际体积总会比理论值略大一点。最后乘的 OVERHEAD = 1.2 是给 KV 缓存和运行时留的余量——这个余量不留,上线并发一上来就 OOM
⚠️ 估算值不能当验收标准 这个脚本给的是数量级判断,用来筛掉明显跑不动的选项,不是精确预测。模型架构、上下文长度、并发数都会影响真实占用。最终结论必须以模型真正跑起来后 ollama ps 的输出为准PROCESSOR 列显示 100% GPU 才算全部装进了显存。

04完整案例:三种机器怎么选型

同一套算术,落到三种真实配置上会得出完全不同的结论

4.1 案例一 · 8GB 显卡的个人开发机

典型配置:RTX 3060 / 4060 笔记本,8GB 显存。这是学习阶段最常见的机器。

候选模型Q4 预估占用结论
qwen2:0.5b不到 1 GB随便跑,适合调通链路、写 demo,但回答质量很有限
qwen2:1.5b约 1 GB入门首选,速度快,简单问答够用
qwen2:7b约 5 GB这块卡的甜点位,留得出上下文余量,质量明显上一个台阶
14B 级别约 10 GB装不下,会溢到内存,速度掉到难以接受

结论:选 7B 的 Q4,上下文别开太大。这台机器的定位是「把流程跑通、做本地实验」,不要指望它承载线上业务。

4.2 案例二 · 24GB 显卡的部门服务器

典型配置:RTX 4090 或 A10,24GB 显存,供一个部门十几个人内网使用。

候选方案Q4 预估占用结论
单个 14B约 10 GB余量充足,可以把上下文开大、并发开到 2~4
单个 32B约 22 GB能装下但几乎没余量,并发只能是 1,上下文也得压着
14B + 一个向量模型约 11 GB为将来接 RAG 留位置,是更常见的真实排布

结论:选 14B,不要贪 32B。把省下来的显存用于提高并发和上下文长度,十几个人同时用的体验,远比单次回答质量高一点更重要。

⚠️ 多人使用时,并发才是瓶颈 单人测试时 32B 跑得挺好,十个人一起用就会排队到超时。并发数每加一档,KV 缓存就多占一份显存。部门级服务的选型原则是:先定并发数,再倒推模型尺寸,而不是反过来。这部分参数怎么配,见《Linux 企业级部署》那一讲。

4.3 案例三 · 纯 CPU 的老机器

没有显卡,只有内存。Ollama 支持纯 CPU 推理,能跑,但要接受速度

  • 1.5B 及以下:可用。每秒十几个 token,做简单分类、抽取任务体验尚可。
  • 7B:勉强能跑,每秒几个 token。用户会明显感觉到「一个字一个字往外蹦」。
  • 14B 以上:不建议。一次回答要等几分钟,没有实用价值。

结论:纯 CPU 的机器只适合做离线批处理,不适合做交互式问答。如果业务确实需要实时对话,这条路走不通,得老实去申请显卡,或者回头考虑云端 API。

4.4 选型验收:用真实样本压一遍

前面三个案例给的都是估算结论,回答的是「跑不跑得起」。但拍板前还得回答第二个问题:跑得多快、答得好不好。这两件事估算不出来,只能实测。

要回答的问题怎么得到答案判断标准
跑不跑得起显存估算 + ollama psPROCESSOR 显示 100% GPU
跑得多快串行压测看生成速度交互式问答至少要 15 token/s 以上才不憋得慌
多人用会不会崩并发压测看耗时退化并发上去后单请求耗时线性变长,就是在排队了
答得好不好人工逐条看存档答案没有自动标准,这一步省不得
bench_local.py —— 用自己的样本压测候选模型,并把答案落盘选型验收
"""选型验收:拿自己的真实样本,把候选模型逐个压一遍。

为什么必须有这一步:
    跑分榜测的是通用能力,和你的业务样本关系有限。
    「能装进显存」只说明跑得起来,不说明答得好。
    真正能拍板的证据只有一个 —— 你自己的三十条数据,各模型跑一遍的结果。

这个脚本负责把**可量化的部分**测出来(首 token 延迟、生成速度、并发下的退化),
并把每个模型的答案原样落盘,方便人工逐条对比质量。
质量这件事没法自动打分,必须人看。

用法:
    # 先把你的真实问题写进 samples.txt,一行一条
    python3 bench_local.py --models qwen2:1.5b,qwen2:7b --samples samples.txt
    python3 bench_local.py --models qwen2:7b --concurrency 4

依赖:只用标准库,不需要 pip 装任何东西。
"""
import argparse
import json
import os
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request

BASE = os.environ.get("OLLAMA_BASE", "http://127.0.0.1:11434").rstrip("/")


# --------------------------------------------------------------------------
def call_once(model, prompt, timeout=300):
    """发一次非流式请求,返回 (答案, 指标字典)。

    Ollama 的响应里自带耗时统计,单位是纳秒:
        total_duration   整次请求耗时
        load_duration    加载模型耗时(模型已在内存里时接近 0)
        prompt_eval_count/duration   读题阶段
        eval_count/eval_duration     生成阶段
    生成速度 = eval_count / (eval_duration / 1e9),这是最该关注的数字。
    """
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": False,
        "options": {"temperature": 0},   # 压测固定为 0,保证可复现
    }
    req = urllib.request.Request(
        BASE + "/api/chat",
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    wall = time.time() - t0

    eval_count = data.get("eval_count", 0)
    eval_dur = data.get("eval_duration", 0) / 1e9
    metrics = {
        "wall": wall,
        "load_s": data.get("load_duration", 0) / 1e9,
        "prompt_tokens": data.get("prompt_eval_count", 0),
        "gen_tokens": eval_count,
        "tps": eval_count / eval_dur if eval_dur else 0,
    }
    return data["message"]["content"], metrics


# --------------------------------------------------------------------------
def warmup(model):
    """先空跑一次,把模型加载进显存。

    不预热的话,第一条样本会把几十秒的加载时间算进成绩,
    整组数据直接废掉 —— 这是压测里最常见的错误。
    """
    try:
        call_once(model, "hi", timeout=600)
        return True
    except Exception as exc:
        print("  预热失败:%s" % exc)
        return False


def bench_serial(model, samples, outdir):
    """串行跑完所有样本,统计速度并把答案落盘。"""
    rows, answers = [], []
    for i, s in enumerate(samples, 1):
        try:
            ans, m = call_once(model, s)
        except Exception as exc:
            print("  样本 %d 失败:%s" % (i, exc))
            continue
        rows.append(m)
        answers.append({"index": i, "prompt": s, "answer": ans, "metrics": m})
        print("  [%2d/%d] %5.1fs  %5.1f token/s  生成 %d token"
              % (i, len(samples), m["wall"], m["tps"], m["gen_tokens"]))

    if not rows:
        return None

    # 答案落盘,人工逐条看质量。自动打分不可信,这一步不能省。
    os.makedirs(outdir, exist_ok=True)
    path = os.path.join(outdir, model.replace(":", "_").replace("/", "_") + ".json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(answers, f, ensure_ascii=False, indent=2)

    tps = [r["tps"] for r in rows]
    walls = [r["wall"] for r in rows]
    return {
        "样本数": len(rows),
        "生成速度中位数": "%.1f token/s" % statistics.median(tps),
        "生成速度最低": "%.1f token/s" % min(tps),
        "单次耗时中位数": "%.1fs" % statistics.median(walls),
        # P95 用来看长尾:中位数好看、P95 很差的服务,用户照样骂
        "单次耗时 P95": "%.1fs" % sorted(walls)[int(len(walls) * 0.95) - 1],
        "答案存档": path,
    }


def bench_parallel(model, prompt, n):
    """并发压测:n 个线程同时发同一条请求。

    单人测着飞快、多人一起用就超时,差别就出在这里。
    并发数超过服务端 OLLAMA_NUM_PARALLEL 时,多出来的请求会排队,
    表现为单次耗时线性变长,而不是报错。
    """
    results, lock = [], threading.Lock()

    def worker():
        try:
            _ans, m = call_once(model, prompt)
        except Exception as exc:
            with lock:
                results.append({"error": str(exc)})
            return
        with lock:
            results.append(m)

    threads = [threading.Thread(target=worker) for _ in range(n)]
    t0 = time.time()
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    total = time.time() - t0

    ok = [r for r in results if "error" not in r]
    failed = len(results) - len(ok)
    if not ok:
        return {"并发数": n, "全部失败": failed}
    return {
        "并发数": n,
        "成功": len(ok),
        "失败": failed,
        "墙钟总耗时": "%.1fs" % total,
        "单请求耗时中位数": "%.1fs" % statistics.median([r["wall"] for r in ok]),
        "单请求速度中位数": "%.1f token/s" % statistics.median([r["tps"] for r in ok]),
    }


# --------------------------------------------------------------------------
DEFAULT_SAMPLES = [
    "用一句话解释什么是私有化部署。",
    "把下面这句话翻译成英文:数据不出内网。",
    "列出三条本地部署大模型的注意事项。",
]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--models", required=True,
                    help="逗号分隔的模型全名,例如 qwen2:1.5b,qwen2:7b")
    ap.add_argument("--samples", help="真实业务样本文件,一行一条;不给就用内置示例")
    ap.add_argument("--concurrency", type=int, default=0,
                    help="大于 0 时额外做一轮并发压测")
    ap.add_argument("--outdir", default="./bench_out", help="答案存档目录")
    args = ap.parse_args()

    if args.samples:
        with open(args.samples, encoding="utf-8") as f:
            samples = [ln.strip() for ln in f if ln.strip()]
    else:
        samples = DEFAULT_SAMPLES
        print("⚠️  用的是内置示例样本,结论只能参考。")
        print("    真正拍板前请换成你自己的三十条真实业务数据。\n")

    for model in [m.strip() for m in args.models.split(",") if m.strip()]:
        print("=" * 60)
        print("模型:%s" % model)
        print("预热中(首次加载可能要几十秒)…")
        if not warmup(model):
            continue

        print("串行跑 %d 条样本:" % len(samples))
        summary = bench_serial(model, samples, args.outdir)
        if summary:
            print("  —— 汇总 ——")
            for k, v in summary.items():
                print("  %-16s %s" % (k, v))

        if args.concurrency > 0:
            print("并发 %d 压测:" % args.concurrency)
            for k, v in bench_parallel(model, samples[0], args.concurrency).items():
                print("  %-16s %s" % (k, v))

    print("=" * 60)
    print("速度数据已打印,答案存档在 %s" % args.outdir)
    print("⚠️  质量必须人工逐条看,脚本不替你判断哪个模型答得好。")
    return 0


if __name__ == "__main__":
    sys.exit(main())
⚠️ 不预热的压测数据全是废的 第一次请求会把几十秒的模型加载时间算进成绩。脚本里的 warmup() 先空跑一次就是为了这个。同理,temperature 固定为 0 是为了可复现——每次跑出不同的答案,横向对比就没意义了。
✅ 三十条样本怎么凑 直接从真实业务里抓:客服工单抽一批、内部问答抽一批、故意放几条边界情况(超长文本、专业术语、模糊提问)。一定要含边界样本——模型之间的差距往往只在这几条上才看得出来
动手之前先体检 上面三个案例的判断依据,都可以用一个脚本查出来:系统型号、内存、显卡、剩余磁盘、Ollama 是否已装。拿到一台陌生机器,先跑它,再决定拉什么模型。
check_ollama.py —— 部署前环境体检:内存、显卡、磁盘、服务状态环境体检
"""探活:确认本地 Ollama 服务真的活着,而不是「我以为它活着」。

只依赖标准库,装完 Ollama 立刻就能跑:
    python3 check_ollama.py
"""
import json
import os
import urllib.error
import urllib.request

# 服务地址走环境变量,方便在本机 / 虚拟机 / 同事机器之间切换,不写死
BASE = os.environ.get("OLLAMA_BASE", "http://127.0.0.1:11434")
TIMEOUT = 5


def get_json(path):
    """向 Ollama 发一个 GET 请求,把响应体解析成字典。"""
    url = BASE.rstrip("/") + path
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        return json.loads(resp.read().decode("utf-8"))


def main():
    print("目标服务:%s" % BASE)

    # 第一步:/api/version 是最轻的一个接口,它通了说明 HTTP 服务在监听
    try:
        version = get_json("/api/version")
    except urllib.error.URLError as exc:
        # 连不上的两种典型原因:服务没起;或者只监听了 127.0.0.1 而你在远程连
        print("连不上:%s" % exc.reason)
        print("排查顺序:systemctl status ollama → ss -lntp | grep 11434 → 防火墙")
        return 1
    print("服务版本:%s" % version.get("version", "未知"))

    # 第二步:/api/tags 列出本地已有的模型。空列表说明服务在跑但一个模型都没拉
    tags = get_json("/api/tags")
    models = tags.get("models", [])
    if not models:
        print("本地没有任何模型,先执行:ollama pull qwen2:1.5b")
        return 0

    print("本地模型共 %d 个:" % len(models))
    for m in models:
        # size 是字节数,换算成 GB 才有体感
        size_gb = m.get("size", 0) / 1024 ** 3
        print("  %-28s %6.2f GB" % (m.get("name", "?"), size_gb))

    # 第三步:/api/ps 看此刻真正占着内存/显存的模型
    running = get_json("/api/ps").get("models", [])
    if running:
        print("正在占用内存的模型:")
        for m in running:
            print("  %-28s %s" % (m.get("name", "?"), m.get("expires_at", "")))
    else:
        print("当前没有模型驻留内存(都已按 keep_alive 卸载)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

05骨架模板:把选型结论落成一份可复跑的探测脚本

同一台机器交给不同的人判断,结论应该一致——靠脚本,不靠经验

前面三个案例的判断过程,本质上是同一套流程:先看机器有什么,再算模型要多少,最后对一遍差额。把它固化成脚本,好处是任何人拿到任何一台机器,跑一次就能拿到同样的结论,不用再去问「我这机器能跑 7B 吗」。

deploy_probe_skeleton.py —— 选型探测骨架,按 TODO 改成你们自己的候选清单可复用模板
"""骨架模板:私有化部署上线前的自检脚本。

复制这个文件到你的运维仓库,把 TODO 填成自己环境的值,
上线前跑一次,全绿再把地址发给同事。

用法:
    OLLAMA_BASE=http://10.0.0.20:11434 python3 deploy_probe_skeleton.py
"""
import json
import os
import urllib.error
import urllib.request

# TODO: 换成你自己的服务地址;口令/令牌一律走环境变量,不要写进源码
BASE = os.environ.get("OLLAMA_BASE", "http://127.0.0.1:11434")
# TODO: 如果前面挂了带鉴权的网关,把令牌放进环境变量再读出来
TOKEN = os.environ.get("OLLAMA_GATEWAY_TOKEN", "")

# TODO: 换成你们生产上真正要用的模型全名(必须带冒号版本)
REQUIRED_MODELS = [
    "qwen2:1.5b",
]

TIMEOUT = 10


def call(path, payload=None):
    """统一的请求封装:带上鉴权头,GET 与 POST 都走这里。"""
    url = BASE.rstrip("/") + path
    headers = {"Accept": "application/json"}
    if TOKEN:
        headers["Authorization"] = "Bearer " + TOKEN
    data = None
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers)
    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
        return json.loads(resp.read().decode("utf-8"))


def check_alive():
    """① 服务在不在。"""
    info = call("/api/version")
    return True, "版本 %s" % info.get("version", "未知")


def check_models():
    """② 生产要用的模型是不是都已经拉好。"""
    have = {m["name"] for m in call("/api/tags").get("models", [])}
    missing = [m for m in REQUIRED_MODELS if m not in have]
    if missing:
        return False, "缺模型:%s(先 ollama pull)" % "、".join(missing)
    return True, "模型齐全:%s" % "、".join(REQUIRED_MODELS)


def check_infer():
    """③ 真发一次推理,只有拿到非空回答才算通。"""
    payload = {
        "model": REQUIRED_MODELS[0],
        "messages": [{"role": "user", "content": "只回复两个字:正常"}],
        "stream": False,
        # TODO: 探活不需要长回答,按需要调小
        "options": {"num_predict": 16},
    }
    text = call("/api/chat", payload).get("message", {}).get("content", "")
    if not text.strip():
        return False, "模型返回空内容"
    return True, "推理正常,返回 %d 字" % len(text.strip())


# TODO: 按需要继续补自己的检查项,例如磁盘余量、显存占用、网关证书有效期
CHECKS = [
    ("服务存活", check_alive),
    ("模型就绪", check_models),
    ("推理可用", check_infer),
]


def main():
    print("探活目标:%s" % BASE)
    failed = 0
    for name, fn in CHECKS:
        try:
            ok, detail = fn()
        except urllib.error.HTTPError as exc:
            ok, detail = False, "HTTP %s" % exc.code
        except urllib.error.URLError as exc:
            ok, detail = False, "连不上:%s" % exc.reason
        print("[%s] %-8s %s" % ("通过" if ok else "失败", name, detail))
        if not ok:
            failed += 1
    print("\n结论:%s" % ("全部通过" if failed == 0 else "有 %d 项没过,别上线" % failed))
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
要改的地方改成什么
CANDIDATES换成你们内部允许使用的模型清单;不在清单里的模型不参与推荐
RESERVE_GB留给系统和其他进程的显存;多任务机器要调大
TARGET_PARALLEL预期并发数,直接影响 KV 缓存的估算倍数
服务探测地址已装 Ollama 的机器改成实际地址,脚本会顺带验一次接口连通性
✅ 这份脚本的定位 它是决策辅助,不是验收工具。它能拦住「显存 8G 却想跑 32B」这类明显错误,但拦不住「模型能跑起来,可是答得不好」。模型质量必须用你自己的真实业务样本测,跑分和参数量都替代不了这一步。

5.2 立项前的八项检查清单

脚本只能回答技术问题。真正让私有化项目死掉的,往往是下面这几栏里的空白——每一条都要能写出具体答案,写不出来就是风险点

检查项及格标准写不出来会怎样
立项理由能说出具体是哪类数据不能出网拿「省钱」当理由,第一轮评审就被算账算穿
调用量估计每天多少次、峰值并发多少选型全基于单人测试,上线就排队超时
真实样本凑齐三十条,含边界情况只能拿跑分榜凑数,模型上线后质量不达预期
授权协议确认该尺寸允许商用产品上线后才发现许可证不允许,返工成本极高
硬件到位时间显卡采购周期写进排期代码写完了卡还没到,项目干等
磁盘规划模型目录指到数据盘并预留两倍空间系统盘被几十 G 的模型撑爆,服务连日志都写不下
运维归属有名有姓的值班人几个月后变成一台没人敢重启的僵尸机器
降级预案服务挂了走什么路一次容量故障就把业务全线阻断
✅ 两个脚本怎么配合用 cost_compare.py 回答「该不该做」,deploy_probe_skeleton.py 回答「这台机器能做到什么程度」,bench_local.py 回答「选哪个模型」。三份输出加上这张清单,就是一份拿得出手的选型报告。
⚠️ 脚本的输出依赖机器上有什么命令 显卡信息靠 nvidia-smi,没装驱动或者是 AMD / 昇腾卡就取不到,脚本会降级成「未检测到 GPU」。取不到不等于没有,这种情况要人工确认,别直接照着「纯 CPU」的结论走。

06易错点汇总

按「认知 / 选型 / 显存 / 成本」四类归并,踩过一次就别再踩

⚠️ 一、认知层面

  • 以为私有化部署会让模型变聪明。不会。私有化只改变数据流向,不改变模型能力。同等预算下本地能跑的模型,能力通常明显弱于顶级云端模型。
  • 把私有化、微调、RAG 当成一条路上的三个台阶。三件事互相独立:私有化解决「跑在哪」,微调解决「会不会干这行的活」,RAG 解决「知不知道公司的资料」。
  • 以为「租一台云 GPU 跑模型」不算私有化。关键在于推理发生在谁控制的机器上。机器是租的、但权限和模型文件归你,仍然算;调厂商的 API 则不算。
  • 以为装完 Ollama 数据就绝对安全了。模型不出网,不代表服务不出网。默认配置下 Ollama 没有任何鉴权,一旦监听到 0.0.0.0 且防火墙放行,谁都能调。这一点在《Linux 企业级部署》里会展开。

⚠️ 二、选型层面

  • 按跑分榜选模型。榜单测的是通用能力,和你的业务样本关系有限。正确做法是拿三十条真实业务数据,把候选模型各跑一遍,人工看结果。
  • 贪大。24GB 的卡硬塞 32B,结果并发只能是 1、上下文开不大,十个人一起用就排队超时。多人服务的场景下,14B 的体验通常好过 32B
  • 忽略模型的授权协议。商用之前必须确认许可证允许商用,不同模型、不同尺寸的条款可能不一样,别默认「开源就能随便用」。
  • 只测单次问答,不测并发。单人测着挺快,上线就慢得没法用——因为并发时 KV 缓存成倍占显存。压测必须在选型阶段做,不是上线后补。

⚠️ 三、显存与量化

  • 只算权重,不留余量。实际占用 = 权重 + KV 缓存 + 运行时开销。至少留 20%~30%,卡得刚好的配置一定会在并发上来时 OOM。
  • 把估算值当精确值。脚本算出来的是数量级参考,真实占用以 ollama ps 的输出为准。
  • 没发现模型其实在跑 CPU。显存装不下时不会报错,只会悄悄变慢。务必看 ollama psPROCESSOR,显示 100% GPU 才是全进显存。
  • 为了省显存无脑选最低量化。量化越低损失越明显,尤其在数学、代码、长逻辑上。显存够就用 Q8,别为了省两个 G 牺牲质量。
  • 忘了上下文长度也吃显存。num_ctx 从 4096 拉到 32768,KV 缓存能翻好几倍。改这个参数前先算一遍账。

⚠️ 四、成本与运维

  • 只比 token 单价和电费。真实成本还包括显卡采购、机房托管、运维人力、升级时的回归测试。低频调用场景私有化几乎一定更贵。
  • 没人负责运维就上私有化。这是一个需要长期值班的系统。没人管的私有化部署,几个月后就是一台没人敢重启的僵尸机器。
  • 把模型存在系统盘。模型动辄几十 GB,系统盘很快撑爆。部署时就该把存放目录指到数据盘,别等满了再迁。
  • 用「省钱」当私有化的立项理由。算不过账的时候这个理由会被推翻。真正站得住的理由只有一个:数据不能出网。

07自测题

点击题目展开答案;能把这 14 题说清楚,选型这一关就过了

一、概念
什么是私有化部署?判断的关键条件是什么?

把大模型的权重文件放在自己控制的机器上,推理过程完全在这台机器内部完成。判断的关键不是「在不在本地」,而是「推理发生在谁控制的机器上」——自己租的云服务器,只要权限和模型文件归你,同样算私有化;调厂商 API 则不算。

企业选择私有化部署,最站得住的理由是什么?

数据不能出网。病历、合同、工资表、代码库这类数据一旦发到厂商机房就不可控。省钱不是好理由——低频调用场景下私有化几乎一定比调 API 更贵。

私有化部署、微调、RAG 三者是什么关系?

三件事互相独立,不是递进关系。私有化解决「模型跑在哪」,微调解决「模型会不会干你这行的活」,RAG 解决「模型知不知道你公司的资料」。私有化的模型照样可能不懂业务,云端模型照样能接 RAG。

哪些情况下不该选私有化?举三种。

① 处理的是公开数据,本来就不怕出网;② 任务需要很强的推理能力,本地小模型和顶级云端模型差距是数量级的;③ 调用量小且不稳定,租卡全天空转不划算;④ 没人负责长期运维。

二、显存与量化
模型名字里的 7B 指什么?怎么估算它占多少显存?

B 是 billion,7B 即七十亿个参数。估算口诀:参数量(B)× 每参数字节数 = 权重占用(GB)。FP16 每参数 2 字节、Q8 约 1 字节、Q4 约 0.5 字节。所以 7B 的 Q4 约 3.5~4.5 GB,32B 的 Q4 约 18 GB。

为什么按权重算出来的数字不能直接当作显存需求?

实际占用 = 权重 + KV 缓存 + 运行时开销。KV 缓存随上下文长度和并发数增长,最容易被忽略。所以要在权重之上留 20%~30% 余量,否则并发一上来就 OOM。

什么是量化?Q4 相比 FP16 牺牲了什么?

用更少的比特存每一个参数,把 16 位浮点压到 8 位或 4 位整数。像把精装菜谱缩印成便携本——菜和步骤都在,但一些细微的火候描述被四舍五入掉了。Q4 体积约为 FP16 的四分之一,代价是在数学、代码、长逻辑这些任务上有可感的质量损失。

同样的显存,该选大模型的 Q4 还是小模型的 Q8?

经验上优先选参数量更大的低量化版本:14B 的 Q4 通常好过 7B 的 Q8,虽然占的显存差不多。参数量带来的能力增益一般压得过量化的损失。但这条不绝对,上线前务必用自己的真实样本各测一遍。

显存装不下模型时会发生什么?怎么发现?

不会报错,只会悄悄变慢——部分权重溢到内存,变成 CPU/GPU 混合推理,速度掉几倍。发现的唯一可靠办法是看 ollama psPROCESSOR 列:显示 100% GPU 才是全部装进了显存,出现 CPU 字样就说明溢出了。

num_ctx 从 4096 调到 32768,有什么代价?

模型能记住更长的对话,但 KV 缓存会翻好几倍,显存占用同步上涨。并发场景下这个涨幅还要再乘以并发数。改这个参数之前必须重新算一遍显存账。

三、选型决策
8GB 显存的开发机,推荐跑多大的模型?

7B 的 Q4 是甜点位,约占 5 GB,留得出上下文余量。1.5B 适合快速调通链路;14B 级别约需 10 GB,装不下会溢到内存,速度掉到难以接受。

24GB 的卡供十几个人用,为什么不推荐 32B?

32B 的 Q4 约占 22 GB,能装下但几乎没有余量:并发只能是 1,上下文也得压着。十几个人同时用会排队超时。选 14B(约 10 GB),把省下的显存用于提高并发和上下文,整体体验明显更好。先定并发数,再倒推模型尺寸。

没有显卡的老机器能跑吗?

能跑但要接受速度。1.5B 及以下可用,每秒十几个 token;7B 勉强能跑,每秒几个 token,用户会明显感觉到卡顿;14B 以上一次回答要几分钟,没有实用价值。纯 CPU 只适合离线批处理,不适合交互式问答。

为什么不能按跑分榜选模型?

榜单测的是通用能力,和你的业务样本关系有限。正确做法是拿三十条真实业务数据,把候选模型各跑一遍,人工看结果。模型质量必须用自己的数据测,跑分和参数量都替代不了这一步。

24GB 的卡跑 14B 占 10G,剩下的显存能再跑一个 14B 吗?有必要吗?

技术上可以,但没必要。模型权重是所有并发请求共用的一份,不会因为多一个人就加载多一份。想支持更多人,把 OLLAMA_NUM_PARALLEL 调上去就行,多出来的开销只是 KV 缓存,而不是再来一份 10G 的权重。加载两份同模型是纯浪费。

请求数超过 OLLAMA_NUM_PARALLEL 会报错吗?

不会。多出来的请求进队列等着,表现是单次耗时线性变长。直到队列也满(OLLAMA_MAX_QUEUE,默认 512),服务端才返回 503。所以排查「变慢」时要先看是不是在排队,而不是一上来就怀疑模型或显卡。

为什么拉模型时必须写全名带冒号版本?

只写 qwen2 会拉到仓库的默认标签,具体是哪一档取决于仓库当时的设置。团队里两个人各拉一次,很可能拿到不同的模型,后面对不上性能数据就会互相扯皮。写 qwen2:1.5b 这种完整标号才能复现。

压测时为什么必须先预热?temperature 为什么设 0?

不预热的话,第一条样本会把几十秒的模型加载时间算进成绩,整组数据就废了。temperature 设 0 是为了可复现——每次跑出不同的答案,模型之间的横向对比就没意义了。

为什么说「省钱」不是好的立项理由?

因为算得出来。按每天 500 次调用估:API 月账单约 72 元,而私有化一次性投入约 2.76 万、每月固定支出还要 1459 元(电费 + 运维工时)。月支出就已经是 API 的二十倍,永远不回本。只有调用量足够大时结论才会翻转。站得住的理由只有数据不能出网。

领导说「先拿一台游戏本试试」,这条路行吗?

试点行,上生产不行。笔记本显卡显存小、持续高负载会降频,而且没有服务器的散热和供电设计,跑一下演示可以,当成共享服务一定出问题。正确做法是先在它上面把链路跑通、把模型选型定下来,再拿着实测数据去申请真正的服务器。

同一个模型,为什么别人跑得比我快很多?

先查三件事,顺序别反① 是不是溢出到 CPU 了ollama psPROCESSOR 列,这是差距最大的一项,差十几倍);② 量化档位是不是同一个q4q8 不是一回事);③ 是不是把加载时间算进去了(没预热)。三项都一致还慢,再谈显卡本身的差距。

为什么说「模型越大越好」在企业里不成立?

因为成本和体验是一起涨的。模型大一档,显存需求、卡的价格、单次延迟、能支持的并发数全部变差。企业场景很多是分类、抽取、格式改写这类活,小模型完全胜任。正确顺序是先拿真实业务题测一批,找到恰好能过关的最小档位,而不是一上来就上最大的。

业务部门说「我们的数据不敏感」,还需要私有化吗?

先问一句:那些数据里有没有客户名、合同号、报价、员工信息?很多人说的「不敏感」指的是「不是国家机密」,但内部运营数据照样不能出网。确实都是公开信息的场景(比如改写对外宣传文案),走 API 反而更划算——效果更好、成本更低、不用运维。别为了私有化而私有化。

私有化之后,模型效果比不上云端,怎么跟领导说?

如实说,并把账算清楚:本地能跑的档位与云端旗舰模型存在真实差距,这是选这条路的已知代价,不是部署没做好。接下来给两个可行动作:用真实业务题测出「恰好能过关的最小档位」,或者走混合路线——涉密走内网、公开且要求高的走云端。

Ollama、vLLM、LM Studio 各适合什么场景?

Ollama:上手最简单,适合个人开发和中小规模内部服务;vLLM:高并发线上服务,吞吐量明显更好,配置也复杂得多;LM Studio:纯图形界面,适合完全不碰命令行的人。先用 Ollama 打通链路,因为它对外是 HTTP 接口,将来换 vLLM 只需改地址。

术语表

术语含义
私有化部署模型权重放在自己控制的机器上,推理全程在内部完成,数据不出网
显存 / VRAM显卡上的内存,决定能装下多大的模型;比喻里的「灶台面积」
参数量(B)模型的参数个数,B 表示十亿;7B 即七十亿个参数
量化用更少的比特存每个参数,体积变小、质量略降;常见档位 FP16 / Q8 / Q4
FP16半精度浮点,每参数 2 字节,本地部署里的高精度档
Q4四位量化,每参数约 0.5 字节,Ollama 的默认档位
KV 缓存推理时缓存的注意力键值,随上下文长度和并发数增长,是显存的隐藏开销
OOMOut Of Memory,显存或内存不足导致的失败
Ollama把「下载权重、装推理引擎、起服务」打包成几条命令的本地部署工具
vLLM面向高并发的推理框架,吞吐量优于 Ollama,配置复杂度也更高
num_ctx上下文长度,决定模型一次能看多少内容,直接影响 KV 缓存大小
ollama ps查看已加载模型及其占用位置的命令,PROCESSOR 列是判断是否溢出的唯一依据
OLLAMA_NUM_PARALLEL一个模型同时处理几个请求;调大只多吃 KV 缓存,不会多加载一份权重
OLLAMA_MAX_QUEUE排队上限,默认 512;超出后服务端直接返回 503
模型标号形如 qwen2:1.5b 的全名;省略冒号部分会拿到仓库默认标签,不可复现
预热压测前先空跑一次把模型加载进显存,避免加载时间污染成绩
回本点私有化累计成本开始低于云端 API 的那一个月;超过显卡服役期就等于不成立
本讲产出的四个脚本回答的问题什么时候跑
cost_compare.py该不该做私有化立项评估阶段,写报告之前
check_ollama.py这台机器现在什么状态拿到陆生机器的第一步
vram_plan.py / deploy_probe_skeleton.py这块卡能跑多大的模型拉模型之前
bench_local.py候选模型选哪个模型拉下来之后、拍板之前
✅ 一句话收束本讲 私有化部署不是技术炫技,它只解决一个问题:让数据留在自己家里。选型的全部工作就是在这个前提下,用手上这块卡的显存,换到质量最高的那个模型。下一讲开始真正动手——把 Ollama 装起来,把模型拉下来。