LangGraph 入门 + MCP 试水:从手写循环到图编排
一个 8 年 Java 后端,把手写的 ReAct while 循环画成 LangGraph 的图,再用 MCP 协议把工具层从 Java 拆到 Python 的一周复盘。
核心就一句话:Agent 不是 DAG,Agent 是循环。LangGraph 把 while 循环变成了图,MCP 把工具变成了协议。
缘起:手写 ReAct 的 while 循环到底丑在哪
手写 ReAct 的核心结构长这样:
while (true) {
String reply = callLlm(messages);
if (reply.contains("Final Answer")) break; // if 判断
String obs = callTool(parse(reply)); // 工具调用
messages.add(obs); // 追加历史
}能跑,但丑在三个地方:
- 控制流和业务焊死:想加个"调完工具先人工确认"的分支?改 while 循环体
- 中断即丢失:进程崩了,
messages在内存里,一切重来 - 图看不见:流程长什么样只能脑补,新人接手先读 200 行循环
本周的主题就是把这三个丑点挨个解决:LangGraph 管"流程",Checkpoint 管"中断",MCP 管"工具在哪"。
一、StateGraph:把 while 和 if 画成图
LangGraph 的核心抽象只有四个:State / Node / Edge / ConditionalEdge。
最简 ReAct 图:
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()), // ← 手写的 if
Map.of("tool", "tool", "end", END))
.addEdge("tool", "llm"); // ← 手写的 while 回环对照关系一目了然:
| 手写 ReAct | LangGraph 图 |
|---|---|
while(true){} 循环体 | 回环边 tool → llm |
if(final) break | 条件边命中 "end" → END |
callTool() | tool 节点 |
history.add(msg) | SCHEMA 里的 Channels.appender |
compile() 之后还能直接吐 PlantUML,那个六边形条件节点就是被物化成图的 if 判断。控制流从代码里的关键字,变成了可声明、可持久化、可视化的数据结构——这是本周第一个认知升级。
State 不是 HashMap,是带 reducer 的状态机
第二个坑/收获:State 的每个字段要声明合并策略(reducer):
public static final Map<String, Channel<?>> SCHEMA = Map.of(
"messages", Channels.appender(ArrayList::new), // 追加:消息历史
"intermediateSteps", Channels.appender(ArrayList::new)
// nextAction 不声明 → 默认覆盖,只留最新决策
);Node 只 return Map.of("key", value) 增量片段,引擎按字段的 reducer 合并。消息要追加、标志位要覆盖,同一个 State 里两种策略混用——想通这个,手写版的 history.add() 在框架里对应什么就彻底清楚了。
二、Checkpoint:Agent 的虚拟机快照
中断恢复实验:第一次跑在迭代第 3 步 break 模拟崩溃,第二次用同一个 threadId 重启:
第一次:llm(steps=0) → tool(step-1) → 💥 崩溃
第二次:llm(steps=1) → tool(step-2) → llm(steps=2) → END ✅关键发现是 Checkpoint 有两层:
- 数据层:messages / intermediateSteps —— 恢复成功
- 执行指针层:上次停在哪个节点 —— 这个 Demo 没恢复,从 START 重走,靠节点逻辑读
steps.size()跳过已执行步骤
和 Spring AI 的 ChatMemory 对比最直观:ChatMemory 是聊天记录.txt,Checkpoint 是虚拟机快照。前者只记住"聊过啥",后者要记住"跑到哪了"。Agent 框架和普通框架的本质区别就在这——Agent 是长时运行任务,必须支持半路续跑。
三、微调:最后一张牌,不是第一张
岗位技能云里"LLM 微调(SFT/RL)"连续多天出现,得能说清楚边界。一张图记住微调的位置:
预训练基座模型
├─ 不微调 → Prompt / RAG / Tool(改输入,不碰模型)
└─ 微调 → SFT → RLHF/DPO(改模型本身)对 Java 转 Agent 的判断标准:
- 80% 场景不需要微调:Agent 工程的核心是编排——RAG、Tool、Memory、Graph,全在外面
- 该微调的 20%:垂直领域术语注入、输出格式 Prompt 锁不死、小模型补能力
- LoRA 的意义:冻结原模型、只训 0.1%-1% 参数,7B QLoRA 约 8GB 显存,个人游戏本也能玩
一句话给面试:"先调 Prompt,再调 RAG,最后才调模型。微调是静态知识,RAG 是动态知识,两者搭配而不是互替。"
四、MCP:工具层的 HTTP 协议
最小 MCP Server 用 Python FastMCP 十几行:
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")FastMCP 自动把函数签名 + docstring 转成 JSON Schema,客户端拿到的就是 Function Calling 的 functions 定义。mcp dev calc_server.py 起 Inspector,浏览器里直接调 add(3, 4) 返回 7。
MCP 和 Function Calling 的关系,本周想清楚的最值钱的一个问题:
- Function Calling 是 API 约定:工具定义塞在一次 LLM 请求里,谁调谁提供焊死在一起
- MCP 是 协议标准:拆成
tools/list(发现)+tools/call(执行)两步,工具层和 LLM 层彻底解耦
代价是多一次往返,收益是跨语言、跨服务、独立部署——工具可以独立升级、独立测试、独立团队维护。
五、收网:LangGraph + MCP 跨语言 Agent
把整条链路串起来。场景一句话:
"查一下上海的天气,然后算一下上海比北京高多少度。"
架构是 Java 编排 + Python 工具,两个进程走 MCP stdio:
Java (Spring Boot + LangGraph4j)
├─ ChatClient(火山引擎 LLM,内部自动 ReAct)
└─ MCP Client ──stdio──┬─ Python weather_server(get_weather)
└─ Python calc_server(add/subtract)跑出来的实际链路,LLM 自主调了 3 次工具,零人工干预:
[LLM 调用 get_weather("上海") → 30℃, 多云]
[LLM 调用 get_weather("北京") → 25℃, 晴]
[LLM 调用 subtract(30, 25) → 5]
→ 上海当前天气多云,气温30℃,比北京(晴,25℃)高5℃。对比手写工具注册的变化最能说明问题:
| 项 | 手写注册表 | MCP 协议 |
|---|---|---|
| 工具注册 | Tools.REGISTRY Java Map | MCP 协议从 Python 进程拉取 |
| 工具绑定 | 编译时,焊死在项目里 | 运行时,Agent 不知道工具用什么语言写的 |
| 工具部署 | 同进程同项目 | 独立进程、独立升级 |
工具层从"编译时绑定"变成"运行时发现"——这就是 Agent 工具生态的基础设施。
踩坑实录(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 编译报"非法字符":pom 里加
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
stdio 的编码坑是隐形税——生产环境应该换 streamable-http,用 HTTP 天然避开字节流编码问题。
总结:三层抽象都站过之后
手写 while 循环 → 框架一行调用 → 把控制流画成图、把工具拆成协议。三层抽象都站过,现在的判断是:
- 1-2 个工具、一次性调用:手写够了,省框架依赖
- 3+ 工具、多轮迭代、要中断恢复:上图编排
- 工具要跨语言、独立部署:上 MCP
面试被问"你写过 Agent 吗",现在的答案是:手撕过 ReAct 的 while 循环,也用 LangGraph 画过带回环的图,最后通过 MCP 让 Java 编排调 Python 工具,LLM 自主调了 3 次工具完成任务。底层原理和上层编排,都不虚。
参考:LangGraph4j 1.8.20 官方文档 · modelcontextprotocol.io 规范 · Spring AI 1.0.0 MCP Client 文档