dotnet 探究 SemanticKernel 的 planner 的原理

在使用 SemanticKernel 時,我着迷於 SemanticKernel 強大的 plan 能力,通過 plan 功能可以讓 AI 自動調度拼裝多個模塊實現複雜的功能。我特別好奇 SemanticKernel 裏的 planner 的原理,好奇底層具體是如何實現的。好在 SemanticKernel 是完全開源的,通過閱讀源代碼,我理解了 SemanticKernel 的工作機制,接下來我將和大家分享我所瞭解到的原理

從最底層的非玄學邏輯來說,可以認爲 SemanticKernel 的底層通過 GPT 等 AI 層的輸入和輸出僅僅只有文本而已,而 Planner 需要執行編排調度多個功能任務從而實現功能。最方便理解的就是預先告訴 AI 層,當前有哪些功能或能力,接下來讓 AI 決定這些功能和能力應該如何調度從而滿足需求

換句話說就是作爲工程師的人類提供了各種各樣的功能能力,作爲提出需求的用戶人類給需求描述,接下來作爲 AI 將根據用戶輸入的需求描述,配合工程師提供的各種功能能力完成用戶的需求

比如說實現使用某個語言的作詩需求,用戶的需求描述大概就是作一首什麼樣的詩,然後翻譯爲什麼語言。這時候工程師提供的是一個作詩函數或插件,以及一個翻譯的函數或插件。然後由 AI 層進行編排調度,先調用作詩函數進行作詩,接着將作詩結果作爲翻譯函數的翻譯進行翻譯,最後將翻譯結果返回給到用戶

以上這個需求在有 SemanticKernel 的輔助下,將會非常簡單實現

接下來咱來嘗試在不使用 SemanticKernel 提供的 Plan 工具的前提下,完成類似的功能。通過自己編寫代碼的方式代替 SemanticKernel 提供的 Plan 的功能,從而瞭解 SemanticKernel 的實現細節

大概的原理實現步驟如下圖

先按照 dotnet SemanticKernel 入門 將技能導入框架 博客提供的方法,向 SemanticKernel 框架裏面導入兩個 SemanticFunction 函數,分別是作詩和翻譯

kernel.RegisterSemanticFunction("WriterPlugin", "ShortPoem", new PromptTemplateConfig()
{
    Description = "Turn a scenario into a short and entertaining poem.",
}, new PromptTemplate(
    @"Generate a short funny poem or limerick to explain the given event. Be creative and be funny. Let your imagination run wild.
Event:{{$input}}
", new PromptTemplateConfig()
    {
        Input = new PromptTemplateConfig.InputConfig()
        {
            Parameters = new List<PromptTemplateConfig.InputParameter>()
            {
                new PromptTemplateConfig.InputParameter()
                {
                    Name = "input",
                    Description = "The scenario to turn into a poem.",
                }
            }
        }
    }, kernel));

kernel.CreateSemanticFunction(@"Translate the input below into {{$language}}

MAKE SURE YOU ONLY USE {{$language}}.

{{$input}}

Translation:
", new PromptTemplateConfig()
{
    Input = new PromptTemplateConfig.InputConfig()
    {
        Parameters = new List<PromptTemplateConfig.InputParameter>()
        {
            new PromptTemplateConfig.InputParameter()
            {
                Name = "input",
            },
            new PromptTemplateConfig.InputParameter()
            {
                Name = "language",
                Description = "The language which will translate to",
            }
        }
    },
    Description = "Translate the input into a language of your choice",
}, functionName: "Translate", pluginName: "WriterPlugin");

以上的 SemanticFunction 的煉丹內容來源於 SemanticKernel 官方倉庫的例子

通過以上代碼即可註冊 WriterPlugin.ShortPoem 以及 WriterPlugin.Translate 兩個函數。大家可以看到在註冊這兩個函數的過程中,還很詳細寫出了這兩個函數的功能描述,以及他的各個參數和參數的描述。這些描述內容就是專門用來給 AI 層閱讀的,方便讓 AI 層理解這些函數的功能,從而讓 AI 層知道如何調用這些函數

原本我是先使用中文編寫以上的 SemanticFunction 實現內容的,然而我的煉丹水平不過關,寫不出一個好的例子,於是就使用官方的例子好了。以上函數裏面的英文描述不是本文的重點,大家要是看不懂就請跳過,只需要知道預先準備了這兩個函數就可以

完成準備工作之後,接下來咱將開始編寫 Plan 的核心邏輯。核心實現其實也是一個類似 SemanticFunction 的功能,請了百萬煉丹師編寫了提示詞內容,用來告訴 AI 層需要創建一個 XML 結構,這個 XML 結構裏面就包含了如何進行調度的邏輯,以及各項參數應該傳入什麼值。由於我請不起百萬煉丹師,於是只好白嫖微軟的百萬煉丹師的提示詞

var semanticFunction = kernel.CreateSemanticFunction(
    @"Create an XML plan step by step, to satisfy the goal given, with the available functions.

[AVAILABLE FUNCTIONS]

{{$available_functions}}

[END AVAILABLE FUNCTIONS]

To create a plan, follow these steps:
0. The plan should be as short as possible.
1. From a <goal> create a <plan> as a series of <functions>.
2. A plan has 'INPUT' available in context variables by default.
3. Before using any function in a plan, check that it is present in the [AVAILABLE FUNCTIONS] list. If it is not, do not use it.
4. Only use functions that are required for the given goal.
5. Append an ""END"" XML comment at the end of the plan after the final closing </plan> tag.
6. Always output valid XML that can be parsed by an XML parser.
7. If a plan cannot be created with the [AVAILABLE FUNCTIONS], return <plan />.

All plans take the form of:
<plan>
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    (... etc ...)
</plan>
<!-- END -->

To call a function, follow these steps:
1. A function has one or more named parameters and a single 'output' which are all strings. Parameter values should be xml escaped.
2. To save an 'output' from a <function>, to pass into a future <function>, use <function.{FullyQualifiedFunctionName} ... setContextVariable=""<UNIQUE_VARIABLE_KEY>""/>
3. To save an 'output' from a <function>, to return as part of a plan result, use <function.{FullyQualifiedFunctionName} ... appendToResult=""RESULT__<UNIQUE_RESULT_KEY>""/>
4. Use a '$' to reference a context variable in a parameter, e.g. when `INPUT='world'` the parameter 'Hello $INPUT' will evaluate to `Hello world`.
5. Functions do not have access to the context variables of other functions. Do not attempt to use context variables as arrays or objects. Instead, use available functions to extract specific elements or properties from context variables.

DO NOT DO THIS, THE PARAMETER VALUE IS NOT XML ESCAPED:
<function.Name4 input=""$SOME_PREVIOUS_OUTPUT"" parameter_name=""some value with a <!-- 'comment' in it-->""/>

DO NOT DO THIS, THE PARAMETER VALUE IS ATTEMPTING TO USE A CONTEXT VARIABLE AS AN ARRAY/OBJECT:
<function.CallFunction input=""$OTHER_OUTPUT[1]""/>

Here is a valid example of how to call a function ""_Function_.Name"" with a single input and save its output:
<function._Function_.Name input=""this is my input"" setContextVariable=""SOME_KEY""/>

Here is a valid example of how to call a function ""FunctionName2"" with a single input and return its output as part of the plan result:
<function.FunctionName2 input=""Hello $INPUT"" appendToResult=""RESULT__FINAL_ANSWER""/>

Here is a valid example of how to call a function ""Name3"" with multiple inputs:
<function.Name3 input=""$SOME_PREVIOUS_OUTPUT"" parameter_name=""some value with a &lt;!-- &apos;comment&apos; in it--&gt;""/>

Begin!

<goal>{{$input}}</goal>
");

以上的提示詞內容也就是先插入名爲 available_functions 的內容,將在後面被替換爲當前可用的函數列表。接着就是告訴 AI 層如何制定計劃,輸出的 XML 格式應該是怎樣的,還給他提供了一個例子,如下面代碼

<plan>
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    <!-- ... reason for taking step ... -->
    <function.{FullyQualifiedFunctionName} ... />
    (... etc ...)
</plan>

以及告訴 AI 層應該寫什麼以及不應該輸出什麼。以上的提示詞內容看起來是經過了微軟官方精心的設計的,我隨便寫的幾個提示詞都達不到以上的效果

由於我擔心博客引擎因爲兩個 { 掛掉,於是我就將 { 換成全角的 符號,實際使用中還是使用標準的 { 字符

完成了核心邏輯提示詞的編寫,創建了一個智能函數,接下來咱嘗試調用這個智能函數實現功能

在開始之前,先注入可被使用的函數列表,如以下代碼,通過 GetFunctionsManualAsync 方法即可導出當前註冊到 SemanticKernel 裏的各個函數,無論是 SemanticFunction 還是 NativeFunction 本機函數

var relevantFunctionsManual = await kernel.Functions.GetFunctionsManualAsync(new SequentialPlannerConfig());

以上的 GetFunctionsManualAsync 方法將會返回註冊進入的各個函數,以及函數的描述和函數的輸入參數和參數描述,大概內容如下面代碼

WriterPlugin.ShortPoem:
  description: Turn a scenario into a short and entertaining poem.
  inputs:
    - input: The scenario to turn into a poem.

WriterPlugin.Translate:
  description: Translate the input into a language of your choice
  inputs:
    - input: 
    - language: The language which will translate to

通過以上的輸出內容,相信大家也就能理解爲什麼在定義 SemanticKernel 的函數時,需要編寫函數的描述的原因了,不僅僅這些描述可以給人類閱讀使用,同時也可以給機器閱讀

將以上的輸出代碼放入到 available_functions 變量裏面,從而讓 AI 層瞭解到當前有哪些可以被使用的函數

ContextVariables vars = new(goal)
{
    ["available_functions"] = relevantFunctionsManual
};

以上代碼的 goal 變量是用戶的輸入需求,在這裏也就是幫忙寫一首詩,然後翻譯爲中文的需求,定義的代碼如下

var goal = "Write a poem about John Doe, then translate it into Chinese.";

或者這裏可以直接輸入中文的需求

var goal = "幫忙寫一首關於水哥的詩, 然後翻譯爲中文";

輸入需求之後開始跑一下百萬煉丹師的智能函數

ContextVariables vars = new(goal)
{
    ["available_functions"] = relevantFunctionsManual
};

var planResult = await kernel.RunAsync(semanticFunction, vars);
string? planResultString = planResult.GetValue<string>()?.Trim();

以上拿到的 planResultString 就是 AI 層輸出的計劃調度 XML 配置結果了,大概內容如下

<plan>
    <!-- First, we create a short poem about "水哥" -->
    <function.WriterPlugin.ShortPoem input="水哥" setContextVariable="POEM"/>
    <!-- Then, we translate the poem into Chinese -->
    <function.WriterPlugin.Translate input="$POEM" language="Chinese" appendToResult="RESULT__FINAL_ANSWER"/>
</plan>

接下來咱需要編寫一些 C# 代碼,根據以上輸出的 XML 調度任務轉換爲一個個的 Plan 任務,進行更細節的調度執行

var xmlString = planResultString;
XmlDocument xmlDoc = new();
xmlDoc.LoadXml("<xml>" + xmlString + "</xml>");
XmlNodeList solution = xmlDoc.GetElementsByTagName("plan");

將邏輯轉爲 XML 之後,接下來就是有手就行,根據 XML 裏面提到的函數以及參數進行調度和配置。對 XML 的解析毫無難度,相信大家一看需求就知道如何編寫代碼,而解析完成之後的具體執行,這時候就換成了在 SemanticKernel 裏面如何執行函數的問題,相信這也是大家所熟悉的

爲了更加方便了解咱這個實現的效果,以下代碼我繼續使用了 SemanticKernel 的 Plan 類型,方便快速導入實現

XmlNodeList solution = xmlDoc.GetElementsByTagName("plan");

var plan = new Plan(goal);

foreach (XmlNode solutionNode in solution)
{
    foreach (XmlNode childNode in solutionNode.ChildNodes)
    {
        if (childNode.Name == "#text" || childNode.Name == "#comment")
        {
            // Do not add text or comments as steps.
            // TODO - this could be a way to get Reasoning for a plan step.
            continue;
        }

        if (childNode.Name.StartsWith("function.", StringComparison.OrdinalIgnoreCase))
        {
            var pluginFunctionName = childNode.Name.Split(new string[] { "function." }, StringSplitOptions.None)?[1] ?? string.Empty;
            SplitPluginFunctionName(pluginFunctionName, out var pluginName, out var functionName);

            if (!string.IsNullOrEmpty(functionName))
            {
                var function = kernel.Functions.GetFunction(pluginName,functionName);
                if (function != null)
                {
                    var planStep = new Plan(function);

                    var functionVariables = new ContextVariables();
                    var functionOutputs = new List<string>();
                    var functionResults = new List<string>();

                    var view = function.Describe();
                    foreach (var p in view.Parameters)
                    {
                        functionVariables.Set(p.Name, p.DefaultValue);
                    }

                    if (childNode.Attributes is not null)
                    {
                        foreach (XmlAttribute attr in childNode.Attributes)
                        {
                            if (attr.Name.Equals("setContextVariable", StringComparison.OrdinalIgnoreCase))
                            {
                                functionOutputs.Add(attr.InnerText);
                            }
                            else if (attr.Name.Equals("appendToResult", StringComparison.OrdinalIgnoreCase))
                            {
                                functionOutputs.Add(attr.InnerText);
                                functionResults.Add(attr.InnerText);
                            }
                            else
                            {
                                functionVariables.Set(attr.Name, attr.InnerText);
                            }
                        }
                    }

                    planStep.Outputs = functionOutputs;
                    planStep.Parameters = functionVariables;
                    foreach (var result in functionResults)
                    {
                        plan.Outputs.Add(result);
                    }

                    foreach (var result in functionResults)
                    {
                        plan.Outputs.Add(result);
                    }

                    plan.AddSteps(planStep);
                }
            }
        }
    }
}

Console.WriteLine(await kernel.RunAsync(plan));

static void SplitPluginFunctionName(string pluginFunctionName, out string pluginName, out string functionName)
{
    var pluginFunctionNameParts = pluginFunctionName.Split('.');
    pluginName = pluginFunctionNameParts?.Length > 1 ? pluginFunctionNameParts[0] : string.Empty;
    functionName = pluginFunctionNameParts?.Length > 1 ? pluginFunctionNameParts[1] : pluginFunctionName;
}

由於 SemanticKernel 的 Plan 的數據結構上是允許 Plan 裏面套 Plan 的,於是就直接和 XML 的結構對應起來,註冊各個函數掉算過程進去

最後依然使用的 SemanticKernel 的執行 Plan 的方法完成所有的功能,在 SemanticKernel 裏面執行 Plan 就是按照步驟逐個遞歸 Plan 執行,執行的最底層依然都是 SemanticKernel 的函數

編寫代碼到這裏,相信大家也就看出來 SemanticKernel 的 planner 的原理就是由百萬煉丹師寫出提示詞內容,將用戶輸入的需求,先轉換爲 XML 格式的計劃調度,接着編寫 C# 代碼解析 XML 內容,從 XML 轉換爲 Plan 類型,接着根據 Plan 對象逐個步驟調用,從而完成用戶的需求

以上代碼運行的輸出結果大概如下,歡迎大家換成其他人的名字去試試輸出結果

在一個說普通話的土地上,
住着一個名叫水哥的人,他是個狂熱的粉絲,
對於清澈的水,
他會笑,他會歡呼,
整天嬉水,就像只有水人才能做的那樣。

他會跳進湖裏,發出大聲的吼叫,
在河裏游泳,從這岸到那岸,
在海里,他會歡蹦亂跳,
在雨中,他會跳舞,
哦,水哥熱愛水,這點可以肯定!

他會在水坑裏洗澡,如此快樂,
或者從小溪裏喝水,如此平靜,
有濺水聲和濺水聲,
還有一點火鍋湯,
水哥,這個水人,生活得如此快樂!

本文的代碼放在githubgitee 歡迎訪問

可以通過如下方式獲取本文的源代碼,先創建一個空文件夾,接着使用命令行 cd 命令進入此空文件夾,在命令行裏面輸入以下代碼,即可獲取到本文的代碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin f4448f4507145f1695b7ef81045ae030fc8f1a20

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換爲 github 的源。請在命令行繼續輸入以下代碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin f4448f4507145f1695b7ef81045ae030fc8f1a20

獲取代碼之後,進入 SemanticKernelSamples\Example12_Planner 文件夾

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章