LangGraph 14. MCP:把“外部能力”标准化接入 LLM

摘要:本文介绍 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 STDIOstdio
  • 远程/web:常用 Streamable HTTP / SSE 等持久连接方式

此外,三件"工程不可跳过"的事:

  1. 安全:暴露 tools 与数据必须有认证/授权,否则就是把后门也暴露给客户端。
  2. 错误处理:server 不可用、tool 执行失败、参数不合法......这些错误需要以协议化方式回传,让 agent 能决定回退策略。
  3. 本地 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 格式的《合同条款分析报告》,包含:

  1. 要点摘要:对合同核心义务、违约与赔偿、解除与终止等的提炼。
  2. 当事人与关键要素(结构化):以 JSON 形式给出的当事人、期限、违约条款片段,以及若干风险相关信号(如是否出现责任上限/无上限、赔偿范围、程序性条款等)。
  3. 风险评估(演示):风险等级(低/中/高)、风险分数(0--100),以及基于文本信号的风险成因说明。
  4. 谈判建议:基于风险等级给出的条款优化方向与示例改写表述。

本 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_btermbreach_clause_snippetsignals(如 has_upper_bound_wordshas_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 定义

图的全局状态 MCPGraphStatemcp_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_querycontract_text 由调用方传入,planner 据此生成规划。
  • 规划输出resource_urisneed_summaryneed_entitiesstage1_tool_tasks 由 planner 写入,供 fetch_resources 与 fan_out 使用。
  • MCP 容错mcp_okmcp_errorforce_fallback 决定是否走 MCP 路径或本地 fallback。
  • resourcespolicy_rubricoutput_template 由 fetch_resources 从 MCP 读取(或回退为本地常量)。
  • stage1_results :并行节点 mcp_execute_tool 各自返回 [{"tool_name": ..., "output": ...}],通过 operator.add 合并为列表,供 calc_risk 读取。
  • stage2 顺序输出 :calc_risk 写 summary_bulletsentitiesrisk_result,suggest_clauses 写 clause_suggestions
  • 最终输出 :finalize 写 report_markdownfinal_answer
  • 并行节点入参 :每个 mcp_execute_tool 分支的 state 中带有本分支的 tool_nametool_arguments

6.3 Stage1:planner 节点

  • Stage1(摘要、抽取) :由 planner 节点 决定。本 demo 要求 planner 必须使用 LLM :用提示词让 LLM 输出 JSON(need_summaryneed_entitiesresource_uris),代码解析后往 stage1_tool_tasks 里追加 summarize_text 和/或 extract_entities。未配置 API Key 时运行会直接报错退出。不是 「模型在对话里自己选工具名」,而是「规划器一次性产出本轮要跑哪些 stage1 工具」。node_planner 源代码如下:

    py 复制代码
    def 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_scoresuggest_clauses 只调 suggest_negotiation_clausesfinalize 只调 render_report_markdown。这三步没有 LLM 再选工具。
  • 并行还是顺序?

    • 并行 :只有 Stage1summarize_textextract_entities。planner 产出的 stage1_tool_tasks 里有几个任务,就通过 Send 触发几个 mcp_execute_tool 分支,LangGraph 会并发执行,结果用 stage1_results 的 reducer(operator.add)合并。
    • 顺序fetch_resources →(并行 Stage1)→ calc_risksuggest_clausesfinalize。即:先读 MCP resources,再并行跑摘要+抽取,再按固定顺序跑 风险计算 → 谈判建议 → 报告渲染(+ 可选 LLM 润色)。

6.4 谁决定调用哪些 tools:planner 节点与 stage1_tool_tasks

Planner 的职责是:根据 user_querycontract_text 决定「要读哪些 resources」「要不要摘要」(need_summary),「要不要抽取实体」(need_entities),并写出 stage1_tool_tasks(即第一轮要并行调用的 MCP 工具列表)。注意,这里的实体指的是:从合同等文本中识别并抽出诸如当事人、日期、金额、关键条款等实体,供后续风险评估等步骤使用。

本 demo 要求必须使用 LLM 作为规划器:用提示词让 LLM 输出 JSON,代码解析后填充 resource_urisneed_summaryneed_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_entitiescontract_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_nametool_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_textextract_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_levelrisk_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_textfallback_tools.py)里用正则匹配「甲方」「乙方」「合同期限」「违约责任」等,拼成若干条 - 当事人:... / 合同期限:... / 违约与赔偿:... / 解除与终止:...

2)extract_entities

从合同条款中抽取结构化要素(当事人、期限、违约条款片段、若干风险相关布尔信号),返回 JSON。

python 复制代码
@mcp.tool()
def extract_entities(text: str) -> dict[str, Any]:
    """从合同条款中抽取结构化要素(启发式)。"""
    return fallback_extract_entities(text)

返回结构包含 party_aparty_btermbreach_clause_snippetsignals(如 has_upper_bound_wordshas_no_upper_bound_wordshas_indemnity_wordshas_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_scorerisk_levelrisk_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,
    )

返回 suggestionsdraft_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_markdowntemplate.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 的结构化内容与可组合粒度。
相关推荐
_张一凡2 小时前
【多模态模型学习】从零手撕一个Vision Transformer(ViT)模型实战篇
人工智能·深度学习·transformer
Westward-sun.2 小时前
OpenCV 实战:银行卡号识别系统(基于模板匹配)
人工智能·opencv·计算机视觉
网安INF3 小时前
【论文阅读】-《TtBA: Two-third Bridge Approach for Decision-Based Adversarial Attack》
论文阅读·人工智能·神经网络·对抗攻击
努力也学不会java3 小时前
【缓存算法】一篇文章带你彻底搞懂面试高频题LRU/LFU
java·数据结构·人工智能·算法·缓存·面试
BPM6664 小时前
2026流程管理软件选型指南:从Workflow、BPM到AI流程平台(架构+实战)
人工智能·架构
金融小师妹4 小时前
基于多模态宏观建模与历史序列对齐:原油能源供给冲击的“类1970年代”演化路径与全球应对机制再评估
大数据·人工智能·能源
JamesYoung79714 小时前
OpenClaw小龙虾如何系统性节省Token,有没有可落地的方案?
人工智能
播播资源4 小时前
OpenAI2026 年 3 月 18 日最新 gpt-5.4-nano模型:AI 智能体的“神经末梢”,以极低成本驱动高频任务
大数据·人工智能·gpt