This commit is contained in:
2026-04-30 16:08:39 +08:00
parent f95aa18724
commit 1503b26959
41 changed files with 4503 additions and 0 deletions

75
week9/pom.xml Normal file
View File

@@ -0,0 +1,75 @@
<?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>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.learn</groupId>
<artifactId>week9-ai-chat</artifactId>
<version>1.0.0</version>
<name>Week 9: AI and LLM Basics - Chat Bot</name>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.6</spring-ai.version>
</properties>
<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>
<!-- Web + Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring AI: OpenAI-compatible chat model -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- WebFlux for SSE streaming (Day 6) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,11 @@
package com.learn;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Week9Application {
public static void main(String[] args) {
SpringApplication.run(Week9Application.class, args);
}
}

View File

@@ -0,0 +1,80 @@
package com.learn.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Spring AI 配置类(手动装配,便于理解各组件的关系)
*
* 架构:
* ChatModel (OpenAiChatModel) ← 底层 HTTP 通信,由 application.yml 自动配置
* ↓ 包装
* ChatClient ← 高级 Fluent API我们手动创建
* ↓ 拦截
* MessageChatMemoryAdvisor ← 自动注入对话历史(多轮对话)
* ↓ 写入 / 读取
* ChatMemory (MessageWindowChatMemory) ← 滑动窗口记忆(默认保留最近 20 条)
* ↓ 存储
* ChatMemoryRepository (InMemoryChatMemoryRepository) ← ConcurrentHashMap 存储
*
* Spring AI 1.0.6 的架构变化:
* - 旧版 InMemoryChatMemory 被拆分为两个角色:
* ChatMemory记忆策略+ ChatMemoryRepository存储实现
* - MessageChatMemoryAdvisor 构造函数私有化,必须用 Builder 创建
* - 会话 ID 常量从 AbstractChatMemoryAdvisor 迁移到 ChatMemory 接口
*/
@Configuration
public class AIConfig {
/**
* 对话记忆存储(内存实现,重启丢失)。
*
* InMemoryChatMemoryRepository = ConcurrentHashMap 包装
* MessageWindowChatMemory = 滑动窗口策略(只保留最近 N 条消息)
*
* 生产环境可替换为 JdbcChatMemoryRepository 实现持久化。
*/
@Bean
public ChatMemory chatMemory() {
var repository = new InMemoryChatMemoryRepository();
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(20) // 每个会话最多保留 20 条历史消息
.build();
}
/**
* ChatClient —— Spring AI 的推荐入口
*
* 构建步骤:
* 1. ChatClient.builder(chatModel) —— 绑定底层的 ChatModel 实现
* 2. .defaultSystem(...) —— 设置系统级角色 Prompt
* 3. .defaultAdvisors(...) —— 注册 Advisor 拦截器链(如记忆管理)
* 4. .build() —— 创建不可变实例
*
* 关键点说明:
* - ChatClient 是线程安全的,整个应用共用一个单例
* - .defaultSystem() 对所有通过此 ChatClient 的请求生效
* - MessageChatMemoryAdvisor 会在每次请求前自动注入历史消息
* - 单次请求可通过 .system() 覆盖默认 System Prompt
*/
@Bean
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultSystem("""
你是一个友好的 AI 助手,名字叫"小智"
请用中文回答用户的问题。
如果你不知道答案,诚实地告诉用户,不要编造信息。
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
}
}

View File

@@ -0,0 +1,93 @@
package com.learn.controller;
import com.learn.dto.ApiResponse;
import com.learn.dto.ChatRequest;
import com.learn.service.ChatService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Map;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
// ==================== Day 5: 同步对话 ====================
/**
* 同步聊天 —— 发送完整消息,等待完整回复。
*
* 请求: POST /api/chat
* Body: {"message": "你好", "conversationId": "会话ID可选"}
* 响应: {"code": 200, "data": {"reply": "你好!有什么可以帮你的?", "conversationId": "xxx"}}
*/
@PostMapping
public ApiResponse<Map<String, String>> chat(@Valid @RequestBody ChatRequest request) {
String conversationId = request.getConversationId() != null
? request.getConversationId() : "default";
String reply = chatService.chat(request.getMessage(), conversationId);
return ApiResponse.success(Map.of(
"reply", reply,
"conversationId", conversationId
));
}
// ==================== Day 6: 流式对话 (SSE) ====================
/**
* 流式聊天 —— 逐 token 返回,打字机效果。
*
* 请求: POST /api/chat/stream
* Body: 同 /api/chat
* 响应: text/event-stream
* data: 你
* data: 好
* data:
* event: done
* data: [DONE]
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
return chatService.chatStream(request.getMessage(), request.getConversationId())
.map(token -> ServerSentEvent.<String>builder()
.data(token)
.build())
.concatWith(Mono.just(ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()));
}
// ==================== Day 7: 历史管理 ====================
/**
* 查询某个会话的历史消息。
*
* GET /api/chat/history?conversationId=xxx
*/
@GetMapping("/history")
public ApiResponse<?> getHistory(@RequestParam(defaultValue = "default") String conversationId) {
return ApiResponse.success(chatService.getHistory(conversationId));
}
/**
* 清除某个会话的历史。
*
* DELETE /api/chat/history?conversationId=xxx
*/
@DeleteMapping("/history")
public ApiResponse<?> clearHistory(@RequestParam(defaultValue = "default") String conversationId) {
chatService.clearHistory(conversationId);
return ApiResponse.success("已清除会话: " + conversationId);
}
}

View File

@@ -0,0 +1,61 @@
package com.learn.dto;
import java.util.HashMap;
import java.util.Map;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
private Map<String, Object> extra;
public ApiResponse() {}
public ApiResponse(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(200, message, data);
}
public static <T> ApiResponse<T> created(T data) {
return new ApiResponse<>(201, "created", data);
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
public static <T> ApiResponse<T> notFound(String message) {
return new ApiResponse<>(404, message, null);
}
public static <T> ApiResponse<T> badRequest(String message) {
return new ApiResponse<>(400, message, null);
}
public ApiResponse<T> put(String key, Object value) {
if (this.extra == null) {
this.extra = new HashMap<>();
}
this.extra.put(key, value);
return this;
}
public int getCode() { return code; }
public void setCode(int code) { this.code = code; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public T getData() { return data; }
public void setData(T data) { this.data = data; }
public Map<String, Object> getExtra() { return extra; }
public void setExtra(Map<String, Object> extra) { this.extra = extra; }
}

View File

@@ -0,0 +1,18 @@
package com.learn.dto;
import java.util.List;
import java.util.Map;
public class ChatHistoryVo {
private String conversationId;
private int messageCount;
private List<Map<String, Object>> messages;
public String getConversationId() { return conversationId; }
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
public int getMessageCount() { return messageCount; }
public void setMessageCount(int messageCount) { this.messageCount = messageCount; }
public List<Map<String, Object>> getMessages() { return messages; }
public void setMessages(List<Map<String, Object>> messages) { this.messages = messages; }
}

View File

@@ -0,0 +1,16 @@
package com.learn.dto;
import jakarta.validation.constraints.NotBlank;
public class ChatRequest {
@NotBlank(message = "消息不能为空")
private String message;
private String conversationId;
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getConversationId() { return conversationId; }
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
}

View File

@@ -0,0 +1,39 @@
package com.learn.exception;
import com.learn.dto.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
var errors = new StringBuilder();
for (var error : ex.getBindingResult().getFieldErrors()) {
if (!errors.isEmpty()) errors.append("; ");
errors.append(error.getField()).append(": ").append(error.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest("参数校验失败: " + errors));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<?>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(500)
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
}
}

View File

@@ -0,0 +1,118 @@
package com.learn.service;
import com.learn.dto.ChatHistoryVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
@Service
public class ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private final ChatClient chatClient;
private final ChatMemory chatMemory;
public ChatService(ChatClient chatClient, ChatMemory chatMemory) {
this.chatClient = chatClient;
this.chatMemory = chatMemory;
}
// ==================== Day 5: 同步对话 ====================
/**
* 一问一答,等待完整回复后返回。
*/
public String chat(String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
// ==================== Day 6: 流式对话 ====================
/**
* 流式对话,逐 token 返回,实现"打字机效果"。
*
* @param message 用户消息
* @param conversationId 会话 ID默认 "default"
* @return Flux<String> 逐 token 流
*/
public Flux<String> chatStream(String message, String conversationId) {
String convId = defaultIfNull(conversationId);
log.debug("Streaming chat for conversation: {}", convId);
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(CONVERSATION_ID, convId))
.stream()
.content();
}
// ==================== Day 7: 多轮对话 ====================
/**
* 带上下文的多轮对话(非流式版)。
*/
public String chat(String message, String conversationId) {
String convId = defaultIfNull(conversationId);
log.debug("Chat with memory, conversation: {}", convId);
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(CONVERSATION_ID, convId))
.call()
.content();
}
/**
* 查询某个会话的历史消息。
*/
public ChatHistoryVo getHistory(String conversationId) {
String convId = defaultIfNull(conversationId);
var messages = chatMemory.get(convId);
var list = new ArrayList<Map<String, Object>>();
for (var msg : messages) {
var item = new HashMap<String, Object>();
item.put("role", msg.getMessageType().name().toLowerCase());
item.put("content", msg.getText());
list.add(item);
}
ChatHistoryVo vo = new ChatHistoryVo();
vo.setConversationId(convId);
vo.setMessageCount(list.size());
vo.setMessages(list);
return vo;
}
/**
* 清除某个会话的所有历史。
*/
public void clearHistory(String conversationId) {
String convId = defaultIfNull(conversationId);
chatMemory.clear(convId);
log.debug("Cleared history for conversation: {}", convId);
}
/**
* 获取所有会话 ID 列表。
*/
public List<String> listConversations() {
// InMemoryChatMemory 没有直接列出所有 key 的方法
// 这里用一个简化方案:客户端自行管理会话 ID
return List.of();
}
private String defaultIfNull(String id) {
return (id == null || id.isBlank()) ? "default" : id;
}
}

View File

@@ -0,0 +1,38 @@
spring:
application:
name: week9-ai-chat
# =============================================================
# AI Provider Configuration (Spring AI)
# =============================================================
# 更换模型只需改两个地方:
# 1. base-url → 服务商的 API 地址
# 2. model → 模型名称
#
# 国内推荐 DeepSeek (注册送免费额度):
# 注册地址: https://platform.deepseek.com
# API Key 获取: 登录后 → API Keys → 创建
#
# 备选: 阿里云百炼 Qwen (通义千问)
# 注册地址: https://bailian.console.aliyun.com
#
# 本地运行 (无需网络, 无需 API Key):
# 安装 Ollama: https://ollama.com
# 拉取模型: ollama pull qwen2.5:7b
# =============================================================
ai:
openai:
api-key: ${AI_API_KEY:sk-fe4d2f26eda04a659b9ff3408ce5024b}
base-url: ${AI_BASE_URL:https://api.deepseek.com}
chat:
options:
model: deepseek-chat
temperature: 0.7
server:
port: 8080
logging:
level:
com.learn: DEBUG
org.springframework.ai.chat.client.advisor: DEBUG

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 聊天机器人 - Week 9</title>
<link rel="stylesheet" href="/css/chat.css">
</head>
<body>
<div class="app">
<!-- 侧边栏Day 7 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<h2>对话列表</h2>
</div>
<button class="btn-new-chat" onclick="newConversation()">+ 新建对话</button>
<div class="conversation-list" id="conversation-list">
<div class="conv-item active" data-id="default" onclick="switchConversation('default')">
<span class="conv-title">默认对话</span>
<button class="conv-delete" onclick="event.stopPropagation(); deleteConversation('default')" title="删除">×</button>
</div>
</div>
<div class="sidebar-footer">
<select id="model-select" onchange="onModelChange()">
<option value="deepseek-chat">DeepSeek-Chat</option>
<option value="gpt-4o-mini">GPT-4o-mini</option>
<option value="qwen-turbo">Qwen-Turbo</option>
</select>
</div>
</aside>
<!-- 主聊天区 -->
<main class="chat-area">
<header class="chat-header">
<button class="btn-toggle-sidebar" onclick="toggleSidebar()" title="切换侧边栏"></button>
<h1>AI 聊天机器人</h1>
<span class="subtitle">Week 9 — Spring AI</span>
</header>
<div class="messages" id="messages">
<div class="welcome-msg">
<div class="welcome-icon">🤖</div>
<h3>你好!我是小智</h3>
<p>我是你的 AI 助手,有什么可以帮你的?</p>
</div>
</div>
<div class="input-area">
<div class="input-toolbar">
<label class="toggle-label">
<input type="checkbox" id="stream-mode" checked>
<span>流式输出</span>
</label>
<button class="btn-clear" onclick="clearCurrentHistory()">清除历史</button>
</div>
<div class="input-row">
<textarea id="user-input"
placeholder="输入消息,按 Enter 发送Shift+Enter 换行..."
rows="1"
onkeydown="handleKeyDown(event)"></textarea>
<button class="btn-send" id="send-btn" onclick="sendMessage()">发送</button>
</div>
</div>
</main>
</div>
<script src="/js/chat.js"></script>
</body>
</html>

View File

@@ -0,0 +1,277 @@
/* ============================================================
Week 9 Chat UI — AI 聊天机器人样式
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f0f2f5;
height: 100vh;
overflow: hidden;
}
/* ---- Layout ---- */
.app {
display: flex;
height: 100vh;
}
/* ---- Sidebar ---- */
.sidebar {
width: 260px;
background: #1e293b;
color: #cbd5e1;
display: flex;
flex-direction: column;
transition: transform 0.3s;
}
.sidebar.collapsed {
transform: translateX(-260px);
position: absolute;
z-index: 100;
height: 100%;
}
.sidebar-header {
padding: 20px 16px 12px;
border-bottom: 1px solid #334155;
}
.sidebar-header h2 { font-size: 16px; color: #f1f5f9; }
.btn-new-chat {
margin: 12px 16px;
padding: 10px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-new-chat:hover { background: #2563eb; }
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
}
.conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
margin: 2px 0;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
.conv-item:hover { background: #334155; }
.conv-item.active { background: #3b82f6; color: #fff; }
.conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-delete {
background: none;
border: none;
color: inherit;
font-size: 18px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
padding: 0 4px;
}
.conv-item:hover .conv-delete { opacity: 0.6; }
.conv-delete:hover { opacity: 1 !important; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #334155; }
.sidebar-footer select {
width: 100%;
padding: 8px;
background: #334155;
color: #f1f5f9;
border: 1px solid #475569;
border-radius: 6px;
font-size: 13px;
}
/* ---- Chat Area ---- */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
min-width: 0;
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #e5e7eb;
background: #fafbfc;
}
.chat-header h1 { font-size: 18px; color: #1e293b; }
.chat-header .subtitle { font-size: 13px; color: #94a3b8; }
.btn-toggle-sidebar {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
color: #64748b;
}
.btn-toggle-sidebar:hover { background: #e2e8f0; }
/* ---- Messages ---- */
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.welcome-msg {
text-align: center;
margin: auto;
color: #94a3b8;
}
.welcome-icon { font-size: 48px; margin-bottom: 12px; }
.welcome-msg h3 { font-size: 20px; color: #475569; margin-bottom: 6px; }
.welcome-msg p { font-size: 14px; }
/* ---- Message Bubbles ---- */
.msg {
display: flex;
gap: 10px;
max-width: 80%;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.msg .avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.msg.assistant .avatar { background: #e0e7ff; }
.msg.user .avatar { background: #dbeafe; }
.msg .bubble {
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.msg.assistant .bubble {
background: #f1f5f9;
color: #1e293b;
border-bottom-left-radius: 4px;
}
.msg.user .bubble {
background: #3b82f6;
color: #fff;
border-bottom-right-radius: 4px;
}
/* Streaming cursor */
.msg.streaming .bubble::after {
content: '▊';
animation: blink 1s infinite;
color: #3b82f6;
}
.msg.streaming.user .bubble::after { color: #fff; }
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ---- Input Area ---- */
.input-area {
padding: 12px 20px 16px;
border-top: 1px solid #e5e7eb;
background: #fafbfc;
}
.input-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #64748b;
cursor: pointer;
}
.btn-clear {
background: none;
border: none;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
padding: 4px 0;
}
.btn-clear:hover { color: #ef4444; }
.input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
#user-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: border-color 0.2s;
max-height: 120px;
}
#user-input:focus { border-color: #3b82f6; }
.btn-send {
padding: 10px 20px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.btn-send:hover { background: #2563eb; }
.btn-send:disabled {
background: #93c5fd;
cursor: not-allowed;
}
/* ---- Responsive ---- */
@media (max-width: 640px) {
.sidebar { display: none; }
.msg { max-width: 90%; }
}

View File

@@ -0,0 +1,272 @@
/**
* Week 9 Chat JS — AI 聊天机器人前端逻辑
*
* 涵盖 Day 5 (同步), Day 6 (SSE 流式), Day 7 (多轮对话管理)
*/
// ---- 全局状态 ----
let currentConversationId = 'default';
const conversations = new Map(); // id -> { title, lastTime }
conversations.set('default', { title: '默认对话', lastTime: Date.now() });
// ---- DOM 引用 ----
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const streamToggle = document.getElementById('stream-mode');
const convListEl = document.getElementById('conversation-list');
// ---- 工具函数 ----
function generateId() {
return 'conv_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ---- UI 渲染 ----
function removeWelcome() {
const welcome = messagesEl.querySelector('.welcome-msg');
if (welcome) welcome.remove();
}
function createMsgElement(role) {
const div = document.createElement('div');
div.className = 'msg ' + role;
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = role === 'user' ? '👤' : '🤖';
const bubble = document.createElement('div');
bubble.className = 'bubble';
div.appendChild(avatar);
div.appendChild(bubble);
messagesEl.appendChild(div);
return div;
}
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ---- Day 5: 同步发送 ----
async function sendSyncMessage(message) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, conversationId: currentConversationId })
});
const result = await response.json();
if (result.code !== 200) {
throw new Error(result.message || '请求失败');
}
return result.data.reply;
}
// ---- Day 6: SSE 流式发送 ----
async function sendStreamMessage(message) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, conversationId: currentConversationId })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || '流式请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
return {
async *[Symbol.asyncIterator]() {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') return;
yield data;
}
}
}
};
}
// ---- 主发送流程 ----
async function sendMessage() {
const message = inputEl.value.trim();
if (!message) return;
// 禁用输入
inputEl.value = '';
inputEl.style.height = 'auto';
sendBtn.disabled = true;
inputEl.disabled = true;
removeWelcome();
// 渲染用户消息
const userMsgEl = createMsgElement('user');
userMsgEl.querySelector('.bubble').textContent = message;
scrollToBottom();
// 渲染 AI 消息占位
const aiMsgEl = createMsgElement('assistant');
const aiBubble = aiMsgEl.querySelector('.bubble');
try {
if (streamToggle.checked) {
// ---- 流式模式 ----
aiMsgEl.classList.add('streaming');
aiBubble.textContent = '';
const stream = await sendStreamMessage(message);
for await (const token of stream) {
aiBubble.textContent += token;
scrollToBottom();
}
aiMsgEl.classList.remove('streaming');
} else {
// ---- 同步模式 ----
aiBubble.textContent = '思考中...';
const reply = await sendSyncMessage(message);
aiBubble.textContent = reply;
scrollToBottom();
}
// 更新会话信息
const conv = conversations.get(currentConversationId);
if (conv) {
conv.lastTime = Date.now();
if (conv.title === '默认对话' || conv.title === '新对话') {
conv.title = message.substring(0, 20) + (message.length > 20 ? '...' : '');
}
}
renderConversationList();
} catch (error) {
aiMsgEl.classList.remove('streaming');
aiBubble.textContent = '错误: ' + error.message;
aiBubble.style.color = '#ef4444';
console.error('Chat error:', error);
} finally {
sendBtn.disabled = false;
inputEl.disabled = false;
inputEl.focus();
}
}
// ---- 键盘处理 ----
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
// 自动调整 textarea 高度
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
});
// ---- Day 7: 会话管理 ----
function switchConversation(id) {
currentConversationId = id;
messagesEl.innerHTML = `
<div class="welcome-msg">
<div class="welcome-icon">🤖</div>
<h3>会话已切换</h3>
<p>当前会话: ${escapeHtml(conversations.get(id)?.title || id)}</p>
</div>`;
renderConversationList();
}
function newConversation() {
const id = generateId();
conversations.set(id, { title: '新对话', lastTime: Date.now() });
currentConversationId = id;
messagesEl.innerHTML = `
<div class="welcome-msg">
<div class="welcome-icon">💬</div>
<h3>新对话已创建</h3>
<p>开始和 AI 聊天吧!</p>
</div>`;
renderConversationList();
}
async function deleteConversation(id) {
if (id === 'default') return;
conversations.delete(id);
try {
await fetch(`/api/chat/history?conversationId=${id}`, { method: 'DELETE' });
} catch (e) { /* ignore */ }
if (currentConversationId === id) {
currentConversationId = 'default';
switchConversation('default');
}
renderConversationList();
}
async function clearCurrentHistory() {
if (!confirm('确定清除当前会话的所有历史消息?')) return;
try {
await fetch(`/api/chat/history?conversationId=${currentConversationId}`, { method: 'DELETE' });
} catch (e) { /* ignore */ }
messagesEl.innerHTML = `
<div class="welcome-msg">
<div class="welcome-icon">🧹</div>
<h3>历史已清除</h3>
<p>当前会话的记忆已清空,开始新的对话吧!</p>
</div>`;
}
function onModelChange() {
alert('模型切换需要在 application.yml 中修改 spring.ai.openai.chat.options.model 并重启应用。\n此处仅作演示。');
}
function renderConversationList() {
convListEl.innerHTML = '';
const sorted = [...conversations.entries()]
.sort((a, b) => b[1].lastTime - a[1].lastTime);
for (const [id, conv] of sorted) {
const item = document.createElement('div');
item.className = 'conv-item' + (id === currentConversationId ? ' active' : '');
item.setAttribute('data-id', id);
item.onclick = () => switchConversation(id);
item.innerHTML = `
<span class="conv-title">${escapeHtml(conv.title)}</span>
${id !== 'default' ? `<button class="conv-delete" title="删除">×</button>` : ''}`;
const delBtn = item.querySelector('.conv-delete');
if (delBtn) {
delBtn.onclick = (e) => {
e.stopPropagation();
deleteConversation(id);
};
}
convListEl.appendChild(item);
}
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
// ---- 初始化 ----
renderConversationList();
inputEl.focus();

626
week9/教案.md Normal file
View File

@@ -0,0 +1,626 @@
# Week 9AI & LLM 基础 —— 从概念到聊天机器人
> **学习周期**7 天
> **每日用时**2-3 小时
> **最终产出**:一个支持流式输出和多轮对话的 AI 聊天机器人 API + 前端
---
## 前置准备
| 事项 | 说明 |
|------|------|
| 注册 DeepSeek | [platform.deepseek.com](https://platform.deepseek.com) → API Keys → 创建(新用户送免费额度) |
| 备选:阿里云百炼 | [bailian.console.aliyun.com](https://bailian.console.aliyun.com) → 开通 Qwen 模型 |
| 安装 Postman | API 调试用Day 3 |
| IDEA 打开项目 | File → Open → 选择 `week9/pom.xml` |
> **关于 API Key 的费用**DeepSeek 注册送免费额度(约 10 元Qwen 有免费额度。本周末用量极少,费用 < 1 元。如果你已经有 OpenAI / DeepSeek / Qwen 的 Key直接复用即可。
---
## 启动方式
```bash
# 1. 修改 application.yml 中的 api-key
# 2. IDEA 中运行 Week9Application.main()
# 3. 浏览器访问 http://localhost:8080/chat.html
```
---
## Day 1AI/ML 基本概念、LLM 是什么
### 核心概念
| 术语 | 白话解释 |
|------|---------|
| **AI**(人工智能) | 让机器模拟人类智能的广义概念 |
| **ML**(机器学习) | 让机器从数据中学习规律,而不是人工写规则 |
| **DL**(深度学习) | 用多层神经网络学习,是 ML 的一个子集 |
| **LLM**(大语言模型) | 用海量文本数据训练的超大规模神经网络,专门理解和生成语言 |
| **NLP**(自然语言处理) | 让机器理解和使用人类语言的技术领域 |
### LLM 是怎么工作的?(简化理解)
```
输入文字 → Tokenizer 切分为 Token → Transformer 模型逐 token 预测 → 输出文字
"你好,世界" → [你, 好, , 世界] → 预测下一个 Token → ""
```
关键概念:
1. **Token**:模型处理的最小文本单位。中文约 1-2 个汉字 = 1 个 Token英文约 1 个单词 = 1-2 个 Token
2. **Transformer**2017 年提出的神经网络架构,几乎所有现代 LLM 都基于它
3. **自回归生成**:逐 token 预测下一个 token每次预测都基于之前已生成的所有 token
4. **训练 vs 推理**:训练 = 用海量数据调整模型参数(耗时数月、烧钱数千万);推理 = 用训练好的模型回答问题(秒级、按 Token 计费)
### 主流模型对比2026 年)
| 模型 | 开发公司 | 特点 | 免费额度 |
|------|---------|------|---------|
| GPT-4o / GPT-4o-mini | OpenAI | 综合能力强,多模态 | 少量 |
| Claude 3.5 / Claude 4 | Anthropic | 安全、长上下文 | 少量 |
| DeepSeek-V3 / DeepSeek-R1 | 深度求索 | 性价比高,中文优秀 | 注册送 10 元 |
| Qwen3.5(通义千问) | 阿里云 | 中文理解好,生态丰富 | 百万 Token 免费 |
| GLM-4智谱清言 | 智谱 AI | 国产自主,多模态 | 注册送额度 |
| Llama 4 | Meta | 开源,可本地部署 | 完全免费 |
### 动手 → 理解
1. 打开 [DeepSeek 网页版](https://chat.deepseek.com) 或 ChatGPT问 5 个完全不同领域的问题,观察回答风格差异
2. 打开 [OpenAI Tokenizer](https://platform.openai.com/tokenizer),输入中英文混合文本,观察 Token 是如何切分的
3. 对比至少 2 个模型的免费版,用同一个问题提问,记录差异
### 思考题
> 为什么同样的问题,不同模型给出的答案差异很大?这和"用了哪些数据训练"有什么关系?
---
## Day 2Prompt Engineering 基础
### 什么是 Prompt
Prompt 是你给模型的指令。好的 Prompt 和差的 Prompt回答质量天差地别。
### Prompt 五大要素
| 要素 | 说明 | 示例 |
|------|------|------|
| **角色Role** | 让 AI 扮演谁 | "你是一个资深的 Java 架构师" |
| **任务Task** | 要做什么 | "请审查以下代码的安全漏洞" |
| **格式Format** | 输出什么格式 | "请用 JSON 格式返回" |
| **约束Constraint** | 有什么限制 | "答案不超过 200 字" |
| **示例Few-shot** | 给一两个例子 | "示例输入: xxx → 示例输出: yyy" |
### Prompt 技巧速查
| 技巧 | 说明 | 适用场景 |
|------|------|---------|
| **Zero-shot** | 不给示例,直接提问 | 简单任务 |
| **Few-shot** | 给 2-3 个示例 | 格式要求严格 |
| **Chain of Thought** | 加一句"让我们一步步思考" | 推理/数学题 |
| **角色扮演** | 设定专业角色 | 专业领域问答 |
| **思维树** | 探索多个分支再综合 | 复杂决策 |
| **反问澄清** | "如果不确定,请先提问澄清" | 需求不明确时 |
### 好 Prompt vs 差 Prompt
```
❌ 差 Prompt: "写点代码"
→ AI 不知道你要什么语言、什么功能、什么风格,输出完全随机
✅ 好 Prompt: "你是一个 Java 后端开发者。请用 Spring Boot 3.x 写一个 RESTful 接口,
实现用户注册功能。要求: (1)包含参数校验 (2)密码用 BCrypt 加密 (3)返回 JWT Token。
请给出完整的 Controller、Service、Config 代码。"
→ AI 明确知道要什么,输出精准可用
```
### 动手 → 理解
1. 用"解释 Spring Boot 的自动配置"这个任务,分别写一个差 Prompt 和好 Prompt对比回答质量
2. 设计一个角色扮演 Prompt让 AI 扮演"Java 面试官",向你提问 10 个 Spring 面试题
3. 用 Few-shot 让 AI 按照固定的 JSON 格式回答(给 2 个示例)
4. 尝试 Chain of Thought在 Prompt 末尾加"请逐步思考后给出答案",观察推理过程的变化
5. 故意写一个模糊 Prompt如"写点代码"),然后逐步添加约束,观察回答如何变化
### 思考题
> 如果用户在前端输入"写一个学生管理系统",我们如何通过后端的 System Prompt 自动给这个请求加上角色设定和技术约束?这在 Day 4-5 的代码中会体现。
---
## Day 3LLM API 调用方式
### Chat Completions API 结构
几乎所有大模型都兼容 OpenAI 的 API 格式。一次调用的 HTTP 请求长这样:
```
POST https://api.deepseek.com/v1/chat/completions
Authorization: Bearer sk-xxxxxxxx
Content-Type: application/json
{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个有用的助手"},
{"role": "user", "content": "你好1+1等于几"}
],
"temperature": 0.7,
"max_tokens": 1000
}
```
### 关键参数说明
| 参数 | 说明 | 典型值 |
|------|------|--------|
| `model` | 模型名称 | `deepseek-chat`, `gpt-4o-mini`, `qwen-turbo` |
| `messages` | 对话消息数组 | system / user / assistant 三种角色 |
| `temperature` | 随机性0=确定, 1=创意) | 代码生成 0.1,聊天 0.7,写作 0.9 |
| `max_tokens` | 最大输出 Token 数 | 视场景而定 |
| `stream` | 是否流式输出 | `false`(默认), `true`(逐 token 返回) |
### messages 中的三种角色
```
system: 设定 AI 的行为和角色("你是一个 Java 专家"
user: 用户说的话
assistant: AI 之前回复的话(用于多轮对话的上下文)
```
### 动手 → 理解
1. 注册 DeepSeek 获取 API Key免费
2. 用 Postman 发送你的第一个 API 请求:
- Method: `POST`
- URL: `https://api.deepseek.com/v1/chat/completions`
- Headers: `Authorization: Bearer <你的API Key>`, `Content-Type: application/json`
- Bodyraw JSON:
```json
{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "你是一个 JSON 生成器,所有回答必须是合法的 JSON 格式"},
{"role": "user", "content": "请用 JSON 格式返回你的名称、版本和擅长的编程语言"}
],
"temperature": 0.0
}
```
3. 修改 `temperature` 为 1.5,问同一个问题 3 次,对比回答的差异
4. 在 messages 中手动添加 assistant 回复 + 新的 user 消息,实现"手动多轮对话"
5. 故意传错误的 API Key观察 401 错误响应的 JSON 结构
### 常见 API 错误码
| 状态码 | 含义 | 处理方法 |
|--------|------|---------|
| 200 | 成功 | 从 `choices[0].message.content` 取回复 |
| 401 | API Key 无效 | 检查 Key 是否正确/过期 |
| 429 | 请求频率超限 | 等几秒重试,或升级套餐 |
| 500 | 服务器错误 | 稍后重试 |
### 思考题
> 看 Postman 返回的完整 JSON。`choices[0].message.content` 和 `choices[0].delta.content` 有什么区别?(提示:后者是流式模式用的,引出 Day 6
---
## Day 4Spring AI 框架入门
### Spring AI 是什么?
Spring AI 是 Spring 生态的 AI 集成框架。它做了一件和 Spring MVC / Spring Data 类似的事:**提供统一抽象,屏蔽底层差异**。
```
你的代码
↓ 调用
ChatClientSpring AI 统一 APIFluent 风格)
↓ 委托
OpenAiChatModelOpenAI 协议实现)
↓ HTTP
任何 OpenAI 兼容的 APIOpenAI / DeepSeek / Qwen / GLM / Ollama …)
```
### 和传统 Spring 模块的类比
| 传统 Spring | Spring AI | 说明 |
|-------------|-----------|------|
| `JdbcTemplate` | `ChatClient` | 高级封装,简化调用 |
| `DataSource` | `ChatModel` | 底层连接,由配置自动创建 |
| `TransactionManager` | `Advisor` | 拦截器,横切关注点 |
| Redis / JPA Repository | `ChatMemory` | 数据持久化抽象 |
### 两个核心接口
| 接口 | 层级 | 用法 |
|------|------|------|
| `ChatModel` | 底层 | `.call(prompt)` — 发送 Prompt返回完整 ChatResponse |
| `ChatClient` | 高层 | `.prompt().user(msg).call().content()` — Fluent API |
**推荐使用 ChatClient**。它是线程安全的整个应用共用一个实例。ChatModel 由 `spring.ai.openai.*` 配置自动创建OpenAiChatModel我们不需要手动创建它。
### 项目依赖的三个关键部分
1. **BOM**`spring-ai-bom:1.0.6` 统一管理所有 AI 依赖的版本
2. **Starter**`spring-ai-starter-model-openai` 自动配置 ChatModel
3. **WebFlux**`spring-boot-starter-webflux` 提供 `Flux` 类型支持 SSE 流式Day 6
### 动手 → 理解
1. 用 IDEA 打开 `week9/pom.xml`,观察 BOM 和依赖的结构
2. 打开 `application.yml`
- 把 `api-key` 改成你的实际 Key
- 如果用的不是 DeepSeek修改 `base-url` 和 `model`
3. 打开 `AIConfig.java`,逐行理解三个 Bean 的创建:
- `ChatMemory`:对话记忆的存储(内存实现,重启丢失)
- `ChatClient`:包装 ChatModel设置 System Prompt 和 Advisor
4. 运行 `Week9Application.main()`,观察启动日志:
- `OpenAiApi` 初始化 → 确认 base-url 正确
- `ChatClient` 创建 → 确认 Bean 注入成功
5. 在 `Week9Application` 中临时添加一个 `CommandLineRunner` 测试:
```java
@Bean
CommandLineRunner test(ChatClient chatClient) {
return args -> {
String reply = chatClient.prompt().user("用一句话介绍 Spring AI").call().content();
System.out.println("AI: " + reply);
};
}
```
跑通后删掉,这只是验证配置是否正确。
### 核心文件
| 文件 | 作用 |
|------|------|
| `pom.xml` | Maven 依赖BOM 管理 |
| `application.yml` | AI 服务商配置 |
| `AIConfig.java` | 手动装配 ChatClient + ChatMemory |
| `ApiResponse.java` | 统一响应格式(复用 Week 8 模式) |
| `GlobalExceptionHandler.java` | 异常拦截 |
### 思考题
> 为什么要用 `ChatClient` 包装 `ChatModel`,而不是直接调用 `ChatModel.call()`提示Advisor 机制(拦截器链)让你想到了 Spring 的什么特性?
---
## Day 5对话接口实现 / Chat Completion
### 最简单的 AI 调用
```java
// 在 Service 中
String reply = chatClient.prompt()
.user("你好1+1等于几")
.call() // 发送请求,等待完整回复
.content(); // 提取文本内容
```
这就是整个 Day 5 的核心。对比 Day 3 用 Postman 发请求的复杂度Spring AI 把它简化到了一行链式调用。
### ChatClient 的 Fluent API 流程
```
chatClient
.prompt() → 开始构建请求
.system("你是一个 xxx") → 覆盖默认的 System Prompt可选
.user(message) → 设置用户消息
.advisors(a -> a.param(...)) → 传递 Advisor 参数Day 7 用)
.call() → 发送同步请求
.content() → 获取文本回复
.chatResponse() → 获取完整响应(含 Token 用量等元数据)
.entity(MyClass.class) → 结构化输出(让 AI 返回 JSON → 自动解析为对象)
```
### System Prompt 的作用
打开 `AIConfig.java`,看 `.defaultSystem(...)`
```java
.defaultSystem("""
你是一个友好的 AI 助手,名字叫"小智"。
请用中文回答用户的问题。
如果你不知道答案,诚实地告诉用户,不要编造信息。
""")
```
`defaultSystem` 对整个 ChatClient 实例生效(全局默认)。如果想在单次请求中覆盖,可以用 `.system(...)`。
### 动手 → 理解
1. 阅读 `ChatService.java` 的 `chat()` 方法(不到 5 行代码)
2. 阅读 `ChatController.java` 的 `POST /api/chat` 端点
3. 阅读 `ChatRequest.java` DTO — 只有 message + conversationId 两个字段
4. 启动项目,浏览器访问 `http://localhost:8080/chat.html`
- 输入"你好,介绍一下你自己"→ 观察 System Prompt 是否生效
- 输入"用 Java 写一个 Hello World"→ 观察回答
- 关掉"流式输出"复选框,对比流式和非流式的体验
5. 修改 `AIConfig.java` 的 System Prompt
- 把角色改成"你是一个只会用文言文回答的助手"
- 重启,验证效果
### 核心文件
| 文件 | 新增内容 |
|------|---------|
| `ChatRequest.java` | 入参 DTO |
| `ChatService.java` | `chat(String message)` 方法 |
| `ChatController.java` | `POST /api/chat` |
| `static/chat.html` | 聊天界面骨架 |
| `static/css/chat.css` | 聊天气泡样式 |
| `static/js/chat.js` | `sendSyncMessage()` 同步请求 |
### 思考题
> 如果用户输入空字符串,`@NotBlank` 能拦截吗?如果用户输入" "(只有空格),会怎样?动手测试验证。
---
## Day 6Streaming 流式响应 / SSE
### 为什么需要流式?
非流式:等 AI 完整生成回复 → 一次性返回 → 用户等着
流式AI 生成一个 Token → 立即返回一个 Token → 用户看到打字机效果
对于长回复,流式可以把"首次响应时间"从 30 秒降到 0.5 秒。
### SSEServer-Sent Events协议
SSE 是 HTTP 标准的一部分,专门用于服务器向客户端推送事件流。
```
HTTP Response Headers:
Content-Type: text/event-stream
HTTP Response Body:
data: 你
data: 好
data:
data: 我
data: 是
...
event: done
data: [DONE]
```
### Spring AI 的流式 API
```java
// 返回 Flux<String> —— 每个元素是一个 Token
public Flux<String> chatStream(String message, String conversationId) {
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.stream() // ← 换成 .stream()
.content(); // 返回 Flux<String> 而不是 String
}
```
**对比**
| | 同步 | 流式 |
|------|------|------|
| 方法 | `.call().content()` | `.stream().content()` |
| 返回类型 | `String` | `Flux<String>` |
| 响应时机 | 全部生成完才返回 | 生成一个 token 返回一个 |
| 用户体验 | 等待 | 打字机效果 |
### Controller 中的 SSE 端点
```java
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
return chatService.chatStream(...)
.map(token -> ServerSentEvent.<String>builder().data(token).build())
.concatWith(Mono.just(ServerSentEvent.<String>builder()
.event("done").data("[DONE]").build()));
}
```
要点:
- `produces = TEXT_EVENT_STREAM_VALUE` 告诉浏览器这是 SSE 流
- `ServerSentEvent` 封装 SSE 格式
- `[DONE]` 信号通知前端流结束
### 前端 ReadableStream 解析
```javascript
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, conversationId })
});
const reader = response.body.getReader(); // 获取流读取器
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析 SSE 格式: "data: xxx\n"
// 逐 token 追加到气泡中
}
```
### 动手 → 理解
1. 阅读 `ChatService.java` 的 `chatStream()` 方法
2. 阅读 `ChatController.java` 的 `POST /api/chat/stream` 端点
3. 阅读 `chat.js` 的 `sendStreamMessage()` 函数
4. 在浏览器中勾选"流式输出",发送消息,观察打字机效果
5. 打开 F12 → Network → 找到 `/api/chat/stream` 请求 → 点击 → EventStream 标签,观察每个 SSE 事件
6. 把浏览器的网速调慢F12 → Network → Throttling → Slow 3G观察流式输出的差异更明显
### 核心文件
| 文件 | 变更 |
|------|------|
| `ChatService.java` | 新增 `chatStream()` 方法 |
| `ChatController.java` | 新增 `POST /api/chat/stream` |
| `chat.js` | 新增 `sendStreamMessage()` + `ReadableStream` |
### 思考题
> 如果流式响应中途网络断了(比如 Wi-Fi 被关掉),前端应该怎么处理?动手修改 `chat.js`,在 catch 块中显示"连接中断,请重试"。另外,`EventSource` API 和 `fetch + ReadableStream` 有什么区别?为什么聊天应用推荐用 fetch
---
## Day 7上下文管理与多轮对话
### 核心问题AI 是无状态的
```
第 1 轮: 用户: "我叫小明" AI: "好的小明!"
第 2 轮: 用户: "我刚才说我叫什么?" AI: "???我不知道"
```
每次 API 请求是独立的。AI 不记得上一轮说了什么,除非你**把历史消息重新传给它**。
### Spring AI 的 ChatMemory 机制
```
┌──────────────────────────────────────────┐
│ 用户: "我叫小明" │
│ ↓ │
│ MessageChatMemoryAdvisor │
│ ├─ 从 ChatMemory 读历史(此时为空) │
│ ├─ 把历史注入到 Prompt 的 messages 中 │
│ ├─ 调用 ChatModel │
│ └─ 把本轮对话写入 ChatMemory │
│ ↓ │
│ AI: "好的小明!" │
│ │
│ 用户: "我刚才说我叫什么?" │
│ ↓ │
│ MessageChatMemoryAdvisor │
│ ├─ 从 ChatMemory 读历史: │
│ │ user: "我叫小明" │
│ │ assistant: "好的小明!" │
│ ├─ 注入到 Prompt → AI 知道上下文 │
│ └─ 返回: "你叫小明!" │
└──────────────────────────────────────────┘
```
### conversationId —— 区分不同会话的关键
```java
chatClient.prompt()
.user(message)
.advisors(a -> a.param("chat_memory_conversation_id", conversationId))
.call()
.content();
```
不同的 conversationId → 独立的记忆空间。这就是多用户 / 多会话隔离的基础。
### ChatMemory 实现对比
| 实现 | 存储位置 | 重启丢失 | 适用场景 |
|------|---------|---------|---------|
| `InMemoryChatMemoryRepository` | JVM 堆内存 | 是 | 开发/测试(本周用这个) |
| `MessageWindowChatMemory` | 滑动窗口策略 | - | 包装 Repository只保留最近 N 条 |
| `JdbcChatMemoryRepository` | MySQL/PostgreSQL | 否 | 生产环境 |
> Spring AI 1.0.6 中ChatMemory 被拆分为两个角色:**策略层**MessageWindowChatMemory滑动窗口和 **存储层**ChatMemoryRepository持久化
### 动手 → 理解
1. 阅读 `ChatService.java`
- `chat(String message, String conversationId)` — 多轮对话版
- `getHistory(String conversationId)` — 查看某会话的所有历史消息
- `clearHistory(String conversationId)` — 清除某会话
2. 阅读 `ChatController.java` 的 `GET /api/chat/history` 和 `DELETE /api/chat/history`
3. 阅读 `ChatHistoryVo.java` DTO
4. **完整测试多轮对话**
- 在浏览器中发送:"我叫小明,我是一名 Java 后端开发者,有 3 年经验"
- 再发送:"我刚才说我叫什么名字?我做什么工作?几年经验?"
- 确认 AI 能正确回忆上下文
5. 点击"+ 新建对话",问同样的问题"我叫什么?"——确认 AI "忘记"了(新对话 = 空白记忆)
6. 在侧边栏切换回之前的对话,再问一次——确认旧对话的记忆还在
7. 点击"清除历史"——确认记忆被清空
8. 访问 `GET http://localhost:8080/api/chat/history?conversationId=default` 查看记忆中的数据结构
### 核心文件
| 文件 | 变更 |
|------|------|
| `ChatService.java` | 所有方法增加 conversationId新增 `getHistory()`、`clearHistory()` |
| `ChatController.java` | 新增 GET/DELETE `/history` 端点 |
| `ChatHistoryVo.java` | 新增 |
| `chat.html` | 新增侧边栏 + 新建/切换/删除对话 |
| `chat.js` | 新增会话管理函数 |
### 思考题
> `InMemoryChatMemoryRepository` 在应用重启后会丢失所有记忆。如果要持久化到数据库,需要改哪些代码?提示:只需要把 `AIConfig.java` 中的 `InMemoryChatMemoryRepository` 换成 `JdbcChatMemoryRepository`,其他代码完全不需要改 —— 这就是面向接口编程的威力。
---
## Week 9 总结
### 技术栈全景
```
浏览器 (chat.html)
│ fetch + SSE
ChatController (/api/chat, /api/chat/stream, /api/chat/history)
ChatService (chat, chatStream, getHistory, clearHistory)
ChatClient (Spring AI Fluent API)
├─ ChatModel (OpenAiChatModel → HTTP → AI Service)
└─ MessageChatMemoryAdvisor → ChatMemory (InMemory)
```
### 能力清单
| 维度 | 掌握内容 |
|------|---------|
| LLM 概念 | Token、Transformer、自回归生成、主流模型对比 |
| Prompt 设计 | 五大要素、Few-shot、CoT、角色扮演 |
| API 调用 | REST API 格式、messages 结构、Temperature |
| Spring AI | ChatClient、ChatModel、BOM、手动装配 |
| 同步对话 | POST /api/chat、call().content() |
| 流式对话 | SSE、Flux、ReadableStream、打字机效果 |
| 多轮对话 | ChatMemory、conversationId、MessageChatMemoryAdvisor |
### vs Week 5 (Spring Security)
| | Week 5 | Week 9 |
|------|--------|--------|
| 核心框架 | Spring Security + JWT | Spring AI |
| 数据方向 | 客户端 → 服务器 → DB | 客户端 → 服务器 → AI API |
| 状态管理 | JWT Token客户端持有 | ChatMemory服务端持有 |
| 响应模式 | 同步 JSON | 同步 JSON + SSE 流 |
| 拦截器 | SecurityFilterChain | Advisor Chain |
### 常见问题
| 问题 | 原因 | 解决 |
|------|------|------|
| 启动报 "API key not valid" | api-key 没改 | 在 `application.yml` 中填入真实 API Key |
| 返回 "Connection refused" | base-url 不对 | 检查 `base-url` 是否与 provider 匹配 |
| 返回 401 | API Key 无效或过期 | 登录平台重新生成 Key |
| 返回 429 | 调用频率超限 | 免费版通常有 RPM 限制,等几秒再试 |
| 流式没效果 | 前端没开流式开关 | 勾选"流式输出"复选框 |
| 多轮对话不记得上下文 | 没传 conversationId | 检查请求体中是否包含 `conversationId` |
| 中文显示乱码 | 编码问题 | 确认 `application.yml` 中有 `characterEncoding=utf-8`(虽本项目不连 MySQL |
| `mvn compile` 报找不到 Spring AI | BOM 未生效 | 确认 `dependencyManagement` 配置正确,`mvn clean compile` 重试 |
| 前端页面 404 | 文件路径不对 | 确认 `chat.html` 在 `src/main/resources/static/` 下 |
---
> **本周核心**:你第一次把 AI 集成到了自己的应用中。从最原始的 HTTP 调用,到 Spring AI 封装的一行 `.call().content()`,再到流式 SSE 和 ChatMemory 多轮对话——你掌握了让应用"拥有 AI 能力"的完整链路。这也是整个学习计划转折的一周:之前你学的是"如何写好代码",从今天开始你学的是"如何让代码拥有智能"。
---
> **下一阶段Week 10 — AI Agent 核心概念**。在聊天机器人基础上,让 AI 能调用工具Function Calling、检索知识库RAG、持久化记忆Redis。从一个"会聊天的 AI"升级到"会干活的 Agent"。