两个月前,我们团队接到一个律所的需求:在内部系统里集成一个法律AI助手,能回答法律咨询、生成起诉书、审查合同。律所的要求很明确——回答必须准确,数据不能出域,响应要快,而且不能把API Key硬编码到代码里。我们最初想直接调通用大模型,比如GPT-4或DeepSeek,但试了几轮就发现问题:通用模型对法条引用不精确,容易胡编法条编号,而且数据隐私问题没法绕过。随后我们调研了通义法睿(farui-plus)和LegalOne等法律专用模型,才找到方向。这篇文章就复盘整个落地过程,把选型、API接入、流式处理、重试、RAG优化、本地部署、可观测性这几个关键环节的工程决策和坑点写清楚。

选型:专用模型还是通用模型?我们踩过两次坑

第一次踩坑是直接调DeepSeek V4。回答看起来很流畅,但仔细核对发现它把《合同法》和《民法典》的条款混在一起,甚至自行编造了一个“第XX条”。对于律所来说,这种幻觉是不能接受的。第二次踩坑是尝试用RAG套通用模型,虽然检索了真实法条,但通用模型在生成时仍然会偏离检索结果,凭空“补充”内容。最终我们锁定两个选项:通义法睿(阿里云百炼)和LegalOne(基于Qwen3微调的法律模型)。通义法睿是千问基座经法律数据专门训练,API成熟,开箱即用,但数据要经过云端。LegalOne可以本地部署在昇腾910B或NVIDIA GPU,数据不出域,但需要自己搭推理服务。选型判断依据很简单:如果客户允许数据上云且要求快速上线,选通义法睿;如果客户是政府、律所对内使用,必须本地部署,选LegalOne + vLLM。我们最后因为客户数据敏感,选择了LegalOne-8B + vLLM 本地部署方案。

API接入第一关:流式输出与SSE协议

不论是用通义法睿的HTTP接口还是本地部署的vLLM,法律场景下用户对首字延迟(TTFT)非常敏感。律师提问后超过3秒没反应就会质疑系统。因此必须用流式输出。通义法睿支持SSE(Server-Sent Events),请求时设置 X-DashScope-SSE: enable,响应以 text/event-stream 格式返回。vLLM 部署的模型也原生支持流式,通过 stream=True 参数即可。这里有个工程陷阱:默认的HTTP客户端(如 requests)读取SSE流时,如果不设置 stream=True 并逐行迭代,会一直等到全部token生成完才返回,跟非流式没区别。必须用 requests.get(..., stream=True) 或异步库 aiohttp 配合 async for 逐块消费。我们在第一版上线时就踩了这个坑:生产环境QPS一高,同步读取导致前端长时间白屏,用户直接投诉。后来改成异步流式,TTFT从平均8秒降到1.5秒。

SSE流式代码示例:Python异步调用通义法睿

下面是一个经过生产验证的异步流式调用通义法睿(farui-plus)的代码片段,包含基本的错误处理。注意API Key从环境变量读取,不要在代码里硬编码。

import os, json, aiohttp, asyncio

DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY")
if not DASHSCOPE_API_KEY:
    raise ValueError("环境变量 DASHSCOPE_API_KEY 未设置")

async def stream_legal_chat(messages, model="farui-plus"):
    url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
    headers = {
        "Authorization": f"Bearer {DASHSCOPE_API_KEY}",
        "Content-Type": "application/json",
        "X-DashScope-SSE": "enable"
    }
    payload = {
        "model": model,
        "input": {"messages": messages},
        "parameters": {
            "result_format": "message",
            "max_tokens": 2048,
            "temperature": 0.3
        }
    }
    async with aiohttp.ClientSession() as session:
        async with session.post(url, json=payload, headers=headers) as resp:
            if resp.status != 200:
                error_body = await resp.text()
                raise RuntimeError(f"API错误 {resp.status}: {error_body}")
            # 逐行读取SSE流
            async for line in resp.content:
                line_str = line.decode("utf-8").strip()
                if line_str.startswith("data:"):
                    data_str = line_str[5:].strip()
                    if data_str == "[DONE]":
                        break
                    try:
                        chunk = json.loads(data_str)
                        # message格式下,choices[0].delta.content
                        delta = chunk.get("output", {}).get("choices", [{}])[0].get("delta", {}).get("content", "")
                        if delta:
                            yield delta
                    except json.JSONDecodeError:
                        continue

错误处理与重试:限流、超时、网络抖动,一个都不能少

法律场景的API调用不允许随意断裂。我们遇到过通义法睿返回 429 Too Many Requests 限流,也遇到过公网网络抖动导致连接重置。处理策略必须是“指数退避 + 随机抖动”。第一次失败等待1~2秒,第二次3~4秒,第三次7~8秒,最多重试3次。注意流式重试的边界:如果已经在流式输出过程中断开,不能简单重发整个请求,因为用户可能已经看到一部分内容。我们目前的方案是:前端记录已接收的token,重试时通过 messages 追加一条 assistant 消息包含之前已响应内容,让模型继续生成,但这可能引入重复。更稳妥的做法是在应用层做会话管理,断连后让用户手动点击“继续”或重新提问。另外,必须处理 InvalidApiKeyModelNotReady 错误。通义法睿的异常响应示例:{"code":"InvalidApiKey","message":"Invalid API-key provided."}。我们会在日志中记录 request_id 以便排查。

本地部署LegalOne:vLLM配置与内存管理

当必须本地部署时,我们选择LegalOne-8B + vLLM。LegalOne基于Qwen3微调,可以直接用transformers推理,但生产环境必须用vLLM提高吞吐。以下是我们生产用的启动脚本关键参数,注意内存管理是核心坑点:必须设置 gpu_memory_utilization=0.9 避免OOM,max_model_len 要根据显卡显存调整,否则启动直接崩溃。

from vllm import LLM, SamplingParams

llm = LLM(
    model="/path/to/LegalOne-8B",
    tensor_parallel_size=1,  # 单卡
    gpu_memory_utilization=0.9,
    max_model_len=8192,      # 8K上下文,显存12GB勉强够
    trust_remote_code=True,
    enable_prefix_caching=True  # 对重复法律条文前缀加速
)
params = SamplingParams(temperature=0.3, max_tokens=2048)
outputs = llm.generate(["请分析以下合同:..."], params)

vLLM的 max_model_len 直接影响显存占用。LegalOne-8B原生支持32K上下文,但如果我们用16GB显存的卡,只能设8K左右,否则 torch.cuda.OutOfMemoryError。我们被迫对超长合同做智能分块,按章节切割,每块加上上下文标头,分块后再检索。分块策略参考了开源项目legal_rag的做法:在段落边界分割,保留重叠2000字符以防丢失上下文。

RAG优化:法律文档必须做的六件事

单纯靠模型记忆法条肯定不行,必须结合RAG。但法律RAG有特殊性:法条编号、案号、术语规范。我们在legal_rag项目基础上做了6项优化:结构化分块(按章、节、条分割),上下文标头注入(每个分块开头加上法律名称和条号),HyDE(先生成假设法律条文再检索),术语规范化(将“欠款”“借条”统一为“借款合同”),犯罪知识图谱(识别罪名后直接注入构成要件),自我反思(模型先检索再判断是否充分,不够则重检索)。这些优化让回答准确性从62%提升到89%(内部评测)。

可观测性:你不能只在出问题时才知道模型答错了

线上跑了三天,律所反馈某类问题的回答正确率下降。我们立刻加了监控:每个请求记录 request_idmodelprompt_tokenscompletion_tokenslatencyerror_code。还实施了回答质量评估:使用ROUGE-L评估与参考答案的文本重叠度,用LLM-as-Judge(另一个模型)评估忠实度。当忠实度低于0.8时自动告警。另外,部署了实时CPU/内存监控,因为本地部署时模型推理会占满显存,如果并发过高会OOM killer。我们在vLLM启动时设置了 max_num_batched_tokensmax_num_seqs 限制并发,避免资源耗尽。

踩坑总结:合同审查场景下的三个血泪教训

第一个教训:SSE流式连接在前端Nginx反代时被缓冲。必须关闭Nginx的 proxy_buffering,否则流式数据会等到完整响应后才推给浏览器。第二个教训:通义法睿API的 max_tokens 如果不设置,默认可能输出很长,但我们遇到了 finish_reason: "length" 导致回答被截断,所以务必设置合理的最大值(比如法律文书生成设2048)。第三个教训:本地部署时模型加载时间长达3-5分钟,如果进程挂掉重启,用户会感受到长时间不可用。解决方案是用 systemd 管理进程,并添加健康检查接口 /health 返回模型就绪状态。

参考资料

  • 阿里云百炼 - 通义法睿API文档:https://help.aliyun.com/zh/model-studio/tongyi-farui-api
  • 阿里云百炼 - RunLegalAdviceConsultation接口:https://help.aliyun.com/zh/model-studio/api-farui-2024-06-28-runlegaladviceconsultation
  • GitHub - legal_rag项目:https://github.com/Teeeeen/legal_rag
  • GitHub - LegalOne项目:https://github.com/CSHaitao/LegalOne
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐