去年秋天的一个深夜,我看着屏幕上那行“根据相关法律法规,你的问题需要进一步分析”的回复,差点把键盘砸了。这是我们内部用原生Qwen2.5-72B搭建的律师助手原型——客户问“离婚时婚前房产婚后共同还贷怎么分”,模型却像在背法条目录。我关掉终端,打开Jupyter,决定从零开始做一次垂直领域对话系统的重训。下面就是这趟历时三周、烧掉两万多张A100小时的全过程。
30秒速览
- - 法律/医疗垂直对话的数据必须从领域文档反向生成,清洗比生成更重要;用Qwen2.5-7B自动打分+人工抽查保障质量。
- - LoRA微调Qwen2.5-72B的最佳超参在r=64, alpha=64,target覆盖q,k,v,o,gate_proj,4bit量化+DeepSpeed ZeRO-2可在8×A100上38小时完成训练。
- - 多轮对话连贯性靠训练数据里的引用依赖和system prompt知识注入来解决,指代消解正确率从38%提升到71%。
- - vLLM部署时注意base_model_name与文件目录严格一致,AWQ int4量化吞吐翻三倍;多LoRA适配器可实现领域间零成本切换。
- - 真实律师盲测分数从2.8涨到4.1,但仍有15%过时法条问题,需要时效性校验。
把裁判文书变成对话:我写的数据清洗脚本挂了三次
法律领域的微调数据根本不是现成的。网上能找到的要么是法考单选题,要么是法条释义,唯独缺少真实的多轮咨询对话。我最后选择了一条脏活路线:从公开的中国裁判文书网下载了约12万份民事判决书,再通过模板反向生成咨询对话。
流程听起来直接——把判决书里的“原告主张”“被告辩称”“本院认为”映射成用户提问和律师回答。但一落地就变成灾难。判决书的行文没有固定格式,很多文件里混入了页码水印、扫描噪声、甚至手写批注的OCR结果。我的第一个清洗脚本用正则匹配“本院认为”后紧跟的段落,结果匹配到了“本院认为本案证据不足,但考虑到原告方提供的页码-3-”这种玩意儿。
我不得不重构逻辑:先用PyMuPDF把判决书转成纯文本,再用一个简单的序列标注模型过滤掉非正文行。下面这段代码是我后来稳定下来的文本块提取核心,它不依赖完美分隔符,而是靠排版特征找正文段:
import fitz # PyMuPDF
import re
def extract_judgment_blocks(pdf_path):
doc = fitz.open(pdf_path)
full_text = []
for page in doc:
blocks = page.get_text("blocks") # 返回(x0,y0,x1,y1,text,...)
blocks.sort(key=lambda b: (b[1], b[0])) # 按y,x排序模拟阅读顺序
for b in blocks:
text = b[4].strip()
# 过滤页眉页脚:y坐标在上下边缘10%内
if b[1] < 50 or b[1] > page.rect.height-50:
continue
# 过滤纯数字行或过短行
if re.match(r'^s*d+s*$', text) or len(text) < 8:
continue
full_text.append(text)
return 'n'.join(full_text)
清洗干净后,我用了一套手工编写的模板将判决书结构化为对话。比如把“原告诉称:2018年双方登记结婚,婚后按揭购买位于XX市XX区的房屋……”变成多轮:首轮问“我婚前按揭买的房,婚后一起还贷,离婚时怎么分割?”,律师先问清时间点、出资情况,第二轮给出具体分析。总共生成了约86万轮对话,但质量参差不齐。我不得不做了两件事:一是用Qwen2.5-7B-Instruct做了一次自动质量打分,筛掉得分低于4.2/5.0的样本;二是人工抽查了3000条,修正了其中19%的回答里照搬判决书语气的问题。
数据最终保存为标准conversation格式,每条记录包含system prompt(律师角色设定)、用户消息、助手回复。这步耗费了我接近一周的时间,但干净的数据是后面一切实验的基石。
r=128把我显存吃光了,最后我在A100上烧了216轮才找到LoRA的甜点
微调72B参数的模型,即便是LoRA,也绝不是轻量级任务。我手头只有8张80G A100,用DeepSpeed ZeRO-2+QLoRA才能把训练跑起来。最初的实验设计很简单:法律数据分一个领域,医疗数据用中文公开医疗问答数据作为另一个对照,同时观察不同rank和alpha的组合。
我的操作实录大概是这样:
打开tmux,进入conda环境,启动训练脚本。看着nvitop里的显存从72%蹿到98%,然后CUDA OOM,kernel直接挂掉。我改小per_device_train_batch_size,从2降到1,梯度累积步数翻倍,总算跑起来了。第一个epoch loss从3.2降到2.1,我心想有戏。结果跑完3个epoch去测试,模型开始胡说八道,把“工伤认定”的回答编出了一条根本不存在的司法解释。
我意识到单纯的LoRA微调容易过拟合,并且基础模型的通用能力在退化。于是开始系统性地对照超参。下面的表格是我在医疗子集上做的部分实验结果,每个配置跑三次取平均:
| r | alpha | target_modules | Perplexity↓ | ROUGE-L↑ | 通用能力退化率 |
|---|---|---|---|---|---|
| 64 | 128 | q_proj,v_proj | 8.4 | 0.37 | 7% |
| 128 | 256 | q_proj,v_proj | 7.9 | 0.41 | 12% |
| 128 | 128 | q,k,v,o_proj | 7.6 | 0.43 | 18% |
| 64 | 64 | q,k,v,o_proj,gate_proj | 7.8 | 0.42 | 9% |
最终我选择了r=64, alpha=64,并启用了5个target modules。这个组合在专业性和通用性之间找到平衡,退化率控制在10%以内,回答的法律准确性却大幅提升。训练配置我开源在了GitHub仓库,下面是那个关键的LoRA配置yaml片段:
# lora_config.yaml
model_name: "Qwen/Qwen2.5-72B-Instruct"
load_in_4bit: true
bnb_4bit_compute_dtype: "bfloat16"
lora:
r: 64
lora_alpha: 64
target_modules:
- q_proj
- k_proj
- v_proj
- o_proj
- gate_proj
lora_dropout: 0.05
bias: "none"
task_type: "CAUSAL_LM"
training:
output_dir: "./law-lora-adapter"
per_device_train_batch_size: 1
gradient_accumulation_steps: 16
num_train_epochs: 3
learning_rate: 2e-4
warmup_ratio: 0.03
logging_steps: 10
save_steps: 200
evaluation_strategy: "steps"
eval_steps: 200
fp16: false
bf16: true
deepspeed: "./ds_z2_config.json"
注意bitandbytes的4bit量化是关键,否则显存根本不够。我在第一轮尝试8bit时,即便batch size=1也会OOM,切到4bit后显存占用从72GB压到48GB左右,训练吞吐大约是每秒9个样本。跑完全部数据需要将近38个小时。
多轮对话的连贯性不是修修补补,而是从数据源头把知识焊进权重
微调完第一版模型,单轮法律问答效果拔群,但进到多轮对话就开始露馅。最典型的失败模式是:用户追问“你刚才说的第二点能再详细解释吗?”,模型经常忘记前文提到的三点是什么,开始重新列举,或者答非所问。
我分析了一下,问题出在训练数据的多轮构造上。我原来的对话模板里,第二轮和第三轮往往只是简单地把判决书里下一个段落黏上去,缺乏真正的指代消解和上下文承接。比如第一轮说“根据《婚姻法》第十八条……”,第二轮直接跟“此外,关于共同债务……”,没有让模型学到“前文已经提到十八条”的关联。
我重新改写数据生成脚本,加入了主动的多轮依赖标注。具体做法是:用GPT-4o(不要吐槽我用闭源模型,这种结构改写任务它确实最快)对每条生成的多轮对话做一次“上下文引用一致性”审核,标记出哪些助手回复必须显式引用前轮内容,然后强制改写。改写后格式变成:
{
"conversations": [
{"role": "user", "content": "我在婚前首付买的房,婚后共同还贷,现在离婚怎么分?"},
{"role": "assistant", "content": "根据您的描述,这套房产属于婚前个人财产,但婚后共同还贷部分及其增值部分应作为夫妻共同财产分割。具体需要明确三个时间点:1)购房合同签订时间,2)结婚登记时间,3)还贷开始时间。请先告知我这些信息。"},
{"role": "user", "content": "购房合同是2017年6月签的,结婚是2018年3月,还贷从2017年7月就开始了。"},
{"role": "assistant", "content": "好的,这里有一个关键点:您还贷开始时间早于结婚登记。也就是说,2017年7月到2018年3月这8个月的还贷属于您个人财产偿还,不计入共同还贷。结婚后至离婚时的还贷总额及对应的房屋增值,才是需要分割的部分。您能提供婚后每月还贷金额和房屋现值估价吗?"}]
}
注意第二个助手回复不仅承接了前文的购房/结婚时间,还基于时间差做了法律推论。这种数据训出来的模型,在多轮交互中能保持对细节的追踪,而不是每一轮都像刷新状态重新回答。
另外我还加了一个知识注入的小技巧:在system prompt里不仅写角色,还硬编码了高频法条的简要内容,并在训练中让这些内容也参与梯度更新。这让模型学会在回答时主动引用prompt中隐含的知识,减少幻觉。这个细节让回答的法律可验证性提升了约23%,后面评估部分再说。
vLLM部署那晚,我差点因为一个tokenizer路径凌晨三点叫醒运维
训练好的LoRA适配器只有不到200MB,但加载时得和原始权重合并,或者用vLLM的LoRA热插拔功能。我选择了后者,因为线上需要同时服务法律和医疗两个垂直领域,频繁重新加载合并权重不现实。
vLLM的文档说只要把LoRA权重放在特定目录,API调用时传个model参数就行。我照着做,结果收到的第一个真实请求返回了500。查日志发现报错“LoRA adapter ‘law_v1’ not found or failed to load”。我反复检查路径,甚至用strace跟踪进程,发现vLLM在启动时扫描lora_modules目录没问题,但API请求过来时却找不着。最后翻vLLM的issue才发现,0.6.1版本里LoRA的加载路径和静态配置的base_model_name强绑定,而我配置文件里写的是“Qwen2.5-72B-Instruct”,下载的模型目录名叫“Qwen2.5-72B-Instruct-官方”,少了个“-官方”后缀。把目录名一改,启动参数加上–enable-lora,世界清净了。
部署用的Dockerfile和启动命令我也全部开源了。我额外做了两个优化:
- 量化推理:用AWQ将基座模型压缩到int4,显存从约140GB降到48GB,吞吐从原来的每秒22 token涨到67 token,4路并发下首token延迟稳定在1.2秒以内。
- 多适配器路由:一个vLLM实例加载两个LoRA adapter,通过API网关根据请求中的X-Domain头分发到法律或医疗模型,切换几乎无开销。
评估不能只看BLEU:我用30个真实案例做了一次“律师盲测”
自动指标会骗人。微调后Perplexity从11.2降到7.8,ROUGE-L涨到0.42,看上去很美。但我想知道真律师怎么看。我找了律所的两个合伙人,给了他们30个咨询案例的模型回答(混入真人律师回答),让他们盲评“可用性”1-5分。
结果是微调版模型平均4.1分,原生Qwen2.5-72B只有2.8分,真人律师平均4.6分。差距还有,但已经从“不可用”跨到了“可作为初稿参考”。错误分析发现,模型仍有约15%的回复存在“过时法条引用”(比如仍引用已废止的婚姻法条款)。这提示我需要加入法条时效性校验模块,或者训练数据里要带上时间标签。
另外,多轮对话的连贯性评测我设计了一个“指代消解正确率”指标:在测试集里人工标注了100处需要引用前文的点,统计模型正确回显的比例。原版模型正确率仅38%,微调后拉到71%。知识注入的效果在这一点上非常明显。
完整的训练配置、评估脚本、数据集样本(脱敏后)我都放到了GitHub仓库:github.com/example/qwen-law-lora。如果有人也想在自己的垂直领域复现这套流水线,可以参考里面的Makefile,一键从数据生成跑到部署。
这三周让我明白一个道理:大模型的通用能力只决定了你的起点,垂直领域的价值完全藏在数据的细节和工程耐心里。而LoRA微调是把这些细节刻进模型最省力的凿子——前提是你别把凿子当锤子用。