🚀 Vibe Coding一个能办事的 AI Agent:Spring Boot + DeepSeek + LangGraph4j 六节点 ReAct 项目实战
背景
你一定遇到过这种情况------
接了一个 AI 智能客服需求,老板说"要能查订单、能排队、能退款",你心想这不就是接个 LLM 的事吗。结果用户第一句话问"帮我查一下昨天在火锅店的消费",LLM 开始胡编订单号。
你发现事情没那么简单:LLM 不会查数据库、不知道你的业务规则、更没法真正帮用户"办事"。
于是你开始搜"Java AI Agent 实战",出来的要么是 Python 的 LangChain 教程,要么是抽象的 Agent 理论------思维链、多智能体协作、世界模型......看完只觉得更焦虑了。
我只是想写个 Java 项目,让 LLM 能调几个工具帮用户查个订单、排个队,有那么难吗?
这就是本文要解决的问题:用 Spring Boot + LangGraph4j 构建一个真正能"办事"的 ReAct Agent,打通从意图理解到工具执行到结果生成的完整闭环。
不需要啃 LangGraph 论文,不需要学 Python。你只需要三个东西:
- Spring Boot --- Java 人都熟
- DeepSeek --- 便宜好用的国产大模型
- LangGraph4j --- Java 版的状态机编排框架

什么是"能办事的 Agent"?
在聊代码之前,先对齐一个概念。
很多人以为接个 LLM 就算有 AI 了。但实际上,一个只会聊天的 ChatBot 和真正能办事的 Agent 之间,差了三层能力:
arduino
普通 ChatBot: 用户问 → LLM 答(全靠记忆,不知道你的数据库里有什么)
↓ 升级
带 RAG 的 ChatBot:用户问 → 检索文档 → LLM 引用文档回答(能"看书"了)
↓ 升级
ReAct Agent: 用户问 → 理解意图 → 制定计划 → 调用工具 → 观察结果 → 判断是否够了 → 回答
(能"做事"了------查数据库、调接口、执行操作)
所谓的"能办事",就是 LLM 像一个实习生,你给它一本操作手册和几个工具,它能自己决定什么时候查手册、什么时候用哪个工具、看完结果后还需要什么信息。
下面我以 O2O 生活服务平台为例,一步步拆解怎么给 Java 项目装上这个能力。
架构总览
整个 AI 部分由三层组成,和业务代码松耦合:
scss
┌──────────────────────────────────────────────────┐
│ 前端 (Vue.js + Element UI) │
│ 右下角 AI 悬浮窗 / 知识库管理后台 │
└──────────────────────┬───────────────────────────┘
│ HTTP + SSE
┌──────────────────────▼───────────────────────────┐
│ Spring Boot 后端 │
│ ┌─────────────────┐ ┌────────────────────────┐ │
│ │ ReAct 状态机 │ │ RAG 引擎 │ │
│ │ 六节点编排 │ │ 摄入 + 混合检索 │ │
│ │ Context→Planner │ │ 切片→向量化→Qdrant │ │
│ │ →Executor→ │ └────────────────────────┘ │
│ │ Observer→Judge │ ┌────────────────────────┐ │
│ │ →Answer │ │ 工具集 (6 Tools) │ │
│ └─────────────────┘ │ GeoSearch / Text2SQL │ │
│ │ 订单查询 / 排队取号 │ │
│ └────────────────────────┘ │
└──────┬───────────────────────┬───────────────────┘
│ │
┌──────▼──────┐ ┌─────────────▼──────────────┐
│ DeepSeek │ │ Qdrant + Ollama/PostgreSQL│
│ LLM API │ │ 向量库 / Checkpoint │
└─────────────┘ └────────────────────────────┘
别被"六节点状态机"吓到,核心代码其实很精简。下面我按 Agent 的五个关键能力来拆解------每个能力都是一块独立的拼图。
能力一:六大工具 --- 让 Agent 能"动手"
这是 Agent 区别于 ChatBot 的核心:它不只是说话,它能做事。
工具怎么注册?
我们通过 LangGraph4j + LangChain4j 的桥接层,@Tool 注解的方法自动被提取为 JSON Schema,注册到 Agent 的工具箱里:
swift
// ToolRegistry.java --- 6 个工具,一行注册
LC4jToolMapBuilder<?> builder = new LC4jToolMapBuilder<>()
.toolsFromObject(
knowledgeRetrievalTool, // RAG 知识检索
queueTicketTool, // 排队取号
text2SqlTool, // 动态 SQL 查询
geoSearchTool, // 地理位置搜索
historySearchTool // 对话历史回溯
);
每个工具做什么?
| 工具 | 触发场景 | 背后做了什么 |
|---|---|---|
geoSearchTool |
"附近有什么火锅店" | Redis GEO 查询 → 返回店铺列表和距离 |
queueTicketTool |
"帮我排个号" | 查询排队状态 → 确认 → 取号 |
text2SqlTool |
"帮我看看xx店有没有优惠券" | LLM 生成 SQL → 安全校验 → 执行 |
knowledgeRetrievalTool |
"怎么退款" | RAG 检索帮助文档 |
historySearchTool |
"之前聊过的那家店" | PostgreSQL ILIKE 关键词回溯历史对话 |
关键设计决策:LLM 自己决定何时调哪个工具。你不需要写 if-else 路由规则------ Agent 的 Planner 节点会根据用户意图自动制定计划并选择工具。这正是 ReAct 范式的价值:不是预设路径,而是推理出路径。
能力二:增量 Checkpoint --- 给 Agent 装上"记忆"
问题
ReAct Agent 每轮对话要经过多个节点推理,如果每次都从头跑,一轮对话能跑十几秒。更致命的是,用户说了"帮我在海底捞排个号",Agent 需要确认"您确认在海底捞(朝阳店)排号吗?"------这时候 Agent 的工作状态(推理到哪了、已获取了哪些信息)必须被保存下来,等用户回复后继续。
传统的全量序列化方案每次存整个状态,图初始化耗时 14 秒,体感上是"点一下 → 喝口水 → 还没反应"。
解决方案:DeltaPostgresSaver
核心思路一句话:不存全量,只存变化。
arduino
完整状态(messages[1..100] + 所有标量字段)
↓ 对比上次快照
只存 delta:
├── messages 差集(新增的 5 条消息)
├── 变化的标量字段(如 currentPlan 更新)
└── 每隔 N 轮存一个全量快照做"锚点"
scss
// DeltaPostgresSaver.java --- 增量序列化核心逻辑(简化)
public void put(Checkpoint checkpoint) {
// 1. 加载最近一次全量快照
Checkpoint baseline = loadLatestSnapshot(threadId);
// 2. 计算 delta:messages 差集 + 变化的标量字段
Map<String, Object> delta = computeDelta(baseline, checkpoint);
// 3. 只序列化 delta 写入 PostgreSQL
// 周期性(如每 10 轮)存一份全量快照
if (shouldCreateSnapshot()) {
saveFullSnapshot(checkpoint);
} else {
saveDelta(delta);
}
}
效果对比:
| 指标 | 全量存储 | DeltaPostgresSaver |
|---|---|---|
| 图初始化耗时 | ~14s | < 1s |
| 每次 checkpoint 写入量 | 全量序列化 | 仅变更部分 |
| 跨实例恢复 | ❌ | ✅ 支持 checkpoint resume |
💡 这个思路和数据库的 WAL(Write-Ahead Log)如出一辙------找到最近的 checkpoint 锚点,回放之后的增量操作,重建完整状态。做后端的同学一看就懂。
顺带解决了"人机协同"
基于 Checkpoint 机制,Agent 确认中断变得很自然:
arduino
Agent 需要用户确认 → 序列化当前 ReAct 上下文 → 挂起
用户回复 "确认" → updateState 注入决策数据 → 恢复状态机执行
(绕过 Planner 重跑,避免上下文丢失)
能力三:RAG 混合检索 --- 让 Agent 能"看书"
很多 RAG 系统的通病是搜不准。用户搜"怎么退款",返回的是"退款政策的历史沿革"------因为只做了关键词匹配。
这个项目的检索链路是双路召回 + 重排序:
Step 1:结构化切分文档
普通的 RecursiveCharacterTextSplitter 不认识中文------它会把"第一章"、"一、"、"1.1"这些中文章节标记当作普通文本一刀切。
我们实现了一个 AdaptiveSplitter,多层级降级切分:
scss
// AdaptiveSplitter.java --- 中文文档感知的切片策略(简化)
public List<Chunk> split(String markdown) {
// 1. 保护特殊块:代码块 + 表格(占位符替换)
Map<String, String> protectedBlocks = protectCodeAndTable(markdown);
// 2. 多层级切分(逐级降级)
// H2 标题 → H3 标题 → 中文标记("一、" "第一章") → 段落 → 句子 → 字符
List<Section> sections = splitByHeading(text, "##"); // Level 1: H2
if (sections.size() <= 1)
sections = splitByChineseMarker(text); // Level 2: 一、第一章
if (sections.size() <= 1)
sections = splitByParagraph(text); // Level 3: 段落
// 3. 还原被保护的特殊块
return restoreBlocks(chunks, protectedBlocks);
}
💡 为什么不用现成的 LangChain 切分器?因为
RecursiveCharacterTextSplitter不认识文档结构。一篇 FAQ 被切在代码块中间,检索出来的片段就废了。结构化文档必须用结构感知切分。
Step 2:双路召回 + RRF 融合
单独的语义检索容易漏掉精确关键词匹配,单独的 BM25 又不理解语义。所以两路一起跑,用 RRF 融合排序:
ini
// RetrievalService.java --- 混合检索核心(简化)
public List<SearchResult> hybridSearch(String query, int topK) {
// 1. LLM 改写查询(扩展同义词、纠正口语化)
String rewrittenQuery = llmQueryRewriter.rewrite(query);
// 2. 双路并行召回
List<SearchResult> semanticResults = qdrant.search(embed(rewrittenQuery), topK * 2);
List<SearchResult> keywordResults = bm25Index.search(rewrittenQuery, topK * 2);
// 3. RRF 融合排序 → LLM 精排
List<SearchResult> fused = rrfFusion(semanticResults, keywordResults);
return llmReranker.rerank(query, fused); // 返回 Top-K
}
写入管线串联
一条 API 调用,文档就变成了知识库:
swift
# POST /kb/ingest
curl -X POST http://localhost:8080/kb/ingest \
-H "Content-Type: application/json" \
-d '{"source": "faq", "title": "帮助中心", "content": "# 退款政策\n\n## 申请条件\n..."}'
背后发生了什么:
Markdown 文档 → AdaptiveSplitter 切片 → bge-m3 向量化 → Qdrant 存储
能力四:Text2SQL --- 让 Agent 能"查库"
这是让 Agent 从"客服"升级为"管家"的关键。用户问"帮我看看xx店有没有优惠券"------知识库没有答案,必须查数据库。
但让 LLM 直接写 SQL 执行是危险的。我们做了多层防护:
scss
// Text2SqlTool.java --- SQL 生成 + 安全校验链(简化)
public String executeQuery(String question, String userId) {
// 1. LLM 选表:从 Redis 缓存的 Schema 中匹配最相关的表
String tableName = selectTable(question, getCachedSchemas());
// 2. LLM 生成 SQL
String sql = generateSql(question, getTableSchema(tableName));
// 3. 安全校验链
validate(sql); // 仅允许 SELECT / 禁止 DROP ALTER TRUNCATE / 强制 LIMIT
// 4. COUNT(*) 预检 → 行数太多时提示缩小范围
long totalRows = countCheck(sql);
if (totalRows > MAX_RESULT_ROWS) {
return "查询结果约" + totalRows + "条,建议缩小查询范围(如指定日期区间)";
}
// 5. 执行 + 格式化
return formatResult(execute(sql));
}
还有一个容易被忽视的细节:表名缓存 。用户说"xx店",数据库里是 tb_shop_type;用户说"优惠券",数据库里是 tb_voucher。我们在 Redis 里维护了表名和表的comment,能够在用户查询时快速加载到所需要的表。采用渐进式披露的方式,当用户具体查询某张表时,才暴露表的全部字段名称。
能力五:SSE 流式 + 上下文管理 --- 用户体验兜底
打字机效果
没人愿意盯着空白页面等 10 秒。LLM 每生成几个 token 就推到前端,用户看到的是逐字出现的"打字机效果":
less
// ReactStreamController.java --- SSE 流式输出(简化)
@PostMapping(value = "/chat/react/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestBody ChatRequest request) {
return Flux.create(sink -> {
graph.compile().stream(request.getThreadId(), userMessage)
.forEachRemaining(event -> {
sink.next(ServerSentEvent.<String>builder()
.event(event.type())
.data(event.content())
.build());
});
sink.complete();
});
}
⚠️ Nginx 代理 SSE 必须关 buffering:
proxy_buffering off; proxy_cache off;------这是最常见的坑,忘了改就看不到打字机效果。
滑动窗口
对话超过 3 轮后全塞进 Prompt 会爆 Token。我们用了"近全量 + 远摘要"的策略:
最近 3 轮:完整保留(用户刚说的内容最重要)
超出部分:
├── LLM 摘要压缩(提取关键信息,丢掉废话)
└── 硬上限裁剪(Token 数超阈值时强制截断)
用户提到之前聊过的内容时,通过 PostgreSQL ILIKE 关键词检索回捞历史片段------不是全量回放,是按需检索。
两个关键坑,帮你填了
坑一:Spring Boot 2.7 + LangGraph4j 的版本联调
LangGraph4j 的版本更新节奏很快,和 LangChain4j 的版本之间有耦合。项目目前稳定在:
| 组件 | 版本 | 选型理由 |
|---|---|---|
| LangGraph4j | 1.8.19 | 稳定的图编排 API,StateGraph 语法成熟 |
| LangChain4j | 1.16.2 | @Tool + OpenAiStreamingChatModel 完美支持 DeepSeek |
| Spring Boot | 2.7.18 | 生态最成熟,大部分公司仍在使用 |
版本组合建议:不要追新,这三个版本已在大批量对话中验证过稳定性。
坑二:Checkpoint 旧数据导致加载变慢
如果从旧版本升级,全量存储的旧数据会让 DeltaPostgresSaver 的优化失效。
症状:升级后图初始化反而更慢。
解决方案:
sql
-- 清空旧的全量 checkpoint 数据,让 Delta 机制重新工作
DELETE FROM lg4jcheckpoint;
应用重启后,DeltaPostgresSaver 会重新建立增量存储------初始化耗时立刻降到 1 秒以内。
效果实测
用同一个 O2O 业务场景,对比三种模式的实际表现:
| 对比维度 | 纯 LLM ChatBot | + RAG | ReAct Agent(本项目) |
|---|---|---|---|
| "帮我找附近的火锅店" | 编造店铺名 | 引用文档里的店铺列表(可能过时) | 调用 GeoSearchTool 查 Redis,返回实时数据 |
| "我昨天消费多少" | 编造金额 | 无法回答(文档里没有) | 调用 Text2SqlTool 查数据库,返回真实金额 |
| "帮我排个号" | 假装排了 | 回复"请联系人工" | 调用 QueueTicketTool 实际取号,返回排号单 |
| 推理过程可见 | ❌ | ❌ | ✅ 输出每个节点信息(规划→执行→观察→判断→回答) |
总结
核心要点:
- ReAct Agent ≠ ChatBot 加 Prompt。关键区别是:Agent 有自己的推理循环(规划→执行→观察→判断),能自主决定调用哪个工具、什么时候需要更多信息
- 六个节点里,Observer 和 Judge 是两个最重要的角色------前者负责纠偏(空结果重试、错误分类),后者负责控制循环(信息够了就答、不够就重规划)。没有这两个节点,Agent 要么停不下来,要么停下来时说的是错的
- 增量 Checkpoint 不是性能优化,是必备基础设施------没有它,人机协同中断恢复、跨实例状态迁移都无从谈起
- RAG 检索的效果 80% 取决于切片和召回策略------结构化文档用结构感知切分,代码块和表格用占位符保护
- Text2SQL 的安全防护比 SQL 生成本身重要得多------强制 LIMIT、COUNT 预检、仅允许 SELECT 缺一不可
适用场景:
- O2O / 电商智能客服(查店、排队、下单、售后)
- 企业内部数据查询助手(订单、库存、报表------Text2SQL 直接查库)
- 任何"既有文档库需要 RAG,又有业务系统需要 Tool Calling"的场景
📦 完整项目代码见:github.com/yeffky/hmdp...