resources/skills/process-refund.yaml
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇结尾留了个伏笔:如果比特严选的多个场景都反复出现"先查订单、验证条件、再退款"这类组合动作,每次都让 Planner 临时拆解和安排步骤,既浪费 Token,也增加不稳定性。这一篇就来解决这个问题——把高频、稳定的多步编排封装成 Skill(技能)。
回顾一下 TinyAgent 目前的能力光谱:ReAct 让 Agent 能一步一步推理和调用工具,Plan-and-Execute 让 Agent 能先规划再执行。两者的共同点是——每次面对用户请求,大脑都在做全量决策,从零开始选工具、排步骤。对于"帮我退掉订单 88231"这种中等复杂度的任务,Plan-and-Execute 每次 都要花一次 LLM 调用生成一模一样的计划,而 ReAct 则要自己摸索出查订单、判断状态、退款这条路。能不能把这种验证过的编排沉淀下来,让 Agent 直接用?
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.10.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
重复编排的代价:每次都从零规划
1. 退款场景的重复规划
拿退款这个高频场景举例。用户说"帮我退掉订单 88231",Plan-and-Execute 会先调一次 Planner 生成计划:
Step 1: 查询订单 88231 的详情,确认订单状态 → queryOrder
Step 2: 如果订单已签收,为订单 88231 申请退款 → applyRefund
Step 3: 综合以上结果,告知用户退款进展 → null
下一个用户说"订单 99001 的耳机有问题,帮我退款",Planner 又生成一遍:
Step 1: 查询订单 99001 的详情,确认订单状态 → queryOrder
Step 2: 如果订单已签收,为订单 99001 申请退款 → applyRefund
Step 3: 综合以上结果,告知用户退款进展 → null
两份计划的结构一模一样,只有订单号不同。但每次生成计划都需要一次 LLM 调用——输入包含系统提示词(规划原则 + 工具列表约 400 Token)和用户问题,输出约 200 Token 的 JSON 计划。加上网络延迟,每次多花 1-2 秒和几百 Token。
如果退款场景一天命中 500 次,500 次完全相同的规划就是纯浪费。
2. 哪些编排适合沉淀
不是所有编排都值得封装成技能。适合沉淀的编排有两个特征:
高频。这个编排在业务中反复出现。比特严选的退款流程、订单全流程查询、商品推荐——这些是客服场景的高频操作。
稳定。步骤顺序和分支逻辑基本固定,不会因用户措辞不同而大幅变化。"退款"不管用户怎么说,底层都是"查订单 → 检查状态 → 提交退款"。
| 场景 | 频率 | 步骤稳定性 | 适合封装 |
|---|---|---|---|
| 退款处理 | 高 | 固定:查订单 → 检查 → 退款 | 适合 |
| 订单全流程查询 | 高 | 固定:查订单 → 有运单则查物流 | 适合 |
| "帮我对比 A 和 B 哪个好" | 中 | 不固定:品类不同、对比维度不同 | 不太适合 |
| "我家扫地机不吸灰了怎么办" | 低 | 不固定:故障诊断树因机型而异 | 暂不适合 |
用一句话概括:高频 + 稳定 = 值得封装成 Skill;低频或步骤不固定的场景,留给 Plan-and-Execute 动态规划更合适。
Tool vs Skill:边界在哪
1. Skill 的本质
在主流 Agent 框架里,Skill 的核心定义是一致的:一个有作用域的执行上下文(Scoped Execution Context)——专属的指令提示词 + 受限的工具子集。
打个比方:Tool 是厨房里的菜刀、炒锅、调料瓶——每一件都是基础工 具,厨师(LLM)随意取用。Skill 是一份菜谱——"红烧排骨"这道菜写清楚了该用哪些工具(菜刀、炒锅、酱油、冰糖)、每一步怎么做(切块、焯水、煎炸、焖煮)。厨师照着菜谱做,不用每次都即兴发挥。
落到技术上:
| 维度 | Tool(工具) | Skill(技能) |
|---|---|---|
| 定义方式 | Java 类,实现 Tool 接口 | 配置文件(YAML),放在项目目录下 |
| 发现方式 | 代码中手动注册 | 从目录自动扫描加载 |
| 内部逻辑 | 直接调用业务接口,无 LLM 参与 | LLM 按指令驱动执行,调用工具子集 |
| 工具范围 | 自身就是一个原子操作 | 限定可用的工具列表(Tool Scoping) |
| 灵活性 | 高(LLM 自由组合) | 中等(LLM 在指令约束下执行) |
| 新增成本 | 写 Java 代码、编译、部署 | 新建一个 YAML 文件,无需编译 |
2. 在主流框架里,Skill 长什么样
LangChain4j 用 SKILL.md 文件定义技能——YAML 前置元数据(名称、描述、工具列表)加上 Markdown 格式的指令。技能放在项目的指定目录下,框架启动时自动扫描加载。技能激活后,LLM 只能看到技能声明的工具子集(Skill-scoped Tools),看不到其他工具。
Semantic Kernel(微软)用目录结构定义 Plugin(早期叫 Skill)——每个 Plugin 是一个文件夹,里面有 skprompt.txt(提示词模板)和 config.json(描述、参数、执行配置)。Kernel 从文件系统发现并注册 Plugin。
Spring AI 没有显式的 Skill 抽象,但思路相同——用 ChatClient.builder().defaultSystem("...").defaultTools(...) 构建一个有作用域的 ChatClient 实例,本质上就是一个"专属指令 + 工具子集"的执行上下文。
OpenAI Swarm 的 Agent(Routine)= instructions(指令字符串)+ functions(工具列表)。主 Agent 通过 handoff 把任务交给另一个 Agent 处理。
TinyAgent 的实现和 LangChain4j 最接近:YAML 文件定义技能、从目录自动发现、激活后运行一个只暴露技能声明工具的小型 ReAct 循环。
技能定义:YAML 文件结构
一个技能的 YAML 文件包含五个字段:
# resources/skills/process-refund.yaml
name: processRefund
description: "退款处理技能:自动查询订单状态,验证退款资格,条件满足则提交退款申请。
适用于用户要求退货退款的场景,无需手动分步操作。"
tools:
- queryOrder
- applyRefund
parameters:
type: object
properties:
orderId:
type: string
description: "要退款的订单号"
reason:
type: string
description: "退款原因,如 质量问题、不想要了"
required:
- orderId
- reason
instructions: |
你是比特严选的退款处理专员。请严格按以下流程处理用户的退款请求:
1. 使用 queryOrder 查询用户提供的订单号,确认订单状态和商品信息
2. 根据订单状态判断:
- 如果状态为"已签收",使用 applyRefund 提交退款申请,退款原因使用用户描述的原因
- 如果状态不是"已签收",直接告知用户当前订单状态,说明需要签收后才能申请退款
3. 如果订单不存在,告知用户订单号可能有误
处理完成后,给出简洁明了的处理结果。包含订单商品名称、退款单号(如有)和预计到账时间。
五个字段各有职责:
name:技能的唯一标识。LLM 通过 Function Calling 调用技能时用的就是这个名字。命名风格和 Tool 保持一致——动词开头的驼峰命名(processRefund、inquireOrderStatus)。
description:给 LLM 看的技能描述。LLM 根据这段文字决定是否激活技能,所以写法很关键——后面会专门讲。
tools:技能可以使用的工具列表。这是 Skill 最核心的设计——工具作用域限定(Tool Scoping)。退款技能只能调用 queryOrder 和 applyRefund,看不到 searchKnowledge、queryLogistics 等其他工具。这样做有两个好处:一是减少 LLM 的选择空间、降低出错概率;二是防止技能越界——退款流程不应该顺手去搜知识库。
parameters:技能的输入参数,格式和 Function Calling 的参数 JSON Schema 完全一致。主 Agent 调用技能时传入的参数会被格式化为用户消息,传递给技能内部的 LLM。
instructions:技能的执行指令——这是技能的灵魂。它作为系统提示词注入技能内部的 LLM 上下文,指导 LLM 按照预定义的流程、使用限定的工具来处理请求。相比 Plan-and-Execute 的临时规划,这份指令是开发者编写并验证过的,稳定性和可靠性都更高。
注意
instructions和 Plan-and-Execute 的 Planner 提示词的区别:Planner 的提示词教 LLM 怎么拆解步骤(元能力),instructions直接告诉 LLM 具体该怎么做(领域知识)。一个是"教你怎么做规划",一个是"照着这份规划来执行"。
再看一个订单查询技能的定义:
# resources/skills/order-inquiry.yaml
name: inquireOrderStatus
description: "订单全流程查询技能:查询订单详情,如果已发货则同时查询物流轨迹,
返回包含订单状态和物流进度的完整信息。无需分步调用。"
tools:
- queryOrder
- queryLogistics
parameters:
type: object
properties:
orderId:
type: string
description: "要查询的订单号"
required:
- orderId
instructions: |
你是比特严选的订单查询专员。请按以下流程查询订单的完整信息:
1. 使用 queryOrder 查询订单详情,获取商品名称、价格、订单状态、运单号等信息
2. 如果订单有运单号(trackingNo 不为空),使用 queryLogistics 查询物流轨迹
3. 如果订单没有运单号,说明可能尚未发货,只返回订单基本信息即可
查询完成后,给出一段完整的订单状态说明,包含商品信息、当前状态和物流进度(如有)。
这两个技能的 tools 列表完全不同——退款技能用 queryOrder + applyRefund,订单查询技能用 queryOrder + queryLogistics。这就是 Tool Scoping 的效果:每个技能只看到它需要的工具。
Skill 数据类
YAML 文件加载后需要映射成 Java 对象。Skill 是一个纯粹的数据类:
@Data
@NoArgsConstructor
public class Skill {
private String name;
private String description;
private List<String> tools;
private JsonNode parameters;
private String instructions;
}
用 Lombok 的 @Data 生成 getter/setter,@NoArgsConstructor 用于 Jackson 反序列化。parameters 字段用 JsonNode 类型——YAML 里的嵌套结构会被 Jackson YAML 解析为 JsonNode 树,后续构建 Function Calling 的 tools 数组时可以直接复用,不需要手动拼 JSON。
注意这里和前面几篇的 Tool 接口的区别:Skill 不实现 Tool 接口。Skill 是一个数据类,存放从 YAML 加载的配置;Tool 是一个行为接口,定义了 invoke() 方法。两者之间通过一个适配器桥接——这是 SkillRegistry 的职责。
SkillRegistry:目录发现与桥接注册
SkillRegistry 做三件事:从目录扫描 YAML 文件、解析成 Skill 对象、把 Skill 包装成 Tool 桥接到 ToolRegistry。
public class SkillRegistry {
private final Map<String, Skill> skills = new LinkedHashMap<>();
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
private final ToolRegistry toolRegistry;
private final SkillExecutor executor;
public SkillRegistry(ToolRegistry toolRegistry, LlmClient llmClient) {
this.toolRegistry = toolRegistry;
this.executor = new SkillExecutor(llmClient, toolRegistry);
}
// 从指定目录扫描所有 .yaml 文件,解析为 Skill 并注册
public void loadFromDirectory(Path skillsDir) {
if (!Files.isDirectory(skillsDir)) {
System.out.println("[SkillRegistry] 技能目录不存在:" + skillsDir);
return;
}
try (DirectoryStream<Path> stream =
Files.newDirectoryStream(skillsDir, "*.yaml")) {
for (Path file : stream) {
try {
Skill skill = yamlMapper.readValue(file.toFile(), Skill.class);
register(skill);
System.out.println("[SkillRegistry] 加载技能:" + skill.getName()
+ "(" + file.getFileName() + ")");
} catch (IOException e) {
System.out.println("[SkillRegistry] 解析失败:"
+ file.getFileName() + " → " + e.getMessage());
}
}
} catch (IOException e) {
System.out.println("[SkillRegistry] 扫描目录失败:" + e.getMessage());
}
}
private void register(Skill skill) {
skills.put(skill.getName(), skill);
toolRegistry.register(new SkillToolAdapter(skill, executor));
}
// ......
}
1. YAML 解析
ObjectMapper(new YAMLFactory()) 是 Jackson 的 YAML 扩展模块(jackson-dataformat-yaml)。它和处理 JSON 的 ObjectMapper 用法完全一样,只是底层解析器换成了 YAML。一行 yamlMapper.readValue(file, Skill.class) 就把 YAML 文件映射成了 Skill 对象。
Maven 依赖:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
2. 适配器:从 Skill 到 Tool
Skill 是数据类,Tool 是行为接口。要让 LLM 通过 Function Calling 调用 Skill,需要一个适配器把数据类包装成接口实现:
private static class SkillToolAdapter implements Tool {
private final Skill skill;
private final SkillExecutor executor;
private final String parametersJson;
SkillToolAdapter(Skill skill, SkillExecutor executor) {
this.skill = skill;
this.executor = executor;
if (skill.getParameters() != null) {
this.parametersJson = skill.getParameters().toString();
} else {
this.parametersJson = "{\"type\":\"object\"}";
}
}
@Override
public String name() {
return skill.getName();
}
@Override
public String description() {
return skill.getDescription();
}
@Override
public String parameters() {
return parametersJson;
}
@Override
public String invoke(String input) {
return executor.execute(skill, input);
}
}
invoke() 是关键——当 LLM 调用 processRefund 这个函数时,SkillToolAdapter.invoke() 把控制权交给 SkillExecutor,由 SkillExecutor 启动一个有作用域的小型 ReAct 循环来执行技能。
parametersJson 在构造时就从 JsonNode 序列化为 JSON 字符串。这样 ToolRegistry.buildToolsJsonArray() 构建 Function Calling 的 tools 数组时,技能的参数定义和普通工具的参数定义格式完全一致——LLM 无法区分哪个是工具、哪个是技能。
3. 注册顺序
register() 方法调用 toolRegistry.register(adapter),把适配器作为一个 Tool 注入 ToolRegistry。从这一刻起,LLM 就能在 tools 列表里看到这个技能了。
注意:先注册基础工具,再加载技能。技能内部会通过 SkillExecutor 调用基础工具,如果基础工具还没注册,技能执行时会找不到工具。
SkillExecutor:有作用域的小型 ReAct 循环
SkillExecutor 是技能的运行时引擎。它为每次技能调用创建一个独立 的上下文——用技能的 instructions 作为系统提示词,只暴露技能声明的工具子集,运行一个简化版的 ReAct 循环。
public class SkillExecutor {
private static final int MAX_ROUNDS = 5;
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
public SkillExecutor(LlmClient llmClient, ToolRegistry toolRegistry) {
this.llmClient = llmClient;
this.toolRegistry = toolRegistry;
}
public String execute(Skill skill, String input) {
ObjectMapper objectMapper = llmClient.getObjectMapper();
ArrayNode messages = objectMapper.createArrayNode();
// 系统提示词来自技能定义文件
ObjectNode systemMsg = messages.addObject();
systemMsg.put("role", "system");
systemMsg.put("content", skill.getInstructions());
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
userMsg.put("content", "请处理以下请求:" + input);
// 只暴露技能声明的工具子集
List<Tool> scopedTools = skill.getTools().stream()
.map(toolRegistry::getTool)
.filter(Objects::nonNull)
.toList();
ArrayNode tools = toolRegistry.buildToolsJsonArray(
objectMapper, scopedTools);
System.out.println("[Skill:" + skill.getName()
+ "] 激活,可用工具:" + skill.getTools());
for (int round = 1; round <= MAX_ROUNDS; round++) {
ChatResponse response = llmClient.chatWithTools(messages, tools);
if (!response.hasToolCalls()) {
System.out.println("[Skill:" + skill.getName() + "] 执行完成");
return response.content() != null ? response.content() : "";
}
// ...... 构建 assistant 消息、执行工具、追加 tool 消息
// (和 ReActAgent / PlanAndExecuteAgent 的工具调用处理逻辑完全相同)
}
return "技能执行超出最大轮次限制(" + MAX_ROUNDS + " 轮)";
}
}
1. 工具作用域限定
List<Tool> scopedTools = skill.getTools().stream()
.map(toolRegistry::getTool)
.filter(Objects::nonNull)
.toList();
这三行代码是 Skill 和普通 ReAct 最关键的区别。普通 ReAct 把所有注册的工具都暴露给 LLM——5 个工具、8 个工具、甚至 15 个工具。而 SkillExecutor 只暴露技能声明的工具子集——退款技能只给 queryOrder 和 applyRefund,其他工具 LLM 看都看不到。
这就是 LangChain4j 说的 Skill-scoped Tools 机制。好处有两个:
减少幻觉。工具越少,LLM 选错工具的概率越低。退款技能的 LLM 不可能调用 searchKnowledge 去搜知识库——因为它根本看不到这个工具。
防止越界。技能应该只做它被设计做的事。如果退款技能看到了 searchKnowledge,LLM 可能会"好心"地帮用户搜一下替代商品推荐——这不是退款技能该做的事,而且会消耗额外的轮次和 Token。
2. 和 ReActAgent 的关系
SkillExecutor 的循环逻辑和 ReActAgent 几乎一样——都是"调 LLM → 有 tool_calls 就执行 → 追加到 messages → 再调 LLM"。区别在于:
| 维度 | ReActAgent | SkillExecutor |
|---|---|---|
| 系统提示词 | 通用客服 prompt,硬编码在代码里 | 来自技能 YAML 的 instructions |
| 工具范围 | 全部注册工具 | 技能声明的工具子集 |
| 最大轮次 | 10 圈(默认) | 5 圈(技能步骤少,不需要太多) |
| 记忆/上下文管理 | 有(ChatMemory、LongTermMemory) | 无(技能是一次性执行,不需要跨轮记忆) |
| 重复检测 | 有(RepeatDetector) | 无(技能步骤固定,不容易死循环) |
你可以把 SkillExecutor 理解为一个轻量化的、有作用域的 ReActAgent——它继承了 ReAct 的"边想边干"能力,但被技能的 instructions 和工具子集框定了行为范围。
3. 为什么 instructions 由 LLM 执行而不是硬编码
一个自然的疑问:退款流程不就是"查订单 → 判断状态 → 退款"吗,为什么不直接写成 Java 代码?
// 硬编码方式——不需要 LLM
String orderResult = callTool("queryOrder", orderId);
if ("已签收".equals(extractStatus(orderResult))) {
return callTool("applyRefund", orderId, reason);
}
这种方式当然可以,也更快更省 Token。但它有两个局限:
参数提取依赖硬编码。LLM 调用技能时传入的参数是结构化的({"orderId":"88231","reason":"扫地机坏了"}),但用户的原始表达可能更复杂——"帮我把上周买的那个扫地机退了,坏了好几天了"。主 Agent 的 LLM 负责从自然语言提取参数,但如果提取得不够精确(比如 reason 只传了"坏了"),技能内部的 LLM 可以结合上下文丰富退款原因的描述,硬编码做不到这一点。
新增技能需要编译部署。硬编码的技能是 Java 类,每次新增或修改都需要改代码、编译、重新部署。YAML + LLM 驱动的技能只需要新建一个文件——写清楚 instructions 和 tools,重启加载即可。在快速迭代的业务场景下,这个差异很重要。
当然,对于步骤极其固定、完全不需要 LLM 灵活性的场景,硬编码 Tool 也是合理的选择。但那时候它就是一个普通的复合 Tool(在 Spring AI 里就是一个 @Tool 方法里写了多步逻辑),不叫 Skill。Skill 的核心特征是 LLM 驱动执行 + 工具作用域限定 + 文件定义 + 目录发现。
LLM 发现与激活机制
1. 技能注册后如何被 LLM 看到
SkillRegistry.register() 的最后一步调了 toolRegistry.register(adapter),所以技能会自动出现在 ToolRegistry.buildToolsJsonArray() 生成的 tools 数组里。LLM 看到的 tools 列表长这样:
[
{"type":"function","function":{"name":"queryOrder","description":"查询订单详情..."}},
{"type":"function","function":{"name":"applyRefund","description":"发起退款申请..."}},
{"type":"function","function":{"name":"searchKnowledge","description":"检索知识库..."}},
{"type":"function","function":{"name":"processRefund",
"description":"退款处理技能:自动查询订单状态...无需手动分步操作。"}},
{"type":"function","function":{"name":"inquireOrderStatus",
"description":"订单全流程查询技能:...无需分步调用。"}}
]
技能和工具混在一起。LLM 根据用户意图选择最合适的那个。
整个流程的时序如下:
@startuml
skinparam backgroundColor #FAFBFC
skinparam shadowing true
skinparam defaultFontName "Microsoft YaHei,Arial,sans-serif"
skinparam defaultFontSize 12
skinparam sequence {
ArrowColor #1565C0
ArrowThickness 2
LifeLineBorderColor #1565C0
LifeLineBackgroundColor #E3F2FD
ParticipantBackgroundColor #E3F2FD
ParticipantBorderColor #1565C0
ParticipantFontColor #0D47A1
DatabaseBackgroundColor #C8E6C9
DatabaseBorderColor #2E7D32
}
skinparam note {
BackgroundColor #FFF9C4
BorderColor #F9A825
FontColor #5D4037
FontSize 11
}
actor 用户
participant "ReActAgent\n(主 Agent)" as Main
participant "LLM\n(主循环)" as MainLLM
participant ToolRegistry
participant SkillToolAdapter
participant SkillExecutor
participant "LLM\n(技能内部)" as SkillLLM
participant QueryOrderTool
participant ApplyRefundTool
用户 -> Main : "帮我退掉订单 88231,\n扫地机坏了"
Main -> MainLLM : messages + tools\n(含技能 processRefund)
MainLLM -> Main : tool_call: processRefund\n({orderId:"88231",\nreason:"扫地机质量问题"})
note right of MainLLM : 主 LLM 选择技能\n而非单个工具
Main -> ToolRegistry : execute(processRefund, ...)
ToolRegistry -> SkillToolAdapter : invoke(input)
SkillToolAdapter -> SkillExecutor : execute(skill, input)
note over SkillExecutor : 创建有作用域的\n小型 ReAct 循环\n只暴露 queryOrder\n和 applyRefund
SkillExecutor -> SkillLLM : skill.instructions\n+ 用户请求
SkillLLM -> SkillExecutor : tool_call: queryOrder
SkillExecutor -> QueryOrderTool : invoke
QueryOrderTool --> SkillExecutor : 订单详情(已签收)
SkillExecutor -> SkillLLM : 工具结果
SkillLLM -> SkillExecutor : tool_call: applyRefund
SkillExecutor -> ApplyRefundTool : invoke
ApplyRefundTool --> SkillExecutor : 退款结果
SkillExecutor -> SkillLLM : 工具结果
SkillLLM --> SkillExecutor : 最终文本回复
SkillExecutor --> Main : 技能执行结果
Main -> MainLLM : 工具结果
MainLLM --> Main : 最终回复文本
Main --> 用户 : "已为您提交退款申请..."
@enduml
这张图的关键在于两层 LLM 调用:
- 主 LLM(ReActAgent 的循环)负责理解用户意图、选择合适的技能、提取参数、最后生成面向用户的回复。
- 技能内部 LLM(SkillExecutor 的循环)负责按照技能的 instructions 执行具体流程——调用工具、处理结果、做条件判断。
主 LLM 看到的是全部工具和技能;技能内部 LLM 只看到技能声明的工具子集。这就是 Tool Scoping 的运作方式。
2. 技能描述的设计技巧
Skill 的 description 是主 LLM 选择技能的唯一依据,写得好不好直接影响激活准确率。三个原则:
说清楚做了什么。不要只写"处理退款",要写"查询订单状态,验证退款资格,条件满足则提交退款申请"——让 LLM 知道这个技能覆盖了哪些步骤。
说清楚什么时候用。"适用于用户要求退货退款的场景"——帮助 LLM 判断当前意图是否匹配。
说清楚不用手动做什么。"无需手动分步操作"——告诉 LLM 调了这个技能就不需要再单独调 queryOrder 和 applyRefund 了,避免重复操作。
| 描述写法 | 问题 | 改进 |
|---|---|---|
"处理退款" | 太模糊,LLM 不确定调了之后还需不需要查订单 | 写明内部步骤 |
"查询订单并退款" | 没说清适用场景 | 加上场景说明 |
"退款处理技能:自动查询订单状态,验证退款资格,条件满足则提交退款申请。适用于用户要求退货退款的场景,无需手动分步操作。" | 三个要素齐全 | — |
3. 技能与工具共存时的选择逻辑
一个自然的疑问:processRefund 和 queryOrder + applyRefund 都在 tools 列表里,LLM 不会搞混吗?
实际跑下来,LLM 会根据用户意图选择粒度最合适的函数:
- 用户说"帮我退掉订单 88231" → LLM 选
processRefund(意图完整匹配技能描述) - 用户说"查一下订单 88231 的状态" → LLM 选
queryOrder或inquireOrderStatus(取决于是否需要物流信息) - 用户说"帮我把退款单号 RF001 的退款取消" → LLM 不会选
processRefund(技能描述里没有取消退款的语义),会去找其他工具或告知用户超出能力范围
这就像一个团队里有初级工程师(Tool)和高级工程师(Skill)。简单任务分配给初级工程师更高效;复杂任务交给高级工程师,他会自己协调初级工程师配合完成。Team Lead(LLM)根据任务复杂度分配就行了。
Demo:完整运行示例
把所有组件串起来:
public class SkillDemo {
public static void main(String[] args) {
// 1. 注册基础工具
ToolRegistry toolRegistry = new ToolRegistry();
toolRegistry.register(new QueryOrderTool());
toolRegistry.register(new QueryLogisticsTool());
toolRegistry.register(new ApplyRefundTool());
toolRegistry.register(new SearchKnowledgeTool());
toolRegistry.register(new GetCurrentTimeTool());
Properties dotEnv = loadDotEnv();
LlmClient llmClient = new LlmClient(
setting(dotEnv, "TINYAGENT_API_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"),
requiredSetting(dotEnv, "TINYAGENT_API_KEY"),
setting(dotEnv, "TINYAGENT_MODEL", "qwen-plus")
);
// 2. 从 skills/ 目录发现并加载技能
SkillRegistry skillRegistry = new SkillRegistry(toolRegistry, llmClient);
skillRegistry.loadFromDirectory(Path.of("src/main/resources/skills"));
// 3. 使用 ReActAgent,技能已自动桥接到 ToolRegistry
ReActAgent agent = new ReActAgent(llmClient, toolRegistry, 10, 8000);
// 场景 1:退款
System.out.println("========== 场景 1:退款 ==========");
String answer1 = agent.run("我的订单 88231 那个扫地机坏了,帮我退掉");
System.out.println("\n[最终结果] " + answer1);
// 场景 2:订单查询
System.out.println("\n\n========== 场景 2:订单全流程查询 ==========");
String answer2 = agent.run("帮我看看订单 88231 现在到哪了");
System.out.println("\n[最终结果] " + answer2);
}
// loadDotEnv()、requiredSetting()、setting() ......
}
注意三个步骤的顺序和关系:
Step 1:注册基础工具。QueryOrderTool、ApplyRefundTool 等原子工具注册到 ToolRegistry。
Step 2:加载技能。SkillRegistry.loadFromDirectory() 扫描 skills/ 目录下的 YAML 文件,解析为 Skill 对象,通过 SkillToolAdapter 桥接注册到同一个 ToolRegistry。
Step 3:构建 Agent。ReActAgent 只需要 ToolRegistry,不需要感知 SkillRegistry 的存在——技能已经以 Tool 的身份混在工具列表里了。
预期控制台输出:
场景 1:退款
========== 场景 1:退款 ==========
[SkillRegistry] 加载技能:processRefund(process-refund.yaml)
[SkillRegistry] 加载技能:inquireOrderStatus(order-inquiry.yaml)
===== 第 1 圈 =====
[工具调用] processRefund({"orderId":"88231","reason":"扫地机质量问题"})
[Skill:processRefund] 激活,可用工具:[queryOrder, applyRefund]
[Skill:processRefund] 调用 queryOrder({"orderId":"88231"})
[Skill:processRefund] 结果 {"orderId":"88231","product":"比特 S10 Pro 扫地机",...,"status":"已签收",...}
[Skill:processRefund] 调用 applyRefund({"orderId":"88231","reason":"扫地机质量问题"})
[Skill:processRefund] 结果 {"success":true,"refundId":"RF20260629001",...}
[Skill:processRefund] 执行完成
[工具结果] 已经为您处理了订单 88231 的退款。您购买的比特 S10 Pro 扫地机退款已提交,
退款单号 RF20260629001,预计 1-3 个工作日到账。
===== 第 2 圈 =====
[最终答复] 已经为您处理了订单 88231 的退款申请......
看控制台日志:主 Agent 第 1 圈调用了 processRefund 技能,技能内部的 LLM 按照 instructions 的指引,先调了 queryOrder 确认状态是"已签收",然后调了 applyRefund 提交退款。技能执行完毕后,结果返回给主 Agent,主 Agent 第 2 圈生成最终回复。
场景 2:订单全流程查询
========== 场景 2:订单全流程查询 ==========
===== 第 1 圈 =====
[工具调用] inquireOrderStatus({"orderId":"88231"})
[Skill:inquireOrderStatus] 激活,可用工具:[queryOrder, queryLogistics]
[Skill:inquireOrderStatus] 调用 queryOrder({"orderId":"88231"})
[Skill:inquireOrderStatus] 结果 {"orderId":"88231",...,"trackingNo":"SF1234567890",...}
[Skill:inquireOrderStatus] 调用 queryLogistics({"trackingNo":"SF1234567890"})
[Skill:inquireOrderStatus] 结果 {"trackingNo":"SF1234567890","carrier":"顺丰速运","status":"已签收",...}
[Skill:inquireOrderStatus] 执行完成
[工具结果] 订单 88231 的完整状态:商品比特 S10 Pro 扫地机,已签收。物流:顺丰速运,
6月22日已由本人签收。
===== 第 2 圈 =====
[最终答复] 您的订单 88231(比特 S10 Pro 扫地机)已签收......
两个场景的共同点:主 Agent 只跑了 2 圈(选技能 + 生成回复)。技能内部的工具调用对主 Agent 是透明的——主 Agent 只看到技能返回的最终结果,不需要关心技能内部调了几个工具、做了什么判断。
渐进式工具展现:从扁平到分层
1. 扁平工具列表的问题
到目前为止的 Demo 里,主 Agent 看到的 tools 列表是这样的——所有基础工具 + 所有技能扁平地铺在一起:
主 Agent 视图(扁平):
queryOrder, queryLogistics, applyRefund,
searchKnowledge, getCurrentTime,
processRefund(技能), inquireOrderStatus(技能)
= 7 个 function
5 个基础工具加 2 个技能,总共 7 个。现在比特严选只有 5 个基础工具,数量还好。但如果系统成长到 15 个工具 + 5 个技能呢?主 LLM 每次调用都要阅读 20 个函数的描述——每个函数描述约 100 Token,光工具描述就消耗 2000 Token,而且函数越多,LLM 选错的概率越高。
更关键的问题是:有些工具只应该在特定技能内部使用,暴露给主 Agent 反而有害。比如 applyRefund——直接调用退款接口而不先查订单状态是很危险的操作。退款应该总是走 processRefund 技能,由技能内部的 instructions 来保证先查状态再退款。如果主 LLM 直接看到 applyRefund,它可能跳过状态检查直接退款。
2. 渐进式展现的分层设计
解决方案是两层可见性——主 Agent 看到精简的工具列表(公共工具 + 技能),技能激活后再展开为具体的工具子集:
@startuml
skinparam backgroundColor #FAFBFC
skinparam shadowing true
skinparam defaultFontName "Microsoft YaHei,Arial,sans-serif"
skinparam defaultFontSize 12
skinparam rectangle {
BackgroundColor #E3F2FD
BorderColor #1565C0
FontColor #0D47A1
}
skinparam note {
BackgroundColor #FFF9C4
BorderColor #F9A825
FontColor #5D4037
FontSize 11
}
rectangle "**第一层:主 Agent 视图**" as L1 {
rectangle "queryOrder" as qo
rectangle "searchKnowledge" as sk
rectangle "getCurrentTime" as gct
rectangle "**processRefund**\n(技能)" as pr #C8E6C9
rectangle "**inquireOrderStatus**\n(技能)" as ios #C8E6C9
}
note bottom of L1 : 主 LLM 看到 5 个函数\n公共工具 + 技能\n不含 applyRefund / queryLogistics
rectangle "**第二层:技能内部视图**" as L2 {
rectangle "processRefund 激活后" as pr2 #C8E6C9 {
rectangle "queryOrder " as qo2
rectangle "applyRefund" as ar
}
rectangle "inquireOrderStatus 激活后" as ios2 #C8E6C9 {
rectangle " queryOrder" as qo3
rectangle "queryLogistics" as ql
}
}
note bottom of L2 : 技能内部 LLM 只看到\n技能声明的 2 个工具
L1 -[hidden]down- L2
@enduml
第一层(主 Agent):只看到 queryOrder、searchKnowledge、getCurrentTime 三个公共工具,加上 processRefund、inquireOrderStatus 两个技能——总共 5 个函数。applyRefund 和 queryLogistics 被技能吸收,不再出现在主视图。
第二层(技能内部):当主 LLM 调用 processRefund 后,技能内部的 LLM 只看到 queryOrder 和 applyRefund 两个工具。当调用 inquireOrderStatus 时,内部 LLM 只看到 queryOrder 和 queryLogistics。
这就是渐进式工具展现 ——工具不是一次性全部铺开,而是按需逐层展开。主 Agent 面对用户意图做粗粒度分发,技能内部做细粒度执行。
3. 代码实现
SkillRegistry 提供一个 getPublicToolView() 方法,返回移除了被技能吸收的内部工具后的精简工具列表:
// SkillRegistry
public Collection<Tool> getPublicToolView(Set<String> keepPublic) {
// 收集所有技能引用的工具名
Set<String> absorbed = new HashSet<>();
for (Skill skill : skills.values()) {
absorbed.addAll(skill.getTools());
}
// keepPublic 中的工具保留在主视图
absorbed.removeAll(keepPublic);
List<Tool> publicView = new ArrayList<>();
for (Tool tool : toolRegistry.getTools()) {
if (!absorbed.contains(tool.name())) {
publicView.add(tool);
}
}
return publicView;
}
keepPublic 参数的作用:有些工具既在技能内使用,也允许主 Agent 直接调用。比如 queryOrder——用户说"帮我查订单 88231 的状态"时,主 Agent 直接调 queryOrder 就够了,不需要走 inquireOrderStatus 技能。所以 queryOrder 要留在主视图。
使用方式:
// 指定哪些工具保留在主 Agent 视图(即使被技能引用)
Set<String> keepPublic = Set.of("queryOrder", "searchKnowledge", "getCurrentTime");
Collection<Tool> publicView = skillRegistry.getPublicToolView(keepPublic);
// 用精简后的工具列表构建 ReActAgent
ArrayNode tools = toolRegistry.buildToolsJsonArray(objectMapper, publicView);
4. 收益量化
用比特严选的场景算一笔帐:
| 维度 | 扁平展现(无技能) | 扁平展现(有技能但不吸收) | 渐进式展现 |
|---|---|---|---|
| 主 Agent 函数数量 | 5 个工具 | 7 个(5 工具 + 2 技能) | 5 个(3 公共工具 + 2 技能) |
| 工具描述 Token 消耗 | ~500 | ~700 | ~500 |
| 主 LLM 选择准确率 | 中等 | 中等偏低(选项多) | 高(选项少且语义清晰) |
| 误操作风险 | 有(可直接调 applyRefund) | 有 | 低(applyRefund 对主 Agent 不可见) |
当工具规模从 5 个增长到 30 个时,渐进式展现的收益更明显——假设其中 20 个工具被 8 个技能吸收,主 Agent 只需要看到 10 + 8 = 18 个函数,而不是 38 个。工具描述的 Token 消耗从 3800 降到 1800,选择准确率也随之提升。
用一句话概括:技能不只是把多步编排封装成一个函数——它还把内部工具从主 Agent 的视野中收敛起来,让主 LLM 在更小的决策空间里做更准确的选择。这就是渐进式工具展现的核心价值。
Skill vs Plan-and-Execute:什么时候用哪个
1. 三层能力光谱
到这里,TinyAgent 有了三种执行能力,它们不是替代关系,而是一个从确定到灵活的光谱:
确定性高 ◀━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━▶ 灵活性高
Tool Skill Plan-and-Execute
(原子操作) (文件定义的 (动态规划)
有作用域执行)
查一个订单 退款全流程 "帮我退款,顺便 推荐
搜一次知识库 订单全流程查询 一个新扫地机"
Tool 是最小单元:一次操作,一个结果。
Skill 是预定义的有作用域执行:YAML 定义指令和工具子集,LLM 在约束下执行,不需要临时规划。新增技能只需要新建 YAML 文件,无需改代码。
Plan-and-Execute 是动态规划:每次根据用户需求生成新计划。最灵活,但多花一次规划的 LLM 调用。
2. 决策矩阵
| 判断维度 | 用 Tool | 用 Skill | 用 Plan-and-Execute |
|---|---|---|---|
| 任务步骤数 | 1 步 | 2-4 步,流程固定 | 3 步以上,流程不固定 |
| 场景频率 | 不限 | 高频(值得沉淀) | 不限 |
| 步骤稳定性 | — | 固定(写入 instructions) | 不固定,需要临时规划 |
| 子任务数 | 1 个 | 1 个(内含多步) | 多个独立子任务 |
| 新增成本 | 写 Java + 编译 | 新建 YAML 文件 | 零(通用引擎) |
| Token 效率 | 每步 1 次 LLM | 技能内部 2-3 次 LLM | 规划 1 次 + 每步 1 次 LLM |
3. 组合使用
三种模式可以组合。上一篇的 AgentRouter 可以进一步扩展:
用户输入
↓
[AgentRouter]
├── 知识问答? → 固定 RAG Workflow
├── 匹配已有技能? → ReAct + Skill
├── 简单工具任务? → ReAct
└── 复杂多目标? → Plan-and-Execute
实际上不需要显式路由——技能注册到 ToolRegistry 后,ReAct 和 Plan-and-Execute 都能自动使用它们。ReAct 场景下主 LLM 会直接选择技能;Plan-and-Execute 场景下 Planner 也可以在计划步骤的 toolHint 里指定技能名称。
进阶思考:技能的更多可能性
1. 动态加载与热更新
当前实现在启动时扫描一次 skills/ 目录。生产环境可以加一个文件监听(如 WatchService),目录里新增或修改了 YAML 文件就自动重新加载——不重启服务就能上线新技能。这和 Spring Boot 的 application.yml 热更新是同一个思路。
2. 技能组合
一个技能的 tools 列表里能不能放另一个技能的名字?技术上可以——SkillToolAdapter 注册到了 ToolRegistry,getTool("processRefund") 能找到它。但这会导致嵌套执行(技能内部再启动一个技能的 ReAct 循环),增加延迟和 Token 消耗。更好的做法是在需要多技能协作的场景下使用 Plan-and-Execute 或多智能体架构——这正是后续篇章要讲的内容。
3. 技能质量评估
instructions 写得好不好,直接决定技能执行的质量。可以为每个技能配置测试用例:
# process-refund.yaml 的末尾可以加
test_cases:
- input: '{"orderId":"88231","reason":"质量问题"}'
expect_tools: [queryOrder, applyRefund]
- input: '{"orderId":"99999","reason":"不想要了"}'
expect_tools: [queryOrder]
expect_contains: "订单不存在"
这些用例可以在 CI 里跑,确保技能的 instructions 调整后行为不退化。
文末总结
这一篇从 Plan-and-Execute 的重复规划问题出发,引入了 Skill 的完整设计:
- 文件定义:每个技能是一个 YAML 文件,包含名称、描述、工具子集、参数定义和执行指令(instructions)。新增技能只需新建 YAML 文件,不需要改代码、编译、部署。对齐了 LangChain4j 的 SKILL 文件和 Semantic Kernel 的 Plugin 目录。
- 目录发现:
SkillRegistry.loadFromDirectory()扫描skills/目录,解析 YAML 为 Skill 对象,通过SkillToolAdapter桥接注册到ToolRegistry。现有的 ReActAgent 和 PlanAndExecuteAgent 无需任何修改就能使用技能。 - LLM 驱动执行:
SkillExecutor为每次技能调用创建一个有作用域的小型 ReAct 循环——用技能的 instructions 作为系统提示词,只暴露技能声明的工具子集(Tool Scoping)。技能的步骤由 LLM 按指令执行,不是硬编码在 Java 里 。 - Function Calling 发现:技能以 Tool 的身份混在工具列表里,主 LLM 通过描述匹配自动选择技能,不需要额外的路由机制。
- 渐进式工具展现:被技能吸收的内部工具从主 Agent 视图中移除,主 LLM 在更小的决策空间里做更准确的选择。工具不是一次性全部铺开,而是按需逐层展开——主 Agent 做粗粒度分发,技能内部做细粒度执行。
用一句话概括:Tool 是原子操作,Plan-and-Execute 是临时规划,Skill 是把验证过的流程写成 YAML 指令、让 LLM 在受限工具范围内照着执行。三者不是替代关系,而是确定性与灵活性的光谱——越稳定的场景越往 Skill 靠,越灵活的需求越往 Plan-and-Execute 靠。
到这里,TinyAgent 有了单工具调用、预定义技能和动态规划三种能力。但之前的例子一直在处理单品类的需求——退一台扫地机、查一个订单。如果用户说"帮我对比你们的比特 AirX 耳机和比特 BandPro 头戴式耳机,顺便推荐一个搭配手机用的智能手表",这句话横跨耳机和智能穿戴两个品类,涉及商品对比和搭配推荐两种任务类型。下一篇,咱们用跨品类场景来验证工具、技能和规划的组合编排能力——对比 + 搭配 + 售后一起跑。