MAF快速入门(21)RC5引入的Script运行能力

大家好,我是Edison。

最近我一直在跟着圣杰的《.NET+AI智能体开发进阶》课程学习MAF开发多智能体工作流,我强烈推荐你也上车跟我一起出发!

上一篇我们了解下.NET 10新推出的File-Based App模式,它和MAF一起可以形成一个强大的"王牌组合"。而就在这两天,MAF 1.0.0-rc5发布了,之前github demo中的agent skills demo也可以运行了!有了script执行能力,skill的拼图终于完整了,我也相信GA版本就快到来了!

1 RC5的新变化

在RC5之前,我们使用Skills的方式还主要是静态知识注入,一方面解决如何让Agent知道领域知识,另一方面让Agent通过渐进式披露策略降低Token消耗。而至于script能力,MAF一直为开放,而前面的内容,圣杰也给出了一个思路。但是到了RC5,官方实现来了,别激动,请接住!

Skill的定位变化

有了script的运行能力,skill 从 静态知识包 开始走向 可执行能力包,其新增的接口 run_skill_script 就是来执行script的入口。

根据之前的了解,现在AgentSkillProvider就能有三个接口来构建Skill了:

  • load_skill

  • read_skill_resource

  • run_skill_script

因此,我们对Agent Skills的定义也会更加完善:

Agent Skill = 指令 + 资源 + 脚本 共同组成的可移植能力包

MAF Skills的4层架构

从MAF对Skills的实现来看,它做了较多的抽象 和 工程化,基本可以拆分为4层,如下图所示:

    1. 对象层:定义 Skill 是什么
    1. Source 层:定义 Skill 从哪里来
    1. Decorator 层:定义 Skill 怎么过滤、去重、组合
    1. Provider 层:定义 Skill 怎么进入 Agent 运行时

更多地解析推荐大家阅读圣杰的《MAF悄悄更新到RC5,Agent Skills的脚本运行能力Ready了》,这里我就不再重复了,下面我们具体看看DEMO。

Code as Skill : 代码定义Skill

在RC5中,支持在代码中定义Skill,而不再限制于目录中的markdown文档,在Source层,它叫做 AgentInMemorySkillsSource。下面也会给出一个例子展示这个代码定义Skill的模式。

2 快速开始:单位转换器

这里我们直接来看官方团队给出的案例:单位转换器。虽然,这个案例有点脱了裤子放屁的意思,但是足够简单和覆盖知识点。

我们希望创建一个单位转换的Agent,能够通过查询skill及其相关计算规则 和 运行脚本 回复用户关于单位转换的结果。

老规矩,我们先写Skill的内容:SKILL.md,references 和 scripts。

SKILL.md

首先,我们创建这个SKILL.md,内容如下:

复制代码
---
name: unit-converter
description: 使用乘法换算系数在常见单位之间进行转换。在需要将英里/公里、磅/千克等单位互相换算时使用。
---

## 使用方法

当用户请求单位换算时:
1. 先查看 `references/conversion-table.md`,找到正确的换算系数
2. 使用 `--value <数值> --factor <系数>` 运行 `scripts/convert.py` 脚本(例如:`--value 26.2 --factor 1.60934`)
3. 输出内容需要清晰地展示换算系数、换算过程和换算结果,并同时标明换算前后的两个单位

reference: 转换公式表

其次,我们创建一个conversion-table.md,用于告诉Agent转换的系数和公式:

复制代码
# Conversion Tables(换算表)

Formula(公式): **result = value × factor(结果 = 数值 × 系数)**

> Note(说明):
> - `From` / `To` 列请保持英文(miles, kilometers, pounds, kilograms),便于在工具参数/代码中稳定引用。
> - 中文列仅用于阅读理解。

| From       | To         | Factor   | From (中文) | To (中文) |
|------------|------------|----------|------------ |----------|
| miles      | kilometers | 1.60934  | 英里        | 千米/公里 |
| kilometers | miles      | 0.621371 | 千米/公里   | 英里     |
| pounds     | kilograms  | 0.453592 | 磅          | 千克/公斤 |
| kilograms  | pounds     | 2.20462  | 千克/公斤   | 磅       |

scripts: 可执行脚本

这里我们编写了一个python脚本 convert.py 来做一个简单的运算,虽然它太简单了:

复制代码
# 单位换算脚本
# 使用乘法系数对数值进行换算:result = value × factor
#
# 用法:
#   python scripts/convert.py --value 26.2 --factor 1.60934
#   python scripts/convert.py --value 75 --factor 2.20462

import argparse
import json


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Convert a value using a multiplication factor.",
        epilog="Examples:\n"
        "  python scripts/convert.py --value 26.2 --factor 1.60934\n"
        "  python scripts/convert.py --value 75 --factor 2.20462",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--value", type=float, required=True, help="The numeric value to convert.")
    parser.add_argument("--factor", type=float, required=True, help="The conversion factor from the table.")
    args = parser.parse_args()

    result = round(args.value * args.factor, 4)
    print(json.dumps({"value": args.value, "factor": args.factor, "result": result}))


if __name__ == "__main__":
    main()

自定义脚本执行器

这里,官方定义了一个ScriptRunner,它会通过一个本地进程来执行Skill中的脚本,也就是上面的 convert.py 代码脚本。

复制代码
internal static class SubprocessScriptRunner
{
    /// <summary>
    /// Runs a skill script as a local subprocess.
    /// </summary>
    public static async Task<object?> RunAsync(
        AgentFileSkill skill,
        AgentFileSkillScript script,
        AIFunctionArguments arguments,
        CancellationToken cancellationToken)
    {
        if (!File.Exists(script.FullPath))
        {
            return $"Error: Script file not found: {script.FullPath}";
        }

        string extension = Path.GetExtension(script.FullPath);
        string? interpreter = extension switch
        {
            ".py" => "python3",
            ".js" => "node",
            ".sh" => "bash",
            ".ps1" => "pwsh",
            _ => null,
        };

        var startInfo = new ProcessStartInfo
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
        };

        if (interpreter is not null)
        {
            startInfo.FileName = interpreter;
            startInfo.ArgumentList.Add(script.FullPath);
        }
        else
        {
            startInfo.FileName = script.FullPath;
        }

        if (arguments is not null)
        {
            foreach (var (key, value) in arguments)
            {
                if (value is bool boolValue)
                {
                    if (boolValue)
                    {
                        startInfo.ArgumentList.Add(NormalizeKey(key));
                    }
                }
                else if (value is not null)
                {
                    startInfo.ArgumentList.Add(NormalizeKey(key));
                    startInfo.ArgumentList.Add(value.ToString()!);
                }
            }
        }

        Process? process = null;
        try
        {
            process = Process.Start(startInfo);
            if (process is null)
            {
                return $"Error: Failed to start process for script '{script.Name}'.";
            }

            Task<string> outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
            Task<string> errorTask = process.StandardError.ReadToEndAsync(cancellationToken);

            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);

            string output = await outputTask.ConfigureAwait(false);
            string error = await errorTask.ConfigureAwait(false);

            if (!string.IsNullOrEmpty(error))
            {
                output += $"\nStderr:\n{error}";
            }

            if (process.ExitCode != 0)
            {
                output += $"\nScript exited with code {process.ExitCode}";
            }

            return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Kill the process on cancellation to avoid leaving orphaned subprocesses.
            process?.Kill(entireProcessTree: true);
            throw;
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
        }
        finally
        {
            process?.Dispose();
        }
    }

    /// <summary>
    /// Normalizes a parameter key to a consistent --flag format.
    /// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
    /// </summary>
    private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
}

可以看到,在该Runner中定义了一些基本的校验规则,然后就通过启动一个本地进程去执行脚本。

主文件C#代码

这里,我们还是一步一步来看:

Step1. 创建SkillsProvider

复制代码
var skillsProvider = new AgentSkillsProvider(
    skillPath: Path.Combine(Directory.GetCurrentDirectory(), "skills"),
    scriptRunner: SubprocessScriptRunner.RunAsync);
Console.WriteLine("✅ AgentSkillsProvider 创建成功");
Console.WriteLine("📂 自动注册工具: load_skill, read_skill_resource, run_skill_script");
Console.WriteLine();

Step2. 创建Agent:

复制代码
AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
    Name = "UnitConverterAgent",
    ChatOptions = new()
    {
        Instructions = "你是一个专业的AI助手,负责帮助用户实现单位的转换。",
    },
    // 🔑 知识层:通过 AIContextProviders 注入 Skills
    AIContextProviders = [skillsProvider],
});
Console.WriteLine("✅ Agent 创建成功");
Console.WriteLine();

Step3. 测试Agent:

复制代码
var session = await agent.CreateSessionAsync();
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Console.WriteLine($"开始测试:基于 File-Based Skills");
// 中文问题:英里 -> 公里
var question1 = "马拉松比赛的距离26.2 英里是多少公里?";
Console.WriteLine($"👤 用户: {question1}");
Console.WriteLine();
var response1 = await agent.RunAsync(question1, session);
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Console.WriteLine($"🤖 Agent: {response1.Text}");
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Console.WriteLine();
// 英文问题:磅 -> 千克
var question2 = "How many pounds is 75 kilograms?";
Console.WriteLine($"👤 用户: {question2}");
Console.WriteLine();
var response2 = await agent.RunAsync(question2, session);
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Console.WriteLine($"🤖 Agent: {response2.Text}");
Console.WriteLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
Console.WriteLine();

测试效果如下图:

可以看到,无论是中文还是英文,它都严格按照skill中的要求,输出了换算系数、换算过程 和 最终结果。

3 快速开始:代码定义Skills

这里我们再来看看官方的第二个DEMO:通过代码定义Skills,这里就会用到 AgentInlineSkill 这个新增对象。

复制代码
var unitConverterSkill = new AgentInlineSkill(
    name: "unit-converter",
    description: "使用乘法换算系数在常见单位之间进行转换。在需要将英里/公里、磅/千克等单位互相换算时使用。",
    instructions: """
        当用户请求单位换算时,请使用本 Skill。

        1. 查看 `conversion-table` 资源,找到正确的换算系数。
        2. 查看 `conversion-policy` 资源,了解取整和格式化规则。
        3. 使用 `convert` 脚本,并传入从表中查到的数值和系数。
        """)
    // 1. 静态资源: conversion tables
    .AddResource(
        "conversion-table",
        """
        # 换算表

        公式: **结果 = 数值 × 系数**

        | From        | To          | Factor   |
        |-------------|-------------|----------|
        | miles       | kilometers  | 1.60934  |
        | kilometers  | miles       | 0.621371 |
        | pounds      | kilograms   | 0.453592 |
        | kilograms   | pounds      | 2.20462  |
        """)
    // 2. 动态资源: conversion policy (运行时计算)
    .AddResource("conversion-policy", () =>
    {
        const int Precision = 4;
        return $"""
            # 换算策略

            **小数位数:** {Precision}
            **格式:** 始终同时显示原始值、换算后值以及单位
            **生成时间:** {DateTime.UtcNow:O}
            """;
    })
    // 3. 代码脚本: convert by C# code
    .AddScript("convert", (double value, double factor) =>
    {
        double result = Math.Round(value * factor, 4);
        return JsonSerializer.Serialize(new { value, factor, result });
    });
var skillsProvider = new AgentSkillsProvider(unitConverterSkill);

可以看到,MAF释放的信号很明确:Skill不只是磁盘中的markdown文件!Code as Skill!

那么,这类型的动态skill适合哪些场景呢?

答案:它特别适合那些只存在于运行时 的信息,比如:

  • 当前租户配置;
  • 当前区域;
  • 实时配额;
  • 当前系统状态;
  • 动态生成的业务规则。

这些信息如果硬要放到磁盘中形成文件,反而不自然。

4 小结

本文介绍了RC5引入的令人激动的script执行能力,有了script执行能力,Agent Skill才变得更加完整,也 静态知识包 开始走向 可执行能力包,Agent Skill = 知识 + 资源 + 脚本

由此也可见,MAF对Skill的理解也在不断地进化,开始往软件工程层面迈进!

不过,要真的走上生产环境,还需要在应用层面考虑安全性 和 扩展性方面的内容,毕竟企业级应用还是得控制风险!

示例源码

GitHub: https://github.com/EdisonTalk/MAFD

参考资料

圣杰,《.NET + AI 智能体开发进阶》(推荐指数:★★★★★)

Microsoft Learn,《Agent Framework Tutorials


作者:爱迪生

出处:https://edisontalk.cnblogs.com

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。

相关推荐
马丁路的King3 小时前
免费获取 Claude 顶级模型使用(二)
ai·agent·openclaw·龙虾
字节逆旅10 小时前
多Agent工作流开发
agent·claude
浑水摸鱼仙君14 小时前
源码级讲述OpenClaw中的会话、记忆、上下文、系统提示词架构和原理
agent·记忆·会话·openclaw
花生Peadar14 小时前
有没有办法跨 AI 代理工具安装MCP、斜杠命令、Skill?
agent·ai编程·mcp
胡耀超14 小时前
Token的八副面孔:为什么“词元“不需要更好的翻译,而需要更多的读者
大数据·人工智能·python·agent·token·代币·词元
带娃的IT创业者14 小时前
WeClaw_42_Agent工具注册全链路:从BaseTool到意图识别的标准化接入
大数据·网络·人工智能·agent·意图识别·basetool·工具注册
ANii_Aini15 小时前
Claude Code源码架构分析(含可以启动的源码本地部署)
架构·agent·claude·claude code
DevnullCoffe16 小时前
MCP × 亚马逊数据:用Model Context Protocol构建实时选品AI工作流
agent·mcp
带刺的坐椅17 小时前
SolonCode CLI v2026.4.5 发布(编码智能体)
ai·llm·ai编程·cli·claudecode·opencode·sloncode