Skip to main content

用 TinyAgent 从零实现 Skill 机制

作者:程序员马丁

在线博客:https://nageoffer.com

note

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 可见列表中排除,激活后才动态注入:

  • queryOrderapplyRefundqueryLogistics 这类流程专属工具放进技能的 tools 列表,激活前不可见
  • searchKnowledgegetCurrentTime 这类通用工具保持始终可见
  • 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 指令
发现方式代码中手动注册到 ToolRegistryskills/ 目录自动扫描加载
执行方式直接调用业务接口,无 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 桥接。

四个字段:namedescription 来自 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 列表,收集技能专属工具名(如 queryOrderapplyRefundqueryLogistics),然后只把不在这个集合里的通用工具注册到主 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;
}

yamlMappernew 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)。queryOrderqueryLogisticsapplyRefund 在激活对应技能之前完全不在 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 机制的核心——它同时实现了 ToolDynamicToolProvider 两个接口,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 如何注入动态工具

上一节讲了 ActivateSkillToolinvoke() 把技能专属工具暂存到 activatedToolsdynamicTools() 负责取出并清空。那 ReActAgent 什么时候调 dynamicTools()?取出的工具又怎么加到 LLM 可见列表里?

答案在 run() 方法的主循环里。先看两个关键的局部变量(都在 run() 内部声明,每次调用重新创建):

  • budgetedToolsList<Tool>,决定下一次 LLM 调用能看到哪些工具。初始时只有通用工具 + activate_skill,一共 3 个。
  • dynamicToolMapMap<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_skilltoolRegistry 里有 activate_skill(通用工具),找到并执行 invoke()。执行过程中,ActivateSkillToolqueryOrderapplyRefund 存进了 activatedTools。执行完毕后,代码检测到 ActivateSkillTool 实现了 DynamicToolProvider,于是调 dynamicTools() 取出这两个工具:

  • dynamicToolMap 存入 queryOrderapplyRefund(后续执行时能找到)
  • budgetedTools 加入这两个工具(LLM 下一圈可见)
  • 重建 tools JSON,工具列表从 3 个变为 5 个

第 2 圈:LLM 调用 queryOrdertoolRegistry 里没有 queryOrder——它是技能专属工具,启动时就被 buildAgentRegistry() 排除了。但 dynamicToolMap 里有(第 1 圈刚放进去的),找到并执行,返回订单详情。QueryOrderTool 没有实现 DynamicToolProvider,跳过注入检查。

第 3 圈:LLM 调用 applyRefund。同理,toolRegistry 里没有,dynamicToolMap 里有,找到并执行,返回退款结果。

整个过程可以用一张表概括:

圈次LLM 调用从哪找到执行后动态注入LLM 可见工具数
1activate_skilltoolRegistryqueryOrderapplyRefund3 → 5
2queryOrderdynamicToolMap5
3applyRefunddynamicToolMap5
4(无工具调用,输出最终回复)5

5.2 三个设计要点

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

注入时去重process-refundorder-inquiry 都声明了 queryOrder。如果用户在一次 run() 里先后激活两个技能,queryOrder 会被注入两次。代码用 existingNames 集合做去重——Set.add() 返回 true 说明是新元素,false 说明已存在。dynamicToolMap 始终覆盖更新(保证最新版本能执行),但 budgetedTools 只在首次出现时才添加,避免 tools JSON 里出现重复的工具定义。

局部变量保证跨调用隔离dynamicToolMapbudgetedTools 都是 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 看不到 queryOrderapplyRefundqueryLogistics——它们是技能专属工具,只有激活对应技能后才会出现在 tools 列表中。面对退掉订单 88231 这种请求,LLM 唯一能做的就是调用 activate_skill

整个流程的时序如下:

这张图的关键在于两点。第一,只有一个 LLM、一个主循环。技能指令作为 tool 角色的消息进入主上下文,LLM 在同一个对话里读取指令、调用工具、生成回复,没有子 Agent。第二,工具动态注入。LLM 初始只看到 3 个工具,调用 activate_skill 后通过 DynamicToolProvider 解锁了 queryOrderapplyRefund,工具列表从 3 个变为 5 个。

2. 技能描述的设计技巧

activate_skilldescription() 内嵌了所有技能的名称和说明——这是 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,过滤掉技能专属工具(queryOrderqueryLogisticsapplyRefund),只注册通用工具(searchKnowledgegetCurrentTime)和 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 后,DynamicToolProviderqueryOrderapplyRefund 注入了可见列表。从第 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 的自由发挥变成了标准化流程。

Skill vs Plan-and-Execute:什么时候用哪个

1. 三层能力光谱

到这里,TinyAgent 有了三种执行能力,它们不是替代关系,而是一个从确定到灵活的光谱:

确定性高 ◀━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━▶ 灵活性高

Tool Skill Plan-and-Execute
(原子操作) (文件定义的 (动态规划)
指令驱动执行)

查一个订单 退款全流程 帮我退款,顺便推荐
搜一次知识库 订单全流程查询 一个新扫地机

Tool 是最小单元:一次操作,一个结果。

Skill 是预定义的指令驱动执行:SKILL.md 定义指令,LLM 在指令引导下执行,不需要临时规划。新增技能只需要新建一个 .md 文件,无需改代码。

Plan-and-Execute 是动态规划:每次根据用户需求生成新计划。最灵活,但多花一次规划的 LLM 调用。

2. 决策矩阵

判断维度用 Tool用 Skill用 Plan-and-Execute
任务步骤数1 步2-4 步,流程固定3 步以上,流程不固定
场景频率不限高频(值得沉淀)不限
步骤稳定性固定(写入 instructions)不固定,需要临时规划
子任务数1 个1 个(内含多步)多个独立子任务
新增成本写 Java + 编译新建 .md 文件零(通用引擎)
Token 效率每步走主 Agent 上下文只多一次 activate_skill 调用加载指令规划 1 次 + 每步走 Executor 上下文

3. 组合使用

三种模式可以组合。上一篇的 AgentRouter 可以进一步扩展:

用户输入

[AgentRouter]
├── 知识问答? → 固定 RAG Workflow
├── 匹配已有技能? → ReAct + Skill
├── 简单工具任务? → ReAct
└── 复杂多目标? → Plan-and-Execute

实际上不需要显式路由——技能注册到主 Agent 的 ToolRegistry 后,ReAct 和 Plan-and-Execute 都能自动使用它们。ReAct 场景下主 LLM 会直接选择技能;Plan-and-Execute 场景下 Planner 也可以在计划步骤的 toolHint 里指定技能名称。

进阶思考:技能的更多可能性

1. 动态加载与热更新

当前实现在启动时扫描一次 skills/ 目录。生产环境可以加一个文件监听(如 WatchService),目录里新增或修改了 .md 文件就自动重新加载——不重启服务就能上线新技能。这和 Spring Boot 的 application.yml 热更新是同一个思路。

2. 技能组合

LLM 能不能在一次对话里激活多个技能?技术上可以——activate_skill 始终在 tools 列表里,LLM 可以多次调用它。但连续激活多个技能会导致指令堆叠,上下文膨胀,LLM 可能搞混不同技能的步骤。更好的做法是在需要多技能协作的场景下使用 Plan-and-Execute 或多智能体架构——这正是后续篇章要讲的内容。

3. 技能质量评估

instructions 写得好不好,直接决定技能执行的质量。但不像 Java 代码有编译器兜底,Markdown 指令改一个字就可能让 LLM 跳步骤或者选错工具——而且你改完之后看不出来,得跑一遍才能发现。

解决思路是给每个技能写回归测试。TinyAgent 当前没有内置这个机制,但思路很直接——写一个测试方法,把用户消息喂给 Agent,录制 LLM 实际调用了哪些工具,然后断言工具调用序列符合预期:

// 技能回归测试示意(非 TinyAgent 现有代码)
@Test
void processRefund_normalFlow() {
// 正常退款:已签收订单,应该走 activate_skill → queryOrder → applyRefund
String answer = agent.run("订单 88231 扫地机坏了,帮我退掉");

// 断言:工具调用序列包含这三个,且顺序正确
assertThat(toolCallRecorder.getCalledTools())
.containsExactly("activate_skill", "queryOrder", "applyRefund");
// 断言:最终回复包含退款单号
assertThat(answer).contains("RF");
}

@Test
void processRefund_orderNotFound() {
// 订单不存在:应该走 activate_skill → queryOrder,不应该调 applyRefund
String answer = agent.run("帮我退掉订单 99999");

assertThat(toolCallRecorder.getCalledTools())
.containsExactly("activate_skill", "queryOrder");
assertThat(toolCallRecorder.getCalledTools())
.doesNotContain("applyRefund");
assertThat(answer).containsAnyOf("订单不存在", "订单号有误");
}

这里的关键是 toolCallRecorder——一个记录 LLM 实际工具调用序列的组件,在测试时注入到 Agent 中。它不干预执行过程,只是旁路记录每次 invoke() 被调用时的工具名称。

有了这类测试,每次修改 process-refund.md 的指令文本后跑一遍,就能确认 LLM 是否仍然按照先查订单 → 再退款的顺序执行,不会因为改了一句描述就把流程搞乱。

需要注意的是,LLM 的输出具有不确定性——同样的输入可能偶尔走出不同的工具调用路径。生产环境如果要做这类测试,通常会把 LLM 的 temperature 设为 0 来降低随机性,或者多次运行取通过率而不是要求每次都通过。

文末总结

这一篇从 TinyAgent 的项目约定出发,完整实现了 activate_skill 模式的技能系统:

  • 技能专属工具隔离:在 SKILL.md 标准字段之外扩展了 tools 字段,声明技能专属工具。buildAgentRegistry() 启动时将专属工具从 LLM 可见列表中排除,解决了 LLM 绕过技能直接调工具的鸡生蛋问题——对齐了 LangChain4j 的 Skill-Scoped Tools 设计。
  • 目录发现SkillRegistry.loadFromDirectory() 扫描 skills/ 目录的 .md 文件,解析 YAML 前置元数据和 Markdown 指令正文。加载过程只存储定义,不做任何注册。
  • activate_skill + DynamicToolProvider 执行模式:LLM 调用 activate_skill(name) 加载技能指令到主上下文,同时通过 DynamicToolProvider 接口将技能专属工具动态注入 LLM 可见列表。ActivateSkillTool 同时实现 ToolDynamicToolProvider 两个接口,桥接了 SKILL.md 规范到 Function Calling 体系。
  • 工具隔离保证ReActAgentdynamicToolMaprun() 的局部变量,保证多次调用之间的工具不泄漏。
  • 三层能力光谱:Tool(原子操作)→ Skill(指令驱动执行)→ Plan-and-Execute(动态规划),三者不是替代关系,越稳定的场景越往 Skill 靠,越灵活的需求越往 Plan-and-Execute 靠。

到这里,TinyAgent 有了单工具调用、预定义技能和动态规划三种能力。但之前的例子一直在处理单品类的需求——退一台扫地机、查一个订单。如果用户要求对比比特 AirX 耳机和比特 BandPro 头戴式耳机,并顺便推荐一个搭配手机用的智能手表,这句话横跨耳机和智能穿戴两个品类,涉及商品对比和搭配推荐两种任务类型。下一篇,咱们用跨品类场景来验证工具、技能和规划的组合编排能力——对比 + 搭配 + 售后一起跑。