目录
- [1. 引言](#1. 引言)
- [2. 系统需求与技术选型](#2. 系统需求与技术选型)
- [2.1 目标能力](#2.1 目标能力)
- [2.2 技术栈](#2.2 技术栈)
- [2.3 为什么是 Qwen3.8-Max](#2.3 为什么是 Qwen3.8-Max)
- [3. 整体架构设计](#3. 整体架构设计)
- [4. 文档解析层:多格式合同与版面还原](#4. 文档解析层:多格式合同与版面还原)
- [4.1 统一文档模型](#4.1 统一文档模型)
- [4.2 PDF 解析实现](#4.2 PDF 解析实现)
- [4.3 Word 解析实现](#4.3 Word 解析实现)
- [5. 条款化切分](#5. 条款化切分)
- [5.1 切分策略](#5.1 切分策略)
- [6. 多轮条款分析 Pipeline 设计](#6. 多轮条款分析 Pipeline 设计)
- [6.1 为什么用多轮而不是单轮](#6.1 为什么用多轮而不是单轮)
- [6.2 Pipeline 数据流](#6.2 Pipeline 数据流)
- [7. 核心代码实现](#7. 核心代码实现)
- [7.1 模型客户端封装](#7.1 模型客户端封装)
- [7.2 三轮分析 Prompt](#7.2 三轮分析 Prompt)
- [7.3 Pydantic 校验](#7.3 Pydantic 校验)
- [7.4 组装 Pipeline](#7.4 组装 Pipeline)
- [7.5 缓存层](#7.5 缓存层)
- [7.6 汇总与报告生成](#7.6 汇总与报告生成)
- [7.7 入口](#7.7 入口)
- [8. 工程实战中的关键问题](#8. 工程实战中的关键问题)
- [8.1 长合同的上下文管理](#8.1 长合同的上下文管理)
- [8.2 温度与稳定性](#8.2 温度与稳定性)
- [8.3 成本控制](#8.3 成本控制)
- [8.4 风险等级的一致性](#8.4 风险等级的一致性)
- [9. 部署与扩展建议](#9. 部署与扩展建议)
- [10. 总结](#10. 总结)
1. 引言
在企业法务、采购与风控场景中,合同审查是一项高频、高强度、强依赖经验的工作。一份几十页的合同,法务人员往往需要逐条核对付款条款、违约责任、知识产权归属、保密义务、争议解决等关键项,不仅耗时,还容易因疲劳或经验差异出现漏审。
随着大语言模型能力的快速提升,用 AI 辅助合同审查已经从"能不能做"走向"如何做好"。本文将以阿里云通义千问旗舰模型 Qwen3.8-Max 为核心,从零构建一个可落地的 AI 合同精审系统,重点讲解两个工程难点:
- 文档解析:如何把 PDF / Word 合同还原成带结构、带位置、可精确定位的条款文本;
- 多轮条款分析 Pipeline:如何把"整份合同审查"拆解为可控、可追溯、可复用提示词的多轮分析流程。
最终我们会得到一个输入合同文件、输出风险清单与修改建议的完整命令行工具。
2. 系统需求与技术选型
2.1 目标能力
系统需要实现以下核心能力:
| 能力 | 说明 |
|---|---|
| 多格式解析 | 支持 PDF、DOCX 合同解析,还原标题、段落、表格 |
| 条款切分 | 按合同章节/条款自动切分为独立的分析单元 |
| 多轮分析 | 对每个条款执行风险识别、依据引用、修改建议三步分析 |
| 汇总输出 | 输出结构化风险报告(JSON + Markdown) |
| 可追溯 | 每一条风险都关联原文片段与条款定位 |
2.2 技术栈
- 模型:Qwen3.8-Max,通过 DashScope 的 OpenAI 兼容接口调用;
- 文档解析:PyMuPDF(PDF)+ python-docx(Word)+ RapidOCR(扫描件备用);
- 编排框架:使用轻量的 Pipeline 自研控制流,不引入 Agent 框架以降低复杂度;
- 结构化输出:依赖模型的 JSON Mode,配合 Pydantic 做校验与重试;
- 缓存与成本控制:SQLite 缓存模型结果,避免重复调用。
2.3 为什么是 Qwen3.8-Max
合同审查对模型有三点硬要求:长上下文理解 (整合同)、指令遵循 (稳定输出 JSON)、中文法律语义(条款表述严谨)。Qwen3.8-Max 在这三方面表现均衡,且通过 OpenAI 兼容接口接入成本低,适合做工程化封装。
3. 整体架构设计
系统采用"解析 → 切分 → 多轮分析 → 汇总"四层 Pipeline,整体架构如下:
#mermaid-svg-K1F3ymel1BatOBN4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K1F3ymel1BatOBN4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K1F3ymel1BatOBN4 .error-icon{fill:#552222;}#mermaid-svg-K1F3ymel1BatOBN4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K1F3ymel1BatOBN4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K1F3ymel1BatOBN4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K1F3ymel1BatOBN4 .marker.cross{stroke:#333333;}#mermaid-svg-K1F3ymel1BatOBN4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K1F3ymel1BatOBN4 p{margin:0;}#mermaid-svg-K1F3ymel1BatOBN4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-K1F3ymel1BatOBN4 .cluster-label text{fill:#333;}#mermaid-svg-K1F3ymel1BatOBN4 .cluster-label span{color:#333;}#mermaid-svg-K1F3ymel1BatOBN4 .cluster-label span p{background-color:transparent;}#mermaid-svg-K1F3ymel1BatOBN4 .label text,#mermaid-svg-K1F3ymel1BatOBN4 span{fill:#333;color:#333;}#mermaid-svg-K1F3ymel1BatOBN4 .node rect,#mermaid-svg-K1F3ymel1BatOBN4 .node circle,#mermaid-svg-K1F3ymel1BatOBN4 .node ellipse,#mermaid-svg-K1F3ymel1BatOBN4 .node polygon,#mermaid-svg-K1F3ymel1BatOBN4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-K1F3ymel1BatOBN4 .rough-node .label text,#mermaid-svg-K1F3ymel1BatOBN4 .node .label text,#mermaid-svg-K1F3ymel1BatOBN4 .image-shape .label,#mermaid-svg-K1F3ymel1BatOBN4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-K1F3ymel1BatOBN4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-K1F3ymel1BatOBN4 .rough-node .label,#mermaid-svg-K1F3ymel1BatOBN4 .node .label,#mermaid-svg-K1F3ymel1BatOBN4 .image-shape .label,#mermaid-svg-K1F3ymel1BatOBN4 .icon-shape .label{text-align:center;}#mermaid-svg-K1F3ymel1BatOBN4 .node.clickable{cursor:pointer;}#mermaid-svg-K1F3ymel1BatOBN4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-K1F3ymel1BatOBN4 .arrowheadPath{fill:#333333;}#mermaid-svg-K1F3ymel1BatOBN4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-K1F3ymel1BatOBN4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-K1F3ymel1BatOBN4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K1F3ymel1BatOBN4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-K1F3ymel1BatOBN4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K1F3ymel1BatOBN4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-K1F3ymel1BatOBN4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-K1F3ymel1BatOBN4 .cluster text{fill:#333;}#mermaid-svg-K1F3ymel1BatOBN4 .cluster span{color:#333;}#mermaid-svg-K1F3ymel1BatOBN4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-K1F3ymel1BatOBN4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-K1F3ymel1BatOBN4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-K1F3ymel1BatOBN4 .icon-shape,#mermaid-svg-K1F3ymel1BatOBN4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K1F3ymel1BatOBN4 .icon-shape p,#mermaid-svg-K1F3ymel1BatOBN4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-K1F3ymel1BatOBN4 .icon-shape .label rect,#mermaid-svg-K1F3ymel1BatOBN4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K1F3ymel1BatOBN4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-K1F3ymel1BatOBN4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-K1F3ymel1BatOBN4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 合同文件
PDF / DOCX
文档解析层
版面与结构识别
条款化切分
条款数据模型
多轮条款分析 Pipeline
风险识别轮
依据引用轮
修改建议轮
结构化校验与重试
风险汇总与报告生成
Markdown / JSON 报告
每一层的输出都是下一层的输入,层与层之间通过数据类解耦,便于单独替换解析器或分析策略。
4. 文档解析层:多格式合同与版面还原
合同解析的难点不在于"读到文字",而在于保留结构:哪些是章节标题、哪些是正文条款、哪些是表格中的金额与期限。如果只是把 PDF 全文抽成一个字符串,后续条款切分和分析都会失真。
4.1 统一文档模型
我们定义一个与格式无关的中间数据模型:
python
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
class BlockType(str, Enum):
HEADING = "heading" # 章节标题
PARAGRAPH = "paragraph" # 正文段落
TABLE = "table" # 表格
LIST_ITEM = "list_item" # 列表项
@dataclass
class Block:
type: BlockType
text: str
level: int = 0 # 标题层级
page: int = 1 # 所在页码
table_data: Optional[List[List[str]]] = None # 表格内容
@dataclass
class ContractDocument:
source: str # 文件路径
title: str = ""
blocks: List[Block] = field(default_factory=list)
4.2 PDF 解析实现
PDF 合同是最常见的输入。我们借助 PyMuPDF 提取文本块,并基于字体大小、加粗特征做启发式标题识别:
python
import fitz # PyMuPDF
def parse_pdf(path: str) -> ContractDocument:
doc = ContractDocument(source=path)
pdf = fitz.open(path)
for page_no, page in enumerate(pdf, start=1):
blocks = page.get_text("dict")["blocks"]
for b in blocks:
if b["type"] != 0: # 跳过图片块
continue
for line in b["lines"]:
spans = line["spans"]
if not spans:
continue
text = "".join(s["text"] for s in spans).strip()
if not text:
continue
# 启发式:字号明显大于正文且非空,判定为标题
max_size = max(s["size"] for s in spans)
is_bold = any("Bold" in s["font"] for s in spans)
if max_size >= 14 or is_bold and len(text) < 40:
doc.blocks.append(
Block(type=BlockType.HEADING, text=text,
level=1 if max_size >= 16 else 2,
page=page_no)
)
else:
doc.blocks.append(
Block(type=BlockType.PARAGRAPH, text=text, page=page_no)
)
pdf.close()
return doc
4.3 Word 解析实现
DOCX 天然带样式,解析更可靠:
python
from docx import Document
def parse_docx(path: str) -> ContractDocument:
doc = ContractDocument(source=path)
word = Document(path)
for para in word.paragraphs:
text = para.text.strip()
if not text:
continue
style = para.style.name.lower()
if "heading" in style or "标题" in style:
level = 1 if "1" in style else 2
doc.blocks.append(
Block(type=BlockType.HEADING, text=text, level=level)
)
else:
doc.blocks.append(Block(type=BlockType.PARAGRAPH, text=text))
for table in word.tables:
rows = [
[cell.text.strip() for cell in row.cells]
for row in table.rows
]
doc.blocks.append(
Block(type=BlockType.TABLE, text="\n".join(" | ".join(r) for r in rows),
table_data=rows)
)
return doc
对于扫描件,可在解析前增加 OCR 前置环节,使用 RapidOCR 得到文本后再走同样的块化逻辑。这里不再展开。
5. 条款化切分
解析得到块序列后,我们需要把它们组织成条款(Clause)------每个条款是一段语义完整、可独立分析的文本,并带有定位信息。
5.1 切分策略
以标题为边界切分,相邻标题之间的内容归入同一章节;章节下再按段落聚合,避免段落过碎导致模型上下文不足。
python
@dataclass
class Clause:
uid: str # 唯一标识,如 C001
title: str # 所属章节标题
text: str # 条款完整文本
page: int # 起始页
blocks: List[Block] # 原始块引用,便于溯源
def split_clauses(doc: ContractDocument) -> List[Clause]:
clauses: List[Clause] = []
current_title = "前言"
current_blocks: List[Block] = []
current_page = 1
for block in doc.blocks:
if block.type == BlockType.HEADING:
# 遇到新标题,先保存上一章节
if current_blocks:
clauses.append(
Clause(
uid=f"C{len(clauses) + 1:03d}",
title=current_title,
text="\n".join(b.text for b in current_blocks),
page=current_page,
blocks=current_blocks,
)
)
current_title = block.text
current_blocks = []
current_page = block.page
else:
current_blocks.append(block)
if current_blocks:
clauses.append(
Clause(
uid=f"C{len(clauses) + 1:03d}",
title=current_title,
text="\n".join(b.text for b in current_blocks),
page=current_page,
blocks=current_blocks,
)
)
return clauses
这里有一个工程细节:切分粒度要平衡。一条条款文本太长,模型容易忽略末尾;太短,模型缺少上下文。实践中单条控制在 800~1500 字之间效果较好,超长时可对段落做二次切分。
6. 多轮条款分析 Pipeline 设计
这是整个系统的核心。我们不在一次调用里让模型"读完整个合同并输出全部风险",而是把审查任务拆成三轮,每轮职责单一、输出可控:
- 风险识别轮:从条款中识别潜在风险点及风险等级;
- 依据引用轮:为每个风险点定位原文依据;
- 修改建议轮:给出可落地的修改建议。
6.1 为什么用多轮而不是单轮
单轮"一口气输出"存在三个问题:输出过长容易超限或截断;JSON 结构复杂导致解析失败率高;失败后重试成本高(要重发全部内容)。多轮拆解后,每一轮输出短小、结构简单,且中间结果可缓存、可单独重试,工程上更稳健。
6.2 Pipeline 数据流
SQLite 缓存 Pydantic 校验 Qwen3.8-Max Pipeline SQLite 缓存 Pydantic 校验 Qwen3.8-Max Pipeline #mermaid-svg-9152FnYvA9EKRJKS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9152FnYvA9EKRJKS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9152FnYvA9EKRJKS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9152FnYvA9EKRJKS .error-icon{fill:#552222;}#mermaid-svg-9152FnYvA9EKRJKS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9152FnYvA9EKRJKS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9152FnYvA9EKRJKS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9152FnYvA9EKRJKS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9152FnYvA9EKRJKS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9152FnYvA9EKRJKS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9152FnYvA9EKRJKS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9152FnYvA9EKRJKS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9152FnYvA9EKRJKS .marker.cross{stroke:#333333;}#mermaid-svg-9152FnYvA9EKRJKS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9152FnYvA9EKRJKS p{margin:0;}#mermaid-svg-9152FnYvA9EKRJKS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9152FnYvA9EKRJKS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9152FnYvA9EKRJKS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9152FnYvA9EKRJKS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9152FnYvA9EKRJKS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9152FnYvA9EKRJKS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9152FnYvA9EKRJKS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9152FnYvA9EKRJKS .sequenceNumber{fill:white;}#mermaid-svg-9152FnYvA9EKRJKS #sequencenumber{fill:#333;}#mermaid-svg-9152FnYvA9EKRJKS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9152FnYvA9EKRJKS .messageText{fill:#333;stroke:none;}#mermaid-svg-9152FnYvA9EKRJKS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9152FnYvA9EKRJKS .labelText,#mermaid-svg-9152FnYvA9EKRJKS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9152FnYvA9EKRJKS .loopText,#mermaid-svg-9152FnYvA9EKRJKS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9152FnYvA9EKRJKS .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9152FnYvA9EKRJKS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9152FnYvA9EKRJKS .noteText,#mermaid-svg-9152FnYvA9EKRJKS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9152FnYvA9EKRJKS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9152FnYvA9EKRJKS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9152FnYvA9EKRJKS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9152FnYvA9EKRJKS .actorPopupMenu{position:absolute;}#mermaid-svg-9152FnYvA9EKRJKS .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9152FnYvA9EKRJKS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9152FnYvA9EKRJKS .actor-man circle,#mermaid-svg-9152FnYvA9EKRJKS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9152FnYvA9EKRJKS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 缓存命中 缓存未命中 查询条款缓存 返回已有风险结果 风险识别轮(条款文本) JSON 风险列表 校验风险结构 校验通过 / 失败 依据引用轮(风险列表) JSON 依据引用 修改建议轮(风险+依据) JSON 修改建议 写入缓存
7. 核心代码实现
7.1 模型客户端封装
基于 DashScope 的 OpenAI 兼容接口封装,统一处理 JSON Mode 与重试:
python
import json
import os
from typing import List, Dict, Any
from openai import OpenAI
class QwenClient:
def __init__(self, model: str = "qwen3.8-max"):
self.client = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
self.model = model
def chat_json(self, system: str, user: str,
max_tokens: int = 2048) -> Dict[str, Any]:
"""请求 JSON 输出,失败时做一次降级重试。"""
for attempt in range(2):
try:
resp = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=0.1, # 合同审查要稳定,不宜发散
response_format={"type": "json_object"},
max_tokens=max_tokens,
)
content = resp.choices[0].message.content
return json.loads(content)
except (json.JSONDecodeError, KeyError):
# 第二次重试时追加更严格的格式提示
user += "\n\n请严格只输出符合要求的 JSON 对象,不要包含任何多余说明文字。"
raise RuntimeError("模型 JSON 输出连续失败,请检查提示词与模型配置。")
7.2 三轮分析 Prompt
把每一轮的角色、任务和输出 Schema 写成常量,便于团队内审与版本管理:
python
RISK_ROUND_SYSTEM = """你是一名资深企业法务,精通合同风险审查。
你的任务:阅读给定合同条款,识别其中对甲方(委托方)不利的风险点。
输出严格 JSON:
{
"risks": [
{
"id": "R1",
"category": "付款条款",
"level": "high | medium | low",
"description": "风险描述",
"original_text": "涉及的原文关键句"
}
]
}
没有风险时输出 {"risks": []}。"""
REF_ROUND_SYSTEM = """你是合同审查依据定位助手。
给定条款原文和已识别风险列表,为每个风险补充:
1. 合同原文中的完整依据片段;
2. 是否违反常见商业惯例的简要说明。
输出严格 JSON:
{
"refs": [
{
"risk_id": "R1",
"evidence": "合同原文完整依据片段",
"comment": "简要说明"
}
]
}"""
SUGGEST_ROUND_SYSTEM = """你是合同修改专家。
给定风险点及其原文依据,给出可直接复用的修改建议。
输出严格 JSON:
{
"suggestions": [
{
"risk_id": "R1",
"rewrite": "建议修改后的条款文字",
"reason": "修改理由",
"priority": "P0 | P1 | P2"
}
]
}"""
7.3 Pydantic 校验
LLM 输出不可完全信任,必须用 Pydantic 做结构校验,失败则触发重试:
python
from pydantic import BaseModel, Field, ValidationError
from typing import List, Literal, Optional
class Risk(BaseModel):
id: str
category: str
level: Literal["high", "medium", "low"]
description: str
original_text: str
class RiskResult(BaseModel):
risks: List[Risk] = Field(default_factory=list)
class Evidence(BaseModel):
risk_id: str
evidence: str
comment: str = ""
class EvidenceResult(BaseModel):
refs: List[Evidence] = Field(default_factory=list)
class Suggestion(BaseModel):
risk_id: str
rewrite: str
reason: str
priority: Literal["P0", "P1", "P2"]
class SuggestionResult(BaseModel):
suggestions: List[Suggestion] = Field(default_factory=list)
def validate_or_raise(data: Dict[str, Any], model_cls: BaseModel) -> BaseModel:
try:
return model_cls.model_validate(data)
except ValidationError as e:
raise RuntimeError(f"输出结构校验失败: {e}") from e
7.4 组装 Pipeline
python
class ContractReviewPipeline:
def __init__(self, client: QwenClient):
self.client = client
def analyze_clause(self, clause: Clause) -> Dict[str, Any]:
user = f"条款标题:{clause.title}\n\n条款原文:\n{clause.text}"
# 第一轮:风险识别
risk_data = self.client.chat_json(RISK_ROUND_SYSTEM, user)
risks = validate_or_raise(risk_data, RiskResult)
if not risks.risks:
return {"clause_id": clause.uid, "title": clause.title, "risks": []}
# 第二轮:依据引用
ref_input = json.dumps({
"clause": clause.text,
"risks": [r.model_dump() for r in risks.risks],
}, ensure_ascii=False)
ref_data = self.client.chat_json(REF_ROUND_SYSTEM, ref_input)
refs = validate_or_raise(ref_data, EvidenceResult)
# 第三轮:修改建议
sug_input = json.dumps({
"clause": clause.text,
"risks": [r.model_dump() for r in risks.risks],
"refs": [r.model_dump() for r in refs.refs],
}, ensure_ascii=False)
sug_data = self.client.chat_json(SUGGEST_ROUND_SYSTEM, sug_input)
sugs = validate_or_raise(sug_data, SuggestionResult)
return {
"clause_id": clause.uid,
"title": clause.title,
"page": clause.page,
"risks": [
{
**r.model_dump(),
"evidence": self._find_evidence(r.id, refs),
"suggestion": self._find_suggestion(r.id, sugs),
}
for r in risks.risks
],
}
@staticmethod
def _find_evidence(risk_id: str, refs: EvidenceResult) -> Optional[str]:
for e in refs.refs:
if e.risk_id == risk_id:
return e.evidence
return None
@staticmethod
def _find_suggestion(risk_id: str, sugs: SuggestionResult) -> Optional[str]:
for s in sugs.suggestions:
if s.risk_id == risk_id:
return f"{s.rewrite}(理由:{s.reason})"
return None
7.5 缓存层
合同审查中,同一份合同的同一版本可能被反复分析(如调参后重跑)。SQLite 缓存能显著降低成本:
python
import hashlib
import sqlite3
class ClauseCache:
def __init__(self, db_path: str = "review_cache.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS cache "
"(hash TEXT PRIMARY KEY, result TEXT)"
)
@staticmethod
def _hash(clause: Clause) -> str:
return hashlib.sha256(
f"{clause.title}\n{clause.text}".encode("utf-8")
).hexdigest()
def get(self, clause: Clause) -> Dict[str, Any] | None:
row = self.conn.execute(
"SELECT result FROM cache WHERE hash = ?", (self._hash(clause),)
).fetchone()
if row:
return json.loads(row[0])
return None
def set(self, clause: Clause, result: Dict[str, Any]) -> None:
self.conn.execute(
"INSERT OR REPLACE INTO cache (hash, result) VALUES (?, ?)",
(self._hash(clause), json.dumps(result, ensure_ascii=False)),
)
self.conn.commit()
7.6 汇总与报告生成
python
def generate_report(results: List[Dict[str, Any]], out_path: str) -> None:
high = medium = low = 0
lines = ["# 合同风险审查报告\n"]
for res in results:
if not res["risks"]:
continue
lines.append(f"\n## {res['title']}({res.get('page', '---')} 页)\n")
for r in res["risks"]:
level = r["level"]
if level == "high":
high += 1
elif level == "medium":
medium += 1
else:
low += 1
lines.append(f"- **[{level.upper()}] {r['category']}**")
lines.append(f" - 风险描述:{r['description']}")
lines.append(f" - 原文:{r['original_text']}")
if r.get("evidence"):
lines.append(f" - 依据:{r['evidence']}")
if r.get("suggestion"):
lines.append(f" - 建议:{r['suggestion']}")
summary = (
f"\n> 共发现高风险 {high} 项、中风险 {medium} 项、"
f"低风险 {low} 项。\n"
)
lines.insert(1, summary)
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
7.7 入口
python
def main(path: str) -> None:
client = QwenClient()
pipeline = ContractReviewPipeline(client)
cache = ClauseCache()
if path.endswith(".pdf"):
doc = parse_pdf(path)
elif path.endswith(".docx"):
doc = parse_docx(path)
else:
raise ValueError("仅支持 PDF 或 DOCX 格式")
clauses = split_clauses(doc)
results = []
for clause in clauses:
cached = cache.get(clause)
if cached is not None:
results.append(cached)
continue
result = pipeline.analyze_clause(clause)
cache.set(clause, result)
results.append(result)
generate_report(results, "review_report.md")
print(f"审查完成,共处理 {len(clauses)} 个条款。")
8. 工程实战中的关键问题
8.1 长合同的上下文管理
Qwen3.8-Max 虽然支持长上下文,但一次塞入整份合同并非最优解。本文采用的条款级多轮分析天然规避了这个问题:每个条款独立进模型,单次上下文短、聚焦、可并行。
8.2 温度与稳定性
合同审查需要可复现 。我们把 temperature 设为 0.1,并在系统提示词中明确输出 Schema,搭配 JSON Mode 与 Pydantic 双重校验,把"输出不可用"的概率降到最低。
8.3 成本控制
三层手段:条款级缓存(同一内容不重复计费);短输出(每轮只输出必要字段,控制 max_tokens);风险识别轮先过滤(无风险条款直接跳过后续两轮,能省掉约三分之二的调用量)。
8.4 风险等级的一致性
不同条款间模型给出的等级可能出现口径漂移。实践中可以在风险识别 Prompt 中给出等级判定标准,例如:付款主体缺失为高风险、违约金比例偏高为中风险、表述不严谨为低风险,并在汇总阶段统一展示。
9. 部署与扩展建议
系统可封装为 FastAPI 服务,前端上传合同后异步跑 Pipeline:
#mermaid-svg-m1T1KOWJKtpRQ3Wj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .error-icon{fill:#552222;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .marker.cross{stroke:#333333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj p{margin:0;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster-label text{fill:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster-label span{color:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster-label span p{background-color:transparent;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .label text,#mermaid-svg-m1T1KOWJKtpRQ3Wj span{fill:#333;color:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .node rect,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node circle,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node ellipse,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node polygon,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .rough-node .label text,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node .label text,#mermaid-svg-m1T1KOWJKtpRQ3Wj .image-shape .label,#mermaid-svg-m1T1KOWJKtpRQ3Wj .icon-shape .label{text-anchor:middle;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .rough-node .label,#mermaid-svg-m1T1KOWJKtpRQ3Wj .node .label,#mermaid-svg-m1T1KOWJKtpRQ3Wj .image-shape .label,#mermaid-svg-m1T1KOWJKtpRQ3Wj .icon-shape .label{text-align:center;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .node.clickable{cursor:pointer;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .arrowheadPath{fill:#333333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-m1T1KOWJKtpRQ3Wj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-m1T1KOWJKtpRQ3Wj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster text{fill:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .cluster span{color:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-m1T1KOWJKtpRQ3Wj rect.text{fill:none;stroke-width:0;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .icon-shape,#mermaid-svg-m1T1KOWJKtpRQ3Wj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .icon-shape p,#mermaid-svg-m1T1KOWJKtpRQ3Wj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .icon-shape .label rect,#mermaid-svg-m1T1KOWJKtpRQ3Wj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-m1T1KOWJKtpRQ3Wj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-m1T1KOWJKtpRQ3Wj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-m1T1KOWJKtpRQ3Wj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发文上传接口
任务队列
Worker 执行 Pipeline
结果落库
状态轮询接口
后续可扩展的方向包括:接入公司自有合同模板库做"偏离模板对比"、把修改建议一键替换回 Word 保留原格式、引入人工复核闭环标注数据微调提示词等。
10. 总结
本文从工程视角完整走了一遍 AI 合同精审系统的构建路径,核心结论有三点:
- 文档解析的质量决定审查上界------花时间做好结构与版面还原,收益远大于堆叠提示词技巧;
- 多轮 Pipeline 优于单次全量输出------职责单一、可缓存、可重试,是生产环境的关键设计;
- 稳定性和成本是可工程化的------JSON Mode + Pydantic 校验 + 条款级缓存,三者组合即可支撑严肃业务场景。
完整的代码可按本文顺序拼装成一个可运行脚本,替换真实 API Key 后即可对 PDF / DOCX 合同进行初步审查。建议先用真实但已脱敏的合同做小批量验证,确认风险口径后再逐步放开自动化范围。