Skip to content

LangGraph 入门 + MCP 试水:从手写循环到图编排

一个 8 年 Java 后端,把手写的 ReAct while 循环画成 LangGraph 的图,再用 MCP 协议把工具层从 Java 拆到 Python。

核心结论:Agent 不是 DAG,Agent 是循环。LangGraph 把 while 循环变成了图,MCP 把工具变成了协议。


为什么手写 ReAct 循环不够用

手写 ReAct 的核心结构长这样:

java
while (true) {
    String reply = callLlm(messages);
    if (reply.contains("Final Answer")) break;
    String obs = callTool(parse(reply));
    messages.add(obs);
}

能跑,但有三个实际问题:

  • 控制流和业务绑在一起:想加个"调完工具先让人确认"的分支?得改 while 循环体本身
  • 中断即丢失:进程崩了,messages 全在内存里,重启要从头再来
  • 流程不可见:长了以后就是黑盒,新人接手先读 200 行循环

LangGraph 解决前两个问题(流程编排 + 中断恢复),MCP 解决第三个维度的问题(工具在哪儿、用什么语言写)。


一、LangGraph:把 while 和 if 画成图

四个核心概念

LangGraph 的抽象可以浓缩为四个词:State / Node / Edge / ConditionalEdge

  • State:图在运行过程中携带的数据。每个节点读取 State、修改 State、返回新 State
  • Node:执行单元,可以是 LLM 调用、工具调用、任意业务逻辑
  • Edge:节点之间的连线,表示执行顺序
  • ConditionalEdge:条件边,根据当前 State 决定下一跳去哪——这是实现循环和分支的关键

手写循环里的 whileifbreak,在图里全部变成了边和节点的组合。

最简 ReAct 图实现

java
var workflow = new StateGraph<>(MyAgentState.SCHEMA, MyAgentState::new)
    .addNode("llm", node_async(llmNode))
    .addNode("tool", node_async(toolNode))
    .addEdge(START, "llm")
    .addConditionalEdges("llm",
        edge_async(state -> state.nextAction()),
        Map.of("tool", "tool", "end", END))
    .addEdge("tool", "llm");

逐段拆解:

第 1 行:声明图和 State 类型new StateGraph<>(MyAgentState.SCHEMA, MyAgentState::new) —— 第一个参数是字段合并策略的 Schema(下面会讲),第二个参数是 State 的构造器,用来在图启动时创建初始状态。这一行对应手写版的"声明 messages 列表"。

第 2-3 行:注册节点.addNode("llm", node_async(llmNode)) 把 LLM 调用封装成名叫 llm 的节点,.addNode("tool", ...) 把工具调用封装成 tool 节点。node_async() 是 LangGraph4j 的包装器,把普通函数转成异步节点。

第 4 行:入口边.addEdge(START, "llm") —— 图从 START 出发,第一步走 llm 节点。对应手写版 while(true) 的入口。

第 5-7 行:条件边(最关键的一行).addConditionalEdges("llm", edge_async(state -> state.nextAction()), Map.of("tool", "tool", "end", END)) —— llm 节点跑完后,调用 state.nextAction() 拿到一个字符串。如果返回 "tool",走 tool 节点;如果返回 "end",走到 END 终止。

这就是手写版里 if(reply.contains("Final Answer")) break 的图式表达。区别在于:条件判断从代码里的 if 语句,变成了可声明、可序列化的数据结构。

第 8 行:回环边.addEdge("tool", "llm") —— tool 执行完再回到 llm。这就是那个 while 循环本身,用一条回环边实现。

手写 vs 框架对照

手写 ReActLangGraph 图
while(true){} 循环体回环边 tool → llm
if(final) break条件边命中 "end" → END
callTool()tool 节点
history.add(msg)SCHEMA 里的 Channels.appender

compile() 之后还能直接输出 PlantUML,可以把流程可视化出来。控制流从代码关键字变成了可声明、可持久化的数据结构。

State 的合并策略

写 State Schema 时要显式声明每个字段的合并策略:

java
public static final Map<String, Channel<?>> SCHEMA = Map.of(
    "messages", Channels.appender(ArrayList::new),
    "intermediateSteps", Channels.appender(ArrayList::new)
);

Channels.appender(ArrayList::new) 的含义是:每次节点 return 增量,引擎自动 append 到列表里。对应手写版的 messages.add(obs)

注意 nextAction 这个字段没有声明。没声明的字段走默认行为:覆盖,不累积,只保留最新值。这对应手写版里"标志位置新就覆盖"的逻辑。

想通这个,手写版里 history.add() 在框架里对应什么就清楚了:不是直接改数组,而是声明"这个字段 append"的合并策略,节点只 return 增量。


二、Checkpoint:Agent 的状态持久化

为什么 ChatMemory 不够

很多人把"聊天记录持久化"和"Agent 中断恢复"混为一谈,但这是两回事。

拿对比说:ChatMemory 是聊天记录.txt,Checkpoint 是虚拟机快照。前者只记住"聊过啥",后者要记住"跑到哪了"。Agent 是长时运行任务,跑一半崩了,光有聊天记录不够——你还得知道执行到第几步、中间变量是什么。

Checkpoint 的两层语义

Checkpoint 有两层语义,这里容易混淆:

  • 数据层:messages、intermediateSteps 完整恢复,这是真正有用的部分
  • 执行指针层:上次停在哪个节点——Demo 里没持久化这个信息,重启后从 START 重走,靠节点逻辑自己读 steps.size() 跳过已执行步骤

实际生产环境中,执行指针也可以持久化(LangGraph 的 checkpointer 配置),但即使不持久化,数据层的恢复已经能解决大部分问题。

中断恢复实验

第一次跑到第 3 步主动 break 模拟崩溃,第二次用同一个 threadId 重启看结果:

第一次:llm(steps=0) → tool(step-1) → 💥 崩溃
第二次:llm(steps=1) → tool(step-2) → llm(steps=2) → END ✅

两次用同一个 threadId,第二次自动跳过了前两步。背后的逻辑是:Checkpoint 把 State 存了下来,重启时先加载历史 State,节点内部判断"已经做过的就不做了"。


三、微调:什么时候该动模型本身

微调在 Pipeline 里的位置

先搞清楚微调在整个 LLM 应用栈里的位置:

预训练基座模型
   ├─ 不动模型 → Prompt / RAG / Tool(改输入)
   └─ 动模型 → SFT → RLHF/DPO

很多人一上来就想微调,其实是顺序搞反了。微调是改动模型权重,成本最高、周期最长、效果最不可控,应该放在最后考虑。

判断标准

  • 80% 的场景不需要微调:Agent 工程的核心是编排——RAG、Tool、Memory、Graph,全在外层
  • 该微调的 20%:垂直领域术语注入、输出格式 prompt 锁不住、小模型能力补足
  • LoRA 的意义:冻结原模型,只训练 0.1%~1% 参数量,7B QLoRA 约 8GB 显存,主流游戏本跑得动

LoRA(Low-Rank Adaptation)的核心思路是:大模型的权重矩阵本身是低秩的,微调时不需要改整个矩阵,只需要加一个小的"旁路"矩阵来捕捉领域知识。训练时冻结原参数、只训旁路,推理时把旁路合并回原矩阵,效果和全量微调接近但成本低一个数量级。

实用决策顺序:先调 Prompt,再调 RAG,最后才动模型。微调解决静态知识,RAG 解决动态知识,两者搭配而非互替。


四、MCP:工具层的协议化

Function Calling 的局限

Function Calling 是 LLM 提供的 API 约定:你在请求里带上 functions 定义,模型决定什么时候调用哪个函数。问题在于,工具定义焊死在每一次 LLM 请求里,谁调用谁提供。

如果工具是另一个团队维护的、用另一种语言写的、部署在另一台机器上——Function Calling 的模式就很别扭:你得把工具定义复制粘贴到调用方,工具升级了调用方也得跟着改。

MCP 的两层设计

MCP(Model Context Protocol)把工具层拆成两步:

  • tools/list:工具发现——客户端问服务端"你有哪些工具",服务端返回工具列表和参数 Schema
  • tools/call:工具执行——客户端传工具名和参数,服务端执行后返回结果

这样工具层和 LLM 层彻底解耦。工具可以独立升级、独立测试、独立团队维护。代价是多一次网络往返。

维度Function CallingMCP
本质API 约定协议标准
工具定义随请求一起发运行时发现
绑定时机编译时运行时
跨语言
独立部署
网络开销无额外往返多一次 list 调用

最小 MCP Server 实现

用 Python FastMCP 写一个最小 Server:

python
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("calculator")

@mcp.tool()
def add(a: float, b: float) -> float:
    """两数相加"""
    return a + b

if __name__ == "__main__":
    mcp.run(transport="stdio")

逐段看:

第 1 行:导入 FastMCPfrom mcp.server.fastmcp import FastMCP —— FastMCP 是 MCP 官方的 Python 快速开发框架,类似 Flask 之于 HTTP。

第 3 行:创建 Server 实例mcp = FastMCP("calculator") —— 给这个 Server 起个名字叫 calculator。名字会在 tools/list 时返回给客户端,用来区分不同的 MCP Server。

第 5-7 行:注册工具@mcp.tool() 装饰器把一个普通 Python 函数注册成 MCP 工具。FastMCP 自动读函数签名(a: float, b: float)和 docstring(两数相加),转成 JSON Schema。客户端拿到后直接可以当 Function Calling 的 functions 定义用。

不用手写 Schema,不用手动处理 JSON-RPC 消息——装饰器搞定一切。这是 FastMCP 相比裸写 MCP Server 的最大便利。

第 9-10 行:启动 Servermcp.run(transport="stdio") —— 用 stdio 传输启动。stdio 是最简单的传输方式:客户端起一个子进程,通过 stdin/stdout 传 JSON-RPC 消息。生产环境可以换成 streamable-http

mcp dev calc_server.py 起 Inspector,浏览器里直接调 add(3, 4) 返回 7。


五、跨语言 Agent 实战

架构

场景:查上海天气,然后算上海比北京高几度。

架构是 Java 编排 + Python 工具,两个进程走 MCP stdio:

Java (Spring Boot + LangGraph4j)
  ├─ ChatClient(火山引擎 LLM,内部自动 ReAct)
  └─ MCP Client ──stdio──┬─ Python weather_server
                         └─ Python calc_server

Java 端负责编排和 LLM 交互,Python 端提供天气查询和计算器两个工具。两端通过 MCP stdio 通信,Java 端不需要知道工具的实现语言。

实际运行链路

LLM 自主调了 3 次工具:

[LLM 调用 get_weather("上海") → 30℃, 多云]
[LLM 调用 get_weather("北京") → 25℃, 晴]
[LLM 调用 subtract(30, 25) → 5]
→ 上海当前天气多云,气温30℃,比北京(晴,25℃)高5℃。

整个过程不需要人工干预,LLM 自己决定调什么工具、传什么参数、拿到结果后怎么组织回答。

前后对比

改之前改之后
工具注册Java Map 硬编码MCP 协议从 Python 进程拉取
工具绑定编译时,焊死在项目里运行时,Agent 不知道工具用什么语言写的
LLMFakeLlm 硬编码真实 LLM

工具层从"编译时绑定"变成"运行时发现"。

踩坑实录(Windows 用户必读)

  1. JDK 8 跑不起来:Spring AI 1.0 / text block 全要 JDK 17+,换成 Temurin 17
  2. Spring Boot 4.1 与 Spring AI 1.0.0 不兼容:降到 3.4.10
  3. 编码对不上:Python stdout 默认 GBK,JSON 里"北京"变乱码,Jackson 直接崩。两层修:Python 端 sys.stdout.reconfigure(encoding='utf-8') + JVM 端 -Dfile.encoding=UTF-8
  4. Maven 编译报"非法字符 '\u3002'":pom 里加 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

stdio 的编码坑是隐形税。生产环境建议换 streamable-http,HTTP 天然避开字节流编码问题。


选型判断:什么时候用什么

三层抽象都踩过之后,可以给出一个实用的选型框架:

  • 1-2 个工具、一次性调用:手写够了,省框架依赖
  • 3+ 工具、多轮迭代、要中断恢复:上图编排
  • 工具要跨语言、独立部署:上 MCP

实际写过的判断标准:手撕过 ReAct while 循环,用 LangGraph 画过带回环的图,通过 MCP 让 Java 编排调 Python 工具,LLM 自主调了 3 次工具完成任务——底层原理和上层编排都清楚。


参考:LangGraph4j 1.8.20 官方文档 · modelcontextprotocol.io 规范

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1