三相方波逆变电路:从基础原理到谐波分析与工程实践
2026/9/3 17:21:00
┌──────────────────────────────────────────────────────┐ │ 前端 (index.html) │ │ 原生 HTML + Tailwind CSS + SSE 流式 │ │ 职责:用户交互、实时展示 AI 回复 │ ├──────────────────────────────────────────────────────┤ │ Controller 层 │ │ ChatController (REST + SSE API) │ │ 职责:接收请求、参数校验、返回响应 │ ├──────────────────────────────────────────────────────┤ │ Service 层 │ │ ConversationService (业务逻辑) │ │ 职责:会话管理、消息存储、历史查询 │ ├──────────────────────────────────────────────────────┤ │ Model 层 │ │ ChatMessage / Conversation / ChatRequest │ │ 职责:数据结构定义、数据传输对象 │ ├──────────────────────────────────────────────────────┤ │ AI 接入层 │ │ AgentConfig → ChatClient → DeepSeek API │ │ 职责:封装 AI 调用、统一接入接口 │ └──────────────────────────────────────────────────────┘架构设计原则:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <!-- parent:继承 Spring Boot 的默认配置 Spring Boot 4.0.0 内置 Spring Framework 7.0.9 版本号要严格对应,不能随意更改 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.0.0</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>agent-console</artifactId> <version>0.0.1-SNAPSHOT</version> <name>agent-console</name> <description>AI Agent 教学项目 - 第一章骨架</description> <properties> <!-- JDK 版本要求:Spring Boot 4.0 最低 JDK 17 推荐 JDK 21,因为 LTS 且支持虚拟线程 --> <java.version>21</java.version> <!-- Spring AI 版本:2.0.0 是目前最新的 GA 版本 注意:2.0 只能在 Spring Boot 4.0+ 上运行 --> <spring-ai.version>2.0.0</spring-ai.version> </properties> <!-- dependencyManagement:统一管理版本号 子模块引入依赖时不需要指定版本,避免版本冲突 --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- spring-boot-starter-web:提供 MVC 架构 包含:Tomcat、Jackson、Validation 等 用于处理普通的 REST 请求 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- spring-boot-starter-webflux:提供响应式编程支持 包含:Reactor、Netty 等 必须引入,因为 Spring AI 2.0 的 stream() 返回 Flux<String> Flux 是 Reactor 的核心类型,在 webflux 包中 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- spring-ai-starter-model-openai:Spring AI 2.0 的 OpenAI 协议适配器 注意命名变化: - 1.x 叫 spring-ai-openai-spring-boot-starter - 2.0 改名为 spring-ai-starter-model-openai 通过配置 base-url 可以指向任何兼容 OpenAI 协议的 API --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> </dependencies> <build> <plugins> <!-- Spring Boot Maven 插件:打包成可执行 jar --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>关键设计决策:
Flux时,自动切换到响应式处理spring-ai-starter-model-{provider}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 # embedding:DeepSeek 不支持 embedding 功能 # 必须显式禁用,否则启动时会报错 embedding: enabled: false配置要点说明:
${}引用环境变量?spring.ai.openai.chat.options配置段spring.ai.openai.chat下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. 创建 ApplicationContext(IoC 容器) * 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 └── 打印启动日志(端口、耗时等)package 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(用户)、assistant(AI) * 注意: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()); } }package 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 List<ChatMessage> 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 List<ChatMessage> getMessages() { return messages; } /** * addMessage:添加消息到会话 * * 为什么不直接暴露 messages 的 add 方法? * 1. 可以在添加消息时做校验(如消息内容不能为空) * 2. 可以在添加消息时触发其他逻辑(如更新会话时间) * 3. 符合迪米特法则(最少知道原则) */ public void addMessage(ChatMessage message) { if (message == null) { throw new IllegalArgumentException("消息不能为 null"); } messages.add(message); } }package com.example.agent.model; /** * ChatRequest:聊天请求的 DTO(数据传输对象) * * 为什么需要这个类而不是直接使用 Map<String, String>? * * 根本原因:Spring Framework 7.0 的破坏性变更 * * Spring Framework 7.0(Spring Boot 4.0 的内核)不再支持 * 将 Map、Collection 等接口类型作为 @RequestBody 的参数。 * * 原因是:框架需要通过反射创建参数的实例, * 但 Map 是接口,Spring 不知道应该用哪个实现类 * (HashMap?TreeMap?LinkedHashMap?) * * 而 Record 是具体的类,Spring 可以直接反射创建实例。 * * 额外好处: * 1. 类型安全:编译期就能发现字段名拼写错误 * 2. Swagger 文档:自动生成 API 文档,字段名和类型一目了然 * 3. 参数校验:可以加 @NotNull、@NotBlank 等注解 * 4. 重构友好:IDE 可以轻松追踪字段的使用情况 */ public record ChatRequest( /** 会话 ID,用于关联到指定的会话 */ String conversationId, /** 用户发送的消息内容 */ String message ) {}package com.example.agent.model; /** * CreateConversationRequest:创建会话请求的 DTO * * 为什么单独定义一个类而不是复用 ChatRequest? * 1. 单一职责:每个 DTO 只对应一个 API 接口 * 2. 字段不同:创建会话只需要 title,不需要 message * 3. 未来扩展:创建会话可能需要其他参数(如模型选择) */ public record CreateConversationRequest( /** * 会话标题,可选参数 * 如果不传,后端会自动生成默认标题"新会话" * 使用 required = false 标记为非必填 */ String title ) {}package com.example.agent.config; import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * AgentConfig:AI 客户端的配置类 * * @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 请求 序列化和反序列化使用 Jacksonpackage 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 Map<String, 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 如果会话不存在 * * 为什么不返回 Optional<Conversation>? * 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 List<Conversation> 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 List<ChatMessage> getHistory(String conversationId) { return List.copyOf(getConversation(conversationId).getMessages()); } }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 List<Conversation> listConversations() { return conversationService.listConversations(); } /** * 获取指定会话的消息历史 * * GET /ai/conversation/{conversationId}/messages * * @param conversationId 会话 ID(路径变量) * @return 消息列表 */ @GetMapping("/conversation/{conversationId}/messages") public List<ChatMessage> getMessages(@PathVariable String conversationId) { return conversationService.getHistory(conversationId); } // ==================== 消息收发接口 ==================== /** * 发送消息(非流式) * * POST /ai/chat * * 适用场景: * - 内部服务调用(不需要展示中间结果) * - 简单问答(回答很短) * - 测试和调试 * * @param request 聊天请求(包含 conversationId 和 message) * @return 包含 AI 回复的 Map */ @PostMapping("/chat") public Map<String, 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 Flux<ServerSentEvent<String>> 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() // 返回 Flux<String> // map:将每个文本块转换为 SSE 事件 .map(chunk -> { fullResponse.append(chunk); return ServerSentEvent.<String>builder(chunk) .event("text") // 事件类型:text .build(); }) // concatWith:在所有文本块之后追加结束事件 .concatWith(Flux.just( ServerSentEvent.<String>builder("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 (stream=true) ──→│ │ │ │ │←── 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──│ │ │ │── 保存完整回复 ──────────→│ │ │ │<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Agent 控制台</title> <!-- Tailwind CSS:实用优先的 CSS 框架 通过 CDN 引入,不需要构建工具 适合快速原型开发 --> <script src="https://cdn.tailwindcss.com"></script> </head> <body class="bg-gray-100"> <div class="flex h-screen"> <!-- 左侧会话列表(sidebar) 布局说明: w-64:固定宽度 256px bg-gray-900:深色背景 flex flex-col:垂直排列 --> <div class="w-64 bg-gray-900 text-white p-4 flex flex-col"> <h2 class="text-xl font-bold mb-4">Agent 控制台</h2> <!-- 新建会话按钮 --> <button class="bg-blue-600 hover:bg-blue-700 rounded px-4 py-2 mb-4" onclick="newConversation()"> + 新建会话 </button> <!-- 会话列表容器 --> <div id="conversation-list" class="flex-1 overflow-y-auto space-y-2"></div> </div> <!-- 右侧聊天区域 flex-1:占据剩余空间 flex flex-col:垂直排列 --> <div class="flex-1 flex flex-col"> <!-- 消息展示区域 --> <div id="messages" class="flex-1 overflow-y-auto p-6 space-y-4"></div> <!-- 输入区域 --> <div class="p-4 border-t bg-white"> <div class="flex"> <input id="message-input" type="text" placeholder="输入消息..." class="flex-1 border rounded-l px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"> <button onclick="sendMessage()" class="bg-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 class="p-2 hover:bg-gray-800 cursor-pointer rounded ${c.id === currentConversationId ? 'bg-gray-700' : ''}" onclick="switchConversation('${c.id}')"> <div class="font-medium">${c.title}</div> <div class="text-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 class="max-w-[70%] rounded-lg px-4 py-2 ${role === 'user' ? 'bg-blue-600 text-white' : 'bg-white text-gray-800 border'}"> <div class="content 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> </html># 1. 设置环境变量(Linux/Mac) export DEEPSEEK_API_KEY=sk-你的key # 2. 启动应用 mvn spring-boot:run # 3. 浏览器访问 open http://localhost:8080# 创建会话 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: