在开始三步集成前,先确保你已准备好以下基础环境:

JDK 17+:Spring Boot 3 和 Spring AI 基于较新的 Java 版本,确保你的 JDK 版本不低于 17。
Maven 或 Gradle:本文以 Maven 为例,它是我们管理项目依赖的得力助手。
一个顺手的 IDE:选择你最熟悉的 IDE 即可,本文以 IntelliJ IDEA 为例。
一个 LLM厂商的 API Key:本文以 DeepSeek 为例,价格便宜,使用起来很方便。
第一步:添加依赖
让我们从创建项目开始。如果你使用 Spring Initializr 会更简单,但为了让大家理解每一步的细节,我们选择从零开始创建一个 Maven 项目。

首先,创建一个新的Maven空项目:
————————————————

<?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.5.0</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>spring-ai-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>21</java.version>
        <spring-ai.version>1.0.0-M5</spring-ai.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring AI OpenAI Starter -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        </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>

</project>

第二步:配置凭证

有了依赖,接下来需要告诉 Spring AI 如何连接到 AI 服务。在 src/main/resources 目录下创建 application.yml 文件(如果不存在的话),添加以下配置:

server:
  error:
    include-message: always
    include-stacktrace: always

spring:
  ai:
    openai:
      api-key: **************************************
      base-url: https://open.bigmodel.cn
      chat:
        completions-path: /api/paas/v4/chat/completions
        options:
          model: glm-5.1

第三步:编写代码调用

万事俱备,让我们开始编写代码来调用 AI 模型。

package com.ai.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
@RequestMapping("/chat")
public class ChatController {
    
    private final ChatClient chatClient;
    private final ChatMemory chatMemory;  // 新增:对话记忆
    private final ImageModel imageModel;
    
    public ChatController(ChatClient.Builder chatClientBuilder, ImageModel imageModel) {
        this.chatClient = chatClientBuilder.build();
        this.chatMemory = new InMemoryChatMemory();  // 初始化内存记忆
        this.imageModel = imageModel;
    }

    // 带历史的对话接口
    @GetMapping("/with-history")
    public String chatWithHistory(@RequestParam String message, 
                                   @RequestParam(defaultValue = "default") String sessionId) {
        return chatClient.prompt()
                .user(message)
                .advisors(new MessageChatMemoryAdvisor(chatMemory, sessionId, 10))
                .call()
                .content();
    }


    //因为大模型配置的url不支持图片调用。所以需要单独调用图片模型。
    @GetMapping("/image")
    public String generateImage(@RequestParam String prompt) {
        ImageResponse response = imageModel.call(new ImagePrompt(prompt));
        return response.getResult().getOutput().getUrl();
    }


    @PostMapping("/analyze")
    public String analyzeFile(@RequestParam("file") MultipartFile file) throws IOException {
        String content = new String(file.getBytes());
        String prompt = "请分析以下文档内容,并给出摘要:\n" + content;

        return chatClient.prompt()
                .user(prompt)
                .call()
                .content();
    }


}
package com.ai.controller;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@Configuration
public class CorsConfig implements WebMvcConfigurer {
   
 
    @Override
    public void addCorsMappings(CorsRegistry registry) {
   
        registry.addMapping("/**")
                .allowedOrigins("/chat/*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}
package com.ai.controller;

import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.AbstractEmbeddingModel;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * 学习/本地开发用的 EmbeddingModel:
 * <ul>
 *   <li>不依赖外部向量服务或厂商 embedding 接口,避免因网络/网关不兼容导致 RAG 跑不通。</li>
 *   <li>用一个简单的“hash bag-of-words”把文本映射到固定维度向量(可重复、确定性)。</li>
 * </ul>
 *
 * <p>注意:该实现只用于演示 RAG 流程,不具备真实 embedding 的语义能力;生产环境请替换为正规 embedding 模型。</p>
 */
@Component
@Primary
@ConditionalOnProperty(value = "app.rag.embedding", havingValue = "local", matchIfMissing = true)
public class LocalHashEmbeddingModel extends AbstractEmbeddingModel {

    private static final int DIMS = 384;

    @Override
    public EmbeddingResponse call(EmbeddingRequest request) {
        List<String> inputs = request.getInstructions();
        List<Embedding> results = new ArrayList<>(inputs.size());
        for (int i = 0; i < inputs.size(); i++) {
            results.add(new Embedding(embedText(inputs.get(i)), i));
        }
        return new EmbeddingResponse(results);
    }

    @Override
    public float[] embed(Document document) {
        return embedText(document.getText());
    }

    private float[] embedText(String text) {
        float[] v = new float[DIMS];
        if (text == null || text.isBlank()) {
            return v;
        }

        // 基于 token 的简易 hashing:把 token hash 到维度桶里累加
        // 为了演示稳定性,使用 UTF-8 bytes 做 hash。
        String[] tokens = text.toLowerCase().split("\\s+");
        for (String token : tokens) {
            if (token.isBlank()) continue;
            int h = fnv1a32(token.getBytes(StandardCharsets.UTF_8));
            int idx = Math.floorMod(h, DIMS);
            v[idx] += 1.0f;
        }

        // L2 normalize,便于余弦相似度比较
        double norm = 0.0;
        for (float x : v) norm += (double) x * x;
        norm = Math.sqrt(norm);
        if (norm > 0.0) {
            for (int i = 0; i < v.length; i++) {
                v[i] = (float) (v[i] / norm);
            }
        }

        return v;
    }

    private int fnv1a32(byte[] data) {
        int hash = 0x811c9dc5;
        for (byte b : data) {
            hash ^= (b & 0xff);
            hash *= 0x01000193;
        }
        return hash;
    }
}
package com.ai.controller;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.File;

@Configuration
public class RagConfig {

    /**
     * RAG 向量库(VectorStore)的最简实现:
     * <ul>
     *   <li>使用 {@link SimpleVectorStore}:不依赖外部数据库,适合入门与本地开发。</li>
     *   <li>依赖 {@link EmbeddingModel} 将文本片段向量化后存储,供相似度检索使用。</li>
     *   <li>可选落盘:重启应用后仍能继续使用已入库的内容。</li>
     * </ul>
     *
     * <p>注意:生产环境通常会替换为 Milvus/pgvector/Elastic 等持久化向量库。</p>
     */
    @Bean
    public VectorStore vectorStore(
            EmbeddingModel embeddingModel,
            @Value("${app.rag.store-path:data/vectorstore.json}") String storePath
    ) {
        var store = new SimpleVectorStore(embeddingModel);

        var file = new File(storePath);
        if (file.exists() && file.isFile()) {
            // 本地开发:启动时自动加载上次保存的向量库快照
            store.load(file);
        } else {
            var parent = file.getParentFile();
            if (parent != null) {
                // 确保目录存在;如果创建失败,后续 save() 会抛出更明确的异常
                parent.mkdirs();
            }
        }

        return store;
    }
}
package com.ai.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/rag")
public class RagController {

    /**
     * 这个 Controller 演示“进阶版”的核心:RAG(Retrieval-Augmented Generation,检索增强生成)。
     *
     * <p>RAG 的基本流程:</p>
     * <ol>
     *   <li>Ingest:把文档切分为多个 chunk(片段),每个 chunk 做 embedding 并写入 VectorStore。</li>
     *   <li>Ask:对问题做 embedding 检索出最相关的 chunk,把这些 chunk 作为“资料”拼进 prompt 让模型回答。</li>
     * </ol>
     *
     * <p>这里用 {@link SimpleVectorStore} 作为最简向量库实现,便于你先跑通思路;后续再替换为真正的向量数据库。</p>
     */
    private final ChatClient chatClient;
    private final VectorStore vectorStore;
    private final TokenTextSplitter textSplitter;
    private final String storePath;

    public RagController(
            ChatClient.Builder chatClientBuilder,
            VectorStore vectorStore,
            @Value("${app.rag.store-path:data/vectorstore.json}") String storePath
    ) {
        this.chatClient = chatClientBuilder.build();
        this.vectorStore = vectorStore;
        this.storePath = storePath;
        // TokenTextSplitter:按 token 粗略切分文本,避免单次 embedding/检索的文本过长
        this.textSplitter = TokenTextSplitter.builder().build();
    }

    /**
     * 文档入库:上传一个文件,把内容切分为多个 chunk 写入向量库。
     *
     * <p>Postman 测试:</p>
     * <ul>
     *   <li>POST /rag/ingest</li>
     *   <li>Body=form-data,字段名必须是 file(类型选 File)</li>
     * </ul>
     *
     * <p>返回 docId 方便你做追踪(metadata 里也会带上)。</p>
     */
    @PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public Map<String, Object> ingest(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "source", required = false) String source
    ) throws IOException {
        String text = new String(file.getBytes(), StandardCharsets.UTF_8);
        String docId = UUID.randomUUID().toString();

        Map<String, Object> baseMetadata = new HashMap<>();
        baseMetadata.put("docId", docId);
        baseMetadata.put("filename", file.getOriginalFilename());
        baseMetadata.put("source", source != null ? source : "upload");

        // 先构建一个“整篇文档” Document,再切分成 chunks;每个 chunk 继承 metadata
        List<Document> chunks = textSplitter.apply(List.of(new Document(text, baseMetadata)));
        vectorStore.add(chunks);

        // SimpleVectorStore 支持落盘;便于本地开发重启后继续使用之前入库的内容
        persistIfPossible();

        return Map.of(
                "docId", docId,
                "chunks", chunks.size()
        );
    }

    /**
     * 带检索的问答:从向量库中检索与问题最相近的 topK 个片段,作为“资料”交给模型回答。
     *
     * <p>返回里会带 contexts(检索到的片段),方便你观察:</p>
     * <ul>
     *   <li>检索是否命中正确内容</li>
     *   <li>topK 取值该怎么调</li>
     * </ul>
     */
    @GetMapping("/ask")
    public Map<String, Object> ask(
            @RequestParam String question,
            @RequestParam(defaultValue = "4") int topK
    ) {
        List<Document> docs = vectorStore.similaritySearch(SearchRequest.query(question).withTopK(topK));

        String context = buildContext(docs);
        String prompt = """
你是一个严谨的助手。请只基于【资料】回答【问题】。如果资料不足以回答,请明确说“不确定/资料不足”,并说明缺少什么信息。

【资料】
%s

【问题】
%s
""".formatted(context, question);

        String answer = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        return Map.of(
                "answer", answer,
                "topK", topK,
                "contexts", docs.stream().map(d -> Map.of(
                        "id", d.getId(),
                        "score", d.getScore(),
                        "metadata", d.getMetadata(),
                        "content", d.getContent()
                )).toList()
        );
    }

    private String buildContext(List<Document> docs) {
        if (docs.isEmpty()) {
            return "(无检索结果)";
        }
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < docs.size(); i++) {
            Document d = docs.get(i);
            sb.append("[").append(i + 1).append("] ");
            Object filename = d.getMetadata().getOrDefault("filename", "");
            Object docId = d.getMetadata().getOrDefault("docId", "");
            sb.append("(docId=").append(docId).append(", file=").append(filename).append(")\n");
            sb.append(d.getContent()).append("\n\n");
        }
        return sb.toString();
    }

    private void persistIfPossible() {
        if (vectorStore instanceof SimpleVectorStore svs) {
            // 仅对 SimpleVectorStore 生效:把内存向量库保存为本地文件
            svs.save(new File(storePath));
        }
    }
}
package com.ai.controller;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;


@SpringBootApplication
public class SpringAiDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringAiDemoApplication.class, args);
    }
}

说明:

RAG 是什么(用你项目的话讲)

RAG = 检索增强生成:先把文档切成小段并“向量化”存起来/ingest;提问时先从向量库里检索最相关的几段,再把这些“资料”连同问题一起交给大模型回答/ask

Postman 怎么测

  1. 需要分析的文件,入本地库-->>目前用的文件夹
  • POST http://localhost:8080/rag/ingest
  • Body → form-data
    • file (File) 选一个 txt/md 等文本文件
    • source (Text) 可填 manual
  1. 提问
  • GET http://localhost:8080/rag/ask?question=你文档里提到的xxx是什么&topK=4

返回会包含:

  • answer:模型回答

contexts:检索到的片段(含 metadata/score)
 

你现在项目已经做到:

  • Spring Boot 已集成 Spring AI,并且 chat 已对接智谱(base-url=https://open.bigmodel.cn + chat.completions-path=/api/paas/v4/chat/completions),/chat/with-history/chat/analyze 这类“文本对话/总结”可以走智谱。
  • 进阶能力(RAG)代码已经加上了:/rag/ingest + /rag/ask,并且能跑通“入库→检索→拼资料→回答”的流程。

那如果我要分析项目中的某个类的代码呢,怎么实现?

在你现有项目上加一个“代码分析”接口:传入 Java 文件路径或类全限定名,服务端读取源码内容(限制在 src/main/java 内,防止路径穿越),再调用当前的 chatClient 让大模型做解释/风险点/改进建议。下面我直接把这个功能加到项目里,并给你 Postman 的测试方式。

package com.ai.controller;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;

@RestController
@RequestMapping("/code")
public class CodeAnalysisController {

    private final ChatClient chatClient;
    private final Path projectRoot;

    /**
     * 代码分析接口(面向学习):
     * <ul>
     *   <li>传入类全限定名(fqcn)或相对路径(path)</li>
     *   <li>服务端读取源码后交给大模型做讲解/问题点/改进建议</li>
     * </ul>
     *
     * <p>安全约束:仅允许读取 src/main/java 下的 .java 文件。</p>
     */
    public CodeAnalysisController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
        this.projectRoot = Paths.get("").toAbsolutePath().normalize();
    }

    @GetMapping(value = "/analyze", produces = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Object> analyze(
            @RequestParam(required = false) String fqcn,
            @RequestParam(required = false) String path
    ) throws IOException {
        Path javaFile = resolveJavaFile(fqcn, path);
        String code = Files.readString(javaFile, StandardCharsets.UTF_8);

        // 避免一次塞太多 token(学习项目先做个简单上限)
        int maxChars = 40_000;
        if (code.length() > maxChars) {
            code = code.substring(0, maxChars) + "\n\n// ... truncated ...\n";
        }

        String prompt = """
                你是资深 Java/Spring Boot 工程师。请对下面这段源码进行分析,输出要点清晰的学习笔记:
                1) 这个类的职责/对外接口/关键依赖
                2) 关键逻辑流程(按调用顺序)
                3) 潜在问题:异常处理、边界条件、并发/性能、安全风险
                4) 可改进建议:可读性、可测试性、可扩展性(给出具体改法)
                5) 如果是 Controller,请补充如何用 Postman 测试

                【文件路径】
                %s

                【源码】
                ```java
                %s
                ```
                """.formatted(javaFile.toString(), code);

        String result = chatClient.prompt()
                .user(prompt)
                .call()
                .content();

        return Map.of(
                "file", javaFile.toString(),
                "analysis", result
        );
    }

    private Path resolveJavaFile(String fqcn, String path) {
        if (!StringUtils.hasText(fqcn) && !StringUtils.hasText(path)) {
            throw new IllegalArgumentException("Either 'fqcn' or 'path' must be provided.");
        }
        if (StringUtils.hasText(fqcn) && StringUtils.hasText(path)) {
            throw new IllegalArgumentException("Provide only one: 'fqcn' or 'path'.");
        }

        Path srcRoot = projectRoot.resolve("src/main/java").normalize();
        Path resolved;

        if (StringUtils.hasText(fqcn)) {
            String rel = fqcn.trim().replace('.', '/') + ".java";
            resolved = srcRoot.resolve(rel);
        } else {
            // 允许传 "com/ai/controller/ChatController.java" 或 "src/main/java/..."
            String p = path.trim().replace('\\', '/');
            if (p.startsWith("src/main/java/")) {
                p = p.substring("src/main/java/".length());
            }
            resolved = srcRoot.resolve(p);
        }

        resolved = resolved.toAbsolutePath().normalize();

        if (!resolved.startsWith(srcRoot.toAbsolutePath().normalize())) {
            throw new IllegalArgumentException("Path is not allowed.");
        }
        if (!resolved.toString().endsWith(".java")) {
            throw new IllegalArgumentException("Only .java files are allowed.");
        }
        if (!Files.exists(resolved) || !Files.isRegularFile(resolved)) {
            throw new IllegalArgumentException("File not found: " + resolved);
        }
        return resolved;
    }
}

已在项目里加好了“分析某个类源码”的实现:新增了 CodeAnalysisController,提供接口 GET /code/analyze,会读取 src/main/java 下的 .java 文件并交给大模型输出学习笔记。

怎么用(Postman)

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐