JP3-2-3-MyItem智能业务

Java道经 - 项目 - MyItem - 智能业务

文章目录

  • [S01. ChatClient对话](#S01. ChatClient对话)
    • [E01. 基础对话](#E01. 基础对话)
      • [1. 补充主配项](#1. 补充主配项)
      • [2. 开发顾问器](#2. 开发顾问器)
      • [3. 开发控制器](#3. 开发控制器)
    • [E02. 增强对话(RAG)](#E02. 增强对话(RAG))
      • [1. 补充主配项](#1. 补充主配项)
      • [2. 开发配置类](#2. 开发配置类)
      • [3. 开发RAG文档](#3. 开发RAG文档)
      • [4. 加载RAG文档](#4. 加载RAG文档)
      • [5. 开发控制器](#5. 开发控制器)
  • [S02. Agent工具调用](#S02. Agent工具调用)
    • [E01. 数据管理Agent](#E01. 数据管理Agent)
      • [1. ItemTool](#1. ItemTool)
      • [2. SKILL.MD](#2. SKILL.MD)
      • [3. ItemAgentConfig](#3. ItemAgentConfig)
      • [4. RoomTool](#4. RoomTool)
      • [5. SKILL.MD](#5. SKILL.MD)
      • [6. RoomAgentConfig](#6. RoomAgentConfig)
      • [7. RoutingAgentConfig](#7. RoutingAgentConfig)
      • [8. DataAgentController](#8. DataAgentController)
    • [E02. 今日推荐Agent](#E02. 今日推荐Agent)
      • [1. MCP配置](#1. MCP配置)
      • [2. WeatherAgent](#2. WeatherAgent)
      • [3. OutfitTool](#3. OutfitTool)
      • [4. OutfitAgentConfig](#4. OutfitAgentConfig)
      • [5. SupervisorAgentConfig](#5. SupervisorAgentConfig)
      • [6. RecommendController](#6. RecommendController)

心法:MyItem 智能业务均在 com.joezhou.agent 包下开发,物理隔离。

S01. ChatClient对话

心法:本章节基于 ChatClient 对话客户端进行开发。
武技:完成智能对话的准备工作。

  1. 开发提示词模板文件:

classpath:prompt/ChatPrompt.st

txt 复制代码
# 角色定位
$role$
# 输入规则:
$input:{ rule |$i$、$rule$;
}$
# 输出规则:
$output:{ rule |$i$、$rule$;
}$
# 回答示例
- 正确示例:恭喜您...
- 错误示例:"恭喜您..."
- 错误示例:{"message": "恭喜您..."}
  1. 开发提示词模板工具类:用于加载提示词模板文件并创建 ST 对象:
java 复制代码
package com.joezhou.agent.util;

/** @author 周航宇 */
@Slf4j
public class StUtil {

    /** 模板分隔符开始字符 */
    private static final char DELIMITER_START_CHAR = '$';
    /** 模板分隔符结束字符 */
    private static final char DELIMITER_STOP_CHAR = '$';
    /** 资源加载器 */
    private static final DefaultResourceLoader RESOURCE_LOADER = new DefaultResourceLoader();

    /**
     * 通过加载 .st 文件模板(使用 $ 分隔符)来构建 ST 实例
     *
     * @param location 资源路径 prompt/xxx.st
     * @return ST 实例
     */
    public static ST load(String location) {
        try {
            // 加载资源,支持 classpath 下的文件
            Resource resource = RESOURCE_LOADER.getResource(location);
            // 获取文件路径
            Path path = resource.getFile().toPath();
            // 读取文件内容
            String content = Files.readString(path, StandardCharsets.UTF_8);
            // 构建 ST 对象,使用 $ 和 $ 作为分隔符
            return new ST(content, DELIMITER_START_CHAR, DELIMITER_STOP_CHAR);
        } catch (Exception e) {
            throw new RuntimeException("加载 classpath 下 .st 文件模板失败", e);
        }
    }

    /**
     * 构建内联字符串模板(使用 < 和 > 分隔符)
     *
     * @param templateText 模板文本
     * @return ST 实例
     */
    public static ST build(String templateText) {
        // 构建 ST 对象,使用 < 和 > 作为分隔符(默认值)
        return new ST(templateText);
    }
}

E01. 基础对话

心法:基于 ChatClient 开发基础对话功能。

业务功能

  • 使用 ST 模板来构建的系统提示词。
  • 使用 ChatOptions 来构建运行时配置,覆盖启动时配置。
  • 附加 LogAdvisor 顾问器完成请求和响应的拦截和日志打印功能。
  • 附加 RedisAdvisor 顾问器完成对话记忆功能(历史对话存入 Redis 中):
    • 使用 userId 进行会话和记忆的隔离。
    • 使用滑动窗口进行记忆滚动(最大窗口值为 10,即 5 轮对话)。
    • 记忆过期时间 30 分钟。
  • 使用流式对话,并约定自定义的结束标记 "over"。

武技:基于 ChatClient 开发基础对话功能。

1. 补充主配项

application.yml:添加 RedisAdvisor 相关的主项:

yaml 复制代码
myitem:
  memory:
    key-prefix: "myitem:chat:memory" # Redis 键前缀
    conversation-id: "conversationId" # 会话 ID 键
    default-conversation-id: "default" # 默认会话 ID
    max-messages: 10 # 最大消息数
    expire: 30 # 过期时间(分钟)

2. 开发顾问器

开发 RedisAdvisor 记忆顾问器:

java 复制代码
package com.joezhou.agent.advisor;
import org.springframework.ai.chat.messages.Message;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.Assert;
import java.util.concurrent.TimeUnit;

/** @author 周航宇 */
@Slf4j
@Component
@SuppressWarnings("all")
public class RedisAdvisor implements BaseAdvisor {

    private final RedissonClient redissonClient;

    public RedisAdvisor(RedissonClient redissonClient){
        this.redissonClient = redissonClient;
    }

    /** Redis Key 前缀 */
    @Value("${myitem.memory.key-prefix}")
    private String KEY_PREFIX;
    /** 对话 ID 键名 */
    @Value("${myitem.memory.conversation-id}")
    private String CONVERSATION_ID;
    /** 默认对话 ID */
    @Value("${myitem.memory.default-conversation-id}")
    private String DEFAULT_CONVERSATION_ID;
    /** 滑动窗口最大消息数(用户 + AI 各算 1 条) */
    @Value("${myitem.memory.max-messages}")
    private int MAX_MESSAGES;
    /** 记忆过期时间(分钟):30 分钟无操作自动删除 */
    @Value("${myitem.memory.expire}")
    private long EXPIRE;

    /**
     * AI 调用前执行:
     * 1. 从 Redis 中读取历史消息
     * 2. 将本次用户消息加入到历史消息
     * 3. 重新构建提示词和请求(包含最新的历史消息)
     * 4. 返回新请求
     */
    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {

        // 获取会话 ID
        String conversationId = this.getConversationId(chatClientRequest.context(), DEFAULT_CONVERSATION_ID);
        log.info("✅【RedisAdvisor】获取对话 ID:conversationId={}", conversationId);

        // 通过会话 ID 从 Redis 读取历史消息【M】,该列表用于存取记忆消息
        String key = KEY_PREFIX + conversationId;
        RList<Message> memoryMessages = redissonClient.getList(key);
        log.info("✅【RedisAdvisor】获取 Redis 历史消息:memoryMessages={}", memoryMessages);
        log.info("✅【RedisAdvisor】获取 Redis 历史消息数量:memoryMessagesSize={}", memoryMessages.size());

        // 创建消息列表【P】,内容和【M】一样,但功能不同,该列表仅用于更新提示词和请求
        List<Message> processedMessages = new ArrayList<>(memoryMessages);

        // 将本次的请求中的用户消息(列表类型)加入到消息列表【P】中
        List<Message> userMessages = chatClientRequest.prompt().getInstructions();
        log.info("✅【RedisAdvisor】获取本次用户消息:userMessages={}", userMessages);
        processedMessages.addAll(userMessages);

        // 更新本次请求(ChatClientRequest 是只读对象,必须使用 mutate 构建新对象)
        ChatClientRequest processedChatClientRequest = chatClientRequest.mutate().prompt(
                // 更新本次请求中的提示词(Prompt 是只读对象,必须使用 mutate 构建新对象)
                chatClientRequest.prompt().mutate().messages(processedMessages).build()
        ).build();

        // 从更新后的请求中获取用户消息(单条消息)加入到消息列表【M】中
        UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
        memoryMessages.add(userMessage);
        memoryMessages.expire(EXPIRE, TimeUnit.MINUTES);
        log.info("✅【RedisAdvisor】更新 Redis 历史消息:将用户消息加入到 Redis 历史消息列表中");
        log.info("✅【RedisAdvisor】更新 Redis 历史消息数量:memoryMessagesSize={}", memoryMessages.size());

        // 返回处理后的本次请求对象
        return processedChatClientRequest;
    }

    /**
     * AI 调用后执行:
     * 1. 从 Redis 中读取历史消息
     * 2. 将本次 AI 消息加入到历史消息
     * 3. 滑动窗口裁剪,只保留最新 MAX_MESSAGES 条消息
     * 4. 刷新过期时间(每次请求自动续期)
     * 5. 返回响应
     */
    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {

        // 获取会话 ID
        String conversationId = this.getConversationId(chatClientResponse.context(), DEFAULT_CONVERSATION_ID);
        log.info("✅【RedisAdvisor】获取对话 ID:conversationId={}", conversationId);

        // 通过会话 ID 从 Redis 读取历史消息
        String key = KEY_PREFIX + conversationId;
        RList<Message> memoryMessages = redissonClient.getList(key);
        log.info("✅【RedisAdvisor】获取 Redis 历史消息:memoryMessages={}", memoryMessages);
        log.info("✅【RedisAdvisor】获取 Redis 历史消息数量:memoryMessagesSize={}", memoryMessages.size());

        // 获取 AI 回复消息
        AssistantMessage assistantMessage = chatClientResponse.chatResponse().getResult().getOutput();
        log.info("✅【RedisAdvisor】获取 AI 回复消息:assistantMessage={}", assistantMessage);

        // 将 AI 回复的消息(单条消息)加入到历史消息中
        memoryMessages.add(assistantMessage);
        log.info("✅【RedisAdvisor】更新 Redis 历史消息:将 AI 回复消息加入到 Redis 历史消息列表中");
        log.info("✅【RedisAdvisor】更新 Redis 历史消息数量:memoryMessagesSize={}", memoryMessages.size());

        // 滑动窗口:只保留最新 MAX_MESSAGES 条,防止上下文过长
        if (memoryMessages.size() > MAX_MESSAGES) {
            List<Message> latestMessages = memoryMessages.subList(memoryMessages.size() - MAX_MESSAGES, memoryMessages.size());
            memoryMessages.clear();
            memoryMessages.addAll(latestMessages);
            log.info("✅【RedisAdvisor】更新 Redis 历史消息:滑动窗口裁剪,保留最新 {} 条消息", MAX_MESSAGES);
        }
        
        // 刷新过期时间(每次请求自动续期)
		memoryMessages.expire(EXPIRE, TimeUnit.MINUTES);
		log.info("✅【RedisAdvisor】刷新 Redis 历史消息过期时间");

        // 返回响应
        return chatClientResponse;
    }

    /** Advisor 唯一名称 */
    @Override
    public String getName() {
        return "RedisAdvisor";
    }

    /** 执行顺序:数字越小,越先执行 */
    @Override
    public int getOrder() {
        return 0;
    }

    /** 获取会话 ID */
    private String getConversationId(Map<String, Object> context, String defaultConversationId) {
        Assert.notNull(context, "context cannot be null");
        Assert.noNullElements(context.keySet().toArray(), "context cannot contain null keys");
        Assert.hasText(defaultConversationId, "defaultConversationId cannot be null or empty");
        // 若请求中包含 key="conversationId",则返回对应的 value
        // 若不包含,则返回默认的 "default":参考 ChatMemory → DEFAULT_CONVERSATION_ID
        return context.containsKey(CONVERSATION_ID) ? context.get(CONVERSATION_ID).toString() : defaultConversationId;
    }
}

3. 开发控制器

java 复制代码
package com.joezhou.agent.controller;
import io.swagger.v3.oas.annotations.tags.Tag;

/** @author 周航宇 */
@RestController
@Tag(name = "AI智能聊天专员")
@RequestMapping("/api/v1/client")
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder chatClientBuilder, RedisAdvisor redisAdvisor) {

        // 创建系统提示词模板
        ST st = StUtil.load("classpath:prompt/ChatPrompt.st");
        st.add("role", "你是一个聊天助手,负责用最精炼的语言和用户进行沟通");
        st.add("input", List.of("必须基于用户的输入来生成回复"));
        st.add("output", List.of("只能输出纯中文文本,严禁输出JSON、代码块或其他格式"));

        // 构建模型配置(运行时配置)
        ChatOptions chatOptions = ChatOptions.builder()
                .model("qwen-max") // 设置模型名称
                .maxTokens(100) // 设置最大令牌数(100 个字符)
                .temperature(0.7) // 设置温度(0.7 = 中等风险)
                .topK(50) // 设置 Top-K 滤波(50 个候选)
                .topP(0.9) // 设置 Top-P 滤波(0.9 = 90% 概率)
                .build(); // 构建模型配置

        // 创建日志顾问器(自定义需要打印的请求日志内容,响应日志内容和优先级)
        SimpleLoggerAdvisor loggerAdvisor = new SimpleLoggerAdvisor(
                req -> "✅【LogAdvisor】提示词:" + req.prompt().getContents(), // 拦截请求,但只打印提示词文本
                res -> "✅【LogAdvisor】AI回复:" + res.getResult().getOutput().getText(), // 拦截响应,但只打印 AI 回复的内容
                0 // 执行优先级:数字越小越先执行(0 = 最高优先级)
        );

        // 创建对话客户端
        this.chatClient = chatClientBuilder
                .defaultSystem(st.render()) // 设置默认系统提示
                .defaultOptions(chatOptions) // 设置默认模型配置
                .defaultAdvisors(loggerAdvisor, redisAdvisor) // 附加顾问器
                .build();
    }

    @Operation(summary = "AI对话")
    @GetMapping("chat")
    public Flux<String> chat(@RequestParam("input") String input, HttpServletRequest request) {

        // 取出用户的 ID
        long userId = Long.parseLong(request.getAttribute("userId").toString());

        // 流式发送消息(将响应结果以流的形式推送给前端)
        return chatClient.prompt()
                .user(input) // 设置用户消息
                .advisors(e -> e.param("conversationId", userId)) // 设置对话 ID:相同 ID 共享同一套记忆
                .stream() // 流式发送消息(将响应结果以流的形式推送给前端)
                .content() // 获取响应内容
                .concatWith(Flux.just("[over]")); // 约定结束标记,方便前端灵活终止 SSE 推送
    }
}

E02. 增强对话(RAG)

心法:基于 ChatClient 开发 RAG 增强对话功能。

业务功能

  • 使用 ST 模板来构建的系统提示词。
  • 使用 ETL 加载收纳相关的知识文档。
  • 附加 LogAdvisor 顾问器完成请求和响应的拦截和日志打印功能。
  • 附加 QuestionAnswerAdvisor 顾问器完成基于向量库相似度搜索的问答功能。
  • 使用流式对话,并约定自定义的结束标记 "over"。

武技:基于 ChatClient 开发 RAG 增强对话功能。

1. 补充主配项

application.yml:添加 RedisStack 相关的主项:

yaml 复制代码
myitem:
  redis-stack:
    host: 192.168.40.77 # 主机
    port: 5379 # 端口
    password: joezhou # 密码
    prefix: "myitem:rag:" # 键前缀,RedisStack 存值取值时,均会自动使用该前缀

2. 开发配置类

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Value;

/** @author 周航宇 */
@Slf4j
@Configuration
public class RedisVectorStoreConfig {

    @Value("${myitem.redis-stack.host}")
    private String REDIS_HOST;
    @Value("${myitem.redis-stack.port}")
    private int REDIS_PORT;
    @Value("${myitem.redis-stack.password}")
    private String REDIS_PASSWORD;
    @Value("${myitem.redis-stack.prefix}")
    private String REDIS_PREFIX;

    @Bean("redisVectorStore")
    public VectorStore redisVectorStore(EmbeddingModel embeddingModel) {

        // 创建独立 Redis Stack 连接
        JedisPooled jedisPooled = new JedisPooled(REDIS_HOST, REDIS_PORT, null, REDIS_PASSWORD);

        // 创建 RedisVectorStore 实例
        return RedisVectorStore.builder(jedisPooled, embeddingModel)
                .prefix(REDIS_PREFIX)
                .initializeSchema(true) // 自动创建索引
                .build();
    }
}

3. 开发RAG文档

classpath:rag/knowledge.txt

txt 复制代码
1.  **先"断舍离",后收纳**:整理的第一步永远是清理不需要的物品,而不是买收纳盒。
2.  **80/20 法则**:每个空间只填满 80%,留出 20% 的弹性空间,让物品有"呼吸"的余地。
3.  **三成展示、七成隐藏**:露出 30% 的美观物品,藏起 70% 的杂乱杂物,视觉最平衡。
4.  **同类相聚**:把同类物品放在一起,是收纳的最基本原则。
5.  **动线优先**:物品应放在使用它的地方附近,顺着生活习惯来收纳。
6.  **黄金区域**:最常用的物品放在视线到腰部之间(离地 60-180cm),取用最省力。
7.  **垂直收纳**:与其平铺,不如向上发展,利用墙面和层高。
8.  **可视化管理**:尽量用透明容器,一眼就能看到里面装了什么。
9.  **贴标签**:给收纳盒、文件夹贴上标签,全家人都知道东西在哪、该放回哪。
10. **定期维护**:收纳不是一次性的,每周或每月花点时间维持。
11. **清空**:把一个柜子或抽屉彻底清空。
12. **清洁**:趁清空时把内部擦干净。
13. **分类**:把物品分成三大类:保留、丢弃、待定。
14. **三袋法**:准备三个袋子,分别装"要留的"、"要捐/卖的"、"要扔的"。
15. **一年法则**:超过一年没用过的东西,可以考虑处理掉。
16. **心动法则**:只留下让你心动的物品(来自近藤麻理惠的整理魔法)。
17. **拍照留存**:对有纪念意义但不再使用的物品拍照,然后处理掉实物。
18. **从大到小**:先整理大型物品(家具、家电),再处理小物件。
19. **从多到少**:先整理数量最多的品类(如衣服、书),效果最明显。
20. **一次一个区域**:不要试图一次性整理全屋,逐个击破更有成就感。
21. **接受不完美**:不必追求杂志封面般的整齐,顺手好用最重要。
22. **收纳是治愈的过程**:把整理看作放松心情的方式,而非负担。
23. **告别完美主义**:"糊弄式收纳"也是一种智慧,怎么舒服怎么来。
24. **从每天 10 分钟开始**:养成每天小整理的习惯,避免积重难返。
25. **家人的参与**:让家人也参与到收纳中,尤其是孩子的玩具区。
26. **换位整理**:如果舍不得扔自己的东西,试试和家人交换整理,旁观者清。
27. **寻找灵感**:多看家居美图,给自己整理的动力。
28. **享受过程**:把整理当作与物品对话的过程。
29. **不盲目跟风**:不要因为网红推荐就买一堆收纳用品,先看看家里有什么可用的。
30. **以终为始**:想清楚你想要什么样的生活空间,再动手整理。
31. **一进一出**:买一件新东西,就处理掉一件旧东西。
32. **临时放置区**:在玄关或客厅设一个中转站,放暂时没空处理的物品。
33. **就地取材**:不花钱买收纳工具,用废旧物品改造。
34. **颜色标签**:用不同颜色的标签区分季节或类别(如蓝色=秋冬,黄色=春夏)。
35. **利用门后**:门后空间是极佳的隐形收纳区。
36. **向上借空间**:天花板附近的区域也可以开发成收纳空间。
37. **向下借空间**:床底、沙发底也是天然的收纳区。
38. **转角不浪费**:房间转角可以做成转角柜或展示架。
39. **缝隙利用**:冰箱和橱柜之间的缝隙,可以用窄缝收纳架塞满。
40. **隐藏式设计**:把收纳功能融入家具中(如可储物的床、茶几)。
41. **抽拉式收纳**:在狭窄或深处空间使用抽拉设计,方便取物。
42. **立式收纳**:用窄柜或置物架利用垂直角落。
43. **统一容器**:选用统一颜色、材质的收纳盒,视觉更整洁。
44. **视觉焦点**:在收纳区设置一个视觉焦点(如一个漂亮的摆件),提升美感。
45. **奇数原则**:摆放装饰品时,3 个比 4 个看起来更顺眼。
46. **可堆叠**:选择可以堆叠的收纳箱,充分利用高度。
47. **带轮子**:给大收纳箱装上轮子,移动方便。
48. **美型收纳**:把收纳本身变成装饰,比如用漂亮的瓶子装食材。
49. **借光线**:在收纳区增加灯光,让物品看起来更高级。
50. **一致性**:保持收纳风格与家居风格一致,避免凌乱感。
51. **鞋柜隔板**:用可调节隔板,把鞋柜空间利用到极致。
52. **鞋托**:用双层鞋托,一双鞋的空间能放两双。
53. **换鞋凳**:选择带收纳功能的换鞋凳,放常用拖鞋。
54. **伞架**:门后放吸盘式伞架,长柄折伞都能收纳,还不用担心滴水。
55. **洞洞板**:玄关墙面装洞洞板,挂帽子、包包、钥匙。
56. **挂钩**:进门处设一排挂钩,次净衣、外套随手挂。
57. **托盘**:在鞋柜台面放托盘,收纳钥匙、零钱、门卡。
58. **抽屉分隔**:玄关抽屉用分隔盒,放口罩、纸巾、拆快递刀。
59. **铁皮窄柜**:门后放铁皮窄柜,划分落尘区,收纳外衣鞋子。
60. **隐形储藏室**:如果空间允许,在玄关旁规划一个迷你储藏室,放行李箱、工具、包裹。
61. **墙面挂钩**:利用墙面空间挂包包、帽子、围巾。
62. **鞋柜底部悬空**:鞋柜底部留空 15cm,放常穿的鞋子,进门一脚踢进去。
63. **统一鞋盒**:用统一的透明鞋盒收纳换季鞋子,叠放整齐。
64. **换季区**:鞋柜上层放当季不常穿的鞋,下层放当季常穿的。
65. **雨伞沥水**:雨伞收纳区下方放一个托盘接水,保护地板。
66. **壁挂置物盒**:在门后装壁挂置物盒,收纳零碎小物。
67. **镜子+收纳**:选带小置物架的穿衣镜,放梳子、发胶。
68. **挂烫机收纳**:如果玄关有空间,可以收纳手持挂烫机,出门前熨一下。
69. **宠物用品区**:在玄关低处设宠物出门用品区(牵引绳、拾便袋)。
70. **快递工具区**:在玄关抽屉里放拆快递用的剪刀、美工刀。
71. **季节性鞋履**:长靴、雪地靴放在鞋柜最上层或最下层。
72. **儿童鞋区**:有孩子的家庭,在玄关低处给孩子一个专属鞋位。
73. **访客拖鞋**:用收纳篮装几双访客拖鞋,放在鞋柜旁。
74. **清洁工具**:在玄关柜内挂一个小扫把或粘毛滚,出门前整理衣物。
75. **信件架**:在玄关墙面装一个小信件架,放待拆的信件和账单。
76. **小物篮**:在鞋柜台面放一个编织篮,随手扔钥匙、门禁卡。
77. **穿鞋辅助**:在换鞋凳旁放一个鞋拔,收纳在挂钩上。
78. **香薰**:在玄关收纳区放一个香薰,进门心情愉悦。
79. **磁吸收纳**:在铁门上贴磁吸挂钩或磁吸置物架。
80. **利用鞋盒盖**:把鞋盒盖钉在墙上,做成小置物架(放钥匙等)。
81. **折叠伞**:折叠伞可以统一放在一个长条收纳盒里。
82. **鞋油鞋刷**:在鞋柜里用一个小盒收纳鞋油鞋刷。
83. **季节性物品**:围巾、手套、帽子在玄关用收纳篮集中管理。
84. **背包区**:在玄关墙面设置背包专用挂钩区。
85. **折叠凳**:在玄关放一个可折叠的凳子,不占地方。
86. **墙面衣帽架**:选择设计感强的墙面衣帽架,兼具装饰和收纳。
87. **雨衣收纳**:雨衣折叠好放在玄关柜抽屉里。
88. **鞋拔挂钩**:把鞋拔挂在门后挂钩上,方便取用。
89. **宠物牵引绳**:在门边低处装挂钩,挂宠物牵引绳。
90. **玄关地毯**:在玄关铺一块易清洁的地毯,阻挡灰尘入屋。
91. **钥匙挂钩**:在进门处装一个钥匙专用挂钩,出门不会忘。
92. **口罩收纳盒**:在玄关放一个口罩收纳盒,出门随手拿。
93. **小盆栽**:在玄关置物架上放一小盆绿植,增添生气。
94. **信报箱**:如果信件多,可以在玄关装一个小型信报箱。
95. **折叠雨伞架**:选带集水盘的折叠雨伞架,美观又实用。
96. **增高架**:在鞋柜台面上放增高架,增加一层置物空间。
97. **壁挂式鞋架**:在墙面装壁挂式折叠鞋架,节省地面空间。
98. **留言板**:在玄关墙面装一块小黑板或软木板,留言备忘。
99. **证件区**:在玄关抽屉里用一个文件袋收纳所有家庭成员的证件复印件。
100. **次净衣区**:在玄关设一个次净衣挂放区,穿过但不脏的外套有地方放。

4. 加载RAG文档

使用单元测试方法,加载 RAG 文档到向量库中:

java 复制代码
package rag;
import org.junit.Test;
import org.springframework.ai.document.Document;
import org.springframework.core.io.ClassPathResource;

/** @author 周航宇 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MyItemApp.class)
public class LoadTest {

    @Resource
    private VectorStore redisVectorStore;

    @Test
    public void loadKnowledge() {
        // 使用 TikaDocumentReader 读取文档
        List<Document> documents = new TikaDocumentReader(new ClassPathResource("rag/knowledge.txt")).read();
        log.info("✅读取 {} 条文档", documents.size());

        // 使用 TokenTextSplitter 切分文档
        List<Document> chunks = TokenTextSplitter.builder()
                .build()
                .apply(documents);
        log.info("✅切分 {} 条文档", chunks.size());

        // 存入向量数据库,这个过程会自动调用 embeddingModel 将文本变成向量再存入向量库
        redisVectorStore.add(chunks);
        log.info("✅存储 {} 条文档", chunks.size());
    }
}

5. 开发控制器

java 复制代码
package com.joezhou.agent.controller;
import io.swagger.v3.oas.annotations.tags.Tag;

/** @author 周航宇 */
@Slf4j
@RestController
@Tag(name = "AI收纳技巧专员")
@RequestMapping("/api/v1/rag")
public class RagController {

    private final ChatClient chatClient;

    public RagController(ChatClient.Builder chatClientBuilder, @Qualifier("redisVectorStore") VectorStore redisVectorStore) {

        // 创建系统提示词模板
        ST st = StUtil.load("classpath:prompt/ChatPrompt.st");
        st.add("role", "你是一个聊天助手,负责根据 RAG 知识库内容用最精炼的语言和用户进行沟通");
        st.add("input", List.of("所有回答必须基于向量数据库中检索到相关知识进行分析,扩展和总结,并生成回复"));
        st.add("output", List.of(
                "只能输出纯中文文本,严禁输出JSON、代码块或其他格式",
                "若 RAG 知识库中不存在相关知识,直接返回 "我不清楚""));

        // 创建日志顾问器(自定义需要打印的请求日志内容,响应日志内容和优先级)
        SimpleLoggerAdvisor loggerAdvisor = new SimpleLoggerAdvisor(
                req -> "✅【LogAdvisor】提示词:" + req.prompt().getContents(),
                res -> "✅【LogAdvisor】AI回复:" + res.getResult().getOutput().getText(),
                0
        );

        // 创建问答顾问器
        QuestionAnswerAdvisor questionAnswerAdvisor = QuestionAnswerAdvisor.builder(redisVectorStore)
                .searchRequest(SearchRequest.builder()
                        .topK(2)
                        .similarityThreshold(0.0)
                        .build())
                .build();

        // 创建对话客户端
        this.chatClient = chatClientBuilder
                .defaultSystem(st.render())
                .defaultAdvisors(loggerAdvisor, questionAnswerAdvisor)
                .build();
    }

    @Operation(summary = "AI对话")
    @GetMapping("chat")
    public Flux<String> chat(@RequestParam("input") String input) {
        // 流式发送消息(将响应结果以流的形式推送给前端)
        return chatClient.prompt()
                .user(input)
                .stream()
                .content()
                .concatWith(Flux.just("[over]"));
    }
}

S02. Agent工具调用

心法:本章节基于 ReactAgent 进行开发。

开发 Skills 注册器:

java 复制代码
package com.joezhou.agent.config;

/** @author 周航宇 */  
@Configuration  
@Slf4j  
public class SkillConfig {  
    private static final String CACHE_PATH = "D:/workspace/java/my-item-backend/cache";  
  
    /**  
     * 配置 Skill 注册器,该注册器用于扫描,加载和管理指定目录下的 SKILL.md  
     * 让 Agent 知道他有哪些技能包可以用  
     *  
     * @return Skill 注册器,全局可用,推荐单独配 bean,加入 Spring 管理,而非局部创建  
     */  
    @Bean  
    public SkillRegistry skillRegistry() {  
        // 清理缓存  
        log.info("✅清理缓存:{}", FileUtil.del(CACHE_PATH));  
        // 注册 SKILL.md 文件  
        return ClasspathSkillRegistry.builder()  
                // 指定扫描路径,默认 resources/skills/ 中的全部 SKILL.MD 文件  
                .classpathPath("skills")  
                // 指定缓存目录,默认生成 D:/tmp 目录并使用  
                .basePath(CACHE_PATH)  
                .build();  
    }  
}

开发钩子,用于记录 Agent 调用日志:

java 复制代码
package com.joezhou.agent.hook;

/** @author 周航宇 */
@Scope("prototype") // 让每个 Agent 都有一个独立的钩子实例,否则会共享状态
@Slf4j
@Component
public class MyLogHook extends AgentHook {

    /**
     * 在 Agent 开始前调用,记录开始时间并初始化状态变量
     *
     * @param state  全局状态,包含用户输入、模型输出等信息
     * @param config 运行配置,包含模型选择、参数设置等
     * @return 包含自定义数据的 CompletableFuture,用于后续处理
     */
    @Override
    public CompletableFuture<Map<String, Object>> beforeAgent(OverAllState state, RunnableConfig config) {
        String agentName = this.getAgentName();
        log.info("✅【{}】开始执行", agentName);
        return CompletableFuture.completedFuture(Map.of("agentName", agentName));
    }

    /**
     * 在 Agent 结束后调用
     *
     * @param state  全局状态,包含用户输入、模型输出等信息
     * @param config 运行配置,包含模型选择、参数设置等
     * @return 包含自定义数据的 CompletableFuture,用于后续处理
     */
    @Override
    public CompletableFuture<Map<String, Object>> afterAgent(OverAllState state, RunnableConfig config) {
        log.info("✅【{}】执行结束", this.getAgentName());
        // 返回空 CompletableFuture,表示没有后续处理需求
        return CompletableFuture.completedFuture(Map.of());
    }

    @Override
    public String getName() {
        return "MyLogHook";
    }
}

E01. 数据管理Agent

心法:基于 ReactAgent + LlmRoutingAgent 开发数据管理功能。

业务功能

  • 使用 LlmRoutingAgent 完成 Agent 的路由功能:
    • 物品管理 → ItemAgent。
    • 房间管理 → RoomAgent。
  • 附加 LogAgentHook 完成 Agent 调用过程的日志打印功能。
  • 附加 SkillsAgentHook 完成本地工具注册功能。
  • 使用流式对话,并约定自定义的结束标记 "over"。

业务流程 - 图示

心法:基于 ReactAgent + LlmRoutingAgent 开发数据管理功能。

1. ItemTool

java 复制代码
package com.joezhou.agent.tool;
import static com.joezhou.entity.table.ItemCategoryTableDef.ITEM_CATEGORY;
import static com.joezhou.entity.table.ItemTableDef.ITEM;

/** @author 周航宇 */
@Slf4j
@Component
public class ItemTool {

    @Resource
    private ItemMapper itemMapper;
    @Resource
    private ItemCategoryMapper itemCategoryMapper;

    @Tool(name = "queryItemDetail", description = "根据物品名称查询该物品的完整详细信息")
    public String queryItemDetail(
            @ToolParam(description = "用户ID") String userId,
            @ToolParam(description = "物品名称") String itemName) {
        log.info("✅调用 queryItemDetail,参数:userId={}, itemName={}", userId, itemName);
        // 查询物品详细信息
        Item item = QueryChain.of(itemMapper)
                .where(ITEM.ITEM_NAME.like(itemName))
                .and(ITEM.USER_ID.eq(userId))
                .one();
        if (ObjectUtil.isNull(item)) {
            return "未查询到物品详细信息" + itemName;
        }

        // 查询物品详细信息
        String itemCategoryName = QueryChain.of(itemCategoryMapper)
                .select(ITEM_CATEGORY.CATEGORY_NAME)
                .where(ITEM_CATEGORY.ID.eq(item.getItemCategoryId()))
                .objAs(String.class);

        // 处理查询结果
        String result = """
                名称:%s<br/>
                类别:%s<br/>
                标签:%s<br/>
                简介:%s<br/>
                价格:%s元<br/>
                库存:%s<br/>
                使用率:%s颗星<br/>
                性价比:%s颗星<br/>
                购入日期:%s<br/>
                过期日期:%s<br/>
                物品状态:%s<br/>
                状态变更:%s<br/>
                物品去向:%s<br/>
                去向变更:%s<br/>
                """.formatted(
                item.getItemName(),
                itemCategoryName,
                item.getTags(),
                item.getSummary(),
                item.getPrice(),
                item.getCount(),
                item.getUsageScore(),
                item.getValueScore(),
                ObjectUtil.isNull(item.getBuyDate()) ? "无" : item.getBuyDate().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")),
                ObjectUtil.isNull(item.getExpireDate()) ? "无" : item.getExpireDate().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日")),
                this.formatItemStatus(item.getItemStatus()),
                ObjectUtil.isNull(item.getStatusChangeTime()) ? "无" : item.getStatusChangeTime().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分")),
                this.formatItemOutcome(item.getItemOutcome()),
                ObjectUtil.isNull(item.getOutcomeChangeTime()) ? "无" : item.getOutcomeChangeTime().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH时mm分"))
                
        );
        log.info("✅调用 queryItemDetail,结果:result={}", result);
        return result;
    }

    private String formatItemStatus(Integer itemStatusCode) {
        if (0 == itemStatusCode) return "服役中";
        if (1 == itemStatusCode) return "闲置中";
        if (2 == itemStatusCode) return "出借中";
        if (3 == itemStatusCode) return "维修中";
        return "未知状态";
    }

    private String formatItemOutcome(Integer itemOutcomeCode) {
        if (0 == itemOutcomeCode) return "持有中";
        if (1 == itemOutcomeCode) return "已消耗";
        if (2 == itemOutcomeCode) return "已丢弃";
        if (3 == itemOutcomeCode) return "已丢失";
        if (4 == itemOutcomeCode) return "已转赠";
        if (5 == itemOutcomeCode) return "已售出";
        return "未知状态";
    }
}

2. SKILL.MD

classpath:skills/item-skills/SKILL.MD

txt 复制代码
---
name: item-skills
description: 物品管理业务技能,提供物品详情查询、物品位置查询等能力。
version: 1.0
author: 周航宇
tags: 物品查询,物品位置
tools:
  - queryItemDetail
  - queryItemPosition
---

# 物品管理技能

## 技能定位

作为个人物品系统专属助手,统一处理查询物品详情与查询物品位置两类核心业务操作,严格按照业务规则调用对应工具完成诉求。

## 启用范围

当用户提问包含以下关键词及相关语义时,启用本技能:

- 查询物品详情:XXX → 提取参数 itemName = XXX → 调用 queryItemDetail(itemName=XXX) 工具
- 查询物品位置:XXX → 提取参数 itemName = XXX → 调用 queryItemPosition(itemName=XXX) 工具

## 工具详情

### 1. queryItemDetail(查询物品详情)

- 功能描述:根据物品名称查询该物品的完整详细信息。
- 入参描述:
    - itemName(物品名称,必填,1-128字符)
- 返回格式:字符串(物品详情描述)
- 执行逻辑:直接调用工具并返回结果。

### 2. queryItemPosition(查询物品位置)

- 功能描述:根据物品名称查询该物品的位置。
- 入参描述:
    - itemName(物品名称,必填,1-128字符)
- 返回格式:字符串(物品位置描述)
- 执行逻辑:直接调用工具并返回结果。

## 行为规范

1. 工具隔离:严格区分工具职责,禁止跨场景混用工具;
2. 参数校验:调用工具前逐项校验必填参数,参数缺失时明确告知用户并引导补全;
3. 工具范围:仅使用本技能声明的工具,不调用技能外其他能力;
4. 返回规范:所有正常执行结果直接返回文本,不使用JSON格式;
5. 数据安全:禁止编造参数或结果数据,严格依赖工具返回结果,如实返回工具执行结果;
6. 禁用缓存:每次都必须调用工具,禁止使用缓存或记忆中的数据;

## 交互示例

### 正常示例01

用户请求:查询物品详情:苹果手机
工具调用:queryItemDetail(itemName=苹果手机)

### 正常示例02

用户请求:查询物品位置:苹果手机
工具调用:queryItemPosition(itemName=苹果手机)  

3. ItemAgentConfig

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j
@Configuration
public class ItemAgentConfig {

    private final String NAME = "itemAgent";
    private final String SYSTEM_PROMPT = """
            # 角色定位
            你是物品管理专员,负责查询物品相关信息。
            # 输入规则
            - 你必须分析用户意图,然后调用 skill 中定义的对应工具,禁止直接回答用户问题;
            - userId必须从用户输入中直接提取,格式为【userId=userId】,不存在则直接文字提示用户补充,禁止调用工具;
            # 输出规则
            - 必须直接返回工具调用的结果,不做任何处理,结果中的 <br/> 标记必须保留;
            """;

    @Bean(NAME)
    public ReactAgent skillAgent(ChatModel chatModel,
                                 SkillRegistry skillRegistry,
                                 ItemTool itemTool,
                                 MyLogHook myLogHook) {
        log.info("✅初始化智能体: {}", NAME);
        
        // 加载 ItemTool 工具
        List<ToolCallback> itemToolCallbacks = List.of(MethodToolCallbackProvider.builder()
                .toolObjects(itemTool)
                .build()
                .getToolCallbacks());
        itemToolCallbacks.forEach(callback -> log.info("✅加载工具:{}", callback.getToolDefinition().name()));
        
        // 创建钩子
        SkillsAgentHook skillsAgentHook = SkillsAgentHook.builder()
                .skillRegistry(skillRegistry)
                .groupedTools(Map.of("item-skills", itemToolCallbacks))
                .build();

        // 创建智能体
        return ReactAgent.builder()
                .name(NAME)
                .systemPrompt(SYSTEM_PROMPT)
                .model(chatModel)
                .hooks(skillsAgentHook, myLogHook) // 添加钩子
                .toolExecutionTimeout(Duration.ofMinutes(5)) // 增加超时时间
                .build();
    }
}

4. RoomTool

java 复制代码
package com.joezhou.agent.tool;
import static com.joezhou.entity.table.RoomTableDef.ROOM;

/** @author 周航宇 */
@Slf4j
@Component
public class RoomTool {

    @Resource
    private RoomMapper roomMapper;

    @Tool(name = "queryRoomDetail", description = "根据房间名称查询该房间的完整详细信息")
    public String queryRoomDetail(
            @ToolParam(description = "用户ID") String userId,
            @ToolParam(description = "房间名称") String roomName) {
        log.info("✅调用 queryRoomDetail,参数:userId={}, roomName={}", userId, roomName);

        // 查询房间详细信息
        Room room = QueryChain.of(roomMapper)
                .where(ROOM.ROOM_NAME.like(roomName))
                .and(ROOM.USER_ID.eq(userId))
                .one();
        
        // 处理查询结果
        String result = """
                名称:%s<br/>
                图标:%s<br/>
                简介:%s<br/>
                """.formatted(
                room.getRoomName(),
                room.getEmoji(),
                room.getSummary()
        );
        log.info("✅调用 queryRoomDetail,结果:result={}", result);
        return result;
    }
}

5. SKILL.MD

classpath:skills/room-skills/SKILL.MD

txt 复制代码
---
name: room-skills
description: 房间管理业务技能,提供房间详情查询等能力。
version: 1.0
author: 周航宇
tags: 房间查询
tools:
  - queryRoomDetail
---

# 房间管理技能

## 技能定位

作为个人房间系统专属助手,统一处理查询房间详情的业务操作,严格按照业务规则调用对应工具完成诉求。

## 启用范围

当用户提问包含以下关键词及相关语义时,启用本技能:

- 查询房间详情:XXX → 提取参数 roomName = XXX → 调用 queryRoomDetail(roomName=XXX) 工具

## 工具详情

### 1. queryRoomDetail(查询房间详情)

- 功能描述:根据房间名称查询该房间的完整详细信息。
- 入参描述:
    - roomName(房间名称,必填,1-128字符)
- 返回格式:字符串(房间详情描述)
- 执行逻辑:直接调用工具并返回结果。

## 行为规范

1. 工具隔离:严格区分工具职责,禁止跨场景混用工具;
2. 参数校验:调用工具前逐项校验必填参数,参数缺失时明确告知用户并引导补全;
3. 工具范围:仅使用本技能声明的工具,不调用技能外其他能力;
4. 返回规范:所有正常执行结果直接返回文本,不使用JSON格式;
5. 数据安全:禁止编造参数或结果数据,严格依赖工具返回结果,如实返回工具执行结果;
6. 禁用缓存:每次都必须调用工具,禁止使用缓存或记忆中的数据;

## 交互示例

### 正常示例01

用户请求:查询房间详情:主卧
工具调用:queryRoomDetail(roomName=主卧)

6. RoomAgentConfig

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j
@Configuration
public class RoomAgentConfig {

    private static final String NAME = "roomAgent";
    private final String SYSTEM_PROMPT = """
            # 角色定位
            你是房间管理专员,负责查询房间相关信息。
            # 输入规则
            - 你必须分析用户意图,然后调用 skill 中定义的对应工具,禁止直接回答用户问题;
            - userId必须从用户输入中直接提取,格式为【userId=userId】,不存在则直接文字提示用户补充,禁止调用工具;
            # 输出规则
            - 必须直接返回工具调用的结果,不做任何处理,结果中的 <br/> 标记必须保留;
            """;

    @Bean(NAME)
    public ReactAgent skillAgent(ChatModel chatModel,
                                 SkillRegistry skillRegistry,
                                 RoomTool roomTool,
                                 MyLogHook myLogHook) {
        log.info("✅初始化智能体: {}", NAME);
        
        // 加载 RoomTool 工具
        List<ToolCallback> roomToolCallbacks = List.of(MethodToolCallbackProvider.builder()
                .toolObjects(roomTool)
                .build()
                .getToolCallbacks());
        roomToolCallbacks.forEach(callback -> log.info("✅加载工具:{}", callback.getToolDefinition().name()));
        
        // 创建钩子
        SkillsAgentHook skillsAgentHook = SkillsAgentHook.builder()
                .skillRegistry(skillRegistry)
                .groupedTools(Map.of("room-skills", roomToolCallbacks))
                .build();
        
        // 创建智能体
        return ReactAgent.builder()
                .name(NAME)
                .systemPrompt(SYSTEM_PROMPT)
                .model(chatModel)
                .hooks(skillsAgentHook, myLogHook) // 添加钩子
                .toolExecutionTimeout(Duration.ofMinutes(5)) // 增加超时时间
                .build();
    }
}

7. RoutingAgentConfig

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j
@Configuration
public class RoutingAgentConfig {

    private final String NAME = "routingAgent";
    private final String SYSTEM_PROMT = """
            # 角色定位
            你是Agent调度专员,负责严格匹配问题并分配对应的Agent专员;
            # 输出规则
            - 你只能输出匹配的Agent专员的名字,不能输出其他内容;
            - 物品相关的问题,如查询物品详情,查询物品位置,查询临期物品等 → itemAgent;
            - 房间相关的问题,如查询房间详情,查询房间内的全部位置,查询房间数量等 → roomAgent;
            """;

    @Bean(NAME)
    public LlmRoutingAgent routingAgent(ChatModel chatModel,
                                        @Qualifier("itemAgent") ReactAgent itemAgent,
                                        @Qualifier("roomAgent") ReactAgent roomAgent) {
        log.info("✅初始化智能体: {}", NAME);
        return LlmRoutingAgent.builder()
                .name(NAME)
                .model(chatModel)
                .systemPrompt(SYSTEM_PROMT)
                .subAgents(List.of(itemAgent, roomAgent)) // 子路由
                .build();
    }
}

8. DataAgentController

java 复制代码
package com.joezhou.agent.controller;
import java.util.UUID;

/** @author 周航宇 */
@RestController
@RequestMapping("/api/v1/dataAgent")
public class DataAgentController {

    private final LlmRoutingAgent routingAgent;

    public DataAgentController(@Qualifier("routingAgent") LlmRoutingAgent routingAgent) {
        this.routingAgent = routingAgent;
    }

    @SneakyThrows
    @GetMapping("/chat")
    public Flux<String> chat(@RequestParam("input") String input, HttpServletRequest request) {
        
        // 取出用户的 ID
        String userId = (String) request.getAttribute("userId");
        
        // 增加用户 ID 到输入中
        input = "【userId=" + userId + "】 用户输入: " + input;
        
        // 执行路由 Agent,使用随机的 threadId 进行隔离
        return routingAgent.stream(input, RunnableConfig.builder().threadId(UUID.randomUUID().toString()).build())
                .ofType(StreamingOutput.class)
                .filter(output -> output.getOutputType() == OutputType.AGENT_MODEL_STREAMING)
                .map(StreamingOutput::message)
                .ofType(AssistantMessage.class)
                .mapNotNull(AssistantMessage::getText)
                .filter(Objects::nonNull)
                .concatWith(Flux.just("[over]"));
    }
}

E02. 今日推荐Agent

心法:基于 ReactAgent + SupervisorAgent 开发今日推荐功能。

业务功能

  • 使用 SupervisorAgent 完成 Agent 的调度功能:
    • 调度 WeatherAgent → 查询实时天气。
    • 调度 OutfitAgent → 查询用户的穿搭列表。
  • 使用流式对话,并约定自定义的结束标记 "over"。

业务流程 - 图示

1. MCP配置

classpath:mcp-servers-configuration.json

json 复制代码
{
  "mcpServers": {
    "amap-maps": {
      "args": [
        "-y",
        "@amap/amap-maps-mcp-server"
      ],
      "command": "D:\\nvm\\nodejs\\npx.cmd",
      "env": {
        "AMAP_MAPS_API_KEY": "你的高德KEY"
      }
    }
  }
}

application.yml

yaml 复制代码
spring:
  ai:
    mcp:
      client:
        enabled: true # 启用 MCP 客户端
        request-timeout: 200000 # 请求超时时间(毫秒)
        toolcallback:
          enabled: true # 启用工具回调
        name: my-item-backend # 客户端名称
        stdio:
          servers-configuration: classpath:mcp-servers-configuration.json # MCP 服务器配置文件

2. WeatherAgent

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j
@Configuration
public class WeatherAgentConfig {

    private static final String NAME = "weatherAgent";
    private static final String SYSTEM_PROMPT = """
            # 角色定位
            你是天气查询专用智能体,仅负责调用高德地图MCP工具获取实时天气数据即可。
            # 输入规范
            - 如果提取不到城市名 city,直接回复"请提供您所在的城市",禁止调用工具;
            # 输出规范
            - 获取到天气数据后,整理润色成专业播报的一段文本字符串,便于后续穿搭智能体读取;
            - 禁止输出JSON格式的字符串,代码块等,也不要展示推理和思考的过程;
            - 回复尽量简洁,尽量不超过200个字,给出天气信息即可;
            """;
    private final String OUTPUT_KEY = "WEATHER_AGENT_OUTPUT";
    
    @Bean(NAME)
    public ReactAgent weatherAgent(ChatModel chatModel, ToolCallbackProvider toolCallbackProvider) {
        log.info("✅初始化智能体: {}", NAME);
        return ReactAgent.builder()
                .model(chatModel)
                .name(NAME)
                .systemPrompt(SYSTEM_PROMPT)
                .outputKey(OUTPUT_KEY)
                .toolExecutionTimeout(Duration.ofMinutes(5))
                .toolCallbackProviders(toolCallbackProvider)
                .build();
    }
}

3. OutfitTool

java 复制代码
package com.joezhou.agent.tool;
import com.joezhou.entity.Item;
import static com.joezhou.entity.table.ItemTableDef.ITEM;

/** @author 周航宇 */
@Slf4j
@Component
public class OutfitTool {

    @Resource
    private ItemMapper itemMapper;

    @Tool(name = "queryOutfit", description = "根据用户ID查询该用户的所有穿搭数据")
    public String queryOutfit(@ToolParam(description = "用户ID") String userId) {
        log.info("✅执行 OutfitTool → queryOutfit() 工具,获取到用户ID:{}", userId);
        // 获取用户所有穿搭列表
        List<Item> items = QueryChain.of(itemMapper)
                .select(ITEM.ITEM_NAME, ITEM.SUMMARY)
                .where(ITEM.REMARK.eq("穿搭"))
                .and(ITEM.USER_ID.eq(userId))
                .orderBy(ITEM.USAGE_SCORE.desc())
                .limit(30) // 最多取 30 件,避免撑爆大模型
                .list();

        if (CollUtil.isEmpty(items)) {
            return "当前用户没有穿搭物品。";
        }

        // 拼接成简洁的字符串
        String result = items.stream()
                .map(item -> item.getItemName() + ":" + item.getSummary())
                .collect(Collectors.joining(";"));
        log.info("✅执行 OutfitTool → queryOutfit() 工具,返回穿搭描述:{}", result);

        return result;
    }
}

4. OutfitAgentConfig

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j  
@Configuration  
public class OutfitAgentConfig {  
  
    private static final String NAME = "outfitAgent";  
    private static final String SYSTEM_PROMPT = """  
            # 角色定位  
            你是穿搭查询专用智能体,仅负责查询用户的所有穿搭数据。  
            # 输入规范  
            - userId必须从用户输入中直接提取,格式为【userId=userId】,不存在则直接文字提示用户补充,禁止调用工具;  
            # 输出规范  
            - 必须调用本地工具 queryOutfit 来获取最终的查询结果,禁止使用模型内置知识编造穿搭数据;  
            - 直接返回工具调用结果,无需额外修饰;  
            """;  
    private final String OUTPUT_KEY = "OUTFIT_AGENT_OUTPUT";  
  
    @Bean(NAME)  
    public ReactAgent outfitAgent(ChatModel chatModel, OutfitTool outfitTool) {  
        log.info("✅初始化智能体: {}", NAME);  
        // 创建智能体  
        return ReactAgent.builder()  
                .model(chatModel)  
                .name(NAME)  
                .systemPrompt(SYSTEM_PROMPT)  
                .outputKey(OUTPUT_KEY)  
                .toolExecutionTimeout(Duration.ofMinutes(5))  
                .tools(ToolCallbacks.from(outfitTool)) // 引入本地工具  
                .build();  
    }  
}

5. SupervisorAgentConfig

java 复制代码
package com.joezhou.agent.config;
import org.springframework.ai.chat.model.ChatModel;

/** @author 周航宇 */
@Slf4j
@Configuration
public class SupervisorAgentConfig {
    private final String SUPERVISOR_NAME = "supervisorAgent";
    private final String MAIN_NAME = "mainAgent";
    private final String MAIN_SYSTEM_PROMPT = """
            # 角色定位
            你是调度主管,你的唯一任务就是输出下一步要调用的子Agent名称的JSON数组。
            # 执行规则
            - 请严格按照下面的顺序,只输出JSON数组,不要添加任何解释、空格、换行或其他文字:
                - 第一步输出:["weatherAgent"]
                - 第二步输出:["outfitAgent"]
                - 最后输出:["FINISH"]
            """;
    
    @Bean(SUPERVISOR_NAME)
    public SupervisorAgent supervisorAgent(ChatModel chatModel,
                                           @Qualifier("weatherAgent") ReactAgent weatherAgent,
                                           @Qualifier("outfitAgent") ReactAgent outfitAgent) {
        // 创建主智能体(相当于调度智能体的大脑)
        ReactAgent mainAgent = ReactAgent.builder()
                .model(chatModel)
                .name(MAIN_NAME)
                .toolExecutionTimeout(Duration.ofMinutes(3))
                .systemPrompt(MAIN_SYSTEM_PROMPT)
                .build();
        log.info("✅初始化主智能体 {}", MAIN_NAME);

        // 创建调度智能体
        SupervisorAgent supervisor = SupervisorAgent.builder()
                .name(SUPERVISOR_NAME)
                .mainAgent(mainAgent)
                .subAgents(List.of(weatherAgent, outfitAgent))
                .build();
        log.info("✅初始化调度智能体 {}, 包含 2 个子智能体", SUPERVISOR_NAME);
        return supervisor;
    }
}

6. RecommendController

java 复制代码
package com.joezhou.agent.controller;
import org.springframework.ai.chat.client.ChatClient;
import java.util.UUID;

/** @author 周航宇 */
@Slf4j
@RestController
@RequestMapping("/api/v1/recommend")
public class RecommendController {

    private final SupervisorAgent supervisorAgent;
    private final ChatClient chatClient;
    private final String SYSTEM_PROMPT = """
            # 角色定位
            你是一个穿搭推荐助手,根据以下信息推荐穿衣搭配:
            - 今日天气信息:%s
            - 用户目前拥有的穿搭物品信息:%s
            # 输出规则
            - 只能根据用户目前拥有的服装进行搭配推荐;
            - 回复尽量简洁,尽量不超过200个字,给出一套推荐方案和原因即可;
            - 如果用户目前没有合适的穿搭物品,直接文字提示用户即可;
            """;
    private final MyRedis myRedis;

    public RecommendController(@Qualifier("supervisorAgent") SupervisorAgent supervisorAgent, ChatModel chatModel, MyRedis myRedis) {
        this.supervisorAgent = supervisorAgent;
        this.chatClient = ChatClient.builder(chatModel).build();
        this.myRedis = myRedis;
    }

    @SneakyThrows
    @GetMapping("/exe")
    public Map<String, Object> exe(@RequestParam("city") String city, HttpServletRequest request) {

        // 取出用户的 ID
        String userId = request.getAttribute("userId").toString();

        // 组装用户输入
        String input = "根据%s今日的天气信息获取今日推荐,userId=%s".formatted(city, userId);

        // 尝试从缓存中获取推荐信息
        String recommendWeatherKey = "myitem:recommend-weather:%s:%s:%s".formatted(userId, city, LocalDate.now().toString());
        String recommendOutfitKey = "myitem:recommend-outfit:%s:%s:%s".formatted(userId, city, LocalDate.now().toString());
        if (myRedis.exists(recommendWeatherKey) && myRedis.exists(recommendOutfitKey)) {
            String recommendWeatherFromRedis = myRedis.get(recommendWeatherKey);
            String recommendOutfitFromRedis = myRedis.get(recommendOutfitKey);
            log.info("✅从缓存中获取到推荐信息:{}", recommendWeatherFromRedis);
            log.info("✅从缓存中获取到推荐信息:{}", recommendOutfitFromRedis);
            return Map.of("weather", recommendWeatherFromRedis, "outfit", recommendOutfitFromRedis);
        }

        try {
            // 调用Agent
            OverAllState state = supervisorAgent.invoke(input, RunnableConfig.builder().threadId(UUID.randomUUID().toString()).build())
                    .orElseThrow();

            // 提取两个子 Agent 的输出
            String weatherAgentOutPut = this.getText(state, "WEATHER_AGENT_OUTPUT");
            String outfitAgentOutPut = this.getText(state, "OUTFIT_AGENT_OUTPUT");
            log.info("✅获取到子 Agent 的输出,天气信息:{},用户穿搭列表:{}", weatherAgentOutPut, outfitAgentOutPut);

            // 穿搭推荐
            String outfit = chatClient.prompt()
                    .system(SYSTEM_PROMPT.formatted(weatherAgentOutPut, outfitAgentOutPut))
                    .call()
                    .content();
            if (StrUtil.isBlank(outfit)) {
                outfit = "用户目前没有合适的穿搭物品,无法推荐穿衣搭配。";
            }
            log.info("✅推荐穿衣搭配:{}", outfit);

            // 缓存信息
            myRedis.setEx(recommendWeatherKey, weatherAgentOutPut, 24, TimeUnit.HOURS);
            myRedis.setEx(recommendOutfitKey, outfit, 24, TimeUnit.HOURS);
            log.info("✅将推荐信息缓存到Redis");
            return Map.of("weather", weatherAgentOutPut, "outfit", outfit);
        } catch (Exception e) {
            log.error("❌推荐穿衣搭配失败:{}", e.getMessage());
            return new HashMap<>();
        }
    }

    private String getText(OverAllState state, String key) {
        return state.value(key, AssistantMessage.class).orElseThrow().getText();
    }
}

Java道经 - 项目 - MyItem - 智能业务

相关推荐
ShiXZ2132 小时前
Java 8 Stream API 实用技巧详解:从入门到精通
java·开发语言
C++、Java和Python的菜鸟3 小时前
第9章 后端Web进阶(AOP)
java·开发语言·前端
东北赵四3 小时前
关于Java泛型的知识点及其相关面试题
java·windows·python
姓蔡小朋友3 小时前
Java线程并发
java·开发语言·python
执笔画流年呀3 小时前
⾃动化测试常⽤函数
java·dubbo
疯狂打码的少年3 小时前
【面向对象】UML概述(基本构造块:事物/关系/图)
java
一水4 小时前
TTFT优化:一个方案的5次推倒重来
java·ai·状态模式
AC赳赳老秦4 小时前
CSDN 技术社区数据采集:OpenClaw 抓取公开技术热帖,生成领域技术热点周报
java·大数据·前端·数据库·python·php·openclaw
磁爆步兵4 小时前
内存分区:程序运行的核心秘密
java·开发语言·jvm
cxr8285 小时前
第6章 强制执行令牌
人工智能·智能体