开发实践 / Spring AI 实战

Spring AI 2.0 Agent Skills:使 Skill 读取文档、执行脚本

给 Skill 配上业务规则、计算脚本和输出模板,用电商售后案例理解文档与脚本的接入方式。

各位朋友大家好,我是一言,继续更新 Spring AI 2.0 系列。

上一篇中,我们通过使用 order-cancellation Skill 来连接 queryOrder 和 cancelOrder 两个 Tool。当时整个 Skill 只有一份 SKILL.md。

实际项目中,一个流程一般包含业务规则、计算脚本以及输出模板。这篇继续以电商场景为例,跑一个完整的订单退款 Skill:

text
读退款规则 → 运行计算脚本 → 读客服模板 → 生成回复

我会用真实的模型跑完这条调用链,并且检查额外资源是否真的按照需求进入到上下文中去。

一个 Skill 可以带什么#

Agent Skills 规范中,一个 Skill 至少要有 SKILL.md 文件,并且可以附加三个目录:

目录 适合放什么 这次的内容
references/ 业务规则、接口文档、长资料 未发货订单退款规则
scripts/ 可重复执行的确定性任务 退款金额计算脚本
assets/ 模板、图片、输出所需的素材 客服回复模板

本文目录如下:

text
order-refund/
├── SKILL.md
├── references/
│   └── refund-policy.md
├── scripts/
│   └── calculate-refund.py
└── assets/
    └── customer-reply.md

SkillsTool 加载命中的 SKILL.md,返回 Skill 根目录。模型按照流程调用 Read 和 Bash,得到当前步骤所需要的文件。

订单退款 Skill 按需读取规则、执行计算脚本并读取客服模板的完整调用链
订单退款 Skill 按需读取规则、执行计算脚本并读取客服模板的完整调用链

跑一个订单退款的 Skill#

本次用户请求如下:

text
订单 ORD-2001 已支付,商品金额 299 元,
使用优惠券 20 元,尚未发货。
我确认申请退款。请按团队流程处理。

预期可以退回的金额为 279.00 元。该数值应该来源于脚本返回值,以便于我们在测试中验证模型没有出现跳跃。

本篇没有使用真实的退款提交 Tool,只报告可以退的金额以及规则校验的结果。“已经受理”和“已经到账”不在 Demo 的能力范围之内。

示例中使用了 Java 21、Spring AI 2.0.1 以及 spring-ai-agent-utils 0.12.0。SkillsTool、FileSystemTools、ShellTools 等来自 Spring AI Community 的 spring-ai-agent-utils。

第一,把顺序写在 SKILL.md 中#

主要步骤用中文表示如下:

markdown
---
name: order-refund
description: Use when a user asks to refund a paid,
  unshipped e-commerce order and needs the refund amount
  or a customer-service reply.
---

# Order Refund

1. 提取订单状态、金额和用户确认信息。
2. 把 `references/refund-policy.md` 与 Skill 根目录
   拼成绝对路径,再使用 `Read` 读取。
3. 规则通过后,直接使用 `Bash` 执行:
   `python3 scripts/calculate-refund.py
   --item-cents <item-cents> --coupon-cents <coupon-cents>`。
   执行前不运行探测命令。
4. 把 `assets/customer-reply.md` 与 Skill 根目录
   拼成绝对路径,再使用 `Read` 读取。
5. 填充模板并返回简洁中文。

Agent Skills 规范规定 SKILL.md 中要使用相对路径。本例把绝对目录传给 SkillsTool,它加载之后会返回 Skill 根目录;Read 的参数需要绝对路径。因此 SKILL.md 保留了相对文件名,并且要求模型调用 Read 之前与已经返回的根目录进行拼接。

SKILL.md 只写步骤和资源路径。退款规则单独写在 references/refund-policy.md 中:

markdown
- 订单状态必须是 `PAID`。
- 发货状态必须是 `NOT_SHIPPED`。
- 用户需要明确确认退款。
- 退款金额 = 商品金额 - 优惠券金额。
- 金额统一交给脚本计算。

规则修改的时候直接修改这个文件。SKILL.md 保持简洁,没有命中退款场景的时候,该规则也不会被读取。

第二步就是把计算工作交给脚本来完成#

calculate-refund.py 接受两个以分作单位的参数:

python
parser.add_argument("--item-cents", type=int, required=True)
parser.add_argument("--coupon-cents", type=int, required=True)
args = parser.parse_args()

if args.coupon_cents > args.item_cents:
    raise SystemExit("coupon amount cannot exceed item amount")

refund_cents = args.item_cents - args.coupon_cents
print(json.dumps({
    "calculator": "refund-v1",
    "refundCents": refund_cents,
    "refundAmount": f"{refund_cents / 100:.2f}"
}, separators=(",", ":")))

这里还可以增加参数校验、舍入规则以及版本号。本次调用中,脚本保留在本地文件里,Bash 只把执行的结果返回给模型:

json
{"calculator":"refund-v1","refundCents":27900,"refundAmount":"279.00"}

最后,assets/customer-reply.md 保存客服回复的字段以及顺序。模型读取它,然后把订单号和脚本返回的金额填进去。

第一版只给出了脚本路径以及两个参数名,并没有给出可以直接执行的完整命令。真实模型多调了一次 Bash,顺序断言直接失败:

text
actual:   Skill → Read → Bash → Bash → Read
expected: Skill → Read → Bash → Read

我把命令全部写出来,并且要求执行之前不再探测路径。第二次回到四步调用。Skill 中的脚本说明要尽量让模型拿到就可以执行。

第三步是注册 Skill、Read 和 Bash#

java
Path skillRoot = Path.of("skills/order-refund")
        .toAbsolutePath().normalize();

ToolCallback skill = SkillsTool.builder()
        .addSkillsDirectory(skillRoot.getParent().toString())
        .build();

ToolCallback read = namedTool(
        FileSystemTools.builder()
                .allowedDirectory(skillRoot)
                .build(),
        "Read");

ToolCallback bash = namedTool(
        ShellTools.builder()
                .workingDirectory(skillRoot)
                .build(),
        "Bash");

ChatClient chatClient = ChatClient.builder(model)
        .defaultTools(skill, read, bash)
        .build();

FileSystemTools 提供了 Read、Write 和 Edit 三种工具,ShellTools 还有后台任务相关的工具。本例用工具名来过滤,只把 Read 和 Bash 交给模型:

java
static ToolCallback namedTool(Object source, String name) {
    return Arrays.stream(ToolCallbacks.from(source))
            .filter(tool -> tool.getToolDefinition()
                    .name().equals(name))
            .findFirst()
            .orElseThrow();
}

allowedDirectory(skillRoot) 会把文件访问限制在 Skill 目录之内。workingDirectory(skillRoot) 只设置 Shell 的工作目录,并没有提供命令隔离。在正式项目中,要使用受限账号、容器或者沙箱 ExecBackend 来运行脚本。

真实的模型运行一次#

在 System Prompt 中只写到“命中 Skill 后严格按照步骤调用工具”,没有提到 Skill 名称、文件名以及计算公式。order-refund 来自工具元数据,文件名以及计算规则都是在 Skill 加载之后才出现的。

运行真实模型集成测试:

bash
RUN_REAL_MODEL_TEST=true \
  mvn -Dtest=RealModelSkillResourcesTest test

这次接入的模型为 doubao-seed-2-1-turbo-260628,记录到的工具顺序如下:

text
Skill → Read → Bash → Read

四个检查项都合格了:Skill 已经加载,退款规则已经读取,脚本返回 279.00,客服模板也已经读取。

Spring AI Agent Skills 订单退款模型集成测试:顺序为 Skill、Read、Bash、Read,可退金额为 279.00 元,JUnit 返回 BUILD SUCCESS
Spring AI Agent Skills 订单退款模型集成测试:顺序为 Skill、Read、Bash、Read,可退金额为 279.00 元,JUnit 返回 BUILD SUCCESS

我用固定模型响应的方式做了两个确定性测试,用来检验按需加载的情况:

text
metadataContainsPolicy=false
skillContainsPolicy=false
policyLoaded=true
templateLoaded=true

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

注册的时候,Skill 列表只显示 name 和 description,正文以及附加资源没有被包含在上下文中。调用 Skill 后,上下文里出现了完整的 SKILL.md 文件,但是退款规则和客服模板还没有被加载。它们分别在两次 Read 调用之后才出现。

最后#

带资源的 Skill 可以这样来理解:

text
SKILL.md    定义流程
references  提供当前任务的知识
scripts     执行确定性操作
assets      提供输出素材

这样拆分之后,流程、知识、执行和输出就各自独立了。模型每走一步的时候,再加载该步骤所需要的内容。

如果你的 SKILL.md 已经塞进了几百行规则,可以把长文档放进 references/。对于金额计算、格式转换以及结果校验的部分,可以把它们放进 scripts/ 中。

参考资料#

  • Agent Skills 开放规范
  • Spring Blog:Agent Skills — Modular, Reusable Capabilities for Agents
  • spring-ai-agent-utils 0.12.0:SkillsTool 文档

公众号同名

微信搜索「雷一言说」

关注公众号,继续看我的文章。

手机阅读时,可以保存二维码后在微信中识别。

雷一言说微信公众号二维码
扫码关注公众号