第1992篇:我用AI工程化实现的最满意的项目——完整的技术回顾
第1992篇:我用AI工程化实现的最满意的项目——完整的技术回顾
如果让我从这几年做过的项目里选一个"最满意的",我会选一个很多人可能没见过的类型:一个企业级智能合同审查系统。
不是因为它技术最炫,也不是因为它规模最大。而是因为它让我第一次完整地经历了"AI系统从0到生产"的全链路——包括那些教科书上不会写的部分:怎么处理业务方的不信任、怎么在没有标注数据的情况下冷启动、怎么在模型幻觉和合规要求之间找到平衡点。
这篇文章,我打算完整地复盘这个项目。从需求背景到架构设计,从踩过的坑到最终的解法,一点都不藏着掖着。
一、项目背景:一个真实存在的业务痛点
这个项目的业务背景是这样的:
一家中型制造企业,每年要审核大约8000份合同,涉及采购、销售、服务、保密等多种类型。法务团队只有5个人,每人每天平均要看15-20份合同。审核内容包括:条款完整性检查、风险条款识别、与公司标准模板的偏差分析。
最大的问题不是效率,是一致性。不同的法务同事,对同样的风险条款,判断标准不完全一致。而且到了合同高峰期(每年3-4月和9-10月),加班加点也追不上,难免漏看。
初始需求很简单:给法务团队一个辅助工具,帮他们提前标记出需要重点关注的条款,减少漏看。
注意,是"辅助",不是"替代"。这个定位非常重要,它决定了后续整个系统的设计方向。
二、为什么这个方向难做
合同审查是一个看起来"很适合AI",但实际上陷阱极多的领域。
陷阱一:没有足够的标注数据
业务方没有历史的"合同+审核意见"对应数据,有的只是法务人员脑子里的知识和几十份标准合同模板。这意味着我们几乎没法用传统的监督学习。
陷阱二:错误代价不对称
漏掉一个高风险条款,可能导致企业实质性损失。误报一个正常条款,只是让法务多看一眼,浪费几分钟。这种不对称性决定了系统的精度要求非常高——可以多报,但不能少报。
陷阱三:法律语言的模糊性
同一句话,在不同合同背景下,风险等级可能完全不同。"乙方有权单方面终止合同"这句话,在采购合同里是重大风险,但在某些框架协议里可能是标准条款。LLM在处理这种上下文依赖时很容易出错。
陷阱四:结果必须可解释
法务不是开发,他们不接受"AI觉得这里有风险",他们需要知道"哪个条款、什么风险、参考依据是什么"。黑盒系统直接被拒。
三、整体架构设计
综合以上约束,我最终设计了一个三层架构:
三层各有分工,关键是让它们互相校验,而不是只依赖其中一个:
- 规则引擎负责确定性的检查(必须包含的条款、禁止出现的表述)——速度快、不会出错、但覆盖范围有限
- RAG检索负责把当前合同与标准模板对比,找出偏差——可解释性好,但依赖模板库的质量
- LLM分析负责语义层的风险识别,处理规则和模板都覆盖不到的长尾情况——能力强,但需要被校验
四、核心模块实现
4.1 合同结构化解析
这是整个系统的基础,也是最容易被低估的部分。合同格式千变万化,PDF、Word、甚至扫描件都有,而且很多合同的条款编号方式五花八门。
@Service
public class ContractDocumentParser {
private final TesseractOCRClient ocrClient;
private final StructureExtractor structureExtractor;
public ContractDocument parse(MultipartFile file) throws ParseException {
String rawText = extractRawText(file);
return structureExtractor.extract(rawText, detectContractType(rawText));
}
private String extractRawText(MultipartFile file) throws ParseException {
String fileName = file.getOriginalFilename();
if (fileName.endsWith(".pdf")) {
return parsePDF(file);
} else if (fileName.endsWith(".docx") || fileName.endsWith(".doc")) {
return parseWord(file);
} else if (isImageFile(fileName)) {
// 扫描件走OCR
return ocrClient.recognize(file.getBytes(), "chi_sim+eng");
}
throw new ParseException("不支持的文件格式: " + fileName);
}
private String parsePDF(MultipartFile file) throws ParseException {
try (PDDocument doc = PDDocument.load(file.getInputStream())) {
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(doc);
// PDF提取有时候格式很乱,需要后处理
return cleanPDFText(text);
} catch (IOException e) {
throw new ParseException("PDF解析失败", e);
}
}
/**
* 识别合同类型,影响后续分析策略
*/
private ContractType detectContractType(String text) {
// 简单的关键词匹配,先粗分
if (containsKeywords(text, "采购", "供应商", "货物")) return ContractType.PURCHASE;
if (containsKeywords(text, "销售", "买方", "卖方")) return ContractType.SALES;
if (containsKeywords(text, "服务", "委托", "乙方提供")) return ContractType.SERVICE;
if (containsKeywords(text, "保密", "机密信息", "不得披露")) return ContractType.NDA;
return ContractType.GENERAL;
}
}结构化提取是难点:
@Component
public class StructureExtractor {
// 条款编号的多种格式
private static final Pattern CLAUSE_PATTERN = Pattern.compile(
"^(第[一二三四五六七八九十百]+条|\\d+\\.\\d*|[一二三四五六七八九十]+[、.]|\\([一二三四五六七八九十\\d]+\\))\\s*(.+)",
Pattern.MULTILINE
);
public ContractDocument extract(String rawText, ContractType type) {
List<ContractClause> clauses = new ArrayList<>();
Matcher matcher = CLAUSE_PATTERN.matcher(rawText);
int lastEnd = 0;
String lastClauseNumber = null;
StringBuilder lastContent = new StringBuilder();
while (matcher.find()) {
if (lastClauseNumber != null) {
// 保存上一个条款
String content = rawText.substring(lastEnd, matcher.start()).trim();
clauses.add(new ContractClause(lastClauseNumber, lastContent.toString(), content));
}
lastClauseNumber = matcher.group(1);
lastContent = new StringBuilder(matcher.group(2));
lastEnd = matcher.end();
}
// 处理最后一个条款
if (lastClauseNumber != null) {
clauses.add(new ContractClause(lastClauseNumber, lastContent.toString(),
rawText.substring(lastEnd).trim()));
}
return new ContractDocument(clauses, type, rawText);
}
}4.2 RAG模板对比引擎
这是系统最核心的创新点之一。我们没有标注数据,但有标准合同模板。思路是:把标准模板的每个条款向量化存入向量库,审查时找出当前合同条款与模板条款的差异。
@Service
public class TemplateComparisonEngine {
private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;
private final LLMClient llmClient;
/**
* 对每个合同条款,找到最相似的模板条款,分析差异
*/
public List<ClauseDeviationReport> analyze(ContractDocument contract) {
List<ClauseDeviationReport> reports = new ArrayList<>();
for (ContractClause clause : contract.getClauses()) {
// 1. 向量检索最相似的模板条款
List<TemplateClause> similarClauses = findSimilarTemplateClauses(
clause, contract.getType(), 3
);
if (similarClauses.isEmpty()) {
// 找不到对应模板条款,可能是额外条款,需要重点标注
reports.add(ClauseDeviationReport.extraClause(clause));
continue;
}
// 2. 用LLM分析偏差
ClauseDeviationReport report = analyzeDeviation(clause, similarClauses.get(0));
if (report.hasDeviation()) {
reports.add(report);
}
}
return reports;
}
private List<TemplateClause> findSimilarTemplateClauses(
ContractClause clause, ContractType type, int topK) {
float[] queryVector = embeddingClient.embed(clause.getFullText());
// 按合同类型过滤,避免跨类型误匹配
Map<String, Object> filters = Map.of("contract_type", type.name());
return vectorStore.similaritySearch(queryVector, topK, filters, SIMILARITY_THRESHOLD)
.stream()
.map(this::toTemplateClause)
.collect(Collectors.toList());
}
private ClauseDeviationReport analyzeDeviation(ContractClause actual, TemplateClause template) {
String prompt = String.format("""
你是专业的合同法律顾问。请分析以下两个合同条款的差异,并评估实际合同条款是否存在法律风险。
【标准模板条款】:
%s
【实际合同条款】:
%s
请严格按照以下JSON格式输出,不要有任何其他文字:
{
"has_deviation": true/false,
"risk_level": "HIGH/MEDIUM/LOW/NONE",
"deviation_points": ["偏差点1", "偏差点2"],
"risk_analysis": "风险说明(如果有)",
"suggestion": "修改建议(如果需要)"
}
""",
template.getContent(),
actual.getFullText()
);
String response = llmClient.complete(prompt, Map.of("temperature", 0.1));
return parseDeviationReport(response, actual, template);
}
}4.3 结果置信度管理
这是我认为整个系统最关键的部分,也是最容易被忽略的。
三个分析模块会产生不同的结果,必须有机制来评估每个结果的可信度,然后决定:直接展示给法务、放入高优复核队列、还是自动过滤:
@Component
public class RiskResultAggregator {
/**
* 将三个模块的结果合并,计算综合置信度
*/
public List<ContractRisk> aggregate(
List<RuleCheckResult> ruleResults,
List<ClauseDeviationReport> deviationReports,
List<LLMRiskFlag> llmFlags) {
Map<String, RiskBuilder> riskMap = new HashMap<>();
// 规则引擎结果:置信度最高,直接置1.0
for (RuleCheckResult rule : ruleResults) {
riskMap.computeIfAbsent(rule.getClauseId(), RiskBuilder::new)
.addEvidence(RiskEvidence.fromRule(rule), 1.0);
}
// RAG偏差结果:基础置信度0.75,HIGH风险加权
for (ClauseDeviationReport deviation : deviationReports) {
if (!deviation.hasDeviation()) continue;
double confidence = deviation.getRiskLevel() == RiskLevel.HIGH ? 0.85 : 0.70;
riskMap.computeIfAbsent(deviation.getClauseId(), RiskBuilder::new)
.addEvidence(RiskEvidence.fromDeviation(deviation), confidence);
}
// LLM标记:基础置信度0.65,需要多条证据互相印证才提升
for (LLMRiskFlag flag : llmFlags) {
riskMap.computeIfAbsent(flag.getClauseId(), RiskBuilder::new)
.addEvidence(RiskEvidence.fromLLM(flag), 0.65);
}
return riskMap.values().stream()
.map(RiskBuilder::build)
.filter(risk -> risk.getCompositeConfidence() >= MIN_REPORT_THRESHOLD)
.sorted(Comparator.comparingDouble(ContractRisk::getCompositeConfidence).reversed())
.collect(Collectors.toList());
}
/**
* 置信度合并策略:多证据时取加权最高值,非简单叠加
*/
static class RiskBuilder {
private final String clauseId;
private final List<Pair<RiskEvidence, Double>> evidences = new ArrayList<>();
RiskBuilder(String clauseId) { this.clauseId = clauseId; }
void addEvidence(RiskEvidence evidence, double confidence) {
evidences.add(Pair.of(evidence, confidence));
}
ContractRisk build() {
// 取最高置信度,加上额外证据的小幅加权
double maxConf = evidences.stream().mapToDouble(Pair::getRight).max().orElse(0);
double additionalBonus = (evidences.size() - 1) * 0.05;
double composite = Math.min(maxConf + additionalBonus, 0.98);
List<RiskEvidence> allEvidence = evidences.stream()
.map(Pair::getLeft).collect(Collectors.toList());
return new ContractRisk(clauseId, composite, allEvidence);
}
}
}五、踩过的最大的三个坑
坑一:OCR质量导致的连锁失效
扫描件的OCR识别率不够稳定。有一批扫描件是在光线不好的情况下拍摄的,OCR错字率达到15%。这导致:条款分割失败→向量化偏差→检索结果错误→LLM分析跑偏。整个链路崩了。
解决方案:在OCR之后加了一个质量评估步骤,低于质量阈值的文件直接进"人工扫描"队列,不走自动分析。宁可少处理,不出垃圾结果。
坑二:LLM的"过度热情"
早期版本的LLM分析Prompt调好之后,我发现它有一个问题:太敏感了。几乎所有条款都被标注为"存在风险",误报率超过60%。法务团队看了两天就放弃用了。
后来加了一个反向验证步骤——对LLM标注的风险,再用一个Prompt问它:"如果这个条款出现在标准的XX类合同里,它还是风险吗?"这样能过滤掉大量的误报。
坑三:系统性能与批量处理的矛盾
法务说,审核高峰期需要一天处理300份合同。我算了一下,每份合同平均100个条款,每个条款要调3次LLM(偏差分析、风险识别、反向验证),那就是:300 × 100 × 3 = 90,000次LLM调用/天。这个成本是无法接受的。
最终的解法是分级处理:
大约70%的合同走轻量分析,LLM调用量降低了85%。
六、上线之后的真实数据
系统上线后运行了6个月,一些真实数据:
- 处理合同总量:4,200份
- 平均处理时间:从人工的25分钟/份降至3分钟/份(含人工复核时间)
- 高风险条款识别率:91%(与法务专家对比评测)
- 误报率:约18%(业务方认为可接受,"宁可多看")
- 法务团队加班频率:高峰期基本消除
有一个细节让我特别有成就感:法务团队里一位做了15年合同审核的老师,最开始非常抵触这个系统,觉得AI审合同是在威胁她的岗位。六个月后,她成了这个系统最重度的用户,还会主动找我反馈哪些规则需要优化。
这比任何技术指标都重要。
七、如果重做,我会改什么
一件事:更早引入反馈闭环。
现在系统的规则和Prompt基本上是我和法务初期讨论后定下来的,后续很少更新。但法务在使用过程中积累了大量"这个判断对/不对"的隐性知识,这些知识没有系统性地流回系统。
理想的设计应该是:法务每次标记"误报"或"漏报",这个反馈自动进入训练数据集,定期用来微调模型或更新规则。这样系统会越用越准,而不是上线就固化了。
这是下一个版本要做的事。也是我认为AI工程化最核心的长期工程:让系统在使用中持续进化。
