21188 字
约 70 分钟
1
第12章 Skills-工具的组合与复用

第12章 Skills-工具的组合与复用

来源:https://ai-agent-guide.xiaofuge.cn/chapters/ch08-skills.html 所属:第三篇-Agent的手脚


第三篇:Agent 的手脚 📌 本章目标

第10章讲了 Function Calling 工具调用,第11章讲了工具的标准化协议(MCP)。本章讲工具的组合——把多个工具打包成一个"技能",让 Agent 拥有更高层次的能力。

12.1 从工具到技能

打个比方:工具是一把锤子,技能是"钉钉子"——锤子只是一个工具,但"钉钉子"需要锤子 + 钉子 + 判断角度 + 控制力度。技能是工具的组合 + 使用知识。 | 维度 | Tool(工具) | Skill(技能) | | --- | --- | --- | | 粒度 | 单一操作(get_weather) | 复合能力(天气查询+行程规划+穿衣建议) | | 工具数量 | 1 个 | 1~N 个 | | 包含知识 | 无 | 使用策略、调用顺序、参数约束 | | 可复用性 | 低(每个项目重新定义) | 高(打包成包,跨项目共享) | | 类比 | 函数 | 类/模块 | ## 12.2 Skills 的三层结构

一个完整的 Skill 包含三层:

# 一个 Skill 的 YAML 定义示例
name: weather-advisor
version: 1.0.0
description: 天气顾问技能,查询天气并提供穿衣/出行建议
triggers:
- "天气"
- "下雨"
- "穿什么"
- "带伞"
permissions:
- network: true    # 需要网络访问
- location: false  # 不需要定位

tools:
- name: get_weather
    description: 查询天气
    parameters:
      city: { type: string, required: true }
      date: { type: string, default: today }
- name: get_clothing_advice
    description: 根据天气获取穿衣建议
    parameters:
      temperature: { type: number, required: true }
      weather: { type: string, required: true }

knowledge: | ## 使用策略
  1. 先调用 get_weather 获取天气信息
  2. 如果用户问穿衣建议,再调用 get_clothing_advice
  3. 如果是雨天,主动提醒带伞
  4. 温度低于 10°C 提醒保暖,高于 30°C 提醒防暑

  ## 参数约束
- city 必须是中文城市名
- date 支持 today/tomorrow/YYYY-MM-DD

12.3 Skills 的发现与加载

Agent 怎么知道有哪些 Skills 可用?和 CLI 工具类似,Skills 也需要自动发现机制:

12.3.1 文件系统发现

Skill 以文件形式存储在 skills/ 目录下,Agent 启动时扫描目录:

import os
import yaml

class SkillLoader:
    def __init__(self, skills_dir: str = "skills"):
        self.skills_dir = skills_dir
        self.registry: dict = {}  # name -> skill_definition

    def scan(self):
        """扫描 skills 目录,加载所有技能"""
        if not os.path.exists(self.skills_dir):
            return

        for entry in os.scandir(self.skills_dir):
            if not entry.is_dir():
                continue

            skill_file = os.path.join(entry.path, "skill.yml")
            if not os.path.exists(skill_file):
                continue

            with open(skill_file, "r") as f:
                skill_def = yaml.safe_load(f)
                skill_def["path"] = entry.path
                self.registry[skill_def["name"]] = skill_def

        print(f"加载了 {len(self.registry)} 个技能: {list(self.registry.keys())}")

    def get_skill_prompt(self) -> str:
        """生成技能描述,注入到 system prompt"""
        if not self.registry:
            return ""

        lines = ["你可以使用以下技能:"]
        for name, skill in self.registry.items():
            triggers = ", ".join(skill.get("triggers", []))
            lines.append(f"- {name}: {skill['description']} (触发词: {triggers})")

        return "\n".join(lines)
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';

class SkillLoader {
  private registry: Map = new Map();

  constructor(private skillsDir: string = 'skills') {}

  scan(): void {
    if (!fs.existsSync(this.skillsDir)) return;

    for (const entry of fs.readdirSync(this.skillsDir, { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;

      const skillFile = path.join(this.skillsDir, entry.name, 'skill.yml');
      if (!fs.existsSync(skillFile)) continue;

      const skillDef = yaml.load(fs.readFileSync(skillFile, 'utf-8')) as any;
      skillDef.path = path.join(this.skillsDir, entry.name);
      this.registry.set(skillDef.name, skillDef);
    }

    console.log(`加载了 ${this.registry.size} 个技能: ${[...this.registry.keys()]}`);
  }

  getSkillPrompt(): string {
    if (this.registry.size === 0) return '';

    const lines: string[] = ['你可以使用以下技能:'];
    for (const [name, skill] of this.registry) {
      const triggers = (skill.triggers || []).join(', ');
      lines.push(`- ${name}: ${skill.description} (触发词: ${triggers})`);
    }
    return lines.join('\n');
  }
}
package main

import (
	"fmt"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

type SkillLoader struct {
	SkillsDir string
	Registry  map[string]map[string]interface{}
}

func NewSkillLoader(skillsDir string) *SkillLoader {
	return &SkillLoader{
		SkillsDir: skillsDir,
		Registry:  make(map[string]map[string]interface{}),
	}
}

func (sl *SkillLoader) Scan() error {
	entries, err := os.ReadDir(sl.SkillsDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}

	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}

		skillFile := filepath.Join(sl.SkillsDir, entry.Name(), "skill.yml")
		data, err := os.ReadFile(skillFile)
		if err != nil {
			continue
		}

		var skillDef map[string]interface{}
		if err := yaml.Unmarshal(data, &skillDef); err != nil {
			continue
		}
		skillDef["path"] = filepath.Join(sl.SkillsDir, entry.Name())
		if name, ok := skillDef["name"].(string); ok {
			sl.Registry[name] = skillDef
		}
	}

	keys := make([]string, 0, len(sl.Registry))
	for k := range sl.Registry {
		keys = append(keys, k)
	}
	fmt.Printf("加载了 %d 个技能: %v\n", len(sl.Registry), keys)
	return nil
}

func (sl *SkillLoader) GetSkillPrompt() string {
	if len(sl.Registry) == 0 {
		return ""
	}
	lines := []string{"你可以使用以下技能:"}
	for name, skill := range sl.Registry {
		triggers, _ := skill["triggers"].([]interface{})
		triggerStrs := make([]string, len(triggers))
		for i, t := range triggers {
			triggerStrs[i] = fmt.Sprintf("%v", t)
		}
		desc, _ := skill["description"].(string)
		lines = append(lines, fmt.Sprintf("- %s: %s (触发词: %s)", name, desc, joinStrings(triggerStrs, ", ")))
	}
	return joinStrings(lines, "\n")
}

func joinStrings(strs []string, sep string) string {
	result := ""
	for i, s := range strs {
		if i > 0 {
			result += sep
		}
		result += s
	}
	return result
}
import java.io.*;
import java.nio.file.*;
import java.util.*;
import org.yaml.snakeyaml.Yaml;

public class SkillLoader {
    private String skillsDir;
    private Map> registry = new HashMap<>();

    public SkillLoader(String skillsDir) {
        this.skillsDir = skillsDir;
    }

    public void scan() {
        Path dirPath = Paths.get(skillsDir);
        if (!Files.exists(dirPath)) return;

        try (var stream = Files.newDirectoryStream(dirPath)) {
            for (var entry : stream) {
                if (!Files.isDirectory(entry)) continue;

                Path skillFile = entry.resolve("skill.yml");
                if (!Files.exists(skillFile)) continue;

                try (var reader = Files.newBufferedReader(skillFile)) {
                    Yaml yaml = new Yaml();
                    Map skillDef = yaml.load(reader);
                    skillDef.put("path", entry.toString());
                    String name = (String) skillDef.get("name");
                    registry.put(name, skillDef);
                }
            }
            System.out.printf("加载了 %d 个技能: %s%n", registry.size(), registry.keySet());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public String getSkillPrompt() {
        if (registry.isEmpty()) return "";

        StringBuilder sb = new StringBuilder("你可以使用以下技能:\n");
        for (var entry : registry.entrySet()) {
            String name = entry.getKey();
            Map skill = entry.getValue();
            List triggers = (List) skill.getOrDefault("triggers", Collections.emptyList());
            String desc = (String) skill.get("description");
            sb.append(String.format("- %s: %s (触发词: %s)%n", name, desc, String.join(", ", triggers)));
        }
        return sb.toString();
    }
}

12.3.2 热重载

生产环境需要热重载——修改 Skill 文件后无需重启 Agent:

from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

class SkillHotReloader(FileSystemEventHandler):
    def __init__(self, loader: SkillLoader):
        self.loader = loader

    def on_modified(self, event):
        if event.src_path.endswith("skill.yml"):
            print(f"检测到 Skill 变更,重新加载...")
            self.loader.scan()

# 启动热重载监控
observer = Observer()
observer.schedule(
    SkillHotReloader(loader),
    path="skills/",
    recursive=True
)
observer.start()
import * as chokidar from 'chokidar';

class SkillHotReloader {
  constructor(private loader: SkillLoader) {}

  start(): void {
    chokidar.watch('skills/', {
      ignored: /(^|[/\\])\./,
      persistent: true
    }).on('change', (filePath) => {
      if (filePath.endsWith('skill.yml')) {
        console.log('检测到 Skill 变更,重新加载...');
        this.loader.scan();
      }
    });
    console.log('热重载监控已启动');
  }
}

// 启动热重载监控
const reloader = new SkillHotReloader(loader);
reloader.start();
package main

import (
	"fmt"
	"path/filepath"
	"time"

	"github.com/fsnotify/fsnotify"
)

type SkillHotReloader struct {
	Loader *SkillLoader
}

func NewSkillHotReloader(loader *SkillLoader) *SkillHotReloader {
	return &SkillHotReloader{Loader: loader}
}

func (r *SkillHotReloader) Start() error {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	defer watcher.Close()

	err = watcher.Add("skills/")
	if err != nil {
		return err
	}

	fmt.Println("热重载监控已启动")
	for {
		select {
		case event :=  {
            while (true) {
                try {
                    WatchKey key = watchService.take();
                    for (WatchEvent event : key.pollEvents()) {
                        Path changedFile = (Path) event.context();
                        if (changedFile.toString().endsWith("skill.yml")) {
                            System.out.println("检测到 Skill 变更,重新加载...");
                            loader.scan();
                        }
                    }
                    key.reset();
                } catch (InterruptedException e) {
                    break;
                }
            }
        }).start();
    }
}

// 启动热重载监控
SkillHotReloader reloader = new SkillHotReloader(loader);
reloader.start();

12.4 Skills 的懒加载

和 CLI 工具一样,Skills 太多会导致 Prompt 过长。解决方案是三层懒加载: | 层级 | 策略 | 注入到 Prompt | 示例 | | --- | --- | --- | --- | | 第一层 | 始终加载 | Skill 名称 + 描述 | weather-advisor, file-reader | | 第二层 | 触发词匹配时加载 | 完整 Schema + 使用知识 | 用户提到"天气"时加载 weather-advisor 的完整定义 | | 第三层 | AI 主动搜索 | 按需 | 罕见 Skill,靠 AI 用 ToolSearch 查找 | ``` class LazySkillManager: def init(self, loader: SkillLoader): self.loader = loader self.activated: set = set() # 已激活的 Skill

def get_prompt(self, user_message: str = "") -> str: """根据用户消息生成 Prompt""" lines = []

第一层:始终列出所有 Skill 名称(简短)

for name, skill in self.loader.registry.items(): triggers = skill.get("triggers", [])

第二层:触发词匹配 → 激活完整定义

if any(t in user_message for t in triggers): self.activated.add(name) lines.append(self._format_full_skill(skill)) else:

第一层:只注入名称和描述

lines.append(f"- {name}: {skill['description']}")

第三层:已激活但用户消息不匹配的,保持激活

for name in self.activated: if name not in [l.split(":")[0].strip("- ") for l in lines]: skill = self.loader.registry.get(name) if skill: lines.append(self._format_full_skill(skill))

return "\n你可以使用以下技能:\n" + "\n".join(lines)

def _format_full_skill(self, skill: dict) -> str: """格式化完整 Skill 定义""" tools_desc = "\n".join( f" - {t['name']}: {t['description']}" for t in skill.get("tools", []) ) return f"- {skill['name']} (已激活)\n 工具:\n{tools_desc}\n 策略:\n{skill.get('knowledge', '')}"


class LazySkillManager { private activated: Set = new Set();

constructor(private loader: SkillLoader) {}

getPrompt(userMessage: string = ''): string { const lines: string[] = [];

// 第一层:始终列出所有 Skill 名称(简短) for (const [name, skill] of this.loader.registry) { const triggers: string[] = skill.triggers || []; // 第二层:触发词匹配 → 激活完整定义 if (triggers.some(t => userMessage.includes(t))) { this.activated.add(name); lines.push(this.formatFullSkill(skill)); } else { // 第一层:只注入名称和描述 lines.push(- ${name}: ${skill.description}); } }

// 第三层:已激活但用户消息不匹配的,保持激活 for (const name of this.activated) { const alreadyListed = lines.some(l => l.startsWith(- ${name}:)); if (!alreadyListed) { const skill = this.loader.registry.get(name); if (skill) lines.push(this.formatFullSkill(skill)); } }

return '\n你可以使用以下技能:\n' + lines.join('\n'); }

private formatFullSkill(skill: any): string { const toolsDesc = (skill.tools || []) .map((t: any) => - ${t.name}: ${t.description}) .join('\n'); return - ${skill.name} (已激活)\n 工具:\n${toolsDesc}\n 策略:\n${skill.knowledge || ''}; } }


package main

import ( "fmt" "strings" )

type LazySkillManager struct { Loader *SkillLoader Activated map[string]bool }

func NewLazySkillManager(loader *SkillLoader) *LazySkillManager { return &LazySkillManager{ Loader: loader, Activated: make(map[string]bool), } }

func (m *LazySkillManager) GetPrompt(userMessage string) string { var lines []string

// 第一层:始终列出所有 Skill 名称(简短)
for name, skill := range m.Loader.Registry {

triggers, _ := skill["triggers"].([]interface{}) matched := false for _, t := range triggers { if strings.Contains(userMessage, fmt.Sprintf("%v", t)) { matched = true break } } if matched { m.Activated[name] = true lines = append(lines, m.formatFullSkill(skill)) } else { desc, _ := skill["description"].(string) lines = append(lines, fmt.Sprintf("- %s: %s", name, desc)) } }

// 第三层:已激活但用户消息不匹配的,保持激活
for name := range m.Activated {

alreadyListed := false for _, l := range lines { if strings.HasPrefix(l, fmt.Sprintf("- %s:", name)) { alreadyListed = true break } } if !alreadyListed { skill, ok := m.Loader.Registry[name] if ok { lines = append(lines, m.formatFullSkill(skill)) } } }

return "\n你可以使用以下技能:\n" + strings.Join(lines, "\n")

}

func (m *LazySkillManager) formatFullSkill(skill map[string]interface{}) string { tools, _ := skill["tools"].([]interface{}) var toolLines []string for _, t := range tools { tool, _ := t.(map[string]interface{}) toolLines = append(toolLines, fmt.Sprintf(" - %v: %v", tool["name"], tool["description"])) } knowledge, _ := skill["knowledge"].(string) return fmt.Sprintf("- %v (已激活)\n 工具:\n%s\n 策略:\n%s", skill["name"], strings.Join(toolLines, "\n"), knowledge) }


import java.util.*;

public class LazySkillManager { private SkillLoader loader; private Set activated = new HashSet<>();

public LazySkillManager(SkillLoader loader) { this.loader = loader; }

public String getPrompt(String userMessage) { List lines = new ArrayList<>();

// 第一层:始终列出所有 Skill 名称(简短) for (var entry : loader.getRegistry().entrySet()) { String name = entry.getKey(); Map skill = entry.getValue(); List triggers = (List) skill.getOrDefault("triggers", Collections.emptyList()); boolean matched = triggers.stream().anyMatch(userMessage::contains); if (matched) { activated.add(name); lines.add(formatFullSkill(skill)); } else { lines.add(String.format("- %s: %s", name, skill.get("description"))); } }

// 第三层:已激活但用户消息不匹配的,保持激活 for (String name : activated) { boolean alreadyListed = lines.stream().anyMatch(l -> l.startsWith("- " + name + ":")); if (!alreadyListed) { Map skill = loader.getRegistry().get(name); if (skill != null) lines.add(formatFullSkill(skill)); } }

return "\n你可以使用以下技能:\n" + String.join("\n", lines); }

private String formatFullSkill(Map skill) { List> tools = (List>) skill.getOrDefault("tools", Collections.emptyList()); String toolsDesc = tools.stream() .map(t -> String.format(" - %s: %s", t.get("name"), t.get("description"))) .reduce((a, b) -> a + "\n" + b) .orElse(""); return String.format("- %s (已激活)\n 工具:\n%s\n 策略:\n%s", skill.get("name"), toolsDesc, skill.getOrDefault("knowledge", "")); } }


## 12.5 Skill 匹配与调度

当用户说"北京明天天气怎么样,穿什么合适?",Agent 需要:
- 匹配到 `weather-advisor` Skill(触发词"天气")
- 激活该 Skill 的完整定义
- 按知识层策略执行:先调 get_weather,再调 get_clothing_advice
- 综合两个工具的结果,生成自然语言回答

## 12.6 Skills vs MCP vs Function Calling | 维度 | Function Calling | MCP | Skills | | --- | --- | --- | --- | | 本质 | 单个工具调用 | 工具的标准协议 | 工具的组合 + 知识 | | 粒度 | 函数级 | Server 级 | 能力级 | | 包含知识 | ❌ | ❌ | ✅ 使用策略、调用顺序 | | 跨项目复用 | ❌ 需重新定义 | ✅ Server 共享 | ✅ Skill 包共享 | | 关系 | 底层机制 | 传输层 | 上层组织 | 三者的关系是**层级递进**的:
- **Function Calling** 是底层机制——AI 调用函数的能力
- **MCP** 是传输层——标准化工具的发现和调用协议
- **Skills** 是上层组织——把多个工具(无论 Function Calling 还是 MCP)打包成可复用的能力单元
**💡 一个 Skill 可以包含 MCP 工具**

Skills 不排斥 MCP。一个 `database-advisor` Skill 可以包含一个 MCP database server 提供的查询工具,加上自定义的 SQL 优化建议工具,以及"先分析表结构再优化查询"的使用策略。

## 12.7 实战:构建一个 Skill

把第2章的天气查询升级为一个完整的 Skill:

skills/weather-advisor/skill.yml

name: weather-advisor version: 1.0.0 description: 天气顾问,查询天气并提供穿衣和出行建议 triggers:

  • "天气"
  • "下雨"
  • "穿什么"
  • "带伞"
  • "气温"

tools:

  • name: get_weather description: 查询指定城市指定日期的天气 parameters: city: { type: string, required: true, description: "城市名" } date: { type: string, default: "today", description: "日期" }
  • name: get_clothing_advice description: 根据天气获取穿衣建议 parameters: temperature: { type: number, required: true, description: "温度(°C)" } weather: { type: string, required: true, description: "天气状况" }

knowledge: | ## 使用策略

  1. 总是先调用 get_weather 获取天气
  2. 如果用户问穿衣/出行建议,调用 get_clothing_advice
  3. 主动建议:
  • 雨天 → 带伞
  • 30°C → 防暑
  • 雾霾 → 口罩
  1. 如果查多天天气,逐天调用 get_weather

对应的 Python 实现:

skills/weather-advisor/main.py

def get_weather(city: str, date: str = "today") -> dict:

... 同第2章实现 ...

def get_clothing_advice(temperature: float, weather: str) -> dict: advice = [] if temperature { // ... 同第2章实现 ... }

function getClothingAdvice(temperature: number, weather: string): { advice: string } { const advice: string[] = []; if (temperature getWeather(String city, String date) { // ... 同第2章实现 ... return null; }

public static Map getClothingAdvice(double temperature, String weather) { List advice = new ArrayList<>(); if (temperature result = new HashMap<>(); result.put("advice", String.join("、", advice)); return result; } }


## 12.8 Skill 市场:未来的应用商店

Skills 的终极目标是形成**市场**——开发者像发布 App 一样发布 Skill 包,用户像装 App 一样安装 Skill:

安装 Skill(类似 npm install)

skill install weather-advisor skill install code-reviewer skill install data-analyst

查看已安装的 Skills

skill list

weather-advisor 1.0.0 天气顾问

code-reviewer 2.1.0 代码审查

data-analyst 0.9.0 数据分析

卸载

skill remove weather-advisor


这和 MCP 的愿景类似,但层次不同:MCP 是工具级的市场,Skills 是能力级的市场。未来可能两者融合——MCP Server 提供工具,Skill 包提供组合策略。

## 12.9 Skill 的分层体系

就像企业架构有基础设施层、业务逻辑层、应用层一样,Skill 也存在**三层金字塔**——从底层的基础 Skill 到顶层的领域 Skill,每一层封装不同复杂度的能力: | 维度 | 基础 Skill | 复合 Skill | 领域 Skill | | --- | --- | --- | --- | | 工具数量 | 1 | 2~5 | 5~20+(含子 Skill) | | 知识含量 | 无(纯工具映射) | 中等(调用策略) | 高(行业规则 + 合规约束) | | 触发方式 | AI 自主调用 | 触发词匹配 | 领域关键词 + 上下文推理 | | 复用范围 | 跨项目通用 | 跨团队复用 | 跨行业复用(需定制) | | 类比 WaLiCode | ToolDefinition 单工具 | WorkflowChain 多步骤编排 | DomainPackage 行业配置包 | | 验证方式 | 单元测试 | 集成测试 + Prompt 测试 | 端到端场景测试 + 合规审计 | ### 12.9.1 基础 Skill:单工具的标准化封装

基础 Skill 就是把一个原始工具加上元数据、Schema 校验、异常处理,变成一个**标准化**的工具单元。它不包含调用策略,但包含**防御性编程**:

skills/base/read_file/skill.yml — 基础 Skill 示例

name: read_file version: 1.0.0 description: 读取文件内容,支持文本和图片 layer: base

tools:

  • name: read_file description: 读取指定路径的文件 parameters: path: { type: string, required: true, description: "文件路径" } offset: { type: number, default: null, description: "起始行号" } limit: { type: number, default: null, description: "最大行数" }

guards:

  • path_traversal: true # 防止路径穿越攻击
  • max_file_size: 10MB # 限制读取大小
  • allowed_extensions: [.txt, .md, .py, .json, .yaml, .yml, .csv, .jpg, .png]

error_handling: file_not_found: "文件不存在,请检查路径是否正确" permission_denied: "无权限读取该文件,请确认访问权限" file_too_large: "文件超过 10MB 限制,请使用 offset/limit 分段读取"


skills/base/read_file/main.py

import os import pathlib

class ReadFileSkill: """基础 Skill:单工具封装,侧重防御性编程"""

ALLOWED_EXTENSIONS = {'.txt', '.md', '.py', '.json', '.yaml', '.yml', '.csv', '.jpg', '.png'} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB

def execute(self, path: str, offset: int = None, limit: int = None) -> dict:

1. 路径穿越防御(WaLiCode 的 permissionGuard 模式)

resolved = pathlib.Path(path).resolve() if ".." in str(resolved) or not str(resolved).startswith(os.getcwd()): return {"error": "路径穿越攻击被拦截,禁止访问"}

2. 扩展名白名单

if resolved.suffix not in self.ALLOWED_EXTENSIONS: return {"error": f"不支持的文件类型: {resolved.suffix}"}

3. 文件大小限制

if resolved.stat().st_size > self.MAX_FILE_SIZE: return {"error": f"文件超过 {self.MAX_FILE_SIZE//1024//1024}MB 限制"}

4. 执行核心逻辑

if resolved.suffix in {'.jpg', '.png'}: return {"type": "image", "path": str(resolved)}

with open(resolved, "r", encoding="utf-8") as f: lines = f.readlines() if offset: lines = lines[offset-1:] # 1-indexed if limit: lines = lines[:limit] return {"type": "text", "content": "".join(lines), "lines": len(lines)}


// skills/base/read_file/main.ts import * as fs from 'fs'; import * as path from 'path';

class ReadFileSkill { /** 基础 Skill:单工具封装,侧重防御性编程 */

private readonly ALLOWED_EXTENSIONS = new Set([ '.txt', '.md', '.py', '.json', '.yaml', '.yml', '.csv', '.jpg', '.png' ]); private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

execute(filePath: string, offset?: number, limit?: number): Record { // 1. 路径穿越防御 const resolved = path.resolve(filePath); if (resolved.includes('..') || !resolved.startsWith(process.cwd())) { return { error: '路径穿越攻击被拦截,禁止访问' }; }

// 2. 扩展名白名单 const ext = path.extname(resolved).toLowerCase(); if (!this.ALLOWED_EXTENSIONS.has(ext)) { return { error: 不支持的文件类型: ${ext} }; }

// 3. 文件大小限制 const stats = fs.statSync(resolved); if (stats.size > this.MAX_FILE_SIZE) { return { error: 文件超过 ${this.MAX_FILE_SIZE / 1024 / 1024}MB 限制 }; }

// 4. 执行核心逻辑 if (['.jpg', '.png'].includes(ext)) { return { type: 'image', path: resolved }; }

const content = fs.readFileSync(resolved, 'utf-8'); let lines = content.split('\n'); if (offset) { lines = lines.slice(offset - 1); // 1-indexed } if (limit) { lines = lines.slice(0, limit); } return { type: 'text', content: lines.join('\n'), lines: lines.length }; } }


package main

import ( "fmt" "os" "path/filepath" "strings" )

type ReadFileSkill struct { AllowedExtensions map[string]bool MaxFileSize int64 }

func NewReadFileSkill() *ReadFileSkill { return &ReadFileSkill{ AllowedExtensions: map[string]bool{ ".txt": true, ".md": true, ".py": true, ".json": true, ".yaml": true, ".yml": true, ".csv": true, ".jpg": true, ".png": true, }, MaxFileSize: 10 * 1024 * 1024, // 10MB } }

func (s *ReadFileSkill) Execute(pathStr string, offset, limit int) map[string]interface{} { // 1. 路径穿越防御 resolved, _ := filepath.Abs(pathStr) cwd, _ := os.Getwd() if strings.Contains(resolved, "..") || !strings.HasPrefix(resolved, cwd) { return map[string]interface{}{"error": "路径穿越攻击被拦截,禁止访问"} }

// 2. 扩展名白名单
ext := filepath.Ext(resolved)
if !s.AllowedExtensions[ext] {

return map[string]interface{}{"error": fmt.Sprintf("不支持的文件类型: %s", ext)} }

// 3. 文件大小限制
info, err := os.Stat(resolved)
if err != nil {

return map[string]interface{}{"error": err.Error()} } if info.Size() > s.MaxFileSize { return map[string]interface{}{"error": fmt.Sprintf("文件超过 %dMB 限制", s.MaxFileSize/1024/1024)} }

// 4. 执行核心逻辑
if ext == ".jpg" || ext == ".png" {

return map[string]interface{}{"type": "image", "path": resolved} }

data, err := os.ReadFile(resolved)
if err != nil {

return map[string]interface{}{"error": err.Error()} } lines := strings.Split(string(data), "\n") if offset > 0 { lines = lines[offset-1:] } if limit > 0 && limit ALLOWED_EXTENSIONS = Set.of( ".txt", ".md", ".py", ".json", ".yaml", ".yml", ".csv", ".jpg", ".png" ); private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

public Map execute(String path, Integer offset, Integer limit) { // 1. 路径穿越防御 Path resolved = Paths.get(path).toAbsolutePath().normalize(); String cwd = System.getProperty("user.dir"); if (resolved.toString().contains("..") || !resolved.startsWith(cwd)) { return Map.of("error", "路径穿越攻击被拦截,禁止访问"); }

// 2. 扩展名白名单 String ext = resolved.toString().substring(resolved.toString().lastIndexOf(".")); if (!ALLOWED_EXTENSIONS.contains(ext)) { return Map.of("error", "不支持的文件类型: " + ext); }

try { // 3. 文件大小限制 long size = Files.size(resolved); if (size > MAX_FILE_SIZE) { return Map.of("error", "文件超过 " + (MAX_FILE_SIZE / 1024 / 1024) + "MB 限制"); }

// 4. 执行核心逻辑 if (ext.equals(".jpg") || ext.equals(".png")) { return Map.of("type", "image", "path", resolved.toString()); }

List allLines = Files.readAllLines(resolved); int start = offset != null ? offset - 1 : 0; int end = limit != null ? Math.min(start + limit, allLines.size()) : allLines.size(); List lines = allLines.subList(start, end); return Map.of("type", "text", "content", String.join("\n", lines), "lines", lines.size()); } catch (IOException e) { return Map.of("error", e.getMessage()); } } }


### 12.9.2 复合 Skill:多工具组合 + 调用策略

复合 Skill 是基础 Skill 的组合,核心价值是**调用策略**——告诉 AI "先做什么、后做什么、什么条件下做什么":

skills/compound/code-reviewer/skill.yml — 复合 Skill 示例

name: code-reviewer version: 2.1.0 description: 代码审查技能,自动检查代码质量、安全漏洞和最佳实践 layer: compound triggers: ["代码审查", "review", "检查代码", "code review", "PR"]

tools:

  • name: read_file # 基础 Skill description: 读取代码文件
  • name: lint_check # 基础 Skill description: 运行 lint 工具检查代码风格 parameters: file_path: { type: string, required: true } linter: { type: string, default: "auto" }
  • name: security_scan # 基础 Skill description: 安全漏洞扫描 parameters: file_path: { type: string, required: true } severity: { type: string, default: "medium" }
  • name: generate_report # 基础 Skill description: 生成审查报告 parameters: findings: { type: array, required: true } format: { type: string, default: "markdown" }

knowledge: | ## 调用策略

  1. 先 read_file 读取目标代码
  2. 并行调用 lint_check + security_scan
  3. 汇总结果 → generate_report
  4. 严重安全问题 → 优先报告(severity=high)
  5. 如果是 Python 文件,优先用 pylint;JS 用 eslint

WaLiCode 对应

  • WorkflowChain 模式:read → [lint + security] → report
  • ParallelGateway 模式:lint 和 security 可并行

### 12.9.3 领域 Skill:行业知识包

领域 Skill 是金字塔顶端——它不是几个工具的组合,而是**一个行业的完整知识框架**。一个金融领域 Skill 可能包含合规检查、风控计算、报表生成等 10+ 个复合 Skill,以及金融法规的领域知识:

skills/domain/finance-advisor/skill.yml — 领域 Skill 示例

name: finance-advisor version: 1.0.0 description: 金融顾问技能包,包含合规检查、风控分析和报表生成 layer: domain triggers: ["投资", "风险评估", "合规", "财报", "K线", "理财"]

sub_skills:

  • compliance_checker # 合规检查(复合 Skill)
  • risk_analyzer # 风控分析(复合 Skill)
  • report_generator # 报表生成(复合 Skill)

domain_knowledge: | ## 金融合规规则

  • 所有投资建议必须附带风险提示
  • 不得推荐具体个股(合规红线)
  • 用户资产配置需遵循"适当性原则"
  • 引用数据需标注来源和时效性

风控计算规则

  • 夏普比率 > 1.0 才算优质策略
  • 最大回撤 > 30% 需特别警示
  • 杠杆倍数 > 3x 需风险提示

WaLiCode 对应模式

  • DomainPackage:行业配置包
  • ComplianceGuard:合规红线拦截
  • RiskThresholdGuard:风控阈值告警

permissions:

  • requires_approval: true # 领域 Skill 需审批才能启用
  • audit_log: true # 所有调用需审计日志
  • data_classification: financial_confidential
**💡 金字塔原则**

底层越通用越好(read_file 跨所有项目可用),顶层越专业越好(finance-advisor 只在金融场景有用)。企业 Agent 应**自下而上构建**——先把基础 Skill 打磨好,再组装复合 Skill,最后才封装领域 Skill。不要跳层——没有稳固的基础 Skill,复合 Skill 就是空中楼阁。

## 12.10 Skill 的沉淀与演进

Skill 不是一次设计就完成的,它像代码一样有**版本迭代**的生命周期。好的 Skill 来自项目实践的**反复提炼**——从临时脚本 → 内部最佳实践 → 可复用 Skill 包。

### 12.10.1 从项目中提炼 Skill 的四步流程 | 步骤 | 动作 | 产出 | WaLiCode 对应 | | --- | --- | --- | --- | | 1. 模式识别 | 发现项目中重复出现的工具组合模式 | 模式列表(如:每次都先读文件再 lint) | PatternMining | | 2. 抽象提炼 | 把模式抽象为通用 Skill,去除项目特有逻辑 | Skill YAML + 知识层 | AbstractionLayer | | 3. 验证打磨 | 在 2~3 个项目中试用,收集反馈 | 反馈清单 + Bug 修复 | IterativeRefinement | | 4. 发布复用 | 稳定后发布到 Skill 市场 / 内部仓库 | 版本号 + Changelog | ReleasePipeline | ```
# 步骤 1:模式识别 — 从项目日志中提取重复模式
import re
from collections import Counter

def extract_patterns(execution_logs: list[str]) -> list[dict]:
    """从 Agent 执行日志中识别重复的工具调用模式"""
    # 解析工具调用序列
    sequences = []
    current_seq = []
    for log in execution_logs:
        if "tool_call:" in log:
            tool_name = re.search(r"tool_call: (\w+)", log).group(1)
            current_seq.append(tool_name)
        elif "response:" in log:
            if current_seq:
                sequences.append(tuple(current_seq))
                current_seq = []

    # 统计高频模式(长度 >= 2)
    pattern_counter = Counter(
        seq for seq in sequences if len(seq) >= 2
    )
    return [
        {"pattern": list(p), "count": c}
        for p, c in pattern_counter.most_common(5)
    ]

# 示例输出
patterns = extract_patterns(logs)
# [
#   {"pattern": ["read_file", "lint_check", "security_scan"], "count": 23},
#   {"pattern": ["read_file", "generate_report"], "count": 15},
#   {"pattern": ["get_weather", "get_clothing_advice"], "count": 12},
# ]
// 步骤 1:模式识别 — 从项目日志中提取重复模式
import * as re from 'regex';

interface PatternResult {
  pattern: string[];
  count: number;
}

function extractPatterns(executionLogs: string[]): PatternResult[] {
  /** 从 Agent 执行日志中识别重复的工具调用模式 */
  // 解析工具调用序列
  const sequences: string[][] = [];
  let currentSeq: string[] = [];

  for (const log of executionLogs) {
    if (log.includes('tool_call:')) {
      const match = log.match(/tool_call: (\w+)/);
      if (match) currentSeq.push(match[1]);
    } else if (log.includes('response:')) {
      if (currentSeq.length > 0) {
        sequences.push([...currentSeq]);
        currentSeq = [];
      }
    }
  }

  // 统计高频模式(长度 >= 2)
  const counter = new Map();
  for (const seq of sequences) {
    if (seq.length >= 2) {
      const key = seq.join(' → ');
      counter.set(key, (counter.get(key) || 0) + 1);
    }
  }

  return Array.from(counter.entries())
    .map(([key, count]) => ({ pattern: key.split(' → '), count }))
    .sort((a, b) => b.count - a.count)
    .slice(0, 5);
}

// 示例输出
const patterns = extractPatterns(logs);
// [
//   { pattern: ["read_file", "lint_check", "security_scan"], count: 23 },
//   { pattern: ["read_file", "generate_report"], count: 15 },
//   { pattern: ["get_weather", "get_clothing_advice"], count: 12 },
// ]
package main

import (
	"fmt"
	"regexp"
	"sort"
	"strings"
)

// 步骤 1:模式识别 — 从项目日志中提取重复模式
func ExtractPatterns(executionLogs []string) []map[string]interface{} {
	/** 从 Agent 执行日志中识别重复的工具调用模式 */
	// 解析工具调用序列
	var sequences [][]string
	var currentSeq []string

	toolCallRegex := regexp.MustCompile(`tool_call: (\w+)`)

	for _, log := range executionLogs {
		if strings.Contains(log, "tool_call:") {
			match := toolCallRegex.FindStringSubmatch(log)
			if len(match) > 1 {
				currentSeq = append(currentSeq, match[1])
			}
		} else if strings.Contains(log, "response:") {
			if len(currentSeq) > 0 {
				sequences = append(sequences, currentSeq)
				currentSeq = nil
			}
		}
	}

	// 统计高频模式(长度 >= 2)
	counter := make(map[string]int)
	patternMap := make(map[string][]string)
	for _, seq := range sequences {
		if len(seq) >= 2 {
			key := strings.Join(seq, " → ")
			counter[key]++
			patternMap[key] = seq
		}
	}

	type kv struct {
		key   string
		count int
	}
	var sorted []kv
	for k, v := range counter {
		sorted = append(sorted, kv{k, v})
	}
	sort.Slice(sorted, func(i, j int) bool {
		return sorted[i].count > sorted[j].count
	})

	var result []map[string]interface{}
	for i, kv := range sorted {
		if i >= 5 {
			break
		}
		result = append(result, map[string]interface{}{
			"pattern": patternMap[kv.key],
			"count":   kv.count,
		})
	}
	return result
}

// 示例输出
// patterns := ExtractPatterns(logs)
// [
//   {"pattern": ["read_file", "lint_check", "security_scan"], "count": 23},
//   {"pattern": ["read_file", "generate_report"], "count": 15},
//   {"pattern": ["get_weather", "get_clothing_advice"], "count": 12},
// ]
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;

// 步骤 1:模式识别 — 从项目日志中提取重复模式
public class PatternExtractor {

    public static List> extractPatterns(List executionLogs) {
        /** 从 Agent 执行日志中识别重复的工具调用模式 */
        // 解析工具调用序列
        List> sequences = new ArrayList<>();
        List currentSeq = new ArrayList<>();

        Pattern toolCallPattern = Pattern.compile("tool_call: (\\\\w+)");

        for (String log : executionLogs) {
            if (log.contains("tool_call:")) {
                Matcher matcher = toolCallPattern.matcher(log);
                if (matcher.find()) {
                    currentSeq.add(matcher.group(1));
                }
            } else if (log.contains("response:")) {
                if (!currentSeq.isEmpty()) {
                    sequences.add(new ArrayList<>(currentSeq));
                    currentSeq.clear();
                }
            }
        }

        // 统计高频模式(长度 >= 2)
        Map counter = new HashMap<>();
        Map> patternMap = new HashMap<>();

        for (List seq : sequences) {
            if (seq.size() >= 2) {
                String key = String.join(" → ", seq);
                counter.merge(key, 1, Integer::sum);
                patternMap.put(key, seq);
            }
        }

        return counter.entrySet().stream()
            .sorted((a, b) -> b.getValue().compareTo(a.getValue()))
            .limit(5)
            .map(e -> {
                Map m = new LinkedHashMap<>();
                m.put("pattern", patternMap.get(e.getKey()));
                m.put("count", e.getValue());
                return m;
            })
            .collect(Collectors.toList());
    }
}

// 示例输出
// List> patterns = PatternExtractor.extractPatterns(logs);
// [
//   {pattern: ["read_file", "lint_check", "security_scan"], count: 23},
//   {pattern: ["read_file", "generate_report"], count: 15},
//   {pattern: ["get_weather", "get_clothing_advice"], count: 12},
// ]
# 步骤 2:抽象提炼 — 把模式转化为 Skill YAML
def pattern_to_skill_yaml(pattern: list[str], count: int) -> str:
    """将高频工具调用模式转化为 Skill 定义模板"""
    skill_name = f"{pattern[0]}-workflow"
    tools_yaml = []
    for i, tool in enumerate(pattern):
        tools_yaml.append(f"""  - name: {tool}
    description: 第{i+1}步:{tool}
    parameters:
      input_from_previous: {{ type: string, default: null }}""")

    knowledge = "## 调用策略\n" + "\n".join(
        f"{i+1}. 调用 {tool}" for i, tool in enumerate(pattern)
    )

    return f"""name: {skill_name}
version: 0.1.0
description: 从 {count} 次项目实践中提炼的工具编排模式
layer: compound
triggers: ["{pattern[0]}"]

tools:
{chr(10).join(tools_yaml)}

knowledge: | {knowledge}"""

# 输出:code-reviewer 的初始 YAML 定义
// 步骤 2:抽象提炼 — 把模式转化为 Skill YAML
function patternToSkillYaml(pattern: string[], count: number): string {
  /** 将高频工具调用模式转化为 Skill 定义模板 */
  const skillName = `${pattern[0]}-workflow`;

  const toolsYaml: string[] = [];
  for (let i = 0; i  `${i + 1}. 调用 ${tool}`).join('\n');

  return `name: ${skillName}
version: 0.1.0
description: 从 ${count} 次项目实践中提炼的工具编排模式
layer: compound
triggers: ["${pattern[0]}"]

tools:
${toolsYaml.join('\n')}

knowledge: | ${knowledge}`;
}

// 输出:code-reviewer 的初始 YAML 定义
package main

import (
	"fmt"
	"strings"
)

// 步骤 2:抽象提炼 — 把模式转化为 Skill YAML
func PatternToSkillYaml(pattern []string, count int) string {
	/** 将高频工具调用模式转化为 Skill 定义模板 */
	skillName := fmt.Sprintf("%s-workflow", pattern[0])

	var toolsYaml []string
	for i, tool := range pattern {
		toolsYaml = append(toolsYaml, fmt.Sprintf(
			"  - name: %s\n    description: 第%d步:%s\n    parameters:\n      input_from_previous: { type: string, default: null }",
			tool, i+1, tool,
		))
	}

	var steps []string
	for i, tool := range pattern {
		steps = append(steps, fmt.Sprintf("%d. 调用 %s", i+1, tool))
	}
	knowledge := "## 调用策略\n" + strings.Join(steps, "\n")

	return fmt.Sprintf(`name: %s
version: 0.1.0
description: 从 %d 次项目实践中提炼的工具编排模式
layer: compound
triggers: ["%s"]

tools:
%s

knowledge: | %s`, skillName, count, pattern[0], strings.Join(toolsYaml, "\n"), knowledge)
}

// 输出:code-reviewer 的初始 YAML 定义
import java.util.*;

// 步骤 2:抽象提炼 — 把模式转化为 Skill YAML
public class PatternToYaml {

    public static String patternToSkillYaml(List pattern, int count) {
        /** 将高频工具调用模式转化为 Skill 定义模板 */
        String skillName = pattern.get(0) + "-workflow";

        StringBuilder toolsYaml = new StringBuilder();
        for (int i = 0; i  version

    def check_compatibility(self, skill_name: str, required_version: str) -> bool:
        """检查已安装版本是否满足需求"""
        installed = self.installed.get(skill_name)
        if not installed:
            return False
        # SemVer 兼容性检查
        req_major, req_minor, req_patch = map(int, required_version.split("."))
        inst_major, inst_minor, inst_patch = map(int, installed.split("."))
        # MAJOR 必须一致,MINOR >= 要求
        return inst_major == req_major and inst_minor >= req_minor

    def upgrade(self, skill_name: str, target_version: str) -> dict:
        """升级 Skill,返回迁移指南"""
        current = self.installed.get(skill_name)
        if not current:
            return {"error": f"Skill {skill_name} 未安装"}

        # 加载 CHANGELOG,检查是否需要迁移
        changelog = self._load_changelog(skill_name)
        migrations = []
        for entry in changelog:
            if self._version_between(entry["version"], current, target_version):
                if any(c.startswith("MAJOR") for c in entry["changes"]):
                    migrations.append(entry)

        self.installed[skill_name] = target_version
        return {
            "upgraded": f"{current} → {target_version}",
            "migration_steps": migrations
        }
// skills/weather-advisor/CHANGELOG.yml — Skill 版本变更记录
/*
versions:
- version: 2.0.0
    date: 2025-03-15
    changes:
- "MAJOR: 重构调用策略,支持多天天气批量查询"
- "MAJOR: 新增 get_travel_advice 工具"
- "MINOR: 穿衣建议增加雾霾场景"
    migration_note: "多天查询需要传 date_list 参数,旧 date 参数仍兼容"
- version: 1.1.0
    date: 2025-01-20
    changes:
- "MINOR: 新增紫外线指数提醒"
- "PATCH: 修复 date='tomorrow' 解析错误"
- version: 1.0.0
    date: 2024-11-01
    changes:
- "初始稳定版本发布"
- "包含 get_weather + get_clothing_advice"
*/

// 版本管理器:自动处理兼容性
class SkillVersionManager {
  private installed: Map = new Map();

  checkCompatibility(skillName: string, requiredVersion: string): boolean {
    /** 检查已安装版本是否满足需求 */
    const installed = this.installed.get(skillName);
    if (!installed) return false;
    // SemVer 兼容性检查
    const [reqMajor, reqMinor, reqPatch] = requiredVersion.split('.').map(Number);
    const [instMajor, instMinor, instPatch] = installed.split('.').map(Number);
    // MAJOR 必须一致,MINOR >= 要求
    return instMajor === reqMajor && instMinor >= reqMinor;
  }

  upgrade(skillName: string, targetVersion: string): Record {
    /** 升级 Skill,返回迁移指南 */
    const current = this.installed.get(skillName);
    if (!current) return { error: `Skill ${skillName} 未安装` };

    // 加载 CHANGELOG,检查是否需要迁移
    const changelog = this.loadChangelog(skillName);
    const migrations = changelog.filter(entry =>
      this.versionBetween(entry.version, current, targetVersion) &&
      entry.changes.some((c: string) => c.startsWith('MAJOR'))
    );

    this.installed.set(skillName, targetVersion);
    return {
      upgraded: `${current} → ${targetVersion}`,
      migration_steps: migrations
    };
  }

  private loadChangelog(skillName: string): any[] { /* ... */ return []; }
  private versionBetween(v: string, from: string, to: string): boolean { /* ... */ return true; }
}
package main

import (
	"fmt"
	"strconv"
	"strings"
)

// skills/weather-advisor/CHANGELOG.yml — Skill 版本变更记录
// versions:
//   - version: 2.0.0
//     date: 2025-03-15
//     changes:
//       - "MAJOR: 重构调用策略,支持多天天气批量查询"
//       - "MAJOR: 新�� get_travel_advice 工具"
//       - "MINOR: 穿衣建议增加雾霾场景"
//     migration_note: "多天查询需要传 date_list 参数,旧 date 参数仍兼容"
//   - version: 1.1.0
//     date: 2025-01-20
//     changes:
//       - "MINOR: 新增紫外线指数提醒"
//       - "PATCH: 修复 date='tomorrow' 解析错误"
//   - version: 1.0.0
//     date: 2024-11-01
//     changes:
//       - "初始稳定版本发布"
//       - "包含 get_weather + get_clothing_advice"

// 版本管理器:自动处理兼容性
type SkillVersionManager struct {
	Installed map[string]string
}

func (m *SkillVersionManager) CheckCompatibility(skillName, requiredVersion string) bool {
	installed, ok := m.Installed[skillName]
	if !ok {
		return false
	}
	// SemVer 兼容性检查
	reqParts := strings.Split(requiredVersion, ".")
	instParts := strings.Split(installed, ".")
	reqMajor, _ := strconv.Atoi(reqParts[0])
	reqMinor, _ := strconv.Atoi(reqParts[1])
	instMajor, _ := strconv.Atoi(instParts[0])
	instMinor, _ := strconv.Atoi(instParts[1])
	// MAJOR 必须一致,MINOR >= 要求
	return instMajor == reqMajor && instMinor >= reqMinor
}

func (m *SkillVersionManager) Upgrade(skillName, targetVersion string) map[string]interface{} {
	current, ok := m.Installed[skillName]
	if !ok {
		return map[string]interface{}{"error": fmt.Sprintf("Skill %s 未安装", skillName)}
	}

	// 加载 CHANGELOG,检查是否需要迁移
	changelog := m.loadChangelog(skillName)
	var migrations []map[string]interface{}
	for _, entry := range changelog {
		if m.versionBetween(entry["version"].(string), current, targetVersion) {
			changes, _ := entry["changes"].([]interface{})
			for _, c := range changes {
				if strings.HasPrefix(c.(string), "MAJOR") {
					migrations = append(migrations, entry)
					break
				}
			}
		}
	}

	m.Installed[skillName] = targetVersion
	return map[string]interface{}{
		"upgraded":         fmt.Sprintf("%s → %s", current, targetVersion),
		"migration_steps":  migrations,
	}
}

func (m *SkillVersionManager) loadChangelog(skillName string) []map[string]interface{} { /* ... */ return nil }
func (m *SkillVersionManager) versionBetween(v, from, to string) bool { /* ... */ return true }
import java.util.*;

// skills/weather-advisor/CHANGELOG.yml — Skill 版本变更记录
/*
versions:
- version: 2.0.0
    date: 2025-03-15
    changes:
- "MAJOR: 重构调用策略,支持多天天气批量查询"
- "MAJOR: 新增 get_travel_advice 工具"
- "MINOR: 穿衣建议增加雾霾场景"
    migration_note: "多天查询需要传 date_list 参数,旧 date 参数仍兼容"
- version: 1.1.0
    date: 2025-01-20
    changes:
- "MINOR: 新增紫外线指数提醒"
- "PATCH: 修复 date='tomorrow' 解析错误"
- version: 1.0.0
    date: 2024-11-01
    changes:
- "初始稳定版本发布"
- "包含 get_weather + get_clothing_advice"
*/

// 版本管理器:自动处理兼容性
public class SkillVersionManager {
    private Map installed = new HashMap<>();

    public boolean checkCompatibility(String skillName, String requiredVersion) {
        /** 检查已安装版本是否满足需求 */
        String installedVersion = installed.get(skillName);
        if (installedVersion == null) return false;
        // SemVer 兼容性检查
        String[] reqParts = requiredVersion.split("\\.");
        String[] instParts = installedVersion.split("\\.");
        int reqMajor = Integer.parseInt(reqParts[0]);
        int reqMinor = Integer.parseInt(reqParts[1]);
        int instMajor = Integer.parseInt(instParts[0]);
        int instMinor = Integer.parseInt(instParts[1]);
        // MAJOR 必须一致,MINOR >= 要求
        return instMajor == reqMajor && instMinor >= reqMinor;
    }

    public Map upgrade(String skillName, String targetVersion) {
        /** 升级 Skill,返回迁移指南 */
        String current = installed.get(skillName);
        if (current == null) {
            return Map.of("error", "Skill " + skillName + " 未安装");
        }

        // 加载 CHANGELOG,检查是否需要迁移
        List> changelog = loadChangelog(skillName);
        List> migrations = new ArrayList<>();
        for (var entry : changelog) {
            if (versionBetween((String) entry.get("version"), current, targetVersion)) {
                List changes = (List) entry.get("changes");
                if (changes.stream().anyMatch(c -> c.startsWith("MAJOR"))) {
                    migrations.add(entry);
                }
            }
        }

        installed.put(skillName, targetVersion);
        Map result = new HashMap<>();
        result.put("upgraded", current + " → " + targetVersion);
        result.put("migration_steps", migrations);
        return result;
    }

    private List> loadChangelog(String skillName) { /* ... */ return Collections.emptyList(); }
    private boolean versionBetween(String v, String from, String to) { /* ... */ return true; }
}

💡 沉淀优先级

不要一开始就追求完美的 Skill 设计。WaLiCode 的 IterativeRefinement 模式告诉我们:先让 Agent 在项目中跑起来,观察它反复使用哪些工具组合,再把这些组合提炼成 Skill。项目实践是最好的 Skill 设计师——你只是把最佳实践固化下来。

12.11 Skill 的权限与安全

Skill 越强大,潜在风险越大。一个可以删除文件的 Skill 如果不加限制,Agent 可能误删关键数据。本章引入五层安全架构——参考 WaLiCode 的 permissionGuard 设计模式,给每一层加上护栏: | 安全层 | 机制 | 拦截时机 | 示例 | WaLiCode 对应 | | --- | --- | --- | --- | --- | | Layer 1 声明式 | YAML 中声明 permissions | Skill 注册时 | network: true, filesystem: write | PermissionDeclaration | | Layer 2 运行时拦截 | permissionGuard 中间件 | 每次工具调用前 | 拦截 path_traversal、SSRF | RuntimeGuard | | Layer 3 审批机制 | 危险操作暂停,等用户确认 | 高危操作执行前 | 删除文件、发送邮件需 /approve | ApprovalGate | | Layer 4 审计日志 | 结构化记录每次调用 | 调用完成后 | skill_audit_log 表 | AuditTrail | | Layer 5 沙箱隔离 | Docker/VM 限制资源访问 | Skill 执行期间 | payment Skill 在沙箱中运行 | SandboxExecutor | ### 12.11.1 Layer 1:声明式权限

每个 Skill 在 YAML 中声明自己需要哪些权限——这是最小权限原则的起点:

# 声明式权限定义
# skills/file-manager/skill.yml
name: file-manager
version: 1.0.0
description: 文件管理技能,支持读写删除操作
permissions:
  filesystem:
    read: true       # 需要读取文件
    write: true      # 需要写入文件
    delete: false    # 不允许删除(即使有工具也不给权限)
  network: false     # 不需要网络访问
  email: false       # 不需要发送邮件

danger_level: medium  # low / medium / high / critical
requires_approval:    # 哪些操作需要审批
- filesystem.write  # 写文件需审批
  # filesystem.delete 不在列表中——因为权限已禁止

12.11.2 Layer 2:运行时拦截(permissionGuard)

声明只是声明,运行时拦截才是真正的防线。permissionGuard 是一个中间件,在每次工具调用前检查权限:

# permissionGuard 中间件实现
class PermissionGuard:
    """WaLiCode 运行时拦截模式——在工具调用前检查权限"""

    # 黑名单:永远禁止的操作
    BLOCKED_OPERATIONS = {
        "rm_rf": "禁止递归删除",
        "eval": "禁止动态代码执行",
        "exec_shell": "禁止直接执行 shell 命令(除非白名单)",
    }

    # 白名单:特定 Skill 的允许操作
    ALLOWED_PER_SKILL = {
        "file-manager": {
            "filesystem.read": True,
            "filesystem.write": True,
            "filesystem.delete": False,
        },
        "weather-advisor": {
            "network.http_get": True,
            "network.http_post": False,
        },
    }

    def check(self, skill_name: str, operation: str, params: dict) -> dict:
        """检查权限,返回 allow/deny/need_approval"""
        # 1. 黑名单检查(最高优先级)
        if operation in self.BLOCKED_OPERATIONS:
            return {
                "status": "denied",
                "reason": self.BLOCKED_OPERATIONS[operation]
            }

        # 2. 白名单检查
        skill_perms = self.ALLOWED_PER_SKILL.get(skill_name, {})
        if not skill_perms.get(operation, False):
            return {
                "status": "denied",
                "reason": f"Skill {skill_name} 未获得 {operation} 权限"
            }

        # 3. 参数级安全检查(如路径穿越、SSRF)
        if "path" in params:
            if ".." in params["path"] or params["path"].startswith("/etc"):
                return {"status": "denied", "reason": "路径穿越攻击被拦截"}

        if "url" in params:
            blocked_hosts = ["localhost", "127.0.0.1", "0.0.0.0", "internal."]
            if any(h in params["url"] for h in blocked_hosts):
                return {"status": "denied", "reason": "SSRF 攻击被拦截"}

        # 4. 审批检查
        if operation in self.NEEDS_APPROVAL.get(skill_name, []):
            return {"status": "need_approval", "operation": operation}

        return {"status": "allow"}
// permissionGuard 中间件实现
class PermissionGuard {
  /** WaLiCode 运行时拦截模式——在工具调用前检查权限 */

  // 黑名单:永远禁止的操作
  private readonly BLOCKED_OPERATIONS: Record = {
    rm_rf: '禁止递归删除',
    eval: '禁止动态代码执行',
    exec_shell: '禁止直接执行 shell 命令(除非白名单)',
  };

  // 白名单:特定 Skill 的允许操作
  private readonly ALLOWED_PER_SKILL: Record> = {
    'file-manager': {
      'filesystem.read': true,
      'filesystem.write': true,
      'filesystem.delete': false,
    },
    'weather-advisor': {
      'network.http_get': true,
      'network.http_post': false,
    },
  };

  private readonly NEEDS_APPROVAL: Record = {};

  check(skillName: string, operation: string, params: Record): {
    status: string; reason?: string; operation?: string;
  } {
    /** 检查权限,返回 allow/deny/need_approval */
    // 1. 黑名单检查(最高优先级)
    if (this.BLOCKED_OPERATIONS[operation]) {
      return { status: 'denied', reason: this.BLOCKED_OPERATIONS[operation] };
    }

    // 2. 白名单检查
    const skillPerms = this.ALLOWED_PER_SKILL[skillName] || {};
    if (!skillPerms[operation]) {
      return {
        status: 'denied',
        reason: `Skill ${skillName} 未获得 ${operation} 权限`,
      };
    }

    // 3. 参数级安全检查(如路径穿越、SSRF)
    if (params.path) {
      const path = params.path as string;
      if (path.includes('..') || path.startsWith('/etc')) {
        return { status: 'denied', reason: '路径穿越攻击被拦截' };
      }
    }

    if (params.url) {
      const url = params.url as string;
      const blockedHosts = ['localhost', '127.0.0.1', '0.0.0.0', 'internal.'];
      if (blockedHosts.some(h => url.includes(h))) {
        return { status: 'denied', reason: 'SSRF 攻击被拦截' };
      }
    }

    // 4. 审批检查
    const needsApproval = this.NEEDS_APPROVAL[skillName] || [];
    if (needsApproval.includes(operation)) {
      return { status: 'need_approval', operation };
    }

    return { status: 'allow' };
  }
}
package main

import (
	"fmt"
	"strings"
)

// permissionGuard 中间件实现
type PermissionGuard struct {
	// 黑名单:永远禁止的操作
	BlockedOperations map[string]string
	// 白名单:特定 Skill 的允许操作
	AllowedPerSkill map[string]map[string]bool
	// 需审批的操作
	NeedsApproval map[string][]string
}

func NewPermissionGuard() *PermissionGuard {
	return &PermissionGuard{
		BlockedOperations: map[string]string{
			"rm_rf":      "禁止递归删除",
			"eval":       "禁止动态代码执行",
			"exec_shell": "禁止直接执行 shell 命令(除非白名单)",
		},
		AllowedPerSkill: map[string]map[string]bool{
			"file-manager": {
				"filesystem.read":   true,
				"filesystem.write":  true,
				"filesystem.delete": false,
			},
			"weather-advisor": {
				"network.http_get":  true,
				"network.http_post": false,
			},
		},
		NeedsApproval: map[string][]string{},
	}
}

func (g *PermissionGuard) Check(skillName, operation string, params map[string]interface{}) map[string]interface{} {
	/** 检查权限,返回 allow/deny/need_approval */
	// 1. 黑名单检查(最高优先级)
	if reason, ok := g.BlockedOperations[operation]; ok {
		return map[string]interface{}{"status": "denied", "reason": reason}
	}

	// 2. 白名单检查
	skillPerms, ok := g.AllowedPerSkill[skillName]
	if !ok || !skillPerms[operation] {
		return map[string]interface{}{
			"status": "denied",
			"reason": fmt.Sprintf("Skill %s 未获得 %s 权限", skillName, operation),
		}
	}

	// 3. 参数级安全检查(如路径穿越、SSRF)
	if path, ok := params["path"].(string); ok {
		if strings.Contains(path, "..") || strings.HasPrefix(path, "/etc") {
			return map[string]interface{}{"status": "denied", "reason": "路径穿越攻击被拦截"}
		}
	}

	if url, ok := params["url"].(string); ok {
		blockedHosts := []string{"localhost", "127.0.0.1", "0.0.0.0", "internal."}
		for _, h := range blockedHosts {
			if strings.Contains(url, h) {
				return map[string]interface{}{"status": "denied", "reason": "SSRF 攻击被拦截"}
			}
		}
	}

	// 4. 审批检查
	for _, op := range g.NeedsApproval[skillName] {
		if op == operation {
			return map[string]interface{}{"status": "need_approval", "operation": operation}
		}
	}

	return map[string]interface{}{"status": "allow"}
}
import java.util.*;

// permissionGuard 中间件实现
public class PermissionGuard {
    /** WaLiCode 运行时拦截模式——在工具调用前检查权限 */

    // 黑名单:永远禁止的操作
    private static final Map BLOCKED_OPERATIONS = Map.of(
        "rm_rf", "禁止递归删除",
        "eval", "禁止动态代码执行",
        "exec_shell", "禁止直接执行 shell 命令(除非白名单)"
    );

    // 白名单:特定 Skill 的允许操作
    private static final Map> ALLOWED_PER_SKILL = Map.of(
        "file-manager", Map.of(
            "filesystem.read", true,
            "filesystem.write", true,
            "filesystem.delete", false
        ),
        "weather-advisor", Map.of(
            "network.http_get", true,
            "network.http_post", false
        )
    );

    private final Map> needsApproval = new HashMap<>();

    public Map check(String skillName, String operation, Map params) {
        /** 检查权限,返回 allow/deny/need_approval */
        // 1. 黑名单检查(最高优先级)
        if (BLOCKED_OPERATIONS.containsKey(operation)) {
            return Map.of("status", "denied", "reason", BLOCKED_OPERATIONS.get(operation));
        }

        // 2. 白名单检查
        Map skillPerms = ALLOWED_PER_SKILL.getOrDefault(skillName, Collections.emptyMap());
        if (!skillPerms.getOrDefault(operation, false)) {
            return Map.of(
                "status", "denied",
                "reason", "Skill " + skillName + " 未获得 " + operation + " 权限"
            );
        }

        // 3. 参数级安全检查(如路径穿越、SSRF)
        if (params.containsKey("path")) {
            String path = (String) params.get("path");
            if (path.contains("..") || path.startsWith("/etc")) {
                return Map.of("status", "denied", "reason", "路径穿越攻击被拦截");
            }
        }

        if (params.containsKey("url")) {
            String url = (String) params.get("url");
            List blockedHosts = List.of("localhost", "127.0.0.1", "0.0.0.0", "internal.");
            if (blockedHosts.stream().anyMatch(url::contains)) {
                return Map.of("status", "denied", "reason", "SSRF 攻击被拦截");
            }
        }

        // 4. 审批检查
        List approvalList = needsApproval.getOrDefault(skillName, Collections.emptyList());
        if (approvalList.contains(operation)) {
            return Map.of("status", "need_approval", "operation", operation);
        }

        return Map.of("status", "allow");
    }
}

12.11.3 Layer 3:审批机制(ApprovalGate)

对于高危操作(删除文件、发送邮件、支付),Agent 必须暂停并等待用户确认

# 审批机制实现
class ApprovalGate:
    """WaLiCode ApprovalGate 模式——高危操作需用户确认"""

    def __init__(self):
        self.pending: dict = {}  # approval_id → operation details

    def request_approval(self, skill_name: str, operation: str, params: dict) -> str:
        """请求审批,返回 approval_id"""
        approval_id = f"apr_{skill_name}_{operation}_{int(time.time())}"
        self.pending[approval_id] = {
            "skill": skill_name,
            "operation": operation,
            "params": params,
            "status": "pending",
            "created_at": time.time()
        }
        # 暂停执行,通知用户
        print(f"⚠️ 需要审批: {skill_name} 想执行 {operation}")
        print(f"   参数: {params}")
        print(f"   请回复 /approve {approval_id} 或 /reject {approval_id}")
        return approval_id

    def handle_response(self, approval_id: str, approved: bool) -> dict:
        """处理用户的审批响应"""
        request = self.pending.get(approval_id)
        if not request:
            return {"error": "审批 ID 不存在"}

        if approved:
            request["status"] = "approved"
            # 执行被暂停的操作
            return {"status": "approved", "proceed": True}
        else:
            request["status"] = "rejected"
            return {"status": "rejected", "proceed": False}

# 使用示例
guard_result = permission_guard.check("file-manager", "filesystem.write", {"path": "/data/report.csv"})
if guard_result["status"] == "need_approval":
    approval_id = approval_gate.request_approval("file-manager", "filesystem.write", {"path": "/data/report.csv"})
    # Agent 等待用户响应...
// 审批机制实现
class ApprovalGate {
  /** WaLiCode ApprovalGate 模式——高危操作需用户确认 */

  private pending: Map> = new Map();

  requestApproval(skillName: string, operation: string, params: Record): string {
    /** 请求审批,返回 approval_id */
    const approvalId = `apr_${skillName}_${operation}_${Date.now()}`;
    this.pending.set(approvalId, {
      skill: skillName,
      operation,
      params,
      status: 'pending',
      created_at: Date.now(),
    });
    // 暂停执行,通知用户
    console.log(`⚠️ 需要审批: ${skillName} 想执行 ${operation}`);
    console.log(`   参数: ${JSON.stringify(params)}`);
    console.log(`   请回复 /approve ${approvalId} 或 /reject ${approvalId}`);
    return approvalId;
  }

  handleResponse(approvalId: string, approved: boolean): Record {
    /** 处理用户的审批响应 */
    const request = this.pending.get(approvalId);
    if (!request) return { error: '审批 ID 不存在' };

    if (approved) {
      request.status = 'approved';
      return { status: 'approved', proceed: true };
    } else {
      request.status = 'rejected';
      return { status: 'rejected', proceed: false };
    }
  }
}

// 使用示例
const guardResult = permissionGuard.check('file-manager', 'filesystem.write', { path: '/data/report.csv' });
if (guardResult.status === 'need_approval') {
  const approvalId = approvalGate.requestApproval('file-manager', 'filesystem.write', { path: '/data/report.csv' });
  // Agent 等待用户响应...
}
package main

import (
	"fmt"
	"time"
)

// 审批机制实现
type ApprovalGate struct {
	Pending map[string]map[string]interface{}
}

func NewApprovalGate() *ApprovalGate {
	return &ApprovalGate{
		Pending: make(map[string]map[string]interface{}),
	}
}

func (g *ApprovalGate) RequestApproval(skillName, operation string, params map[string]interface{}) string {
	/** 请求审批,返回 approval_id */
	approvalId := fmt.Sprintf("apr_%s_%s_%d", skillName, operation, time.Now().Unix())
	g.Pending[approvalId] = map[string]interface{}{
		"skill":      skillName,
		"operation":   operation,
		"params":      params,
		"status":     "pending",
		"created_at": time.Now().Unix(),
	}
	// 暂停执行,通知用户
	fmt.Printf("⚠️ 需要审批: %s 想执行 %s\n", skillName, operation)
	fmt.Printf("   参数: %v\n", params)
	fmt.Printf("   请回复 /approve %s 或 /reject %s\n", approvalId, approvalId)
	return approvalId
}

func (g *ApprovalGate) HandleResponse(approvalId string, approved bool) map[string]interface{} {
	/** 处理用户的审批响应 */
	request, ok := g.Pending[approvalId]
	if !ok {
		return map[string]interface{}{"error": "审批 ID 不存在"}
	}

	if approved {
		request["status"] = "approved"
		return map[string]interface{}{"status": "approved", "proceed": true}
	}
	request["status"] = "rejected"
	return map[string]interface{}{"status": "rejected", "proceed": false}
}

// 使用示例
// guardResult := permissionGuard.Check("file-manager", "filesystem.write", map[string]interface{}{"path": "/data/report.csv"})
// if guardResult["status"] == "need_approval" {
//   approvalId := approvalGate.RequestApproval("file-manager", "filesystem.write", map[string]interface{}{"path": "/data/report.csv"})
//   // Agent 等待用户响应...
// }
import java.util.*;
import java.time.Instant;

// 审批机制实现
public class ApprovalGate {
    /** WaLiCode ApprovalGate 模式——高危操作需用户确认 */

    private Map> pending = new HashMap<>();

    public String requestApproval(String skillName, String operation, Map params) {
        /** 请求审批,返回 approval_id */
        String approvalId = String.format("apr_%s_%s_%d", skillName, operation, Instant.now().getEpochSecond());

        Map request = new HashMap<>();
        request.put("skill", skillName);
        request.put("operation", operation);
        request.put("params", params);
        request.put("status", "pending");
        request.put("created_at", Instant.now().getEpochSecond());
        pending.put(approvalId, request);

        // 暂停执行,通知用户
        System.out.printf("⚠️ 需要审批: %s 想执行 %s%n", skillName, operation);
        System.out.printf("   参数: %s%n", params);
        System.out.printf("   请回复 /approve %s 或 /reject %s%n", approvalId, approvalId);
        return approvalId;
    }

    public Map handleResponse(String approvalId, boolean approved) {
        /** 处理用户的审批响应 */
        Map request = pending.get(approvalId);
        if (request == null) {
            return Map.of("error", "审批 ID 不存在");
        }

        if (approved) {
            request.put("status", "approved");
            return Map.of("status", "approved", "proceed", true);
        } else {
            request.put("status", "rejected");
            return Map.of("status", "rejected", "proceed", false);
        }
    }
}

// 使用示例
// Map guardResult = permissionGuard.check("file-manager", "filesystem.write", Map.of("path", "/data/report.csv"));
// if (guardResult.get("status").equals("need_approval")) {
//     String approvalId = approvalGate.requestApproval("file-manager", "filesystem.write", Map.of("path", "/data/report.csv"));
//     // Agent 等待用户响应...
// }

12.11.4 Layer 4:审计日志

所有 Skill 调用必须可追溯——谁在什么时候调用了什么、参数是什么、结果是什么:

# 审计日志实现
import sqlite3
from datetime import datetime

class SkillAuditLog:
    """WaLiCode AuditTrail 模式——所有调用可追溯"""

    def __init__(self, db_path: str = "skill_audit.db"):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS audit_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                session_id TEXT NOT NULL,
                skill_name TEXT NOT NULL,
                operation TEXT NOT NULL,
                params TEXT NOT NULL,       -- JSON 格式
                result TEXT NOT NULL,        -- JSON 格式
                approved TEXT DEFAULT NULL,  -- approval_id 或 NULL
                risk_level TEXT NOT NULL     -- low/medium/high/critical
            )
        """)

    def log(self, session_id: str, skill_name: str, operation: str,
            params: dict, result: dict, approved: str = None,
            risk_level: str = "low"):
        """记录一次 Skill 调用"""
        self.conn.execute(
            "INSERT INTO audit_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
            (datetime.now().isoformat(), session_id, skill_name, operation,
             json.dumps(params), json.dumps(result), approved, risk_level)
        )
        self.conn.commit()

    def query(self, skill_name: str = None, risk_level: str = None,
              since: str = None) -> list[dict]:
        """查询审计日志(合规审查用)"""
        conditions = []
        if skill_name:
            conditions.append(f"skill_name = '{skill_name}'")
        if risk_level:
            conditions.append(f"risk_level = '{risk_level}'")
        if since:
            conditions.append(f"timestamp >= '{since}'")

        where = " AND ".join(conditions) if conditions else "1=1"
        rows = self.conn.execute(
            f"SELECT * FROM audit_log WHERE {where} ORDER BY timestamp DESC LIMIT 100"
        ).fetchall()
        return [dict(zip(["id","ts","sid","skill","op","params","result","approved","risk"], r)) for r in rows]

# 查询所有高风险调用(合规审查场景)
high_risk_calls = audit_log.query(risk_level="critical")
# → 返回所有 critical 级别的调用记录,供安全团队审查
// 审计日志实现
import Database from 'better-sqlite3';

class SkillAuditLog {
  /** WaLiCode AuditTrail 模式——所有调用可追溯 */

  private db: Database.Database;

  constructor(dbPath: string = 'skill_audit.db') {
    this.db = new Database(dbPath);
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS audit_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        timestamp TEXT NOT NULL,
        session_id TEXT NOT NULL,
        skill_name TEXT NOT NULL,
        operation TEXT NOT NULL,
        params TEXT NOT NULL,
        result TEXT NOT NULL,
        approved TEXT DEFAULT NULL,
        risk_level TEXT NOT NULL
      )
    `);
  }

  log(sessionId: string, skillName: string, operation: string,
      params: Record, result: Record,
      approved: string | null = null, riskLevel: string = 'low'): void {
    /** 记录一次 Skill 调用 */
    this.db.prepare(
      'INSERT INTO audit_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
    ).run(
      new Date().toISOString(), sessionId, skillName, operation,
      JSON.stringify(params), JSON.stringify(result), approved, riskLevel
    );
  }

  query(opts?: { skillName?: string; riskLevel?: string; since?: string }): Record[] {
    /** 查询审计日志(合规审查用) */
    const conditions: string[] = [];
    const values: string[] = [];

    if (opts?.skillName) {
      conditions.push('skill_name = ?');
      values.push(opts.skillName);
    }
    if (opts?.riskLevel) {
      conditions.push('risk_level = ?');
      values.push(opts.riskLevel);
    }
    if (opts?.since) {
      conditions.push('timestamp >= ?');
      values.push(opts.since);
    }

    const where = conditions.length > 0 ? conditions.join(' AND ') : '1=1';
    const rows = this.db.prepare(
      `SELECT * FROM audit_log WHERE ${where} ORDER BY timestamp DESC LIMIT 100`
    ).all(...values);

    return rows as Record[];
  }
}

// 查询所有高风险调用(合规审查场景)
const highRiskCalls = auditLog.query({ riskLevel: 'critical' });
// → 返回所有 critical 级别的调用记录,供安全团队审查
package main

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"time"

	_ "github.com/mattn/go-sqlite3"
)

// 审计日志实现
type SkillAuditLog struct {
	Db *sql.DB
}

func NewSkillAuditLog(dbPath string) *SkillAuditLog {
	db, _ := sql.Open("sqlite3", dbPath)
	db.Exec(`
		CREATE TABLE IF NOT EXISTS audit_log (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			timestamp TEXT NOT NULL,
			session_id TEXT NOT NULL,
			skill_name TEXT NOT NULL,
			operation TEXT NOT NULL,
			params TEXT NOT NULL,
			result TEXT NOT NULL,
			approved TEXT DEFAULT NULL,
			risk_level TEXT NOT NULL
		)
	`)
	return &SkillAuditLog{Db: db}
}

func (l *SkillAuditLog) Log(sessionId, skillName, operation string,
	params, result map[string]interface{}, approved, riskLevel string) {
	/** 记录一次 Skill 调用 */
	paramsJSON, _ := json.Marshal(params)
	resultJSON, _ := json.Marshal(result)
	l.Db.Exec(
		"INSERT INTO audit_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
		time.Now().Format(time.RFC3339), sessionId, skillName, operation,
		string(paramsJSON), string(resultJSON), approved, riskLevel,
	)
}

func (l *SkillAuditLog) Query(skillName, riskLevel, since string) []map[string]interface{} {
	/** 查询审计日志(合规审查用) */
	conditions := []string{}
	args := []interface{}{}
	if skillName != "" {
		conditions = append(conditions, "skill_name = ?")
		args = append(args, skillName)
	}
	if riskLevel != "" {
		conditions = append(conditions, "risk_level = ?")
		args = append(args, riskLevel)
	}
	if since != "" {
		conditions = append(conditions, "timestamp >= ?")
		args = append(args, since)
	}

	where := "1=1"
	if len(conditions) > 0 {
		where = fmt.Sprintf("%s", joinStrings(conditions, " AND "))
	}

	rows, _ := l.Db.Query(
		fmt.Sprintf("SELECT * FROM audit_log WHERE %s ORDER BY timestamp DESC LIMIT 100", where),
		args...,
	)
	defer rows.Close()

	var results []map[string]interface{}
	for rows.Next() {
		var id int
		var ts, sid, skill, op, paramsStr, resultStr, approvedStr, risk sql.NullString
		rows.Scan(&id, &ts, &sid, &skill, &op, &paramsStr, &resultStr, &approvedStr, &risk)
		results = append(results, map[string]interface{}{
			"id": id, "ts": ts.String, "sid": sid.String,
			"skill": skill.String, "op": op.String,
			"params": paramsStr.String, "result": resultStr.String,
			"approved": approvedStr.String, "risk": risk.String,
		})
	}
	return results
}

// 查询所有高风险调用(合规审查场景)
// highRiskCalls := auditLog.Query("", "critical", "")
// → 返回所有 critical 级别的调用记录,供安全团队审查
import java.sql.*;
import java.time.Instant;
import java.util.*;
import com.google.gson.Gson;

// 审计日志实现
public class SkillAuditLog {
    /** WaLiCode AuditTrail 模式——所有调用可追溯 */

    private Connection conn;
    private Gson gson = new Gson();

    public SkillAuditLog(String dbPath) throws SQLException {
        conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        conn.createStatement().execute(
            "CREATE TABLE IF NOT EXISTS audit_log (" +
            "  id INTEGER PRIMARY KEY AUTOINCREMENT," +
            "  timestamp TEXT NOT NULL," +
            "  session_id TEXT NOT NULL," +
            "  skill_name TEXT NOT NULL," +
            "  operation TEXT NOT NULL," +
            "  params TEXT NOT NULL," +
            "  result TEXT NOT NULL," +
            "  approved TEXT DEFAULT NULL," +
            "  risk_level TEXT NOT NULL" +
            ")"
        );
    }

    public void log(String sessionId, String skillName, String operation,
                    Map params, Map result,
                    String approved, String riskLevel) throws SQLException {
        /** 记录一次 Skill 调用 */
        PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO audit_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)"
        );
        ps.setString(1, Instant.now().toString());
        ps.setString(2, sessionId);
        ps.setString(3, skillName);
        ps.setString(4, operation);
        ps.setString(5, gson.toJson(params));
        ps.setString(6, gson.toJson(result));
        ps.setString(7, approved);
        ps.setString(8, riskLevel);
        ps.executeUpdate();
    }

    public List> query(String skillName, String riskLevel, String since) throws SQLException {
        /** 查询审计日志(合规审查用) */
        List conditions = new ArrayList<>();
        List values = new ArrayList<>();

        if (skillName != null) {
            conditions.add("skill_name = ?");
            values.add(skillName);
        }
        if (riskLevel != null) {
            conditions.add("risk_level = ?");
            values.add(riskLevel);
        }
        if (since != null) {
            conditions.add("timestamp >= ?");
            values.add(since);
        }

        String where = conditions.isEmpty() ? "1=1" : String.join(" AND ", conditions);
        PreparedStatement ps = conn.prepareStatement(
            "SELECT * FROM audit_log WHERE " + where + " ORDER BY timestamp DESC LIMIT 100"
        );
        for (int i = 0; i > results = new ArrayList<>();
        while (rs.next()) {
            Map row = new LinkedHashMap<>();
            row.put("id", rs.getInt("id"));
            row.put("ts", rs.getString("timestamp"));
            row.put("sid", rs.getString("session_id"));
            row.put("skill", rs.getString("skill_name"));
            row.put("op", rs.getString("operation"));
            row.put("params", rs.getString("params"));
            row.put("result", rs.getString("result"));
            row.put("approved", rs.getString("approved"));
            row.put("risk", rs.getString("risk_level"));
            results.add(row);
        }
        return results;
    }
}

// 查询所有高风险调用(合规审查场景)
// List> highRiskCalls = auditLog.query(null, "critical", null);
// → 返回所有 critical 级别的调用记录,供安全团队审查

12.11.5 Layer 5:沙箱隔离

最危险的操作(如支付、访问生产数据库)应该在沙箱中执行——限制网络和文件系统访问:

# 沙箱执行器实现(基于 subprocess + 资源限制)
import subprocess
import tempfile

class SandboxExecutor:
    """WaLiCode SandboxExecutor 模式——危险 Skill 在沙箱中执行"""

    SANDBOX_CONFIG = {
        "payment-skill": {
            "network": "restricted",       # 只允许访问支付 API 域名
            "filesystem": "read_only",     # 只读文件系统
            "max_memory": "256MB",         # 内存限制
            "max_cpu_time": 30,            # CPU 时间限制(秒)
            "allowed_domains": ["api.payment.com"],
        },
        "database-query": {
            "network": "restricted",
            "filesystem": "none",          # 无文件系统访问
            "max_memory": "512MB",
            "max_cpu_time": 60,
            "allowed_domains": ["db.internal.com"],
        }
    }

    def execute_in_sandbox(self, skill_name: str, tool_name: str,
                           params: dict) -> dict:
        """在沙箱中执行 Skill 工具"""
        config = self.SANDBOX_CONFIG.get(skill_name)
        if not config:
            # 无沙箱配置 → 正常执行
            return self._execute_normal(skill_name, tool_name, params)

        # 将调用序列化为临时脚本
        script = self._serialize_call(skill_name, tool_name, params)
        script_path = tempfile.mktemp(suffix=".py")
        with open(script_path, "w") as f:
            f.write(script)

        # 在受限环境中执行
        result = subprocess.run(
            ["python3", script_path],
            capture_output=True,
            timeout=config["max_cpu_time"],
            env={
                "SKILL_SANDBOX": "true",
                "ALLOWED_DOMAINS": ",".join(config["allowed_domains"]),
                "MAX_MEMORY": config["max_memory"],
            }
        )

        return {
            "stdout": result.stdout.decode(),
            "stderr": result.stderr.decode(),
            "exit_code": result.returncode,
            "sandbox": True
        }
// 沙箱执行器实现(基于 child_process + 资源限制)
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

class SandboxExecutor {
  /** WaLiCode SandboxExecutor 模式——危险 Skill 在沙箱中执行 */

  private readonly SANDBOX_CONFIG: Record = {
    'payment-skill': {
      network: 'restricted',
      filesystem: 'read_only',
      maxMemory: '256MB',
      maxCpuTime: 30,
      allowedDomains: ['api.payment.com'],
    },
    'database-query': {
      network: 'restricted',
      filesystem: 'none',
      maxMemory: '512MB',
      maxCpuTime: 60,
      allowedDomains: ['db.internal.com'],
    },
  };

  executeInSandbox(skillName: string, toolName: string, params: Record): Promise> {
    /** 在沙箱中执行 Skill 工具 */
    const config = this.SANDBOX_CONFIG[skillName];
    if (!config) {
      // 无沙箱配置 → 正常执行
      return this.executeNormal(skillName, toolName, params);
    }

    return new Promise((resolve) => {
      // 将调用序列化为临时脚本
      const script = this.serializeCall(skillName, toolName, params);
      const scriptPath = path.join(os.tmpdir(), `skill_${Date.now()}.js`);
      fs.writeFileSync(scriptPath, script);

      // 在受限环境中执行
      execFile('node', [scriptPath], {
        timeout: config.maxCpuTime * 1000,
        env: {
          ...process.env,
          SKILL_SANDBOX: 'true',
          ALLOWED_DOMAINS: config.allowedDomains.join(','),
          MAX_MEMORY: config.maxMemory,
        },
      }, (err, stdout, stderr) => {
        resolve({
          stdout: stdout || '',
          stderr: stderr || (err?.message || ''),
          exitCode: err ? 1 : 0,
          sandbox: true,
        });
      });
    });
  }

  private serializeCall(skillName: string, toolName: string, params: Record): string {
    return `// Auto-generated sandbox script\n` +
      `const skill = require('./skills/${skillName}');\n` +
      `skill.${toolName}(${JSON.stringify(params)});`;
  }

  private async executeNormal(skillName: string, toolName: string, params: Record): Promise> {
    return { error: 'No sandbox config found, executing normally' };
  }
}
package main

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

// 沙箱执行器实现(基于 subprocess + 资源限制)
type SandboxExecutor struct {
	SandboxConfig map[string]map[string]interface{}
}

func NewSandboxExecutor() *SandboxExecutor {
	return &SandboxExecutor{
		SandboxConfig: map[string]map[string]interface{}{
			"payment-skill": {
				"network":         "restricted",
				"filesystem":      "read_only",
				"max_memory":       "256MB",
				"max_cpu_time":    30,
				"allowed_domains": []string{"api.payment.com"},
			},
			"database-query": {
				"network":         "restricted",
				"filesystem":      "none",
				"max_memory":       "512MB",
				"max_cpu_time":    60,
				"allowed_domains": []string{"db.internal.com"},
			},
		},
	}
}

func (s *SandboxExecutor) ExecuteInSandbox(skillName, toolName string, params map[string]interface{}) map[string]interface{} {
	/** 在沙箱中执行 Skill 工具 */
	config, ok := s.SandboxConfig[skillName]
	if !ok {
		// 无沙箱配置 → 正常执行
		return s.executeNormal(skillName, toolName, params)
	}

	// 将调用序列化为临时脚本
	script := s.serializeCall(skillName, toolName, params)
	tmpDir, _ := os.MkdirTemp("", "skill_*")
	scriptPath := filepath.Join(tmpDir, "run.py")
	os.WriteFile(scriptPath, []byte(script), 0644)

	// 在受限环境中执行
	maxCpuTime := int(config["max_cpu_time"].(int))
	domains := config["allowed_domains"].([]string)
	domainStrs := make([]string, len(domains))
	for i, d := range domains {
		domainStrs[i] = fmt.Sprintf("%v", d)
	}

	cmd := exec.Command("python3", scriptPath)
	cmd.Env = append(os.Environ(),
		"SKILL_SANDBOX=true",
		fmt.Sprintf("ALLOWED_DOMAINS=%s", joinStrings(domainStrs, ",")),
		fmt.Sprintf("MAX_MEMORY=%v", config["max_memory"]),
	)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	err := cmd.Run()

	exitCode := 0
	if err != nil {
		exitCode = 1
	}

	return map[string]interface{}{
		"stdout":   stdout.String(),
		"stderr":   stderr.String(),
		"exit_code": exitCode,
		"sandbox":  true,
	}
}

func (s *SandboxExecutor) serializeCall(skillName, toolName string, params map[string]interface{}) string {
	return fmt.Sprintf("# Auto-generated sandbox script\nimport %s\n", skillName)
}

func (s *SandboxExecutor) executeNormal(skillName, toolName string, params map[string]interface{}) map[string]interface{} {
	return map[string]interface{}{"error": "No sandbox config found"}
}
import java.io.*;
import java.nio.file.*;
import java.util.*;

// 沙箱执行器实现(基于 ProcessBuilder + 资源限制)
public class SandboxExecutor {
    /** WaLiCode SandboxExecutor 模式——危险 Skill 在沙箱中执行 */

    private static final Map> SANDBOX_CONFIG = new HashMap<>();

    static {
        Map paymentConfig = new HashMap<>();
        paymentConfig.put("network", "restricted");
        paymentConfig.put("filesystem", "read_only");
        paymentConfig.put("max_memory", "256MB");
        paymentConfig.put("max_cpu_time", 30);
        paymentConfig.put("allowed_domains", List.of("api.payment.com"));
        SANDBOX_CONFIG.put("payment-skill", paymentConfig);

        Map dbConfig = new HashMap<>();
        dbConfig.put("network", "restricted");
        dbConfig.put("filesystem", "none");
        dbConfig.put("max_memory", "512MB");
        dbConfig.put("max_cpu_time", 60);
        dbConfig.put("allowed_domains", List.of("db.internal.com"));
        SANDBOX_CONFIG.put("database-query", dbConfig);
    }

    public Map executeInSandbox(String skillName, String toolName, Map params) throws Exception {
        /** 在沙箱中执行 Skill 工具 */
        Map config = SANDBOX_CONFIG.get(skillName);
        if (config == null) {
            // 无沙箱配置 → 正常执行
            return executeNormal(skillName, toolName, params);
        }

        // 将调用序列化为临时脚本
        String script = serializeCall(skillName, toolName, params);
        Path scriptPath = Files.createTempFile("skill_", ".py");
        Files.writeString(scriptPath, script);

        // 在受限环境中执行
        int maxCpuTime = (int) config.get("max_cpu_time");
        List allowedDomains = (List) config.get("allowed_domains");

        ProcessBuilder pb = new ProcessBuilder("python3", scriptPath.toString());
        pb.environment().put("SKILL_SANDBOX", "true");
        pb.environment().put("ALLOWED_DOMAINS", String.join(",", allowedDomains));
        pb.environment().put("MAX_MEMORY", (String) config.get("max_memory"));

        pb.redirectErrorStream(false);
        Process proc = pb.start();

        // 设置超时
        if (!proc.waitFor(maxCpuTime, TimeUnit.SECONDS)) {
            proc.destroyForcibly();
        }

        String stdout = new String(proc.getInputStream().readAllBytes());
        String stderr = new String(proc.getErrorStream().readAllBytes());
        int exitCode = proc.exitValue();

        Map result = new HashMap<>();
        result.put("stdout", stdout);
        result.put("stderr", stderr);
        result.put("exit_code", exitCode);
        result.put("sandbox", true);
        return result;
    }

    private String serializeCall(String skillName, String toolName, Map params) {
        return "# Auto-generated sandbox script\nimport " + skillName + "\n";
    }

    private Map executeNormal(String skillName, String toolName, Map params) {
        return Map.of("error", "No sandbox config found");
    }
}

💡 安全分层原则

不是所有 Skill 都需要五层防护。基础 Skill(如 get_weather)只需 Layer 1(声明)+ Layer 2(运行时拦截)。复合 Skill 加上 Layer 3(审批)+ Layer 4(审计)。领域 Skill 才需要完整的五层——包括沙箱隔离。安全不是越厚越好,而是按风险等级分层

12.12 实战:构建企业级 Skill 包

前面讲了 Skill 的分层体系和安全架构,现在把它们串起来——从零构建一个完整的企业级 Skill 包,包括 YAML 定义、Python 实现、测试和发布流程:

12.12.1 Step 1:YAML 定义

report-generator(报表生成器)为例——这是一个复合 Skill,需要读取数据、分析数据、生成报表三个工具:

# skills/compound/report-generator/skill.yml
name: report-generator
version: 1.0.0
description: 企业报表生成器,从数据源读取数据并生成格式化报表
layer: compound
triggers: ["报表", "报告", "生成报告", "generate report", "周报", "月报"]

permissions:
  filesystem:
    read: true
    write: true         # 需要写入报表文件
  network:
    http_get: true      # 需要从 API 获取数据
  email:
    send: false         # 不直接发邮件(由上层 Skill 处理)

danger_level: medium
requires_approval:
- filesystem.write    # 写文件需审批

tools:
- name: fetch_data
    description: 从指定数据源获取原始数据
    parameters:
      source: { type: string, required: true, description: "数据源(api/file/db)" }
      query: { type: string, default: null, description: "查询条件" }
      format: { type: string, default: "json", description: "返回格式" }
- name: analyze_data
    description: 分析数据,计算统计指标
    parameters:
      data: { type: array, required: true, description: "原始数据" }
      metrics: { type: array, default: ["sum", "avg", "max", "min"], description: "统计指标" }
      group_by: { type: string, default: null, description: "分组字段" }
- name: render_report
    description: 将分析结果渲染为格式化报表
    parameters:
      analysis: { type: object, required: true, description: "分析结果" }
      template: { type: string, default: "default", description: "报表模板" }
      output_format: { type: string, default: "markdown", description: "输出格式" }

knowledge: | ## 调用策略
  1. 先 fetch_data 从数据源获取数据
  2. 再 analyze_data 计算统计指标
  3. 最后 render_report 生成报表
  4. 如果数据量 > 10万条,建议分批处理
  5. 如果源是 API,注意分页和超时处理

  ## WaLiCode 对应
- WorkflowChain: fetch → analyze → render
- ErrorRecovery: fetch 失败时尝试备用数据源
- PermissionGuard: 写文件前需审批

  ## 异常处理
- fetch_data 失败 → 提示用户检查数据源配置
- analyze_data 数据为空 → 返回"数据不足,无法生成报表"
- render_report 模板缺失 → 使用默认模板

12.12.2 Step 2:Python 实现

# skills/compound/report-generator/main.py
import json
import statistics
from pathlib import Path
from datetime import datetime

class ReportGeneratorSkill:
    """企业级报表生成 Skill——带 permissionGuard"""

    def __init__(self, permission_guard: PermissionGuard = None):
        self.guard = permission_guard

    def fetch_data(self, source: str, query: str = None, format: str = "json") -> dict:
        """从数据源获取原始数据"""
        if source.startswith("http"):
            # API 数据源(带超时和分页)
            import requests
            params = {"query": query} if query else {}
            response = requests.get(source, params=params, timeout=30)
            return {"data": response.json(), "source": source}
        elif source.endswith(".csv") or source.endswith(".json"):
            # 文件数据源(带安全检查)
            if self.guard:
                check = self.guard.check("report-generator", "filesystem.read", {"path": source})
                if check["status"] == "denied":
                    return {"error": check["reason"]}
            with open(source, "r", encoding="utf-8") as f:
                if source.endswith(".json"):
                    data = json.load(f)
                else:
                    import csv
                    reader = csv.DictReader(f)
                    data = list(reader)
            return {"data": data, "source": source}
        else:
            return {"error": f"不支持的数据源类型: {source}"}

    def analyze_data(self, data: list, metrics: list = None, group_by: str = None) -> dict:
        """分析数据,计算统计指标"""
        if not data:
            return {"error": "数据为空,无法分析"}

        metrics = metrics or ["sum", "avg", "max", "min"]
        numeric_fields = self._find_numeric_fields(data)
        results = {}

        for field in numeric_fields:
            values = [row.get(field, 0) for row in data if row.get(field) is not None]
            field_stats = {}
            for m in metrics:
                if m == "sum":
                    field_stats["sum"] = sum(values)
                elif m == "avg":
                    field_stats["avg"] = statistics.mean(values) if values else 0
                elif m == "max":
                    field_stats["max"] = max(values) if values else 0
                elif m == "min":
                    field_stats["min"] = min(values) if values else 0
            results[field] = field_stats

        # 分组统计(如果指定 group_by)
        if group_by:
            groups = {}
            for row in data:
                key = row.get(group_by, "unknown")
                groups.setdefault(key, []).append(row)
            results["groups"] = {k: len(v) for k, v in groups.items()}

        return {"analysis": results, "data_count": len(data), "fields": numeric_fields}

    def render_report(self, analysis: dict, template: str = "default",
                      output_format: str = "markdown") -> dict:
        """渲染报表"""
        stats = analysis.get("analysis", {})
        count = analysis.get("data_count", 0)

        # 生成 Markdown 报表
        report = f"# 数据报表\n\n"
        report += f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
        report += f"> 数据量: {count} 条\n\n"

        report += "## 统计指标\n\n"
        report += "| 字段 | 求和 | 平均值 | 最大值 | 最小值 |\n"
        report += "|------|------|--------|--------|--------|\n"
        for field, field_stats in stats.items():
            if field == "groups":
                continue
            report += f"| {field} | {field_stats.get('sum', '-')} | "
            report += f"{field_stats.get('avg', '-')} | {field_stats.get('max', '-')} | "
            report += f"{field_stats.get('min', '-')} |\n"

        if "groups" in stats:
            report += "\n## 分组统计\n\n"
            for group, group_count in stats["groups"].items():
                report += f"- **{group}**: {group_count} 条\n"

        # 写入文件(需审批)
        output_path = f"reports/report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
        if self.guard:
            check = self.guard.check("report-generator", "filesystem.write", {"path": output_path})
            if check["status"] == "need_approval":
                return {
                    "report": report,
                    "approval_needed": True,
                    "message": f"需要审批才能写入文件: {output_path}"
                }
            elif check["status"] == "denied":

                return {"error": check["reason"]}

        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(report)

        return {"report": report, "output_path": output_path, "written": True}

    def _find_numeric_fields(self, data: list) -> list[str]:
        """自动检测数值型字段"""
        numeric = []
        if data:
            for key, val in data[0].items():
                if isinstance(val, (int, float)):
                    numeric.append(key)
        return numeric
// skills/compound/report-generator/main.ts
import * as fs from 'fs';
import * as path from 'path';

class ReportGeneratorSkill {
  /** 企业级报表生成 Skill——带 permissionGuard */

  constructor(private guard?: PermissionGuard) {}

  fetchData(source: string, query?: string, format: string = 'json'): Record {
    /** 从数据源获取原始数据 */
    if (source.startsWith('http')) {
      // API 数据源(带超时和分页)
      // 使用 fetch API
      const params = query ? `?query=${query}` : '';
      // In production: const response = await fetch(source + params);
      return { data: [], source };
    } else if (source.endsWith('.csv') || source.endsWith('.json')) {
      // 文件数据源(带安全检查)
      if (this.guard) {
        const check = this.guard.check('report-generator', 'filesystem.read', { path: source });
        if (check.status === 'denied') {
          return { error: check.reason };
        }
      }
      const content = fs.readFileSync(source, 'utf-8');
      if (source.endsWith('.json')) {
        return { data: JSON.parse(content), source };
      } else {
        // Parse CSV
        const lines = content.split('\n');
        const headers = lines[0].split(',');
        const data = lines.slice(1).map(line => {
          const values = line.split(',');
          const row: Record = {};
          headers.forEach((h, i) => { row[h.trim()] = values[i]?.trim() || ''; });
          return row;
        });
        return { data, source };
      }
    }
    return { error: `不支持的数据源类型: ${source}` };
  }

  analyzeData(data: Record[], metrics?: string[], groupBy?: string): Record {
    /** 分析数据,计算统计指标 */
    if (!data || data.length === 0) return { error: '数据为空,无法分析' };

    metrics = metrics || ['sum', 'avg', 'max', 'min'];
    const numericFields = this.findNumericFields(data);
    const results: Record = {};

    for (const field of numericFields) {
      const values = data.map(row => Number(row[field] || 0)).filter(v => !isNaN(v));
      const fieldStats: Record = {};
      for (const m of metrics) {
        if (m === 'sum') fieldStats.sum = values.reduce((a, b) => a + b, 0);
        else if (m === 'avg') fieldStats.avg = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0;
        else if (m === 'max') fieldStats.max = values.length ? Math.max(...values) : 0;
        else if (m === 'min') fieldStats.min = values.length ? Math.min(...values) : 0;
      }
      results[field] = fieldStats;
    }

    if (groupBy) {
      const groups: Record = {};
      for (const row of data) {
        const key = String(row[groupBy] || 'unknown');
        groups[key] = (groups[key] || 0) + 1;
      }
      results.groups = groups;
    }

    return { analysis: results, data_count: data.length, fields: numericFields };
  }

  renderReport(analysis: Record, template: string = 'default',
               outputFormat: string = 'markdown'): Record {
    /** 渲染报表 */
    const stats = analysis.analysis as Record;
    const count = analysis.data_count as number;

    let report = '# 数据报表\n\n';
    report += `> 生成时间: ${new Date().toLocaleString()}\n`;
    report += `> 数据量: ${count} 条\n\n`;

    report += '## 统计指标\n\n';
    report += '| 字段 | 求和 | 平均值 | 最大值 | 最小值 |\n';
    report += '|------|------|--------|--------|--------|\n';
    for (const [field, fieldStats] of Object.entries(stats)) {
      if (field === 'groups') continue;
      const fs2 = fieldStats as Record;
      report += `| ${field} | ${fs2.sum ?? '-'} | ${fs2.avg ?? '-'} | ${fs2.max ?? '-'} | ${fs2.min ?? '-'} |\n`;
    }

    if (stats.groups) {
      report += '\n## 分组统计\n\n';
      for (const [group, groupCount] of Object.entries(stats.groups as Record)) {
        report += `- **${group}**: ${groupCount} 条\n`;
      }
    }

    const outputPath = `reports/report_${Date.now()}.md`;
    if (this.guard) {
      const check = this.guard.check('report-generator', 'filesystem.write', { path: outputPath });
      if (check.status === 'need_approval') {
        return { report, approval_needed: true, message: `需要审批才能写入文件: ${outputPath}` };
      } else if (check.status === 'denied') {
        return { error: check.reason };
      }
    }

    fs.mkdirSync(path.dirname(outputPath), { recursive: true });
    fs.writeFileSync(outputPath, report, 'utf-8');
    return { report, output_path: outputPath, written: true };
  }

  private findNumericFields(data: Record[]): string[] {
    /** 自动检测数值型字段 */
    const numeric: string[] = [];
    if (data.length > 0) {
      for (const [key, val] of Object.entries(data[0])) {
        if (typeof val === 'number') numeric.push(key);
      }
    }
    return numeric;
  }
}
package main

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

// skills/compound/report-generator/main.go
type ReportGeneratorSkill struct {
	Guard *PermissionGuard
}

func NewReportGeneratorSkill(guard *PermissionGuard) *ReportGeneratorSkill {
	return &ReportGeneratorSkill{Guard: guard}
}

func (s *ReportGeneratorSkill) FetchData(source, query, format string) map[string]interface{} {
	/** 从数据源获取原始数据 */
	if strings.HasPrefix(source, "http") {
		// API 数据源(带超时和分页)
		return map[string]interface{}{"data": nil, "source": source}
	} else if strings.HasSuffix(source, ".csv") || strings.HasSuffix(source, ".json") {
		// 文件数据源(带安全检查)
		if s.Guard != nil {
			check := s.Guard.Check("report-generator", "filesystem.read", map[string]interface{}{"path": source})
			if check["status"] == "denied" {
				return map[string]interface{}{"error": check["reason"]}
			}
		}
		data, err := os.ReadFile(source)
		if err != nil {
			return map[string]interface{}{"error": err.Error()}
		}
		if strings.HasSuffix(source, ".json") {
			var result interface{}
			json.Unmarshal(data, &result)
			return map[string]interface{}{"data": result, "source": source}
		}
		// Parse CSV
		lines := strings.Split(string(data), "\n")
		if len(lines)  0 {
					sum := 0.0
					for _, v := range values { sum += v }
					fieldStats["avg"] = sum / float64(len(values))
				} else { fieldStats["avg"] = 0 }
			case "max":
				if len(values) > 0 {
					maxVal := values[0]
					for _, v := range values { if v > maxVal { maxVal = v } }
					fieldStats["max"] = maxVal
				} else { fieldStats["max"] = 0 }
			case "min":
				if len(values) > 0 {
					minVal := values[0]
					for _, v := range values { if v  生成时间: %s\n", time.Now().Format("2006-01-02 15:04"))
	report += fmt.Sprintf("> 数据量: %d 条\n\n", count)
	report += "## 统计指标\n\n"
	report += "| 字段 | 求和 | 平均值 | 最大值 | 最小值 |\n"
	report += "|------|------|--------|--------|--------|\n"
	for field, fsRaw := range stats {
		if field == "groups" { continue }
		fs, _ := fsRaw.(map[string]interface{})
		report += fmt.Sprintf("| %s | %v | %v | %v | %v |\n",
			field, fs["sum"], fs["avg"], fs["max"], fs["min"])
	}
	if groups, ok := stats["groups"].(map[string]int); ok {
		report += "\n## 分组统计\n\n"
		for group, groupCount := range groups {
			report += fmt.Sprintf("- **%s**: %d 条\n", group, groupCount)
		}
	}

	outputPath := fmt.Sprintf("reports/report_%s.md", time.Now().Format("20060102_150405"))
	if s.Guard != nil {
		check := s.Guard.Check("report-generator", "filesystem.write", map[string]interface{}{"path": outputPath})
		if check["status"] == "need_approval" {
			return map[string]interface{}{"report": report, "approval_needed": true, "message": fmt.Sprintf("需要审批才能写入文件: %s", outputPath)}
		} else if check["status"] == "denied" {
			return map[string]interface{}{"error": check["reason"]}
		}
	}
	os.MkdirAll(filepath.Dir(outputPath), 0755)
	os.WriteFile(outputPath, []byte(report), 0644)
	return map[string]interface{}{"report": report, "output_path": outputPath, "written": true}
}

func (s *ReportGeneratorSkill) findNumericFields(data []map[string]interface{}) []string {
	var numeric []string
	if len(data) > 0 {
		for key, val := range data[0] {
			switch val.(type) {
			case int, int64, float64, float32:
				numeric = append(numeric, key)
			}
		}
	}
	sort.Strings(numeric)
	return numeric
}

func toFloat64(val interface{}) (float64, error) {
	switch v := val.(type) {
	case int:
		return float64(v), nil
	case float64:
		return v, nil
	case string:
		return strconv.ParseFloat(v, 64)
	}
	return 0, fmt.Errorf("not numeric")
}
import java.io.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import com.google.gson.Gson;

// skills/compound/report-generator/ReportGeneratorSkill.java
public class ReportGeneratorSkill {
    /** 企业级报表生成 Skill——带 permissionGuard */

    private PermissionGuard guard;
    private Gson gson = new Gson();

    public ReportGeneratorSkill(PermissionGuard guard) {
        this.guard = guard;
    }

    public Map fetchData(String source, String query, String format) {
        /** 从数据源获取原始数据 */
        if (source.startsWith("http")) {
            // API 数据源(带超时和分页)
            // In production: use HttpClient with timeout
            return Map.of("data", List.of(), "source", source);
        } else if (source.endsWith(".csv") || source.endsWith(".json")) {
            // 文件数据源(带安全检查)
            if (guard != null) {
                Map check = guard.check("report-generator", "filesystem.read", Map.of("path", source));
                if (check.get("status").equals("denied")) {
                    return Map.of("error", check.get("reason"));
                }
            }
            try {
                String content = Files.readString(Paths.get(source));
                if (source.endsWith(".json")) {
                    Object data = gson.fromJson(content, Object.class);
                    return Map.of("data", data, "source", source);
                } else {
                    // Parse CSV
                    String[] lines = content.split("\n");
                    String[] headers = lines[0].split(",");
                    List> data = new ArrayList<>();
                    for (int i = 1; i  row = new HashMap<>();
                        for (int j = 0; j  analyzeData(List> data, List metrics, String groupBy) {
        /** 分析数据,计算统计指标 */
        if (data == null || data.isEmpty()) {
            return Map.of("error", "数据为空,无法分析");
        }

        if (metrics == null) metrics = List.of("sum", "avg", "max", "min");
        List numericFields = findNumericFields(data);
        Map results = new LinkedHashMap<>();

        for (String field : numericFields) {
            List values = new ArrayList<>();
            for (Map row : data) {
                Object val = row.get(field);
                if (val instanceof Number) {
                    values.add(((Number) val).doubleValue());
                }
            }
            Map fieldStats = new LinkedHashMap<>();
            for (String m : metrics) {
                switch (m) {
                    case "sum":
                        fieldStats.put("sum", values.stream().mapToDouble(d -> d).sum());
                        break;
                    case "avg":
                        fieldStats.put("avg", values.isEmpty() ? 0 : values.stream().mapToDouble(d -> d).average().orElse(0));
                        break;
                    case "max":
                        fieldStats.put("max", values.isEmpty() ? 0 : values.stream().mapToDouble(d -> d).max().orElse(0));
                        break;
                    case "min":
                        fieldStats.put("min", values.isEmpty() ? 0 : values.stream().mapToDouble(d -> d).min().orElse(0));
                        break;
                }
            }
            results.put(field, fieldStats);
        }

        if (groupBy != null) {
            Map groups = new LinkedHashMap<>();
            for (Map row : data) {
                String key = String.valueOf(row.getOrDefault(groupBy, "unknown"));
                groups.merge(key, 1, Integer::sum);
            }
            results.put("groups", groups);
        }

        Map result = new LinkedHashMap<>();
        result.put("analysis", results);
        result.put("data_count", data.size());
        result.put("fields", numericFields);
        return result;
    }

    public Map renderReport(Map analysis, String template, String outputFormat) {
        /** 渲染报表 */
        Map stats = (Map) analysis.get("analysis");
        int count = (int) analysis.get("data_count");

        StringBuilder report = new StringBuilder();
        report.append("# 数据报表\n\n");
        report.append("> 生成时间: ").append(LocalDateTime.now()).append("\n");
        report.append("> 数据量: ").append(count).append(" 条\n\n");
        report.append("## 统计指标\n\n");
        report.append("| 字段 | 求和 | 平均值 | 最大值 | 最小值 |\n");
        report.append("|------|------|--------|--------|--------|\n");
        for (var entry : stats.entrySet()) {
            if (entry.getKey().equals("groups")) continue;
            Map fs = (Map) entry.getValue();
            report.append(String.format("| %s | %s | %s | %s | %s |\n",
                entry.getKey(), fs.getOrDefault("sum", "-"), fs.getOrDefault("avg", "-"),
                fs.getOrDefault("max", "-"), fs.getOrDefault("min", "-")));
        }
        if (stats.containsKey("groups")) {
            report.append("\n## 分组统计\n\n");
            Map groups = (Map) stats.get("groups");
            for (var entry : groups.entrySet()) {
                report.append(String.format("- **%s**: %d 条\n", entry.getKey(), entry.getValue()));
            }
        }

        String outputPath = "reports/report_" + System.currentTimeMillis() + ".md";
        if (guard != null) {
            Map check = guard.check("report-generator", "filesystem.write", Map.of("path", outputPath));
            if (check.get("status").equals("need_approval")) {
                return Map.of("report", report.toString(), "approval_needed", true,
                    "message", "需要审批才能写入文件: " + outputPath);
            } else if (check.get("status").equals("denied")) {
                return Map.of("error", check.get("reason"));
            }
        }

        try {
            Files.createDirectories(Paths.get("reports"));
            Files.writeString(Paths.get(outputPath), report.toString());
        } catch (IOException e) {
            return Map.of("error", e.getMessage());
        }
        return Map.of("report", report.toString(), "output_path", outputPath, "written", true);
    }

    private List findNumericFields(List> data) {
        List numeric = new ArrayList<>();
        if (!data.isEmpty()) {
            for (var entry : data.get(0).entrySet()) {
                if (entry.getValue() instanceof Number) {
                    numeric.add(entry.getKey());
                }
            }
        }
        return numeric;
    }
}

12.12.3 Step 3:测试

企业级 Skill 需要三层测试:单元测试(工具函数)、Prompt 测试(AI 调用策略)、安全测试(权限拦截):

# tests/test_report_generator.py
import pytest
from skills.compound.report_generator.main import ReportGeneratorSkill
from skills.compound.report_generator.permission_guard import PermissionGuard

class TestReportGenerator:
    """单元测试——验证工具函数逻辑"""

    def setup_method(self):
        self.skill = ReportGeneratorSkill()

    def test_analyze_data_basic(self):
        """测试基本统计分析"""
        data = [
            {"name": "A", "value": 10},
            {"name": "B", "value": 20},
            {"name": "C", "value": 30},
        ]
        result = self.skill.analyze_data(data)
        assert result["data_count"] == 3
        assert result["analysis"]["value"]["sum"] == 60
        assert result["analysis"]["value"]["avg"] == 20

    def test_analyze_data_empty(self):
        """测试空数据返回错误"""
        result = self.skill.analyze_data([])
        assert "error" in result
        assert "数据为空" in result["error"]

    def test_analyze_data_with_groups(self):
        """测试分组统计"""
        data = [
            {"department": "工程", "salary": 15000},
            {"department": "工程", "salary": 18000},
            {"department": "市场", "salary": 12000},
        ]
        result = self.skill.analyze_data(data, group_by="department")
        assert result["analysis"]["groups"]["工程"] == 2
        assert result["analysis"]["groups"]["市场"] == 1

    def test_render_report_markdown(self):
        """测试 Markdown 报表生成"""
        analysis = {
            "analysis": {"salary": {"sum": 45000, "avg": 15000}},
            "data_count": 3,
        }
        result = self.skill.render_report(analysis)
        assert "# 数据报表" in result["report"]
        assert "salary" in result["report"]

class TestPromptIntegration:
    """Prompt 测试——验证 AI 能正确调用 Skill"""

    PROMPT_TEST_CASES = [
        {
            "user_message": "帮我生成上周的销售报表",
            "expected_skill": "report-generator",
            "expected_call_order": ["fetch_data", "analyze_data", "render_report"],
        },
        {
            "user_message": "分析一下各部门的工资分布",
            "expected_skill": "report-generator",
            "expected_call_order": ["fetch_data", "analyze_data"],
        },
    ]

    def test_skill_activation(self):
        """验证触发词能正确激活 Skill"""
        manager = LazySkillManager(loader)
        for case in self.PROMPT_TEST_CASES:
            prompt = manager.get_prompt(case["user_message"])
            assert "report-generator" in prompt
            assert "(已激活)" in prompt

class TestSecurity:
    """安全测试——验证 permissionGuard 拦截"""

    def setup_method(self):
        self.guard = PermissionGuard()
        self.skill = ReportGeneratorSkill(permission_guard=self.guard)

    def test_path_traversal_blocked(self):
        """测试路径穿越被拦截"""
        result = self.skill.fetch_data(source="/etc/passwd")
        assert "error" in result
        assert "拦截" in result["error"]

    def test_write_needs_approval(self):
        """测试写文件需要审批"""
        result = self.skill.render_report({"analysis": {}, "data_count": 0})
        # 在审批机制下,render_report 应返回 need_approval
        assert result.get("approval_needed") == True or "审批" in str(result)

    def test_ssrf_blocked(self):
        """测试 SSRF 被拦截"""
        result = self.skill.fetch_data(source="http://127.0.0.1:8080/internal")
        assert "error" in result
        assert "SSRF" in result["error"] or "拦截" in result["error"]
// tests/test_report_generator.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ReportGeneratorSkill } from '../skills/compound/report-generator/main';
import { PermissionGuard } from '../skills/compound/report-generator/permission_guard';

describe('TestReportGenerator', () => {
  /** 单元测试——验证工具函数逻辑 */
  let skill: ReportGeneratorSkill;

  beforeEach(() => {
    skill = new ReportGeneratorSkill();
  });

  it('test_analyze_data_basic', () => {
    /** 测试基本统计分析 */
    const data = [
      { name: 'A', value: 10 },
      { name: 'B', value: 20 },
      { name: 'C', value: 30 },
    ];
    const result = skill.analyzeData(data);
    expect(result.data_count).toBe(3);
    expect((result.analysis as any).value.sum).toBe(60);
    expect((result.analysis as any).value.avg).toBe(20);
  });

  it('test_analyze_data_empty', () => {
    /** 测试空数据返回错误 */
    const result = skill.analyzeData([]);
    expect(result.error).toBeDefined();
    expect(result.error).toContain('数据为空');
  });

  it('test_analyze_data_with_groups', () => {
    /** 测试分组统计 */
    const data = [
      { department: '工程', salary: 15000 },
      { department: '工程', salary: 18000 },
      { department: '市场', salary: 12000 },
    ];
    const result = skill.analyzeData(data, undefined, 'department');
    expect((result.analysis as any).groups['工程']).toBe(2);
    expect((result.analysis as any).groups['市场']).toBe(1);
  });

  it('test_render_report_markdown', () => {
    /** 测试 Markdown 报表生成 */
    const analysis = {
      analysis: { salary: { sum: 45000, avg: 15000 } },
      data_count: 3,
    };
    const result = skill.renderReport(analysis);
    expect(result.report).toContain('# 数据报表');
    expect(result.report).toContain('salary');
  });
});

describe('TestPromptIntegration', () => {
  /** Prompt 测试——验证 AI 能正确调用 Skill */

  const PROMPT_TEST_CASES = [
    {
      user_message: '帮我生成上周的销售报表',
      expected_skill: 'report-generator',
      expected_call_order: ['fetch_data', 'analyze_data', 'render_report'],
    },
    {
      user_message: '分析一下各部门的工资分布',
      expected_skill: 'report-generator',
      expected_call_order: ['fetch_data', 'analyze_data'],
    },
  ];

  it('test_skill_activation', () => {
    /** 验证触发词能正确激活 Skill */
    const manager = new LazySkillManager(loader);
    for (const testCase of PROMPT_TEST_CASES) {
      const prompt = manager.getPrompt(testCase.user_message);
      expect(prompt).toContain('report-generator');
      expect(prompt).toContain('(已激活)');
    }
  });
});

describe('TestSecurity', () => {
  /** 安全测试——验证 permissionGuard 拦截 */
  let guard: PermissionGuard;
  let skill: ReportGeneratorSkill;

  beforeEach(() => {
    guard = new PermissionGuard();
    skill = new ReportGeneratorSkill(guard);
  });

  it('test_path_traversal_blocked', () => {
    /** 测试路径穿越被拦截 */
    const result = skill.fetchData('/etc/passwd');
    expect(result.error).toBeDefined();
    expect(result.error).toContain('拦截');
  });

  it('test_write_needs_approval', () => {
    /** 测试写文件需要审批 */
    const result = skill.renderReport({ analysis: {}, data_count: 0 });
    expect(result.approval_needed === true || JSON.stringify(result).includes('审批')).toBeTruthy();
  });

  it('test_ssrf_blocked', () => {
    /** 测试 SSRF 被拦截 */
    const result = skill.fetchData('http://127.0.0.1:8080/internal');
    expect(result.error).toBeDefined();
    expect(result.error.includes('SSRF') || result.error.includes('拦截')).toBeTruthy();
  });
});
package main

import (
	"testing"
)

// tests/test_report_generator_test.go

func TestAnalyzeDataBasic(t *testing.T) {
	/** 测试基本统计分析 */
	skill := NewReportGeneratorSkill(nil)
	data := []map[string]interface{}{
		{"name": "A", "value": 10},
		{"name": "B", "value": 20},
		{"name": "C", "value": 30},
	}
	result := skill.AnalyzeData(data, nil, "")
	if result["data_count"].(int) != 3 {
		t.Error("Expected data_count=3")
	}
	analysis := result["analysis"].(map[string]interface{})
	valueStats := analysis["value"].(map[string]interface{})
	if valueStats["sum"].(float64) != 60 {
		t.Error("Expected sum=60")
	}
}

func TestAnalyzeDataEmpty(t *testing.T) {
	/** 测试空数据返回错误 */
	skill := NewReportGeneratorSkill(nil)
	result := skill.AnalyzeData([]map[string]interface{}{}, nil, "")
	if _, ok := result["error"]; !ok {
		t.Error("Expected error for empty data")
	}
}

func TestAnalyzeDataWithGroups(t *testing.T) {
	/** 测试分组统计 */
	skill := NewReportGeneratorSkill(nil)
	data := []map[string]interface{}{
		{"department": "工程", "salary": 15000},
		{"department": "工程", "salary": 18000},
		{"department": "市场", "salary": 12000},
	}
	result := skill.AnalyzeData(data, nil, "department")
	analysis := result["analysis"].(map[string]interface{})
	groups := analysis["groups"].(map[string]int)
	if groups["工程"] != 2 {
		t.Error("Expected 工程=2")
	}
	if groups["市场"] != 1 {
		t.Error("Expected 市场=1")
	}
}

func TestPathTraversalBlocked(t *testing.T) {
	/** 测试路径穿越被拦截 */
	guard := NewPermissionGuard()
	skill := NewReportGeneratorSkill(guard)
	result := skill.FetchData("/etc/passwd", "", "json")
	if _, ok := result["error"]; !ok {
		t.Error("Expected error for path traversal")
	}
}

func TestSSRFBlocked(t *testing.T) {
	/** 测试 SSRF 被拦截 */
	guard := NewPermissionGuard()
	skill := NewReportGeneratorSkill(guard)
	result := skill.FetchData("http://127.0.0.1:8080/internal", "", "json")
	err, _ := result["error"].(string)
	if err == "" || (err != "SSRF 攻击被拦截" && err != "路径穿越攻击被拦截") {
		t.Error("Expected SSRF or path traversal block")
	}
}
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

import java.util.*;

// tests/TestReportGenerator.java
public class TestReportGenerator {

    private ReportGeneratorSkill skill;

    @BeforeEach
    void setUp() {
        skill = new ReportGeneratorSkill(null);
    }

    @Test
    @DisplayName("测试基本统计分析")
    void testAnalyzeDataBasic() {
        List> data = List.of(
            Map.of("name", "A", "value", 10),
            Map.of("name", "B", "value", 20),
            Map.of("name", "C", "value", 30)
        );
        Map result = skill.analyzeData(data, null, null);
        assertEquals(3, result.get("data_count"));
        Map analysis = (Map) result.get("analysis");
        Map valueStats = (Map) analysis.get("value");
        assertEquals(60.0, valueStats.get("sum"));
        assertEquals(20.0, valueStats.get("avg"));
    }

    @Test
    @DisplayName("测试空数据返回错误")
    void testAnalyzeDataEmpty() {
        Map result = skill.analyzeData(Collections.emptyList(), null, null);
        assertTrue(result.containsKey("error"));
        assertTrue(result.get("error").toString().contains("数据为空"));
    }

    @Test
    @DisplayName("测试分组统计")
    void testAnalyzeDataWithGroups() {
        List> data = List.of(
            Map.of("department", "工程", "salary", 15000),
            Map.of("department", "工程", "salary", 18000),
            Map.of("department", "市场", "salary", 12000)
        );
        Map result = skill.analyzeData(data, null, "department");
        Map analysis = (Map) result.get("analysis");
        Map groups = (Map) analysis.get("groups");
        assertEquals(2, groups.get("工程"));
        assertEquals(1, groups.get("市场"));
    }

    @Test
    @DisplayName("测试路径穿越被拦截")
    void testPathTraversalBlocked() {
        PermissionGuard guard = new PermissionGuard();
        ReportGeneratorSkill guardedSkill = new ReportGeneratorSkill(guard);
        Map result = guardedSkill.fetchData("/etc/passwd", null, "json");
        assertTrue(result.containsKey("error"));
        assertTrue(result.get("error").toString().contains("拦截"));
    }

    @Test
    @DisplayName("测试 SSRF 被拦截")
    void testSSRFBlocked() {
        PermissionGuard guard = new PermissionGuard();
        ReportGeneratorSkill guardedSkill = new ReportGeneratorSkill(guard);
        Map result = guardedSkill.fetchData("http://127.0.0.1:8080/internal", null, "json");
        assertTrue(result.containsKey("error"));
        String err = result.get("error").toString();
        assertTrue(err.contains("SSRF") || err.contains("拦截"));
    }
}

12.12.4 Step 4:发布流程

# 发布流程脚本 — publish_skill.sh
#!/bin/bash
# 企业级 Skill 发布流程(WaLiCode ReleasePipeline 模式)

SKILL_NAME="report-generator"
SKILL_DIR="skills/compound/${SKILL_NAME}"

echo "=== Step 1: 版本检查 ==="
CURRENT_VERSION=$(grep "version:" ${SKILL_DIR}/skill.yml | head -1 | cut -d' ' -f2)
echo "当前版本: ${CURRENT_VERSION}"

echo "=== Step 2: 运行测试 ==="
cd tests && python -m pytest test_report_generator.py -v
if [ $? -ne 0 ]; then
    echo "❌ 测试失败,中止发布"
    exit 1
fi
echo "✅ 所有测试通过"

echo "=== Step 3: 安全审计 ==="
python security_audit.py ${SKILL_NAME}
# 检查: 权限声明完整性、danger_level 评估、是否有 known vulnerabilities

echo "=== Step 4: 生成 Changelog ==="
# 从 CHANGELOG.yml 中提取当前版本的变更记录
python generate_changelog.py ${SKILL_NAME} ${CURRENT_VERSION}

echo "=== Step 5: 打包 ==="
tar -czf ${SKILL_NAME}-${CURRENT_VERSION}.tar.gz \
    ${SKILL_DIR}/skill.yml \
    ${SKILL_DIR}/main.py \
    ${SKILL_DIR}/CHANGELOG.yml \
    tests/test_report_generator.py

echo "=== Step 6: 注册到 Skill 仓库 ==="
# 内部仓库(类似 npm registry)
curl -X POST http://skill-registry.internal.com/api/register \
-F "name=${SKILL_NAME}" \
-F "version=${CURRENT_VERSION}" \
-F "file=@${SKILL_NAME}-${CURRENT_VERSION}.tar.gz"

echo "✅ 发布完成: ${SKILL_NAME} ${CURRENT_VERSION}"

# 安装验证
skill install ${SKILL_NAME}
skill list | grep ${SKILL_NAME}
``` | 发布阶段 | 检查项 | 失败处理 | WaLiCode 对应 | | --- | --- | --- | --- | | 版本检查 | 版本号是否符合 SemVer | 格式错误 → 修正后重试 | VersionValidation | | 运行测试 | 单元 + Prompt + 安全三层测试 | 测试失败 → 中止发布 | QualityGate | | 安全审计 | 权限声明、danger_level、已知漏洞 | 高危漏洞 → 修复后重新审计 | SecurityAudit | | 打包 | YAML + 代码 + 测试 + Changelog | 文件缺失 → 补充后重新打包 | PackageBuilder | | 注册 | 推送到 Skill 仓库 / 市场 | 网络失败 → 重试 3 次 | RegistryPublish | | 安装验证 | skill install → skill list 能看到 | 安装失败 → 回滚发布 | SmokeTest | ## 12.13 Skill 与 OpenClaw 的关系

OpenClaw 是一个**实际落地**的 Agent 平台,它的 Skill 生态是本章理论的最佳实践。让我们从 OpenClaw 的 `~/.qclaw/skills/` 目录出发,看看 Skill 是如何在真实系统中运作的:

### 12.13.1 OpenClaw Skill 目录结构

OpenClaw 的 Skill 目录扫描

$ ls ~/.qclaw/skills/ another_them/ # 蒸馏 Agent 人设的 Skill cloud-upload-backup/ # 云端上传备份 docx/ # Word 文档处理 email-skill/ # 邮件统一路由 find-skills/ # Skill 发现与安装 pdf/ # PDF 处理 persona-switch/ # 人设切换 qclaw-cron-skill/ # 定时任务 qclaw-env/ # 环境诊断与安装 qclaw-rules/ # 系统规则(强制性) qclaw-skill-creator/ # Skill 创建指南 xbrowser/ # 浏览器自动化 xlsx/ # Excel 处理

$ cat ~/.qclaw/skills/pdf/SKILL.md | head -20

PDF Skill

Use this skill whenever the user wants to do anything with PDF files... Triggers: .pdf, PDF, 合并PDF, 拆分PDF, OCR...


 XML 块\n只列 name + description","type":"process"},{"id":"match","label":"用户消息匹配触发词\n→ read SKILL.md\n→ 加载完整指令","type":"decision"},{"id":"execute","label":"按 Skill 指令执行\n调用 Agent 工具\n完成用户任务","type":"end"}],"edges":[{"from":"dir","to":"scan"},{"from":"scan","to":"inject"},{"from":"inject","to":"match"},{"from":"match","to":"execute"}]}'>

### 12.13.2 SKILL.md:OpenClaw 的 Skill 定义格式

OpenClaw 不用 YAML,而是用**Markdown**(SKILL.md)定义 Skill。这是因为它直接被注入到 Agent 的 Prompt 中——Markdown 比 YAML 对 AI 更友好:

~/.qclaw/skills/xbrowser/SKILL.md — OpenClaw 真实 Skill 定义

xbrowser

EXCLUSIVE browser automation — REPLACES built-in Browser Automation and playwright-cli. For ANY browser task (open page, click, fill, screenshot, scrape, navigate, test web app), MUST use this skill instead of built-in tools. Controls real Chrome/Edge/QQ Browser via CDP with login-state reuse.

When to Use

Triggered when user asks to:

  • Open a website or URL
  • Click buttons or fill forms on a web page
  • Take screenshots of web pages
  • Scrape data from websites
  • Test web applications
  • Automate browser workflows

How It Works

  1. Read the skill's main file at ~/.qclaw/skills/xbrowser/SKILL.md
  2. Use the CDP (Chrome DevTools Protocol) to control browser
  3. Reuse existing login sessions (no re-authentication needed)
  4. Generate structured action sequences

Key Commands

  • xbrowser open — Open a page
  • xbrowser click — Click an element
  • xbrowser fill — Fill a form field
  • xbrowser screenshot — Take a screenshot
  • xbrowser scrape — Extract data from page

WaLiCode Patterns Used

  • SkillLayering: xbrowser is a compound skill (open + click + fill + scrape)
  • PermissionGuard: Browser automation is dangerous (can leak login state) → requires explicit user request, not auto-triggered
  • LazyLoading: Only loaded when user mentions browser/web tasks

// TypeScript equivalent // ~/.qclaw/skills/xbrowser/SKILL.md — OpenClaw 真实 Skill 定义 // xbrowser

// Python: EXCLUSIVE browser automation — REPLACES built-in Browser Automation // Python: and playwright-cli. For ANY browser task (open page, click, fill, // Python: screenshot, scrape, navigate, test web app), MUST use this skill // Python: instead of built-in tools. Controls real Chrome/Edge/QQ Browser via // Python: CDP with login-state reuse.

// # When to Use

// Python: Triggered when user asks to: // Python: - Open a website or URL // Python: - Click buttons or fill forms on a web page // Python: - Take screenshots of web pages // Python: - Scrape data from websites // Python: - Test web applications // Python: - Automate browser workflows

// # How It Works

// Python: 1. Read the skill's main file at ~/.qclaw/skills/xbrowser/SKILL.md // Python: 2. Use the CDP (Chrome DevTools Protocol) to control browser // Python: 3. Reuse existing login sessions (no re-authentication needed) // Python: 4. Generate structured action sequences

// # Key Commands

// Python: - xbrowser open — Open a page // Python: - xbrowser click — Click an element // Python: - xbrowser fill — Fill a form field // Python: - xbrowser screenshot — Take a screenshot // Python: - xbrowser scrape — Extract data from page

// # WaLiCode Patterns Used

// Python: - SkillLayering: xbrowser is a compound skill (open + click + fill + scrape) // Python: - PermissionGuard: Browser automation is dangerous (can leak login state) // Python: → requires explicit user request, not auto-triggered // Python: - LazyLoading: Only loaded when user mentions browser/web tasks }


package main

import ( "fmt" )

// ~/.qclaw/skills/xbrowser/SKILL.md — OpenClaw 真实 Skill 定义 // xbrowser

// Python: EXCLUSIVE browser automation — REPLACES built-in Browser Automation
// Python: and playwright-cli. For ANY browser task (open page, click, fill,
// Python: screenshot, scrape, navigate, test web app), MUST use this skill
// Python: instead of built-in tools. Controls real Chrome/Edge/QQ Browser via
// Python: CDP with login-state reuse.

// # When to Use

// Python: Triggered when user asks to:
// Python: - Open a website or URL
// Python: - Click buttons or fill forms on a web page
// Python: - Take screenshots of web pages
// Python: - Scrape data from websites
// Python: - Test web applications
// Python: - Automate browser workflows

// # How It Works

// Python: 1. Read the skill's main file at ~/.qclaw/skills/xbrowser/SKILL.md
// Python: 2. Use the CDP (Chrome DevTools Protocol) to control browser
// Python: 3. Reuse existing login sessions (no re-authentication needed)
// Python: 4. Generate structured action sequences

// # Key Commands

// Python: - `xbrowser open ` — Open a page
// Python: - `xbrowser click ` — Click an element
// Python: - `xbrowser fill  ` — Fill a form field
// Python: - `xbrowser screenshot ` — Take a screenshot
// Python: - `xbrowser scrape ` — Extract data from page

// # WaLiCode Patterns Used

// Python: - **SkillLayering**: xbrowser is a compound skill (open + click + fill + scrape)
// Python: - **PermissionGuard**: Browser automation is dangerous (can leak login state)
// Python: → requires explicit user request, not auto-triggered
// Python: - **LazyLoading**: Only loaded when user mentions browser/web tasks

import java.util.*;

public class AgentCode { // ~/.qclaw/skills/xbrowser/SKILL.md — OpenClaw 真实 Skill 定义 // xbrowser

    // Python: EXCLUSIVE browser automation — REPLACES built-in Browser Automation
    // Python: and playwright-cli. For ANY browser task (open page, click, fill,
    // Python: screenshot, scrape, navigate, test web app), MUST use this skill
    // Python: instead of built-in tools. Controls real Chrome/Edge/QQ Browser via
    // Python: CDP with login-state reuse.

// # When to Use

    // Python: Triggered when user asks to:
    // Python: - Open a website or URL
    // Python: - Click buttons or fill forms on a web page
    // Python: - Take screenshots of web pages
    // Python: - Scrape data from websites
    // Python: - Test web applications
    // Python: - Automate browser workflows

// # How It Works

    // Python: 1. Read the skill's main file at ~/.qclaw/skills/xbrowser/SKILL.md
    // Python: 2. Use the CDP (Chrome DevTools Protocol) to control browser
    // Python: 3. Reuse existing login sessions (no re-authentication needed)
    // Python: 4. Generate structured action sequences

// # Key Commands

    // Python: - `xbrowser open ` — Open a page
    // Python: - `xbrowser click ` — Click an element
    // Python: - `xbrowser fill  ` — Fill a form field
    // Python: - `xbrowser screenshot ` — Take a screenshot
    // Python: - `xbrowser scrape ` — Extract data from page

// # WaLiCode Patterns Used

    // Python: - **SkillLayering**: xbrowser is a compound skill (open + click + fill + scrape)
    // Python: - **PermissionGuard**: Browser automation is dangerous (can leak login state)
    // Python: → requires explicit user request, not auto-triggered
    // Python: - **LazyLoading**: Only loaded when user mentions browser/web tasks
}

}


### 12.13.3 OpenClaw 的 Skill 懒加载机制

OpenClaw 实现了本章 7.4 节描述的**三层懒加载**: | 层级 | OpenClaw 实现 | 注入内容 | 加载时机 | | --- | --- | --- | --- | | 第一层 | `` XML 块 | name + description + trigger 词 | 每次会话自动注入 | | 第二层 | AI 主动 `read SKILL.md` | 完整 Skill 指令 | 匹配到触发词时 | | 第三层 | 用户安装新 Skill | skillhub_install 工具 | 用户请求时 | ```
# OpenClaw 第一层注入示例(来自实际的 system prompt)

pdf
Use this skill whenever the user wants to do anything
with PDF files. This includes reading, combining, splitting,
rotating, adding watermarks, OCR...
~/.qclaw/skills/pdf/SKILL.md

xbrowser
EXCLUSIVE browser automation — REPLACES built-in
Browser Automation and playwright-cli...
~/.qclaw/skills/xbrowser/SKILL.md

# AI 的决策流程
# 1. 用户说"帮我合并这两个PDF"
# 2. AI 匹配到 pdf Skill(触发词 "PDF")
# 3. AI 执行: read ~/.qclaw/skills/pdf/SKILL.md(第二层加载)
# 4. 按照 SKILL.md 中的指令调用 pdf 工具

12.13.4 OpenClaw Skill 的分层映射

将 OpenClaw 的 13 个 Skill 映射到本章的三层金字塔: | 层级 | OpenClaw Skill | 原因 | | --- | --- | --- | | 基础 Skill | qclaw-rules、qclaw-env、qclaw-cron-skill | 单功能封装:规则加载、环境安装、定时任务 | | 复合 Skill | pdf、docx、xlsx、xbrowser、email-skill | 多工具组合:如 pdf = read + merge + split + OCR | | 领域 Skill | another_them、qclaw-skill-creator | 领域知识:蒸馏人设、创建 Skill 的方法论 | ### 12.13.5 OpenClaw Skill 的安全实践

OpenClaw 的 Skill 安全设计直接体现了本章 7.11 节的五层架构:

# OpenClaw 安全实践对照

# Layer 1:声明式权限 — qclaw-rules Skill 的强制性声明
# (SKILL.md 中写道:[SYSTEM RULES - MANDATORY - ALWAYS LOAD - DO NOT SKIP])
# → 这是一种"强制加载"声明,比普通权限声明更严格

# Layer 2:运行时拦截 — elevated 权限机制
# exec 工具参数中有 elevated: true
# → 需要用户 /approve 才能执行提权命令

# Layer 3:审批机制 — /approve 命令
# Agent 请求审批 → 用户回复 /approve
# → 体现了 ApprovalGate 模式

# Layer 4:审计日志 — 会话记录
# 所有工具调用记录在会话历史中
# → 用户可以随时回溯 Agent 做了什么

# Layer 5:沙箱隔离 — sandbox 执行
# exec 工具支持 host: sandbox 参数
# → 危险命令在沙箱中执行

# 具体示例:qclaw-cron-skill 的安全设计
# ~/.qclaw/skills/qclaw-cron-skill/SKILL.md:
# "[MANDATORY - MUST LOAD] 凡是涉及定时/提醒/闹钟/周期执行/
#  打卡/签到/cron/schedule/remind 等需求...必须读取本 skill,
#  严禁凭记忆猜测参数。"
# → Layer 1(声明式权限)+ Layer 3(审批式执行)的混合

💡 从 OpenClaw 学什么

OpenClaw 的 Skill 生态证明了本章的核心论点:Skill = 工具 + 知识。每个 SKILL.md 不仅是工具列表,更是使用策略——告诉 AI 什么时候用、怎么用、什么不能用。这是 Skill 区别于普通工具的关键。 🔗 第12章 核心要点

Skill = 工具 + 知识:不只是工具的集合,还包含使用策略、调用顺序、参数约束。

三层结构:元数据层(what)+ 工具层(how)+ 知识层(when/why)。

自动发现:文件系统扫描 + 触发词匹配 + 热重载。

三层懒加载:名称列表 → 触发词激活 → AI 主动搜索,控制 Prompt 长度。

三者关系:Function Calling(底层)→ MCP(传输层)→ Skills(组织层)。

分层金字塔:基础 Skill(单工具)→ 复合 Skill(多工具组合)→ 领域 Skill(行业知识包),自下而上构建。

沉淀与演进:从项目实践中提炼 Skill,遵循 SemVer 版本迭代,模式识别 → 抽象提炼 → 验证打磨 → 发布复用。

五层安全架构:声明式权限 → 运行时拦截 → 审批机制 → 审计日志 → 沙箱隔离,按风险等级分层。

OpenClaw 实践:SKILL.md 格式 + 三层懒加载 + 五层安全,是本章理论的落地验证。 📋 八股总结 — 面试高频考点

Q1: Skill 和 Tool 的区别?

Tool 是单一操作,Skill 是工具的组合 + 使用知识。Tool 是函数,Skill 是模块。Skill 不仅包含工具,还包含调用策略、参数约束和使用时机。

Q2: 如何让 Agent 发现 Skills?

文件系统扫描 + YAML 元数据 + 触发词匹配 + 热重载。

Q3: Skills 太多导致 Prompt 过长怎么办?

三层懒加载——始终加载名称、触发词匹配时加载完整定义、AI 主动搜索罕见 Skill。这样可以在 Skill 数量很多时控制 Prompt 长度。

Q4: Skills 和 MCP 的关系?

层次不同。MCP 是工具级标准协议,Skills 是能力级组织单元。一个 Skill 可以包含 MCP 工具。MCP 连接 Agent 和工具,Skills 组织工具为能力包。

Q5: Skill 的三层金字塔是什么?

基础 Skill(单工具封装,如 read_file)→ 复合 Skill(多工具组合 + 调用策略,如 code-reviewer)→ 领域 Skill(行业知识包,如 finance-advisor)。底层越通用、顶层越专业,应自下而上构建。

Q6: Skill 如何从项目实践中沉淀?

四步流程——模式识别(从日志提取重复模式)→ 抽象提炼(转化为 Skill YAML)→ 验证打磨(在多项目试用)→ 发布复用(版本号 + Changelog)。版本遵循 SemVer:PATCH 修复、MINOR 新增、MAJOR 重构。

Q7: Skill 的五层安全架构是什么?

Layer 1 声明式权限(YAML 中声明)→ Layer 2 运行时拦截(permissionGuard 中间件)→ Layer 3 审批机制(高危操作需 /approve)→ Layer 4 审计日志(所有调用可追溯)→ Layer 5 沙箱隔离(危险 Skill 限制资源)。不是所有 Skill 都需要五层,按风险等级分层。

第12章 Skills-工具的组合与复用
http://www.clxhxhhr.top/posts/715/
作者
clxstart
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。