
1. 法务合同审核为什么不能只靠“把 PDF 丢给大模型”法务合同审核这件事表面看是“读文档”实际是“在几十页里定位某一条、判断它有没有风险、再指回原文第几页第几行”。我一开始也想过偷懒把 PDF 转成图片直接喂给视觉模型让它“看”完整份合同。结果第一份 20 页的劳动合同就翻车了——模型能说出“这份合同有风险”但问它风险在哪一页、哪一句它只能给个模糊的“大约在补偿金部分”。对法务来说这种答案没法用因为审核报告必须能高亮到具体条款律师和业务方要拿着报告去改合同。所以这个场景真正需要的是三件事第一把 PDF 里的文字连同坐标一起抽出来第二把长文档切成可检索、可定位的片段第三让 Agent 基于检索到的片段做条款抽取和风险判断并且每个结论都能回指原文。这就是 OCR RAG 的组合价值。OCR 负责“看得准、带坐标”RAG 负责“找得到、可追溯”LangChain 1.0 负责把这两段串成一个能跑起来的 Agent。本文面向的是已经会写 Python、想跑通一个法务合同审核原型的开发者。我会给出可复制的config.toml/settings.json骨架、TaoToken 统一 Key 的接入方式以及源码级的验证动作目标是让你跑通“合同条款抽取 风险点定位”这条链路。下面所有配置和代码都可以直接改路径后运行。2. TaoToken 前置一个 Key 打通 OCR 与审核模型在动手写 Agent 之前先把模型接入这层理顺。法务审核链路里其实有两类模型调用一类是 OCR 解析服务比如 MinerU 这类带坐标输出的解析器另一类是审核用的对话模型负责条款抽取、风险判断、结构化输出。如果每个服务都单独配一套 Key 和 base_url配置会散得到处都是换环境时特别容易漏。我的做法是用 TaoToken 作为统一入口把对话模型的调用收敛到一个 Key 上。TaoToken 的 API 地址是https://taotoken.net/api兼容 OpenAI 风格的接口所以 LangChain 里直接用ChatOpenAI就能接。你需要在控制台创建一个 API Key然后把它写进环境变量而不是硬编码在代码里。创建 Key 的入口在控制台的 API Keys 页面https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite。进去之后新建一个 Key复制出来后面配置里会用到。如果你还没决定用哪个模型可以先在模型对话页面试几条合同片段看看抽取效果https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite。这里要强调一点TaoToken 是模型调用的统一接入层不是让你绕过任何合规流程的工具。法务数据本身敏感建议在本地或内网环境跑 OCR只把脱敏后的文本片段送去审核模型。下面配置里的 Key 一律走环境变量不要提交到 Git。3. 可复制配置config.toml 与 settings.json 骨架先把配置文件搭好后面代码只读配置不写死参数。我用config.toml管服务地址和模型参数用settings.json管审核规则和切分策略两者职责分开改规则不用动代码。3.1 config.toml服务与模型参数# config.toml [app] name legal-contract-audit-agent temp_dir ./temp output_dir ./output [ocr] # 本地 OCR 解析服务地址按你的部署改 api_url http://127.0.0.1:8000/parse backend pipeline parse_method auto return_md true return_content_list true # 关键必须开启否则拿不到坐标 timeout 600 [llm] # TaoToken 统一接入 base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY # 从环境变量读取不写明文 model qwen3-max temperature 0.1 max_retries 2 [chunk] chunk_size 800 # 每个片段目标 token 数 overlap 80 # 片段重叠避免条款被切断 split_by_heading true # 优先按标题层级切 [audit] rules_file ./settings.json output_format jsonreturn_content_list true这一行是整个系统的命门。没有它OCR 只返回 Markdown 文本坐标信息全丢后面就没法做高亮定位。api_key_env指向环境变量名代码里用os.environ读这样 Key 不会进版本库。3.2 settings.json审核规则与切分策略{ chunk_strategy: { max_tokens: 800, overlap_tokens: 80, heading_levels: [#, ##, ###], keep_bbox: true }, audit_rules: { P0: [ 金额数字必须大小写一致且可计算, 日期字段不得为空或格式不完整, 权利义务主体必须明确可识别, 法律术语使用需与文书性质匹配 ], P1: [ 条款编号连续且无重复, 标点符号符合合同书写规范, 引用法条需注明具体条款号 ], P2: [ 错别字与形近字检查, OCR 识别导致的符号错误单独归类 ] }, output_schema: { has_issues: boolean, overall_risk_level: high|medium|low|none, issues: array, modifications: array, corrected_text: string, summary: string } }规则分级很重要。P0 是法律风险必须逐条查P1 是规范性P2 是文本质量。把 OCR 识别错误单独归到 P2避免把“书名号多了个空格”误判成法律术语问题——这个坑我在第一版里踩过模型把 OCR 噪声当成了条款缺陷报告里全是误报。4. 源码级实现OCR 解析、坐标切分与 Agent 组装配置就绪后进入代码。整条链路分四步OCR 解析拿坐标、带坐标切分、构建审核链、执行并回指原文。4.1 OCR 解析拿到带 bbox 的 content_listimport os import json import requests from pathlib import Path import tomllib with open(config.toml, rb) as f: cfg tomllib.load(f) def parse_pdf_with_ocr(pdf_path: str) - str: 调用 OCR 服务解析 PDF返回带坐标的 JSON 路径 out_dir Path(cfg[app][temp_dir]) out_dir.mkdir(exist_okTrue) with open(pdf_path, rb) as f: files [(files, (Path(pdf_path).name, f, application/pdf))] data { backend: cfg[ocr][backend], parse_method: cfg[ocr][parse_method], return_md: true, return_content_list: true, # 坐标来源 } resp requests.post( cfg[ocr][api_url], filesfiles, datadata, timeoutcfg[ocr][timeout], ) resp.raise_for_status() result resp.json() json_path out_dir / f{Path(pdf_path).stem}_output.json json_path.write_text( json.dumps(result, ensure_asciiFalse, indent2), encodingutf-8, ) return str(json_path)解析结果里content_list的每一项长这样{text: ..., bbox: [x1, y1, x2, y2], page_idx: 0}。bbox是文本块在页面上的矩形坐标page_idx是页码。这两个字段后面用来做高亮定位。4.2 带坐标切分一个 Chunk 对应多个 BBox切分的难点在于切完之后每个片段必须记住自己包含哪些原始文本块的坐标。我的做法是先按段落和标题切文本再遍历content_list用文本位置判断每个 bbox 落在哪个片段里。from typing import Dict, Any, List def estimate_tokens(text: str) - int: # 粗略估算中文约 1 字 1 token英文按 4 字符 1 token return len(text) def split_document_with_coords( md_content: str, content_list: List[Dict], chunk_size: int 800, overlap: int 80, ) - List[Dict[str, Any]]: 切分 Markdown 并把坐标绑定到对应片段 paragraphs [p for p in md_content.split(\n\n) if p.strip()] chunks, positions [], [] current, start , 0 for para in paragraphs: if estimate_tokens(current) estimate_tokens(para) chunk_size and current: chunks.append(current) positions.append(start) # 保留重叠避免条款被切断 current current[-overlap:] \n\n para start md_content.find(para) else: current current \n\n para if current else para if current: chunks.append(current) positions.append(start) result [] for i, chunk_text in enumerate(chunks): bbox_list [] chunk_start positions[i] chunk_end chunk_start len(chunk_text) for item in content_list: if item.get(type) ! text: continue text (item.get(text) or ).strip() if not text: continue pos md_content.find(text) if pos -1: continue if chunk_start pos chunk_end: bbox_list.append({ bbox: item.get(bbox), page_idx: item.get(page_idx), text: text, }) result.append({ content: chunk_text, token_count: estimate_tokens(chunk_text), bbox_list: bbox_list, }) return result关键点是bbox_list里保留了每个原始文本块的text。审核模型返回问题原文后用if issue_original in bbox_item[text]就能反查到坐标和页码实现“问题 → 原文 → 页面位置”的闭环。4.3 构建审核 Agent结构化输出审核链用 LangChain 的ChatPromptTemplate加结构化输出。模型从 TaoToken 接入Key 走环境变量。import os from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from pydantic import BaseModel, Field class Issue(BaseModel): rule_category: str Field(description规则分类如法律术语规范性) issue_type: str Field(description问题类型) description: str Field(description问题描述) original: str Field(description有问题的原文) suggestion: str Field(description修改建议) severity: str Field(descriptionhigh/medium/low) legal_risk: str Field(default, description法律风险说明) class AuditResult(BaseModel): has_issues: bool overall_risk_level: str issues: List[Issue] corrected_text: str summary: str llm ChatOpenAI( modelcfg[llm][model], base_urlcfg[llm][base_url], api_keyos.environ[cfg[llm][api_key_env]], temperaturecfg[llm][temperature], ) structured_llm llm.with_structured_output(AuditResult) SYSTEM_PROMPT 你是法务合同审核专家。请严格按审核规则逐条审查 对每个问题精确引用原文说明违反的规则评估严重程度 并给出修改建议。OCR 识别导致的符号错误单独归类为 OCR 识别问题。 USER_PROMPT 【审核规则】 {rules} 【待审核文本】 {text} 请输出结构化审核结果。 audit_prompt ChatPromptTemplate.from_messages([ (system, SYSTEM_PROMPT), (user, USER_PROMPT), ]) audit_chain audit_prompt | structured_llmwith_structured_output让模型直接返回AuditResult对象省掉手动解析 JSON 的麻烦。temperature0.1是为了让审核结论稳定法务场景不需要“创意”。4.4 执行审核并回指原文坐标def audit_chunk(chunk: Dict, rules: str) - AuditResult: return audit_chain.invoke({rules: rules, text: chunk[content]}) def locate_issue(issue: Issue, chunk: Dict) - Dict: 把问题原文映射回 PDF 坐标 for item in chunk[bbox_list]: if issue.original and issue.original in item[text]: return { page: item[page_idx], bbox: item[bbox], matched_text: item[text], } return {page: None, bbox: None, matched_text: None} # 主流程 json_path parse_pdf_with_ocr(./contracts/sample.pdf) data json.loads(Path(json_path).read_text(encodingutf-8)) md data.get(md_content, ) content_list data.get(content_list, []) chunks split_document_with_coords(md, content_list, chunk_size800) rules json.dumps(json.loads(Path(settings.json).read_text())[audit_rules], ensure_asciiFalse) for chunk in chunks: result audit_chunk(chunk, rules) if result.has_issues: for issue in result.issues: loc locate_issue(issue, chunk) print(f[{issue.severity}] {issue.issue_type} - 第 {loc[page]} 页 {loc[bbox]})跑通后你会看到类似输出[high] 金额数字不规范 - 第 3 页 [170, 200, 847, 255]。这个坐标可以直接喂给 PDF 批注库做高亮。5. 验证请求与成功结果配置和代码都齐了怎么确认真的跑通了我一般分三步验证。第一步单独验证 OCR 解析。用一份 2 到 3 页的合同跑parse_pdf_with_ocr打开生成的 JSON确认content_list里每项都有bbox和page_idx。如果bbox全是 null说明 OCR 服务没开return_content_list回去检查config.toml。第二步验证切分后的坐标绑定。打印第一个 chunk 的bbox_list长度应该大于 0。再随便挑一个bbox_list里的text用md_content.find(text)确认能在原文里找到。这一步能提前发现“切分后坐标错位”的问题。第三步验证审核链。用一段故意有问题的合同片段比如金额只写小写、日期留空调用audit_chain.invoke确认返回的AuditResult里has_issuesTrue且issues里能定位到原文。下面是我实测的一段输出是否发现问题: True 整体风险等级: high 问题总数: 6 高风险问题: 1. [金额与数字准确性] 金额未大小写并用 原文: 计 元 建议: 补充为计人民币【大写】元整¥【小写】 坐标: 第 2 页 [170, 200, 847, 255] 2. [必备条款完整性] 日期字段为空 原文: 年 月 日 建议: 统一格式为____年__月__日 坐标: 第 2 页 [199, 164, 373, 181]看到坐标能对上页码就说明 OCR RAG Agent 这条链路通了。如果你在验证模型抽取效果时想快速对比不同模型可以在模型对话页面直接贴合同片段试https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite。6. 本篇常见错排查跑这条链路时报错基本集中在几个地方我按出现频率排一下。OCR 返回没有 bbox最常见。检查请求参数里return_content_list是否为true字符串不是布尔。有些 OCR 服务默认只返回 Markdown必须显式开启。切分后 bbox_list 为空多半是md_content.find(text)返回 -1。原因是 OCR 返回的文本和 Markdown 里的文本有细微差异比如多余空格、全半角。解决办法是在匹配前做一次规范化去掉首尾空格、统一全半角再匹配。结构化输出报 schema 不匹配with_structured_output对模型能力有要求。如果模型不支持 function calling会退化成文本输出导致解析失败。换一个支持结构化输出的模型或者在 prompt 里明确要求返回 JSON 并手动解析。审核结果全是误报检查settings.json里的规则分级。把 OCR 识别错误单独归类别让它混进法律风险。另外temperature别设太高0.1 左右比较稳。Key 读取失败确认环境变量名和config.toml里的api_key_env一致。Linux/macOS 用export TAOTOKEN_API_KEYxxxWindows 用set。别把 Key 写进配置文件。长合同超时OCR 解析 20 页以上文档可能超过 600 秒。把timeout调大或者分批解析。审核阶段按 chunk 逐个调用不要一次性把整份合同塞进去。7. 下一步把审核链接到你的工作流到这里一个能跑通“OCR 解析 → 坐标切分 → Agent 审核 → 原文定位”的法务合同审核原型就搭好了。你可以先把config.toml里的 OCR 地址换成自己的服务用一份真实合同跑一遍看看风险点定位准不准。如果你打算长期跑这条链路比如每天审几十份合同建议把模型调用收敛到 Coding Plan 上避免每次手动换 Keyhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite。接入文档在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有 LangChain 和 OpenAI SDK 的对接示例照着改 base_url 就行。最后提醒一句法务数据敏感OCR 尽量本地跑送去审核模型的文本先脱敏。Agent 给的是辅助判断最终签字还得人来。