☰
本地法律大模型实战:PyTorch+LoRA+RAG实现合同审查与咨询
2026/10/5 12:13:16 网站建设 项目流程

简介:大语言模型在垂直领域的落地,往往绕不开数据安全与私有化部署的现实约束。以法律行业为例,合同文本和咨询记录受保密协议限制,无法上传公有云,这就需要在本地GPU环境完成模型微调与推理。参数高效微调技术LoRA通过冻结底座模型、只训练少量低秩矩阵,显著降低显存门槛;而RAG检索增强生成则能有效抑制法律咨询中的幻觉问题,让模型基于本地法规库作答。从PyTorch环境搭建、Transformers加载开源底座,到指令样本构造、LoRA训练,再到合同条款级审查与RAG知识库检索,这一整套方案完整覆盖了法律AI落地的技术链路。本文基于PyTorch与Transformers生态,结合CUDA部署实践,为合同审查、法律咨询等高频场景提供了一条数据不出内网的可行路径,适合需要私有化大模型能力的团队参考。

1. 本地法律大模型:不是图新鲜,是合同数据根本出不了门

律所和企业法务普遍卡在同一个矛盾上:手里压着几万份历史合同和咨询记录,领导一拍板要上“AI 审合同”,但你没法把合同原文传到任何一家公有云 API——保密协议第一条就写死了。于是基于 PyTorch 和 Transformers 的本地法律大模型就成了最现实的一条出路:用开源底座、私有化部署、数据不出内网,先把合同审查和法律咨询这两个高频场景跑通。这篇文章给一套能落地的方案和代码:从 CUDA 环境搭起,用 LoRA 把通用底座微调成懂法务的模型,再用 RAG 解决咨询场景的幻觉问题,最后落到内网服务。适合手里有一张 GPU 卡、想把法务场景私有化的团队,新手照着跑得通,熟手可以直接跳去调参数。

2. 先立底座:PyTorch 与 Transformers 的版本匹配、CUDA 坑和最小加载链路

2.1 为什么选 PyTorch + Transformers 而不是别的组合

本地法律大模型不是从零训练,而是在开源底座上做微调和推理,所以框架选型只有一个要求:跟上开源模型的脚步。PyTorch 是当前开源大模型的事实标准,Hugging Face 的 Transformers 库所有重量级模型首发都先给 PyTorch 版本,DeepSpeed、bitsandbytes、PEFT 这些微调工具链也都围绕 PyTorch 转。用 Transformers 做底座还有一个好处:它把加载、分词、生成、保存全部统一成一套 Auto API,换底座模型时不需要重写业务代码,后面微调和部署会省掉大量精力。

有人会问,为什么不直接用 TensorFlow 或者 JAX?不是不能用,而是社区生态跟不上。LoRA、QLoRA、vLLM 这些工具对 PyTorch 的支持最完整,你能搜到的“大模型微调实战”帖子九成跑在 PyTorch 上。真遇到一个只有英伟达卡和 CUDA 的环境,PyTorch 的排查经验也最多,血泪教训都替你踩过一遍了。

2.2 PyTorch 环境搭建:CUDA 版本匹配的一次到位做法

环境搭建是本地部署的第一个翻车点。很多人第一步就错在 PyTorch 的 CUDA 版本和驱动不匹配,装完之后import torch看着正常,一跑torch.cuda.is_available()返回 False,心态直接崩掉。先记住一个原则:PyTorch 的 CUDA 版本只要小于等于驱动支持的 CUDA 版本就能跑,不是非要一模一样。

查看驱动支持的最高 CUDA 版本用nvidia-smi,看右上角的 “CUDA Version”。比如驱动显示 CUDA 12.2,那你用 PyTorch 官方给的 cu118、cu121、cu122 的 wheel 都能跑。下面是完整的创建环境步骤:

# 1. 创建独立环境,Python 用 3.10,不要用 3.7/3.8 跑新模型 conda create -n legal-llm python=3.10 conda activate legal-llm # 2. 安装 PyTorch 2.x,注意指定 CUDA 版本(这里以 cu121 为例) pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 3. 安装 Transformers 全家桶 pip install transformers==4.45.2 datasets peft accelerate bitsandbytes

安装完成后先别急着往下走,跑一段可见性验证:你的 GPU 要真能被 PyTorch 看到,后面所有微调才有意义。

import torch print("PyTorch 版本:", torch.__version__) # 例如 2.4.0 print("CUDA 可用:", torch.cuda.is_available()) # 必须是 True print("GPU 数量:", torch.cuda.device_count()) # 至少 1 print("GPU 名称:", torch.cuda.get_device_name(0)) # 例如 NVIDIA A100-SXM4 print("当前显存占用:", torch.cuda.mem_get_info())

如果torch.cuda.is_available()返回 False,优先检查三件事:是不是装的 CPU 版(pip list | grep torch看看有没有+cpu后缀)、驱动版本是不是太老、系统 CUDA 环境变量是不是把路径指错了。最怕的是机器上装过多个 CUDA,LD_LIBRARY_PATH指到了旧版本上。解决方法是把环境变量清掉,让 PyTorch 自己找系统驱动。

2.3 加载底座模型:最小链路与显存规划

底座选多大,取决于显存而不是理想。7B 模型加载到 GPU 做推理,16GB 显存很紧张,24GB 是甜点,48GB 以上可以舒服地做 LoRA 微调。如果手里只有 12GB,就走 4bit 量化加载,后面第 6 章会讲。先看最小加载链路:

from transformers import AutoTokenizer, AutoModelForCausalLM import torch # 本地路径,提前用 huggingface-cli 下载好底座权重 model_path = "/data/models/legal-base-7b" tokenizer = AutoTokenizer.from_pretrained( model_path, trust_remote_code=True, # 部分开源模型需要 padding_side="right" ) model = AutoModelForCausalLM.from_pretrained( model_path, device_map="auto", # 自动把层分配到 GPU torch_dtype=torch.float16, # 半精度加载,显存减半 trust_remote_code=True ) # 跑一次前向,确认没有报错 input_text = "请简要说明:合同中的违约金条款需要注意什么?" inputs = tokenizer(input_text, return_tensors="pt").to("cuda") output = model.generate(**inputs, max_new_tokens=128) print(tokenizer.decode(output[0], skip_special_tokens=True))

device_map="auto"是 Transformers 在 accelerate 支持下自动做显存规划的方式:GPU 放得下就全放 GPU,放不下会把部分层放到 CPU,跑是能跑但会慢。torch_dtype=torch.float16让 7B 模型显存占用从 28GB 左右降到 14GB 左右。加载链路跑通之后,第一个里程碑达成——你已经在本地拥有一个能对话的通用大模型了,下一步是让它懂法务。

3. 把模型变成法务:合同数据清洗、指令样本构造与 LoRA 微调实战

3.1 合同文本清洗:数据里全是隐藏的地雷

直接从业务部门拿到的合同文本,九成都是脏的:有 PDF 转出来的全角标点、有扫描件 OCR 留下的乱码、有页眉页脚的页码混在正文里。如果不做清洗直接送去微调,模型会学到“每段开头是页眉”这种错误规律,审查结果会变得非常不稳定。清洗这一步我一般按“全角转半角—去页码—归一化编号”三步走:

import re def clean_contract(text: str) -> str: # 1. 全角转半角:合同里最常见的是全角逗号、括号和数字 text = text.translate(str.maketrans( ',。:;()【】0123456789', ',.:;()[]0123456789' )) # 2. 去掉单独成行的页码和页眉页脚 text = re.sub(r'\n\s*\d{1,4}\s*\n', '\n', text) # 3. 把“第一条”、“第1条”统一成“第1条”便于后续条款切分 text = re.sub(r'第([一二三四五六七八九十百零]+)条', lambda m: f'第{cn2num(m.group(1))}条', text) # 4. 压缩多余空行,统一换行符为 \n text = text.replace('\r\n', '\n') text = re.sub(r'\n{3,}', '\n\n', text) return text.strip()

这里的cn2num是把中文数字转阿拉伯数字的小函数,网上有现成实现,核心是把“第一条”变“第1条”。为什么必须做这一步?因为后面风险条款定位要按“第X条第Y款”输出,如果训练数据里一会儿“第一条”一会儿“第1条”,模型输出格式就会分裂。

清洗完要做抽样检查,不能只看总量。常见做法是清洗后随机抽 50 条,肉眼确认没有乱码、没有残留 OCR 噪声。这一步的瑕疵会在微调阶段被模型放大——数据脏,模型比人学得更脏。

3.2 构造指令样本:条款风险对与三段式问答

微调数据的质量直接决定模型能不能从“通用助手”变成“法务助手”。合同审查场景,我用的数据格式是“指令 + 合同条款输入 + 结构化输出”:

{ "instruction": "你是合同审查助手。阅读合同条款,指出风险,按风险级别、风险位置、风险描述、修改建议四部分输出。", "input": "第8条 违约责任:任何一方违约,应向守约方支付违约金,违约金金额为合同总额的0.5%。", "output": "风险级别:中风险\n风险位置:第8条\n风险描述:该条款未约定违约损失的具体计算方式,争议发生时责任范围不明确。\n修改建议:建议补充损失计算方式,并复核违约金比例是否与商业预期一致。" }

注意这里故意不让模型去做具体法律判断,而是输出结构化的提示框架——风险级别、位置、描述、建议四个字段,位置必须精确到条款号。真实项目中,这些标注数据由法务整理,一条合同条款可以变成一正一反两个样本:一个有风险、一个无风险,让模型学会区分。

法律咨询场景的数据则是另一种格式:问题、分析依据、结论三段式,其中“依据”一栏必须写明引用了哪份法规或内部制度原文。这样训练出的模型习惯性先给依据再下结论,为后面的 RAG 检索对齐做铺垫。

3.3 LoRA 微调:参数选型与完整训练脚本

全参微调一个 7B 模型,最小也要 60GB 显存,几人团队根本吃不消。LoRA 的做法是冻结原模型,只在 attention 层旁边加一小撮可训练的低秩矩阵,把训练参数压缩到原来的 1% 不到。7B 模型 LoRA 微调,配合 8bit 量化,24GB 显存就能跑起来。这是我的标准配置:

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model, TaskType from datasets import load_dataset model_path = "/data/models/legal-base-7b" model = AutoModelForCausalLM.from_pretrained( model_path, device_map="auto", load_in_8bit=True, # 8bit 加载省显存 trust_remote_code=True ) tokenizer = AutoTokenizer.from_pretrained(model_path) # LoRA 配置:只改 attention 四个投影矩阵 lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, r=8, # 秩,小数据集用 8,大数据集可以上 16 lora_alpha=32, # 缩放系数,alpha/r 一般是 2~8 lora_dropout=0.05, # 丢弃率,防过拟合 target_modules=["q_proj", "k_proj", "v_proj", "o_proj"] ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 7B 模型的训练参数应该只有 8~20M 级别 training_args = TrainingArguments( output_dir="./lora-legal-out", num_train_epochs=3, # 法律数据量小,3 个 epoch 足够 per_device_train_batch_size=2, gradient_accumulation_steps=8, # 等效 batch size = 2*8 = 16 learning_rate=2e-4, # LoRA 学习率比全参大,常见 1e-4~5e-4 fp16=True, save_strategy="epoch", logging_steps=20, ) trainer = Trainer( model=model, args=training_args, train_dataset=dataset, ) trainer.train()
参数小数据集(千条级)中数据集(万条级)
r816
lora_alpha3232
lora_dropout0.050.1
learning_rate2e-41e-4
num_train_epochs32

lora_alpha=32配合r=8,等价缩放系数 4,是最稳妥的起步组合。训练完只保存 LoRA 增量权重,model.save_pretrained("./lora-legal-out")出来也就几十 MB,原模型一点没动,这相当于微调的“后悔药”机制——觉得增量权重不行,删掉重训或换一组参数,底座永远干净。

4. 合同审查功能落地:条款切分、风险识别和结构化报告怎么串起来

4.1 审查链路总览与文本抽取

微调完的模型放进真实流程,第一步是处理用户丢进来的合同文件。真实合同是 PDF、Word 混合体,不能直接送进模型。我的链路是:上传文件 → 抽取文本 → 清洗分条 → 逐条送模型 → 汇总报告。PDF 用pdfplumber抽取,Word 用python-docx,这两种库在中文场景下表现比老牌库稳,表格布局丢失问题也少一些。

import pdfplumber from docx import Document def extract_text(path: str) -> str: if path.endswith(".pdf"): with pdfplumber.open(path) as pdf: return "\n".join(page.extract_text() or "" for page in pdf.pages) elif path.endswith(".docx"): doc = Document(path) return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) else: raise ValueError("仅支持 PDF 或 DOCX 文件")

抽取完成先做长度检查:一份合同通常在 3000 到 15000 字之间,如果抽出来只有几百字,八成是扫描件,ocr 那套流程需要在前面另起一路。文本抽取的质量决定了后面切分和识别的上限,这里卡住了后面全都白做。

4.2 条款级风险识别:低温度 prompt 与结构化输出

合同审查用的是微调后的模型,但 prompt 仍然要设计得足够“硬”——要求模型输出 JSON,审查结果才能被下游系统解析入库。这里有一个关键参数:temperature=0.1。审查场景要的是确定性和可复现性,不是创造力和多样性。同一份合同每次审查结果必须一致,这一点对客户信任极其重要。

prompt_template = """你是合同审查助手。分析以下合同条款,只输出 JSON,不要输出额外文字。 格式要求:{"risk_level": "高|中|低", "clause_no": "条款号", "risk_desc": "风险描述", "suggestion": "修改建议"} 合同条款: {clause_text} """ def review_clause(clause_text: str) -> dict: response = model.generate( tokenizer(prompt_template.format(clause_text=clause_text), return_tensors="pt").to("cuda"), max_new_tokens=256, temperature=0.1, # 审查场景用低温度,保证结果稳定 do_sample=False # 关闭采样,输出更确定 ) raw = tokenizer.decode(response[0], skip_special_tokens=True) # 取 JSON 片段,防止模型输出多余解释 import json, re match = re.search(r"\{.*\}", raw, re.S) return json.loads(match.group(0))

do_sample=False是降低幻觉的第二个抓手。通俗说,模型不再“抽签选词”,而是每一步都挑概率最高的词,代价是输出略显机械,但换来了审查结果的稳定性。真到业务验收时,客户问“为什么昨天审查说高风险今天说低风险”,你能拍着胸脯说温度参数是固定 0.1。

4.3 审查报告落地:风险分级与修改建议

逐条过完模型后,还要汇总一份人能直接看的报告。我一般把风险分成三档:高风险是付款节点不明、无限连带责任;中风险是条款存在歧义;低风险是格式不规范。汇总结果用 Pandas 存成结构化表格,按风险级别排序输出:

import pandas as pd def build_report(results: list[dict]) -> pd.DataFrame: df = pd.DataFrame(results) df["risk_level"] = df["risk_level"].astype("category") # 按风险级别排序:高 > 中 > 低 df["risk_order"] = df["risk_level"].map({"高": 0, "中": 1, "低": 2}) df = df.sort_values("risk_order", ignore_index=True) return df[["risk_level", "clause_no", "risk_desc", "suggestion"]] report = build_report(results) report.to_excel("./审查报告.xlsx", index=False)

导出成 Excel 是最稳妥的做法,法务同事可以在上面直接批注。不要试图做成网页展示——真实环境的法务用 Excel 用了十年,改变习惯的难度远大于做功能。到这里,合同审查的完整闭环已经通了:文件进、报告出,中间模型只负责条款级判断。

5. 法律咨询的 RAG 增强与高频踩坑排查:幻觉、上下文截断和显存溢出

5.1 为什么咨询场景必须加 RAG

微调后的模型能做合同审查,因为审查是“从条款里找问题”,信息都写在条文里。但法律咨询场景完全不同——“公司单方面解约需要赔偿吗”这类开放问题,模型如果全靠记忆回答,结果就是一本正经地编法条,编得还特别像真的。这是大模型的通病,微调解决不了,因为微调只是强化了记忆,并没有给模型一个“查资料”的通道。

RAG(检索增强生成)的思路是:先从一个本地知识库里检索出和问题相关的法规原文,再把原文拼进 prompt 里,让模型基于给定材料作答。这样模型的任务从“回忆法条”变成“转述法条”,幻觉率大幅下降。本地知识库的素材来源有:公开法规库、司法解释、公司内部合规手册、历史咨询记录里的标准答复。

5.2 本地知识库:向量化、检索与 prompt 组装

知识库的核心是“切块—向量化—检索”三步。法规原文很长,整篇塞进提示词会让模型顾头不顾尾。我的做法是:按段落切块,每块 512 字符,重叠 32 字符,这样相邻段落不会因为切分丢掉上下文。向量模型用 BGE 系列或 sentence-transformers 都行,国内法律文本用 BGE 的兼容性更好。

from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity import numpy as np encoder = SentenceTransformer("/data/models/bge-base-zh") # 本地向量模型 # 知识库文本提前切块,这里是示意 chunks = [ "劳动合同法第X条规定:……", "民法典对违约责任的规定:……", "公司法关于股东责任的规定:……", ] chunk_vectors = encoder.encode(chunks, normalize_embeddings=True) def retrieve(query: str, top_k: int = 5): q_vec = encoder.encode([query], normalize_embeddings=True) # 直接用向量点积排序,等价于余弦相似度 scores = np.dot(chunk_vectors, q_vec.T).squeeze() idx = scores.argsort()[-top_k:][::-1] return [chunks[i] for i in idx] # 检索结果拼进 prompt,约束模型只依据给定材料回答 context = "\n----\n".join(retrieve("公司单方面解约的赔偿标准")) prompt = f"""根据以下法律条文回答用户问题。只能引用给定条文,不得自行编造。 如果给定条文不能完全支持问题答案,明确说明“检索材料未能覆盖此问题”。 材料: {context} 问题:公司单方面解约需要赔偿吗? """

检索结果拼进 prompt 的方式有几个细节:一是要强调“只能引用给定条文”,这句话是约束幻觉的关键咒语;二是要给模型留一条退路——“检索材料未能覆盖此问题”,允许它拒绝回答。有了拒绝选项,模型反而更可信。

5.3 五个高频踩坑:现象、原因、解决

踩坑一:torch.cuda.is_available()返回 False,但 nvidia-smi 明明有 GPU。现象是 PyTorch 检测不到 CUDA。原因是 pip 装了 CPU 版的 torch(wheel 带+cpu后缀),或者系统 CUDA 驱动版本低于 PyTorch 要求。解决:先pip list | grep torch看有没有+cpu,有就重装--index-url https://download.pytorch.org/whl/cu121;没有就检查nvidia-smi驱动版本,驱动低于 525 的先把显卡驱动升级。

踩坑二:加载模型时抛 “xxx is already used by a transformers config, pick another name” 之类的报错。现象是第二次加载同一个本地模型路径时,transformers 报配置名冲突。原因是本地缓存目录或输出目录里残留着上一次保存的 config 同名文件,新配置注册时名字撞车。解决:清理该模型目录下的config.json和*.json临时文件,或者换一个全新的输出目录名,不要跟旧目录复用一个名字。

踩坑三:微调刚开始就 OOM,启动即崩。现象是训练脚本跑起来几秒钟显存报错。原因是只顾着模型显存,忽略了优化器状态和激活值也吃显存。解决:加上load_in_8bit=True,同时开gradient_checkpointing=True(用时间换显存),把per_device_train_batch_size降到 1,用gradient_accumulation_steps补回 batch size。

踩坑四:模型引用了一个根本不存在的新规。现象是咨询回答里出现具体年份和文号,去查发现子虚乌有。原因是模型在自由发挥,微调数据和原模型参数里都没有这个信息。解决:咨询场景强制走 RAG,prompt 里写明“只能引用给定材料”,并且把温度降到 0.2 以下。审查场景因为是对条款做判断,出现编造的概率低一些,但输出 JSON 解析失败时也要有重试机制。

踩坑五:直接把 8000 字合同原文一股脑送进模型,结果关键条款被截断。现象是审查报告里缺了最后几章的内容。原因是上下文窗口不够时,Transformers 会从尾部截断,而合同的违约责任、管辖条款往往就在后半部分。解决:先按“第X条”正则把合同切成条款级别,对每条单独推理,而不是整篇硬塞。这也让风险定位能精确到条款号,一举两得。

6. 部署落地与验证:从能跑到敢给业务用

6.1 量化加载与本地服务化

微调和验证阶段的代码直接服务业务不行:Trainer 带着训练逻辑,启动慢、内存开销大。部署时我一般用两种方式。第一种是 bitsandbytes 4bit 量化加载,能跑但吞吐有瓶颈;第二种是 vLLM,它兼容 OpenAI 的 API 格式,启动一个本地服务后,业务系统直接用 HTTP 调chat/completions接口,改造量最小。显存够 48GB 的直接 vLLM,16~24GB 的先用 4bit 量化顶着。

6.2 用已知风险的合同做回归验证

部署前最重要的一课:拿 20 份“已知风险点”的合同做回归验证。让法务同事标注每份合同有哪些高风险条款,然后跑一遍你的审查链路,算检出率和误报率。检出率低于 80% 不要上线,宁可先让法务在系统辅助下干活,也别让模型直接出正式法律意见。RAG 检索质量也要单独测:抽 100 个真实咨询问题,人工确认检索回的前 5 条材料里有多少条真正相关,低于 70% 就回去调切块大小和向量模型的选型。这套机制是整个系统最值钱的部分,没有它,模型看起来再聪明你也不敢真用。

我把“先小规模跑通,再追求规模”当成做这类本地法律大模型的铁律。第一次做别想着几万份合同一起训练,先用 1000 条标注数据把链路跑通,再逐步加量。我最大的教训是跳过质量评估直接扩充数据,结果训练了两天,指标反而变差。教训就一句话:法律场景里,评估方案必须和数据方案、微调方案同时设计。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询