简介:一份DeepSeek法律智能助手对话系统构建方案文档,共554页,面向AI产品经理、法律科技研发人员及NLP算法工程师,系统讲解如何基于对话管理框架实现多轮法律咨询场景下的上下文理解与精准应答生成。内容从法律行业服务痛点、技术选型论证,到底层架构解析、实体识别与意图识别均有完整展开。资源包仅有1个PDF文件,大小14.38MB,支持目录章节跳转和阅读器书签大纲定位,文字、图表、目录显示清晰完整。全文共分50个大章节,前18章聚焦法律实体识别与意图识别两条主线,细致拆解专业词汇库构建、数据标注规范、训练数据准备、模型训练参数设置、微调、蒸馏及轻量化部署等关键环节,同时涵盖损失函数选择、优化器配置、模糊意图消歧与场景迁移等工程实践内容。已有108人学习下载,适合作为法律智能对话系统设计、法律领域NLP方案研发的技术参考,能够帮助读者系统梳理从语料采集到模型部署优化的全流程落地思路。
1. DeepSeek法律智能助手:为什么“多轮”才是法律咨询真正的门槛
法律咨询场景里,最难的从来不是第一次回答,而是第二轮以后。用户第一轮说“房东不退押金”,你给出法律意见很简单;他马上补一句“但合同里写了租期内退租押金不退”,系统如果还按上一轮结论来答,就是在给用户帮倒忙。一个能落地的DeepSeek法律智能助手,核心不是把提示词写漂亮,而是先用一套对话管理框架把“谁、什么事、诉求多少、有什么证据”盯住,再把每轮新增事实和法条检索结果喂给大模型,生成带依据的答复。这套方案适合有基础研发能力的团队,从零搭一个能在真实咨询中跑起来、能排查、能验收的最小系统。后面所有内容,都围绕这条主线展开。
2. 对话管理框架选型:先定骨架,再让DeepSeek发挥
2.1 法律咨询不是聊天,而是有目标的证据收集
很多团队拿到法律咨询需求后,习惯性地写一个大prompt把DeepSeek包起来,让模型“扮演律师”。单轮聊还可以,多轮一长就露馅:用户在第一轮说的是“合同没到期房东让我搬走”,第二、第三轮开始解释背景“房子是亲戚的”“房租是微信转的”,模型可能只记得最后一句话,把房屋租赁当成房屋买卖来答。原因很直接:大模型本身没有跨轮状态,每次调用都是一次“失忆重来”。
法律咨询的对话结构其实非常稳定:先采集案情事实,再识别争议焦点,再查法条,最后给方案和风险提示。这中间用户可能跳跃式补充信息,可能推翻自己上一轮的说法(“其实没签合同”“不是押金是定金”),也可能一口气问好几个问题。这种结构天然适合“状态机 + 槽位”的思路:把法律咨询要收集的关键信息抽象成槽位,用对话管理框架维护槽位和轮次,每轮先把用户的话理解成槽位更新,再决定下一步是该追问、该查法条还是该出结论。
“对话管理框架”这个词听起来很重,实际操作里可以轻量实现。常见做法是自建一个会话状态管理器:负责槽位追踪(slot tracking)和对话策略决策,语言理解与生成这两段交给DeepSeek。这个组合比纯规则状态机灵活,又比纯粹让大模型自由发挥可控,是法律咨询类项目里比较稳妥的起点。
2.2 三条路线对比:状态机、槽位框架与大模型编排
| 路线 | 核心机制 | 优点 | 短板 |
|---|---|---|---|
| 规则状态机 | 预定义节点与转移条件 | 完全可控、可审计、延迟低 | 流程一改就要改代码,用户稍微绕就掉出节点 |
| 槽位填充框架(Rasa/自建DST) | 定义槽位与填槽策略,NLU每轮提取槽位,DST维护状态,策略决定动作 | 每轮状态可读、可回滚,与LLM结合后理解能力强 | 需要设计槽位,复杂推理要靠外部模型 |
| 大模型智能体编排(LangGraph等) | 把检索、回答做成图节点,模型决定流程 | 灵活、能做多工具协同 | 行动路径难审计,会为了“完成任务”省略追问,且比自建状态机难排查 |
法律咨询不同于闲聊或写代码助手,它的输出有潜在后果,无法接受“模型觉得差不多就给结论”。我一般会选第二条路线:自定义槽位填充框架当骨架,DeepSeek当大脑。理由有两条。
一是可审计。每轮更新了哪个槽位、触发什么动作,代码里都有记录。用户问“押金是多少”和“合同还剩多久”,系统能准确得出“诉求金额”和“剩余租期”两个槽位的变化。二是可兜底。遇到没定义好的槽位类型,可以走“澄清追问”,而不是让模型随口回答。这个框架下,DeepSeek负责把自然语言转成槽位,再把槽位和法条生成自然语言答案,它不必去记“第几轮说了什么”,那部分交给状态管理器。
2.3 DeepSeek接入:API配置与最小调用
DeepSeek提供OpenAI兼容接口,用openai的SDK就能直接调起来。这个兼容性帮了大忙,不用给团队引入两套客户端。最小调用代码:
from openai import OpenAI client = OpenAI( api_key="sk-你的密钥", base_url="https://api.deepseek.com", ) def call_deepseek(messages, temperature=0.3, max_tokens=1024): resp = client.chat.completions.create( model="deepseek-chat", messages=messages, temperature=temperature, max_tokens=max_tokens, stream=False, ) return resp.choices[0].message.content几个参数值得先说清楚:
- base_url填API网关地址,DeepSeek兼容OpenAI协议,端口和路径不要自己拼;
- model默认用deepseek-chat,日常法律咨询响应速度更快;需要复杂法理分析时可以切换deepseek-reasoner,那个会先产出推理链,回答更严谨,但延迟明显增加;
- temperature建议压在0.3到0.5之间。法律咨询要稳定输出,温度太高会出现同一个问题两次回答措辞相差很大;
- max_tokens不要给太小的值,生成内容一旦包含“法律依据”和“风险提示”就很容易超过512,我一般给1024以上,超出窗口会自动截断。
这里先不展开具体提示词模板,先理解调用骨架即可。下一章会把调用代码和对话状态串起来,做成真正的多轮系统。
3. 多轮上下文理解落地:从“记住说过什么”到“知道该问什么”
3.1 法律咨询的槽位表:先定义系统要盯住的信息
把法律咨询中频繁出现的要素抽出来,做成一张槽位表:
| 槽位 | 含义 | 示例 | 备注 |
|---|---|---|---|
| consult_type | 咨询类型 | 房屋租赁/劳动/借贷/婚姻 | 决定后续法条范围 |
| identity | 用户在这个纠纷里的角色 | 租客、房东、员工 | 影响权利主张方向 |
| dispute_focus | 争议焦点 | 押金退还、合同解除、工资拖欠 | 可多个 |
| amount | 标的额/诉求金额 | 4500元 | 金额影响管辖与程序 |
| evidence | 已有证据 | 合同照片、转账记录 | 列表追加 |
| facts | 用户陆续陈述的关键事实 | 合同未到期;房东说要卖房 | 会被改口覆盖 |
| confirmed | 是否已确认核心诉求 | true/false | 决定是否进入生成阶段 |
这张表是后面所有逻辑的地基。槽位定义得越清楚,多轮上下文理解就越可控。比如用户说“押金他到现在没退”,系统能提取amount=押金、dispute_focus=押金退还;用户说“我提前30天跟房东说了”,系统只更新facts,不改变dispute_focus。用代码定义:
from dataclasses import dataclass, field @dataclass class LegalSessionState: session_id: str consult_type: str | None = None identity: str | None = None dispute_focus: list[str] = field(default_factory=list) amount: str | None = None evidence: list[str] = field(default_factory=list) facts: list[str] = field(default_factory=list) confirmed: bool = False def summary(self) -> str: return ( f"咨询类型:{self.consult_type or '未知'} 角色:{self.identity or '未知'} " f"争议焦点:{','.join(self.dispute_focus) or '未知'} " f"标的额:{self.amount or '未知'} 证据:{','.join(self.evidence) or '暂无'} " f"核心陈述:{';'.join(self.facts) or '暂无'} 已确认:{self.confirmed}" )这里用Python的dataclass直接落地。每个会话有一个session_id,外部请求带着同一个session_id进来,状态管理器从字典里取对应对象;不同用户不同session_id,天然隔离。summary()方法很关键,后面生成回复时直接把这个字符串塞进system提示,模型就能看到全局状态,不用每个人反复重读所有历史。
3.2 槽位更新策略:让DeepSeek做抽取,让规则做决策
槽位填充不是简单让模型跑一趟就完事。我通常拆成两步。
第一步,用DeepSeek做槽位抽取,只输出JSON,不生成回答:
import json SLOT_KEYS = ["consult_type", "identity", "dispute_focus", "amount", "evidence", "facts", "confirmed"] def extract_slots(client, user_text, recent_messages): messages = [ {"role": "system", "content": ( "你是会话状态抽取器。只输出JSON,不要解释。" "从用户本轮表述中抽取这些字段:" "consult_type、identity、dispute_focus、amount、evidence、facts、confirmed。" "没提到的字段给null;用户表示确认时confirmed给true;" "facts存放用户新陈述的事实,按原意改写为简短句子。" )}, *recent_messages[-4:], {"role": "user", "content": user_text} ] resp = client.chat.completions.create( model="deepseek-chat", messages=messages, response_format={"type": "json_object"}, temperature=0.1, max_tokens=512, ) return json.loads(resp.choices[0].message.content)这里几个点容易踩:
- response_format固定成JSON,否则模型可能夹带解释文字,json.loads直接抛异常;
- temperature打到0.1,抽取任务不允许发挥,稳定优先;
- recent_messages只带最近4条,避免整段历史把抽取逻辑带偏。抽取类任务靠的是当前表述和最近几轮上下文;
- evidence用列表结构,用户每轮提到新证据就追加,而不是覆盖;facts则相反,下面讲覆盖策略。
第二步,状态管理器拿到JSON做更新决策。这一步我坚持用规则,不交给模型:
def update_state(state: LegalSessionState, slots: dict) -> LegalSessionState: for k, v in slots.items(): if v is None or v == "null": continue if k == "dispute_focus": if isinstance(v, list): state.dispute_focus = list(set(state.dispute_focus + v)) elif v not in state.dispute_focus: state.dispute_focus.append(v) elif k == "evidence": if v not in state.evidence: state.evidence.append(v) elif k == "facts": state.facts.append(v) elif k == "confirmed" and v: state.confirmed = True else: setattr(state, k, v) return state多轮对话里有一个真实高频问题:用户推翻上一轮的说法。比如第一轮说“房东不退押金”,第二轮说“其实合同没签,押金是现金给的”。如果facts是全量追加,生成模型会同时看到“签了合同”和“没签合同”两种矛盾描述,答案没法看。我的做法是:facts也采用“改口覆盖”策略——每条事实保留来源轮次,每当新陈述与旧事实在语义上冲突时,用新值替换旧值。实际实现时最简单的方式是给每轮facts打一个轮次标签,更新规则命中同一个dispute_focus时覆盖旧的。这里的代码是简版,先按追加处理;更重的业务系统可以引入“事实版本号”。
3.3 每轮生成:状态摘要、法条检索与消息序列拼装
槽位更新完,把状态摘要、法条检索结果和用户最新问题拼成一个独立完整的生成请求,这一步是“多轮上下文理解”真正落地的位置。
def build_generation_messages(state_summary, user_text, law_hits): law_block = "\n".join( f"《{h['law_name']}》第{h['article_no']}条:{h['content']}" for h in law_hits ) system_prompt = ( "你是法律咨询助手的应答生成器。先阅读用户画像与案情摘要," "再基于给出的法条回答。\n" f"案情摘要:{state_summary}\n\n" f"可引用法条:\n{law_block}\n\n" "回答要求:\n" "1. 只引用上面列出的法条,不要凭记忆补充条文。\n" "2. 如果案情摘要缺少关键信息,先向用户追问,不要急着下结论。\n" "3. 输出分四段:事实梳理/法律依据/结论与建议/风险提示。" ) return [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_text} ]注意这里没有把全部对话历史塞进去,只放了两样东西:状态摘要和最新一条用户消息。这样的设计在工程上叫“带外状态”,让模型每一轮都看到完整状态,而不是靠历史去复盘。经验是:当历史超过6轮后,摘要比raw历史可靠,而且省token。长对话时甚至可以额外加一条由上一轮生成的“会话小结”,把更老的内容压缩成两三句话,避免上下文窗口直接被撑满。
4. 精准应答生成:引用法条、抑制幻觉、输出可操作的结论
4.1 “精准”在法律咨询里的三层含义
第一层是“不知道就说不可以”。用户问“我这种情况能拿多少赔偿”,如果案情缺关键数据,系统应该追问,而不是编一个数。第二层是“有依据才能下结论”。法条是唯一的信源,模型不该凭训练记忆背法条,背错一条后果比不答更恶劣。第三层是“结论要可执行”——回答以“你可以向法院起诉,需要准备以下材料……”结束,而不是“建议进一步咨询律师”。
这层要落地,RAG几乎绕不过去:把法条库做好索引与检索,每次生成前先取回相关条文,让DeepSeek只在限定的法条范围内作答。DeepSeek本身的文本生成能力再强,也扛不住把整部民法典塞进上下文去记忆,所以思路不是让模型更强,而是把法条检索从生成环节里独立出来,做成可控的证据来源。
4.2 法条切分:按“条”切,不按字符切
RAG领域最经典的坑就是“按固定长度切分”,放在法条上这是致命的。一条法律条文通常包含多款多项,按512个字符切分很容易把“第X条”后半段的某个款项切断,导致检索只召回半条。正确的做法是按条边界切分:
import re def split_into_articles(law_text: str) -> list[str]: pattern = r'(?=第[一二三四五六七八九十百零〇0-9]+条)' articles = re.split(pattern, law_text) return [ a.strip() for a in articles if re.match(r'^第[一二三四五六七八九十百零〇0-9]+条', a.strip()) ]逻辑说明:用零宽断言(?=...)匹配“第X条”的位置,在不消费字符的前提下切分,保留“第X条”字头;切出来的每一块就是一条相对完整的法条文本。这里有个关键点:法条里还会嵌套“第X款”“第X项”,这些不该作为顶层切分点,所以模式里只匹配“条”,不匹配“款”“项”。这是法律域RAG和通用文本RAG在切分策略上的重要区别。
切完后建立索引。中文向量模型可以选m3e-base或顺手的中文embedding模型,向量维度、精度差异不大时优先选响应快的:
from sentence_transformers import SentenceTransformer import faiss import numpy as np encoder = SentenceTransformer("moka-ai/m3e-base") def build_faiss_index(articles: list[dict]): texts = [a["article_text"] for a in articles] vecs = encoder.encode(texts, normalize_embeddings=True) index = faiss.IndexFlatIP(vecs.shape[1]) index.add(np.array(vecs)) return index def retrieve_laws(query, index, articles, top_k=3): qv = encoder.encode([query], normalize_embeddings=True) scores, ids = index.search(np.array(qv), top_k) return [articles[i] for i in ids[0]]逻辑说明:IndexFlatIP与normalize_embeddings=True配合,做的是余弦相似度检索。top_k=3是法条场景里比较稳的选择——太少不够覆盖争议焦点,太多会把不相关内容塞进上下文干扰生成。检索时query不要直接用用户原文,我建议用state.summary()拼上当前用户消息作为查询词,让槽位信息参与召回。
一个更稳的增强:先用BM25或Lucene做一遍关键词召回,再对召回结果做向量重排。法律条文里的“定金”“违约金”是强信号词,向量检索不一定把它们排到最前,关键词召回能兜底。如果法条库规模不大(几千条),直接用内存中的RankBM25即可,不用上ES。
4.3 生成端约束:让模型只在“可见法条”里作答
检索完的下一步,是把法条拼进system提示,并在提示里写明“不得引用可见法条之外的条文”。这是我做过之后最想强调的一个习惯。
def build_structured_answer_prompt(state_summary, user_text, law_hits): law_block = "\n".join( f"《{h['law_name']}》第{h['article_no']}条:{h['content']}" for h in law_hits ) system = ( "你是法律咨询助手的结构化回复生成器。" "输出JSON,不要包含JSON以外的文字。字段:" "opinion(string, 对用户问题的直接结论)、reasoning(string, 事实梳理与法律推理)、" "legal_basis(array of string, 引用法条的具体条文号)、" "action_advice(string, 可执行的下一步建议)、" "risk_tips(array of string, 风险提示)。" f"\n案情摘要:{state_summary}\n" f"可引用法条:\n{law_block}\n" "约束:只能引用可引用法条中的条文;信息不足时,opinion明确写‘需要补充以下材料’。" ) return [ {"role": "system", "content": system}, {"role": "user", "content": user_text} ]调用时temperature继续压在0.2左右,配上response_format={"type": "json_object"}锁定JSON输出。后端把JSON字段拆出来渲染成Web页面或企业微信的文本卡片,比让用户读一大段对话体体验好很多。
把完整链路组装起来:
def handle_turn(client, session_id, user_text, history, state_store): state = state_store.get(session_id) slots = extract_slots(client, user_text, history) state = update_state(state, slots) query = state.summary() + " " + user_text law_hits = retrieve_laws(query, index, law_articles, top_k=3) messages = build_structured_answer_prompt(state.summary(), user_text, law_hits) answer = call_deepseek(messages, temperature=0.2, max_tokens=1024) return answer, state这一版已经能跑通一个最小的多轮法律咨询系统。下一节把最常把人绊倒的几个问题列出来。
5. 避坑与排查:多轮法律对话系统的高频事故现场
5.1 用户第二轮改口,系统却还在用旧结论
现象:用户先问“房东不退押金怎么办”,系统回答“可以要求返还”;下一轮用户补充“其实没有签合同,押金是直接打微信的”,系统仍坚持上一轮依据“合同约定”的推理。
原因:状态管理器没有对facts做“最新覆盖”,旧事实“签了合同”和新事实“没签合同”同时存在于状态里,模型看到互相矛盾的输入,无法判断该信哪个。
解决:在update_state中加入冲突检测。简单做法是facts带轮次标签,当新fact与同一dispute_focus下的旧fact语义相似时,用新值顶掉旧值;高级做法是每轮让DeepSeek先做一次“用户是否推翻上一轮陈述”的二分类。我早期在这一步图省事,结果翻车翻得最难看。
5.2 回复“很有法律感”,但没有任何一条具体法条
现象:模型输出大段“根据相关法律规定,您有权主张……”但全文找不到一条真实法律条文,就是听起来专业的废话。
原因:system prompt没做硬约束,模型觉得“泛泛而谈”够安全;也有一部分原因是RAG没有命中,但没有触发“未命中”分支,模型只能用训练记忆里的法律知识硬编。
解决:一是生成提示里明确写“只允许引用已提供的法条,未命中时输出固定话术”;二是实现零命中分支:检索结果为空时不让大模型生成,而是回复“请补充合同签订日期、双方身份等材料,我才能匹配具体条款”,再引导用户补充信息。
5.3 对话超过10轮后,调用直接报context length exceeded
现象:用户聊了十几轮,每轮都拼全量历史,最终请求体超过模型的上下文窗口,API直接报错。
原因:消息序列把历史全都带上了,忽略了摘要压缩。法律咨询本来就是要聊很久才能把案情讲完整,越聊越长的场景天然容易踩中。
解决:限制历史窗口为最近4到6轮,更早的内容由上一轮的对话总结成一段“会话摘要”放进状态。状态摘要本身已经压缩了大量信息,这比盲目塞原文更有效。另外给输入设置token预算,超了就先把最老的轮次折叠进摘要。
5.4 检索命中了法条,但引用错位、答非所问
现象:问“押金不退”,检索结果给了租赁合同解除的条文,但生成的回答却引用了关于违约金的部分。
原因:第一,法条切分没有按“条”边界做,导致引用了残缺条文;第二,top_k里混入了与当前争议焦点无关的条目;第三,检索query用的是用户原文,没把槽位里的“纠纷焦点”参与进来。
解决:切分务必按条边界;检索query换成“案情摘要 + 最新问题”;top_k降到3;提示里写明某条法条与案情无关时标记为不引用。这样即使某次命中噪声,模型也更容易舍弃它。
5.5 本地一切正常,部署后多用户会话相互串场
现象:A用户的案情出现在B用户的回复里,而且开发环境只有一个人测试时基本复现不出来。
原因:用字典存状态时,键用了全局变量而不是session_id,或session_id没从请求头传进来,导致两个请求共享同一个“最近状态”。
解决:session_id必须由外部渠道显式传入,比如企业微信的userId、网页的会话token;状态库不要用进程内全局字典,建议放Redis,key设计为legal:session:{userId},value就是LegalSessionState的JSON序列化。上线前用两个模拟用户并发跑一轮,专门检查串场。
5.6 DeepSeek tool calls 报错,答到一半就断
现象:在引入法条检索工具后,API返回类似tool calls need immediate results的错误,整个对话流程中断。
原因:请求中声明了tools,模型返回的补全结果里带着tool_calls;OpenAI兼容协议要求调用方必须把工具执行结果作为role=tool的消息追加回去,再发起一次补全请求。很多人把tools声明当成给模型“参考”,没做后续闭环,协议就报错。
解决:收到tool_calls后立即执行函数,再把结果回填到消息序列里,发起第二轮请求。
def chat_with_law_search(client, messages): resp = client.chat.completions.create( model="deepseek-chat", messages=messages, tools=[law_search_tool_schema], temperature=0.2, ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content # 把带tool_calls的assistant消息追加回序列,这是协议要求的 messages.append({ "role": msg.role, "content": msg.content, "tool_calls": [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments, }, } for tc in msg.tool_calls ], }) # 立即执行工具调用,并把结果挂回对应的tool_call_id for call in msg.tool_calls: if call.function.name == "search_law": args = json.loads(call.function.arguments) result = retrieve_laws(args["query"], index, law_articles) messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) final = client.chat.completions.create(model="deepseek-chat", messages=messages) return final.choices[0].message.content逻辑说明:messages.append把带tool_calls的assistant消息加回序列,然后用tool_call_id把检索结果挂到对应调用下,这是兼容API的强制要求;第二轮请求通过后才给出最终文本。配套还有两点:tools的description要写清楚“根据案情查法条”,避免模型把“查法条”理解成“帮我写法条”;tool执行时间不能太长,必要时加超时,检索要做到单次200ms内返回。
6. 上线的最后一公里:评测集、回归测试与成本控制
6.1 二十条评测集:把系统钉死在及格线上
我会在项目里长期维护一个小型评测集,二十条左右,覆盖房屋租赁、劳动、借贷、婚姻、侵权五类高频咨询。每条包含一段多轮对话、期望的槽位结果和期望的回复动作。例子:
EVAL_CASES = [ { "id": "case_01", "dialog": [ ("u", "我租的房子还没到期,房东让我搬走"), ("a", "请问合同剩余租期还有多久?"), ("u", "还有三个月,房东说要把房子卖掉"), ], "expected_slots": { "consult_type": "房屋租赁", "dispute_focus": ["合同未到期解除"], }, "expected_action": "query_remaining_term_and_reason", }, ]跑测试时只看三个硬指标:槽位准确率(抽取的槽位与expected_slots一致)、法条引用合法率(引用是否来自RAG返回集)、拒答率(信息不足时应追问而不是硬给结论)。这三个指标能拦住绝大多数回归事故。
6.2 成本控制与最小调用
DeepSeek本身的定价已经很低,但法律咨询一次长对话会触发多次调用:抽取一次、生成一次、可能还有工具调用一次。我最后的优化是:抽取和生成合并成一次调用,用JSON结构把槽位和回复一起返回;只有信息确实不足时才走追问分支。这样成本再降一块,延迟也能压到体验可接受的范围内。
我自己第一次上线这个系统时就吃了没做回归集的亏:改了一句system prompt,以为只是调整措辞,结果第二天用户问“押金是退多少”,系统答“根据民法典第七百条”,而那条法条根本没在RAG结果里。从那以后,任何提示词改动都先跑一遍评测集再发版。现在这个习惯帮我拦下了不少蠢错误。评测集本身也会持续生长,每一条线上翻车的会话,修好后都会被补成一条新的用例。希望帮到你。
本文还有配套的精品资源,点击获取