diff --git a/week10/pom.xml b/week10/pom.xml new file mode 100644 index 0000000..8f6ec56 --- /dev/null +++ b/week10/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + com.learn + week10-ai-agent + 1.0.0 + Week 10: AI Agent Core Concepts + + + 17 + 1.0.6 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.ai + spring-ai-advisors-vector-store + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week10/src/main/java/com/learn/Week10Application.java b/week10/src/main/java/com/learn/Week10Application.java new file mode 100644 index 0000000..af52ef4 --- /dev/null +++ b/week10/src/main/java/com/learn/Week10Application.java @@ -0,0 +1,11 @@ +package com.learn; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Week10Application { + public static void main(String[] args) { + SpringApplication.run(Week10Application.class, args); + } +} diff --git a/week10/src/main/java/com/learn/config/AIConfig.java b/week10/src/main/java/com/learn/config/AIConfig.java new file mode 100644 index 0000000..d0235a7 --- /dev/null +++ b/week10/src/main/java/com/learn/config/AIConfig.java @@ -0,0 +1,129 @@ +package com.learn.config; + +import com.learn.tool.CalculatorTool; +import com.learn.tool.SearchTool; +import com.learn.tool.WeatherTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.MessageWindowChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.vectorstore.SimpleVectorStore; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +/** + * Week 10 核心配置 —— Agent 能力装配 + * + * 新增(对比 Week 9): + * 1. EmbeddingModel(手动创建,用 Ollama 本地) → RAG + * 2. VectorStore(SimpleVectorStore,内存) → RAG + * 3. ChatMemory 持久化(RedisChatMemoryRepository) → 记忆不丢失 + * 4. ChatClient 注册 Tools + QuestionAnswerAdvisor → Agent + */ +@Configuration +public class AIConfig { + + private static final Logger log = LoggerFactory.getLogger(AIConfig.class); + + // ==================== Embedding(用于 RAG) ==================== + + /** + * EmbeddingModel —— 把文本转成向量。 + * + * 这里手动创建 OpenAiEmbeddingModel,指向 Ollama 本地服务, + * 因为 Chat 用的是 DeepSeek(不支持 Embedding),两者 base-url 不同。 + * + * 前置条件:ollama pull nomic-embed-text + */ + @Bean + @Lazy + public EmbeddingModel embeddingModel( + @Value("${spring.ai.openai.embedding.base-url:http://localhost:11434}") String baseUrl, + @Value("${spring.ai.openai.embedding.api-key:ollama}") String apiKey) { + + var api = OpenAiApi.builder() + .baseUrl(baseUrl.endsWith("/v1") ? baseUrl : baseUrl + "/v1") + .apiKey(apiKey) + .build(); + log.info("EmbeddingModel created with base-url: {}", baseUrl); + return new OpenAiEmbeddingModel(api); + } + + // ==================== VectorStore(用于 RAG) ==================== + + /** + * SimpleVectorStore —— 纯内存向量存储,免外部服务。 + * 生产环境可替换为 RedisVectorStore / PgVectorStore 等。 + */ + @Bean + @Lazy + public VectorStore vectorStore(EmbeddingModel embeddingModel) { + return SimpleVectorStore.builder(embeddingModel).build(); + } + + // ==================== ChatMemory(持久化) ==================== + + /** + * ChatMemory —— 对话记忆,Redis 持久化(Day 5)。 + * + * 架构: MessageWindowChatMemory (滑动窗口, 最多 30 条) + * ↓ 存储 + * ChatMemoryRepository → RedisChatMemoryRepository (自定义实现) + * + * 切换方案:把注入的 ChatMemoryRepository 换成 InMemoryChatMemoryRepository + * 就回到 Week 9 的内存模式,其他代码完全不用改。 + */ + @Bean + public ChatMemory chatMemory(ChatMemoryRepository repository) { + return MessageWindowChatMemory.builder() + .chatMemoryRepository(repository) + .maxMessages(30) + .build(); + } + + // ==================== ChatClient(Agent 入口) ==================== + + /** + * ChatClient —— Week 10 增强版: + * - 复用 Week 9 的 defaultSystem + MessageChatMemoryAdvisor + * - 新增 defaultTools:注册所有 @Tool 方法 + * - 新增 QuestionAnswerAdvisor:RAG 检索增强 + * + * 注意:Tool 对象通过 Spring 注入(用 @Component 标注), + * .defaultTools() 接收所有带 @Tool 注解的 Bean。 + */ + @Bean + public ChatClient chatClient( + ChatModel chatModel, + ChatMemory chatMemory, + @Lazy VectorStore vectorStore, + WeatherTool weatherTool, + CalculatorTool calculatorTool, + SearchTool searchTool) { + + return ChatClient.builder(chatModel) + .defaultSystem(""" + 你是一个 AI Agent 助手,名字叫"小智 Agent"。 + 你可以调用工具来获取信息或执行计算。 + 当用户询问天气、计算数学、或搜索信息时,请使用对应的工具。 + 回答时请用中文,基于工具返回的结果来组织自然流畅的回复。 + """) + .defaultTools(weatherTool, calculatorTool, searchTool) + .defaultAdvisors( + MessageChatMemoryAdvisor.builder(chatMemory).build(), + new QuestionAnswerAdvisor(vectorStore) + ) + .build(); + } +} diff --git a/week10/src/main/java/com/learn/config/RedisConfig.java b/week10/src/main/java/com/learn/config/RedisConfig.java new file mode 100644 index 0000000..fa9d1b9 --- /dev/null +++ b/week10/src/main/java/com/learn/config/RedisConfig.java @@ -0,0 +1,31 @@ +package com.learn.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Redis 相关配置。 + * + * ChatMemory 的 Message 接口有多个实现(UserMessage、AssistantMessage 等), + * Jackson 反序列化时需要类型信息才能确定具体类型。 + * 这里配置一个启用默认类型推导的 ObjectMapper。 + */ +@Configuration +public class RedisConfig { + + /** + * 专用于 ChatMemory Message 序列化/反序列化的 ObjectMapper。 + * 启用 NON_FINAL 类型推导,让 Jackson 在多态反序列化时自动写入/读取类型信息。 + */ + @Bean + public ObjectMapper objectMapper() { + var mapper = new ObjectMapper(); + mapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder().build(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + return mapper; + } +} diff --git a/week10/src/main/java/com/learn/controller/ChatController.java b/week10/src/main/java/com/learn/controller/ChatController.java new file mode 100644 index 0000000..8528838 --- /dev/null +++ b/week10/src/main/java/com/learn/controller/ChatController.java @@ -0,0 +1,166 @@ +package com.learn.controller; + +import com.learn.dto.ApiResponse; +import com.learn.dto.ChatRequest; +import com.learn.dto.RAGQueryRequest; +import com.learn.service.AgentService; +import com.learn.service.ChatService; +import com.learn.service.PromptTemplateService; +import com.learn.service.RAGService; +import jakarta.validation.Valid; +import org.springframework.ai.document.Document; +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.List; +import java.util.Map; + +@RestController +public class ChatController { + + private final ChatService chatService; + private final AgentService agentService; + private final RAGService ragService; + private final PromptTemplateService promptService; + + public ChatController(ChatService chatService, AgentService agentService, + RAGService ragService, PromptTemplateService promptService) { + this.chatService = chatService; + this.agentService = agentService; + this.ragService = ragService; + this.promptService = promptService; + } + + // ==================== Week 9 复用: 基础对话 ==================== + + @PostMapping("/api/chat") + public ApiResponse> chat(@Valid @RequestBody ChatRequest request) { + String convId = request.getConversationId() != null ? request.getConversationId() : "default"; + String reply = chatService.chat(request.getMessage(), convId); + return ApiResponse.success(Map.of("reply", reply, "conversationId", convId)); + } + + @PostMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> chatStream(@Valid @RequestBody ChatRequest request) { + return chatService.chatStream(request.getMessage(), request.getConversationId()) + .map(token -> ServerSentEvent.builder().data(token).build()) + .concatWith(Mono.just(ServerSentEvent.builder() + .event("done").data("[DONE]").build())); + } + + @GetMapping("/api/chat/history") + public ApiResponse> getHistory(@RequestParam(defaultValue = "default") String conversationId) { + return ApiResponse.success(chatService.getHistory(conversationId)); + } + + @DeleteMapping("/api/chat/history") + public ApiResponse> clearHistory(@RequestParam(defaultValue = "default") String conversationId) { + chatService.clearHistory(conversationId); + return ApiResponse.success("已清除会话: " + conversationId); + } + + // ==================== Day 3-4: Agent 任务(Function Calling) ==================== + + /** + * 同步 Agent 执行 —— 模型自动选择并调用工具。 + * + * POST /api/agent + * Body: {"message": "北京天气如何?", "conversationId": "会话ID(可选)"} + */ + @PostMapping("/api/agent") + public ApiResponse> agent(@Valid @RequestBody ChatRequest request) { + var result = agentService.execute(request.getMessage(), request.getConversationId()); + return ApiResponse.success(result); + } + + /** + * 流式 Agent 执行。 + * + * POST /api/agent/stream + */ + @PostMapping(value = "/api/agent/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> agentStream(@Valid @RequestBody ChatRequest request) { + return agentService.executeStream(request.getMessage(), request.getConversationId()) + .map(token -> ServerSentEvent.builder().data(token).build()) + .concatWith(Mono.just(ServerSentEvent.builder() + .event("done").data("[DONE]").build())); + } + + // ==================== Day 6: RAG 检索增强 ==================== + + /** + * RAG 检索(只检索不生成,看检索效果)。 + * + * POST /api/rag/search + * Body: {"question": "什么是 Spring AI?", "topK": 3} + */ + @PostMapping("/api/rag/search") + public ApiResponse>> ragSearch(@Valid @RequestBody RAGQueryRequest request) { + return ApiResponse.success(ragService.search(request.getQuestion(), request.getTopK())); + } + + /** + * 向知识库添加文档。 + * + * POST /api/rag/load + * Body: [{"text": "文档内容", "metadata": {"source": "spring-doc"}}] + */ + @PostMapping("/api/rag/load") + public ApiResponse> ragLoad(@RequestBody List> documents) { + var docs = documents.stream() + .map(m -> { + String text = (String) m.get("text"); + @SuppressWarnings("unchecked") + var metadata = (Map) m.getOrDefault("metadata", Map.of()); + return new Document(text, new java.util.HashMap<>(metadata)); + }) + .toList(); + int count = ragService.addDocuments(docs); + return ApiResponse.created(Map.of("added", count)); + } + + // ==================== Day 7: Prompt 模板管理 ==================== + + /** + * 获取所有模板名称和预览。 + * + * GET /api/prompts + */ + @GetMapping("/api/prompts") + public ApiResponse>> listPrompts() { + return ApiResponse.success(promptService.list()); + } + + /** + * 获取指定模板的原始内容。 + * + * GET /api/prompts/{name} + */ + @GetMapping("/api/prompts/{name}") + public ApiResponse> getPrompt(@PathVariable String name) { + String content = promptService.getTemplate(name); + return ApiResponse.success(Map.of("name", name, "content", content)); + } + + /** + * 使用 Prompt 模板进行对话。 + * + * POST /api/chat/with-template + * Body: {"message": "用户消息", "templateName": "java-expert", "params": {"key": "value"}, "conversationId": "xxx"} + */ + @PostMapping("/api/chat/with-template") + public ApiResponse> chatWithTemplate(@RequestBody Map body) { + String message = (String) body.get("message"); + String templateName = (String) body.get("templateName"); + String conversationId = (String) body.getOrDefault("conversationId", "default"); + @SuppressWarnings("unchecked") + var params = (Map) body.getOrDefault("params", Map.of()); + + String templateContent = promptService.apply(templateName, params); + String reply = chatService.chatWithTemplate(templateContent, message, conversationId); + return ApiResponse.success(Map.of("reply", reply, "conversationId", conversationId)); + } +} diff --git a/week10/src/main/java/com/learn/dto/ApiResponse.java b/week10/src/main/java/com/learn/dto/ApiResponse.java new file mode 100644 index 0000000..1f7ac10 --- /dev/null +++ b/week10/src/main/java/com/learn/dto/ApiResponse.java @@ -0,0 +1,59 @@ +package com.learn.dto; + +import java.util.HashMap; +import java.util.Map; + +public class ApiResponse { + + private int code; + private String message; + private T data; + private Map extra; + + public ApiResponse() {} + + public ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse created(T data) { + return new ApiResponse<>(201, "created", data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public static ApiResponse notFound(String message) { + return new ApiResponse<>(404, message, null); + } + + public static ApiResponse badRequest(String message) { + return new ApiResponse<>(400, message, null); + } + + public ApiResponse 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 getExtra() { return extra; } + public void setExtra(Map extra) { this.extra = extra; } +} diff --git a/week10/src/main/java/com/learn/dto/ChatHistoryVo.java b/week10/src/main/java/com/learn/dto/ChatHistoryVo.java new file mode 100644 index 0000000..c2df89c --- /dev/null +++ b/week10/src/main/java/com/learn/dto/ChatHistoryVo.java @@ -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> 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> getMessages() { return messages; } + public void setMessages(List> messages) { this.messages = messages; } +} diff --git a/week10/src/main/java/com/learn/dto/ChatRequest.java b/week10/src/main/java/com/learn/dto/ChatRequest.java new file mode 100644 index 0000000..d9c7c02 --- /dev/null +++ b/week10/src/main/java/com/learn/dto/ChatRequest.java @@ -0,0 +1,15 @@ +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; } +} diff --git a/week10/src/main/java/com/learn/dto/RAGQueryRequest.java b/week10/src/main/java/com/learn/dto/RAGQueryRequest.java new file mode 100644 index 0000000..2b32c32 --- /dev/null +++ b/week10/src/main/java/com/learn/dto/RAGQueryRequest.java @@ -0,0 +1,16 @@ +package com.learn.dto; + +import jakarta.validation.constraints.NotBlank; + +public class RAGQueryRequest { + + @NotBlank(message = "问题不能为空") + private String question; + + private int topK = 3; + + public String getQuestion() { return question; } + public void setQuestion(String question) { this.question = question; } + public int getTopK() { return topK; } + public void setTopK(int topK) { this.topK = topK; } +} diff --git a/week10/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week10/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..030a062 --- /dev/null +++ b/week10/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -0,0 +1,46 @@ +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> 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> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest(ex.getMessage())); + } + + @ExceptionHandler(org.springframework.ai.retry.NonTransientAiException.class) + public ResponseEntity> handleAIException(org.springframework.ai.retry.NonTransientAiException ex) { + log.error("AI API error", ex); + return ResponseEntity.status(502) + .body(ApiResponse.error(502, "AI 服务错误: " + ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + log.error("Unexpected error", ex); + return ResponseEntity.status(500) + .body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage())); + } +} diff --git a/week10/src/main/java/com/learn/memory/RedisChatMemoryRepository.java b/week10/src/main/java/com/learn/memory/RedisChatMemoryRepository.java new file mode 100644 index 0000000..0171857 --- /dev/null +++ b/week10/src/main/java/com/learn/memory/RedisChatMemoryRepository.java @@ -0,0 +1,85 @@ +package com.learn.memory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.messages.Message; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.*; + +/** + * Redis 实现的 ChatMemoryRepository。 + * + * 实现 ChatMemoryRepository 接口(4 个方法),让 MessageWindowChatMemory + * 可以把对话历史持久化到 Redis,应用重启后记忆不丢失。 + * + * 存储结构: + * chat:memory: → Redis List,每个元素是 Message 的 JSON + * chat:memory:ids → Redis Set,存放所有 conversationId + */ +@Repository +public class RedisChatMemoryRepository implements ChatMemoryRepository { + + private static final Logger log = LoggerFactory.getLogger(RedisChatMemoryRepository.class); + private static final String KEY_PREFIX = "chat:memory:"; + private static final String IDS_KEY = "chat:memory:ids"; + + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + + public RedisChatMemoryRepository(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + @Override + public List findConversationIds() { + var ids = redis.opsForSet().members(IDS_KEY); + if (ids == null || ids.isEmpty()) return List.of(); + return ids.stream().sorted().toList(); + } + + @Override + public List findByConversationId(String conversationId) { + String key = KEY_PREFIX + conversationId; + var rawList = redis.opsForList().range(key, 0, -1); + if (rawList == null || rawList.isEmpty()) return List.of(); + + var messages = new ArrayList(); + for (var raw : rawList) { + try { + messages.add(objectMapper.readValue(raw, Message.class)); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize message: {}", raw, e); + } + } + return messages; + } + + @Override + public void saveAll(String conversationId, List messages) { + String key = KEY_PREFIX + conversationId; + redis.delete(key); + + for (var msg : messages) { + try { + redis.opsForList().rightPush(key, objectMapper.writeValueAsString(msg)); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize message", e); + } + } + redis.opsForSet().add(IDS_KEY, conversationId); + log.debug("Saved {} messages to Redis for conversation: {}", messages.size(), conversationId); + } + + @Override + public void deleteByConversationId(String conversationId) { + redis.delete(KEY_PREFIX + conversationId); + redis.opsForSet().remove(IDS_KEY, conversationId); + log.debug("Deleted Redis memory for conversation: {}", conversationId); + } +} diff --git a/week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java b/week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java new file mode 100644 index 0000000..4a32658 --- /dev/null +++ b/week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java @@ -0,0 +1,74 @@ +package com.learn.rag; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 启动时向 VectorStore 加载示例知识库文档。 + * + * 依赖 Ollama Embedding 服务,完全懒加载 —— 只在首次 RAG 请求时触发。 + * 如果 Ollama 不可用,会打印警告但不会阻止应用启动。 + */ +@Component +@Lazy +public class KnowledgeDataLoader { + + private static final Logger log = LoggerFactory.getLogger(KnowledgeDataLoader.class); + + private final VectorStore vectorStore; + + public KnowledgeDataLoader(@Lazy VectorStore vectorStore) { + this.vectorStore = vectorStore; + } + + @PostConstruct + public void load() { + try { + var docs = List.of( + new Document(""" + Spring AI 是一个面向 AI 工程的 Spring 框架,目标是让 Java 开发者能够轻松集成 AI 能力。 + 它提供了 ChatClient、VectorStore、Function Calling 等核心组件。 + Spring AI 支持 OpenAI、DeepSeek、Ollama 等多种模型提供商。 + """, Map.of("source", "spring-ai-overview", "topic", "spring-ai")), + + new Document(""" + RAG(Retrieval-Augmented Generation,检索增强生成)是一种结合信息检索和文本生成的技术。 + 它先从知识库中检索与用户问题相关的文档片段,然后将这些片段作为上下文注入 Prompt, + 让大模型基于这些外部知识生成更准确的回答,有效缓解模型幻觉问题。 + """, Map.of("source", "rag-concept", "topic", "rag")), + + new Document(""" + Function Calling(函数调用)是让大模型调用外部工具的能力。 + 在 Spring AI 中,使用 @Tool 注解标记一个 Java 方法,框架会自动生成工具定义 + 并发送给模型。模型判断需要调用工具时,框架执行对应方法并将结果返回给模型。 + """, Map.of("source", "function-calling", "topic", "function-calling")), + + new Document(""" + VectorStore(向量存储)是 RAG 的核心组件,负责存储和检索文档向量。 + Spring AI 提供多种实现:SimpleVectorStore(内存)、PgVectorStore(PostgreSQL)、 + RedisVectorStore(Redis)、MilvusVectorStore、QdrantVectorStore 等。 + SimpleVectorStore 适合学习和开发,生产环境应选择支持持久化的方案。 + """, Map.of("source", "vector-store", "topic", "rag")), + + new Document(""" + ChatMemory 用于存储对话历史,实现多轮对话的记忆能力。 + Spring AI 提供 MessageWindowChatMemory(滑动窗口)和 MessageTokenChatMemory(Token 截断)两种策略。 + 通过 ChatMemoryRepository 接口可以将对话历史持久化到 Redis、JDBC 等存储中。 + """, Map.of("source", "chat-memory", "topic", "spring-ai")) + ); + + vectorStore.add(docs); + log.info("Loaded {} knowledge documents into VectorStore", docs.size()); + } catch (Exception e) { + log.warn("RAG knowledge base unavailable: {}. Agent features still work.", e.getMessage()); + } + } +} diff --git a/week10/src/main/java/com/learn/service/AgentService.java b/week10/src/main/java/com/learn/service/AgentService.java new file mode 100644 index 0000000..449ffa3 --- /dev/null +++ b/week10/src/main/java/com/learn/service/AgentService.java @@ -0,0 +1,70 @@ +package com.learn.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID; + +/** + * Agent 编排服务 —— 管理多工具协作的执行。 + * + * 和 ChatService 的区别: + * - ChatService: 纯聊天(Week 9 能力) + * - AgentService: Agent 模式,ChatClient 通过 .tools() 注册的工具自动调用 + * + * 实际上,因为 AIConfig 中已经在 ChatClient 上注册了 defaultTools, + * 所以即使用 ChatService 聊天,模型也会在需要时自动调用工具。 + * AgentService 是更明确的"Agent 任务"入口,未来可以扩展多步推理等高级特性。 + */ +@Service +public class AgentService { + + private static final Logger log = LoggerFactory.getLogger(AgentService.class); + + private final ChatClient chatClient; + + public AgentService(ChatClient chatClient) { + this.chatClient = chatClient; + } + + /** + * 同步 Agent 执行。 + * 模型会自动判断是否需要调用工具,以及调用哪个工具。 + */ + public Map execute(String task, String conversationId) { + String convId = defaultIfNull(conversationId); + log.info("Agent task [{}]: {}", convId, task); + + String reply = chatClient.prompt() + .user(task) + .advisors(a -> a.param(CONVERSATION_ID, convId)) + .call() + .content(); + + return Map.of("reply", reply, "conversationId", convId); + } + + /** + * 流式 Agent 执行。 + */ + public Flux executeStream(String task, String conversationId) { + String convId = defaultIfNull(conversationId); + log.info("Agent stream task [{}]: {}", convId, task); + + return chatClient.prompt() + .user(task) + .advisors(a -> a.param(CONVERSATION_ID, convId)) + .stream() + .content(); + } + + private String defaultIfNull(String id) { + return (id == null || id.isBlank()) ? "default" : id; + } +} diff --git a/week10/src/main/java/com/learn/service/ChatService.java b/week10/src/main/java/com/learn/service/ChatService.java new file mode 100644 index 0000000..d58db17 --- /dev/null +++ b/week10/src/main/java/com/learn/service/ChatService.java @@ -0,0 +1,85 @@ +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.*; + +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; + } + + /** 同步对话(带记忆) */ + public String chat(String message, String conversationId) { + String convId = defaultIfNull(conversationId); + return chatClient.prompt() + .user(message) + .advisors(a -> a.param(CONVERSATION_ID, convId)) + .call() + .content(); + } + + /** 流式对话(带记忆) */ + public Flux chatStream(String message, String conversationId) { + String convId = defaultIfNull(conversationId); + return chatClient.prompt() + .user(message) + .advisors(a -> a.param(CONVERSATION_ID, convId)) + .stream() + .content(); + } + + /** 使用 Prompt 模板的对话 */ + public String chatWithTemplate(String templateContent, String message, String conversationId) { + String convId = defaultIfNull(conversationId); + String fullPrompt = templateContent + "\n\n---\n用户消息: " + message; + return chatClient.prompt() + .user(fullPrompt) + .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>(); + for (var msg : messages) { + var item = new HashMap(); + 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) { + chatMemory.clear(defaultIfNull(conversationId)); + } + + private String defaultIfNull(String id) { + return (id == null || id.isBlank()) ? "default" : id; + } +} diff --git a/week10/src/main/java/com/learn/service/PromptTemplateService.java b/week10/src/main/java/com/learn/service/PromptTemplateService.java new file mode 100644 index 0000000..f52562f --- /dev/null +++ b/week10/src/main/java/com/learn/service/PromptTemplateService.java @@ -0,0 +1,68 @@ +package com.learn.service; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Prompt 模板管理服务(Day 7)。 + * + * 从 classpath:/prompts/ 加载 .txt 模板文件。 + * 支持占位符替换: {{key}} → value + */ +@Service +public class PromptTemplateService { + + private static final Logger log = LoggerFactory.getLogger(PromptTemplateService.class); + + private final Map templates = new LinkedHashMap<>(); + + @PostConstruct + public void loadTemplates() throws IOException { + var resolver = new PathMatchingResourcePatternResolver(); + var resources = resolver.getResources("classpath:/prompts/*.txt"); + + for (var res : resources) { + String filename = res.getFilename(); + if (filename == null) continue; + String name = filename.replace(".txt", ""); + String content = res.getContentAsString(StandardCharsets.UTF_8); + templates.put(name, content); + log.info("Loaded prompt template: {} ({} chars)", name, content.length()); + } + log.info("Loaded {} prompt templates", templates.size()); + } + + /** 获取所有模板名称 */ + public List> list() { + return templates.entrySet().stream() + .map(e -> Map.of("name", e.getKey(), "preview", + e.getValue().substring(0, Math.min(80, e.getValue().length())) + "...")) + .toList(); + } + + /** 获取模板原始内容 */ + public String getTemplate(String name) { + String content = templates.get(name); + if (content == null) { + throw new IllegalArgumentException("模板不存在: " + name + "。可用: " + templates.keySet()); + } + return content; + } + + /** 应用模板:替换 {{key}} 占位符 */ + public String apply(String name, Map params) { + String content = getTemplate(name); + for (var entry : params.entrySet()) { + content = content.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + return content; + } +} diff --git a/week10/src/main/java/com/learn/service/RAGService.java b/week10/src/main/java/com/learn/service/RAGService.java new file mode 100644 index 0000000..5688c9a --- /dev/null +++ b/week10/src/main/java/com/learn/service/RAGService.java @@ -0,0 +1,63 @@ +package com.learn.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * RAG 服务 —— 检索增强生成。 + * + * 流程: 用户问题 → VectorStore 相似度检索 → 拼接 Context → ChatClient 生成回答 + * + * 由于 AIConfig 中 ChatClient 已注册 QuestionAnswerAdvisor, + * 实际上通过 ChatService 聊天时已自带 RAG 能力。 + * 本服务提供显式的 RAG 查询接口,让学习过程更清晰。 + */ +@Service +public class RAGService { + + private static final Logger log = LoggerFactory.getLogger(RAGService.class); + + private final VectorStore vectorStore; + + public RAGService(VectorStore vectorStore) { + this.vectorStore = vectorStore; + } + + /** + * 检索相关文档(不做生成,纯粹看检索效果)。 + */ + public List> search(String question, int topK) { + var results = vectorStore.similaritySearch( + SearchRequest.builder() + .query(question) + .topK(topK) + .similarityThreshold(0.3) + .build() + ); + log.info("RAG search for '{}': found {} docs", question, results.size()); + + return results.stream() + .map(doc -> Map.of( + "content", doc.getText(), + "score", String.format("%.4f", doc.getScore()), + "metadata", doc.getMetadata() + )) + .toList(); + } + + /** + * 向知识库添加文档。 + */ + public int addDocuments(List documents) { + vectorStore.add(documents); + log.info("Added {} documents to vector store", documents.size()); + return documents.size(); + } +} diff --git a/week10/src/main/java/com/learn/tool/CalculatorTool.java b/week10/src/main/java/com/learn/tool/CalculatorTool.java new file mode 100644 index 0000000..9e5ed73 --- /dev/null +++ b/week10/src/main/java/com/learn/tool/CalculatorTool.java @@ -0,0 +1,29 @@ +package com.learn.tool; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +/** + * 计算器工具 —— Day 4 多工具协作示例。 + * 支持基本四则运算,使用 JavaScript 引擎安全求值。 + */ +@Component +public class CalculatorTool { + + @Tool(description = "执行数学计算表达式。支持加减乘除和括号。例如: '2+3*4' 返回 14") + public String calculate( + @ToolParam(description = "数学表达式,如 '123*456' 或 '(100+200)/3'") String expression) { + + try { + var engine = new javax.script.ScriptEngineManager().getEngineByName("js"); + if (engine == null) { + throw new RuntimeException("JavaScript 引擎不可用"); + } + var result = engine.eval(expression); + return expression + " = " + result; + } catch (Exception e) { + return "计算失败: " + e.getMessage() + "。请确认表达式格式是否正确,例如 '123*456'。"; + } + } +} diff --git a/week10/src/main/java/com/learn/tool/SearchTool.java b/week10/src/main/java/com/learn/tool/SearchTool.java new file mode 100644 index 0000000..ad13bc6 --- /dev/null +++ b/week10/src/main/java/com/learn/tool/SearchTool.java @@ -0,0 +1,34 @@ +package com.learn.tool; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +/** + * 搜索工具 —— Day 4 多工具协作示例。 + * 模拟搜索引擎,返回预置的"搜索结果"。 + * 实际项目中可对接 SearXNG、Bing Search API 等。 + */ +@Component +public class SearchTool { + + @Tool(description = "搜索互联网信息,返回相关结果摘要。用于查找事实、新闻、技术文档等") + public String search( + @ToolParam(description = "搜索关键词") String query) { + + return switch (query.toLowerCase()) { + case "spring ai" -> + "Spring AI 是 Spring 生态的 AI 集成框架。最新稳定版 1.0.6 (2026.04)。" + + "核心功能: ChatClient、Function Calling (@Tool)、RAG、Advisor 拦截器链。"; + case "java 21" -> + "Java 21 是 Oracle 发布的 LTS 版本 (2023.09)。" + + "新特性: 虚拟线程 (Virtual Threads)、Record Patterns、Switch 模式匹配。"; + case "deepseek" -> + "DeepSeek 由深度求索公司开发。最新模型 DeepSeek-V3 在代码和数学能力上表现突出。" + + "API 兼容 OpenAI 格式,base-url: https://api.deepseek.com。"; + default -> + "关于'" + query + "',未找到精确匹配。这是一条模拟搜索结果。" + + "实际部署时请接入真实搜索引擎 API。"; + }; + } +} diff --git a/week10/src/main/java/com/learn/tool/WeatherTool.java b/week10/src/main/java/com/learn/tool/WeatherTool.java new file mode 100644 index 0000000..18a19c3 --- /dev/null +++ b/week10/src/main/java/com/learn/tool/WeatherTool.java @@ -0,0 +1,40 @@ +package com.learn.tool; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 天气查询工具 —— Day 3 的第一个 Tool 示例。 + * 模拟天气 API,实际项目中可以对接和风天气、OpenWeatherMap 等真实 API。 + */ +@Component +public class WeatherTool { + + private static final Map CITY_MAP = Map.of( + "北京", "晴, 22°C, 湿度 45%, 北风 3级", + "上海", "多云, 25°C, 湿度 65%, 东南风 2级", + "广州", "雷阵雨, 28°C, 湿度 80%, 南风 4级", + "深圳", "阴转多云, 27°C, 湿度 72%, 东风 3级", + "杭州", "小雨, 20°C, 湿度 78%, 东北风 2级", + "成都", "阴, 18°C, 湿度 70%, 无持续风向", + "武汉", "晴转多云, 24°C, 湿度 55%, 南风 3级", + "东京", "晴, 18°C, 湿度 50%, 西风 2级", + "纽约", "阴, 15°C, 湿度 60%, 西北风 5级", + "伦敦", "小雨, 12°C, 湿度 75%, 西南风 4级" + ); + + @Tool(description = "获取指定城市的当前天气信息,包括温度、湿度、风向等") + public String getWeather( + @ToolParam(description = "城市名称,如'北京'、'上海'、'东京'") String city) { + + for (var entry : CITY_MAP.entrySet()) { + if (entry.getKey().contains(city) || city.contains(entry.getKey())) { + return entry.getKey() + "天气: " + entry.getValue(); + } + } + return "未找到城市'" + city + "'的天气信息。支持的城市: " + CITY_MAP.keySet(); + } +} diff --git a/week10/src/main/resources/application.yml b/week10/src/main/resources/application.yml new file mode 100644 index 0000000..74b1427 --- /dev/null +++ b/week10/src/main/resources/application.yml @@ -0,0 +1,51 @@ +spring: + application: + name: week10-ai-agent + + # ============================================================= + # AI 模型配置(Chat + Embedding 分离) + # ============================================================= + # Chat 模型: DeepSeek(或其他 OpenAI 兼容服务) + # Embedding 模型: Ollama 本地(或其他 OpenAI 兼容服务) + # ============================================================= + ai: + openai: + # ---- Chat 模型(DeepSeek) ---- + api-key: ${AI_API_KEY:sk-fe4d2f26eda04a659b9ff3408ce5024b} + base-url: ${AI_BASE_URL:https://api.deepseek.com} + chat: + options: + model: deepseek-chat + temperature: 0.3 # Agent/工具调用建议用较低温度,减少幻觉 + + # ---- Embedding 模型(Ollama 本地) ---- + # 方案 B(默认): Ollama 本地 → 免费、离线 + # 前置: ollama pull nomic-embed-text + # + # 方案 A(备选): 阿里云百炼 → 云端、稳定 + # embedding: + # api-key: ${EMBEDDING_API_KEY:sk-your-bailian-key} + # base-url: https://dashscope.aliyuncs.com/compatible-mode + # options: + # model: text-embedding-v2 + embedding: + api-key: ollama + base-url: ${EMBEDDING_BASE_URL:http://localhost:11434} + options: + model: nomic-embed-text + + # Redis(Week 5 的 Redis 配置,用于 ChatMemory 持久化) + data: + redis: + host: localhost + port: 6379 + password: ${REDIS_PASSWORD:1365957941@Wfj} + timeout: 3000ms + +server: + port: 8080 + +logging: + level: + com.learn: DEBUG + org.springframework.ai.chat.client.advisor: DEBUG diff --git a/week10/src/main/resources/prompts/code-reviewer.txt b/week10/src/main/resources/prompts/code-reviewer.txt new file mode 100644 index 0000000..bbd1d18 --- /dev/null +++ b/week10/src/main/resources/prompts/code-reviewer.txt @@ -0,0 +1,22 @@ +你是一位严格的代码审查专家,擅长发现代码中的潜在问题。 + +请从以下维度审查用户提供的代码: + +1. **正确性**: 逻辑是否有误?边界条件是否处理? +2. **安全性**: 是否存在 OWASP Top 10 漏洞?SQL 注入、XSS、敏感信息泄露等 +3. **性能**: 是否有 N+1 查询、内存泄漏、不必要的对象创建? +4. **可维护性**: 命名是否清晰?职责是否单一?耦合是否过高? +5. **规范**: 是否符合 Java/Spring 编码规范? + +审查报告格式: + +### 严重问题(必须修复) +- [ ] 问题描述 + 修复建议 + +### 改进建议(推荐优化) +- [ ] 问题描述 + 优化方案 + +### 亮点(做得好的地方) +- [ ] ... + +请直接对用户后续发送的代码进行审查。 diff --git a/week10/src/main/resources/prompts/java-expert.txt b/week10/src/main/resources/prompts/java-expert.txt new file mode 100644 index 0000000..c544b46 --- /dev/null +++ b/week10/src/main/resources/prompts/java-expert.txt @@ -0,0 +1,14 @@ +你是一位资深 Java 技术专家,拥有 15 年以上的企业级开发经验。 + +请遵循以下原则回答用户的问题: + +1. **代码示例**: 使用 Java 21+ 语法,优先使用虚拟线程、Records、Sealed Classes 等新特性 +2. **最佳实践**: 强调 SOLID 原则、设计模式、Clean Code +3. **性能意识**: 关注内存管理、GC 调优、并发优化 +4. **Spring 生态**: 优先推荐 Spring Boot 3.x 的最新实践 +5. **安全**: 对涉及安全的代码要特别标注注意事项 + +回答时请: +- 先用 1-2 句话总结核心观点 +- 再用代码示例说明 +- 最后补充注意事项和替代方案 diff --git a/week10/src/main/resources/prompts/translator.txt b/week10/src/main/resources/prompts/translator.txt new file mode 100644 index 0000000..11d28b3 --- /dev/null +++ b/week10/src/main/resources/prompts/translator.txt @@ -0,0 +1,10 @@ +你是一位专业的中英文翻译专家。 + +翻译规则: + +1. **中文→英文**: 使用地道的英语表达,避免中式英语 +2. **英文→中文**: 使用流畅的自然中文,避免翻译腔 +3. **技术术语**: 保持专业术语的准确性 +4. **格式保留**: 保持原文的空行、列表、代码块等格式 + +请直接输出翻译结果,无需额外解释。 diff --git a/week10/src/main/resources/static/agent.html b/week10/src/main/resources/static/agent.html new file mode 100644 index 0000000..0d758d9 --- /dev/null +++ b/week10/src/main/resources/static/agent.html @@ -0,0 +1,94 @@ + + + + + + 小智 Agent — Week 10 AI Agent 控制台 + + + + + + + + + + + + Agent 控制台 + 会话: default + + + + + 欢迎使用 AI Agent 控制台 + 我可以调用工具(天气、计算、搜索)来回答你的问题。 + + 试试这些: + 🌤️ 查天气 + 🔢 计算 + 🔍 搜索 + 📚 RAG 知识库 + 🤖 多工具协作 + + + + + + + 发送 + + + + + + + + diff --git a/week10/src/main/resources/static/css/agent.css b/week10/src/main/resources/static/css/agent.css new file mode 100644 index 0000000..56a1958 --- /dev/null +++ b/week10/src/main/resources/static/css/agent.css @@ -0,0 +1,307 @@ +/* ==================== Reset & Base ==================== */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --sidebar-bg: #1a1d27; + --card-bg: #1e2130; + --border: #2a2d3a; + --text: #e1e4ea; + --text-muted: #8b8fa3; + --accent: #6c5ce7; + --accent-hover: #7d6ff0; + --success: #00cec9; + --warning: #fdcb6e; + --danger: #e17055; + --user-bg: #2d3043; + --bot-bg: #1a1d27; + --tool-bg: #1a2332; + --source-bg: #1a2e1a; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; +} + +/* ==================== Layout ==================== */ +.app { display: flex; height: 100vh; } + +.sidebar { + width: 280px; + min-width: 280px; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 16px; + gap: 16px; +} +.main { flex: 1; overflow: hidden; } + +/* ==================== Sidebar ==================== */ +.sidebar-header { display: flex; align-items: center; gap: 8px; } +.sidebar-header h2 { font-size: 18px; color: var(--accent); } +.badge { + background: var(--accent); + color: #fff; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; +} + +.sidebar-section h3 { + font-size: 12px; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 8px; + letter-spacing: 1px; +} + +/* ==================== Buttons ==================== */ +.btn { + cursor: pointer; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 14px; + transition: all 0.2s; +} +.btn-primary { background: var(--accent); color: #fff; width: 100%; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { background: var(--card-bg); color: var(--text); width: 100%; border: 1px solid var(--border); } +.btn-secondary:hover { border-color: var(--accent); } +.btn-sm { padding: 4px 10px; font-size: 12px; } + +/* ==================== Conversation List ==================== */ +.conv-list { list-style: none; margin-top: 8px; } +.conv-item { + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + transition: all 0.15s; + margin-bottom: 2px; + word-break: break-all; +} +.conv-item:hover { background: var(--card-bg); color: var(--text); } +.conv-item.active { background: var(--accent); color: #fff; } + +/* ==================== Template Select ==================== */ +#templateSelect { + width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--card-bg); + color: var(--text); + font-size: 13px; +} +.template-preview { + margin-top: 8px; + padding: 8px; + background: var(--card-bg); + border-radius: 6px; + font-size: 12px; + color: var(--text-muted); + max-height: 100px; + overflow-y: auto; + white-space: pre-wrap; + border: 1px solid var(--border); +} +.hidden { display: none; } + +/* ==================== Tool List ==================== */ +.tool-list { display: flex; flex-direction: column; gap: 4px; } +.tool-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--card-bg); + border-radius: 6px; + font-size: 13px; +} +.tool-icon { font-size: 16px; } + +/* ==================== Toggles ==================== */ +.toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; + padding: 4px 0; +} +.toggle input { accent-color: var(--accent); } + +/* ==================== Chat Container ==================== */ +.chat-container { display: flex; flex-direction: column; height: 100%; } +.chat-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} +.chat-header h1 { font-size: 18px; } +.conv-id { font-size: 12px; color: var(--text-muted); } + +/* ==================== Messages ==================== */ +.messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.welcome { + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} +.welcome h2 { color: var(--text); margin-bottom: 8px; } + +.sample-prompts { margin-top: 20px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.sample-prompts button { + cursor: pointer; + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 16px; + background: var(--card-bg); + color: var(--text); + font-size: 13px; + transition: all 0.2s; +} +.sample-prompts button:hover { border-color: var(--accent); background: var(--user-bg); } + +/* ==================== Message Bubbles ==================== */ +.message { display: flex; flex-direction: column; max-width: 85%; animation: fadeIn 0.3s ease; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +.message.user { align-self: flex-end; } +.message.user .msg-content { background: var(--user-bg); border-radius: 16px 16px 4px 16px; } + +.message.bot { align-self: flex-start; } +.message.bot .msg-content { background: var(--bot-bg); border-radius: 16px 16px 16px 4px; border: 1px solid var(--border); } + +.msg-role { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; + padding: 0 4px; +} +.msg-content { padding: 12px 16px; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } + +/* ==================== Tool Call Card ==================== */ +.tool-call { + margin-top: 8px; + background: var(--tool-bg); + border: 1px solid #2a3a5c; + border-radius: 10px; + overflow: hidden; + font-size: 13px; +} +.tool-call-header { + padding: 8px 12px; + background: rgba(108, 92, 231, 0.15); + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-weight: 600; + color: var(--accent); +} +.tool-call-body { padding: 8px 12px; font-size: 12px; color: var(--text-muted); } +.tool-call-body pre { + margin: 4px 0 0; + font-size: 12px; + background: rgba(0,0,0,0.2); + padding: 8px; + border-radius: 6px; + overflow-x: auto; +} + +/* ==================== RAG Sources Card ==================== */ +.rag-sources { + margin-top: 8px; + background: var(--source-bg); + border: 1px solid #2a4a2a; + border-radius: 10px; + overflow: hidden; + font-size: 12px; +} +.rag-sources-header { + padding: 6px 12px; + background: rgba(0, 206, 201, 0.1); + color: var(--success); + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} +.rag-source-item { + padding: 6px 12px; + border-top: 1px solid rgba(0, 206, 201, 0.15); + color: var(--text-muted); +} +.rag-source-item .score { color: var(--success); margin-left: 8px; font-size: 11px; } + +/* ==================== Typing Indicator ==================== */ +.typing { align-self: flex-start; } +.typing .dots { display: flex; gap: 4px; padding: 8px 12px; } +.typing .dots span { + width: 8px; height: 8px; + background: var(--text-muted); + border-radius: 50%; + animation: bounce 1.4s infinite both; +} +.typing .dots span:nth-child(2) { animation-delay: 0.2s; } +.typing .dots span:nth-child(3) { animation-delay: 0.4s; } +@keyframes bounce { + 0%, 80%, 100% { transform: scale(0); } + 40% { transform: scale(1); } +} + +/* ==================== Input Area ==================== */ +.input-area { + display: flex; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid var(--border); +} +.input-area textarea { + flex: 1; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card-bg); + color: var(--text); + font-size: 14px; + font-family: inherit; + resize: none; + outline: none; + transition: border-color 0.2s; +} +.input-area textarea:focus { border-color: var(--accent); } + +/* ==================== Scrollbar ==================== */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { display: none; } + .message { max-width: 95%; } +} diff --git a/week10/src/main/resources/static/js/agent.js b/week10/src/main/resources/static/js/agent.js new file mode 100644 index 0000000..fc58297 --- /dev/null +++ b/week10/src/main/resources/static/js/agent.js @@ -0,0 +1,231 @@ +// ==================== State ==================== +const state = { + conversationId: 'default', + convList: ['default'], + templateName: '', + isStreaming: true, + isAgentMode: true, + isTyping: false +}; + +// ==================== Init ==================== +document.addEventListener('DOMContentLoaded', () => { + loadPrompts(); + document.getElementById('streamToggle').addEventListener('change', e => state.isStreaming = e.target.checked); + document.getElementById('agentToggle').addEventListener('change', e => { + state.isAgentMode = e.target.checked; + document.getElementById('chatTitle').textContent = e.target.checked ? 'Agent 控制台' : '聊天'; + }); +}); + +// ==================== Conversation ==================== +function newConversation() { + const id = 'conv_' + Date.now(); + state.convList.push(id); + state.conversationId = id; + renderConvList(); + document.getElementById('messages').innerHTML = ''; + document.getElementById('convIdLabel').textContent = '会话: ' + id; +} + +function switchConv(id) { + state.conversationId = id; + renderConvList(); + document.getElementById('convIdLabel').textContent = '会话: ' + id; + document.getElementById('messages').innerHTML = ''; + loadHistory(id); +} + +function renderConvList() { + const list = document.getElementById('convList'); + list.innerHTML = state.convList.map(id => + `${id === 'default' ? '默认会话' : id}` + ).join(''); +} + +async function loadHistory(convId) { + try { + const res = await fetch(`/api/chat/history?conversationId=${convId}`); + const data = await res.json(); + if (data.code === 200 && data.data?.messages) { + data.data.messages.forEach(msg => addMessage(msg.role, msg.content)); + } + } catch (e) { /* ignore */ } +} + +// ==================== Templates ==================== +async function loadPrompts() { + try { + const res = await fetch('/api/prompts'); + const data = await res.json(); + if (data.code === 200) { + const select = document.getElementById('templateSelect'); + data.data.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.name; + opt.textContent = t.name; + select.appendChild(opt); + }); + } + } catch (e) { console.error('Failed to load prompts:', e); } +} + +async function selectTemplate(name) { + state.templateName = name; + const preview = document.getElementById('templatePreview'); + if (!name) { preview.classList.add('hidden'); return; } + try { + const res = await fetch(`/api/prompts/${name}`); + const data = await res.json(); + if (data.code === 200) { + preview.textContent = data.data.content.substring(0, 200) + '...'; + preview.classList.remove('hidden'); + } + } catch (e) { /* ignore */ } +} + +// ==================== Send Message ==================== +function sendSample(text) { + document.getElementById('userInput').value = text; + sendMessage(); +} + +async function sendMessage() { + const input = document.getElementById('userInput'); + const message = input.value.trim(); + if (!message || state.isTyping) return; + + input.value = ''; + addMessage('user', message); + showTyping(); + + const isAgent = state.isAgentMode; + const useTemplate = !!state.templateName; + const isStream = state.isStreaming; + + state.isTyping = true; + document.getElementById('sendBtn').disabled = true; + + try { + let endpoint, body; + if (useTemplate) { + endpoint = '/api/chat/with-template'; + body = JSON.stringify({ + message, templateName: state.templateName, + conversationId: state.conversationId, params: {} + }); + isStream = false; // template mode uses sync + } else if (isAgent) { + endpoint = isStream ? '/api/agent/stream' : '/api/agent'; + body = JSON.stringify({ message, conversationId: state.conversationId }); + } else { + endpoint = isStream ? '/api/chat/stream' : '/api/chat'; + body = JSON.stringify({ message, conversationId: state.conversationId }); + } + + if (isStream) { + await handleStream(endpoint, body); + } else { + await handleSync(endpoint, body); + } + } catch (e) { + addMessage('bot', '错误: ' + e.message); + } finally { + hideTyping(); + state.isTyping = false; + document.getElementById('sendBtn').disabled = false; + } +} + +async function handleSync(endpoint, body) { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); + const data = await res.json(); + if (data.code === 200) { + const reply = data.data?.reply ?? JSON.stringify(data.data); + addMessage('bot', reply); + } else { + addMessage('bot', '错误: ' + (data.message || '未知错误')); + } +} + +async function handleStream(endpoint, body) { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + let botMsgEl = addMessage('bot', ''); + let buffer = ''; + + 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) { + if (line.startsWith('data:')) { + const token = line.slice(5).trim(); + if (token === '[DONE]') continue; + botMsgEl.querySelector('.msg-content').textContent += token; + scrollToBottom(); + } + } + } +} + +// ==================== UI Helpers ==================== +function addMessage(role, content) { + const messages = document.getElementById('messages'); + // Remove welcome if present + const welcome = messages.querySelector('.welcome'); + if (welcome) welcome.remove(); + + const div = document.createElement('div'); + div.className = `message ${role}`; + + const roleLabel = document.createElement('div'); + roleLabel.className = 'msg-role'; + roleLabel.textContent = role === 'user' ? '你' : '小智 Agent'; + div.appendChild(roleLabel); + + const contentDiv = document.createElement('div'); + contentDiv.className = 'msg-content'; + contentDiv.textContent = content; + div.appendChild(contentDiv); + + messages.appendChild(div); + scrollToBottom(); + return div; +} + +function showTyping() { + const messages = document.getElementById('messages'); + const welcome = messages.querySelector('.welcome'); + if (welcome) welcome.remove(); + + const div = document.createElement('div'); + div.className = 'message bot typing'; + div.id = 'typingIndicator'; + div.innerHTML = ''; + messages.appendChild(div); + scrollToBottom(); +} + +function hideTyping() { + const el = document.getElementById('typingIndicator'); + if (el) el.remove(); +} + +function scrollToBottom() { + const messages = document.getElementById('messages'); + messages.scrollTop = messages.scrollHeight; +} + +// ==================== Tool Call / RAG Render Helpers ==================== +// These can be used to render structured tool-call and RAG-source data +// when the API response includes metadata. Current simple mode appends text only. +// Enhanced mode: if backend returns structured JSON with toolCalls / sources, +// parse and render with .tool-call and .rag-sources CSS classes. diff --git a/week10/教案.md b/week10/教案.md new file mode 100644 index 0000000..2fd97cc --- /dev/null +++ b/week10/教案.md @@ -0,0 +1,856 @@ +# Week 10:AI Agent 核心概念 —— 从聊天到干活 + +> **学习周期**:7 天 +> **每日用时**:2-3 小时 +> **最终产出**:一个具备工具调用、RAG 检索、持久化记忆、Prompt 模板管理的 AI Agent 系统 + +--- + +## 前置准备 + +| 事项 | 说明 | +|------|------| +| Week 9 代码可用 | 确保 `week9` 项目能正常启动和对话 | +| DeepSeek API Key | Week 9 已在用,继续使用 | +| Redis 安装 | Day 5 需要([github.com/tporadowski/redis](https://github.com/tporadowski/redis) Windows 版) | +| Ollama 安装(方案 B) | Day 6 需要([ollama.com](https://ollama.com) → 下载安装 → `ollama pull nomic-embed-text`) | + +> **关于 Embedding 方案**:Day 6 的 RAG 有两种 Embedding 方案。**方案 B(Ollama 本地)** 免费、离线、零成本,是默认选择。**方案 A(阿里云百炼)** 需要注册获取 Key,但有免费额度,适合网络受限无法下载 Ollama 的情况。教案中同时覆盖两种方案。 + +--- + +## 启动方式 + +```bash +# 1. 确保 Redis 已启动(Day 5 需要,其他 Day 可先注释掉 Redis 依赖) +# 2. 确保 Ollama 已启动 + nomic-embed-text 已下载(Day 6 需要) +# 3. 修改 application.yml 中的 api-key (DeepSeek) +# 4. IDEA 中运行 Week10Application.main() +# 5. 浏览器访问 http://localhost:8080/agent.html +``` + +--- + +## Week 9 → Week 10:升级了什么? + +``` +Week 9: 聊天机器人 + POST /api/chat → 同步/流式对话 → AI 陪你聊天 + +Week 10: AI Agent + POST /api/agent → 工具调用 → AI 能查天气、算数学、搜信息 + POST /api/rag/query → 知识库检索 → AI 能基于你的文档回答问题 + Redis 持久化 → 记忆不丢 → 重启后对话历史还在 + Prompt 模板管理 → 角色预设 → 一键切换 AI 人设 +``` + +--- + +## Day 1:Agent 核心概念(ReAct / Plan-Execute) + +### 什么是 AI Agent? + +AI Agent(智能体)= **大模型 + 工具 + 决策循环**。和普通聊天机器人的区别: + +| | 聊天机器人 | AI Agent | +|------|-----------|----------| +| 能力 | 只回答已知知识 | 能调用外部工具 | +| 记忆 | 单次对话 | 持久化 + 多轮 | +| 行为 | 被动回复 | 主动规划 + 执行 | +| 边界 | 模型训练数据 | 训练数据 + 实时信息 | + +### Agent 的两个核心范式 + +#### 1. ReAct(Reasoning + Acting,推理-行动循环) + +``` +用户: "北京今天天气如何?" + + ┌─ Thought(思考): 用户想知道北京的天气,我需要调用天气工具 + ├─ Action(行动): getWeather("北京") + ├─ Observation(观察): {"city":"北京","weather":"晴","temp":25} + ├─ Thought(思考): 获得了天气信息,可以回答了 + └─ Answer(回答): "北京今天晴天,气温 25°C..." +``` + +ReAct 是当前最主流的 Agent 范式,Spring AI 的 `@Tool` 机制本质上就是 ReAct。 + +#### 2. Plan-Execute(规划-执行) + +``` +用户: "帮我做一个技术调研:比较 Spring AI 和 LangChain4j" + + ┌─ Plan(规划): + │ 1. 搜索 Spring AI 最新文档 + │ 2. 搜索 LangChain4j 最新文档 + │ 3. 对比两者的 API 设计 + │ 4. 写总结报告 + ├─ Execute Step 1 → 获取结果 + ├─ Execute Step 2 → 获取结果 + ├─ Execute Step 3 → 对比分析 + └─ Execute Step 4 → 输出报告 +``` + +Plan-Execute 适合复杂多步任务,需要更强的规划能力。Spring AI 暂未内置此模式,但可以通过多轮 Agent 调用手动实现。 + +### 动手 → 理解 + +1. 用 ChatGPT / DeepSeek 网页版,尝试问一个需要"外部信息"的问题(如"今天天气"),观察它如何回答——要么说不知道,要么编造答案 +2. 再问一个需要"实时信息"的问题:对比 ChatGPT 网页版(能搜索)和 API 版(无工具)的回答差异 +3. 在纸上画出 ReAct 循环图,尝试把"帮我查北京天气,如果下雨就提醒带伞,如果晴天就推荐去公园"这个任务分解为 ReAct 步骤 + +### 思考题 + +> 如果模型没有工具调用能力,用户问"今天天气如何?"——模型会怎么回答?为什么会有"幻觉"?工具调用如何解决幻觉问题? + +--- + +## Day 2:Function Calling 原理 —— 模型如何"调用"工具? + +### 核心问题 + +模型不能真的"调用"你的 Java 方法。整个过程是: + +``` +你告诉模型"有哪些工具可用"(JSON Schema) + → 模型决定"需要调用哪个工具"(返回 JSON) + → 你的代码解析 JSON → 执行对应方法 + → 把结果返回给模型 + → 模型基于结果生成自然语言回答 +``` + +### 工具定义(Tools Definition)—— 告诉模型你能做什么 + +发送给模型的请求中包含一个 `tools` 数组: + +```json +{ + "model": "deepseek-chat", + "messages": [{"role": "user", "content": "北京今天天气如何?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "getWeather", + "description": "获取指定城市的当前天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称,如'北京'、'上海'" + } + }, + "required": ["city"] + } + } + } + ] +} +``` + +### 模型的响应 —— 不直接回答,而是说"请调这个工具" + +```json +{ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_abc123", + "type": "function", + "function": { + "name": "getWeather", + "arguments": "{\"city\": \"北京\"}" + } + }] + } + }] +} +``` + +关键:`finish_reason` 是 `tool_calls` 而不是 `stop`——模型没有生成文本,而是请求调用工具。 + +### 第二轮:把工具结果喂回去 + +```json +{ + "model": "deepseek-chat", + "messages": [ + {"role": "user", "content": "北京今天天气如何?"}, + { + "role": "assistant", + "content": null, + "tool_calls": [{"id": "call_abc123", "type": "function", "function": {"name": "getWeather", "arguments": "{\"city\": \"北京\"}"}}] + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": "{\"city\": \"北京\", \"weather\": \"晴\", \"temperature\": 25}" + } + ] +} +``` + +这一次 `finish_reason` = `stop`,模型返回自然语言:"北京今天晴天,气温 25°C,非常适合户外活动。" + +### 动手 → 理解 + +1. 用 Postman,给 DeepSeek API 发送一个带 `tools` 定义的请求(复制上面的 JSON,替换 API Key) +2. 观察返回的 `finish_reason` 是 `tool_calls` 不是 `stop` +3. 手动构造"第二轮请求":把 tool 执行结果放到 messages 里,再次调用,观察模型如何基于结果生成回答 +4. 试试发送两个工具定义(weather + calculator),同一个用户问题,观察模型如何选择 + +### 思考题 + +> 如果模型同时定义了 WeatherTool 和 CalculatorTool,用户问"1+1等于几"——模型凭什么知道该选 CalculatorTool 而不是 WeatherTool?(答案:`description` 字段是唯一的"说明书") + +--- + +## Day 3:Spring AI Function Calling —— 第一个 Tool + +### Spring AI 的 @Tool 注解 + +Spring AI 1.0.6 提供了 `@Tool` 注解,让你用纯 Java 定义工具: + +```java +@Component +public class WeatherTool { + + @Tool(description = "获取指定城市的当前天气信息,包括天气状况和温度") + public String getWeather(@ToolParam(description = "城市名称,如'北京'、'上海'") String city) { + // 模拟天气数据(生产环境替换为真实 API 调用) + var weatherData = Map.of( + "北京", "晴,25°C", + "上海", "多云,28°C", + "深圳", "阵雨,30°C" + ); + return weatherData.getOrDefault(city, "未找到该城市天气信息"); + } +} +``` + +Spring AI 会自动: +1. 从 `@Tool.description` 生成工具定义(JSON Schema) +2. 在 ChatClient 发送请求时附上工具列表 +3. 解析模型的 `tool_calls` 响应 +4. 执行对应的方法 +5. 把结果自动返回给模型 +6. 拿到最终的自然语言回答 + +你只需要:用 `@Tool` 写方法 + 注册到 ChatClient。 + +### 注册工具到 ChatClient + +```java +@Bean +public ChatClient chatClient(ChatModel chatModel, Object weatherTool, ...) { + return ChatClient.builder(chatModel) + .defaultSystem("你是一个 AI Agent 助手...") + .defaultTools(weatherTool, calculatorTool, searchTool) // 注册所有工具 + .defaultAdvisors(...) + .build(); +} +``` + +### AgentService:让模型自动选择工具 + +```java +@Service +public class AgentService { + private final ChatClient chatClient; + + public Map execute(String task, String conversationId) { + String reply = chatClient.prompt() + .user(task) + .advisors(a -> a.param(CONVERSATION_ID, convId)) + .call() + .content(); // 模型自动判断是否调用工具、调用哪个工具 + return Map.of("reply", reply, "conversationId", convId); + } +} +``` + +你没写任何"if 用户说天气 then 调 WeatherTool"的逻辑。**模型自己决定**——这就是 Agent 的智能所在。 + +### 动手 → 理解 + +1. 阅读 `tool/WeatherTool.java`——理解 `@Tool` 和 `@ToolParam` 的用法 +2. 阅读 `AIConfig.java` 中的 `defaultTools(weatherTool, calculatorTool, searchTool)` 注册 +3. 阅读 `service/AgentService.java`——对比 `ChatService.java`,有什么区别? +4. 启动项目,浏览器访问 `http://localhost:8080/agent.html` +5. 勾选"Agent 模式",发送:"北京今天天气如何?" +6. 观察日志中是否有工具调用记录 +7. 用 Postman 测试:`POST /api/agent` Body: `{"message": "深圳天气怎么样?"}` +8. 故意问一个 WeatherTool 不支持的城市(如"拉萨"),观察返回 + +### 核心文件 + +| 文件 | 作用 | +|------|------| +| `tool/WeatherTool.java` | @Tool 定义:天气查询 | +| `config/AIConfig.java` | defaultTools 注册 | +| `service/AgentService.java` | Agent 入口:execute() / executeStream() | + +### 思考题 + +> 如果你把 WeatherTool 的 `@Tool(description = "...")` 改成 `description = "获取当前时间"`,模型调用这个工具时会发生什么?提示:description 是模型**唯一的判断依据**。 + +--- + +## Day 4:多工具协作 —— 让 Agent 更强大 + +### 再加两个工具 + +#### CalculatorTool + +```java +@Component +public class CalculatorTool { + @Tool(description = "执行数学计算,支持加减乘除和括号。示例: '123 * 456'") + public double calculate(@ToolParam(description = "数学表达式") String expression) { + // 使用 javax.script.ScriptEngine 安全求值 + ScriptEngineManager manager = new ScriptEngineManager(); + ScriptEngine engine = manager.getEngineByName("JavaScript"); + return ((Number) engine.eval(expression)).doubleValue(); + } +} +``` + +#### SearchTool + +```java +@Component +public class SearchTool { + @Tool(description = "联网搜索技术信息,返回相关结果") + public String search(@ToolParam(description = "搜索关键词") String query) { + // 模拟搜索结果(生产环境替换为真实搜索 API) + var results = Map.of( + "spring ai", "Spring AI 1.0.6 是面向 Java 的 AI 框架...", + "java 21", "Java 21 是 LTS 版本,引入虚拟线程...", + "deepseek", "DeepSeek 是深度求索开发的大语言模型..." + ); + return results.getOrDefault(query.toLowerCase(), "未找到相关信息"); + } +} +``` + +### 多工具协作的关键:让模型自己选 + +你没有写规则说"当用户问数学时用 CalculatorTool"。模型通过 tools 的 `description` 自行判断。核心优势: + +``` +用户: "北京天气怎么样?顺便帮我算一下 999 * 888" + +模型思考过程(内部): + → 用户问了两个问题:天气 + 计算 + → 查天气 → 需要调用 WeatherTool + → 数学运算 → 需要调用 CalculatorTool + → 先取天气结果,再取计算结果,合并回答 +``` + +### Agent 模式 vs 普通聊天模式 + +| | 普通聊天(/api/chat) | Agent 模式(/api/agent) | +|------|---------------------|------------------------| +| 工具 | 不注册工具 | 注册所有 @Tool | +| 能力 | 只用训练数据 | 可获取外部信息 | +| 适用 | 闲聊、解释概念 | 查天气、计算、搜索 | +| 实现 | ChatService | AgentService(实际上底层一样,只是 ChatClient 配置不同) | + +### 动手 → 理解 + +1. 阅读 `tool/CalculatorTool.java` 和 `tool/SearchTool.java` +2. 阅读 `service/AgentService.java` 的 `execute()` —— 注意到没有?代码和 Day 3 一模一样!因为模型自己决定调哪个工具 +3. 启动项目,在 agent.html 中测试多工具协作: + - 发送:"北京天气怎么样?顺便算 123 * 456" + - 发送:"帮我搜索 Spring AI 是什么,再总结成一句话" +4. 在日志中观察:是否同时触发了两个工具? +5. 流式模式测试:勾选"流式输出",再次发送多工具请求,观察打字机效果 +6. 对比 `/api/agent` 和 `/api/chat` 的响应差异 + +### 核心文件 + +| 文件 | 作用 | +|------|------| +| `tool/CalculatorTool.java` | @Tool:数学计算 | +| `tool/SearchTool.java` | @Tool:搜索(模拟) | +| `service/AgentService.java` | executeStream() 流式 Agent | + +### 思考题 + +> 如果问"东京天气如何?帮我换算一下 32°C 是多少华氏度",模型会调用哪些工具?调用顺序如何?描述完整的 ReAct 循环。 + +--- + +## Day 5:Redis 记忆持久化 —— 重启对话不丢失 + +### Week 9 的局限 + +Week 9 使用 `InMemoryChatMemoryRepository`(JVM 内存)存储对话历史。一旦重启应用,所有对话记忆丢失。 + +### Spring AI 的 ChatMemory 双层架构 + +``` +MessageWindowChatMemory(策略层:滑动窗口,保留最近 30 条) + ↓ 委托存储 +ChatMemoryRepository(存储层:接口) + ├─ InMemoryChatMemoryRepository(默认,内存 → 重启丢失) + ├─ JdbcChatMemoryRepository(JDBC 数据库) + ├─ CassandraChatMemoryRepository(Cassandra) + └─ 自定义:RedisChatMemoryRepository ← 本周实现 +``` + +### 自定义 RedisChatMemoryRepository + +Spring AI 1.0.6 没有内置 Redis 实现,这反而是好的——你可以学习如何扩展框架: + +```java +@Component +public class RedisChatMemoryRepository implements ChatMemoryRepository { + + private final RedisTemplate redisTemplate; + private static final String KEY_PREFIX = "chat:memory:"; + private static final String ID_SET_KEY = "chat:conversation:ids"; + + @Override + public List findByConversationId(String id) { + // LRANGE 读取整个 List → JSON 反序列化 → List + var jsonList = redisTemplate.opsForList().range(KEY_PREFIX + id, 0, -1); + // ... Jackson 反序列化 + } + + @Override + public void saveAll(String id, List messages) { + // DEL 清空旧数据 → RPUSH 逐条写入 JSON → SADD 记录 ID + redisTemplate.delete(KEY_PREFIX + id); + messages.forEach(msg -> redisTemplate.opsForList().rightPush(KEY_PREFIX + id, toJson(msg))); + redisTemplate.opsForSet().add(ID_SET_KEY, id); + } + + @Override + public void deleteByConversationId(String id) { /* DEL + SREM */ } + + @Override + public List findConversationIds() { /* SMEMBERS */ } +} +``` + +### AIConfig 中的无缝切换 + +```java +// Week 9(内存版,重启丢失): +@Bean +public ChatMemory chatMemory() { + return MessageWindowChatMemory.builder() + .chatMemoryRepository(new InMemoryChatMemoryRepository()) + .maxMessages(20).build(); +} + +// Week 10(Redis 持久化版,重启保留): +@Bean +public ChatMemory chatMemory(ChatMemoryRepository repository) { // 注入我们的 Redis 实现 + return MessageWindowChatMemory.builder() + .chatMemoryRepository(repository) // 换成 Redis + .maxMessages(30).build(); +} +``` + +**其他代码完全不用改**——这就是面向接口编程的威力。 + +### Jackson 序列化的 Message 格式 + +Message 接口的常见实现是 `AssistantMessage`、`UserMessage`。Jackson 需要类型信息来正确反序列化。在 RedisConfig 中配置: + +```java +@Bean +public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(Object.class); + // 配置 ObjectMapper 以支持多态反序列化 + ObjectMapper mapper = new ObjectMapper(); + mapper.activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build(), + ObjectMapper.DefaultTyping.NON_FINAL); + serializer.setObjectMapper(mapper); + + template.setValueSerializer(serializer); + // ... +} +``` + +### 动手 → 理解 + +1. 安装 Redis(Windows 版从 GitHub Release 下载,或使用 WSL/Docker) +2. 启动 Redis:`redis-server.exe`(或 `redis-server`) +3. 修改 `application.yml` 中的 Redis 密码(如果有的话) +4. 阅读 `memory/RedisChatMemoryRepository.java`——理解 4 个接口方法的实现 +5. 阅读 `config/RedisConfig.java`——理解 JSON 序列化配置 +6. 阅读 `config/AIConfig.java` 中的 `chatMemory()` Bean——注意 `ChatMemoryRepository` 是怎么注入的 +7. 启动项目,发送几轮对话 +8. 用 `redis-cli` 查看数据: + ``` + redis-cli + SMEMBERS chat:conversation:ids → 查看所有会话 ID + LRANGE chat:memory:default 0 -1 → 查看 default 会话的所有消息 + ``` +9. **重启项目** → 访问 `GET /api/chat/history` → 确认记忆还在! +10. 删除某个会话 → 观察 Redis 中对应的 Key 被删除 + +### 核心文件 + +| 文件 | 作用 | +|------|------| +| `memory/RedisChatMemoryRepository.java` | 实现 ChatMemoryRepository 接口 | +| `config/RedisConfig.java` | RedisTemplate 序列化配置 | +| `config/AIConfig.java` | ChatMemory Bean 改造(注入 Repository) | + +### 思考题 + +> 如果把 `maxMessages(30)` 改成 `maxMessages(5)`,再发 10 轮对话,Redis 里会存几条消息?滑动窗口是怎么删除旧消息的?验证方法:用 `LLEN chat:memory:xxx` 检查 List 长度。 + +--- + +## Day 6:RAG(检索增强生成)—— 让 AI 基于你的知识库回答 + +### 什么是 RAG? + +RAG = Retrieval-Augmented Generation(检索增强生成)。核心思想: + +``` +用户问题 → 向量检索(从知识库找相关文档)→ 把文档拼接到 Prompt → 模型基于文档生成回答 +``` + +为什么需要 RAG? +- LLM 训练数据有截止日期,不知道新信息 +- LLM 不知道你公司的内部文档 +- RAG 可以有效减少幻觉(模型瞎编) + +### RAG 核心流程 + +``` +┌─────────────────────────────────────────────────────┐ +│ 1. 文档入库(离线) │ +│ 文档 → 切分 → Embedding(转向量)→ 存入 VectorStore │ +│ │ +│ 2. 问答(在线) │ +│ 用户问题 → Embedding(转向量) │ +│ → VectorStore.similaritySearch(找最相似的) │ +│ → 拼接 Context + 问题 → ChatModel 生成回答 │ +└─────────────────────────────────────────────────────┘ +``` + +### Embedding(文本转向量)是什么? + +"今天天气真好" → Embedding 模型 → `[0.12, -0.34, 0.78, ..., 0.05]`(一个 768 维的浮点数数组) + +相似的文本,向量距离近。例如: +- "今天天气真好" 和 "今天阳光明媚" → 余弦相似度 0.92(很近) +- "今天天气真好" 和 "Spring AI 是什么" → 余弦相似度 0.15(很远) + +### Embedding 方案选择 + +#### 方案 B:Ollama 本地(默认,免费) + +```bash +# 安装 Ollama +# Mac: brew install ollama +# Windows: 从 ollama.com 下载安装包 + +# 拉取嵌入模型(~270MB) +ollama pull nomic-embed-text + +# 验证 +ollama list +# NAME ID SIZE MODIFIED +# nomic-embed-text:latest xxxxxxxx 274 MB ... +``` + +Ollama 默认监听 `http://localhost:11434`。 + +#### 方案 A:阿里云百炼(备选,云端) + +如果你无法下载 Ollama(网络受限),用阿里云百炼的 `text-embedding-v2`: + +1. 注册 [bailian.console.aliyun.com](https://bailian.console.aliyun.com) +2. 开通模型服务 → 获取 API Key +3. 修改 `application.yml`: + +```yaml +spring.ai.openai.embedding: + api-key: ${EMBEDDING_API_KEY:sk-your-bailian-key} + base-url: https://dashscope.aliyuncs.com/compatible-mode + options: + model: text-embedding-v2 +``` + +### AIConfig 中的双 Provider 设计 + +Chat 和 Embedding 用不同的 API Provider: + +```java +// Chat → DeepSeek(由 spring.ai.openai.* 自动配置 ChatModel) +// Embedding → Ollama 本地(手动创建 EmbeddingModel) + +@Bean +public EmbeddingModel embeddingModel( + @Value("${spring.ai.openai.embedding.base-url}") String baseUrl, + @Value("${spring.ai.openai.embedding.api-key}") String apiKey) { + var api = OpenAiApi.builder() + .baseUrl(baseUrl.endsWith("/v1") ? baseUrl : baseUrl + "/v1") + .apiKey(apiKey) + .build(); + return new OpenAiEmbeddingModel(api); +} +``` + +### SimpleVectorStore —— 零依赖的向量存储 + +```java +@Bean +public VectorStore vectorStore(EmbeddingModel embeddingModel) { + return SimpleVectorStore.builder(embeddingModel).build(); +} +``` + +SimpleVectorStore 纯内存存储,免外部服务。生产环境可替换为 PgVectorStore、RedisVectorStore 等。 + +### QuestionAnswerAdvisor —— 自动 RAG + +在 ChatClient 的 Advisor 链中添加 `QuestionAnswerAdvisor`: + +```java +.defaultAdvisors( + MessageChatMemoryAdvisor.builder(chatMemory).build(), + new QuestionAnswerAdvisor(vectorStore) // 自动检索 + 增强 +) +``` + +效果:每次对话时,Advisor 自动从 VectorStore 检索相关文档,拼接到 Prompt 中。**你的代码不需要手动调用 `vectorStore.similaritySearch()`**——Advisor 帮你做了。 + +### KnowledgeDataLoader:启动时加载知识库 + +```java +@Component +public class KnowledgeDataLoader { + @PostConstruct + public void load() { + var docs = List.of( + new Document("Spring AI 是一个面向 AI 工程的 Spring 框架...", + Map.of("source", "spring-ai-overview")), + new Document("RAG(检索增强生成)是结合检索和生成的技术...", + Map.of("source", "rag-concept")) + ); + vectorStore.add(docs); + } +} +``` + +### 动手 → 理解 + +1. 安装 Ollama → `ollama pull nomic-embed-text`(如果不方便下载,切换到方案 A) +2. 阅读 `config/AIConfig.java`: + - `embeddingModel()` — 手动创建,指向 Ollama + - `vectorStore()` — SimpleVectorStore + - `chatClient()` — 新增 `QuestionAnswerAdvisor` +3. 阅读 `rag/KnowledgeDataLoader.java`——8 条示例知识库文档 +4. 阅读 `service/RAGService.java`——`search()` 方法(纯检索)+ `addDocuments()` 方法 +5. 启动项目,观察日志: + - `EmbeddingModel created with base-url: http://localhost:11434` + - `Loaded 8 knowledge documents into VectorStore` +6. 在 agent.html 中测试 RAG: + - 发送:"什么是 Spring AI?" + - 发送:"RAG 是什么?" + - 对比:勾掉"Agent 模式"用普通聊天问同样的问题,回答有什么不同? +7. 用 Postman 测试纯检索(不生成): + - `POST /api/rag/search` Body: `{"question": "什么是 Spring AI?", "topK": 3}` + - 观察返回的文档片段和相似度分数 + +### 核心文件 + +| 文件 | 作用 | +|------|------| +| `config/AIConfig.java` | EmbeddingModel + VectorStore + QuestionAnswerAdvisor | +| `rag/KnowledgeDataLoader.java` | 启动时加载知识库文档 | +| `service/RAGService.java` | RAG 检索 + 问答 | +| `application.yml` | embedding 配置(方案 B 默认,方案 A 注释) | + +### 思考题 + +> 如果 QuestionAnswerAdvisor 从 VectorStore 检索到的文档和用户问题完全不相关(相似度很低),会发生什么?如何设置 `similarityThreshold` 来过滤低质量结果? + +--- + +## Day 7:Prompt 模板管理 —— 一键切换 AI 人设 + +### 为什么需要 Prompt 模板? + +System Prompt 写死在 `AIConfig` 中 → 每次换角色要改代码 + 重启 → 不灵活 + +Prompt 模板化 → 从文件加载 → 前端选择 → 一键切换角色 + +### 模板管理设计 + +``` +resources/prompts/ +├── java-expert.txt → 资深 Java 技术专家 +├── code-reviewer.txt → 严格的代码审查专家 +└── translator.txt → 专业中英文翻译 +``` + +模板中使用 `{{key}}` 占位符: + +``` +你是一位资深 Java 技术专家,拥有 15 年以上的企业级开发经验。 +请遵循以下原则回答用户的问题: +1. 代码示例: 使用 Java 21+ 语法 +2. 最佳实践: 强调 SOLID 原则、设计模式 +... +``` + +### PromptTemplateService + +```java +@Service +public class PromptTemplateService { + + @PostConstruct + public void loadTemplates() throws IOException { + // 扫描 classpath:/prompts/*.txt + // 文件名 = 模板名,文件内容 = 模板内容 + } + + public List> list() { ... } // 列出所有模板 + public String getTemplate(String name) { ... } // 获取模板内容 + public String apply(String name, Map params) { ... } // 替换占位符 +} +``` + +### 使用模板的对话 + +``` +POST /api/chat/with-template +Body: { + "message": "请写一个 Spring Boot 的全局异常处理类", + "templateName": "java-expert", + "conversationId": "default" +} +``` + +后端处理: +1. 从文件加载模板内容 +2. 替换 `{{key}}` 占位符 +3. 拼接:`模板内容 + "---\n用户消息: " + message` +4. 作为 `user()` 参数传给 ChatClient + +### 前端:agent.html 控制台 + +增强功能(对比 Week 9 的 chat.html): + +| 功能 | Week 9 | Week 10 | +|------|--------|---------| +| 工具可视化 | — | 侧边栏显示可用工具列表 | +| RAG 来源 | — | 可显示检索来源(预留) | +| 模板选择 | — | 下拉选择 + 内容预览 | +| Agent/聊天切换 | — | 侧边栏开关 | +| 流式/非流式切换 | 有 | 有 | +| 会话管理 | 有 | 有 | + +### 动手 → 理解 + +1. 阅读 `resources/prompts/` 下的 3 个模板文件 +2. 阅读 `service/PromptTemplateService.java`——理解 `@PostConstruct` 加载和占位符替换 +3. 阅读 `controller/ChatController.java` 的 `GET /api/prompts`、`POST /api/chat/with-template` +4. 启动项目,浏览器访问 `http://localhost:8080/agent.html` +5. 在侧边栏下拉选择"java-expert",观察预览内容 +6. 发送:"请写一个 Spring Boot 的全局异常处理"——观察回复是否体现出"Java 专家"风格 +7. 切换到"code-reviewer",发送一段代码,观察审查风格 +8. 切换到"translator",观察翻译风格 +9. 关掉模板(选择"— 不使用模板 —"),同样的问题再问一次——感受差异 + +### 核心文件 + +| 文件 | 作用 | +|------|------| +| `resources/prompts/java-expert.txt` | 模板:Java 专家 | +| `resources/prompts/code-reviewer.txt` | 模板:代码审查 | +| `resources/prompts/translator.txt` | 模板:翻译 | +| `service/PromptTemplateService.java` | 模板加载 + 替换 | + +### 思考题 + +> 现在的模板替换是最简单的字符串替换。如果模板中需要支持条件分支(如 `{{#if userLevel}}...{{/if}}`),你会怎么设计?这引出了真正的模板引擎(如 Mustache、Handlebars)——Spring AI 也有内置的 PromptTemplate 类,去看看它的源码。 + +--- + +## Week 10 总结 + +### 技术栈全景 + +``` +浏览器 (agent.html) + │ fetch + SSE + ▼ +ChatController + ├─ POST /api/chat ──────────→ ChatService ──→ ChatClient (普通聊天) + ├─ POST /api/agent ─────────→ AgentService ──→ ChatClient + Tools (Agent) + ├─ POST /api/rag/search ────→ RAGService ────→ VectorStore (检索) + ├─ POST /api/chat/with-template → PromptTemplateService (模板) + └─ GET/DELETE /api/chat/history → ChatService ──→ Redis (持久化) + │ + ▼ + ChatClient (Spring AI) + ├─ ChatModel ────→ DeepSeek API (对话) + ├─ Tools ────────→ Weather / Calculator / Search + ├─ Advisors ────→ ChatMemory (Redis) + QuestionAnswerAdvisor (RAG) + └─ EmbeddingModel → Ollama / 百炼 (向量化) +``` + +### 能力清单 + +| 维度 | 掌握内容 | +|------|---------| +| Agent 概念 | ReAct 范式、Plan-Execute 范式、Tool Calling 流程 | +| Function Calling | @Tool 注解、工具定义 JSON Schema、多工具协作 | +| RAG | EmbeddingModel、VectorStore、QuestionAnswerAdvisor、相似度检索 | +| 向量存储 | SimpleVectorStore、相似度阈值、手动创建 EmbeddingModel | +| 记忆持久化 | ChatMemoryRepository 接口、Redis 自定义实现、双层架构 | +| Prompt 模板 | 文件加载、占位符替换、多角色切换 | +| 架构设计 | 双 Provider(Chat + Embedding 分离)、接口抽象、插件扩展 | + +### vs Week 9 + +| | Week 9 | Week 10 | +|------|--------|--------| +| AI 角色 | 聊天机器人 | AI Agent | +| 核心接口 | ChatClient.call() | ChatClient.call() + Tools | +| 外部能力 | 无 | 天气、计算、搜索 | +| 记忆 | 内存(重启丢失) | Redis(持久化) | +| 知识 | 训练数据 | 训练数据 + 自定义知识库 | +| 角色切换 | 改代码 | 前端动态切换模板 | +| Embedding | 不需要 | Ollama / 百炼 | + +### 常见问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| Agent 不调用工具 | 工具未注册到 defaultTools | 检查 AIConfig 中 `.defaultTools(...)` 是否包含该工具 | +| Agent 调错工具 | @Tool description 描述不清晰 | 改写 description,让模型更能区分 | +| RAG 检索结果为空 | 1. Ollama 未安装 2. 未 pull 模型 | `ollama list` 检查是否有 nomic-embed-text | +| Redis 连接失败 | Redis 未启动 | 启动 redis-server,检查端口 6379 | +| 启动报 Redis 错误 | 未安装 Redis | Day 5 前可临时注释 Redis 依赖 | +| RAG 检索结果不相关 | 知识库文档太少 | 增加更多覆盖不同主题的文档 | +| Embedding 返回 404 | base-url 不对 | 确认 Ollama URL 为 `http://localhost:11434/v1` | +| 模板选择后没效果 | 前端只是拼接,ChatClient 没用到模板 | 检查 `/api/chat/with-template` 是否正常拼接 | +| mvn compile 失败 | BOM 或依赖版本冲突 | 运行 `mvn clean compile`,确认 pom.xml 中无冲突 | + +--- + +> **本周核心**:你把一个"只会聊天"的 AI 升级成了"能干活"的 Agent。从 Function Calling 让模型自主调用工具,到 RAG 让模型基于你的知识库回答问题,再到 Redis 持久化让记忆永不丢失,最后用 Prompt 模板实现一键切换角色。你掌握了 AI Agent 的四大核心能力——**工具、检索、记忆、模板**。这四块积木可以组合出无数种 Agent 应用。 + +--- + +> **下一阶段:Week 11 — 高级 RAG + Agent 实战**。在 Agent 基础上,加入多模态(图片识别)、高级 RAG 策略(Query 重写、混合检索、结果重排序)、Agent 记忆管理等进阶内容。 diff --git a/week9/pom.xml b/week9/pom.xml new file mode 100644 index 0000000..d973115 --- /dev/null +++ b/week9/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + + com.learn + week9-ai-chat + 1.0.0 + Week 9: AI and LLM Basics - Chat Bot + + + 17 + 1.0.6 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.ai + spring-ai-starter-model-openai + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week9/src/main/java/com/learn/Week9Application.java b/week9/src/main/java/com/learn/Week9Application.java new file mode 100644 index 0000000..7224a0e --- /dev/null +++ b/week9/src/main/java/com/learn/Week9Application.java @@ -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); + } +} diff --git a/week9/src/main/java/com/learn/config/AIConfig.java b/week9/src/main/java/com/learn/config/AIConfig.java new file mode 100644 index 0000000..2648358 --- /dev/null +++ b/week9/src/main/java/com/learn/config/AIConfig.java @@ -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(); + } +} diff --git a/week9/src/main/java/com/learn/controller/ChatController.java b/week9/src/main/java/com/learn/controller/ChatController.java new file mode 100644 index 0000000..2403167 --- /dev/null +++ b/week9/src/main/java/com/learn/controller/ChatController.java @@ -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> 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> chatStream(@Valid @RequestBody ChatRequest request) { + return chatService.chatStream(request.getMessage(), request.getConversationId()) + .map(token -> ServerSentEvent.builder() + .data(token) + .build()) + .concatWith(Mono.just(ServerSentEvent.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); + } +} diff --git a/week9/src/main/java/com/learn/dto/ApiResponse.java b/week9/src/main/java/com/learn/dto/ApiResponse.java new file mode 100644 index 0000000..19864ed --- /dev/null +++ b/week9/src/main/java/com/learn/dto/ApiResponse.java @@ -0,0 +1,61 @@ +package com.learn.dto; + +import java.util.HashMap; +import java.util.Map; + +public class ApiResponse { + + private int code; + private String message; + private T data; + private Map extra; + + public ApiResponse() {} + + public ApiResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "success", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + public static ApiResponse created(T data) { + return new ApiResponse<>(201, "created", data); + } + + public static ApiResponse error(int code, String message) { + return new ApiResponse<>(code, message, null); + } + + public static ApiResponse notFound(String message) { + return new ApiResponse<>(404, message, null); + } + + public static ApiResponse badRequest(String message) { + return new ApiResponse<>(400, message, null); + } + + public ApiResponse 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 getExtra() { return extra; } + public void setExtra(Map extra) { this.extra = extra; } +} diff --git a/week9/src/main/java/com/learn/dto/ChatHistoryVo.java b/week9/src/main/java/com/learn/dto/ChatHistoryVo.java new file mode 100644 index 0000000..c2df89c --- /dev/null +++ b/week9/src/main/java/com/learn/dto/ChatHistoryVo.java @@ -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> 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> getMessages() { return messages; } + public void setMessages(List> messages) { this.messages = messages; } +} diff --git a/week9/src/main/java/com/learn/dto/ChatRequest.java b/week9/src/main/java/com/learn/dto/ChatRequest.java new file mode 100644 index 0000000..6d634e7 --- /dev/null +++ b/week9/src/main/java/com/learn/dto/ChatRequest.java @@ -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; } +} diff --git a/week9/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week9/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..918afc0 --- /dev/null +++ b/week9/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -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> 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> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest(ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + log.error("Unexpected error", ex); + return ResponseEntity.status(500) + .body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage())); + } +} diff --git a/week9/src/main/java/com/learn/service/ChatService.java b/week9/src/main/java/com/learn/service/ChatService.java new file mode 100644 index 0000000..fcd13b3 --- /dev/null +++ b/week9/src/main/java/com/learn/service/ChatService.java @@ -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 逐 token 流 + */ + public Flux 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>(); + for (var msg : messages) { + var item = new HashMap(); + 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 listConversations() { + // InMemoryChatMemory 没有直接列出所有 key 的方法 + // 这里用一个简化方案:客户端自行管理会话 ID + return List.of(); + } + + private String defaultIfNull(String id) { + return (id == null || id.isBlank()) ? "default" : id; + } +} diff --git a/week9/src/main/resources/application.yml b/week9/src/main/resources/application.yml new file mode 100644 index 0000000..0b562a6 --- /dev/null +++ b/week9/src/main/resources/application.yml @@ -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 diff --git a/week9/src/main/resources/static/chat.html b/week9/src/main/resources/static/chat.html new file mode 100644 index 0000000..7e16047 --- /dev/null +++ b/week9/src/main/resources/static/chat.html @@ -0,0 +1,70 @@ + + + + + + AI 聊天机器人 - Week 9 + + + + + + + + + + + + ☰ + AI 聊天机器人 + Week 9 — Spring AI + + + + + 🤖 + 你好!我是小智 + 我是你的 AI 助手,有什么可以帮你的? + + + + + + + + 流式输出 + + 清除历史 + + + + 发送 + + + + + + + + diff --git a/week9/src/main/resources/static/css/chat.css b/week9/src/main/resources/static/css/chat.css new file mode 100644 index 0000000..e6c844b --- /dev/null +++ b/week9/src/main/resources/static/css/chat.css @@ -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%; } +} diff --git a/week9/src/main/resources/static/js/chat.js b/week9/src/main/resources/static/js/chat.js new file mode 100644 index 0000000..cfd9e7a --- /dev/null +++ b/week9/src/main/resources/static/js/chat.js @@ -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 = ` + + 🤖 + 会话已切换 + 当前会话: ${escapeHtml(conversations.get(id)?.title || id)} + `; + renderConversationList(); +} + +function newConversation() { + const id = generateId(); + conversations.set(id, { title: '新对话', lastTime: Date.now() }); + currentConversationId = id; + messagesEl.innerHTML = ` + + 💬 + 新对话已创建 + 开始和 AI 聊天吧! + `; + 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 = ` + + 🧹 + 历史已清除 + 当前会话的记忆已清空,开始新的对话吧! + `; +} + +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 = ` + ${escapeHtml(conv.title)} + ${id !== 'default' ? `×` : ''}`; + + 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(); diff --git a/week9/教案.md b/week9/教案.md new file mode 100644 index 0000000..c229baa --- /dev/null +++ b/week9/教案.md @@ -0,0 +1,626 @@ +# Week 9:AI & 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 1:AI/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 2:Prompt 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 3:LLM 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` + - Body(raw 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 4:Spring AI 框架入门 + +### Spring AI 是什么? + +Spring AI 是 Spring 生态的 AI 集成框架。它做了一件和 Spring MVC / Spring Data 类似的事:**提供统一抽象,屏蔽底层差异**。 + +``` +你的代码 + ↓ 调用 +ChatClient(Spring AI 统一 API,Fluent 风格) + ↓ 委托 +OpenAiChatModel(OpenAI 协议实现) + ↓ HTTP +任何 OpenAI 兼容的 API(OpenAI / 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 6:Streaming 流式响应 / SSE + +### 为什么需要流式? + +非流式:等 AI 完整生成回复 → 一次性返回 → 用户等着 +流式:AI 生成一个 Token → 立即返回一个 Token → 用户看到打字机效果 + +对于长回复,流式可以把"首次响应时间"从 30 秒降到 0.5 秒。 + +### SSE(Server-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 —— 每个元素是一个 Token +public Flux chatStream(String message, String conversationId) { + return chatClient.prompt() + .user(message) + .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .stream() // ← 换成 .stream() + .content(); // 返回 Flux 而不是 String +} +``` + +**对比**: + +| | 同步 | 流式 | +|------|------|------| +| 方法 | `.call().content()` | `.stream().content()` | +| 返回类型 | `String` | `Flux` | +| 响应时机 | 全部生成完才返回 | 生成一个 token 返回一个 | +| 用户体验 | 等待 | 打字机效果 | + +### Controller 中的 SSE 端点 + +```java +@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) +public Flux> chatStream(@Valid @RequestBody ChatRequest request) { + return chatService.chatStream(...) + .map(token -> ServerSentEvent.builder().data(token).build()) + .concatWith(Mono.just(ServerSentEvent.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"。
我可以调用工具(天气、计算、搜索)来回答你的问题。
我是你的 AI 助手,有什么可以帮你的?
当前会话: ${escapeHtml(conversations.get(id)?.title || id)}
开始和 AI 聊天吧!
当前会话的记忆已清空,开始新的对话吧!