Agent Skills 实战(.NET):理论 × 代码 × 企业案例

效果演示

MiniClaw:启动时自动注册技能,并根据用户意图调用技能,返回结果。

食用指南

2025年10月Anthropic发布Claude Skills后,Agent生态迎来范式变革。本文将深入浅出地介绍Agent Skills的核心思想------渐进式披露与工具调用,并通过.NET与OpenAI从零构建简易框架和企业级实战案例,最后解析OpenClaw原理并手写类似框架。

引言:超越MCP,Agent Skills为何是下一个范式?

在过去的一年里,AI Agent的开发范式经历了从"提示词工程"到"工具调用",再到"MCP(模型上下文协议)"的演进。MCP解决了AI如何统一调用外部工具的"手"的问题,但它依然需要开发者在代码层面编排流程。而2025年底兴起的 Agent Skills,则从根本上改变了游戏规则。

如果把MCP比作给AI装上了可以抓取任何工具的"手",那么Agent Skills就是给AI培养了完成特定工作的"肌肉记忆" 。它不仅仅是调用工具,更是封装了特定领域的专业知识、工作流程、约束规则和可执行代码,让AI从一个需要手把手教的实习生,变成了一个能独立看懂SOP(标准作业程序)并熟练执行的专业人士 。

本文将站在.NET开发者的视角,结合dotnet OpenAI SDK,带您深入理解并掌握这一前沿技术。

准备工作

.NET8 开发环境,Windows/Linux 操作系统均可

1、(可选)本地部署模型

使用 Ollama 或者 vLLM 本地部署 Qwen3.5 模型:

2、siliconflow 模型服务

如果电脑配置无法支持本地部署模型,可以考虑使用云服务,例如硅基流动,它提供了很多免费使用的模型:

注册之后申请一个 api key:

记下需要使用的模型名字:

通过官网文档获取最佳推理参数:

3、(可选)OpenRouter 模型服务

使用邮件注册:

创建一个 api key:

免费模型:

注意部分模型需要余额才能使用,部分模型因策略不可使用,请先测试再决定使用

4、准备一个 mysql 数据库

执行数据库脚本:

sql 复制代码
-- 创建数据库
CREATE DATABASE IF NOT EXISTS sqlagent;
USE sqlagent;

-- 创建employees表(员工表)
CREATE TABLE employees (
    emp_no INT PRIMARY KEY AUTO_INCREMENT,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    INDEX idx_name (last_name, first_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 创建departments表(部门表)
CREATE TABLE departments (
    dept_no CHAR(4) PRIMARY KEY,
    dept_name VARCHAR(50) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 创建dept_emp表(员工部门关联表)
CREATE TABLE dept_emp (
    emp_no INT,
    dept_no CHAR(4),
    from_date DATE NOT NULL,
    to_date DATE NOT NULL,
    PRIMARY KEY (emp_no, dept_no),
    FOREIGN KEY (emp_no) REFERENCES employees(emp_no) ON DELETE CASCADE,
    FOREIGN KEY (dept_no) REFERENCES departments(dept_no) ON DELETE CASCADE,
    INDEX idx_dept_no (dept_no),
    INDEX idx_dates (from_date, to_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 创建salaries表(薪资表)
CREATE TABLE salaries (
    emp_no INT,
    salary DECIMAL(10,2) NOT NULL,
    from_date DATE NOT NULL,
    to_date DATE NOT NULL,
    PRIMARY KEY (emp_no, from_date),
    FOREIGN KEY (emp_no) REFERENCES employees(emp_no) ON DELETE CASCADE,
    INDEX idx_dates (from_date, to_date),
    CHECK (salary > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 创建titles表(职位表)
CREATE TABLE titles (
    emp_no INT,
    title VARCHAR(50) NOT NULL,
    from_date DATE NOT NULL,
    to_date DATE NOT NULL,
    PRIMARY KEY (emp_no, title, from_date),
    FOREIGN KEY (emp_no) REFERENCES employees(emp_no) ON DELETE CASCADE,
    INDEX idx_dates (from_date, to_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入部门数据
INSERT INTO departments (dept_no, dept_name) VALUES
('d001', '技术部'),
('d002', '市场部'),
('d003', '人事部'),
('d004', '财务部'),
('d005', '运营部');

-- 插入员工数据
INSERT INTO employees (first_name, last_name) VALUES
('张', '伟'),
('王', '芳'),
('李', '强'),
('刘', '洋'),
('陈', '静'),
('赵', '磊'),
('孙', '丽'),
('周', '涛'),
('吴', '娜'),
('郑', '鑫');

-- 插入员工部门关联数据
INSERT INTO dept_emp (emp_no, dept_no, from_date, to_date) VALUES
(1, 'd001', '2022-01-01', '9999-01-01'),
(2, 'd002', '2022-03-15', '9999-01-01'),
(3, 'd003', '2022-06-01', '9999-01-01'),
(4, 'd001', '2022-08-10', '9999-01-01'),
(5, 'd004', '2023-01-05', '9999-01-01'),
(6, 'd005', '2023-02-20', '9999-01-01'),
(7, 'd002', '2023-04-12', '9999-01-01'),
(8, 'd003', '2023-05-08', '9999-01-01'),
(9, 'd004', '2023-07-01', '9999-01-01'),
(10, 'd005', '2023-09-15', '9999-01-01');

-- 插入薪资数据
INSERT INTO salaries (emp_no, salary, from_date, to_date) VALUES
(1, 25000.00, '2022-01-01', '9999-01-01'),
(2, 18000.00, '2022-03-15', '9999-01-01'),
(3, 15000.00, '2022-06-01', '9999-01-01'),
(4, 22000.00, '2022-08-10', '9999-01-01'),
(5, 16000.00, '2023-01-05', '9999-01-01'),
(6, 14000.00, '2023-02-20', '9999-01-01'),
(7, 19000.00, '2023-04-12', '9999-01-01'),
(8, 15500.00, '2023-05-08', '9999-01-01'),
(9, 16500.00, '2023-07-01', '9999-01-01'),
(10, 13500.00, '2023-09-15', '9999-01-01');

-- 插入职位数据
INSERT INTO titles (emp_no, title, from_date, to_date) VALUES
(1, '技术总监', '2022-01-01', '9999-01-01'),
(2, '市场经理', '2022-03-15', '9999-01-01'),
(3, '人事专员', '2022-06-01', '9999-01-01'),
(4, '高级工程师', '2022-08-10', '9999-01-01'),
(5, '财务分析师', '2023-01-05', '9999-01-01'),
(6, '运营专员', '2023-02-20', '9999-01-01'),
(7, '市场专员', '2023-04-12', '9999-01-01'),
(8, '人事经理', '2023-05-08', '9999-01-01'),
(9, '财务经理', '2023-07-01', '9999-01-01'),
(10, '运营助理', '2023-09-15', '9999-01-01');

-- 查询验证数据
SELECT '=== 员工信息表 ===' as '';
SELECT e.emp_no, e.first_name, e.last_name, d.dept_name, s.salary, t.title 
FROM employees e
JOIN dept_emp de ON e.emp_no = de.emp_no
JOIN departments d ON de.dept_no = d.dept_no
JOIN salaries s ON e.emp_no = s.emp_no
JOIN titles t ON e.emp_no = t.emp_no
ORDER BY e.emp_no;

第一章:什么是 Agent Skills?------ AI 的"岗位说明书"与"SOP 大礼包"

1.1 核心概念:从"工具使用者"到"流程执行者"

Agent Skills,直译为"智能体技能",是由Anthropic提出的一种开放标准,旨在将智能体的能力封装为可发现、可复用、可分享的模块化单元 。

一个Skill本质上就是一个包含特定文件的文件夹。它不仅仅是几行代码,而是包含:

  • 元数据 (Metadata):技能的名字和描述,告诉AI"我是什么、什么时候该用我"。
  • 指令 (Instruction/SKILL.md):核心部分,用自然语言编写的详细工作流程、注意事项、约束条件和最佳实践。
  • 资源 (Resources):可选的脚本、模板、参考文档等,用于执行具体操作或提供背景知识。

1.2 核心设计理念:渐进式披露 (Progressive Disclosure)

这是Agent Skills最精妙的设计,完美解决了LLM上下文窗口的限制 。信息被分为三个层次,按需加载:

  1. 第一层:元数据 (Metadata) :Agent启动时,仅加载所有Skills的namedescription(约100 tokens)。这让AI知道"我有这些技能可用",但具体怎么做,它还不知道。
  2. 第二层:技能主体 (Instruction) :当AI根据元数据判断某个Skill与当前任务相关时,它会加载该Skill完整的SKILL.md文件。此时,AI获得了执行任务的全部"理论知识"。
  3. 第三层:附加资源 (Resources) :在具体执行过程中,如果SKILL.md指引AI去查阅某个参考文档或运行某个脚本,AI才会去加载或执行这些外部文件。例如,在处理PDF时,只有当需要填写表单时,AI才加载forms.md指南 。

这种机制让开发者可以将海量的企业知识、复杂的操作流程打包进Skill,而无需担心撑爆上下文,实现了无限扩展的可能性。

据大语言模型的一般换算规则:

  • 1个token ≈ 1.5个汉字(英文约为0.75个单词)

  • 256,000 tokens × 1.5 = 约 384,000 个汉字

所以,256K上下文大约可以处理 38万 汉字。

  • 相当于 三体三部曲的体量。
  • 相当于 8本《活着》(一本约5万字)。
  • 相当于 1.5本《白鹿原》(一本约25万字)。

1.3 Skill vs MCP:并非取代,而是共生

这是一个常见的误区。我们可以用一个比喻来清晰区分:

  • MCP (Model Context Protocol) :好比是电源插座 。它定义了一套统一的接口标准,让AI可以方便地插上各种"电器"(外部工具、数据源)。它关注的是 "连接"
  • Agent Skill :好比是 "电器"的使用说明书 + 操作流程 。它告诉AI,当你用这台"电器"(通过MCP连接的某个工具)时,应该先按哪个按钮,后按哪个按钮,达到什么效果算成功,遇到什么情况算异常。它关注的是 "流程"和"专业知识"

简单来说,Skill可以调用MCP Server 。一个Skill的SKILL.md里可以写明:"首先,通过MCP的filesystem服务读取文件列表;然后,调用create_pptx Python脚本生成PPT。" 二者结合,相得益彰。

第二章:从零开始------实现一个简易的 Agent Skills 核心框架

为了深刻理解其原理,我们暂时忽略复杂的文件解析和框架依赖,聚焦于最核心的渐进式披露工具调用逻辑

我们将构建一个极度简化的控制台应用,模拟一个拥有"代码审查"技能的Agent。

bash 复制代码
dotnet new console -f net8.0

2.1 定义Skill的数据结构

首先,我们需要一个类来表示一个Skill,包含其元数据和指令。

csharp 复制代码
// Simplified Skill Model
public class Skill
{
    public string Name { get; set; } // 技能名称(元数据)
    public string Description { get; set; } // 技能描述(元数据)
    public string InstructionPath { get; set; } // 指令文件路径(对应 SKILL.md)
    public string? ScriptPath { get; set; } // 可选脚本路径

    private string? _fullInstruction;
    
    // 模拟渐进式披露的第二层:按需加载完整指令
    public async Task<string> GetFullInstructionAsync()
    {
        if (_fullInstruction == null && File.Exists(InstructionPath))
        {
            Console.WriteLine($"[系统] 加载技能 '{Name}' 的详细指令...");
            _fullInstruction = await File.ReadAllTextAsync(InstructionPath);
        }
        return _fullInstruction ?? "指令文件不存在。";
    }
}

2.2 设计Skill Manager:负责发现与按需加载

Skill Manager 负责在启动时加载所有Skill的元数据(第一层),并提供根据任务匹配Skill及加载其详细指令(第二层)的能力。

csharp 复制代码
using System.Text.Json;

public class SkillManager
{
    private readonly string _skillsDirectory;
    private List<Skill> _skillsMetaData = new(); // 只存元数据

    public SkillManager(string skillsDirectory)
    {
        _skillsDirectory = skillsDirectory;
    }

    // 模拟渐进式披露的第一层:发现并加载所有技能的元数据
    public void DiscoverAndLoadMetadata()
    {
        Console.WriteLine("[系统] 正在扫描可用技能...");
        var skillFolders = Directory.GetDirectories(_skillsDirectory);
        
        foreach (var folder in skillFolders)
        {
            var metaFile = Path.Combine(folder, "skill.json"); // 简化起见,用json存元数据
            if (File.Exists(metaFile))
            {
                var metaJson = File.ReadAllText(metaFile);
                var skillMeta = JsonSerializer.Deserialize<Skill>(metaJson);
                if (skillMeta != null)
                {
                    skillMeta.InstructionPath = Path.Combine(folder, "INSTRUCTION.md"); // 约定指令文件
                    skillMeta.ScriptPath = Path.Combine(folder, "script.py"); // 约定脚本文件(如果存在)
                    _skillsMetaData.Add(skillMeta);
                    Console.WriteLine($"  发现技能: {skillMeta.Name} - {skillMeta.Description}");
                }
            }
        }
        Console.WriteLine($"[系统] 元数据加载完成。共发现 {_skillsMetaData.Count} 个技能。\n");
    }

    // 根据用户输入,匹配合适的技能(基于元数据描述)
    public Skill? MatchSkill(string userQuery)
    {
        // 极简匹配:检查技能描述是否出现在用户查询中
        // 实际应用中应由LLM判断,这里简化模拟
        return _skillsMetaData.FirstOrDefault(s => 
            userQuery.Contains(s.Name, StringComparison.OrdinalIgnoreCase) ||
            s.Description.Split(' ').Any(keyword => userQuery.Contains(keyword, StringComparison.OrdinalIgnoreCase))
        );
    }
}

2.3 模拟Agent执行循环:集成OpenAI进行推理

这是最核心的部分。Agent收到用户请求后,先尝试匹配Skill。如果匹配到,则加载详细指令(第二层),连同用户问题一起发送给LLM。

为了演示,我们准备一个简单的"代码审查"Skill。

技能文件结构:

复制代码
Skills/
└── code-reviewer/
    ├── skill.json
    └── INSTRUCTION.md

skill.json (元数据):

json 复制代码
{
  "Name": "code-reviewer",
  "Description": "审查C#代码,找出潜在bug、性能问题和风格违规"
}

INSTRUCTION.md (指令主体):

markdown 复制代码
# C#代码审查指南
当你被要求审查C#代码时,请遵循以下步骤:
1.  **识别语法错误和潜在Bug**:检查空引用、类型不匹配、异步方法缺少await等。
2.  **分析性能问题**:关注LINQ查询的滥用、装箱拆箱、大对象分配。
3.  **检查代码风格**:确保符合常见的C#编码规范(如PascalCase命名方法,camelCase命名参数)。
4.  **输出格式**:将问题分类为 [Bug]、[性能]、[风格],并给出修改建议。

Agent核心逻辑 (Program.cs):

需要安装 OpenAI NuGet 包:

bash 复制代码
Microsoft.Extensions.AI.OpenAI
csharp 复制代码
using OpenAI;
using OpenAI.Chat;

// 初始化 OpenAI 客户端 (需配置环境变量或直接填写 Key)
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")?? string.Empty;
string baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com/v1"; 

OpenAIClient client = new(
    new ApiKeyCredential(apiKey),
    new OpenAIClientOptions { Endpoint = new Uri(baseUrl) }
);

// 使用的模型
ChatClient chatClient = client.GetChatClient("Qwen/Qwen3.5-397B-A17B"); 

// 初始化 Skill Manager
SkillManager manager = new("./Skills");
manager.DiscoverAndLoadMetadata(); // 加载所有元数据(第一层)

// 模拟用户请求
string userRequest = "code-reviewer,帮我审查一下这段C#代码:public void Process(List<string> items) { foreach(var item in items) { if(item.Length > 0) Console.WriteLine(item); } }";

Console.WriteLine($"用户: {userRequest}\n");

// 1. Agent 思考:匹配合适的 Skill
Skill? matchedSkill = manager.MatchSkill(userRequest);

List<ChatMessage> messages = new();

if (matchedSkill != null)
{
    // 2. 渐进式披露:获取匹配技能的详细指令(第二层)
    string fullInstruction = await matchedSkill.GetFullInstructionAsync();
    Console.WriteLine($"[Agent] 已匹配技能 [{matchedSkill.Name}],加载详细指南。\n");
    
    // 3. 构建系统提示词:融合 Skill 指令
    string systemPrompt = 
        $"你是一个有用的AI助手。请严格按照以下专业技能指南来响应用户。\n" +
        $"=== 技能名称: {matchedSkill.Name} ===\n" +
        $"{fullInstruction}\n" +
        $"=== 指南结束 ===\n";
    
    messages.Add(new SystemChatMessage(systemPrompt));
}
else
{
    Console.WriteLine("[Agent] 未找到匹配的特定技能,使用通用能力响应。\n");
    messages.Add(new SystemChatMessage("你是一个有用的AI助手,用通用知识回答问题。"));
}

// 用户输入
messages.Add(new UserChatMessage(userRequest));

// 4. 调用 OpenAI
ChatCompletion completion = await chatClient.CompleteChatAsync(messages);
string answer = completion.Content[0].Text;

Console.WriteLine($"AI: {answer}");

2.4 运行与观察

bash 复制代码
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.siliconflow.cn/v1/"
# export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
bash 复制代码
dotnet run

运行程序,控制台会输出:

  1. Skill Manager加载元数据(发现code-reviewer技能)。
  2. 用户提问包含"审查代码",Agent匹配到code-reviewer技能。
  3. Agent打印"加载技能详细指令"的日志(模拟按需加载)。
  4. AI最终的回答将严格遵循INSTRUCTION.md中规定的步骤和格式,对代码进行分类审查。

这个简易框架虽然简陋,但它完整演示了Agent Skills的元数据发现指令按需加载的核心思想。这就是所有复杂Skill系统的基石。

第三章:企业级实战------构建一个"自然语言生成SQL"分析 Agent

理论知识储备完毕,现在我们来构建一个更具实用价值的企业级案例:一个能够根据中文业务问题,查询MySQL数据库并生成分析报告的NL2SQL(自然语言转SQL)Agent 。

我们将使用.NET的 MySql.Data 连接 MySql 数据库,并结合OpenAI的函数调用功能,让Agent能够自主决定何时以及如何查询数据。

3.1、场景设定与 Skill 设计

假设我们有一个员工数据库 (employees),包含employees, departments, salaries, titles等表。我们希望AI能回答如"分析下公司里谁的话语权最高?"或"市场部和销售部的平均薪资对比如何?"这类复杂问题。

我们设计一个名为mysql-employees-analyst的Skill,其目录结构如下:

复制代码
mysql-employees-analyst/
├── SKILL.md          # 核心指令
└── scripts/
    └── execute_sql.py  # Python脚本执行SQL

说明虽然我们的主程序是.NET,但Skill的scripts/目录可以包含任何语言的可执行脚本,只要Agent能调用它。

3.2、SKILL.md:制定铁律与工作流

这是Skill的灵魂。它告诉AI关于数据库的一切规则。

markdown 复制代码
---
name: mysql-employees-analyst
description: 专门分析MySQL employees示例数据库,将中文问题转为受控SQL并输出分析报告
---

# MySQL Employees 数据分析 Skill

你必须完成一个完整闭环:理解用户问题 -> 生成受控SQL -> 调用脚本执行 -> 分析结果并输出报告。

## 一、数据库背景
这是MySQL官方employees示例库。核心表及关系:
- employees (emp_no, first_name, last_name)
- departments (dept_no, dept_name)
- dept_emp (emp_no, dept_no, from_date, to_date)
- salaries (emp_no, salary, from_date, to_date)
- titles (emp_no, title, from_date, to_date)
当前日期是 {current_date},查询当前在职员工请使用 `to_date = '9999-01-01'`。

## 二、你的职责边界(硬约束)
1.  **只读查询**:只能生成 `SELECT` 语句。
2.  **单条SQL**:每次只能生成并执行**一条**SQL。如果问题复杂,你需要自己规划步骤,多次调用工具,而不是试图一次查出所有答案。
3.  **禁止推测**:不允许编造查询结果。必须先执行SQL,再基于真实结果分析。

## 三、SQL生成规则
- 日期过滤:查询当前信息必须用 `to_date = '9999-01-01'`。
- 聚合函数:使用 `AVG()`, `COUNT()`, `MAX()` 等时,务必给列起别名。
- 字符串模糊匹配:使用 `LIKE` 时,注意处理中文通配符。

## 四、工具使用协议
- 工具名称:`execute_sql`
- 调用方法:将你生成的SQL语句作为参数传递给此工具。
- 工具返回:JSON格式的查询结果或错误信息。

## 五、最终输出要求
- 如果查询出错,请解释错误并尝试修正SQL。
- 如果查询成功,基于返回的数据,用Markdown格式输出结构化的分析报告,包括你使用的SQL语句。

3.3、编写.NET Agent:集成OpenAI函数调用

在.NET程序中,我们需要定义好工具(即execute_sql函数),让OpenAI模型可以调用它。

需要安装:

bash 复制代码
MySql.Data
csharp 复制代码
using MySql.Data.MySqlClient;
using OpenAI;
using OpenAI.Chat;
using System.ClientModel;
using System.Text.Json;

// 1. 初始化 OpenAI 客户端
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? string.Empty;
string baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "https://api.openai.com/v1";

OpenAIClient client = new(
    new ApiKeyCredential(apiKey),
    new OpenAIClientOptions { Endpoint = new Uri(baseUrl) }
);

// 使用的模型
ChatClient chatClient = client.GetChatClient("Pro/MiniMaxAI/MiniMax-M2.5");


// 2. 初始化 SQL 连接
string dbConnectionString = Environment.GetEnvironmentVariable("MYSQL_CONN_STRING") ?? "";

// 3. 定义 Agent 的运行循环
async Task RunAgentAsync(string userQuery)
{
    // 加载 SKILL.md 内容(假设已从文件读取)
    string skillInstruction = File.ReadAllText("./mysql-employees-analyst/SKILL.md");
    // 用当前日期替换占位符
    skillInstruction = skillInstruction.Replace("{current_date}", DateTime.Now.ToString("yyyy-MM-dd"));

    // 构建消息列表
    var messages = new List<ChatMessage>
    {
        new SystemChatMessage(skillInstruction), // 将Skill指令作为系统提示
        new UserChatMessage(userQuery)
    };

    // 定义工具
    var executeSqlTool = ChatTool.CreateFunctionTool(
        functionName: "execute_sql",
        functionDescription: "在MySQL employees数据库上执行一条SELECT语句,并返回JSON格式的结果。",
        functionParameters: BinaryData.FromObjectAsJson(
            new
            {
                type = "object",
                properties = new
                {
                    sql_query = new
                    {
                        type = "string",
                        description = "要执行的SQL SELECT语句"
                    }
                },
                required = new[] { "sql_query" }
            },
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
        )
    );

    var chatOptions = new ChatCompletionOptions
    {
        Tools = { executeSqlTool }
    };

    bool requiresAction = true;
    int maxTurns = 10; // 防止无限循环

    while (requiresAction && maxTurns-- > 0)
    {
        requiresAction = false;
        ChatCompletion completion = await chatClient.CompleteChatAsync(messages, chatOptions);

        switch (completion.FinishReason)
        {
            case ChatFinishReason.Stop:
                // AI 给出了最终回答,输出结果
                Console.WriteLine($"AI助手: {completion.Content[0].Text}");
                messages.Add(new AssistantChatMessage(completion));
                break;

            case ChatFinishReason.ToolCalls:
                // AI 请求调用工具
                messages.Add(new AssistantChatMessage(completion)); // 添加助手消息,包含工具调用请求

                // 遍历所有工具调用请求
                foreach (var toolCall in completion.ToolCalls)
                {
                    if (toolCall.FunctionName == "execute_sql")
                    {
                        Console.WriteLine($"[Agent] 正在调用工具: {toolCall.FunctionName}");

                        // 解析参数
                        using JsonDocument args = JsonDocument.Parse(toolCall.FunctionArguments);
                        string sql = args.RootElement.GetProperty("sql_query").GetString() ?? "";
                        Console.WriteLine($"[Agent] 执行SQL: {sql}");

                        // 执行SQL(真实环境中需要添加防SQL注入和安全校验)
                        string resultJson = await ExecuteSqlAsync(dbConnectionString, sql);

                        // 将工具执行结果返回给模型
                        messages.Add(new ToolChatMessage(toolCall.Id, resultJson));
                        requiresAction = true; // 需要再次调用模型,让它基于结果继续
                    }
                }
                break;

            case ChatFinishReason.Length:
            case ChatFinishReason.ContentFilter:
            case ChatFinishReason.FunctionCall: // 旧版,忽略
            default:
                Console.WriteLine($"结束,原因: {completion.FinishReason}");
                requiresAction = false;
                break;
        }
    }
}

// 4. 执行SQL的函数 (简化版,生产环境需更健壮的错误处理)
async Task<string> ExecuteSqlAsync(string connectionString, string sqlQuery)
{
    // **重要:在生产环境中,你需要对sqlQuery进行严格校验,确保它只包含SELECT语句,防止SQL注入**
    if (!sqlQuery.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
    {
        return JsonSerializer.Serialize(new { error = "Only SELECT queries are allowed." });
    }

    var results = new List<object>();
    try
    {
        using var conn = new MySqlConnection(connectionString);
        await conn.OpenAsync();
        using var cmd = new MySqlCommand(sqlQuery, conn);
        using var reader = await cmd.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            var row = new Dictionary<string, object>();
            for (int i = 0; i < reader.FieldCount; i++)
            {
                row[reader.GetName(i)] = reader.GetValue(i);
            }
            results.Add(row);
        }
        return JsonSerializer.Serialize(new { success = true, data = results, rowCount = results.Count });
    }
    catch (Exception ex)
    {
        return JsonSerializer.Serialize(new { error = ex.Message });
    }
}

// 5. 启动 Agent
Console.WriteLine("Employee 数据分析Agent已启动。请输入您的问题:");
string query = Console.ReadLine() ?? "";
await RunAgentAsync(query);

运行效果:

bash 复制代码
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.siliconflow.cn/v1/"
# export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export MYSQL_CONN_STRING="server=localhost;Database=sqlagent;Uid=root;Pwd=0123456789;SslMode=Disabled;AllowPublicKeyRetrieval=True;"

3.4、脚本封装与调用

例如,将安全级别极高或者成熟固定的一套工作流封装为脚本,作为AI的拓展工具

为了演示跨语言能力,我们用一个Python脚本来执行SQL,或者我们可以用.NET的File-Based Apps特性编写一个C#脚本 。

这种方式的好处是:

  • 解耦:SQL执行逻辑与Agent主程序分离
  • 灵活性:Python或其他语言有丰富的数据处理库(pandas、numpy等)
  • 跨平台:脚本可以在不同环境下独立运行

Python脚本编写 (execute_sql.py):

python 复制代码
#!/usr/bin/env python3
"""
SQL执行脚本 - 供.NET Agent调用
接收JSON格式的输入,返回JSON格式的结果
"""
# 安装MySQL连接器
# pip install mysql-connector-python

import decimal
import json
import sys
import mysql.connector
from mysql.connector import Error
import pandas as pd
from datetime import datetime, date
import logging

import os

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

class SQLExecutor:
    def __init__(self, config_file=None):
        """初始化数据库连接配置"""
        
        # 使用默认配置文件
        if config_file == None:
            # 获取脚本文件所在的目录
            script_dir = os.path.dirname(os.path.abspath(__file__))
            # 拼接文件名
            config_file = os.path.join(script_dir, 'db_config.json')

        self.config = self._load_config(config_file)
        self.connection = None
    
    def _load_config(self, config_file):
        """加载数据库配置文件"""
        try:
            with open(config_file, 'r') as f:
                config = json.load(f)
            logger.info(f"成功加载配置文件: {config_file}")
            return config
        except FileNotFoundError:
            logger.error(f"配置文件 {config_file} 不存在")
            # 返回默认配置(实际生产环境应从环境变量读取)
            return {
                "host": "localhost",
                "database": "employees",
                "user": "root",
                "password": "password",
                "port": 3306
            }
        except json.JSONDecodeError as e:
            logger.error(f"配置文件格式错误: {e}")
            sys.exit(1)
    
    def connect(self):
        """建立数据库连接"""
        try:
            self.connection = mysql.connector.connect(
                host=self.config['host'],
                database=self.config['database'],
                user=self.config['user'],
                password=self.config['password'],
                port=self.config.get('port', 3306)
            )
            logger.info("数据库连接成功")
            return True
        except Error as e:
            logger.error(f"数据库连接失败: {e}")
            return False
    
    def close(self):
        """关闭数据库连接"""
        if self.connection and self.connection.is_connected():
            self.connection.close()
            logger.info("数据库连接已关闭")
    
    def _convert_value(self, value):
        """转换特殊类型为JSON可序列化格式"""
        if isinstance(value, (datetime, date)):
            return value.isoformat()
        elif isinstance(value, decimal.Decimal):
            return float(value)
        elif isinstance(value, bytes):
            return value.decode('utf-8', errors='ignore')
        return value
    
    def execute_query(self, sql_query, params=None, limit=1000):
        """
        执行SQL查询并返回结果
        
        Args:
            sql_query: SQL查询语句
            params: 查询参数(可选)
            limit: 最大返回行数
        
        Returns:
            dict: 包含查询结果或错误信息的字典
        """
        # SQL注入防护:只允许SELECT语句
        if not sql_query.strip().upper().startswith('SELECT'):
            return {
                'success': False,
                'error': '只允许执行SELECT查询'
            }
        
        # 添加LIMIT子句防止返回过多数据
        if 'LIMIT' not in sql_query.upper():
            sql_query += f" LIMIT {limit}"
        
        cursor = None
        try:
            cursor = self.connection.cursor(dictionary=True)
            
            logger.info(f"执行SQL: {sql_query}")
            if params:
                cursor.execute(sql_query, params)
            else:
                cursor.execute(sql_query)
            
            # 获取列名
            columns = [desc[0] for desc in cursor.description] if cursor.description else []
            
            # 获取数据
            rows = cursor.fetchall()
            
            # 转换特殊类型
            converted_rows = []
            for row in rows:
                converted_row = {}
                for key, value in row.items():
                    converted_row[key] = self._convert_value(value)
                converted_rows.append(converted_row)
            
            logger.info(f"查询成功,返回 {len(converted_rows)} 行数据")
            
            return {
                'success': True,
                'data': converted_rows,
                'row_count': len(converted_rows),
                'columns': columns,
                'sql': sql_query
            }
            
        except Error as e:
            logger.error(f"SQL执行错误: {e}")
            return {
                'success': False,
                'error': str(e),
                'sql': sql_query
            }
        finally:
            if cursor:
                cursor.close()
    
    def execute_multi_query(self, queries):
        """
        执行多条SQL语句(用于复杂分析)
        
        Args:
            queries: SQL语句列表
        
        Returns:
            list: 每条查询的结果
        """
        results = []
        for i, query in enumerate(queries):
            logger.info(f"执行第 {i+1}/{len(queries)} 条查询")
            result = self.execute_query(query)
            results.append(result)
            
            # 如果某条查询失败,可以选择停止或继续
            if not result.get('success', False):
                logger.warning(f"第 {i+1} 条查询失败,继续执行后续查询")
        
        return results

def print_json_response(success, error=None, data=None, ensure_ascii=False):
    """
    打印JSON格式的响应
    
    Args:
        success: 是否成功
        error: 错误信息(可选)
        data: 返回数据(可选)
        ensure_ascii: 是否确保ASCII编码
    """
    response = {'success': success}
    
    if error:
        response['error'] = error
    
    if data is not None:
        response['data'] = data
    
    print(json.dumps(response, ensure_ascii=ensure_ascii))

def main():
    """主函数:从标准输入读取JSON,执行SQL,输出JSON结果"""
    
    # 读取标准输入
    try:
        # 设置标准输入使用utf-8-sig编码
        sys.stdin.reconfigure(encoding='utf-8-sig')
        input_data = sys.stdin.read()
            
        if not input_data:
            # 如果没有输入,尝试从命令行参数获取
            if len(sys.argv) > 1:
                input_data = sys.argv[1]
            else:
                print_json_response(False, '未提供输入数据', None, False)
                sys.exit(1)
        
        # 解析JSON输入
        try:
            request = json.loads(input_data)
        except json.JSONDecodeError as e:
            print_json_response(False, f'JSON解析错误: {e}', None, False)
            sys.exit(1)
        
        # 获取SQL查询
        sql_query = request.get('sql_query')
        if not sql_query:
            print_json_response(False, '未提供SQL查询语句', None, False)
            sys.exit(1)
        
        # 获取参数
        params = request.get('params')
        limit = request.get('limit', 1000)
        
        # 执行查询
        executor = SQLExecutor()
        if not executor.connect():
            print_json_response(False, '数据库连接失败', None, False)
            sys.exit(1)
        
        try:
            result = executor.execute_query(sql_query, params, limit)
            # 输出JSON结果
            print(json.dumps(result, ensure_ascii=False, indent=2))
        finally:
            executor.close()
            
    except Exception as e:
        logger.exception("未预期的错误")
        print_json_response(False, f'系统错误: {str(e)}', None, False)

if __name__ == "__main__":
    main()

数据库配置文件 (db_config.json):

json 复制代码
{
    "host": "localhost",
    "port": 3306,
    "database": "sqlagent",
    "user": "root",
    "password": "0123456789",
    "charset": "utf8mb4",
    "use_unicode": true
}

脚本测试:

bash 复制代码
# Linux/Mac
echo '{"sql_query": "SELECT * FROM titles WHERE to_date = %s", "params": ["9999-01-01"], "limit": 10}' | python3 execute_sql.py

# Windows (PowerShell)
'{"sql_query": "SELECT * FROM titles WHERE to_date = %s", "params": ["9999-01-01"], "limit": 10}' | python execute_sql.py

.NET 中调用 Python 脚本的辅助类:

csharp 复制代码
using System.Diagnostics;
using System.Text;
using System.Text.Json;

/// <summary>
/// Python脚本调用辅助类
/// </summary>
public class PythonScriptExecutor
{
    private readonly string _pythonPath;
    private readonly string _scriptPath;
    private readonly int _timeoutMilliseconds;

    public PythonScriptExecutor(string pythonPath = "python3", string scriptPath = "./mysql-employees-analyst/scripts/execute_sql.py", int timeoutSeconds = 30)
    {
        _pythonPath = pythonPath;
        _scriptPath = Path.GetFullPath(scriptPath);
        _timeoutMilliseconds = timeoutSeconds * 1000;
        
        // 验证Python脚本是否存在
        if (!File.Exists(_scriptPath))
        {
            throw new FileNotFoundException($"Python脚本不存在: {_scriptPath}");
        }
    }

    /// <summary>
    /// 执行SQL查询(同步版本)
    /// </summary>
    public async Task<SqlExecutionResult> ExecuteSqlAsync(string sqlQuery, object? parameters = null, int? limit = null)
    {
        var request = new
        {
            sql_query = sqlQuery, // 这里必须显式赋值
            @params = parameters, // 使用@转义避免关键字冲突
            limit = limit ?? 1000
        };

        string jsonInput = JsonSerializer.Serialize(request, new JsonSerializerOptions 
        { 
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase 
        });

        return await ExecutePythonScriptAsync(jsonInput);
    }

    /// <summary>
    /// 执行Python脚本
    /// </summary>
    private async Task<SqlExecutionResult> ExecutePythonScriptAsync(string jsonInput)
    {
        try
        {
            using var process = new Process
            {
                StartInfo = new ProcessStartInfo
                {
                    FileName = _pythonPath,
                    Arguments = $"\"{_scriptPath}\"",
                    RedirectStandardInput = true,
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    StandardInputEncoding = Encoding.UTF8,
                    StandardOutputEncoding = Encoding.UTF8,
                    StandardErrorEncoding = Encoding.UTF8
                }
            };

            var outputBuilder = new StringBuilder();
            var errorBuilder = new StringBuilder();

            // 设置输出数据接收事件
            process.OutputDataReceived += (sender, e) =>
            {
                if (e.Data != null)
                {
                    outputBuilder.AppendLine(e.Data);
                }
            };

            process.ErrorDataReceived += (sender, e) =>
            {
                if (e.Data != null)
                {
                    errorBuilder.AppendLine(e.Data);
                }
            };

            // 启动进程
            process.Start();
            
            // 开始异步读取输出
            process.BeginOutputReadLine();
            process.BeginErrorReadLine();

            // 写入输入数据
            await process.StandardInput.WriteLineAsync(jsonInput);
            await process.StandardInput.FlushAsync();
            process.StandardInput.Close();

            // 等待进程完成(带超时)
            using var cts = new CancellationTokenSource(_timeoutMilliseconds);
            await process.WaitForExitAsync(cts.Token);

            // 检查退出码
            if (process.ExitCode != 0)
            {
                string error = errorBuilder.Length > 0 ? errorBuilder.ToString() : $"进程退出码: {process.ExitCode}";
                return new SqlExecutionResult
                {
                    Success = false,
                    Error = $"Python脚本执行失败: {error}"
                };
            }

            // 解析输出
            string output = outputBuilder.ToString();
            if (string.IsNullOrWhiteSpace(output))
            {
                return new SqlExecutionResult
                {
                    Success = false,
                    Error = "Python脚本未返回任何输出"
                };
            }

            // 尝试解析JSON结果
            try
            {
                var result = JsonSerializer.Deserialize<SqlExecutionResult>(output);
                if (result != null)
                {
                    return result;
                }
                else
                {
                    return new SqlExecutionResult
                    {
                        Success = false,
                        Error = "Python脚本返回的JSON格式无效"
                    };
                }
            }
            catch (JsonException ex)
            {
                return new SqlExecutionResult
                {
                    Success = false,
                    Error = $"解析Python脚本返回的JSON失败: {ex.Message}\n原始输出: {output}"
                };
            }
        }
        catch (OperationCanceledException)
        {
            return new SqlExecutionResult
            {
                Success = false,
                Error = $"Python脚本执行超时({_timeoutMilliseconds/1000}秒)"
            };
        }
        catch (Exception ex)
        {
            return new SqlExecutionResult
            {
                Success = false,
                Error = $"执行Python脚本时发生异常: {ex.Message}"
            };
        }
    }
}

/// <summary>
/// SQL执行结果
/// </summary>
public class SqlExecutionResult
{
    [JsonPropertyName("success")]
    public bool Success { get; set; }
    [JsonPropertyName("error")]
    public string? Error { get; set; }
    [JsonPropertyName("data")]
    public List<Dictionary<string, object>>? Data { get; set; }
    [JsonPropertyName("row_count")]
    public int? RowCount { get; set; }
    [JsonPropertyName("columns")]
    public List<string>? Columns { get; set; }
    [JsonPropertyName("sql")]
    public string? Sql { get; set; }
}

在Agent中使用Python脚本执行器:

csharp 复制代码
using OpenAI;
using OpenAI.Chat;
using System.Text.Json;

public class SqlAnalysisAgent
{
    private readonly OpenAIClient _client;
    private readonly ChatClient _chatClient;
    private readonly PythonScriptExecutor _pythonExecutor;
    private readonly string _skillInstruction;

    public SqlAnalysisAgent(string apiKey, string baseUrl, string pythonPath = "python3")
    {   
        _client = new(
            new ApiKeyCredential(apiKey),
            new OpenAIClientOptions { Endpoint = new Uri(baseUrl) }
        );

        _chatClient = _client.GetChatClient("Qwen/Qwen3.5-397B-A17B");
        _pythonExecutor = new PythonScriptExecutor(pythonPath);
        
        // 加载SKILL.md指令
        _skillInstruction = File.ReadAllText("./mysql-employees-analyst/SKILL.md");
        _skillInstruction = _skillInstruction.Replace("{current_date}", DateTime.Now.ToString("yyyy-MM-dd"));
    }

    /// <summary>
    /// 处理用户查询的主循环
    /// </summary>
    public async Task ProcessUserQueryAsync(string userQuery)
    {
        var messages = new List<ChatMessage>
        {
            new SystemChatMessage(_skillInstruction),
            new UserChatMessage(userQuery)
        };

        // 定义工具 - 这里的工具将调用Python脚本
        var executeSqlTool = ChatTool.CreateFunctionTool(
            functionName: "execute_sql_python",
            functionDescription: "使用Python脚本在MySQL employees数据库上执行SELECT语句,并返回JSON格式的结果。",
            functionParameters: BinaryData.FromObjectAsJson(
                new
                {
                    type = "object",
                    properties = new
                    {
                        sql_query = new
                        {
                            type = "string",
                            description = "要执行的SQL SELECT语句"
                        },
                        limit = new
                        {
                            type = "integer",
                            description = "最大返回行数(可选,默认1000)",
                            minimum = 1,
                            maximum = 5000
                        }
                    },
                    required = new[] { "sql_query" }
                },
                new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
            )
        );

        var chatOptions = new ChatCompletionOptions
        {
            Tools = { executeSqlTool },
            ToolChoice = ChatToolChoice.CreateAutoChoice() // 让模型决定是否调用工具
        };

        bool requiresAction = true;
        int maxTurns = 10;

        while (requiresAction && maxTurns-- > 0)
        {
            requiresAction = false;
            ChatCompletion completion = await _chatClient.CompleteChatAsync(messages, chatOptions);

            switch (completion.FinishReason)
            {
                case ChatFinishReason.Stop:
                    // AI给出最终回答
                    Console.WriteLine($"\n[AI]: {completion.Content[0].Text}");
                    messages.Add(new AssistantChatMessage(completion));
                    break;

                case ChatFinishReason.ToolCalls:
                    // AI请求调用工具
                    messages.Add(new AssistantChatMessage(completion));
                    
                    foreach (var toolCall in completion.ToolCalls)
                    {
                        if (toolCall.FunctionName == "execute_sql_python")
                        {
                            Console.WriteLine($"\n[Agent] 🔧 调用Python脚本执行SQL...");
                            
                            // 解析参数
                            using JsonDocument args = JsonDocument.Parse(toolCall.FunctionArguments);
                            string sql = args.RootElement.GetProperty("sql_query").GetString();
                            int limit = args.RootElement.TryGetProperty("limit", out var limitElement) 
                                ? limitElement.GetInt32() 
                                : 1000;

                            Console.WriteLine($"[Agent] SQL: {sql}");
                            Console.WriteLine($"[Agent] Limit: {limit}");

                            // 调用Python脚本执行SQL
                            var result = await _pythonExecutor.ExecuteSqlAsync(sql, null, limit);

                            // 格式化结果输出(调试用)
                            if (result.Success)
                            {
                                Console.WriteLine($"[Agent] ✅ 执行成功,返回 {result.RowCount} 行数据");
                                if (result.RowCount > 0)
                                {
                                    Console.WriteLine($"[Agent] 列: {string.Join(", ", result.Columns ?? new List<string>())}");
                                }
                            }
                            else
                            {
                                Console.WriteLine($"[Agent] ❌ 执行失败: {result.Error}");
                            }

                            // 将结果返回给模型
                            messages.Add(new ToolChatMessage(
                                toolCall.Id, 
                                JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = false })
                            ));
                            
                            requiresAction = true;
                        }
                    }
                    break;

                case ChatFinishReason.Length:
                    Console.WriteLine("[Agent] ⚠️ 达到最大token限制");
                    requiresAction = false;
                    break;

                default:
                    Console.WriteLine($"[Agent] 结束,原因: {completion.FinishReason}");
                    requiresAction = false;
                    break;
            }
        }

        if (maxTurns <= 0)
        {
            Console.WriteLine("[Agent] ⚠️ 达到最大对话轮次限制");
        }
    }
}

完整的 Program.cs 入口:

csharp 复制代码
// 配置编码以支持中文
Console.OutputEncoding = Encoding.UTF8;
Console.InputEncoding = Encoding.UTF8;

try
{
    // 从环境变量读取配置
    string? openAiApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
    string? baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");

    if (string.IsNullOrEmpty(openAiApiKey))
    {
        Console.WriteLine("错误: 请设置 OPENAI_API_KEY 环境变量");
        return;
    }

    if (string.IsNullOrEmpty(baseUrl))
    {
        Console.WriteLine("错误: 请设置 OPENAI_BASE_URL 环境变量");
        return;
    }

    // 初始化Agent
    var agent = new SqlAnalysisAgent(openAiApiKey, baseUrl, pythonPath: "python3");

    Console.WriteLine("=" + new string('=', 60));
    Console.WriteLine("  SQL分析Agent已启动 - 使用Python脚本执行跨语言查询");
    Console.WriteLine("=" + new string('=', 60));
    Console.WriteLine("可尝试的问题示例:");
    Console.WriteLine("  - 统计公司员工总数");
    Console.WriteLine("  - 分析各部门的平均薪资");
    Console.WriteLine("  - 找出薪资最高的前10名员工");
    Console.WriteLine("  - 市场部和销售部各有多少员工?");
    Console.WriteLine("  - 公司里各种职位的分布情况");
    Console.WriteLine("-" + new string('-', 60));
    Console.WriteLine("输入 'exit' 退出程序\n");

    while (true)
    {
        Console.Write("\n👤 请输入问题: ");
        string? userQuery = Console.ReadLine();
        
        if (string.IsNullOrWhiteSpace(userQuery) || userQuery.Equals("exit", StringComparison.OrdinalIgnoreCase))
        {
            break;
        }

        try
        {
            await agent.ProcessUserQueryAsync(userQuery);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"\n[错误] 处理问题时发生异常: {ex.Message}");
        }

        Console.WriteLine("\n" + new string('-', 60));
    }

    Console.WriteLine("\n程序已退出。");
}
catch (Exception ex)
{
    Console.WriteLine($"\n[致命错误] {ex.Message}");
    Console.WriteLine(ex.StackTrace);
}

3.5、实战效果演示

bash 复制代码
export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.siliconflow.cn/v1/"
# export OPENAI_BASE_URL="https://openrouter.ai/api/v1"

当我们问"统计公司员工总数"时,Agent会按照SKILL.md的指引,自主进行多步推理和查询:

  1. 第一轮工具调用 :生成SQL查询 SELECT COUNT(*) AS total_employees FROM employees
  2. 收到结果 :得到部门内员工总数 total_employees
  3. 下一轮工具调用:在上一轮工具调用时已经获取到了必要的数据信息,因此无需再次调用工具。而在实际应用场景中,可能需要调用多次工具才能获取到全部信息。
  4. 最终分析:结合所有数据,Agent给出结论:"当前数据库中共有 10 名员工"

这个案例展示了Skill如何将业务逻辑("员工人数"的分析维度)和多步操作流程,通过"指令+工具"的方式,封装成一个AI可自主执行的标准化能力。

第四章:Agent Skill 编写指南------打造高质量"肌肉记忆"

详情请参考官方文档介绍: https://agentskills.io/specification

编写一个好的Skill,就像编写一份优秀的代码文档加上清晰的SOP。以下是一些核心指南:

4.1 元数据:精确的"自我介绍"

  • name: 必须与文件夹名一致,使用小写字母和连字符 (kebab-case) 。
  • description : 这是最关键的部分。Agent通过它来匹配任务。公式:动词 + 名词 + 场景关键词 。例如:"分析MySQL employees数据库 中的员工、薪资和部门数据"。确保包含足够的业务术语,让Agent能准确联想 。

4.2 指令主体:清晰、结构化、可操作

  • 明确职责与边界:在开头就定义好"你的职责是..."、"你绝对不能做..."。这能有效防止AI"幻觉"和越权操作 。
  • 步骤化流程:使用有序列表或小标题,将复杂任务拆解为清晰的"规划-执行-观察"循环步骤。例如:第一步理解意图,第二步生成代码/工具调用,第三步分析结果 。
  • 提供上下文示例 :包括数据库 Schema、API 文档、常见错误处理案例。但要注意,过长的示例应放在references/目录中按需加载 。
  • 定义输出格式:明确告诉AI最终给用户的答案应该是什么样子,是Markdown表格、JSON还是纯文本报告 。

4.3 资源与脚本:各司其职

  • SKILL.md:负责"思考"和"规划"。
  • scripts/:负责"执行"。将那些需要确定性、高精度、重复性的操作(如计算、格式转换、调用遗留系统API)写成脚本。脚本应该设计为接受标准输入,返回标准输出,便于AI调用 。
  • references/:负责"知识库"。存放冗长的API文档、PDF表单填充指南、设计规范PDF等。只有当SKILL.md中明确指引时,AI才会去翻阅它们 。

4.4 渐进式披露的最佳实践

  • 保持SKILL.md精炼:建议控制在500行以内,约5000 tokens 。如果过长,考虑拆分为多个子Skill,或在正文中仅包含核心流程,细节放在references中。
  • 使用相对路径引用 :在SKILL.md中,使用[参考表单指南](references/forms.md)这样的链接,AI可以理解并主动加载 。

4.5 官方Skill示例

官方提供了开箱即用的skills仓库,我们可以直接拿来用,或者持续优化迭代:

bash 复制代码
git clone https://github.com/anthropics/skills

第五章:深度解析 OpenClaw------下一代 Agent 网关与 Skill 运行时

相关文章:零成本养虾指南:OpenClaw从入门到卸载

理论落地,我们来看一个目前GitHub上火热的项目------OpenClaw 。它不仅仅是一个聊天机器人,而是一个自托管的个人AI助手网关,是Agent Skills理念的集大成者 。

5.1 OpenClaw 是什么?

OpenClaw 可以被理解为"AI的操作系统"。它提供了一个统一的基础设施,让AI Agent能够连接各种通道(Telegram、微信、飞书)、调用各种技能(本地文件管理、浏览器自动化、API调用),并拥有持久化的记忆 。

5.2 OpenClaw 的核心架构原理

  1. 网关架构 :OpenClaw的核心是运行在127.0.0.1:18789的网关(Gateway),作为WebSocket控制平面,协调所有组件通信。这种设计使得Agent运行时(Pi Agent Runtime)与通讯通道(Channel)解耦,实现了高度的可扩展性 。
  2. 三级记忆系统 :这是OpenClaw最亮眼的创新之一 。
    • 短期记忆memory/目录下的每日日志(Daily Log),以append-only方式记录当天发生的事。新会话启动时自动加载当天和昨天的日志,提供连续感。
    • 近端记忆sessions/目录存档的完整会话记录。
    • 长期记忆MEMORY.md文件,存储经过筛选的、跨会话的持久知识(如用户偏好、重要决策)。
    • 检索机制 :使用本地SQLite存储文件元数据和文本块,结合sqlite-vec(向量搜索)和FTS5(全文搜索)实现BM25 + 向量的混合检索,兼顾精准匹配和语义理解。若向量库不可用,还可优雅降级为内存暴力计算 。
  3. Skills 系统:OpenClaw 原生支持 Skills。你可以在市场上安装各种"技能",例如"浏览器自动登录"、"飞书消息监听"、"PDF发票信息提取"等。这些Skills正是本章所述的Agent Skills标准实现 。
  4. 自举与多模型支持:OpenClaw支持配置十余家模型提供商(OpenAI, Anthropic, 国产模型等),并可在不同模型间灵活切换,甚至可以实现"用这个模型规划,用那个模型写诗"的自举配置 。

5.3 如何手写一个"迷你版 OpenClaw" (MiniClaw)

理解了OpenClaw的原理,我们可以在.NET中手写一个极度简化的核心框架,聚焦于消息路由Skill调用

核心设计目标:接收用户消息 -> 识别意图 -> 调用对应的.NET方法作为"技能"。

csharp 复制代码
using System.Reflection;
using OpenAI;
using OpenAI.Chat;

public interface IAgentSkill
{
    string Name { get; }
    string Description { get; }
    Task<string> ExecuteAsync(params string[] parameters);
}

// 示例技能1: 获取天气
public class WeatherSkill : IAgentSkill
{
    public string Name => "get_weather";
    public string Description => "获取指定城市的天气";

    public async Task<string> ExecuteAsync(params string[] parameters)
    {
        string city = parameters.FirstOrDefault() ?? "北京";
        // 这里实际可以调用天气API,为简化直接返回模拟数据
        return $"{city} 的天气是晴天,气温 25°C。";
    }
}

// 示例技能2: 执行计算
public class CalculatorSkill : IAgentSkill
{
    public string Name => "calculate";
    public string Description => "执行数学计算,例如 'calculate 1+2'";

    public Task<string> ExecuteAsync(params string[] parameters)
    {
        try
        {
            var expression = string.Join("", parameters);
            // 警告:在生产环境中,千万不要用这种方式执行任意表达式!这里有巨大安全风险!
            // 仅用于概念演示。应使用安全的表达式解析库如 NCalc。
            var result = new System.Data.DataTable().Compute(expression, null);
            return Task.FromResult($"计算结果: {expression} = {result}");
        }
        catch (Exception ex)
        {
            return Task.FromResult($"计算错误: {ex.Message}");
        }
    }
}

// 简易的"大脑":意图识别与技能调度
public class MiniClawBrain
{
    private readonly Dictionary<string, IAgentSkill> _skills = new();
    private readonly OpenAIClient _openAIClient;
    private readonly OpenAI.Chat.ChatClient _chatClient;

    public MiniClawBrain(string openAiApiKey)
    {
        _openAIClient = new OpenAIClient(openAiApiKey);
        _chatClient = _openAIClient.GetChatClient("gpt-4o-mini");
        RegisterSkills(); // 启动时注册所有技能
    }

    private void RegisterSkills()
    {
        // 通过反射自动注册实现了 IAgentSkill 的类
        var skillTypes = Assembly.GetExecutingAssembly().GetTypes()
            .Where(t => typeof(IAgentSkill).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

        foreach (var type in skillTypes)
        {
            var skill = (IAgentSkill)Activator.CreateInstance(type);
            if (skill != null)
            {
                _skills[skill.Name] = skill;
                Console.WriteLine($"[MiniClaw] 已加载技能: {skill.Name} - {skill.Description}");
            }
        }
    }

    public async Task<string> ProcessMessageAsync(string userMessage)
    {
        // 1. 构建系统提示词,告诉AI有哪些技能可用(相当于加载元数据)
        var skillDescriptions = string.Join("\n", _skills.Select(s => 
            $"- {s.Value.Name}: {s.Value.Description} 调用格式: @技能名(参数)"));
        
        var systemPrompt = 
            $"你是一个智能助手内核。你可以通过调用特定技能来完成任务。可用技能列表:\n{skillDescriptions}\n" +
            "当用户请求需要调用技能时,请严格遵循调用格式输出。如果不需要,则正常对话。";

        var messages = new List<ChatMessage>
        {
            new SystemChatMessage(systemPrompt),
            new UserChatMessage(userMessage)
        };

        // 2. 调用LLM判断意图(简化:这里假设LLM直接返回了调用指令,如 "@get_weather(上海)")
        // 真实场景需要更复杂的解析和处理函数调用类似第三章。
        ChatCompletion completion = await _chatClient.CompleteChatAsync(messages);
        string response = completion.Content[0].Text;

        // 3. 解析LLM输出,看是否包含技能调用指令
        if (response.StartsWith("@"))
        {
            // 极简解析:@技能名(参数)
            var firstParen = response.IndexOf('(');
            var lastParen = response.LastIndexOf(')');
            if (firstParen > 1 && lastParen > firstParen)
            {
                string skillName = response.Substring(1, firstParen - 1);
                string parameters = response.Substring(firstParen + 1, lastParen - firstParen - 1);
                
                if (_skills.TryGetValue(skillName, out var skill))
                {
                    Console.WriteLine($"[MiniClaw] 命中技能: {skillName}");
                    // 4. 执行技能
                    var skillResult = await skill.ExecuteAsync(parameters.Split(','));
                    return skillResult; // 直接返回技能执行结果
                }
            }
        }

        // 没有技能调用,返回LLM的原始回复
        return response;
    }
}

// 使用示例
class Program
{
    static async Task Main(string[] args)
    {
        var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
        var brain = new MiniClawBrain(apiKey);
        
        Console.WriteLine("MiniClaw 已启动,请输入您的消息:");
        while (true)
        {
            Console.Write("> ");
            var input = Console.ReadLine();
            if (string.IsNullOrEmpty(input)) break;
            
            var result = await brain.ProcessMessageAsync(input);
            Console.WriteLine($"Agent: {result}\n");
        }
    }
}

这个MiniClawBrain虽然简陋,但它包含了OpenClaw最核心的两个思想:

  • 技能注册与发现 :通过反射自动加载所有实现了IAgentSkill的类,相当于加载了Skills元数据。
  • 意图识别与调度:利用LLM判断应该调用哪个技能,并执行对应的.NET代码。

基于这个框架,我们可以初步搭建一个智能助理:

csharp 复制代码
using System.ClientModel;
using System.Reflection;
using System.Text;
using System.Text.Json;
using OpenAI;
using OpenAI.Chat;

// 配置编码以支持中文
Console.OutputEncoding = Encoding.UTF8;
Console.InputEncoding = Encoding.UTF8;

// 从环境变量读取配置
string? openAiApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
string? baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL");

if (string.IsNullOrEmpty(openAiApiKey))
{
    Console.WriteLine("错误: 请设置 OPENAI_API_KEY 环境变量");
    return;
}

if (string.IsNullOrEmpty(baseUrl))
{
    Console.WriteLine("错误: 请设置 OPENAI_BASE_URL 环境变量");
    return;
}

// 初始化 MiniClaw 核心引擎
var brain = new MiniClawBrain(openAiApiKey, baseUrl);

Console.WriteLine("MiniClaw 已启动,请输入您的消息:");
while (true)
{
    Console.Write("> ");
    var input = Console.ReadLine();
    if (string.IsNullOrEmpty(input)) break;
    
    var result = await brain.ProcessMessageAsync(input);
    Console.WriteLine($"Agent: {result}\n");
}

// Agent Skill 接口
public interface IAgentSkill
{
    string Name { get; }
    string Description { get; }
    string Parameters { get; } // 参数说明
    Task<string> ExecuteAsync(params string[] parameters);
}

// 示例技能1: 获取天气
public class WeatherSkill : IAgentSkill
{
    public string Name => "get_weather";
    public string Description => "获取指定城市的天气";
    public string Parameters => "city: 城市名称";

    public async Task<string> ExecuteAsync(params string[] parameters)
    {
        string city = parameters.FirstOrDefault() ?? "北京";
        // 模拟API调用延迟
        await Task.Delay(500);
        
        // 模拟天气数据
        var weathers = new[] { "晴天", "多云", "小雨", "大雨", "阴天" };
        var random = new Random();
        var weather = weathers[random.Next(weathers.Length)];
        var temperature = random.Next(15, 35);
        
        return $"{city} 的天气是{weather},气温 {temperature}°C。";
    }
}

// 示例技能2: 执行计算
public class CalculatorSkill : IAgentSkill
{
    public string Name => "calculate";
    public string Description => "执行数学计算";
    public string Parameters => "expression: 数学表达式,例如 1+2*3";

    public Task<string> ExecuteAsync(params string[] parameters)
    {
        try
        {
            var expression = string.Join("", parameters);
            // 警告:在生产环境中,千万不要用这种方式执行任意表达式!这里有巨大安全风险!
            // 仅用于概念演示。应使用安全的表达式解析库如 NCalc。
            var result = new System.Data.DataTable().Compute(expression, null);
            return Task.FromResult($"计算结果: {expression} = {result}");
        }
        catch (Exception ex)
        {
            return Task.FromResult($"计算错误: {ex.Message}");
        }
    }
}

// 示例技能3: 获取时间
public class TimeSkill : IAgentSkill
{
    public string Name => "get_time";
    public string Description => "获取指定城市的当前时间";
    public string Parameters => "city: 城市名称 (可选)";

    public Task<string> ExecuteAsync(params string[] parameters)
    {
        string city = parameters.FirstOrDefault() ?? "本地";
        var now = DateTime.Now;
        return Task.FromResult($"{city} 的当前时间是 {now:yyyy-MM-dd HH:mm:ss}");
    }
}

// 技能调用结果封装
public class SkillCallResult
{
    public string SkillName { get; set; }
    public string[] Parameters { get; set; }
    public string Result { get; set; }
    public bool Success { get; set; }
}

// 对话历史记录
public class ConversationMessage
{
    public string Role { get; set; } // "user", "assistant", "system", "tool"
    public string Content { get; set; }
    public string? ToolCallId { get; set; }
    public string? ToolName { get; set; }
}

// 简易的"大脑":意图识别与技能调度
public class MiniClawBrain
{
    private readonly Dictionary<string, IAgentSkill> _skills = new();
    private readonly OpenAIClient _openAIClient;
    private readonly ChatClient _chatClient;
    private readonly List<ConversationMessage> _conversationHistory;
    private const int MaxHistoryMessages = 20; // 最大历史消息数

    public MiniClawBrain(string apiKey, string baseUrl)
    {
        _openAIClient = new(
            new ApiKeyCredential(apiKey),
            new OpenAIClientOptions { Endpoint = new Uri(baseUrl) }
        );

        _chatClient = _openAIClient.GetChatClient("Pro/MiniMaxAI/MiniMax-M2.5");
        _conversationHistory = new List<ConversationMessage>();
        RegisterSkills();
        
        // 添加系统提示到历史
        var systemPrompt = BuildSystemPrompt();
        _conversationHistory.Add(new ConversationMessage 
        { 
            Role = "system", 
            Content = systemPrompt 
        });
    }

    private string BuildSystemPrompt()
    {
        var skillDescriptions = string.Join("\n", _skills.Select(s => 
            $"- {s.Value.Name}: {s.Value.Description}\n  参数: {s.Value.Parameters}"));
        
        return $"你是一个智能助手内核,可以通过调用特定技能来完成任务。\n\n可用技能列表:\n{skillDescriptions}\n\n" +
               "当用户请求需要调用技能时,请严格按照以下JSON格式输出:\n" +
               "{\"tool_call\": {\"name\": \"技能名称\", \"parameters\": [\"参数1\", \"参数2\"]}}\n\n" +
               "如果不需要调用技能,则正常对话回复用户。";
    }

    private void RegisterSkills()
    {
        // 通过反射自动注册实现了 IAgentSkill 的类
        var skillTypes = Assembly.GetExecutingAssembly().GetTypes()
            .Where(t => typeof(IAgentSkill).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

        foreach (var type in skillTypes)
        {
            var skill = (IAgentSkill)Activator.CreateInstance(type);
            if (skill != null)
            {
                _skills[skill.Name] = skill;
                Console.WriteLine($"[MiniClaw] 已加载技能: {skill.Name} - {skill.Description}");
            }
        }
    }

    private void AddToHistory(string role, string content, string? toolCallId = null, string? toolName = null)
    {
        _conversationHistory.Add(new ConversationMessage
        {
            Role = role,
            Content = content,
            ToolCallId = toolCallId,
            ToolName = toolName
        });

        // 限制历史消息数量
        if (_conversationHistory.Count > MaxHistoryMessages)
        {
            // 保留系统提示和最近的 N-1 条消息
            var systemMessage = _conversationHistory.First(m => m.Role == "system");
            _conversationHistory.RemoveAll(m => m.Role != "system");
            _conversationHistory.Insert(0, systemMessage);
            
            // 保留最近的 MaxHistoryMessages-1 条非系统消息
            var recentMessages = _conversationHistory.Skip(1)
                .TakeLast(MaxHistoryMessages - 1)
                .ToList();
            _conversationHistory.Clear();
            _conversationHistory.Add(systemMessage);
            _conversationHistory.AddRange(recentMessages);
        }
    }

    private List<ChatMessage> BuildChatMessages()
    {
        var messages = new List<ChatMessage>();
        
        foreach (var msg in _conversationHistory)
        {
            switch (msg.Role)
            {
                case "system":
                    messages.Add(new SystemChatMessage(msg.Content));
                    break;
                case "user":
                    messages.Add(new UserChatMessage(msg.Content));
                    break;
                case "assistant":
                    messages.Add(new AssistantChatMessage(msg.Content));
                    break;
                case "tool":
                    // 将工具调用结果作为用户消息的一部分返回
                    messages.Add(new UserChatMessage($"[工具 {msg.ToolName} 执行结果]: {msg.Content}"));
                    break;
            }
        }
        
        return messages;
    }

    private SkillCallResult? ParseToolCall(string response)
    {
        try
        {
            // 尝试解析JSON格式的工具调用
            if (response.TrimStart().StartsWith("{"))
            {
                using JsonDocument doc = JsonDocument.Parse(response);
                if (doc.RootElement.TryGetProperty("tool_call", out JsonElement toolCall))
                {
                    string name = toolCall.GetProperty("name").GetString();
                    var parameters = toolCall.GetProperty("parameters")
                        .EnumerateArray()
                        .Select(p => p.GetString())
                        .ToArray();
                    
                    return new SkillCallResult
                    {
                        SkillName = name,
                        Parameters = parameters,
                        Success = true
                    };
                }
            }
            
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[MiniClaw] 解析工具调用失败: {ex.Message}");
        }
        
        return null;
    }

    public async Task<string> ProcessMessageAsync(string userMessage)
    {
        // 1. 添加用户消息到历史
        AddToHistory("user", userMessage);

        // 2. 构建消息列表并调用LLM
        var messages = BuildChatMessages();
        ChatCompletion completion = await _chatClient.CompleteChatAsync(messages);
        string response = completion.Content[0].Text;

        // 3. 检查是否是工具调用
        var toolCall = ParseToolCall(response);
        if (toolCall != null && _skills.TryGetValue(toolCall.SkillName, out var skill))
        {
            Console.WriteLine($"[MiniClaw] 调用技能: {toolCall.SkillName}");
            
            // 4. 执行技能
            var skillResult = await skill.ExecuteAsync(toolCall.Parameters);
            
            // 5. 将工具调用和结果添加到历史
            AddToHistory("assistant", $"[调用工具 {toolCall.SkillName}]", toolName: toolCall.SkillName);
            AddToHistory("tool", skillResult, toolName: toolCall.SkillName);
            
            // 6. 将技能结果返回给LLM生成最终回复
            var finalMessages = BuildChatMessages();
            ChatCompletion finalCompletion = await _chatClient.CompleteChatAsync(finalMessages);
            string finalResponse = finalCompletion.Content[0].Text;
            
            // 7. 添加最终回复到历史
            AddToHistory("assistant", finalResponse);
            
            return finalResponse;
        }

        // 8. 没有工具调用,直接返回LLM的回复
        AddToHistory("assistant", response);
        return response;
    }
}

运行效果:

总结与展望

从Anthropic提出Agent Skills标准,到.NET开发者利用dotnet openai库实现具体案例,再到OpenClaw这样的开源网关项目引爆社区,我们清晰地看到,AI Agent的开发正在从"手工作坊"迈向"工业化大生产"。

Agent Skills的核心价值在于它将人的专业知识,以标准化的方式,无缝地注入到AI的推理过程中。它不再是让人去适应机器的逻辑,而是让机器去理解人的SOP。对于我们.NET开发者而言,这是一个巨大的机遇。我们可以利用.NET强大的类型系统、丰富的企业级库和最新的File-Based Apps特性,成为这场AI生产力革命中的"技能封装师",为企业打造真正智能、安全、可控的数字员工。

参考资料

相关推荐
jinanwuhuaguo1 小时前
OpenClaw、飞书、Claude Code、Codex:四维AI生态体系的深度解构与颗粒化对比分析
大数据·人工智能·学习·飞书·openclaw
blackicexs2 小时前
第九周第四天
人工智能·深度学习·机器学习
math_learning2 小时前
方法思路推广|EG:基于机器学习的岩石坠落危害下桥梁脆弱性量化
人工智能·机器学习
小编2 小时前
Agent 时代,App 会消失吗?
agent
Rubin智造社2 小时前
# OpenClaude命令实战|核心控制三剑客/reasoning+/verbose+/status 实操指南
大数据·人工智能
码路高手2 小时前
Trae-Agent中的Function Calling逻辑分析
人工智能·架构
菜鸟分享录2 小时前
OpenClaw 公网访问难题?一招解决 “control ui requires device identity“ 报错
ai·openclaw·小龙虾
Gale2World2 小时前
OpenClaw 技术专题 (三):行动层与现实世界交互 (The Hands)
agent
小白_史蒂夫2 小时前
【环境搭建】(九)飞桨EasyDL发布的模型转换onnx(附工程代码)
人工智能·paddlepaddle