Documentary · AI功能交付纪实

Java全栈开发者的一天 — AI功能交付纪实

从需求分析到模型部署上线,记录一个Java全栈开发者如何独立完成"智能客服问答系统"功能的全过程。Spring Boot后端 + Vue前端 + LLM集成 + 数据处理

👤 小林 · 5年经验Java全栈 🏢 智客AI科技 🔧 Spring Boot + Vue3 + LLM + Docker ⏱️ 一天完成MVP

小林 · Java Full Stack Developer

5年经验,后端Java/Spring Boot + 前端Vue3 + AI集成都能搞。在AI创业公司里,一个人扛起从数据建模到接口部署的全链路。

Spring Boot 3 Vue3 PostgreSQL LLM集成 Docker

今天的目标:独立完成"智能客服问答系统"MVP — 用户输入问题,系统先从公司文档库搜索相关内容,再让LLM基于搜索结果生成回答,返回答案+引用来源。

这可不是简单的"调一下ChatGPT API",需要做RAG(检索增强生成),涉及向量数据库、embedding、prompt工程、异步任务、流式输出...Java全栈的挑战在于:把AI能力和企业级Java架构融合起来,保证稳定性、可维护性。

系统架构图 — RAG智能客服完整链路
🌐
Vue3 前端
聊天界面
SSE流式接收
Spring Boot
REST + SSE
@RestController
🗄️
pgvector
语义搜索
Top-K文档
📝
Prompt构造
文档+问题
组装Prompt
🤖
LLM生成
GPT-4o
基于文档回答
用户提问 → Spring Boot接收 → pgvector语义搜索 → 构造Prompt → 调LLM → SSE流式返回
📋

晨会 + 需求分析 09:00

PM发来需求文档,拆解任务和技术选型

💬 钉钉群 — 智客AI产品组
钉钉 · 智客AI产品组 (8人)
智客AI产品组 8人在线
PM小陈 09:02
各位早上好!这周的核心任务是智能客服问答系统

需求很简单:用户输入问题,AI自动回答,基于公司文档库。场景是内部员工自助查询(年假政策、报销流程、IT操作手册等)。

需求文档我放JIRA上了,大家看一下。
小林 09:05
收到。我看了需求文档,理一下关键技术点:
1. 不是直接调ChatGPT — 因为需要基于公司文档回答,通用LLM不了解我们公司政策
2. 需要做RAG(检索增强生成) — 先从文档库搜索相关内容,再让LLM基于搜索结果回答
3. 文档要向量化存储 — 用embedding模型把文档转成向量,做语义搜索
4. 需要引用来源 — 告诉用户答案来自哪个文档

问题:文档量大概多少?实时性要求高吗?
PM小陈 09:08
文档大概500-1000篇(PDF、Word、Markdown都有),后面会持续增加。实时性要求不高,管理员上传后几分钟内可搜索就行。

用户期望回答速度:3秒内出结果。
小林 09:10
OK,需求清晰了。我来定技术方案:

后端:Spring Boot 3 + MyBatis(团队最熟、最稳定)
向量存储:PostgreSQL + pgvector(复用现有PG)
异步任务:Spring @Async + 线程池(文档批量处理)
前端:Vue3 + SSE流式输出
部署:Docker Compose

今天先做MVP,核心链路跑通。明天优化。
运维老周 09:12
👍 服务器资源够用。PostgreSQL我们已经有了,加个pgvector扩展就行。Redis也有。Java运行环境用JDK 21,我们已经准备好了。

这个需求最关键的是RAG。很多人以为AI客服就是调ChatGPT,但通用LLM根本不知道你公司的年假政策是什么,会胡编乱造(hallucination)。

RAG的思路:先从文档库"查资料",再把资料喂给LLM让它"看着资料回答",准确率提升几个数量级。

类比:纯LLM = 闭卷考试(凭记忆答,可能记错);RAG = 开卷考试(翻书找答案,准确率高得多)。

小林打开JIRA,把需求拆解成8个子任务,按优先级排序。先跑通核心RAG链路,再做前端和部署。
📌 JIRA — 任务拆解
# 任务 优先级 预估
1数据库设计 + pgvector配置P01h
2Spring Boot项目骨架 + ControllerP01h
3RAG核心逻辑(embedding+搜索+LLM)P01.5h
4@Async异步文档导入P11h
5Vue3聊天界面 + SSEP11.5h
6前后端联调P10.5h
7Docker部署P20.5h
8测试验证 + 性能优化P20.5h
🤔 技术选型:纯LLM vs RAG方案
对比维度纯LLM方案RAG方案(选用)
原理用户问题直接给LLM生成回答先检索相关文档,再让LLM基于文档回答
准确性❌ 容易胡编(幻觉严重)✅ 基于真实文档,可溯源
公司知识❌ 不知道公司内部政策✅ 已学习所有公司文档
更新成本每次更新要重新Fine-tune(贵)删旧文档+传新文档即可(便宜)
类比闭卷考试(凭记忆)开卷考试(翻书找答案)
延迟1-2秒3-4秒(多了检索环节)
成本每次调用LLMembedding便宜 + LLM调用
结论:选RAG。准确性是企业场景的第一要求,宁可慢1秒也不能答错。
🗄️

数据库设计 + 向量库选型 09:30

设计PostgreSQL表结构,引入pgvector做语义搜索

DataGrip — smartqa@localhost:5432
-- ============================================= -- 智能客服问答系统 · 表结构设计 -- Author: 小林 -- Date: 2026-06-16 -- ============================================= -- 启用pgvector扩展(PostgreSQL向量搜索插件) CREATE EXTENSION IF NOT EXISTS vector; -- 1. 文档表:存储所有原始文档和向量 CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, title VARCHAR(500) NOT NULL, content TEXT NOT NULL, category VARCHAR(100), embedding vector(1536), -- 文档向量(OpenAI 1536维) file_path VARCHAR(500), status VARCHAR(20) DEFAULT 'ready', created_at TIMESTAMPTZ DEFAULT NOW() ); -- 2. 问答历史表:记录每次对话 CREATE TABLE chat_history ( id BIGSERIAL PRIMARY KEY, user_id VARCHAR(100) NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL, sources JSONB, tokens_used INTEGER DEFAULT 0, response_ms INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 3. 反馈表:用户对答案的评价 CREATE TABLE feedback ( id BIGSERIAL PRIMARY KEY, chat_id BIGINT REFERENCES chat_history(id), rating SMALLINT CHECK (rating BETWEEN 1 AND 5), comment TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); -- 创建向量索引(IVFFlat,加速相似度搜索) CREATE INDEX idx_documents_embedding ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
✓ Execute Successful — 4 statements executed in 0.124s · pgvector extension ready

为什么不直接用 LIKE '%关键词%' 搜索?

SQL的LIKE是字面匹配,用户问"年假怎么请"但文档里写的是"带薪休假申请流程",LIKE根本搜不到。语义不同但意思相同的内容会漏掉。

向量搜索是语义匹配 — 把文本变成1536维的向量,用余弦相似度找"意思最接近"的内容。"年假怎么请"和"带薪休假申请"在向量空间里距离很近,能搜到。

类比:LIKE = 精确匹配关键词(就像只能搜书名);向量搜索 = 理解意思搜索(就像图书管理员知道你想找什么类型的书)。

📊 向量数据库选型对比
方案优点缺点适用场景
pgvector ✅
选用
复用现有PG、事务一致、运维简单 超大规模(亿级)性能一般 中小规模文档库(<100万)
Milvus 专业向量库、性能强、支持十亿级 需要单独部署维护、架构复杂 大规模向量搜索
Elasticsearch + dense_vector 已有ES团队可复用,全文+向量混合搜索 向量搜索成熟度不如专业库 已有ES基础设施的团队
Redis Stack (RediSearch) 延迟极低,和缓存共用 内存成本高,不适合大量持久化 小规模实时搜索

为什么选pgvector?因为我们公司已经有PostgreSQL了,加个扩展就能用,不用多维护一个组件。MyBatis对PostgreSQL的JSONB和自定义类型也有很好的支持。

类比:家里已经有冰箱了(PostgreSQL),买个保鲜盒(pgvector扩展)就能存菜,不需要再买一个冰柜(Milvus)。文档量500-1000篇,pgvector完全够用。

IntelliJ IDEA — com/smartqa/model/Document.java
Document.java
ChatHistory.java
// 文档实体类 — 对应 documents 表 package com.smartqa.model; import lombok.Data; import java.time.LocalDateTime; @Data public class Document { private Long id; private String title; private String content; private String category; private float[] embedding; // 1536维向量 private String filePath; private String status; private LocalDateTime createdAt; } // ──────────────────────────── // ChatHistory.java — 问答历史 // ──────────────────────────── @Data public class ChatHistory { private Long id; private String userId; private String question; private String answer; private String sources; // JSONB → 存为String,前端JSON.parse private Integer tokensUsed; private Integer responseMs; private LocalDateTime createdAt; }
IntelliJ IDEA — resources/mapper/DocumentMapper.xml · 向量搜索SQL
DocumentMapper.xml
<!-- pgvector语义搜索:按余弦距离排序找Top-K相关文档 --> <select id="searchByVector" resultType="Document"> SELECT id, title, content, category, 1 - (embedding <=> #{queryVector,jdbcType=OTHER}::vector) AS relevance FROM documents WHERE status = 'ready' AND embedding IS NOT NULL ORDER BY embedding <=> #{queryVector,jdbcType=OTHER}::vector LIMIT #{topK} </select> <!-- 插入文档(含向量) --> <insert id="insertWithEmbedding" parameterType="Document"> INSERT INTO documents (title, content, category, embedding, file_path, status) VALUES (#{title}, #{content}, #{category}, #{embedding,jdbcType=OTHER}::vector, #{filePath}, #{status}) </insert>

Spring Boot后端骨架搭建 10:30

搭建项目结构、pom.xml依赖、Controller入口、DTO校验

IntelliJ IDEA — smart-qa-backend/ 项目结构
PROJECT
📂 src/main/java/
📄 SmartQaApplication.java
📂 config/
📄 WebConfig.java
📄 AsyncConfig.java
📂 controller/
📄 ChatController.java
📄 DocumentController.java
📂 service/
📄 RagService.java
📄 EmbeddingService.java
📄 LlmService.java
📄 DocumentService.java
📂 mapper/
📄 DocumentMapper.java
📄 ChatHistoryMapper.java
📂 model/
📄 Document.java
📄 ChatHistory.java
📂 dto/
📄 ChatRequest.java
📄 ChatResponse.java
📂 src/main/resources/
📄 application.yml
📄 pom.xml
📄 Dockerfile
📄 docker-compose.yml
// 项目分层架构说明 // ──────────────── // controller/ → 接口层(接收请求) // dto/ → 数据传输对象(校验) // service/ → 业务逻辑层(RAG核心) // mapper/ → 数据访问层(MyBatis) // model/ → 实体类 // config/ → 基础设施(异步/CORS/连接池) // // 请求流程: // Client → ChatController → ChatRequest (@Valid校验) → RagService → DocumentMapper (读写DB) → ChatResponse → Client
IntelliJ IDEA — pom.xml · 核心依赖
pom.xml
application.yml
<!-- Spring Boot 3.x + JDK 21 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <!-- Web + REST --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 参数校验 @Valid --> <dependency> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <!-- PostgreSQL + pgvector驱动 --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency> <!-- WebClient(调LLM API) --> <dependency> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- Redis --> <dependency> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>
IntelliJ IDEA — application.yml · 核心配置
pom.xml
application.yml
server: port: 8080 spring: datasource: url: jdbc:postgresql://localhost:5432/smartqa username: ${DB_USER:postgres} password: ${DB_PASS:postgres} driver-class-name: org.postgresql.Driver data: redis: host: ${REDIS_HOST:localhost} port: 6379 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 自定义配置 llm: api-key: ${OPENAI_API_KEY:} base-url: https://api.openai.com/v1 embedding-model: text-embedding-3-small chat-model: gpt-4o-mini
IntelliJ IDEA — controller/ChatController.java · REST接口
ChatController.java
DocumentController.java
package com.smartqa.controller; import com.smartqa.dto.ChatRequest; import com.smartqa.dto.ChatResponse; import com.smartqa.service.RagService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/chat") public class ChatController { private final RagService ragService; @PostMapping("/ask") public ChatResponse ask(@Valid @RequestBody ChatRequest req) { // 同步问答:等LLM完整生成后返回 return ragService.ragChat(req.getQuestion(), 5); } @GetMapping("/stream") public SseEmitter chatStream(@RequestParam String question) { // SSE流式:逐token推送 SseEmitter emitter = new SseEmitter(120_000L); // 2分钟超时 ragService.ragChatStream(question, emitter); return emitter; } } // ──────────────────────────── // ChatRequest.java — DTO + 校验 // ──────────────────────────── @Data public class ChatRequest { @NotBlank(message = "问题不能为空") @Size(max = 500, message = "问题不能超过500字") private String question; @Size(max = 100) private String userId = "anonymous"; }

Spring Boot vs FastAPI vs Express 选型思考:

FastAPI适合Python生态的快速原型,Express适合轻量Node项目。但公司主要技术栈是Java,Spring Boot的优势在于:成熟的依赖注入、统一的异常处理(@RestControllerAdvice)、声明式事务(@Transactional)、完善的监控体系(Actuator + Prometheus)

AI应用也需要稳定的企业级架构 — 连接池管理、线程池隔离、优雅停机,这些都是Spring Boot的强项。

🤖

LLM集成 + RAG核心逻辑 11:30

Embedding向量化 → pgvector语义搜索 → 构造Prompt → 调LLM生成

小林打开IntelliJ IDEA,开始写RAG核心代码。这是整个系统最关键的部分 — 5个步骤串起来才能跑通。
🔄 RAG工作流 — 检索增强生成
用户提问
🔢
问题向量化
WebClient调API
🔍
向量搜索
pgvector Top5
📝
构造Prompt
文档+问题
🤖
LLM生成
基于文档回答
💬
返回答案
+引用来源
IntelliJ IDEA — service/EmbeddingService.java · 向量化
EmbeddingService.java
LlmService.java
// Embedding服务:调用OpenAI API把文本转成1536维向量 package com.smartqa.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @Service public class EmbeddingService { private final WebClient webClient; @Value("${llm.embedding-model}") private String embeddingModel; public float[] getEmbedding(String text) { // 调用 OpenAI Embedding API Map<String, Object> request = Map.of( "model", embeddingModel, "input", text ); EmbeddingResponse resp = webClient.post() .uri("/embeddings") .contentType(MediaType.APPLICATION_JSON) .bodyValue(request) .retrieve() .bodyToMono(EmbeddingResponse.class) .block(); // 同步阻塞等待结果 return resp.getData().get(0).getEmbedding(); } }
IntelliJ IDEA — service/RagService.java · RAG核心
RagService.java
// RAG核心服务 — 检索增强生成 package com.smartqa.service; @Service public class RagService { private final EmbeddingService embeddingService; private final LlmService llmService; private final DocumentMapper documentMapper; /** * RAG问答主流程: * 1. 问题向量化 * 2. pgvector搜索相关文档 * 3. 构造prompt * 4. 调LLM生成答案 */ public ChatResponse ragChat(String question, int topK) { long start = System.currentTimeMillis(); // Step 1: 问题向量化 float[] queryVec = embeddingService.getEmbedding(question); String vecStr = vectorToString(queryVec); // [0.01, -0.03, ...] // Step 2: pgvector语义搜索(Top-K相关文档) List<Document> docs = documentMapper.searchByVector(vecStr, topK); if (docs.isEmpty()) { return ChatResponse.notFound("文档库中没有找到相关内容"); } // Step 3: 构造Prompt(把文档内容拼进prompt) String context = docs.stream() .map(d -> "【文档" + d.getId() + "】" + d.getContent()) .collect(Collectors.joining("\n\n")); String prompt = """ 你是公司内部客服助手。请基于以下文档回答问题。 如果文档中没有相关信息,请说"未找到相关资料"。 参考文档: %s 问题:%s """.formatted(context, question); // Step 4: 调LLM生成答案 String answer = llmService.generate(prompt); return ChatResponse.builder() .answer(answer) .sources(docs.stream().map(Document::toSource).toList()) .responseMs((int)(System.currentTimeMillis() - start)) .build(); } /** SSE流式版本 — 逐token推送 */ @Async("ragExecutor") public void ragChatStream(String question, SseEmitter emitter) { try { // 同样的RAG流程,但LLM生成时用流式API float[] queryVec = embeddingService.getEmbedding(question); List<Document> docs = documentMapper.searchByVector( vectorToString(queryVec), 5); String prompt = buildPrompt(docs, question); // 逐chunk推送 llmService.generateStream(prompt, chunk -> { emitter.send(SseEmitter.event() .data(Map.of("token", chunk))); }); // 推送引用来源 emitter.send(SseEmitter.event() .data(Map.of("sources", toSourceList(docs)))); emitter.send(SseEmitter.event().data(Map.of("done", true))); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } } }
IntelliJ IDEA — service/LlmService.java · LLM调用
EmbeddingService.java
LlmService.java
// LLM服务:调用OpenAI Chat Completions API @Service public class LlmService { private final WebClient webClient; @Value("${llm.chat-model}") private String chatModel; /** 同步生成 */ public String generate(String prompt) { Map<String, Object> request = Map.of( "model", chatModel, "messages", List.of( Map.of("role", "system", "content", "你是客服助手"), Map.of("role", "user", "content", prompt) ), "temperature", 0.3 ); return webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToMono(ChatCompletionResponse.class) .block() .getChoices().get(0) .getMessage().getContent(); } /** 流式生成 — 逐chunk回调 */ public void generateStream(String prompt, Consumer<String> onChunk) { Map<String, Object> request = Map.of( "model", chatModel, "messages", List.of( Map.of("role", "user", "content", prompt) ), "stream", true // 关键:开启流式 ); webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToFlux(String.class) // Flux<String> = 响应式流 .doOnNext(onChunk) // 每个chunk回调 .blockLast(); // 阻塞直到完成 } }

Java调LLM的两种方式:

1. 直接用WebClient(我们用的) — 构造HTTP请求调OpenAI API,最灵活,不依赖第三方SDK。WebClient天然支持响应式流(Flux),做SSE流式输出很方便。

2. 用LangChain4j / Spring AI — 封装了更多的AI操作(RAG链、记忆管理、Agent等),但对底层控制力弱一些。MVP阶段先自己写,后面如果需求复杂再引入框架。

为什么temperature设0.3?客服场景要准确,不要"创造性"。temperature越低回答越确定、越稳定。写诗可以设0.9,做客服0.3就够了。

🧠 Prompt工程:好的Prompt vs 坏的Prompt
维度❌ 坏的Prompt✅ 好的Prompt
示例"用户问了{question},请回答""你是公司客服助手。基于以下文档回答。找不到就说'未找到'。文档:{context}。问题:{question}"
文档来源不提供文档,让LLM自己编明确给出参考文档
兜底指令没有"如果文档中没有相关信息,请说'未找到'"
幻觉率很高(50%+)很低(<5%)
📦

@Async异步任务:文档批量导入 13:30

用Spring @Async + 线程池处理文档批量上传,不阻塞API

午饭回来想到一个问题:管理员上传100个文档,每个都要做embedding,如果同步处理,HTTP请求会超时(100个文档embedding要好几分钟)。

必须异步处理 — 上传接口立刻返回"处理中",后台用线程池慢慢做,做完通知前端。

核心机制:Spring @Async = 方法加个注解就变成异步。调用方立即返回,实际执行在线程池的工作线程上。配合CompletableFuture可以做回调。

📦 Spring @Async 异步任务架构
📤
上传API
@RestController
📋
@Async
提交线程池
👷
Worker线程
处理embedding
写入DB
更新状态
上传API立即返回 → @Async方法在线程池中异步执行 → 前端轮询任务状态
IntelliJ IDEA — config/AsyncConfig.java · 线程池配置
AsyncConfig.java
// 异步线程池配置 — 控制并发数,避免OOM @Configuration @EnableAsync public class AsyncConfig { /** RAG专用线程池 — 处理文档embedding */ @Bean("ragExecutor") public ThreadPoolTaskExecutor ragExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); // 核心线程:CPU核数 executor.setMaxPoolSize(8); // 最大线程 executor.setQueueCapacity(100); // 等待队列 executor.setThreadNamePrefix("rag-"); executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy()); // 队列满时由调用线程执行 executor.initialize(); return executor; } }
IntelliJ IDEA — service/DocumentService.java · 异步文档处理
DocumentService.java
// 文档服务:异步处理文档导入 @Service public class DocumentService { private final DocumentMapper documentMapper; private final EmbeddingService embeddingService; /** 上传接口:保存文件后立即返回,异步处理embedding */ public Map<String, Object> uploadDocument(MultipartFile file, String category) { // 1. 保存文件到磁盘 String filePath = saveFile(file); // 2. 插入DB,状态为pending Document doc = new Document(); doc.setTitle(file.getOriginalFilename()); doc.setContent(""); // 还没提取 doc.setCategory(category); doc.setFilePath(filePath); doc.setStatus("pending"); documentMapper.insert(doc); // 3. 异步处理(不阻塞当前请求) processDocumentAsync(doc.getId()); // 4. 立即返回 — 用户不用等 return Map.of( "docId", doc.getId(), "status", "pending", "message", "文档已上传,正在处理中" ); } /** 异步处理:文本提取 → embedding → 入库 */ @Async("ragExecutor") public void processDocumentAsync(Long docId) { Document doc = documentMapper.findById(docId); try { // 更新状态:处理中 documentMapper.updateStatus(docId, "processing"); // Step 1: 文本提取(PDF/Word → 纯文本) String content = extractText(doc.getFilePath()); // Step 2: 生成embedding向量 float[] embedding = embeddingService.getEmbedding(content); // Step 3: 写入数据库(更新content + embedding) documentMapper.updateContentAndEmbedding( docId, content, vectorToString(embedding)); documentMapper.updateStatus(docId, "ready"); } catch (Exception e) { documentMapper.updateStatus(docId, "failed"); log.error("文档处理失败 docId={}", docId, e); } } }

Spring @Async的底层原理:

当你调用一个@Async方法时,Spring不是直接执行,而是通过AOP代理把任务提交到线程池的队列里。调用线程立即返回,工作线程从队列取任务执行。

三个关键坑:

1. @Async方法必须是public — Spring通过代理调用,private/protected方法不生效

2. 不能在同一个类内this调用 — this.xxx()不走代理,@Async不生效。必须通过注入的Bean调用

3. 必须配合@EnableAsync — 在配置类上加这个注解才开启异步支持

📊 Celery vs Spring @Async 对比
维度Python CelerySpring @Async
部署方式需要单独的Worker进程应用内线程池,无需额外进程
消息中间件必须配Redis/RabbitMQ不需要(线程池内置队列)
任务持久化✅ 重启不丢任务❌ 重启丢未完成任务
分布式✅ 多机器Worker❌ 单机(需XXL-JOB做分布式)
适用场景大规模、高可靠异步轻量级、单机内异步
我们的选择:用Spring @Async就够了。文档量500-1000篇,单机4线程池足够处理。如果后续需要分布式任务调度,再引入XXL-JOB。
🎨

Vue3前端开发 14:30

聊天界面 + SSE流式接收 + 文档上传组件

VS Code — frontend/src/components/ChatInterface.vue
ChatInterface.vue
DocumentUpload.vue
<template> <div class="chat-container"> <div class="messages" ref="msgList"> <div v-for="msg in messages" :key="msg.id" :class="['msg', msg.role]"> <div class="bubble">{{ msg.content }}</div> <!-- 引用来源折叠 --> <details v-if="msg.sources?.length"> <summary>📚 引用来源 ({{ msg.sources.length }})</summary> <div v-for="s in msg.sources" :key="s.doc_id"> 📄 {{ s.title }} ({{ (s.relevance*100).toFixed(0) }}%) </div> </details> </div> </div> <div class="input-area"> <input v-model="input" @keyup.enter="send" placeholder="输入你的问题..." /> <button @click="send" :disabled="loading">发送</button> </div> </div> </template> <script setup> import { ref, nextTick } from 'vue' const messages = ref([]) const input = ref('') const loading = ref(false) async function send() { const question = input.value.trim() if (!question) return // 添加用户消息 messages.value.push({ id: Date.now(), role: 'user', content: question }) input.value = '' loading.value = true // 用SSE流式接收(EventSource) const evtSrc = new EventSource( `/api/chat/stream?question=${question}` ) const aiMsg = { id: Date.now()+1, role: 'ai', content: '', sources: [] } messages.value.push(aiMsg) evtSrc.onmessage = (e) => { const data = JSON.parse(e.data) if (data.token) { // 打字机效果:逐字追加 aiMsg.content += data.token } if (data.sources) { aiMsg.sources = data.sources } if (data.done) { evtSrc.close() loading.value = false } } } </script> <style scoped> /* 聊天气泡样式省略... */ </style>

为什么用SSE(Server-Sent Events)流式接收?

LLM生成完整答案要3秒,如果等全部生成完再返回,用户盯着空白屏幕干等3秒,体验极差。

用SSE,LLM每生成一个token就推送给前端,用户看到打字机效果,首字延迟<500ms,体感上"AI在实时思考"。

类比:一次性端上一桌菜(普通HTTP) vs 一道道菜上(SSE流式) — 后者体验好太多,虽然总时间一样。

SSE vs WebSocket vs HTTP:SSE = 收音机广播(服务器单向推送);WebSocket = 电话(双向通话);HTTP = 寄信(一问一答)。AI回答场景用SSE最合适。

IntelliJ IDEA — controller/ChatController.java · SSE流式接口
ChatController.java
// SSE流式问答接口 — 逐字推送 @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestParam String question) { // 创建SseEmitter,超时120秒 SseEmitter emitter = new SseEmitter(120_000L); // 异步执行RAG + 流式推送 ragService.ragChatStream(question, emitter); // 立即返回emitter — Spring MVC保持连接打开 return emitter; } // RagService中的流式推送: @Async("ragExecutor") public void ragChatStream(String question, SseEmitter emitter) { try { // ... RAG流程(向量搜索+构造prompt)... // 逐chunk推送给前端 llmService.generateStream(prompt, chunk -> { emitter.send(SseEmitter.event() .data(Map.of("token", chunk))); }); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }
🔗

前后端联调 + 跨域处理 15:30

解决CORS跨域、SSE接收、JSON格式校验三大坑

前端localhost:5173调后端localhost:8080,打开浏览器一看 — 满屏报错。联调总是踩坑,小林已经习惯了。
Chrome DevTools — Console 报错
Console
Network
Application
🚫 Access to fetch at 'http://localhost:8080/api/chat/ask'
from origin 'http://localhost:5173' has been blocked
by CORS policy: No 'Access-Control-Allow-Origin' header
is present on the requested resource.
这是典型的 CORS(跨域资源共享)错误 — 浏览器安全策略拦截了。

CORS是什么?为什么浏览器要限制跨域?

浏览器有同源策略:A网站的JS不能随意请求B网站的API(防止恶意网站窃取数据)。当前端(5173)和后端(8080)端口不同,就是"跨域",浏览器会拦截。

解决:后端响应里加上 Access-Control-Allow-Origin 头,告诉浏览器"我允许这个前端访问"。

类比:CORS = 写字楼的访客登记制度。不是所有访客都能随便进大楼,必须前台登记(CORS头)确认后才能进。

// 修复:Spring Boot 添加 CORS 配置 — WebConfig.java @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") // Vue开发服务器 .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true); } }
🔥 联调过程中踩的3个坑
坑1:CORS跨域
问题:前端5173调后端8080被浏览器拦截
解决:WebConfig.addCorsMappings配置允许的origin(见上面代码)
坑2:SSE在Vue中无法接收
问题:用axios调SSE接口,收不到流式数据
解决:axios不支持SSE!必须用原生 EventSource API,或用 fetch + ReadableStream
坑3:@Valid校验报错返回格式不统一
问题:@NotBlank校验失败时Spring返回自己的错误JSON,前端解析出错
解决:加@RestControllerAdvice全局异常处理器,统一返回 {code, message, data} 格式
IntelliJ IDEA — config/GlobalExceptionHandler.java · 统一异常处理
GlobalExceptionHandler.java
@RestControllerAdvice public class GlobalExceptionHandler { // @Valid校验失败 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation( MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .collect(Collectors.joining("; ")); return ResponseEntity.badRequest().body(Map.of( "code", 400, "message", msg )); } // LLM调用超时 @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handleGeneric(Exception e) { log.error("未处理异常", e); return ResponseEntity.internalServerError().body(Map.of( "code", 500, "message", "服务器内部错误" )); } }
Chrome — 智能客服系统运行效果
🔒 http://localhost:5173
🤖 智客AI · 智能客服 在线
怎么申请年假?
AI
根据公司规定,年假申请流程如下:
1. 登录OA系统,进入"休假管理"
2. 选择"年假申请",填写日期和天数
3. 直属主管审批
4. HR备案

需提前3个工作日申请。
📚 引用:员工手册v3.pdf (92%) · OA操作指南 (87%)

看到打字机效果的那一刻 — 前后端终于跑通了!SSE流式输出让AI回答有了"实时思考"的感觉,体验远好于等3秒一次性返回。

🐳

Docker部署 + 环境配置 16:30

Dockerfile + docker-compose一键编排三个服务

本地开发完成,要部署到测试服务器。三个组件(API+DB+Redis)手动启动太麻烦,用Docker Compose一键搞定。
IntelliJ IDEA — Dockerfile
Dockerfile
docker-compose.yml
.env
# Dockerfile — Spring Boot后端镜像 # 多阶段构建:先Maven打包,再瘦身运行 # Stage 1: 构建 FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /build # 先复制pom.xml,利用Docker缓存层 COPY pom.xml . RUN mvn dependency:go-offline # 复制源码并打包 COPY src/ ./src/ RUN mvn package -DskipTests # Stage 2: 运行(JRE精简镜像) FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --from=builder /build/target/*.jar app.jar # 暴露端口 EXPOSE 8080 # JVM参数 + 启动命令 CMD ["java", \ "-XX:+UseG1GC", \ "-XX:MaxRAMPercentage=75.0", \ "-jar", "app.jar"]
IntelliJ IDEA — docker-compose.yml · 三服务编排
Dockerfile
docker-compose.yml
.env
# docker-compose.yml — 一键启动所有服务 version: "3.9" services: # 1. Spring Boot 后端 api: build: . ports: - "8080:8080" environment: - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/smartqa - SPRING_DATASOURCE_USERNAME=postgres - SPRING_DATASOURCE_PASSWORD=pass - SPRING_DATA_REDIS_HOST=redis - OPENAI_API_KEY=${OPENAI_API_KEY} depends_on: - db - redis # 2. PostgreSQL + pgvector db: image: pgvector/pgvector:pg16 environment: POSTGRES_DB: smartqa POSTGRES_USER: postgres POSTGRES_PASSWORD: pass ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data # 3. Redis(缓存 + 会话) redis: image: redis:7-alpine ports: - "6379:6379" volumes: pgdata:

为什么用docker-compose?这个项目有3个服务:Spring Boot、PostgreSQL、Redis。手动启动要开3个终端,敲十几条命令,还容易漏环境变量。

docker-compose把所有服务、依赖关系、端口映射写在一个YAML里,docker compose up -d 一条命令全部启动。

Spring Boot的Dockerfile技巧:多阶段构建(Stage1 Maven打包 → Stage2 JRE运行),最终镜像只有JRE + jar包,体积从900MB缩到180MB。

Terminal — 部署过程
$ docker compose up -d --build [+] Building 45.2s (12/12) FINISHED => [internal] load build definition from Dockerfile => [builder 1/6] FROM maven:3.9-eclipse-temurin-21 => [builder 4/6] COPY pom.xml . => [builder 5/6] RUN mvn dependency:go-offline => [builder 6/6] RUN mvn package -DskipTests => exporting to image 2.1s [+] Running 4/4 ✔ Container smartqa-db-1 Started 0.3s ✔ Container smartqa-redis-1 Started 0.2s ✔ Container smartqa-api-1 Started 1.1s $ curl http://localhost:8080/api/chat/ask -X POST \ -H "Content-Type: application/json" \ -d '{"question":"年假怎么请"}' {"answer":"根据公司规定,年假申请流程如下:1. 登录OA系统..." ,"sources":[{"doc_id":3,"title":"员工手册v3.pdf","relevance":0.92}] ,"responseMs":2847} ✓ RAG系统运行正常!

测试验证 + 性能优化 17:00

API测试、边界case、Redis缓存优化

Postman — API测试
POST http://localhost:8080/api/chat/ask Send
// Request Body
{
  "question": "报销流程是什么?",
  "userId": "test-user"
}
// Response 200 OK · 2.8s
{
  "answer": "报销流程:1. 填写报销单 2. 附发票 3. 主管审批...",
  "sources": [
    {"docId": 7, "title": "财务报销制度.pdf", "relevance": 0.89},
    {"docId": 12, "title": "OA操作手册.md", "relevance": 0.76}
  ],
  "responseMs": 2847
}
🧪 边界Case测试
场景输入预期结果实际结果
正常问题"年假怎么请?"返回答案+引用✅ 2.8s
无关问题"今天天气怎么样?""未找到相关资料"✅ 正确兜底
空问题""400 "问题不能为空"✅ @Valid拦截
超长问题500字+的提问400 "问题不能超过500字"✅ @Size拦截
无文档时清空documents表后提问"文档库中没有找到"✅ 正确兜底
LLM超时断网模拟LLM不可达500 "服务器内部错误"✅ 全局异常处理
⚡ 性能优化:Redis缓存热门问题
// 优化:高频问题缓存到Redis,避免重复RAG @Service public class RagService { @Autowired private StringRedisTemplate redis; public ChatResponse ragChat(String question, int topK) { // 1. 先查缓存(5分钟过期) String cacheKey = "qa:" + DigestUtils.md5DigestAsHex( question.getBytes()); String cached = redis.opsForValue().get(cacheKey); if (cached != null) { return JSON.parseObject(cached, ChatResponse.class); } // 2. 缓存没有 → 走完整RAG流程 ChatResponse response = doRagChat(question, topK); // 3. 写入缓存(5分钟TTL) redis.opsForValue().set(cacheKey, JSON.toJSONString(response), 5, TimeUnit.MINUTES); return response; } }
效果:高频问题(如"年假怎么请")第一次2.8s,之后从Redis读只要8ms,提升350倍!
📊

收工总结 17:30

一天完成智能客服MVP,从需求到部署全流程

✅ 今天完成的功能

🎯 核心功能

  • RAG智能问答(检索+生成)
  • pgvector语义搜索(Top-K相关文档)
  • SSE流式输出(打字机效果)
  • 引用来源展示(答案可溯源)

🛠️ 管理功能

  • 文档上传 + 异步处理(@Async)
  • 文档管理列表
  • 问答历史记录
  • 用户反馈收集
⏱️ 今天的工作量分布
需求分析
45min
15%
后端开发
2.5h
35%
前端开发
1.5h
20%
联调部署
1.5h
20%
测试优化
45min
10%
总计约 7.5 小时,从 09:00 到 17:30(含午休)

📚 今天用到的技术栈

  • 后端:Java 21 + Spring Boot 3.2 + MyBatis
  • 数据库:PostgreSQL 16 + pgvector(向量搜索)
  • 异步任务:Spring @Async + 线程池
  • AI:OpenAI API(Embedding + GPT-4o-mini)
  • HTTP客户端:WebClient(响应式,支持流式)
  • 前端:Vue3 + Composition API + EventSource
  • 部署:Docker + Docker Compose
  • 缓存:Redis(热门问题缓存)

⚠️ 今天踩过的坑

  • CORS跨域 — WebConfig配置解决
  • SSE在Vue中无法接收 — 用EventSource API
  • @Async不生效 — 同类内this调用不走代理
  • pgvector的vector类型 — MyBatis需要自定义TypeHandler
  • WebClient同步阻塞 — generateStream要异步处理
  • Docker镜像太大 — 多阶段构建瘦身到180MB
☕ Java vs 🐍 Python 全栈开发AI应用对比
维度Java (Spring Boot)Python (FastAPI)
类型安全✅ 编译期检查,DTO强类型❌ 运行时才发现错误
性能✅ JIT优化,高并发强⚠️ GIL限制,IO密集还行
AI生态⚠️ LangChain4j还在发展✅ LangChain/LlamaIndex最成熟
企业级✅ 事务/监控/线程池完善⚠️ 需要自己搭
部署体积⚠️ JRE + jar ~180MB✅ Python镜像~120MB
流式处理✅ SseEmitter + WebClient✅ StreamingResponse + async
团队成本✅ Java团队直接上手⚠️ 需要学Python
📝 Java全栈做AI应用的三条心法

1. AI是调用方,架构是根基 — LLM只是HTTP API调用,真正决定系统质量的是Spring Boot的事务管理、线程池控制、异常处理等企业级能力
2. 异步是必须的 — LLM调用动辄2-3秒,@Async + SseEmitter让用户不等待、不超时
3. 缓存是免费的性能提升 — 高频问题Redis缓存5分钟,响应时间从秒级降到毫秒级,零额外成本