法律 AI 落地:审合同、答法条,先看它敢不敢说“没找到依据”

0 阅读17分钟

律师把一份合同拖进浏览器,几分钟后拿到一张按高/中/低排好的风险清单。这个演示,今天任何一个会调大模型 API 的团队都能做。

难的是点开之后。那条「违约金过高」的依据,是不是真的来自《民法典》第五百八十五条?合同里没写争议解决条款,系统是提示缺失,还是编一段看起来很专业的建议?法规修订之后,旧版本还会不会被召回,当成现行法引用?

我们把这套东西做成了在线系统(legal.case.chyan.tech)。两条链路:法律问答、合同审查。下面按工程顺序拆。重点不在怎么调模型,在那些演示时看不出来、上线后天天找你的事。

封面-首页

先把不该做的砍掉

需求里有两个硬约束:开发机没有 GPU,生产环境按 5 Mbps 的 ECS 算;还要支持私有化和离线交付。约束一摆,架构就没法往大了画。

最后是 FastAPI 单体 + React SPA + PostgreSQL/pgvector + Nginx,Docker Compose 四个服务。没有 Kong,没有 Elasticsearch,没有 Milvus,没有 K3s,也没有 BPM 引擎。审查流程是线性状态机,状态字段加事件表就够了,上 Camunda 是给自己找运维。

Redis + RQ 和 MinIO 更典型。架构图画了,compose 配了,代码一行没用——长任务同步执行,上传件解析完不落对象存储。2026-10-12 把这两个组件删了。没有代码引用的组件,留着就是负债。

数据量也不大,撑得起这个选择:法规 2,000+ 部、7 万+ 个向量块;案例 1,000+ 篇、5,000+ 块;外部案例 3 万+ 篇、4 万+ 块;咨询答复 4 万+ 块。全库 16 万+ 块,没有缺向量的块。这个量级,pgvector 的 cosine 距离够用,同库事务还省掉一次跨组件一致性。

现在的架构,一张图

图上只画软件组件,网络入口和端口不在图里展开。

legal-ai 软件架构:FastAPI 单体、pgvector、模型服务与离线数据管线

线上是三个业务容器:nginx、api、postgres。没有 worker,没有队列,也没有对象存储。合同解析和审查是同步长请求,问答走 SSE 流式;nginx 的读超时给到 300 秒,proxy_buffering off,不然 token 会被缓冲成一段一段。

请求进入本机后,nginx 托管 frontend/dist 的 SPA,把 /api/ 反代给 FastAPI。再往里是 PostgreSQL 16 + pgvector,业务表、知识库文档、向量块都在同一个库里。

FastAPI 是单体,但按域分了模块:

  • 认证与权限:JWT + RBAC,登录态和角色每请求查库;
  • 问答:查询改写、混合检索、引用校验、SSE 流式;
  • 合同:上传解析、规则初筛、LLM 五维、采纳/忽略、导出;
  • 规则库:草稿/发布版本化,所内标准和个人意见并存;
  • 审计与质检:操作留痕,问答/审查结果异步抽检。

模型服务在进程外:LLM 和 Embedding 走 OpenAI 兼容网关,Reranker 走 SiliconFlow。三者的 base URL、key、模型名都在 .env,私有化交付时把 LLM/Embedding 指到客户内网的兼容端点,代码不用动。Reranker 没配 key 或调用失败时,检索降级为纯向量序,不阻断主链路。

离线数据管线不在请求链路里:采集器把法规、案例、指导案例落到 data/ 下的原始 JSON,入库管线负责切块、向量化、写库;旧版 .doc 走本地 Tika,扫描件 PDF 目前不进主链路。这条线可以手动跑,也可以挂 cron,和线上 API 完全解耦。

架构上没有神秘组件。真正花时间的,是下面五关。

第一关:数据源不是 API,是对抗

法律 AI 的第一个幻觉,是以为语料能像 npm 包一样下载。

国家法律法规数据库是免费公开的,但 detail 和 download 接口有基于 TLS 指纹的 WAF。裸 requests 跑几十次,返回的就是一页「Please enable JavaScript」;curl_cffi 模拟 Chrome 指纹,前期能过,持续请求照样被拦;把 clearance cookie 注入进去也没用。最后稳定的方案是 Playwright:浏览器先过挑战,再用同一个 context 请求直链。采集器里还留了一条静默规则——连续失败就歇十几分钟,四连败直接退出,因为密集重试只会把 IP 惩罚拉长。说白了,采集器得写得像半个浏览器。

过了 WAF 还有格式问题。老法规请求 format=docx,服务端返回的是 OLE2 的旧版 .doc,zip 解不开。这类文件有几十部,走本地 Tika(PUT /tika,Accept: text/plain)转文本;Tika 不可用再回退 PDF。PDF 里还有扫描件,那一段只能认栽。

源本身也有缺口。flk 只收法律、行政法规和司法解释,不收部门规章,也不收两高两部、两高三部联合发的「意见」。这些恰恰是实务里天天引用的东西。我们手动补了二十多件:醉驾、认罪认罚、排非、量刑指导意见、电诈、网络犯罪、非法放贷……每件都从官方全文清洗,记录真实 source_url,已废止或已修改的状态如实标注,不装作还在生效。

案例库更麻烦。人民法院案例库不提供匿名访问,登录走最高法统一身份认证,Cookie 几个小时就过期,过期后接口返回 401,采集器直接停,断点在本地,换了 Cookie 接着跑。服务端还有配额,实测一百来篇就被切断,返回 500「获取失败」。我们的处理是软冷却:连续几次拿不到正文,就歇十几分钟再试;几轮冷却还没成功,才判定是真封或登录失效,停止采集。

这里栽过一次:风控失败响应被当成正文写进了 raw 目录,留下上千个空壳。更糟的是断点续采的判据是「文件存在即跳过」,这些空壳会把后续重试全部挡掉。修复方式很朴素:没有正文就不落盘。这个坑不高级,但特别费时间。

最高法指导性案例是另一套打法。官网列表只覆盖最近一批,早年的案例得靠站内搜索定位,再早的走 /fabu 旧通道;公报站有 WAF,用浏览器过;最早的几十件从维基文库补;个别搜不到的,最后在洛阳中院的镜像里找到。几个通道拼起来,才凑齐两百多件。

法律-问答-附引用源

第二关:RAG 不是把法条塞进向量库

调检索的时候才发现,「向量相似度」和「法律检索」是两件事。

用户问「公司拖欠工资怎么办」,法条写的是「不得克扣或者无故拖欠劳动者的工资」。字面差得远,向量也可能召不回。我们加了一条关键词召回:中文 2-gram,不引分词依赖;标题命中权重 3,正文命中 1;同一部法律最多取 2 条,否则条文多的法会把名额全占了。抽关键词时还要均匀采样,不能只取开头——长问题末尾的「发票」往往才是关键。

检索前还有一次 LLM 改写,temperature=0。它负责补全省略的指代,把口语换成法条用词。改写失败就退回原问题,不能让检索挂掉,也不能让同一个问题两次命中完全不同的法条。

然后是粗召 60 条、重排取 10 条。重排模型是 bge-reranker-v2-m3。结果发现,案例是完整叙述,风格更像用户的提问,重排经常把法条挤出前十。法律问答要的是依据,三个相似案例帮不上忙。于是加了一条法条保底:最终信源里至少 3 条法规。

保底也不是随便加。追加到末尾不行,因为后面还有 [:k] 截断,补位的法条会被切掉,等于白补。正确做法是从结果末尾挑相关度最低的非法规条目,原位替换。

还有一类东西更麻烦:咨询答复。我们入了一套外部法律问答数据集,几万条,抽样下来相当一部分答非所问,里面还有选项菜单式的回复。它一度霸占信源前三。现在的处理是整体禁用:不进粗召、不进重排、不出现在引用来源里。少一个来源没关系,别让咨询答复混进法条里。

几个不起眼、但会直接毁掉引用体验的过滤:

现象原因处理
引用位被目录占掉《民法典》第一条前的「标题+沿革+目录」整块被召回无条号且含「目录」的块不进候选
同一条文出现两次采集重跑产生整份重复文档去重管线 + 运行时按内容兜底
旧法被当现行法修订前旧文本还在库里已废止/已修改/尚未生效一律不召回
长条文尾部丢失fastembed 512 token 静默截断换 32k 上下文的 qwen3-embedding:0.6b

最后一条展开说。最早用本地 fastembed 的 bge-small-zh,512 token 上限,大约 500 个中文字,超出部分静默截断。我们的切块上限是 1200 字,也就是说每条长条文的后半段根本没进向量。后来换成 qwen3-embedding:0.6b,1024 维、32k 上下文,全量重嵌。换模型没那么简单:向量维度变了,pgvector 列要迁移,历史块要全部重算,否则新旧向量根本不在一个空间里。

时效过滤也踩过坑。一开始只排「尚未生效」,结果 1992 年版《海商法》还在被当成现行依据召回。后来把「已废止」「已修改」一起排掉,新旧版本以新为准:旧版几百块不再出现,修订后的现行版正常命中。库里的历史版本没删,按状态还能查到,只是不再进问答引用。

第三关:防幻觉是一条校验链

Prompt 里写「不要编造法条号」,是必要的,但不够。模型会一边标着 [1][2],一边给出信源里没有的结论;也会在流式输出里把 [1] 和 [99] 拆到两个分片,前端拼出来就是一个点不开的引用。

我们的做法是把「引用」变成硬约束。

回答必须逐句标 [n],n 对应当轮检索结果的位次。输出后统一校验:出现任何越界编号,整段判失败,而不是把编号删掉就放行。因为正文留着「[99]」而信源只有 10 条,用户点不到,等于凭空断言。失败后重试,最多两次;仍失败就降级为信源原文列表,并明确告诉用户「未找到可支撑结论的依据,以下是相关原文」。

nums = {int(m) for m in re.findall(r"\[(\d+)\]", answer)}
if any(n < 1 or n > len(hits) for n in nums):
    return None  # 整段判失败,不是过滤放行

流式更麻烦。打字机是一个 delta 一个 delta 往下发,校验不能等全文结束。我们在下发前做两件事:剔除已经完整的越界编号;把末尾未闭合的 [1 先挂起,等下一个分片到了再判。否则 [1 + ][99] 拼起来,就是一个完整的悬空引用。

还有个容易漏的判定:有合法引用,不等于结论有依据。模型可能逐条标着引用,最后来一句「就所问问题,未找到相关依据」。这种情况如果只看「有没有 [n]」,就会把无依据的回答标成已核查。我们改成按句判定:前两句就自述无依据,或者无依据句超过总句数一半,判 grounded=false;只是末尾对某个没问到的子问题补一句「未找到依据」,不拉低整篇。

引用编号也藏着一个坑。返回给前端的 citations 只包含「被正文引用到的条目」,而正文的 [n] 对应全部检索结果的位次。比如正文引了 [1] 和 [8],citations 只有两条,前端要是按列表下标重新编号,就会变成 [1][2],和正文对不上。我们的做法是给每条 citation 带上原始位次 n,前端按 n 渲染。

同样的坑在自动质检 Agent 上又出现了一次。质检要判断「回答有没有编造引用」,最初按 citations 的列表下标重编号,结果把合法的跳号引用判成「编造不存在的编号」。另一个假阳性是只给判定模型两百字的 snippet,而量刑建议、法条尾款常在两百字之外,模型看不到就断言「信源中没有该内容」。两个问题都修了:按原始 n 判定,信源给全文。

合同审查这边至少守住一条:LLM 调用失败时,规则初筛的结果还在,不会整单变成空的;但来源字段会区分「规则初筛」和「AI 分析」,不把前者冒充成后者。降级可以,但别悄悄降级。

第四关:合同审查不是一次大 prompt

把整份合同丢给模型,让它输出风险清单,Demo 效果通常不差。上线后问题会集中出现:条款定位对不上、同一风险重复报、合同类型不同却用同一套标准、模型这次输出 JSON、下次输出一段 Markdown。

现在拆成两段。

第一段是规则初筛,负责「确定性」。按合同类型取审查清单,判断必备条款是否缺失、某个维度是否有覆盖。比如合同全文找不到「争议」「仲裁」「管辖」「诉讼」中的任何一个,就提示争议解决条款缺失。这类判断不需要模型,也不会今天一个样明天一个样。

第二段才是 LLM 五维分析:违约责任对等性、知识产权归属、争议解决合理性、缺失条款提示、术语一致性。按条款分块送进去,强制 JSON 输出,每条带 clause_id、dimension、level、reason、suggestion。

JSON 不能信。不同 provider 会用 findings、risks、results 各种键包一层;dimension 可能冒出五维之外的值;level 可能写「严重」;clause_id 可能指向不存在的条款。我们的解析器逐项校验:dimension 不在五维内丢掉,level 非法回落「中」,clause_id 必须是有效 seq,reason 为空也丢掉。脏数据宁可不要,不能让它进风险清单。模型不是不听话,是每次听话的方式不一样。

合同类型必须由上传方显式指定,不传就是未分类。类型决定吃哪套规则清单,后端不能猜——猜错一次,整份审查的标准都错了。

规则清单本身要版本化。一条 rule_set 记录就是一个版本,草稿可以改,publish 才固化 vN,旧的 active 变 superseded。审查只吃 active,并把 {public_id}:{version} 固化进这次审查会话。这样三个月后回头看,能知道当时是按哪版标准审的。所内统一标准和个人意见并存,finding 上标 rule_scope,不互相覆盖。

最后是导出。不管律师确认到哪一步,导出件都带免责声明、「AI 初稿 / 待复核」和规则版本,系统不把 AI 结果伪装成定稿。这属于底线问题:系统给的是工作底稿,不是法律意见。

第五关:上线后才开始踩的坑

代码写完只是开始,下面这些是上线后真实花时间的地方。

镜像不重建,线上跑旧代码。 api 是构建进镜像的,不是挂源码。改完 rag.py 只重启容器没用,必须 docker compose build api && docker compose up -d api。有次线上检索行为没变,排查半天,最后发现容器里还是旧代码。

数据库恢复把序列倒回去了。 演示库每晚从快照 partial restore,pg_restore --data-only 会把 dump 里的 SEQUENCE SET 一起回放,knowledge_doc_id_seq 落后于 max(id)。第二天增量入库,第一条就主键冲突。现在启动和入库前都会跑一次序列对齐,GREATEST(max(id), last_value),幂等。

前端构建产物权限不对,nginx 直接 403。 dist 是构建机生成的,容器里的 nginx 读不了。现在 npm run build 末尾带 chmod -R a+rX dist,不再靠手工补权限。

弱密钥拒绝启动。 JWT_SECRET 和数据库口令必须是强值,命中公开弱串就直接抛错。站点已经公网部署,弱密钥等于任何人可伪造登录态。这个选择会让第一次部署多一步,但比带病上线强。

对外 ID 和越权。 站内文档、会话都用 6 位小写 base36 的 public_id;别人的会话一律返回 404,不泄露存在性。public_id 只是纵深防御,真正解决问题的是会话、合同这类私有资源的每个查询都带属主校验。

流式会话的断线语义。 用户消息先落库,再生成回答。刷新页面、关标签页,后台继续生成并落库,回来能看到完整回答;点「中断」才停,而且不落库。多个会话可以同时生成,切换会话不会打断上一个。SSE 用 POST + fetch 读流,不是 EventSource,因为要带鉴权头和请求体。

5 Mbps 的 ECS。 合同文件是 MB 级,单用户上传没问题,并发上传要限。TLS 在外层 nginx 终结,服务之间走明文 HTTP;api 和 postgres 只绑 127.0.0.1,对外只经 nginx。这些谈不上性能优化,目的只有一个:别把数据库直接送到公网。这些事,没有一件写在架构文档里。

给同样在做法律 AI 的团队

八条,按我踩坑的顺序:

  1. 语料来源能不能稳定复采?Cookie、配额、WAF 各留一条退路。
  2. 法规有没有时效状态?旧版会不会被召回?
  3. 检索有没有关键词补位?纯向量在中文法律词表上不够。
  4. 重排之后法条还在不在?不在就加法条保底。
  5. 引用编号有没有校验?越界的 [n] 必须整段拦截。
  6. 流式输出会不会拼出悬空引用?
  7. 合同审查有没有规则层?全交给 LLM,结果不可复现。
  8. 导出有没有免责声明和复核状态?

这套系统能保证什么,不能保证什么

客户案例页记录了一家 30 人律所的落地过程:18 名执业律师,年合同处理量 500 多份,审查时间从 1-2 小时降到 40-60 分钟,初级律师日均从 4-5 份到 8-10 份,风险遗漏率从约 15% 到约 5%。

这些数来自客户案例页,演示系统本身没做效果实测。系统能保证的事更具体:

  • 每条风险能定位到具体条款,带字符区间;
  • 每条引用能回到法规或案例原文,带来源和时效状态;
  • 规则版本固化,审查结果可复现;
  • 导出件必带免责声明和待复核状态;
  • 合同数据可以不出所:LLM 端点可切本地兼容服务,整套 Docker Compose 离线交付。

不能保证的也直说:AI 不给法律意见,不替代律师判断;不处理涉密案件;不做冲突检索和利益判断。OCR 也没做,扫描件这一期不进主链路。这些边界最好写进产品流程,光写免责声明没用。

在线演示与呈言科技

在线演示:legal.case.chyan.tech

客户案例的完整背景、实施时间线、效果指标和 FAQ 在:某中型律所合同审查效率提升落地实录

更多行业方案(制造、电商、教育、知识管理、财务流程)在:呈言科技行业方案

这套系统由上海呈言科技开发。公司主要服务 20-200 人的中小企业,做软件定制、AI 应用落地和技术咨询;支持私有化/本地化部署,可签 NDA,客户数据不用于训练通用模型。如果你的合同审查或知识库问答也卡在「Demo 能跑、上线不敢用」,可以先看案例页,再聊具体场景。

参考来源

  1. 国家法律法规数据库
  2. 人民法院案例库
  3. pgvector
  4. qwen3-embedding