Skills
第九章介绍了 Microsoft Agent Framework 的基本原理,而 Skill 只是在它之上多套一层,把一个领域能力的说明书 + 参考资料 + 可执行动作打包成整体交给模型,模型照着说明书走,不用自己编排。
不过开发者本机的 skill 可能几十上百个,每个完整内容都很长,全塞进系统提示会 token 爆炸、注意力稀释。所以 MAF 框架设计了一个 AgentSkillsProvider,使得 skill 不像工具那样一次性全发给模型,而是用渐进式披露(progressive disclosure)。
- 模型一开始只看到每个 skill 的名字和一句话描述;
- 判断要用时,调
load_skill工具取完整说明书; - 说明书引用的资料/脚本,再调
read_skill_resource/run_skill_script按需取用。
记住四点:
① skill 四种来源,统一抽象成 AgentSkill;
② AgentSkillsProvider 本质是个 AIContextProvider,每次 run 注入目录 + 三个工具;
③ 三个工具都是 AIFunction,包了 ApprovalRequiredAIFunction(默认要审批);
④ 加载是条装饰器流水线。
加载 skill 并跟模型对话
skill 有四种来源(文件、代码、类、MCP),但接到 agent 上的方式是统一的:把 skill 包成一个 AgentSkillsProvider(它本身是个 AIContextProvider),放进 ChatClientAgentOptions.AIContextProviders。
一般来说,文件 skill 会安装到用户的 ~/.agents/skills 目录,每个子目录就是一个 skill:

把整个目录加载进来,再塞给 Agent,就能对话了:
string skillsRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".agents", "skills");
var skillsProvider = new AgentSkillsProvider(skillsRoot);
IChatClient chatClient = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsIChatClient();
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "你是一个智能助手." },
AIContextProviders = [skillsProvider],
});
Console.WriteLine(await agent.RunAsync("列出所有技能"));
Console.WriteLine();

注意:上面加载的 skill 只有指令和资料、没有脚本,所以构造函数第二个参数(脚本运行器)省略了。一旦 skill 含脚本(
scripts/*.py之类),就必须传脚本运行器,否则模型调run_skill_script时会因没有 runner 报错。下一节演示带脚本的完整链路。
实际上首次请求时,AgentSkillsProvider 只会给大模型提供 load_skill、read_skill_resource、run_skill_script 三个工具,消耗的 token 极少,只有大模型觉得需要加载 skill 列表时,AgentSkillsProvider 才会读取这些 skill 的信息,实现动态加载。
使用 skill 并执行脚本
上一节只是让模型看" skill 目录、读读说明书。要真正执行 skill 里的脚本,需要两样东西:
① skill 带脚本(如 scripts/convert.py);
② 提供一个脚本运行器委托。
先看一个最小 skill 的目录结构(skills/unit-converter/):

skills/unit-converter/
├── SKILL.md # name: unit-converter,说明怎么用
└── scripts/
└── convert.py # 实际干活的脚本:python convert.py --value 26.2 --factor 1.60934
# scripts/convert.py , 读命令行参数,算乘法,输出 JSON
import argparse, json
parser = argparse.ArgumentParser()
parser.add_argument("--value", type=float, required=True)
parser.add_argument("--factor", type=float, required=True)
args = parser.parse_args()
print(json.dumps({"value": args.value, "factor": args.factor, "result": round(args.value * args.factor, 4)}))
MAF 不带任何脚本解释器,文件脚本的执行完全委托给你提供的 AgentFileSkillScriptRunner 委托。下面这个实现按扩展名选解释器、起子进程执行。
也就是说,如果需要执行 unit-converter 这个 skill 里面的 .py 脚本,需要我们实现一个脚本执行器。
public static class SubprocessScriptRunner
{
public static async Task<object?> RunAsync(
AgentFileSkill skill, AgentFileSkillScript script, JsonElement? arguments,
IServiceProvider? serviceProvider, CancellationToken cancellationToken)
{
// 按扩展名选解释器
string? interpreter = Path.GetExtension(script.FullPath) switch
{
".py" => "python", ".js" => "node", ".sh" => "bash", ".ps1" => "pwsh", _ => null,
};
var startInfo = new ProcessStartInfo
{
FileName = interpreter ?? script.FullPath,
RedirectStandardOutput = true, RedirectStandardError = true,
UseShellExecute = false, CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(script.FullPath)!,
};
if (interpreter is not null) startInfo.ArgumentList.Add(script.FullPath);
// 模型传来的 arguments 是 JSON 字符串数组,逐个转成命令行参数
if (arguments is { ValueKind: JsonValueKind.Array } arr)
foreach (var e in arr.EnumerateArray())
startInfo.ArgumentList.Add(e.GetString()!);
using var process = new Process { StartInfo = startInfo };
process.Start();
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
string output = await outputTask, error = await errorTask;
if (!string.IsNullOrEmpty(error)) output += $"\n[stderr]\n{error}";
if (process.ExitCode != 0) output += $"\n[退出码 {process.ExitCode}]";
return string.IsNullOrWhiteSpace(output) ? "(无输出)" : output.Trim();
}
}
有了 runner,加载时把它传给 provider,就能端到端跑通了。
这里的案例完全放通脚本执行权限,后续我们可以按需审批。
// skill 含脚本,必须传脚本运行器
var skillsProvider = new AgentSkillsProvider(
Path.Combine(AppContext.BaseDirectory, "skills"),
SubprocessScriptRunner.RunAsync);
IChatClient chatClient = new OpenAIClient(
credential: new ApiKeyCredential(key: "1234"),
options: new OpenAIClientOptions { Endpoint = new Uri("http://127.0.0.1:1234/v1") })
.GetChatClient(model: "qwen/qwen3.5-9b")
.AsIChatClient();
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "你是一个智能助手." },
AIContextProviders = [skillsProvider],
})
.AsBuilder()
.UseToolApproval(new ToolApprovalAgentOptions
{
// 全放行,让模型能直接 load_skill 和 run_skill_script
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
})
.Build();
AgentResponse response = await agent.RunAsync("一个马拉松是 26.2 英里,等于多少公里?");
foreach (var msg in response.Messages)
foreach (var c in msg.Contents)
{
if (c is FunctionCallContent fcc)
Console.WriteLine($">> 调用工具 {fcc.Name}({string.Join(", ", fcc.Arguments?.Select(kv => $"{kv.Key}={kv.Value}") ?? [])})");
else if (c is FunctionResultContent frc)
Console.WriteLine($"<< 工具结果: {frc.Result}");
}
Console.WriteLine(response.Text);
本地模型兼容:上面的 demo 直接对接 OpenAI / Azure OpenAI 时能跑通。但是笔者接入的是本地的 LM Studio 本地模型引擎,它们的模型 chat template 通常要求每条请求至少有一条 user 消息,而函数执行引擎在工具调用循环的后续轮次发出的 messages 只有
assistant(tool_call) +tool(result),没有 user,本地模型会报No user query found in messages.(HTTP 400)。官方云服务能容忍,本地引擎不能。解决办法是给
IChatClient套一个补 user 消息的装饰器(记住第一次的 user 消息,缺了就补回去)。这个装饰器和 skill 无关,是本地模型的通用兼容处理,用云服务时不需要:public sealed class EnsureUserMessageChatClient(IChatClient inner) : DelegatingChatClient(inner) { private string? _lastUserText; public override Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken ct = default) => InnerClient.GetResponseAsync(EnsureUser(messages), options, ct); private IEnumerable<ChatMessage> EnsureUser(IEnumerable<ChatMessage> messages) { var list = messages as List<ChatMessage> ?? messages.ToList(); if (list.FirstOrDefault(m => m.Role == ChatRole.User) is { Text: var t and not null and not "" } u) _lastUserText = t; if (list.Count > 0 && !list.Any(m => m.Role == ChatRole.User) && _lastUserText is not null) list.Insert(0, new ChatMessage(ChatRole.User, _lastUserText)); return list; } } // 用法:IChatClient chatClient = new EnsureUserMessageChatClient(openAiChatClient);
第一轮运行时,会注入一些系统提示词和 load_skill、read_skill_resource、run_skill_script 三个工具。可以看到,MAF 框架会自动在 System 角色内容里面注入一段提示词。并且第一轮对话时不会默认推送这些 Skills,而是由 AI 决定要不要读取 skills 列表。
下面的请求结构已被简化。
{
"messages": [
{
"role": "system",
"content": "你是一个智能助手.\nYou have access to skills containing dom... <Truncated in logs> ...sted.\nOnly load what is needed, when it is needed."
},
{
"role": "user",
"content": "一个马拉松是 26.2 英里,等于多少公里?"
}
],
"model": "qwen/qwen3.5-9b",
"tools": [
{
"type": "function",
"function": {
"description": "Loads the full content of a specific skill",
"name": "load_skill",
}
},
{
"type": "function",
"function": {
"description": "Reads a resource associated with a skill, such as references, assets, or dynamic data.",
"name": "read_skill_resource",
}
},
{
"type": "function",
"function": {
"description": "Runs a script associated with a skill.",
"name": "run_skill_script"
}
],
"tool_choice": "auto"
}
第一轮 AI 要求调用 load_skill 获取本地 skills 列表。
2026-07-24 15:31:23 [INFO]
[qwen/qwen3.5-9b] Start to generate a tool call...
2026-07-24 15:31:23 [INFO]
[qwen/qwen3.5-9b] Tool name generated: load_skill
然后 AI 模型确认本地的 Skills 后,单独加载 unit-converter。
{
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "用户想知道将26.2英里换算成公里。这是一个单位换算任务。\n\n根据可用技能,有一个 `unit-converter` 技能用于单位换算。我需要:\n1. 首先使用 `load_skill` 来获取该技能的说明和指引\n2. 然后可能使用 `read_skill_resource` 读取相关的资源\n3. 如果需要使用脚本运行转换,则使用 `run_skill_script`\n\n让我先加载这个技能来了解具体的使用说明。\n",
"tool_calls": [
{
"type": "function",
"id": "z75mwila62QwDpJCBkAz5XnmOuKE7OTa",
"function": {
"name": "load_skill",
"arguments": "{\"skillName\":\"unit-converter\"}"
}
}
]
}
}
]
}
下一轮对话中,Agent 加载该技能并把技能信息推送给 AI 模型。
{
"messages": [
{
"role": "system",
"content": "你是一个智能助手.\nYou have access to skills containing dom... <Truncated in logs> ...sted.\nOnly load what is needed, when it is needed."
},
{
"role": "user",
"content": "一个马拉松是 26.2 英里,等于多少公里?"
},
{
"role": "assistant",
"tool_calls": [
{
"id": "z75mwila62QwDpJCBkAz5XnmOuKE7OTa",
"type": "function",
"function": {
"name": "load_skill",
"arguments": "{\r\n \"skillName\": \"unit-converter\"\r\n}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "z75mwila62QwDpJCBkAz5XnmOuKE7OTa",
"content": "\"---\\nname: unit-converter\\ndescription: \\\"单位换算。当用... <Truncated in logs> ...meters_schema>\\n </script>\\n</available_scripts>\""
}
],
"model": "qwen/qwen3.5-9b",
"tools": [
{
"type": "function",
"function": {
"description": "Loads the full content of a specific skill",
"name": "load_skill",
}
},
{
"type": "function",
"function": {
"description": "Reads a resource associated with a skill, such as references, assets, or dynamic data.",
"name": "read_skill_resource",
}
},
{
"type": "function",
"function": {
"description": "Runs a script associated with a skill.",
"name": "run_skill_script",
}
]
}
AI 模型清楚这个 skills 结构后,开始使用 run_skill_script 调用脚本。
2026-07-24 15:31:29 [INFO]
[qwen/qwen3.5-9b] Model generated a tool call.
2026-07-24 15:31:29 [INFO]
[qwen/qwen3.5-9b] Model generated tool calls: [run_skill_script(skillName="unit-converter", scriptName="scripts/convert.py", arguments=["--value","26.2","--factor","...")]
最终 AI 模型根据脚本执行结果,返回最后的回答,该 Agent 的问题通过多轮对话后自动结束。

加载文件 skill
一个 skill 会占用一个目录,每个 skill 必须有一个 SKILL.md,可选 scripts/、references/ 子目录。
skills/unit-converter/
├── SKILL.md ← frontmatter + 正文
├── references/conversion-table.md ← 自动发现为 resource
└── scripts/convert.py ← 自动发现为 script
---
name: unit-converter
description: Convert between common units using a multiplication factor. ...
---
## Usage
1. First, review `references/conversion-table.md` to find the correct factor
2. Run the `scripts/convert.py` script with `--value <number> --factor <factor>`
AgentFileSkillsSource 扫描磁盘有几条硬规则:
- 深度上限 2 层(
MaxSkillDirectorySearchDepth),超过不下钻。 - 遇到
SKILL.md就停,该目录是 skill 根,子目录(references/、scripts/)属于这个 skill,子目录就算还有 skill 也会被忽略。 - frontmatter 的
name必须等于目录名,skills/unit-converter/的SKILL.md必须是name: unit-converte,否则会被拒。这是路径校验的一环。 - 按扩展名白名单发现资
.md .json .yaml .yml .csv .xml .txt,脚本.py .js .sh .ps1 .cs .csx,可在AgentFileSkillsSourceOptions改。 - 路径安全:
Path.GetFullPath规范化 +StartsWith包含检查 + 符号链接检测,挡路径穿越(skill 常来自第三方,是真实攻击面)。
skill 的头部是一段 YAML 格式的内容,但是 AgentFileSkillsSource 解析 skill 的头部属性时,frontmatter 的解析并不是使用 YAML 库,是手写正则表达式解析,只支持标量、引号、块标量(|/>)、metadata: 缩进块,所以 SKILL.md 的头部别用太花哨的 YAML 特性。
另外 AgentFileSkillsSource 带 5 秒超时防 ReDoS。
脚本执行整体委托给一个 AgentFileSkillScriptRunner 委托,MAF 不带解释器,所以上一个案例中,我们定义了一个 SubprocessScriptRunner 执行脚本,其实现了该委托结构:
public delegate Task<object?> AgentFileSkillScriptRunner(
AgentFileSkill skill, AgentFileSkillScript script, JsonElement? arguments,
IServiceProvider? serviceProvider, CancellationToken cancellationToken);
加载代码 skill
skill 可以不存在实体,也就是不存在目录和文件或真实资源,我们可以在 MAF 框架中使用内存动态构造一个 skill。
var unitConverterSkill = new AgentInlineSkill(
name: "unit-converter",
description: "Convert between common units using a multiplication factor. ...",
instructions: "1. Review the conversion-table resource ...\n2. Use the convert script ...")
.AddResource("conversion-table", """# Conversion Tables ...""") // 静态值
.AddResource("conversion-policy", () => $"...{DateTime.UtcNow:O}...") // 委托,每次现算
.AddScript("convert", (double value, double factor) => // 委托脚本
JsonSerializer.Serialize(new { value, factor, result = Math.Round(value * factor, 4) }));
// 直接传 skill 实例构造 provider,再塞进 agent
var skillsProvider = new AgentSkillsProvider(unitConverterSkill);
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
AIContextProviders = [skillsProvider],
});
代码 skill 的脚本就是普通 C# 委托,在进程内执行,不像文件 skill 要起子进程,所以不用传 scriptRunner,也不用怕跨平台解释器问题。模型调 run_skill_script 时,框架直接调你的委托。
代码 skill 没有 markdown 文件,GetContentAsync 返回的是运行时合成的 XML:
<name>unit-converter</name>
<description>...</description>
<instructions>...</instructions>
<available_resources>
<resource name="conversion-table" description="..."/>
</available_resources>
<available_scripts>
<script name="convert" description="...">
<parameters_schema>{"type":"object","properties":{"value":{"type":"number"}...}}</parameters_schema>
</script>
</available_scripts>
加载类 skill
MAF 提供了一个 AgentClassSkill<T> 抽象,用于实现代码 skill,也就是通过一个类型实现一个 skill,这样我们可以通过 nuget 分发 skill,或者在代码中内置一个 skill。
internal sealed class TemperatureConverterSkill : AgentClassSkill<TemperatureConverterSkill>
{
public override AgentSkillFrontmatter Frontmatter { get; } = new("temperature-converter", "...");
protected override string Instructions => "1. Review the temperature-conversion-formulas resource ...";
[AgentSkillResource("temperature-conversion-formulas")]
[Description("Formulas for converting between ...")]
public string ConversionFormulas => "...";
[AgentSkillScript("convert-temperature")]
[Description("Converts a temperature value ...")]
private static string ConvertTemperature(double value, string from, string to) => "...";
}
// 和代码 skill 一样,实例化后传给 provider
var skillsProvider = new AgentSkillsProvider(new TemperatureConverterSkill());
MAF 框架用反射发现标了特性的成员,运行时和代码 skill 完全一样,底层都是 AgentInlineSkillResource/AgentInlineSkillScript。脚本同样是进程内执行的 C# 方法,不需要 scriptRunner。
使用 MCP 库加载 Skills
前面三种 skill(文件、代码、类)都在本地定义。第四种 MCP skill 把 skill 的分发搬到网络上,skill 的内容仍然是一份 SKILL.md,只是它存放在某个 MCP 服务器上,客户端通过 MCP 协议按需取回。
也就是通过 MCP 协议读取服务器的 skill。
一个团队或平台可以把一批 skill 集中托管在一个 MCP 服务器上,多个 agent、多个进程连上去就能用同一套 skill,不用每个项目复制一份 SKILL.md。skill 的更新只在服务器做一次,所有客户端下次加载就生效。
这样好处是,团队内不需要每个人电脑本地都拉取这些 skill,而且使用网络进行读取,避免本地电脑的 skill 版本落后、需要同步,减少出现问题的几率。
下面是官方示例的服务器端,它把一个 unit-converter skill 暴露成两个 resource:
// 服务器端:用 [McpServerResource] 把 SKILL.md 和目录暴露成 resource
[McpServerResourceType]
internal sealed class SkillResources
{
// 目录:列出所有 skill。AgentMcpSkillsSource 就是从这里发现 skill 的
[McpServerResource(UriTemplate = "skill://index.json", Name = "Skill Index", MimeType = "application/json")]
public static string GetIndex() => """
{
"skills": [
{
"name": "unit-converter",
"type": "skill-md", // 类型:按需读 SKILL.md
"description": "Convert between common units ...",
"url": "skill://unit-converter/SKILL.md" // 说明书的地址
}
]
}
""";
// 说明书:skill 的正文,和文件 skill 的 SKILL.md 完全一样的格式
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", MimeType = "text/markdown")]
public static string GetSkillMd() => """
---
name: unit-converter
description: Convert between common units using a multiplication factor. ...
---
## Usage
When the user requests a unit conversion, use these factors: ...
""";
}
客户端只要连上这个 MCP 服务器,调 UseMcpSkills,剩下的发现和加载全自动:
// 客户端:连接 MCP 服务器(这里把当前程序当成子进程启动,它跑 --server 模式)
await using McpClient client = await McpClient.CreateAsync(
new StdioClientTransport(new()
{
Command = "dotnet", Arguments = [thisAssemblyPath, "--server"],
}));
var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(client) // 读 skill://index.json,把每个 skill-md 条目变成 AgentMcpSkill
.Build();
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
AIContextProviders = [skillsProvider],
});
加载后,对模型和上层完全透明,AgentMcpSkill 和文件 skill 一样实现了 AgentSkill,模型照样看到目录、照样调 load_skill / read_skill_resource。区别只在内容从哪来:
load_skill时,AgentMcpSkill.GetContentAsync才发resources/read请求去服务器取SKILL.md(懒加载,首次取回后缓存,AgentMcpSkill.cs:63-82);read_skill_resource时,把资源名拼到 skill 根 URI 上(如skill://unit-converter/references/table.md),再resources/read(AgentMcpSkill.cs:91-113)。
index 里每个 skill 条目有个 type 字段,目前支持两种:
| type | 含义 | 怎么取内容 |
|---|---|---|
skill-md | 标准 skill,正文是一份 SKILL.md | 按需 resources/read 取 SKILL.md |
archive | 整个 skill 打包成压缩文件 | 下载并解压到本地目录(有大小/数量限制防压缩炸弹) |
其它 type 会被跳过并记日志。
审批
三个工具都包了 ApprovalRequiredAIFunction,默认全要审批(skill 正文/脚本常来自第三方)。如果不配,模型一调 load_skill 就会被拦下,agent 返回空文本。
MAF 框架有两条预置自动审批规则:
ReadOnlyToolsAutoApprovalRule // 只放行 load_skill + read_skill_resource,脚本仍审批
AllToolsAutoApprovalRule // 三个全放行
方式一:自动放行,通过 UseToolApproval 配规则,匹配的工具直接批准。前面"使用 skill 并执行脚本"那节用的就是 AllToolsAutoApprovalRule:
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
AIContextProviders = [skillsProvider],
})
.AsBuilder()
.UseToolApproval(new ToolApprovalAgentOptions
{
// 只放行读类(load_skill / read_skill_resource),run_skill_script 仍要人工批准
AutoApprovalRules = [AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule],
})
.Build();
这两条对非 skill 工具返回 false,不误伤别的 provider 的工具,可和其它规则混用。
方式二:手动响应审批,脚本这类高危操作不想自动放行,就在循环里拿到 ToolApprovalRequestContent、人工判断后回复。未放行的审批请求会出现在 response.Messages 里。:
AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync("把 26.2 英里换算成公里", session);
// 找出所有待审批的工具调用,人工决定批准与否
List<ToolApprovalRequestContent> approvals = response.Messages
.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
while (approvals.Count > 0)
{
List<ChatMessage> replies = approvals.ConvertAll(req =>
{
var toolCall = (FunctionCallContent)req.ToolCall;
Console.WriteLine($"是否批准执行 {toolCall.Name}?(Y/N)");
bool ok = Console.ReadLine()?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false;
return new ChatMessage(ChatRole.User, [req.CreateResponse(ok)]); // 把批准/拒绝结果发回去
});
response = await agent.RunAsync(replies, session); // 带着审批结果继续
approvals = response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().ToList();
}
Console.WriteLine(response.Text);
req.CreateResponse(true/false) 把你的决定包成一条 user 消息发回去,agent 据此继续执行(批准)或换路(拒绝)。配合 ReadOnlyToolsAutoApprovalRule,就实现了"读类自动、脚本人工"的常见策略。
审批机制本身在第八章(
ToolApprovalAgent、ApprovalRequiredAIFunction)已经讲过,这里只讲 skill 提供的两条预置规则。