在银行内网部署Llama 3,我踩了六个坑后终于把推理延迟压到了1.8秒

30秒速览

  • 合规是大前提,别想着用API取巧,私有化部署是唯一出路
  • 量化模型必须用金融领域校准集重训,否则数字错误率能坑死你
  • vLLM看着香,但AWQ量化下用TensorRT-LLM延迟从5.7秒压到1.8秒
  • 审计日志别直接写文件,用journald转发,一天200G的坑我替你踩过了
  • 脱敏规则要写白名单保护,否则“第3季度”会被当成身份证号

银行合规这关过不去,后面全是白干

去年年底,行里风控部门提了一个需求:对公客户经理在撰写贷前调查报告时,希望用AI辅助生成财务分析摘要,但所有客户数据绝对不能离开内网。之前试过某头部云厂商的API,虽然签了最严苛的保密协议,合规审计还是亮了红灯——监管白纸黑字写着“客户财务数据、授信审批文件属于敏感信息,严禁出境,甚至不允许离开分行机房”。私有化部署成了唯一的选项。

我接到这个任务时,Llama 3才发布一个月,社区工具链像半成品的脚手架。要在完全断网的 CentOS 7 服务器上跑通一条从模型加载到安全加固的完整链路,我心里完全没底。果然,从硬件选型到最终上线,前后折腾了两个半月,填了六个能把人逼疯的坑。今天我把这些经历捋一遍,既是复盘,也希望给同行提个醒。

先说说合规的硬杠杠。银行的数据出境红线不是闹着玩的,《个人金融信息保护技术规范》和《数据安全法》把客户信息分为三级,我们处理的财报、流水属于C3级,一旦违规,罚款能到上一年度营收的5%。而且审计是穿透式的,不仅要看数据存哪里,还要看推理过程中的缓存、日志、甚至报错堆栈里有没有带出敏感字段。我之前做的技术选型文档里有一页直接写了“模型权重文件从HuggingFace下载”,被安全部门打回来三次,最后改成“模型文件经由安全介质摆渡至生产网离线加载”才过审。所以,没有退路,只能把整套系统圈在铁桶里。

从买机器到拔网线,我们搞了一套物理隔离的土办法

硬件选型不是拍脑袋。行里数据中心机架供电每路限制16A,单台服务器不能超过3kW。最初想上A100 80GB,单卡TDP就400W,插4张加上CPU、风扇,满载直奔2.4kW,运维直接摇头:“跳闸了谁负责?”后来折中选了NVIDIA A40 48GB,TDP 300W,4卡总共1200W,算上两颗至强金牌5318Y,整机功耗控制在1800W以内,还留了余量给冗余电源。内存则塞满了512GB,因为量化前的原始模型加载就需要近30GB,加上KV cache,内存小了根本扛不住并发。

网络隔离做得更绝。服务器出厂时带了一块无线网卡和两个千兆电口,我亲手把无线网卡用螺丝刀卸了,BIOS里禁用了蓝牙和USB存储启动。两台服务器通过光纤直连到一个没有上联口的二层交换机,组成孤岛集群。模型文件用加密移动硬盘从研发网拷贝,硬盘插上去之前先在沙箱里扫了三遍病毒。最搞笑的是,安全部门要求所有操作必须有审计录像,于是我每次进机房都要扛着摄像机对着屏幕录半小时,搞得跟拆弹现场一样。

零信任架构在这一层也铺开了。我们部署了一套自签CA,所有服务间通信强制mTLS,推理API和前端界面之间加了一层基于角色的访问网关,连健康检查接口都要带JWT令牌。有人觉得内网没必要搞这么复杂,但审计记录显示,上线第二周就有人用测试脚本撞了300次登录接口,还好被Raft算法加固的网关直接黑洞了。

操作系统选的是Rocky Linux 8.6,内核升到5.4.246,因为A40需要较新的NVIDIA驱动(525.60.13),而默认内核的KABI兼容性太差。驱动安装过程也是离线地狱的第一步,我提前用另一台联网机器下载了驱动runfile和全部依赖的rpm包,刻成ISO镜像挂载上去。这里有个教训:别信NVIDIA官网说的“runfile安装会自动处理依赖”,在离线环境它会静默失败,必须手动把kernel-devel、dkms、libglvnd等十几个包先装好,顺序错了还得重来。

# 离线安装NVIDIA驱动的正确姿势
# 1. 在联网机器上下载驱动和依赖
repotrack --download_path=./nvidia_pkgs kernel-devel-$(uname -r) dkms libglvnd libglvnd-devel ...
wget https://us.download.nvidia.com/tesla/525.60.13/NVIDIA-Linux-x86_64-525.60.13.run

# 2. 创建本地repo并安装依赖
createrepo /mnt/iso/nvidia_pkgs
dnf --disablerepo=* --enablerepo=local_nvidia install kernel-devel dkms

# 3. 禁用nouveau,否则驱动加载冲突
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
dracut -f && reboot

# 4. 以无交互模式运行驱动安装(必须指定--no-cc-version-check,否则会卡在验证compiler版本)
./NVIDIA-Linux-x86_64-525.60.13.run --silent --no-questions --no-cc-version-check

两台机器做完基础环境,还搭了一套K3s轻量级编排,主要为了在服务挂了以后能自动拉起。别小看K3s,在资源紧张的时候,它的containerd-shim比Docker少占500MB内存,这对跑着量化模型的机器来说就是救命稻草。

模型量化:我差点被bitsandbytes的8bit坑死

原始Llama 3 8B的BF16精度需要约16GB显存,单卡推理没问题,但我想给KV cache留足空间,而且4卡做tensor parallelism时显存碎片会更严重,量化是必走的路。市面上的量化方案主要有三种:bitsandbytes的8bit加载、GPTQ的int4离线量化、以及AWQ。我开始天真地以为bitsandbytes的load_in_8bit最省事,结果踩了第一个大坑。

bitsandbytes的幻觉问题

用transformers一行代码加载:

from transformers import AutoModelForCausalLM, BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(load_in_8bit=True, llm_int8_threshold=6.0)
model = AutoModelForCausalLM.from_pretrained(
    "/data/models/Llama-3-8B",
    quantization_config=bnb_config,
    device_map="auto"
)

加载很快,单卡显存占用降到10GB,但推理输出直接把测试组吓到了。输入“根据以下财务指标生成分析摘要:营收增长率12%,净利润率8%”,模型生成的文本变成了“营收增长12%属于中等水平,建议关注…利润率8%在行业内属于低水平,需加大费用控制…”——表面上勉强通顺,但仔细看数字:原始输入是“净利润率8%”,模型直接篡改成“利润率”,丢掉了“净”字,还编造了“费用控制”这种无中生有的建议。更要命的是,有时它会凭空生成“存货周转率从3.2次提升至4.1次”这样的虚假数据,在金融场景下等于生产事故。

追查原因发现,bitsandbytes的动态8bit量化在混合精度计算时,对attention层的缩放因子计算存在数值溢出,导致某些token的embedding被归零,模型就靠上下文瞎猜补位。这个问题在GitHub上有一长串issue(#1156、#1342),官方给的workaround是调整llm_int8_threshold参数,但实测无论调到多少,都无法根治幻觉。我最终弃用了bitsandbytes,转向离线量化方案。

GPTQ与AWQ的取舍

摆在前面的有GPTQ(使用AutoGPTQ库)和AWQ(使用autoawq)。两者都是int4权重量化,但理念不同:GPTQ基于最优脑外科手术式的逐层量化,需要校准数据集来计算hessian;AWQ则是通过分析激活值分布,保留对重要通道的fp16精度,量化过程更快,而且对PPL的影响声称比GPTQ小0.3左右。

先试了GPTQ。用WikiText-2的前2000条作为校准数据,量化为4-bit组大小128,耗时6小时。推理用AutoGPTQ加载:

from auto_gptq import AutoGPTQForCausalLM
model = AutoGPTQForCausalLM.from_quantized(
    "/data/models/Llama-3-8B-GPTQ",
    use_triton=False,
    device="cuda:0"
)

显存占用降到6.7GB,推理速度比BF16快40%左右,但财务摘要任务上还是有0.7%的概率生成“营收增长率为-3%”这种凭空变号的情况。原因在于量化校准集是通用文本,缺少财务报表的分布特征,导致某些代表财务数字的token被错误压缩。

AWQ的效果明显更稳。我花了三天构造了一个银行专属校准集,包含5000条脱敏后的财报摘要、审计意见、行业分析段落。用autoawq量化:

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = "/data/models/Llama-3-8B"
quant_path = "/data/models/Llama-3-8B-AWQ"
calib_data = load_json_calibration("finance_calib.json")  # 自定义加载

quant_config = { "zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM" }
model = AutoAWQForCausalLM.from_pretrained(model_path)
model.quantize(tokenizer, quant_config=quant_config, calib_data=calib_data)
model.save_quantized(quant_path)

量化时间4.5小时,加载后显存6.9GB,推理速度提升35%,且财务数字还原准确率99.2%。我专门用100条带数字的prompt做A/B测试,AWQ的数字错误率(含符号、单位丢失)只有0.1%,而GPTQ是0.7%,bitsandbytes是灾难性的3.5%。最终敲定AWQ,代价是量化脚本需要自己维护校准集更新,每次模型微调都要重跑一遍量化流水线。

量化工具选择总结成下面这张表:

方案 显存占用(8B) 推理速度(BF16基准) 数字准确率 校准需求
bitsandbytes 8bit 10.2GB 1.2x 96.5%
GPTQ int4 6.7GB 1.4x 99.3% 2000条通用文本
AWQ int4 (金融校准) 6.9GB 1.35x 99.9% 5000条领域文本

最终AWQ胜出,但需要特别说明:这个99.9%的数字准确率是在我自己构造的测试集上测的,如果你直接拿官方AWQ模型跑金融任务,照样会翻车。定制校准集这步省不了。

推理引擎对决:vLLM的PagedAttention在大并发下翻车,最后转投TensorRT-LLM

量化模型搞定了,下一步是构建高性能推理服务。我首先想到的就是vLLM,它的PagedAttention机制在社区里快被吹上天了,而且支持AWQ模型直接加载。部署命令很简单:

python -m vllm.entrypoints.openai.api_server 
    --model /data/models/Llama-3-8B-AWQ 
    --quantization awq 
    --dtype auto 
    --max-model-len 4096 
    --gpu-memory-utilization 0.92 
    --tensor-parallel-size 2

单卡测试时一切正常,并发压到15个请求也能扛住。但问题出在长时间运行的稳定性上。生产环境经常需要连续生成2000字以上的报告,持续推理30分钟后,vLLM开始抛OOM错误,而且不是显存真的用完,而是显存碎片化导致无法分配连续的KV cache block。

PagedAttention把KV cache切分为固定大小的block,理想很美好,但随着并发动态增减,已释放的block无法高效合并,显存像打了马赛克一样。我用nvidia-smi观察,显存使用率在85%附近抖动,但vllm日志显示“No available memory for 1 blocks”,这明显是内存碎片的锅。临时解决方案是降低gpu-memory-utilization到0.8,但浪费了宝贵的显存,而且最大并发掉到了8。

另一个致命问题是vLLM对AWQ量化的kernel实现依赖triton,而我们的GPU计算能力是8.6,triton在特定矩阵形状下会走一个低效的数据搬运路径,导致首token延迟从1.2秒飙升到3.5秒。这个问题在triton的issue #1987有讨论,短期无解。

我决定换成TensorRT-LLM,尽管它的构建流程比vLLM繁琐十倍,但显存管理和kernel优化是硬件级别的。

TensorRT-LLM的模型构建

我们需要先把AWQ模型转成TensorRT-LLM能识别的checkpoint,再用trtllm-build编译成引擎。步骤如下:

# 1. 将AWQ模型转换为TensorRT-LLM的quantized checkpoint
python examples/llama/convert_checkpoint.py 
    --model_dir /data/models/Llama-3-8B-AWQ 
    --output_dir /data/trt_ckpts/llama3_awq 
    --dtype float16 
    --use_weight_only 
    --weight_only_precision int4_awq 
    --group_size 128

这里的–use_weight_only和–weight_only_precision int4_awq是关键,它会保留AWQ的量化参数,不重新校准。

# 2. 编译为推理引擎
trtllm-build 
    --checkpoint_dir /data/trt_ckpts/llama3_awq 
    --output_dir /data/trt_engines/llama3_awq 
    --gemm_plugin float16 
    --max_batch_size 16 
    --max_input_len 3072 
    --max_output_len 2048 
    --max_num_tokens 8192 
    --use_paged_context_fmha enable 
    --multiple_profiles enable

这个编译过程花了将近40分钟,但生成的是一个单一plan文件,加载引擎只需要3秒。更惊喜的是,TensorRT-LLM使用自己的XQA kernel做paged attention,显存碎片被压缩到几乎为零,同样0.92的显存利用率,可以稳定跑16并发,24小时压测一次OOM都没触发。

推理延迟对比(用同一组100个财务prompt,输出长度256 tokens,并发16):

方案 平均首token延迟 平均总延迟 吞吐 (tokens/s)
vLLM + AWQ (tensor-parallel=2) 3.5s 5.7s 1520
TensorRT-LLM + AWQ engine 1.2s 1.8s 4100

从5.7秒压到1.8秒,这才是生产能接受的响应速度。

调优过程中还挖到一个坑:TensorRT-LLM的paged KV cache默认使用FP16,但FP16存储的KV值在长序列下会产生累积误差,导致摘要后半段出现语无伦次。查阅文档后发现在构建引擎时可以加–kv_cache_dtype int8,牺牲微小精度换来更稳定的显存占用和数值精度。改成int8后,长文本一致性明显改善,但kv cache命中率略微下降,实测影响不到1%的token质量。

安全加固:审计日志差点把/var分区撑爆,脱敏规则写了300行

金融行业的安全加固不是装个WAF就完事。我们需要覆盖三大块:推理流量的敏感信息脱敏、全链路审计日志、以及防模型泄露。

动态脱敏

客户经理输入的prompt可能包含未经脱敏的客户名称、身份证号、手机号。虽然他们理论上应该只输入脱敏后的数据,但人是不可靠的。我在推理网关层插了一个脱敏插件,基于Presidio框架实时检测并替换:

from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine

analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()

def mask_sensitive(text):
    results = analyzer.analyze(text=text, language='zh', entities=["PHONE_NUMBER","ID_NUM","PERSON","COMPANY_NAME"])
    return anonymizer.anonymize(text=text, analyzer_results=results).text

但是Presidio的中文支持很弱,默认只识别数字序列,把“营收增长率15%”里的“15%”也当成了身份证号片段,导致报告里所有百分比全变成了。我被迫写了一个基于规则的前处理层,用正则先保护金融数字格式:

import re
def protect_finance_numbers(text):
    # 保护百分比、金额、比率等格式
    patterns = [
        (r'd+(?:.d+)?%', ''),
        (r'd{1,3}(?:,d{3})*.?d*万元', ''),
    ]
    for pat, tag in patterns:
        text = re.sub(pat, tag, text)
    return text

这样先替换金融数字为占位符,再走Presidio脱敏,最后把占位符还原,效果好了很多,但依然有误伤。最终我整理出23条正则规则,写成300多行的脱敏配置文件,覆盖了我们业务场景下99%的敏感数据类型,这个工作量远比我想象的大。

审计日志

合规要求每次推理请求都必须记录用户ID、时间戳、原始prompt(脱敏后)、生成结果、模型版本、推理耗时等字段,并且日志不可篡改、保留至少180天。最初我直接把日志写到/var/log/llm/下的纯文本文件,结果第三天凌晨Zabbix告警磁盘使用率100%——一天的日志量达到了180GB!排查发现是TensorRT-LLM的verbose日志没关,把每个token的生成概率全吐出来了。

紧急改了日志级别,但纯文本格式不利于审计查询。后来换成结构化的JSON日志,用systemd-journald收集,再通过rsyslog转发到单独的日志服务器,本地只保留最近7天。代码里集成Python logging:

import logging, json, time, os
from systemd.journal import JournalHandler

logger = logging.getLogger('llm_audit')
logger.addHandler(JournalHandler(SYSLOG_IDENTIFIER='llama_infer'))
logger.setLevel(logging.INFO)

def log_request(user_id, prompt_masked, generated, duration):
    log_entry = {
        "timestamp": time.time(),
        "user": user_id,
        "prompt": prompt_masked,
        "generated": generated[:200],  # 只存前200字符摘要,完整内容存对象存储
        "model_ver": "Llama-3-8B-AWQ-v2.1",
        "cost_ms": duration
    }
    # 计算HMAC防篡改
    hmac_key = os.environ['LOG_HMAC_KEY']
    digest = hmac.new(hmac_key.encode(), json.dumps(log_entry).encode(), 'sha256').hexdigest()
    log_entry['hmac'] = digest
    logger.info(json.dumps(log_entry))

还有一个细节:日志里绝对不能出现原始敏感信息。我在日志记录之前又加了一层脱敏,确保即便脱敏插件在推理侧漏了,写到磁盘的数据也是安全的。这样双层防护,总算通过了安全团队的渗透测试。

防模型泄露

模型权重文件本身是行里的重要资产。我设置了文件权限700,只有推理服务用户可读,并且用dm-crypt对存放权重的分区做了全盘加密,启动时需要插一个UKey才能解密。此外,还加了输出过滤,防止用户通过prompt注入诱导模型吐出原始权重或训练数据片段。虽然这种攻击对8B模型比较困难,但安全团队说“做就做全套”,我只好又加了基于相似度比对的输出过滤器,检测是否包含疑似权重数据的16进制长串。

六个真实坑的血泪复盘:从OOM幽灵到日志塞爆磁盘

前面已经散落了一些坑,这里我把整个项目过程中最典型的六个坑按时间线系统梳理一遍,每个坑都记录了当时的现象、错误日志、排查思路和最终解法。

坑1:离线依赖地狱 —— pip install 把依赖全搬进内网,但CUDA扩展编译失败

现象:内网服务器上安装autoawq时,编译CUDA kernel报错“nvcc fatal: Unsupported gpu architecture ‘compute_86’”。明明驱动和CUDA toolkit版本都对。

排查:原来autoawq的setup.py在编译时会检测本地GPU计算能力,如果没有实际插入显卡或者nvidia-smi返回非预期值,它会fallback到默认的compute_75,而我们没有装对应架构的CUDA运行时。离线环境没有网络,无法下载合适的toolkit头文件。

解决:在联网机器上用相同CUDA版本预编译wheel,通过设置TORCH_CUDA_ARCH_LIST=”8.6″强制生成对应架构的二进制包,然后拷贝到内网直接pip install xxx.whl。同时把所有递归依赖的whl也打包一份,做个本地PyPI索引。

坑2:AWQ校准集不当导致“营收同比增长-12%”

现象:AWQ量化后模型在财务场景偶尔生成负增长率,比如输入“营收增长12%”,输出变成“营收相较去年下滑12%”。

排查:加载量化模型的scale和zero_point参数,发现“增长”这个token的量化误差异常大。回溯校准集,原来我偷懒用了通用新闻语料,里面“增长”常与“负增长”共现,导致量化时对这类转折搭配的权重保护不足。

解决:重构校准集,从行内脱敏后的20000份贷后报告中抽取5000条,保证正面/负面财务表述比例均衡,重新量化,问题消失。

坑3:vLLM + AWQ 的 triton kernel 导致首 token 延迟爆炸

现象:同样AWQ量化模型,用vLLM加载推理,首token延迟始终在3秒以上,而相同模型用AutoAWQ自带推理只要900ms。

排查:profiling发现,vLLM在AWQ模式下对QKV矩阵的解量化操作走了triton的matmul,但triton为A40的SM优化生成的低效网格,导致解量化时间比原生CUDA kernel多出2倍。这个问题只在特定batch size下触发。

解决:换成TensorRT-LLM,它用专门的int4 AWQ kernel,没有triton中间层。性能恢复正常。

坑4:TensorRT-LLM 构建引擎时 “exceeded max batch size” 错误

现象:执行trtllm-build时指定了max_batch_size=16,但构建出的引擎只能接受batch=1,启动推理服务后发第一个请求就报“Requested batch size 1 exceeds engine max batch size 0”。

排查:查看trtllm-build日志,有一行警告“max_num_tokens 8192 is too small for specified max_batch_size, max_batch_size capped to 0”。因为max_num_tokens = max_batch_size * (max_input_len + max_output_len),而我设置的max_input_len=3072,max_output_len=2048,乘积远超8192,导致优化器无法分配profile,直接崩了。

解决:按公式重算,提升max_num_tokens到98304(16*6144),并打开–multiple_profiles,让TensorRT为多种batch/concurrency组合编译不同的CUDA graph。

坑5:审计日志的logrotate没生效,一天写满200G分区

现象:上线第三天/var分区100%占用,所有写操作失败,服务假死。

排查:查看/log/llm/audit.log,单个文件170GB,全是推理verbose信息。原来我配置的logrotate pattern是/var/log/llm/*.log,但TensorRT-LLM的日志是用Python logging写入,文件名固定,每次rotate后程序因为没有收到HUP信号,继续往旧inode写入,导致空间不释放。

解决:改为copytruncate模式,并添加postrotate脚本发送SIGHUP到推理进程。更根本的是直接停掉文件日志,改用journald转发,本地不落盘。

坑6:脱敏规则把“第3季度”里的“3”替换成了

现象:报告里所有带数字的“第X季度”“第X条”都变成了“第季度”,客户经理直接投诉。

排查:Presidio的身份证识别正则过于宽泛,遇到“第3季度”中的“3”与上下文长度刚好匹配身份证前几位的模式,误判率极高。

解决:在敏感信息识别前加入白名单保护层,针对业务常用语(季度、排名、序号)进行正则保护,替换为,等脱敏完成后再还原。这种方法虽然有点补丁摞补丁,但在不训练自定义NLP模型的情况下是最快见效的。

这六个坑每个都花了我至少半天时间定位,有些还导致业务中断,好在最后都填平了。如果要给后来人一句忠告:在银行内网玩大模型,工程上的坑不比你调参少,别以为模型能跑起来就万事大吉,真正的考验在部署、安全、稳定性这些“非AI”的地方。

上线两周后,系统日均处理300次摘要生成请求,平均延迟1.8秒,敏感信息零泄露,审计零整改项。看着监控大屏上平稳的曲线,我知道这两个半月的折腾值了。

发表评论