1. 为什么 Skills 只会“读”不会“做”,Shell 工具怎么补上这一环
.NET 生态里用 Microsoft Agent Framework(MAF)做 Agent 开发的朋友,最近大概率都碰过 Agent Skills 这个能力。它的核心价值很直白:把领域知识从代码里抽出来,放进SKILL.md这样的 Markdown 文件,模型按需加载,不用把所有提示词一次性塞进系统提示。但很多人上手之后会发现一个尴尬的现实——Skills 让 Agent“知道该干什么”,却没法让它“真的去干”。
我拿一个具体场景说明。假设你写了一个系统运维技能,SKILL.md里清清楚楚写着“执行pwsh -File scripts/check-disk-usage.ps1可以检查磁盘”,模型加载完技能后完全理解这句话,可它手里没有能执行命令的工具,最后只能回你一句“建议你手动运行以下命令”。知识到位了,能力没到位,Agent 就卡在“纸上谈兵”这一步。
这就是本文要解决的问题:在 MAF 场景下,给 Agent Skills 配一个 Shell 工具,让技能里写的脚本真正跑起来。整套方案的关键词是 .NET、AI、Agent Skills、Shell、MAF,适合已经了解 Function Calling、想在本地或内网环境快速落地“能读会做”Agent 的开发者。读完你能拿到三样东西:一份可直接复制的SKILL.md技能定义、一段带安全护栏的run_shell封装代码、一套端到端验证步骤。
先说清楚设计哲学,后面所有代码都围绕它展开:工具是“能力”,技能是“知识”,决策权留给模型。一个 Shell 工具提供执行能力,SKILL.md提供“该执行什么命令”的知识,模型负责把两者串起来。你不需要为每个脚本单独写一个工具函数,也不需要run_skill_script、run_code、run_command三层套娃。一个run_shell就够了。
为什么这么设计?因为传统做法里,代码要承担“技能名 → 目录 → 脚本 → 解释器”的完整路由逻辑,每加一个脚本就得改代码。而知识驱动的方式下,SKILL.md里直接标注每个脚本的执行命令,模型加载知识后自然知道调什么,代码层只提供执行能力,新增脚本只改 Markdown,不动 C#。这个差异在后面的对比里会非常明显。
2. TaoToken 前置准备:给 MAF Agent 配一个稳定的模型入口
在写 Shell 工具之前,得先让 Agent 能连上模型。MAF 通过IChatClient抽象对接各种模型服务,你只需要一个兼容 OpenAI 协议、支持 Function Calling 的端点即可。这里我用 TaoToken 作为模型接入层,原因是它在 .NET 场景下配置简单,Base URL 和 Key 直接填进IChatClient就行,不用折腾额外的适配代码。
先拿 Key。打开 https://taotoken.net/api-keys ,登录后创建一个 API Key,复制保存。注意这个 Key 只在创建时完整显示一次,丢了就得重建。拿到之后,你的接入信息是三项:
| 配置项 | 值 |
|---|---|
| Base URL | https://taotoken.net/api |
| API Key | 你刚创建的那串 |
| Model ID | 按需选择,比如claude-sonnet-4-5或gpt-4o这类支持工具调用的模型 |
这里有个容易踩的坑:Base URL 末尾不要带/v1,也不要带斜杠。MAF 的 OpenAI 兼容客户端会自己拼接路径,你多写一段就会 404。我试过在Endpoint里手滑加了/v1,结果请求直接打到https://taotoken.net/api/v1/v1/chat/completions,报错信息还不太直观,排查了好一会儿。
如果你用的是 Claude Code 这类编码工具,或者想走 Anthropic 协议,TaoToken 也提供了对应的接入方式,文档在 https://taotoken.net/doc 。不过本文聚焦 MAF 的IChatClient路径,用 OpenAI 兼容协议最省事。
关于模型选择,Function Calling 是硬性要求。不是所有模型都稳定支持工具调用,选之前确认一下。我实测下来,带工具调用能力的模型在run_shell这种场景下表现差异挺大:有的模型会老老实实先load_skill再执行脚本,有的会跳过加载直接猜命令。所以SKILL.md里的指令要写得足够明确,后面会讲怎么写。
还有一个成本相关的点值得提前说。Skills 的内容是通过工具调用结果注入的,不是塞进系统提示。这意味着系统提示前缀保持稳定,可以命中缓存;技能详情作为对话中的tool_result出现,不会破坏系统提示的缓存前缀。即使你加载了多个技能的完整内容,系统提示的 token 成本也只算一次。这个特性在长对话里省得比较明显。
配置代码我封装在一个 helper 里,方便复用:
// AIClientHelper.cs using Microsoft.Extensions.AI; using OpenAI; public static class AIClientHelper { public static IChatClient GetDefaultChatClient(bool enableLogging = false) { var apiKey = Environment.GetEnvironmentVariable("TAOTOKEN_API_KEY") ?? throw new InvalidOperationException("请设置 TAOTOKEN_API_KEY 环境变量"); var client = new OpenAIClient( new System.ClientModel.ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = new Uri("https://taotoken.net/api") }); IChatClient chatClient = client.GetChatClient("claude-sonnet-4-5").AsIChatClient(); if (enableLogging) { chatClient = new ChatClientBuilder(chatClient) .UseLogging() .Build(); } return chatClient; } }把 Key 写进环境变量,别硬编码在源码里。Windows 上用setx TAOTOKEN_API_KEY "你的key",Linux/macOS 上写进.bashrc或.zshrc。设置完记得重开终端,否则当前会话读不到。
3. 可复制配置:SKILL.md 技能定义与 run_shell 封装
这一节是全文的核心,分两块:技能文件怎么写,Shell 工具怎么封。两块都给你可直接复制的完整代码。
3.1 目录结构与 SKILL.md
先建目录。在项目运行目录下创建skills-bash/system-ops/,里面放SKILL.md、scripts/、references/三部分。SKILL.md的 front matter 里name和description是必须的,模型靠description判断该不该加载这个技能。
--- name: system-ops description: 系统运维诊断技能。适用于系统健康检查、磁盘空间分析、进程资源监控、故障排查等系统运维场景。包含可执行的诊断脚本。 --- # 系统运维(System Operations) ## 可用诊断脚本 以下脚本位于本技能的 `scripts/` 目录,可通过 `run_shell` 工具执行: | 脚本 | 用途 | 执行命令 | |------|------|----------| | check-system-info.ps1 | 获取系统基本信息(OS、CPU、内存) | `pwsh -File "<技能目录>/scripts/check-system-info.ps1"` | | check-disk-usage.ps1 | 检查磁盘使用情况和剩余空间 | `pwsh -File "<技能目录>/scripts/check-disk-usage.ps1"` | | check-top-processes.ps1 | 查看 CPU/内存占用 Top 进程 | `pwsh -File "<技能目录>/scripts/check-top-processes.ps1"` | ## 运维检查流程 1. **基础检查**:先执行 `check-system-info.ps1` 获取系统概况 2. **针对性诊断**:根据用户问题,选择性执行磁盘或进程检查脚本 3. **分析报告**:综合脚本输出和故障排查指引,给出诊断结论和建议 4. **故障排查**:如需深入排查,参考 `references/troubleshooting-guide.md` ## 告警阈值 | 指标 | 正常 | 警告 | 严重 | |------|------|------|------| | CPU 使用率 | <70% | 70-90% | >90% | | 内存使用率 | <80% | 80-95% | >95% | | 磁盘使用率 | <70% | 70-90% | >90% | | 单进程 CPU | <30% | 30-60% | >60% |注意表格里“执行命令”那一列。对人类读者它是文档,对模型它是 API 契约。模型加载技能后,直接照着这一列的命令去调run_shell,不需要代码层做任何路由。这就是“知识驱动行为”的落点。
三个脚本我放在scripts/下,内容不复杂,核心是输出结构化文本方便模型解析:
# check-system-info.ps1 — 获取系统基本信息 Write-Host "=== 系统基本信息 ===" Write-Host "计算机名: $env:COMPUTERNAME" Write-Host "操作系统: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)" Write-Host "处理器架构: $([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)" Write-Host "逻辑处理器数: $([Environment]::ProcessorCount)" $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue if ($os) { $totalMemGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2) $freeMemGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2) $usedMemGB = [math]::Round($totalMemGB - $freeMemGB, 2) $memUsagePercent = [math]::Round(($usedMemGB / $totalMemGB) * 100, 1) Write-Host "" Write-Host "=== 内存信息 ===" Write-Host "总内存: ${totalMemGB} GB" Write-Host "已使用: ${usedMemGB} GB ($memUsagePercent%)" Write-Host "可用: ${freeMemGB} GB" }# check-disk-usage.ps1 — 检查磁盘使用情况 Write-Host "=== 磁盘使用情况 ===" Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { $totalGB = [math]::Round($_.Size / 1GB, 2) $freeGB = [math]::Round($_.FreeSpace / 1GB, 2) $usedGB = [math]::Round($totalGB - $freeGB, 2) $usagePercent = if ($totalGB -gt 0) { [math]::Round(($usedGB / $totalGB) * 100, 1) } else { 0 } $status = if ($usagePercent -gt 90) { "严重" } elseif ($usagePercent -gt 70) { "警告" } else { "正常" } Write-Host "" Write-Host "驱动器 $($_.DeviceID)" Write-Host " 总容量: ${totalGB} GB" Write-Host " 已使用: ${usedGB} GB ($usagePercent%)" Write-Host " 可用: ${freeGB} GB" Write-Host " 状态: $status" }# check-top-processes.ps1 — 查看资源占用 Top 进程 param([int]$Top = 10) Write-Host "=== CPU 占用 Top $Top 进程 ===" Get-Process | Sort-Object CPU -Descending | Select-Object -First $Top | Format-Table -Property @{N='进程名';E={$_.ProcessName}}, @{N='PID';E={$_.Id}}, @{N='CPU(s)';E={[math]::Round($_.CPU, 2)}}, @{N='内存(MB)';E={[math]::Round($_.WorkingSet64/1MB, 1)}} -AutoSize | Out-String | Write-Hostreferences/troubleshooting-guide.md放故障排查建议,模型在需要时会用read_skill_resource读取,内容按 CPU 高负载、内存不足、磁盘空间不足三类写清楚处理步骤即可。
3.2 run_shell 工具封装
工具封装遵循“最小能力 + 必要安全”原则。三条护栏:危险命令黑名单、输出截断 50KB、超时 60 秒。跨平台用原生 Shell 分发——Windows 走cmd /c,Linux/macOS 走bash -c。
这里解释一个设计细节:为什么不用pwsh -Command包一层?因为SKILL.md里的命令已经是pwsh -File "..."格式,如果run_shell再用pwsh -Command包裹,就变成pwsh → pwsh的冗余嵌套。用原生 Shell 分发,pwsh -File直接执行,零嵌套。
using System.ComponentModel; using System.Diagnostics; using System.Text; // 危险命令黑名单 string[] dangerousPatterns = [ "rm -rf /", "rm -rf /*", "sudo ", "shutdown", "reboot", "> /dev/", ":(){ :|:& };:", "mkfs.", "dd if=", "format ", "del /f /s /q", ]; [Description("执行 Shell 命令。通过操作系统原生 Shell 执行命令(Windows 用 cmd,Linux/Mac 用 bash)。包含安全护栏:危险命令阻止、输出截断(50KB)、超时控制(60秒)。")] string RunShell( [Description("要执行的 Shell 命令。例如:'pwsh -File /path/to/script.ps1' 或 'dir'")] string command, [Description("命令执行的工作目录(可选)。如果不指定,使用当前目录。")] string? workingDirectory = null) { try { // 安全护栏 1:危险命令检查 if (dangerousPatterns.Any(d => command.Contains(d, StringComparison.OrdinalIgnoreCase))) { return "安全拦截:检测到危险命令,已阻止执行。"; } var isWindows = OperatingSystem.IsWindows(); var processInfo = new ProcessStartInfo { FileName = isWindows ? "cmd" : "bash", Arguments = isWindows ? $"/c {command}" : $"-c \"{command.Replace("\"", "\\\"")}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (!string.IsNullOrWhiteSpace(workingDirectory) && Directory.Exists(workingDirectory)) { processInfo.WorkingDirectory = workingDirectory; } using var process = Process.Start(processInfo); if (process == null) { return "无法启动 Shell 进程"; } var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); // 安全护栏 3:超时控制(60秒) if (!process.WaitForExit(60_000)) { process.Kill(entireProcessTree: true); return "命令执行超时(60秒),已强制终止。"; } var result = new StringBuilder(); if (!string.IsNullOrWhiteSpace(stdout)) { result.AppendLine(stdout.Trim()); } if (!string.IsNullOrWhiteSpace(stderr)) { result.AppendLine($"stderr: {stderr.Trim()}"); } if (process.ExitCode != 0) { result.AppendLine($"退出码: {process.ExitCode}"); } var output = result.Length > 0 ? result.ToString() : "(命令执行成功,无输出)"; // 安全护栏 2:输出截断(50KB) const int maxOutputLength = 50_000; if (output.Length > maxOutputLength) { output = output[..maxOutputLength] + "\n... (输出已截断,超过 50KB 上限)"; } return output; } catch (Exception ex) { return $"执行失败: {ex.Message}"; } }3.3 组装 Agent
把 SkillsProvider 和run_shell组合起来。FileAgentSkillsProvider会自动注册load_skill和read_skill_resource两个工具,你只需要额外注册run_shell。SkillsInstructionPrompt是关键,它引导模型“先加载知识,再执行操作”。
using Microsoft.Agents.AI; using Microsoft.Extensions.AI; var skillsRootPath = Path.Combine(Directory.GetCurrentDirectory(), "skills-bash"); var skillsProvider = new FileAgentSkillsProvider( skillPath: skillsRootPath, options: new FileAgentSkillsProviderOptions { SkillsInstructionPrompt = """ 你可以使用以下技能获取领域知识和操作指引。 每个技能提供专业指令、参考文档和可执行脚本。 <available_skills> {0} </available_skills> 工作流程: 1. 当用户任务匹配技能描述时,使用 `load_skill` 加载该技能的完整指令 2. 技能指令中会标明可用脚本及其执行命令 3. 使用 `run_shell` 工具执行技能中标注的命令 4. 需要时使用 `read_skill_resource` 读取参考资料 重要原则:先加载知识,再执行操作。 """ }); var chatClient = AIClientHelper.GetDefaultChatClient(enableLogging: true); AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions { Name = "SkillsBashAgent", ChatOptions = new() { Instructions = "你是一个专业的系统运维助手。请用中文回答所有问题。", Tools = [AIFunctionFactory.Create(RunShell)], }, AIContextProviders = [skillsProvider], });到这里,Agent 手里一共三个工具:load_skill、read_skill_resource(知识层,由 SkillsProvider 自动注册)、run_shell(能力层,你手动注册)。对比那种为每个脚本单独写工具函数的方案,工具数量从 5 个降到 3 个,代码量减少一大截。
4. 验证请求:从加载技能到执行脚本的完整链路
配置写完,跑起来验证。我准备三个测试,分别覆盖正常诊断、结合参考资料排查、安全护栏。
4.1 测试一:系统健康检查
string question1 = "帮我检查一下当前系统的整体健康状态,包括 CPU、内存和磁盘使用情况。"; Console.WriteLine($"用户: {question1}"); AgentResponse response1 = await agent.RunAsync(question1); Console.WriteLine($"Agent: {response1.Text}");预期链路是这样的:模型看到系统提示里的技能摘要,识别出属于system-ops领域,调用load_skill("system-ops")拿到完整指令,从SKILL.md的表格里读到三个脚本的执行命令,然后依次调run_shell执行check-system-info.ps1、check-disk-usage.ps1,最后根据告警阈值表分析结果。
如果你开了日志,能看到工具调用序列大致是load_skill → run_shell(check-system-info) → run_shell(check-disk-usage)。模型自己决定执行哪几个脚本、按什么顺序,代码层完全没参与路由。
4.2 测试二:结合参考资料的故障排查
string question2 = "我的电脑最近变慢了,帮我查一下哪些进程占用了最多的 CPU 和内存,并给出排查建议。"; Console.WriteLine($"用户: {question2}"); AgentResponse response2 = await agent.RunAsync(question2); Console.WriteLine($"Agent: {response2.Text}");这个测试多了一步:模型执行完check-top-processes.ps1后,会调read_skill_resource读取references/troubleshooting-guide.md,把脚本输出和排查指引结合起来给建议。观察点是模型会不会主动去读参考资料——SKILL.md里写了“如需深入排查,参考 references/...”,指令够明确的话模型会照做。
4.3 测试三:安全护栏验证
var dangerousTestCases = new[] { ("rm -rf /", "删除根目录"), ("sudo apt-get install malware", "提权操作"), ("shutdown -s -t 0", "关机命令"), }; foreach (var (cmd, desc) in dangerousTestCases) { var result = RunShell(cmd); Console.WriteLine($"测试: {desc}"); Console.WriteLine($" 命令: {cmd}"); Console.WriteLine($" 结果: {result}"); } var normalResult = RunShell("Get-Date"); Console.WriteLine($"正常命令结果: {normalResult.Trim()}");预期输出:三个危险命令全部被拦截,返回“安全拦截”提示;Get-Date正常执行返回当前时间。这一步是直接调RunShell方法验证护栏逻辑,不经过模型,所以结果确定。
三个测试跑通,说明“知识层 + 能力层”的组合生效了。模型能读技能、能执行脚本、能结合参考资料给建议,安全护栏也在工作。
5. 本篇常见错排查:401、local proxy failed、reading choices 报错怎么解
配置过程中最容易卡在几个报错上,我按实际遇到的顺序列出来。
401 Unauthorized。最常见的原因是 Key 没读到或读错了。先确认环境变量设置成功:Windows 上echo %TAOTOKEN_API_KEY%,Linux/macOS 上echo $TAOTOKEN_API_KEY。如果为空,说明setx之后没重开终端。另一个原因是 Key 复制时带了空格或换行,重新创建一把干净的。还有一种情况是 Base URL 写错,比如写成了https://taotoken.net/api/v1,路径重复导致鉴权失败。记住 Base URL 就是https://taotoken.net/api,不带/v1。
local proxy failed / connection refused。这个报错通常出现在网络层,说明客户端根本没连上端点。检查三件事:Base URL 拼写、本机网络是否正常、有没有配置系统级的 HTTP 代理干扰。如果你在OpenAIClientOptions里手动设过Transport或代理,先去掉试试。MAF 默认走系统网络栈,不需要额外配置。
reading choices 报错 / 返回体解析失败。这类错误一般是模型返回的 JSON 结构不符合预期,常见诱因有两个。一是模型不支持 Function Calling,你选了个纯对话模型,它返回的内容里没有tool_calls字段,客户端解析就炸了。换一个明确支持工具调用的模型即可。二是SKILL.md的 front matter 格式有问题,比如name或description缺失、YAML 缩进错误,导致 SkillsProvider 加载技能时抛异常,间接影响请求。检查 front matter 是否严格以---开头和结尾。
OAuth / 鉴权方式不匹配。如果你之前用的是需要 OAuth 流程的服务,切到 TaoToken 的 API Key 方式后,记得把旧的鉴权代码删干净。OpenAIClient构造时用ApiKeyCredential,不要再传 token provider 之类的东西。混用会导致请求头里带错凭证。
技能加载了但模型不执行脚本。这不是报错,但很常见。原因通常是SKILL.md里的执行命令写得不够明确,模型不确定该调什么。解决办法是在表格里把命令写全,包括pwsh -File前缀和完整路径,别只写脚本文件名。另外SkillsInstructionPrompt里的工作流程要强调“先加载知识,再执行操作”,给模型明确的步骤引导。
Codex auth.json / Cline MCP 场景的配置。如果你在别的工具里也接了同一套模型,注意配置项要对齐三件套:Base URL 填https://taotoken.net/api,Key 用同一把,Model ID 选支持工具调用的。Cline 的 MCP 配置里如果引用了本地脚本,路径要用绝对路径,相对路径在不同工作目录下会失效。Codex 的auth.json里字段名按官方文档来,别自己改键名。
排查顺序建议:先确认 Key 和 Base URL,再确认模型支持工具调用,最后看SKILL.md格式。大部分问题出在前两步。
6. 继续往下走:把 Shell 能力接到你的编码工作流
跑通上面的例子后,你手里已经有一个能读会做的 Agent 了。接下来可以往两个方向扩展。
一是加技能。在skills-bash/下再建一个目录,比如code-review,写自己的SKILL.md和脚本,Agent 会自动发现并路由。新增脚本只改 Markdown,C# 代码一行不动。这就是知识驱动的扩展性。
二是把模型入口固定下来,方便长期用。如果你打算把这个 Agent 接到日常编码流程里,比如让它跑测试、查日志、做代码检查,建议把模型配置和 Key 管理独立出来。TaoToken 的接入文档在 https://taotoken.net/doc ,里面有不同协议的配置示例。需要长期跑编码任务或 Agent 工作流的话,可以看看 Coding Plan 方案,地址是 https://taotoken.net/coding-plan ,按用量规划比临时切模型稳定。想先验证模型对话效果,直接去 https://taotoken.net/chat 试几句也行。
最后留一个练习:给system-ops技能加一个check-network.ps1网络诊断脚本,只改SKILL.md和scripts/目录,验证一下 Agent 能不能自动发现新脚本并执行。这个练习做完,你就彻底理解“工具是能力、技能是知识”这套设计了。