dotnet 探究 SemanticKernel 的 planner 的原理

lindexi發表於2023-11-05

在使用 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 資料夾

相關文章