用 TinyAgent 从零实现 Skill 机制
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇梳理了 Skill 的通用规范和行业框架实现——SKILL.md 是什么格式、渐进式披露怎么工作、Spring AI 和 LangChain4j 分别怎么做。这一篇把概念落地:用 TinyAgent 从零实现 activate_skill 模式,包括 SKILL.md 解析、技能专属工具隔离、DynamicToolProvider 动态注入,以及完整的 Demo 演示。
TinyAgent 对齐了行业主流的执行模式——LLM 调用 activate_skill(name) 加载技能指令到主上下文,同时解锁技能专属工具,然后在主循环中按指令调用业务工具。整个过程只有一个 LLM、一个对话上下文,没有子 Agent。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.11.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
TinyAgent 怎么用 Skill
TinyAgent 对齐了行业主流的 activate_skill 模式——LLM 调用 activate_skill(name) 加载技能指令到主上下文,同时解锁技能专属工具,然后在主循环中按指令调用业务工具。在标准格式之上,TinyAgent 只新增了一个 tools 字 段,做了一处简化。
1. 为什么需要 tools 字段
上一篇讲到 LangChain4j 有技能作用域工具(Skill-Scoped Tools)——绑定在 Skill 上的工具,激活前对 LLM 不可见,激活后才动态暴露。TinyAgent 也实现了这个机制,原因是:如果所有工具都可见,LLM 会跳过 activate_skill 直接调用业务工具,技能指令永远不会进入上下文。
打个比方,如果 LLM 从一开始就能看到 applyRefund,用户要求退掉订单 88231 时,LLM 很可能直接调 applyRefund——毕竟功能名称和描述就摆在眼前,何必多走一步激活技能?但这样一来,先查订单 → 验证状态 → 再退款的标准流程就被跳过了,技能指令永远不会被读取。
这就是鸡生蛋问题:LLM 不会调 activate_skill,因为它已经看到了想要的工具;技能指令不进上下文,因为 LLM 没调 activate_skill。

解决方案很直接——在 Skill 定义中声明专属工具,启动时从 LLM 可见列表中排除,激活后才动态注入:
queryOrder、applyRefund、queryLogistics这类流程专属工具放进技能的tools列表,激活前不可见searchKnowledge、getCurrentTime这类通用工具保持始终可见- LLM 初始只看到通用工具 +
activate_skill,必须先激活技能才能拿到业务工具
这和 LangChain4j 的 Skill-Scoped Tools 是同一个思路。区别在于 LangChain4j 通过
ToolProvider机制在每轮 LLM 调用前重新评估可见工具,TinyAgent 用更轻量的DynamicToolProvider接口在工具执行后一次性注入——实现不同,效果一致。
2. 我们简化了哪些规则
Skill 是单文件而非文件夹。官方规范中,一个 Skill 是以 SKILL.md 为入口的整个文件夹(可含 scripts/、references/ 等子目录)。TinyAgent 简化为 skills/ 目录下的单个 .md 文件,没有子目录结构。这是因为当前的两个技能(退款、查单)流程简单,不需要脚本和参考文档。生产环境如果技能复杂度上升,应该按照官方规范的文件夹结构组织。
3. 比特严选的两个 Skill 定义
退款处理技能:
<!-- resources/skills/process-refund.md -->
---
name: process-refund
description: >
退款处理技能:自动查询订单状态,验证退款资格,条件满足则提交退款申请。
适用于用户要求退货退款的场景,无需手动分步操作。
tools:
- queryOrder
- applyRefund
---
# 退款处理
## 概述
你是比特严选的退款处理专员。当用户要求退货退款时,按照以下流程处理。
## 处理步骤
### Step 1: 查询订单
使用 queryOrder 查询用户提供的订单号,确认订单状态和商品信息。
### Step 2: 验证退款条件并处理
根据订单状态判断:
- 如果状态为已签收,使用 applyRefund 提交退款申请,退款原因使用用户描述的原因
- 如果状态不是已签收,直接告知用户当前订单状态,说明需要签收后才能申请退款
- 如果订单不存在,告知用户订单号可能有误
## 输出要求
处理完成后,给出简洁明了的处理结果。包含订单商品名称、退款单号(如有)和预计到账时间。
订单全流程查询技能:
<!-- resources/skills/order-inquiry.md -->
---
name: order-inquiry
description: >
订单全流程查询技能:查询订单详情,如果已发货则同时查询物流轨迹,
返回包含订单状态和物流进度的完整信息。无需分步调用。
tools:
- queryOrder
- queryLogistics
---
# 订单全流程查询
## 概述
你是比特严选的订单查询专员。当用户要求查看订单状态或物流进度时,按照以下流程查询完整信息。
## 处理步骤
### Step 1: 查询订单详情
使用 queryOrder 查询订单详情,获取商品名称、价格、订单状态、运单号等信息。
### Step 2: 查询物流(按需)
如果订单有运单号(trackingNo 不为空),使用 queryLogistics 查询物流轨迹。
如果订单没有运单号,说明可能尚未发货,只返回订单基本信息即可。
## 输出要求
查询完成后,给出一段完整的订单状态说明,包含商品信息、当前状态和物流进度(如有)。
frontmatter 有三个字段:name(kebab-case,符合官方规范)、description 是标准字段,tools 是 TinyAgent 扩展的——声明该技能的专属工具,激活前对 LLM 不可见。Markdown 正文就是技能指令——LLM 激活技能后读取这段正文,按步骤调用里面提到的工具。
4. Tool vs Skill:本项目的边界
在 TinyAgent 里,Tool 和 Skill 的区别如下:
| 维度 | Tool(工具) | Skill(技能) |
|---|---|---|
| 定义方式 | Java 类,实现 Tool 接口 | .md 文件,YAML 前置元数据 + Markdown 指令 |
| 发现方式 | 代码中手动注册到 ToolRegistry | 从 skills/ 目录自动扫描加载 |
| 执行方式 | 直接调用业务接口,无 LLM 参与 | LLM 在主上下文中读取指令,按步骤调用工具 |
| 灵活性 | 高(LLM 自由组合) | 中等(LLM 在指令约束下执行) |
| 新增成本 | 写 Java 代码、编译、部署 | 新建一个 .md 文件,无需编译 |
instructions和 Plan-and-Execute 的 Planner 提示词的区别:Planner 的提示词教 LLM 怎么拆解步骤(元能力),Skill 的 Markdown 正文直接告诉 LLM 具体该怎么做(领域知识)。前者教你怎么做规划,后者要求照着这份规划来执行。
Skill 数据类与核心组件
1. Skill 数据类
SKILL.md 文件加载后需要映射成 Java 对象。Skill 是一个纯粹的数据类:
@Data
@NoArgsConstructor
public class Skill {
private String name;
private String description;
private List<String> tools;
private String instructions;
}
用 Lombok 的 @Data 生成 getter/setter,@NoArgsConstructor 用于 Jackson 反序列化。和之前几篇的 Tool 接口不同,Skill 不实现 Tool 接口——Skill 是数据类,存放从 YAML 加载的配置;Tool 是行为接口,定义了 invoke() 方法。两者之间通过 ActivateSkillTool 桥接。
四个字段:name 和 description 来自 YAML frontmatter,tools 也来自 frontmatter——声明该技能的专属工具名列表,instructions 来自 Markdown 正文。tools 里列出的工具在技能激活前对 LLM 不可见,激活后由 DynamicToolProvider 动态注入。
2. 类关系一览

SkillRegistry:目录发现与注册
SkillRegistry 做三件事:从目录扫描 .md 文件、按 SKILL.md 格式解析、通过 buildAgentRegistry() 构建主 Agent 的工具注册表。
public class SkillRegistry {
private final Map<String, Skill> skills = new LinkedHashMap<>();
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
public SkillRegistry() {
}
// 从指定目录扫描所有 .md 文件,按 SKILL.md 格式解析
public void loadFromDirectory(Path skillsDir) {
// ...... 遍历 *.md 文件,调用 parseSkillMd
}
/**
* 构建主 Agent 的工具注册表:
* - 收集所有技能声明的 tools,标记为技能专属
* - 只注册不属于任何技能的通用工具 + activate_skill
* - 技能专属工具在 activate_skill 激活后由 DynamicToolProvider 动态注入
*/
public ToolRegistry buildAgentRegistry(ToolRegistry baseRegistry) {
Set<String> skillScopedNames = new HashSet<>();
for (Skill skill : skills.values()) {
if (skill.getTools() != null) {
skillScopedNames.addAll(skill.getTools());
}
}
ToolRegistry agentRegistry = new ToolRegistry();
for (Tool tool : baseRegistry.getTools()) {
if (!skillScopedNames.contains(tool.name())) {
agentRegistry.register(tool);
}
}
agentRegistry.register(new ActivateSkillTool(this, baseRegistry));
return agentRegistry;
}
// ......
}
构造函数很简单——不需要任何外部依赖。SkillRegistry 只负责加载和注册,不参与执行。buildAgentRegistry() 的核心逻辑是分离通用工具和技能专属工具:先遍历所有技能的 tools 列表,收集技能专属工具名(如 queryOrder、applyRefund、queryLogistics),然后只把不在这个集合里的通用工具注册到主 Agent 的注册表。ActivateSkillTool 同时接收 this(用于查找技能定义)和 baseRegistry(全量注册表,用于激活时查找专属工具对象)。
1. SKILL.md 解析
SkillRegistry 扫描 skills/ 目录下的 .md 文件,按 SKILL.md 格式解析——提取 --- 之间的 YAML 前置元数据和 --- 之后的 Markdown 正文:
private Skill parseSkillMd(Path file) throws IOException {
String content = Files.readString(file);
String trimmed = content.strip();
int secondSep = trimmed.indexOf("---", 3);
String yamlPart = trimmed.substring(3, secondSep).strip();
String markdownBody = trimmed.substring(secondSep + 3).strip();
// YAML 部分解析为 Skill 对象(name、description、tools)
Skill skill = yamlMapper.readValue(yamlPart, Skill.class);
// Markdown 正文作为 instructions
skill.setInstructions(markdownBody);
return skill;
}

yamlMapper 是 new ObjectMapper(new YAMLFactory()),Jackson 的 YAML 扩展模块。Maven 依赖:
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
2. 两个注册表的关系
buildAgentRegistry() 构建的主 Agent 注册表不包含技能专属工具——它们被隔离到激活后才可见:

LLM 初始只能看到 3 个工具(2 个通用工具 + 1 个 activate_skill)。queryOrder、queryLogistics、applyRefund 在激活对应技能之前完全不在 LLM 的 tools 列表中——LLM 不知道它们的存在,也无法直接调用。这从根本上解决了绕过技能直接调工具的问题。
3. DynamicToolProvider:动态工具注入接口
在讲 ActivateSkillTool 之前,先引入一个关键接口——DynamicToolProvider:
public interface DynamicToolProvider {
List<Tool> dynamicTools();
}
这个接口只有一个方法:dynamicTools() 返回需要动态注入的工具列表。ReActAgent 在执行完一个工具后,检查它是否实现了 DynamicToolProvider——如果是,就把返回的工具加入 LLM 可见列表。这是一种通用机制,不限于 Skill 场景。
4. ActivateSkillTool:SKILL.md 到 Function Calling 的桥梁
ActivateSkillTool 是整个 Skill 机制的核心——它同时实现了 Tool 和 DynamicToolProvider 两个接口,LLM 通过 Function Calling 调用它来激活技能,同时解锁技能专属工具:
public class ActivateSkillTool implements Tool, DynamicToolProvider {
private final SkillRegistry skillRegistry;
private final ToolRegistry executionRegistry;
private final String parametersJson;
private final String descriptionText;
private final List<Tool> activatedTools = new ArrayList<>();
public ActivateSkillTool(SkillRegistry skillRegistry,
ToolRegistry executionRegistry) {
this.skillRegistry = skillRegistry;
this.executionRegistry = executionRegistry;
this.parametersJson = buildParametersJson(skillRegistry.getSkills());
this.descriptionText = buildDescription(skillRegistry.getSkills());
}
@Override
public String name() { return "activate_skill"; }
@Override
public String description() { return descriptionText; }
@Override
public String parameters() { return parametersJson; }
@Override
public String invoke(String input) {
String skillName = ToolUtils.extractRequiredField(input, "name");
if (skillName.isBlank()) {
return "{\"error\":\"缺少必填参数 name\"}";
}
Skill skill = skillRegistry.getSkill(skillName);
if (skill == null) {
return "{\"error\":\"未找到技能:" + skillName + "\"}";
}
activatedTools.clear();
if (skill.getTools() != null) {
for (String toolName : skill.getTools()) {
Tool tool = executionRegistry.getTool(toolName);
if (tool != null) {
activatedTools.add(tool);
}
}
}
return "技能已激活:" + skillName
+ "。请严格按照以下指令操作:\n\n"
+ skill.getInstructions();
}
@Override
public List<Tool> dynamicTools() {
List<Tool> result = new ArrayList<>(activatedTools);
activatedTools.clear();
return result;
}
private static String buildDescription(Collection<Skill> skills) {
StringBuilder sb = new StringBuilder("激活一个技能来获取专业处理指令。可用技能:");
boolean first = true;
for (Skill skill : skills) {
if (!first) {
sb.append("、");
}
sb.append(skill.getName()).append("(")
.append(skill.getDescription().strip()).append(")");
first = false;
}
sb.append("。激活后会返回详细的处理步骤,并解锁该技能所需的专属工具。");
return sb.toString();
}
private static String buildParametersJson(Collection<Skill> skills) {
String enumValues = skills.stream()
.map(s -> "\"" + s.getName() + "\"")
.collect(Collectors.joining(","));
return "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\","
+ "\"description\":\"要激活的技能名称\",\"enum\":["
+ enumValues + "]}},\"required\":[\"name\"]}";
}
}
这个类做两件事:返回指令文本 + 暂存技能专属工具。几个设计要点:
description 动态拼接技能列表。构造时 buildDescription() 遍历所有已注册的技能,把每个技能的 name 和完整 description 拼成一段文本,存入 descriptionText——这对应渐进式披露的 Level 1。LLM 在 tools 数组里看到这段描述,就知道有哪些技能可以激活、每个技能做什么,不需要额外的系统提示词。注意结尾写了“并解锁该技能所需的专属工具”——告诉 LLM 激活后会有新工具出现。
parameters 带 enum 约束。buildParametersJson() 同样在构造时从技能列表动态生成 JSON Schema,enum 列出了所有合法的技能名,LLM 只能从中选择,不会瞎编一个不存在的技能名。两个构建方法都在构造时执行一次,description() 和 parameters() 直接返回缓存的字符串,避免每次 LLM 调用都重复拼接。
invoke 返回指令文本 + 暂存工具。当 LLM 调用 activate_skill 并传入 process-refund 时,invoke() 做两件事:①根据技能的 tools 列表从全量注册表(executionRegistry)中查找对应的 Tool 对象,暂存到 activatedTools;②返回技能的完整 Markdown 指令作为 observation。
dynamicTools 取出并清空。ReActAgent 执行完 invoke() 后,检测到 DynamicToolProvider,调用 dynamicTools() 取走暂存的工具。activatedTools 随即清空,避免重复注入。
5. ReActAgent 如何注入动态工具
上一节讲了 ActivateSkillTool 的 invoke() 把技能专属工具暂存到 activatedTools,dynamicTools() 负责取出并清空。那 ReActAgent 什么时候调 dynamicTools()?取出的工具又怎么加到 LLM 可见列表里?
答案在 run() 方法的主循环里。先看两个关键的局部变量(都在 run() 内部声明,每次调用重新创建):
budgetedTools:List<Tool>,决定下一次 LLM 调用能看到哪些工具。初始时只有通用工具 +activate_skill,一共 3 个。dynamicToolMap:Map<String, Tool>,存放激活后注入的技能专属工具。执行阶段查找工具时,先查toolRegistry(通用工具),没找到再查这个 Map。
每执行完一个工具,ReActAgent 检查它是否实现了 DynamicToolProvider——如果是,就取出动态工具,同时放进这两个数据结构:
// ReActAgent.run() 中的工具执行块(简化,省略了 observation 折叠、消息构建等)
Map<String, Tool> dynamicToolMap = new LinkedHashMap<>();
for (ToolCallInfo tc : response.toolCalls()) {
// 第一步:查找工具——先查静态注册表,再查动态注入表
Tool targetTool = toolRegistry.getTool(tc.functionName());
if (targetTool == null) {
targetTool = dynamicToolMap.get(tc.functionName());
}
String observation = targetTool != null
? targetTool.invoke(tc.arguments() == null ? "" : tc.arguments())
: "{\"error\":\"未找到工具:" + tc.functionName() + "\"}";
// 第二步:检查是否有动态工具需要注入
if (targetTool instanceof DynamicToolProvider provider) {
List<Tool> newTools = provider.dynamicTools();
if (!newTools.isEmpty()) {
Set<String> existingNames = new HashSet<>();
for (Tool t : budgetedTools) {
existingNames.add(t.name());
}
for (Tool newTool : newTools) {
// 放进执行 查找表(始终覆盖,保证能执行)
dynamicToolMap.put(newTool.name(), newTool);
// 放进 LLM 可见列表(去重,existingNames.add 返回 true 说明是新工具)
if (existingNames.add(newTool.name())) {
budgetedTools.add(newTool);
}
}
// 重建 tools JSON,LLM 下一圈就能看到新工具
tools = toolRegistry.buildToolsJsonArray(objectMapper, budgetedTools);
}
}
}
5.1 用退款场景走一遍
用退掉订单 88231 这个请求,逐圈走一遍这段逻辑:
第 1 圈:LLM 调用 activate_skill。toolRegistry 里有 activate_skill(通用工具),找到并执行 invoke()。执行过程中,ActivateSkillTool 把 queryOrder 和 applyRefund 存进了 activatedTools。执行完毕后,代码检测到 ActivateSkillTool 实现了 DynamicToolProvider,于是调 dynamicTools() 取出这两个工具:
dynamicToolMap存入queryOrder和applyRefund(后续执行时能找到)budgetedTools加入这两个工具(LLM 下一圈可见)- 重建 tools JSON,工具列表从 3 个变为 5 个
第 2 圈:LLM 调用 queryOrder。toolRegistry 里没有 queryOrder——它是技能专属工具,启动时就被 buildAgentRegistry() 排除了。但 dynamicToolMap 里有(第 1 圈刚放进去的),找到并执行,返回订单详情。QueryOrderTool 没有实现 DynamicToolProvider,跳过注入检查。
第 3 圈:LLM 调用 applyRefund。同理,toolRegistry 里没有,dynamicToolMap 里有,找到并执行,返回退款结果。
整个过程可以用一张表概括:
| 圈次 | LLM 调用 | 从哪找到 | 执行后动态注入 | LLM 可见工具数 |
|---|---|---|---|---|
| 1 | activate_skill | toolRegistry | queryOrder、applyRefund | 3 → 5 |
| 2 | queryOrder | dynamicToolMap | 无 | 5 |
| 3 | applyRefund | dynamicToolMap | 无 | 5 |
| 4 | (无工具调用,输出最终回复) | — | — | 5 |

5.2 三个设计要点
工具查找两步走。执行工具时先查 toolRegistry(静态注册的通用工具),没找到再查 dynamicToolMap(动态注入的技能专属工具)。这样做的好处是:技能激活前 LLM 即使幻觉出一个 queryOrder 调用,也会得到 未找到工具 的错误;激活后 queryOrder 进了 dynamicToolMap,同样的调用就能正常执行。

注入时去重。process-refund 和 order-inquiry 都声明了 queryOrder。如果用户在一次 run() 里先后激活两个技能,queryOrder 会被注入两次。代码用 existingNames 集合做去重——Set.add() 返回 true 说明是新元素,false 说明已存在。dynamicToolMap 始终覆盖更新(保证最新版本能执行),但 budgetedTools 只在首次出现时才添加,避免 tools JSON 里出现重复的工具定义。
局部变量保证跨调用隔离。dynamicToolMap 和 budgetedTools 都是 run() 方法的局部变量,每次 run() 调用都从空 Map / 初始列表开始。场景 1 激活了 applyRefund,场景 2 调用 run() 时这个工具不会带过来——必须重新激活技能才能获取。
6. 为什么 instructions 由 LLM 执行而不是硬编码
一个自然的疑问:退款流程不就是查订单 → 判断状态 → 退款吗,为什么不直接写成 Java 代码?
// 伪代码示意——硬编码方式,不需要 LLM
String orderJson = toolRegistry.execute(new Action("queryOrder", input));
if ("已签收".equals(extractStatus(orderJson))) {
return toolRegistry.execute(new Action("applyRefund", input));
}
这种方式当然可以,也更快更省 Token。但它有两个局限:
参数提取和错误处理依赖硬编码。用户可能只说想退上周买的扫地机,原因是坏了好几天。LLM 在主上下文中能结合对话历史理解用户意图,灵活组织退款原因描述,硬编码做不到这一点。
新增技能需要编译部署。硬编码的技能是 Java 类,每次新增或修改都需要改代码、编译、重新部署。SKILL.md + LLM 驱动的技能只需要新建一个文件——写清楚指令正文和工具列表,重启加载即可。在快速迭代的业务场景下,这个差异很重要。
当然,对于步骤 极其固定、完全不需要 LLM 灵活性的场景,硬编码 Tool 也是合理的选择。但那时候它就是一个普通的复合 Tool(在 Spring AI 里就是一个 @Tool 方法里写了多步逻辑),不叫 Skill。Skill 的核心特征是 LLM 驱动执行 + 指令引导 + 文件定义 + 目录发现。
7. Skill 指令的写作技巧
SKILL.md 的 Markdown 正文就是技能的灵魂。写得好,LLM 按部就班完成任务;写得差,LLM 可能跳步骤或者瞎发挥。几个实践下来管用的原则:
用祈使句直接下指令。不要写“你应该查询订单”,直接写“使用 queryOrder 查询订单详情”。LLM 对直接指令的遵循度远高于建议式表达。
每一步说清楚用哪个工具。不要写“查一下订单状态”,要写“使用 queryOrder 查询订单详情”——把工具名明确写出来。LLM 在 2 个工具的选择空间里都可能纠结,明确指令能大幅降低出错率。
用 Markdown 结构组织步骤。概述、处理步骤、输出要求分节写,每个步骤用 ### Step N 标记。LLM 对结构化文本的理解比一大段自然语言好得多。
把分支条件写成列表。例如“如果已签收则退款,否则告知用户”——写成带 - 的列表比写成一段话清晰。LLM 对列表格式的条件判断遵循率更高。
LLM 发现与激活机制
1. 技能注册后如何被 LLM 看到
buildAgentRegistry() 过滤掉了技能专属工具,主 Agent 初始看到的 tools 列表只有 3 个:
[
{"type":"function","function":{"name":"searchKnowledge","description":"检索知识库..."}},
{"type":"function","function":{"name":"getCurrentTime","description":"获取当前时间..."}},
{"type":"function","function":{"name":"activate_skill",
"description":"激活一个技能来获取专业处理指令。可用技能:process-refund(退款处理技能:自动查询订单状态,验证退款资格,条件满足则提交退款申请。适用于用户要求退货退款的场景,无需手动分步操作。)、order-inquiry(订单全流程查询技能:查询订单详情,如果已发货则同时查询物流轨迹,返回包含订单状态和物流进度的完整信息。无需分步调用。)。激活后会返回详细的处理步骤,并解锁该技能所需的专属工具。",
"parameters":{"type":"object","properties":{"name":{"type":"string",
"description":"要激活的技能名称",
"enum":["process-refund","order-inquiry"]}},"required":["name"]}}}
]
LLM 看不到 queryOrder、applyRefund、queryLogistics——它们是技能专属工具,只有激活对应技能后才会出现在 tools 列表中。面对退掉订单 88231 这种请求,LLM 唯一能做的就是调用 activate_skill。
整个流程的时序如下:

这张图的关键在于两点。第一,只有一个 LLM、一个主循环。技能指令作为 tool 角色的消息进入主上下文,LLM 在同一个对话里读取指令、调用工具、生成回复,没有子 Agent。第二,工具动态注入。LLM 初始只看到 3 个工具,调用 activate_skill 后通过 DynamicToolProvider 解锁了 queryOrder 和 applyRefund,工具列表从 3 个变为 5 个。
2. 技能描述的设计技巧
activate_skill 的 description() 内嵌了所有技能的名称和说明——这是 LLM 判断是否需要激活技能的唯一依据。三个原则:
说清楚做了什么。不要只写“处理 退款”,要写“查询订单状态,验证退款资格,条件满足则提交退款申请”——让 LLM 知道这个技能覆盖了哪些步骤。
说清楚什么时候用。“适用于用户要求退货退款的场景”——帮助 LLM 判断当前意图是否匹配。
说清楚不用手动做什么。“无需手动分步操作”——告诉 LLM 激活技能后按指令走就行,不需要自己规划步骤。
| 描述写法 | 问题 | 改进 |
|---|---|---|
| 处理退款 | 太模糊,LLM 不确定该不该激活 | 写明内部步骤 |
| 查询订单并退款 | 没说清适用场景 | 加上场景说明 |
| 退款处理技能:自动查询订单状态,验证退款资格,条件满足则提交退款申请。适用于用户要求退货退款的场景,无需手动分步操作。 | 三个要素齐全 | — |
3. 技能与工具共存时的选择逻辑
初始状态下,主 Agent 看到 2 个通用工具 + 1 个 activate_skill。由于 queryOrder 等业务工具被隔离在技能里,LLM 面对业务请求时只能走技能路径:
- 用户要求退掉订单 88231 → LLM 选
activate_skill并传入process-refund(意图匹配退款技能描述,且没有其他业务工具可选) - 用户询问订单 88231 到哪了 → LLM 选
activate_skill并传入order-inquiry(意图匹配订单查询技能描述) - 用户询问退换货政策 → LLM 选
searchKnowledge(知识查询属于通 用工具,不需要走技能) - 用户询问当前时间 → LLM 选
getCurrentTime(通用工具,直接可用)
Demo:完整运行示例
把所有组件串起来:
public class SkillDemo {
public static void main(String[] args) {
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")
);
// 从 skills/ 目录加载技能定义(SKILL.md 格式)
SkillRegistry skillRegistry = new SkillRegistry();
skillRegistry.loadFromDirectory(Path.of("src/main/resources/skills"));
// 构建主 Agent 注册表:通用工具 + activate_skill(技能专属工具激活后才可见)
ToolRegistry agentTools = skillRegistry.buildAgentRegistry(toolRegistry);
ReActAgent agent = new ReActAgent(llmClient, agentTools, 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:注册业务工具。所有 5 个基础工具注册到 toolRegistry。
Step 2:加载技能定义。loadFromDirectory() 扫描 .md 文件,按 SKILL.md 格式解析(YAML 前置元数据 + Markdown 指令正文),存入 SkillRegistry。注意这一步只是解析和存储,不做任何 ToolRegistry 注册。
Step 3:构建主 Agent 注册表。buildAgentRegistry(toolRegistry) 创建一个新的 ToolRegistry,过滤掉技能专属工具(queryOrder、queryLogistics、applyRefund),只注册通用工具(searchKnowledge、getCurrentTime)和 activate_skill。LLM 初始只能看到 3 个函数。

预期控制台输出(大模型措辞每次会有细微差异,但技能激活和工具调用的结构一致):
场景 1:退款
[SkillRegistry] 加载技能:process-refund(process-refund.md)
[SkillRegistry] 加载技能:order-inquiry(order-inquiry.md)
[SkillRegistry] 技能专属工具(激活前不可见):[queryOrder, queryLogistics, applyRefund]
========== 场景 1:退款 ==========
===== 第 1 圈 =====
[工具调用] activate_skill({"name":"process-refund"})
[Skill] 激活技能:process-refund,解锁工具:[queryOrder, applyRefund]
[工具结果] 技能已激活:process-refund。请严格按照以下指令操作......
[动态工具] 解锁 2 个技能专属工具:[queryOrder, applyRefund]
===== 第 2 圈 =====
[工具调用] queryOrder({"orderId":"88231"})
[工具结果] {"orderId":"88231","product":"比特 S10 Pro 扫地机",
"price":1999,...,"status":"已签收",...}
===== 第 3 圈 =====
[工具调用] applyRefund({"orderId":"88231","reason":"扫地机质量问题"})
[工具结果] {"success":true,"refundId":"RF20260629001",...}
===== 第 4 圈 =====
[最终答复] 已经为您处理了订单 88231 的退款申请......
看控制台日志的三个关键点:
第一,启动时打印了技能专属工具列表。[SkillRegistry] 技能专属工具(激活前不可见):[queryOrder, queryLogistics, applyRefund]——这三个工具被从主 Agent 注册表中排除了,LLM 初始看不到它们。
第二,第 1 圈激活技能后解锁了专属工具。LLM 调用 activate_skill 后,DynamicToolProvider 把 queryOrder 和 applyRefund 注入了可见列表。从第 2 圈开始,LLM 才能调用这两个工具。
第三,第 2-3 圈 LLM 按照技能指令依次调用工具——先 queryOrder 确认状态是已签收,然后 applyRefund 提交退款。指令文本里写了“使用 queryOrder 查询”和“使用 applyRefund 提交退款”,LLM 自然按步骤执行。整个过程在主循环中完成,没有子 Agent。
场景 2:订单全流程查询
========== 场景 2:订单全流程查询 ==========
===== 第 1 圈 =====
[工具调用] activate_skill({"name":"order-inquiry"})
[Skill] 激活技能:order-inquiry,解锁工具:[queryOrder, queryLogistics]
[工具结果] 技能已激活:order-inquiry。请严格按照以下指令操作......
[动态工具] 解锁 2 个技能专属工具:[queryOrder, queryLogistics]
===== 第 2 圈 =====
[工具调用] queryOrder({"orderId":"88231"})
[工具结果] {"orderId":"88231",...,"trackingNo":"SF1234567890",...}
===== 第 3 圈 =====
[工具调用] queryLogistics({"trackingNo":"SF1234567890"})
[工具结果] {"trackingNo":"SF1234567890","carrier":"顺丰速运","status":"已签收",...}
===== 第 4 圈 =====
[最终答复] 您的订单 88231(比特 S10 Pro 扫地机)已签收......
两个场景的共同点:主循环跑了 4 圈(激活技能 + 调工具 1-2 次 + 生成回复),全程在同一个 LLM 上下文中完成。对比没有技能的 ReAct 方式:场景 1 同样需要 3-4 圈(查订单 → 判断状态 → 退款 → 回复),圈数差不多。
那 Skill 的价值在哪?不在于减少圈数,而在于两点:工具隔离 + 流程引导。工具隔离确保 LLM 必须先激活技能才能拿到业务工具,不可能跳过技能直接操作;技能指令保证了 LLM 先查订单再退款,不会跳步骤、不会乱序。两者配合,把退款从 LLM 的自由发挥变成了标准化流程。