tmp
This commit is contained in:
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 记忆管理等进阶内容。
|
||||
Reference in New Issue
Block a user