SpringAI Agent开发秘籍:让javaer也可以用上Agent Skills

要说最近AI相关话题中什么最火,毫无疑问是Claude Skills,让我感到震惊的倒不是它为什么火爆,而是SpringAI居然已经迅速支持上Skills了,这效率真的是堪比🚀了。

谁说AI时代java开发者要掉队了? 肉虽然不一定吃得上,但是喝口汤还是妥妥的

接下来我们通过构建一个code reviewer, 来实际体验一把,如何将SpringAI和Skills结合起来使用

一、项目创建

1. 基础环境要求

要体验SpringAI & Skills,目前需要升级到SpringAI 2.x版本,同时我们的SpringBoot也可以升级到4.x

  • SpringAI: 2.0.0-M2
  • JDK21
  • SpringBoot: 4.0.1

除了这几个基本依赖之外,我们可以选择一个支持Function Tool的大模型来作为这个实现的大脑中枢

我们这里选择智谱的大模型GLM-4.5-Flash (原因就是因为它免费,且效果还行,对所有想体验的小伙伴没有任何额外成本投入)

2. 项目创建

接下来我们创建一个SpringAI应用,对于一个标准的SpringAI应用,在pom.xml配置中,你会看到下面这些基础版本指定,这个也没什么好说的

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.1</version>
    <relativePath /> 
</parent>
<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <spring-ai.version>2.0.0-M2</spring-ai.version>
</properties>


<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>

<repositories>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/snapshot</url>
        <releases>
            <enabled>false</enabled>
        </releases>
    </repository>
    <repository>
        <name>Central Portal Snapshots</name>
        <id>central-portal-snapshots</id>
        <url>https://central.sonatype.com/repository/maven-snapshots/</url>
        <releases>
            <enabled>false</enabled>
        </releases>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
</repositories>

接下来重点看一下我们这个项目所用到的几个核心依赖

<dependencies>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-agent-utils</artifactId>
        <version>0.4.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-zhipuai</artifactId>
    </dependency>
</dependencies>
  • spring-ai-agent-utils: 这个就是SpringAI进行agent开发的关键依赖包
  • spring-ai-starter-model-zhipuai: 这个是智谱大模型进行交互的依赖包

3. 项目配置

依赖搞定之后,接下来就是在配置文件中,配置LLM访问的相关信息、以及agent相关配置参数,对应的配置文件 resources/application.yml

spring:
  ai:
    zhipuai:
      # api-key 使用你自己申请的进行替换;如果为了安全考虑,可以通过启动参数进行设置
      api-key: ${zhipuai-api-key}
      chat: # 聊天模型
        options:
          model: GLM-4.5-Flash

## Agent Configuration
agent:
  skills:
    dirs: classpath:/.claude/skills
  model: GLM-4.5-Flash

这几个配置看起来和之前SpringAI相关的并没有太多的区别,其中 agent 相关的配置中,主要设置了skills的存放路径,使用的model

根据上面的定义,我们将skills信息,放在resources/.claude/skills目录下

新增一个目录code-reviewer,目录下的文件为 SKILL.md

.claude/skills/code-reviewer/
└── SKILL.md

对应的内容如下

---
name: code-reviewer
description: Reviews Java code for best practices, security issues, and Spring Framework conventions. Use when user asks to review, analyze, or audit code.
---

# Code Reviewer

## Instructions

在审查代码时:

1. 检查是否存在安全漏洞(如SQL注入、XSS等)
2. 验证是否遵循了Spring Boot的最佳实践(如正确使用@Service、@Repository等注解)
3. 查找潜在的空指针异常
4. 提出提高代码可读性和可维护性的建议
5. 提供具体的逐行反馈,并附上代码示例
6. 以中文的方式返回代码评审结果

4. Skills简要说明

我们上面的Skill比较简单,就是一个markdown文档,SpringAI支持的Skills中,除了包含基本的SKILL.md文件(包含元数据(名称和描述)以及指导代理如何执行特定任务的说明)之外,还可以有相关的脚本、模板和参考资料

一个常见的skills结构如下

my-skill/
├── SKILL.md          # Required: instructions + metadata
├── scripts/          # Optional: executable code
├── references/       # Optional: documentation
└── assets/           # Optional: templates, resources

二、核心实现

现在前置准备已经完成,接下来开始正式的体验吧

2.1 交互日志打印 MyLoggingAdvisor

为了让系统与大模型之间的交互更清晰,我们将双方交互的日志进行更友好的打印(也顺便看一下,一次用户感知的问答过程中,实际上有几次交互)

public class MyLoggingAdvisor implements BaseAdvisor {
    private final int order;

    public final boolean showSystemMessage;

    public final boolean showAvailableTools;

    private AtomicInteger cnt = new AtomicInteger(1);

    private MyLoggingAdvisor(int order, boolean showSystemMessage, boolean showAvailableTools) {
        this.order = order;
        this.showSystemMessage = showSystemMessage;
        this.showAvailableTools = showAvailableTools;
    }

    @Override
    public int getOrder() {
        return this.order;
    }

    @Override
    public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
        System.out.println("======================= 第 " + cnt.getAndAdd(1) + " 轮 ====================================");

        StringBuilder sb = new StringBuilder("\nUSER: ");

        if (this.showSystemMessage && chatClientRequest.prompt().getSystemMessage() != null) {
            sb.append("\n - SYSTEM: ").append(first(chatClientRequest.prompt().getSystemMessage().getText(), 300));
        }

        if (this.showAvailableTools) {
            Object tools = "No Tools";

            if (chatClientRequest.prompt().getOptions() instanceof ToolCallingChatOptions toolOptions) {
                tools = toolOptions.getToolCallbacks().stream().map(tc -> tc.getToolDefinition().name()).toList();
            }

            sb.append("\n - TOOLS: ").append(ModelOptionsUtils.toJsonString(tools));
        }

        Message lastMessage = chatClientRequest.prompt().getLastUserOrToolResponseMessage();

        if (lastMessage.getMessageType() == MessageType.TOOL) {
            ToolResponseMessage toolResponseMessage = (ToolResponseMessage) lastMessage;
            for (var toolResponse : toolResponseMessage.getResponses()) {
                var tr = toolResponse.name() + ": " + first(toolResponse.responseData(), 1000);
                sb.append("\n - TOOL-RESPONSE: ").append(tr);
            }
        } else if (lastMessage.getMessageType() == MessageType.USER) {
            if (StringUtils.hasText(lastMessage.getText())) {
                sb.append("\n - TEXT: ").append(first(lastMessage.getText(), 1000));
            }
        }

        System.out.println("before: " + sb);
        return chatClientRequest;
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        StringBuilder sb = new StringBuilder("\nASSISTANT: ");

        if (chatClientResponse.chatResponse() == null || chatClientResponse.chatResponse().getResults() == null) {
            sb.append(" No chat response ");
            System.out.println("after: " + sb);
            return chatClientResponse;
        }

        for (var generation : chatClientResponse.chatResponse().getResults()) {
            var message = generation.getOutput();
            if (message.getToolCalls() != null) {
                for (var toolCall : message.getToolCalls()) {
                    sb.append("\n - TOOL-CALL: ")
                            .append(toolCall.name())
                            .append(" (")
                            .append(toolCall.arguments())
                            .append(")");
                }
            }

            if (message.getText() != null) {
                if (StringUtils.hasText(message.getText())) {
                    sb.append("\n - TEXT: ").append(first(message.getText(), 1200));
                }
            }
        }

        System.out.println("after: " + sb);
        return chatClientResponse;
    }

    private String first(String text, int n) {
        if (text.length() <= n) {
            return text;
        }
        return text.substring(0, n) + "...";
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private int order = 0;

        private boolean showSystemMessage = true;

        private boolean showAvailableTools = true;

        public Builder order(int order) {
            this.order = order;
            return this;
        }

        public Builder showSystemMessage(boolean showSystemMessage) {
            this.showSystemMessage = showSystemMessage;
            return this;
        }

        public Builder showAvailableTools(boolean showAvailableTools) {
            this.showAvailableTools = showAvailableTools;
            return this;
        }

        public MyLoggingAdvisor build() {
            MyLoggingAdvisor advisor = new MyLoggingAdvisor(this.order, this.showSystemMessage,
                    this.showAvailableTools);
            return advisor;
        }
    }
}

2.2 准备用于评审的代码

我们直接使用 实战 | 零基础搭建知识库问答机器人:基于SpringAI+RAG的完整实现 中的代码分块的内容作为待评审的内容,看下这段简单的文本分块工具会评审出什么内容

package com.git.hui.springai.app.demo;

import org.springframework.ai.document.Document;

import java.util.ArrayList;
import java.util.List;

/**
 * 文档分块工具类
 * 将长文档分割成较小的块,以便更好地进行向量化和检索
 */
public class DocumentChunker {
    
    private final int maxChunkSize;
    private final int overlapSize;

    public static DocumentChunker DEFAULT_CHUNKER = new DocumentChunker();
    
    public DocumentChunker() {
        this(500, 50); // 默认值:最大块大小500个字符,重叠50个字符
    }
    
    public DocumentChunker(int maxChunkSize, int overlapSize) {
        this.maxChunkSize = maxChunkSize;
        this.overlapSize = overlapSize;
    }
    
    /**
     * 将文档分割成块
     * 
     * @param document 输入文档
     * @return 分割后的文档块列表
     */
    public List<Document> chunkDocument(Document document) {
        String content = document.getText();
        if (content == null || content.trim().isEmpty()) {
            return List.of(document);
        }
        
        List<String> chunks = splitText(content);
        List<Document> chunkedDocuments = new ArrayList<>();
        
        for (int i = 0; i < chunks.size(); i++) {
            String chunk = chunks.get(i);
            String chunkId = document.getId() + "_chunk_" + i;
            
            // 创建新的文档块,保留原始文档的元数据
            Document chunkDoc = new Document(chunkId, chunk, new java.util.HashMap<>(document.getMetadata()));
            
            // 添加块相关的元数据
            chunkDoc.getMetadata().put("chunk_index", i);
            chunkDoc.getMetadata().put("total_chunks", chunks.size());
            chunkDoc.getMetadata().put("original_document_id", document.getId());
            
            chunkedDocuments.add(chunkDoc);
        }
        
        return chunkedDocuments;
    }
    
    /**
     * 将文本分割成块
     * 
     * @param text 输入文本
     * @return 分割后的文本块列表
     */
    private List<String> splitText(String text) {
        List<String> chunks = new ArrayList<>();
        
        // 按多种分隔符分割,优先在语义边界处分割(包括中文句号、问号、感叹号等)
        String[] sentences = text.split("(?<=。)|(?<=!)|(?<=!)|(?<=?)|(?<=\\?)|(?<=\\n\\n)");
        
        StringBuilder currentChunk = new StringBuilder();
        
        for (String sentence : sentences) {
            // 跳过空句子
            if (sentence.trim().isEmpty()) {
                continue;
            }
            
            // 如果当前块加上新句子不超过最大大小,就添加到当前块
            if (currentChunk.length() + sentence.length() <= maxChunkSize) {
                if (currentChunk.length() > 0) {
                    currentChunk.append(sentence);
                } else {
                    currentChunk.append(sentence);
                }
            } else {
                // 如果当前块为空,但是单个句子太长,需要强制分割
                if (currentChunk.length() == 0) {
                    List<String> subChunks = forceSplit(sentence, maxChunkSize);
                    for (int i = 0; i < subChunks.size(); i++) {
                        String subChunk = subChunks.get(i);
                        // 如果不是最后一个子块,添加到当前块并保存
                        if (i < subChunks.size() - 1) {
                            chunks.add(subChunk);
                        } else {
                            currentChunk.append(subChunk);
                        }
                    }
                } else {
                    // 保存当前块
                    chunks.add(currentChunk.toString());
                    // 开始新块,包含重叠部分
                    currentChunk = new StringBuilder();
                    
                    // 添加重叠部分,如果句子长度大于重叠大小,则只取末尾部分
                    if (sentence.length() > overlapSize) {
                        String overlap = sentence.substring(Math.max(0, sentence.length() - overlapSize));
                        currentChunk.append(overlap);
                        currentChunk.append(sentence);
                    } else {
                        currentChunk.append(sentence);
                    }
                }
            }
        }
        
        // 添加最后一个块
        if (currentChunk.length() > 0) {
            chunks.add(currentChunk.toString());
        }
        
        return chunks;
    }
    
    /**
     * 强制将长文本分割成指定大小的块
     * 
     * @param text 输入文本
     * @param maxSize 最大块大小
     * @return 分割后的文本块列表
     */
    private List<String> forceSplit(String text, int maxSize) {
        List<String> chunks = new ArrayList<>();
        
        int start = 0;
        while (start < text.length()) {
            int end = Math.min(start + maxSize, text.length());
            String chunk = text.substring(start, end);
            chunks.add(chunk);
            start = end;
        }
        
        return chunks;
    }
    
    /**
     * 将多个文档分别分割成块
     * 
     * @param documents 输入文档列表
     * @return 分割后的文档块列表
     */
    public List<Document> chunkDocuments(List<Document> documents) {
        List<Document> allChunks = new ArrayList<>();
        
        for (Document document : documents) {
            allChunks.addAll(chunkDocument(document));
        }
        
        return allChunks;
    }
}

2.3 核心实现

配置Agent实现代码评审

Bean定义与依赖注入

  • CommandLineRunner: Spring启动后自动执行的接口
  • ChatClient.Builder: 用于构建聊天客户端
  • @Value("${agent.skills.dirs:Unknown}"): 注入配置属性,获取技能目录资源列表

ChatClient配置链

  • 系统提示词配置:
  • 技能工具配置:
    • SkillsTool.builder().addSkillsResources(agentSkillsDirs).build(): 动态加载预定义的技能资源
    • FileSystemTools.builder().build(): 提供文件系统访问能力
    • ShellTools.builder().build(): 提供命令行执行能力
  • Advisor配置:
    • ToolCallAdvisor.builder().build(): 处理工具调用逻辑
    • MyLoggingAdvisor.builder().showAvailableTools(false).showSystemMessage(false).build(): 自定义日志记录,隐藏工具和系统消息详情

代码评审执行流程

请求执行

  • prompt(): 构建提示词
  • .call(): 发起AI请求
  • .content(): 获取返回结果
@Bean
CommandLineRunner commandLineRunner(ChatClient.Builder chatClientBuilder,
                                    @Value("${agent.skills.dirs:Unknown}") List<Resource> agentSkillsDirs) throws IOException {

    return args -> {

        ChatClient chatClient = chatClientBuilder // @formatter:off
                .defaultSystem("始终运用现有技能协助用户满足其要求.")

                // Skills tool
                .defaultToolCallbacks(SkillsTool.builder().addSkillsResources(agentSkillsDirs).build())
                // 支持读取系统文件内容,用于读取我们需要评审的代码
                .defaultTools(FileSystemTools.builder().build())
                // 支持执行脚本,如果skills中存在script,那么这些脚本的执行,靠的就是它
                .defaultTools(ShellTools.builder().build())

                .defaultAdvisors(
                        // Tool Calling advisor
                        ToolCallAdvisor.builder().build(),
                        // Custom logging advisor
                        MyLoggingAdvisor.builder()
                                .showAvailableTools(false)
                                .showSystemMessage(false)
                                .build())
                .build();
        // @formatter:on

        var answer = chatClient
                // 下面具体的代码位置,请根据实际的位置进行替换
                .prompt("""
                        按照最佳实际的方式,评审下面的代码实现:

                         D:\\Workspace\\hui\\project\\spring-ai-demo\\v2\\T01-agentic-skills-simple-design\\src\\main\\java\\com\\git\\hui\\springai\\app\\demo\\DocumentChunker.java
                         """)
                .call()
                .content();

        System.out.println("The Answer: " + answer);
    };
}

2.4 执行演示

接下来我们启动项目,验证一下效果如何(在启动命令行参数中,配置上大模型的api-key,当然也可以直接在yml配置文件中进行配置)

@Slf4j
@SpringBootApplication
public class T01Application {

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

从上面的截图也可以看出,系统与大模型之间进行了三轮对话,

  1. 第一轮: 用户的原始诉求 -> 大模型
  • 大模型识别到需要进行代码评审,给系统响应 {"command":"code-reviewer"}
  1. 第二轮:系统接收到响应之后,读取skills的约束进行响应
  • 大模型返回需要读取需要评审的代码内容,对应的响应为 {"filePath":"D:\\Workspace\\hui\\project\\spring-ai-demo\\v2\\T01-agentic-skills-simple-design\\src\\main\\java\\com\\git\\hui\\springai\\app\\demo\\DocumentChunker.java"}
  1. 第三轮:系统读取代码内容,发送给大模型
  • 大模型返回评审结果

下面是完整的返回文本,有兴趣的小伙伴可以看看

基于对代码的详细分析,我为您提供以下代码评审报告:

## 📋 代码评审报告 - DocumentChunker.java

### ✅ **优点**

1. **功能完整性强**:代码实现了完整的文档分块功能,包括:
   - 单文档分块
   - 批量文档分块
   - 智能语义边界分割
   - 重叠机制
   - 长文本强制分割

2. **良好的文档注释**:类和方法都有清晰的中文注释,说明了功能用途

3. **合理的默认值**:提供了合理的默认参数(maxChunkSize=500, overlapSize=50)

4. **元数据保留**:正确保留了原始文档的元数据,并添加了块相关的元数据

### ⚠️ **需要改进的问题**

#### 1. **线程安全问题**

// 问题:静态实例不是线程安全的
public static DocumentChunker DEFAULT_CHUNKER = new DocumentChunker();

**建议**:使用 `volatile` 或 `AtomicReference`,或者移除静态实例让用户自己管理。

#### 2. **参数验证不足**

// 问题:构造函数没有参数验证
public DocumentChunker(int maxChunkSize, int overlapSize) {
    this.maxChunkSize = maxChunkSize;
    this.overlapSize = overlapSize;
}

**建议**:添加参数验证,确保 `maxChunkSize > 0` 且 `overlapSize < maxChunkSize`。

#### 3. **正则表达式复杂度**

// 问题:正则表达式过于复杂,难以维护
String[] sentences = text.split("(?<={。})|(?<=!)|(?<=!)|(?<=?)|(?<=\\?)|(?<=\\n\\n)");

**建议**:提取为常量或使用更清晰的方式处理多语言分隔符。

#### 4. **性能优化空间**

// 问题:StringBuilder操作可以优化
if (currentChunk.length() > 0) {
    currentChunk.append(sentence);
} else {
    currentChunk.append(sentence);
}

**建议**:简化为 `currentChunk.append(sentence);`

#### 5. **异常处理缺失**
代码中没有处理可能的异常情况,如:
- 内存不足
- 文档过大导致性能问题

### 🔧 **具体改进建议**

#### 1. **添加参数验证**

public DocumentChunker(int maxChunkSize, int overlapSize) {
    if (maxChunkSize <= 0) {
        throw new IllegalArgumentException("maxChunkSize must be positive");
    }
    if (overlapSize >= maxChunkSize) {
        throw new IllegalArgumentException("overlapSize must be less than maxChunkSize");
    }
    this.maxChunkSize = maxChunkSize;
    this.overlapSize = overlapSize;
}


#### 2. **线程安全改进**

public static final DocumentChunker DEFAULT_CHUNKER = new DocumentChunker();
// 或者移除静态实例,让用户自己管理


#### 3. **提取常量**

private static final String SENTENCE_SEPARATOR_PATTERN = 
    "(?<={。})|(?<=!)|(?<=!)|(?<=?)|(?<=\\?)|(?<=\\n\\n)";


#### 4. **添加Builder模式**
考虑使用Builder模式来更灵活地配置参数。

#### 5. **性能优化**

// 优化StringBuilder操作
private List<String> splitText(String text) {
    List<String> chunks = new ArrayList<>();
    String[] sentences = text.split(SENTENCE_SEPARATOR_PATTERN);
    StringBuilder currentChunk = new StringBuilder();
    
    for (String sentence : sentences) {
        if (sentence.trim().isEmpty()) continue;
        
        if (currentChunk.length() + sentence.length() <= maxChunkSize) {
            currentChunk.append(sentence);
        } else {
            // 处理块分割逻辑
        }
    }
    // ...
}


### 🎯 **总体评价**

这是一个功能完整、设计合理的文档分块工具类,主要问题集中在线程安全、参数验证和代码可维护性方面。通过上述改进,可以显著提升代码的健壮性和可维护性。

**建议优先级**:
1. 高优先级:参数验证、线程安全
2. 中优先级:性能优化、代码简化
3. 低优先级:Builder模式、异常处理

三、小结

SpringAI的Agent开发范式配合Skills机制,非常简单就实现了AI应用的工程化。整个过程实现下来,门槛还是比较低的。 不得不高喊一声:Spring🐂🍺

实现方式虽然简单,但是这个背后的设计哲学、开发思维的转变,还是很值得我们学习参考的——在AI时代,如何将我们现有的技能(如可复用、模块化思设计等)转变到大模型应用开发,这可能是我们每一个旧时代程序员最大的财富。

那么这一套是怎么实现的呢?

Spring AI采用基于工具的集成方法,通过实现各种工具,使任何LLM都能回调执行,Skills的运行过程,通常是下面三步:

  1. 发现(启动阶段)
  • 通过SKILL.md文件中的元数据,快速实现技能的安装注册
  1. 语义匹配(对话过程中)
  • 当用户发出请求时,LLM 会检查工具定义中嵌入的技能描述。如果 LLM 判断用户请求在语义上与某个技能的描述匹配,则会调用该技能工具,并将技能名称作为参数传递给它。
  1. 执行(技能调用时)
  • 当调用技能工具时,SkillsTool会从磁盘加载完整的SKILL.md内容,并将其与技能的基础目录路径一起返回给大型语言模型(LLM)。然后,LLM会按照技能内容中的指令执行。如果技能引用了其他文件或辅助脚本,LLM会使用FileSystemToolsRead函数或ShellToolsBash函数来按需访问它们

项目源码:

零基础入门:


实战

参考:

posted @ 2026-01-28 10:37  一灰灰Blog  阅读(1)  评论(0)    收藏  举报