ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

第一章 从零搭建可运行、可演进的 Agent 控制台

2026/9/3 16:40:41 拓冰建站 浏览量
第一章 从零搭建可运行、可演进的 Agent 控制台 1.1 整体架构总览┌──────────────────────────────────────────────────────┐ │ 前端 (index.html) │ │ 原生 HTML Tailwind CSS SSE 流式 │ │ 职责用户交互、实时展示 AI 回复 │ ├──────────────────────────────────────────────────────┤ │ Controller 层 │ │ ChatController (REST SSE API) │ │ 职责接收请求、参数校验、返回响应 │ ├──────────────────────────────────────────────────────┤ │ Service 层 │ │ ConversationService (业务逻辑) │ │ 职责会话管理、消息存储、历史查询 │ ├──────────────────────────────────────────────────────┤ │ Model 层 │ │ ChatMessage / Conversation / ChatRequest │ │ 职责数据结构定义、数据传输对象 │ ├──────────────────────────────────────────────────────┤ │ AI 接入层 │ │ AgentConfig → ChatClient → DeepSeek API │ │ 职责封装 AI 调用、统一接入接口 │ └──────────────────────────────────────────────────────┘架构设计原则单向依赖上层依赖下层下层不依赖上层接口隔离每一层通过明确的接口通信可替换性替换任何一层不影响其他层比如把 DeepSeek 换成 GPT只改配置1.2 pom.xml —— 项目依赖配置?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion !-- parent继承 Spring Boot 的默认配置 Spring Boot 4.0.0 内置 Spring Framework 7.0.9 版本号要严格对应不能随意更改 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version4.0.0/version relativePath/ /parent groupIdcom.example/groupId artifactIdagent-console/artifactId version0.0.1-SNAPSHOT/version nameagent-console/name descriptionAI Agent 教学项目 - 第一章骨架/description properties !-- JDK 版本要求Spring Boot 4.0 最低 JDK 17 推荐 JDK 21因为 LTS 且支持虚拟线程 -- java.version21/java.version !-- Spring AI 版本2.0.0 是目前最新的 GA 版本 注意2.0 只能在 Spring Boot 4.0 上运行 -- spring-ai.version2.0.0/spring-ai.version /properties !-- dependencyManagement统一管理版本号 子模块引入依赖时不需要指定版本避免版本冲突 -- dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies !-- spring-boot-starter-web提供 MVC 架构 包含Tomcat、Jackson、Validation 等 用于处理普通的 REST 请求 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- spring-boot-starter-webflux提供响应式编程支持 包含Reactor、Netty 等 必须引入因为 Spring AI 2.0 的 stream() 返回 FluxString Flux 是 Reactor 的核心类型在 webflux 包中 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency !-- spring-ai-starter-model-openaiSpring AI 2.0 的 OpenAI 协议适配器 注意命名变化 - 1.x 叫 spring-ai-openai-spring-boot-starter - 2.0 改名为 spring-ai-starter-model-openai 通过配置 base-url 可以指向任何兼容 OpenAI 协议的 API -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId /dependency /dependencies build plugins !-- Spring Boot Maven 插件打包成可执行 jar -- plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project关键设计决策为什么同时引入 web 和 webfluxSpring 检测到两者共存时以 web 为主处理普通请求但 Controller 方法返回Flux时自动切换到响应式处理这是 Spring 框架的智能路由机制为什么 starter 名字变了Spring AI 2.0 重构了模块结构新的命名规范spring-ai-starter-model-{provider}目的是统一所有 AI 提供商的 starter 命名风格1.3 application.yml —— 应用配置server: port: 8080 spring: application: name: agent-console ai: openai: # base-url指向 DeepSeek 的 API 地址 # DeepSeek 完全兼容 OpenAI 的 REST 接口规范 # 这意味着所有 OpenAI 的 SDK 和工具都可以直接使用 base-url: https://api.deepseek.com # api-key从环境变量读取不硬编码在配置文件中 # ${DEEPSEEK_API_KEY} 会在运行时从环境变量中取值 # 这样做的好处 # 1. 配置文件可以提交到 Git不会泄露密钥 # 2. 不同环境使用不同的 Key互不影响 # 3. Key 泄露后只需更换环境变量无需修改代码 api-key: ${DEEPSEEK_API_KEY} chat: # model指定使用的模型名称 # deepseek-chat 是 DeepSeek 的通用对话模型 model: deepseek-chat # temperature控制输出的随机性 # 范围 0-2值越大输出越随机 # 0.7 是一个平衡创造性和确定性的好选择 temperature: 0.7 # max-tokens限制单次响应的最大 token 数 # 2048 对于一般对话足够了 # 如果需要长文本输出可以适当调大 max-tokens: 2048 # embeddingDeepSeek 不支持 embedding 功能 # 必须显式禁用否则启动时会报错 embedding: enabled: false配置要点说明为什么用${}引用环境变量Spring 的 SpEL 表达式语法运行时从环境变量、JVM 参数、命令行参数依次查找优先级命令行 JVM 参数 环境变量 配置文件为什么 DeepSeek 可以用 OpenAI 的配置DeepSeek 实现了 OpenAI 的 REST API 规范相同的请求格式、相同的认证方式、相同的响应格式唯一的区别是 base-url 和模型名称为什么 2.0 没有 options 配置段Spring AI 1.x 有spring.ai.openai.chat.options配置段2.0 将其合并到spring.ai.openai.chat下简化配置层级减少混淆1.4 启动类package com.example.agent; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * AgentApplication应用启动入口 * * SpringBootApplication 是一个组合注解包含 * 1. Configuration标记为配置类 * 2. EnableAutoConfiguration启用 Spring Boot 自动配置 * 3. ComponentScan自动扫描当前包及其子包的组件 * * 扫描范围com.example.agent 及其所有子包 * 所以所有组件类Service、Controller、Configuration 等 * 都必须放在 com.example.agent 或其子包下 */ SpringBootApplication public class AgentApplication { /** * main 方法Java 应用的入口 * * SpringApplication.run() 做了这些事 * 1. 创建 ApplicationContextIoC 容器 * 2. 扫描并注册所有 Bean * 3. 启动内嵌的 Tomcat 服务器 * 4. 执行 CommandLineRunner 和 ApplicationRunner * 5. 注册 shutdown hook优雅关闭 */ public static void main(String[] args) { SpringApplication.run(AgentApplication.class, args); } }启动流程详解SpringApplication.run() 的执行顺序 1. 准备阶段 ├── 确定应用类型Servlet 还是 Reactive ├── 加载所有 spring.factories 中的自动配置类 └── 准备 Environment读取配置文件、环境变量等 2. 创建容器 ├── 创建 AnnotationConfigServletWebServerApplicationContext ├── 注册启动类本身SpringBootApplication 也是 Configuration └── 执行 BeanDefinitionRegistryPostProcessor 3. 刷新容器最关键的步骤 ├── 扫描并注册所有 Bean ├── 执行 BeanFactoryPostProcessor ├── 实例化所有单例 Bean非懒加载的 ├── 初始化 MessageSource国际化 ├── 注册 ApplicationListener └── 启动内嵌 Web 服务器 4. 完成阶段 ├── 执行 CommandLineRunner ├── 执行 ApplicationRunner └── 打印启动日志端口、耗时等1.5 模型层 —— 数据结构定义ChatMessage.javapackage com.example.agent.model; import java.time.LocalDateTime; /** * ChatMessage聊天消息的数据模型 * * 使用 Record 而不是 Class 的原因 * * 1. 不可变性Immutability * - Record 的所有字段都是 final 的 * - 一旦创建就不能修改天然线程安全 * - 在多线程环境中不需要额外的同步措施 * * 2. 简洁性 * - 自动生成构造器、getter、equals、hashCode、toString * - 10 行代码完成了传统 Class 50 行才能做的事 * * 3. 语义清晰 * - Record 本身就是数据载体的语义 * - 看到 Record 就知道它只用来传输数据不含复杂逻辑 * * 4. 模式匹配Java 21 * - 可以在 switch 中进行模式匹配 * - 解构赋值更加方便 */ public record ChatMessage( /** * role消息角色 * 取值user用户、assistantAI * 注意system 角色在系统提示词中配置不在消息中体现 */ String role, /** * content消息内容 * 纯文本格式Markdown 由前端渲染 */ String content, /** * timestamp消息时间戳 * 用于前端展示消息顺序和时间 */ LocalDateTime timestamp ) { /** * 静态工厂方法创建用户消息 * * 为什么用静态工厂方法而不是构造器 * 1. 方法名可以表达语义user vs assistant * 2. 可以自动填充 timestamp减少重复代码 * 3. 后续可以增加参数校验逻辑 */ public static ChatMessage user(String content) { return new ChatMessage(user, content, LocalDateTime.now()); } /** * 静态工厂方法创建 AI 回复消息 */ public static ChatMessage assistant(String content) { return new ChatMessage(assistant, content, LocalDateTime.now()); } }Conversation.javapackage com.example.agent.model; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; /** * Conversation会话的数据模型 * * 为什么用 Class 而不是 Record * 因为 messages 列表需要动态添加内容 * Record 的所有字段都是 final 的无法满足这个需求 * * 设计权衡 * - id、title、createdAt不可变一旦创建就不应该改变 * - messages可变因为要不断添加新消息 */ public class Conversation { /** * id会话唯一标识 * 使用 UUID 而不是自增 ID 的原因 * 1. 全局唯一不需要数据库生成 * 2. 无法被猜测安全性 * 3. 分布式环境下也不会冲突 */ private final String id; /** * title会话标题 * 默认值为新会话用户可以后续修改 */ private final String title; /** * createdAt创建时间 * 用于排序和展示 */ private final LocalDateTime createdAt; /** * messages消息列表 * 使用 ArrayList 因为 * 1. 需要保持插入顺序 * 2. 主要是尾部添加操作 * 3. 需要按索引访问前端翻页 * * 不使用 LinkedList 的原因 * 1. 占用更多内存每个节点存储前后指针 * 2. 随机访问性能差O(n) */ private final ListChatMessage messages; /** * 构造器 * * param title 会话标题如果为 null 或空字符串则使用默认值 */ public Conversation(String title) { this.id UUID.randomUUID().toString(); this.title title null || title.isBlank() ? 新会话 : title; this.createdAt LocalDateTime.now(); this.messages new ArrayList(); } // Getter 方法 // 只提供 getter不提供 setter // 外部代码只能读取不能修改会话的基本信息 public String getId() { return id; } public String getTitle() { return title; } public LocalDateTime getCreatedAt() { return createdAt; } public ListChatMessage getMessages() { return messages; } /** * addMessage添加消息到会话 * * 为什么不直接暴露 messages 的 add 方法 * 1. 可以在添加消息时做校验如消息内容不能为空 * 2. 可以在添加消息时触发其他逻辑如更新会话时间 * 3. 符合迪米特法则最少知道原则 */ public void addMessage(ChatMessage message) { if (message null) { throw new IllegalArgumentException(消息不能为 null); } messages.add(message); } }ChatRequest.java★ 关键新增package com.example.agent.model; /** * ChatRequest聊天请求的 DTO数据传输对象 * * 为什么需要这个类而不是直接使用 MapString, String * * 根本原因Spring Framework 7.0 的破坏性变更 * * Spring Framework 7.0Spring Boot 4.0 的内核不再支持 * 将 Map、Collection 等接口类型作为 RequestBody 的参数。 * * 原因是框架需要通过反射创建参数的实例 * 但 Map 是接口Spring 不知道应该用哪个实现类 * HashMapTreeMapLinkedHashMap * * 而 Record 是具体的类Spring 可以直接反射创建实例。 * * 额外好处 * 1. 类型安全编译期就能发现字段名拼写错误 * 2. Swagger 文档自动生成 API 文档字段名和类型一目了然 * 3. 参数校验可以加 NotNull、NotBlank 等注解 * 4. 重构友好IDE 可以轻松追踪字段的使用情况 */ public record ChatRequest( /** 会话 ID用于关联到指定的会话 */ String conversationId, /** 用户发送的消息内容 */ String message ) {}CreateConversationRequest.javapackage com.example.agent.model; /** * CreateConversationRequest创建会话请求的 DTO * * 为什么单独定义一个类而不是复用 ChatRequest * 1. 单一职责每个 DTO 只对应一个 API 接口 * 2. 字段不同创建会话只需要 title不需要 message * 3. 未来扩展创建会话可能需要其他参数如模型选择 */ public record CreateConversationRequest( /** * 会话标题可选参数 * 如果不传后端会自动生成默认标题新会话 * 使用 required false 标记为非必填 */ String title ) {}1.6 配置层 —— AI 客户端配置package com.example.agent.config; import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * AgentConfigAI 客户端的配置类 * * Configuration 注解告诉 Spring * 这是一个配置类其中定义的 Bean 方法会被 IoC 容器管理 * * 配置类的设计原则 * 1. 集中管理所有 AI 相关的配置都在这里 * 2. 易于测试可以轻松替换为 Mock 对象 * 3. 可扩展新增功能只需添加新的 Bean 方法 */ Configuration public class AgentConfig { /** * chatClient创建 ChatClient Bean * * ChatClient 是 Spring AI 2.0 的核心抽象 * 类似于 JDBC 中的 DataSource是所有 AI 操作的入口 * * Builder 模式的设计意图 * 1. 链式调用可以连续设置多个属性 * 2. 不可变对象build() 之后就不能修改 * 3. 线程安全同一个 Builder 可以创建多个 Client * * param builder ChatClient.Builder 由 Spring 自动注入 * 它的配置来自 application.yml 中的 spring.ai.openai.* * return 配置好的 ChatClient 实例 */ Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder /* * defaultSystem设置默认的系统提示词 * * System Prompt 的作用 * 1. 定义 AI 的角色和行为准则 * 2. 每次对话都会附加在消息列表的最前面 * 3. AI 会根据 system prompt 来调整回答风格 * * 这里的提示词包含了三个要素 * 1. 身份声明让 AI 知道它是谁 * 2. 行为规范回答应该简洁、准确、友好 * 3. 边界约束不知道就说不知道不要编造 * * 注意每个模型的 system prompt 效果不同 * DeepSeek 对 system prompt 的遵循程度较高 */ .defaultSystem( 你是一个用 Java Spring AI 构建的 AI Agent 助手。 回答要求 1. 简洁、准确、友好 2. 如果你不确定答案直接说不知道不要编造 3. 涉及代码问题时优先给出 Java 示例 ) .build(); } }ChatClient 的工作原理ChatClient 的内部工作流程 1. 构建请求 prompt() → 创建 Prompt 对象 .user(你好) → 添加用户消息 .system(...) → 可选覆盖默认 system prompt .tools(...) → 可选注册工具Function Calling 2. 发送请求 call() → 同步调用阻塞等待响应 stream() → 异步调用返回 Flux 流 3. 处理响应 .content() → 提取文本内容 .entity(Class) → 将响应映射为指定类型 .getResult() → 获取完整的 ChatResponse 对象 4. 底层实现 ChatClient 内部使用 RestClient 或 WebClient 根据配置的 base-url 和 api-key 构建 HTTP 请求 序列化和反序列化使用 Jackson1.7 服务层 —— 业务逻辑package com.example.agent.service; import com.example.agent.model.ChatMessage; import com.example.agent.model.Conversation; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * ConversationService会话管理的业务逻辑层 * * Service 注解的作用 * 1. 标记这是一个 Service 组件 * 2. Spring 会自动扫描并注册为 Bean * 3. 默认是单例模式Singleton * * 单例模式在多线程环境下的注意事项 * - 成员变量必须线程安全 * - 不能使用有状态的实例变量 * - 方法参数和局部变量是线程安全的每个线程独享栈空间 */ Service public class ConversationService { /** * conversations会话存储 * * 为什么使用 ConcurrentHashMap 而不是 HashMap * * 并发场景分析 * - 用户 A 和用户 B 同时发送请求 * - 两个请求分别在 Tomcat 的不同线程中执行 * - 如果使用 HashMap两个线程同时 put 可能导致 * 1. 数据丢失后写入的覆盖先写入的 * 2. 死循环JDK 7 的 HashMap 在扩容时可能形成环形链表 * 3. CPU 100%死循环导致 * * ConcurrentHashMap 的并发机制 * - 读操作不加锁通过 volatile 保证可见性 * - 写操作分段锁CAS synchronized * - size() 等聚合操作尝试多次无锁计算失败才加锁 * - 迭代器弱一致性允许遍历时修改 * * 为什么不用 Collections.synchronizedMap * - 对整个 Map 加锁并发性能差 * - 迭代时需要手动同步容易出错 */ private final MapString, Conversation conversations new ConcurrentHashMap(); /** * createConversation创建新会话 * * param title 会话标题可以为 null * return 创建的会话对象 */ public Conversation createConversation(String title) { Conversation c new Conversation(title); conversations.put(c.getId(), c); return c; } /** * getConversation获取指定会话 * * param conversationId 会话 ID * return 会话对象 * throws IllegalArgumentException 如果会话不存在 * * 为什么不返回 OptionalConversation * 1. 在这个业务场景中请求一个不存在的会话是异常情况 * 2. 调用方每次都需要处理 Optional增加样板代码 * 3. 异常可以让调用方更清晰地知道发生了什么问题 */ public Conversation getConversation(String conversationId) { Conversation c conversations.get(conversationId); if (c null) { throw new IllegalArgumentException(会话不存在: conversationId); } return c; } /** * listConversations获取所有会话列表 * * 为什么返回新的 ArrayList 而不是直接返回 Map 的 values * 1. 防御性拷贝防止调用方修改返回的列表影响内部状态 * 2. 快照视图返回的是当前时刻的会话列表快照 * 3. 线程安全即使遍历过程中有其他线程修改了 Map也不会抛出 ConcurrentModificationException */ public ListConversation listConversations() { return new ArrayList(conversations.values()); } /** * addMessage向指定会话添加消息 * * param conversationId 会话 ID * param message 要添加的消息 */ public void addMessage(String conversationId, ChatMessage message) { getConversation(conversationId).addMessage(message); } /** * getHistory获取指定会话的历史消息 * * 使用 List.copyOf 返回不可变列表的原因 * 1. 防止调用方修改历史消息 * 2. 调用方可以安全地缓存这个列表 * 3. 符合最小权限原则只给调用方需要的权限 * * param conversationId 会话 ID * return 不可变的消息列表 */ public ListChatMessage getHistory(String conversationId) { return List.copyOf(getConversation(conversationId).getMessages()); } }1.8 控制器层 —— API 接口package com.example.agent.controller; import com.example.agent.model.*; import com.example.agent.service.ConversationService; import org.springframework.ai.chat.client.ChatClient; import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; import java.util.List; import java.util.Map; /** * ChatController聊天相关的 REST API 控制器 * * RestController Controller ResponseBody * 所有方法的返回值都会自动序列化为 JSON * * RequestMapping(/ai)所有接口都以 /ai 开头 * 这样做的好处 * 1. 统一前缀便于 Nginx 做反向代理 * 2. 便于后期拆分微服务 * 3. Swagger 文档分组更清晰 */ RestController RequestMapping(/ai) public class ChatController { private final ChatClient chatClient; private final ConversationService conversationService; /** * 构造器注入 * * 为什么使用构造器注入而不是 Autowired 字段注入 * 1. 不可变性final 字段必须在构造器中初始化 * 2. 测试友好可以轻松传入 Mock 对象 * 3. 依赖明确一眼就能看出这个类需要哪些依赖 * 4. 循环依赖检测构造器注入会在启动时就发现循环依赖 * * Spring 官方推荐始终使用构造器注入 */ public ChatController(ChatClient.Builder builder, ConversationService conversationService) { // 注意这里传入的是 Builder每次 build() 创建一个新的 ChatClient // 为什么不直接注入 ChatClient // 因为 ChatClient 是有状态的包含 conversationId 等上下文 // 使用 Builder 可以确保每个请求得到独立的 ChatClient this.chatClient builder.build(); this.conversationService conversationService; } // 会话管理接口 /** * 创建新会话 * * POST /ai/conversation * * param request 创建会话的请求体可选required false * 如果不传 title后端使用默认值新会话 * return 创建的会话对象 */ PostMapping(/conversation) public Conversation createConversation(RequestBody(required false) CreateConversationRequest request) { String title request null ? null : request.title(); return conversationService.createConversation(title); } /** * 获取所有会话列表 * * GET /ai/conversations * * return 会话列表 */ GetMapping(/conversations) public ListConversation listConversations() { return conversationService.listConversations(); } /** * 获取指定会话的消息历史 * * GET /ai/conversation/{conversationId}/messages * * param conversationId 会话 ID路径变量 * return 消息列表 */ GetMapping(/conversation/{conversationId}/messages) public ListChatMessage getMessages(PathVariable String conversationId) { return conversationService.getHistory(conversationId); } // 消息收发接口 /** * 发送消息非流式 * * POST /ai/chat * * 适用场景 * - 内部服务调用不需要展示中间结果 * - 简单问答回答很短 * - 测试和调试 * * param request 聊天请求包含 conversationId 和 message * return 包含 AI 回复的 Map */ PostMapping(/chat) public MapString, String chat(RequestBody ChatRequest request) { // 1. 保存用户消息到会话历史 conversationService.addMessage(request.conversationId(), ChatMessage.user(request.message())); // 2. 调用 AI 模型获取回复 // prompt() 创建一个新的 Prompt // user() 添加用户消息 // call() 同步调用阻塞等待完整响应 // content() 提取文本内容 String response chatClient.prompt() .user(request.message()) .call() .content(); // 3. 保存 AI 回复到会话历史 conversationService.addMessage(request.conversationId(), ChatMessage.assistant(response)); // 4. 返回结果 // 使用 Map.of() 创建不可变的 Map return Map.of(reply, response); } /** * 发送消息流式 SSE * * POST /ai/chat/stream * produces MediaType.TEXT_EVENT_STREAM_VALUE 表示返回 SSE 格式 * * 适用场景 * - 面向用户的对话界面 * - 长文本生成 * - 需要打字机效果 * * SSE 协议格式 * event: text * data: {content: 一段文本} * * event: end * data: done * * param request 聊天请求 * return SSE 事件流 */ PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxServerSentEventString streamChat(RequestBody ChatRequest request) { String conversationId request.conversationId(); String message request.message(); // 1. 先保存用户消息 conversationService.addMessage(conversationId, ChatMessage.user(message)); // 2. 用 StringBuilder 收集完整回复 // 为什么用 StringBuilder 而不是 StringBuffer // StringBuilder 是非线程安全的但在这个场景中 // - 所有 append 操作都在同一个 Flux 链中顺序执行 // - 不需要跨线程同步 // - StringBuilder 的性能优于 StringBuffer StringBuilder fullResponse new StringBuilder(); // 3. 返回流式响应 return chatClient.prompt() .user(message) .stream() // 启用流式模式 .content() // 返回 FluxString // map将每个文本块转换为 SSE 事件 .map(chunk - { fullResponse.append(chunk); return ServerSentEvent.Stringbuilder(chunk) .event(text) // 事件类型text .build(); }) // concatWith在所有文本块之后追加结束事件 .concatWith(Flux.just( ServerSentEvent.Stringbuilder(done) .event(end) // 事件类型end .build() )) // doOnTerminate流结束时执行的回调 // 无论正常结束还是异常结束都会触发 .doOnTerminate(() - { String completeResponse fullResponse.toString(); if (!completeResponse.isEmpty()) { conversationService.addMessage( conversationId, ChatMessage.assistant(completeResponse) ); } }); } }流式 SSE 的执行流程时序图 前端 后端 DeepSeek API │ │ │ │── POST /chat/stream ────→│ │ │ │── 保存用户消息 ──────────→│ │ │ │ │ │── POST /v1/chat/completions (streamtrue) ──→│ │ │ │ │←── event: text, data:深──│←── chunk 1 ────────────│ │←── event: text, data:圳──│←── chunk 2 ────────────│ │←── event: text, data:的──│←── chunk 3 ────────────│ │←── event: text, data:天──│←── chunk 4 ────────────│ │←── event: text, data:气──│←── chunk 5 ────────────│ │←── event: text, data:真──│←── chunk 6 ────────────│ │←── event: text, data:好──│←── chunk 7 ────────────│ │ │ │ │←── event: end, data:done──│ │ │ │── 保存完整回复 ──────────→│ │ │ │1.9 前端页面 —— 用户界面!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleAgent 控制台/title !-- Tailwind CSS实用优先的 CSS 框架 通过 CDN 引入不需要构建工具 适合快速原型开发 -- script srchttps://cdn.tailwindcss.com/script /head body classbg-gray-100 div classflex h-screen !-- 左侧会话列表sidebar 布局说明 w-64固定宽度 256px bg-gray-900深色背景 flex flex-col垂直排列 -- div classw-64 bg-gray-900 text-white p-4 flex flex-col h2 classtext-xl font-bold mb-4Agent 控制台/h2 !-- 新建会话按钮 -- button classbg-blue-600 hover:bg-blue-700 rounded px-4 py-2 mb-4 onclicknewConversation() 新建会话 /button !-- 会话列表容器 -- div idconversation-list classflex-1 overflow-y-auto space-y-2/div /div !-- 右侧聊天区域 flex-1占据剩余空间 flex flex-col垂直排列 -- div classflex-1 flex flex-col !-- 消息展示区域 -- div idmessages classflex-1 overflow-y-auto p-6 space-y-4/div !-- 输入区域 -- div classp-4 border-t bg-white div classflex input idmessage-input typetext placeholder输入消息... classflex-1 border rounded-l px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 button onclicksendMessage() classbg-blue-600 hover:bg-blue-700 text-white rounded-r px-6 py-2 发送 /button /div /div /div /div script // 当前会话 ID let currentConversationId null; // 页面加载完成后初始化 window.onload () { loadConversations(); newConversation(); }; /** * 创建新会话 * * 流程 * 1. 调用后端 API 创建会话 * 2. 切换到新会话 * 3. 清空消息区域 * 4. 刷新会话列表 */ async function newConversation() { const res await fetch(/ai/conversation, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({title: 新会话}) }); const conv await res.json(); currentConversationId conv.id; document.getElementById(messages).innerHTML ; loadConversations(); } /** * 加载会话列表 * * 每次加载都会重新渲染整个列表 * 虽然简单粗暴但对于少量会话来说性能足够 * 后续优化虚拟滚动、增量更新 */ async function loadConversations() { const res await fetch(/ai/conversations); const convs await res.json(); const list document.getElementById(conversation-list); list.innerHTML convs.map(c div classp-2 hover:bg-gray-800 cursor-pointer rounded ${c.id currentConversationId ? bg-gray-700 : } onclickswitchConversation(${c.id}) div classfont-medium${c.title}/div div classtext-xs text-gray-500${new Date(c.createdAt).toLocaleTimeString()}/div /div ).join(); } /** * 切换会话 * * 流程 * 1. 更新当前会话 ID * 2. 从后端获取历史消息 * 3. 清空并重新渲染消息区域 * 4. 高亮当前会话 */ async function switchConversation(id) { currentConversationId id; const res await fetch(/ai/conversation/${id}/messages); const messages await res.json(); const container document.getElementById(messages); container.innerHTML ; messages.forEach(m addBubble(m.role, m.content)); loadConversations(); } /** * 发送消息 * * 这是最核心的函数实现了流式 SSE 的接收和渲染 * * 为什么使用 Fetch API 而不是 EventSource * - EventSource 只能发送 GET 请求 * - 我们需要 POST 请求传递 conversationId 和 message * - 所以使用 Fetch API ReadableStream 手动解析 SSE * * 为什么需要缓冲区buffer * - SSE 数据可能跨多个 TCP 包到达 * - 最后一个 \n 后面的数据可能不完整 * - 需要保留 buffer下次收到数据时拼接 */ async function sendMessage() { const input document.getElementById(message-input); const message input.value.trim(); if (!message || !currentConversationId) return; input.value ; addBubble(user, message); // 发起流式请求 const res await fetch(/ai/chat/stream, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ conversationId: currentConversationId, message: message }) }); // 创建 AI 回复的气泡初始为空 const assistantDiv addBubble(assistant, ); const contentDiv assistantDiv.querySelector(.content); // 获取响应流的读取器 const reader res.body.getReader(); const decoder new TextDecoder(); let buffer ; let fullContent ; while (true) { const { done, value } await reader.read(); if (done) break; // 解码二进制数据为文本 buffer decoder.decode(value, {stream: true}); // 按行分割SSE 协议使用 \n 分隔行 const lines buffer.split(\n); buffer lines.pop(); // 最后一行可能不完整 for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim(); if (data [DONE]) continue; // 尝试解析 JSON // 有些 SSE 事件的数据是 JSON 格式 // 有些是纯文本 try { const json JSON.parse(data); if (json.content) { fullContent json.content; contentDiv.textContent fullContent; } } catch (e) { // 纯文本直接追加 fullContent data; contentDiv.textContent fullContent; } } } } // 滚动到底部 document.getElementById(messages).scrollTop document.getElementById(messages).scrollHeight; } /** * 添加消息气泡 * * param {string} role 角色user 或 assistant * param {string} content 消息内容 * returns {HTMLElement} 气泡的父容器 * * 样式说明 * - 用户消息蓝色背景右对齐 * - AI 回复白色背景左对齐 * - 最大宽度 70%防止过长 */ function addBubble(role, content) { const container document.getElementById(messages); const div document.createElement(div); div.className flex ${role user ? justify-end : justify-start}; div.innerHTML div classmax-w-[70%] rounded-lg px-4 py-2 ${role user ? bg-blue-600 text-white : bg-white text-gray-800 border} div classcontent whitespace-pre-wrap${content}/div /div ; container.appendChild(div); container.scrollTop container.scrollHeight; return div; } // 回车键发送消息 document.getElementById(message-input).addEventListener(keydown, e { if (e.key Enter) sendMessage(); }); /script /body /html1.10 运行与验证启动命令# 1. 设置环境变量Linux/Mac export DEEPSEEK_API_KEYsk-你的key # 2. 启动应用 mvn spring-boot:run # 3. 浏览器访问 open http://localhost:8080验证步骤创建会话点击新建会话按钮左侧出现新会话发送消息输入框输入消息按回车或点击发送观察流式输出AI 回复逐字显示有打字机效果切换会话点击左侧其他会话查看历史消息创建多个会话验证会话隔离测试 API# 创建会话 curl -X POST http://localhost:8080/ai/conversation \ -H Content-Type: application/json \ -d {title:测试会话} # 发送消息非流式 curl -X POST http://localhost:8080/ai/chat \ -H Content-Type: