
先说结论这套组合做 AI Agent是目前 Java 技术栈里性价比最高、落地最快、前后端心智负担最小的方案之一。SpringAI 解决模型接入和工具调用编排DeepSeek 提供高性价比的模型推理能力HTMX 则把前端交互复杂度降到几乎没有。我花了一个周末把整套链路跑通从 Spring Boot 工程搭建到 Agent 能自主决定调用哪个工具、把结果整理成自然语言回复前后不到 800 行代码没有写一行前端路由没有配置任何中间件。如果你正在纠结“Java 能不能做 AI Agent”“SpringAI 和 LangChain4j 到底选哪个”“前端怎么处理流式输出这么麻烦的问题”这篇内容基本能回答你大部分困惑。1. 为什么偏偏是 SpringAI DeepSeek HTMX网上聊 AI Agent十个里有九个是 Python LangChain 或 LangGraph剩下一个是 Node.js。Java 开发者很容易陷入一种错觉做 Agent 必须会 Python否则就落后了。但实际从工程化角度看Java 生态做 Agent 有它不可替代的优势。SpringAI 项目是 Spring 官方在 2024 年启动的 AI 框架目前已经迭代到 1.0 版本。它做了一件很关键的事把主流大模型OpenAI、DeepSeek、Ollama、Qwen、Claude 等的接入方式统一成了一套接口、一套配置、一套注解。这意味着你原来会用 Spring 的RestTemplate写 HTTP 调用、会用Configuration管理 Bean那你就会用 SpringAI。模型可以随时切换业务代码基本不用动。LangChain4j也有不少人推荐但 LangChain4j 目前更偏向于API层面的对齐整个项目仍在快速变化且API不够稳定SpringAI 背靠 Spring 官方生态和 Spring Boot 的自动装配、配置管理体系结合得更深工具调用Function Calling的机制也更清晰对已经熟悉 Spring 生态的团队来说学习成本明显低很多。如果你在纠结“SpringAI 和 LangChain4j 的区别”一个简单的判断标准是你的项目里是不是已经重度依赖 Spring Boot如果是SpringAI 会让你更丝滑。DeepSeek的吸引力在于两个方面。第一是便宜DeepSeek-V3 和 R1 的 API 定价约是 OpenAI 同级模型的几十分之一个人开发者做原型、中小团队做生产试点成本压力可以忽略不计。第二是接口兼容DeepSeek 提供了 OpenAI 兼容的 API 格式SpringAI 可以直接用spring.ai.openai这套配置来连 DeepSeek不需要任何中间层适配。DeepSeek 在中文场景下的代码能力、逻辑推理能力也很出色做 Agent 的工具调用规划表现稳定。再来说HTMX。传统前端做 AI 对话交互典型路径是 Vue/React WebSocket Markdown 渲染 流式解析插件光前端工程就几百个依赖。HTMX 的思路是“把 HTML 本身当作超媒体 API”服务端直接返回 HTML 片段前端通过hx-swap、hx-trigger这些属性就能实现局部更新。配合 SSEServer-Sent Events处理流式输出代码量可以压缩一个数量级。三个组件组合起来整个 AI Agent 的技术栈变得异常清爽后端就是标准 Spring Boot前端是服务端渲染的模板页面 HTMX 属性。2. 环境准备与工程骨架搭建先说环境版本这些是我实测稳定的一组组合直接照着配不会踩到版本兼容的坑。组件版本说明JDK17 或 2117 可跑21 更稳建议直接用 21Spring Boot3.4.x需 3.x2.x 不支持 SpringAI 1.xSpringAI1.0.0 以上我用的是当时最新稳定版HTMX2.x通过 CDN 引入即可DeepSeek APIV3 / R1官方开放平台获取 keyMaven3.8项目构建Spring Boot 3.4 SpringAI 1.0 的 Maven 依赖需要单独处理。SpringAI 目前没有跟着 Spring Boot 的 BOM 走所以使用pom.xml时内容要写完整除了starter-ai-openai还要显式声明 SpringAI 的 BOM否则会因为传递依赖版本不一致出现ClassNotFound这类问题。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.4.1/version /parent dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-openai/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency /dependencies注意这里用的是spring-ai-starter-openai这个 starter 连接 DeepSeek因为 DeepSeek 兼容 OpenAI 协议。如果直连 DeepSeek 有原生 SDK 或更直接的 starter也可以用spring-ai-starter-deepseek但 OpenAI 兼容方式通用性更强以后切换模型不用改代码。配置application.yml时有一个关键点SpringAI 默认会加载所有模型相关的自动配置如果没有设置对应的 key启动时会直接报错。只连 DeepSeek 的话配置文件的 api-key 必须写全而且要主动用 base-url 指向 DeepSeek 的兼容接口地址。DeepSeek 的接口地址是基于https://api.deepseek.com的 OpenAI 兼容路径所以 base-url 通常配成https://api.deepseek.com或https://api.deepseek.com/v1。Spring AI 的默认 OpenAI 地址是https://api.openai.com因此 base-url 这里不改的话请求会发到 OpenAI 去。密钥、模型名、编码这些都要在配置里覆盖掉。spring: application: name: springai-demo ai: openai: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com chat: options: model: deepseek-chat temperature: 0.7 server: port: 8080 servlet: encoding: charset: UTF-8 force: true还有一个常见坑DeepSeek 接口虽然兼容 OpenAI但模型名必须是deepseek-chat对应 V3 对话模型或deepseek-reasoner对应 R1 推理模型。如果你把 OpenAI 习惯的gpt-4o之类的名字直接搬过来调用时会直接收到Model Not Exist错误。另外DeepSeek 有时候会返回this model doesnt support images之类的提示说明你用了多模态参数去掉 image 相关设置就好。整一套工程结构不需要 AI 相关的专项目录就是把普通 Web 项目按职责拆。controller层放接口和事件流service层写 Agent 核心逻辑components放具体的工具类。前端往下的模板也走标准 Thymeleaf 结构。3. Agent 核心工作流从配置定义到工具调用Agent 与普通 Chat 最大的区别在于**“会行动”**——它不只是生成文字而是能够识别用户意图、选择工具、执行工具、最后把结果包装成自然语言回复。SpringAI 提供了工具注册和调用的标准机制用起来比 LangChain 的 Agent 概念要轻但能力一样不缺。先定义 Agent 的能力边界。我这个 Demo 选了三个非常典型的工具方便展示不同类型工具名功能说明getCurrentTime获取当前时间无参数演示无参工具调用calculateExpression数学计算有参数演示参数传递验证sendEmail发邮件测试演示工具执行结果影响回复内容每个工具就是一个普通的 Spring 组件核心是加一个Tool注解。这个注解做了几件事注册工具名、绑定参数描述可被传给模型用来规划调用、把方法声明为可被 SpringAI 回调的函数。Component public class AgentTools { Tool(description 获取当前日期和时间) public String getCurrentTime() { return 现在是 LocalDateTime.now().format( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } Tool(description 计算数学表达式例如 (12)*3-4/2) public String calculateExpression(String expression) { try { // 这里不要用 ScriptEngine只支持四则运算的解析器就够了 double result new ExpressionParser().parse(expression).evaluate(); return expression result; } catch (Exception e) { return 表达式无效请检查; } } Tool(description 发送一封测试邮件参数为收件人和邮件内容) public String sendEmail(String to, String content) { // 模拟发送 return 已向 to 成功发送邮件; } }这里有个非常重要的细节Tool注解加在方法上但 SpringAI 是通过ApplicationContext扫描这个 Bean 的所以这个工具类必须被 Spring 管理。如果你是用new自己 new 出来的或者在工具类里没有加ComponentSpringAI 只会静默跳过工具注册调用 Agent 时模型永远答“我暂时无法获取当前时间”。这个坑我查了好久才定位到特别隐蔽。接下来就是 Agent 服务。在 SpringAI 1.0 里最推荐的调用方式是ChatClient它相当于 AI 版的RestClient链式调用非常舒服方法名call和stream分别对应用户请求、流程图等场景。ChatClient有一个.tools()方法可以把多个工具对象传进去模型会自行决定什么时候触发谁。为了让多个用户互不干扰我需要用一个ChatMemory来管理会话历史这里用的是InMemoryChatMemory后续你也可以把它替换成数据库存储。Service public class AgentService { private final ChatMemory chatMemory; private final ChatClient chatClient; public AgentService(AgentTools agentTools, ChatMemory chatMemory, ChatClient.Builder builder) { this.chatMemory chatMemory; this.chatClient builder .defaultSystem( 你是一个智能助手可以根据用户的问题选择调用合适的工具。 如果你觉得工具调用结果还不够可以继续调用其他工具。 你需要在最后用中文汇总回答用户的问题。 ) .build(); } public String chat(String userMessage, String sessionId) { return chatClient.prompt() .user(userMessage) .options(ChatOptions.builder() .internalToolExecutionEnabled(true) .build()) .advisors(a - a .param(ChatMemory.CHAT_MEMORY_CONVERSATION_ID_KEY, sessionId) .param(ChatMemory.CHAT_MEMORY_RETRIEVE_SIZE_KEY, 20)) .tools(agentTools) .call() .content(); } }ChatMemory是管理会话历史的核心接口这里有两个参数要理解清楚CHAT_MEMORY_CONVERSATION_ID_KEY会话 ID相当于给每个用户/会话开一条独立记忆线。CHAT_MEMORY_RETRIEVE_SIZE_KEY每次调用时携带的历史消息条数。这个值不要设太大超过 20 条后 DeepSeek 的上下文窗口占用会明显增加响应时间变长而且费用变高。internalToolExecutionEnabled(true)这个配置相当关键。它的意思是SpringAI 内部自动执行工具调用循环——模型先返回“我要调用 getCurrentTime”框架自动执行该方法再把结果返回给模型模型根据结果继续生成最终回复。如果你把这个开关关掉就必须自己去实现 Tool Execution Loop编排复杂度会高出一大截。默认情况下这个开关是开启的所以如果你想让 Agent 自己完成“思考-调用-总结”的闭环就不用额外处理了。4. 深度拆解DeepSeek 接入中的关键配置与避坑很多人在这个环节会翻车。DeepSeek 的 API 支持 OpenAI 兼容格式但和 OpenAI 原生服务有几处行为差异和 SpringAI 结合时若不注意轻则多耗 token重则直接报错。4.1 base-url 与模型名必须对齐DeepSeek 目前有两种合法的 base-url 写法https://api.deepseek.com和https://api.deepseek.com/v1两者实际兼容路径保持一致。但如果 base-url 配置成https://api.deepseek.com在 SpringAI 里健康检查或对话调用时OpenAI 的客户端会拼成类似https://api.deepseek.com/chat/completions的路径有 /v1 时会拼成/v1/chat/completionsDeepSeek 官方两个路径都能接受所以实测两种都行。真正出问题的是client 默认对/models等额外接口的探测逻辑。如果你在 Spring Boot 启动时发现控制台打印了一堆404请求日志然后用curl试一下base-url/models路径如果返回 404那很可能就是 base-url 前面多加了一段/v1。这种 404 不影响对话主流程但会干扰日志排查。我的建议是统一使用https://api.deepseek.com路径拼装少一层少一个出错点。4.2 上下文管理DeepSeek 对 history 的容错DeepSeek 的 API 对历史消息数组里的name字段支持存在历史版本差异部分早期版本如果messages中混有带name的system消息会直接拒绝整个请求HTTP 返回 400错误信息往往模糊。SpringAI 对系统消息的处理默认是不加name的所以只要你没有自己拼ListMessage基本不会触发这个问题。如果你是从 LangChain 迁移过来注意去掉 messages 里多余的 name 字段。4.3 流式输出与 SSE 的坑调用 DeepSeek 的stream接口时SpringAI 底层走了 SSE 管道。实际使用中DeepSeek 的流式输出正常情况下每个 chunk 都只含一个增量 token没有 OpenAI 那种 useage 汇总帧。这意味着如果你在 stream 回调里统计 token 或者检查 finish reason需要自己收集最后一段。好在 SpringAI 1.0 已经把finishReason和usage透传出来了可以手动判断。有一次我在自测时发现流式输出一旦生成完整回复content里经常带null这是因为 DeepSeek 流式返回的delta.content字段可能为空表示该帧只是增量角色或响应元数据。SpringAI 在 chunk 组装时对这种情况处理方式较保守导致前端渲染时会出现短暂的空白闪烁。解决方案是前端在onmessage事件里判断event.data是否为[DONE]并且内容为空时跳过渲染不要直接输出。4.4 系统提示词与 Function Calling 的一致性Agent 要用好“调用工具”能力关键在系统提示词里要明确告诉模型可以进行工具调用并且必须基于工具返回结果生成回复。不然模型很可能在没有实质调用工具的情况下就直接给出一个“参考答案”这会让用户以为 Agent 是伪造结果或表现得像个不带工具的单轮对话。我常用的系统提示词增加了两个关键约束在无法唯一判断用户意图时先调用工具获取所需数据比如时间、天气、数据库里的配置对计算结果、时间等硬数字信息必须在回复中附带工具返回的原始数据不能自行编造数值。这两个约束成本很低但对 Agent 的可靠性和可信度提升非常明显。4.5 环境变量泄露问题最后提醒一句DeepSeek 的 API key 保存在application.yml时如果项目的.gitignore没有把该文件排除掉就可能被推到公共仓库泄露。我自己用的方式是在application.yml里占位${DEEPSEEK_API_KEY}然后在本地环境变量里配置真实 key。如果你用 Docker 部署可以用 Docker secret 或 Compose 的 env 文件注入。这个虽然是老生常谈但我确实见过不止一个开源项目把 key 硬编码提交上去的惨案。5. HTMX 前端交互用 SSE 实现打字机流式输出AI Agent 的用户体验里流式输出几乎是刚需。用户发一句话如果等 10 秒才看到完整回复体验非常差如果能看到一个字一个字蹦出来等待感会降低很多。传统方案是前端接 WebSocket 或自己拼 EventSource复杂度不小。HTMX 在这里发挥了一个很好的作用。5.1 页面骨架Thymeleaf 模板页面只做两件事展示消息列表 一个输入框。关键是用hx-triggersubmit拦截表单提交把请求打到后端 Agent 接口。div classcontainer stylemax-width: 900px; margin: 0 auto; padding: 20px; h2SpringAI DeepSeek HTMX 智能助手/h2 div idchat-history styleborder: 1px solid #ddd; padding: 16px; min-height: 400px; margin: 12px 0; div hx-get/chat/history hx-triggerload/div /div form idchat-form hx-post/chat/send hx-target#chat-history hx-swapbeforeend input typetext namemessage placeholder输入你的问题例如现在几点 required stylewidth: 80%; padding: 8px; button typesubmit发送/button /form /div这里有几个 HTMX 属性和传统表单提交的区别要注意hx-post/chat/send表示 AJAX 以 POST 方式请求后端。hx-target#chat-history响应回来的 HTML 片段要插入到#chat-history节点。hx-swapbeforeend插入方式是“追加到末尾”而不是替换内容。这样每次都把新消息追加到对话历史尾部不覆盖旧消息。hx-triggersubmit没有显式写是因为form的默认触发事件就是submitHTMX 会自动拦截表单提交。这里也可以补充一个hx-indicator用来在等待响应时显示加载动画。5.2 SSE 流式事件处理HTMX 官方对 SSE 的支持有两种方式hx-sse旧版扩展和hx-triggersse:自定义事件HTMX 2.x 内建。我使用后者因为内建能力能减少不必要的库依赖而且事件名可以自定义。后端实现一个 SSE 事件流接口返回text/event-stream关键点在于把每个增量 token 包装成 HTML 片段前段持续追加到一个新的消息容器里。这里有一个小技巧不要让后端直接把文本流式输出成纯文本因为 HTMX 的 SSE 事件里如果塞一段多行文本前端解析会很麻烦。我后端把每次增量都拼成span文本/span事件名为message。PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(RequestParam String message, RequestParam String sessionId) { SseEmitter emitter new SseEmitter(0L); // 0L 表示不超时 FluxString fakeChunks agentService.streamChat(message, sessionId); fakeChunks.subscribe( content - { try { emitter.send( SseEmitter.event() .name(message) .data(span content /span)); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); return emitter; }注意这里SseEmitter构造函数的0L代表永不过期。默认超时时间 30 秒一旦模型推理时间超过 30 秒前端会看到连接被断开表现为回复丢了一半。必须用 0L 或在配置文件中调大超时。前端页面对应地要监听div idchat-history hx-triggerload, sse:message from:body hx-get/chat/history hx-swapbeforeend /divhx-triggersse:message from:body的含义是监听body上名为message的事件。当后端通过 SSE 发来message事件时HTMX 会触发一次请求流程。这里还需要一个配套的细节由于每个message事件本身已经包含了span片段如果不加处理hx-swapbeforeend会不断追加新的span到对话区不会覆盖旧内容。为了做出“同一个回复持续更新”效果需要一个 JS 钩子去定位当前正在输出的消息容器。我采用的是更简单的方式后端发送事件时事件里带的 data 不是纯文本而一个完整的追加片段并且在响应结束后再发一条[DONE]事件前端 JS 监听到[DONE]时停止追加逻辑。为了控制复杂度也可以直接用我最后的简化版本一次性生成完回复但用 SSE 分页把内容分多个事件发送到同一个 div。其实如果你不想写事件名这种比较底层的细节有更简单的做法后端把整个 Agent 的回复完成后一次性返回 HTML 片段渲染照样没问题。但“打字机效果”确实是 Agent 体验中比较值得做的一部分我个人建议还是花半小时把这些事件封装搞定对用户观感和可用性提升明显。5.3 动态工具回调结果的渲染Agent 执行过程中用户可能看到工具调用过程。更有趣的是我们可以在 SSE 管道里同时推送“工具调用中”“工具执行完成”“最终回复”三类事件前端分别渲染成不同类型的 UI 气泡。后端stream方法不再只是返回FluxString而是返回FluxAgentEvent。其中AgentEvent可以携带类型TOOL_START、TOOL_END、TEXT。在 SpringAI 的ChatClient中可以通过 doOnNext 拦截 Advisors 等回调或者在你自定义工具方法里提前把消息通过 Memory 保存的 sessionId 关联推给前端。不过我建议第一次实现时不要过度设计先只把最终自然语言回复流式展示出来工具调用过程在回复中体现比如模型会说“我查了一下当前时间是……”。这样前端就不需要额外处理事件类型Agent 的表现也已经足够聪明。当你把基础链路跑通再回头加中间过程展示会容易很多。6. 一次完整踩坑实录从 500 错误到跑通全部流程这一部分我想完整记录一遍我在集成测试过程中遇到的坑因为这些都是真实发生过的而且排查链路很有代表性。你不一定遇到全部但如果遇到了直接照我这个思路排查会省很多时间。6.1 首次启动报错HF_TOKEN 环境变量SpringAI 1.0 的某些依赖特别是 embedding 相关 starter在加载时需要读取 HuggingFace 相关配置。我把spring-ai-starter-openai加进 Maven 后启动时直接报Caused by: java.lang.IllegalArgumentException: HF_TOKEN environment variable not set一开始我以为是依赖元数据问题但排查到最后发现是 SpringAI 的自动配置把所有可用的模型组件全部加载了其中一个 embedding 模型需要读 HuggingFace 的 token 做默认鉴权。解决办法是在配置文件里显示排除 embedding 相关组件或者把不需要的自动配置关掉spring: autoconfigure: exclude: - org.springframework.ai.model.embedding.EmbeddingModelAutoConfiguration如果你的 pom 里根本没有引 embedding 相关的依赖通常不会遇到这个报错。但当你把别的例子里的依赖复制过来时这个问题很容易出现。建议只依赖starter-openai不要复制spring-ai-transformers或spring-ai-pgvector-store这类非必要的组件。6.2 工具方法返回 GenericApiException工具方法本身没问题但调用 Agent 时一旦模型触发工具调用就抛GenericApiException: 400 Bad Request。我最初以为是工具方法代码有 bug但本地直接调用工具方法完全正常模型单独 chat 也正常就是“chat tools”联动时报错。后来我用日志解析了实际发给 DeepSeek 的请求体发现 SpringAI 在 Function Calling 时携带了 DeepSeek 不支持的工具参数格式。DeepSeek 官方对工具调用的 JSON Schema 支持较严格多了一些工具参数描述里的additionalProperties或空$schema字段就可能报 400。解决方案是在spring.ai.openai.chat.options.tools层面不额外传 toolSchemas而是让 SpringAI 自己根据Tool注解自动生成 schema同时确保 DeepSeek 用的模型是deepseek-chat。排查问题时抓原始请求体是最能说明问题的做法。就在日志里打开 HTTP Client 的 debug 输出对比 OpenAI 请求和 DeepSeek 请求的差异很快就能定位出问题。6.3 流式输出中断卡在约 30 秒处正如上文提到的SseEmitter 默认超时 30 秒。Agent 如果调用多个工具比如先查时间、再计算表达式、再汇总生成自然语言总耗时很容易超过 30 秒。前端表现为输出到一半突然中断后端日志没有明显报错。解决办法是 SseEmitter 超时设为 0L同时为了健壮性我在 controller 里加了onCompletion回调来清理资源在onTimeout里调用complete()保证 Emitter 不会被一直挂着。另外Nginx 反向代理如果设置了proxy_read_timeout 60s同样会掐断 SSE 长连接。有 Nginx 的话需要在location /chat/stream里设成proxy_read_timeout 300s并让proxy_buffering off否则流式响应会被缓冲区累积延迟无法做到逐字输出。6.4 DeepSeek 返回内容里的 “final answer” 杂音DeepSeek R1 是推理模型它的输出在流式传输时可能会把内部推理过程混进content里。如果你发现回复内容非常啰嗦带有“嗯……用户想查询当前时间……那我需要调用 getCurrentTime 工具……”这类自言自语说明模型没有按预期压缩输出。我现在默认用deepseek-chat而不是deepseek-reasoner因为这个 Agent 场景不需要逐步推理过程直接返回结果反而更干净。如果你确实需要 R1 的推理能力可以在系统提示词里加一条“不要输出你的思考过程只输出最终回复”实测有一定收敛效果但不能保证 100%。6.5 JSON 请求体中的 Long 类型序列化问题这是我在另一个项目里踩到的相似坑当工具返回一个Long类型比如当前时间戳SpringAI 内部会把工具返回结果序列化成 JSON 再传给模型。如果这个 Long 值非常大比如时间戳有的 JSON 库默认会转成字符串或报精度溢出错误模型收到后就无法正确识别。解决方案很简单工具方法里尽量返回字符串类型不要让 Spring 或 Jackson 自动序列化一个大整数。这个坑表面上和 SpringAI 无关但 AI Agent 的工具返回值确实比普通 Web 接口更容易触发边界类型值得多留个心。7. 进阶扩展从单 Agent 到多 Agent 的演进路径把单 Agent 跑通后你很快就会遇到更复杂的需求比如一个 Agent 管数据查询、另一个 Agent 管内容生成两个 Agent 还要协作。这个过程正式语境里叫 Multi-AgentSpringAI 也有相关支持。SpringAI 1.0 里提供了多 Agent 编排功能不过说实话大部分业务场景其实不需要从零搭多个 Agent 流程。你可以在单个ChatClient中定义多套Tool然后根据用户在 prompt 里的语义由模型自行选择加载哪一层的工具。这种“单 Agent 多 Tool”模式在多数中小业务里已经绰绰有余。如果真的需要多个 Agent 独立维护记忆和工具集比较实用的一种模式是用 Map 按 agentName 存储多个 ChatClient 实例每个实例有自己的 systemPrompt 和 tools通过路由 Service 转发用户请求。Service public class AgentRouter { private final MapString, ChatClient agentClients new ConcurrentHashMap(); public AgentRouter(ChatClient.Builder builder) { agentClients.put(data, builder .defaultSystem(你是数据分析助手负责调用数据库工具) .build()); agentClients.put(writer, builder .defaultSystem(你是文案写作助手基于传入的资料生成内容) .build()); } public String dispatch(String agent, String prompt) { ChatClient client agentClients.get(agent); if (client null) { return 未知的 Agent 类型; } return client.prompt().user(prompt).call().content(); } }这种路由结构的好处是扩展性非常清晰。将来接入 LangGraph 或者 SpringAI 自己的 Multi-Agent 编排能力时只需要替换路由决策逻辑底层的 ChatClient 配置几乎不用改。另外可观测性也是个值得提前考虑的方向。Agent 的失败往往是链路问题——用户输入、工具调用、模型中间输出、最终回复任何一个环节都可能出问题。我目前在 Service 里接入了 Spring Boot Actuator 的 metrics每次 Agent 调用都记录耗时和结果工具调用单独标记出入参。排查“为什么 Agent 在某次请求里不调用工具”这类问题时日志里能清晰看到哪一步断了而不是对着模型回复猜。8. 部署与上线前必须做的三件事开发环境下 Agent 跑通只是一小步。如果要部署到测试或生产环境下面几个点务必提前处理。8.1 API Key 管理绝对不能把 DeepSeek 的 key 写死在application.yml里提交到代码仓库。推荐的方式是用环境变量注入或使用配置中心。如果是单机部署spring.ai.openai.api-key${DEEPSEEK_API_KEY}这种占位方式已经足够了如果是 K8s 环境建议用 Kubernetes Secret 挂载成环境变量这样 key 就不会散落到镜像或代码仓库中。8.2 接口幂等与限流Agent 接口是有状态的用户的连续对话依赖同一个 sessionId 的上下文。如果你用负载均衡部署多实例注意同一个 sessionId 的请求必须路由到同一个实例否则每次请求的聊天历史都会配对不上Agent 会表现得“失忆”。解决办法可以是Redis 做 ChatMemory 存储或者更轻量一些用 sticky session。当前使用 InMemoryChatMemory 的版本发到生产环境前必须替换为 Redis 或数据库。限流方面DeepSeek 的 API 有 RPM 和 TPM 限制Agent 调用多个工具时一个用户请求背后可能产生 3-5 次大模型调用。为了防止某个用户刷爆额度接口层需要加业务限流用户维度 QPS 限制单会话多轮对话的 token 数也要监控。最简单的方式是结合 Resilience4j 为 DeepSeek 调用加一个简单的滑动窗口限流。8.3 日志脱敏与追踪Agent 的请求日志和响应日志里可能包含用户上传的敏感信息这是最容易踩合规红线的地方。我建议日志输出时对用户消息做截断超过 100 字的只记录前缀长度和摘要。同时要记录一次完整的 TraceId能关联到用户输入、工具调用、模型回复的所有日志。排查问题时如果缺少这样一个 ID在生产环境里基本无法复盘。Java 生态做 AI Agent 的优势恰恰在于这些工程化能力都是现成的可观测性、限流、分布式链路、监控告警生态里都有成熟组件。这是原生 Java 比 Python 更“稳”的地方。最后分享一个个人体会AI Agent 最大的学习成本不在框架而在习惯模型和代码之间的交互方式。我刚接触 SpringAI 时总想用“调用普通 Service”的思路理解 Agent 工作流结果总在工具如何被选择、如何被执行上卡壳。实际上你要想清楚一个问题在这个协作模式中你是谁模型是谁工具是谁你会掌握用户关系和会话记忆模型负责理解和决策工具负责执行具体的操作。你写出的是模型的操作手册以及让你与模型握手通信的管道。想通这一点从普通 Web 开发切换到 AI Agent 开发会顺畅很多。如果你正在尝试用这套技术栈做自己的第一款 Agent希望这篇内容能帮你少走弯路。