ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot3+LangChain4j+Vue3:构建AI智能体与工作流平台

SpringBoot3+LangChain4j+Vue3:构建AI智能体与工作流平台 简介面向全栈开发者这份资源是一套基于Spring Boot 3与LangChain4j的AI应用平台源码能帮助快速搭建具备智能代码生成、智能体编排、工作流管理与工具调用能力的完整系统。前端使用Vue 3构建交互界面后端提供可视化编辑、一键部署、应用管理和智能路由等核心能力并整合多级存储与Nginx通过ARMS、Prometheus与Grafana实现应用监控也兼容Cursor Vibe Coding开发模式。压缩包内共216个文件以143个Java文件为主要组成部分承载后端业务逻辑与集成配置21个TypeScript文件和19个Vue文件对应前端页面与交互逻辑其余包括JSON配置、SQL初始化脚本、YAML部署文件以及说明文档等辅助材料。整体压缩包大小约1.14MB结构清晰便于按需阅读。截至目前已有250人浏览学习适合想要深入实践LangChain4j、LangGraph4j工作流以及AI应用工程化的开发者参考学习。1. 这个“AI应用平台”到底在解决什么问题如果你所在团队已经试过把大模型接进业务系统大概率会遇到这三件事第一模型只会“聊天”让它去查数据库、改工单、调接口就得写一堆胶水代码第二一段固定的提示词应付不了多步任务业务要求“先查库存再算报价最后生成合同”每一步都有严格顺序第三做出来的东西只活在开发者的 IDEA 里业务方想要在页面上自己拖一拖、改一改、点一下就能上线。这个标题把这件事说全了——基于 SpringBoot3 LangChain4j Vue3 搭一个 AI 应用平台用 AI 智能体和 ToolCalling 让模型有手有脚用 LangGraph4j 工作流把不可控的对话变成可控的流程再用 Vue3 做一套可视化编辑、应用管理和一键部署的壳子。它适合两类人一类是 Java 后端为主、想在公司内部落地 AI 功能但不想引一堆 Python 微服务的团队另一类是正在做 AI 应用低代码平台、需要参考一条端到端技术路径的开发者。这篇笔记按后端底座、智能体、工作流、前端落地和常见坑的顺序展开照着能做出一版可演示、可评审、可继续投入的骨架。2. 后端底座SpringBoot3 LangChain4j 的模型接入与多路召回2.1 为什么选 SpringBoot3 LangChain4j而不是自己封装 HTTP 客户端很多 Java 团队接到 AI 需求后的第一反应是直接调模型厂商的 HTTP 接口写一个 RestTemplate 封装再自己管理上下文和历史消息。短期看没毛病一旦要支持多模型切换、流式输出、多轮记忆、工具调用这套手写代码会迅速膨胀成没人敢动的“黑匣子”。LangChain4j 在 Java 生态里的定位相当于把 LangChain 那套抽象用 Java 重写了一遍但它更收敛核心就几个概念——ChatLanguageModel 管模型对话EmbeddingModel 管向量化AiService 管声明式接口Tool 管工具调用Memory 管对话历史。对 SpringBoot3 团队来说集成成本比引入 Python 服务低得多而且可以跟现有的 Spring 容器、配置体系、事务、监控直接融合。SpringBoot3 本身的价值在 AI 场景里会被放大一是原生支持虚拟线程处理 SSE 流式响应和工具并行调用时线程开销明显下降二是 SpringBoot3 的配置绑定和自动装配让 LangChain4j 的模型参数可以全部放进 application.yml换模型时不需要改 Java 代码三是 SpringBoot3 对 GraalVM 的支持虽然还不是万能灵药但做平台类项目时预留了后续优化启动内存的余地。这一层选型的核心诉求不是“谁的生态更热闹”而是“这个团队能不能低成本把 AI 能力接进现有的 Java 服务里”。2.2 模型接入层用 OpenAI 兼容协议适配多模型平台类应用最忌讳把模型厂商写死在代码里。常见的做法是底层统一走 OpenAI 兼容的 Chat 接口协议上层通过配置决定连哪家服务。这样无论是公有云模型、私有化部署的模型还是开源模型网关只要对方暴露了兼容接口就能一根配置切过去。LangChain4j 内置的 OpenAiChatModel 支持自定义 baseUrl官方模型和兼容协议模型都可以挂到同一个入口下。spring: application: name: ai-platform langchain4j: open-ai: base-url: ${LLM_BASE_URL:https://your-llm-gateway.example.com/v1} api-key: ${LLM_API_KEY:} chat-model: model-name: ${LLM_MODEL_NAME:qwen2.5-72b-instruct} temperature: 0.7 timeout: 60s max-tokens: 4096 log-requests: false这段配置的关键在于base-url和model-name都做成了环境变量占位。平台内部测试用一套模型生产切另一套前端的工作流定义、智能体配置完全不用改。log-requests在联调阶段建议开成 true能看到实际发给模型的 payload排查“模型为什么没按预期调用工具”时这是第一手证据上线前一定关掉否则每次对话的完整内容都会落日志时间长了下游日志系统先扛不住。然后是声明式接口。LangChain4j 的 AiService 用注解定义服务方法框架在运行时生成实现类AiService public interface ChatAssistant { String chat(MemoryId String memoryId, UserMessage String userMessage); Streaming FluxString streamChat(MemoryId String memoryId, UserMessage String userMessage); }这个接口直接被 Spring 代理方法上的UserMessage表示哪个人传参数拼进用户消息MemoryId表示按业务维度隔离对话历史——比如一个表单一个记忆一个工单一个记忆而不是全局共享上下文。Streaming返回 Reactor 的 Flux配合 SpringBoot3 的 WebFlux 或 MVC 异步支持把流式 token 通过 SSE 推给前端。需要注意AiService 的实现机制对“自定义上下文拼接”不够灵活。常见做法是平台的应用配置里允许用户填系统提示词这部分不适合写死在注解上而是用 ChatMemory 和 MessageWindow 在会话维度动态构造。2.3 多路召回向量检索 关键词检索 融合排序热词里那个“langchain4j 多路召回”本质是 RAG 里提升召回质量的关键手段。单靠向量检索遇到专有名词、型号、工单编号这类没有语义但字符高度精确的查询效果会很差单靠关键词检索又接不住“帮我把最近一周未结算的订单按金额排个序”这种口语化表达。平台里我给知识库场景设计的召回链路是一路走向量相似度一路走关键词 BM25两边分别取 topK再做归一化融合。Component public class HybridRetriever { private final EmbeddingStoreTextSegment embeddingStore; private final EmbeddingModel embeddingModel; private final KeywordSearchService keywordSearchService; public ListScoredDocument retrieve(String query, int topK) { // 第一路向量召回 var queryEmbedding embeddingModel.embed(query).content(); var vectorResults embeddingStore.search(queryEmbedding, topK 5) .stream() .map(hit - new ScoredDocument(hit.scoredText().text(), hit.score(), vector)) .toList(); // 第二路关键词召回走 BM25 或者数据库全文索引 ListScoredDocument keywordResults keywordSearchService.search(query, topK 5); // 第三路融合用 RRF 公式而非直接加权 return fuse(vectorResults, keywordResults, topK); } }融合逻辑先刷一出分数归一化再用倒数排名融合RRFprivate List fuse(List vectorDocs, List keywordDocs, int topK) { MapString, Double scoreMap new HashMap(); addWithRrf(scoreMap, vectorDocs, 60); addWithRrf(scoreMap, keywordDocs, 60); return scoreMap.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .limit(topK) .map(e - new ScoredDocument(e.getKey(), e.getValue(), fused)) .toList(); }private void addWithRrf(MapString, Double map, List docs, int k) { for (int rank 0; rank docs.size(); rank) { ScoredDocument doc docs.get(rank); map.merge(doc.content(), 1.0 / (k rank 1), Double::sum); } }参数说明里最值得调的是两个值向量召回和关键词召回的数量、RRF 的常数 k。常见做法是多召回一部分比如 topK5让融合阶段有得选k 一般取 60 左右调小会让高排名文档权重更突出调大则更平均。实际跑下来经验是向量召回 top 50 里如果没有答案融合也救不回来问题通常出在文档切分粒度或 Embedding 模型本身。 ### 2.4 代码生成器场景模型输出到工程落地之间还有一道闸 标题里专门点出智能代码生成这是 AI 应用平台最接地气的一个能力。实现上不是扔一个“给我生成 UserController”的提示词就完事而是把代码生成拆成三步需求理解、工程结构约束、代码后处理。工程结构约束是最容易被忽略的——直接让模型自由输出生成的代码大概率跟项目现有框架不一致。 常见做法是把项目的技术栈约束、目录规范、接口风格写成 system prompt 的一部分再把代码生成的产物限定为填充式代码块。例如生成一个 SpringBoot 的 Service 实现时平台先注入项目模板模型只需要补全方法体。生成之后的工作站不住脚代码格式化、编译校验、导入补全这三步必须自动化否则用户拿到一坨缩进混乱、缺 import 文件根本没法看。这部分后续会展开谈但在后端底座这里要明确一条边界——LangChain4j 负责和模型打交道代码生成的工程部分必须由平台自己的服务接管。 ## 3. 让模型有手有脚ToolCalling 与 AI 智能体的实现细节 ### 3.1 ToolCalling 到底是什么为什么智能体离不开它 让模型直接生成“查数据库、发邮件、创建工单”这些操作是不现实的模型本质上是概率生成文本它不知道自己能不能执行、有没有权限、操作结果是什么。ToolCalling 解决的是这个“能力边界”问题模型在对话中输出一个结构化请求比如“调用 searchContract 工具参数 keyword采购合同”应用层负责真正执行再把执行结果作为新的上下文继续让模型推理。智能体之所以叫“智能体”就是因为它具备感知收到用户输入、决策决定调哪个工具、行动执行工具、观察读取执行结果的循环。 ### 3.2 用 LangChain4j 注册一个工具参数描述决定成败 LangChain4j 的 Tool 注解会把方法暴露给模型方法名、参数名、参数描述会拼到模型的工具定义里。这部分是对模型效果影响最大、也是最容易敷衍的地方。 java Component public class ContractTool { private final ContractRepository contractRepository; Tool(根据合同编号或关键字搜索合同返回合同名称、金额、签订日期和当前状态) public ListContractSearchResult searchContract( ToolParameter(搜索关键字可以是合同名称片段或完整合同编号) String keyword, ToolParameter(value 是否只查有效合同默认 true) boolean activeOnly) { return contractRepository.search(keyword, activeOnly); } Tool(查询指定合同金额是否已超过预算上限) public BudgetCheckResult checkBudget( ToolParameter(合同编号) String contractNo, ToolParameter(预算金额单位元) double budgetAmount) { // 参数进 LLM 时是字符串必须做类型校验和范围校验 if (budgetAmount 0 || budgetAmount 1_000_000_000) { throw new IllegalArgumentException(预算金额超出合理范围); } return contractRepository.checkBudget(contractNo, budgetAmount); } }两个细节值得说。第一Tool 的工具描述要写清楚“这个工具能做什么、返回什么”模型依赖这段描述决定何时调用描述写得太短模型容易在其他工具上误选写得太长又占用上下文。第二参数描述必须包含“取值范围、单位、主键格式”等信息——你写“预算金额单位元”模型就知道把用户嘴里的“五百万”转成 5000000 而不是 500 万次调用。这个环节做不好后面所有容错逻辑都是在给提示词背锅。3.3 工具执行的循环控制超时、并发、死循环模型输出工具调用请求后应用层要执行工具并回填结果。LangChain4j 在 AiServices 内建了工具执行循环但我一般会自己控制这个循环因为平台需要统一记录每一次工具调用的入参、出参、耗时和错误。public ChatResponse runAgentLoop(String userMessage, ListObject tools) { ChatRequest request ChatRequest.builder() .messages(List.of(UserMessage.from(userMessage))) .toolSpecs(Specs.from(tools)) .build(); ToolContext toolContext new ToolContext(); for (int step 0; step 5; step) { ChatResponse response model.chat(request); AiMessage aiMessage response.aiMessage(); if (!aiMessage.hasToolExecutionRequests()) { return response; // 模型不再请求工具正常结束 } ListToolExecutionRequest requests aiMessage.toolExecutionRequests(); ListToolExecutionResultMessage results new ArrayList(); for (ToolExecutionRequest req : requests) { try (var ignored TimeLimiter.timeout(30, SECONDS)) { String result executeTool(req, tools, toolContext); results.add(new ToolExecutionResultMessage(req, result)); } catch (Exception e) { // 工具失败必须回填给模型而不是中断整个循环 results.add(new ToolExecutionResultMessage(req, 工具执行失败: e.getMessage())); } } request appendResults(request, aiMessage, results); } throw new AgentLoopExceededException(工具调用超过5轮已终止); }这个循环里三个参数按场景调循环上限建议 3~5超过就是业务设计有问题而不是模型能力问题单工具超时 30 秒已经偏宽松一般查询类接口 5 秒就该返回工具失败信息回填给模型是非常反直觉但极重要的点——模型看到“工具执行失败合同编号不存在”会自己修正参数再试一次而不是生硬地报错给用户。这里有个口语经验宁可让模型多问一轮也别让它乱猜答案。3.4 生产环境给工具加三道锁工具一旦面向业务方开放就不能只考虑“能不能跑通”。第一道锁是幂等控制尤其是写操作工具——创建订单、发送通知这类工具要支持幂等键否则模型在一次循环里重复调用两次业务数据就脏了。第二道锁是范围校验工具里的参数不能用默认值糊弄数字范围、枚举值、超长文本都要在 Java 侧做校验不能把校验压力留给模型。第三道锁是审计日志每次工具调用的入参、出参、耗时、由哪次会话触发都要落库或者打到独立的日志通道里——这不是为了排查问题是为了出问题时能向业务方交代。4. LangGraph4j 工作流从“自由对话”到“可控流程”4.1 为什么有了智能体还需要工作流智能体自由发挥适合“帮我写一段代码”这类开放任务但业务场景里更多是“先查余额再走审批最后发通知”这种固定流程。自由决策意味着同样的输入每次可能走不同的路径这在 ToB 场景里是灾难。LangGraph4j 把工作流建模成一张状态图StateGraph节点是处理单元边是流转规则条件边根据当前状态决定下一步走哪个分支。这样既保留了 AI 节点的灵活性又把整体流程定义成了业务方可预期、可审计的确定性结构。平台里 LangGraph4j 的角色很明确作为后端执行引擎承接前端可视化画布生成的 JSON 工作流定义编译后执行并实时上报节点状态。它和 Reactor 的契合度也不错——每个节点返回的状态变更天然适合用不可变对象传递。4.2 定义状态和节点最小可跑的 LangGraph4j 流程StateGraphAgentState workflow new StateGraph(AgentState::new); workflow.addNode(planner, new PlannerNode()); workflow.addNode(coder, new CodeGenNode()); workflow.addNode(reviewer, new ReviewNode()); workflow.addNode(finish, new FinishNode()); workflow.setEntryPoint(planner); workflow.addEdge(planner, coder); workflow.addEdge(coder, reviewer); workflow.addConditionalEdge(reviewer, state - state.isApproved() ? finish : coder, Set.of(finish, coder)); CompiledGraphAgentState app workflow.compile();这段代码里的核心概念就四个状态AgentState、节点Node、普通边addEdge和条件边addConditionalEdge。AgentState 是这个流程的“黑板上写的东西”——用户需求、生成的代码、评审意见、循环次数都在这个状态对象里传递。常见坑是新手把状态设计成可变对象一边跑一边改并发场景下一改就串号。public class AgentState { private final String userRequirement; private final String generatedCode; private final String reviewComment; private final boolean approved; private final int iterationCount; public AgentState copyWith(String newCode, String newComment, boolean newApproved) { return new AgentState(userRequirement, newCode, newComment, newApproved, iterationCount 1); } }节点实现只需要接收当前状态、返回新状态。例如 CodeGenNode 拿到 userRequirement 后调用模型生成代码生成结果放进 copyWith 返回的新状态里。iterationCount是防止“代码永远评审不通过”的保险丝——ReviewNode 里如果发现 iterationCount 超过 3直接强制 approved 为 true留一个明确的人工介入标记。4.3 条件边背后的“意图识别”怎么做条件边是工作流真正复杂的部分。它会根据当前状态判断下一步走向这里最常见的设计是两类一类是规则判定比如“评审结果是否通过”“金额是否超过阈值”代码写死可解释性强另一类是需要模型裁决的比如让模型判断“用户这个问题是否需要查知识库”这时候条件边内部就内嵌了一次模型调用。这里的实现细节是模型打分结果要映射成离散的边名不要直接用自由文本。常见做法是给模型一个枚举让它输出一个 JSON 字段String rawVerdict model.generate( 根据用户问题判断是否需要检索知识库只返回 JSON: {\needSearch\: true/false}); boolean needSearch JsonPath.read(rawVerdict, $.needSearch); return needSearch ? search_kb : direct_answer;4.4 工作流如何被前端可视化编辑JSON 就是桥梁LangGraph4j 的图定义本质上是一张有向图而前端可视化画布编辑的也正是这张图。平台里把工作流定义统一成一个 JSON 结构节点列表、边列表、每个节点的类型和参数。前端拖拽生成这个 JSON后端解析后动态构建 LangGraph4j 实例。节点类型做三层收敛普通 LLM 节点填提示词和模型参数、工具节点绑定已注册的 Tool、逻辑节点条件判断/循环/聚合。这就带来一个版本问题工作流 JSON 结构会演化必须加 schemaVersion 字段后端做版本迁移。否则线上跑着 20 个应用改了字段格式老的直接编译失败。这块在避坑章节再展开。5. 避坑从本地 Demo 到可用平台最容易翻车的 5 个问题5.1 模型根本不支持 ToolCalling但代码没做降级现象功能调试时工具调用一直不触发或者模型回答里出现一大段 JSON 工具请求文本而不是结构化请求。 原因接入的模型网关不支持该协议或者模型名配置到了不带工具能力的小参数版本上。 解决在模型接入层做一个能力探测请求启动时用一条固定消息发起一次 tool call如果返回里没有结构化 request就把该模型标记为“不支持工具”平台侧在智能体配置页直接禁用或提示换模型。这比运行时反复重试靠谱得多。5.2 工作流状态对象设计成可变 Map并发时状态互相覆盖现象两个用户同时触发同一个工作流A 用户的评审意见出现在 B 用户的生成代码里。 原因状态类里用了共享的 HashMap 存放中间数据没有做不可变拷贝。 解决强制使用不可变对象每次节点返回新状态实例如果有大对象需要传递在节点侧只传引用 ID具体内容存外部存储避免整个对象在每一步都被复制一次导致内存膨胀。5.3 SSE 流式响应老是断流或者首字迟迟不出现象前端 EventSource 收到几个字就断开重连后更乱。 原因中间代理层默认缓冲了响应模型输出攒到一定量才刷给浏览器另外代理超时时间太短模型思考时间长一点就掐断了。 解决服务端设置Cache-Control: no-cache和X-Accel-Buffering: no响应头从架构上绕开缓冲同时定期发一个注释行或空格作为心跳防止空闲超时断开。5.4 多路召回的结果反而比单路更差现象做了向量关键词融合之后检索质量明显下降最相关的文档排在了第五第六位。 原因两路分数没做归一化就线性加权向量相似度分数总体偏高把关键词那路的优势项全压下去了。 解决放弃线性加权改成 RRF 倒数排名融合关键词召回和向量召回各自保证 top 范围里有真相关文档再靠 RRF 将其抬上来。5.5 LangGraph4j 版本 API 变动导致升级翻车现象升级小版本后addEdge 方法签名变了编译直接报错。 原因LangGraph4j 还在快速演进API 稳定性远不如 SpringBoot。如果你搜资料跟着老版本示例写半年后很可能跑不起来。 解决锁定版本并记录迁移步骤升级后先用固定的测试工作流批量回归把工作流定义 JSON 和引擎解耦无论引擎怎么改业务方画布里的 JSON 不变只需要适配层改解析代码。6. Vue3 可视化编辑与一键部署最小实现与验收技巧可视化编辑的本质是把工作流 JSON 变成看得见的节点和连线。Vue3 做这件事的核心优势是 Composition API 配合 reactive/ref 管理画布状态比 Vue2 时期用 data 和大对象操作清晰得多。最小实现只需要三块一个节点面板可拖出不同类型的节点、一个画布放置和连线、一个属性面板编辑选中节点的参数const nodes: RefFlowNode[] ref([]) const edges: RefFlowEdge[] ref([]) function addNode(type: string, position: { x: number; y: number }) { nodes.value.push({ id: crypto.randomUUID(), type, // llm | tool | condition position, config: initConfigByType(type), }) } function toWorkflowJson(): WorkflowDefinition { return { schemaVersion: 1, nodes: nodes.value, edges: edges.value, } } function loadWorkflow(json: WorkflowDefinition) { nodes.value reactive(json.nodes) edges.value reactive(json.edges) }这里要提醒一句不要让画布组件直接持有业务数据。nodes 里存的是“图的结构”具体的提示词、模型参数、工具名都放在每个节点的 config 里。一键部署的做法是把 toWorkflowJson() 的产物交给后端接口后端将其持久化并构建 LangGraph4j 实例运行时的模型 API Key、知识库连接串全部通过环境变量注入工作流 JSON 里不出现任何敏感信息。验收时我会固定用 20 个典型场景跑一遍离线回放对比每个节点的入参出参是否符合预期再放开给业务方试用。这是我踩出来的习惯——以前总觉得线上能跑就是好后来一次升级把评审节点跑丢了只能连夜回滚。从那以后每次改工作流引擎我都先过一遍回放再上线。希望这套路径和这些坑能帮你少走一段弯路。本文还有配套的精品资源点击获取
返回列表