news 2026/9/28 18:55:35

SpringAI实战:从ChatClient到@Tool,构建大模型对话机器人

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringAI实战:从ChatClient到@Tool,构建大模型对话机器人

1. 为什么在这个时间点聊 SpringAI 新特性:项目生态现状与版本脉络

1.1 SpringAI 到底解决了什么问题

这几年做 AI 应用的团队,基本都经历过一段"拼接地狱":今天对接 OpenAI,明天换国产模型,后天又要支持本地部署的模型服务。每次切换模型厂商,都要重写一遍 HTTP 调用、重调 JSON 解析、重新封装流式响应的逻辑。接口风格不统一,错误处理各有各的妖,整个团队疲于应付"连接"这件事,真正有价值的业务逻辑反而没人写。

SpringAI 解决的就是这个核心痛点——把大模型接入抽象成一套统一的编程模型。你可以把它理解成 JDBC 之于数据库:不管底层连的是 MySQL 还是 PostgreSQL,上层都通过统一的 Connection、Statement、ResultSet 接口操作。SpringAI 里的 ChatClient、ChatModel、EmbeddingModel 就是这套"JDBC 接口",底层接 OpenAI、通义千问、DeepSeek、Ollama 都行,业务代码几乎不用动。

我第一次在项目里引入 SpringAI 时,最大的直观感受是:不再需要自己维护一堆 RestTemplate 调用和 JSON 解析工具类。原来手写一个流式对话接口,至少要看半天官方文档,搞清楚 messages 数组怎么构造、stream 参数怎么传、SSE 数据格式长什么样;用 SpringAI 之后,这些细节全部封装在框架内部,我只关心"给模型一段用户消息,把返回的流式内容推给前端"。

1.2 版本演进的几个关键节点

SpringAI 的版本演进速度,说实话比大多数人想象的要快。我很早之前就在博客里关注这个项目,当时它还挂在 Spring 的孵化器里,版本号带着0.8.x这种前缀,API 三天两头变,今天写的ChatClient用法,下周可能就废弃了。但那批早期用户的价值在于,把很多设计问题提前暴露了出来,促使框架在正式版发布前完成了一轮大重构。

到 1.0.0 正式发布,ChatClient的 API 基本稳定成型,Builder 模式成为主流写法。再到后面几个迭代版本,框架陆续引入了结构化输出(Structured Output)、多模态消息封装、Advisor 切面机制、Observation 可观测性埋点等一批重要特性。从"能用"到"好用"的转变非常明显。

这里要特别提醒刚接触 SpringAI 的读者:去网上搜教程时,认准 1.0.0 及以上版本的 API 写法。早期 0.x 版本里很多类名和方法签名跟现在完全不同,照着旧教程写代码,编译都过不去。如果你看到有人还在用AiClient而不是ChatClient,那大概率是旧时代的代码。

1.3 选型视角:SpringAI 与 LangChain4j 的取舍

聊 SpringAI,绕不开另一个项目 LangChain4j。两个框架解决的是同一类问题,但设计哲学差异明显。LangChain4j 更偏"套件型",抽象层次更高,内置了 Prompt Template、Chain、Memory、RAG 等一整套高层组件,上手快,但遇到复杂场景时,要理解它的抽象层级反而需要更多时间。

SpringAI 则更"Spring 原生"——它不是一个独立的框架,而是 Spring 生态的一等公民。依赖注入、自动配置、Starter 机制、Spring Boot Actuator、Micrometer 可观测性全部是一套语言。如果你所在的团队本来就是 Spring Boot 技术栈,引入 SpringAI 的学习成本极低,配置项跑在application.yml里,连 Bean 都不用自己 new。

我的选择逻辑很简单:团队是 Spring 系,就选 SpringAI;如果团队偏 Python 或并不依赖 Spring 生态,LangChain4j 可能更合适。没必要在选型上纠结太久,两个框架的底层能力其实越来越接近,把业务跑通、跑稳才是关键。

2. 从零搭建对话机器人工程:基本对话与流式输出双核心落地

2.1 工程骨架与依赖配置

SpringAI 的工程搭建,本质上是往一个标准 Spring Boot 项目里引入对应的 Starter 依赖。以对接 OpenAI 兼容接口的场景为例,项目结构大概长这样:

dialogue-robot ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/dialoguerobot │ │ │ ├── DialogueRobotApplication.java │ │ │ ├── controller │ │ │ │ └── ChatController.java │ │ │ ├── service │ │ │ │ └── ChatService.java │ │ │ └── config │ │ │ └── ChatModelConfig.java │ │ └── resources │ │ └── application.yml

pom.xml里需要引入 Spring Boot 3.2+ 的父 POM,然后加两个关键依赖:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency>

再补一个 Web 依赖,用于对外提供 HTTP 接口:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

注意一点:SpringAI 的 Starter 依赖命名和 Spring Boot 官方 Starter 有区别,它是spring-ai-starter-model-*这种格式。不同的模型厂商对应不同的 Starter,比如:

  • spring-ai-starter-model-openai:OpenAI 及兼容接口
  • spring-ai-starter-model-ollama:本地 Ollama
  • spring-ai-starter-model-qwen:阿里通义千问
  • spring-ai-starter-model-azure-openai:Azure OpenAI

2.2 基本对话:ChatClient 的第一行代码

SpringAI 1.0 之后,所有对话能力都收口到ChatClient这个核心接口上。创建它有两种方式:一种是直接注入自动配置好的ChatModelBean,再用 Builder 构建;另一种是使用ChatClient.builder()配合自定义模型对象。

先说最简单、也是大多数项目默认的方式。在application.yml里配好模型服务地址和密钥:

spring: ai: openai: base-url: http://localhost:8080 # 指向你自己的模型网关,也可以是云端服务地址 api-key: sk-你的密钥 chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 4096

然后在服务类里写一个同步对话方法:

@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient.Builder builder) { this.chatClient = builder.build(); } public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }

这段代码干的事很直白:构造一个 Prompt,塞入用户消息,发起同步调用,取回模型回答内容。chatClient.prompt()返回一个 PromptSpec 对象,支持链式调用配置 system 提示词、历史消息、模型参数、工具函数等等。call()是同步阻塞调用,适合对延迟不敏感的后端处理场景。

2.3 流式输出:Flux 的正确使用姿势

对话机器人里真正让人"用得上"的往往是流式输出。如果等模型把整段话生成完再一次性返回,慢的模型动辄几十秒,用户体验非常糟糕。流式输出则是一边生成一边吐字,前端像打字机一样把内容逐字显示出来,体感上快很多。

SpringAI 的流式调用基于 Project Reactor 的 Flux,代码如下:

public Flux<String> chatStream(String userMessage) { return chatClient.prompt() .user(userMessage) .stream() .content(); }

调用方拿到的是一个Flux<String>,需要自行订阅消费。如果要把流式内容通过 Web 接口暴露给前端,可以返回Flux<String>配合 Spring WebFlux 的text/event-stream或普通文本流:

@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> chatStream(@RequestBody ChatRequest request) { return chatService.chatStream(request.message()); }

前端用 EventSource 或 fetch 的 ReadableStream 都能接住这段流。

这里有一个非常容易踩的坑:Flux 是惰性的,如果你在 Service 层返回 Flux 之前做了doOnNext(log::info)这种副作用操作,但 Controller 层因为某种异常没有订阅,整个流根本不会执行。调试时不要奇怪"为什么日志一条都没有",因为没人订阅就没人干活。

2.4 流式与同步的选择逻辑

同步还是流式,不是拍脑袋决定的。我的经验判断标准是:

  • 内部服务间调用、需要完整结果后才能继续处理的,用同步call();
  • 面向用户交互、需要快速呈现首字的,用流式stream();
  • 需要拿模型输出做结构化解析、校验后再落库的,先走同步再处理,别自己实现半套流式拼接。

另外,从框架层面看,call()和stream()底层是两套不同的执行链路。同步调用内部直接拿到完整响应,流式调用则要逐个接收数据块再合并。如果你在流式场景里同时启用了 RAG 召回或工具调用,要留意 Advisor 链路的执行顺序,有些组件是为同步设计,在流式下可能不生效。

3. @Tool 注解的核心机制与函数调用实战

3.1 注解属性解析:name、description 与参数绑定

大模型自己不会查数据库、不会调天气接口、不会查订单状态,它只会"编"。要让模型在需要时真正去调外部服务,就得靠 Function Calling 机制——模型决定"我需要调用某个工具",框架负责把参数解析出来,真正执行工具函数,再把结果回传给模型。

SpringAI 把这一整套机制封装成了@Tool注解。在 1.1 版本里,@Tool注解最常用的三个属性是:

  • name:工具名称。模型通过它来识别该调哪个函数,所以名字要语义清晰,一般用动词+名词的组合,比如getWeather、searchOrder。
  • description:工具描述。这是给模型看的说明,它的质量直接决定了模型会不会在合适的时机调用这个工具。
  • resultType(部分版本支持):指定返回值类型的元数据,帮助模型理解返回结构。

方法参数上使用的注解是@P(Parameter),可以配置每个参数的名字和描述。下面看两个典型写法:

@Component public class WeatherTools { @Tool(name = "getWeather", description = "根据城市名称查询当前天气情况") public String getWeather(@P(name = "city", description = "城市名,如北京、上海") String city) { // 这里写真实的天气查询逻辑 return "{\"city\":\"" + city + "\",\"temperature\":25,\"condition\":\"晴\"}"; } }

另一种写法是返回值智能自动匹配,不再绑定到具体方法参数注解,而是统一走一个数据类:

@Tool(name = "getWeather", description = "根据城市名称查询当前天气情况") public WeatherResult getWeather(WeatherRequest request) { // request 里包含 city 字段 return weatherService.query(request.city()); }

第二种写法更利于复杂工具,入参直接是个结构体,字段描述写在 DTO 上。

3.2 一个完整的搜索工具开发示例

用一个完整的"内部文档检索"工具来说明整个链路会更直观。假设系统里有一批产品文档,需要模型在回答问题时先检索文档再作答:

@Component public class DocSearchTools { private final DocumentSearchService searchService; public DocSearchTools(DocumentSearchService searchService) { this.searchService = searchService; } @Tool(name = "searchInternalDoc", description = "在内部产品文档库中搜索相关内容,返回匹配文档的标题和摘要列表") public String searchInternalDoc( @P(name = "query", description = "搜索关键词,建议使用名词短语,如\"退款流程\"") String query) { List<DocSearchResult> results = searchService.search(query, 5); if (results.isEmpty()) { return "未找到相关文档"; } StringBuilder sb = new StringBuilder(); for (DocSearchResult r : results) { sb.append("标题: ").append(r.title()) .append("\n摘要: ").append(r.summary()) .append("\n链接: ").append(r.url()) .append("\n---\n"); } return sb.toString(); } }

把DocSearchTools注册成 Spring Bean 之后,在ChatClient构建时通过.defaultTools()挂载:

@Bean ChatClient chatClient(ChatClient.Builder builder, DocSearchTools docSearchTools) { return builder .defaultSystem("你是公司内部的智能客服助手,回答问题时请优先使用提供的工具获取真实信息。") .defaultTools(docSearchTools) .build(); }

模型收到用户问题后,会先判断是否需要调用searchInternalDoc,如果需要,框架自动把query参数提取出来执行方法,拿到结果再让模型组织语言回答。

3.3 工具调用失败的常见原因

我在生产环境里排查过不少次工具"不生效"的问题,总结下来高频原因有三个:

第一,description写得太含糊。比如写了"查询信息",模型完全不知道什么时候该用、参数传什么。好的描述应该明确触发场景、参数含义、返回内容。我的习惯是:描述里至少包含"什么时候用 + 输入什么 + 返回什么"三段信息。

第二,工具方法所在的 Bean 没被注入。@Tool的解析依赖 Spring 容器扫描,如果你手工new了一个对象而不是使用 Spring Bean,框架根本发现不了这些方法。一定要确保工具类被@Component或@Service注解标注,并且通过构造器注入到ChatClient.Builder。

第三,工具返回内容里没有模型需要的信息。模型拿到工具返回值后,会基于这些信息组织最终回答。如果返回的字符串是空串,模型只能被迫编造,这时候看起来就像工具没生效。所以工具方法里要做好异常兜底,至少返回明确的错误说明,比如"查询超时,请稍后重试"。

4. 模型接入的核心升级:多模态、国产模型与向量存储的架构演进

4.1 Qwen 视觉语言模型的核心架构升级在 SpringAI 里的落点

最近技术圈讨论度最高的模型升级里,Qwen 视觉语言系列占了很大篇幅。从 Qwen2-VL 到 Qwen2.5-VL,再到 Qwen3-VL,背后核心架构的演进方向可以概括为三条线:视觉编码能力的增强、跨模态对齐的优化、以及长上下文处理能力的提升。

不过站在 SpringAI 应用开发者的视角,我不太建议过分纠结这些模型底层的架构细节——你更该关注的是:SpringAI 是否已经把对这些新模型的支持封装好了,业务层代码能不能"无痛"切换到新模型。实际上,SpringAI 对多模态模型的支持把它分为"文字能力"和"视觉能力"两部分。像 Qwen-VL 这类视觉模型接入后,你可以直接传图片 URL 或 Base64 图片数据,让模型做图片理解——识别截图、分析图表、抽取身份证信息都可以做到。

4.2 多模态消息的工程写法

在 SpringAI 里传图片,比我想象的简单。以 OpenAI 兼容接口为例,多模态消息封装在UserMessage里,可以通过 MediaData 携带图片内容:

public String analyzeImage(String imageUrl, String prompt) { Message userMessage = UserMessage.builder() .text(prompt) .media(MediaData.builder() .url(imageUrl) .build()) .build(); return chatClient.prompt() .messages(userMessage) .call() .content(); }

如果是本地上传的图片,需要先转成 Base64 再构造 MediaData,同时指定 mimeType:

String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(path)); MediaData media = MediaData.builder() .data(base64) .mimeType("image/jpeg") .build();

这里要提醒一点:业务里如果频繁使用 Base64 大图,会显著消耗 Token 并增加延迟。简单粗暴的建议是,在做多模态任务前先压缩图片,控制长边在 1024 像素以内,质量损失一般肉眼感知不明显,但 Token 消耗和传输时间能省不少。

4.3 向量存储与检索(含 ES7 相关实践)

多模态模型负责"看懂"内容,RAG(检索增强生成)则负责"记住"私有知识。SpringAI 1.0 之后将VectorStore作为统一抽象,接各类向量数据库。目前支持的类型包括 Redis、Pinecone、Milvus、Chroma、PGVector,以及 Elasticsearch。

网上经常有人搜"es7 新特性",这里有必要澄清一下可能存在的混淆:在 SpringAI 语境里提到的 ES,一般指的是 Elasticsearch 7.x 版本。它的特点是自带稠密向量(dense_vector)字段类型和 KNN 检索能力,可以直接作为 RAG 方案的向量存储,不需要额外引入专用向量数据库。

在 SpringAI 里接入 Elasticsearch 做向量检索,大致步骤是:定义 Document 的索引结构,用EmbeddingModel给文档生成向量,然后写入VectorStore;查询时把用户问题也转成向量,再做相似度检索。代码层面基本是声明式配置,Starter 拉好之后,核心代码可能只有十几行:

@Service public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; public RagService(VectorStore vectorStore, ChatClient.Builder builder) { this.vectorStore = vectorStore; this.chatClient = builder.build(); } public String chatWithRag(String question) { // 先向量检索 List<Document> docs = vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(5).build()); // 拼装上下文 String context = docs.stream() .map(Document::getContent) .reduce((a, b) -> a + "\n---\n" + b) .orElse("暂无相关资料"); return chatClient.prompt() .system("请基于以下资料回答问题:\n" + context) .user(question) .call() .content(); } }

这个实现思路在大部分中小项目里都很够用。如果文档量达到百万级以上,再考虑分片、混合检索、重排模型这些进阶手段。

5. 本地 Web 界面连接远端大模型的工程化折腾记录

5.1 典型架构:后端流式转发 + 前端 SSE

"本地 Web 界面连远端大模型"其实是一种很常见的部署形态:前端页面跑在本地,后端 Spring Boot 服务也跑在本地,但大模型 API 在云端。这样做的价值在于,敏感信息不落在公网服务上,API 密钥也只在本地后端持有,前端拿不到明文密钥。

之前我把一个毕设级别的对话机器人从单体 Controller 改造成这种架构,切身体会到了流式转发的细节坑。整体链路是:

浏览器 → 本地 Spring Boot /chat/stream → 远端大模型 API → 流式响应 → 本地后端 → 浏览器 EventSource

前端的连接方式,最简单的是用原生 EventSource:

const eventSource = new EventSource(`/chat/stream?message=${encodeURIComponent(text)}`); eventSource.onmessage = (event) => { // 每次收到一个数据块就追加到对话框 appendMessage(event.data); };

后端 Controller 直接返回Flux<String>,Spring 会以 SSE 协议写出,每一条 Flux 元素就是一次消息推送。

5.2 跨域与代理配置细节

如果前端和你本地后端是同一个服务(打成一个包),就不存在跨域问题。但如果前端独立起了一个开发服务器,比如 Vue 的vite dev,默认端口是 5173,后端是 8080,那么就会遇到跨域。

我当时的处理方式分两层:

第一层是允许 CORS。在 Spring Boot 后端加一个配置类:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") .allowedMethods("GET", "POST", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); } }

我习惯用allowedOriginPatterns而不是allowedOrigins("*"),因为后者和allowCredentials(true)一起使用时会被浏览器拦截,白名单模式下要带上http://localhost:5173这种具体地址。

第二层是注意OPTIONS预检请求。POST +Content-Type: application/json会触发预检,如果后端不做处理可能直接 403。加上 Spring 的 CORS 映射后,预检由框架自动处理,这一步基本不用自己写。

5.3 API Key 安全与连接池设置

本地 Web 后端的一个重要职责是保管 API Key。一个常见误区是:为了方便,把 API Key 直接下发给前端,让浏览器直连大模型接口。这在自用项目里看着挺省事,但一旦页面被浏览器插件抓到请求、或者被调用方复制,Key 就泄露了。所以正经做法始终是:

  • API Key 只存在于后端环境变量或application.yml中;
  • 后端所有对远端模型的调用走同一个内部接口;
  • 如果有多人共用这个后端,在 Controller 层加简单的访问令牌校验,别裸奔。

连接池方面,OpenAI 类接口一般用 Java 自带的 HTTP 客户端,SpringAI 默认走RestClient或 WebClient。如果你的并发量上来,注意调大连接池参数。我在application.yml里加过这么一段,实测对吞吐有肉眼可见的改善:

spring: threads: virtual: enabled: true

JDK 21 项目可以开启虚拟线程,让 Tomcat 不再为每个请求分配一个昂贵的平台线程。流式转发这种 IO 密集型任务在虚拟线程下表现得非常省资源。

6. 从踩坑到实战:性能、并发与上下文管理的经验清单

6.1 流式回归和背压问题

流式接口最常见的回归现象是"前端只见光标闪,不见字出来"。这类问题十有八九出在后端的 Flux 到 HTTP Response 的桥接上。

我遇到过一种情况:Controller 方法标注的produces = MediaType.TEXT_EVENT_STREAM_VALUE,后端返回Flux<String>,但前端的 EventSource 一直没有收到任何事件。后来排查发现,部分浏览器对 SSE 有缓冲限制,如果服务端很久不刷新一个 heartbeat,连接会被中间层或浏览器静默断开。解决方案是在 Flux 上叠加一个心跳信号:

Flux<String> answer = chatService.chatStream(request.message()); Flux<String> heartbeat = Flux.interval(Duration.ofSeconds(15)) .map(i -> ": heartbeat\n\n"); return Flux.merge(answer, heartbeat);

Flux.merge将模型输出流和心跳流并行合并,前端每 15 秒至少收到一个注释型事件,连接就不会被判定为僵尸连接。

另一个常见问题是流量大时后端向模型服务发请求过多,导致远端限流返回 429。应对策略是给流式调用加上简单的并发控制,比如用 Semaphore 限制同时进行的流式会话数量:

private final Semaphore slots = new Semaphore(20); public Flux<String> limitedChatStream(String message) { if (!slots.tryAcquire()) { return Flux.just("系统繁忙,请稍后再试"); } return chatClient.prompt() .user(message) .stream() .content() .doFinally(signal -> slots.release()); }

6.2 上下文 Token 管理与长对话策略

对话机器人做得越深,越绕不开"模型记不住前面聊了啥"的问题。模型本身是无状态的,每次请求的 messages 数组长什么样,完全由调用方组装。SpringAI 的 ChatClient 里可以手动维护历史消息列表:

public String chatWithMemory(String userMessage, String conversationId) { List<Message> history = memoryStore.get(conversationId); Message userMsg = new UserMessage(userMessage); history.add(userMsg); String answer = chatClient.prompt() .messages(history) .call() .content(); history.add(new AssistantMessage(answer)); memoryStore.save(conversationId, history); return answer; }

但历史消息无限膨胀之后,Token 消耗会越来越大,甚至超出模型上下文窗口。靠谱的做法是滑动窗口裁剪:只保留最近 N 轮消息,较早的消息直接丢弃;或者对早期消息做摘要,用一段精简的"此前对话小结"替换完整历史。

我在项目里实践下来,一个实用的经验值是:短期记忆最多保留 10 轮原始对话,超过之后就把最旧的 5 轮折叠成一句摘要,这样既保证模型对前面话题有基本感知,又不会让 Token 很快被打满。

6.3 并发场景下连接复用与限流

把对话接口放在公网供多个用户使用时,要注意限流和配额管理。你在本地测试时怎么压都行,但远端模型服务往往有每分钟请求数(RPM)和每分钟 Token 数(TPM)限制。超了不是被熔断,就是计费剧增。

我的做法是给后端加一层最简单的令牌桶限流,按用户维度控制请求频率:

@Component public class RateLimiter { private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>(); public boolean allow(String userId, int maxPerMinute) { AtomicInteger counter = counters.computeIfAbsent(userId, k -> new AtomicInteger(0)); return counter.incrementAndGet() <= maxPerMinute; } public void reset(String userId) { counters.remove(userId); } }

上面的实现只适合自用或内部工具。生产级建议直接用 Bucket4j 或 Redis 滑动窗口来做,别自己造限流轮子。

6.4 几个容易被忽略的小坑

最后整理几个我实际踩过、且经常在社群里看到别人再犯一遍的细节问题:

第一,JDK 版本。SpringAI 1.0 要求 JDK 17 起,如果你还在用 JDK 8,要么升级项目基础环境,要么老老实实用老版本框架。网上有人搜"jdk8新特性"却想在 SpringAI 上跑,属于两个时代的东西硬凑,编译期就会暴露问题。

第二,模型名称与接口版本的匹配。同一个供应商的不同模型可能使用不同的 API 协议版本,SpringAI 适配时一定要确认 Starter 版本和模型版本兼容。比如通义千问的某些新模型走兼容 OpenAI 格式的接口,配置时base-url和model名称都别填错。

第三,超时设置。流式调用和同步调用的超时逻辑完全不同。同步调用很容易被远端模型长思考时间打挂,建议在 ChatModel 配置里调整responseTimeout;流式调用则要注意 ReadTimeout 别设太短,否则模型思考超过几秒,连接就断了。

第四,日志和排查。把所有模型请求的出入参打日志,尤其是Messages数组里的 system 提示词和本次上下文长度。很多"为什么答得不对"的问题,看日志第一屏就能定位是不是 context 拼错了。

第五,部署形态的取舍。如果只是个人项目或毕业设计,图省事可以把前端静态文件直接放到 Spring Boot 的resources/static下,一个 jar 包全搞定;如果是正经产品,建议后端只做 API,前端独立部署,这样后续扩展 Web 端、小程序端时,共用一套 AI 能力接口,不必重构。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 18:55:31

设备偶发掉线、重启就好?从现象到根因的系统化排查指南

深夜收到告警&#xff1a;某台关键设备不在线&#xff0c;远程ping不通&#xff0c;管理后台也登不进去。等你赶到现场&#xff0c;按一下电源键重启&#xff0c;设备又正常了。日志里干干净净&#xff0c;既没有报错&#xff0c;也没有异常记录。你以为是个例&#xff0c;结果…

作者头像 李华
网站建设 2026/9/28 18:55:24

蓝牙协议栈7层详解:从物理层到Profile,无线开发避坑指南

做蓝牙开发这些年&#xff0c;被问得最多的问题不是“怎么调用API”&#xff0c;而是“蓝牙协议栈到底有几层、每层干嘛的”。面试爱问&#xff0c;产品经理爱问&#xff0c;自己也经常得对着协议栈文档翻半天。抛开官方文档里那套“BR/EDR、AMP、Controller、Host”的叙事&…

作者头像 李华
网站建设 2026/9/28 18:54:58

偶发掉线排查指南:从物理层到应用层的系统方法

1. 偶发掉线为什么比彻底断网更难查设备偶发掉线、重启后恢复&#xff0c;这个现象在运维圈里有个很形象的说法叫"幽灵故障"。它最让人头疼的地方在于&#xff1a;你赶到现场的时候&#xff0c;设备已经好了。日志里可能只有一条"link down"然后"link…

作者头像 李华
网站建设 2026/9/28 18:53:25

Cursor 实战:用 TaoToken 统一 Key 打通 AI 代码编辑器配置链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华