一、背景与需求在天机AI助手中学生可以通过自然语言查询课程信息。例如学生提供课程ID后系统需要调用课程微服务的接口获取课程详细信息并在前端以卡片形式展示包含课程名称、价格、适用人群、详情等并支持点击跳转。原有的课程微服务已经提供了通过课程ID查询课程基础的Feign接口。我们需要在AI助手中集成该能力同时解决一个关键难题大模型返回的是文本内容而前端需要结构化的JSON数据来渲染卡片。二、整体实现流程定义课程信息结果类CourseInfo编写工具类CourseTools通过Tool注解暴露给Spring AI在ChatClient中注册工具解决卡片渲染问题通过ToolContext传递requestId使用ToolResultHolder暂存工具结果最后在响应流中追加结构化数据下面我们逐步展开。三、代码实现3.1 定义课程信息结果类javaData Builder NoArgsConstructor AllArgsConstructor public class CourseInfo { JsonPropertyDescription(课程id) private Long id; JsonPropertyDescription(课程名称) private String name; JsonPropertyDescription(课程价格单位为元货币为人民币) private double price; JsonPropertyDescription(课程学习有效期单位月) private Integer validDuration; JsonPropertyDescription(适用人群例如初学者) private String usePeople; JsonPropertyDescription(课程详细介绍) private String detail; public static CourseInfo of(CourseBaseInfoDTO dto) { if (dto null) return null; CourseInfo info BeanUtil.toBeanIgnoreError(dto, CourseInfo.class); info.setPrice(Optional.ofNullable(dto.getPrice()) .map(p - NumberUtil.round(p / 100.0, 2).doubleValue()) .orElse(0.0)); return info; } }CourseBaseInfoDTO来自课程微服务的Feign接口其中价格字段单位为分。我们在of方法中将其转换为元并保留两位小数。3.2 定义常量javapublic interface Constant { String REQUEST_ID requestId; interface Tools { String QUERY_COURSE_BY_ID 根据课程id查询课程详细信息; } interface ToolParams { String COURSE_ID 课程id; } }良好的常量管理有助于代码维护。3.3 编写工具类CourseToolsjavaComponent RequiredArgsConstructor public class CourseTools { private final CourseClient courseClient; private static final String FIELD_NAME_FORMAT {}_{}; Tool(description Constant.Tools.QUERY_COURSE_BY_ID) public CourseInfo queryCourseById(ToolParam(description Constant.ToolParams.COURSE_ID) Long courseId, ToolContext toolContext) { return Optional.ofNullable(courseId) .map(id - CourseInfo.of(courseClient.baseInfo(id, true))) .map(courseInfo - { String field StrUtil.format(FIELD_NAME_FORMAT, StrUtil.lowerFirst(CourseInfo.class.getSimpleName()), courseInfo.getId()); String requestId Convert.toStr(toolContext.getContext().get(Constant.REQUEST_ID)); ToolResultHolder.put(requestId, field, courseInfo); return courseInfo; }) .orElse(null); } }关键点方法参数中增加了ToolContext用于接收从调用链传递的requestId。查询结果后将CourseInfo存入ToolResultHolderkey为requestIdvalue中再使用field区分具体的课程数据。3.4 注册工具到ChatClientjavaBean public ChatClient chatClient(ChatClient.Builder builder, Advisor loggerAdvisor, Advisor messageChatMemoryAdvisor, CourseTools courseTools) { return builder .defaultAdvisors(loggerAdvisor, messageChatMemoryAdvisor) .defaultTools(courseTools) .build(); }3.5 实现ToolResultHolder工具结果暂存器javapublic class ToolResultHolder { private static final MapString, MapString, Object HANDLER_MAP new ConcurrentHashMap(); public static void put(String key, String field, Object result) { HANDLER_MAP.computeIfAbsent(key, k - new HashMap()).put(field, result); } public static MapString, Object get(String key) { return key null ? null : HANDLER_MAP.get(key); } public static void remove(String key) { HANDLER_MAP.remove(key); } }这个线程安全的容器用于在一次请求中保存多个工具执行结果。3.6 在聊天服务中生成requestId并处理输出流javaOverride public FluxChatEventVO chat(String question, String sessionId) { var conversationId ChatService.getConversationId(sessionId); StringBuilder outputBuilder new StringBuilder(); var requestId IdUtil.fastSimpleUUID(); // 每次请求生成唯一ID return chatClient.prompt() .system(promptSystem - promptSystem .text(systemPromptConfig.getChatSystemMessage().get()) .param(now, DateUtil.now())) .advisors(advisor - advisor.param(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId)) .toolContext(Map.of(Constant.REQUEST_ID, requestId)) // 传递requestId .user(question) .stream() .chatResponse() .doFirst(() - GENERATE_STATUS.put(sessionId, true)) .doOnComplete(() - GENERATE_STATUS.remove(sessionId)) .doOnError(e - GENERATE_STATUS.remove(sessionId)) .doOnCancel(() - saveStopHistoryRecord(conversationId, outputBuilder.toString())) .takeWhile(s - Optional.ofNullable(GENERATE_STATUS.get(sessionId)).orElse(false)) .map(chatResponse - { String text chatResponse.getResult().getOutput().getText(); outputBuilder.append(text); return ChatEventVO.builder() .eventData(text) .eventType(ChatEventTypeEnum.DATA.getValue()) .build(); }) .concatWith(Flux.defer(() - { MapString, Object map ToolResultHolder.get(requestId); if (CollUtil.isNotEmpty(map)) { ToolResultHolder.remove(requestId); ChatEventVO paramEvent ChatEventVO.builder() .eventData(map) .eventType(ChatEventTypeEnum.PARAM.getValue()) .build(); return Flux.just(paramEvent, STOP_EVENT); } return Flux.just(STOP_EVENT); })); }注意toolContext(Map.of(Constant.REQUEST_ID, requestId))将请求ID传递给Tool。在流结束时通过ToolResultHolder.get(requestId)获取本次请求产生的所有工具结果将其封装为PARAM类型的事件发送给前端。前端约定的事件类型1003对应卡片渲染eventData中存放课程信息。四、测试效果用户输入查询课程课程id为1589905661084430337后端接收到请求生成requestId调用AI模型。模型识别到需要查询课程触发CourseTools.queryCourseById传入课程ID和ToolContext。工具从课程微服务获取数据转成CourseInfo并存入ToolResultHolderkey为requestId。模型生成文本回复如“已为您查询到课程信息如下”。流式传输文本内容最后追加一个PARAM事件携带课程JSON数据。前端根据PARAM事件渲染课程卡片用户可点击跳转详情页。五、小结本文介绍了在天机AI助手中集成课程查询卡片功能的完整实现核心要点包括工具的定义与注册使用Spring AI的Tool注解并通过defaultTools注册到ChatClient。数据的跨层传递通过ToolContext传递请求级唯一ID保证并发场景下数据不会错乱。结构化数据输出借助ToolResultHolder暂存工具结果在响应流末尾追加JSON数据满足前端卡片渲染需求。代码规范常量化、工具类设计、线程安全容器等最佳实践。这一模式可推广到其他需要结构化展示如商品卡片、用户卡片、图表数据的AI助手场景。希望本文能为您提供有益的参考。