企业大模型私有化落地:用生产流量后训练打造通用自托管底座
把大模型接进企业内网,很多团队都走过同一条路:POC 阶段调用公有云 API,效果惊艳;等到要私有化、要自托管、要把模型放进生产环境时,效果突然掉了档,业务方一句“还不如原来的规则引擎”,项目就停在那里。问题往往不在于机器买得不够多,也不在于开源模型“不行”,而在于团队把私有化当成了“部署一个模型”这件事,忽略了模型与企业真实业务语言之间的那道鸿沟。
这篇文章讨论的核心是:企业如何用生产流量做 LLM 后训练,把一个大模型底座打磨成能同时服务 200 多个应用的通用自托管模型。我的判断是:企业大模型落地难,难在数据回流和后训练工程化,与显存和并发关系不大。谁先把“生产流量 → 训练数据 → 后训练 → 评测 → 灰度回流”这条链路跑成常规流程,谁才真正拥有自己的模型资产。
如果你正在做这几件事中的任意一件:公司的 AI 中台要统一给几十上百个业务线提供模型能力、安全合规要求模型必须内网自托管、已经在用开源底座但效果始终差一口气、或者单纯想搞清楚 SFT 和 LoRA 到底该在什么时机用——这篇文章会给你一条可以照着走的路径,包括数据脱敏脚本、LoRA 训练配置、vLLM 部署命令、应用层调用封装和一套能落地的评测方法。
需要提前说明一点:全文涉及的数据采集、清洗、训练、上线,都默认在已获得用户授权、已通过合规评审、已完成数据分级的前提下进行。生产流量不是可以随便拿来训练的资源,这条底线必须先划清楚,后面的技术方案才有意义。
1. 这篇文章真正要解决的问题
1.1 私有化落地的三个典型误区
第一个误区是“模型越大越好”。很多团队一上来就要 70B、要 MoE,结果推理集群成本压不住,P95 延迟超过三秒,业务方直接弃用。实际上大量企业场景是结构化抽取、工单归类、话术改写、合同条款比对,这些任务对模型参数量的敏感度远低于对“领域表达习惯”的敏感度。一个经过针对性后训练的 7B 到 14B 模型,在垂直任务上的可用性常常超过未经适配的更大底座。
第二个误区是“私有化 = 部署”。把权重下载下来、用推理框架跑起来,这只是把模型搬进了机房,模型本身对业务一无所知。它不知道你们公司把“回款单”叫“到账凭证”,不知道你们产品线的缩写,不知道工单里那句“老问题又来了”指的是哪个模块。这些知识藏在生产流量的真实表达里,不在预训练语料里。
第三个误区是“一个应用一个模型”。这是最贵的一种做法。200 个应用各微调一份权重,意味着 200 套评测、200 套版本管理、200 次回归测试,运维同学会先崩溃。真正可行的是:一个通用底座承担 80% 的共性能力,少量高价值场景用轻量适配层做增量。
1.2 200+ 应用的真实成本结构
把 200 多个应用拆开看,会发现问题分布非常不均匀。大概有六七成是“问答 + 摘要 + 抽取 + 改写”这类通用能力,共享同一套语言理解基础;两三成有比较强的格式约束,比如必须输出固定 JSON、必须引用原文片段、必须按工单模板填写;剩下不到一成是真正的深水区,需要工具调用、多轮状态管理或者专业知识库支撑。
这意味着技术投入的重心应该这样排:先把通用能力打牢(靠后训练),再把格式约束收进应用配置层(靠 Prompt 和 Schema 校验),最后才对少数深水区场景做专门的适配。如果反过来,先给每个应用单独调模型,就会陷入无穷无尽的定制泥潭。
1.3 什么样的读者最该读这篇
本文更适合这几类角色:负责 AI 中台或模型平台的工程师、需要把大模型接入业务系统的后端开发、负责数据治理和合规的同学,以及正在评估“到底要不要自建模型”的技术负责人。如果你只是想在个人项目里调一次 API,本文的工程量对你是过重的;但如果你面对的是一堆业务方在催、模型效果上不去、成本还在涨的局面,下面这套流程值得完整看一遍。
2. 通用自托管大模型的核心概念与后训练原理
2.1 先把几个容易混淆的概念分开
基座模型(Base Model):在海量通用语料上预训练得到的模型,具备通用语言能力,但不会“听话”,也不懂业务。指令微调后的版本一般叫 Instruct 或 Chat 模型,能理解对话格式。
后训练(Post-training):在基座或指令模型之上做的所有进一步训练,包括继续预训练、监督微调、偏好优化。它决定了模型“像不像你们公司的人”。
SFT(Supervised Fine-Tuning,监督微调):给模型看成对的“输入 → 理想输出”,让它学会任务形态和表达风格。企业里 90% 的后训练需求落在这一步。
LoRA(Low-Rank Adaptation):一种参数高效微调方法,不改动原权重,只在部分层旁挂低秩矩阵。好处是显存占用小、可多份并存、训练快;代价是容量有限,超复杂任务不如全参微调。
偏好优化(如 DPO):给模型看成对的“好回答 / 差回答”,让它学会偏向某种风格或规避某类错误。适合“SFT 之后模型能力有了但风格不对、拒答不对、啰嗦”这类问题。
自托管(Self-hosted):模型权重和推理服务都跑在自己的机房或私有云里,数据不出域。
2.2 一张表看清几种技术路线该怎么选
| 方案 | 解决什么 | 数据要求 | 成本 | 主要风险 |
|---|---|---|---|---|
| Prompt 工程 | 输出格式、角色设定、简单指令 | 无 | 极低 | 对知识和风格无能为力 |
| RAG 检索增强 | 私域知识、时效性事实 | 文档 + 切片 | 中 | 检索质量差就全盘失效 |
| LoRA SFT | 任务形态、领域表达、格式稳定 | 数百到数万条高质量样本 | 中低 | 数据质量差会放大幻觉 |
| 全参 SFT | 深度领域适配 | 数万条以上 | 高 | 灾难性遗忘、成本高 |
| 继续预训练 | 领域术语、行业语料分布 | 千万 token 级语料 | 很高 | 通用能力退化 |
| 偏好优化 | 风格、拒答、简洁度 | 偏好对 | 中 | 标注一致性难保证 |
| 每应用一个模型 | 极致定制 | 每个应用都要数据 | 极高 | 运维不可持续 |
这张表的用法很直接:如果你的痛点是“模型不懂我们的业务知识”,先上 RAG;如果是“模型懂了但输出格式老是崩”,先加 Schema 约束和 Prompt,再考虑 SFT;如果是“模型回答得像通用助手,不像我们的业务专家”,那就是 SFT 的活。
2.3 为什么“一个通用底座”比“一堆垂类小模型”更划算
共享能力带来的边际成本下降是指数级的。第一个应用做完后训练,你需要搭建数据管道、评测集、训练脚本、部署流水线,这部分是一次性投入;第二个应用开始时,数据管道可以直接复用,评测框架改改指标就行,训练只是换一份数据集。到第二十个应用,新增成本主要就是数据标注。
统一底座还带来一个隐性收益:能力可以互相迁移。合同审查场景训练出来的“严谨引用原文”习惯,会顺带提升工单摘要的忠实度。而如果每个场景一个独立小模型,这种正向迁移就没了。
2.4 三层接入架构
落到工程上,200+ 应用的接入通常分成三层:
第一层是权重层,一个通用底座模型,承载语言理解、生成、格式遵循等共性能力,所有应用共享。
第二层是配置层,每个应用一份配置:system prompt、输出 Schema、温度参数、最大长度、是否启用某类工具。这一层让同一份权重在不同应用里表现出不同的“人格”。
第三层是增量适配层,只对少数高价值或高难度场景训练独立的 LoRA 适配器,推理时按 app_id 动态挂载。这一层的存在让“通用”和“专用”不冲突。
3. 生产流量如何变成可训练的数据
3.1 数据来源与合规前提
生产流量是最贴近真实分布的数据源,但也是最敏感的数据源。可用的流量类型通常包括:用户真实提问与模型回答、人工客服会话、工单标题与处理记录、用户对回答的点赞点踩、转人工的原因、业务系统里的结构化字段。
在动手之前必须先完成三件事:明确数据采集的法律依据和用户授权范围;对数据做分级(哪些可以用于训练、哪些只能用于统计、哪些禁止留存);建立审计机制(谁在什么时候用了哪批数据训练了哪个模型)。这三件事没做完就开始写清洗脚本,是很多项目后来被迫回滚的根本原因。
3.2 采样策略:不要只采头部
生产流量天然向头部应用倾斜。如果直接按全量日志采样,最后训练出来的模型会严重偏向少数几个高频场景,长尾应用的效果依然拉胯。
更稳妥的做法是分层采样:先按 app_id 分层,每个应用保底一定样本量;再按意图分层,保证“查询类 / 生成类 / 抽取类 / 多轮类”都有覆盖;最后按时间窗采样,避免某个时间段的突发事件主导数据分布。对于低质量应用(用户点踩率高),采样时可以降权但不能完全丢弃,因为这些样本恰恰是模型需要学会处理的难例。
3.3 清洗:脱敏、去重、过滤
脱敏是第一优先级,而且必须是规则化、可审计、可复现的,不能靠人工抽查。手机号、身份证、银行卡、邮箱、IP、内部密钥、员工工号,这些都要在入库前替换成占位符。
去重同样关键。生产日志里存在大量重复提问,尤其是高频 FAQ。如果不做近重复检测,训练集会被少数几个问题刷屏,模型会过度拟合这些表达。
过滤则要剔除几类样本:明显是测试流量的、回答被用户明确否定的、回答里包含“抱歉我无法回答”这类兜底的、长度异常的、编码乱码的。
3.4 从原始日志到 SFT 样本
原始日志不能直接训练,需要转换成对话格式。SFT 样本的核心是“输入 → 理想输出”,所以日志里的模型回答不能无脑使用,必须经过质量筛选:点赞的、人工修改后采纳的、客服真实回复的,这些才是“理想输出”的候选。
一个常见做法是把“用户提问 + 人工最终回复”作为正样本,把“用户提问 + 被点踩的模型回答”作为负样本保留,后者在偏好优化阶段会派上用场。
3.5 训练集与评测集的切分
这里最容易犯的错误是随机切分。同一批流量里,同一个用户可能在周一问了一次、周三又问了一次,随机切分会让几乎相同的样本同时出现在训练集和评测集里,评测分数虚高。
正确做法是按时间切分:用较早时间窗的数据训练,用较晚时间窗的数据评测;或者按会话切分:同一个会话的所有轮次只能落在同一侧。评测集一旦确定就冻结,后续所有模型版本都用同一份评测集对比,否则不同版本之间的数字没有可比性。
4. 环境准备与前置条件
4.1 硬件与软件栈
硬件规模取决于你选的模型尺寸和训练方式。经验上,LoRA 微调对显存的要求远低于全参微调,参数量在十亿级的模型用单机多卡即可完成;参数量到几十亿以上,通常需要多机多卡配合 ZeRO 或类似的切分策略。具体的显存数字强依赖序列长度、批大小和是否开启梯度检查点,建议先用极小批量跑通流程,再逐步放大,不要一上来就按理论值采购。
软件栈方面,本文示例涉及的组件包括:Python 环境、PyTorch、Hugging Face 的 transformers 与 peft、训练框架(示例使用 LLaMA-Factory 这类封装度较高的方案,也可以直接用 transformers + peft 自己写训练循环)、推理框架(示例使用 vLLM)、以及用于服务调用的 OpenAI 兼容客户端。所有版本请以实际项目为准,本文重点演示通用思路,不绑定具体版本号。
4.2 目录结构约定
工程一开始就把目录定好,后面会省很多事。推荐如下结构:
llm-platform/ ├── configs/ # 训练、导出、应用注册配置 │ ├── lora_sft_v1.yaml │ ├── export_lora_v1.yaml │ └── apps.yaml ├── data/ │ ├── raw/ # 原始流量导出(受限访问) │ ├── cleaned/ # 脱敏去重后 │ └── sft/ # 训练格式 ├── scripts/ │ ├── 01_sanitize.py │ ├── 02_build_sft.py │ └── 03_eval.py ├── prompts/ # 各应用 system prompt ├── schemas/ # 各应用输出 JSON Schema └── app/ └── llm_client.pydata/raw目录应该设置最严格的访问权限,只允许数据管道服务账号读取,且开启访问审计。这一点在合规评审时经常被问到。
4.3 网络与权限准备
自托管环境通常是隔离网络。需要提前准备:模型权重的离线导入方式(内网镜像仓库或一次性介质拷贝)、Python 依赖的离线源、训练框架的镜像、以及 GPU 节点的驱动与容器运行时。另外要确认训练集群和推理集群是否分离——训练任务会长时间占满显存,和生产推理混部会直接影响线上延迟。
5. 核心流程拆解:从基线到灰度
5.1 第一步:建立基线评测集
在训练任何模型之前,先把评测集建起来。没有基线,后面所有的“效果提升”都无法证明。评测集应该覆盖每个应用的核心任务,每个任务几十到几百条不等,标注必须由真正懂业务的人来做。
评测集的形式建议是“输入 + 期望要点 + 硬性约束”。硬性约束例如“必须输出合法 JSON”“必须包含原文中的金额”“不得编造条款编号”,这类约束可以用程序自动判定,比人工打分稳定得多。
5.2 第二步:跑通未训练模型的基线分数
用原始底座模型在评测集上跑一遍,记录各项指标。这一步的意义是定义“起点在哪”。很多团队跳过这步,结果训练完发现分数提升但不知道提升从哪来,也无法判断新数据是否有用。
5.3 第三步:构造训练数据并做质量抽检
按第 3 节的流程产出 SFT 数据集。抽检环节不能省:随机抽 200 条人工看一遍,统计格式错误率、事实错误率、脱敏遗漏率。如果脱敏遗漏率不为零,整批数据退回重做。
5.4 第四步:LoRA 微调
先用小学习率、少轮次跑一个短周期,确认 loss 正常下降、验证集指标有提升,再放大训练量。第一版不要追求极致效果,先要一个“能跑通、可复现、可回滚”的版本。
5.5 第五步:合并导出与推理部署
LoRA 训练产物是适配器权重,需要和底座合并成完整模型才能交给推理框架。导出后要在推理集群上用统一的启动参数部署,并确认 OpenAI 兼容接口可用。
5.6 第六步:灰度与流量回流
上线不是终点。先影子流量(把生产请求复制一份给新模型,只记录不返回),观察一周的输出质量和延迟;再小比例灰度(比如 5% 真实流量);确认稳定后逐步放量。同时把灰度期间的新流量重新汇入数据池,形成回流。
这个循环跑顺之后,模型迭代周期可以从按月缩短到按周。
6. 完整示例与代码实现
6.1 生产日志脱敏与清洗脚本
下面这个脚本处理的是从生产环境导出的 jsonl 日志,完成脱敏、规范化、质量过滤、去重四件事。
# 文件路径:scripts/01_sanitize.py import re import json import hashlib import argparse from pathlib import Path PII_PATTERNS = [ ("PHONE", re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")), ("ID_CARD", re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)")), ("EMAIL", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")), ("BANK_CARD", re.compile(r"(?<!\d)\d{16,19}(?!\d)")), ("IPV4", re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)")), ] SECRET_PATTERN = re.compile( r"(?i)(access[_-]?key|secret[_-]?key|api[_-]?key|token|password)\s*[:=]\s*\S+" ) BAD_ANSWER_MARKERS = ["抱歉,我无法", "作为一个AI", "我无法回答", "请咨询人工"] def mask(text: str) -> str: if not text: return "" for name, pattern in PII_PATTERNS: text = pattern.sub(f"<{name}>", text) text = SECRET_PATTERN.sub("<SECRET>", text) return text def normalize(text: str) -> str: text = re.sub(r"[ \t\u3000]+", " ", text or "") text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def simhash(text: str, bits: int = 64) -> int: tokens = [text[i:i + 3] for i in range(max(len(text) - 2, 1))] if not tokens: return 0 vector = [0] * bits for token in tokens: h = int(hashlib.md5(token.encode("utf-8")).hexdigest(), 16) for i in range(bits): vector[i] += 1 if (h >> i) & 1 else -1 result = 0 for i in range(bits): if vector[i] > 0: result |= 1 << i return result def hamming(a: int, b: int) -> int: return bin(a ^ b).count("1") def quality_ok(record: dict, min_query: int = 4, max_query: int = 2000) -> bool: query = record.get("query", "") answer = record.get("answer", "") if not (min_query <= len(query) <= max_query): return False if len(answer) < 8: return False if any(marker in answer for marker in BAD_ANSWER_MARKERS): return False if record.get("is_test") is True: return False return True def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--near-dup-threshold", type=int, default=3) args = parser.parse_args() seen_exact = set() buckets = {} kept = 0 total = 0 out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) with open(args.input, "r", encoding="utf-8") as fin, \ open(out_path, "w", encoding="utf-8") as fout: for line in fin: line = line.strip() if not line: continue total += 1 try: record = json.loads(line) except json.JSONDecodeError: continue record["query"] = normalize(mask(record.get("query", ""))) record["answer"] = normalize(mask(record.get("answer", ""))) if not quality_ok(record): continue digest = hashlib.md5( (record["query"] + "\x00" + record["answer"]).encode("utf-8") ).hexdigest() if digest in seen_exact: continue seen_exact.add(digest) fingerprint = simhash(record["query"]) bucket_key = (record.get("app_id", "unknown"), fingerprint >> 52) is_dup = False for other in buckets.get(bucket_key, []): if hamming(fingerprint, other) <= args.near_dup_threshold: is_dup = True break if is_dup: continue buckets.setdefault(bucket_key, []).append(fingerprint) fout.write(json.dumps(record, ensure_ascii=False) + "\n") kept += 1 print(f"total={total} kept={kept} dropped={total - kept}") if __name__ == "__main__": main()运行方式:
python scripts/01_sanitize.py \ --input data/raw/traffic_2024w01_w08.jsonl \ --output data/cleaned/traffic_v1.jsonl \ --near-dup-threshold 3几个关键点值得说明。脱敏采用“先替换后规范化”的顺序,是因为原始文本里的手机号可能被空格或换行打断,先做字符规范化反而会让正则更难匹配,所以这里的正则都加了数字边界断言,保证不会把长数字串的一部分误判成手机号。去重分两层:MD5 精确去重成本极低,先做;SimHash 做近重复检测,按app_id + 高 12 位指纹分桶,只在桶内比较汉明距离,避免 O(n²) 全量对比。near-dup-threshold设成 3 是个偏保守的取值,如果发现重复率仍然很高可以调到 5,但不要调太大,否则会把正常的相似提问也误杀。
脚本输出的统计数字要保留下来,它是数据质量的第一个观测点。正常生产流量的保留率通常在四成到七成之间,如果保留率低于三成,说明质量过滤规则过严或者数据源本身有问题,需要先排查上游。
6.2 转换成训练框架可读的 SFT 格式
清洗后的数据还需要转成对话格式,并生成数据集描述文件。
# 文件路径:scripts/02_build_sft.py import json import argparse from pathlib import Path SYSTEM_PROMPT = "你是企业内部的智能助手,回答需准确、简洁,引用信息时不得编造。" def to_sharegpt(record: dict) -> dict: return { "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": record["query"]}, {"role": "assistant", "content": record["answer"]}, ], "meta": { "app_id": record.get("app_id", "unknown"), "source": record.get("source", "traffic"), }, } def main(): parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--max-samples", type=int, default=200000) args = parser.parse_args() records = [] with open(args.input, "r", encoding="utf-8") as fin: for line in fin: line = line.strip() if not line: continue records.append(to_sharegpt(json.loads(line))) if len(records) >= args.max_samples: break out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) with open(out_path, "w", encoding="utf-8") as fout: for item in records: fout.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"written={len(records)} -> {out_path}") if __name__ == "__main__": main()配套的数据集描述文件,告诉训练框架去哪里找数据、字段怎么映射:
{ "enterprise_sft_v1": { "file_name": "enterprise_sft_v1.jsonl", "formatting": "sharegpt", "columns": { "messages": "messages" }, "tags": { "role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant", "system_tag": "system" } } }这里有一个容易被忽略的细节:不同训练框架对dataset_info.json的字段名要求不完全一致,上面的写法适用于 LLaMA-Factory 这一类框架,如果你用的是别的方案,字段映射需要按官方文档调整。判断标准很简单:数据能正常加载、日志里打印的样本条数和预期一致、没有出现字段缺失报错,就算通过。
6.3 LoRA 微调配置与启动命令
# 文件路径:configs/lora_sft_v1.yaml ### 模型 model_name_or_path: /data/models/base-instruct trust_remote_code: true ### 方法 stage: sft do_train: true finetuning_type: lora lora_rank: 16 lora_target: all lora_dropout: 0.05 ### 数据 dataset: enterprise_sft_v1 dataset_dir: /data/dataset template: default cutoff_len: 4096 max_samples: 200000 overwrite_cache: true preprocessing_num_workers: 16 ### 输出 output_dir: /data/output/lora_sft_v1 logging_steps: 10 save_steps: 500 plot_loss: true overwrite_output_dir: true ### 训练 per_device_train_batch_size: 2 gradient_accumulation_steps: 8 learning_rate: 1.0e-4 num_train_epochs: 3.0 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true gradient_checkpointing: true ddp_timeout: 180000000 ### 验证 val_size: 0.02 per_device_eval_batch_size: 2 eval_strategy: steps eval_steps: 500单机多卡启动:
# 单机 8 卡 FORCE_TORCHRUN=1 NNODES=1 NPROC_PER_NODE=8 \ llamafactory-cli train configs/lora_sft_v1.yaml参数选择的思路说明白比参数本身更重要。lora_rank设 16 是通用起点:任务形态适配(学格式、学语气)16 足够;如果任务涉及较复杂的推理链或多步决策,可以提高到 32 或 64,但要注意过拟合风险。lora_target: all表示对所有线性层挂适配器,覆盖面广但训练量稍大;如果只是想调整输出风格,挂到注意力层就够了。learning_rate用 1e-4 量级是 LoRA 的常见区间,全参微调要低一到两个数量级。val_size这里是随机切分,仅用于监控训练过程是否过拟合,真正的效果评估必须用第 3.5 节提到的冻结评测集,两者不能混用。
6.4 合并导出与推理服务部署
训练产物是适配器,需要与底座合并:
# 文件路径:configs/export_lora_v1.yaml model_name_or_path: /data/models/base-instruct adapter_name_or_path: /data/output/lora_sft_v1 template: default finetuning_type: lora export_dir: /data/models/enterprise-llm-v1 export_size: 4 export_device: cpu export_legacy_format: falsellamafactory-cli export configs/export_lora_v1.yaml用 vLLM 部署成 OpenAI 兼容服务:
vllm serve /data/models/enterprise-llm-v1 \ --served-model-name enterprise-llm-v1 \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 2 \ --max-model-len 8192 \ --gpu-memory-utilization 0.90 \ --enable-prefix-caching--tensor-parallel-size要和 GPU 数量匹配,--max-model-len设得越大显存占用越高,如果业务最长上下文只有 4K,不要盲目设成 32K。--gpu-memory-utilization留在 0.9 左右是给 KV Cache 留空间的常用取值,调满容易在长请求进来时 OOM。
6.5 应用层调用封装与灰度路由
应用层的价值在于把“模型能力”转换成“应用能力”。核心是两件事:统一的调用封装,以及基于 app_id 的灰度路由。
# 文件路径:configs/apps.yaml apps: - app_id: contract_review name: 合同审查助手 model: enterprise-llm-v1 system_prompt_file: prompts/contract_review.md output_format: json schema_file: schemas/contract_review.json temperature: 0.1 max_tokens: 2048 rollout: 0.2 - app_id: ticket_summary name: 工单摘要 model: enterprise-llm-v1 system_prompt_file: prompts/ticket_summary.md output_format: text temperature: 0.3 max_tokens: 512 rollout: 0.5# 文件路径:app/llm_client.py import hashlib from pathlib import Path import yaml from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="EMPTY") CONFIG_PATH = Path("configs/apps.yaml") def load_apps() -> dict: with open(CONFIG_PATH, "r", encoding="utf-8") as f: data = yaml.safe_load(f) return {app["app_id"]: app for app in data["apps"]} APP_REGISTRY = load_apps() def read_prompt(path: str) -> str: return Path(path).read_text(encoding="utf-8").strip() def in_rollout(app_id: str, user_id: str, ratio: float) -> bool: if ratio <= 0: return False if ratio >= 1: return True key = f"{app_id}:{user_id}".encode("utf-8") bucket = int(hashlib.md5(key).hexdigest()[:8], 16) % 100 return bucket < ratio * 100 def chat(app_id: str, user_id: str, query: str) -> dict: app = APP_REGISTRY[app_id] model = app["model"] if in_rollout(app_id, user_id, app.get("rollout", 0)) \ else app.get("fallback_model", "legacy-llm-v0") resp = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": read_prompt(app["system_prompt_file"])}, {"role": "user", "content": query}, ], temperature=app.get("temperature", 0.3), max_tokens=app.get("max_tokens", 1024), timeout=30, ) return { "app_id": app_id, "model": model, "content": resp.choices[0].message.content, "usage": { "prompt_tokens": resp.usage.prompt_tokens, "completion_tokens": resp.usage.completion_tokens, }, }这段代码有三个设计考虑。灰度用app_id + user_id做哈希分桶而不是随机数,是为了保证同一个用户在一次会话里始终命中同一个模型,避免回答风格前后跳变。fallback_model的存在让灰度可以随时一键回退,出问题时改配置比重启服务快得多。timeout必须设置,自托管模型在长输入下延迟波动比公有云 API 更明显,没有超时保护会把上游服务一起拖垮。
6.6 评测脚本
# 文件路径:scripts/03_eval.py import json import re import time import argparse from collections import defaultdict from openai import OpenAI client = OpenAI(base_url="http://127.0.0.1:8000/v1", api_key="EMPTY") JSON_BLOCK = re.compile(r"\{.*\}", re.S) def check_json(text: str) -> bool: match = JSON_BLOCK.search(text or "") if not match: return False try: json.loads(match.group(0)) return True except json.JSONDecodeError: return False def check_keywords(text: str, keywords) -> bool: return all(kw in (text or "") for kw in keywords) def main(): parser = argparse.ArgumentParser() parser.add_argument("--evalset", required=True) parser.add_argument("--model", default="enterprise-llm-v1") args = parser.parse_args() stats = defaultdict(lambda: {"total": 0, "json_ok": 0, "kw_ok": 0, "latency": 0.0}) with open(args.evalset, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue item = json.loads(line) app_id = item["app_id"] start = time.time() resp = client.chat.completions.create( model=args.model, messages=[ {"role": "system", "content": item.get("system", "")}, {"role": "user", "content": item["query"]}, ], temperature=0.1, max_tokens=item.get("max_tokens", 1024), ) cost = time.time() - start content = resp.choices[0].message.content s = stats[app_id] s["total"] += 1 s["latency"] += cost if item.get("expect_json") and check_json(content): s["json_ok"] += 1 if item.get("must_contain") and check_keywords(content, item["must_contain"]): s["kw_ok"] += 1 for app_id, s in stats.items(): n = s["total"] print( f"{app_id}: n={n} " f"json_ok={s['json_ok'] / n:.2%} " f"kw_ok={s['kw_ok'] / n:.2%} " f"avg_latency={s['latency'] / n:.2f}s" ) if __name__ == "__main__": main()运行:
python scripts/03_eval.py --evalset data/eval/frozen_eval_v1.jsonl --model enterprise-llm-v1评测集每行的格式设计成“可程序判定的约束”,而不是“参考答案全文”。原因很实际:生成类任务的参考答案几乎没有完全匹配的可能,用精确匹配会得到全零分;而“必须输出合法 JSON”“必须包含某个金额字段”“不得出现某个禁用词”这类约束既稳定又和业务价值强相关。
7. 运行结果验证与灰度指标
7.1 该看哪些指标
训练是否成功,不能只看 loss。下面这张表是我建议在项目里固定下来的观测面板:
| 指标 | 含义 | 观察重点 |
|---|---|---|
| 格式合规率 | 输出能被 JSON Schema 解析的比例 | 抽取类应用应接近 100% |
| 要点命中率 | 必须包含的关键信息命中比例 | 反映事实准确性 |
| 禁用项违规率 | 出现禁用表达、编造字段的比例 | 应尽可能接近 0 |
| 拒答率 | 模型选择不回答的比例 | 过低说明约束不足,过高说明过度保守 |
| 平均输出长度 | 生成 token 数 | 异常增长往往意味着啰嗦或跑题 |
| P95 延迟 | 端到端响应时间 | 直接影响业务方接受度 |
| 单位请求成本 | token 数 × 单价 | 决定方案能否规模化 |
训练过程中的观测点则不同:loss 是否单调下降但不过快趋近 0(趋近太快往往是过拟合或数据重复)、验证集 loss 是否在某个 epoch 后开始回升、梯度范数是否异常。这些信号比最终评测更早暴露问题。
7.2 灰度节奏
推荐的节奏是四阶段。第一阶段影子流量,把真实请求复制一份给新模型但不返回给用户,只做离线比对,重点看格式合规率和有没有离谱输出,持续一周左右。第二阶段 5% 灰度,真实返回给用户,重点看用户点踩率和转人工率有没有异常上升。第三阶段 30% 到 50%,观察成本和延迟。第四阶段全量。
每个阶段的放量判断标准要提前写进上线文档,不能“感觉差不多就放”。比如可以规定:格式合规率不低于基线的 98%、点踩率不高于基线的 110%、P95 延迟不超过 3 秒,三条同时满足才允许进入下一阶段。
7.3 失败时先看哪里
如果评测分数低于预期,排查顺序建议是:先看训练数据本身(抽 50 条看格式和内容),再看评测脚本是否用错了模型名或 prompt,然后看推理服务是否加载了正确的权重(对比模型路径和启动日志),最后才怀疑超参。实践中相当一部分“训练没效果”的案例,根因是推理服务指向了旧目录,或者应用层 prompt 和训练时的 system prompt 不一致。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| loss 长期不下降 | 学习率过小、数据格式与模板不匹配 | 打印前几条 tokenize 后的样本,检查是否被截断或错位 | 调整学习率,确认 template 与模型匹配 |
| loss 迅速趋近 0 | 数据重复率高、样本量过小 | 统计去重前后样本量比值 | 加强近重复过滤,扩大数据源 |
| 训练后通用能力明显退化 | 训练数据过于单一、轮次过多 | 用通用能力评测集做回归 | 混合通用指令数据,减少 epoch,降低学习率 |
| 输出 JSON 格式频繁崩坏 | 训练样本格式不统一、缺 schema 约束 | 检查训练样本中 JSON 的写法是否一致 | 统一输出格式,推理侧加 schema 校验和重试 |
| 模型开始编造字段 | 训练数据中存在幻觉样本 | 抽检训练集里的事实性内容 | 剔除低质量样本,补充拒答样本 |
| 推理显存 OOM | max-model-len 过大、并发过高 | 查看启动日志和显存监控 | 降低上下文长度和显存利用率,启用分页注意力 |
| 吞吐上不去 | 批处理未开启、请求过短 | 观察吞吐与批大小的关系 | 开启连续批处理与前缀缓存 |
| 同一用户回答风格跳变 | 灰度路由随机导致 | 检查分桶逻辑 | 用 app_id + user_id 做稳定哈希 |
| 训练数据出现敏感信息 | 脱敏规则遗漏 | 对训练集跑一遍脱敏检测脚本 | 补充规则,整批数据回炉 |
| 评测分数虚高 | 训练集与评测集存在重叠 | 检查是否有相同 query | 改为按时间或按会话切分 |
这张表里最值得警惕的是最后一条。评测分数虚高会让团队误判模型已经可用,等真正灰度时才发现效果断崖式下跌,此时已经消耗了大量标注和算力成本。防御办法很朴素:评测集冻结后只读不写,任何新增都进新版本,同时定期用自动化脚本检查训练集和评测集的重叠率。
9. 最佳实践与工程建议
9.1 数据侧:把治理做在采集之前
数据治理最忌讳“先采再治”。更稳妥的做法是在采集端就完成分级和打标:每条流量记录带上data_level、consent_scope、retention_days三个字段,下游管道根据这些字段决定能不能用于训练、能保留多久。这样当合规要求变化时,只需要改配置和跑一次清理任务,不需要重新梳理全量数据。
另外建议给每个训练数据集建立“数据卡”:来源时间窗、样本量、脱敏规则版本、质量抽检结论、对应评测集版本。模型出问题时,这张卡是唯一能快速定位原因的东西。
9.2 模型侧:版本管理与回滚
模型版本号必须能追溯:enterprise-llm-v1要能对应到具体的底座版本、适配器版本、数据集版本和训练配置。推荐用类似{业务线}-{基座简称}-{数据版本}-{训练日期}的命名。推理服务启动时把模型路径和版本信息写进日志,出问题时第一眼就能确认加载的是哪一份权重。
回滚路径要在上线前演练一次。应用注册表里的fallback_model字段就是为了这个,确认改配置后能在秒级切回旧模型,而不是等重新部署。
9.3 评测侧:冻结集 + 场景分布
评测集的质量决定了整个项目的判断力。两个原则:一是冻结,版本化只增不改;二是分布要和真实流量对齐,每个应用的样本量按真实调用量加权,避免评测集里全是容易的头部场景。
对 200+ 应用的场景,建议做两级评测:一级是全局评测集,衡量通用能力有没有退化;二级是应用级评测集,每个应用几十条,衡量垂直任务。全局分数掉了通常是数据配比或训练轮次的问题,单个应用分数掉了通常是那一批数据的问题,两类问题的排查路径完全不同。
9.4 安全侧:最小权限与审计
训练数据的访问权限应该收敛到最小:原始日志只有数据管道服务账号可读,清洗后的数据只有算法团队可读,训练任务运行在独立命名空间。所有读取行为记审计日志,保留时间按合规要求设定。
推理侧同样要注意。应用层不应该拿到模型的全部能力,system prompt 里要明确边界,对可能产生高风险输出的场景(合同结论、财务判断)加人工复核环节。模型输出永远只是建议,不是最终决策,这个定位要在系统设计里体现出来。
9.5 成本侧:路由与缓存
200+ 应用统一走一个模型,成本控制主要靠三件事。一是模型路由:简单任务走小模型,复杂任务走大模型,用规则或轻量分类器判断即可。二是前缀缓存:同一个应用的 system prompt 完全相同,开启前缀缓存能显著降低首 token 延迟和计算量。三是结果缓存:高频重复问题(比如“报销流程是什么”)直接命中缓存,不必每次都推理。
这三件事叠加起来,实际成本往往比“所有请求都走最大模型”低一个数量级,而效果损失在多数场景下难以感知。
9.6 组织侧:让回流成为流程而不是项目
最后一点最容易被忽视。数据回流如果只是“项目上线后顺便做一下”,很快就会断。要让它持续运转,需要明确责任:谁负责每周导出流量、谁负责清洗和质量抽检、谁负责更新评测集、谁决定是否发起新一轮训练。把这些写进值班表或者迭代计划,链路才不会在第三个月就停摆。
10. 从一条链路到一套能力
把这件事想清楚之后,会发现企业自托管大模型的核心资产其实是三样东西:一条可审计的数据回流管道、一套冻结的评测基准、一套能快速迭代和回滚的训练部署流程。模型的权重只是这三样东西的产物,换一个底座,流程照样能跑。
如果要从今天开始动手,建议的顺序是:先用现有日志跑通脱敏和清洗脚本,看看数据保留率和质量分布;然后挑一个应用、几十条评测样本,跑一次 LoRA 微调,把训练到部署的链路走通;等这条最小闭环稳定了,再逐步把应用扩到十个、五十个。一上来就规划 200 个应用的统一平台,很容易在基础设施还没验证的时候就陷进架构设计里。
几个具体的技术提醒:system prompt 在训练和推理时必须保持一致,这是最容易出错也最难发现的问题;评测集一旦冻结就不要因为“这题太难”而删掉,难例恰恰是最有信息量的;灰度路由用稳定哈希而不是随机数;任何一次数据重跑都要记录脱敏规则版本,否则出了问题无法复现。
后面值得继续深入的方向包括:偏好优化在拒答和简洁度上的应用、多 LoRA 动态挂载的推理调度、以及把工具调用能力沉淀进通用底座。这几块做扎实之后,模型才真正从“能聊天”变成“能干活”。