简介:面向全栈 AI 应用开发者的工程示例与平台源码包,聚焦 Spring Boot 3 + LangChain4j + Vue 3 技术栈,覆盖智能代码生成、AI 智能体、LangGraph4j 工作流与 Tool Calling 等核心能力,并展示可视化编辑、一键部署、应用管理及智能路由的实现思路,既有完整的前后端交互链路,也适合作为 AI 应用平台脚手架进一步扩展。压缩包共 216 个文件、约 1.14MB,其中 Java 后端逻辑约占 143 个文件,Vue/TS 前端页面与交互约 40 个文件,另有 JSON、XML、YML、SQL 等配置脚本及 Markdown 说明,目录分层明显,便于按模块阅读与二次开发;智能体编排、工具调用注册、工作流节点配置等关键示例均包含在内。资源还结合多级存储与 Nginx 部署,给出 Prometheus、Grafana、ARMS 监控方案,并预留 Cursor Vibe Coding 协作开发入口,可帮助读者快速复现一套可观测、可扩展的 AI 应用平台骨架。已有 250 人学习下载,适合正在搭建智能体、工具调用或工作流平台的同学对照源码梳理完整实现链路。
1. 从智能体到代码生成:这个 AI 应用平台到底在解决什么问题
一个 Java 技术栈为主的团队想上 AI 应用,往往卡在同一个地方:Python 生态的 LangChain 很成熟,但团队没人愿意跨界维护两套技术栈。这个平台标题给出的答案很直接——用 SpringBoot3 做后端底座,用 LangChain4j 接大模型,用 LangGraph4j 做工作流编排,再配一个 Vue3 的可视化画布把 AI 能力变成能拖拽、能部署、能管理的产品。它解决的不是"怎么调一次大模型接口",而是"怎么把智能体、代码生成、工具调用这些能力沉淀成企业内部的标准化应用平台"。适合两类人:一是想从零构建 AI 应用平台的架构师,二是接了类似需求但不知道从哪落地的后端和前端工程师。
2. SpringBoot3 + LangChain4j 的工程底座:选型理由与最小可运行配置
2.1 为什么是 LangChain4j 而不是自研封装或 Python LangChain
先回答一个很多人纠结的问题:我直接写 OpenAI SDK 或者 Spring AI 不行吗?自己封装 OpenAI SDK 当然能跑通对话,但一旦涉及多轮对话的记忆管理、文档的 Embedding 切分、工具的自动发现和调用,代码量会迅速膨胀。LangChain4j 在 Java 生态里的定位和 Python 版 LangChain 一样,把这些通用能力抽象成 ChatModel、EmbeddingModel、AiServices、Tool 这些接口,你只需要替换模型厂商的依赖,业务代码基本不动。
Spring AI 和 LangChain4j 之间我选了后者,原因是它对 ToolCalling 和函数回调的支持更直接,@Tool 注解的体验和 Java 开发者的直觉一致。LangGraph4j 的出现补齐了 LangChain4j 最缺的工作流编排能力——之前做复杂 Agent 只能自己手写状态机,现在有官方的图执行引擎,节点、条件边、状态传递都是声明式的。这里要认清一个现实:LangChain4j 的迭代速度很快,API 在不同小版本之间有过调整,所以工程落地时要先锁版本,不要一路上最新。
2.2 SpringBoot3 工程骨架与模型接入的最小配置
SpringBoot3 强制要求 JDK17 起步,实际项目我建议直接用 JDK21,LTS 版本,虚拟线程对 AI 场景的流式输出有帮助。创建一个标准的 Maven 工程,关键依赖就三个:langchain4j-spring-boot-starter、langchain4j-open-ai(兼容 OpenAI 协议的服务都走这个)、langgraph4j-core。下面这个 pom 片段是经过验证的最小集合:
<properties> <java.version>21</java.version> <spring-boot.version>3.3.5</spring-boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> </dependency> <!-- LangGraph4j 建议挂在 langchain4j 同一版本族下,避免接口不匹配 --> <dependency> <groupId>com.langchain4j</groupId> <artifactId>langgraph4j-core</artifactId> </dependency> </dependencies>注意 langchain4j 和 langgraph4j 的 groupId 不一样,前者是 dev.langchain4j,后者是 com.langchain4j。版本号我刻意没写死,因为这两个库的版本更新频繁,直接查 Maven 仓库选最新稳定版即可,但一定要检查 langgraph4j 依赖的 langchain4j-core 版本和你项目里的保持一致,否则运行时会出现 NoSuchMethodError。
模型接入配置走 SpringBoot 的配置文件,这里以 OpenAI 兼容接口为例,国内云厂商的模型网关、Ollama、vLLM 部署的本地模型都能通过这个方式接入:
langchain4j: open-ai: chat-model: base-url: ${LLM_BASE_URL:http://localhost:8000/v1} api-key: ${LLM_API_KEY:sk-local} model-name: ${LLM_MODEL:qwen2.5-coder:32b} temperature: 0.2 max-tokens: 4096 log-requests: true log-responses: true embedding-model: base-url: ${LLM_BASE_URL:http://localhost:8000/v1} api-key: ${LLM_API_KEY:sk-local} model-name: ${EMBEDDING_MODEL:bge-m3}base-url 通过环境变量注入,这样同一个 jar 包在开发、测试、生产环境不用改配置。temperature 设成 0.2 是代码生成场景的推荐值,温度太高模型会自由发挥,生成的东西看着像代码但编译不过。log-requests 和 log-responses 在联调阶段一定要开,LangChain4j 会把完整的请求体和响应体打出来,排查 prompt 问题和 token 统计都靠它。
2.3 模型接入层的抽象:OpenAI 兼容接口与本地模型共存
实际项目里不太可能只接一家模型。我一般会在业务代码之上加一层 ModelRouter,按场景路由到不同模型:意图识别用便宜的小模型,代码生成用能力强的 32B 以上模型,Embedding 固定用一个。LangChain4j 的 ChatModel 接口天然支持这种抽象,你只需要在配置类里声明多个 Bean:
@Configuration public class ModelConfig { @Bean @Primary public ChatModel mainChatModel(@Value("${llm.main.model}") String model) { return OpenAiChatModel.builder() .baseUrl("http://localhost:8000/v1") .apiKey("sk-local") .modelName(model) .temperature(0.2) .build(); } @Bean public ChatModel fastChatModel(@Value("${llm.fast.model}") String model) { return OpenAiChatModel.builder() .baseUrl("http://localhost:8000/v1") .apiKey("sk-local") .modelName(model) .temperature(0.1) .maxTokens(1024) .build(); } }@Primary 注解保证默认注入的是能力最强的那个模型,需要快模型的地方用 @Qualifier("fastChatModel") 显式指定。这个做法的好处是后续接 Anthropic 或 Gemini 时,只要再实现一个 ChatModel Bean,业务代码零改动。到了这个阶段,你已经有一个能对话、能 Embedding 的 SpringBoot3 后端了,下一步就是把它升级成能自主完成任务的智能体。
3. 用 LangGraph4j 编排代码生成 Agent:节点、条件边与 ToolCalling
3.1 LangGraph4j 的核心概念:State、节点与条件边
LangGraph4j 解决的核心问题是:一个智能体不只是一次模型调用,而是"感知-规划-行动-观察"的循环。你需要在代码里显式表达这个循环,包括循环什么时候结束、状态怎么在节点之间传递。它有三个基础概念:State(状态)、Node(节点)、Edge(边)。State 是一个在节点间传递的数据载体,Node 是处理状态的一个函数,Edge 定义节点的连接关系,其中一种特殊的边叫条件边,根据当前状态决定下一步走哪个节点。
这样设计的好处是人和模型的分工变清楚了:节点里的逻辑是你写死的确定性代码,模型只负责在节点里产出内容或决定走哪条条件边。相比纯提示词驱动的 Agent,LangGraph4j 让整个流程可观测、可 debug、可回放——这正是生产环境最需要的东西。下面定义一个代码生成工作流的状态类型:
public interface CodeGenState { GraphStateDTO<String> requirement = GraphStateDTO.string("requirement"); GraphStateDTO<String> plan = GraphStateDTO.string("plan"); GraphStateDTO<String> sourceCode = GraphStateDTO.string("sourceCode"); GraphStateDTO<String> execResult = GraphStateDTO.string("execResult"); GraphStateDTO<Integer> retryCount = GraphStateDTO.int32("retryCount"); GraphStateDTO<Boolean> passed = GraphStateDTO.bool("passed"); }每个节点只关心它需要的字段,LangGraph4j 会在节点完成后自动合并新状态。retryCount 在这里很关键,它控制整个工作流的终止条件,避免模型陷入"生成-执行失败-再生成"的死循环。
3.2 定义代码生成工具:@Tool 的参数描述决定调用准确率
ToolCalling 是大模型连接外部系统的通道。LangChain4j 的做法是用 @Tool 注解标记一个 Java 方法,模型根据方法名、描述和参数描述来决定什么时候调用、传什么参数。很多人第一次写工具方法时只写方法名,结果模型死活不调用,原因就是描述信息太少,模型不知道这个方法能干什么、参数应该填什么。
@Component public class CodeExecutionTools { @Tool("执行传入的 Java 源码,返回编译和运行的标准输出;如果编译失败则返回错误信息") public String runJavaCode( @ToolParam("完整的 Java 源码字符串,必须包含 public class Main 和 main 方法") String sourceCode) { // 将 sourceCode 写入临时文件,调用 javac 编译,java 执行, // 捕获 stdout 和 stderr,超时时间设为 10 秒,防止模型生成死循环代码。 return output; } @Tool("读取项目内指定相对路径的文本文件内容") public String readFile( @ToolParam("相对项目根目录的文件路径") String filePath) { return FileUtil.readUtf8String(filePath); } }@ToolParam 里的描述会作为参数说明拼进模型请求的 tools 定义里,描述越具体,模型传参的准确率越高。我踩过的坑是参数描述太模糊,模型把文件路径传成 "test.java",而实际要传 "src/test/java/Test.java"。这个环节没捷径,每个工具都要站在模型的角度写清楚"这个参数应该是怎样的格式"。
3.3 组装可执行的工作流:plan → 生成 → 执行 → 评审
有了状态和工具,就可以组装工作流了。这个代码生成 Agent 的流程是:先让模型读需求做计划,再生成源码,然后调用工具执行,最后让模型评审执行结果。评审不通过且重试次数没超限,就带着错误信息回到生成节点重新生成。
@Service public class CodeGenWorkflow { private final ChatModel chatModel; private final ToolExecutor toolExecutor; public CodeGenWorkflow(ChatModel chatModel, ToolExecutor toolExecutor) { this.chatModel = chatModel; this.toolExecutor = toolExecutor; } public StateGraph<CodeGenState> buildGraph() { StateGraph<CodeGenState> graph = new StateGraph<>(CodeGenState.SPEC); graph.addNode("planner", this::planNode); graph.addNode("codegen", this::codeGenNode); graph.addNode("executor", this::executeNode); graph.addNode("reviewer", this::reviewNode); graph.setEntryPoint("planner"); graph.addEdge("planner", "codegen"); graph.addConditionalEdge("executor", this::needFix, Map.of(true, "codegen", false, "reviewer")); graph.addEdge("reviewer", StateGraph.END); return graph; } private Map<String, Object> planNode(Map<String, Object> state) { String requirement = (String) state.get("requirement"); String prompt = """ 你是资深后端架构师。根据需求输出实现计划,要求: 1. 列出需要创建的类及其职责 2. 标注每个类之间的依赖关系 3. 计划不超过 200 字 需求:%s """.formatted(requirement); String plan = chatModel.generate(prompt); return Map.of("plan", plan); } }condition 边是 LangGraph4j 的核心,needFix 方法返回 true 时回到 codegen 节点,false 时进入 reviewer 节点。这样模型生成错了也不怕,executor 节点拿到的编译错误会被拼进新一轮生成的 Prompt,形成"错误反馈-重新生成"的闭环。注意节点函数的入参和返回值都是 Map,变量名要和状态定义时的 key 保持一致。
组装工具调用要注意 langgraph4j 的机制和 LangChain4j 的 AiServices 不太一样——工作流节点里需要你自己把 ChatModel 和工具的执行结果串联起来。常见的做法是复用 LangChain4j 的 AiServices,把它绑定工具类后作为一个整体节点调用:
private Map<String, Object> codeGenNode(Map<String, Object> state) { CodeGenerator agent = AiServices.builder(CodeGenerator.class) .chatModel(chatModel) .tools(new CodeExecutionTools()) .build(); String requirement = (String) state.get("requirement"); String plan = (String) state.get("plan"); String lastError = state.get("execResult") == null ? "" : (String) state.get("execResult"); String code = agent.generate(requirement, plan, lastError); return Map.of("sourceCode", code); }这里 AiServices 内部已经把 ToolCalling 的消息循环封装好了:模型请求调用工具,框架自动执行工具方法,再把结果回传给模型,直到模型给出最终答案。你只需要在界面接口里定义 generate 方法并标注 @SystemMessage 和 @UserMessage 模板即可,这就是 LangChain4j 把 Java 接口变成 Agent 的标准方式。
3.4 流式输出与进度推送:SSE 把过程反馈给前端
工作流跑起来后,如果整个操作要等 30 秒才返回,前端的体验是灾难。我一般用 SSE(Server-Sent Events)把每个节点的执行状态实时推给前端。后端在节点入口往 Spring 的 SseEmitter 里写一条进度事件,前端在画布对应节点上亮灯。
LangGraph4j 本身没有内置 SSE 支持,但你可以用一个全局的 WorkflowProgressPublisher 组件,节点里调用它发事件,Controller 层暴露 SSE 接口订阅。Go 的实现不复杂:SseEmitter 存进 ConcurrentHashMap,节点状态变化时遍历发送,前端用 EventSource 接收。注意 SseEmitter 默认超时时间是 30 秒,工作流超过这个时间要记得在初始化时显式设置更长超时。
4. Vue3 可视化编排:从拖拽画布到应用管理的一体化前端
4.1 画布的数据模型:一个 JSON 就是一个工作流
可视化编排的本质是"所见即所得地编辑一个 JSON",后端拿着这个 JSON 再把它翻译成 LangGraph4j 的 StateGraph。所以前端画布的数据模型一定要工作流对齐,而不是只顾画得好看。我维护的数据结构长这样:
{ "nodes": [ { "id": "n1", "type": "agent", "label": "代码生成", "config": { "model": "qwen2.5-coder:32b", "temperature": 0.2 } }, { "id": "n2", "type": "tool", "label": "执行器", "config": { "toolName": "runJavaCode" } }, { "id": "n3", "type": "condition", "label": "评审通过?", "config": { "condition": "review.passed == true" } } ], "edges": [ { "source": "n1", "target": "n2", "label": "normal" }, { "source": "n2", "target": "n3", "label": "normal" }, { "source": "n3", "target": "n1", "label": "false" }, { "source": "n3", "target": "end", "label": "true" } ], "global": { "maxRetry": 3, "timeoutSeconds": 120 } }node 的 config 字段是给属性面板用的,每个节点的类型不同,配置项也不同。agent 节点要选模型和调参,tool 节点要选工具名和入参。后端解析这个 JSON 时按 type 分发到不同的节点工厂,这样就实现了一次编排、到处执行。
4.2 节点面板、连线和属性表单的 Vue3 实现
Vue3 实现拖拽画布选 vue-flow 最省力,它对 Vue3 Composition API 支持得很好,节点拖拽、连线、缩放的交互开箱即用。核心组件结构是左侧一个节点物料面板,中间是画布,右侧是选中节点的属性表单,三个区域的数据流都汇到一个 reactive 对象上:
<script setup> import { ref, reactive, computed } from 'vue' import { VueFlow, useVueFlow } from '@vue-flow/core' import '@vue-flow/core/dist/style.css' import AgentNode from './nodes/AgentNode.vue' import ToolNode from './nodes/ToolNode.vue' const nodes = ref([]) const edges = ref([]) const selectedNode = ref(null) // 左侧物料面板的拖拽:把节点类型写进 dataTransfer, // 画布 drop 事件里根据类型创建节点对象。 function onDragStart(event, type) { event.dataTransfer.setData('application/flow-type', type) event.dataTransfer.effectAllowed = 'move' } function onDrop(event) { const type = event.dataTransfer.getData('application/flow-type') const position = project({ x: event.clientX, y: event.clientY }) nodes.value.push({ id: `node-${Date.now()}`, type, position, config: defaultConfig(type) }) } // 选中节点后,它的 config 直接绑定到右侧表单组件, // 表单的每一项都是动态渲染的,节点类型决定渲染哪些字段。 const selectedConfig = computed(() => selectedNode.value?.config || {}) </script>核心思路就两条:节点类型决定默认配置和属性表单的渲染字段;选中节点和属性表单之间用 selectedNode 单例绑定。属性表单这里有一个 Vue3 动态增删表单项的经典需求——agent 节点的环境变量是不定长的,我直接用 v-for 渲染一组 key-value 输入框,添加和删除按钮只操作数组的 push 和 splice,配 deep 监听同步到画布节点的 config:
<div v-for="(env, index) in selectedConfig.envs" :key="index" class="env-row"> <input v-model="env.key" placeholder="环境变量名" /> <input v-model="env.value" placeholder="值" /> <el-button type="danger" @click="selectedConfig.envs.splice(index, 1)">删除</el-button> </div> <el-button @click="selectedConfig.envs.push({ key: '', value: '' })">添加环境变量</el-button>这个表单项的增删改查全在 reactive 对象上完成,Vue3 的代理机制保证画布节点同步刷新。整体画布在工作中就是一个后台管理系统里的复杂表单,只是这个表单的结构不是人定的,是用户自己拖出来的。
4.3 应用管理与一键部署的前后端衔接
可视化编辑只是平台的前半段,后半段是应用管理和一键部署。后端需要提供应用维度的 CRUD 接口:创建应用、保存画布 JSON、发布版本、查看部署状态。我习惯把"保存"和"发布"拆成两个动作——保存只写数据库草稿,发布才真正触发构建和部署。这样用户频繁调整画布不会产生一堆垃圾部署记录,发布记录表里每一行都可回滚。
一键部署的后端实现是异步任务加日志流推送。收到发布请求后,后端把应用 ID、画布 JSON、模型配置打包成一个部署任务,丢给线程池执行。部署过程分四步:生成后端工程骨架、写入工作流配置、Maven 打包、Docker 构建并启动。每一步的日志都通过 SSE 实时推给前端,用户在浏览器里能看到构建进度条一行一行滚动,这是"一键部署"体验的关键。
@PostMapping("/api/apps/{appId}/deploy") public SseEmitter deploy(@PathVariable Long appId, @RequestBody DeployRequest request) { SseEmitter emitter = new SseEmitter(300_000L); deployService.submit(appId, request, emitter); return emitter; }SseEmitter 的超时时间我显式设成了 300 秒,因为首次构建要拉 Maven 依赖和 Docker 基础镜像,慢的时候能跑到 3 分钟以上。前端 EventSource 创建后要监听 readyState 变化,部署完成后主动 close 连接,避免无效连接占满 Tomcat 线程。
5. 从开发到上线的常见问题排查:5 个高频踩坑点
5.1 SpringBoot 版本与 JDK 版本不匹配导致注解扫描失败
现象:项目启动后报 "ClassNotFoundException: jakarta.servlet.http.HttpServlet",或者 swagger 页面打不开,接口全 404。
原因:SpringBoot3 用的是 Jakarta EE 规范,包名从 javax.* 改成了 jakarta.*。如果你的本机 JDK 是 8 或 11,Maven 编译时用了低版本 source/target,生成的字节码指向老的 javax 包,运行时必然找不到类。还有一种情况是依赖里混入了 javax.servlet-api 的老传递依赖,把包名污染了。
解决:JDK 锁到 17 或 21,在 pom 里显式声明 spring-boot-maven-plugin 的 jvmArguments 为 --add-opens java.base/java.lang=ALL-UNNAMED,防止反射告警。排除所有 javax.servlet 开头的依赖,改用 spring-boot-starter-web 统一管理。排查命令是 mvn dependency:tree,看是否有老 servlet 传递进来。
5.2 WebFlux 与 WebMVC 冲突:SSE 流式输出起不来的元凶
现象:后端加了 SSE 接口后,项目启动直接报 "SpringApplicationApplicationContextException: Invalid web application: 'spring.main.web-application-type=none'",或者 SSE 接口永远挂起不返回。
原因:LangChain4j 的流式响应在部分版本里默认依赖 WebFlux 的 Flux 类型,引入 spring-boot-starter-webflux 后和原来的 spring-boot-starter-web 打架。Spring 容器检测到两个 WebApplicationFactory,直接不知道用哪个,只能报错。
解决:确定自己用的是 Servlet 栈还是响应式栈。我用 Servlet 栈做 SSE,就别引 webflux,LangChain4j 流式输出用 StreamingChatModel 接口阻塞式订阅 Flux 再转 SseEmitter。如果非得混用,就把 web-application-type 显式设为 servlet,并保证 webflux 的依赖 scope 是 provided 或完全不引入。这个坑排查起来很费时间,启动日志里看到 "invalid web application" 直接去查依赖树,别去改配置瞎试。
5.3 反代缓冲让流式输出变成"等半天一次性吐出来"
现象:本地 flow 输出一个字一个字蹦,部署到服务器之后前端 EventSource 要等几十秒才收到第一批内容。
原因:Nginx 默认开了 proxy_buffering,会等后端响应全部写完再一次性转发给客户端,SSE 的 chunked 流式效果被彻底吞掉。这不是后端代码的问题,是网关层配置问题,典型的"上线才踩坑"。
解决:在 Nginx 的 location 配置里加上 proxy_buffering off; 和 proxy_cache off;,同时把 proxy_read_timeout 调到 300 秒以上。注意 SSE 用的是长连接,心跳设置要同时照顾 Nginx 和浏览器两侧的超时。代码里 SseEmitter 注释里写清楚"必须配 Nginx 关缓冲",防止部署的人不懂这个关联关系。
5.4 ToolCalling 工具参数缺失描述,模型宁可拒绝也不调用
现象:日志里模型明确说需要调用工具,但响应里的 tool_calls 是空的,或者传过来一个空的 JSON 参数,业务侧解析直接抛异常。
原因:大模型在不确定工具参数格式时会选择不调用或传入空值。而我早期写的 @ToolParam 只有参数名,没有描述,模型不知道这个字符串应该填相对路径还是绝对路径。更隐蔽的是参数类型不匹配——工具方法声明的是 Map,模型传过来的却是 JSON 字符串。
解决:每个 @ToolParam 都按"格式 + 示例"写描述,比如"相对项目根目录的文件路径,例如 src/main/java/Application.java"。复杂参数不要用 Map,定义成 POJO 配合 @ToolParam 逐字段描述。另外要确认工具返回值转成 String 后没有截断,有些模型服务对 tool message 有长度限制,超长返回会被丢弃,表现为工具调用后模型没有反应。
5.5 Vue3 属性继承与表单校验失效的连带问题
现象:属性面板里填的表单值保存后又变回原样,或者画布的全屏按钮在 Edge 浏览器里偶尔点不动,鼠标点上去没反应。
原因:这个现象和 Vue3 的组件属性透传机制有关。自定义组件如果根元素恰好是另一个组件,外部传入的 class、style、事件监听器会透传到根组件上,如果根组件内部也用 v-bind="$attrs",容易产生事件覆盖。比较典型的是 Element Plus 的 el-form-item 在动态渲染时,label 属性被透传走了,导致校验触发条件丢失,表单值不更新;另一种情况是画布全屏按钮的外层容器把 click 事件捕获了。
解决:在 Vue3 组件里显式声明 inheritAttrs: false,并手动在需要的位置绑定 $attrs,避免事件和样式透传到意外的 DOM 节点上。动态表单项的 prop 属性要用 index 拼接区分,不要都用同一个固定字符串,否则校验状态互相覆盖。Edge 里点不动按钮先怀疑是否被某个透明遮罩层挡住,给按钮加 z-index 并确保外层组件有明确的定位上下文,别裸奔。
注意:以上五条是高频但不是全部,AI 应用平台涉及的新依赖多、版本节奏快,上线前把它当"体检列表"过一遍,能省出至少一个周末的排错时间。
6. 进阶:多模型路由、链路追踪与压测验证
平台能跑通以后,下一个问题就是成本和质量。大模型 API 按 token 计费,代码生成场景一次调用动辄几千 token,全公司都用最强的 32B 模型扛不住。我一般会在网关层做模型路由分发,而这正是"多路召回"思路的工程化落地——把用户请求先做意图分类,简单任务(翻译、格式化、解释报错)直接落到一个轻量模型上,复杂任务(生成完整项目、多文件改造)才转发到强模型。
public interface ModelRouter { String route(String intent, int estimatedInputTokens); } @Component public class DefaultModelRouter implements ModelRouter { @Override public String route(String intent, int estimatedInputTokens) { if (estimatedInputTokens > 3000 || intent.startsWith("codegen:")) { return "strong"; } return "fast"; } }估 token 的办法不精确但够用:中文字符按 1.5 倍估算,代码字符按 0.4 倍估算,取整加余量。另外链路追踪要趁早接,工作流的每个节点执行耗时、模型调用的 token 消耗、工具调用的成败,全都要落日志,traceId 从请求进来就生成并贯穿 SSE 和异步任务。我用的是最朴素的方案——MDC put traceId,logback 输出到控制台和文件,排障时按 traceId grep 一次拿到全链路。微服务规模大了再上 SkyWalking 也不迟,单机阶段别过度设计。
压测验证重点关注两个指标:工作流单次完成时间 P95 和部署任务的并发上限。前者决定用户体验,后者决定平台的稳定性。用压测工具模拟 50 个并发用户同时触发代码生成工作流,观察数据库连接池和线程池是否成为瓶颈。LangGraph4j 的节点执行是阻塞式的,但节点之间没有共享状态,可以放心加 @Async 把无依赖的节点并行化。我自己踩过的教训是:刚开始为图省事把所有工具方法都加上 synchronized,结果多路召回一上线就串行排队,P95 从 8 秒暴涨到 40 秒。后来改成工具内部用无状态设计,只在写文件时用临时文件隔离,压测直接达标。
这个平台的完整落地路径是:SpringBoot3 接模型能力,LangGraph4j 编排复杂工作流,ToolCalling 打通系统边界,Vue3 把这一切变成可视化操作。到了后期,你会意识到平台最大的价值不是某一个 AI 能力,而是把散落在各种脚本里的 AI 调用沉淀成了可编排、可观测、可回滚的标准化应用。希望这些思路和踩坑记录能帮到你,少走一段我走过的弯路。
本文还有配套的精品资源,点击获取