# Week 10:AI Agent 核心概念 —— 从聊天到干活 > **学习周期**:7 天 > **每日用时**:2-3 小时 > **最终产出**:一个具备工具调用、RAG 检索、持久化记忆、Prompt 模板管理的 AI Agent 系统 --- ## 前置准备 | 事项 | 说明 | |------|------| | Week 9 代码可用 | 确保 `week9` 项目能正常启动和对话 | | DeepSeek API Key | Week 9 已在用,继续使用 | | Redis 安装 | Day 5 需要([github.com/tporadowski/redis](https://github.com/tporadowski/redis) Windows 版) | | Ollama 安装(方案 B) | Day 6 需要([ollama.com](https://ollama.com) → 下载安装 → `ollama pull nomic-embed-text`) | > **关于 Embedding 方案**:Day 6 的 RAG 有两种 Embedding 方案。**方案 B(Ollama 本地)** 免费、离线、零成本,是默认选择。**方案 A(阿里云百炼)** 需要注册获取 Key,但有免费额度,适合网络受限无法下载 Ollama 的情况。教案中同时覆盖两种方案。 --- ## 启动方式 ```bash # 1. 确保 Redis 已启动(Day 5 需要,其他 Day 可先注释掉 Redis 依赖) # 2. 确保 Ollama 已启动 + nomic-embed-text 已下载(Day 6 需要) # 3. 修改 application.yml 中的 api-key (DeepSeek) # 4. IDEA 中运行 Week10Application.main() # 5. 浏览器访问 http://localhost:8080/agent.html ``` --- ## Week 9 → Week 10:升级了什么? ``` Week 9: 聊天机器人 POST /api/chat → 同步/流式对话 → AI 陪你聊天 Week 10: AI Agent POST /api/agent → 工具调用 → AI 能查天气、算数学、搜信息 POST /api/rag/query → 知识库检索 → AI 能基于你的文档回答问题 Redis 持久化 → 记忆不丢 → 重启后对话历史还在 Prompt 模板管理 → 角色预设 → 一键切换 AI 人设 ``` --- ## Day 1:Agent 核心概念(ReAct / Plan-Execute) ### 什么是 AI Agent? AI Agent(智能体)= **大模型 + 工具 + 决策循环**。和普通聊天机器人的区别: | | 聊天机器人 | AI Agent | |------|-----------|----------| | 能力 | 只回答已知知识 | 能调用外部工具 | | 记忆 | 单次对话 | 持久化 + 多轮 | | 行为 | 被动回复 | 主动规划 + 执行 | | 边界 | 模型训练数据 | 训练数据 + 实时信息 | ### Agent 的两个核心范式 #### 1. ReAct(Reasoning + Acting,推理-行动循环) ``` 用户: "北京今天天气如何?" ┌─ Thought(思考): 用户想知道北京的天气,我需要调用天气工具 ├─ Action(行动): getWeather("北京") ├─ Observation(观察): {"city":"北京","weather":"晴","temp":25} ├─ Thought(思考): 获得了天气信息,可以回答了 └─ Answer(回答): "北京今天晴天,气温 25°C..." ``` ReAct 是当前最主流的 Agent 范式,Spring AI 的 `@Tool` 机制本质上就是 ReAct。 #### 2. Plan-Execute(规划-执行) ``` 用户: "帮我做一个技术调研:比较 Spring AI 和 LangChain4j" ┌─ Plan(规划): │ 1. 搜索 Spring AI 最新文档 │ 2. 搜索 LangChain4j 最新文档 │ 3. 对比两者的 API 设计 │ 4. 写总结报告 ├─ Execute Step 1 → 获取结果 ├─ Execute Step 2 → 获取结果 ├─ Execute Step 3 → 对比分析 └─ Execute Step 4 → 输出报告 ``` Plan-Execute 适合复杂多步任务,需要更强的规划能力。Spring AI 暂未内置此模式,但可以通过多轮 Agent 调用手动实现。 ### 动手 → 理解 1. 用 ChatGPT / DeepSeek 网页版,尝试问一个需要"外部信息"的问题(如"今天天气"),观察它如何回答——要么说不知道,要么编造答案 2. 再问一个需要"实时信息"的问题:对比 ChatGPT 网页版(能搜索)和 API 版(无工具)的回答差异 3. 在纸上画出 ReAct 循环图,尝试把"帮我查北京天气,如果下雨就提醒带伞,如果晴天就推荐去公园"这个任务分解为 ReAct 步骤 ### 思考题 > 如果模型没有工具调用能力,用户问"今天天气如何?"——模型会怎么回答?为什么会有"幻觉"?工具调用如何解决幻觉问题? --- ## Day 2:Function Calling 原理 —— 模型如何"调用"工具? ### 核心问题 模型不能真的"调用"你的 Java 方法。整个过程是: ``` 你告诉模型"有哪些工具可用"(JSON Schema) → 模型决定"需要调用哪个工具"(返回 JSON) → 你的代码解析 JSON → 执行对应方法 → 把结果返回给模型 → 模型基于结果生成自然语言回答 ``` ### 工具定义(Tools Definition)—— 告诉模型你能做什么 发送给模型的请求中包含一个 `tools` 数组: ```json { "model": "deepseek-chat", "messages": [{"role": "user", "content": "北京今天天气如何?"}], "tools": [ { "type": "function", "function": { "name": "getWeather", "description": "获取指定城市的当前天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如'北京'、'上海'" } }, "required": ["city"] } } } ] } ``` ### 模型的响应 —— 不直接回答,而是说"请调这个工具" ```json { "choices": [{ "finish_reason": "tool_calls", "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_abc123", "type": "function", "function": { "name": "getWeather", "arguments": "{\"city\": \"北京\"}" } }] } }] } ``` 关键:`finish_reason` 是 `tool_calls` 而不是 `stop`——模型没有生成文本,而是请求调用工具。 ### 第二轮:把工具结果喂回去 ```json { "model": "deepseek-chat", "messages": [ {"role": "user", "content": "北京今天天气如何?"}, { "role": "assistant", "content": null, "tool_calls": [{"id": "call_abc123", "type": "function", "function": {"name": "getWeather", "arguments": "{\"city\": \"北京\"}"}}] }, { "role": "tool", "tool_call_id": "call_abc123", "content": "{\"city\": \"北京\", \"weather\": \"晴\", \"temperature\": 25}" } ] } ``` 这一次 `finish_reason` = `stop`,模型返回自然语言:"北京今天晴天,气温 25°C,非常适合户外活动。" ### 动手 → 理解 1. 用 Postman,给 DeepSeek API 发送一个带 `tools` 定义的请求(复制上面的 JSON,替换 API Key) 2. 观察返回的 `finish_reason` 是 `tool_calls` 不是 `stop` 3. 手动构造"第二轮请求":把 tool 执行结果放到 messages 里,再次调用,观察模型如何基于结果生成回答 4. 试试发送两个工具定义(weather + calculator),同一个用户问题,观察模型如何选择 ### 思考题 > 如果模型同时定义了 WeatherTool 和 CalculatorTool,用户问"1+1等于几"——模型凭什么知道该选 CalculatorTool 而不是 WeatherTool?(答案:`description` 字段是唯一的"说明书") --- ## Day 3:Spring AI Function Calling —— 第一个 Tool ### Spring AI 的 @Tool 注解 Spring AI 1.0.6 提供了 `@Tool` 注解,让你用纯 Java 定义工具: ```java @Component public class WeatherTool { @Tool(description = "获取指定城市的当前天气信息,包括天气状况和温度") public String getWeather(@ToolParam(description = "城市名称,如'北京'、'上海'") String city) { // 模拟天气数据(生产环境替换为真实 API 调用) var weatherData = Map.of( "北京", "晴,25°C", "上海", "多云,28°C", "深圳", "阵雨,30°C" ); return weatherData.getOrDefault(city, "未找到该城市天气信息"); } } ``` Spring AI 会自动: 1. 从 `@Tool.description` 生成工具定义(JSON Schema) 2. 在 ChatClient 发送请求时附上工具列表 3. 解析模型的 `tool_calls` 响应 4. 执行对应的方法 5. 把结果自动返回给模型 6. 拿到最终的自然语言回答 你只需要:用 `@Tool` 写方法 + 注册到 ChatClient。 ### 注册工具到 ChatClient ```java @Bean public ChatClient chatClient(ChatModel chatModel, Object weatherTool, ...) { return ChatClient.builder(chatModel) .defaultSystem("你是一个 AI Agent 助手...") .defaultTools(weatherTool, calculatorTool, searchTool) // 注册所有工具 .defaultAdvisors(...) .build(); } ``` ### AgentService:让模型自动选择工具 ```java @Service public class AgentService { private final ChatClient chatClient; public Map execute(String task, String conversationId) { String reply = chatClient.prompt() .user(task) .advisors(a -> a.param(CONVERSATION_ID, convId)) .call() .content(); // 模型自动判断是否调用工具、调用哪个工具 return Map.of("reply", reply, "conversationId", convId); } } ``` 你没写任何"if 用户说天气 then 调 WeatherTool"的逻辑。**模型自己决定**——这就是 Agent 的智能所在。 ### 动手 → 理解 1. 阅读 `tool/WeatherTool.java`——理解 `@Tool` 和 `@ToolParam` 的用法 2. 阅读 `AIConfig.java` 中的 `defaultTools(weatherTool, calculatorTool, searchTool)` 注册 3. 阅读 `service/AgentService.java`——对比 `ChatService.java`,有什么区别? 4. 启动项目,浏览器访问 `http://localhost:8080/agent.html` 5. 勾选"Agent 模式",发送:"北京今天天气如何?" 6. 观察日志中是否有工具调用记录 7. 用 Postman 测试:`POST /api/agent` Body: `{"message": "深圳天气怎么样?"}` 8. 故意问一个 WeatherTool 不支持的城市(如"拉萨"),观察返回 ### 核心文件 | 文件 | 作用 | |------|------| | `tool/WeatherTool.java` | @Tool 定义:天气查询 | | `config/AIConfig.java` | defaultTools 注册 | | `service/AgentService.java` | Agent 入口:execute() / executeStream() | ### 思考题 > 如果你把 WeatherTool 的 `@Tool(description = "...")` 改成 `description = "获取当前时间"`,模型调用这个工具时会发生什么?提示:description 是模型**唯一的判断依据**。 --- ## Day 4:多工具协作 —— 让 Agent 更强大 ### 再加两个工具 #### CalculatorTool ```java @Component public class CalculatorTool { @Tool(description = "执行数学计算,支持加减乘除和括号。示例: '123 * 456'") public double calculate(@ToolParam(description = "数学表达式") String expression) { // 使用 javax.script.ScriptEngine 安全求值 ScriptEngineManager manager = new ScriptEngineManager(); ScriptEngine engine = manager.getEngineByName("JavaScript"); return ((Number) engine.eval(expression)).doubleValue(); } } ``` #### SearchTool ```java @Component public class SearchTool { @Tool(description = "联网搜索技术信息,返回相关结果") public String search(@ToolParam(description = "搜索关键词") String query) { // 模拟搜索结果(生产环境替换为真实搜索 API) var results = Map.of( "spring ai", "Spring AI 1.0.6 是面向 Java 的 AI 框架...", "java 21", "Java 21 是 LTS 版本,引入虚拟线程...", "deepseek", "DeepSeek 是深度求索开发的大语言模型..." ); return results.getOrDefault(query.toLowerCase(), "未找到相关信息"); } } ``` ### 多工具协作的关键:让模型自己选 你没有写规则说"当用户问数学时用 CalculatorTool"。模型通过 tools 的 `description` 自行判断。核心优势: ``` 用户: "北京天气怎么样?顺便帮我算一下 999 * 888" 模型思考过程(内部): → 用户问了两个问题:天气 + 计算 → 查天气 → 需要调用 WeatherTool → 数学运算 → 需要调用 CalculatorTool → 先取天气结果,再取计算结果,合并回答 ``` ### Agent 模式 vs 普通聊天模式 | | 普通聊天(/api/chat) | Agent 模式(/api/agent) | |------|---------------------|------------------------| | 工具 | 不注册工具 | 注册所有 @Tool | | 能力 | 只用训练数据 | 可获取外部信息 | | 适用 | 闲聊、解释概念 | 查天气、计算、搜索 | | 实现 | ChatService | AgentService(实际上底层一样,只是 ChatClient 配置不同) | ### 动手 → 理解 1. 阅读 `tool/CalculatorTool.java` 和 `tool/SearchTool.java` 2. 阅读 `service/AgentService.java` 的 `execute()` —— 注意到没有?代码和 Day 3 一模一样!因为模型自己决定调哪个工具 3. 启动项目,在 agent.html 中测试多工具协作: - 发送:"北京天气怎么样?顺便算 123 * 456" - 发送:"帮我搜索 Spring AI 是什么,再总结成一句话" 4. 在日志中观察:是否同时触发了两个工具? 5. 流式模式测试:勾选"流式输出",再次发送多工具请求,观察打字机效果 6. 对比 `/api/agent` 和 `/api/chat` 的响应差异 ### 核心文件 | 文件 | 作用 | |------|------| | `tool/CalculatorTool.java` | @Tool:数学计算 | | `tool/SearchTool.java` | @Tool:搜索(模拟) | | `service/AgentService.java` | executeStream() 流式 Agent | ### 思考题 > 如果问"东京天气如何?帮我换算一下 32°C 是多少华氏度",模型会调用哪些工具?调用顺序如何?描述完整的 ReAct 循环。 --- ## Day 5:Redis 记忆持久化 —— 重启对话不丢失 ### Week 9 的局限 Week 9 使用 `InMemoryChatMemoryRepository`(JVM 内存)存储对话历史。一旦重启应用,所有对话记忆丢失。 ### Spring AI 的 ChatMemory 双层架构 ``` MessageWindowChatMemory(策略层:滑动窗口,保留最近 30 条) ↓ 委托存储 ChatMemoryRepository(存储层:接口) ├─ InMemoryChatMemoryRepository(默认,内存 → 重启丢失) ├─ JdbcChatMemoryRepository(JDBC 数据库) ├─ CassandraChatMemoryRepository(Cassandra) └─ 自定义:RedisChatMemoryRepository ← 本周实现 ``` ### 自定义 RedisChatMemoryRepository Spring AI 1.0.6 没有内置 Redis 实现,这反而是好的——你可以学习如何扩展框架: ```java @Component public class RedisChatMemoryRepository implements ChatMemoryRepository { private final RedisTemplate redisTemplate; private static final String KEY_PREFIX = "chat:memory:"; private static final String ID_SET_KEY = "chat:conversation:ids"; @Override public List findByConversationId(String id) { // LRANGE 读取整个 List → JSON 反序列化 → List var jsonList = redisTemplate.opsForList().range(KEY_PREFIX + id, 0, -1); // ... Jackson 反序列化 } @Override public void saveAll(String id, List messages) { // DEL 清空旧数据 → RPUSH 逐条写入 JSON → SADD 记录 ID redisTemplate.delete(KEY_PREFIX + id); messages.forEach(msg -> redisTemplate.opsForList().rightPush(KEY_PREFIX + id, toJson(msg))); redisTemplate.opsForSet().add(ID_SET_KEY, id); } @Override public void deleteByConversationId(String id) { /* DEL + SREM */ } @Override public List findConversationIds() { /* SMEMBERS */ } } ``` ### AIConfig 中的无缝切换 ```java // Week 9(内存版,重启丢失): @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .chatMemoryRepository(new InMemoryChatMemoryRepository()) .maxMessages(20).build(); } // Week 10(Redis 持久化版,重启保留): @Bean public ChatMemory chatMemory(ChatMemoryRepository repository) { // 注入我们的 Redis 实现 return MessageWindowChatMemory.builder() .chatMemoryRepository(repository) // 换成 Redis .maxMessages(30).build(); } ``` **其他代码完全不用改**——这就是面向接口编程的威力。 ### Jackson 序列化的 Message 格式 Message 接口的常见实现是 `AssistantMessage`、`UserMessage`。Jackson 需要类型信息来正确反序列化。在 RedisConfig 中配置: ```java @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(factory); Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(Object.class); // 配置 ObjectMapper 以支持多态反序列化 ObjectMapper mapper = new ObjectMapper(); mapper.activateDefaultTyping(BasicPolymorphicTypeValidator.builder().build(), ObjectMapper.DefaultTyping.NON_FINAL); serializer.setObjectMapper(mapper); template.setValueSerializer(serializer); // ... } ``` ### 动手 → 理解 1. 安装 Redis(Windows 版从 GitHub Release 下载,或使用 WSL/Docker) 2. 启动 Redis:`redis-server.exe`(或 `redis-server`) 3. 修改 `application.yml` 中的 Redis 密码(如果有的话) 4. 阅读 `memory/RedisChatMemoryRepository.java`——理解 4 个接口方法的实现 5. 阅读 `config/RedisConfig.java`——理解 JSON 序列化配置 6. 阅读 `config/AIConfig.java` 中的 `chatMemory()` Bean——注意 `ChatMemoryRepository` 是怎么注入的 7. 启动项目,发送几轮对话 8. 用 `redis-cli` 查看数据: ``` redis-cli SMEMBERS chat:conversation:ids → 查看所有会话 ID LRANGE chat:memory:default 0 -1 → 查看 default 会话的所有消息 ``` 9. **重启项目** → 访问 `GET /api/chat/history` → 确认记忆还在! 10. 删除某个会话 → 观察 Redis 中对应的 Key 被删除 ### 核心文件 | 文件 | 作用 | |------|------| | `memory/RedisChatMemoryRepository.java` | 实现 ChatMemoryRepository 接口 | | `config/RedisConfig.java` | RedisTemplate 序列化配置 | | `config/AIConfig.java` | ChatMemory Bean 改造(注入 Repository) | ### 思考题 > 如果把 `maxMessages(30)` 改成 `maxMessages(5)`,再发 10 轮对话,Redis 里会存几条消息?滑动窗口是怎么删除旧消息的?验证方法:用 `LLEN chat:memory:xxx` 检查 List 长度。 --- ## Day 6:RAG(检索增强生成)—— 让 AI 基于你的知识库回答 ### 什么是 RAG? RAG = Retrieval-Augmented Generation(检索增强生成)。核心思想: ``` 用户问题 → 向量检索(从知识库找相关文档)→ 把文档拼接到 Prompt → 模型基于文档生成回答 ``` 为什么需要 RAG? - LLM 训练数据有截止日期,不知道新信息 - LLM 不知道你公司的内部文档 - RAG 可以有效减少幻觉(模型瞎编) ### RAG 核心流程 ``` ┌─────────────────────────────────────────────────────┐ │ 1. 文档入库(离线) │ │ 文档 → 切分 → Embedding(转向量)→ 存入 VectorStore │ │ │ │ 2. 问答(在线) │ │ 用户问题 → Embedding(转向量) │ │ → VectorStore.similaritySearch(找最相似的) │ │ → 拼接 Context + 问题 → ChatModel 生成回答 │ └─────────────────────────────────────────────────────┘ ``` ### Embedding(文本转向量)是什么? "今天天气真好" → Embedding 模型 → `[0.12, -0.34, 0.78, ..., 0.05]`(一个 768 维的浮点数数组) 相似的文本,向量距离近。例如: - "今天天气真好" 和 "今天阳光明媚" → 余弦相似度 0.92(很近) - "今天天气真好" 和 "Spring AI 是什么" → 余弦相似度 0.15(很远) ### Embedding 方案选择 #### 方案 B:Ollama 本地(默认,免费) ```bash # 安装 Ollama # Mac: brew install ollama # Windows: 从 ollama.com 下载安装包 # 拉取嵌入模型(~270MB) ollama pull nomic-embed-text # 验证 ollama list # NAME ID SIZE MODIFIED # nomic-embed-text:latest xxxxxxxx 274 MB ... ``` Ollama 默认监听 `http://localhost:11434`。 #### 方案 A:阿里云百炼(备选,云端) 如果你无法下载 Ollama(网络受限),用阿里云百炼的 `text-embedding-v2`: 1. 注册 [bailian.console.aliyun.com](https://bailian.console.aliyun.com) 2. 开通模型服务 → 获取 API Key 3. 修改 `application.yml`: ```yaml spring.ai.openai.embedding: api-key: ${EMBEDDING_API_KEY:sk-your-bailian-key} base-url: https://dashscope.aliyuncs.com/compatible-mode options: model: text-embedding-v2 ``` ### AIConfig 中的双 Provider 设计 Chat 和 Embedding 用不同的 API Provider: ```java // Chat → DeepSeek(由 spring.ai.openai.* 自动配置 ChatModel) // Embedding → Ollama 本地(手动创建 EmbeddingModel) @Bean public EmbeddingModel embeddingModel( @Value("${spring.ai.openai.embedding.base-url}") String baseUrl, @Value("${spring.ai.openai.embedding.api-key}") String apiKey) { var api = OpenAiApi.builder() .baseUrl(baseUrl.endsWith("/v1") ? baseUrl : baseUrl + "/v1") .apiKey(apiKey) .build(); return new OpenAiEmbeddingModel(api); } ``` ### SimpleVectorStore —— 零依赖的向量存储 ```java @Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); } ``` SimpleVectorStore 纯内存存储,免外部服务。生产环境可替换为 PgVectorStore、RedisVectorStore 等。 ### QuestionAnswerAdvisor —— 自动 RAG 在 ChatClient 的 Advisor 链中添加 `QuestionAnswerAdvisor`: ```java .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build(), new QuestionAnswerAdvisor(vectorStore) // 自动检索 + 增强 ) ``` 效果:每次对话时,Advisor 自动从 VectorStore 检索相关文档,拼接到 Prompt 中。**你的代码不需要手动调用 `vectorStore.similaritySearch()`**——Advisor 帮你做了。 ### KnowledgeDataLoader:启动时加载知识库 ```java @Component public class KnowledgeDataLoader { @PostConstruct public void load() { var docs = List.of( new Document("Spring AI 是一个面向 AI 工程的 Spring 框架...", Map.of("source", "spring-ai-overview")), new Document("RAG(检索增强生成)是结合检索和生成的技术...", Map.of("source", "rag-concept")) ); vectorStore.add(docs); } } ``` ### 动手 → 理解 1. 安装 Ollama → `ollama pull nomic-embed-text`(如果不方便下载,切换到方案 A) 2. 阅读 `config/AIConfig.java`: - `embeddingModel()` — 手动创建,指向 Ollama - `vectorStore()` — SimpleVectorStore - `chatClient()` — 新增 `QuestionAnswerAdvisor` 3. 阅读 `rag/KnowledgeDataLoader.java`——8 条示例知识库文档 4. 阅读 `service/RAGService.java`——`search()` 方法(纯检索)+ `addDocuments()` 方法 5. 启动项目,观察日志: - `EmbeddingModel created with base-url: http://localhost:11434` - `Loaded 8 knowledge documents into VectorStore` 6. 在 agent.html 中测试 RAG: - 发送:"什么是 Spring AI?" - 发送:"RAG 是什么?" - 对比:勾掉"Agent 模式"用普通聊天问同样的问题,回答有什么不同? 7. 用 Postman 测试纯检索(不生成): - `POST /api/rag/search` Body: `{"question": "什么是 Spring AI?", "topK": 3}` - 观察返回的文档片段和相似度分数 ### 核心文件 | 文件 | 作用 | |------|------| | `config/AIConfig.java` | EmbeddingModel + VectorStore + QuestionAnswerAdvisor | | `rag/KnowledgeDataLoader.java` | 启动时加载知识库文档 | | `service/RAGService.java` | RAG 检索 + 问答 | | `application.yml` | embedding 配置(方案 B 默认,方案 A 注释) | ### 思考题 > 如果 QuestionAnswerAdvisor 从 VectorStore 检索到的文档和用户问题完全不相关(相似度很低),会发生什么?如何设置 `similarityThreshold` 来过滤低质量结果? --- ## Day 7:Prompt 模板管理 —— 一键切换 AI 人设 ### 为什么需要 Prompt 模板? System Prompt 写死在 `AIConfig` 中 → 每次换角色要改代码 + 重启 → 不灵活 Prompt 模板化 → 从文件加载 → 前端选择 → 一键切换角色 ### 模板管理设计 ``` resources/prompts/ ├── java-expert.txt → 资深 Java 技术专家 ├── code-reviewer.txt → 严格的代码审查专家 └── translator.txt → 专业中英文翻译 ``` 模板中使用 `{{key}}` 占位符: ``` 你是一位资深 Java 技术专家,拥有 15 年以上的企业级开发经验。 请遵循以下原则回答用户的问题: 1. 代码示例: 使用 Java 21+ 语法 2. 最佳实践: 强调 SOLID 原则、设计模式 ... ``` ### PromptTemplateService ```java @Service public class PromptTemplateService { @PostConstruct public void loadTemplates() throws IOException { // 扫描 classpath:/prompts/*.txt // 文件名 = 模板名,文件内容 = 模板内容 } public List> list() { ... } // 列出所有模板 public String getTemplate(String name) { ... } // 获取模板内容 public String apply(String name, Map params) { ... } // 替换占位符 } ``` ### 使用模板的对话 ``` POST /api/chat/with-template Body: { "message": "请写一个 Spring Boot 的全局异常处理类", "templateName": "java-expert", "conversationId": "default" } ``` 后端处理: 1. 从文件加载模板内容 2. 替换 `{{key}}` 占位符 3. 拼接:`模板内容 + "---\n用户消息: " + message` 4. 作为 `user()` 参数传给 ChatClient ### 前端:agent.html 控制台 增强功能(对比 Week 9 的 chat.html): | 功能 | Week 9 | Week 10 | |------|--------|---------| | 工具可视化 | — | 侧边栏显示可用工具列表 | | RAG 来源 | — | 可显示检索来源(预留) | | 模板选择 | — | 下拉选择 + 内容预览 | | Agent/聊天切换 | — | 侧边栏开关 | | 流式/非流式切换 | 有 | 有 | | 会话管理 | 有 | 有 | ### 动手 → 理解 1. 阅读 `resources/prompts/` 下的 3 个模板文件 2. 阅读 `service/PromptTemplateService.java`——理解 `@PostConstruct` 加载和占位符替换 3. 阅读 `controller/ChatController.java` 的 `GET /api/prompts`、`POST /api/chat/with-template` 4. 启动项目,浏览器访问 `http://localhost:8080/agent.html` 5. 在侧边栏下拉选择"java-expert",观察预览内容 6. 发送:"请写一个 Spring Boot 的全局异常处理"——观察回复是否体现出"Java 专家"风格 7. 切换到"code-reviewer",发送一段代码,观察审查风格 8. 切换到"translator",观察翻译风格 9. 关掉模板(选择"— 不使用模板 —"),同样的问题再问一次——感受差异 ### 核心文件 | 文件 | 作用 | |------|------| | `resources/prompts/java-expert.txt` | 模板:Java 专家 | | `resources/prompts/code-reviewer.txt` | 模板:代码审查 | | `resources/prompts/translator.txt` | 模板:翻译 | | `service/PromptTemplateService.java` | 模板加载 + 替换 | ### 思考题 > 现在的模板替换是最简单的字符串替换。如果模板中需要支持条件分支(如 `{{#if userLevel}}...{{/if}}`),你会怎么设计?这引出了真正的模板引擎(如 Mustache、Handlebars)——Spring AI 也有内置的 PromptTemplate 类,去看看它的源码。 --- ## Week 10 总结 ### 技术栈全景 ``` 浏览器 (agent.html) │ fetch + SSE ▼ ChatController ├─ POST /api/chat ──────────→ ChatService ──→ ChatClient (普通聊天) ├─ POST /api/agent ─────────→ AgentService ──→ ChatClient + Tools (Agent) ├─ POST /api/rag/search ────→ RAGService ────→ VectorStore (检索) ├─ POST /api/chat/with-template → PromptTemplateService (模板) └─ GET/DELETE /api/chat/history → ChatService ──→ Redis (持久化) │ ▼ ChatClient (Spring AI) ├─ ChatModel ────→ DeepSeek API (对话) ├─ Tools ────────→ Weather / Calculator / Search ├─ Advisors ────→ ChatMemory (Redis) + QuestionAnswerAdvisor (RAG) └─ EmbeddingModel → Ollama / 百炼 (向量化) ``` ### 能力清单 | 维度 | 掌握内容 | |------|---------| | Agent 概念 | ReAct 范式、Plan-Execute 范式、Tool Calling 流程 | | Function Calling | @Tool 注解、工具定义 JSON Schema、多工具协作 | | RAG | EmbeddingModel、VectorStore、QuestionAnswerAdvisor、相似度检索 | | 向量存储 | SimpleVectorStore、相似度阈值、手动创建 EmbeddingModel | | 记忆持久化 | ChatMemoryRepository 接口、Redis 自定义实现、双层架构 | | Prompt 模板 | 文件加载、占位符替换、多角色切换 | | 架构设计 | 双 Provider(Chat + Embedding 分离)、接口抽象、插件扩展 | ### vs Week 9 | | Week 9 | Week 10 | |------|--------|--------| | AI 角色 | 聊天机器人 | AI Agent | | 核心接口 | ChatClient.call() | ChatClient.call() + Tools | | 外部能力 | 无 | 天气、计算、搜索 | | 记忆 | 内存(重启丢失) | Redis(持久化) | | 知识 | 训练数据 | 训练数据 + 自定义知识库 | | 角色切换 | 改代码 | 前端动态切换模板 | | Embedding | 不需要 | Ollama / 百炼 | ### 常见问题 | 问题 | 原因 | 解决 | |------|------|------| | Agent 不调用工具 | 工具未注册到 defaultTools | 检查 AIConfig 中 `.defaultTools(...)` 是否包含该工具 | | Agent 调错工具 | @Tool description 描述不清晰 | 改写 description,让模型更能区分 | | RAG 检索结果为空 | 1. Ollama 未安装 2. 未 pull 模型 | `ollama list` 检查是否有 nomic-embed-text | | Redis 连接失败 | Redis 未启动 | 启动 redis-server,检查端口 6379 | | 启动报 Redis 错误 | 未安装 Redis | Day 5 前可临时注释 Redis 依赖 | | RAG 检索结果不相关 | 知识库文档太少 | 增加更多覆盖不同主题的文档 | | Embedding 返回 404 | base-url 不对 | 确认 Ollama URL 为 `http://localhost:11434/v1` | | 模板选择后没效果 | 前端只是拼接,ChatClient 没用到模板 | 检查 `/api/chat/with-template` 是否正常拼接 | | mvn compile 失败 | BOM 或依赖版本冲突 | 运行 `mvn clean compile`,确认 pom.xml 中无冲突 | --- > **本周核心**:你把一个"只会聊天"的 AI 升级成了"能干活"的 Agent。从 Function Calling 让模型自主调用工具,到 RAG 让模型基于你的知识库回答问题,再到 Redis 持久化让记忆永不丢失,最后用 Prompt 模板实现一键切换角色。你掌握了 AI Agent 的四大核心能力——**工具、检索、记忆、模板**。这四块积木可以组合出无数种 Agent 应用。 --- > **下一阶段:Week 11 — 高级 RAG + Agent 实战**。在 Agent 基础上,加入多模态(图片识别)、高级 RAG 策略(Query 重写、混合检索、结果重排序)、Agent 记忆管理等进阶内容。