Microsoft Agent Framework 的 Skills 机制,能让 Agent 按需加载技能包并执行 Python 脚本。原文用 meeting-notes 做演示,但照着做之前有两件事没交代:C# 宿主默认不会执行任何脚本,Agent 的模型通道也没说从哪配。这篇把两条线并到一起:先用 TaoToken 统一模型接入——打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 创建 API Key,把模型 Base URL 填成 https://taotoken.net/api;再按原文思路实现 PythonRunner,把执行 .py 的能力暴露成 execute_python 工具。配通之后,Agent 会自己完成发现技能包、读取 SKILL.md、决定调用工具、返回脚本结果这一整条链路,每一次模型推理产生的消耗都统一记在 TaoToken 账号里。
1. Skills 的定位:先搞清楚技能包解决了什么问题
1.1 技能包不是代码插件,是「指令 + 脚本 + 资源」
Agent Skills 的官方定义是:一组可移植的指令、脚本和资源,用来给 Agent 增加特定能力。听起来有点抽象,实际上拆开看就三样东西:
- 一份 SKILL.md:写给模型看的说明书,告诉它这个技能是干什么的、该调什么工具、参数怎么传;
- 若干 Python 脚本或资源文件:真正干活的载荷;
- 一个约定的目录结构:让 Agent 在启动时能自动发现这个技能存在。
这个设计的好处是:技能包可以像文件一样复制到任意 Agent 项目里,Agent 运行时通过目录扫描就能发现自己「会什么」,不需要每次把能力写死在程序里。
1.2 本文的作战目标:让 Agent 自己决定「跑哪个脚本」
原文的示例场景是会议纪要。用户输入一句「帮我获取会议纪要」,Agent 需要自己完成下面的推理链:
- 发现本地存在 meeting-notes 这个技能包;
- 加载它的 SKILL.md,阅读里面的操作说明;
- 根据说明判断:应该调用名为 execute_python 的工具;
- 传参
meeting-notes/scripts/GetMeetingNotes.py给这个工具; - 拿到 Python 脚本打印出来的文本,整理后回复用户。
问题在于:在 C# 下,第 4 步不会自动发生。官方文档白纸黑字写着 Script execution is not yet supported in C#,也就是说 Agent 在看到.py文件后不会自己去启动 Python 进程。解决办法是自己实现一个执行器,再把它注册成 Tool,让 SKILL.md 的指令最终落到这个 Tool 上。
2. skill 目录与 SKILL.md:把「技能说明书」和脚本摆到约定位置
2.1 标准目录结构
先按 Agent Framework 的约定建目录。技能包名字叫 meeting-notes,那么目录名必须也叫 meeting-notes,不能图省事改成 meetings 或者 meetingNotes,否则 Agent 扫描时匹配不上。
skills/ └── meeting-notes/ ├── SKILL.md └── scripts/ └── GetMeetingNotes.py这个结构本身不复杂,但路径一致性会直接影响后面的工具调用。因为最终传给 execute_python 的是相对路径meeting-notes/scripts/GetMeetingNotes.py,执行器要在宿主程序目录下找到 skills 子目录再做路径拼接,任一层目录名对不上都会抛文件不存在。
2.2 SKILL.md 的三个组成部分
SKILL.md 是技能包的入口,也是 Agent 唯一的「操作手册」。它由 frontmatter 和正文 Markdown 组成。frontmatter 里的 name 和 description 是用来做技能匹配的,正文部分才是真正指导模型调用工具的指令。
--- name: meeting-notes description: 与会议纪要相关的 Python 脚本集合,包含获取纪要文本的脚本 --- # meeting-notes 使用说明 本技能提供会议纪要相关脚本。脚本清单如下: - GetMeetingNotes.py:返回当前会议纪要文本 调用方式:把脚本相对路径 `meeting-notes/scripts/GetMeetingNotes.py` 作为参数,交给 execute_python 工具执行。几个关键点:
- name 必须和目录名一致,否则技能发现阶段会把文件夹和技能名当成两个东西;
- description 是模型判断「要不要用这个技能」的主要依据,太泛会导致误匹配,太窄会导致该用时不用;
- 正文里尽量写清楚「用哪个工具、传什么参数」,模型读到这句话才会把路径字符串和 execute_python 关联起来。
2.3 GetMeetingNotes.py:脚本只需要 main() 和输出
Python 脚本本身保持简单。关键约定是:定义main()函数,脚本在被python进程执行时把main()的返回值打印出来。这样执行器只需要读取标准输出,就能拿到结果。
def main(): return "Meeting notes: Chester finished the create user API, next step is to implement the update user API." if __name__ == "__main__": print(main())这段脚本写成什么样并不重要,重要的是它验证了「Agent 发现技能 → 读取说明 → 调用工具 → 执行脚本 → 输出结果」的闭环。你也可以把main()里换成任何真实的业务逻辑,比如读取某个报告文件、解析一份 CSV 再返回统计结果。
3. C# 的坑:Script execution is not yet supported,自己实现 PythonRunner
3.1 官方限制是什么意思
Microsoft Agent Framework 目前对 C# 宿主有一个明确的限制:Agent 不会自动执行 Python 脚本。也就是说,即使 SKILL.md 里写了「调用 execute_python」,如果项目里根本没有这个工具,Agent 只会返回一句「我无法执行脚本」之类的提示。
这个限制不是 bug,而是设计边界。Agent 框架只负责「推理与决策」,不内置「脚本宿主」。执行 Python 这件事需要开发者自己把能力包装成一个工具函数,再通过 AIFunctionFactory 注册给 Agent。理解了这一点,与其抱怨官方没做完,不如顺着机制补上缺失的那块拼图。
3.2 PythonRunner.cs:一个带校验的进程执行器
下面这个实现参考了原文思路,但补上了几个容易踩坑的点:路径标准化、超时控制、错误输出合并、空路径和扩展名校验。
using System; using System.Diagnostics; using System.IO; using System.Text; using System.Threading.Tasks; public static class PythonRunner { public static async Task<string> RunPythonScriptAsync(string pythonFilePath) { if (string.IsNullOrWhiteSpace(pythonFilePath)) throw new ArgumentException("FilePath 不能为空", nameof(pythonFilePath)); // 统一拼接 BaseDirectory 下的 skills 目录,避免相对路径被工作目录影响 string rawPath = Path.Combine(AppContext.BaseDirectory, "skills", pythonFilePath); string fullPath = Path.GetFullPath(rawPath); if (!File.Exists(fullPath)) throw new FileNotFoundException($"Python 文件不存在: {fullPath}", fullPath); if (!string.Equals(Path.GetExtension(fullPath), ".py", StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("文件扩展名必须是 .py", nameof(pythonFilePath)); var startInfo = new ProcessStartInfo { FileName = "python", Arguments = $"\"{fullPath}\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; using var process = new Process { StartInfo = startInfo }; try { process.Start(); string output = await process.StandardOutput.ReadToEndAsync(); string error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); if (process.ExitCode != 0 || !string.IsNullOrWhiteSpace(error)) throw new InvalidOperationException( $"Python 脚本执行失败,ExitCode={process.ExitCode},错误输出:{error.Trim()}"); return output.TrimEnd(); } catch (OperationCanceledException) { throw new TimeoutException("Python 脚本执行超过 30 秒,已终止。"); } } }3.3 三个容易被忽略的边界
第一,Path.GetFullPath不能省。Path.Combine只是拼接字符串,如果 pythonFilePath 里带了..,最终路径可能跳出 skills 目录。先转成完整路径,再校验它是否真的在预期目录内,比事后排查路径穿越要省心得多。
第二,ReadToEnd和WaitForExit的顺序。标准输出和标准错误管道如果先做同步阻塞读取,遇到脚本输出量大时会死锁。用异步读取再等待退出,顺序更稳。
第三,FileName = "python"依赖 PATH 环境变量。如果你的机器上 Python 命令是python3或某个绝对路径,需要在这个执行器里加一个配置项,而不是硬编码。
4. 把 PythonRunner 暴露成 execute_python 工具
4.1 Program.cs 里的注册片段
有了执行器,下一步就是让 Agent 能调用它。Microsoft Agent Framework 底层基于 AIFunctionFactory,把一个 C# 方法转成模型可感知的工具函数,只需要一行注册代码:
// Program.cs 片段 builder.Tools.Add(AIFunctionFactory.Create( PythonRunner.RunPythonScriptAsync, name: "execute_python"));这里name参数特别关键。模型并不知道你的 C# 方法名叫什么,它只会根据 SKILL.md 里的描述去查找工具名。SKILL.md 里写的是 execute_python,那么注册名就必须是 execute_python。如果你在这里手滑改成了run_py,Agent 在读到 SKILL.md 后会四处找 execute_python 这个名字,最终报出工具不存在的错误。
4.2 方法签名:参数名比想象中重要
AIFunctionFactory 默认使用方法参数名作为工具函数的参数名。也就是说,RunPythonScriptAsync(string pythonFilePath)暴露给模型的参数名是pythonFilePath。SKILL.md 里如果写的指令是「把脚本路径作为参数传入」,模型会自然地把路径字符串填到这个参数上。
所以参数命名要和 SKILL.md 的措辞对齐,或者干脆把 SKILL.md 里的指令写细一点,明确写出参数名。比如:
调用方式:把脚本相对路径作为参数 pythonFilePath,交给 execute_python 工具执行。这样模型的推理压力会小很多,错误传参的概率也会下降。
5. 配置模型 Key:从 TaoToken 拿一把统一钥匙
5.1 原文没交代的一环:模型通道
前面几段把 Skill 和 Tool 的代码都写完了,但还有一个前置条件被很多教程默认跳过:Agent 的「决策」本身就是一次模型推理。SKILL.md 写得再清楚、Tool 注册得再对,模型 Key 没配好,整个流程根本跑不起来。原文只教了怎么组织目录和注册工具,没有交代模型通道从哪来。
我在 TaoToken 上注册之后,创建了一把 API Key,然后把模型的 Base URL 指到 https://taotoken.net/api。这样做的直接收益是:不用再为不同模型渠道分别维护 Key,Agent 的每步推理都统一记在 TaoToken 的账号里,后续核对用量只需要回控制台看一个地方。
5.2 创建 Key 和填 Base URL
在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 完成两件事:注册账号、在控制台创建 API Key。Key 的格式是一串有一定长度的随机字符,复制后保存到本地配置,不要直接提交进 Git 仓库。
Base URL 填 https://taotoken.net/api,注意末尾不要加/v1。这是最容易出错的地方:很多模型服务端的地址习惯带版本号,但这里不需要。填错的话,Agent 启动时能正常加载,一旦发起对话就会报连接错误或 404。
5.3 Kernel 构建示例
Agent Framework 底层走 Semantic Kernel,模型连接在 Kernel 构建时指定。下面这段代码把 endpoint 指向 TaoToken,apiKey 用你刚创建的值:
using Microsoft.SemanticKernel; Kernel kernel = Kernel.CreateBuilder() .AddOpenAIChatCompletion( modelId: "YOUR_MODEL_ID", endpoint: new Uri("https://taotoken.net/api"), apiKey: "YOUR_API_KEY") .Build();YOUR_API_KEY替换成你在 TaoToken 创建的那把 Key。YOUR_MODEL_ID填什么,以 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 模型广场当时列表为准,网上流传的旧模型 ID 不一定还在列表里。
6. Skills + Tool 协作链路与运行验证
6.1 一次完整的调用链
模型 Key 配好后,整个链路的走向是固定的:
用户输入“帮我获取会议纪要” ↓ Agent 启动时扫描 skills/ 目录,发现 meeting-notes ↓ 根据 description 判断与当前任务相关,加载 SKILL.md ↓ 模型读取 SKILL.md 正文,看到“交给 execute_python 工具执行” ↓ 模型决定调用 execute_python(pythonFilePath = "meeting-notes/scripts/GetMeetingNotes.py") ↓ PythonRunner 启动 python 进程执行脚本 ↓ 脚本打印结果,Agent 把结果组织成回答返回给用户这条链路里,真正由模型做决定的时刻有两个:一是「是否加载 meeting-notes 这个技能」,二是「是否调用 execute_python」。第二个决策点尤其重要,因为它发生在模型读完 SKILL.md 之后,属于工具调用的推理步骤,消耗的 Token 会计入本次请求。
6.2 运行后的预期输出
用户输入:帮我获取会议纪要
Agent 内部行为:
- 发现技能 meeting-notes;
- 读取 SKILL.md,理解需要调用 execute_python;
- 调用
execute_python("meeting-notes/scripts/GetMeetingNotes.py"); - 拿到脚本输出:
Meeting notes: Chester finished the create user API, next step is to implement the update user API.
最终回复给用户的就是脚本打印的这段文本。如果脚本报错,PythonRunner 会抛异常并携带 stderr 的内容回传,Agent 会把这部分异常信息一并返回。
6.3 验证与对账
跑通一次后,回 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content= 控制台对一下这次调用的 Token 消耗。你可以看到 Agent 在「读取 SKILL.md ‐ 决策调用 ‐ 解析脚本输出」这几步分别消耗了多少,也能确认自己的 Key 配置是否真的生效。
如果控制台显示 0 消耗,大概率是请求没有走到 TaoToken 通道,回去检查 Kernel 构建时用的 endpoint 是不是 https://taotoken.net/api,以及有没有误加/v1。
7. 设计模式与安全红线
7.1 Skill 负责决策,Tool 负责执行
这个例子的核心模式一句话就能说清:Skill 是给模型看的指令,Tool 是给程序干的活。
Skill 本身不包含任何执行逻辑,它只是用 Markdown 写清楚「什么时候用、怎么用」。真正执行的 PythonRunner 是一个普通 C# 方法,不关心调用它的模型是谁、SKILL.md 写了什么。两者通过名字约定连接起来,Skill 发出指令,Tool 响应调用。
| 层级 | 作用 | 示例 |
|---|---|---|
| Skill | 告诉 Agent 做什么 | meeting-notes/SKILL.md |
| Tool | 真正执行动作 | execute_python 工具 |
这种解耦让技能包可以随意增删,不需要改 C# 代码;新技能只要在 SKILL.md 里声明好要调用的工具名,就能被现有 Tool 接住。
7.2 执行脚本的安全红线
脚本执行属于高风险能力。PythonRunner 一旦暴露给 Agent,就意味着模型可以通过工具在宿主机上运行任意 Python 代码,所以边界必须收紧:
- 路径限制:集中拼接
skills/目录,用Path.GetFullPath后校验,防止../路径穿越; - 扩展名白名单:只允许
.py文件,避免被执行器调用到其他类型文件; - 沙箱运行:正式环境建议把 PythonRunner 放进 Docker 容器,限制文件系统访问范围和网络权限。
提示:验证用本机调试可以临时放开,但生产环境不要把这个 Tool 接到会访问生产库或核心业务系统的 Agent 上。脚本执行能力适合在隔离环境跑数据加工和文本处理,不适合直接操作线上数据。
7.3 跑通后的下一步
链路通了以后,建议先在 TaoToken 模型对话 里用同一把 Key 发一条测试消息,确认模型 ID 和 Base URL 没填错。随后打开 Coding Plan 看看当前用量够不够支撑你持续调试;如果后续要换成其他模型,直接在 控制台 API Keys 里再建一把 Key 就行,不用动任何代码。每把 Key 的消耗明细都能在控制台里单独核对,多人协作时给每人分一把,出问题也容易排查。