基于 Qwen3.8-Max 构建 AI 合同精审系统:文档解析、多轮条款分析 Pipeline 工程实战

目录

  • [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 设计

这是整个系统的核心。我们不在一次调用里让模型"读完整个合同并输出全部风险",而是把审查任务拆成三轮,每轮职责单一、输出可控:

  1. 风险识别轮:从条款中识别潜在风险点及风险等级;
  2. 依据引用轮:为每个风险点定位原文依据;
  3. 修改建议轮:给出可落地的修改建议。

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 合同精审系统的构建路径,核心结论有三点:

  1. 文档解析的质量决定审查上界------花时间做好结构与版面还原,收益远大于堆叠提示词技巧;
  2. 多轮 Pipeline 优于单次全量输出------职责单一、可缓存、可重试,是生产环境的关键设计;
  3. 稳定性和成本是可工程化的------JSON Mode + Pydantic 校验 + 条款级缓存,三者组合即可支撑严肃业务场景。

完整的代码可按本文顺序拼装成一个可运行脚本,替换真实 API Key 后即可对 PDF / DOCX 合同进行初步审查。建议先用真实但已脱敏的合同做小批量验证,确认风险口径后再逐步放开自动化范围。

相关推荐
TheBestRucy13 分钟前
Python九阳神功之陆:数据库持久化与缓存·乾坤大挪移
数据库·python·缓存
2601_9676598915 分钟前
2026政务大厅服务机器人选型:咨询导览业务分流四类场景
人工智能·机器人·政务
AI工具测评家17 分钟前
2026实测对比|论文降AI改写底层技术解析:同义词替换≠真正去除AI写作特征
人工智能·机器学习·ai写作·降重·ai检测·查重·降ai
m4Rk_18 分钟前
【论文阅读】Agent 记忆机制(56):R2D2——把历史网页轨迹变成可搜索地图与反思记忆
论文阅读·人工智能·学习·开源·github
Asa1213819 分钟前
Science Advances|大语言模型增强宏基因组酶功能注释
人工智能·语言模型·自然语言处理
OsDepK20 分钟前
OSMDE移动AI.编程工具,现已支持添加ollama本地模型
人工智能
TechEdu20260620 分钟前
[人工智能]MiniMax(上海稀宇科技):模型、智能体与多模态应用工程实践
人工智能·ai
索西引擎22 分钟前
基于 Docker 容器化的 MySQL 数据库部署与访问控制机制研究
数据库·mysql·docker
启芯硬件23 分钟前
《硬件电路设计实战100例》专栏核心信息,定位及设计案例分享
人工智能·经验分享·嵌入式硬件·硬件工程·高速仿真