Skills

SpringAI中如何使用Skill?(Agent)

前面我们基于Qoder开发了一个帮我们做简历评估的Skill,我们接着把他集成到我们的应用代码中我们自己开发的Agent中。 Spring AI本身是不支持Skill的,但是Spring AI Alibaba 1.1.2版本发布了,…

TL;DR

前面我们基于Qoder开发了一个帮我们做简历评估的Skill,我们接着把他集成到我们的应用代码中我们自己开发的Agent中。 Spring AI本身是不支持Skill的,但是Spring AI Alibaba 1.1.2版本发布了,…

前面我们基于Qoder开发了一个帮我们做简历评估的Skill,我们接着把他集成到我们的应用代码中我们自己开发的Agent中。 Spring AI本身是不支持Skill的,但是Spring AI Alibaba 1.1.2版本发布了,他是支持Skill了的。我们接着一步一步的把这个简历评估的Skill集成进来。

增加依赖

在pom中把spring ai alibaba的版本升级到1.1.2.0

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

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-agent-framework</artifactId>
    <version>1.1.2.0</version>
</dependency>

增加Skill

在我们的项目的classpath下,创建一个skill目录,再把resume-check这个skill放进去,如:

工具定义

接着,我们需要提供工具,提供一个可以读取文件的工具,包括PDF、MD的读取:

package cn.hollis.llm.HelloLlm.agent.react.tools;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.extractor.WordExtractor;
import org.apache.poi.sl.usermodel.TextShape;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.ss.usermodel.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

/**
 * 通用文件读取工具,供 ReactAgent 调用。
 * 支持格式:PDF、Word(docx/doc)、Excel(xlsx/xls)、PowerPoint(pptx)、
 *           纯文本(txt/md/csv/log/xml/json/yaml/yml/properties)
 */
public class FileReaderTool {

    private static final Logger log = LoggerFactory.getLogger(FileReaderTool.class);

    /** 单次返回文本的最大字符数,防止超出上下文窗口 */
    private static final int MAX_CHARS = 8000;

    @Tool(description = """
            读取本地文件并返回其文本内容。
            支持格式:
              - PDF (.pdf)
              - Word (.docx / .doc)
              - Excel (.xlsx / .xls)  —— 返回所有 Sheet 的表格文本
              - PowerPoint (.pptx)    —— 返回所有幻灯片的文字
              - 纯文本 (.txt / .md / .csv / .log / .xml / .json / .yaml / .yml / .properties)
            参数 filePath 为文件的绝对路径。
            对于 PDF 文件,可选参数 startPage / endPage 指定读取页码范围(从 1 开始);其他格式忽略此参数。
            若文本过长会自动截断并在末尾注明剩余字符数。
            """)
    public String read_file(
            @ToolParam(description = "文件的绝对路径,支持 pdf/docx/doc/xlsx/xls/pptx/txt/md/csv 等格式") String filePath,
            @ToolParam(required = false, description = "【仅 PDF 有效】起始页码(从 1 开始,默认第 1 页)") Integer startPage,
            @ToolParam(required = false, description = "【仅 PDF 有效】结束页码(含,默认最后一页)") Integer endPage) {

        if (filePath == null || filePath.isBlank()) {
            return "Error: filePath 不能为空";
        }

        File file = new File(filePath);
        if (!file.exists()) {
            return "Error: 文件不存在 -> " + filePath;
        }
        if (!file.canRead()) {
            return "Error: 文件无读取权限 -> " + filePath;
        }

        String ext = getExtension(file.getName()).toLowerCase();

        try {
            return switch (ext) {
                case "pdf"  -> readPdf(file, startPage, endPage);
                case "docx" -> readDocx(file);
                case "doc"  -> readDoc(file);
                case "xlsx" -> readExcel(file, false);
                case "xls"  -> readExcel(file, true);
                case "pptx" -> readPptx(file);
                default     -> readText(file);
            };
        } catch (Exception e) {
            log.error("读取文件失败: {}", filePath, e);
            return "Error: 读取文件失败 -> " + e.getMessage();
        }
    }

    // -------------------------------------------------------------------------
    // PDF
    // -------------------------------------------------------------------------
    private String readPdf(File file, Integer startPage, Integer endPage) throws IOException {
        try (PDDocument doc = Loader.loadPDF(file)) {
            int total = doc.getNumberOfPages();
            int from  = (startPage != null && startPage >= 1) ? startPage : 1;
            int to    = (endPage   != null && endPage   >= 1) ? Math.min(endPage, total) : total;

            if (from > total) {
                return String.format("Error: startPage(%d) 超过文件总页数(%d)", from, total);
            }

            PDFTextStripper stripper = new PDFTextStripper();
            stripper.setStartPage(from);
            stripper.setEndPage(to);
            stripper.setSortByPosition(true);

            String text = stripper.getText(doc);
            String header = String.format("[PDF  共 %d 页,本次读取第 %d-%d 页]\n\n", total, from, to);
            return header + truncate(text);
        }
    }

    // -------------------------------------------------------------------------
    // Word docx
    // -------------------------------------------------------------------------
    private String readDocx(File file) throws IOException {
        try (XWPFDocument doc = new XWPFDocument(new FileInputStream(file))) {
            StringBuilder sb = new StringBuilder();
            for (XWPFParagraph para : doc.getParagraphs()) {
                String text = para.getText();
                if (text != null && !text.isBlank()) {
                    sb.append(text).append("\n");
                }
            }
            return "[Word (.docx)]\n\n" + truncate(sb.toString());
        }
    }

    // -------------------------------------------------------------------------
    // Word doc (旧格式)
    // -------------------------------------------------------------------------
    private String readDoc(File file) throws IOException {
        try (HWPFDocument doc = new HWPFDocument(new FileInputStream(file));
             WordExtractor extractor = new WordExtractor(doc)) {
            String text = String.join("\n", extractor.getParagraphText());
            return "[Word (.doc)]\n\n" + truncate(text);
        }
    }

    // -------------------------------------------------------------------------
    // Excel xlsx / xls
    // -------------------------------------------------------------------------
    private String readExcel(File file, boolean isOld) throws IOException {
        try (InputStream is = new FileInputStream(file);
             Workbook wb = isOld ? new HSSFWorkbook(is) : new XSSFWorkbook(is)) {

            StringBuilder sb = new StringBuilder();
            DataFormatter formatter = new DataFormatter();

            for (int si = 0; si < wb.getNumberOfSheets(); si++) {
                Sheet sheet = wb.getSheetAt(si);
                sb.append("=== Sheet: ").append(sheet.getSheetName()).append(" ===\n");
                for (Row row : sheet) {
                    StringBuilder rowSb = new StringBuilder();
                    for (Cell cell : row) {
                        if (rowSb.length() > 0) rowSb.append("\t");
                        rowSb.append(formatter.formatCellValue(cell));
                    }
                    String rowStr = rowSb.toString().trim();
                    if (!rowStr.isEmpty()) {
                        sb.append(rowStr).append("\n");
                    }
                }
                sb.append("\n");
            }
            String ext = isOld ? ".xls" : ".xlsx";
            return "[Excel (" + ext + ") 共 " + wb.getNumberOfSheets() + " 个 Sheet]\n\n" + truncate(sb.toString());
        }
    }

    // -------------------------------------------------------------------------
    // PowerPoint pptx
    // -------------------------------------------------------------------------
    private String readPptx(File file) throws IOException {
        try (XMLSlideShow ppt = new XMLSlideShow(new FileInputStream(file))) {
            StringBuilder sb = new StringBuilder();
            int slideNum = 1;
            for (var slide : ppt.getSlides()) {
                sb.append("--- 第 ").append(slideNum++).append(" 页 ---\n");
                for (var shape : slide.getShapes()) {
                    if (shape instanceof TextShape<?, ?> ts) {
                        String text = ts.getText();
                        if (text != null && !text.isBlank()) {
                            sb.append(text).append("\n");
                        }
                    }
                }
                sb.append("\n");
            }
            return "[PowerPoint (.pptx) 共 " + ppt.getSlides().size() + " 页]\n\n" + truncate(sb.toString());
        }
    }

    // -------------------------------------------------------------------------
    // 纯文本(txt/md/csv/log/xml/json/yaml/yml/properties 等)
    // -------------------------------------------------------------------------
    private String readText(File file) throws IOException {
        String content = Files.readString(file.toPath(), StandardCharsets.UTF_8);
        String ext = getExtension(file.getName()).toUpperCase();
        return "[文本文件 (." + ext.toLowerCase() + ")]\n\n" + truncate(content);
    }

    // -------------------------------------------------------------------------
    // 工具方法
    // -------------------------------------------------------------------------
    private String truncate(String text) {
        if (text == null) return "";
        if (text.length() <= MAX_CHARS) return text;
        int remaining = text.length() - MAX_CHARS;
        return text.substring(0, MAX_CHARS)
                + String.format("\n\n...[内容过长,已截断,剩余约 %d 字符未显示,请指定更小的范围继续读取]", remaining);
    }

    private String getExtension(String filename) {
        int dot = filename.lastIndexOf('.');
        return (dot >= 0 && dot < filename.length() - 1) ? filename.substring(dot + 1) : "";
    }
}

以上是一个文件读取工具,支持读取各种类型的工具,当然,需要增加依赖:

<!-- PDF 读取:Apache PDFBox -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.3</version>
</dependency>

<!-- Office 文档读取:Apache POI(Word/Excel/PPT) -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.3.0</version>
</dependency>
<!-- 兼容旧版 .doc / .xls 格式 -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-scratchpad</artifactId>
    <version>5.3.0</version>
</dependency>

修改SKILL.md

我们还需要修改一下SKILL.md,把其中使用的Read工具替换为我们自定义的read_file工具: 并且这个jd.md也可以改成markdown语法(如果出了问题的话),指定具体位置。

定义Agent

package cn.hollis.llm.HelloLlm.agent.react.controller;

import cn.hollis.llm.HelloLlm.agent.react.tools.FileReaderTool;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.agent.ReactAgent;
import com.alibaba.cloud.ai.graph.agent.hook.shelltool.ShellToolAgentHook;
import com.alibaba.cloud.ai.graph.agent.hook.skills.SkillsAgentHook;
import com.alibaba.cloud.ai.graph.agent.tools.ShellTool2;
import com.alibaba.cloud.ai.graph.checkpoint.savers.MemorySaver;
import com.alibaba.cloud.ai.graph.exception.GraphRunnerException;
import com.alibaba.cloud.ai.graph.skills.registry.SkillRegistry;
import com.alibaba.cloud.ai.graph.skills.registry.classpath.ClasspathSkillRegistry;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/skill")
public class SkillController {

    @Autowired
    private ChatModel dashScopeChatModel;

    @RequestMapping("/resumeCheck")
    public String resumeCheck(String message) throws GraphRunnerException {

        // 1. 技能注册表:从 classpath:skills 加载
        SkillRegistry registry = ClasspathSkillRegistry.builder()
                .classpathPath("skills")
                .build();

        // 2. Skills Hook:注册 read_skill 工具并注入技能列表到系统提示
        SkillsAgentHook skillsHook = SkillsAgentHook.builder()
                .skillRegistry(registry)
                .build();

        // 3. Shell Hook:提供 Shell 命令执行,用于文件下载
        ShellToolAgentHook shellHook = ShellToolAgentHook.builder()
                //避免脚本执行超时,超时时间设置的长一点
                .shellTool2(ShellTool2.builder("/tmp/skills/resume-check/").withCommandTimeout(300000).build())
                .build();

        // 4. 构建 Agent:同时挂载 Skills Hook、Shell Hook、 文件读取工具
        ReactAgent agent = ReactAgent.builder()
                .name("resume-agent")
                .model(dashScopeChatModel)
                .saver(new MemorySaver())
                .tools(ToolCallbacks.from(new FileReaderTool())[0])
                .hooks(List.of(skillsHook, shellHook))
                .enableLogging(true)
                .build();

        RunnableConfig config = RunnableConfig.builder()
                .threadId("10088") // threadId 指定会话 ID,暂时写死
                .build();

        AssistantMessage assistantMessage = agent.call(message, config);

        return assistantMessage.getText();
    }
}

我们一共给这个Agent提供了两个Hook和一个工具,一个Skills Hook,用于读取和查看Skill;一个Shell Hook,用于执行shell命令;一个文件读取工具,用来读取简历和JD。 SkillsAgentHook和ShellToolAgentHook是Spring AI Alibaba中内置的Hook,这个我们后面介绍一下他的实现原理,这里大家先这么用。

简历上传到MinIO

把我们之前用的那个pdf简历上传到本地minio中,然后再通过share获取到一个下载地址。 或者你把他上传到其他地方也行,只要能拿到一个url可以访问这个PDF就行。或者是写一个前端页面,提供PDF的上传也可以。

发起请求

启动应用,然后访问: http://localhost:8010/skill/resumeCheck?message=请帮我分析下这个简历:http://localhost:9001/api/v1/download-shared-object/xxxxxxxx 把上面的地址换成你自己的pdf的文件地址。

输出结果

这时候就可以去控制台看有日志输出了,过一会之后就能看到页面上的输出了。

常见问题

1、jd.md找不到 另外,这个Agent的执行,我发现有一定的偶发概率会出错,比较常见的出错是无法找到正确的jd.md,虽然我通过在SKILL.md用markdown语法指定了相对地址,确实提高了确定性,失败的概率大大降低了,但是还是有偶发情况。最稳定的办法就是: 1、jd手动传入,像简历一样。 2、jd内容直接写在SKILL.md中。 3、在SKILL.md中使用绝对路径来指定jd.md的地址。 4、定义一个jd读取工具,专门读取jd内容。 2、简历找不到 这就是不稳定导致的,这时候他应该用shell去拉取文件的,但是并没有,所以最好是做个前端页面,让用户上传,就确定性更高一些。 3、输出一半就结束了 agent输出了一些思考,然后就结束了。如: 解决办法是,重写skill.md的内容,让他一次性生成,增加以下约束即可:

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。