各位朋友大家好,我是一言,继续更新 Spring AI 2.0 系列。
上一篇中,我们通过使用 order-cancellation Skill 来连接 queryOrder 和 cancelOrder 两个 Tool。当时整个 Skill 只有一份 SKILL.md。
实际项目中,一个流程一般包含业务规则、计算脚本以及输出模板。这篇继续以电商场景为例,跑一个完整的订单退款 Skill:
读退款规则 → 运行计算脚本 → 读客服模板 → 生成回复我会用真实的模型跑完这条调用链,并且检查额外资源是否真的按照需求进入到上下文中去。
一个 Skill 可以带什么#
Agent Skills 规范中,一个 Skill 至少要有 SKILL.md 文件,并且可以附加三个目录:
| 目录 | 适合放什么 | 这次的内容 |
|---|---|---|
references/ |
业务规则、接口文档、长资料 | 未发货订单退款规则 |
scripts/ |
可重复执行的确定性任务 | 退款金额计算脚本 |
assets/ |
模板、图片、输出所需的素材 | 客服回复模板 |
本文目录如下:
order-refund/
├── SKILL.md
├── references/
│ └── refund-policy.md
├── scripts/
│ └── calculate-refund.py
└── assets/
└── customer-reply.mdSkillsTool 加载命中的 SKILL.md,返回 Skill 根目录。模型按照流程调用 Read 和 Bash,得到当前步骤所需要的文件。

跑一个订单退款的 Skill#
本次用户请求如下:
订单 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 中#
主要步骤用中文表示如下:
---
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 中:
- 订单状态必须是 `PAID`。
- 发货状态必须是 `NOT_SHIPPED`。
- 用户需要明确确认退款。
- 退款金额 = 商品金额 - 优惠券金额。
- 金额统一交给脚本计算。规则修改的时候直接修改这个文件。SKILL.md 保持简洁,没有命中退款场景的时候,该规则也不会被读取。
第二步就是把计算工作交给脚本来完成#
calculate-refund.py 接受两个以分作单位的参数:
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 只把执行的结果返回给模型:
{"calculator":"refund-v1","refundCents":27900,"refundAmount":"279.00"}最后,assets/customer-reply.md 保存客服回复的字段以及顺序。模型读取它,然后把订单号和脚本返回的金额填进去。
第一版只给出了脚本路径以及两个参数名,并没有给出可以直接执行的完整命令。真实模型多调了一次 Bash,顺序断言直接失败:
actual: Skill → Read → Bash → Bash → Read
expected: Skill → Read → Bash → Read我把命令全部写出来,并且要求执行之前不再探测路径。第二次回到四步调用。Skill 中的脚本说明要尽量让模型拿到就可以执行。
第三步是注册 Skill、Read 和 Bash#
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 交给模型:
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 加载之后才出现的。
运行真实模型集成测试:
RUN_REAL_MODEL_TEST=true \
mvn -Dtest=RealModelSkillResourcesTest test这次接入的模型为 doubao-seed-2-1-turbo-260628,记录到的工具顺序如下:
Skill → Read → Bash → Read四个检查项都合格了:Skill 已经加载,退款规则已经读取,脚本返回 279.00,客服模板也已经读取。

我用固定模型响应的方式做了两个确定性测试,用来检验按需加载的情况:
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 可以这样来理解:
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 文档
