AI Agents实战——赋能代理执行行动

151 阅读24分钟

本章内容包括:

  • 代理如何通过行动在自身之外执行任务
  • 定义和使用OpenAI函数
  • 语义内核以及如何使用语义函数
  • 协同使用语义函数和原生函数
  • 使用语义内核实例化GPT接口

在本章中,我们将通过使用函数探索行动,了解代理如何利用它们。我们将首先介绍OpenAI函数调用,随后快速转向微软的一个名为语义内核(Semantic Kernel,SK)的项目,借助它构建和管理代理的技能和函数。

本章最后,我们将使用SK托管我们的第一个代理系统。这将是一个完整的章节,包含大量带注释的代码示例。

5.1 定义代理的行动

ChatGPT插件最初是为了提供具备能力、技能或工具的会话。通过插件,您可以搜索网页、创建电子表格或图表。插件为ChatGPT提供了扩展平台的手段。

图5.1展示了ChatGPT插件的工作原理。在这个例子中,一个新的电影推荐插件已被安装在ChatGPT中。当用户请求ChatGPT推荐一部新电影时,大型语言模型(LLM)识别出它有一个插件可以处理该操作。然后,它将用户请求分解为可操作的参数,并将这些参数传递给新的电影推荐器。

image.png

推荐系统随后会抓取一个展示新电影的网站,并将该信息附加到一个新的提示请求中,传递给LLM。通过这些信息,LLM响应推荐系统,后者再将其传回ChatGPT。ChatGPT随后根据推荐结果回应用户。

我们可以将插件视为行动的代理。插件通常封装了一个或多个能力,例如调用API或抓取网站。因此,行动是插件的扩展——它们赋予插件执行能力。

AI代理可以被视为插件以及插件、工具、技能和其他代理的消费者。为代理/插件添加技能、函数和工具使其能够执行定义良好的行动——图5.2突出了代理行动发生的地方以及它们与LLM和其他系统的交互。

image.png

代理行动是允许代理使用功能、技能或工具的能力。令人困惑的是,不同的框架使用不同的术语。我们将定义行动为代理能够执行的任何操作,以建立一些基本定义。

ChatGPT插件和功能代表了一个可操作的能力,ChatGPT或代理系统可以使用它们来执行额外的行动。现在,让我们来探讨OpenAI插件和功能定义的基础。

5.2 执行OpenAI函数

OpenAI通过启用插件,提出了一种结构规范,用于定义LLM可以执行的函数/插件之间的接口。这一规范正逐渐成为LLM系统可以遵循的标准,以提供可操作的系统。

这些相同的函数定义现在也被用来为ChatGPT和其他系统定义插件。接下来,我们将探讨如何直接使用函数与LLM调用。

5.2.1 将函数添加到LLM API调用中

图5.3展示了LLM如何识别和使用函数定义,将其响应转化为函数调用。

image.png

列出5.1 展示了使用工具和函数定义的LLM API调用的详细信息。添加函数定义允许LLM根据函数的输入参数进行回复。这意味着LLM将识别正确的函数,并解析用户请求的相关参数。

列出5.1 first_function.py (API调用)

response = client.chat.completions.create(
        model="gpt-4-1106-preview",
        messages=[{"role": "system",
                   "content": "You are a helpful assistant."},
                  {"role": "user", "content": user_message}],
        temperature=0.7,
        tools=[     #1
            {
                "type": "function",     #2
                "function": {
                    "name": "recommend",
                    "description": "Provide a … topic.",     #3
                    "parameters": {
                        "type": "object",     #4
                        "properties": {
                            "topic": {
                                "type": "string",
                                "description": 
                                   "The topic,… for.",     #5
                            },
                            "rating": {
                                "type": "string",
                                "description": 
                          "The rating … given.",    #5
                                "enum": ["good",
                                         "bad", 
                                         "terrible"]     #6
                                },
                        },
                        "required": ["topic"],
                    },
                },
                }
            ]
        )
  • #1 新增了名为 tools 的参数
  • #2 设置工具类型为函数
  • #3 提供了函数的描述
  • #4 定义了输入参数的类型;对象表示JSON文档
  • #5 为每个输入参数提供了优秀的描述
  • #6 甚至可以用枚举来描述参数

要查看此如何工作,打开Visual Studio Code(VS Code),并定位到本书的源代码文件夹:chapter_4/first_function.py。建议在VS Code中打开相关章节的文件夹,以创建一个新的Python环境并安装requirements.txt文件。如果需要帮助,请参阅附录B。

在开始之前,确保在chapter_4文件夹中正确设置了.env文件,包含你的API凭证。函数调用是LLM商业服务提供的额外功能。在撰写本文时,该功能还不是开源LLM部署的选项。

接下来,我们将查看first_function.py文件底部的代码,如列出5.2所示。这里有两个使用之前在列出5.1中指定的请求调用LLM的示例。每个请求展示了运行示例时生成的输出。

列出5.2 first_function.py (执行API)

user = "Can you please recommend me a time travel movie?"
response = ask_chatgpt(user)     #1
print(response)

###Output
Function(arguments='{"topic":"time travel movie"}', 
                      name='recommend')     #2

user = "Can you please recommend me a good time travel movie?"
response = ask_chatgpt(user)     #3
print(response)

###Output
Function(arguments='{"topic":"time travel movie",
                     "rating":"good"}',
 name='recommend')     #4
  • #1 之前定义的函数
  • #2 返回函数名称和提取的输入参数
  • #3 之前定义的函数
  • #4 返回函数名称和提取的输入参数

在VS Code中使用调试器(F5)或终端运行first_function.py Python脚本,查看相同的结果。在这里,LLM解析输入请求以匹配任何已注册的工具。在此情况下,工具是单一的函数定义,即推荐函数。LLM从此函数中提取输入参数,并从请求中解析这些参数。然后,它回复带有函数名称和指定输入参数的结果。

注意:实际的函数并未被调用。LLM只返回建议的函数及其相关的输入参数。名称和参数必须被提取并传递给匹配函数签名的函数来执行该功能。在下一节中,我们将查看如何进行这项操作的示例。

5.2.2 执行函数调用

现在我们已经理解LLM并不直接执行函数或插件,我们可以查看一个执行工具的示例。继续保持推荐系统的主题,我们将看另一个示例,添加一个用于简单推荐的Python函数。

图5.4展示了这个简单示例的工作原理。我们将提交一个包含工具函数定义的请求,要求提供三条推荐。LLM将回复三次函数调用,并带有输入参数(时间旅行、食谱和礼物)。执行函数后的结果将传回LLM,LLM再将其转换为自然语言并返回回复。

image.png

现在我们理解了这个示例,打开VS Code中的 parallel_functions.py 文件。列出5.3 展示了您希望调用的Python函数,用于提供推荐。

列出5.3 parallel_functions.py (推荐函数)

def recommend(topic, rating="good"):
    if "time travel" in topic.lower():     #1
        return json.dumps({"topic": "time travel",
                           "recommendation": "Back to the Future",
                           "rating": rating})
    elif "recipe" in topic.lower():    #1
        return json.dumps({"topic": "recipe",
                           "recommendation": "The best thing … ate.",
                           "rating": rating})
    elif "gift" in topic.lower():      #1
        return json.dumps({"topic": "gift",
                           "recommendation": "A glorious new...",
                           "rating": rating})
    else:     #2
        return json.dumps({"topic": topic,
                           "recommendation": "unknown"})     #3
#1 检查字符串是否包含在输入的主题中
#2 如果没有检测到主题,返回默认值
#3 返回一个JSON对象

接下来,我们将查看名为 run_conversation 的函数,这里是所有工作从构建请求开始的地方。

列出5.4 parallel_functions.py (run_conversation, 请求)

user = """Can you please make recommendations for the following:
1. Time travel movies
2. Recipes
3. Gifts"""     #1
messages = [{"role": "user", "content": user}]     #2
tools = [     #3
    {
        "type": "function",
        "function": {
            "name": "recommend",
            "description": 
                "Provide a recommendation for any topic.",
            "parameters": {
                "type": "object",
                "properties": {
                    "topic": {
                        "type": "string",
                        "description": 
                              "The topic, … recommendation for.",
                        },
                        "rating": {
                            "type": "string",
                            "description": "The rating … was given.",
                            "enum": ["good", "bad", "terrible"]
                            },
                        },
                "required": ["topic"],
                },
            },
        }
    ]
#1 用户消息请求三条推荐。
#2 注意没有系统消息。
#3 将函数定义添加到请求的工具部分

列出5.5 显示了已发出的请求,我们之前已经讨论过,但有一些细节需要注意。此调用使用的是较低模型(如GPT-3.5),因为委托函数是一个更简单的任务,可以使用较旧、便宜且不那么复杂的语言模型完成。

列出5.5 parallel_functions.py (run_conversation, API调用)

response = client.chat.completions.create(
    model="gpt-3.5-turbo-1106",     #1
    messages=messages,     #2
    tools=tools,     #2
    tool_choice="auto",   #3
)
response_message = response.choices[0].message     #4
#1 委托给函数的LLM可以是更简单的模型。
#2 添加消息和工具定义
#3 "auto" 是默认值。
#4 LLM返回的消息

此时,在API调用之后,响应应包含所需的函数调用信息。记住,我们要求LLM提供三条推荐,这意味着它应该还会提供三次函数调用输出,如下所示。

列出5.6 parallel_functions.py (run_conversation, 工具调用)

tool_calls = response_message.tool_calls     #1
if tool_calls:    #1
    available_functions = {
        "recommend": recommend,
    }     #2
    # 第4步:将每个函数调用及其响应信息发送回模型
    for tool_call in tool_calls:     #3
        function_name = tool_call.function.name
        function_to_call = available_functions[function_name]
        function_args = json.loads(tool_call.function.arguments)
        function_response = function_to_call(
            topic=function_args.get("topic"),     #4
            rating=function_args.get("rating"),
        )
        messages.append(     #5
            {
                "tool_call_id": tool_call.id,
                "role": "tool",
                "name": function_name,
                "content": function_response,
            }
        )  # 将函数响应添加到对话中
    second_response = client.chat.completions.create(     #6
        model="gpt-3.5-turbo-1106",
        messages=messages,
    )
    return second_response.choices[0].message.content  #6
#1 如果响应包含工具调用,则执行这些调用。
#2 只有一个函数,但可能包含多个函数
#3 遍历这些调用并将内容反馈给LLM
#4 从提取的参数执行recommend函数
#5 将每个函数调用的结果附加到消息集合中
#6 发送一个带有更新信息的请求到LLM,并返回消息回复

工具调用的输出和对推荐函数结果的调用被附加到消息中。注意,消息现在还包含了第一次调用的历史。这些信息随后传回LLM,以构建自然语言的回复。

在VS Code中调试此示例,按下F5键即可查看相同的结果。以下是运行 parallel_functions.py 的输出。

列出5.7 parallel_functions.py (输出)

Here are some recommendations for you:

1. Time travel movies: "Back to the Future"
2. Recipes: "The best thing you ever ate."
3. Gifts: "A glorious new..." (the recommendation was cut off, so I 
couldn't provide the full recommendation)

I hope you find these recommendations helpful! Let me know if you need 
more information.

这完成了这个简单的演示。对于更复杂的应用,函数可以做很多事情,从抓取网站到调用搜索引擎,甚至完成更复杂的任务。

函数是一个极好的方式来执行特定任务的输出。然而,处理函数或工具并进行二次调用的工作可以通过更简洁高效的方式完成。下一节将介绍如何为代理添加更强大的行动系统。

5.3 介绍语义内核

语义内核(Semantic Kernel,SK)是微软的另一个开源项目,旨在帮助构建AI应用,我们称之为代理。该项目的核心最适合用于定义行动,或者平台所称的语义插件,这些插件是技能和功能的封装。

图5.5 展示了如何将SK作为插件使用,并作为OpenAI插件的消费者。SK依赖于OpenAI插件定义来定义插件。通过这种方式,它可以消费并发布自己或其他插件到其他系统。

image.png

OpenAI插件定义与列表5.4中的函数定义完全对应。这意味着SK是API工具调用的协调器,也就是插件。这也意味着SK可以通过聊天界面或代理来帮助组织多个插件。

注意:SK团队最初将功能模块称为技能。然而,为了与OpenAI保持一致,它们后来将“技能”改名为“插件”。更让人困惑的是,代码中仍然使用“技能”一词。因此,在本章中,我们将使用“技能”和“插件”来表示相同的概念。

SK是一个管理多个插件(代理的操作)的有用工具,正如我们稍后将看到的,它还可以辅助内存和规划工具。在本章中,我们将重点介绍操作/插件。在下一节中,我们将介绍如何开始使用SK。

5.3.1 开始使用SK语义功能

SK易于安装,并且可以在Python、Java和C#中运行。这是个好消息,因为它还允许在一种语言中开发的插件在不同语言中使用。然而,您还不能在一种语言中开发本地函数并在另一种语言中使用。

我们将从Python环境开始,使用VS Code中的chapter_4工作区。如果您想探索并运行任何示例,确保配置好工作区。

列表5.8展示了如何从VS Code中的终端安装SK。您还可以安装VS Code的SK扩展。这个扩展是创建插件/技能的有用工具,但并不是必须的。

列表5.8 安装语义内核
pip uninstall semantic-kernel     #1

git clone https://github.com/microsoft/semantic-kernel.git     #2

cd semantic-kernel/python     #3

pip install -e .     #4

#1 卸载任何先前安装的SK
#2 克隆存储库到本地文件夹
#3 切换到源文件夹
#4 从源文件夹安装可编辑包

安装完成后,打开VS Code中的SK_connecting.py。列表5.9展示了如何通过SK快速运行一个示例。该示例使用OpenAI或Azure OpenAI创建一个聊天完成服务。

列表5.9 SK_connecting.py
import semantic_kernel as sk

selected_service = "OpenAI"     #1
kernel = sk.Kernel()     #2

service_id = None
if selected_service == "OpenAI":
    from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion

    api_key, org_id = sk.openai_settings_from_dot_env()     #3
    service_id = "oai_chat_gpt"
    kernel.add_service(
        OpenAIChatCompletion(
            service_id=service_id,
            ai_model_id="gpt-3.5-turbo-1106",
            api_key=api_key,
            org_id=org_id,
        ),
    )
elif selected_service == "AzureOpenAI":
    from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion

    deployment, api_key, endpoint = sk.azure_openai_settings_from_dot_env()   #4
    service_id = "aoai_chat_completion"
    kernel.add_service(
        AzureChatCompletion(
            service_id=service_id,
            deployment_name=deployment,
            endpoint=endpoint,
            api_key=api_key,
        ),
    )

# This function is currently broken
async def run_prompt():
    result = await kernel.invoke_prompt( 
              prompt="recommend a movie about time travel")     #5
    print(result)

# Use asyncio.run to execute the async function
asyncio.run(run_prompt())     #6

输出

One highly recommended time travel movie is "Back to the Future" (1985) 
directed by Robert Zemeckis. This classic film follows the adventures of 
teenager Marty McFly (Michael J. Fox)…

#1 设置您使用的服务(OpenAI或Azure OpenAI)
#2 创建内核
#3 从.env文件加载密钥并将其设置到聊天服务
#4 从.env文件加载密钥并将其设置到聊天服务
#5 调用提示
#6 异步调用函数

按F5(调试)运行示例,您应该会看到与列表5.9类似的输出。这个示例演示了如何使用SK创建并执行一个语义功能。语义功能等同于Microsoft的另一个工具——提示流中的提示模板。在这个示例中,我们将简单的提示定义为一个函数。

需要注意的是,这个语义功能并未定义为插件。然而,内核可以将该功能创建为一个自包含的语义元素,可以在LLM上执行。语义功能可以单独使用,也可以注册为插件,正如您稍后将看到的。接下来我们将进入下一节,介绍上下文变量。

5.3.2 语义功能和上下文变量

在前一个示例的基础上,我们可以看一下如何向语义功能中添加上下文变量。这个将占位符添加到提示模板中的模式,我们将在接下来的内容中多次回顾。在这个示例中,我们将看到一个提示模板,其中包含了主题、类型、格式和自定义信息的占位符。

在VS Code中打开SK_context_variables.py,如下所示。该提示等同于将系统和用户部分分开的提示。

列表5.10 SK_context_variables.py
#顶部部分省略…
prompt = """     #1
system:

You have vast knowledge of everything and can recommend anything provided 
you are given the following criteria, the subject, genre, format and any 
other custom information.

user:
Please recommend a {{$format}} with the subject {{$subject}} and {{$genre}}.
Include the following custom information: {{$custom}}
"""

prompt_template_config = sk.PromptTemplateConfig(     #2
    template=prompt,
    name="tldr",
    template_format="semantic-kernel",
    input_variables=[
        InputVariable(
            name="format", 
            description="The format to recommend", 
            is_required=True
        ),
        InputVariable(
            name="subject", 
            description="The subject to recommend", 
            is_required=True
        ),
        InputVariable(
            name="genre", 
            description="The genre to recommend", 
            is_required=True
        ),
        InputVariable(
            name="custom",
            description="Any custom information [CA]
                       to enhance the recommendation",
            is_required=True,
        ),
    ],
    execution_settings=execution_settings,
)

recommend_function = kernel.create_function_from_prompt(     #3
    prompt_template_config=prompt_template_config,
    function_name="Recommend_Movies",
    plugin_name="Recommendation",
)

async def run_recommendation(     #4
    subject="time travel",
    format="movie", 
    genre="medieval", 
    custom="must be a comedy"
):
    recommendation = await kernel.invoke(
        recommend_function,
        sk.KernelArguments(subject=subject,
                      format=format, 
                      genre=genre, 
                      custom=custom),     #5
    )
    print(recommendation)


# 使用asyncio.run执行异步函数
asyncio.run(run_recommendation())    #5

输出

One movie that fits the criteria of being about time travel, set in a 
medieval period, and being a comedy is "The Visitors" (Les Visiteurs) 
from 1993. This French film, directed by Jean-Marie Poiré, follows a 
knight and his squire who are transported to the modern era by a 
wizard’s spell gone wrong.…

#1 定义一个带占位符的提示
#2 配置提示模板和输入变量定义
#3 从提示中创建内核函数
#4 创建一个异步函数来包装函数调用
#5 设置内核函数的参数

现在,您可以调试这个示例(按F5),并等待输出生成。这就是设置SK、创建和执行语义功能的基础。在下一节中,我们将介绍如何将语义功能注册为技能/插件。

5.4 协同作用的语义功能与本地功能

语义功能封装了一个提示/配置文件,并通过与LLM的交互执行。本地功能是代码的封装,可以执行从抓取网站到搜索网络等任何任务。语义功能和本地功能都可以作为插件/技能注册到SK内核中。

一个功能,无论是语义的还是本地的,都可以注册为插件,并像我们之前直接通过API调用注册功能那样使用。当一个功能注册为插件后,它将根据使用场景,可以通过聊天或代理接口访问。下一节将介绍如何创建并将语义功能注册到内核中。

5.4.1 创建和注册语义技能/插件

SK的VS Code扩展提供了创建插件/技能的有用工具。在本节中,我们将使用SK扩展来创建一个插件/技能,然后编辑该扩展的组件。之后,我们将注册并在SK中执行该插件。

图5.6展示了使用SK扩展在VS Code中创建新技能的过程。(如果需要安装该扩展,请参考附录B中的说明。)接下来,您将被提示选择将功能放置的技能/插件文件夹。始终将类似的功能分组在一起。在创建技能后,输入您想要开发的功能的名称和描述。确保像描述给LLM使用一样描述该功能。

image.png

您可以通过打开技能/插件文件夹并查看文件来查看已完成的技能和功能。我们将沿用之前构建的示例,因此请打开技能/Recommender/Recommend_Movies文件夹,如图5.7所示。在这个文件夹内,有一个config.json文件、功能描述,以及一个名为skprompt.txt的文件,其中包含语义功能/提示。

image.png

列表5.11显示了语义功能定义的内容,也被称为插件定义。请注意,类型标记为“completion”(完成),而不是“function”(函数),因为这是一个语义功能。我们会将本地功能定义为“function”类型。

列表5.11 Recommend_Movies/config.json
{
    "schema": 1,
    "type": "completion",     #1
    "description": "A function to recommend movies based on users list of 
    previously seen movies.",
    "completion": {     #2
        "max_tokens": 256,
        "temperature": 0,
        "top_p": 0,
        "presence_penalty": 0,
        "frequency_penalty": 0
    },
    "input": {
        "parameters": [
            {
                "name": "input",     #3
                "description": "The users list of previously seen movies.",
                "defaultValue": ""
            }
        ]
    },
    "default_backends": []
}

#1 语义功能是类型为“completion”的功能。
#2 我们还可以设置完成参数,以决定如何调用功能。
#3 定义传入语义功能的参数

接下来,我们可以查看语义功能提示的定义,如列表5.12所示。格式稍有不同,但我们看到的内容与之前使用模板的示例相匹配。此提示根据用户之前观看的电影列表推荐电影。

列表5.12 Recommend_Movies/skprompt.txt
You are a wise movie recommender and you have been asked to recommend a 
movie to a user.
You are provided a list of movies that the user has watched before.
You want to recommend a movie that the user has not watched before.
[INPUT]
{{$input}}
[END INPUT]

现在,我们将深入了解加载技能/插件并在简单示例中执行的代码。请在VS Code中打开SK_first_skill.py文件。以下列表显示了重点突出的新部分。

列表5.13 SK_first_skill.py(简化版本)
kernel = sk.Kernel()

plugins_directory = "plugins"

recommender = kernel.import_plugin_from_prompt_directory(
    plugins_directory,
    "Recommender",
)     #1

recommend = recommender["Recommend_Movies"]

seen_movie_list = [     #2
    "Back to the Future",
    "The Terminator",
    "12 Monkeys",
    "Looper",
    "Groundhog Day",
    "Primer",
    "Donnie Darko",
    "Interstellar",
    "Time Bandits",
    "Doctor Strange",
]


async def run():
    result = await kernel.invoke(
        recommend,
        sk.KernelArguments(     #3
            settings=execution_settings, input=", ".join(seen_movie_list)
        ),
    )
    print(result)


asyncio.run(run())     #4

输出

Based on the list of movies you've provided, it seems you have an 
interest in science fiction, time travel, and mind-bending narratives. 
Given that you've watched a mix of classics and modern films in this 
genre, I would recommend the following movie that you have not watched 
before:

"Edge of Tomorrow" (also known as "Live Die Repeat: Edge of Tomorrow")…

#1 从插件文件夹加载提示
#2 用户以前看过的电影列表
#3 输入设置为连接的观看电影列表
#4 异步执行功能

该代码从技能目录和插件文件夹加载技能/插件。当技能被加载到内核中,而不仅仅是创建时,它就变成了一个注册插件。这意味着它可以像这里一样直接执行,或者通过LLM聊天对话通过插件接口执行。

运行代码(按F5),您应该会看到像列表5.13所示的输出。现在,我们有了一个简单的语义功能,可以作为插件托管。然而,该功能要求用户输入完整的他们看过的电影列表。在下一节中,我们将通过引入本地功能来解决这个问题。

5.4.2 应用本地功能

如前所述,本地功能是可以执行任何任务的代码。在下面的示例中,我们将介绍一个本地功能,以协助我们之前构建的语义功能。
这个本地功能将从文件中加载用户之前看过的电影列表。虽然这个功能引入了内存的概念,但我们将在第8章中讨论这一点。在此,您可以将这个新本地功能视为任何几乎可以执行的代码。

本地功能可以使用SK扩展创建并注册。在这个示例中,我们将直接在代码中创建一个本地功能,以使示例更易于跟随。

在VS Code中打开SK_native_functions.py文件。我们首先查看本地功能的定义。本地功能通常在类中定义,这简化了本地功能的管理和实例化。

列表5.14 SK_native_functions.py (MySeenMovieDatabase)
class MySeenMoviesDatabase:
    """
    Description: Manages the list of users seen movies.     #1
    """
    @kernel_function(     #2
        description="Loads a list of movies … user has already seen",
        name="LoadSeenMovies",
    )
    def load_seen_movies(self) -> str:     #3
        try:
            with open("seen_movies.txt", 'r') as file:     #4
                lines = [line.strip() for line in file.readlines()]
                comma_separated_string = ', '.join(lines)
            return comma_separated_string
        except Exception as e:
            print(f"Error reading file: {e}")
            return None

#1 提供容器类的描述
#2 使用装饰器提供函数的描述和名称
#3 实际函数返回逗号分隔的电影列表字符串
#4 从文本文件加载看过的电影

定义好本地功能后,我们可以通过向下滚动文件来查看它是如何使用的,如下所示。

列表5.15 SK_native_functions (剩余代码)
plugins_directory = "plugins"

recommender = kernel.import_plugin_from_prompt_directory(
    plugins_directory,
    "Recommender",
)     #1

recommend = recommender["Recommend_Movies"]

seen_movies_plugin = kernel.import_plugin_from_object(
    MySeenMoviesDatabase(), "SeenMoviesPlugin"
)     #2

load_seen_movies = seen_movies_plugin["LoadSeenMovies"]     #3

async def show_seen_movies():
    seen_movie_list = await load_seen_movies(kernel)
    return seen_movie_list

seen_movie_list = asyncio.run(show_seen_movies())     #4
print(seen_movie_list)

async def run():      #5
    result = await kernel.invoke(
        recommend,
        sk.KernelArguments(
                settings=execution_settings,
                input=seen_movie_list),
    )
    print(result)

asyncio.run(run())    #5

输出

The Matrix, The Matrix Reloaded, The Matrix Revolutions, The Matrix 
Resurrections – output from print statement
Based on your interest in the "The Matrix" series, it seems you enjoy 
science fiction films with a strong philosophical undertone and action 
elements. Given that you've watched all

#1 加载如之前所示的语义功能
#2 将技能导入到内核并注册为插件
#3 加载本地功能
#4 执行功能并返回字符串格式的电影列表
#5 在异步函数中封装插件调用并执行

一个重要的方面是如何将本地功能导入到内核中。导入到内核的动作将该功能注册为插件/技能。这意味着该功能可以作为技能从内核通过其他对话或交互进行使用。在下一节中,我们将看到如何将本地功能嵌入到语义功能中。

5.4.3 将本地功能嵌入到语义功能中

SK中有许多强大的功能,其中一个有益的功能是能够将本地功能或语义功能嵌入到其他语义功能中。下面的列表展示了如何将本地功能嵌入到语义功能中。

列表5.16 SK_semantic_native_functions.py (skprompt)
sk_prompt = """
You are a wise movie recommender and you have been asked to recommend a 
movie to a user.
You have a list of movies that the user has watched before.
You want to recommend a movie that 
the user has not watched before.     #1
Movie List: {{MySeenMoviesDatabase.LoadSeenMovies}}.     #2
"""
#1 与之前的指令文本完全相同  
#2 本地功能通过类名和函数名进行引用和标识  

接下来的示例,SK_semantic_native_functions.py,使用了内联本地功能和语义功能。在VS Code中打开该文件,以下列表展示了创建、注册和执行这些功能的代码。

列表5.17 SK_semantic_native_functions.py(简化版)
prompt_template_config = sk.PromptTemplateConfig(
    template=sk_prompt,
    name="tldr",
    template_format="semantic-kernel",
    execution_settings=execution_settings,
)     #1

recommend_function = kernel.create_function_from_prompt(
    prompt_template_config=prompt_template_config,
    function_name="Recommend_Movies",
    plugin_name="Recommendation",
)     #2

async def run_recommendation():     #3
    recommendation = await kernel.invoke(
        recommend_function,
        sk.KernelArguments(),
    )
    print(recommendation)

# 使用asyncio.run执行异步函数
asyncio.run(run_recommendation())

输出

Based on the list provided, it seems the user is a fan of the Matrix 
franchise. Since they have watched all four existing Matrix movies, I 
would recommend a

#1 为提示创建提示模板配置
#2 从提示中创建内联语义功能
#3 异步执行语义功能

运行代码,您应该会看到像列表5.17所示的输出。一个重要的方面是,本地功能已经在内核中注册,但语义功能没有。这一点很重要,因为创建函数并不会自动注册该功能。
为了使这个示例正确运行,本地功能必须使用import_plugin函数调用注册到内核中——这是列表5.17中第一行的操作。然而,语义功能本身并未注册。注册该功能的一个简单方法是将其作为插件并导入。

这些简单的练习展示了如何将插件和技能集成到聊天或代理接口中。在下一节中,我们将查看一个完整的示例,演示如何将代表服务或GPT接口的插件添加到聊天功能中。

5.5 语义内核作为交互式服务代理

在第1章中,我们介绍了GPT接口的概念——一种通过插件和语义层将服务和其他组件连接到LLM的新范式。SK提供了一个出色的抽象,可以将任何服务转换为GPT接口。

图5.8展示了一个围绕API服务构建的GPT接口,该服务称为电影数据库(TMDB;<www.themoviedb.org>)。TMDB网站提供了一个免费的API,公开了关于电影和电视剧的信息。

image.png

为了跟随本节中的练习,您需要在TMDB注册一个免费账户并创建一个API密钥。获取API密钥的说明可以在TMDB网站(<www.themoviedb.org>)找到,或者您也可以向GPT-4 turbo或更新版的LLM询问。

在接下来的子节中,我们将使用SK的本地功能创建一个GPT接口。然后,我们将使用SK内核测试该接口,并在本章后续将其作为插件集成到聊天功能中。在下一节中,我们将讨论如何基于TMDB API构建GPT接口。

5.5.1 构建语义GPT接口

TMDB是一个很好的服务,但它没有提供语义服务或可以与ChatGPT或代理结合使用的服务。为了实现这一点,我们必须将TMDB暴露的API调用封装在一个语义服务层中。

语义服务层是一个通过自然语言暴露功能的GPT接口。如前所述,为了将功能暴露给ChatGPT或其他接口(如代理),这些功能必须定义为插件。幸运的是,SK可以自动为我们创建这些插件,只要我们正确编写语义服务层。

一个本地插件或一组技能可以充当语义层。要创建本地插件,请创建一个新的插件文件夹,并在该文件夹中放入一个包含本地功能集的类的Python文件。当前,SK扩展并不很好地支持这一点,因此手动创建模块是最好的方法。

图5.9展示了名为Movies的新插件结构,以及名为tmdb.py的语义服务层结构。对于本地功能,父文件夹的名称(Movies)将在导入时使用。

image.png

在VS Code中打开tmdb.py文件,查看文件顶部,如列表5.18所示。该文件包含一个名为TMDbService的类,它暴露了多个与API端点调用相关的函数。我们的目标是将各种相关的API功能调用映射到这个语义服务层中。这样可以将这些函数作为插件暴露给聊天或代理接口。

列表5.18 tmdb.py(文件顶部)
from semantic_kernel.functions import kernel_function
import requests
import inspect

def print_function_call():     #1
    # omitted …

class TMDbService:     #2
    def __init__(self):
        # enter your TMDb API key here
        self.api_key = "your-TMDb-api-key"

    @kernel_function(     #3
        description="Gets the movie genre ID for a given genre name",
        name="get_movie_genre_id",
        input_description="The movie genre name of the genre_id to get",
        )
    def get_movie_genre_id(self, genre_name: str) -> str:     #4
        print_function_call()
        base_url = "https://api.themoviedb.org/3"
        endpoint = f"{base_url}/genre/movie/list?api_key={self.api_key}&language=en-US"

        response = requests.get(endpoint)     #5
        if response.status_code == 200:    #6
            genres = response.json()['genres']
            for genre in genres:
                if genre_name.lower() in genre['name'].lower():
                    return str(genre['id'])     #7
        return None

#1 打印函数调用,用于调试
#2 定义服务类
#3 使用装饰器描述函数
#4 定义函数,接受电影类型名称,返回类型ID
#5 调用TMDB的API端点
#6 如果响应状态代码为200,检查并返回匹配的电影类型
#7 返回找到的电影类型ID

TMDbService类的主体代码及调用TMDB端点的函数是借助GPT-4 Turbo编写的。然后,每个函数都用@kernel_function装饰器进行包装,以便语义地暴露给接口。

列表5.19展示了另一个示例,该函数暴露给语义服务层,这个函数可以拉取当前某个类型下的前10名电影。

列表5.19 tmdb.py(get_top_movies_by_genre)
@kernel_function(     #1
        description="Gets a list of currently playing movies for a given genre",
        name="get_top_movies_by_genre",
        input_description="The genre of the movies to get",
        )
    def get_top_movies_by_genre(self, genre: str) -> str:
        print_function_call()
        genre_id = self.get_movie_genre_id(genre)     #2
        if genre_id:
            base_url = "https://api.themoviedb.org/3"
            playing_movies_endpoint = f"{base_url}/movie/now_playing?api_key={self.api_key}&language=en-US"
            response = requests.get(playing_movies_endpoint)     #3
            if response.status_code != 200:
                return ""

            playing_movies = response.json()['results']  #4
            for movie in playing_movies:     #5
                movie['genre_ids'] = [str(genre_id) for genre_id in movie['genre_ids']]
            filtered_movies = [movie for movie in playing_movies if genre_id in movie['genre_ids']][:10]  #6
            results = ", ".join([movie['title'] for movie in filtered_movies])
            return results
        else:
            return ""

#1 装饰函数并描述
#2 获取给定类型名称的类型ID
#3 获取当前播放的电影列表
#4 转换类型ID为字符串
#5 检查电影的类型ID是否匹配
#6 返回符合条件的前10部电影

浏览其他语义映射的TMDB API调用。正如您所见,将API调用转换为语义服务有一个明确的模式。在运行完整的服务之前,我们将在下一节中测试每个函数。

5.5.2 测试语义服务

在实际应用中,您可能希望为每个语义服务函数编写一整套单元测试或集成测试。我们这里不进行这些测试;相反,我们将编写一个简短的辅助脚本来测试各种功能。

在VS Code中打开test_tmdb_service.py文件,查看代码,如列表5.20所示。您可以注释或取消注释任何函数,以便单独测试它们。确保每次只取消注释一个函数。

列表5.20 test_tmdb_service.py
import semantic_kernel as sk
from plugins.Movies.tmdb import TMDbService

async def main():
    kernel = sk.Kernel()     #1

    tmdb_service = kernel.import_plugin_from_object(
        TMDbService(), "TMDBService"     #2
    )

    print(
        await tmdb_service["get_movie_genre_id"](
            kernel, sk.KernelArguments(
                            genre_name="action")     #3
        )
    )     #4
    print(
        await tmdb_service["get_tv_show_genre_id"](
            kernel, sk.KernelArguments(
                            genre_name="action")     #5
        )
    )     #6
    print(
        await tmdb_service["get_top_movies_by_genre"](
            kernel, sk.KernelArguments(
                            genre_name="action")     #7
        )
    )     #8
    print(
        await tmdb_service["get_top_tv_shows_by_genre"](
            kernel, sk.KernelArguments(
                            genre_name="action")    #7
        )
    )
    print(await tmdb_service["get_movie_genres"](
        kernel, sk.KernelArguments()))                        #9
    print(await tmdb_service["get_tv_show_genres"](
        kernel, sk.KernelArguments()))                       #9

# Run the main function
if __name__ == "__main__":
    import asyncio

    asyncio.run(main())     #10

输出

Function name: get_top_tv_shows_by_genre     #11
Arguments:
  self = <skills.Movies.tmdb.TMDbService object at 0x00000159F52090C0>
  genre = action
Function name: get_tv_show_genre_id    #11
Arguments:
  self = <skills.Movies.tmdb.TMDbService object at 0x00000159F52090C0>
  genre_name = action
Arcane, One Piece, Rick and Morty, Avatar: The Last Airbender, Fullmetal 
Alchemist: Brotherhood, Demon Slayer: Kimetsu no Yaiba, Invincible, 
Attack on Titan, My Hero Academia, Fighting Spirit, The Owl House

#1 实例化内核
#2 导入插件服务
#3 输入参数到函数中(如需要)
#4 执行并测试各种函数
#5 输入参数到函数中(如需要)
#6 执行并测试各种函数
#7 输入参数到函数中(如需要)
#8 执行并测试各种函数
#9 执行并测试各种函数
#10 异步执行main函数
#11 打印函数细节,通知何时调用函数

SK的真正强大之处在于这个测试。注意,TMDbService类被导入为插件,但我们不需要定义任何插件配置,除了我们已经做的那些?通过编写一个类来包装几个API函数,我们已经将TMDB API的一部分语义化暴露了。现在,随着这些函数的暴露,我们可以看看它们如何作为插件在聊天接口中使用,下一节将进行介绍。

5.5.3 通过语义服务层进行交互式聊天

随着TMDB功能的语义暴露,我们可以将它们集成到聊天接口中。这将允许我们在该接口中自然地对话,以获取各种信息,例如当前的热门电影。

在VS Code中打开SK_service_chat.py文件,向下滚动到创建函数的新代码部分,如列表5.21所示。这里创建的函数现在作为插件暴露,除了我们排除掉的聊天函数,聊天函数不应作为插件暴露。这里的聊天函数允许用户直接与LLM进行对话,且不应作为插件。

列表5.21 SK_service_chat.py(函数设置)
system_message = "You are a helpful AI assistant."

tmdb_service = kernel.import_plugin_from_object(
    TMDbService(), "TMDBService"     #1
)

# 提取的代码部分
execution_settings = sk_oai.OpenAIChatPromptExecutionSettings(
    service_id=service_id,
    ai_model_id=model_id,
    max_tokens=2000,
    temperature=0.7,
    top_p=0.8,
    tool_choice="auto",
    tools=get_tool_call_object(
        kernel, {"exclude_plugin": ["ChatBot"]}),     #2
)

prompt_config = sk.PromptTemplateConfig.from_completion_parameters(
    max_tokens=2000,
    temperature=0.7,
    top_p=0.8,
    function_call="auto",
    chat_system_prompt=system_message,
)     #3
prompt_template = OpenAIChatPromptTemplate(
    "{{$user_input}}", kernel.prompt_template_engine, prompt_config
)     #4

history = ChatHistory()

history.add_system_message("You recommend movies and TV Shows.")
history.add_user_message("Hi there, who are you?")
history.add_assistant_message(
    "I am Rudy, the recommender chat bot. I'm trying to figure out what people need."
)     #5

chat_function = kernel.create_function_from_prompt(
    prompt_template_config=prompt_template,
    plugin_name="ChatBot",
    function_name="Chat",
)     #6

#1 导入TMDbService作为插件
#2 配置执行设置并添加过滤掉的工具
#3 配置提示模板配置
#4 定义输入模板,并将完整字符串作为用户输入
#5 添加聊天历史对象并填充部分历史
#6 创建聊天函数

接下来,我们继续滚动查看文件中的聊天函数,如下所示。

列表5.22 SK_service_chat.py(聊天函数)
async def chat() -> bool:
    try:
        user_input = input("User:> ")     #1
    except KeyboardInterrupt:
        print("\n\nExiting chat...")
        return False
    except EOFError:
        print("\n\nExiting chat...")
        return False

    if user_input == "exit":     #2
        print("\n\nExiting chat...")
        return False
    arguments = sk.KernelArguments(     #3
        user_input=user_input,
        history=("\n").join(
           [f"{msg.role}: {msg.content}" for msg in history]),
    )
    result = await chat_completion_with_tool_call(     #4
        kernel=kernel,
        arguments=arguments,
        chat_plugin_name="ChatBot",
        chat_function_name="Chat",
        chat_history=history,
    )
    print(f"AI Agent:> {result}")
    return True

#1 用户输入直接从终端/控制台读取
#2 如果用户输入“exit”,则退出聊天
#3 创建传递给函数的参数
#4 使用工具调用的实用函数来调用函数并执行工具

最后,滚动到文件的底部,查看主函数。这是一个循环调用聊天函数的代码。

列表5.23 SK_service_chat.py(主函数)
async def main() -> None:
    chatting = True
    context = kernel.create_new_context()

    print("Welcome to your first AI Agent\n  Type 'exit' to exit.\n  Ask to get a list of currently playing movies by genre.")  #1
    while chatting:     #2
        chatting, context = await chat(context)     #3

if __name__ == "__main__":
    asyncio.run(main())

#1 向用户介绍
#2 持续运行直到聊天结束
#3 异步调用聊天函数

运行聊天界面,运行文件(F5),然后询问关于特定类型的电影或电视节目的问题。列表5.24展示了一个示例对话会话。该输出显示了如何请求列出两个类型的电影,使聊天界面对get_top_movie_by_genre函数发出多次调用。

列表5.24 SK_service_chat.py(示例对话)
Welcome to your first AI Agent
  Type 'exit' to exit.
  Ask to get a list of currently playing movies by genre.
User:> Input: can you give me list of the current top playing movies for 
the action and comedy genres?

Function name: get_top_movies_by_genre     #1
Arguments:
  genre = action
Function name: get_movie_genre_id     #2
Arguments:
  genre_name = action
Function name: get_top_movies_by_genre    #1
Arguments:
  genre = comedy
Function name: get_movie_genre_id    #2
Arguments:
  genre_name = comedy
Agent:> Here are the current top-playing movies 
for the action and comedy genres:

**Action:**     #3
1. The Hunger Games: The Ballad of Songbirds & Snakes
2. Rebel Moon - Part One: A Child of Fire
3. Aquaman and the Lost Kingdom
4. Silent Night
5. The Family Plan
6. Freelance
7. Migration
8. Sound of Freedom
9. Godzilla Minus One

**Comedy:**     #4
1. The Family Plan
2. Wonka
3. Freelance
4. Saltburn
5. Chicken Run: Dawn of the Nugget
6. Trolls Band Together
7. There's Something in the Barn
8. Migration

Please note that some movies may overlap in both genres, such as 
"The Family Plan" and "Freelance."

#1 LLM对get_top_movies_by_genre进行两次调用
#2 内部调用get_movie_genre_id
#3 当前热门动作电影列表
#4 当前热门喜剧电影列表

确保探索聊天界面的边界,以及您可以从TMDB服务请求到的内容。例如,尝试请求列出电影或电视节目的类型。这个服务是一个很好的初步尝试,但我们或许可以做得更好,正如我们将在下一节看到的那样。

5.6 写语义服务时的语义思维

现在我们已经看到了将API转换为语义服务接口的出色演示。当前,函数返回的是当前播放的热门电影和电视节目的标题。然而,仅仅返回标题限制了LLM解析结果的能力。

因此,我们将创建TMDbService的v2版本来解决这个问题,并将结果作为JSON字符串返回。在VS Code中打开tmdb_v2.py文件,向下滚动到get_top_movies_by_genre函数。

列表5.25 tmdb_v2.py(get_top_movies_by_genre)
def get_top_movies_by_genre(self, genre: str) -> str:
        print_function_call()
        genre_id = self.get_movie_genre_id(genre)
        if genre_id:
            # same code …

            return json.dumps(filtered_movies)     #1
        else:
            return ""

#1 现在返回过滤后的列表作为JSON字符串

现在,在VS Code中打开SK_service_chat.py文件,并注释掉并取消注释列表5.26中所示的行。这样,您将使用v2版本的TMDbService,该版本将结果输出为单一字符串中的完整JSON文档。

列表5.26 SK_service_chat.py(修改导入)
#from skills.Movies.tmdb import TMDbService     #1
from skills.Movies.tmdb_v2 import TMDbService     #2
#1 注释掉这一行。
#2 取消注释这一行,以使用版本2的服务。

重新运行VS Code中的SK_service_chat.py文件,并稍微调整您的查询,如下所示的输出所示。

列表5.27 SK_service_chat.py(TMDb_v2服务输出)
User:> get a list of currently playing movies for the 
action genre and only return movies about space     #1
Agent:> To find currently playing action movies that are specifically 
about space, I will need to manually filter the provided list for those 
that have space-related themes in their overview. Here's what fits that 
criteria from the list:

1. **Rebel Moon - Part One: A Child of Fire**     #2
   - Release Date: 2023-12-15
   - Overview: When a peaceful colony on the edge of the galaxy finds 
itself threatened by the armies of the tyrannical Regent Balisarius, 
they dispatch Kora, a young woman with a mysterious past, to seek out 
warriors from neighboring planets to help them take a stand.

This is the only movie from the provided list that clearly mentions a 
space-related theme in its overview. …

#1 新查询要求添加一个关于太空的额外筛选条件
#2 LLM调用服务并查看符合筛选条件的返回结果

由于语义服务函数现在返回完整的电影列表作为JSON,LLM可以应用额外的过滤。这就是语义服务的真正力量,允许您通过LLM处理数据。如果我们仅返回标题列表,就无法看到这种能力。

最后的练习展示了编写语义服务层时需要改变的思维方式。通常,您会希望返回尽可能多的信息。返回更多信息可以利用LLM的能力,独立地过滤、排序和转换数据。在下一章中,我们将探讨如何使用行为树构建自治代理。

5.7 练习

完成以下练习,以提高您对材料的理解:

练习 1—创建一个温度转换的基础插件

目标 —熟悉为OpenAI聊天完成API创建一个简单的插件。
任务:

  • 开发一个插件,能够在摄氏度和华氏度之间进行温度转换。
  • 通过将插件集成到一个简单的OpenAI聊天会话中进行测试,用户可以在其中请求温度转换。
练习 2—开发一个天气信息插件

目标 —学习创建一个执行特定任务的插件。
任务:

  • 为OpenAI聊天完成API创建一个插件,从公共API获取天气信息。
  • 确保插件能够处理用户请求,获取不同城市的当前天气情况。
练习 3—制作一个创意语义功能

目标 —探索创建语义功能的过程。
任务:

  • 开发一个语义功能,根据用户输入创作诗歌或讲述儿童故事。
  • 在聊天会话中测试该功能,确保其生成创意且连贯的输出。
练习 4—通过本地功能增强语义功能

目标 —理解如何将语义功能和本地功能结合使用。
任务:

  • 创建一个语义功能,使用本地功能增强其能力。
  • 例如,开发一个语义功能,生成膳食计划,并使用本地功能获取食材的营养信息。
练习 5—将现有的Web API封装为语义内核

目标 —学习如何将现有的Web API封装为语义服务插件。
任务:

  • 使用SK将新闻API封装,并将其作为语义服务插件暴露给聊天代理。
  • 确保插件能够处理用户请求,获取各种主题的最新新闻文章。

总结

  • 代理动作扩展了代理系统(如ChatGPT)的功能。这包括将插件添加到ChatGPT和LLM,使它们能够充当动作的代理。

  • OpenAI支持在OpenAI API会话中定义功能和插件。这包括将功能定义添加到LLM API调用中,并理解这些功能如何使LLM执行额外的动作。

  • 语义内核(SK)是微软的一个开源项目,可用于构建AI应用和代理系统。它包括语义插件在定义本地和语义功能中的作用。

  • 语义功能封装了用于与LLM交互的提示/配置文件模板。

  • 本地功能封装了执行操作的代码,使用API或其他接口。

  • 语义功能可以与其他语义或本地功能结合使用,并作为执行阶段嵌套在一起。

  • SK可以用于在API调用上创建一个GPT接口,位于语义服务层之上,并将其作为聊天或代理接口插件暴露。

  • 语义服务代表了LLM与插件之间的交互,以及在创建高效AI代理时这些概念的实际应用。