摘要:本文介绍 MCP(Model Context Protocol)作为"外部能力标准化接入层"的核心概念(resources / prompts / tools)、与常见工具函数调用的对比、传输与工程注意点,并以「合同条款风险分析」为实战案例,说明如何在 LangGraph 中集成 MCP:planner 决定 Stage1 工具、Send + reducer 实现并行调用、固定顺序执行 Stage2、stdio 客户端一次连接内完成操作,以及 MCP 不可用时的本地 fallback。文末给出完整流程图、State 定义、各节点与五个 MCP tools 的代码说明及 verbose 运行示例。
关键词:MCP;Model Context Protocol;LangGraph;LLM;agent;tools;resources;合同条款分析;风险评估;标准化接口;stdio;fallback
源代码链接:LangGraph 14. 通过 MCP 把"外部能力"标准化接入 LLM 案例源代码
让 LLM 真正变成 agent,关键不在于它能不能多会说话,而在于它能不能"做事":读取当前数据、调用外部软件、执行具体操作。MCP(Model Context Protocol)解决的就是这件事:提供一种标准化接口,让 LLM 主机(或智能体运行时)以一致的方式接入外部上下文与能力。
想象一下:你把 LLM 当作一个插座上的设备,外部系统当作各种电器。过去你需要为每一种电器写一套"专用适配器"。MCP 的价值就在于把适配器做成统一标准:只要符合 MCP 协议,客户端就能以相同方式发现并调用它。
💡 理解要点:MCP 本质是一套客户端-服务端契约,把外部能力以统一格式暴露出来,再由 MCP client 消费。
1 MCP 到底是什么:resources / prompts / tools
MCP 在结构上区分了三类"可被暴露"的东西:
resources:静态数据或上下文片段(例如一段规则文本、某个文档内容、配置模板)prompts:模板化的交互方式(指导模型如何和某个资源/能力协作)tools:可执行的函数(例如"计算风险""抽取结构化要素""发送动作"等)
MCP server 负责把这三类能力"列出来、按需读取或调用";MCP client 则负责与 server 通信,完成发现(discovery)与调用(execution)。
🔍 实际例子:你让客户端先问一句"你有哪些 tools/resource?"(list_tools / list_resources),然后才决定要调用哪一个工具。
2 MCP vs 工具函数调用:谁更"通用"?
很多系统使用"工具函数调用"(tool function calling):LLM 直接被告知一个固定工具集合,并以一对一方式请求执行某个预定义函数。这种方式能快搭系统,但耦合度高:工具集合通常要跟特定模型/特定宿主代码写死。
MCP 更像一个标准化的"能力发现 + 能力接入层":客户端可以在运行时对 server 查询能力清单,再以统一协议完成调用,从而获得更强的互操作性与可组合性。
| 维度 | 工具函数调用(常见做法) | MCP |
|---|---|---|
| 标准化 | 常依赖供应商/宿主实现 | 开放标准、跨模型互操作 |
| 发现能力 | 需要在对话里显式提供工具列表 | 支持运行时 list_tools/list_resources |
| 复用性 | 工具往往强耦合到具体应用 | MCP server 可独立部署与复用 |
💡 理解要点:MCP 更像"面向 agent 的接口层",而不是某个单点能力的替代品。
3 为什么 MCP 不会自动让 API 变"可用"
协议只是"怎么接"。如果底层 API/数据本身就缺乏 agent-friendly 的结构化能力,模型仍会吃力。例如:
- 工单系统只能一次拿完整详情,agent 要做"高优先级筛选+批量摘要"就会很慢
- 文档只返回 PDF 文件,但 agent 读不懂 PDF 内容
换句话说:MCP 让你更容易把"外部世界"接进来,但你仍需要让暴露出去的数据/接口具备合适的粒度与格式。
💡 理解要点:MCP 是接入标准,不是数据格式的魔法。
4 传输层与工程注意点:stdio、错误处理、安全
MCP 还定义了通信的传输方式:
- 本地/进程内:常用 JSON-RPC over STDIO (
stdio) - 远程/web:常用 Streamable HTTP / SSE 等持久连接方式
此外,三件"工程不可跳过"的事:
- 安全:暴露 tools 与数据必须有认证/授权,否则就是把后门也暴露给客户端。
- 错误处理:server 不可用、tool 执行失败、参数不合法......这些错误需要以协议化方式回传,让 agent 能决定回退策略。
- 本地 vs 远程部署:本地更快、更适合敏感数据;远程更便于组织内共享能力。
🔍 实际例子:当 MCP 连接失败时,LangGraph 可以自动走本地 fallback(本地实现同名工具),确保系统仍产出结果。
5 实战:合同条款风险分析(本地 MCP Server + LangGraph)
在展开「如何在 LangGraph 里集成 MCP」之前,先明确本章节 demo 的目的、输入、输出,后面第 6 节的流程图与代码才容易对上号。
5.1 Demo 目的
本 demo 用「合同条款风险分析」这一具体场景,演示 MCP + LangGraph 的完整链路:
你给程序一段合同条款原文 和一句用户意图 ,程序通过 MCP 的 tools/resources 做摘要、抽取、风险打分和谈判建议,最后输出一份结构化的 Markdown 分析报告 。
目的是让你看到:输入是一段非结构化合同文本,输出是一份可交付的分析报告;中间的能力(摘要、抽取、评分、建议、渲染)由 MCP server 以 tools/resources 暴露,由 LangGraph 编排与容错。
5.2 输入是什么
- 用户意图(user_query) :一句自然语言,说明你希望程序做什么。例如默认的:
「请将下面合同条款摘要成要点,并给出风险等级与谈判修改建议(尽量输出结构化要素与改写方向)。」 - 合同条款文本(contract_text):一段合同原文(纯文本)。默认示例是一份简化的软件服务合同,包含甲方/乙方、合同期限、服务内容、违约责任、不可抗力、保密、解除与终止等条款。
二者由 main.py 传入图的初始状态;也可通过 --query 和 --contract-file 自定义。
5.3 输出是什么
程序的唯一对外输出 是一份 Markdown 格式的《合同条款分析报告》,包含:
- 要点摘要:对合同核心义务、违约与赔偿、解除与终止等的提炼。
- 当事人与关键要素(结构化):以 JSON 形式给出的当事人、期限、违约条款片段,以及若干风险相关信号(如是否出现责任上限/无上限、赔偿范围、程序性条款等)。
- 风险评估(演示):风险等级(低/中/高)、风险分数(0--100),以及基于文本信号的风险成因说明。
- 谈判建议:基于风险等级给出的条款优化方向与示例改写表述。
本 demo 要求 planner 必须使用 LLM (需在 .env 中配置 OPENAI_API_KEY 或 DASHSCOPE_API_KEY),否则运行时会报错退出。报告生成后,若配置了同一 API Key,会在基线内容之上再做一次 LLM 润色,使表述更贴近正式法务/合规简报;润色步骤可选。
5.4 输入输出示例
输入(节选)
用户意图:请将下面合同条款摘要成要点,并给出风险等级与谈判修改建议(尽量输出结构化要素与改写方向)。
合同条款(节选):
合同编号:2026-03-001
甲方:杭州星河科技有限公司
乙方:北京云端服务有限公司
...
第四条 违约责任
4.1 任一方违约的,违约方应向守约方支付合同总金额20%的违约金。
4.2 同时,违约方还应赔偿守约方因此遭受的全部损失,且不设责任上限。
...
第七条 解除与终止
7.1 乙方单方有权在甲方逾期支付超过10日后解除合同。
输出(结构概览,对应一次真实运行)
程序在控制台打印「========= 最终报告(Markdown) =========」后,输出一整份 Markdown 报告,例如:
- 一、要点摘要:核心义务与交付结构、违约与赔偿机制、解除与终止机制的归纳。
- 二、当事人与关键要素 :JSON 中包含
party_a/party_b、term、breach_clause_snippet、signals(如has_upper_bound_words、has_no_upper_bound_words等)。 - 三、风险评估:例如「综合风险等级:低风险」「量化风险分数:35 / 100」,以及风险成因分析表(强约束信号、赔偿范围信号、责任封顶信号、程序性降险信号等)。
- 四、谈判建议:如强化程序刚性、厘清责任边界与举证逻辑、设定责任上限并排除例外情形等,并配有示例条款表述。
报告末尾会注明为「MCP 演示版」,结论基于演示用启发式规则与文本信号,实际审阅需结合人工复核。
💡 理解要点:输入 = 用户意图 + 合同条款原文;输出 = 一份包含摘要、结构化要素、风险等级/分数、谈判建议的 Markdown 报告。 Demo 用 MCP 的 tools(摘要、抽取、评分、建议、渲染)和 resources(规则与模板)完成从「原始条款」到「可读报告」的管道。
5.5 流程中的五类能力(与 MCP 的对应)
本 demo 在「从输入到输出」的管道里会用到的五类能力,均由 MCP server 以 tools 暴露(并配有 resources 提供规则与模板):
- 要点摘要(
summarize_text) - 当事人/关键条款要素结构化抽取(
extract_entities) - 风险评分(
calc_risk_score) - 谈判建议与示例改写方向(
suggest_negotiation_clauses) - 基于模板渲染 Markdown(
render_report_markdown)
MCP server 还以 resources 提供「风险评分规则」和「报告输出模板」;LangGraph 负责规划要读哪些资源、调用哪些工具,并在 MCP 不可用时走本地 fallback。本地 server 与 fallback 逻辑都在同一工程里,便于你改 prompt、改模板或替换 tools。
5.6 如何运行
你可以在目录下创建虚拟环境、安装依赖并运行:
Windows:
shell
cd ./Agent/Agentic_Design_Patterns_Langgraph/14_Model Context Protocol/demo_codes
python3.11 -m venv venv_mcp
.\venv_mcp\Scripts\activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
python main.py
如需强制走本地容错路径(不连 MCP):
shell
python main.py --no-mcp
🔍 实际例子:当你把 MCP server 换成真实业务 server(如公司内部合同知识库、合规评分工具),LangGraph 图结构基本不变,只需替换 resources/tools 的来源与返回格式约定。
6 在 LangGraph 里集成 MCP:一个可复用的工程套路
有了上面「合同条款风险分析」案例的印象,本节回答三个关键问题:谁决定调用哪些 tools?并行还是固定顺序?调用 tools 的代码长什么样? 并给出每个 MCP tool 的代码与含义。
6.1 流程图
下面这张图概括本 demo 的编排;下文会逐段对应到代码。
mcp_ok
fallback
start
planner:决定 need_summary / need_entities,生成 stage1_tool_tasks
check_mcp:stdio 连接 + list_tools
fetch_resources:read_resource 读规则与模板
fallback_analyze:本地摘要/抽取/评分/建议
fan_out_stage1_tools:按 stage1_tool_tasks 生成多个 Send
mcp_execute_tool(并行,每分支调一个 tool)
calc_risk:固定调 calc_risk_score
suggest_clauses:固定调 suggest_negotiation_clauses
finalize:固定调 render_report_markdown + 可选 LLM 润色
end
6.2 State 定义
图的全局状态 MCPGraphState 在 mcp_graph.py 中定义如下。各节点通过读写不同字段协作;stage1_results 使用 operator.add 作为 reducer,以便并行分支的结果被合并成列表。
python
class Stage1ToolTask(TypedDict, total=False):
tool_name: str
arguments: dict[str, Any]
class MCPGraphState(TypedDict, total=False):
# 输入
user_query: str
contract_text: str
# 规划输出
resource_uris: list[str]
need_summary: bool
need_entities: bool
stage1_tool_tasks: list[Stage1ToolTask]
# MCP 能力发现/容错
mcp_ok: bool
mcp_error: str
force_fallback: bool
# resources 内容
policy_rubric: str
output_template: str
# stage1 并行工具输出(reducer 汇聚)
stage1_results: Annotated[list[dict[str, Any]], operator.add]
# stage2 顺序输出
summary_bullets: str
entities: dict[str, Any]
risk_result: dict[str, Any]
clause_suggestions: dict[str, Any]
# 最终输出
report_markdown: str
final_answer: str
error: str
# 给并行节点使用的动态输入
tool_name: str
tool_arguments: dict[str, Any]
- 输入 :
user_query、contract_text由调用方传入,planner 据此生成规划。 - 规划输出 :
resource_uris、need_summary、need_entities、stage1_tool_tasks由 planner 写入,供 fetch_resources 与 fan_out 使用。 - MCP 容错 :
mcp_ok、mcp_error、force_fallback决定是否走 MCP 路径或本地 fallback。 - resources :
policy_rubric、output_template由 fetch_resources 从 MCP 读取(或回退为本地常量)。 - stage1_results :并行节点
mcp_execute_tool各自返回[{"tool_name": ..., "output": ...}],通过operator.add合并为列表,供 calc_risk 读取。 - stage2 顺序输出 :calc_risk 写
summary_bullets、entities、risk_result,suggest_clauses 写clause_suggestions。 - 最终输出 :finalize 写
report_markdown、final_answer。 - 并行节点入参 :每个
mcp_execute_tool分支的 state 中带有本分支的tool_name、tool_arguments。
6.3 Stage1:planner 节点
-
Stage1(摘要、抽取) :由 planner 节点 决定。本 demo 要求 planner 必须使用 LLM :用提示词让 LLM 输出 JSON(
need_summary、need_entities、resource_uris),代码解析后往stage1_tool_tasks里追加summarize_text和/或extract_entities。未配置 API Key 时运行会直接报错退出。不是 「模型在对话里自己选工具名」,而是「规划器一次性产出本轮要跑哪些 stage1 工具」。node_planner源代码如下:pydef node_planner(state: MCPGraphState) -> dict[str, Any]: """规划资源与 stage1 并行工具任务(summarize / extract)。本 demo 要求必须使用 LLM 作为规划器。""" if not _maybe_get_llm_available(): raise ValueError( "本 demo 的 planner 必须使用 LLM。请在 .env 中配置 OPENAI_API_KEY 或 DASHSCOPE_API_KEY 后重试。" ) user_query = (state.get("user_query") or "").strip() contract_text = state.get("contract_text") or "" resource_uris = DEFAULT_RESOURCE_URIS need_summary = True need_entities = True stage1_tool_tasks: list[Stage1ToolTask] = [] try: if STEP_VERBOSE: logger.info("[Planner] 调用 LLM 生成 JSON 规划...") chain = _planner_chain() raw = chain.invoke({"user_query": user_query, "contract_text": contract_text}) data = _extract_first_json_object(raw) if data: resource_uris = data.get("resource_uris") or DEFAULT_RESOURCE_URIS need_summary = bool(data.get("need_summary", True)) need_entities = bool(data.get("need_entities", True)) except Exception as e: if STEP_VERBOSE: logger.warning("[Planner] LLM 规划解析失败,使用默认 need_summary/need_entities=True:%s", e, exc_info=True) if need_summary: stage1_tool_tasks.append( {"tool_name": "summarize_text", "arguments": {"text": contract_text}}, ) if need_entities: stage1_tool_tasks.append( {"tool_name": "extract_entities", "arguments": {"text": contract_text}}, ) if STEP_VERBOSE: logger.info( "[Planner] resource_uris=%s need_summary=%s need_entities=%s stage1_tools=%s", resource_uris, need_summary, need_entities, [t.get("tool_name") for t in stage1_tool_tasks], ) return { "resource_uris": resource_uris, "need_summary": need_summary, "need_entities": need_entities, "stage1_tool_tasks": stage1_tool_tasks, "stage1_results": [], }- Stage2(风险、建议、渲染) :固定顺序、固定工具名 ,由图的节点写死------
calc_risk节点只调calc_risk_score,suggest_clauses只调suggest_negotiation_clauses,finalize只调render_report_markdown。这三步没有 LLM 再选工具。
- Stage2(风险、建议、渲染) :固定顺序、固定工具名 ,由图的节点写死------
-
并行还是顺序?
- 并行 :只有 Stage1 的
summarize_text与extract_entities。planner 产出的stage1_tool_tasks里有几个任务,就通过Send触发几个mcp_execute_tool分支,LangGraph 会并发执行,结果用stage1_results的 reducer(operator.add)合并。 - 顺序 :
fetch_resources→(并行 Stage1)→calc_risk→suggest_clauses→finalize。即:先读 MCP resources,再并行跑摘要+抽取,再按固定顺序跑 风险计算 → 谈判建议 → 报告渲染(+ 可选 LLM 润色)。
- 并行 :只有 Stage1 的
6.4 谁决定调用哪些 tools:planner 节点与 stage1_tool_tasks
Planner 的职责是:根据 user_query 和 contract_text 决定「要读哪些 resources」「要不要摘要」(need_summary),「要不要抽取实体」(need_entities),并写出 stage1_tool_tasks(即第一轮要并行调用的 MCP 工具列表)。注意,这里的实体指的是:从合同等文本中识别并抽出诸如当事人、日期、金额、关键条款等实体,供后续风险评估等步骤使用。
本 demo 要求必须使用 LLM 作为规划器:用提示词让 LLM 输出 JSON,代码解析后填充 resource_uris、need_summary、need_entities;未配置 API Key 时会直接报错退出。代码要点如下(mcp_graph.py):
python
def node_planner(state: MCPGraphState) -> dict[str, Any]:
# 本 demo 要求必须使用 LLM,未配置 API Key 则报错
if not _maybe_get_llm_available():
raise ValueError(
"本 demo 的 planner 必须使用 LLM。请在 .env 中配置 OPENAI_API_KEY 或 DASHSCOPE_API_KEY 后重试。"
)
user_query = (state.get("user_query") or "").strip()
contract_text = state.get("contract_text") or ""
resource_uris = DEFAULT_RESOURCE_URIS
need_summary = True
need_entities = True
stage1_tool_tasks: list[Stage1ToolTask] = []
try:
chain = _planner_chain()
raw = chain.invoke({"user_query": user_query, "contract_text": contract_text})
data = _extract_first_json_object(raw)
if data:
resource_uris = data.get("resource_uris") or DEFAULT_RESOURCE_URIS
need_summary = bool(data.get("need_summary", True))
need_entities = bool(data.get("need_entities", True))
except Exception as e:
pass # 解析失败则使用默认 need_summary/need_entities=True
if need_summary:
stage1_tool_tasks.append(
{"tool_name": "summarize_text", "arguments": {"text": contract_text}},
)
if need_entities:
stage1_tool_tasks.append(
{"tool_name": "extract_entities", "arguments": {"text": contract_text}},
)
return {
"resource_uris": resource_uris,
"need_summary": need_summary,
"need_entities": need_entities,
"stage1_tool_tasks": stage1_tool_tasks,
"stage1_results": [],
}
因此:LLM 负责「要不要摘要、要不要抽实体」的决策 ,并间接决定 stage1_tool_tasks 里有哪些项;具体调用哪个 tool、传什么参数 ,由这段代码根据 LLM 的 JSON 写死 为 summarize_text / extract_entities 与 contract_text。
6.5 并行 Stage1:Send + reducer,以及"真正调 MCP tool"的代码
并行 只发生在 Stage1:根据 stage1_tool_tasks 的每一项,扇出 到多个 mcp_execute_tool 节点,每个节点只负责调用一个 MCP tool。状态里用 stage1_results: Annotated[list, operator.add] 做 reducer,把多路结果拼成一个列表。
扇出逻辑 (mcp_graph.py):
python
def fan_out_stage1_tools(state: MCPGraphState) -> list[Send]:
tasks = state.get("stage1_tool_tasks") or []
base_state = dict(state)
sends: list[Send] = []
for task in tasks:
sends.append(
Send(
"mcp_execute_tool",
{
**base_state,
"tool_name": task["tool_name"],
"tool_arguments": task["arguments"],
},
)
)
return sends
图中从 fetch_resources 出来的条件边 返回的就是上述 list[Send]:LangGraph 会为每个 Send 启动一个 mcp_execute_tool 分支,并行执行。
每个分支里"真正调 MCP tool"的代码 (mcp_graph.py 中的 node_mcp_execute_tool):
python
def node_mcp_execute_tool(state: MCPGraphState, server_script_path: str) -> dict[str, Any]:
tool_name = (state.get("tool_name") or "").strip()
tool_arguments = state.get("tool_arguments") or {}
if not tool_name or not state.get("mcp_ok"):
return {}
client = MCPContractClient(server_script_path)
output = client.call_tool(tool_name, arguments=tool_arguments)
return {"stage1_results": [{"tool_name": tool_name, "output": output}]}
也就是说:图节点从 state 里取出本分支的 tool_name 和 tool_arguments,用 MCP 客户端 call_tool 调一次 MCP server ;返回值塞进 stage1_results,由 reducer 合并,供后续 calc_risk 使用。
6.6 顺序 Stage2:固定调用 calc_risk_score、suggest_negotiation_clauses、render_report_markdown
Stage2 的这三个工具不 由 LLM 选择,而是固定顺序、固定工具名,在三个图节点里分别调用。
6.6.1 calc_risk 风险节点
calc_risk 节点 (计算风险):从 stage1_results 里取出 summarize_text 和 extract_entities 的输出,再调 MCP 的 calc_risk_score(若 MCP 不可用则用本地 fallback_calc_risk_score)。
风险节点的代码如下:
python
def node_calc_risk(state: MCPGraphState, server_script_path: str) -> dict[str, Any]:
"""stage2:基于 stage1 的摘要/要素计算风险(MCP 优先,失败则本地回退)。"""
stage1_results = state.get("stage1_results") or []
need_summary = bool(state.get("need_summary"))
need_entities = bool(state.get("need_entities"))
contract_text = state.get("contract_text") or ""
# 1) 从 stage1 中找结果;找不到则回退
summary_bullets = _find_stage1_output(stage1_results, "summarize_text")
entities = _find_stage1_output(stage1_results, "extract_entities")
if need_summary and not summary_bullets:
summary_bullets = fallback_summarize_text(contract_text)
if need_entities and not entities:
entities = fallback_extract_entities(contract_text)
if isinstance(entities, str):
# 若工具输出是 JSON 文本,尝试解析
try:
entities = json.loads(entities)
except Exception:
entities = fallback_extract_entities(contract_text)
policy_rubric = (state.get("policy_rubric") or RISK_RUBRIC_V1).strip()
# 2) MCP 优先计算风险
risk_result: dict[str, Any] = {}
if state.get("mcp_ok"):
try:
client = MCPContractClient(server_script_path)
risk_result = client.call_tool(
"calc_risk_score",
arguments={
"summary": summary_bullets or "",
"entities": entities or {},
"policy_rubric": policy_rubric,
},
)
if not isinstance(risk_result, dict):
raise ValueError("risk_result not dict")
except Exception as e:
if STEP_VERBOSE:
logger.warning("[Risk] MCP calc_risk_score 失败,回退本地:%s", e, exc_info=True)
risk_result = fallback_calc_risk_score(
summary=summary_bullets or "",
entities=entities or {},
_policy_rubric=policy_rubric,
)
else:
risk_result = fallback_calc_risk_score(
summary=summary_bullets or "",
entities=entities or {},
_policy_rubric=policy_rubric,
)
return {
"summary_bullets": summary_bullets or "",
"entities": entities or {},
"risk_result": risk_result,
}
6.6.2 suggest_clauses 基于风险等级给出谈判建议节点
suggest_clauses 节点 (基于风险等级给出谈判建议):用上一步的 risk_result(含 risk_level、risk_reasons)调 MCP 的 suggest_negotiation_clauses。
基于风险等级给出谈判建议节点的源代码如下:
python
def node_suggest_clauses(state: MCPGraphState, server_script_path: str) -> dict[str, Any]:
"""stage2:基于风险等级给出谈判建议(MCP 优先,失败回退)。"""
policy_rubric = (state.get("policy_rubric") or RISK_RUBRIC_V1).strip()
risk_result = state.get("risk_result") or {}
risk_level = risk_result.get("risk_level") or ""
risk_reasons = risk_result.get("risk_reasons") or []
clause_suggestions: dict[str, Any] = {}
if state.get("mcp_ok"):
try:
client = MCPContractClient(server_script_path)
clause_suggestions = client.call_tool(
"suggest_negotiation_clauses",
arguments={
"risk_level": risk_level,
"risk_reasons": risk_reasons,
"policy_rubric": policy_rubric,
},
)
if not isinstance(clause_suggestions, dict):
raise ValueError("clause_suggestions not dict")
except Exception as e:
if STEP_VERBOSE:
logger.warning("[Suggest] MCP suggest_negotiation_clauses 失败,回退本地:%s", e, exc_info=True)
clause_suggestions = fallback_suggest_negotiation_clauses(
risk_level=risk_level,
risk_reasons=risk_reasons,
_policy_rubric=policy_rubric,
)
else:
clause_suggestions = fallback_suggest_negotiation_clauses(
risk_level=risk_level,
risk_reasons=risk_reasons,
_policy_rubric=policy_rubric,
)
return {"clause_suggestions": clause_suggestions}
6.6.3 finalize 节点
- finalize 节点 :用当前 state 里的摘要、实体、风险结果、谈判建议,调 MCP 的
render_report_markdown,再可选地调 LLM 润色:
finalize 节点的源代码如下:
python
def node_finalize(state: MCPGraphState, server_script_path: str) -> dict[str, Any]:
"""最终输出:report_markdown ->(可选 LLM 润色)-> final_answer。"""
report_markdown = ""
if state.get("mcp_ok"):
try:
client = MCPContractClient(server_script_path)
policy_template = (state.get("output_template") or OUTPUT_TEMPLATE_V1).strip()
report_markdown = client.call_tool(
"render_report_markdown",
arguments={
"summary_bullets": state.get("summary_bullets") or "",
"entities": state.get("entities") or {},
"risk_result": state.get("risk_result") or {},
"clause_suggestions": state.get("clause_suggestions") or {},
"template": policy_template,
},
)
if not isinstance(report_markdown, str):
report_markdown = ""
except Exception as e:
if STEP_VERBOSE:
logger.warning("[Finalize] MCP 渲染失败,回退本地渲染:%s", e, exc_info=True)
report_markdown = ""
if not report_markdown:
report_markdown = _render_report_local(state)
# 可选:LLM 润色
final_answer = report_markdown
if _maybe_get_llm_available():
try:
if STEP_VERBOSE:
logger.info("[Finalizer] 调用 LLM 润色报告...")
chain = _finalizer_chain()
final_answer = chain.invoke({"report_markdown": report_markdown})
except Exception as e:
if STEP_VERBOSE:
logger.warning("[Finalizer] 润色失败,使用基线报告:%s", e, exc_info=True)
final_answer = report_markdown
return {"report_markdown": report_markdown, "final_answer": final_answer}
小结:LLM 只参与 planner 的"要不要摘要/抽实体"和 finalize 的"要不要润色";具体调用哪个 MCP tool、以什么顺序调用,由图的节点与边写死。
6.7 MCP client 的 stdio 调用:一次连接内完成"初始化 + 单次操作"
本 demo 的 MCP 客户端通过 stdio 启动本地 server 子进程;每次 call_tool / read_resource / list_tools 都会在同一次连接 内完成「建连 → initialize → 单次操作 → 断开」,避免 anyio 的 cancel scope 跨 task 问题。同步封装里用 asyncio.run(或在已有事件循环时用线程池)跑异步逻辑。核心异步逻辑类似(mcp_client.py 中 _with_session + call_tool):
python
async def _with_session(self, async_op):
server_params = StdioServerParameters(command=sys.executable, args=[self.server_script_path], env=None)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
return await async_op(session)
# 实际调 tool 时:在 async_op(session) 里执行 session.call_tool(...)
也就是说:图节点里看到的 client.call_tool(tool_name, arguments=...),底层是一次"stdio 建连 → initialize → call_tool → 断开"的完整往返。
6.8 MCP server 暴露的五个 tools:代码与含义
MCP server(mcp_server.py)用 FastMCP 的 @mcp.tool() 暴露五个工具;每个工具的实现都委托给 fallback_tools 中的同名函数(便于与"无 MCP 时本地回退"共用逻辑)。下面列出每个 tool 的签名、在 server 中的注册方式,以及它做什么。
1)summarize_text
对合同条款原文做要点摘要(本 demo 为启发式:抽甲方/乙方/期限/违约句,再拼成几条要点)。
python
# mcp_server.py
@mcp.tool()
def summarize_text(text: str) -> str:
"""对合同条款文本做要点摘要(启发式)。"""
return fallback_summarize_text(text)
fallback_summarize_text(fallback_tools.py)里用正则匹配「甲方」「乙方」「合同期限」「违约责任」等,拼成若干条 - 当事人:... / 合同期限:... / 违约与赔偿:... / 解除与终止:...。
2)extract_entities
从合同条款中抽取结构化要素(当事人、期限、违约条款片段、若干风险相关布尔信号),返回 JSON。
python
@mcp.tool()
def extract_entities(text: str) -> dict[str, Any]:
"""从合同条款中抽取结构化要素(启发式)。"""
return fallback_extract_entities(text)
返回结构包含 party_a、party_b、term、breach_clause_snippet、signals(如 has_upper_bound_words、has_no_upper_bound_words、has_indemnity_words、has_low_risk_words)等,供后续风险评分使用。
3)calc_risk_score
根据「摘要 + 实体 JSON + 风险规则文本」计算风险分数与等级(演示用启发式:按关键词加减分,再归到低/中/高)。
python
@mcp.tool()
def calc_risk_score(summary: str, entities: dict[str, Any], policy_rubric: str) -> dict[str, Any]:
"""根据摘要与结构化要素计算风险分数与等级(演示启发式)。"""
return fallback_calc_risk_score(summary=summary, entities=entities, _policy_rubric=policy_rubric)
返回 risk_score、risk_level、risk_reasons 等,供 suggest 和报告渲染使用。
4)suggest_negotiation_clauses
根据风险等级和风险原因列表,生成谈判建议与示例改写方向(如"增加责任上限""明确补救期"等)。
python
@mcp.tool()
def suggest_negotiation_clauses(
risk_level: str,
risk_reasons: list[str],
policy_rubric: str,
) -> dict[str, Any]:
"""基于风险等级生成谈判建议与示例改写方向(演示)。"""
return fallback_suggest_negotiation_clauses(
risk_level=risk_level,
risk_reasons=risk_reasons,
_policy_rubric=policy_rubric,
)
返回 suggestions、draft_example 等,供报告模板填充。
5)render_report_markdown
用「摘要、实体 JSON、风险结果、谈判建议」和 resource 提供的模板字符串,填充出最终 Markdown 报告正文。
python
@mcp.tool()
def render_report_markdown(
summary_bullets: str,
entities: dict[str, Any],
risk_result: dict[str, Any],
clause_suggestions: dict[str, Any],
template: str,
) -> str:
"""使用 resources 提供的模板渲染报告 Markdown。"""
# 将 entities 等格式化为 JSON 字符串,risk_reasons / suggestions 格式化为列表
filled = template.format(
summary_bullets=summary_bullets,
entities_json=entities_json,
risk_level=risk_result.get("risk_level") or "",
risk_score=risk_result.get("risk_score") or 0,
risk_reasons=risk_reasons_md,
clause_suggestions=suggestions_md,
)
return filled.strip()
这样「报告长什么样」由 MCP resource 的模板决定,客户端不必硬编码模板格式。
6.9 MCP server 的 resources:规则与模板
除了 tools,server 还暴露两个 resources ,供客户端在 fetch_resources 节点里用 read_resource 拉取:
python
@mcp.resource("policy://risk_rules/v1")
def get_risk_rules() -> str:
"""返回风险评分规则(演示版文本)。"""
return RISK_RUBRIC_V1.strip()
@mcp.resource("template://contract_report_cn/v1")
def get_output_template() -> str:
"""返回报告输出模板(用于结构化渲染 Markdown)。"""
return OUTPUT_TEMPLATE_V1.strip()
policy://risk_rules/v1:风险加减分规则说明(纯文本),供calc_risk_score的语义参考(本 demo 的启发式实现里主要仍用代码逻辑,规则文本更多是"可被读取的配置")。template://contract_report_cn/v1:带占位符的 Markdown 模板(如{summary_bullets}、{risk_level}),供render_report_markdown做template.format(...)。
💡 理解要点 :本 demo 要求 planner 必须使用 LLM (需配置 OPENAI_API_KEY 或 DASHSCOPE_API_KEY);LLM 参与"规划"(要不要摘要/抽实体),报告"润色"为可选。具体调用哪些 MCP tools、以并行还是顺序执行,由 LangGraph 的节点与边写死 ;并行只发生在 Stage1 的 summarize_text 与 extract_entities ;调用代码就是图节点里 MCPContractClient(...).call_tool(tool_name, arguments=...),底层是 stdio 建连后的一次 MCP 协议往返。
6.10 实际运行结果
我们可以通过在terminal中运行 python main.py --verbose 来观察整个flow每一步的详细结果。
我们的输入同样来自 main.py。
py
DEFAULT_USER_QUERY = "请将下面合同条款摘要成要点,并给出风险等级与谈判修改建议(尽量输出结构化要素与改写方向)。"
DEFAULT_CONTRACT_TEXT = """
合同编号:2026-03-001
甲方:杭州星河科技有限公司
乙方:北京云端服务有限公司
鉴于甲乙双方就软件服务相关合作达成一致,订立本合同。
第二条 合同期限
2.1 本合同自双方签署之日起生效,有效期三年。
第三条 服务内容与交付
3.1 乙方应按约提供系统维护服务,并在每个自然月结束后提交运维报告。
3.2 甲方应按合同约定支付服务费用。
第四条 违约责任
4.1 任一方违约的,违约方应向守约方支付合同总金额20%的违约金。
4.2 同时,违约方还应赔偿守约方因此遭受的全部损失,且不设责任上限。
第五条 不可抗力
5.1 因不可抗力导致不能履行或部分履行,不视为违约,但应在合理期限内通知对方。
第六条 保密条款
6.1 双方对在合作中获悉的商业秘密承担保密义务,未经对方书面同意不得泄露。
第七条 解除与终止
7.1 乙方单方有权在甲方逾期支付超过10日后解除合同。
7.2 解除不影响守约方追究违约责任。
""".strip()
需要注意,上面的数据都是造出来的,仅仅为了demo使用。
我们运行代码后,terminal中展示了每一步,包括每一个tool的调用情况:
terminal
2026-03-19 15:57:17 [INFO] mcp_graph: [Planner] 调用 LLM 生成 JSON 规划...
2026-03-19 15:57:19 [INFO] mcp_graph: [Planner] resource_uris=['policy://risk_rules/v1', 'template://contract_report_cn/v1'] need_summary=True need_entities=True stage1_tools=['summarize_text', 'extract_entities']
te://contract_report_cn/v1'] need_summary=True need_entities=True stage1_tools=['summarize_text', 'extract_entities']
, 'extract_entities']
2026-03-19 15:57:20 [INFO] mcp_graph: [MCP-Check] ok=True msg=ok
2026-03-19 15:57:22 [INFO] mcp_graph: [MCP-Tool] 调用 summarize_text(args=['text'])
2026-03-19 15:57:22 [INFO] mcp_graph: [MCP-Tool] 调用 extract_entities(args=['text'])
2026-03-19 15:57:23 [INFO] mcp_graph: [MCP-Tool] summarize_text 输出: - 核心义务/交付结构:根据 文本中"权利义务、服务内容/交付、付款/费用"等字段提炼(演示)。
- 违约与赔偿:未能稳定定位到"违约责任/违约金"句子(演示可能漏项)。
- 解除/终止条款:疑似存在(演示)。
2026-03-19 15:57:23 [INFO] mcp_graph: [MCP-Tool] extract_entities 输出: {"party_a": "", "party_b": "", "term": "", "breach_clause_snippet": "", "signals": {"has_upper_bound_words": true, "has_no_upper_bound_words": true, "has_indemnity_words": true, "has_low_risk_words": true}, "observations": ["该结构由启发式规则提取,用于演示 MCP 工具的结构化输入输出。", "真实场景需要更强的中文法律文本解析与裁量逻辑。"]}
2026-03-19 15:57:26 [INFO] mcp_graph: [Finalizer] 调用 LLM 润色报告...
========= 最终报告(Markdown) =========
以下是润色后的合同条款分析报告(MCP 演示版),在保持原始信息完整性与技术严谨性的前提下,优化了语言表达的准确性、专业性与可读性,更符合中文法律科技场景下的咨询交付风格:
---
# 合同条款分析报告(MCP 演示版)
## 要点摘要
本报告基于对目标文本的结构化语义解析生成,旨在演示 MCP(Contract Mining & Profiling)工具在合同 关键条款识别与风险初筛中的能力。当前分析聚焦以下三类核心条款:
- **权利义务与交付结构**:已从"服务内容""交付标准""双方职责"等字段中提取基础框架(演示级精度);
- **违约与赔偿机制**:未能稳定定位明确的"违约责任"或"违约金"条文,存在条款覆盖不全或表述隐晦的风险(提示潜在漏项);
- **合同解除与终止条件**:检测到疑似相关表述,但尚未完成条款锚定与上下文验证(需人工复核确认)。
---
## 当事人及关键要素(结构化输出)
{
"party_a": "",
"party_b": "",
"term": "",
"breach_clause_snippet": "",
"signals": {
"has_upper_bound_words": true,
"has_no_upper_bound_words": true,
"has_indemnity_words": true,
"has_low_risk_words": true
},
"observations": [
"本结构由轻量级启发式规则驱动,用于展示 MCP 工具的端到端结构化处理流程。",
"实际业务场景中,需结合深度中文法律语义理解模型与领域知识库,提升条款识别准确率与逻辑裁量合 理性。"
]
}
---
## 风险评估(演示)
- **综合风险等级**:低风险
- **量化风险分数**:35 / 100(分数越低表示风险越可控)
### 风险成因分析
系统识别出以下四类相互交织的语义信号,共同支撑当前低风险判断:
1. **强约束信号**:出现"无责任上限""不设上限""全部损失承担"等表述,提示潜在无限责任风险;
2. **赔偿范围信号**:含"赔偿""损失""间接损失""预期利益"等关键词,指向较宽泛的责任覆盖维度;
3. **风险缓释信号**:同时存在"责任上限""以合同总额为限"等封顶类措辞,构成对前述强约束的实质平衡 ;
4. **程序保障信号**:识别到"不可抗力""通知义务""补救措施""宽限期"等程序性安排,有助于降低履约不 确定性与争议升级概率。
> ✅ 综合来看,条款呈现"高敞口+强缓冲"的混合特征,属典型可控型低风险结构。
---
## 谈判建议(条款优化方向)
建议在保留现有风险平衡机制的基础上,进一步强化条款的操作性与司法可执行性,具体可从以下两方面推进:
### 1. 补充程序性要件
- 明确违约触发后的**通知形式与时限**(如:书面通知 + 5个工作日内送达);
- 设定**协商与补救期**(如:收到通知后不少于30日的整改窗口期);
- 规定补救失败后的**责任启动条件与证明标准**(如:经第三方见证仍未消除违约状态)。
### 2. 精确定义关键概念并固化举证路径
- 对"间接损失""预期利益""重大过失"等易引发争议的术语,嵌入合同附件或定义条款予以明示;
- 约定损失计算方式与证据要求(例如:"间接损失须提供经审计的财务数据及因果关系说明"),显著压缩后续争议空间。
#### 【示例条款改写】
> "双方同意,除法律强制性规定外,任一方因本合同项下违约行为所承担的赔偿责任,以本合同总金额为最 高限额;赔偿范围明确排除间接损失、附带损失、惩罚性赔偿及预期利益损失。任何违约主张须以书面形式发出,并给予守约方不少于三十(30)日的合理补救期;逾期未实质性纠正的,方可依据本条款主张赔偿。"
---
*注:本报告为 MCP 工具能力演示输出,不构成正式法律意见。实际合同审阅请以持证律师出具的专业意见为准。*
========= 结束 =========
7 小结与扩展方向
- MCP 解决的是"外部能力接入标准化 + 运行时发现",LangGraph 解决的是"编排与状态流转"。
- 用
Send + reducer做并行工具调用,用条件/容错让系统在 MCP 不可用时仍能产出结果。 - 最佳实践往往不是"包一层 API",而是让 tools/resources 返回 agent-friendly 的结构化内容与可组合粒度。