从0到1,用 LangGraph4j + Spring AI 搭一个多 Agent 应用
本文目录 · 30 节
星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-08-05
从0到1,用 LangGraph4j + Spring AI 搭一个多 Agent 应用
从0到1,用 LangGraph4j + Spring AI 搭一个多 Agent 应用
举个简单的例子。
用户说“帮我推荐武汉的旅游攻略”,背后至少需要四步,查天气、根据天气推荐游玩项目、根据项目推荐美食、把所有结果整合成一篇图文。
四步分别需要不同的提示词和工具,硬塞进一个 Agent 里,提示词很容易就膨胀到没办法维护。
这篇内容用一个旅游推荐的案例,演示怎么用 LangGraph4j + Spring AI 把四个 Agent 编排成一张有向图。技术派: https://paicoding.com/jobclaw-create-agent-from-scratch
01、为什么需要多 Agent?
先看一个具体的需求。
用户输入一个地址,系统先查询当地天气。如果用户还要求做旅游推荐,就继续调用旅游 Agent 和美食 Agent,最后生成一篇小红书风格的图文攻略;如果只是问天气,直接返回天气预报就结束了。

这个需求拆下来有四个 Agent。
-
天气 Agent 通过 Function Call 调用天气工具,拿到实时天气数据
-
旅游 Agent 根据天气推荐适合当前天气的游玩项目
-
美食 Agent 根据游玩项目推荐附近美食,用结构化输出返回 Java 对象
-
图文 Agent 把前面所有结果整合成一篇小红书风格的内容

02、LangGraph4j 把 Agent 变成了一张图
LangGraph4j 是 LangGraph 的 Java 实现,它提供了一种编排多个 Agent 的方式,把协作关系描述成一张有向图。

State 是图的全局共享状态。所有 Node 共享同一份 State 实例,每个 Node 执行完之后把结果写回 State,下一个 Node 从 State 里读取前置数据。
Node 是处理节点,每个 Node 对应一个 Agent。它接收当前的 State,执行自己的逻辑(通常是调用一次大模型),返回一个 Map 写回 State。
Edge 是节点之间的连线,决定了执行顺序。 A → B 表示 A 执行完之后自动轮到 B。
ConditionalEdge 是条件分支,根据 State 中的内容决定走哪条路。比如“用户要推荐就走旅游 Agent,不要推荐就直接跳到图文 Agent”。
Java
new StateGraph<>(TravelState.serializer())
.addNode("weatherAgent", node_async(weatherAgent::callWeatherAgent))
.addNode("travelAgent", node_async(travelAgent::callTravelAgent))
.addNode("foodAgent", node_async(foodAgent::callFoodAgent))
.addNode("xhsAgent", node_async(xhsBlogAgent::callResponseAgent))
.addEdge(START, "weatherAgent")
.addConditionalEdges("weatherAgent", edge_async(shouldContinue), ...)
.addEdge("xhsAgent", END);
7 行代码,四个 Agent 的执行顺序和分支逻辑就全部定义完了。
node_async 把普通的 Java 方法包装成异步节点, edge_async 把条件判断包装成异步边。整张图从 START 开始,到 END 结束。
编译之后可以导出 PlantUML 流程图,两条执行路径清清楚楚。

技术栈版本是 Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4。
大模型选择了智谱的 glm-4.7-flash(免费,支持 200K 上下文,且支持 Function Call)。
Maven 依赖只需要三个包, langgraph4j-springai-agentexecutor(LangGraph4j 和 Spring AI 的集成)、 spring-boot-starter-web(HTTP 接口)、 spring-ai-starter-model-zhipuai(智谱模型接入)。
03、State 怎么设计?
四个 Agent 之间有数据依赖关系。天气 Agent 查完天气之后,旅游 Agent 得拿到天气信息才能做推荐;美食 Agent 得拿到旅游项目才能推荐美食。
这些在节点之间流转的共享数据,就是 State。
自定义一个状态类继承 AgentState,为每个 Agent 的输出各定义一个字段。
Java
public class TravelState extends AgentState {
public static final String INPUT = "input"; // 用户原始输入
public static final String WEATHER = "weather"; // 天气信息
public static final String TRAVEL = "recommendation"; // 游玩项目
public static final String FOOD = "food"; // 美食推荐
public static final String BLOG = "blog"; // 最终图文
}
每个 Agent 执行完之后,把结果以 Map.of(TravelState.WEATHER, response) 的形式返回,LangGraph4j 会自动把返回值合并到全局 State 中。下一个 Node 通过 state.value("weather") 就能拿到前置节点的输出。
整个数据流转不需要 Agent 之间直接调用方法,完全通过 State 这块“白板”中转。
不过这里有一个容易踩的坑。
LangGraph4j 默认用 Java 的 ObjectOutputStream 做状态序列化。如果 State 里存的都是 String 类型,没有任何问题。一旦存了自定义的 POJO(比如美食 Agent 返回的嵌套 record 对象),默认的序列化方式就会报错。
解决办法是给这些 POJO 注册一个 JSON 序列化器。
Java
public static StateSerializer serializer() {
var s = new ObjectStreamStateSerializer<>(TravelState::new);
s.mapper().register(TravelFoodRecommends.class, new JsonSerializer<>(...));
s.mapper().register(TravelResVo.class, new JsonSerializer<>(...));
s.mapper().register(SimpleResVo.class, new JsonSerializer<>(...));
return s;
}
先把 POJO 序列化成 JSON 字符串再存进 State,反序列化时再还原回 Java 对象。
这个方案还有一个额外的好处,JSON 是人类可读的格式,调试的时候可以直接打印 State 的内容,不需要反序列化就能看到每个字段里存了什么。
求职派的岗位数据流水线也用了同样的方式。它的 State 里存了采集任务、清洗结果、发布记录等多种复杂对象,全部通过 JsonSerializer 统一处理,跨节点传递没有出过序列化问题。

04、四个 Agent,各管一段
每个 Agent 在 LangGraph4j 里就是一个 Node,说到底就是一个 Java 方法。方法签名是固定的,接收当前的 TravelState,返回 Map。
四个 Agent 各用了一种不同的 Spring AI 能力,正好覆盖了多 Agent 开发中最常用的几种模式。

天气 Agent
天气数据需要通过工具调用来获取。用 Spring AI 的 @Tool 注解定义一个天气查询工具。
Java
@Tool(description = "根据传入的地区,返回对应地区的当前天气")
public WeatherResponse currentWeatherFunction(WeatherRequest request) {
// 真实场景中调用第三方天气 API,这里用随机数据模拟
return new WeatherResponse(request.area(), randomWeather());
}
WeatherRequest 和 WeatherResponse 都是 record 类型,字段上的 @JsonPropertyDescription 注解告诉大模型每个参数的含义。
把这个工具注册到 ChatClient 上之后,天气 Agent 只需要发送一条提示词(“请查询这个位置的天气”),大模型识别到需要查天气时,会自动触发 Function Call 调用这个方法,拿到结果后组织成自然语言返回。
天气 Agent 的核心方法只有两行,发送提示词、把返回值写进 State。
Function Call 的调用过程对 Agent 来说完全透明,工具的发现和执行全部由 Spring AI 框架处理。Agent 不需要关心“怎么调工具”,只管“问对问题”。

旅游 Agent 和美食 Agent
旅游 Agent 展示的是 PromptTemplate 的用法。它从 State 里读取用户输入和天气信息,通过 {area} 和 {weather} 占位符拼成一条完整的提示词(“请结合当前天气推荐三个游玩项目”),调用 render() 传入实际值后发给大模型。多个变量需要拼接成一条提示词时, PromptTemplate 比手动字符串拼接可读性好得多。
美食 Agent 需要大模型返回一个结构化的 Java 对象,而不是一段自由文本。每个游玩项目对应三种推荐美食,每种美食有名称和推荐理由。
Spring AI 的 BeanOutputConverter 解决了这个问题。它根据目标类的字段定义自动生成 JSON Schema,附在提示词末尾告诉大模型“请按这个格式返回”。
Java
var converter = new BeanOutputConverter<>(TravelFoodRecommends.class);
String prompt = new PromptTemplate("{weather}\n{travel}\n{format}")
.render(Map.of("weather", state.getWeather(),
"travel", state.getTravel(), "format", converter.getFormat()));
var result = converter.convert(agentService.food(prompt));
return Map.of(TravelState.FOOD, result);
getFormat() 生成精确的 JSON Schema 描述,大模型按照这个格式输出 JSON, convert() 方法自动反序列化成 Java 对象。
这也是前面 State 需要注册 JSON 序列化器的原因,嵌套的 record 结构用默认的 ObjectOutputStream 根本处理不了。

图文 Agent
图文 Agent 是最后一个节点,它需要判断数据从哪条路径过来。
如果经过了旅游 Agent 和美食 Agent,手上有完整的推荐数据,就用“小红书旅游博主”的系统提示词生成一篇图文;如果是从天气 Agent 直接过来的(用户没有要求推荐),就用“天气预报专家”的系统提示词生成一段天气预报文案。
判断方式很简单,检查 State 中的美食数据是否为空。
不为空说明走了完整的推荐路径,为空说明只查了天气。两条路径最终都输出一个结构化的博文对象(包含标题和正文),写进 State 的 BLOG 字段。

05、把图编译起来
四个 Agent 写完之后,最后一步是把它们拼成完整的图、编译、执行。
关键在于条件分支。天气 Agent 执行完之后,怎么决定下一步走旅游推荐还是直接跳到图文生成?
LangGraph4j 用 addConditionalEdges 实现条件路由,核心就是一个 lambda 表达式。
Java
EdgeAction shouldContinue = state ->
state.getInput().contains("推荐") ? "recommend" : "xhs";
graph.addConditionalEdges("weatherAgent", edge_async(shouldContinue),
Map.of("recommend", "travelAgent", "xhs", "xhsAgent"));
如果用户的输入里包含了“推荐”两个字,走旅游推荐路径(天气 → 旅游 → 美食 → 图文);否则直接跳到图文 Agent(天气 → 图文)。lambda 返回一个字符串作为路由键, Map 参数把路由键映射到具体的 Node 名称。
这个判断逻辑在实际项目中可以替换成更复杂的策略。求职派的消息路由器就用了“关键词匹配 + LLM 复合分类”的方案,先走关键词匹配(零成本、毫秒级),未命中的再调用大模型做意图分类,兼顾了速度和准确率。
图定义完成后,编译并执行只需要两行。
Java
var compiled = graph.compile();
TravelState result = compiled
.invoke(Map.of(TravelState.INPUT, "我准备去武汉旅游,帮我做些推荐"))
.orElseGet(() -> new TravelState(Map.of()));
传入用户的输入,LangGraph4j 按照图的定义依次执行每个 Node,最终返回包含所有 Agent 执行结果的 State。从 State 里取出 BLOG 字段,就是最终生成的图文内容。在 Spring Boot 里包一个 @GetMapping 接口就可以直接通过浏览器访问了。
走推荐路径的结果(输入包含“推荐”)。

不走推荐、只返回天气预报的结果。

两条路径,四个 Agent,同一张图,运行结果截然不同。

文中的完整代码可以在 spring-ai-demo 项目中获取。
ending
【四个 Agent,四种 Spring AI 能力——Function Call、PromptTemplate、结构化输出、条件路由,拼成一张 LangGraph4j 的有向图。】
多 Agent 的价值不在于 Agent 的数量,在于每个 Agent 的职责足够小、提示词足够短、出了问题能精确定位到具体环节。
求职派的岗位数据流水线也是同样的思路——分类、采集、清洗、发布,四个节点各做一件事,条件边控制流转方向。这个模式一旦跑通了,加新的 Agent 就是往图上插一个 Node 的事。
我们下期见。
如何把求职派写到简历上?
项目名称:求职派(JobClaw),基于多 Agent 的智能求职系统
项目简介:OpenClaw 风格的多 Agent 求职平台,IM 渠道接入共享 Agent 内核,消息经意图分类后路由到可插拔的业务 Agent,支持身份采集、岗位检索、岗位推荐等核心功能。
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4、Spring Modulith
核心职责:
-
基于 LangGraph4j 的 StateGraph 构建多 Agent 编排工作流,将岗位数据采集流程拆分为分类、采集、清洗、发布四个独立节点,利用条件边实现流程自动分支和异常提前终止
-
设计自定义状态类和 JSON 序列化方案,解决 LangGraph4j 默认序列化不支持复杂 POJO 跨节点传递的问题
-
基于 Spring AI 的 @Tool 注解实现外部工具动态集成,支持天气查询、网页爬取、文件解析等多种工具的 Function Call 调用
-
实现基于关键词匹配和大模型复合分类的意图识别策略,先走零成本关键词匹配再兜底 LLM 分类,消息路由准确率超过 95%
-
封装三层 LLM 调用体系,从基础调用到业务 Agent 到用户偏好适配逐层递进,业务 Agent 只需实现接口即可自动注册到路由系统
举个简单的例子。
用户说“帮我推荐武汉的旅游攻略”,背后至少需要四步,查天气、根据天气推荐游玩项目、根据项目推荐美食、把所有结果整合成一篇图文。
四步分别需要不同的提示词和工具,硬塞进一个 Agent 里,提示词很容易就膨胀到没办法维护。
这篇内容用一个旅游推荐的案例,演示怎么用 LangGraph4j + Spring AI 把四个 Agent 编排成一张有向图。技术派: https://paicoding.com/jobclaw-create-agent-from-scratch
01、为什么需要多 Agent?
先看一个具体的需求。
用户输入一个地址,系统先查询当地天气。如果用户还要求做旅游推荐,就继续调用旅游 Agent 和美食 Agent,最后生成一篇小红书风格的图文攻略;如果只是问天气,直接返回天气预报就结束了。

这个需求拆下来有四个 Agent。
-
天气 Agent 通过 Function Call 调用天气工具,拿到实时天气数据
-
旅游 Agent 根据天气推荐适合当前天气的游玩项目
-
美食 Agent 根据游玩项目推荐附近美食,用结构化输出返回 Java 对象
-
图文 Agent 把前面所有结果整合成一篇小红书风格的内容

02、LangGraph4j 把 Agent 变成了一张图
LangGraph4j 是 LangGraph 的 Java 实现,它提供了一种编排多个 Agent 的方式,把协作关系描述成一张有向图。

State 是图的全局共享状态。所有 Node 共享同一份 State 实例,每个 Node 执行完之后把结果写回 State,下一个 Node 从 State 里读取前置数据。
Node 是处理节点,每个 Node 对应一个 Agent。它接收当前的 State,执行自己的逻辑(通常是调用一次大模型),返回一个 Map 写回 State。
Edge 是节点之间的连线,决定了执行顺序。 A → B 表示 A 执行完之后自动轮到 B。
ConditionalEdge 是条件分支,根据 State 中的内容决定走哪条路。比如“用户要推荐就走旅游 Agent,不要推荐就直接跳到图文 Agent”。
Java
new StateGraph<>(TravelState.serializer())
.addNode("weatherAgent", node_async(weatherAgent::callWeatherAgent))
.addNode("travelAgent", node_async(travelAgent::callTravelAgent))
.addNode("foodAgent", node_async(foodAgent::callFoodAgent))
.addNode("xhsAgent", node_async(xhsBlogAgent::callResponseAgent))
.addEdge(START, "weatherAgent")
.addConditionalEdges("weatherAgent", edge_async(shouldContinue), ...)
.addEdge("xhsAgent", END);
7 行代码,四个 Agent 的执行顺序和分支逻辑就全部定义完了。
node_async 把普通的 Java 方法包装成异步节点, edge_async 把条件判断包装成异步边。整张图从 START 开始,到 END 结束。
编译之后可以导出 PlantUML 流程图,两条执行路径清清楚楚。

技术栈版本是 Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4。
大模型选择了智谱的 glm-4.7-flash(免费,支持 200K 上下文,且支持 Function Call)。
Maven 依赖只需要三个包, langgraph4j-springai-agentexecutor(LangGraph4j 和 Spring AI 的集成)、 spring-boot-starter-web(HTTP 接口)、 spring-ai-starter-model-zhipuai(智谱模型接入)。
03、State 怎么设计?
四个 Agent 之间有数据依赖关系。天气 Agent 查完天气之后,旅游 Agent 得拿到天气信息才能做推荐;美食 Agent 得拿到旅游项目才能推荐美食。
这些在节点之间流转的共享数据,就是 State。
自定义一个状态类继承 AgentState,为每个 Agent 的输出各定义一个字段。
Java
public class TravelState extends AgentState {
public static final String INPUT = "input"; // 用户原始输入
public static final String WEATHER = "weather"; // 天气信息
public static final String TRAVEL = "recommendation"; // 游玩项目
public static final String FOOD = "food"; // 美食推荐
public static final String BLOG = "blog"; // 最终图文
}
每个 Agent 执行完之后,把结果以 Map.of(TravelState.WEATHER, response) 的形式返回,LangGraph4j 会自动把返回值合并到全局 State 中。下一个 Node 通过 state.value("weather") 就能拿到前置节点的输出。
整个数据流转不需要 Agent 之间直接调用方法,完全通过 State 这块“白板”中转。
不过这里有一个容易踩的坑。
LangGraph4j 默认用 Java 的 ObjectOutputStream 做状态序列化。如果 State 里存的都是 String 类型,没有任何问题。一旦存了自定义的 POJO(比如美食 Agent 返回的嵌套 record 对象),默认的序列化方式就会报错。
解决办法是给这些 POJO 注册一个 JSON 序列化器。
Java
public static StateSerializer serializer() {
var s = new ObjectStreamStateSerializer<>(TravelState::new);
s.mapper().register(TravelFoodRecommends.class, new JsonSerializer<>(...));
s.mapper().register(TravelResVo.class, new JsonSerializer<>(...));
s.mapper().register(SimpleResVo.class, new JsonSerializer<>(...));
return s;
}
先把 POJO 序列化成 JSON 字符串再存进 State,反序列化时再还原回 Java 对象。
这个方案还有一个额外的好处,JSON 是人类可读的格式,调试的时候可以直接打印 State 的内容,不需要反序列化就能看到每个字段里存了什么。
求职派的岗位数据流水线也用了同样的方式。它的 State 里存了采集任务、清洗结果、发布记录等多种复杂对象,全部通过 JsonSerializer 统一处理,跨节点传递没有出过序列化问题。

04、四个 Agent,各管一段
每个 Agent 在 LangGraph4j 里就是一个 Node,说到底就是一个 Java 方法。方法签名是固定的,接收当前的 TravelState,返回 Map。
四个 Agent 各用了一种不同的 Spring AI 能力,正好覆盖了多 Agent 开发中最常用的几种模式。

天气 Agent
天气数据需要通过工具调用来获取。用 Spring AI 的 @Tool 注解定义一个天气查询工具。
Java
@Tool(description = "根据传入的地区,返回对应地区的当前天气")
public WeatherResponse currentWeatherFunction(WeatherRequest request) {
// 真实场景中调用第三方天气 API,这里用随机数据模拟
return new WeatherResponse(request.area(), randomWeather());
}
WeatherRequest 和 WeatherResponse 都是 record 类型,字段上的 @JsonPropertyDescription 注解告诉大模型每个参数的含义。
把这个工具注册到 ChatClient 上之后,天气 Agent 只需要发送一条提示词(“请查询这个位置的天气”),大模型识别到需要查天气时,会自动触发 Function Call 调用这个方法,拿到结果后组织成自然语言返回。
天气 Agent 的核心方法只有两行,发送提示词、把返回值写进 State。
Function Call 的调用过程对 Agent 来说完全透明,工具的发现和执行全部由 Spring AI 框架处理。Agent 不需要关心“怎么调工具”,只管“问对问题”。

旅游 Agent 和美食 Agent
旅游 Agent 展示的是 PromptTemplate 的用法。它从 State 里读取用户输入和天气信息,通过 {area} 和 {weather} 占位符拼成一条完整的提示词(“请结合当前天气推荐三个游玩项目”),调用 render() 传入实际值后发给大模型。多个变量需要拼接成一条提示词时, PromptTemplate 比手动字符串拼接可读性好得多。
美食 Agent 需要大模型返回一个结构化的 Java 对象,而不是一段自由文本。每个游玩项目对应三种推荐美食,每种美食有名称和推荐理由。
Spring AI 的 BeanOutputConverter 解决了这个问题。它根据目标类的字段定义自动生成 JSON Schema,附在提示词末尾告诉大模型“请按这个格式返回”。
Java
var converter = new BeanOutputConverter<>(TravelFoodRecommends.class);
String prompt = new PromptTemplate("{weather}\n{travel}\n{format}")
.render(Map.of("weather", state.getWeather(),
"travel", state.getTravel(), "format", converter.getFormat()));
var result = converter.convert(agentService.food(prompt));
return Map.of(TravelState.FOOD, result);
getFormat() 生成精确的 JSON Schema 描述,大模型按照这个格式输出 JSON, convert() 方法自动反序列化成 Java 对象。
这也是前面 State 需要注册 JSON 序列化器的原因,嵌套的 record 结构用默认的 ObjectOutputStream 根本处理不了。

图文 Agent
图文 Agent 是最后一个节点,它需要判断数据从哪条路径过来。
如果经过了旅游 Agent 和美食 Agent,手上有完整的推荐数据,就用“小红书旅游博主”的系统提示词生成一篇图文;如果是从天气 Agent 直接过来的(用户没有要求推荐),就用“天气预报专家”的系统提示词生成一段天气预报文案。
判断方式很简单,检查 State 中的美食数据是否为空。
不为空说明走了完整的推荐路径,为空说明只查了天气。两条路径最终都输出一个结构化的博文对象(包含标题和正文),写进 State 的 BLOG 字段。

05、把图编译起来
四个 Agent 写完之后,最后一步是把它们拼成完整的图、编译、执行。
关键在于条件分支。天气 Agent 执行完之后,怎么决定下一步走旅游推荐还是直接跳到图文生成?
LangGraph4j 用 addConditionalEdges 实现条件路由,核心就是一个 lambda 表达式。
Java
EdgeAction shouldContinue = state ->
state.getInput().contains("推荐") ? "recommend" : "xhs";
graph.addConditionalEdges("weatherAgent", edge_async(shouldContinue),
Map.of("recommend", "travelAgent", "xhs", "xhsAgent"));
如果用户的输入里包含了“推荐”两个字,走旅游推荐路径(天气 → 旅游 → 美食 → 图文);否则直接跳到图文 Agent(天气 → 图文)。lambda 返回一个字符串作为路由键, Map 参数把路由键映射到具体的 Node 名称。
这个判断逻辑在实际项目中可以替换成更复杂的策略。求职派的消息路由器就用了“关键词匹配 + LLM 复合分类”的方案,先走关键词匹配(零成本、毫秒级),未命中的再调用大模型做意图分类,兼顾了速度和准确率。
图定义完成后,编译并执行只需要两行。
Java
var compiled = graph.compile();
TravelState result = compiled
.invoke(Map.of(TravelState.INPUT, "我准备去武汉旅游,帮我做些推荐"))
.orElseGet(() -> new TravelState(Map.of()));
传入用户的输入,LangGraph4j 按照图的定义依次执行每个 Node,最终返回包含所有 Agent 执行结果的 State。从 State 里取出 BLOG 字段,就是最终生成的图文内容。在 Spring Boot 里包一个 @GetMapping 接口就可以直接通过浏览器访问了。
走推荐路径的结果(输入包含“推荐”)。

不走推荐、只返回天气预报的结果。

两条路径,四个 Agent,同一张图,运行结果截然不同。

文中的完整代码可以在 spring-ai-demo 项目中获取。
ending
【四个 Agent,四种 Spring AI 能力——Function Call、PromptTemplate、结构化输出、条件路由,拼成一张 LangGraph4j 的有向图。】
多 Agent 的价值不在于 Agent 的数量,在于每个 Agent 的职责足够小、提示词足够短、出了问题能精确定位到具体环节。
求职派的岗位数据流水线也是同样的思路——分类、采集、清洗、发布,四个节点各做一件事,条件边控制流转方向。这个模式一旦跑通了,加新的 Agent 就是往图上插一个 Node 的事。
我们下期见。
如何把求职派写到简历上?
项目名称:求职派(JobClaw),基于多 Agent 的智能求职系统
项目简介:OpenClaw 风格的多 Agent 求职平台,IM 渠道接入共享 Agent 内核,消息经意图分类后路由到可插拔的业务 Agent,支持身份采集、岗位检索、岗位推荐等核心功能。
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4、Spring Modulith
核心职责:
-
基于 LangGraph4j 的 StateGraph 构建多 Agent 编排工作流,将岗位数据采集流程拆分为分类、采集、清洗、发布四个独立节点,利用条件边实现流程自动分支和异常提前终止
-
设计自定义状态类和 JSON 序列化方案,解决 LangGraph4j 默认序列化不支持复杂 POJO 跨节点传递的问题
-
基于 Spring AI 的 @Tool 注解实现外部工具动态集成,支持天气查询、网页爬取、文件解析等多种工具的 Function Call 调用
-
实现基于关键词匹配和大模型复合分类的意图识别策略,先走零成本关键词匹配再兜底 LLM 分类,消息路由准确率超过 95%
-
封装三层 LLM 调用体系,从基础调用到业务 Agent 到用户偏好适配逐层递进,业务 Agent 只需实现接口即可自动注册到路由系统
举个简单的例子。
用户说“帮我推荐武汉的旅游攻略”,背后至少需要四步,查天气、根据天气推荐游玩项目、根据项目推荐美食、把所有结果整合成一篇图文。
四步分别需要不同的提示词和工具,硬塞进一个 Agent 里,提示词很容易就膨胀到没办法维护。
这篇内容用一个旅游推荐的案例,演示怎么用 LangGraph4j + Spring AI 把四个 Agent 编排成一张有向图。技术派: https://paicoding.com/jobclaw-create-agent-from-scratch
01、为什么需要多 Agent?
先看一个具体的需求。
用户输入一个地址,系统先查询当地天气。如果用户还要求做旅游推荐,就继续调用旅游 Agent 和美食 Agent,最后生成一篇小红书风格的图文攻略;如果只是问天气,直接返回天气预报就结束了。

这个需求拆下来有四个 Agent。
-
天气 Agent 通过 Function Call 调用天气工具,拿到实时天气数据
-
旅游 Agent 根据天气推荐适合当前天气的游玩项目
-
美食 Agent 根据游玩项目推荐附近美食,用结构化输出返回 Java 对象
-
图文 Agent 把前面所有结果整合成一篇小红书风格的内容

02、LangGraph4j 把 Agent 变成了一张图
LangGraph4j 是 LangGraph 的 Java 实现,它提供了一种编排多个 Agent 的方式,把协作关系描述成一张有向图。

State 是图的全局共享状态。所有 Node 共享同一份 State 实例,每个 Node 执行完之后把结果写回 State,下一个 Node 从 State 里读取前置数据。
Node 是处理节点,每个 Node 对应一个 Agent。它接收当前的 State,执行自己的逻辑(通常是调用一次大模型),返回一个 Map 写回 State。
Edge 是节点之间的连线,决定了执行顺序。 A → B 表示 A 执行完之后自动轮到 B。
ConditionalEdge 是条件分支,根据 State 中的内容决定走哪条路。比如“用户要推荐就走旅游 Agent,不要推荐就直接跳到图文 Agent”。
Java
new StateGraph<>(TravelState.serializer())
.addNode("weatherAgent", node_async(weatherAgent::callWeatherAgent))
.addNode("travelAgent", node_async(travelAgent::callTravelAgent))
.addNode("foodAgent", node_async(foodAgent::callFoodAgent))
.addNode("xhsAgent", node_async(xhsBlogAgent::callResponseAgent))
.addEdge(START, "weatherAgent")
.addConditionalEdges("weatherAgent", edge_async(shouldContinue), ...)
.addEdge("xhsAgent", END);
7 行代码,四个 Agent 的执行顺序和分支逻辑就全部定义完了。
node_async 把普通的 Java 方法包装成异步节点, edge_async 把条件判断包装成异步边。整张图从 START 开始,到 END 结束。
编译之后可以导出 PlantUML 流程图,两条执行路径清清楚楚。

技术栈版本是 Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4。
大模型选择了智谱的 glm-4.7-flash(免费,支持 200K 上下文,且支持 Function Call)。
Maven 依赖只需要三个包, langgraph4j-springai-agentexecutor(LangGraph4j 和 Spring AI 的集成)、 spring-boot-starter-web(HTTP 接口)、 spring-ai-starter-model-zhipuai(智谱模型接入)。
03、State 怎么设计?
四个 Agent 之间有数据依赖关系。天气 Agent 查完天气之后,旅游 Agent 得拿到天气信息才能做推荐;美食 Agent 得拿到旅游项目才能推荐美食。
这些在节点之间流转的共享数据,就是 State。
自定义一个状态类继承 AgentState,为每个 Agent 的输出各定义一个字段。
Java
public class TravelState extends AgentState {
public static final String INPUT = "input"; // 用户原始输入
public static final String WEATHER = "weather"; // 天气信息
public static final String TRAVEL = "recommendation"; // 游玩项目
public static final String FOOD = "food"; // 美食推荐
public static final String BLOG = "blog"; // 最终图文
}
每个 Agent 执行完之后,把结果以 Map.of(TravelState.WEATHER, response) 的形式返回,LangGraph4j 会自动把返回值合并到全局 State 中。下一个 Node 通过 state.value("weather") 就能拿到前置节点的输出。
整个数据流转不需要 Agent 之间直接调用方法,完全通过 State 这块“白板”中转。
不过这里有一个容易踩的坑。
LangGraph4j 默认用 Java 的 ObjectOutputStream 做状态序列化。如果 State 里存的都是 String 类型,没有任何问题。一旦存了自定义的 POJO(比如美食 Agent 返回的嵌套 record 对象),默认的序列化方式就会报错。
解决办法是给这些 POJO 注册一个 JSON 序列化器。
Java
public static StateSerializer serializer() {
var s = new ObjectStreamStateSerializer<>(TravelState::new);
s.mapper().register(TravelFoodRecommends.class, new JsonSerializer<>(...));
s.mapper().register(TravelResVo.class, new JsonSerializer<>(...));
s.mapper().register(SimpleResVo.class, new JsonSerializer<>(...));
return s;
}
先把 POJO 序列化成 JSON 字符串再存进 State,反序列化时再还原回 Java 对象。
这个方案还有一个额外的好处,JSON 是人类可读的格式,调试的时候可以直接打印 State 的内容,不需要反序列化就能看到每个字段里存了什么。
求职派的岗位数据流水线也用了同样的方式。它的 State 里存了采集任务、清洗结果、发布记录等多种复杂对象,全部通过 JsonSerializer 统一处理,跨节点传递没有出过序列化问题。

04、四个 Agent,各管一段
每个 Agent 在 LangGraph4j 里就是一个 Node,说到底就是一个 Java 方法。方法签名是固定的,接收当前的 TravelState,返回 Map。
四个 Agent 各用了一种不同的 Spring AI 能力,正好覆盖了多 Agent 开发中最常用的几种模式。

天气 Agent
天气数据需要通过工具调用来获取。用 Spring AI 的 @Tool 注解定义一个天气查询工具。
Java
@Tool(description = "根据传入的地区,返回对应地区的当前天气")
public WeatherResponse currentWeatherFunction(WeatherRequest request) {
// 真实场景中调用第三方天气 API,这里用随机数据模拟
return new WeatherResponse(request.area(), randomWeather());
}
WeatherRequest 和 WeatherResponse 都是 record 类型,字段上的 @JsonPropertyDescription 注解告诉大模型每个参数的含义。
把这个工具注册到 ChatClient 上之后,天气 Agent 只需要发送一条提示词(“请查询这个位置的天气”),大模型识别到需要查天气时,会自动触发 Function Call 调用这个方法,拿到结果后组织成自然语言返回。
天气 Agent 的核心方法只有两行,发送提示词、把返回值写进 State。
Function Call 的调用过程对 Agent 来说完全透明,工具的发现和执行全部由 Spring AI 框架处理。Agent 不需要关心“怎么调工具”,只管“问对问题”。

旅游 Agent 和美食 Agent
旅游 Agent 展示的是 PromptTemplate 的用法。它从 State 里读取用户输入和天气信息,通过 {area} 和 {weather} 占位符拼成一条完整的提示词(“请结合当前天气推荐三个游玩项目”),调用 render() 传入实际值后发给大模型。多个变量需要拼接成一条提示词时, PromptTemplate 比手动字符串拼接可读性好得多。
美食 Agent 需要大模型返回一个结构化的 Java 对象,而不是一段自由文本。每个游玩项目对应三种推荐美食,每种美食有名称和推荐理由。
Spring AI 的 BeanOutputConverter 解决了这个问题。它根据目标类的字段定义自动生成 JSON Schema,附在提示词末尾告诉大模型“请按这个格式返回”。
Java
var converter = new BeanOutputConverter<>(TravelFoodRecommends.class);
String prompt = new PromptTemplate("{weather}\n{travel}\n{format}")
.render(Map.of("weather", state.getWeather(),
"travel", state.getTravel(), "format", converter.getFormat()));
var result = converter.convert(agentService.food(prompt));
return Map.of(TravelState.FOOD, result);
getFormat() 生成精确的 JSON Schema 描述,大模型按照这个格式输出 JSON, convert() 方法自动反序列化成 Java 对象。
这也是前面 State 需要注册 JSON 序列化器的原因,嵌套的 record 结构用默认的 ObjectOutputStream 根本处理不了。

图文 Agent
图文 Agent 是最后一个节点,它需要判断数据从哪条路径过来。
如果经过了旅游 Agent 和美食 Agent,手上有完整的推荐数据,就用“小红书旅游博主”的系统提示词生成一篇图文;如果是从天气 Agent 直接过来的(用户没有要求推荐),就用“天气预报专家”的系统提示词生成一段天气预报文案。
判断方式很简单,检查 State 中的美食数据是否为空。
不为空说明走了完整的推荐路径,为空说明只查了天气。两条路径最终都输出一个结构化的博文对象(包含标题和正文),写进 State 的 BLOG 字段。

05、把图编译起来
四个 Agent 写完之后,最后一步是把它们拼成完整的图、编译、执行。
关键在于条件分支。天气 Agent 执行完之后,怎么决定下一步走旅游推荐还是直接跳到图文生成?
LangGraph4j 用 addConditionalEdges 实现条件路由,核心就是一个 lambda 表达式。
Java
EdgeAction shouldContinue = state ->
state.getInput().contains("推荐") ? "recommend" : "xhs";
graph.addConditionalEdges("weatherAgent", edge_async(shouldContinue),
Map.of("recommend", "travelAgent", "xhs", "xhsAgent"));
如果用户的输入里包含了“推荐”两个字,走旅游推荐路径(天气 → 旅游 → 美食 → 图文);否则直接跳到图文 Agent(天气 → 图文)。lambda 返回一个字符串作为路由键, Map 参数把路由键映射到具体的 Node 名称。
这个判断逻辑在实际项目中可以替换成更复杂的策略。求职派的消息路由器就用了“关键词匹配 + LLM 复合分类”的方案,先走关键词匹配(零成本、毫秒级),未命中的再调用大模型做意图分类,兼顾了速度和准确率。
图定义完成后,编译并执行只需要两行。
Java
var compiled = graph.compile();
TravelState result = compiled
.invoke(Map.of(TravelState.INPUT, "我准备去武汉旅游,帮我做些推荐"))
.orElseGet(() -> new TravelState(Map.of()));
传入用户的输入,LangGraph4j 按照图的定义依次执行每个 Node,最终返回包含所有 Agent 执行结果的 State。从 State 里取出 BLOG 字段,就是最终生成的图文内容。在 Spring Boot 里包一个 @GetMapping 接口就可以直接通过浏览器访问了。
走推荐路径的结果(输入包含“推荐”)。

不走推荐、只返回天气预报的结果。

两条路径,四个 Agent,同一张图,运行结果截然不同。

文中的完整代码可以在 spring-ai-demo 项目中获取。
ending
【四个 Agent,四种 Spring AI 能力——Function Call、PromptTemplate、结构化输出、条件路由,拼成一张 LangGraph4j 的有向图。】
多 Agent 的价值不在于 Agent 的数量,在于每个 Agent 的职责足够小、提示词足够短、出了问题能精确定位到具体环节。
求职派的岗位数据流水线也是同样的思路——分类、采集、清洗、发布,四个节点各做一件事,条件边控制流转方向。这个模式一旦跑通了,加新的 Agent 就是往图上插一个 Node 的事。
我们下期见。
如何把求职派写到简历上?
项目名称:求职派(JobClaw),基于多 Agent 的智能求职系统
项目简介:OpenClaw 风格的多 Agent 求职平台,IM 渠道接入共享 Agent 内核,消息经意图分类后路由到可插拔的业务 Agent,支持身份采集、岗位检索、岗位推荐等核心功能。
技术栈:Java 21、Spring Boot 4.0.5、Spring AI 2.0.0-M4、LangGraph4j 1.6.0-rc4、Spring Modulith
核心职责:
-
基于 LangGraph4j 的 StateGraph 构建多 Agent 编排工作流,将岗位数据采集流程拆分为分类、采集、清洗、发布四个独立节点,利用条件边实现流程自动分支和异常提前终止
-
设计自定义状态类和 JSON 序列化方案,解决 LangGraph4j 默认序列化不支持复杂 POJO 跨节点传递的问题
-
基于 Spring AI 的 @Tool 注解实现外部工具动态集成,支持天气查询、网页爬取、文件解析等多种工具的 Function Call 调用
-
实现基于关键词匹配和大模型复合分类的意图识别策略,先走零成本关键词匹配再兜底 LLM 分类,消息路由准确率超过 95%
-
封装三层 LLM 调用体系,从基础调用到业务 Agent 到用户偏好适配逐层递进,业务 Agent 只需实现接口即可自动注册到路由系统