先说结论:这套组合做 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这些属性就能实现局部更新。配合 SSE(Server-Sent Events)处理流式输出,代码量可以压缩一个数量级。
三个组件组合起来,整个 AI Agent 的技术栈变得异常清爽,后端就是标准 Spring Boot,前端是服务端渲染的模板页面 + HTMX 属性。
2. 环境准备与工程骨架搭建
先说环境版本,这些是我实测稳定的一组组合,直接照着配不会踩到版本兼容的坑。
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 17 或 21 | 17 可跑,21 更稳,建议直接用 21 |
| Spring Boot | 3.4.x | 需 3.x,2.x 不支持 SpringAI 1.x |
| SpringAI | 1.0.0 以上 | 我用的是当时最新稳定版 |
| HTMX | 2.x | 通过 CDN 引入即可 |
| DeepSeek API | V3 / R1 | 官方开放平台获取 key |
| Maven | 3.8+ | 项目构建 |
Spring Boot 3.4 + SpringAI 1.0 的 Maven 依赖需要单独处理。SpringAI 目前没有跟着 Spring Boot 的 BOM 走,所以使用pom.xml时内容要写完整,除了starter-ai-openai还要显式声明 SpringAI 的 BOM,否则会因为传递依赖版本不一致出现ClassNotFound这类问题。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.1</version> </parent> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 doesn't 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 = "计算数学表达式,例如 (1+2)*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 出来的,或者在工具类里没有加@Component,SpringAI 只会静默跳过工具注册,调用 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/completions,DeepSeek 官方两个路径都能接受,所以实测两种都行。
真正出问题的是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的,所以只要你没有自己拼List<Message>,基本不会触发这个问题。如果你是从 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-trigger="submit"拦截表单提交,把请求打到后端 Agent 接口。
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 20px;"> <h2>SpringAI + DeepSeek + HTMX 智能助手</h2> <div id="chat-history" style="border: 1px solid #ddd; padding: 16px; min-height: 400px; margin: 12px 0;"> <div hx-get="/chat/history" hx-trigger="load"></div> </div> <form id="chat-form" hx-post="/chat/send" hx-target="#chat-history" hx-swap="beforeend"> <input type="text" name="message" placeholder="输入你的问题,例如:现在几点?" required style="width: 80%; padding: 8px;"> <button type="submit">发送</button> </form> </div>这里有几个 HTMX 属性和传统表单提交的区别要注意:
hx-post="/chat/send":表示 AJAX 以 POST 方式请求后端。hx-target="#chat-history":响应回来的 HTML 片段要插入到#chat-history节点。hx-swap="beforeend":插入方式是“追加到末尾”,而不是替换内容。这样每次都把新消息追加到对话历史尾部,不覆盖旧消息。
hx-trigger="submit"没有显式写,是因为form的默认触发事件就是submit,HTMX 会自动拦截表单提交。这里也可以补充一个hx-indicator,用来在等待响应时显示加载动画。
5.2 SSE 流式事件处理
HTMX 官方对 SSE 的支持有两种方式:hx-sse(旧版扩展)和hx-trigger="sse:自定义事件"(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 表示不超时 Flux<String> 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 id="chat-history" hx-trigger="load, sse:message from:body" hx-get="/chat/history" hx-swap="beforeend"> </div>hx-trigger="sse:message from:body"的含义是:监听body上名为message的事件。当后端通过 SSE 发来message事件时,HTMX 会触发一次请求流程。这里还需要一个配套的细节:由于每个message事件本身已经包含了span片段,如果不加处理,hx-swap="beforeend"会不断追加新的span到对话区,不会覆盖旧内容。
为了做出“同一个回复持续更新”效果,需要一个 JS 钩子去定位当前正在输出的消息容器。我采用的是更简单的方式:后端发送事件时,事件里带的 data 不是纯文本,而一个完整的追加片段,并且在响应结束后再发一条[DONE]事件,前端 JS 监听到[DONE]时停止追加逻辑。为了控制复杂度,也可以直接用我最后的简化版本:一次性生成完回复,但用 SSE 分页把内容分多个事件发送到同一个 div。
其实如果你不想写事件名这种比较底层的细节,有更简单的做法:后端把整个 Agent 的回复完成后一次性返回 HTML 片段,渲染照样没问题。但“打字机效果”确实是 Agent 体验中比较值得做的一部分,我个人建议还是花半小时把这些事件封装搞定,对用户观感和可用性提升明显。
5.3 动态工具回调结果的渲染
Agent 执行过程中,用户可能看到工具调用过程。更有趣的是,我们可以在 SSE 管道里同时推送“工具调用中”“工具执行完成”“最终回复”三类事件,前端分别渲染成不同类型的 UI 气泡。
后端stream方法不再只是返回Flux<String>,而是返回Flux<AgentEvent>。其中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-Agent,SpringAI 也有相关支持。
SpringAI 1.0 里提供了多 Agent 编排功能,不过说实话,大部分业务场景其实不需要从零搭多个 Agent 流程。你可以在单个ChatClient中定义多套@Tool,然后根据用户在 prompt 里的语义,由模型自行选择加载哪一层的工具。这种“单 Agent 多 Tool”模式在多数中小业务里已经绰绰有余。
如果真的需要多个 Agent 独立维护记忆和工具集,比较实用的一种模式是:用 Map 按 agentName 存储多个 ChatClient 实例,每个实例有自己的 systemPrompt 和 tools,通过路由 Service 转发用户请求。
@Service public class AgentRouter { private final Map<String, 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,希望这篇内容能帮你少走弯路。