别再把整份合同丢给大模型了:超长文档审核系统的正确打开方式 5 个帖子 - 3 位参与者 阅读完整话题
前段时间做了一块长文档/合同审核能力,过程比我一开始想的要绕很多。 最早的想法其实特别朴素:文档上传,抽出文本,拼一段 prompt,然后把全文丢给大模型,让它给审核意见。小样本文档跑起来还挺像那么回事,演示的时候也能看到结果。 但一换成长一点的合同,问题就来了。 有的文档几十页,正文、附件、表格、补充说明全混在一起。模型不是完全看不懂,它甚至能给出一段“看起来很专业”的分析。麻烦在于,你继续追问:依据在哪一段?有没有漏掉附件里的限制?为什么这里判成不符合?这时候它就开始不稳定了。 我后来才意识到,这类功能不能按“聊天问答”来做。 它更像一条审核流水线:先把文档整理成可检索、可定位、可追溯的证据片段,再让模型围绕一个个明确审核点去判断。模型只是其中一个环节,不应该直接扛完整份文档。 下面就按我自己的实现过程和踩坑,整理一下这套思路。 详细内容放在下方链接: 别再把整份合同丢给大模型了:超长文档审核系统的正确打开方式 2 个帖子 - 1 位参与者 阅读完整话题
前段时间做了一块长文档/合同审核能力,过程比我一开始想的要绕很多。 最早的想法其实特别朴素:文档上传,抽出文本,拼一段 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 位参与者 阅读完整话题