介绍

在这里插入图片描述

Spring AI 是一个面向人工智能工程的应用框架。解决了 AI 集成的基本挑战:将企业数据和API与AI 模型连接起来。

特性:

提示词工厂

可以说是大模型应用中最简单也是最核心的一个技术。他是我们更大模型交互的媒介,提示词给的好大模型才能按你想要的方式响应。

对话拦截advisors

在这里插入图片描述

面向切面的思想对对模型对话和响应进行增强。

对话记忆
@Autowired
ChatMemoryRepository chatMemoryRepository;

通过一个bean组件就可以让大模型拥有对话记忆功能,可谓是做到了开箱即用

tools

在这里插入图片描述

让大模型可以跟企业业务API进行互联 ,这一块实现起来也是非常的优雅

class DateTimeTools {

    @Tool(description = "Get the current date and time in the user's timezone")
    String getCurrentDateTime() {
        return LocalDateTime.now().atZone(LocaleContextHolder.getTimeZone().toZoneId()).toString();
    }

}
RAG技术下的 ETL

在这里插入图片描述

让大模型可以跟企业业务数据进行互联(包括读取文件、分隔文件、向量化) 向量数据库支持 目前支持20+种向量数据库的集成

MCP

让tools外部化,形成公共工具让外部开箱即用。 原来MCP协议的JAVA SDK就是spring ai团队提供的 提供了MCP 客户端、服务端、以及MCP认证授权方案 ,还有目前正在孵化的Spring MCP Agent 开源项目:

模型的评估

可以测试大模型的幻觉反应
可观察性
它把AI运行时的大量关键指标暴露出来, 可以提供Spring Boot actuctor进行观测

agent应用

springai 提供了5种agent模式的示例

  1. Evaluator Optimizer – The model analyzes its own responses and refines them through a structured process of self-evaluation.
    在这里插入图片描述

  2. Routing – This pattern enables intelligent routing of inputs to specialized handlers based on classification of the user request and context.

  3. Orchestrator Workers – This pattern is a flexible approach for handling complex tasks that require dynamic task decomposition and specialized processing

  4. Chaining – The pattern decomposes complex tasks into a sequence of steps, where each LLM call processes the output of the previous one.

  5. Parallelization – The pattern is useful for scenarios requiring parallel execution of LLM calls with automated output aggregation.

langchain4j vs springAI

在这里插入图片描述

大模型选型

  1. 自研(算法 c++ python 深度学习 机器学习 神经网络 视觉处理 952 211研究生 )AI算法岗位
  2. 云端大模型 占用算力 token计费 功能完善成熟
  3. 开源的大模型(本地部署)Ollama 购买算力
    a. 选型
    b. 自己构建选型–>评估流程
    ⅰ. 业务确定:( 电商、医疗、教育 )
    ⅱ. 样本准备:数据集样本 选择题
    ⅲ. 任务定制:问答 (利用多个大模型)
    ⅳ. 评估: 人工评估
    c. 通用能力毕竟好的
    ⅰ. 2月份 deepseek 6710亿 671b = 算力 显存 H20 96G 140万 ; 比 openai gpt4节省了40/1 成本。
    ⅱ. 3月份 阿里 qwq-32b(不带深度思考) 32b=320亿 媲美deepseek-r1 32G 比deepseek-r1节省20/1
    ⅲ. 4月份 阿里 qwen3 (深度思考) 2350亿=235b 赶超了deepseek-r1 比deepseek-r1节省2-3倍 选择(qwen3-30b)
    ⅳ. 5月 deepseek-r1-0528 6710亿 671b 性能都要要
  4. 对成本有要求: 选择(qwen3-30b)
  5. 不差钱 deepseek-r1-0528 满血版本

快速使用

  1. 创建项目
    在这里插入图片描述
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.xs</groupId>
    <artifactId>spring-ai-GA</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-ai-GA</name>
    <description>公众号:程序员徐庶</description>
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0</spring-ai.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency> 

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

接入deepseek

1.依赖


        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-deepseek</artifactId>
        </dependency>
  1. 获取deepseek api-key
    ● API Key:需从 DeepSeek 创建并获取 API 密钥:https://platform.deepseek.com/api_keys
    在这里插入图片描述

在这里插入图片描述
3.配置

spring:
  ai:
    deepseek:
      api-key: ${DEEP_SEEK_KEY}
      chat:
        options:
          model: deepseek-chat

4.4. 测试
spring-ai-starter-model-deepseek 会为你增加自动配置类, 其中DeepSeekChatModel这个就是专门负责智能对话的。

package com.xs.springaiga;

import org.junit.jupiter.api.Test;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class DeepseelTest {


    @Test
    public void testChat(@Autowired
                         DeepSeekChatModel chatModel) {
        String call = chatModel.call("你是谁");
        System.out.println(call);
    }
}

在这里插入图片描述

流式对话
 @Test
    public void testChatStream (@Autowired DeepSeekChatModel chatModel){
        Flux<String> stream = chatModel.stream("你是谁");
        //阻塞输出
        stream.toIterable().forEach(System.out::println);
    }
options配置选项
temperature(温度)

0-2 浮点数值
数值越高 更有创造性 热情
数值越低 保守

@Test
public void testChatOptions(@Autowired
                            DeepSeekChatModel chatModel) {
    DeepSeekChatOptions options = DeepSeekChatOptions.builder().temperature(1.9d).build();
    ChatResponse res = chatModel.call(new Prompt("请写一句诗描述清晨。", options));
    System.out.println(res.getResult().getOutput().getText());
}

也可以通过配置文件配置

spring.ai.deepseek.chat.options.temperature=0.8

temperature:0.2 规规矩矩,像是被应试教育出来的老实学生没有创造力

在这里插入图片描述

temperature:1.9 可以看出来表现欲更强, 像是一个在领导面前想要表现的你。
在这里插入图片描述
也可以通过提示词降低他的主观臆想:

● 只引用可靠来源中的信息,不做任何假设或扩展描述。
● 请只基于已知事实回答,不要主观臆想或添加额外内容。
● 请简明、客观地给出答案,不要进行修饰或补充未经请求的信息。

建议

temperature范围 建议业务场景 输出风格 说明/应用举例
0.0 ~ 0.2 严谨问答、代码补全、数学答题 严格、确定、标准 法律/金融答题、接口返回模板、考试答卷等
0.3 ~ 0.6 聊天机器人、日常摘要、辅助写作 稍有变化、较稳妥 公众号摘要、普通对话、邮件生成等
0.7 ~ 1.0 创作内容、广告文案、标题生成 丰富、有创意、灵活 诗歌、短文案、趣味对话、产品描述等
1.1 ~ 1.5 脑洞风格、头脑风暴、灵感碰撞场景 大开脑洞、变化极强 故事创作、异想天开的推荐语、多样化内容

说明
● 温度越低,输出越收敛和中规中矩;
● 温度越高,输出越多变、富有惊喜但有风险;
● 实战用法一般建议选 0.5~0.8 作为日常生产起点,需要根据业务不断测试调整。

maxTokens

默认低 token
maxTokens:限制AI模型生成的最大token数(近似理解为字数上限)。

  • 需要简洁回复、打分、列表、短摘要等,建议小值(如10~50)。
  • 防止用户跑长对话导致无关内容或花费过多token费用。
  • 如果遇到生成内容经常被截断,可以适当配置更大maxTokens。
stop

截断你不想输出的内容 比如:

spring:
  ai:
    deepseek:
      api-key: ${DEEP_SEEK_KEY}
      chat:
        options:
          model: deepseek-chat
          max-tokens: 20
          stop:
              - "\n"    #只想一行
              - "。"    #只想一句话
              - "政治"  #敏感词
              - "最后最总结一下"  #这种AI惯用的模板词, 减少AI词汇, 让文章更拟人
模型推理

设置深度思考, 思考的内容有个专业名词叫:Chain of Thought (CoT)
在这里插入图片描述
在deepseek中, deepseek-reasoner模型是深度思考模型:

  • deepseek-chat 模型已全面升级为DeepSeek-V3,接口不变。通过指定model-'deepseek-chat'即可调用DeepSeek-V3。
  • deepseek-reasonerDeepSeek最新推出的推理模型DeepSeek-R1。通过指定model='deepseek-reasoner',即可调用DeepSeek-R1。
@Test
    public void deepSeekReasonerExample(@Autowired DeepSeekChatModel deepSeekChatModel) {
        DeepSeekChatOptions options = DeepSeekChatOptions.builder()
                .model("deepseek-reasoner").build();


        Prompt prompt = new Prompt("请写一句诗描述清晨。", options);
        ChatResponse res = deepSeekChatModel.call(prompt);

        DeepSeekAssistantMessage assistantMessage =  (DeepSeekAssistantMessage)res.getResult().getOutput();

        String reasoningContent = assistantMessage.getReasoningContent();
        String content = assistantMessage.getText();

        System.out.println(reasoningContent);
        System.out.println("--------------------------------------------");
        System.out.println(content);


    }


    @Test
    public void deepSeekReasonerStreamExample(@Autowired DeepSeekChatModel deepSeekChatModel) {
        DeepSeekChatOptions options = DeepSeekChatOptions.builder()
                .model("deepseek-reasoner").build();


        Prompt prompt = new Prompt("请写一句诗描述清晨。", options);
        Flux<ChatResponse> stream = deepSeekChatModel.stream(prompt);

        stream.toIterable().forEach(res -> {
            DeepSeekAssistantMessage assistantMessage =  (DeepSeekAssistantMessage)res.getResult().getOutput();
            String reasoningContent = assistantMessage.getReasoningContent();
            System.out.print(reasoningContent);
        });
        System.out.println("--------------------------------------------");
        stream.toIterable().forEach(res -> {
            DeepSeekAssistantMessage assistantMessage =  (DeepSeekAssistantMessage)res.getResult().getOutput();
            String content = assistantMessage.getText();
            System.out.print(content);
        });

    }

也可以在配置文件中配置

spring.ai.deepseek.chat.options.model= deepseek-reasoner

原理:
在这里插入图片描述
1.当调用cahtModel.call

default String call(String message) {
    Prompt prompt = new Prompt(new UserMessage(message));
    Generation generation = call(prompt).getResult();
    return (generation != null) ? generation.getOutput().getText() : "";
}

a. 首先会将提示词解析到Prompt对象中(用于远程请求的message)

在这里插入图片描述
2.调用deepseekModel#call—> internalCall方法

public ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) {

    // a
    ChatCompletionRequest request = createRequest(prompt, false);

    //..省略   
    ResponseEntity<ChatCompletion> completionEntity = this.retryTemplate
    // b
    .execute(ctx -> this.deepSeekApi.chatCompletionEntity(request));

    var chatCompletion = completionEntity.getBody();
    //..省略
    ChatResponse chatResponse = new ChatResponse(generations,
                                                 from(completionEntity.getBody(), accumulatedUsage));

    observationContext.setResponse(chatResponse);

    return chatResponse;
    //.. 省略
    return response;
}

a.通过createRequest封装为远程请求所需的json对象
b.通过spring retry重试机制去远程请求

deepseekthis.deepSeekApi.chatCompletionEntity(request)

// 通过restClient 进行远程请求
public ResponseEntity<ChatCompletion> chatCompletionEntity(ChatCompletionRequest chatRequest) {
 
		return this.restClient.post()
			.uri(this.getEndpoint(chatRequest))
			.body(chatRequest)
			.retrieve()
			.toEntity(ChatCompletion.class);
	}

c.封装响应数据

接入阿里云百炼

在这里插入图片描述
阿里自己的团队维护spring-ai-alibaba. 但是也必须依赖spring-ai 。 好处是扩展度更高,坏处是必须是springai先出来, spring-ai-alibaba.延迟几天出来。
如果需要接入阿里的百炼平台, 就必须用该组件

使用
1.申请api-key
在调用前,需要开通模型服务并获取APIKey,再配置APIKey到环境变量。
2. 依赖

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>
      <artifactId>spring-ai-alibaba-bom</artifactId>
      <version>1.0.0.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
  </dependency>
</dependencies>

2.配置

spring:
  ai:
    dashscope:
      api-key: ${AI_DASHSCOPE_API_KEY}

3.使用

@Test
    public void testQwen(@Autowired DashScopeChatModel dashScopeChatModel) {

        String content = dashScopeChatModel.call("你好你是谁");
        System.out.println(content);
    }

文生图

 @Test
    public void text2Img(
           @Autowired DashScopeImageModel imageModel) {
        DashScopeImageOptions imageOptions = DashScopeImageOptions.builder()
                .withModel("wanx2.1-t2i-turbo").build();

        ImageResponse imageResponse = imageModel.call(
                new ImagePrompt("程序员徐庶", imageOptions));
        String imageUrl = imageResponse.getResult().getOutput().getUrl();

        // 图片url
        System.out.println(imageUrl);

        // 图片base64
        // imageResponse.getResult().getOutput().getB64Json();

        /*
        按文件流相应
        InputStream in = url.openStream();

        response.setHeader("Content-Type", MediaType.IMAGE_PNG_VALUE);
        response.getOutputStream().write(in.readAllBytes());
        response.getOutputStream().flush();*/
    }

文生语音text2audio

 // https://bailian.console.aliyun.com/?spm=5176.29619931.J__Z58Z6CX7MY__Ll8p1ZOR.1.74cd59fcXOTaDL&tab=doc#/doc/?type=model&url=https%3A%2F%2Fhelp.aliyun.com%2Fdocument_detail%2F2842586.html&renderType=iframe
    @Test
    public void testText2Audio(@Autowired DashScopeSpeechSynthesisModel speechSynthesisModel) throws IOException {
        DashScopeSpeechSynthesisOptions options = DashScopeSpeechSynthesisOptions.builder()
                //.voice()   // 人声
                //.speed()    // 语速
                //.model()    // 模型
                //.responseFormat(DashScopeSpeechSynthesisApi.ResponseFormat.MP3)
                .build();

        SpeechSynthesisResponse response = speechSynthesisModel.call(
                new SpeechSynthesisPrompt("大家好, 我是人帅活好的徐庶。",options)
        );

        File file = new File( System.getProperty("user.dir") + "/output.mp3");
        try (FileOutputStream fos = new FileOutputStream(file)) {
            ByteBuffer byteBuffer = response.getResult().getOutput().getAudio();
            fos.write(byteBuffer.array());
        }
        catch (IOException e) {
            throw new IOException(e.getMessage());
        }
    }

语音翻译audio2text

   private static final String AUDIO_RESOURCES_URL = "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav";


@Test
    public void testAudio2Text(
            @Autowired
            DashScopeAudioTranscriptionModel transcriptionModel
    ) throws MalformedURLException {
        DashScopeAudioTranscriptionOptions transcriptionOptions = DashScopeAudioTranscriptionOptions.builder()
                //.withModel()   模型
                .build();
        AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(
                new UrlResource(AUDIO_RESOURCES_URL),
                transcriptionOptions
        );
        AudioTranscriptionResponse response = transcriptionModel.call(
                prompt
        );

        System.out.println(response.getResult().getOutput());

    }

多模态
图片 语音 视频 传给大模型 理解

@Test
    public void testMultimodal(@Autowired DashScopeChatModel dashScopeChatModel
    ) throws MalformedURLException {
        // flac、mp3、mp4、mpeg、mpga、m4a、ogg、wav 或 webm。
        var audioFile = new ClassPathResource("/files/xushu.png");

        Media media = new Media(MimeTypeUtils.IMAGE_JPEG, audioFile);
        DashScopeChatOptions options = DashScopeChatOptions.builder()
                .withMultiModel(true)
                .withModel("qwen-vl-max-latest").build();

        Prompt  prompt= Prompt.builder().chatOptions(options)
                .messages(UserMessage.builder().media(media)
                        .text("识别图片").build())
                .build();
        ChatResponse response = dashScopeChatModel.call(prompt);

        System.out.println(response.getResult().getOutput().getText());
    }

文生视频(更多功能)

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>dashscope-sdk-java</artifactId>
    <!-- 请将 'the-latest-version' 替换为最新版本号:https://mvnrepository.com/artifact/com.alibaba/dashscope-sdk-java -->
    <version>the-latest-version</version>
</dependency>
@Test
    public void text2Video() throws ApiException, NoApiKeyException, InputRequiredException {
        VideoSynthesis vs = new VideoSynthesis();
        VideoSynthesisParam param =
                VideoSynthesisParam.builder()
                        .model("wanx2.1-t2v-turbo")
                        .prompt("一只小猫在月光下奔跑")
                        .size("1280*720")
                        .apiKey(System.getenv("ALI_AI_KEY"))
                        .build();
        System.out.println("please wait...");
        VideoSynthesisResult result = vs.call(param);
        System.out.println(result.getOutput().getVideoUrl());
    }

接入ollama本地模型

ollama是大语言模型的运行环境,支持将开源的大语言模型以离线的方式部署到本地,进行私有化部署。这也是企业中常用的方案,因为本地化部署能保证企业级的数据安全,降低企业使用成本。

1.1.本地大模型安装
  1. https://ollama.com/download
    2.点击下载,一直下一步即可非常简单
    在这里插入图片描述

3.安装完后运行cmd->ollamalist查看已安装的大模型(开始肯定什么都没有)
4.拉取模型 ollama run qwen3:4b

a.这里的4b=40亿参数对应gpu显存差不多是4G,当然8B也可以只是比较卡

5.测试
在这里插入图片描述

1.2.基于spring-ai使用

1.添加依赖

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

2.配置

spring.ai.ollama.base-url= http://localhost:11434
spring.ai.ollama.chat.model= qwen3:4b

3.测试

@SpringBootTest
public class OllamaTest {

    @Test
    public void testChat(@Autowired OllamaChatModel ollamaChatModel) {

        String text = ollamaChatModel.call("你是谁");
        System.out.println(text);
    }
}

在这里插入图片描述

1.3.关闭thingking

在这里插入图片描述
可以通过在提示词结尾加入“/no_think”指令

String text = ollamaChatModel.call("你是谁/no_think");
        System.out.println(text);

但是依然有标签, 暂时可以前端单独处理下
在这里插入图片描述

ollama 0.9.0 支持了关闭think。但是在spring1.0版本还不兼容
https://ollama.com/blog/thinking

1.4. 流式输出

在这里插入图片描述

 @Test
    public void testStream(@Autowired OllamaChatModel chatModel) {

        Flux<String> stream = chatModel.stream("你是谁/no_think");
        // 阻塞输出
        stream.toIterable().forEach(System.out::println);
    }

ollama 0.8.0之前的版本不支持 stream+tools

https://ollama.com/blog/streaming-tool 0.8.0+支持stream+tools . 但是和springai1.0有兼容问题:https://github.com/spring-projects/spring-ai/issues/3369

在SpringAi 1.0.1已修复:
·在Ollama聊天模型响应中添加了持续时间元数据的空安全检查,以防止潜在的空指针异常1eecd17

1.5. 多模态

在这里插入图片描述
目前ollama支持的多模态模型:
Meta Llama 4

  • Google Gemma 3
  • Qwen 2.5 VL
  • Mistral Small 3.1
  • and more vision models.
/**
 * 多模态  图像识别,  采用的gemma3 
 * @param ollamaChatModel
 */
 @Test
    public void testMultimodality(@Autowired OllamaChatModel ollamaChatModel) {
        var imageResource = new ClassPathResource("gradle.png");

        OllamaOptions ollamaOptions = OllamaOptions.builder()
                .model("gemma3")
                .build();

        Media media = new Media(MimeTypeUtils.IMAGE_PNG, imageResource);


        ChatResponse response = ollamaChatModel.call(
                new Prompt(
                        UserMessage.builder().media(media)
                                .text("识别图片").build(),
                        ollamaOptions
                )
        );

        System.out.println(response.getResult().getOutput().getText());
    }

在这里插入图片描述

ChatClient

ChatClient 基于ChatModel进行了封装提供了通用的 API,它适用所有的大模型, 使用ChatClient可以让你面向SpringAi通用的api 而无需面向为每一种不同的模型的api来进行编程, 虽然您仍然可以使用 ChatModel 来实现某些模型更加个性化的操作(ChatModel更偏向于底层),但 ChatClient 提供了灵活、更全面的方法来构建您的客户端选项以与模型进行交互: 比如系统提示词、格式式化响应、聊天记忆 、tools 都更加易用和优雅,所以除非ChatClient无法实现,否则我们优先考虑用ChatClient。

基本使用
  • 必须通过ChatClient.Builder 来进行构造
 @SpringBootTest
public class ChatClientTest {
    @Test
    public void testChatClient(ChatClient.Builder builder) {

        ChatClient chatClient =builder.build();
        String content = chatClient.prompt()
                .user("Hello")
                .call()
                .content();
        System.out.println(content);
    }
}

这种方式会在底层自动注入1个ChatModel , 如果你配置了多个模型依赖, 会无法注入。

可以通过这种方式动态选择ChatModel:

@SpringBootTest
public class ChatClientTest {

    @Test
    public void testChatOptions(@Autowired
                                    DeepSeekChatModel chatModel) {

        ChatClient chatClient = ChatClient.builder(chatModel).build();
        String content = chatClient.prompt()
                .user("Hello")
                .call()
                .content();
        System.out.println(content);
    }
}

流式
@Test
    public void testChatStream() {
        Flux<String> content = chatClient.prompt()
                .user("Hello")
                .stream()
                .content();

        // 阻塞输出
        content.toIterable().forEach(System.out::println);
    }

《多个模型动态切管理实战》

1)application.properties

# DeepSeek 配置
spring.ai.deepseek.chat.api-key=你的APIKey
spring.ai.deepseek.chat.options.model=deepseek-chat

# Ollama 配置,模型暂定qwen3:4b已拉取到本地
spring.ai.ollama.chat.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=qwen3:4b
<!-- DeepSeek -->
 <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<!-- Ollama -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

定义3个ChatClient的bean。 也可以根据请求动态创建, 看需求

@Configuration
public class AiConfig {

    @Bean
    public ChatClient deepseekR1(DeepSeekChatProperties chatProperties) {

        DeepSeekApi deepSeekApi = DeepSeekApi.builder()
                .apiKey(System.getenv("DEEP_SEEK_KEY"))
                .build();


        DeepSeekChatModel deepSeekChatModel = DeepSeekChatModel.builder()
                .deepSeekApi(deepSeekApi)
                .defaultOptions(DeepSeekChatOptions.builder().model(DeepSeekApi.ChatModel.DEEPSEEK_REASONER).build())
                .build();

        return ChatClient.builder(deepSeekChatModel).build();
    }

    @Bean
    public ChatClient deepseekV3() {

        DeepSeekApi deepSeekApi = DeepSeekApi.builder()
                .apiKey(System.getenv("DEEP_SEEK_KEY"))
                .build();


        DeepSeekChatModel deepSeekChatModel = DeepSeekChatModel.builder()
                .deepSeekApi(deepSeekApi)
                .defaultOptions(
                        DeepSeekChatOptions.builder()
                                .model(DeepSeekApi.ChatModel.DEEPSEEK_CHAT)
                                .build()
                )
                .build();

        return ChatClient.builder(deepSeekChatModel).build();
    }

    @Bean
    public ChatClient ollama(@Autowired OllamaApi ollamaApi, @Autowired OllamaChatProperties options) {
        OllamaChatModel ollamaChatModel = OllamaChatModel.builder()
                .ollamaApi(ollamaApi)
                .defaultOptions(OllamaOptions.builder().model(options.getModel()).build())
                .build();

        return ChatClient.builder(ollamaChatModel).build();
    }
}

请求:

@RestController
public class MultiModelsController {

    @Autowired
    private Map<String, ChatClient> chatClientMap;

    @GetMapping("/chat")
    String generation(@RequestParam String message,
                      @RequestParam String model) {
        ChatClient chatClient = chatClientMap.get(model);
        String content = chatClient.prompt().user(message).call().content();
        return content;
    }
}

提示词

在生成式人工智能中,创建提示对于开发人员来说是一项至关重要的任务。这些提示的质量和结构会显著影响人工智能输出的有效性。投入时间和精力设计周到的提示可以显著提升人工智能的成果。
例如,一项重要的研究表明,以“深呼吸,一步一步解决这个问题”作为提示开头,可以显著提高解决问题的效率。这凸显了精心选择的语言对生成式人工智能系统性能的影响。

提示词类型:

public enum MessageType {

	USER("user"),		// 用户(显示)

	ASSISTANT("assistant"),  // AI回复

	SYSTEM("system"),      // 系统 (隐式)

	TOOL("tool");    // 工具

    ...
}

SYSTEM系统角色:引导AI的行为和响应方式,设置AI如何解释和回复输入的参数或规则。这类似于在发起对话之前向AI提供指令。
USER用户角色:代表用户的输入——他们向AI提出的问题、命令或语句。这个角色至关重要,因为它构成了AI响应的基础。
ASSISTANT助手角色:AI 对用户输入的响应。它不仅仅是一个答案或反应,对于维持对话的流畅性至关重要。通过追踪 AI 之前的响应(其“助手角色”消息),系统可以确保交互的连贯性以及与上下文的相关性。助手消息也可能包含功能工具调用请求信息。它就像 AI 中的一项特殊功能,在需要执行特定功能(例如计算、获取数据或其他不仅仅是对话的任务)时使用。
● TOOL工具/功能角色:工具/功能角色专注于响应工具调用助手消息返回附加信息。

提示词模板:

有时候, 提示词里面的内容不能写死, 需要根据对话动态传入

chatModel $

可以使用SystemPromptTemplate

String userText = """
    请告诉我三位著名的海盗,他们的黄金时代和他们的动机。
    每位海盗至少写一句话。
    """;

Message userMessage = new UserMessage(userText);

String systemText = """
  你是一个友好的 AI 助手,帮助人们寻找信息。
  你的名字是 {name}。
  你应该用你的名字回复用户的请求,并以一种 {voice} 的风格进行回复。
  """;

SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemText);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice));

Prompt prompt = new Prompt(List.of(userMessage, systemMessage));

List<Generation> response = chatModel.call(prompt).getResults();
chatClient
String answer = ChatClient.create(chatModel).prompt()
    .user(u -> u
            .text("告诉我5部{composer}的电影.")
            .param("composer", "周星驰"))
    .call()
    .content();

自定义提示词模板

chatModel $
PromptTemplate promptTemplate = PromptTemplate.builder()
    .renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
    .template("""
            告诉我5部<composer>的电影.
            """)
    .build();

String prompt = promptTemplate.render(Map.of("composer", "John Williams"));
chatClient
String answer = ChatClient.create(chatModel).prompt()
    .user(u -> u
            .text("告诉我5部<composer>的电影")
            .param("composer", "John Williams"))
    .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
    .call()
    .content();

提示词模板文件

chatModel $
@Value("classpath:/prompts/system-message.st")
private Resource systemResource;

SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);
chatClient

/prompts/system-message.st

告诉我5部{composer}的电影
@Test
public void testPrompt(@Autowired DeepSeekChatModel chatModel,
                       @Value("classpath:/prompts/system-message.st")
                       Resource systemResource) {
    ChatClient  chatClient = ChatClient.builder(chatModel)
            .defaultSystem(systemResource)
            .build();

    String content = chatClient.prompt()
            .system(p -> p.param("composer","周星驰"))
            .call()
            .content();

    System.out.println(content);
}

提示词设置技巧 $

简单技巧
  • 文本摘要:
    将大量文本缩减为简洁的摘要,捕捉关键点和主要思想,同时省略不太重要的细节。
  • 问答:
    专注于根据用户提出的问题,从提供的文本中获取具体答案。它旨在精准定位并提取相关信息以响应查询。
  • 文本分类:
    系统地将文本分类到预定义的类别或组中,分析文本并根据其内容将其分配到最合适的类别。
  • 对话:
    创建交互式对话,让人工智能可以与用户进行来回交流,模拟自然的对话流程。
  • 代码生成:
    根据特定的用户要求或描述生成功能代码片段,将自然语言指令转换为可执行代码。
高级技术
  • 零样本、少样本学习:
    使模型能够利用特定问题类型的极少或没有先前的示例做出准确的预测或响应,并使用学习到的概括来理解和执行新任务。
  • 思路链:
    将多个AI响应连接起来,创建连贯且符合语境的对话。它帮助AI保持讨论的线索,确保相关性和连续性。
  • ReAct(推理 + 行动):
    在这种方法中,人工智能首先分析输入(推理),然后确定最合适的行动或响应方案。它将理解与决策结合在一起。
Microsoft 指导
  • 提示创建和优化框架:
    微软提供了一种结构化的方法来开发和完善提示。该框架指导用户创建有效的提示,以便从 AI 模型中获取所需的响应,并优化交互以提高清晰度和效率。
    1.指令明确

a.避免情绪化内容
   i .“求求你好好说啊!”“你这样我不会啊
b.不要让大模型去猜去臆想你的想法, 描述足够清楚
   i.补充必要背景信息:身份、场景、用途、已有内容等,避免 AI “脑补” 出错。
   ii.避免“或许、可能、你懂的”等模糊修饰语
c.把大模型当一个小学生,你描述的任务越清楚他执行越具体
  ❌ 模糊:写一篇文章
  ✅ 清晰:写一篇 800 字的高考作文,主题 “坚持与创新”,结构分引言、三个论点(每个配历史案例)、结论,语言风格正式书面
2. 格式清晰(结构化)
可以通关markdown格式,确定一级标题、二级标题、列表 这样更利于模型理解。后续维也更加清晰
公式:「角色设定」+「具体任务(技能)」+「限制条件(约束)」+「示例参考」

# 角色
你是一位热情、专业的导游,熟悉各种旅游目的地的风土人情和景点信息。你的任务是根据用户的需求,为他们规划一条合理且有趣的旅游路线。

## 技能
### 技能1:理解客户需求
- 询问并了解用户的旅行偏好,包括但不限于目的地、预算、出行日期、活动偏好等信息。
- 根据用户的需求,提供个性化的旅游建议。

### 技能2:规划旅游路线
- 结合用户的旅行偏好,设计一条详细的旅游路线,包括行程安排、交通方式、住宿建议、餐饮推荐等。
- 提供每个景点的详细介绍,包括历史背景、特色活动、最佳游览时间等。

### 技能3:提供实用旅行建议
- 给出旅行中的实用建议,如必备物品清单、当地风俗习惯、安全提示等。
- 回答用户关于旅行的各种问题,例如签证、保险、货币兑换等。
- 如果有不确定的地方,可以调用搜索工具来获取相关信息。

## 限制
- 只讨论与旅行相关的话题。
- 确保所有推荐都基于客户的旅行需求。
- 不得提供任何引导客户参与非法活动的建议。
- 所提供的价格均为预估,可能会受到季节等因素的影响。
- 不提供预订服务,只提供旅行建议和信息。
# 知识库
请记住以下材料,他们可能对回答问题有帮助。

Advisor对话拦截

Spring AI 利用面向切面的思想提供 Advisors API , 它提供了灵活而强大的方法来拦截、修改和增强 Spring 应用程序中的 AI 驱动交互。
在这里插入图片描述

Advisor 接口提供了CallAdvisor和组成CallAdvisorChain(适用于非流式场景),以及StreamAdvisor和 (StreamAdvisorChain适用于流式场景)。它还包括ChatClientRequest,用于表示未密封的 Prompt 请求,以及 ,ChatClientResponse用于表示聊天完成响应。
在这里插入图片描述

日志拦截:

由于整个对话过程是一个“黑盒”, 不利于我们调试, 可以通过SimpleLoggerAdvisor拦截对话记录可以帮助观察我们发了什么信息给大模型便于调试。
1.设置defaultAdvisors


@SpringBootTest
public class AdvisorTest {

    ChatClient chatClient;
    @BeforeEach
    public  void init(@Autowired
                      DeepSeekChatModel chatModel) {
        chatClient = ChatClient
                .builder(chatModel)
                .defaultAdvisors(
                        new SimpleLoggerAdvisor()
                )
                .build();
    }
    @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("Hello")
                .call()
                .content();
        System.out.println(content);
    }
}

2.设置日志级别

logging.level.org.springframework.ai.chat.client.advisor=DEBUG

日志中就记录了
request: 请求的日志信息
response: 响应的信息

自定义拦截:

重读(Re2)

重读策略的核心在于让LLMs重新审视输入问题,这借鉴了人类解决问题的思维方式。通过这种方式,LLMs能够更深入地理解问题,发现复杂的模式,从而在各种推理任务中表现得更加强大。

{Input_Query}
再次阅读问题:{Input_Query}

可以基于BaseAdvisor来实现自定义Advisor, 他实现了重复的代码 提供 模板方法让我们可以专注自己业务编写即可。

public class ReReadingAdvisor implements BaseAdvisor {

	private static final String DEFAULT_USER_TEXT_ADVISE = """
      {re2_input_query}
      Read the question again: {re2_input_query}
      """;

	@Override
	public int getOrder() {
		return 0;
	}

	@Override
	public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
		// 获得用户输入文本
		String inputQuery = chatClientRequest.prompt().getUserMessage().getText();

		// 定义重复输入模版
		String augmentedSystemText = PromptTemplate.builder().template(DEFAULT_USER_TEXT_ADVISE).build()
				.render(Map.of("re2_input_query", inputQuery));

		// 设置请求的提示词
		ChatClientRequest processedChatClientRequest =
				// 不保留
				ChatClientRequest.builder()
				.prompt(Prompt.builder().content(augmentedSystemText).build())
				.build();
		return processedChatClientRequest;
	}

	@Override
	public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
		//我们不做任何处理
		return chatClientResponse;
	}
}

测试:


@SpringBootTest
public class AdvisorTest {

    ChatClient chatClient;
    @BeforeEach
    public  void init(@Autowired
                      DeepSeekChatModel chatModel) {
        chatClient = ChatClient
                .builder(chatModel)
                .defaultAdvisors(
                        new SimpleLoggerAdvisor()
                )
                .build();
    }
    @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("中国有多大?")
                .advisors(new ReReadingAdvisor())
                .call()
                .content();
        System.out.println(content);
    }
}
原理

在这里插入图片描述

记住!
dvisor只有结合ChatClient才能用! 是SpringAi上层提供的。 模型底层并没有这个东西

对话记忆

大型语言模型 (LLM) 是无状态的,这意味着它们不会保留先前交互的信息。

 @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("我叫徐庶 ")
                .call()
                .content();
        System.out.println(content);
        System.out.println("--------------------------------------------------------------------------");

       content = chatClient.prompt()
                .user("我叫什么 ?")
                .call()
                .content();
        System.out.println(content);
    }

在这里插入图片描述

那我们平常跟一些大模型聊天是怎么记住我们对话的呢?实际上,每次对话都需要将之前的对话消息内置发送给大模型,这种方式称为多轮对话
在这里插入图片描述

SpringAi提供了一个ChatMemory的组件用于存储聊天记录,允许您使用 LLM 跨多个交互存储和检索信息。并且可以为不同用户的多个交互之间维护上下文或状态。

可以在每次对话的时候把当前聊天信息和模型的响应存储到ChatMemory, 然后下一次对话把聊天记录取出来再发给大模型。


`

//输出 名字叫

但是这样做未免太麻烦! 能不能简化? 思考一下!
用我们之前的Advisor对话拦截是不是就可以不用每次手动去维护了。 并且SpringAi早已体贴的为我提供了ChatMemoryAutoConfiguration自动配置类

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>
@AutoConfiguration
@ConditionalOnClass({ ChatMemory.class, ChatMemoryRepository.class })
public class ChatMemoryAutoConfiguration {

	@Bean
	@ConditionalOnMissingBean
	ChatMemoryRepository chatMemoryRepository() {
		return new InMemoryChatMemoryRepository();
	}

	@Bean
	@ConditionalOnMissingBean
	ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
		return MessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).build();
	}

}

所以我们可以这样用:

使用

SpringAi提供了 PromptChatMemoryAdvisor 专门用于对话记忆的拦截


@SpringBootTest
public class ChatMemoryTest {
    ChatClient chatClient;
    @BeforeEach
    public  void init(@Autowired
                      DeepSeekChatModel chatModel,
                      @Autowired
                      ChatMemory chatMemory) {
        chatClient = ChatClient
                .builder(chatModel)
                .defaultAdvisors(
                        PromptChatMemoryAdvisor.builder(chatMemory).build()
                )
                .build();
    }
    @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("我叫徐庶 ?")
                .advisors(new ReReadingAdvisor())
                .call()
                .content();
        System.out.println(content);
        System.out.println("--------------------------------------------------------------------------");

        content = chatClient.prompt()
                .user("我叫什么 ?")
                .advisors(new ReReadingAdvisor())
                .call()
                .content();
        System.out.println(content);
    }
}

配置聊天记录最大存储数量

要知道, 我们把聊天记录发给大模型, 都是算token计数的。

大模型的token是有上限了, 如果你发送过多聊天记录,可能就会导致token过长。

模型 deepseek-chat deepseek-reasoner
上下文长度 64k 64k
输出长度 默认4k,最大8k 默认32k,最大64k

并且更多的token也意味更多的费用, 更久的解析时间. 所以不建议太长
(DEFAULT_MAX_MESSAGES默认20即10次对话)

一旦超出DEFAULT_MAX_MESSAGES只会存最后面N条(可以理解为先进先出),参考MessageWindowChatMemory源码

   @Bean
   ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory
                .builder()
                .maxMessages(10)
                .chatMemoryRepository(chatMemoryRepository).build();
    }

配置多用户隔离记忆

如果有多个用户在进行对话, 肯定不能将对话记录混在一起, 不同的用户的对话记忆需要隔离

@Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("我叫徐庶 ?")
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,"1"))
                .call()
                .content();
        System.out.println(content);
        System.out.println("--------------------------------------------------------------------------");

        content = chatClient.prompt()
                .user("我叫什么 ?")
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,"1"))
                .call()
                .content();
        System.out.println(content);


        System.out.println("--------------------------------------------------------------------------");

        content = chatClient.prompt()
                .user("我叫什么 ?")
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,"2"))
                .call()
                .content();
        System.out.println(content);
    }

会发现, 不同的CONVERSATION_ID,会有不同的记忆
在这里插入图片描述

原理源码$

主要有前置存储
MessageWindowChatMemory
具体存储实现
ChatMemoryRepository
在这里插入图片描述

数据库存储对话记忆

默认情况, 对话内容会存在jvm内存会导致:
1.一直存最终会撑爆JVM导致OOM。
2.重启就丢了, 如果已想存储到第三方存储进行持久化
springAi内置提供了以下几种方式(例如 Cassandra、JDBC 或 Neo4j), 这里演示下JDBC

1.添加依赖


        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
        </dependency>

        <!--jdbc-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>


        <!--mysql驱动-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

2.添加配置

spring.ai.chat.memory.repository.jdbc.initialize-schema=always
spring.ai.chat.memory.repository.jdbc.schema=classpath:/schema-mysql.sql
spring:
  datasource:
    username: root
    password: 123456
    url: jdbc:mysql://localhost:3306/springai?characterEncoding=utf8&useSSL=false&serverTimezone=UTC&
    driver-class-name: com.mysql.cj.jdbc.Driver

3.配置类

@Configuration
public class ChatMemoryConfig {
    @Bean
    ChatMemory chatMemory(JdbcChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory
        .builder()
        .maxMessages(1)
        .chatMemoryRepository(chatMemoryRepository).build();
    }
}

4.resources/schema-mysql.sql(目前1.0.0版本需要自己定义,没有提供脚本)

CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
    `conversation_id` VARCHAR(36) NOT NULL,
    `content` TEXT NOT NULL,
    `type` VARCHAR(10) NOT NULL,
    `timestamp` TIMESTAMP NOT NULL,

    INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX` (`conversation_id`, `timestamp`)
    );

5.测试


@SpringBootTest
public class ChatMemoryTest {


    ChatClient chatClient;
    @BeforeEach
    public  void init(@Autowired
                      DeepSeekChatModel chatModel,
                      @Autowired
                      ChatMemory chatMemory) {
        chatClient = ChatClient
                .builder(chatModel)
                .defaultAdvisors(
                        PromptChatMemoryAdvisor.builder(chatMemory).build()
                )
                .build();
    }
    @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("你好,我叫徐庶!")
                .advisors(new ReReadingAdvisor())
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,"1"))
                .call()
                .content();
        System.out.println(content);
        System.out.println("--------------------------------------------------------------------------");

        content = chatClient.prompt()
                .user("我叫什么 ?")
                .advisors(new ReReadingAdvisor())
                .advisors(advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID,"1"))
                .call()
                .content();
        System.out.println(content); 
    }
}

可以看到由于我设置.maxMessages(1)数据库只存一条

在这里插入图片描述

Redis存储

如果你想用redis , 你需要自己实现ChatMemoryRepository接口(自己实现增、删、查)
但是alibaba-ai有现成的实现:(还包括ES)
https://github.com/alibaba/spring-ai-alibaba/tree/main/community/memories

<properties>
    <jedis.version>5.2.0</jedis.version>
</properties>

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
</dependency>


    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>${jedis.version}</version>
    </dependency>
 
spring:
  ai:
    memory:
      redis:
        host: localhost
        port: 6379
        timeout:  5000
        password:```

```java

@Configuration
public class RedisMemoryConfig {

    @Value("${spring.ai.memory.redis.host}")
    private String redisHost;
    @Value("${spring.ai.memory.redis.port}")
    private int redisPort;
    @Value("${spring.ai.memory.redis.password}")
    private String redisPassword;
    @Value("${spring.ai.memory.redis.timeout}")
    private int redisTimeout;

    @Bean
    public RedisChatMemoryRepository redisChatMemoryRepository() {
        return RedisChatMemoryRepository._builder_()
                .host(redisHost)
                .port(redisPort)
                // 若没有设置密码则注释该项
//           .password(redisPassword)
                .timeout(redisTimeout)
                .build();
    }
}

多层次记忆架构 痛点

记忆多=聪明, 记忆多会触发token上限
要知道, 无论你用什么存储对话以及, 也只能保证服务端的存储性能。
但是一旦聊天记录多了依然会超过token上限, 但是有时候我们依然希望存储更多的聊天记录,这样才能保证整个对话更像“人”。

多层次记忆架构(模仿人类)
  • 近期记忆:保留在上下文窗口中的最近几轮对话,每轮对话完成后立即存储(可通过ChatMemory); 10 条
  • 中期记忆:通过RAG检索的相关历史对话(每轮对话完成后,异步将对话内容转换为向量并存入向量数据库) 5条
  • 长期记忆:关键信息的固化总结
    • 方式一:定时批处理
      • 通过定时任务(如每天或每周)对积累的对话进行总结和提炼
      • 提取关键信息、用户偏好、重要事实等
      • 批处理方式降低计算成本,适合大规模处理
    • 方式二:关键点实时处理
      • 在对话中识别出关键信息点时立即提取并存储
      • 例如,当用户明确表达偏好、提供个人信息或设置持久性指令时
      • 采用"写入触发器"机制,在特定条件下自动更新长期记忆

结构化输出

基础类型:

以Boolean为例 , 在agent中可以用于判定用于的内容2个分支, 不同的分支走不同的逻辑

ChatClient chatClient;
@BeforeEach
public  void init(@Autowired
                  DashScopeChatModel chatModel) {
    chatClient = ChatClient.builder(chatModel).build();
}
@Test
public void testBoolOut() {
    Boolean isComplain = chatClient
    .prompt()
    .system("""
            请判断用户信息是否表达了投诉意图?
            只能用 true 或 false 回答,不要输出多余内容
            """)
    .user("你们家的快递迟迟不到,我要退货!")
    .call()
    .entity(Boolean.class);

    // 分支逻辑
    if (Boolean.TRUE.equals(isComplain)) {
        System.out.println("用户是投诉,转接人工客服!");
    } else {
        System.out.println("用户不是投诉,自动流转客服机器人。");
        // todo 继续调用 客服ChatClient进行对话
    }
}

Pojo类型:

用购物APP应该见过复制一个地址, 自动为你填入每个输入框。 用大模型轻松完成!
在这里插入图片描述


    @Test
    public void testEntityOut() {
        Address address = chatClient.prompt()
                .system("""
                        请从下面这条文本中提取收货信息
                        """)
                .user("收货人:张三,电话13588888888,地址:浙江省杭州市西湖区文一西路100号8幢202室")
                .call()
                .entity(Address.class);
        System.out.println(address);
    }
public record Address(
    String name,        // 收件人姓名
    String phone,       // 联系电话
    String province,    // 省
    String city,        // 市
    String district,    // 区/县
    String detail       // 详细地址
) {}

原理

ChatModel或者直接使用低级API:

@Test
    public void testLowEntityOut(
           @Autowired DashScopeChatModel chatModel) {
        BeanOutputConverter<ActorsFilms> beanOutputConverter =
                new BeanOutputConverter<>(ActorsFilms.class);

        String format = beanOutputConverter.getFormat();

        String actor = "周星驰";

        String template = """
        提供5部{actor}导演的电影.
        {format}
        """;

        PromptTemplate promptTemplate = PromptTemplate.builder().template(template).variables(Map.of("actor", actor, "format", format)).build();
        ChatResponse response = chatModel.call(
                promptTemplate.create()
        );

        ActorsFilms actorsFilms = beanOutputConverter.convert(response.getResult().getOutput().getText());
        System.out.println(actorsFilms);
    }

链接多个模型协调工作实战 - 初代tools: $

背景:

大模型如果它无法和企业API互联那将毫无意义! 比如我们开发一个智能票务助手, 当用户需要退票, 基础大模型它肯定做不到, 因为票务信息都存在了我们系统中, 必须通过我们系统的业务方法才能进行退票。 那怎么能让大模型“调用”我们自己系统的业务方法呢? 今天叫大家通过结构化输入连接多个模型一起协同完成这个任务:

票务助手

在这里插入图片描述

效果

在这里插入图片描述
在这里插入图片描述

输入姓名和预定号:
在这里插入图片描述
在这里插入图片描述

普通对话:
在这里插入图片描述

代码:
public class AiJob {
     record Job(JobType jobType, Map<String,String> keyInfos) {
    }

    public enum JobType{
        CANCEL,
        QUERY,
        OTHER,
    }
}

@Configuration
public class AiConfig {

    @Bean
    public ChatClient planningChatClient(DashScopeChatModel chatModel,
                                         DashScopeChatProperties options,
                                         ChatMemory chatMemory) {
        DashScopeChatOptions dashScopeChatOptions = DashScopeChatOptions.fromOptions(options.getOptions());
        dashScopeChatOptions.setTemperature(0.7);

            return  ChatClient.builder(chatModel)
                    .defaultSystem("""
                            # 票务助手任务拆分规则
                            ## 1.要求
                            ### 1.1 根据用户内容识别任务
                            
                            ## 2. 任务
                            ### 2.1 JobType:退票(CANCEL) 要求用户提供姓名和预定号, 或者从对话中提取;
                            ### 2.2 JobType:查票(QUERY) 要求用户提供预定号, 或者从对话中提取;
                            ### 2.3 JobType:其他(OTHER)
                            """)
                    .defaultAdvisors(
                            MessageChatMemoryAdvisor.builder(chatMemory).build()
                    )
                    .defaultOptions(dashScopeChatOptions)
                    .build();
    }

    @Bean
    public ChatClient botChatClient(DashScopeChatModel chatModel,
                                    DashScopeChatProperties options,
                                         ChatMemory chatMemory) {

        DashScopeChatOptions dashScopeChatOptions = DashScopeChatOptions.fromOptions(options.getOptions());
        dashScopeChatOptions.setTemperature(1.2);
        return  ChatClient.builder(chatModel)
                .defaultSystem("""
                           你是XS航空智能客服代理, 请以友好的语气服务用户。
                            """)
                .defaultAdvisors(
                        MessageChatMemoryAdvisor.builder(chatMemory).build()
                )
                .defaultOptions(dashScopeChatOptions)
                .build();
    }
}

@RestController
public class MultiModelsController {

    @Autowired
    ChatClient planningChatClient;

    @Autowired
    ChatClient botChatClient;




    @GetMapping(value = "/stream", produces = "text/stream;charset=UTF8")
    Flux<String> stream(@RequestParam String message) {
        // 创建一个用于接收多条消息的 Sink
        Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
        // 推送消息
        sink.tryEmitNext("正在计划任务...<br/>");


        new Thread(() -> {
        AiJob.Job job = planningChatClient.prompt().user(message)
                .call().entity(AiJob.Job.class);

        switch (job.jobType()){
            case CANCEL ->{
                System.out.println(job);
                // todo.. 执行业务
                if(job.keyInfos().size()==0){
                    sink.tryEmitNext("请输入姓名和订单号.");
                }
                else {
                    sink.tryEmitNext("退票成功!");
                }
            }
            case QUERY -> {
                System.out.println(job);
                // todo.. 执行业务
                sink.tryEmitNext("查询预定信息:xxxx");
            }
            case OTHER -> {
                Flux<String> content = botChatClient.prompt().user(message).stream().content();
                content.doOnNext(sink::tryEmitNext) // 推送每条AI流内容
                        .doOnComplete(() -> sink.tryEmitComplete())
                        .subscribe();
            }
            default -> {
                System.out.println(job);
                sink.tryEmitNext("解析失败");
            }
        }
        }).start();

        return sink.asFlux();
    }
}

tools/function-call

在这里插入图片描述

想做企业级智能应用开发, 你肯定会有需求要让大模型和你的企业API能够互连,

因为对于基础大模型来说, 他只具备通用信息,他的参数都是拿公网进行训练,并且有一定的时间延迟, 无法得知一些具体业务数据和实时数据, 这些数据往往被各软件系统存储在自己数据库中:

比如我问大模型:“中国有多少个叫徐庶的” 他肯定不知道, 我们就需要去调用政务系统的接口。

比如我现在开发一个智能票务助手, 我现在跟AI说需要退票, AI怎么做到呢? 就需要让AI调用我们自己系统的退票业务方法,进行操作数据库。

在之前我们可以通过链接多个模型的方式达到, 但是很麻烦, 那用tools, 可以轻松完成。

tool calling也可以直接叫tool(也称为function-call), 主要用于提供大模型不具备的信息和能力:
1.信息检索:可用于从外部源(如数据库、Web 服务、文件系统或 Web 搜索引擎)检索信息。目标是增强模型的知识,使其能够回答无法回答的问题。例如,工具可用于检索给定位置的当前天气、检索最新的新闻文章或查询数据库以获取特定记录。 这也是一种检索增强方式。
2.采取行动:例如发送电子邮件、在数据库中创建新记录、提交表单或触发工作流。目标是自动执行原本需要人工干预或显式编程的任务。例如,可以使用工具为与聊天机器人交互的客户预订航班,在网页上填写表单等。

在这里插入图片描述

使用

在这里插入图片描述

1.声明tools的类:

@Service
class NameCountsTools {

    @Tool(description = "长沙有多少名字的数量")
    String LocationNameCounts(
            @ToolParam(description = "名字")
            String name) {
        return "10个";
    }

}

1.将Tool类配置为bean(非必须)
2.@Tool 用户告诉大模型提供了什么工具
3.@ToolParam 用于告诉大模型你要用这个工具需要什么参数(非必须)

2.绑定到ChatClient


@SpringBootTest
public class ToolTest {
    ChatClient chatClient;
    @BeforeEach
    public  void init(@Autowired
                      DashScopeChatModel chatModel,
                      @Autowired
                      NameCountsTools nameCountsTools) {
        chatClient = ChatClient.builder(chatModel)
                .defaultTools(nameCountsTools)
                .build();
    }
    @Test
    public void testChatOptions() {
        String content = chatClient.prompt()
                .user("长沙有多少个叫徐庶的/no_think")
                // .tools() 也可以单独绑定当前对话
                .call()
                .content();
        System.out.println(content);
    }
}

在这里插入图片描述

原理

在这里插入图片描述

1.当我们设置了defaultTools 相当于就告诉了大模型我提供了什么工具, 你需要用我的工具必须给我什么参数, 底层实际就是将这些信息封装了json提供给大模型
2.当大模型识别到我们的对话需要用到工具, 就会响应需要调用tool

源码

在这里插入图片描述

tools注意事项:

1.参数或者返回值不支持:
在这里插入图片描述

推荐: pojo record java基础类型 list map

2.Tools参数无法自动推算问题

  • 温度(即模型随机性)太低,AI可能缺失自由度变得比较拘谨(从一定程度可以解决, 但是不推荐)
  • 也可以通过描述更加明确
  @Tool(description = "获取指定位置天气,根据位置自动推算经纬度")
    public String getAirQuality(@ToolParam(description = "纬度") double latitude,
                                @ToolParam(description = "经度") double longitude) {
        return "天晴";
    }

3.大模型“强行适配”Tool参数的幻觉问题

  • 加严参数描述与校验
@Parameter(description = "真实人名(必填,必须为人的真实姓名,严禁用其他信息代替;如缺失请传null)")
String name
  • 后端代码加强校验和兜底保护
  • 系统Prompt设定限制
“严禁随意补全或猜测工具调用参数。
参数如缺失或语义不准,请不要补充或随意传递,请直接放弃本次工具调用。”
  • 高风险接口(如资金、风控等)tools方法加强人工确认,多走一步校验。

4.工具暴露的接口名、方法名、参数名要可读、业务化

  • AI是“看”你的签名和注释来决定用不用工具的;
  • 尽量避免乱码、缩写等。

方法参数数量不宜过多

  • 建议每个工具方法尽量少于5个参数,否则AI提示会变复杂、出错率高。

5.工具方法不适合做超耗时操作, 更长的耗时意味着用户延迟响应时间变长
性能优化 能异步处理就异步处理、 查询数据 redis

6. 关于Tools的权限控制
  a.可以利用SpringSecurity限制

    @Tool(description = "退票")
    @PreAuthorize("hasRole('ADMIN')")
    public String cancel(
            // @ToolParam告诉大模型参数的描述
      @ToolParam(description = "预定号,可以是纯数字") String ticketNumber,
      @ToolParam(description = "真实人名(必填,必须为人的真实姓名,严禁用其他信息代替;如缺失请传null)") String name
           ) {
        // 当前登录用户名
        String username = SecurityContextHolder.getContext().getAuthentication().getName();
        // 先查询 --->先校验
        ticketService.cancel(ticketNumber, name);
        return username+"退票成功!";
    }

  b.将tools和权限资源一起存储, 然后动态设置tools

.defaultToolCallbacks(toolService.getToolCallList(toolService))

根据当前用户读取当前用户所属角色的所有tools

public List<ToolCallback> getToolCallList(ToolService toolService) {

        Method method = ReflectionUtils.findMethod(ToolService.class, "cancel",String.class,String.class);
        ToolDefinition build = ToolDefinition.builder()
                .name("cancel")
                .description("退票")
                .inputSchema("""
                        {
                          "type": "object",
                          "properties": {
                            "ticketNumber": {
                              "type": "string",
                              "description": "预定号,可以是纯数字"
                            },
                            "name": {
                              "type": "string",
                              "description": "真实人名"
                            }
                          },
                          "required": ["ticketNumber", "name"]
                        }
                        """)
                .build();
        ToolCallback toolCallback = MethodToolCallback.builder()
                .toolDefinition(
                        build)
                .toolMethod(method)
                .toolObject(toolService)
                .build();

        return List.of(toolCallback);
    }

7.tools过多导致AI出现选择困难证
问题:
  a.token上限
  b.选择困难证

tools的描述作用 保存 向量数据库。
实现方式:
  1.把所有的tools描述信息存入到向量数据库
  2.每次对话的时候根据当前对话信息检索到相似的tools(RAG)
  3.然后动态设置tools

《智能客服项目实战》

https://www.yuque.com/geren-t8lyq/ncgl94/yqnlrri5gavanx0f?singleDoc# 《Spring AI1.0 智能航空助手项目》

项目效果:

角色预设:

在这里插入图片描述

记忆对话

在这里插入图片描述

tools

在这里插入图片描述

MCP

问题:

1.当有服务商需要将tools提供外部使用(比如高德地图提供了位置服务tools, 比如百度提供了联网搜索的tools…)
2. 或者在企业级中, 有多个智能应用,想将通用的tools公共化, 怎么办?

可以把tools单独抽取出来, 由应用程序读取外部的tools。 那关键是怎么读呢? 怎么解析呢? 如果每个提供商各用一种规则你能想象有多麻烦! 所以MCP就诞生了, 他指定了标准规则, 以jsonrpc2.0的方式进行通讯。

那问题又来了, 以什么方式通讯呢? http? rpc? stdio? mcp提供了sse和stdio这2种方式。
在这里插入图片描述

使用

Streamable http目前springai1.0版本不支持, 我们先掌握SSE和STDIO
分别说下STDIO和SSE的方式:
- STDIO更适合客户端桌面应用和辅助工具
- SSE更适合web应用 、业务有关的公共tools
在这里插入图片描述

STDIO
MCP Server
现成共用MCP Server

现在有很多MCP 服务 给大家提供一个网站:MCP Server(MCP 服务器)
在这里插入图片描述
那MCP有了, 怎么调用呢? 这里介绍2种使用方式:

自定义MCP Server

创建一个springai项目

  1. 依赖

<!--mcp-server  -->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-mcp-server</artifactId>
</dependency>

 <dependencyManagement>
        <dependencies>
            <!--springai  -->
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
  </dependencyManagement>

<!-- 打包 -->
<build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

2.添加工具

@Service
public class UserToolService {

    Map<String,Double> userScore = Map.of(
        "xushu",99.0,
        "zhangsan",2.0,
        "lisi",3.0);
    @Tool(description = "获取用户分数")
    public String getScore(String username) { 
        if(userScore.containsKey(userName)){
            return userScore.get(userName).toString();
        }  

        return "未检索到当前用户"+userName;
    }
}

3.暴露工具

@Bean
public ToolCallbackProvider weatherTools(UserToolService userToolService) {
    return MethodToolCallbackProvider.builder().toolObjects(userToolService).build();
}

4.配置

spring:
  main:
    banner-mode: off
  ai:
    mcp:
      server:
        name: my-weather-server
        version: 0.0.1

注意:您必须禁用横幅和控制台日志记录,以允许 STDIO 传输!!工作 banner-mode: off

5.打包 mvn package
此时target/生成了jar则成功!

MCP Client
通过工具

CherryStudio、Cursor、Claude Desktop、Cline等等很多,这里不一 一演示,不会的话自己找个文章,工具使用都很简单!
在这里插入图片描述
以Cline为例:他是Vscode的插件
1.安装VSCode
2.安装插件:
在这里插入图片描述
3.配置cline的模型:
在这里插入图片描述

4.配置cline的mcpserver
在这里插入图片描述

{
    "mcpServers": {
        "baidu-map": {
            "command": "cmd",
            "args": [
                "/c",
                "npx",
                "-y",
                "@baidumap/mcp-server-baidu-map"
            ],
            "env": {
                "BAIDU_MAP_API_KEY": "LEyBQxG9UzR9C1GZ6zDHsFDVKvBem2do"
            }
        },
        "filesystem": {
            "command": "cmd",
            "args": [
                "/c",
                "npx",
                "-y",
                "@modelcontextprotocol/server-filesystem",
                "C:/Users/tuling/Desktop"
            ]
        },
        "mcp-server-weather": {
            "command": "java",
            "args": [
                "-Dspring.ai.mcp.server.stdio=true",
                "-Dlogging.pattern.console=",
                "-jar",
                "D:\\ideaworkspace\\git_pull\\tuling-flight-booking_all\\mcp-stdio-server\\target\\mcp-stdio-server-xs-1.0.jar"
            ]
        }
    }
}

5.开启cline权限
在这里插入图片描述
6.测试:
在这里插入图片描述

通过Spring AI

1.依赖

<!--既支持sse\也支持Stdio-->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
</dependency>

2.配置

spring:
  ai:
    mcp:
      client:
        request-timeout: 60000
        stdio:
          servers-configuration: classpath:/mcp-servers-config.json
          connections:
            server1:
              command: /path/to/server
              args:
                - --port=8080
                - --mode=production
              env:
                API_KEY: your-api-key
                DEBUG: "true"
  1. mcp-servers-config.json:
    获取Baidu地图key:控制台|百度地图开放平台
{
    "mcpServers": {
        "baidu-map": {
            "command": "cmd",
            "args": [
                "/c",
                "npx",
                "-y",
                "@baidumap/mcp-server-baidu-map"
            ],
            "env": {
                "BAIDU_MAP_API_KEY": "xxxx"
            }
        },
        "filesystem": {
            "command": "cmd",
            "args": [
                "/c",
                "npx",
                "-y",
                "@modelcontextprotocol/server-filesystem",
                "C:/Users/tuling/Desktop"
            ]
        },
        "mcp-server-weather": {
            "command": "java",
            "args": [
                "-Dspring.ai.mcp.server.stdio=true",
                "-Dlogging.pattern.console=",
                "-jar",
                "D:\\xxx\\target\\mcp-stdio-server-xs-1.0.jar"
            ]
        }
    }
}

4.绑定到Chatclient

/**
 * @version 1.0
 * @description: 智能航空助手:需要一对一解答关注wx: 程序员徐庶
 */
@RestController
@CrossOrigin
public class OpenAiController {
    
    private final ChatClient chatClient;
    
    public OpenAiController(
            DashScopeChatModel dashScopeChatModel,
                            // 外部 mcp tools
                            ToolCallbackProvider mcpTools) {
        this.chatClient =ChatClient.builder(dashScopeChatModel)
        .defaultToolCallbacks(mcpTools)
        .build();
    }
    

 @CrossOrigin
@GetMapping(value = "/ai/generateStreamAsString", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> generateStreamAsString(@RequestParam(value = "message", defaultValue = "讲个笑话") String message) {

    Flux<String> content = chatClient.prompt()
            .user(message)
            .stream()
            .content();

    return  content;
}
# 调试日志
logging:
  level:
    io:
      modelcontextprotocol:
        client: DEBUG
        spec: DEBUG
SSE
MCP Server

这种方式需要将部署为Web服务
1.依赖

        <!--mcp服务器核心依赖— 响应式-->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>

如果用:spring-ai-starter-mcp-server-webflux
会出现:在这里插入图片描述

根据官方:https://github.com/spring-projects/spring-ai/pull/3511
建议加入: spring.main.web-application-type=reactive

2.定义外部工具


@Service
public class UserToolService {

    Map<String,Double> userScore = Map.of(
            "xushu",99.0,
            "zhangsan",2.0,
            "lisi",3.0);
    @Tool(description = "获取用户分数")
    public String getScore(String username) {
        if(userScore.containsKey(username)){
            return userScore.get(username).toString();
        }

        return "未检索到当前用户";
    }
}

3.暴露工具

 @Bean
    public ToolCallbackProvider weatherToolCallbackProvider(WeatherService weatherService,
                                                            UserToolService userToolService) {
        return MethodToolCallbackProvider.builder().toolObjects(userToolService).build();
    }

4.配置

server:
  port: 8088

5.启动

MCP Client

1.添加依赖


<!--既支持sse\也支持Stdio-->
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-mcp-client-webflux</artifactId>
</dependency>

2.配置

spring:
  ai:
    mcp:
      client:
        enabled: true
        name: my-mcp-client
        version: 1.0.0
        request-timeout: 30s
        type: ASYNC  # or SYNC
        sse:
          connections:
            server1:
              url: http://localhost:8088

3.代码

/**
 * @version 1.0
 * @description: 智能航空助手:需要一对一解答关注wx: 程序员徐庶
 */
@RestController
@CrossOrigin
public class OpenAiController {

    private final ChatClient chatClient;

    public OpenAiController(
        DashScopeChatModel dashScopeChatModel,
        // 外部 mcp tools
        ToolCallbackProvider mcpTools) {
        this.chatClient =ChatClient.builder(dashScopeChatModel)
        .defaultToolCallbacks(mcpTools)
        .build();
    }


    @CrossOrigin
    @GetMapping(value = "/ai/generateStreamAsString", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> generateStreamAsString(@RequestParam(value = "message", defaultValue = "讲个笑话") String message) {

        Flux<String> content = chatClient.prompt()
        .user(message)
        .stream()
        .content();
        return  content;
    }
原理

1.STDIO 是基于标准输入\输出流的方式, 需要在MCP 客户端安装一个包(可以是jar包、python包、npm包等…). 它是“客户端”的MCP Server。

2.SSE 是基于Http的方式进行通讯, 需要将MCP Server部署为一个web服务. 它是服务端的MCP Server

STDIO原理

在这里插入图片描述

很多人不理解stdio到底什么意思, 为什么一定要把stdio server的banner关掉, 还要清空控制台?
在这里插入图片描述

1.首先SpringAi底层会读取到mcp-servers-config.json的信息
2.然后执行命令(其实聪明的小伙伴早就发现了,mcp-servers-config.json文件中就是一堆shell命令)

  a.怎么执行? 熟悉java的同学应该知道,java里面有一个对象用于执行命令:

 ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command("java","-version");

        Process process = processBuilder.start();

        process.errorReader().lines().forEach(System.out::println);

3.所以springAi底层相当于读取到信息后, 会通过processBuilder去执行命令

String[] commands={"java",
                "-Dspring.ai.mcp.server.stdio=true",
                "-Dlogging.pattern.console=",
                "-jar",
                "D:\\ideaworkspace\\git_pull\\tuling-flight-booking_all\\mcp-stdio-server\\target\\mcp-stdio-server-xs-1.0.jar"};

        ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command(commands);
        // processBuilder.environment().put("username","xushu");

        Process process = processBuilder.start();

其实你也完全可以自己通过mcd去执行命令
在这里插入图片描述

1.运行jar -jar mcp-stdio-server.jar
2.输入{“jsonrpc”:“2.0”,“method”:“tools/list”,“id”:“3b3f3431-1”,“params”:{}}
3.输出tools列表

这就是标准输入输出流! 看到这里你应该知道, 为什么需要-Dlogging.pattern.console= 完全是为了清空控制台,才能读取信息!

所以利用java也是一样的原理:

@Test
    public void test() throws IOException, InterruptedException {
        String[] commands={"java",
                "-Dspring.ai.mcp.server.stdio=true",
                "-Dlogging.pattern.console=",
                "-jar",
                "D:\\ideaworkspace\\git_pull\\tuling-flight-booking_all\\mcp-stdio-server\\target\\mcp-stdio-server-xs-1.0.jar"};

        ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command(commands);
        processBuilder.environment().put("username","xushu");

        Process process = processBuilder.start();

        Thread thread = new Thread(() -> {
            try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line=processReader.readLine())!=null) {
                        System.out.println(line);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        thread.start();


        Thread.sleep(1000);

        new Thread(() -> {

            try {
                //String jsonMessage="{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":\"3670122a-0\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"spring-ai-mcp-client\",\"version\":\"1.0.0\"}}}";
                String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":\"3b3f3431-1\",\"params\":{}}";

                jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n");

                var os = process.getOutputStream();
                synchronized (os) {
                    os.write(jsonMessage.getBytes(StandardCharsets.UTF_8));
                    os.write("\n".getBytes(StandardCharsets.UTF_8));
                    os.flush();
                }
                System.out.println("写入完成!");
            }catch (IOException e){
                e.printStackTrace();
            }
        }).start();


        thread.join();
        /*JSONRPCRequest[jsonrpc=2.0, method=initialize, id=5d83d0d1-0, params=InitializeRequest[protocolVersion=2024-11-05, capabilities=ClientCapabilities[experimental=null, roots=null, sampling=null],
        clientInfo=Implementation[name=spring-ai-mcp-client, version=1.0.0]]]*/
    }

1.通过ProcessBuilder执行命令
2.通过子线程轮询 process.getInputStream 获取输出流
3.通过process.getOutputStream(); 进行写入流

所以整个过程是这样的:再回顾上面的图

启动程序—>读取mcpjson—>通过ProcessBuilder启动命令—> 写入初始化jsonrpc---->写入获取tools列表jsonrpc---->请求大模型(携带tools)---->写入请求外部tool的jsonrpc---->获取数据—>发送给大模型---->响应。

STDIO源码

在这里插入图片描述

MCP鉴权

在做MCP企业级方案落地时, 我们可能不想让没有权限的人访问MCP Server, 或者需要根据不同的用户返回不同的数据, 这里就涉及到MCP Server授权操作。

那MCP Server有2种传输方式, 实现起来不一样:

STDIO

这种方式在本地运行,它 将MCP Server作为子进程启动。 我们称为标准输入输出, 其实就是利用运行命令的方式写入和读取控制台的信息,以达到传输。
在这里插入图片描述

通常我们会配置一段json,比如这里的百度地图MCP Server :

  • 其中command和args代表运行的命令和参数。
  • 其实env中的节点BAIDU_MAP_API_KEY就是做授权的。

如果你传入的BAIDU_MAP_API_KEY不对, 就没有使用权限。

"baidu-map": {
  "command": "cmd",
  "args": [
    "/c",
    "npx",
    "-y",
    "@baidumap/mcp-server-baidu-map"
  ],
  "env": {
    "BAIDU_MAP_API_KEY": "LEyBQxG9UzR9C1GZ6zDHsFDVKvBem2do"
  }
},

所以STDIO做授权的方式很明确, 就是通过env【环境变量】,实现步骤如下:
1.服务端发放一个用户的凭证(可以是秘钥、token) 这步不细讲,需要有一个授权中心发放凭证。
2.通过mcp client通过env传入凭证
3.mcp server通过环境变量鉴权

所以在MCP Server端就可以通过获取环境变量的方式获取env里面的变量:
也可以通过AOP的方式统一处理

    @Tool(description = "获取用户余额")
    public String getScore() {
        String userName = System.getenv("API_KEY"); 
        // todo .. 鉴权处理
        return "未检索到当前用户"+userName;
    }

这种方式要注意: 他不支持动态鉴权, 也就是动态更换环境变量, 因为STDIO是本地运行方式,它 将MCP Server作为子进程启动, 如果是多个用户动态切换凭证, 会对共享的环境变量造成争抢, 最终只能存储一个。 除非一个用户对应一个STDIO MCP Server. 但是这样肯定很吃性能! 如果要多用户动态切换授权, 可以用SSE的方式;

SSE
说明

不过,如果你想把 MCP 服务器开放给外部使用,就需要暴露一些标准的 HTTP 接口。对于私有场景,MCP 服务器可能并不需要严格的身份认证,但在企业级部署下,对这些接口的安全和权限把控就非常重要了。为了解决这个问题,2025 年 3 月发布的最新 MCP 规范引入了安全基础,借助了广泛使用的 OAuth2 框架

在这里插入图片描述

本文不会详细介绍 OAuth2 的所有内容,不过简单回顾一下还是很有帮助。
在规范的草案中,MCP 服务器既是资源服务器,也是授权服务器

  • 作为资源服务器,MCP 负责检查每个请求中的 Authorization请求头。这个请求头必须包括一个 OAuth2access_token(令牌),它代表客户端的“权限”。这个令牌通常是一个 JWT(JSON Web Token),也可能只是一个不可读的随机字符串。如果令牌缺失或无效(无法解析、已过期、不是发给本服务器的等),请求会被拒绝。正常情况下,调用示例如下:
curl https://mcp.example.com/sse -H "Authorization: Bearer <有效的 access token>"
  • 作为授权服务器,MCP 还需要有能力为客户端安全地签发 access_token。在发放令牌前,服务器会校验客户端的凭据,有时还需要校验访问用户的身份。授权服务器决定令牌的有效期、权限范围、目标受众等特性。

用 Spring Security 和 Spring Authorization Server,可以方便地为现有的 Spring MCP 服务器加上这两大安全能力。
在这里插入图片描述

给 Spring MCP 服务器加上 OAuth2 支持

这里以官方例子仓库的【天气】MCP 工具演示如何集成 OAuth2,主要是让服务器端能签发和校验令牌。
首先,pom.xml 里添加必要的依赖:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

接着,在 application.properties配置里加上简易的 OAuth2 客户端信息,便于请求令牌:

spring.security.oauth2.authorizationserver.client.oidc-client.registration.client-id=xushu
spring.security.oauth2.authorizationserver.client.oidc-client.registration.client-secret={noop}xushu666
spring.security.oauth2.authorizationserver.client.oidc-client.registration.client-authentication-methods=client_secret_basic
spring.security.oauth2.authorizationserver.client.oidc-client.registration.authorization-grant-types=client_credentials

这样定义后,你可以直接通过 POST 请求和授权服务器交互,无需浏览器,用配置好的 /secret 作为固定凭据。 比如 最后一步是开启授权服务器和资源服务器功能。通常会新增一个安全配置类,比如 SecurityConfiguration,如下:

import static org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer.authorizationServer;

@Configuration
@EnableWebSecurity
class SecurityConfiguration {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .with(authorizationServer(), Customizer.withDefaults())
        .oauth2ResourceServer(resource -> resource.jwt(Customizer.withDefaults()))
        .csrf(CsrfConfigurer::disable)
        .cors(Customizer.withDefaults())
        .build();
    }
}

这个过滤链主要做了这些事情:

  • 要求所有请求都要经过身份认证。也就是访问 MCP 的接口,必须带上 access_token。
  • 同时启用了授权服务器和资源服务器两大能力。
  • 关闭了 CSRF(跨站请求伪造防护),因为 MCP 不是给浏览器直接用的,这部分无需开启。
  • 打开了 CORS(跨域资源共享),方便用 MCP inspector 测试。

这样配置之后,只有带 access_token 的访问才会被接受,否则会直接返回 401 未授权错误,例如:

curl http://localhost:8080/sse --fail-with-body
# 返回:
# curl: (22) The requested URL returned error: 401

要使用 MCP 服务器,先要获取一个 access_token。可通过 client_credentials 授权方式(用于机器到机器、服务账号的场景):

curl -XPOST http://localhost:8080/oauth2/token --data grant_type=client_credentials --user xushu:xushu666
# 返回:
# {"access_token":"<YOUR-ACCESS-TOKEN>","token_type":"Bearer","expires_in":299}

把返回的 access_token 记下来(它一般以 “ey” 开头),之后就可以用它来正常请求服务器了:

curl http://localhost:8080/sse -H"Authorization: Bearer YOUR_ACCESS_TOKEN"
# 服务器响应内容

你还可以直接在 MCP inspector 工具里用这个 access_token。从菜单的 Authentication > Bearer 处粘贴令牌并连接即可。

为MCP Client设置请求头

目前, mcp 的java sdk 没有提供api直接调用, 经过徐庶老师研究源码后, 你只能通过2种方式实现:

重写源码

扩展mcp 的sse方式java sdk的源码, 整个重写一遍。 工作量较大, 并且我预计过不了多久, spring ai和mcp协议都会更新这块。 看你的紧急程度, 如果考虑整体扩展性维护性,可以整体重写一遍:

提供一个重写思路

重写McpSseClientProperties

MCPSse客户端属性配置:新增请求头字段

package org.springframework.ai.autoconfigure.mcp.client.properties;

@ConfigurationProperties("spring.ai.mcp.client.sse")
public class McpSseClientProperties {
    public static final String CONFIG_PREFIX = "spring.ai.mcp.client.sse";
    private final Map<String, SseParameters> connections = new HashMap();
    
    private final Map<String, String> headersMap = new HashMap<>();
    private String defaultHeaderName;
    private String defaultHeaderValue;
    private boolean enableCompression = false;
    private int connectionTimeout = 5000;

    public McpSseClientProperties() {
    }

    public Map<String, SseParameters> getConnections() {
        return this.connections;
    }

    public Map<String, String> getHeadersMap() {
        return this.headersMap;
    }

    public String getDefaultHeaderName() {
        return this.defaultHeaderName;
    }

    public void setDefaultHeaderName(String defaultHeaderName) {
        this.defaultHeaderName = defaultHeaderName;
    }

    public String getDefaultHeaderValue() {
        return this.defaultHeaderValue;
    }

    public void setDefaultHeaderValue(String defaultHeaderValue) {
        this.defaultHeaderValue = defaultHeaderValue;
    }

    public boolean isEnableCompression() {
        return this.enableCompression;
    }

    public void setEnableCompression(boolean enableCompression) {
        this.enableCompression = enableCompression;
    }

    public int getConnectionTimeout() {
        return this.connectionTimeout;
    }

    public void setConnectionTimeout(int connectionTimeout) {
        this.connectionTimeout = connectionTimeout;
    }

    public static record SseParameters(String url) {
        public SseParameters(String url) {
            this.url = url;
        }

        public String url() {
            return this.url;
        }
    }
}

重写SseWebFluxTransportAutoConfiguration
自动装配添加请求头配置信息

package org.springframework.ai.autoconfigure.mcp.client;

@AutoConfiguration
@ConditionalOnClass({WebFluxSseClientTransport.class})
@EnableConfigurationProperties({McpSseClientProperties.class, McpClientCommonProperties.class})
@ConditionalOnProperty(
        prefix = "spring.ai.mcp.client",
        name = {"enabled"},
        havingValue = "true",
        matchIfMissing = true
)
public class SseWebFluxTransportAutoConfiguration {
    public SseWebFluxTransportAutoConfiguration() {
    }

    @Bean
    public List<NamedClientMcpTransport> webFluxClientTransports(McpSseClientProperties sseProperties, WebClient.Builder webClientBuilderTemplate, ObjectMapper objectMapper) {
        List<NamedClientMcpTransport> sseTransports = new ArrayList();
        Iterator var5 = sseProperties.getConnections().entrySet().iterator();
        Map<String, String> headersMap = sseProperties.getHeadersMap();
        while(var5.hasNext()) {
            Map.Entry<String, McpSseClientProperties.SseParameters> serverParameters = (Map.Entry)var5.next();
            WebClient.Builder webClientBuilder = webClientBuilderTemplate.clone()
                    .defaultHeaders(headers -> {
                        if (headersMap != null && !headersMap.isEmpty()) {
                            headersMap.forEach(headers::add);
                        }
                    })
                    .baseUrl(((McpSseClientProperties.SseParameters)serverParameters.getValue()).url());
            WebFluxSseClientTransport transport = new WebFluxSseClientTransport(webClientBuilder, objectMapper);
            sseTransports.add(new NamedClientMcpTransport((String)serverParameters.getKey(), transport));
        }

        return sseTransports;
    }

    @Bean
    @ConditionalOnMissingBean
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }

    @Bean
    @ConditionalOnMissingBean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}

使用:
在这里插入图片描述

设置WebClientCustomizer
在用Spring-ai-M8版本的时候, 发现提供了WebClientCustomizer进行扩展。 可以尝试:

1.根据用户凭证进行授权

curl -XPOST http://localhost:8080/oauth2/token --data grant_type=client_credentials --user xushu:xushu666 

2.根据授权后的token进行请求:

@Bean
public WebClientCustomizer webClientCustomizer() {
    // 认证 mcp server  /oauth?username:password   --> access_token
    return (builder) -> {
        builder.defaultHeader("Authorization","Bearer eyJraWQiOiIzYmMzMDRmZC02NzcyLTRkYTItODJiMy1hNTEwNGExMDBjNTYiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4dXNodSIsImF1ZCI6Inh1c2h1IiwibmJmIjoxNzQ2NzE4MjE5LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAiLCJleHAiOjE3NDY3MTg1MTksImlhdCI6MTc0NjcxODIxOSwianRpIjoiM2VhMzIyODctNTQ5NC00NWZlLThlZDItZGY1MjViNmIwNzkxIn0.Q-zWBZxa2CeFZo2YinenyaLb8KBMMua40X8YSs4n2fez7ODihtoVuCeJQnd2Q6qV2Pa8Z3cfH4QcMUuxMJ-_sLtZaSXpbCThH5q3KoQZ8C4MLJRTpuRqv4z1n7uLNXiVG2rya5hGwjTxu5qzHuBa2ri9pamRwmsjTz4vLHBJ1ILxDJcTkZUFuV1ExQJViewGt_7KMYcFqzGyRPiS4mm4wVvJTDjqcEGwMelu51L44K1DDYgt29vVLRVQEmnUtbBzePAxRqfw_HWJdhRSeQNiqRYCYhdAlPr3QZUFJa54GpuZn3CNyaXFoL7mENSR7wCYWx6wi--_REw6oaIfeSm-Xg");
    };
}

SSE是支持动态切换token的, 因为一个请求就是一个新的http请求, 不会出现多线程争抢。
但是需要动态请求:
curl -XPOST http://localhost:8080/oauth2/token --data grant_type=client_credentials --user xushu:xushu666 进行重新授权

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐