tmp
This commit is contained in:
85
week10/pom.xml
Normal file
85
week10/pom.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.learn</groupId>
|
||||
<artifactId>week10-ai-agent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Week 10: AI Agent Core Concepts</name>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring-ai.version>1.0.6</spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI: Chat -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- SSE Streaming -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RAG: Vector Store Advisor -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-advisors-vector-store</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis: Memory persistence -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
11
week10/src/main/java/com/learn/Week10Application.java
Normal file
11
week10/src/main/java/com/learn/Week10Application.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
129
week10/src/main/java/com/learn/config/AIConfig.java
Normal file
129
week10/src/main/java/com/learn/config/AIConfig.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
31
week10/src/main/java/com/learn/config/RedisConfig.java
Normal file
31
week10/src/main/java/com/learn/config/RedisConfig.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
166
week10/src/main/java/com/learn/controller/ChatController.java
Normal file
166
week10/src/main/java/com/learn/controller/ChatController.java
Normal file
@@ -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<Map<String, String>> 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<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
|
||||
return chatService.chatStream(request.getMessage(), request.getConversationId())
|
||||
.map(token -> ServerSentEvent.<String>builder().data(token).build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>builder()
|
||||
.event("done").data("[DONE]").build()));
|
||||
}
|
||||
|
||||
@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<Map<String, Object>> 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<ServerSentEvent<String>> agentStream(@Valid @RequestBody ChatRequest request) {
|
||||
return agentService.executeStream(request.getMessage(), request.getConversationId())
|
||||
.map(token -> ServerSentEvent.<String>builder().data(token).build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>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<List<Map<String, Object>>> 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<Map<String, Object>> ragLoad(@RequestBody List<Map<String, Object>> documents) {
|
||||
var docs = documents.stream()
|
||||
.map(m -> {
|
||||
String text = (String) m.get("text");
|
||||
@SuppressWarnings("unchecked")
|
||||
var metadata = (Map<String, Object>) 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<List<Map<String, String>>> listPrompts() {
|
||||
return ApiResponse.success(promptService.list());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定模板的原始内容。
|
||||
*
|
||||
* GET /api/prompts/{name}
|
||||
*/
|
||||
@GetMapping("/api/prompts/{name}")
|
||||
public ApiResponse<Map<String, String>> 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<Map<String, String>> chatWithTemplate(@RequestBody Map<String, Object> 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<String, String>) 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));
|
||||
}
|
||||
}
|
||||
59
week10/src/main/java/com/learn/dto/ApiResponse.java
Normal file
59
week10/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
public ApiResponse() {}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(200, "success", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(200, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> created(T data) {
|
||||
return new ApiResponse<>(201, "created", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> notFound(String message) {
|
||||
return new ApiResponse<>(404, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> badRequest(String message) {
|
||||
return new ApiResponse<>(400, message, null);
|
||||
}
|
||||
|
||||
public ApiResponse<T> put(String key, Object value) {
|
||||
if (this.extra == null) this.extra = new HashMap<>();
|
||||
this.extra.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public void setCode(int code) { this.code = code; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public T getData() { return data; }
|
||||
public void setData(T data) { this.data = data; }
|
||||
public Map<String, Object> getExtra() { return extra; }
|
||||
public void setExtra(Map<String, Object> extra) { this.extra = extra; }
|
||||
}
|
||||
18
week10/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
18
week10/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ChatHistoryVo {
|
||||
|
||||
private String conversationId;
|
||||
private int messageCount;
|
||||
private List<Map<String, Object>> messages;
|
||||
|
||||
public String getConversationId() { return conversationId; }
|
||||
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
|
||||
public int getMessageCount() { return messageCount; }
|
||||
public void setMessageCount(int messageCount) { this.messageCount = messageCount; }
|
||||
public List<Map<String, Object>> getMessages() { return messages; }
|
||||
public void setMessages(List<Map<String, Object>> messages) { this.messages = messages; }
|
||||
}
|
||||
15
week10/src/main/java/com/learn/dto/ChatRequest.java
Normal file
15
week10/src/main/java/com/learn/dto/ChatRequest.java
Normal file
@@ -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; }
|
||||
}
|
||||
16
week10/src/main/java/com/learn/dto/RAGQueryRequest.java
Normal file
16
week10/src/main/java/com/learn/dto/RAGQueryRequest.java
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -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<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
var errors = new StringBuilder();
|
||||
for (var error : ex.getBindingResult().getFieldErrors()) {
|
||||
if (!errors.isEmpty()) errors.append("; ");
|
||||
errors.append(error.getField()).append(": ").append(error.getDefaultMessage());
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("参数校验失败: " + errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(org.springframework.ai.retry.NonTransientAiException.class)
|
||||
public ResponseEntity<ApiResponse<?>> 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<ApiResponse<?>> handleAll(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -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:<conversationId> → 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<String> findConversationIds() {
|
||||
var ids = redis.opsForSet().members(IDS_KEY);
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return ids.stream().sorted().toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> 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<Message>();
|
||||
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<Message> 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);
|
||||
}
|
||||
}
|
||||
74
week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java
Normal file
74
week10/src/main/java/com/learn/rag/KnowledgeDataLoader.java
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
70
week10/src/main/java/com/learn/service/AgentService.java
Normal file
70
week10/src/main/java/com/learn/service/AgentService.java
Normal file
@@ -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<String, Object> 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<String> 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;
|
||||
}
|
||||
}
|
||||
85
week10/src/main/java/com/learn/service/ChatService.java
Normal file
85
week10/src/main/java/com/learn/service/ChatService.java
Normal file
@@ -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<String> 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<Map<String, Object>>();
|
||||
for (var msg : messages) {
|
||||
var item = new HashMap<String, Object>();
|
||||
item.put("role", msg.getMessageType().name().toLowerCase());
|
||||
item.put("content", msg.getText());
|
||||
list.add(item);
|
||||
}
|
||||
ChatHistoryVo vo = new ChatHistoryVo();
|
||||
vo.setConversationId(convId);
|
||||
vo.setMessageCount(list.size());
|
||||
vo.setMessages(list);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 清除历史 */
|
||||
public void clearHistory(String conversationId) {
|
||||
chatMemory.clear(defaultIfNull(conversationId));
|
||||
}
|
||||
|
||||
private String defaultIfNull(String id) {
|
||||
return (id == null || id.isBlank()) ? "default" : id;
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<Map<String, String>> 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<String, String> params) {
|
||||
String content = getTemplate(name);
|
||||
for (var entry : params.entrySet()) {
|
||||
content = content.replace("{{" + entry.getKey() + "}}", entry.getValue());
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
63
week10/src/main/java/com/learn/service/RAGService.java
Normal file
63
week10/src/main/java/com/learn/service/RAGService.java
Normal file
@@ -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<Map<String, Object>> 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.<String, Object>of(
|
||||
"content", doc.getText(),
|
||||
"score", String.format("%.4f", doc.getScore()),
|
||||
"metadata", doc.getMetadata()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 向知识库添加文档。
|
||||
*/
|
||||
public int addDocuments(List<Document> documents) {
|
||||
vectorStore.add(documents);
|
||||
log.info("Added {} documents to vector store", documents.size());
|
||||
return documents.size();
|
||||
}
|
||||
}
|
||||
29
week10/src/main/java/com/learn/tool/CalculatorTool.java
Normal file
29
week10/src/main/java/com/learn/tool/CalculatorTool.java
Normal file
@@ -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'。";
|
||||
}
|
||||
}
|
||||
}
|
||||
34
week10/src/main/java/com/learn/tool/SearchTool.java
Normal file
34
week10/src/main/java/com/learn/tool/SearchTool.java
Normal file
@@ -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。";
|
||||
};
|
||||
}
|
||||
}
|
||||
40
week10/src/main/java/com/learn/tool/WeatherTool.java
Normal file
40
week10/src/main/java/com/learn/tool/WeatherTool.java
Normal file
@@ -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<String, String> 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();
|
||||
}
|
||||
}
|
||||
51
week10/src/main/resources/application.yml
Normal file
51
week10/src/main/resources/application.yml
Normal file
@@ -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
|
||||
22
week10/src/main/resources/prompts/code-reviewer.txt
Normal file
22
week10/src/main/resources/prompts/code-reviewer.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
你是一位严格的代码审查专家,擅长发现代码中的潜在问题。
|
||||
|
||||
请从以下维度审查用户提供的代码:
|
||||
|
||||
1. **正确性**: 逻辑是否有误?边界条件是否处理?
|
||||
2. **安全性**: 是否存在 OWASP Top 10 漏洞?SQL 注入、XSS、敏感信息泄露等
|
||||
3. **性能**: 是否有 N+1 查询、内存泄漏、不必要的对象创建?
|
||||
4. **可维护性**: 命名是否清晰?职责是否单一?耦合是否过高?
|
||||
5. **规范**: 是否符合 Java/Spring 编码规范?
|
||||
|
||||
审查报告格式:
|
||||
|
||||
### 严重问题(必须修复)
|
||||
- [ ] 问题描述 + 修复建议
|
||||
|
||||
### 改进建议(推荐优化)
|
||||
- [ ] 问题描述 + 优化方案
|
||||
|
||||
### 亮点(做得好的地方)
|
||||
- [ ] ...
|
||||
|
||||
请直接对用户后续发送的代码进行审查。
|
||||
14
week10/src/main/resources/prompts/java-expert.txt
Normal file
14
week10/src/main/resources/prompts/java-expert.txt
Normal file
@@ -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 句话总结核心观点
|
||||
- 再用代码示例说明
|
||||
- 最后补充注意事项和替代方案
|
||||
10
week10/src/main/resources/prompts/translator.txt
Normal file
10
week10/src/main/resources/prompts/translator.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
你是一位专业的中英文翻译专家。
|
||||
|
||||
翻译规则:
|
||||
|
||||
1. **中文→英文**: 使用地道的英语表达,避免中式英语
|
||||
2. **英文→中文**: 使用流畅的自然中文,避免翻译腔
|
||||
3. **技术术语**: 保持专业术语的准确性
|
||||
4. **格式保留**: 保持原文的空行、列表、代码块等格式
|
||||
|
||||
请直接输出翻译结果,无需额外解释。
|
||||
94
week10/src/main/resources/static/agent.html
Normal file
94
week10/src/main/resources/static/agent.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小智 Agent — Week 10 AI Agent 控制台</title>
|
||||
<link rel="stylesheet" href="/css/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>小智 Agent</h2>
|
||||
<span class="badge">Week 10</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话管理 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>会话</h3>
|
||||
<button class="btn btn-sm btn-secondary" onclick="newConversation()">+ 新会话</button>
|
||||
<ul class="conv-list" id="convList">
|
||||
<li class="conv-item active" data-id="default" onclick="switchConv('default')">默认会话</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Prompt 模板 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>Prompt 模板</h3>
|
||||
<select id="templateSelect" onchange="selectTemplate(this.value)">
|
||||
<option value="">— 不使用模板 —</option>
|
||||
</select>
|
||||
<div id="templatePreview" class="template-preview hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- 工具状态 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>可用工具</h3>
|
||||
<div class="tool-list" id="toolList">
|
||||
<div class="tool-item"><span class="tool-icon">🌤️</span> WeatherTool</div>
|
||||
<div class="tool-item"><span class="tool-icon">🔢</span> CalculatorTool</div>
|
||||
<div class="tool-item"><span class="tool-icon">🔍</span> SearchTool</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置 -->
|
||||
<div class="sidebar-section">
|
||||
<h3>模式</h3>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="streamToggle" checked>
|
||||
<span>流式输出 (SSE)</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="agentToggle" checked>
|
||||
<span>Agent 模式</span>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<main class="main">
|
||||
<div class="chat-container">
|
||||
<div class="chat-header">
|
||||
<h1 id="chatTitle">Agent 控制台</h1>
|
||||
<span class="conv-id" id="convIdLabel">会话: default</span>
|
||||
</div>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
<div class="welcome">
|
||||
<h2>欢迎使用 AI Agent 控制台</h2>
|
||||
<p>我可以调用工具(天气、计算、搜索)来回答你的问题。</p>
|
||||
<div class="sample-prompts">
|
||||
<h4>试试这些:</h4>
|
||||
<button onclick="sendSample('北京今天天气如何?')">🌤️ 查天气</button>
|
||||
<button onclick="sendSample('帮我算一下 123 * 456 等于多少')">🔢 计算</button>
|
||||
<button onclick="sendSample('搜索一下 Spring AI 的最新信息')">🔍 搜索</button>
|
||||
<button onclick="sendSample('什么是 Function Calling?')">📚 RAG 知识库</button>
|
||||
<button onclick="sendSample('北京天气如何?顺便帮我算一下 999 * 888')">🤖 多工具协作</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="userInput" placeholder="输入你的消息..." rows="2"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
|
||||
<button class="btn btn-primary" onclick="sendMessage()" id="sendBtn">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
307
week10/src/main/resources/static/css/agent.css
Normal file
307
week10/src/main/resources/static/css/agent.css
Normal file
@@ -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%; }
|
||||
}
|
||||
231
week10/src/main/resources/static/js/agent.js
Normal file
231
week10/src/main/resources/static/js/agent.js
Normal file
@@ -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 =>
|
||||
`<li class="conv-item${id === state.conversationId ? ' active' : ''}"
|
||||
data-id="${id}" onclick="switchConv('${id}')">${id === 'default' ? '默认会话' : id}</li>`
|
||||
).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 = '<div class="dots"><span></span><span></span><span></span></div>';
|
||||
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.
|
||||
856
week10/教案.md
Normal file
856
week10/教案.md
Normal file
@@ -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<String, Object> 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<String, Object> redisTemplate;
|
||||
private static final String KEY_PREFIX = "chat:memory:";
|
||||
private static final String ID_SET_KEY = "chat:conversation:ids";
|
||||
|
||||
@Override
|
||||
public List<Message> findByConversationId(String id) {
|
||||
// LRANGE 读取整个 List → JSON 反序列化 → List<Message>
|
||||
var jsonList = redisTemplate.opsForList().range(KEY_PREFIX + id, 0, -1);
|
||||
// ... Jackson 反序列化
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAll(String id, List<Message> 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<String> 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<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
|
||||
Jackson2JsonRedisSerializer<Object> 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<Map<String, String>> list() { ... } // 列出所有模板
|
||||
public String getTemplate(String name) { ... } // 获取模板内容
|
||||
public String apply(String name, Map<String, String> 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 记忆管理等进阶内容。
|
||||
75
week9/pom.xml
Normal file
75
week9/pom.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.learn</groupId>
|
||||
<artifactId>week9-ai-chat</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Week 9: AI and LLM Basics - Chat Bot</name>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring-ai.version>1.0.6</spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Web + Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI: OpenAI-compatible chat model -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebFlux for SSE streaming (Day 6) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
11
week9/src/main/java/com/learn/Week9Application.java
Normal file
11
week9/src/main/java/com/learn/Week9Application.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.learn;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Week9Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Week9Application.class, args);
|
||||
}
|
||||
}
|
||||
80
week9/src/main/java/com/learn/config/AIConfig.java
Normal file
80
week9/src/main/java/com/learn/config/AIConfig.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.learn.config;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
|
||||
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Spring AI 配置类(手动装配,便于理解各组件的关系)
|
||||
*
|
||||
* 架构:
|
||||
* ChatModel (OpenAiChatModel) ← 底层 HTTP 通信,由 application.yml 自动配置
|
||||
* ↓ 包装
|
||||
* ChatClient ← 高级 Fluent API,我们手动创建
|
||||
* ↓ 拦截
|
||||
* MessageChatMemoryAdvisor ← 自动注入对话历史(多轮对话)
|
||||
* ↓ 写入 / 读取
|
||||
* ChatMemory (MessageWindowChatMemory) ← 滑动窗口记忆(默认保留最近 20 条)
|
||||
* ↓ 存储
|
||||
* ChatMemoryRepository (InMemoryChatMemoryRepository) ← ConcurrentHashMap 存储
|
||||
*
|
||||
* Spring AI 1.0.6 的架构变化:
|
||||
* - 旧版 InMemoryChatMemory 被拆分为两个角色:
|
||||
* ChatMemory(记忆策略)+ ChatMemoryRepository(存储实现)
|
||||
* - MessageChatMemoryAdvisor 构造函数私有化,必须用 Builder 创建
|
||||
* - 会话 ID 常量从 AbstractChatMemoryAdvisor 迁移到 ChatMemory 接口
|
||||
*/
|
||||
@Configuration
|
||||
public class AIConfig {
|
||||
|
||||
/**
|
||||
* 对话记忆存储(内存实现,重启丢失)。
|
||||
*
|
||||
* InMemoryChatMemoryRepository = ConcurrentHashMap 包装
|
||||
* MessageWindowChatMemory = 滑动窗口策略(只保留最近 N 条消息)
|
||||
*
|
||||
* 生产环境可替换为 JdbcChatMemoryRepository 实现持久化。
|
||||
*/
|
||||
@Bean
|
||||
public ChatMemory chatMemory() {
|
||||
var repository = new InMemoryChatMemoryRepository();
|
||||
return MessageWindowChatMemory.builder()
|
||||
.chatMemoryRepository(repository)
|
||||
.maxMessages(20) // 每个会话最多保留 20 条历史消息
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatClient —— Spring AI 的推荐入口
|
||||
*
|
||||
* 构建步骤:
|
||||
* 1. ChatClient.builder(chatModel) —— 绑定底层的 ChatModel 实现
|
||||
* 2. .defaultSystem(...) —— 设置系统级角色 Prompt
|
||||
* 3. .defaultAdvisors(...) —— 注册 Advisor 拦截器链(如记忆管理)
|
||||
* 4. .build() —— 创建不可变实例
|
||||
*
|
||||
* 关键点说明:
|
||||
* - ChatClient 是线程安全的,整个应用共用一个单例
|
||||
* - .defaultSystem() 对所有通过此 ChatClient 的请求生效
|
||||
* - MessageChatMemoryAdvisor 会在每次请求前自动注入历史消息
|
||||
* - 单次请求可通过 .system() 覆盖默认 System Prompt
|
||||
*/
|
||||
@Bean
|
||||
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
|
||||
return ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
你是一个友好的 AI 助手,名字叫"小智"。
|
||||
请用中文回答用户的问题。
|
||||
如果你不知道答案,诚实地告诉用户,不要编造信息。
|
||||
""")
|
||||
.defaultAdvisors(
|
||||
MessageChatMemoryAdvisor.builder(chatMemory).build()
|
||||
)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
93
week9/src/main/java/com/learn/controller/ChatController.java
Normal file
93
week9/src/main/java/com/learn/controller/ChatController.java
Normal file
@@ -0,0 +1,93 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.dto.ChatRequest;
|
||||
import com.learn.service.ChatService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chat")
|
||||
public class ChatController {
|
||||
|
||||
private final ChatService chatService;
|
||||
|
||||
public ChatController(ChatService chatService) {
|
||||
this.chatService = chatService;
|
||||
}
|
||||
|
||||
// ==================== Day 5: 同步对话 ====================
|
||||
|
||||
/**
|
||||
* 同步聊天 —— 发送完整消息,等待完整回复。
|
||||
*
|
||||
* 请求: POST /api/chat
|
||||
* Body: {"message": "你好", "conversationId": "会话ID(可选)"}
|
||||
* 响应: {"code": 200, "data": {"reply": "你好!有什么可以帮你的?", "conversationId": "xxx"}}
|
||||
*/
|
||||
@PostMapping
|
||||
public ApiResponse<Map<String, String>> chat(@Valid @RequestBody ChatRequest request) {
|
||||
String conversationId = request.getConversationId() != null
|
||||
? request.getConversationId() : "default";
|
||||
String reply = chatService.chat(request.getMessage(), conversationId);
|
||||
return ApiResponse.success(Map.of(
|
||||
"reply", reply,
|
||||
"conversationId", conversationId
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Day 6: 流式对话 (SSE) ====================
|
||||
|
||||
/**
|
||||
* 流式聊天 —— 逐 token 返回,打字机效果。
|
||||
*
|
||||
* 请求: POST /api/chat/stream
|
||||
* Body: 同 /api/chat
|
||||
* 响应: text/event-stream
|
||||
* data: 你
|
||||
* data: 好
|
||||
* data: !
|
||||
* event: done
|
||||
* data: [DONE]
|
||||
*/
|
||||
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
|
||||
return chatService.chatStream(request.getMessage(), request.getConversationId())
|
||||
.map(token -> ServerSentEvent.<String>builder()
|
||||
.data(token)
|
||||
.build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>builder()
|
||||
.event("done")
|
||||
.data("[DONE]")
|
||||
.build()));
|
||||
}
|
||||
|
||||
// ==================== Day 7: 历史管理 ====================
|
||||
|
||||
/**
|
||||
* 查询某个会话的历史消息。
|
||||
*
|
||||
* GET /api/chat/history?conversationId=xxx
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
public ApiResponse<?> getHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
return ApiResponse.success(chatService.getHistory(conversationId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个会话的历史。
|
||||
*
|
||||
* DELETE /api/chat/history?conversationId=xxx
|
||||
*/
|
||||
@DeleteMapping("/history")
|
||||
public ApiResponse<?> clearHistory(@RequestParam(defaultValue = "default") String conversationId) {
|
||||
chatService.clearHistory(conversationId);
|
||||
return ApiResponse.success("已清除会话: " + conversationId);
|
||||
}
|
||||
}
|
||||
61
week9/src/main/java/com/learn/dto/ApiResponse.java
Normal file
61
week9/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
public ApiResponse() {}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(200, "success", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>(200, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> created(T data) {
|
||||
return new ApiResponse<>(201, "created", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> notFound(String message) {
|
||||
return new ApiResponse<>(404, message, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> badRequest(String message) {
|
||||
return new ApiResponse<>(400, message, null);
|
||||
}
|
||||
|
||||
public ApiResponse<T> put(String key, Object value) {
|
||||
if (this.extra == null) {
|
||||
this.extra = new HashMap<>();
|
||||
}
|
||||
this.extra.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getCode() { return code; }
|
||||
public void setCode(int code) { this.code = code; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public T getData() { return data; }
|
||||
public void setData(T data) { this.data = data; }
|
||||
public Map<String, Object> getExtra() { return extra; }
|
||||
public void setExtra(Map<String, Object> extra) { this.extra = extra; }
|
||||
}
|
||||
18
week9/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
18
week9/src/main/java/com/learn/dto/ChatHistoryVo.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class ChatHistoryVo {
|
||||
|
||||
private String conversationId;
|
||||
private int messageCount;
|
||||
private List<Map<String, Object>> messages;
|
||||
|
||||
public String getConversationId() { return conversationId; }
|
||||
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
|
||||
public int getMessageCount() { return messageCount; }
|
||||
public void setMessageCount(int messageCount) { this.messageCount = messageCount; }
|
||||
public List<Map<String, Object>> getMessages() { return messages; }
|
||||
public void setMessages(List<Map<String, Object>> messages) { this.messages = messages; }
|
||||
}
|
||||
16
week9/src/main/java/com/learn/dto/ChatRequest.java
Normal file
16
week9/src/main/java/com/learn/dto/ChatRequest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class ChatRequest {
|
||||
|
||||
@NotBlank(message = "消息不能为空")
|
||||
private String message;
|
||||
|
||||
private String conversationId;
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public String getConversationId() { return conversationId; }
|
||||
public void setConversationId(String conversationId) { this.conversationId = conversationId; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.learn.exception;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
var errors = new StringBuilder();
|
||||
for (var error : ex.getBindingResult().getFieldErrors()) {
|
||||
if (!errors.isEmpty()) errors.append("; ");
|
||||
errors.append(error.getField()).append(": ").append(error.getDefaultMessage());
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("参数校验失败: " + errors));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
|
||||
log.error("Unexpected error", ex);
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
118
week9/src/main/java/com/learn/service/ChatService.java
Normal file
118
week9/src/main/java/com/learn/service/ChatService.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.learn.service;
|
||||
|
||||
import com.learn.dto.ChatHistoryVo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
|
||||
|
||||
@Service
|
||||
public class ChatService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final ChatMemory chatMemory;
|
||||
|
||||
public ChatService(ChatClient chatClient, ChatMemory chatMemory) {
|
||||
this.chatClient = chatClient;
|
||||
this.chatMemory = chatMemory;
|
||||
}
|
||||
|
||||
// ==================== Day 5: 同步对话 ====================
|
||||
|
||||
/**
|
||||
* 一问一答,等待完整回复后返回。
|
||||
*/
|
||||
public String chat(String message) {
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
// ==================== Day 6: 流式对话 ====================
|
||||
|
||||
/**
|
||||
* 流式对话,逐 token 返回,实现"打字机效果"。
|
||||
*
|
||||
* @param message 用户消息
|
||||
* @param conversationId 会话 ID(默认 "default")
|
||||
* @return Flux<String> 逐 token 流
|
||||
*/
|
||||
public Flux<String> chatStream(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.debug("Streaming chat for conversation: {}", convId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.stream()
|
||||
.content();
|
||||
}
|
||||
|
||||
// ==================== Day 7: 多轮对话 ====================
|
||||
|
||||
/**
|
||||
* 带上下文的多轮对话(非流式版)。
|
||||
*/
|
||||
public String chat(String message, String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
log.debug("Chat with memory, conversation: {}", convId);
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(CONVERSATION_ID, convId))
|
||||
.call()
|
||||
.content();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某个会话的历史消息。
|
||||
*/
|
||||
public ChatHistoryVo getHistory(String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
var messages = chatMemory.get(convId);
|
||||
var list = new ArrayList<Map<String, Object>>();
|
||||
for (var msg : messages) {
|
||||
var item = new HashMap<String, Object>();
|
||||
item.put("role", msg.getMessageType().name().toLowerCase());
|
||||
item.put("content", msg.getText());
|
||||
list.add(item);
|
||||
}
|
||||
ChatHistoryVo vo = new ChatHistoryVo();
|
||||
vo.setConversationId(convId);
|
||||
vo.setMessageCount(list.size());
|
||||
vo.setMessages(list);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除某个会话的所有历史。
|
||||
*/
|
||||
public void clearHistory(String conversationId) {
|
||||
String convId = defaultIfNull(conversationId);
|
||||
chatMemory.clear(convId);
|
||||
log.debug("Cleared history for conversation: {}", convId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有会话 ID 列表。
|
||||
*/
|
||||
public List<String> listConversations() {
|
||||
// InMemoryChatMemory 没有直接列出所有 key 的方法
|
||||
// 这里用一个简化方案:客户端自行管理会话 ID
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String defaultIfNull(String id) {
|
||||
return (id == null || id.isBlank()) ? "default" : id;
|
||||
}
|
||||
}
|
||||
38
week9/src/main/resources/application.yml
Normal file
38
week9/src/main/resources/application.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
spring:
|
||||
application:
|
||||
name: week9-ai-chat
|
||||
|
||||
# =============================================================
|
||||
# AI Provider Configuration (Spring AI)
|
||||
# =============================================================
|
||||
# 更换模型只需改两个地方:
|
||||
# 1. base-url → 服务商的 API 地址
|
||||
# 2. model → 模型名称
|
||||
#
|
||||
# 国内推荐 DeepSeek (注册送免费额度):
|
||||
# 注册地址: https://platform.deepseek.com
|
||||
# API Key 获取: 登录后 → API Keys → 创建
|
||||
#
|
||||
# 备选: 阿里云百炼 Qwen (通义千问)
|
||||
# 注册地址: https://bailian.console.aliyun.com
|
||||
#
|
||||
# 本地运行 (无需网络, 无需 API Key):
|
||||
# 安装 Ollama: https://ollama.com
|
||||
# 拉取模型: ollama pull qwen2.5:7b
|
||||
# =============================================================
|
||||
ai:
|
||||
openai:
|
||||
api-key: ${AI_API_KEY:sk-fe4d2f26eda04a659b9ff3408ce5024b}
|
||||
base-url: ${AI_BASE_URL:https://api.deepseek.com}
|
||||
chat:
|
||||
options:
|
||||
model: deepseek-chat
|
||||
temperature: 0.7
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.learn: DEBUG
|
||||
org.springframework.ai.chat.client.advisor: DEBUG
|
||||
70
week9/src/main/resources/static/chat.html
Normal file
70
week9/src/main/resources/static/chat.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 聊天机器人 - Week 9</title>
|
||||
<link rel="stylesheet" href="/css/chat.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<!-- 侧边栏(Day 7) -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>对话列表</h2>
|
||||
</div>
|
||||
<button class="btn-new-chat" onclick="newConversation()">+ 新建对话</button>
|
||||
<div class="conversation-list" id="conversation-list">
|
||||
<div class="conv-item active" data-id="default" onclick="switchConversation('default')">
|
||||
<span class="conv-title">默认对话</span>
|
||||
<button class="conv-delete" onclick="event.stopPropagation(); deleteConversation('default')" title="删除">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<select id="model-select" onchange="onModelChange()">
|
||||
<option value="deepseek-chat">DeepSeek-Chat</option>
|
||||
<option value="gpt-4o-mini">GPT-4o-mini</option>
|
||||
<option value="qwen-turbo">Qwen-Turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主聊天区 -->
|
||||
<main class="chat-area">
|
||||
<header class="chat-header">
|
||||
<button class="btn-toggle-sidebar" onclick="toggleSidebar()" title="切换侧边栏">☰</button>
|
||||
<h1>AI 聊天机器人</h1>
|
||||
<span class="subtitle">Week 9 — Spring AI</span>
|
||||
</header>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🤖</div>
|
||||
<h3>你好!我是小智</h3>
|
||||
<p>我是你的 AI 助手,有什么可以帮你的?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="input-toolbar">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="stream-mode" checked>
|
||||
<span>流式输出</span>
|
||||
</label>
|
||||
<button class="btn-clear" onclick="clearCurrentHistory()">清除历史</button>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea id="user-input"
|
||||
placeholder="输入消息,按 Enter 发送,Shift+Enter 换行..."
|
||||
rows="1"
|
||||
onkeydown="handleKeyDown(event)"></textarea>
|
||||
<button class="btn-send" id="send-btn" onclick="sendMessage()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
277
week9/src/main/resources/static/css/chat.css
Normal file
277
week9/src/main/resources/static/css/chat.css
Normal file
@@ -0,0 +1,277 @@
|
||||
/* ============================================================
|
||||
Week 9 Chat UI — AI 聊天机器人样式
|
||||
============================================================ */
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-260px);
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
height: 100%;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 16px; color: #f1f5f9; }
|
||||
|
||||
.btn-new-chat {
|
||||
margin: 12px 16px;
|
||||
padding: 10px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-new-chat:hover { background: #2563eb; }
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: #334155; }
|
||||
.conv-item.active { background: #3b82f6; color: #fff; }
|
||||
.conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conv-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.conv-item:hover .conv-delete { opacity: 0.6; }
|
||||
.conv-delete:hover { opacity: 1 !important; }
|
||||
|
||||
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #334155; }
|
||||
.sidebar-footer select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: #334155;
|
||||
color: #f1f5f9;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- Chat Area ---- */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.chat-header h1 { font-size: 18px; color: #1e293b; }
|
||||
.chat-header .subtitle { font-size: 13px; color: #94a3b8; }
|
||||
.btn-toggle-sidebar {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
color: #64748b;
|
||||
}
|
||||
.btn-toggle-sidebar:hover { background: #e2e8f0; }
|
||||
|
||||
/* ---- Messages ---- */
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.welcome-msg {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.welcome-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.welcome-msg h3 { font-size: 20px; color: #475569; margin-bottom: 6px; }
|
||||
.welcome-msg p { font-size: 14px; }
|
||||
|
||||
/* ---- Message Bubbles ---- */
|
||||
.msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 80%;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg.assistant .avatar { background: #e0e7ff; }
|
||||
.msg.user .avatar { background: #dbeafe; }
|
||||
|
||||
.msg .bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.assistant .bubble {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.user .bubble {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* Streaming cursor */
|
||||
.msg.streaming .bubble::after {
|
||||
content: '▊';
|
||||
animation: blink 1s infinite;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.msg.streaming.user .bubble::after { color: #fff; }
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- Input Area ---- */
|
||||
.input-area {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.input-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.btn-clear:hover { color: #ef4444; }
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
#user-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
max-height: 120px;
|
||||
}
|
||||
#user-input:focus { border-color: #3b82f6; }
|
||||
|
||||
.btn-send {
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-send:hover { background: #2563eb; }
|
||||
.btn-send:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 640px) {
|
||||
.sidebar { display: none; }
|
||||
.msg { max-width: 90%; }
|
||||
}
|
||||
272
week9/src/main/resources/static/js/chat.js
Normal file
272
week9/src/main/resources/static/js/chat.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Week 9 Chat JS — AI 聊天机器人前端逻辑
|
||||
*
|
||||
* 涵盖 Day 5 (同步), Day 6 (SSE 流式), Day 7 (多轮对话管理)
|
||||
*/
|
||||
|
||||
// ---- 全局状态 ----
|
||||
let currentConversationId = 'default';
|
||||
const conversations = new Map(); // id -> { title, lastTime }
|
||||
conversations.set('default', { title: '默认对话', lastTime: Date.now() });
|
||||
|
||||
// ---- DOM 引用 ----
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const inputEl = document.getElementById('user-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const streamToggle = document.getElementById('stream-mode');
|
||||
const convListEl = document.getElementById('conversation-list');
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function generateId() {
|
||||
return 'conv_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ---- UI 渲染 ----
|
||||
function removeWelcome() {
|
||||
const welcome = messagesEl.querySelector('.welcome-msg');
|
||||
if (welcome) welcome.remove();
|
||||
}
|
||||
|
||||
function createMsgElement(role) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + role;
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'avatar';
|
||||
avatar.textContent = role === 'user' ? '👤' : '🤖';
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble';
|
||||
div.appendChild(avatar);
|
||||
div.appendChild(bubble);
|
||||
messagesEl.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ---- Day 5: 同步发送 ----
|
||||
async function sendSyncMessage(message) {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, conversationId: currentConversationId })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.code !== 200) {
|
||||
throw new Error(result.message || '请求失败');
|
||||
}
|
||||
return result.data.reply;
|
||||
}
|
||||
|
||||
// ---- Day 6: SSE 流式发送 ----
|
||||
async function sendStreamMessage(message) {
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, conversationId: currentConversationId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.message || '流式请求失败');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === '[DONE]') return;
|
||||
yield data;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 主发送流程 ----
|
||||
async function sendMessage() {
|
||||
const message = inputEl.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
// 禁用输入
|
||||
inputEl.value = '';
|
||||
inputEl.style.height = 'auto';
|
||||
sendBtn.disabled = true;
|
||||
inputEl.disabled = true;
|
||||
|
||||
removeWelcome();
|
||||
|
||||
// 渲染用户消息
|
||||
const userMsgEl = createMsgElement('user');
|
||||
userMsgEl.querySelector('.bubble').textContent = message;
|
||||
scrollToBottom();
|
||||
|
||||
// 渲染 AI 消息占位
|
||||
const aiMsgEl = createMsgElement('assistant');
|
||||
const aiBubble = aiMsgEl.querySelector('.bubble');
|
||||
|
||||
try {
|
||||
if (streamToggle.checked) {
|
||||
// ---- 流式模式 ----
|
||||
aiMsgEl.classList.add('streaming');
|
||||
aiBubble.textContent = '';
|
||||
const stream = await sendStreamMessage(message);
|
||||
|
||||
for await (const token of stream) {
|
||||
aiBubble.textContent += token;
|
||||
scrollToBottom();
|
||||
}
|
||||
aiMsgEl.classList.remove('streaming');
|
||||
|
||||
} else {
|
||||
// ---- 同步模式 ----
|
||||
aiBubble.textContent = '思考中...';
|
||||
const reply = await sendSyncMessage(message);
|
||||
aiBubble.textContent = reply;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
const conv = conversations.get(currentConversationId);
|
||||
if (conv) {
|
||||
conv.lastTime = Date.now();
|
||||
if (conv.title === '默认对话' || conv.title === '新对话') {
|
||||
conv.title = message.substring(0, 20) + (message.length > 20 ? '...' : '');
|
||||
}
|
||||
}
|
||||
renderConversationList();
|
||||
|
||||
} catch (error) {
|
||||
aiMsgEl.classList.remove('streaming');
|
||||
aiBubble.textContent = '错误: ' + error.message;
|
||||
aiBubble.style.color = '#ef4444';
|
||||
console.error('Chat error:', error);
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
inputEl.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 键盘处理 ----
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
inputEl.addEventListener('input', () => {
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
// ---- Day 7: 会话管理 ----
|
||||
function switchConversation(id) {
|
||||
currentConversationId = id;
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🤖</div>
|
||||
<h3>会话已切换</h3>
|
||||
<p>当前会话: ${escapeHtml(conversations.get(id)?.title || id)}</p>
|
||||
</div>`;
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
function newConversation() {
|
||||
const id = generateId();
|
||||
conversations.set(id, { title: '新对话', lastTime: Date.now() });
|
||||
currentConversationId = id;
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">💬</div>
|
||||
<h3>新对话已创建</h3>
|
||||
<p>开始和 AI 聊天吧!</p>
|
||||
</div>`;
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
async function deleteConversation(id) {
|
||||
if (id === 'default') return;
|
||||
conversations.delete(id);
|
||||
try {
|
||||
await fetch(`/api/chat/history?conversationId=${id}`, { method: 'DELETE' });
|
||||
} catch (e) { /* ignore */ }
|
||||
if (currentConversationId === id) {
|
||||
currentConversationId = 'default';
|
||||
switchConversation('default');
|
||||
}
|
||||
renderConversationList();
|
||||
}
|
||||
|
||||
async function clearCurrentHistory() {
|
||||
if (!confirm('确定清除当前会话的所有历史消息?')) return;
|
||||
try {
|
||||
await fetch(`/api/chat/history?conversationId=${currentConversationId}`, { method: 'DELETE' });
|
||||
} catch (e) { /* ignore */ }
|
||||
messagesEl.innerHTML = `
|
||||
<div class="welcome-msg">
|
||||
<div class="welcome-icon">🧹</div>
|
||||
<h3>历史已清除</h3>
|
||||
<p>当前会话的记忆已清空,开始新的对话吧!</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function onModelChange() {
|
||||
alert('模型切换需要在 application.yml 中修改 spring.ai.openai.chat.options.model 并重启应用。\n此处仅作演示。');
|
||||
}
|
||||
|
||||
function renderConversationList() {
|
||||
convListEl.innerHTML = '';
|
||||
const sorted = [...conversations.entries()]
|
||||
.sort((a, b) => b[1].lastTime - a[1].lastTime);
|
||||
for (const [id, conv] of sorted) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'conv-item' + (id === currentConversationId ? ' active' : '');
|
||||
item.setAttribute('data-id', id);
|
||||
item.onclick = () => switchConversation(id);
|
||||
item.innerHTML = `
|
||||
<span class="conv-title">${escapeHtml(conv.title)}</span>
|
||||
${id !== 'default' ? `<button class="conv-delete" title="删除">×</button>` : ''}`;
|
||||
|
||||
const delBtn = item.querySelector('.conv-delete');
|
||||
if (delBtn) {
|
||||
delBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteConversation(id);
|
||||
};
|
||||
}
|
||||
convListEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
document.getElementById('sidebar').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ---- 初始化 ----
|
||||
renderConversationList();
|
||||
inputEl.focus();
|
||||
626
week9/教案.md
Normal file
626
week9/教案.md
Normal file
@@ -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<String> —— 每个元素是一个 Token
|
||||
public Flux<String> chatStream(String message, String conversationId) {
|
||||
return chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
|
||||
.stream() // ← 换成 .stream()
|
||||
.content(); // 返回 Flux<String> 而不是 String
|
||||
}
|
||||
```
|
||||
|
||||
**对比**:
|
||||
|
||||
| | 同步 | 流式 |
|
||||
|------|------|------|
|
||||
| 方法 | `.call().content()` | `.stream().content()` |
|
||||
| 返回类型 | `String` | `Flux<String>` |
|
||||
| 响应时机 | 全部生成完才返回 | 生成一个 token 返回一个 |
|
||||
| 用户体验 | 等待 | 打字机效果 |
|
||||
|
||||
### Controller 中的 SSE 端点
|
||||
|
||||
```java
|
||||
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<ServerSentEvent<String>> chatStream(@Valid @RequestBody ChatRequest request) {
|
||||
return chatService.chatStream(...)
|
||||
.map(token -> ServerSentEvent.<String>builder().data(token).build())
|
||||
.concatWith(Mono.just(ServerSentEvent.<String>builder()
|
||||
.event("done").data("[DONE]").build()));
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- `produces = TEXT_EVENT_STREAM_VALUE` 告诉浏览器这是 SSE 流
|
||||
- `ServerSentEvent` 封装 SSE 格式
|
||||
- `[DONE]` 信号通知前端流结束
|
||||
|
||||
### 前端 ReadableStream 解析
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, conversationId })
|
||||
});
|
||||
|
||||
const reader = response.body.getReader(); // 获取流读取器
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
// 解析 SSE 格式: "data: xxx\n"
|
||||
// 逐 token 追加到气泡中
|
||||
}
|
||||
```
|
||||
|
||||
### 动手 → 理解
|
||||
|
||||
1. 阅读 `ChatService.java` 的 `chatStream()` 方法
|
||||
2. 阅读 `ChatController.java` 的 `POST /api/chat/stream` 端点
|
||||
3. 阅读 `chat.js` 的 `sendStreamMessage()` 函数
|
||||
4. 在浏览器中勾选"流式输出",发送消息,观察打字机效果
|
||||
5. 打开 F12 → Network → 找到 `/api/chat/stream` 请求 → 点击 → EventStream 标签,观察每个 SSE 事件
|
||||
6. 把浏览器的网速调慢(F12 → Network → Throttling → Slow 3G),观察流式输出的差异更明显
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `ChatService.java` | 新增 `chatStream()` 方法 |
|
||||
| `ChatController.java` | 新增 `POST /api/chat/stream` |
|
||||
| `chat.js` | 新增 `sendStreamMessage()` + `ReadableStream` |
|
||||
|
||||
### 思考题
|
||||
|
||||
> 如果流式响应中途网络断了(比如 Wi-Fi 被关掉),前端应该怎么处理?动手修改 `chat.js`,在 catch 块中显示"连接中断,请重试"。另外,`EventSource` API 和 `fetch + ReadableStream` 有什么区别?为什么聊天应用推荐用 fetch?
|
||||
|
||||
---
|
||||
|
||||
## Day 7:上下文管理与多轮对话
|
||||
|
||||
### 核心问题:AI 是无状态的
|
||||
|
||||
```
|
||||
第 1 轮: 用户: "我叫小明" AI: "好的小明!"
|
||||
第 2 轮: 用户: "我刚才说我叫什么?" AI: "???我不知道"
|
||||
```
|
||||
|
||||
每次 API 请求是独立的。AI 不记得上一轮说了什么,除非你**把历史消息重新传给它**。
|
||||
|
||||
### Spring AI 的 ChatMemory 机制
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 用户: "我叫小明" │
|
||||
│ ↓ │
|
||||
│ MessageChatMemoryAdvisor │
|
||||
│ ├─ 从 ChatMemory 读历史(此时为空) │
|
||||
│ ├─ 把历史注入到 Prompt 的 messages 中 │
|
||||
│ ├─ 调用 ChatModel │
|
||||
│ └─ 把本轮对话写入 ChatMemory │
|
||||
│ ↓ │
|
||||
│ AI: "好的小明!" │
|
||||
│ │
|
||||
│ 用户: "我刚才说我叫什么?" │
|
||||
│ ↓ │
|
||||
│ MessageChatMemoryAdvisor │
|
||||
│ ├─ 从 ChatMemory 读历史: │
|
||||
│ │ user: "我叫小明" │
|
||||
│ │ assistant: "好的小明!" │
|
||||
│ ├─ 注入到 Prompt → AI 知道上下文 │
|
||||
│ └─ 返回: "你叫小明!" │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### conversationId —— 区分不同会话的关键
|
||||
|
||||
```java
|
||||
chatClient.prompt()
|
||||
.user(message)
|
||||
.advisors(a -> a.param("chat_memory_conversation_id", conversationId))
|
||||
.call()
|
||||
.content();
|
||||
```
|
||||
|
||||
不同的 conversationId → 独立的记忆空间。这就是多用户 / 多会话隔离的基础。
|
||||
|
||||
### ChatMemory 实现对比
|
||||
|
||||
| 实现 | 存储位置 | 重启丢失 | 适用场景 |
|
||||
|------|---------|---------|---------|
|
||||
| `InMemoryChatMemoryRepository` | JVM 堆内存 | 是 | 开发/测试(本周用这个) |
|
||||
| `MessageWindowChatMemory` | 滑动窗口策略 | - | 包装 Repository,只保留最近 N 条 |
|
||||
| `JdbcChatMemoryRepository` | MySQL/PostgreSQL | 否 | 生产环境 |
|
||||
|
||||
> Spring AI 1.0.6 中,ChatMemory 被拆分为两个角色:**策略层**(MessageWindowChatMemory,滑动窗口)和 **存储层**(ChatMemoryRepository,持久化)。
|
||||
|
||||
### 动手 → 理解
|
||||
|
||||
1. 阅读 `ChatService.java`:
|
||||
- `chat(String message, String conversationId)` — 多轮对话版
|
||||
- `getHistory(String conversationId)` — 查看某会话的所有历史消息
|
||||
- `clearHistory(String conversationId)` — 清除某会话
|
||||
2. 阅读 `ChatController.java` 的 `GET /api/chat/history` 和 `DELETE /api/chat/history`
|
||||
3. 阅读 `ChatHistoryVo.java` DTO
|
||||
4. **完整测试多轮对话**:
|
||||
- 在浏览器中发送:"我叫小明,我是一名 Java 后端开发者,有 3 年经验"
|
||||
- 再发送:"我刚才说我叫什么名字?我做什么工作?几年经验?"
|
||||
- 确认 AI 能正确回忆上下文
|
||||
5. 点击"+ 新建对话",问同样的问题"我叫什么?"——确认 AI "忘记"了(新对话 = 空白记忆)
|
||||
6. 在侧边栏切换回之前的对话,再问一次——确认旧对话的记忆还在
|
||||
7. 点击"清除历史"——确认记忆被清空
|
||||
8. 访问 `GET http://localhost:8080/api/chat/history?conversationId=default` 查看记忆中的数据结构
|
||||
|
||||
### 核心文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `ChatService.java` | 所有方法增加 conversationId;新增 `getHistory()`、`clearHistory()` |
|
||||
| `ChatController.java` | 新增 GET/DELETE `/history` 端点 |
|
||||
| `ChatHistoryVo.java` | 新增 |
|
||||
| `chat.html` | 新增侧边栏 + 新建/切换/删除对话 |
|
||||
| `chat.js` | 新增会话管理函数 |
|
||||
|
||||
### 思考题
|
||||
|
||||
> `InMemoryChatMemoryRepository` 在应用重启后会丢失所有记忆。如果要持久化到数据库,需要改哪些代码?提示:只需要把 `AIConfig.java` 中的 `InMemoryChatMemoryRepository` 换成 `JdbcChatMemoryRepository`,其他代码完全不需要改 —— 这就是面向接口编程的威力。
|
||||
|
||||
---
|
||||
|
||||
## Week 9 总结
|
||||
|
||||
### 技术栈全景
|
||||
|
||||
```
|
||||
浏览器 (chat.html)
|
||||
│ fetch + SSE
|
||||
▼
|
||||
ChatController (/api/chat, /api/chat/stream, /api/chat/history)
|
||||
│
|
||||
▼
|
||||
ChatService (chat, chatStream, getHistory, clearHistory)
|
||||
│
|
||||
▼
|
||||
ChatClient (Spring AI Fluent API)
|
||||
├─ ChatModel (OpenAiChatModel → HTTP → AI Service)
|
||||
└─ MessageChatMemoryAdvisor → ChatMemory (InMemory)
|
||||
```
|
||||
|
||||
### 能力清单
|
||||
|
||||
| 维度 | 掌握内容 |
|
||||
|------|---------|
|
||||
| LLM 概念 | Token、Transformer、自回归生成、主流模型对比 |
|
||||
| Prompt 设计 | 五大要素、Few-shot、CoT、角色扮演 |
|
||||
| API 调用 | REST API 格式、messages 结构、Temperature |
|
||||
| Spring AI | ChatClient、ChatModel、BOM、手动装配 |
|
||||
| 同步对话 | POST /api/chat、call().content() |
|
||||
| 流式对话 | SSE、Flux、ReadableStream、打字机效果 |
|
||||
| 多轮对话 | ChatMemory、conversationId、MessageChatMemoryAdvisor |
|
||||
|
||||
### vs Week 5 (Spring Security)
|
||||
|
||||
| | Week 5 | Week 9 |
|
||||
|------|--------|--------|
|
||||
| 核心框架 | Spring Security + JWT | Spring AI |
|
||||
| 数据方向 | 客户端 → 服务器 → DB | 客户端 → 服务器 → AI API |
|
||||
| 状态管理 | JWT Token(客户端持有) | ChatMemory(服务端持有) |
|
||||
| 响应模式 | 同步 JSON | 同步 JSON + SSE 流 |
|
||||
| 拦截器 | SecurityFilterChain | Advisor Chain |
|
||||
|
||||
### 常见问题
|
||||
|
||||
| 问题 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| 启动报 "API key not valid" | api-key 没改 | 在 `application.yml` 中填入真实 API Key |
|
||||
| 返回 "Connection refused" | base-url 不对 | 检查 `base-url` 是否与 provider 匹配 |
|
||||
| 返回 401 | API Key 无效或过期 | 登录平台重新生成 Key |
|
||||
| 返回 429 | 调用频率超限 | 免费版通常有 RPM 限制,等几秒再试 |
|
||||
| 流式没效果 | 前端没开流式开关 | 勾选"流式输出"复选框 |
|
||||
| 多轮对话不记得上下文 | 没传 conversationId | 检查请求体中是否包含 `conversationId` |
|
||||
| 中文显示乱码 | 编码问题 | 确认 `application.yml` 中有 `characterEncoding=utf-8`(虽本项目不连 MySQL) |
|
||||
| `mvn compile` 报找不到 Spring AI | BOM 未生效 | 确认 `dependencyManagement` 配置正确,`mvn clean compile` 重试 |
|
||||
| 前端页面 404 | 文件路径不对 | 确认 `chat.html` 在 `src/main/resources/static/` 下 |
|
||||
|
||||
---
|
||||
|
||||
> **本周核心**:你第一次把 AI 集成到了自己的应用中。从最原始的 HTTP 调用,到 Spring AI 封装的一行 `.call().content()`,再到流式 SSE 和 ChatMemory 多轮对话——你掌握了让应用"拥有 AI 能力"的完整链路。这也是整个学习计划转折的一周:之前你学的是"如何写好代码",从今天开始你学的是"如何让代码拥有智能"。
|
||||
|
||||
---
|
||||
|
||||
> **下一阶段:Week 10 — AI Agent 核心概念**。在聊天机器人基础上,让 AI 能调用工具(Function Calling)、检索知识库(RAG)、持久化记忆(Redis)。从一个"会聊天的 AI"升级到"会干活的 Agent"。
|
||||
Reference in New Issue
Block a user