介绍
Spring AI 是 Spring 项目中一个面向 AI 应用的模块,旨在通过集成开源框架、提供标准化的工具和便捷的开发体验,加速 AI 应用程序的构建和部署。
依赖
<!-- 基于 WebFlux 的响应式 SSE 传输 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-client-webflux-spring-boot-starter</artifactId>
</dependency>
<!-- mcp -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-client-spring-boot-starter</artifactId>
</dependency>
<!-- spring-ai -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- spring-web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
配置文件
在 yml 中配置大模型的 API Key 和模型类型:
spring:
ai:
openai:
base-url: ${AI_BASE_URL}
api-key: ${AI_API_KEY} # 通过环境变量文件 .env 获取
chat:
options:
model: ${AI_MODEL}
temperature: 0.8
在 yml 配置文件的同目录下创建一个 .env 文件,配置以下内容。这里使用的是 DeepSeek 的 API,可以去官网查看:https://platform.deepseek.com/
# AI URL
AI_BASE_URL=https://api.deepseek.com
# AI 密钥
AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxx
# AI 模型
AI_MODEL=deepseek-chat
配置类
概念
首先,简单介绍一些概念
- ChatClient
ChatClient 提供了与 AI 模型通信的 Fluent API,它支持同步和反应式(Reactive)编程模型。
ChatClient 类似于应用程序开发中的服务层,它为应用程序直接提供 AI 服务,开发者可以使用 ChatClient Fluent API 快速完成一整套 AI 交互流程的组装
- ChatModel
ChatModel 即对话模型,它接收一系列消息(Message)作为输入,与模型 LLM 服务进行交互,并接收返回的聊天消息(ChatMessage)作为输出。目前,它有 3 种类型:
-
ChatModel:文本聊天交互模型,支持纯文本格式作为输入,并将模型的输出以格式化文本形式返回
-
ImageModel:接收用户文本输入,并将模型生成的图片作为输出返回(文生图)
-
AudioModel:接收用户文本输入,并将模型合成的语音作为输出返回
ChatModel 的工作原理是接收 Prompt 或部分对话作为输入,将输入发送给后端大模型,模型根据其训练数据和对自然语言的理解生成对话响应,应用程序可以将响应呈现给用户或用于进一步处理。
问题
一个项目中可能会存在多个大模型的调用实例,例如 ZhiPuAiChatModel(智谱)、OllamaChatModel(Ollama本地模型)、OpenAiChatModel(OpenAi),这些实例都实现了ChatModel 接口,当然,我们可以直接使用这些模型实例来实现需求,但我们通常通过 ChatModel 来构建 ChatClient,因为这更通用。
可以通过在 yml 配置文件中设置 spring.ai.chat.client.enabled=false 来禁用 ChatClient bean 的自动配置,然后为每个聊天模型 build 出一个 ChatClient。
spring:
ai:
chat:
client:
enabled: false
配置类
package cn.onism.mcp.config;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.zhipuai.ZhiPuAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* ChatClient 配置
*
* @author wxw
* @date 2025-03-25
*/
@Configuration
public class ChatClientConfig {
@Resource
private OpenAiChatModel openAiChatModel;
@Resource
private ZhiPuAiChatModel zhiPuAiChatModel;
@Resource
private OllamaChatModel ollamaChatModel;
@Resource
private ToolCallbackProvider toolCallbackProvider;
@Bean("openAiChatClient")
public ChatClient openAiChatClient() {
return ChatClient.builder(openAiChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
.defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build();
}
@Bean("zhiPuAiChatClient")
public ChatClient zhiPuAiChatClient() {
return ChatClient.builder(zhiPuAiChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
.defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build();
}
@Bean("ollamaChatClient")
public ChatClient ollamaChatClient() {
return ChatClient.builder(ollamaChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
.defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build();
}
}
使用 ChatClient 的时候,@Resource 注解会按 Bean 的名称注入
@Resource
private ChatClient openAiChatClient;
@Resource
private ChatClient ollamaChatClient;
@Resource
private ChatClient zhiPuAiChatClient;
基础对话
普通响应
使用 call 方法来调用大模型
private ChatClient openAiChatModel;
@GetMapping("/chat")
public String chat(){
Prompt prompt = new Prompt("你好,请介绍下你自己");
String response = openAiChatModel.prompt(prompt)
.call()
.content();
return response;
}
流式响应
call 方法修改为 stream,最终返回一个 Flux 对象
@GetMapping(value = "/chat/stream", produces = "text/html;charset=UTF-8")
public Flux<String> stream() {
Prompt prompt = new Prompt("你好,请介绍下你自己");
String response = openAiChatModel.prompt(prompt)
.stream()
.content();
return response;
}
tips:我们可以通过缓存减少重复请求,提高性能。可以使用 Spring Cache 的 @Cacheable 注解实现:
@Cacheable("getChatResponse")
public String getChatResponse(String message){
String response = openAiChatModel.prompt()
.user(message)
.call()
.content();
return response;
}
tips: 适用于批量处理场景。可以使用 Spring 的 @Async
注解实现:
@Async
public CompletableFuture<String> getAsyncChatResponse(String message) {
return CompletableFuture.supplyAsync(() -> openAiChatModel.prompt()
.user(message)
.call()
.content());
}
3 种组织提示词的方式
Prompt
通过 Prompt 来封装提示词实体,适用于简单场景
Prompt prompt = new Prompt("介绍下你自己");
PromptTemplate
使用提示词模板 PromptTemplate 来复用提示词,即将提示词的大体框架构建好,用户仅输入关键信息完善提示词
其中,{ } 作为占位符,promptTemplate.render 方法来填充
@GetMapping("/chat/formatPrompt")
public String formatPrompt(
@RequestParam(value = "money") String money,
@RequestParam(value = "number") String number,
@RequestParam(value = "brand") String brand
) {
PromptTemplate promptTemplate = new PromptTemplate("""
根据我目前的经济情况{money},只推荐{number}部{brand}品牌的手机。
""");
Prompt prompt = new Prompt(promptTemplate.render(
Map.of("money",money,"number", number, "brand", brand)));
return openAiChatModel.prompt(prompt)
.call()
.content();
}
Message
使用 Message ,提前约定好大模型的功能或角色
消息类型:
系统消息(SystemMessage):设定对话的背景、规则或指令,引导 AI 的行为
用户消息(UserMessage):表示用户的输入,即用户向 AI 提出的问题或请求
助手消息(AssistantMessage):表示 AI 的回复,即模型生成的回答
工具响应消息(ToolResponseMessage):当 AI 调用外部工具(如 API)后,返回 工具的执行结果,供 AI 进一步处理
@GetMapping("/chat/messagePrompt")
public String messagePrompt(@RequestParam(value = "book", defaultValue = "《白夜行》") String book) {
// 用户输入
UserMessage userMessage = new UserMessage(book);
log.info("userMessage: {}", userMessage);
// 对系统的指令
SystemMessage systemMessage = new SystemMessage("你是一个专业的评书人,给出你的评价吧!");
log.info("systemMessage: {}", systemMessage);
// 组合成完整的提示词,注意,只能是系统指令在前,用户消息在后,否则会报错
Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
return openAiChatModel.prompt(prompt)
.call()
.content();
}
保存 prompt
prompt 不宜嵌入到代码中,可以将作为一个 .txt 文件 其保存到 src/main/resources/prompt 目录下,使用读取文件的工具类就可以读取到 prompt
package cn.onism.mcp.utils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* @description: 读取文件内容的工具类
* @date: 2025/5/8
*/
@Component
public class FileContentReader {
public String readFileContent(String filePath) {
StringBuilder content = new StringBuilder();
try {
// 创建 ClassPathResource 对象以获取类路径下的资源
ClassPathResource resource = new ClassPathResource(filePath);
// 打开文件输入流
InputStream inputStream = resource.getInputStream();
// 创建 BufferedReader 用于读取文件内容
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
// 逐行读取文件内容
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
// 关闭输入流
reader.close();
} catch (IOException e) {
// 若读取文件时出现异常,打印异常信息
e.printStackTrace();
}
return content.toString();
}
}
PromptTemplate promptTemplate = new PromptTemplate(
fileContentReader.readFileContent("prompt/formatPrompt.txt")
);
解析模型输出(结构化)
模型输出的格式是不固定的,无法直接解析或映射到 Java 对象,因此,Spring AI 通过在提示词中添加格式化指令要求大模型按特定格式返回内容,在拿到大模型输出数据后通过转换器做结构化输出。
实体类 Json 格式
首先我们定义一个实体类 ActorInfo
@Data
@Description("演员信息")
public class ActorInfo {
@JsonPropertyDescription("演员姓名")
private String name;
@JsonPropertyDescription("演员年龄")
private Integer age;
@JsonPropertyDescription("演员性别")
private String gender;
@JsonPropertyDescription("演员出生日期")
private String birthday;
@JsonPropertyDescription("演员国籍")
private String nationality;
}
在 call 方法后面调用 entity 方法,把对应实体类的 class 传递进去即能做到结构化输出
@GetMapping("/chat/actor")
public ActorInfo queryActorInfo(@RequestParam(value = "actorName") String actorName) {
PromptTemplate promptTemplate = new PromptTemplate("查询{actorName}演员的详细信息");
Prompt prompt = new Prompt(promptTemplate.render(Map.of("actorName", actorName)));
ActorInfo response = openAiChatModel.prompt(prompt)
.call()
.entity(ActorInfo.class);
return response;
}
结果符合要求
List 列表格式
在 entity 方法中传入 new ListOutputConverter(new DefaultConversionService())
@GetMapping("/chat/actorMovieList")
public List<String> queryActorMovieList(@RequestParam(value = "actorName") String actorName) {
PromptTemplate promptTemplate = new PromptTemplate("查询{actorName}主演的电影");
Prompt prompt = new Prompt(promptTemplate.render(Map.of("actorName", actorName)));
List<String> response = openAiChatModel.prompt(prompt)
.call()
.entity(new ListOutputConverter(new DefaultConversionService()));
return response;
}
Map 格式
tips: 目前在 Map 中暂不支持嵌套复杂类型,因此 Map 中不能返回实体类,而只能是 Object。
在 entity 方法中传入 new ParameterizedTypeReference<>() {}
@GetMapping("/chat/actor")
public Map<String, Object> queryActorInfo(@RequestParam(value = "actorName") String actorName) {
PromptTemplate promptTemplate = new PromptTemplate("查询{actorName}演员及另外4名相关演员的详细信息");
Prompt prompt = new Prompt(promptTemplate.render(Map.of("actorName", actorName)));
Map<String, Object> response = openAiChatModel.prompt(prompt)
.call()
.entity(new ParameterizedTypeReference<>() {});
return response;
}
多模态
deepseek 暂时不支持多模态,因此这里选用 智谱:https://bigmodel.cn/
依赖与配置
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-zhipuai</artifactId>
<version>1.0.0-M6</version>
</dependency>
spring:
ai:
zhipuai:
api-key: ${ZHIPUAI_API_KEY}
chat:
options:
model: ${ZHIPUAI_MODEL}
temperature: 0.8
# api-key
ZHIPUAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx
# 所选模型
ZHIPUAI_MODEL=glm-4v-plus-0111
理解图片
在 src/main/resources/images 目录下保存图片
创建一个 ZhiPuAiChatModel,将用户输入和图片封装成一个 UserMessage,然后使用 call 方法传入一个 prompt,最后获得输出
@Resource
private ZhiPuAiChatModel zhiPuAiChatModel;
@GetMapping("/chat/pic")
public String pic() {
Resource imageResource = new ClassPathResource("images/mcp.png");
// 构造用户消息
var userMessage = new UserMessage("解释一下你在这幅图中看到了什么?",
new Media(MimeTypeUtils.IMAGE_PNG, imageResource));
ChatResponse chatResponse = zhiPuAiChatModel.call(new Prompt(userMessage));
return chatResponse.getResult().getOutput().getText();
}
文生图
这里需要使用 zhiPuAiImageModel,我们调用它的 call 方法,传入一个 ImagePrompt,ImagePrompt 由**用户图片描述输入 ImageMessage **和 **图片描述信息 OpenAiImageOptions **所构成,
其中,
- model 要选择适用于图像生成任务的模型,这里我们选择了 cogview-4-250304
- quality 为图像生成图像的质量,默认为 standard
- hd : 生成更精细、细节更丰富的图像,整体一致性更高,耗时约20 秒
- standard :快速生成图像,适合对生成速度有较高要求的场景,耗时约 5-10 秒
@Autowired
ZhiPuAiImageModel ziPuAiImageModel;
@Autowired
private FileUtils fileUtils;
@GetMapping("/outputImg")
public void outputImg() throws IOException {
ImageMessage userMessage = new ImageMessage("一个仙人掌大象");
OpenAiImageOptions chatOptions = OpenAiImageOptions.builder()
.model("cogview-4-250304").quality("hd").N(1).height(1024).width(1024).build();
ImagePrompt prompt = new ImagePrompt(userMessage, chatOptions);
// 调用
ImageResponse imageResponse = ziPuAiImageModel.call(prompt);
// 输出的图片
Image image = imageResponse.getResult().getOutput();
// 保存到本地
InputStream in = new URL(image.getUrl()).openStream();
fileUtils.saveStreamToFile(in,
"src/main/resources/images",
"pic"+RandomUtils.insecure().randomInt(0, 100)+".png"
);
}
@Component
public class FileUtils {
public String saveStreamToFile(InputStream inputStream, String filePath, String fileName) throws IOException {
// 创建目录(如果不存在)
Path dirPath = Paths.get(filePath);
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath);
}
// 构建完整路径
Path targetPath = dirPath.resolve(fileName);
// 使用 try-with-resources 确保流关闭
try (inputStream) {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return targetPath.toAbsolutePath().toString();
}
}
调用本地模型
_**tips: **_若想零成本调用大模型,并且保障隐私,可以阅读本章节
下载安装 Ollama
下载安装 Ollama:https://ollama.com/ [Ollama 是一个开源的大型语言模型服务工具,旨在帮助用户快速在本地运行大模型。通过简单的安装指令,用户可以通过一条命令轻松启动和运行开源的大型语言模型。Ollama 是 LLM 领域的 Docker],安装完成后执行 ollama 得到如下输出则表明安装成功
选择一个模型下载到本地:https://ollama.com/search,我这里选择了 qwen3:8b
引入 ollama 依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
配置
spring:
ai:
ollama:
chat:
model: ${OLLAMA_MODEL}
base-url: ${OLLAMA_BASE_URL}
# 本地模型
OLLAMA_MODEL=qwen3:8b
# URL
OLLAMA_BASE_URL=http://localhost:11434
实际调用
/**
* ollama本地模型测试
* @param input
* @return
*/
@GetMapping("/ollama/chat")
public String ollamaChat(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
return ollamaChatModel.call(prompt).getResult().getOutput().getText();
}
结果
对话记忆
内存记忆(短期)
MessageChatMemoryAdvisor 可以用来提供聊天记忆功能,这需要传递一个基于内存记忆的 ChatMemory
/**
* 内存记忆/短期记忆
* @param input
* @return
*/
@GetMapping("/memory/chat")
public String chat(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
// 内存记忆的 ChatMemory
InMemoryChatMemory inMemoryChatMemory = new InMemoryChatMemory();
return openAiChatClient.prompt(prompt)
.advisors(new MessageChatMemoryAdvisor(inMemoryChatMemory))
.call()
.content();
}
测试
隔离
多用户参与 AI 对话,每个用户的对话记录要做到互不干扰,因此要对不同用户的记忆按一定规则做好隔离。
由于在配置类中已经设置好了默认的 Advisors 为基于内存的聊天记忆 InMemoryChatMemory,因此,我们只需调用 openAiChatClient 的 advisors 方法,并设置好相应的参数即可
其中,
chat_memory_conversation_id 表示 会话 ID,用于区分不同用户的对话历史
chat_memory_response_size 表示每次最多检索 x 条对话历史
@Bean("openAiChatClient")
public ChatClient openAiChatClient() {
return ChatClient.builder(openAiChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
// 设置记忆
.defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build();
}
/**
* 短期记忆,按用户 ID 隔离
* @param input
* @param userId
* @return
*/
@GetMapping("/memory/chat/user")
public String chatByUser(@RequestParam(value = "input") String input,
@RequestParam(value = "userId") String userId) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
// 设置记忆的参数
.advisors(advisor -> advisor.param("chat_memory_conversation_id", userId)
.param("chat_memory_response_size", 100))
.call()
.content();
}
测试
集成 Redis
基于内存的聊天记忆可能无法满足开发者的需求,因此,我们可以构建一个基于 Redis 的长期记忆 RedisChatMemory
引入 Redis 依赖
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yml 配置
spring:
data:
redis:
host: localhost
port: 6379
password: xxxxx
Redis 配置类
创建了一个 RedisTemplate 实例
package cn.onism.mcp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @description: Redis配置类
* @date: 2025/5/9
*/
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
定义消息实体类
用于存储对话的 ID、类型和内容,同时实现了序列化接口以便在 Redis 中存储
/**
* @description: 聊天消息实体类
* @date: 2025/5/9
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ChatEntity implements Serializable {
private static final long serialVersionUID = 1555L;
/**
* 聊天ID
*/
private String chatId;
/**
* 聊天类型
*/
private String type;
/**
* 聊天内容
*/
private String content;
}
构造 RedisChatMemory
创建一个 RedisChatMemory 并实现 ChatMemory 接口,重写该接口的 3 个方法
其中,
add表示添加聊天记录,conversationId 为会话 ID,messages 为消息列表
get表示获取聊天记录,lastN 表示获取最后 lastN 条聊天记录
clear表示清除聊天记录
package cn.onism.mcp.memory;
import cn.onism.mcp.model.entity.ChatEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.*;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @description: 基于Redis的聊天记忆
* @date: 2025/5/9
*/
public class ChatRedisMemory implements ChatMemory {
/**
* 聊天记录的Redis key前缀
*/
private static final String KEY_PREFIX = "chat:history:";
private final RedisTemplate<String, Object> redisTemplate;
public ChatRedisMemory(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 添加聊天记录
* @param conversationId
* @param messages
*/
@Override
public void add(String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
List<ChatEntity> chatEntityList = new ArrayList<>();
for (Message message : messages) {
// 解析消息内容
String[] strings = message.getText().split("</think>");
String text = strings.length == 2 ? strings[1] : strings[0];
// 构造聊天记录实体
ChatEntity chatEntity = new ChatEntity();
chatEntity.setChatId(conversationId);
chatEntity.setType(message.getMessageType().getValue());
chatEntity.setContent(text);
chatEntityList.add(chatEntity);
}
// 保存聊天记录到Redis, 并设置过期时间为60分钟
redisTemplate.opsForList().rightPushAll(key, chatEntityList.toArray());
redisTemplate.expire(key, 60, TimeUnit.MINUTES);
}
/**
* 获取聊天记录
* @param conversationId
* @param lastN
* @return List<Message>
*/
@Override
public List<Message> get(String conversationId, int lastN) {
String key = KEY_PREFIX + conversationId;
Long size = redisTemplate.opsForList().size(key);
if (size == null || size == 0) {
return Collections.emptyList();
}
// 取最后lastN条聊天记录,如果聊天记录数量少于lastN,则取全部
int start = Math.max(0, size.intValue() - lastN);
List<Object> objectList = redisTemplate.opsForList().range(key, start, -1);
List<Message> outputList = new ArrayList<>();
// 解析聊天记录实体,并构造消息对象
ObjectMapper mapper = new ObjectMapper();
for (Object object : objectList){
ChatEntity chatEntity = mapper.convertValue(object, ChatEntity.class);
if(MessageType.USER.getValue().equals(chatEntity.getType())){
outputList.add(new UserMessage(chatEntity.getContent()));
}else if (MessageType.SYSTEM.getValue().equals(chatEntity.getType())){
outputList.add(new SystemMessage(chatEntity.getContent()));
}else if (MessageType.ASSISTANT.getValue().equals(chatEntity.getType())){
outputList.add(new AssistantMessage(chatEntity.getContent()));
}
}
return outputList;
}
/**
* 清除聊天记录
* @param conversationId
*/
@Override
public void clear(String conversationId) {
String key = KEY_PREFIX + conversationId;
redisTemplate.delete(key);
}
}
更改 ChatClient 配置
将 MessageChatMemoryAdvisor 中的参数替换为我们实现的 ChatRedisMemory
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Bean("openAiChatClient")
public ChatClient openAiChatClient() {
return ChatClient.builder(openAiChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
.defaultAdvisors(new MessageChatMemoryAdvisor(new ChatRedisMemory(redisTemplate)))
.build();
}
测试
/**
* RedisChatMemory
* @param input
* @param userId
* @return String
*/
@GetMapping("/memory/chat/user")
public String chatByUser(@RequestParam(value = "input") String input,
@RequestParam(value = "userId") String userId) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
.advisors(advisor -> advisor.param("chat_memory_conversation_id", userId)
.param("chat_memory_response_size", 100))
.call()
.content();
}
执行结果
可以看到,Redis 中有对应的记录,我们可以通过 lrange key start end 命令获取列表中的数据,其中 content 为 UTF-8 编码
Tool/Function Calling
工具(Tool)或功能调用(Function Calling)允许大型语言模型在必要时调用一个或多个可用的工具,这些工具通常由开发者定义。工具可以是任何东西:网页搜索、对外部 API 的调用,或特定代码的执行等。
下面是工具调用的流程图:
更加简洁的流程图:
- 工具注册阶段,当需要为模型提供工具支持时,需在聊天请求中声明工具定义。每个工具定义包含三个核心要素:工具名称(唯一标识符)、功能描述(自然语言说明)、输入参数结构(JSON Schema格式)
- 模型决策阶段,模型分析请求后,若决定调用工具,将返回结构化响应,包含:目标工具名称、符合预定义Schema的格式化参数
- 工具执行阶段,客户端应用负责根据工具名称定位具体实现,使用验证后的参数执行目标工具
- 工具响应阶段,工具执行结果返回给应用程序
- 重新组装阶段,应用将标准化处理后的执行结果返回给模型再次处理
- 结果响应阶段,模型结合用户初始输入以及工具执行结果再次加工返回给用户
工具定义与使用
Methods as Tools
- 注解式定义
创建一个 DateTimeTool 工具类,在 getCurrentDateTime 方法上使用 @Tool 注解,表示将该方法标记为一个 Tool,description 表示对工具的描述,大模型会根据这个描述来理解该工具的作用
@Component
public class DateTimeTool {
private static final Logger LOGGER = LoggerFactory.getLogger(DateTimeTool.class);
@Tool(description = "获取当前用户的日期和时间")
public String getCurrentDateTime() {
LOGGER.info("---------- getCurrentDateTime 工具被调用 ----------");
return LocalDateTime.now()
.atZone(LocaleContextHolder.getTimeZone().toZoneId())
.toString();
}
}
在使用时,可以在 ChatClient 配置类中将所有工具都提前加载到 ChatClient 中
@Configuration
public class ChatClientConfig {
@Resource
private OpenAiChatModel openAiChatModel;
@Resource
private ToolCallbackProvider toolCallbackProvider;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Bean("openAiChatClient")
public ChatClient openAiChatClient() {
return ChatClient.builder(openAiChatModel)
// 默认加载所有的工具,避免重复 new
.defaultTools(toolCallbackProvider.getToolCallbacks())
.defaultAdvisors(new MessageChatMemoryAdvisor(new ChatRedisMemory(redisTemplate)))
.build();
}
}
或者是不在配置类中加载所有工具,而是在调用 ChatClient 时将需要用到的工具传递进去,即使用 tools 方法,传入工具类
@GetMapping("/tool/chat")
public String toolChat(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
.tools(new DateTimeTool())
.call()
.content();
}
测试后发现大模型的确调用了 DateTimeTool
- 编程式定义
我们可以不使用 @Tool 注解,而是采用编程式的方式构造一个 Tool
@Component
public class DateTimeTool {
private static final Logger LOGGER = LoggerFactory.getLogger(DateTimeTool.class);
// no annotation
public String getCurrentDateTime() {
LOGGER.info("---------- getCurrentDateTime 工具被调用 ----------");
return LocalDateTime.now()
.atZone(LocaleContextHolder.getTimeZone().toZoneId())
.toString();
}
}
首先通过反射获取方法,然后定义一个 ToolDefinition,最后创建一个 MethodToolCallback,将其传入到 tools 方法中即可
@GetMapping("/tool/chat")
public String toolChat(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
// 通过反射获取方法
Method method = ReflectionUtils.findMethod(DateTimeTool.class, "getCurrentDateTime");
// 工具定义
ToolDefinition toolDefinition = ToolDefinition.builder(method)
.description("获取当前用户的日期和时间")
.build();
// 创建一个 MethodToolCallback
MethodToolCallback methodToolCallback = MethodToolCallback.builder()
.toolDefinition(toolDefinition)
.toolMethod(method)
.toolObject(new DateTimeTool())
.build();
return openAiChatClient.prompt(prompt)
.tools(methodToolCallback)
.call()
.content();
}
Fuctions as Tools
除方法外,Function、Supplier、Consumer 等函数式接口也可以定义为 Tool
下面**模拟一个查询天气的服务,首先定义 WeatherRequest 和 WeatherResponse**
其中,@ToolParam 注解用于定义工具所需参数, description 为工具参数的描述,模型通过描述可以更好的理解参数的作用
/**
* 天气查询请求参数
*/
@Data
public class WeatherRequest {
/**
* 坐标
*/
@ToolParam(description = "经纬度,精确到小数点后4位,格式为:经度,纬度")
String location;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WeatherResponse {
/**
* 温度
*/
private double temp;
/**
* 单位
*/
private Unit unit;
}
/**
* 温度单位
*/
public enum Unit {
C, F
}
接下来创建一个 WeatherService,实现 Function 接口,编写具体逻辑。这里获取天气使用的是彩云科技开放平台提供的免费的 API 接口:https://docs.caiyunapp.com/weather-api/,构造好请求后使用 HttpURLConnection 发送请求,读取响应后使用 Jackson 解析 JSON,获取天气数据。
package cn.onism.mcp.tool.service;
import cn.onism.mcp.tool.Unit;
import cn.onism.mcp.tool.WeatherRequest;
import cn.onism.mcp.tool.WeatherResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;
/**
* @description: 天气服务
* @date: 2025/5/9
*/
@Slf4j
@Component
public class WeatherService implements Function<WeatherRequest, WeatherResponse> {
private static final Logger LOGGER = LoggerFactory.getLogger(WeatherService.class);
private static final String TOEKN = "xxxxxxxxxxxxxxxxxx";
/**
* 实时天气接口
*/
private static final String API_URL = "https://api.caiyunapp.com/v2.6/%s/%s/realtime";
private double temp;
private String skycon;
@Override
public WeatherResponse apply(WeatherRequest weatherRequest) {
LOGGER.info("Using caiyun api, getting weather information...");
try {
// 构造API请求
String location = weatherRequest.getLocation();
String encodedLocation = URLEncoder.encode(location, StandardCharsets.UTF_8);
String apiUrl = String.format(
API_URL,
TOEKN,
encodedLocation
);
URL url = new URL(apiUrl);
// 发送请求
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
// 读取响应
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
String inputLine;
StringBuilder response = new StringBuilder();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
// 使用Jackson解析JSON
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(response.toString());
// 获取天气数据
JsonNode resultNode = rootNode.get("result");
LOGGER.info("获取到天气信息: " + resultNode.toString());
temp = resultNode.get("realtime").get("temperature").asDouble();
skycon = resultNode.get("realtime").get("skycon").asText();
} else {
System.out.println("请求失败,响应码为: " + responseCode);
}
} catch (IOException e) {
e.printStackTrace();
}
return new WeatherResponse(temp, skycon, Unit.C);
}
}
创建一个 WeatherTool 工具类,定义一个 Bean,Bean 名称为工具名称,@Description 中描述工具作用,该 Bean 调用了 WeatherService 中的方法
package cn.onism.mcp.tool;
import cn.onism.mcp.tool.service.WeatherService;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Description;
import org.springframework.stereotype.Component;
import java.util.function.Function;
/**
* @description: 天气工具类
* @date: 2025/5/9
*/
@Slf4j
@Component
public class WeatherTool {
private final WeatherService weatherService = new WeatherService();
@Bean(name = "currentWeather")
@Description("依据位置获取天气信息")
public Function<WeatherRequest, WeatherResponse> currentWeather() {
return weatherService::apply;
}
}
将天气工具和日期工具传入 tools 方法中
@GetMapping("/tool/weather")
public String toolFunctionAnnotation(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
.tools("currentWeather","getCurrentDateTime")
.call()
.content();
}
测试
可以看到,大模型首先会调用日期工具获取时间,同时,我们向大模型询问的地点会被自动解析为 location 经纬度参数,接着调用天气工具获取天气信息
在之前的流程图中,工具的调用会与大模型进行 2 次交互,第一次为发起请求,第二次在工具执行完成后带着工具执行的结果再次调用大模型,而某些情况下,我们想在工具执行完成后直接返回,而不去调用大模型。在 @Tool 注解中令 returnDirect = true 即可
MCP
首先来看这张经典的图,MCP(Model Context Protocol 模型上下文协议)可以被视为 AI 应用的 USB-C 端口,它为 AI 模型/应用与不同数据源/工具建立了统一对接规范,旨在标准化应用程序向大语言模型提供上下文的交互方式。
MCP 采用客户端-服务器架构,
其中,
- MCP Hosts(宿主程序):如 Claude Desktop、IDE 等,通过 MCP 访问数据
- MCP Clients(客户端):与服务器建立 1:1 连接,处理通信
- MCP Servers(服务端):轻量级程序,提供标准化的数据或工具访问能力
- Local Data Sources(本地数据源):如文件、数据库等,由 MCP 服务端安全访问
- Remote Services(远程服务):如API、云服务等,MCP 服务端可代理访问
好的,你已经知道了 MCP Servers 就是展示和调用工具的地方,而 MCP Clients 就是用户与各种大模型/AI应用对话的地方
假设,我们的 MCP Servers 提供了 2 个简单的工具,分别是获取日期、获取天气。那么 MCP Servers 启动成功之后,MCP Clients 就可以获取到这两个工具。
我们在 MCP Clients 向 AI 询问 今天是星期几? 接下来, MCP Clients 会首先使用模型的 Function call 能力,由大模型决定是否使用工具,以及使用哪个工具。随后,MCP Clients 把确定要使用的工具和参数发送回 MCP Servers,由 MCP Servers 实现工具调用并返回结果。最后,MCP Clients 根据返回结果,再次调用大模型,由大模型进行回答。
你可能会问,这和工具调用有什么区别?这个流程和 Function/Tool call 不是一样嘛……
确实,MCP 只是一个协议,就像后端开发中的 RESTful API ,基本上就是一组接口约定,通过固定的模式来理解每个工具的作用和参数,实际上并没有新的技术。 而且实际使用过程中,会产生巨量的token 消耗。
并且,在真实的生产环境中,工具数量可能会非常多,一些工具之间的界限可能并没有那么清晰,模型也会出现调用错误的情况。此外,若一个工具需要传入多个参数,那么模型提取参数可能不是那么准确。
但是,作为一个协议,它的出发点是好的。
了解更多,请阅读 MCP 中文文档:https://mcplab.cc/zh/docs/getstarted
MCP Client/Server
从下面这幅图可以看到,通过 Spring AI,我们可以自己实现一个 MCP Client 和 MCP Server,并通过 MCP 来连接 MCP Server。
MCP 有两种通信模式:stdio、sse。
stdio(标准输入/输出)通过本地进程间通信实现,客户端以子进程形式启动服务器,双方通过stdin/stdout 交换 JSON-RPC 消息,每条消息以换行符分隔。适用场景:本地工具集成、隐私数据处理、快速原型开发。
SSE 又分为 Spring MVC (Server-Sent Events) 和 Spring WebFlux (Reactive SSE) 。
1. <font style="color:rgb(77, 77, 77);">Spring MVC 的 SSE </font><font style="color:rgb(51, 51, 51);">适合传统的基于 Servlet 的 Web 应用程序,能够与现有的 Spring M</font>VC 项目无缝集成,支持**同步模式**,适合传统的请求-响应模式。
2. <font style="color:rgb(77, 77, 77);">Spring WebFlux 的 SSE </font><font style="color:rgb(51, 51, 51);">适合需要高性能、非阻塞 I/O 的现代响应式应用,特别是在处理大量并发连接时表现出色</font>,支持异步模式,适合需要高并发和低延迟的应用。
MCP Server 开发
基于 stdio 形式是将 MCP Server 当做一个本地的子进程,基于 sse 可将 MCP Server 部署在远端,各有千秋
stdio
引入 **mcp-server **依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
<version>1.0.0-M7</version>
</dependency>
定义一个工具,这里模拟获取某地区新闻头条
/**
* @description:
* @date: 2025/5/10
*/
@Service
public class NewsService {
@Tool(description = "获取某地区新闻头条")
public String getNewsTop(String location) {
return "今日" + location + "地区的新闻头条是: " + location + "申奥成功";
}
}
在配置类中将工具加载到 ToolCallbackProvider 中
/**
* @description:
* @date: 2025/5/10
*/
@Configuration
public class ToolConfiguration {
@Bean
public ToolCallbackProvider weatherTools(NewsService newsService) {
return MethodToolCallbackProvider
.builder()
.toolObjects(newsService)
.build();
}
}
接下来在 yml 文件中配置如下内容,含义见注释:
spring:
main:
# 禁用 web 应用类型
web-application-type: none
# 禁用 banner
banner-mode: off
ai:
mcp:
server:
# mcp 服务名称
name: mcp-stdio-news
# mcp 服务版本
version: 1.0.0
# mcp 通信模式为 stdio
stdio: true
application:
name: mcp-stdio-demo
# 日志
logging:
file:
name: mcp-stdio-demo.log
接下来打包,在 target 目录中获取 jar 包路径
编写 mcp-servers-config.json 文件,该文件可以在任何 MCP Client 平台中导入
其中:
“mcpServers” 中可以有多个 MCP Server 配置,这里只写了一个
“mcp-stdio-news” 表示 MCP Server 的名称,随便取
“command” 表示命令
“args” 表示命令中的参数
“E:/Java-Projects/mcp-server-demo/target/mcp-server-demo-0.0.1-SNAPSHOT.jar” 表示 jar 包的路径
{
"mcpServers": {
"mcp-stdio-news": {
"command": "java",
"args": [
"-Dspring.ai.mcp.server.stdio=true",
"-Dspring.main.web-application-type=none",
"-Dlogging.pattern.console=",
"-jar",
"E:/Java-Projects/mcp-server-demo/target/mcp-server-demo-0.0.1-SNAPSHOT.jar"
]
}
}
}
为了测试,我们使用 Spring AI 来实现一个 MCP Client,具体见 MCP Client 开发的 stdio 章节
sse
相较于 stdio 方式,sse 更适用于远程部署的 MCP 服务器,客户端可以通过标准 HTTP 协议与服务器建立连接,实现单向的实时数据推送。
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>1.0.0-M7</version>
</dependency>
接下来在 yml 文件中配置如下内容
spring:
ai:
mcp:
server:
name: mcp-sse-server
version: 1.0.0
application:
name: mcp-server
server:
port: 8090
增加配置类
@Configuration
public class McpConfig implements WebMvcConfigurer {
@Bean
public WebMvcSseServerTransportProvider transportProvider(ObjectMapper mapper) {
return new WebMvcSseServerTransportProvider(mapper, "/mcp"); // 基础路径设为/mcp
}
@Bean
public RouterFunction<ServerResponse> mcpRouterFunction(
WebMvcSseServerTransportProvider transportProvider) {
return transportProvider.getRouterFunction();
}
}
下面的步骤和 stdio 一样
定义一个工具,这里模拟获取某地区新闻头条
/**
* @description:
* @date: 2025/5/10
*/
@Service
public class NewsService {
@Tool(description = "获取某地区新闻头条")
public String getNewsTop(String location) {
return "今日" + location + "地区的新闻头条是: " + location + "申奥成功";
}
}
在配置类中将工具加载到 ToolCallbackProvider 中
/**
* @description:
* @date: 2025/5/10
*/
@Configuration
public class ToolConfiguration {
@Bean
public ToolCallbackProvider weatherTools(NewsService newsService) {
return MethodToolCallbackProvider
.builder()
.toolObjects(newsService)
.build();
}
}
最后直接运行,不用打包和编写 json 文件,为了测试,我们使用 Spring AI 来实现一个 MCP Client,具体见 MCP Client 开发的 sse 章节
MCP Client 开发
stdio
引入以下依赖
<!-- mcp-client -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<version>1.0.0-M7</version>
</dependency>
<!-- spring-ai-openai -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
编写 yml 配置文件,这里还是使用 deepseek 的 API,其中的 servers-configuration 为 MCP Server 提供的 json 文件,我们将其放在 resources 目录下即可,toolcallback 设置为 true 表示启用工具回调功能
spring:
ai:
openai:
base-url: xxxxxxxxxxxx
api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxx
chat:
options:
model: deepseek-chat
temperature: 0.8
mcp:
client:
stdio:
servers-configuration: classpath:/mcp-servers-config.json
toolcallback:
enabled: true
request-timeout: 60000
server:
port: 8085
编写配置类
/**
* @description:
* @date: 2025/5/10
*/
@Configuration
public class ChatClientConfig {
@Resource
private OpenAiChatModel openAiChatModel;
@Bean("openAiChatClient")
public ChatClient openAiChatClient(ToolCallbackProvider toolCallbackProvider) {
return ChatClient.builder(openAiChatModel)
.defaultTools(toolCallbackProvider)
.build();
}
}
进行测试,可以看到,Client 端的确调用到了 Server 端的工具
/**
* @description:
* @date: 2025/5/10
*/
@RestController
public class MCPController {
@Resource
private ChatClient openAiChatClient;
@GetMapping(value = "/chat")
public String generate(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
.call()
.content();
}
}
sse
引入依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-client-webflux-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
在 yml 文件中配置 MCP 服务器,其中,sse.connections.server1.url 填写 MCP 服务器的地址;news-server 为自定义的服务名
spring:
ai:
openai:
base-url: https://api.deepseek.com
api-key: sk-0e48a66aef34434489ce9710ba45f8e2
chat:
options:
model: deepseek-chat
temperature: 0.8
mcp:
client:
toolcallback:
enabled: true
name: mcp-sse-client
version: 1.0.0
request-timeout: 20s
type: ASYNC
sse:
connections:
news-server:
url: http://localhost:8090
server:
port: 8087
编写配置类
/**
* @description:
* @date: 2025/5/10
*/
@Configuration
public class ChatClientConfig {
@Resource
private OpenAiChatModel openAiChatModel;
@Bean("openAiChatClient")
public ChatClient openAiChatClient(ToolCallbackProvider toolCallbackProvider) {
return ChatClient.builder(openAiChatModel)
.defaultTools(toolCallbackProvider)
.build();
}
}
进行测试,可以看到,Client 端的确调用到了 Server 端的工具
/**
* @description:
* @date: 2025/5/10
*/
@RestController
public class MCPController {
@Resource
private ChatClient openAiChatClient;
@GetMapping(value = "/chat/sse")
public String generate(@RequestParam(value = "input") String input) {
Prompt prompt = new Prompt(input);
return openAiChatClient.prompt(prompt)
.call()
.content();
}
}
MCP 操作文件系统
MCP 操作数据库
Dify 结合 MCP
进入 Dify 工作室:https://cloud.dify.ai/apps,创建一个 Chatflow
添加 Agent 节点
选择支持 MCP工具的 Agent 策略,若没有该选项,请前往 https://marketplace.dify.ai/ 中安装 MCP 插件
选择模型,进行 MCP 服务配置,注意,本地启动的 MCP Server 无法进行被访问到,需要在云服务器上部署好 MCP Server,详情请见 简易部署 MCP Server 章节
将开始节点和直接回复节点与 Agent 节点相连
为了提高 Agent 调用工具的准确性,可以编写指令,当然 1. 若包含地区,则提取地区,调用工具”getNewsTop“ 删掉也能成功地调用相应工具。
在查询中,添加开始节点中的 sys.query,最大迭代次数可以设置为 2
点击预览,进行聊天,发现的确调用了相关工具
简易部署 MCP Server
RAG
检索增强生成(Retrieval-Augmented Generation, RAG),是一种结合了语言模型和信息检索的技术,其大致流程如下:
- 文档预处理与向量化存储:将生产数据处理为文档 Documents,用嵌入模型(如 OpenAI text-embedding、BERT 等)转为向量并存入向量数据库(如 Milvus、Faiss),保留语义。
- 用户查询向量化:用与文档处理相同的嵌入模型,将用户自然语言查询转为向量,确保向量空间一致。
- 语义检索:计算用户查询向量与库中文档向量的余弦相似度,返回相似度最高的 Top K 文档。
- 生成 LLM 输入:将用户查询和检索到的文档按模板合并,形成 LLM 的输入提示。
- 大模型输出与后处理:LLM 基于输入生成回答,减少幻觉 API 对输出格式化后返回结构化响应给用户。
下面我们按流程进行演示
文档预处理与向量化存储
文档提取
首先,我们要将生产数据(文本TXT、JSON、PDF、DOCX、Markdown、HTML、数据库等)处理为 Document
Spring AI 提供了 DocumentReader 来将生产数据转换为 Document,它的实现类有:
- **<font style="color:rgb(77, 77, 77);">JsonReader</font>**<font style="color:rgb(77, 77, 77);">:读取 JSON 格式的文件</font>
- **<font style="color:rgb(77, 77, 77);">TextReader</font>**<font style="color:rgb(77, 77, 77);">:读取 txt 文件</font>
- **<font style="color:rgb(77, 77, 77);">PagePdfDocumentReader</font>**<font style="color:rgb(77, 77, 77);">:使用 Apache PdfBox 读取 PDF 文件</font>
- **<font style="color:rgb(77, 77, 77);">TikaDocumentReader</font>**<font style="color:rgb(77, 77, 77);">:使用 Apache Tika 来读取 PDF、DOC/DOCX、PPT/PPTX、HTML等文件</font>
我们进行逐个演示:
txt
使用 TextReader 来读取违规行为管理规范.txt 文件内容:
@Component
public class DocumentService {
@Value("classpath:违规行为管理规范.txt")
private Resource txtResource;
public List<Document> loadText() {
TextReader textReader = new TextReader(txtResource);
textReader.getCustomMetadata().put("title", "违规行为管理规范.txt");
List<Document> documentList = textReader.get();
return documentList;
}
}
@RestController
public class RAGController {
@Resource
private DocumentService documentService;
@GetMapping("/document/text")
public List<Document> txtDocument() {
return documentService.loadText();
}
}
可以看到,Document 的格式由 id 文档的唯一标识符、text 文档主要内容、media 与文档相关的媒体内容、metadata 元数据和 score 用于排序和过滤的分数组成
JSON
使用 JsonReader 来读取 weather.json 文件内容:
@Component
public class DocumentService {
@Value("classpath:weather.json")
private Resource jsonResource;
public List<Document> loadJson() {
JsonReader jsonReader = new JsonReader(jsonResource);
List<Document> documents = jsonReader.get();
return documents;
}
}
@RestController
public class RAGController {
@Resource
private DocumentService documentService;
@GetMapping("/document/json")
public List<Document> jsonDocument() {
return documentService.loadJson();
}
}
引入 spring-ai-pdf-document-reader 依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
<version>1.0.0-M6</version>
</dependency>
使用 PagePdfDocumentReader 来读取 raft.pdf 文件内容:
@Component
public class DocumentService {
@Value("classpath:raft.pdf")
private Resource pdfResource;
public List<Document> loadPdf() {
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(pdfResource,
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageExtractedTextFormatter(ExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(0)
.build())
.withPagesPerDocument(1)
.build());
List<Document> read = pdfReader.read();
return read;
}
}
@RestController
public class RAGController {
@Resource
private DocumentService documentService;
@GetMapping("/document/pdf")
public List<Document> pdfDocument() {
return documentService.loadPdf();
}
}
DOCX
引入 spring-ai-tika-document-reader 依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<version>1.0.0-M6</version>
</dependency>
使用 TikaDocumentReader 来读取 test.docx 文件内容:
@Component
public class DocumentService {
@Value("classpath:test.docx")
private Resource docxResource;
public List<Document> loadDocx() {
TikaDocumentReader docxReader = new TikaDocumentReader(docxResource);
List<Document> documents = docxReader.read();
return documents;
}
}
@RestController
public class RAGController {
@Resource
private DocumentService documentService;
@GetMapping("/document/docx")
public List<Document> docxDocument() {
return documentService.loadDocx();
}
}
HTML
使用 TikaDocumentReader 来读取 =email.html 文件内容:
@Component
public class DocumentService {
@Value("classpath:email.html")
private Resource htmlResource;
public List<Document> loadHtml(){
TikaDocumentReader htmlReader = new TikaDocumentReader(htmlResource);
List<Document> documents = htmlReader.read();
return documents;
}
}
@RestController
public class RAGController {
@Resource
private DocumentService documentService;
@GetMapping("/document/html")
public List<Document> htmlDocument() {
return documentService.loadHtml();
}
}
文档转换
接下来,我们要将文档 Document 分割成适合 AI 模型上下文窗口的片段,这里要使用的是
TokenTextSplitter,它基于 CL100K_BASE 编码,按 Token 数量分割文本,默认按 800Tokens/块 来分块
我们创建一个 TokenTextSplitter 对象
其中:
- defaultChunkSize 表示目标 Token 数,即每个块的 Token 数
- minChunkSizeChars 表示最小字符数,当某块因分隔符导致不足 defaultChunkSize 时,会至少保留minChunkSize 的内容
- minChunkLengthToEmbed 表示最小有效分块长度,避免过滤短句
- maxNumChunks 表示最大分块数
- keepSeparator 表示是否保留分隔符(如换行符)在文本块中,中文无需保留
调用 TokenTextSplitter 的 split 方法就可以对 Document 进行分块了
@Component
public class DocumentService {
@Value("classpath:test.docx")
private Resource docxResource;
public List<Document> loadDocx() {
TikaDocumentReader docxReader = new TikaDocumentReader(docxResource);
List<Document> documents = docxReader.read();
TokenTextSplitter splitter = new TokenTextSplitter(
30, // defaultChunkSize 目标Token数,即每个块的Token数
20, // minChunkSizeChars 最小字符数,当某块因分隔符导致不足chunkSize时,会至少保留minChunkSize的内容
1, // minChunkLengthToEmbed 最小有效分块长度,避免过滤短句
20, // maxNumChunks 最大分块数
false // keepSeparator 是否保留分隔符(如换行符)在文本块中,中文无需保留
);
List<Document> documentList = splitter.split(documents);
LOGGER.info("分块后文档数: {}", documentList.size());
documentList.stream()
.forEach(document -> LOGGER.info("Processing document: {}", document));
return documents;
}
}
当然,我们还可以对 Document 的格式进行处理,使用 ContentFormatTransformer 并结合特定规则处理文档内容
其中,DefaultContentFormatter 定义了格式规则
public class ContentFormatTransformerTest {
public static void main(String[] args) {
Document doc = new Document(
"北京今日天气:晴朗,气温 25°C,空气质量优",
Map.of(
"city", "北京",
"date", "2024-03-20",
"temperature", "25°C",
"weather", "晴朗",
"air_quality", "优",
"humidity", "45%",
"wind_speed", "3级"
)
);
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("{key}---{value}") // 元数据显示格式
.withMetadataSeparator("\n") // 元数据分隔符
.withTextTemplate("METADATA:\n{metadata_string}\nCONTENT:\n{content}") // 内容模板
.withExcludedInferenceMetadataKeys("air_quality") // 推理时排除的元数据
.withExcludedEmbedMetadataKeys("wind_speed") // 嵌入时排除的元数据
.build();
String content = formatter.format(doc, MetadataMode.EMBED);
System.out.println(content);
ContentFormatTransformer transformer = new ContentFormatTransformer(formatter, false);
List<Document> documentList = transformer.apply(List.of(doc));
documentList.forEach(System.out::println);
}
}
此外,我们还可以使用 KeywordMetadataEnricher,它会使用大模型提取文档关键词,并添加在元数据中
其中,KeywordMetadataEnricher 中的第一个参数为 ChatModel 模型,第二个参数为所提取的最大关键词数量
@Service
public class KeywordMetadataEnricherTest {
@Resource
private OpenAiChatModel openAiChatModel;
public String getKeywords() {
Document doc = new Document("""
今日北京,碧空如洗,澄澈的蓝天如同被反复擦拭过的蓝宝石,不见一丝云彩。
温暖的阳光毫无保留地倾洒在这座古老又现代的城市,气温稳定维持在 25°C,
体感舒适宜人,微风轻拂,带着些许春日的惬意。
空气质量达到了优的级别,清新的空气沁人心脾,无论是漫步在故宫的红墙黄瓦间,
还是穿梭于国贸的高楼大厦中,都能尽情享受每一口呼吸。
这样的好天气,引得市民纷纷走出家门,公园里孩童嬉笑玩耍,护城河旁老人悠闲垂钓,
街头巷尾满是活力与生机,处处洋溢着幸福的气息。
""",
Map.of(
"city", "北京",
"date", "2024-03-20",
"temperature", "25°C",
"weather", "晴朗",
"air_quality", "优",
"humidity", "45%",
"wind_speed", "3级"
));
// 构造 KeywordMetadataEnricher,传入 OpenAiChatModel,并设置最大关键词数量为 5
KeywordMetadataEnricher enricher = new KeywordMetadataEnricher(openAiChatModel,5);
List<Document> documentList = enricher.apply(List.of(doc));
// 输出生成的关键词
String keywords = (String) documentList.get(0).getMetadata().get("excerpt_keywords");
return keywords;
}
}
测试
@RestController
public class RAGController {
@Resource
private KeywordMetadataEnricherTest keywordMetadataEnricherTest;
@GetMapping("/document/keywords")
public String xmlDocument() {
return keywordMetadataEnricherTest.getKeywords();
}
}
接下来,演示 SummaryMetadataEnricher(它使用大模型提取文档摘要,在元数据中添加一个 section_summary/prev_section_summary/next_section_summary 字段),它不仅可以生成当前文档的摘要,还能生成相邻文档(前一篇和后一篇)的摘要
SummaryMetadataEnricher 中的参数如下:
* chatModel:用于生成摘要的 AI 模型
* summaryTypes:指定生成哪些类型的摘要(PREVIOUS、CURRENT、NEXT)
* summaryTemplate(可选):自定义摘要生成模板(默认是英文,可以改写成中文的)
* metadataMode(可选):控制生成摘要时如何处理文档的元数据
@Service
public class SummaryMetadataEnricherTest {
@Resource
private OpenAiChatModel openAiChatModel;
public List<String> getSummary() {
Document doc1 = new Document("""
今日北京,碧空如洗,澄澈的蓝天如同被反复擦拭过的蓝宝石,不见一丝云彩。
温暖的阳光毫无保留地倾洒在这座古老又现代的城市,气温稳定维持在 25°C,
体感舒适宜人,微风轻拂,带着些许春日的惬意。
空气质量达到了优的级别,清新的空气沁人心脾,无论是漫步在故宫的红墙黄瓦间,
还是穿梭于国贸的高楼大厦中,都能尽情享受每一口呼吸。
这样的好天气,引得市民纷纷走出家门,公园里孩童嬉笑玩耍,护城河旁老人悠闲垂钓,
街头巷尾满是活力与生机,处处洋溢着幸福的气息。
""",
Map.of(
"city", "北京",
"date", "2024-03-20",
"temperature", "25°C",
"weather", "晴朗",
"air_quality", "优",
"humidity", "45%",
"wind_speed", "3级"
)
);
Document doc2 = new Document("""
今日上海,全球瞩目的2024世界人工智能大会在浦东世博中心盛大开幕。
来自40多个国家的2000余位科技精英齐聚一堂,展示了最新的AI研究成果。
开幕式上,由中国科学院研发的"启明-9000"超级计算机首次公开亮相,
其算力达到每秒10^18次浮点运算,较前代提升300%,能耗却降低40%。
现场还展示了AI驱动的医疗诊断系统,能在10秒内完成肺部CT影像分析,
准确率高达99.7%。各大科技巨头纷纷发布智能机器人、自动驾驶等前沿产品,
彰显了AI技术在赋能实体经济、改善民生等领域的巨大潜力。
""",
Map.of(
"city", "上海",
"date", "2024-05-15",
"event", "世界人工智能大会",
"location", "浦东世博中心",
"key_technology", "超级计算机、AI医疗、智能机器人",
"organizer", "中国科学院、科技部"
)
);
Document doc3 = new Document("""
西安城墙下,第十四届"长安国际艺术节"在春日的暖阳中拉开帷幕。
来自全球15个国家的80余个艺术团体带来了为期20天的文化盛宴。
开幕式上,敦煌研究院与法国卢浮宫联合打造的"丝路文明对话"光影秀惊艳全场,
3D投影技术将莫高窟壁画与卢浮宫馆藏完美融合,呈现出跨越时空的艺术对话。
日本能剧、西班牙弗拉门戈等世界非遗表演轮番登场,
更有陕派秦腔与现代交响乐的创新融合演出,吸引了3万余名观众到场欣赏。
艺术节期间,还将举办20余场学术研讨会和50多场艺术工作坊,
为不同文化背景的艺术家和观众搭建起交流互鉴的桥梁。
""",
Map.of(
"city", "西安",
"date", "2024-04-08",
"event", "长安国际艺术节",
"duration", "20天",
"participants", "15国80余个艺术团体",
"highlight", "丝路文明对话光影秀、非遗表演"
)
);
// 构造 SummaryMetadataEnricher,传入 OpenAiChatModel,指定要生成的摘要类型
String template = """
请基于以下文本提取核心信息:
{context_str}
要求:
1. 使用简体中文
2. 包含关键实体
3. 不超过50字
摘要:
""";
SummaryMetadataEnricher summaryMetadataEnricher = new SummaryMetadataEnricher(
openAiChatModel,
List.of(SummaryMetadataEnricher.SummaryType.PREVIOUS,
SummaryMetadataEnricher.SummaryType.CURRENT,
SummaryMetadataEnricher.SummaryType.NEXT),
template,
MetadataMode.ALL
);
List<Document> documentList = summaryMetadataEnricher.apply(List.of(doc1, doc2, doc3));
String summary1 = (String) documentList.get(0).getMetadata().get("section_summary");
String summary2 = (String) documentList.get(1).getMetadata().get("prev_section_summary");
String summary3 = (String) documentList.get(2).getMetadata().get("next_section_summary");
return Arrays.asList(summary1, summary2, summary3);
}
}
@RestController
public class RAGController {
@Resource
private SummaryMetadataEnricherTest summaryMetadataEnricherTest;
@GetMapping("/document/summary")
public List<String> summaryDocument() {
return summaryMetadataEnricherTest.getSummary();
}
}
文档写入到文件
使用 FileDocumentWriter 来将 Document 写入到文件中
其中:
- fileName 表示目标文件名
- withDocumentMarkers 表示是否在输出中包含文档标记(默认false)
- metadataMode 表示指定写入文件的文档内容格式(默认MetadataMode.NONE)
- append 表示是否追加写入文件(默认false)
调用 accept 方法,传入 List
@Service
public class DocumentWriterTest {
public static void main(String[] args) {
Document doc1 = new Document("今日北京,碧空如洗,澄澈的蓝天如同被反复擦拭过的蓝宝石,不见一丝云彩。");
Document doc2 = new Document("温暖的阳光毫无保留地倾洒在这座古老又现代的城市,气温稳定维持在 25°C");
Document doc3 = new Document("体感舒适宜人,微风轻拂,带着些许春日的惬意。");
FileDocumentWriter fileDocumentWriter = new FileDocumentWriter(
"output.txt",
true,
MetadataMode.ALL,
false
);
fileDocumentWriter.accept(List.of(doc1, doc2, doc3));
}
}
向量化存储
Spring AI 通过 VectorStore 接口为向量数据库交互提供了抽象化的 API,这里的向量数据库我选择了 Milvus
安装 Milvus 及 Attu
在 Docker 中安装 Milvus,输入以下命令:
curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh
bash standalone_embed.sh start
可以访问 Milvus WebUI,网址是 http://your_host:9091/webui/
Attu 是 Milvus 的官方 GUI 客户端,提供更直观的操作体验
在 Docker 中安装运行 Attu,输入以下命令:
docker run -d --name attu -p 8000:3000 -e MILVUS_URL=your_host:19530 zilliz/attu:v2.5.6
访问 Attu 的地址:http://your_host:8000
使用 Milvus
引入 spring-ai-milvus-store 依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
这里我使用的文本向量模型是阿里的 text-embedding 系列,阿里云百炼:https://help.aliyun.com/zh/model-studio/get-api-key,可能是不兼容的原因,我们不再使用 spring-ai-openai-spring-boot-starter 依赖,而是使用 spring-ai-alibaba-starter 依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M5.1</version>
</dependency>
修改 yml 文件中的配置,在 .env 中填写 api-key
spring:
# ai 配置
ai:
dashscope:
api-key: ${AI_API_KEY}
首先将数据存入到 Milvus 中,使用 vectorStore 的 add 方法,传入 List
@Service
public class MilvusService {
@Value("classpath:违规行为管理规范.txt")
private Resource textResource;
@jakarta.annotation.Resource
private MilvusVectorStore vectorStore;
public void initMilvus() {
TextReader textReader = new TextReader(textResource);
textReader.getCustomMetadata().put("title", "违规行为管理规范.txt");
TokenTextSplitter tokenTextSplitter = new TokenTextSplitter();
List<Document> documentList = tokenTextSplitter.apply(textReader.get());
vectorStore.add(documentList);
LOGGER.info("Vector data initialized");
}
}
LLM + Milvus
首先测试一下向量搜索,根据用户输入搜索最相似的文本
在 advisors 方法中 new 一个 QuestionAnswerAdvisor,传入 milvusVectorStore 和SearchRequest,其中,.query(userInput) 表示搜索的文本,.topK(5) 表示要搜索的数量
@RestController
public class RAGController {
private static final Logger LOGGER = LoggerFactory.getLogger(RAGController.class);
@Resource
private ChatClient chatClient;
@Resource
private MilvusVectorStore milvusVectorStore;
@GetMapping("/chat/rag")
public String initChat(@RequestParam(value = "userInput") String userInput) {
return chatClient.prompt()
.user(userInput)
.advisors(new QuestionAnswerAdvisor(milvusVectorStore,
SearchRequest.builder()
.query(userInput)
.topK(5)
.build()
)
)
.call()
.content();
}
}
对比文档可以看到确实进行了向量搜索
当然,我们可以将用户输入和检索到的文档进行合并,形成 LLM 的输入提示
@GetMapping("/rag")
public String milvusRag(@RequestParam(value = "input") String input) {
PromptTemplate promptTemplate = new PromptTemplate("""
你是一个违规行为管理规范答疑助手,请结合文档提供的内容回答用户的问题,如果不知道请直接回答不知道
用户输入的问题:
{input}
文档:
{documents}
""");
// 构造 SearchRequest
SearchRequest searchRequest = SearchRequest.builder()
.query(input)
.topK(5).build();
// 从向量数据库中搜索到的最相似的 Document
List<Document> documentList = milvusVectorStore.similaritySearch(searchRequest);
// 转换为 String
List<String> stringList = documentList.stream()
.map(Document::getText)
.collect(Collectors.toList());
// 拼接
String relevantDocs = String.join("\n", stringList);
LOGGER.info("用户问题={},查询结果={}", input, relevantDocs);
// 构造 PromptTemplate 参数
Map<String, Object> params = new HashMap<>();
params.put("input", input);
params.put("documents", relevantDocs);
return chatClient.prompt(new Prompt(promptTemplate.render(params)))
.call()
.content();
}
Aegnt
Agent 并非一个标准概念,它可以是完全自主的系统,也可以是遵循预定工作流的系统。Anthropic 将这些变体统称为 Agent 系统,并将其在实现架构上划分为两类:
- 工作流 Workflow:通过预定义的代码路径去编排 LLM 和工具
- 智能体 Agent:LLM 动态指导自身流程和工具使用,保持对任务完成方式的控制
Agent 框架通常以 LLM 为核心,以工具 Tool、规划 Planning、记忆 Memory 和行动 Action 等多个模块共同组成
Workflow Agent(Workflow)
Workflow Agent 是通过预定义代码路径协调 LLM 和工具的系统,我们在构建 Workflow Agent 时需要针对业务进行步骤分解和流程设计
提示链(Prompt chaining)
提示链将任务分解为一系列步骤,其中每个 LLM 调用都会处理前一个步骤的输出。我们可以在任何中间步骤上添加程序化检查(Gate),以确保流程仍在正常进行。
此工作流程非常适合任务可以轻松清晰地分解为固定子任务的情况。其主要目标是通过简化每次 LLM 调用,以降低延迟并提高准确率。
为此,我们需要对业务步骤进行拆解,构建出一个 Prompt 列表,通过循环串行化执行 LLM Call
示例:使用提示链将电商平台商品信息格式化输出
首先编写一个 WorkflowService 接口,定义 startPromptChainingWorkflow 方法
public interface WorkflowService {
String startPromptChainingWorkflow(String userInput);
}
在其实现类中,我们将该示例业务拆解成 4 个步骤,对每个步骤编写对应的 Prompt
在 chain 方法中,我们在 for 循环中链式执行 Prompt 数组
@Service
public class WorkflowServiceImpl implements WorkflowService {
private static final String [] DEFAULT_SYSTEM_PROMPTS = {
// 步骤1
"""
从文本中提取商品名称、价格、库存数量、销量或其他重要信息。
每条数据格式为 "信息类别:具体内容",各占一行。
示例格式:
商品名称:智能蓝牙耳机
价格: 199 元
库存数量: 50
销量: 1200
""",
// 步骤2
"""
将价格统一保留两位小数,并去除货币单位(元)。
将库存数量和销量转换为整数形式(每隔3位用逗号分隔)。
保持每行一个信息。
示例格式:
商品名称:智能蓝牙耳机
价格: 199.00
库存数量: 5,000,000
销量: 1,200
""",
// 步骤3
"""
仅保留销量大于 1000 的商品信息,并按销量降序排列。
保持每行 "信息类别:具体内容" 的格式。
示例:
商品名称:智能蓝牙耳机
价格: 199.00
库存数量: 5,000,000
销量: 1,200
""",
// 步骤4
"""
将排序后的数据格式化为Markdown表格,包含商品名称、价格、库存数量、销量或其他信息列
要求仅输出Markdown表格,不要包含任何其他文本:
| 商品名称 | 价格 | ... |
|:--|--:||--:|
| 智能蓝牙耳机 | 199.00 || ... |
"""
};
@Resource(name = "openAiChatClient")
private ChatClient openAiChatClient;
public String chain(String Input) {
int step = 0;
String response = Input;
// 初始用户输入
System.out.println(String.format("\nSTEP %s:\n %s", step++, response));
// 串行化调用 LLM
for (String prompt : DEFAULT_SYSTEM_PROMPTS) {
String input = String.format("{%s}\n {%s}", prompt, response);
response = openAiChatClient.prompt(input)
.call()
.content();
System.out.println(String.format("\nSTEP %s:\n %s", step++, response));
}
return response;
}
@Override
public String startPromptChainingWorkflow(String userInput) {
return chain(userInput);
}
}
测试效果,可以看到,控制台中输出的每个步骤的执行结果,最终返回了一个 Markdown 表格,符合预期要求
@RestController
public class AgentController {
@Resource
private WorkflowService workflowService;
@GetMapping("/agent/chain")
public String chain(@RequestParam("userInput") String userInput) {
return workflowService.startPromptChainingWorkflow(userInput);
}
}
STEP 0:
我们新上架了一批商品,智能蓝牙耳机,价格 199.8 元,库存 50 件,销量 120000;复古机械键盘,价格 299 元,库存 30 件,销量 800;无线鼠标,价格 89.9 元,库存 100 件,销量 15000。
STEP 1:
商品名称:智能蓝牙耳机
价格:199.8 元
库存数量:50
销量:120000
商品名称:复古机械键盘
价格:299 元
库存数量:30
销量:800
商品名称:无线鼠标
价格:89.9 元
库存数量:100
销量:15000
STEP 2:
商品名称:智能蓝牙耳机
价格:199.80
库存数量:50
销量:120,000
商品名称:复古机械键盘
价格:299.00
库存数量:30
销量:800
商品名称:无线鼠标
价格:89.90
库存数量:100
销量:15,000
STEP 3:
商品名称:智能蓝牙耳机
价格:199.80
库存数量:50
销量:120,000
商品名称:无线鼠标
价格:89.90
库存数量:100
销量:15,000
STEP 4:
| 商品名称 | 价格 | 库存数量 | 销量 |
|:---------------|------:|---------:|--------:|
| 智能蓝牙耳机 | 199.80| 50 | 120,000 |
| 无线鼠标 | 89.90 | 100 | 15,000 |
路由(Routing)
路由会对输入进行分类,并将其定向到专门的后续任务。此工作流程允许分离关注点,并构建更专业的提示。如果没有此工作流程,针对一种输入进行优化可能会损害其他输入的性能。
路由非常适合复杂任务,其中存在不同的类别,最好分别处理
示例:将不同类型的客户服务查询(一般问题、退款请求、技术支持、合作沟通)引导到不同的下游流程中
在接口中添加 startRoutingWorkflow 方法
String startRoutingWorkflow(String userInput);
我们将 Prompt 全部放到一个常量类中,方便管理
public class PromptConstant {
public static final String [] DEFAULT_SYSTEM_PROMPTS = {
// 步骤1
"""
从文本中提取商品名称、价格、库存数量、销量或其他重要信息。
每条数据格式为 "信息类别:具体内容",各占一行。
示例格式:
商品名称:智能蓝牙耳机
价格: 199 元
库存数量: 50
销量: 1200
""",
// 步骤2
"""
将价格统一保留两位小数,并去除货币单位(元)。
将库存数量和销量转换为整数形式(每隔3位用逗号分隔)。
保持每行一个信息。
示例格式:
商品名称:智能蓝牙耳机
价格: 199.00
库存数量: 5,000,000
销量: 1,200
""",
// 步骤3
"""
仅保留销量大于 1000 的商品信息,并按销量降序排列。
保持每行 "信息类别:具体内容" 的格式。
示例:
商品名称:智能蓝牙耳机
价格: 199.00
库存数量: 5,000,000
销量: 1,200
""",
// 步骤4
"""
将排序后的数据格式化为Markdown表格,包含商品名称、价格、库存数量、销量或其他信息列
要求仅输出Markdown表格,不要包含任何其他文本:
| 商品名称 | 价格 | ... |
|:--|--:||--:|
| 智能蓝牙耳机 | 199.00 || ... |
"""
};
public static final Map<String, String> ROUTE_MAP = Map.of (
"general",
"""
您是一位客户服务专员。请遵循以下准则:
1.始终以 "通用问题回复:" 开头
2.全面理解客户提出的一般问题,先表达共情与理解
3.用简洁易懂的语言解答问题,若涉及复杂政策,进行通俗解释
4.对于无法直接回答的问题,告知客户后续跟进方式及预计时间
5.保持友好、耐心的沟通语气
输入:
""",
"refund",
"""
您是一位退款处理专员。请遵循以下准则:
1.始终以 "退款请求回复:" 开头
2.优先核实客户订单及退款原因,安抚客户情绪
3.详细说明退款政策、流程及预计到账时间
4.指导客户提交必要的退款材料,提供提交方式和地址
5.定期告知客户退款进度查询方式
6.保持专业且负责的态度
输入:
""",
"technical_support",
"""
1.您是一位技术支持人员。请遵循以下准则:
2.始终以 "技术支持回复:" 开头
3.询问客户具体的技术问题表现,收集系统环境信息
4.列出分步骤的排查和解决方法,附上操作截图示例
5.提供临时替代方案以保障客户紧急使用需求
6.若问题复杂,说明升级至高级技术团队的流程和预计响应时间
7.使用清晰的技术术语,表述严谨
输入:
""",
"cooperation",
"""
1.您是一位合作对接专员。请遵循以下准则:
2.始终以 "合作沟通回复:" 开头
3.热情回应客户的合作意向,询问合作需求和期望
4.介绍公司合作政策、优势资源及成功案例
5.安排专人与客户进行后续详细对接,告知对接人信息和时间
6.提供合作相关资料的获取方式
7.保持积极主动、开放合作的态度
输入:
"""
);
}
使用到的实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RoutingResponse {
private String reason;
private String selection;
}
在接口实现类中,**route 方法负责进行路由操作,并根据路由到的提示词调用 LLM 返回结果 **
public String route(String input, Map<String, String> routeMap){
Set<String> stringSet = routeMap.keySet();
// 路由操作提示词
String routePrompt = String.format("""
分析输入内容并从以下选项中选择最合适的查询类型:%s
首先解释你的判断依据,然后按照以下JSON格式提供选择:
\\{
"reasoning": "简要说明为何该客户服务查询应选择该查询类型。
需考虑关键词、用户意图和紧急程度。",
"selection": "所选查询类型名称"
\\}
输入:%s""", stringSet, input);
// 调用 LLM,获取路由结果响应
RoutingResponse routeResponse = openAiChatClient.prompt(new Prompt(routePrompt))
.call()
.entity(RoutingResponse.class);
LOGGER.info("RouteResponse: " + "Reason---" + routeResponse.getReason() +
"\n" + "Selection---" + routeResponse.getSelection());
// 获取路由结果响应中的提示词
String callPrompt = routeMap.get(routeResponse.getSelection());
// 调用 LLM
String content = openAiChatClient.prompt(new Prompt(callPrompt + "\n" + input))
.call()
.content();
// 返回结果
return content;
}
@Override
public String startRoutingWorkflow(String userInput) {
return route(userInput, PromptConstant.ROUTE_MAP);
}
测试效果,从控制台中可以看到,用户输入被路由到了 refund,符合预期要求
@GetMapping("/agent/route")
public String route(@RequestParam("userInput") String userInput) {
return workflowService.startRoutingWorkflow(userInput);
}
并行化(Parallelization)
LLM 有时可以同时处理一项任务,并以编程方式聚合其输出
何时使用:拆分后的子任务可以并行化以提高速度、需要多个视角或尝试以获得更高置信度的结果
对于涉及多个考量的复杂任务,当每个考量都由单独的 LLM 调用处理时,LLM 通常表现更好,从而能够专注于每个特定方面。
示例:分析气候变化趋势对各个行业的系统性风险和机遇
由于可以对每个行业的分析单独执行,因此可以并行化
在接口中新增 startParallelWorkflow 方法
List<String> startParallelWorkflow(String userInput);
在常量中增加一个 PARALLEL_PROMPT
public static final String PARALLEL_PROMPT =
"""
分析气候变化趋势对该行业的系统性风险和机遇,
构建包含短期(1-3年)、中期(3-5年)、长期(5-10年)的战略应对框架。
使用清晰的分区和优先级进行格式化。
""";
用户可以输入多个行业,startParallelWorkflow 方法可以将其拆分为多个子任务并行执行,其中核心线程数为子任务数(行业数)
@Override
public List<String> startParallelWorkflow(String userInput) {
String regex = "[,。!?;、,.!?;]+";
String[] userInputArr = userInput.split(regex);
List<String> userInputList = Arrays.asList(userInputArr);
// 核心线程数
int nWorkers = userInputArr.length;
String prompt = PromptConstant.PARALLEL_PROMPT;
ExecutorService executorService = Executors.newFixedThreadPool(nWorkers);
try{
List<CompletableFuture<String>> completableFutureList = userInputList.stream().map(input ->
CompletableFuture.supplyAsync(() -> {
try {
return openAiChatClient.prompt(prompt + "\nInput: " + input)
.call()
.content();
} catch (Exception e) {
throw new RuntimeException(e);
}
}, executorService)
).collect(Collectors.toList());
// 并行等待所有任务完成
CompletableFuture<Void> voidCompletableFuture = CompletableFuture.allOf(
completableFutureList.toArray(CompletableFuture[]::new)
);
voidCompletableFuture.join();
List<String> collect = completableFutureList.stream().map(CompletableFuture::join)
.collect(Collectors.toList());
LOGGER.info("ParallelWorkflow: " + collect);
return collect;
} finally {
executorService.shutdown();
}
}
测试
@GetMapping("/agent/parallel")
public List<String> parallel(@RequestParam("userInput") String userInput) {
return workflowService.startParallelWorkflow(userInput);
}
#### **一、系统性风险分析**
---
1. **物理风险**
- **极端天气事件**:干旱、洪水、热浪等频率增加,影响作物产量和质量。
- **水资源短缺**:降水模式变化导致灌溉用水不足。
- **土壤退化**:高温和侵蚀加剧土壤肥力下降。
2. **转型风险**
- **政策压力**:碳排放税、环保法规趋严增加生产成本。
- **市场需求变化**:消费者偏好转向低碳农产品,传统产品需求下降。
- **技术滞后**:未能适应气候智能型农业技术可能被淘汰。
3. **供应链风险**
- **物流中断**:极端天气破坏运输和仓储设施。
- **投入成本上升**:化肥、农药等因能源价格波动或短缺而涨价。
---
### 农业行业的气候变化趋势分析及战略应对框架
#### **二、机遇分析**
1. **气候适应型农业**
- 耐旱/耐涝作物品种、精准灌溉技术等需求增长。
2. **碳汇经济**
- 通过再生农业、森林碳汇项目获取额外收益。
3. **政策与市场激励**
- 政府对绿色农业的补贴和碳交易机制支持。
4. **技术创新**
- 垂直农业、AI病虫害预测等提升效率。
---
### **三、战略应对框架**
#### **短期(1-3年):风险缓解与基础能力建设**
**优先级:高(生存与合规)**
1. **风险应对**
- 引入天气指数保险,对冲极端气候损失。
- 建立应急水源储备和多元化供应链。
2. **技术试点**
- 试点耐候作物品种和小规模精准灌溉系统。
3. **政策响应**
- 跟踪碳减排政策,优化现有生产流程以符合法规。
---
#### **中期(3-5年):转型与效率提升**
**优先级:中高(竞争力提升)**
1. **技术规模化**
- 推广气候智能农业技术(如土壤湿度传感器、滴灌)。
- 投资可再生能源(太阳能水泵、生物质能)。
2. **市场调整**
- 开发低碳认证农产品,抢占细分市场。
3. **合作网络**
- 与科研机构合作育种,参与碳汇项目试点。
---
#### **长期(5-10年):可持续领导力构建**
**优先级:中(战略重塑)**
1. **系统变革**
- 全面转向再生农业(轮作、免耕、有机肥料)。
- 布局垂直农业或气候适应性强的区域生产基地。
2. **价值链整合**
- 构建从生产到销售的低碳闭环供应链。
3. **政策与资本利用**
- 利用碳交易市场实现盈利,争取长期政策红利。
---
### **四、优先级矩阵**
| 时间框架 | 关键行动领域 | 优先级 | 资源投入建议 |
|----------|----------------------------|--------|--------------|
| 短期 | 应急风险管理、合规 | 高 | 20%-30% |
| 中期 | 技术升级、市场差异化 | 中高 | 40%-50% |
| 长期 | 模式创新、生态整合 | 中 | 20%-30% |
---
**注**:农业企业需根据自身规模(如小农户 vs 大型农企)调整策略重点,小农户可优先依赖政策支持与合作社协作,大企业则需引领技术创新和标准制定。,
### 气候变化对保险业的系统性风险与机遇分析及战略应对框架
#### **一、系统性风险**
1. **物理风险**
- **短期(1-3年)**:极端天气事件(如飓风、洪水、野火)频发,导致财产险赔付激增,再保险成本上升。
- **中期(3-5年)**:海平面上升和慢性气候灾害(如干旱)影响长期保单定价,部分高风险地区承保能力下降。
- **长期(5-10年)**:气候移民和基础设施损毁导致系统性风险累积,传统精算模型失效。
2. **转型风险**
- **短期(1-3年)**:碳密集型行业(如化石燃料)投保需求下降,责任险面临环保诉讼风险。
- **中期(3-5年)**:监管趋严(如碳税、绿色金融政策)倒逼产品结构调整,资本配置压力增大。
- **长期(5-10年)**:低碳技术普及导致传统业务萎缩,需重构风险评估逻辑。
3. **声誉与合规风险**
- **短期(1-3年)**:客户对“绿色washing”敏感,ESG评级影响融资成本。
- **中期(3-5年)**:强制披露气候相关财务信息(如TCFD),合规成本上升。
---
#### **二、机遇**
1. **新产品开发**
- **短期(1-3年)**:推出 parametric insurance(参数化保险)应对极端天气,覆盖快速理赔场景。
- **中期(3-5年)**:定制可再生能源项目保险(如风电、光伏设备险)。
- **长期(5-10年)**:气候适应型保险(如生态修复保险、碳捕获技术险)。
2. **数据与技术革新**
- **短期(1-3年)**:利用AI和卫星数据优化灾害预测与动态定价。
- **中期(3-5年)**:区块链提升气候风险共担机制(如P2P保险)。
3. **政策与市场协同**
- **短期(1-3年)**:参与政府气候韧性项目(如洪水防御基金),获取补贴。
- **长期(5-10年)**:成为绿色债券和碳交易市场的风险中介。
---
### **三、战略应对框架**
| **时间维度** | **优先级措施** | **关键行动** |
|--------------|----------------------------------------|-----------------------------------------------------------------------------|
| **短期(1-3年)** | 1. 风险建模升级<br>2. 应急产品创新 | - 整合气候数据到精算模型<br>- 试点参数化保险产品<br>- 剥离高碳资产承保 |
| **中期(3-5年)** | 1. 业务结构转型<br>2. 合规能力建设 | - 设立绿色保险专项基金<br>- 开发ESG投资组合<br>- 培训气候风险评估团队 |
| **长期(5-10年)**| 1. 生态协同<br>2. 系统性风险对冲 | - 与政府合作建立巨灾风险池<br>- 投资气候适应技术(如海绵城市保险解决方案) |
**优先级排序**:短期聚焦风险对冲与监管适应,中期转向绿色产品线,长期构建气候韧性生态。
---
**注**:战略需动态调整,建议每年评估气候情景分析(如RCP 2.6 vs RCP 8.5)对业务的影响。,
### 旅游业应对气候变化的战略框架
(按风险/机遇优先级排序,★数量代表紧迫性)
---
#### **一、系统性风险分析**
**1. 物理风险**
- **短期(1-3年)★★★**:极端天气事件(如飓风、野火)导致景区关闭、基础设施损坏。
- **中期(3-5年)★★☆**:海平面上升威胁沿海度假区(如马尔代夫、加勒比地区)。
- **长期(5-10年)★☆☆**:生态系统退化(珊瑚白化、冰川消失)降低目的地吸引力。
**2. 转型风险**
- **短期★★☆**:碳税政策增加航空及酒店运营成本。
- **中期★★★**:消费者偏好转向低碳旅行,高排放业务(如邮轮)面临需求下降。
- **长期★★☆**:全球“净零”目标倒逼全产业链脱碳改革。
---
#### **二、核心机遇分析**
**1. 需求转型**
- **短期★★★**:低碳旅游产品(如本地游、生态民宿)需求激增。
- **中期★★☆**:气候适应性旅游(如极地旅游替代消失的冰川游)兴起。
- **长期★☆☆**:虚拟旅游(VR+碳中和)成为新增长点。
**2. 政策与投资**
- **短期★★☆**:绿色补贴(如欧盟可持续旅游基金)支持企业转型。
- **中期★★★**:碳交易市场为低碳景区创造额外收益。
- **长期★★☆**:气候韧性基建(如防波堤、智能电网)提升目的地竞争力。
---
#### **三、战略应对框架**
**短期(1-3年)** ★★★
- **优先级1**:建立气候风险监测系统,针对高频灾害(如洪水)制定应急方案。
- **优先级2**:推出低碳认证(如酒店能源改造、短途旅游套餐),抢占市场先机。
- **优先级3**:与航空公司合作开发碳抵消计划,缓解政策压力。
**中期(3-5年)** ★★☆
- **优先级1**:投资气候适应性项目(如人工珊瑚礁、高海拔滑雪场)。
- **优先级2**:供应链脱碳(切换可再生能源交通、零碳供应链)。
- **优先级3**:培训员工掌握可持续旅游服务技能(如生态导游)。
**长期(5-10年)** ★☆☆
- **优先级1**:参与目的地气候韧性规划(如马尔代夫“漂浮城市”)。
- **优先级2**:开发“气候友好型”旅游IP(如碳中和主题公园)。
- **优先级3**:布局虚拟旅游技术,减少实体资源依赖。
---
#### **四、关键指标(KPI)**
- 短期:应急响应时间缩短30%,低碳产品收入占比达20%。
- 中期:供应链碳排放下降50%,10个目的地获气候韧性认证。
- 长期:虚拟旅游收入占比超15%,全产业链实现净零排放。
---
**注**:框架需结合区域特性调整(如海岛vs内陆),并定期评估气候模型更新影响。,
### 能源行业气候变化战略应对框架
(按风险/机遇优先级排序,★为关键程度)
---
#### **一、系统性风险分析**
**1. 政策与监管风险**
- **短期(1-3年)**:碳税、排放标准趋严(★★★)
- **中期(3-5年)**:化石燃料补贴取消、可再生能源配额制(★★★)
- **长期(5-10年)**:全球碳边境税(如CBAM)扩大化(★★☆)
**2. 物理风险**
- **短期**:极端天气导致能源基础设施损坏(如电网、炼油厂)(★★☆)
- **中期**:水资源短缺影响火电/核电运营(★☆☆)
- **长期**:海平面上升威胁沿海能源设施(★★☆)
**3. 市场与转型风险**
- **短期**:可再生能源成本下降挤压传统能源利润(★★★)
- **中期**:投资者撤资化石燃料资产(★★☆)
- **长期**:能源需求结构颠覆(如电动汽车普及)(★★★)
---
#### **二、战略机遇分析**
**1. 清洁能源转型**
- **短期**:分布式光伏、储能技术商业化(★★★)
- **中期**:绿氢产业链突破(★★☆)
- **长期**:碳捕集与封存(CCUS)规模化应用(★☆☆)
**2. 能效与创新**
- **短期**:智能电网与需求响应技术(★★☆)
- **中期**:工业流程电气化(如绿电制钢)(★★★)
- **长期**:核聚变等颠覆性能源技术(★☆☆)
**3. 新商业模式**
- **短期**:能源即服务(EaaS)订阅模式(★☆☆)
- **中期**:碳信用交易与绿证金融化(★★☆)
- **长期**:全球可再生能源电力贸易(★★★)
---
#### **三、战略应对框架**
**优先级排序**:政策响应 > 技术投资 > 资产重组 > 市场重塑
| **时间维度** | **核心措施** | **关键行动示例** |
|--------------|---------------------------------------|-------------------------------------------|
| **短期** | 合规与灵活性提升 | - 加速煤电资产剥离或改造<br>- 布局储能+可再生能源混合项目 |
| **中期** | 技术突破与产业链重构 | - 投资绿氢试点项目<br>- 建立碳资产管理团队 |
| **长期** | 系统性转型与生态构建 | - 参与国际能源标准制定<br>- 打造零碳工业园 |
---
#### **四、监控指标**
- **短期**:政策变动频率、可再生能源装机增速
- **中期**:CCUS成本下降曲线、绿氢产能占比
- **长期**:全球温升目标进展、颠覆性技术专利数
---
**注**:★☆表示优先级,需结合企业具体业务板块调整权重(如油气企业需更高权重关注转型风险)。
协调者-工作者(Orchestrator-Workers)
在协调器-工作者工作流中,中央 LLM 动态分解任务,将其委托给工作者 LLM,并综合其结果。
此工作流程非常适合无法预测所需子任务的复杂任务。虽然它在拓扑结构上与并行化类似,但其与并行化的关键区别在于灵活性——子任务并非预先定义,而是由编排器根据具体输入确定。
示例:我要到访秘鲁,为我写一篇旅游规划书
首先明确一点,任务(用户输入)要先被 Orchestrator 动态分解,然后 Workers 再执行这些子任务,最后汇总结果
下面定义 Orchestrator 和 Workers 的 Prompt,添加到常量类中
public static final String ORCHESTRATOR_PROMPT = """
分析此任务并分解为3-4种不同的处理方式:
任务:{task}
请按以下JSON格式返回响应:
\\{
"analysis": "说明你对任务的理解以及哪些变化会很有价值。
重点关注每种方式如何服务于任务的不同方面。",
"tasks": [
\\{
"type": "adventure",
"description": "规划充满冒险元素的行程,包括徒步、露营等活动"
\\},
\\{
"type": "luxury",
"description": "设计高端奢华的行程,包含精品酒店和私人定制体验"
\\}
]
\\}
""";
public static final String WORKER_PROMPT = """
根据以下要求生成内容:
任务:{original_task}
风格:{task_type}
指南:{task_description}
""";
定义 OrchestratorResponse 实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrchestratorResponse {
private String analysis;
private List<Task> tasks;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Task {
private String type;
private String description;
}
}
在接口中定义 startOrchestratorWorkerWorkflow 方法
List<String> startOrchestratorWorkerWorkflow(String userInput);
在实现类中,首先使用协调者动态分解任务,返回一个 orchestratorResponse,然后工作者会执行多个子任务,最后返回一个列表
@Override
public List<String> startOrchestratorWorkerWorkflow(String userInput) {
// 协调者动态分解任务
OrchestratorResponse orchestratorResponse = openAiChatClient.prompt()
.user(u -> u.text(PromptConstant.ORCHESTRATOR_PROMPT)
.param("task", userInput))
.call()
.entity(OrchestratorResponse.class);
LOGGER.info("\n" + "OrchestratorResponse: " + "Analysis---"
+ orchestratorResponse.getAnalysis() + "\n" + "Tasks---"
+ orchestratorResponse.getTasks());
// 工作者执行多个子任务
List<String> stringList = orchestratorResponse.getTasks().stream().map(
task -> openAiChatClient.prompt()
.user(u -> u.text(PromptConstant.WORKER_PROMPT)
.param("original_task", userInput)
.param("task_type", task.getType())
.param("task_description", task.getDescription()))
.call()
.content()
).toList();
LOGGER.info("Worker Output: " + stringList);
return stringList;
}
测试,可以看到,Orchestrator 将任务分解成了 4 个子任务(adventure、luxury、cultural、family)
2025-05-13 18:03:32 [http-nio-8095-exec-1] INFO c.o.m.s.impl.WorkflowServiceImpl -
OrchestratorResponse: Analysis---理解任务为根据用户需求定制秘鲁旅游规划书,重点在于满足不同旅行偏好。
有价值的变体包括行程强度、预算水平、文化深度和活动类型,以覆盖冒险、奢华、文化和家庭等不同需求。
Tasks---[
OrchestratorResponse.Task(type=adventure, description=规划充满冒险元素的行程,包括徒步、露营等活动),
OrchestratorResponse.Task(type=luxury, description=设计高端奢华的行程,包含精品酒店和私人定制体验),
OrchestratorResponse.Task(type=cultural, description=聚焦秘鲁历史与文化的深度探索,涵盖博物馆、遗址和当地社区互动),
OrchestratorResponse.Task(type=family, description=设计适合家庭出游的行程,包含亲子友好活动和舒适住宿)
]
Worker Output: [# **秘鲁冒险之旅规划书**
**目的地:秘鲁**
**旅行风格:探险 & 户外**
**时长:10天**
## **行程概览**
秘鲁是冒险者的天堂,拥有安第斯山脉、亚马逊雨林和古老的印加遗迹。本行程专注于徒步、露营、户外探险和文化体验,适合热爱挑战的旅行者。
---
## **📅 详细行程**
### **Day 1-2: 利马(Lima)→ 库斯科(Cuzco)**
- **抵达利马**,短暂休整,适应时差。
- **飞往库斯科**(海拔3,400米),适应高原环境。
- **库斯科探险准备**:
- 租借徒步装备(背包、登山杖、睡袋等)。
- 短途徒步至**Sacsayhuamán**(印加军事要塞),欣赏库斯科全景。
### **Day 3-5: 圣谷(Sacred Valley)→ 奥扬泰坦博(Ollantaytambo)**
- **圣谷探险**:
- 骑行或徒步探索**Moray**(印加农业实验梯田)和**Maras盐田**。
- **白水漂流**(Urubamba河,Class III-IV级急流)。
- **奥扬泰坦博过夜**,体验传统印加小镇风情。
### **Day 6-9: 印加古道徒步(Inca Trail)→ 马丘比丘(Machu Picchu)**
- **4天3夜印加古道徒步**(经典路线):
- **Day 1**: 从**Km 82**出发,徒步至**Wayllabamba**营地(12km)。
- **Day 2**: 挑战**Dead Woman’s Pass**(海拔4,215米),露营于**Pacaymayo**。
- **Day 3**: 探索**Runkurakay**和**Sayacmarca**遗址,夜宿**Wiñay Wayna**营地。
- **Day 4**: 黎明抵达**太阳门(Inti Punku)**,俯瞰**马丘比丘**,全天深度探索。
- **可选**:下山后泡**Aguas Calientes**温泉放松。
### **Day 10: 返回库斯科 → 利马 → 返程**
- **乘火车返回库斯科**,短暂休整。
- **飞回利马**,结束冒险之旅。
---
## **🏕️ 住宿推荐**
- **库斯科**:Wild Rover Hostel(背包客氛围)
- **圣谷**:Pisac Inn(生态旅馆)
- **印加古道**:露营(需向导安排)
- **马丘比丘**:Belmond Sanctuary Lodge(唯一山巅酒店,可选奢侈体验)
---
## **⚠️ 冒险贴士**
✅ **高原适应**:提前2天到库斯科适应海拔,多喝水,避免剧烈运动。
✅ **装备清单**:登山鞋、防水外套、头灯、防晒霜、水袋。
✅ **向导要求**:印加古道必须跟持证向导团队进入,提前6个月预订许可。
✅ **安全提示**:避免独自徒步偏远路线,随身携带急救包。
---
## **🌿 可选扩展冒险**
- **彩虹山(Vinicunca)**:1日徒步(海拔5,200米)。
- **亚马逊雨林(Puerto Maldonado)**:3天丛林探险,观察野生动物。
**📌 结语**
秘鲁的冒险之旅将挑战你的体能,同时带来无与伦比的自然与文化震撼。准备好征服高山、穿越雨林,揭开印加帝国的神秘面纱吧!
**🚀 出发吧,探险者!**, # **秘鲁奢华之旅规划书**
**目的地:秘鲁**
**旅行风格:高端定制 & 奢华体验**
**时长:10天**
## **✨ 行程亮点**
- **私人向导全程陪同**,VIP通道免排队
- **顶级精品酒店 & 豪华列车**
- **独家文化体验 & 美食盛宴**
- **直升机游览 & 私人游艇**
---
## **📅 尊享行程**
### **Day 1-2: 利马(Lima)—— 美食之都的奢华初体验**
- **抵达利马**,专车接送至**Belmond Miraflores Park**(海景套房)。
- **私人城市导览**:
- 参观**Larco Museum**(秘鲁黄金与文物珍藏)。
- **VIP品酒会**,品尝秘鲁国酒Pisco Sour。
- **米其林星级晚餐**:
- **Central**(世界50佳餐厅)或**Maido**(日秘融合料理)。
### **Day 3-4: 库斯科(Cuzco)—— 印加帝国的贵族之旅**
- **私人飞机前往库斯科**,入住**Palacio del Inka, a Luxury Collection Hotel**(16世纪宫殿改建)。
- **圣谷(Sacred Valley)尊享体验**:
- **私人导游**陪同游览**Pisac市场**和**Ollantaytambo遗址**。
- **Belmond Hiram Bingham豪华列车**前往马丘比丘(含香槟午餐)。
- **安第斯山野餐**:厨师现场烹饪,搭配本地葡萄酒。
### **Day 5-6: 马丘比丘(Machu Picchu)—— 云端奇迹的私密探索**
- **入住Belmond Sanctuary Lodge**(马丘比丘山巅唯一酒店)。
- **VIP日出游览**:清晨私人开放,避开人群。
- **直升机返程**(俯瞰安第斯山脉)。
### **Day 7-8: 的的喀喀湖(Lake Titicaca)—— 水上漂浮宫殿**
- **私人飞机前往普诺(Puno)**,入住**Titilaka Lodge**(全包式湖畔别墅)。
- **私人游艇游览**乌鲁斯浮岛(Uros Islands)。
- **星空晚宴**:湖边私人厨师定制菜单。
### **Day 9-10: 利马—— 完美收官**
- **返回利马**,入住**Country Club Lima Hotel**(高尔夫度假风)。
- **最后狂欢**:
- **私人购物导览**(秘鲁羊驼毛精品店)。
- **海滨直升机巡游**(俯瞰太平洋海岸线)。
- **专车送机**,结束尊贵之旅。
---
## **🏨 顶奢住宿推荐**
| 城市 | 酒店 | 特色 |
|---------------|-----------------------------------|-------------------------------|
| **利马** | Belmond Miraflores Park | 无边泳池+太平洋全景 |
| **库斯科** | Palacio del Inka | 殖民时期宫殿改建 |
| **马丘比丘** | Belmond Sanctuary Lodge | 遗址旁唯一五星级 |
| **的的喀喀湖**| Titilaka Lodge | 全包式私人湖畔别墅 |
---
## **🎁 独家增值服务**
- **24小时管家** & 英语/中文私人导游
- **行李无忧**:全程专人运送
- **摄影跟拍**:专业摄影师记录旅程
- **健康护航**:随行高原反应医护团队
---
## **🍾 不可错过的奢华体验**
1. **马丘比丘私人日出仪式**(萨满祈福)
2. **库斯科私人庄园晚宴**(印加后裔家族接待)
3. **亚马逊河私人游艇巡游**(需延长行程)
**💎 设计说明**
本行程通过航空接驳最大化节约时间,每个节点都注入秘鲁最顶级的资源。从世界级餐厅到只有2间客房的野奢营地,彻底重新定义奢华旅行。
**🛎️ 定制提醒**
所有服务均可按需调整,包括延长亚马逊行程、增加珠宝采购等个性化需求。我们的旅行设计师将为您1v1优化方案。
**🌺 您值得拥有最完美的秘鲁!**, # **秘鲁文化深度之旅规划书**
**目的地:秘鲁**
**旅行风格:历史文化沉浸式体验**
**时长:12天**
---
## **🌄 行程核心理念**
本行程专为文化爱好者设计,通过博物馆、考古遗址、传统工艺作坊和原住民社区互动,深入探索秘鲁5000年文明史。重点呈现:
✔ **前哥伦布时期文明**(莫切、纳斯卡、印加)
✔ **西班牙殖民艺术与建筑**
✔ **活态安第斯传统文化**
---
## **📜 文化行程路线**
### **Day 1-3: 利马——殖民瑰宝与秘鲁文明之源**
#### **文化焦点**:混血文化(Mestizo)的形成
- **国家博物馆(MALI)**:秘鲁艺术史通览
- **圣弗朗西斯科修道院**:地下墓穴与殖民时期宗教艺术
- **帕查卡马克遗址**:前印加神圣之城
- **巴兰科区(Barranco)**:街头艺术与文学咖啡馆
- **特别体验**:
- **秘鲁国菜烹饪课**(学习Ceviche和Pisco Sour制作)
- **私人收藏家宅邸参观**(预览未公开的莫切文物)
### **Day 4-6: 特鲁希略与昌昌古城——莫切文明深度行**
#### **文化焦点**:沙漠中的古老帝国
- **太阳神庙与月亮神庙**:莫切文明政治宗教中心
- **昌昌古城**:世界最大土坯城(UNESCO)
- **西潘王墓博物馆**:堪比图坦卡蒙的黄金宝藏
- **特别体验**:
- **传统芦苇船出海**(与当代渔民交流古老航海技术)
- **陶艺大师工作坊**(学习莫切浮雕陶器制作)
### **Day 7-9: 库斯科与圣谷——活着的印加帝国**
#### **文化焦点**:印加智慧与当代克丘亚文化
- **科里坎查(太阳神殿)**:印加建筑与殖民教堂的层叠
- **圣谷(Pisac+Chinchero)**:梯田系统与纺织合作社
- **奥扬泰坦博**:仍在使用的印加城市规划范本
- **特别体验**:
- **克丘亚家族共进午餐**(参与传统Pachamanca地灶烹饪)
- **安第斯星象解读**(原住民天文学家讲解)
### **Day 10-12: 普诺与的的喀喀湖——浮岛上的乌罗斯人**
#### **文化焦点**:高原湖泊文明
- **乌鲁斯浮岛**:用芦苇再造生活的智慧
- **塔基列岛**:世界非遗纺织社区
- **西卢斯塔尼墓塔**:前印加生死观实证
- **特别体验**:
- **夜宿浮岛民宿**(参与芦苇船制作)
- **Capachica半岛仪式**(与萨满共同祈福)
---
## **🏛️ 核心文化站点解析**
| **遗址/博物馆** | **文明归属** | **不可错过亮点** |
|-----------------------|--------------|-----------------------------------|
| 西潘王墓博物馆 | 莫切文明 | 孔雀羽头饰/黄金葬礼面具 |
| 奥扬泰坦bo水利系统 | 印加文明 | 至今运作的灌溉网络 |
| 塔基列纺织合作社 | 当代克丘亚 | 用编织记录历史的密码文字 |
---
## **🎭 文化互动日历**
▸ **Day3傍晚**:利马传统Marinera舞蹈私教课
▸ **Day6全天**:参与北海岸圣佩德罗仙人掌仪式(需提前精神准备)
▸ **Day9夜间**:库斯科Q'eswachaka节(如逢6月,见证草绳桥重建仪式)
---
## **🛌 文化住宿推荐**
- **利马**:Casa Republica(19世纪共和时期豪宅改建)
- **特鲁希略**:Hotel Libertador(殖民时期总督府旧址)
- **库斯科**:El Mercado Tunqui(前印加市场改造的设计酒店)
---
## **📚 行前文化准备建议**
1. **阅读清单**:
- 《印加帝国的末日》(Kim MacQuarrie)
- 《莫切文明的暴力与仪式》(Steve Bourget)
2. **影视推荐**:纪录片《秘鲁:隐藏的王国》(BBC)
3. **语言基础**:学习10个克丘亚语问候语
---
## **💡 专业贴士**
- **摄影伦理**:拍摄原住民前务必征得同意(建议携带宝丽来即时赠送)
- **纪念品采购**:库斯科Centro Qosqo认证的公平贸易商店
- **学术支持**:可预约随行考古学家(需提前2月预定)
**🌾 这不仅仅是一次旅行,而是一场文明对话。**
从沙漠金字塔到漂浮岛屿,让我们沿着时间的纤维,触摸秘鲁文明的温度。, # **秘鲁家庭欢乐之旅规划书**
**目的地:秘鲁**
**旅行风格:亲子友好 & 家庭休闲**
**时长:10天**
---
## **👨👩👧👦 行程特色**
✔ **轻松节奏**:每天1个主要景点+充足休息时间
✔ **趣味学习**:互动式文化体验激发孩子好奇心
✔ **安全舒适**:家庭房住宿+专业儿童餐食
✔ **交通优化**:包车服务+短途内陆航班
---
## **📅 亲子行程安排**
### **Day 1-2: 利马(Lima)—— 海滨初体验**
- **住宿**:**Sheraton Lima Hotel & Convention Center**(家庭连通房,儿童泳池)
- **活动**:
- **魔法水公园(Parque de la Reserva)**:世界最大喷泉综合体夜间灯光秀
- **拉尔科博物馆(Larco Museum)**:儿童专用讲解器+巧克力制作工坊
- **米拉弗洛雷斯海滨步道**:骑四轮协力车+品尝儿童友好版酸橘汁腌鱼
### **Day 3-4: 帕拉卡斯(Paracas)—— 海洋奇遇**
- **交通**:私人包车(3小时,配备儿童安全座椅)
- **住宿**:**DoubleTree Resort by Hilton Paracas**(私人沙滩+儿童俱乐部)
- **活动**:
- **鸟岛游船(Ballestas Islands)**:看海狮/企鹅/海鸟(提供儿童望远镜)
- **沙漠越野车**:小型沙丘滑沙体验(5岁以上可参与)
- **生态手工课**:用贝壳制作纪念品
### **Day 5-7: 库斯科(Cuzco)—— 印加探险**
- **交通**:1小时航班(选择上午班次减少疲劳)
- **住宿**:**Novotel Cusco**(高原供氧系统+家庭游戏室)
- **活动**:
- **圣谷小火车**:乘坐全景列车前往皮萨克(Pisac)集市
- **羊驼农场**:喂食+学习传统纺织(提供儿童尺寸纺织工具)
- **巧克力博物馆**:从可可豆到成品的互动体验(可制作专属巧克力)
- **儿童版印加古道**:2小时轻松徒步到Moray圆形梯田
### **Day 8-9: 马丘比丘(Machu Picchu)—— 奇幻之旅**
- **交通**:Vistadome景观列车(车厢魔术表演+儿童餐)
- **住宿**:**Tierra Viva Machu Picchu**(家庭套房+热水浴缸)
- **活动**:
- **马丘比丘寻宝游戏**:定制儿童探险手册完成打卡任务
- **温泉镇手工坊**:用天然粘土制作迷你印加文物
- **夜间故事会**:酒店安排克丘亚语童话讲述
### **Day 10: 返回利马 —— 回忆封存**
- **活动**:
- **家庭旅行相册DIY**:市区专业工作室1小时快制
- **拉尔科博物馆儿童证书**:完成所有文化挑战可获得
---
## **🍽️ 亲子餐饮推荐**
| **城市** | **餐厅** | **特色** |
|------------|---------------------------|-------------------------------|
| 利马 | Panchita | 儿童餐含可食用乐高积木 |
| 库斯科 | Papacho's | 印加主题汉堡+自制柠檬水站 |
| 马丘比丘 | Tree House | 树屋座位+动物造型甜点 |
---
## **🎒 行前准备清单**
- **健康**:
- 儿科医生开具高原反应预防建议(库斯科海拔3400米)
- 准备儿童常用药+便携式血氧仪
- **装备**:
- 可折叠婴儿车(鹅卵石路面适用)
- 亲子装(当地节日拍照更出片)
- **教育**:
- 下载《印加文明儿童绘本》电子版
- 准备空白旅行日记本收集印章
---
## **🌟 特别关怀服务**
- **机场快速通道**:利马/库斯科机场VIP通关(避免排队)
- **灵活调整权**:每天可免费取消1项活动(根据孩子状态)
- **应急支持**:24小时中文保姆服务(需提前48小时预约)
---
## **📌 家长须知**
1. **最佳季节**:5-9月(旱季,适合户外活动)
2. **年龄建议**:5岁以上儿童体验更完整
3. **文化礼仪**:提前教孩子用克丘亚语说"谢谢"(Sulpayki)
**🦙 让羊驼见证家庭的成长之旅!**
从海岸到高山,从古代文明到自然奇观,这将是孩子们终身难忘的南美课堂。]
评估者-优化器(Evaluator-Optimizer)
在评估者-优化器工作流中,一个 LLM 调用生成响应,而另一个调用在循环中提供评估和反馈
该模式对于翻译、代码生成等场景十分适用
示例: 通过评估优化实现一个 Java 队列代码生成
下面定义 Generator 和 的 Evaluator 的 Prompt,添加到常量类中
public static final String GENERATOR_PROMPT =
"""
你的目标是根据输入完成任务。如果存在之前生成的反馈,
你应该反思这些反馈以改进你的解决方案。
关键要求:响应必须是单行有效的JSON,除明确使用\\n转义外,不得包含换行符。
以下是必须严格遵守的格式(包括所有引号和花括号):
{"thoughts":"此处填写简要说明","code":"public class Example {\\n // 代码写在这里\\n}"}
响应字段的规则:
1. 所有换行必须使用\\n
2. 所有引号必须使用\\"
3. 所有反斜杠必须双写:\\
4. 不允许实际换行或格式化 - 所有内容必须在一行
5. 不允许制表符或特殊字符
6. Java代码必须完整且正确转义
正确格式的响应示例:
{"thoughts":"实现计数器","code":"public class Counter {\\n private int count;\\n public Counter() {\\n count = 0;\\n }\\n public void increment() {\\n count++;\\n }\\n}"}
必须严格遵循此格式 - 你的响应必须是单行有效的JSON。
""";
public static final String EVALUATOR_PROMPT =
"""
评估这段代码实现的正确性、时间复杂度和最佳实践。
确保代码有完整的javadoc文档。
用单行JSON格式精确响应:
{"evaluation":"PASS,NEEDS_IMPROVEMENT,FAIL", "feedback":"你的反馈意见"}
evaluation字段必须是以下之一: "PASS", "NEEDS_IMPROVEMENT", "FAIL"
仅当所有标准都满足且无需改进时才使用"PASS"。
""";
在接口中定义 startEvaluatorOptimizerWorkflow 方法
String startEvaluatorOptimizerWorkflow(String userInput);
在实现类中,定义 loop 方法,在该方法通过 generate 生成代码,evaluate 评估生成的代码,若评估不通过,则进行优化(再次执行loop)
@Override
public String startEvaluatorOptimizerWorkflow(String userInput) {
List<String> memory = new ArrayList<>();
List<GenerationResponse> chainOfThought = new ArrayList<>();
return loop(userInput, "", memory, chainOfThought).toString();
}
public RefinedResponse loop(String userInput, String context, List<String> memory, List<GenerationResponse> chainOfThought) {
// Generator生成代码
GenerationResponse generationResponse = generate(userInput, context);
chainOfThought.add(generationResponse);
memory.add(generationResponse.getCode());
// Evaluator评估代码
EvaluationResponse evaluationResponse = evaluate(userInput, generationResponse.getCode());
// 若评估通过,则返回RefinedResponse
if (Evaluation.PASS.equals(evaluationResponse.getEvaluation())){
return new RefinedResponse(generationResponse.getCode(), chainOfThought);
}
// 若评估不通过,则进行优化(再次执行loop)
StringBuilder newContext = new StringBuilder();
newContext.append("之前的尝试:");
for (String m : memory) {
newContext.append("\n- ").append(m);
}
newContext.append("\nFeedback: ").append(evaluationResponse.getFeedback());
return loop(userInput, newContext.toString(), memory, chainOfThought);
}
public GenerationResponse generate(String userInput, String context) {
GenerationResponse generationResponse = openAiChatClient.prompt()
.user(u -> u.text("{prompt}\n{context}\nTask: {task}")
.param("prompt", PromptConstant.GENERATOR_PROMPT)
.param("context", context)
.param("task", userInput))
.call()
.entity(GenerationResponse.class);
System.out.println(String.format("\n=== 输出 ===\n思考: %s\n\n代码:\n %s\n",
generationResponse.getThoughts(), generationResponse.getCode()));
return generationResponse;
}
public EvaluationResponse evaluate(String userInput, String code) {
EvaluationResponse evaluationResponse = openAiChatClient.prompt()
.user(u -> u.text("{prompt}\nOriginal task: {task}\nContent to evaluate: {content}")
.param("prompt", PromptConstant.EVALUATOR_PROMPT)
.param("task", userInput)
.param("content",code))
.call()
.entity(EvaluationResponse.class);
System.out.println(String.format("\n=== 评价输出 ===\n评价: %s\n\n反馈: %s\n",
evaluationResponse.getEvaluation(), evaluationResponse.getFeedback()));
return evaluationResponse;
}
测试
@GetMapping("/agent/generator_evaluator")
public String generatorEvaluator() {
String userInput =
"""
实现一个具有以下功能的Java队列:
1. enqueue(x) - 将元素x添加到队列尾部
2. dequeue() - 移除并返回队列头部元素
3. getMin() - 获取队列中的最小值
所有操作的时间复杂度应为O(1)。
所有内部字段必须声明为private,使用时需加"this."前缀。
""";
return workflowService.startEvaluatorOptimizerWorkflow(userInput);
}
=== 输出 ===
思考: 使用双端队列维护最小值实现O(1)操作
代码:
public class MinQueue {
private java.util.Queue<Integer> mainQueue;
private java.util.Deque<Integer> minDeque;
public MinQueue() {
this.mainQueue = new java.util.LinkedList<>();
this.minDeque = new java.util.LinkedList<>();
}
public void enqueue(int x) {
this.mainQueue.add(x);
while (!this.minDeque.isEmpty() && this.minDeque.getLast() > x) {
this.minDeque.removeLast();
}
this.minDeque.addLast(x);
}
public int dequeue() {
if (this.mainQueue.isEmpty()) throw new RuntimeException("Queue is empty");
int val = this.mainQueue.remove();
if (val == this.minDeque.getFirst()) {
this.minDeque.removeFirst();
}
return val;
}
public int getMin() {
if (this.minDeque.isEmpty()) throw new RuntimeException("Queue is empty");
return this.minDeque.getFirst();
}
}
=== 评价输出 ===
评价: NEEDS_IMPROVEMENT
反馈: 实现正确且满足时间复杂度要求,但缺少Javadoc文档和更具体的异常处理。建议添加完整的类和方法文档,并使用更具体的异常类型如NoSuchElementException。
=== 输出 ===
思考: 改进后的实现添加了Javadoc文档和使用NoSuchElementException
代码:
public class MinQueue {
private java.util.Queue<Integer> mainQueue;
private java.util.Deque<Integer> minDeque;
/**
* 构造一个新的MinQueue
*/
public MinQueue() {
this.mainQueue = new java.util.LinkedList<>();
this.minDeque = new java.util.LinkedList<>();
}
/**
* 将元素添加到队列尾部
* @param x 要添加的元素
*/
public void enqueue(int x) {
this.mainQueue.add(x);
while (!this.minDeque.isEmpty() && this.minDeque.getLast() > x) {
this.minDeque.removeLast();
}
this.minDeque.addLast(x);
}
/**
* 移除并返回队列头部元素
* @return 队列头部元素
* @throws java.util.NoSuchElementException 如果队列为空
*/
public int dequeue() {
if (this.mainQueue.isEmpty()) throw new java.util.NoSuchElementException("Queue is empty");
int val = this.mainQueue.remove();
if (val == this.minDeque.getFirst()) {
this.minDeque.removeFirst();
}
return val;
}
/**
* 获取队列中的最小值
* @return 队列中的最小值
* @throws java.util.NoSuchElementException 如果队列为空
*/
public int getMin() {
if (this.minDeque.isEmpty()) throw new java.util.NoSuchElementException("Queue is empty");
return this.minDeque.getFirst();
}
}
=== 评价输出 ===
评价: PASS
反馈: 实现完全符合要求,包括正确的时间复杂度、私有字段访问、完善的Javadoc文档和适当的异常处理。
Autonomous Agent(Agent)
TODO