Vibe Coding一个能办事的 AI Agent:Spring Boot + DeepSeek + LangGraph4j 六节点 ReAct 项目实战

🚀 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...

相关推荐
乒乓狂魔14786739970001 小时前
OpenClaw 终于可以读懂 OpenTelemetry APM 了
后端
不好听6131 小时前
从零构建一个 AI Coding Agent:不会写代码?让 AI 帮你写
agent
Rain的Java大神之路1 小时前
高并发下如何防止商品超卖少卖
后端
小Z加点糖1 小时前
开源 FlowScript:把 Skill 变成可执行、可检查、可回放的工作流
agent
东风破_1 小时前
RAG 是什么?从关键词搜索到语义搜索的完整过程
人工智能
张三丰21 小时前
Fiber 节点 —— 一个数据结构如何承载整个 React 运行时
后端
AINative软件工程1 小时前
AI Agent 不是会重试就可靠:长任务的断点恢复与 Durable Execution 设计
人工智能·后端·ai编程
卷无止境1 小时前
SFML 深度解读:一个教科书级 C++ 多媒体库的内功心法
c++·后端
GuangdongTong1 小时前
基于RabbitMQ实现延迟任务
后端·rabbitmq