主题
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 决定下一跳去哪——这是实现循环和分支的关键
手写循环里的 while、if、break,在图里全部变成了边和节点的组合。
最简 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 框架对照
| 手写 ReAct | LangGraph 图 |
|---|---|
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:工具发现——客户端问服务端"你有哪些工具",服务端返回工具列表和参数 Schematools/call:工具执行——客户端传工具名和参数,服务端执行后返回结果
这样工具层和 LLM 层彻底解耦。工具可以独立升级、独立测试、独立团队维护。代价是多一次网络往返。
| 维度 | Function Calling | MCP |
|---|---|---|
| 本质 | 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_serverJava 端负责编排和 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 不知道工具用什么语言写的 |
| LLM | FakeLlm 硬编码 | 真实 LLM |
工具层从"编译时绑定"变成"运行时发现"。
踩坑实录(Windows 用户必读)
- JDK 8 跑不起来:Spring AI 1.0 / text block 全要 JDK 17+,换成 Temurin 17
- Spring Boot 4.1 与 Spring AI 1.0.0 不兼容:降到 3.4.10
- 编码对不上:Python stdout 默认 GBK,JSON 里"北京"变乱码,Jackson 直接崩。两层修:Python 端
sys.stdout.reconfigure(encoding='utf-8')+ JVM 端-Dfile.encoding=UTF-8 - 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 规范