ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

企业大模型私有化落地:用生产流量后训练打造通用自托管底座

企业大模型私有化落地:用生产流量后训练打造通用自托管底座

把大模型接进企业内网,很多团队都走过同一条路: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.py

data/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: false
llamafactory-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 校验和重试
模型开始编造字段训练数据中存在幻觉样本抽检训练集里的事实性内容剔除低质量样本,补充拒答样本
推理显存 OOMmax-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 动态挂载的推理调度、以及把工具调用能力沉淀进通用底座。这几块做扎实之后,模型才真正从“能聊天”变成“能干活”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进