实战:用 LLM + 结构化输出自动提取合同风险条款并标注红线
面向开发者 / 架构师 | 开发者实战教程 | 适配 CSDN / 掘金 / 博客园
一份 50 页采购合同,法务平均 4 小时才能看出哪里有坑。本文带你从零写一个可运行的风险条款提取器:把合同分块、用 LLM 做结构化输出、给每条风险打分、按三档策略标红,并强制回贴原文防幻觉。核心参考 draft-legal 的 Redline Agent 思路。
一、整体流水线
合同文本
│ 按条款分块(保留"第X条"语义边界)
▼
clause chunks
│ 逐块送 LLM + 结构化 schema
▼
RiskClause[] (category / severity / quote / suggestion)
│ 按 RED_LINE_POLICY 过滤
▼
redline 清单(高亮原文 + 风险评分)
分块策略很关键:按条款("第 X 条"或编号)切,而非固定字数,保证一个 chunk 是一条完整义务,模型才不会断章取义。

二、结构化输出:用 schema 锁住格式
单靠"请提取风险"会拿到一段自由文本,没法做红线标注。正确做法是用 structured output(function calling / JSON mode)强制模型返回固定 schema。
python
from pydantic import BaseModel
from openai import OpenAI
class RiskClause(BaseModel):
category: str # PAYMENT / DELIVERY / WARRANTY / LIABILITY
severity: int # 1-3, 3 最高
quote: str # 必须回贴原文片段
reason: str # 为什么有风险
suggestion: str | None # 修改建议
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") # 自托管
def extract_risk(clause_text: str) -> RiskClause:
resp = client.chat.completions.create(
model="qwen2.5:32b",
messages=[{
"role": "system",
"content": "你是合同风控工程师,只输出结构化风险,quote 必须原样来自文本。",
}, {
"role": "user",
"content": f"审查以下条款:\n{clause_text}",
}],
response_model=RiskClause, # instructor / structured output
)
return resp
三、三档红线策略:给提示词换尺度
保守 / 中等 / 激进不是三个模型,而是三个审查尺度。把它做成一个 policy 开关,注入 system prompt。
python
RED_LINE_POLICY = {
"conservative": "仅标注明确违法或重大金额偏差的条款。",
"moderate": "标注违法 + 不公平 + 模糊义务条款。",
"aggressive": "标注一切对己方不利的表述,含隐含风险。",
}
def redline(clause: str, level: str) -> list[RiskClause]:
policy = RED_LINE_POLICY[level]
return extract_risk_with_policy(clause, policy)
实测口径(52 页汽配合同,本地 qwen2.5:32b):中等档召回率 92%,误标率经人工门压到 3% 以下;单份约 12 分钟,Token 成本约 0.4 元(自托管零 API 费)。
四、风险评分与引用溯源(防幻觉核心)
最危险的不是漏标,是模型"编"出一条不存在的风险。强制每条输出回贴 quote,无出处一律不显示------这和 draft-legal 的引用溯源纪律一致。
python
def safe_redlines(clauses: list[str], level: str) -> list[RiskClause]:
out = []
for c in clauses:
r = redline(c, level)
if not r.quote or r.quote.strip() not in c: # 出处校验
continue # 无原文字段段,丢弃
out.append(r)
return out
# 风险评分加权:severity * 权重(category)
RISK_WEIGHT = {"LIABILITY": 3, "PAYMENT": 2, "WARRANTY": 2, "DELIVERY": 1}
def score(risks: list[RiskClause]) -> float:
return sum(RISK_WEIGHT.get(r.category, 1) * r.severity for r in risks)
五、标红渲染:把清单贴回原文
提取完直接在编辑器里高亮。TipTap(draft-legal / killua2312 采用)可用 decoration 标红:
typescript
// 用 TipTap decoration 高亮风险片段
import { Decoration } from '@tiptap/pm/view';
function buildRedlineDecorations(risks: RiskClause[]) {
return risks.map(r => {
const from = doc.textBetween(0, doc.content.size).indexOf(r.quote);
return Decoration.inline(from, from + r.quote.length, {
class: r.severity >= 3 ? 'redline-high' : 'redline-mid',
});
});
}
六、踩坑经验
- 中文术语被切坏:"连带责任""质保金"常被拆成普通词导致漏标。Redline 前加制造业词典做关键词锚定,召回率 +5 点。
- 小模型幻觉:本地 7B 误标率约 12%,32B 降到 3% 以下。取舍是 Redline 用 32B 保准,Ask 用 7B 保快。
- PDF 表格丢字段:参数在表格里被揉成一团,准确率 71% → 换带版面分析解析器 → 94%。解析质量决定上层一切。
- 出处造假 :模型有时把融合结果编成不存在条款。强制回贴
quote且做in原文校验,无 id/无原文不展示。
开放性问题
如果让你给"风险评分"设计一套加权公式,你会按条款类型(付款/违约/质保)加权,还是按交易对手风险等级动态加权?为什么?
动手试试: 想跑通这套提取器?拉取 合同管理系统开源 Demo ,或到 官网免费试用 ,上传一份合同看红线清单。也欢迎 加入技术社群 交流 LLM 结构化输出在合同场景的调参经验。