前段时间做了一块长文档/合同审核能力,过程比我一开始想的要绕很多。
最早的想法其实特别朴素:文档上传,抽出文本,拼一段 prompt,然后把全文丢给大模型,让它给审核意见。小样本文档跑起来还挺像那么回事,演示的时候也能看到结果。
但一换成长一点的合同,问题就来了。
有的文档几十页,正文、附件、表格、补充说明全混在一起。模型不是完全看不懂,它甚至能给出一段“看起来很专业”的分析。麻烦在于,你继续追问:依据在哪一段?有没有漏掉附件里的限制?为什么这里判成不符合?这时候它就开始不稳定了。
我后来才意识到,这类功能不能按“聊天问答”来做。
它更像一条审核流水线:先把文档整理成可检索、可定位、可追溯的证据片段,再让模型围绕一个个明确审核点去判断。模型只是其中一个环节,不应该直接扛完整份文档。
下面就按我自己的实现过程和踩坑,整理一下这套思路。
1. 第一个版本:全文直接塞给模型
第一版真的很粗暴,流程差不多是这样:
上传合同
-> 抽取全文
-> 拼接 prompt
-> 调用大模型
-> 返回审核意见
这个做法的优点也很明显:写起来快,演示直观,不需要先搭一堆检索、切片、规则之类的东西。
但是它很快就暴露出几个问题。
1.1 文档一长,细节会被淹掉
短合同还好,模型基本能照顾到大部分内容。
但文档一长,尤其是带附件、表格、补充条款的时候,模型经常会抓住几个显眼条款,然后漏掉一些中间细节。
比如你让它检查“是否约定验收条件”,它可能看到正文里出现“验收”就认为有,但附件里真正限制验收标准的部分并没有结合进去。这个问题不是每次都出现,正因为不是每次都出现,才更难排查。
1.2 结论没有证据,用户很难信
全文审核还有个硬伤:结论不好追溯。
模型说“付款条款不完整”,用户下一句基本就是:哪一段不完整?
如果系统只能给一段总结,却不能把原文依据拉出来,这个审核结果就很难进入真正的复核流程。
1.3 同一份文档,多跑几次结果会飘
聊天场景里,回答每次有点差异可以接受。但审核不一样,审核更像半结构化流程,用户期待的是稳定结论。
同一份文档、同一套规则,如果多跑几次结论都不太一样,那后面就很难解释。
1.4 用户体验也不好
长文档审核可能要跑很久。第一版是等全部结束后一次性返回,前端只有一个 loading。
用户不知道它是在正常处理,还是卡住了,也不能取消。这个体验在本地测试时不明显,一旦文档变长就很明显。
所以后面我把思路改了:
不让模型一次性“审全文”,而是把文档拆成证据片段,再按审核规则逐项判断。
我当时给自己画过一个很粗的链路图,差不多是这样:
flowchart LR
A[上传文档] --> B[抽取文本]
B --> C[结构化切片]
C --> D[建立检索索引]
E[审核规则] --> F[拆成审核要点]
F --> G[按要点召回片段]
D --> G
G --> H[模型局部判断]
H --> I[结论聚合]
I --> J[流式返回结果]
这个图不复杂,但它帮我把边界想清楚了:模型不直接面对整份文档,而是面对“当前审核要点 + 相关证据片段”。
2. 后来我把它改成了一条审核流水线
后来整体链路变成这样:
文档上传
-> 原文保存
-> 文本抽取
-> 文档切片
-> 建立索引
-> 配置审核规则
-> 按审核点召回相关片段
-> 模型局部判断
-> 结果聚合
-> 流式返回
-> 人工复核/导出
这条链路比“直接调模型”麻烦很多,但它解决的是另外一类问题:
- 文档再长,也不用一次性塞进模型;
- 每个结论都能挂到原文片段;
- 一个审核点失败,不影响其他审核点;
- 结果可以边生成边展示;
- 后续要做人审、导出、版本追踪,也有数据基础。
我现在看这类系统,会把模型放在一个比较克制的位置:
模型负责判断,系统负责组织证据和管理流程。
文档怎么保存、怎么切片、怎么检索、怎么防止任务重复、怎么聚合结果,这些最好不要交给模型自由发挥。
3. 文档上传后,先别急着审核
我踩过的一个坑是:太早进入模型阶段。
其实很多后续问题,都是文档预处理阶段埋下的。比如文本抽取乱了、表格丢了、条款编号没了,后面再调 prompt 也很难救回来。
所以现在我会先做三件事。
3.1 原始文件一定要留着
抽取后的文本只是中间产物,不应该替代原文。
原因很简单,审核结果最终还是要给人复核。人需要回到原文确认:这条结论到底对应哪一段?附件里是不是还有补充说明?
所以原文要单独保存,后面在线预览、证据定位、报告导出、版本对比都会用到。
3.2 文本抽取要尽量保留结构
合同和普通文章不一样,它的结构信息本身就是语义的一部分。
条款编号、标题层级、表格行列、附件标题、前后段落顺序,这些东西丢掉之后,模型看到的就是一坨散文本。
我遇到过一个很典型的情况:付款计划在表格里,抽取后只剩一堆数字和日期。人看原表格很清楚,但模型看到散掉的文本后,很难知道哪个金额对应哪个节点。
所以抽取阶段不一定要做到完美结构化,但至少要保证“人读起来顺,模型也能读出上下文”。
3.3 切片不要只按固定长度硬切
我一开始也试过按固定字数切,比如每 1000 字一段。实现很简单,但很容易把一个完整条款切成两半。
后面我更倾向于按结构切:优先看标题、条款编号、段落边界,实在太长再按长度二次切。表格能整体保留就整体保留。
每个切片我至少会保留这些信息:
切片 ID
文档 ID
标题路径
原文内容
位置标识
前一个切片 ID
后一个切片 ID
这样检索到一个片段后,还能向前后扩一点上下文。很多条款单独看不完整,带上前后一两段后,判断会稳不少。
切片对象可以抽象成这样:
public class DocumentChunk {
private String chunkId;
private String documentId;
private String titlePath;
private String content;
private Integer pageNo;
private String prevChunkId;
private String nextChunkId;
}
实际落库时字段可以更多,比如字符偏移、段落编号、表格标识、附件标识等。但核心点只有一个:每个片段必须能回到原文位置。
切片伪代码大概是这样:
List<DocumentChunk> splitToChunks(DocumentText doc) {
List<Section> sections = parseByHeadingAndNumber(doc.getText());
List<DocumentChunk> chunks = new ArrayList<>();
for (Section section : sections) {
if (section.length() <= MAX_CHARS) {
chunks.add(toChunk(section));
} else {
chunks.addAll(splitLongSection(section, MAX_CHARS));
}
}
linkPrevAndNext(chunks);
return chunks;
}
这里不用一上来就追求特别复杂的版面解析。先保证条款顺序、标题路径和前后关系不丢,已经能解决很多问题。
4. 检索比我一开始想的重要
刚开始我有点高估模型,觉得只要模型够强,检索粗一点也没事。
实际不是这样。
审核系统里,模型判断得准不准,很大程度取决于你给它的上下文是不是对的。召回片段不相关,模型会被噪声带偏;关键片段没召回,模型就只能猜。
4.1 只做关键词检索不够
合同里很多内容确实适合关键词检索,比如付款、验收、发票、违约、交付、保密、争议解决。
但也有不少表达不是完全匹配关键词。
比如审核点是:
是否约定逾期交付责任?
文档里可能写的是:
未按约定时间完成交付的,应按每日一定比例承担责任。
它没直接写“逾期交付责任”,但语义上就是相关的。
4.2 只做向量检索也不够
向量检索能找语义相近内容,但合同审核里有很多东西必须精确。
金额、日期、比例、主体名称、条款编号,这些不能太“语义化”。只靠向量检索,有时会召回一堆看着相关但不够准的片段。
4.3 后来我改成混合召回
比较稳的方式是全文检索和向量检索都做,然后合并、去重、排序、扩上下文。
审核要点
-> 全文检索召回候选片段
-> 向量检索召回候选片段
-> 合并去重
-> 相关性筛选
-> 上下文扩展
-> 交给模型判断
伪代码类似这样:
List<DocumentChunk> retrieveEvidence(String documentId, String reviewPoint) {
List<DocumentChunk> keywordHits = keywordSearch(documentId, reviewPoint, 30);
List<DocumentChunk> vectorHits = vectorSearch(documentId, reviewPoint, 30);
List<DocumentChunk> merged = mergeByChunkId(keywordHits, vectorHits);
List<DocumentChunk> reranked = rerankByScoreAndPosition(merged, reviewPoint);
return expandContext(reranked, 1, 1);
}
这里有个经验:第一次召回宁愿多一点,后面再筛。漏召回比多召回更麻烦,多召回还能过滤,漏了关键片段就很难补。
我还会加一个“相关性筛选”步骤,让模型先只返回片段 ID,不让它直接下结论:
你只需要从候选片段中选出和审核要点相关的片段 ID。
不要解释,不要总结。
输出格式:
id: chunk_001
id: chunk_009
这个约束很小,但很有用。先筛选,再判断,比一步到位稳。
5. 审核规则要拆细,不要写成一句大需求
我踩过的另一个坑是规则写得太大。
比如一开始写:
请检查合同付款条款是否完整、合理、清晰。
这句话人能理解,但系统不好执行。模型可能输出一段不错的分析,但你很难知道它到底检查了哪些点,哪些没检查。
后来我把规则拆成三层:
审核规则
-> 审核项
-> 审核要点
举个例子:
审核规则:合同基础审核
审核项:主体信息
- 是否包含双方主体名称
- 是否包含联系方式或地址
- 是否包含签署日期
审核项:付款条款
- 是否约定付款金额
- 是否约定付款节点
- 是否约定发票要求
审核项:风险条款
- 是否约定违约责任
- 是否约定争议解决方式
- 是否约定保密义务
拆细以后,系统每次只处理一个明确问题。
审核要点:是否约定付款节点
-> 检索相关片段
-> 判断是否符合
-> 输出依据
规则配置可以设计成这种结构:
{
"ruleName": "合同基础审核",
"items": [
{
"name": "付款条款",
"points": [
"是否约定付款金额",
"是否约定付款节点",
"是否约定发票要求"
]
},
{
"name": "风险条款",
"points": [
"是否约定违约责任",
"是否约定争议解决方式"
]
}
]
}
后端执行时就是把这棵规则树展开:
for (ReviewItem item : rule.getItems()) {
for (String point : item.getPoints()) {
ReviewPointResult result = reviewOnePoint(documentId, item.getName(), point);
stream.send(result);
}
}
这样还有一个好处:天然适合流式输出。一个审核点完成,就可以先返回一个结果。
6. 三类审核不能混成一种逻辑
做了一段时间后,我发现文档审核至少要分三类处理:完整性、规范性、一致性。
它们看起来都是“让模型判断”,但技术逻辑不太一样。
6.1 完整性:看有没有
完整性审核关注的是文档里有没有某类内容。
比如有没有签署日期、付款方式、违约责任、争议解决条款。
这类审核最关键的是召回。找不到相关片段时,可以提示“未找到相关内容”;找到了,再让模型判断是否满足要求。
我一般会保留这几个状态:
符合
部分符合
不符合
未找到相关内容
“未找到相关内容”和“不符合”最好分开。前者表示没找到依据,后者表示找到了依据但判断不满足。
6.2 规范性:看写得对不对
规范性审核关心的是内容是否达标。
比如付款节点是否明确,验收条件是否清晰,违约责任是否可执行,争议解决方式是否明确。
这类审核只找到片段还不够,还要判断片段内容本身是否符合要求。
我后面加了一个相关性筛选:
召回候选片段
-> 让模型先挑出真正相关的片段 ID
-> 再对相关片段逐条判断
-> 最后聚合结论
这个步骤能减少不少噪声。如果直接把 20 个候选片段都丢进去,模型很容易被无关内容干扰。
6.3 一致性:看前后有没有冲突
一致性审核最麻烦。
它不是判断有没有,也不是判断某一段写得对不对,而是要比较多个位置的内容。
比如正文和附件金额是否一致,前后交付时间是否冲突,付款条件和验收条件是否矛盾,主文档和补充说明是否打架。
这种场景要先召回多处相关片段,再放到同一个上下文里对比。
审核要点
-> 大范围召回相关片段
-> 相关性筛选
-> 组合多个片段
-> 判断一致 / 部分一致 / 不一致
-> 转成符合 / 部分符合 / 不符合
一致性审核不能只看一个片段,这是我后面才真正确认的点。
7. 模型输出必须结构化
刚开始我让模型自由输出,结果读起来很自然,但后端不好接。
比如模型返回:
该条款基本符合要求,但建议进一步明确付款时间。
这句话人能看懂,但系统不知道该归到“符合”还是“部分符合”。
后面我改成固定结构输出:
{
"conclusion": "部分符合",
"basis": "文档中约定了付款节点,但没有明确发票开具要求。",
"evidence_ids": ["chunk_001", "chunk_008"]
}
结构化以后,后端才能做聚合、证据关联、报告导出和前端状态展示。
接口也可以按这个思路设计。比如发起审核:
POST /api/reviews/start
Content-Type: application/json
{
"documentId": "doc_123",
"ruleId": "rule_contract_basic",
"reviewTypes": ["完整性", "规范性", "一致性"]
}
流式返回事件可以长这样:
{
"type": "review_item",
"reviewType": "完整性",
"itemName": "付款条款",
"point": "是否约定付款节点",
"conclusion": "部分符合",
"basis": "已找到付款节点描述,但缺少明确日期或触发条件。",
"evidenceIds": ["chunk_012", "chunk_013"]
}
当然,模型不一定每次都老老实实返回标准 JSON。这个地方要做兜底:去 Markdown 代码块、提取 JSON、校验字段、校验结论枚举,解析失败时重试或给默认状态。
这部分很琐碎,但不能省。审核结果要进入系统流转,就不能直接相信模型原文。
8. 结论聚合用代码控制,别让模型自由总结
长文档审核通常不是一个结论,而是一组结论。
比如:
审核项:付款条款
- 是否约定付款金额:符合
- 是否约定付款节点:符合
- 是否约定发票要求:不符合
这个审核项最后应该是什么状态?如果让模型总结,每次表述可能都不一样。
我更倾向于用代码把聚合规则写清楚:
全部符合 -> 符合
有符合也有不符合 -> 部分符合
有部分符合 -> 部分符合
全部不符合 -> 不符合
全部未找到相关内容 -> 未找到相关内容
简单实现类似这样:
String aggregate(List<String> conclusions) {
if (conclusions.stream().allMatch("符合"::equals)) {
return "符合";
}
if (conclusions.stream().allMatch("未找到相关内容"::equals)) {
return "未找到相关内容";
}
if (conclusions.contains("部分符合")) {
return "部分符合";
}
if (conclusions.contains("符合") && conclusions.contains("不符合")) {
return "部分符合";
}
return "不符合";
}
聚合规则可以按场景调整,但一定要明确。稳定性不能完全靠 prompt。
9. 流式输出是后面补的,但很值
第一版是等全部审核完一次性返回。文档短的时候没问题,文档一长,用户就只能盯着 loading。
后来我改成流式输出:
开始审核
-> 返回第 1 个审核项结果
-> 返回第 2 个审核项结果
-> 返回第 3 个审核项结果
-> ...
-> 返回完成事件
这个改动对体验提升很明显。用户能看到结果一点点出来,也能提前发现哪些地方有风险。
前端消费流式结果时,大概可以这样写:
const source = new EventSource(`/api/reviews/stream?taskId=${taskId}`);
source.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "review_item") {
appendReviewResult(data);
}
if (data.type === "done") {
source.close();
}
};
source.onerror = () => {
showError("审核连接异常,请稍后重试");
source.close();
};
如果是 POST 发起的流式接口,也可以用 fetch + ReadableStream,思路差不多。
流式还有一个好处:某个审核项失败,不代表前面已经完成的结果不能用。这个对长任务很重要。
10. 长任务必须能取消,也要防重复提交
这是实际用起来以后才发现必须做的能力。
用户可能连续点多次开始审核,也可能选错规则、传错文档,或者等太久想取消。如果后端不做控制,很容易同时跑多个相同任务,结果互相覆盖。
产品界面上,我比较建议把任务状态展示清楚,类似这样:
[正在审核] 付款条款完整性 2/8
[已完成] 主体信息完整性 符合
[风险] 发票要求 部分符合
[待审核] 违约责任 -
[操作] 取消审核
文章里如果需要截图,可以自己画一个简化示意图,不建议贴真实系统页面。
我后面用了类似这样的任务 key:
文档 ID + 审核类型
同一个 key 正在运行时,不允许重复启动。
取消则通过任务信号来做:
任务启动时注册取消信号
审核过程中监听取消信号
用户取消时触发信号
审核流停止并清理资源
这个设计不复杂,但很实用。长任务如果没有取消和防重复提交,线上体验会很差。
11. 长文档摘要也是类似思路
审核之外,长文档通常还需要摘要。
摘要也不能一次性把全文丢给模型。我用的是类似 Map-Reduce 的方式:
长文档
-> 分块
-> 每块生成局部摘要
-> 多个局部摘要再合并
-> 最终压缩成整体摘要
这个做法会多几次模型调用,但结果更可控。文档特别长时,还可以分组多轮合并。
如果用户有关注主题,可以把关注点带进每个块摘要里。但 prompt 里要说清楚:重点关注这些主题,同时不要忽略其他关键事实。否则模型可能只盯着关注点,漏掉其他重要内容。
12. 我最后保留下来的最小闭环
如果重新做一个最小可用版本,我不会一开始就做很复杂。
我会先把这条链路跑通:
1. 上传文档
2. 抽取文本
3. 按结构切片
4. 建立检索索引
5. 配置审核要点
6. 按要点召回相关片段
7. 模型输出结构化结论
8. 流式返回结果
9. 人工补充意见
10. 导出审核报告
这个闭环稳定以后,再逐步加向量检索、OCR、一致性审核、版本对比、任务取消、输出重试、证据高亮、审批流程。
我现在比较反对一开始就做“大而全”。这类系统最重要的是先跑通一条稳定链路,而不是堆功能。
13. 如果再做一遍,我会优先注意这些点
这部分算复盘。
第一,先做证据定位,再做漂亮结论。
漂亮总结不难,难的是每个结论都能回到原文。
第二,检索参数要留配置空间。
召回数量、最低分、上下文扩展长度、每批送给模型的片段数量,不要写死。不同文档差异很大,后面肯定要调。
第三,不要过度相信模型的 JSON 输出。
即使 prompt 写得很明确,模型也可能返回解释性文本、Markdown 包裹、字段缺失。解析和兜底一定要有。
第四,旧结果不要轻易覆盖。
如果每次重新审核都先删旧结果,一旦新任务失败,用户可能连旧结果也看不到。更稳的是引入运行批次,等新结果完成后再切换展示。
第五,人工复核不是附加功能。
合同和长文档审核不是让 AI 直接拍板。AI 更适合做初筛、召回、归纳和风险提示,最终仍然需要人确认。
14. 总结
如果只用一句话总结这次经验:
超长文档/合同审核,不是把全文丢给大模型,而是把文档处理成可检索、可定位、可追溯的证据片段,再让模型围绕明确审核点做局部判断。
真正有价值的不是某个 prompt,而是这条链路:
文档抽取
-> 结构化切片
-> 混合召回
-> 规则驱动
-> 局部判断
-> 结构化输出
-> 结果聚合
-> 流式反馈
-> 人工复核
模型能力当然重要,但工程链路更重要。
用户最终需要的不是一段“看起来很专业”的回答,而是一个能解释、能追溯、能复核的审核结果。
这也是我做完之后最大的感受:
AI 应用落地,最难的往往不是把模型接进来,而是把模型放进一套稳定、可控、可追溯的流程里。
6 个帖子 - 6 位参与者