微软-SemanticKernel-人工智能应用构建指南-全-

微软 SemanticKernel 人工智能应用构建指南(全)

原文:zh.annas-archive.org/md5/b048cd406070a1a3bf0ce90bfc65c7cb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

人工智能正经历前所未有的增长,每天都有新的模型出现。拥有 20 多年的科技行业经验,我可以证实创新的速度从未如此之快。这不仅带来了机遇,也带来了相当大的变化。导航这些变化可能会很具挑战性和成本高昂,因为你可能会投入大量时间学习可能变得过时的新技术。

进入 Microsoft Semantic Kernel——一个通过启用通过流行的编程语言访问各种 AI 服务来降低这些风险的框架。这个框架让你免于处理不断演变的 API 的细节。通过学习 Microsoft Semantic Kernel,你可以在框架级别编写代码,框架会为你调用底层模型。这让你可以专注于核心概念,而不是每个模型的细节。

Semantic Kernel 的关键优势之一是能够使用不同的 AI 服务。例如,最初针对 OpenAI GPT 平台的代码可以切换到 Google Gemini,通常无需任何修改。这种灵活性使得将 AI 集成到应用程序中以及当变化不可避免时对其进行最小修改变得更加容易。

此外,Semantic Kernel 使 AI 对企业编程语言变得可访问。虽然 Python 长期以来一直主导着 AI 领域,但许多企业应用程序依赖于 C#或 Java。认识到这一点,Semantic Kernel 不仅支持 Python,还将 C#提升为第一类 AI 语言。Java 支持目前处于测试阶段,预计将在 2024 年全面推出。

无论你是独立开发者还是大型企业的一部分,将 AI 功能添加到应用程序中的需求是不可避免的。本书旨在为你提供必要的技能,以便快速有效地实施 AI,确保你为满足不断增长的需求做好准备。

本书面向的对象

本书的目标受众是以下三个主要角色:

  • 开发者们希望在不增加连接多个不同服务复杂性以及维护每次发布后连接的情况下,将人工智能添加到他们的应用程序中

  • 技术项目经理们可以编写一些代码,并希望快速原型化 AI 功能,而无需学习细节

  • 应用研究人员和数据科学家们希望将他们的时间集中在快速解决商业问题上,而不是专注于不断变化的连接和使用人工智能服务的方式

本书涵盖的内容

第一章介绍 Microsoft Semantic Kernel,介绍了几个 AI 概念,并展示了 Semantic Kernel 可以帮助你实现什么,展示了如何连接到 AI 服务并使用它来实现目标。

第二章创建更好的提示,教您几种与 AI 互动的技巧,提高您在第一次尝试就能得到好结果的机会,使用一个称为提示工程的概念。

第三章扩展 Semantic Kernel,教您如何通过添加原生函数语义函数来为 Semantic Kernel 添加功能,这些函数以后可以由您作为开发者或您的用户重用,以实现他们的目标。

第四章通过链式函数执行复杂操作,展示了如何按顺序使用内核的几个函数,使编程复杂操作变得容易得多。

第五章使用规划器编程,探讨了 Semantic Kernel 如何接收自然语言请求并自动决定调用哪些函数以实现目标,使用户能够执行您不必编写代码的功能。

第六章将记忆添加到您的 AI 应用程序中,探讨了如何将外部知识添加到 Semantic Kernel 使用的 AI 模型中,使 AI 模型更容易记住最近的对话和个人化。

第七章现实世界用例 – 检索增强生成,展示了如何将大量数据添加到 AI 模型中,使它们能够有效地使用它们未接受过训练的信息,包括非常最近和私人数据。

第八章现实世界用例 – 在 ChatGPT 上使您的应用程序可用,展示了如何将您使用 Microsoft Semantic Kernel 编写的应用程序发布到 OpenAI 的 GPT 商店,使其立即对数百万用户可用。

要充分利用本书

读者需要熟悉 Python 或 C#的编程,并熟悉在云上创建和连接到 AI 服务。

本书涵盖的软件/硬件 操作系统要求
Python 3.11 Windows、macOS 或 Linux
.NET 8
OpenAI GPT-3.5 和 GPT-4

对于第七章,您需要在 Azure AI 搜索中创建一个索引。有一个免费层。对于第八章,要创建与他人共享的 GPT,您需要一个 ChatGPT 订阅。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误

下载示例代码文件

您可以从 GitHub 下载这本书的示例代码文件 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们吧!

使用的约定

在这本书中使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“由于我们的提示将是新函数并且具有多个参数,我们还需要创建一个新的 config.json 文件。”

代码块应如下设置:

    response = await kernel.invoke(pe_plugin["chain_of_thought"], KernelArguments(problem = problem, input = solve_steps))
    print(f"\n\nFinal answer: {str(response)}\n\n")

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都应如下所示:

dotnet add package Microsoft.SemanticKernel.
s.Handlebars --version 1.0.1-preview

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以 粗体 显示。以下是一个示例:“一旦完成这些配置,点击 Review + create,您的 Web 应用程序将在几分钟内部署。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈: 如果您对这本书的任何方面有疑问,请通过 mailto:customercare@packtpub.com 给我们发邮件,并在消息主题中提及书籍标题。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 mailto:copyright@packt.com 与我们联系,并在消息主题中提及书籍标题。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了 Building AI Applications with Microsoft Semantic Kernel,我们很乐意听到您的想法!请点击此处直接访问此书的 Amazon 评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载这本书的免费 PDF 副本

感谢您购买这本书!

你喜欢在路上阅读,但又无法携带你的印刷书籍到处走?

你的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的访问权限

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781835463703

  1. 提交你的购买证明

  2. 就这些了!我们将直接将你的免费 PDF 和其他优惠发送到你的电子邮件中

第一部分:生成式 AI 和微软语义内核简介

在这部分,你将了解生成式 AI 及其如何与微软语义内核一起使用,此外,你还将学习到一些最佳实践,这些实践不仅在你使用语义内核时有用,而且在你自己与 AI 互动时也同样有用。

本部分包括以下章节:

第一章**,介绍微软 语义内核

第二章**,创建 更好的提示

第一章:介绍微软语义内核

生成式人工智能GenAI)领域正在快速发展,每周都有数十种新产品和服务推出;对于开发者来说,跟上每个服务不断变化的特性和应用程序编程接口API)变得越来越困难。在这本书中,你将了解微软语义内核,这是一个 API,它将使你作为开发者使用 GenAI 变得更加容易,使你的代码更短、更简单、更易于维护。微软语义内核将允许你作为开发者使用一个单一接口连接到多个不同的 GenAI 提供商。微软使用语义内核开发了其协同飞行员,例如微软 365 协同飞行员。

数亿人已经作为消费者使用 GenAI,你可能就是其中之一。我们将通过展示一些你作为消费者可以使用 GenAI 的例子来开始本章。然后,你将学习如何作为开发者开始使用 GenAI,将 AI 服务添加到你的应用程序中。

在本章中,你将学习使用 GenAI 作为用户和作为开发者的区别,以及如何使用微软语义内核创建和运行一个简单的端到端请求。这将帮助你看到语义内核是多么强大和简单,并将作为所有后续章节的框架。它将使你能够立即开始将 AI 集成到自己的应用程序中。

在本章中,我们将介绍以下主题:

  • 理解像 ChatGPT 这样的生成式 AI 应用的基本用法

  • 安装微软语义内核

  • 配置语义内核以与 AI 服务交互

  • 使用语义内核运行简单任务

技术要求

要完成本章,你需要拥有你首选的 Python 或 C#开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是.NET 8

重要提示

示例以 C#和 Python 展示,你可以选择只阅读你偏好的语言的示例。偶尔,某个功能只在一个语言中可用。在这种情况下,我们提供另一种语言中的替代方案,以实现相同的目标。

在本章中,我们将调用 OpenAI 服务。鉴于公司在训练这些大型语言模型(LLMs)上花费的金额,使用这些服务不是免费的也就不足为奇了。你需要一个OpenAI API密钥,可以通过OpenAI微软直接获得,或者通过Azure OpenAI服务。

重要:使用 OpenAI 服务不是免费的

本书将运行的示例将调用 OpenAI API。这些调用需要付费订阅,并且每次调用都会产生费用。通常,每个请求的费用很小(例如,GPT-4 每千个标记的费用高达 0.12 美元),但它们可能会累积。此外,请注意,不同模型的价格不同,GPT-3.5 的每标记价格比 GPT-4 低 30 倍。

OpenAI 的定价信息可在此处查看:openai.com/pricing

Azure OpenAI 的定价信息可在此处查看:azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/

如果您使用 .NET,本章的代码位于github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch1

如果您使用 Python,本章的代码位于github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch1

您可以通过访问 GitHub 仓库并使用以下命令安装所需的软件包:pip install -r requirements.txt

获取 OpenAI API 密钥

  1. 访问 OpenAI 平台网站(platform.openai.com)。

  2. 注册新账户或使用现有账户登录。您可以使用电子邮件或现有的 Microsoft、Google 或 Apple 账户。

  3. 在左侧侧边栏菜单中选择 API 密钥

  4. 项目 API 密钥 界面中,点击标有 + 创建新的密钥 的按钮(可选,为其命名)。

重要

您必须立即复制并保存密钥。一旦点击 完成,它就会消失。如果您没有复制密钥或丢失了密钥,您需要生成一个新的。生成新密钥没有费用。请记住删除旧密钥。

获取 Azure OpenAI API 密钥

目前,您需要提交申请以获取访问 Azure OpenAI 服务的权限。要申请访问权限,您需要在aka.ms/oai/access填写表格。

获取 Azure OpenAI API 密钥的说明可在learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource找到。

生成式 AI 及其使用方法

生成式 AI 指的是一类人工智能程序,这些程序能够创建与人类生产的内容相似的内容。这些系统使用来自非常大数据集的训练来学习它们的模式、风格和结构。然后,它们可以生成全新的内容,例如合成的图像、音乐和文本。

使用 GenAI 作为消费者或最终用户非常简单,作为一个技术人员,你可能已经这样做了。有许多面向消费者的 AI 产品。最著名的是 OpenAI 的 ChatGPT,但还有许多其他产品,每天有数亿用户,例如 Microsoft Copilot、Google Gemini(以前称为 Bard)和 Midjourney。截至 2023 年 10 月,Facebook、WhatsApp 和 Instagram 的母公司 Meta 正在将其 GenAI 服务提供给所有用户,使 GenAI 的日用户数量增加到数十亿。

虽然 GenAI 的概念已经存在了一段时间,但它在 2022 年 11 月 OpenAI 发布 ChatGPT 后获得了大量用户。ChatGPT 的初始版本是基于名为 生成式预训练转换器GPT)的 3.5 版本。这个版本在模仿人类写作的任务上比早期版本要好得多。此外,OpenAI 通过添加类似聊天机器人的界面并使其对公众开放,使其易于使用。这个界面被称为 ChatGPT。有了 ChatGPT,用户可以轻松地用自己的话启动任务。在发布时,ChatGPT 是历史上采用速度最快的产品。

GenAI 概念随着 Midjourney 的发布而进一步普及,Midjourney 是一个应用程序,允许用户通过 Discord(一个流行的聊天应用)提交提示来生成高质量的图像,以及 Microsoft Copilot(一个免费的网络应用),它可以通过使用 OpenAI 的 GPT-4(OpenAI 的 GPT 最新版本)生成文本,并通过使用名为 DALL-E 3 的 OpenAI 模型生成图像。

在接下来的小节中,我们将讨论使用 GenAI 应用程序生成文本和图像,并解释使用 ChatGPT 等应用程序与作为开发人员使用 API 生成它们之间的区别。

文本生成模型

GenAI 的初始用例是根据一个简单的指令生成文本,这个指令被称为 prompt

大多数基于文本的 GenAI 产品背后的技术被称为 transformer,它在 2017 年的论文 Attention is All you Need [1] 中被引入。transformer 极大地提高了生成文本的质量,仅在几年内,文本看起来就非常类似于人类生成的文本。transformer 在训练了大量的文档(一个 语料库)后,极大地提高了 AI 在短语中猜测被掩盖的单词的能力。在非常大的语料库上训练的模型被称为 大型语言模型LLMs)。

如果给 LLMs 一个像“我去快餐店去 ”这样的短语,它们可以为 X 生成好的选项,例如“”。重复应用 transformer 可以生成连贯的短语甚至故事。下一个迭代可能是“我去快餐店去吃 ”,返回“a”,然后“我去快餐店去吃 a ”,可能会返回“burger”,形成完整的短语“我去快餐店去吃 一个汉堡”。

LLM 模型的性能取决于参数数量,这大致与模型一次可以进行的比较数量、上下文窗口、一次可以处理的文本最大尺寸以及用于训练模型的训练数据成正比,这些数据通常由创建 LLM 的公司保密。

GPT 是由 OpenAI 创建的一个使用 Transformer 的模型,擅长生成文本。GPT 有许多版本:

2018 年 2 月发布的 GPT-1 拥有 1.2 亿个参数和一个 512 个标记的上下文窗口。

2019 年 2 月发布的 GPT-2,参数数量增加到 15 亿,上下文窗口增加到 1024 个标记。到目前为止,尽管它们有时会产生有趣的结果,但这些模型主要被学者们使用。

这种情况在 2020 年 6 月发布的 GPT-3 中发生了变化,它有几个尺寸:小型、中型、大型和超大型。超大型拥有 1750 亿个参数和 2048 个标记的上下文窗口。生成的文本在大多数情况下难以与人类生成的文本区分开来。OpenAI 随后发布了 GPT-3.5,于 2022 年 11 月发布,参数数量仍为 1750 亿,上下文窗口为 4096 个标记(现在已扩展到 16384 个标记),并推出了名为 ChatGPT 的用户界面。

ChatGPT 是一个使用后台 GPT 模型的网页和移动应用程序,允许用户向 GPT 模型提交提示并在线获取响应。它与 GPT-3.5 同时发布,当时是采用率最快的消费产品,不到两个月就达到了一亿用户。

2023 年 2 月,微软发布了 Bing Chat,该产品也使用 OpenAI 的 GPT 模型作为后端,进一步普及了 Transformer 模型和 AI 的使用。最近,微软将其更名为 Microsoft Copilot。

仅仅一个月后,在 2023 年 3 月,OpenAI 发布了 GPT-4 模型,该模型很快就被集成到 ChatGPT 和 Bing 等消费产品的后端。

关于 GPT-4 模型的所有细节尚未向公众发布。已知其上下文窗口可达 32,768 个标记;然而,其参数数量尚未公开,但据估计为 1.8 万亿。

GPT-4 模型在涉及文本生成的类似人类任务方面表现突出。GPT-4 技术报告学术论文 [2] 中展示的基准测试显示了 GPT-3.5 和 GPT-4 在考试中的表现。GPT-4 可以通过许多高中和大学水平的考试。您可以在 doi.org/10.48550/arXiv.2303.08774 阅读这篇论文。

理解应用和模型之间的区别

包括你在内的大多数人可能都使用过 GenAI 应用程序,例如 ChatGPT、Microsoft Copilot、Bing Image Creator、Bard(现在更名为 Gemini)或 Midjourney。这些应用程序在其后端使用 GenAI 模型,但它们也添加了用户界面和配置,这些配置限制了并控制了模型的输出。

当你在开发自己的应用程序时,你需要自己完成这些事情。你可能还没有意识到像 Bing 和 ChatGPT 这样的应用程序在幕后执行了多少工作。

当你向应用程序提交提示时,应用程序可能会在你提交的提示中添加几个额外的指令。最典型的是添加限制某些类型输出的指令,例如:“你的回复不应包含脏话。”例如,当你向 ChatGPT 这样的应用程序提交“讲一个笑话”的提示时,它可能会修改你的提示为“讲一个笑话。你的回复不应包含脏话”,并将这个修改后的提示传递给模型。

应用程序还可能添加你已经提交的问题和已经给出的答案的摘要。例如,如果你问,“巴西里约热内卢在夏天有多热?”,答案可能是,“里约热内卢在夏天通常在 90 到 100 华氏度(30-40 摄氏度)之间。”如果你接着问,“从纽约到那里的航班有多长?”,像 ChatGPT 这样的应用程序不会直接将“从纽约到那里的航班有多长?”提交给模型,因为答案可能类似于“我不明白你说的‘那里’是什么意思。”

解决这个问题的直接方法是将用户输入的每一项内容以及提供的所有答案保存下来,并在每次新的提示中重新提交它们。例如,当用户在询问温度之后提交“从纽约到那里的航班有多长?”时,应用程序会在提示前添加之前的问题和答案,实际提交给模型的内容是:“巴西里约热内卢在夏天有多热?里约热内卢在夏天通常在 90 到 100 华氏度(30-40 摄氏度)之间。从纽约到那里的航班有多长?”现在,模型知道“那里”指的是“里约热内卢”,答案可能类似于“大约 10 小时。”

将所有之前的提示和响应附加到每个新提示的后果是,它会在上下文窗口中消耗大量空间。因此,已经开发了一些技术来压缩添加到用户提示中的信息。最简单的技术是只保留早期的用户问题,但不包括应用程序给出的答案。在这种情况下,例如,修改后的提示可能类似于“之前我说过:‘巴西里约热内卢夏天有多热?’,现在只需回答:‘从纽约到那里的航班有多长?’”。请注意,提示需要告诉模型只对用户提交的最后一个问题进行响应。

如果您使用消费者应用程序测试您的提示,了解应用程序会修改您的提示将是有意义的,因为您从它们那里获得的输出可能与您直接通过 API(如 Microsoft Semantic Kernel)使用模型时获得的输出大不相同。通常无法知道应用程序是如何修改您的提示的,因为提供商通常不会透露所有他们的技术。

此外,您作为应用程序开发者将要做的大部分工作将是创建与您自己的应用程序相匹配的提示修改。因此,当您的用户提交他们的提示时,您将添加自己的提示修改以确保他们获得适当的结果。修改用户提示的技术被称为提示工程,我们将在下一章中简要探讨。

使用消费者应用程序生成文本

让我们探索两个最受欢迎的文本生成应用程序:ChatGPT 和 Microsoft Copilot。如果您不熟悉 GenAI 的力量,尝试它们将让您了解它们可以做什么以及它们的强大之处。我们还将简要讨论它们的配置参数和架构,这可以帮助您决定自己应用程序的适当架构。

OpenAI ChatGPT

如果您从未使用过 GenAI 应用程序,ChatGPT 可能是开始的最佳选择。ChatGPT 的默认后端模型是 GPT 3.5,这是一个快速且非常强大的模型。当 OpenAI 的服务器上有可用容量时,ChatGPT 是免费的。您还可以每月支付 20 美元购买 ChatGPT Plus 订阅,这将使您能够使用他们最强大的模型(目前是 GPT-4),并确保您始终有容量。

要使用 ChatGPT,请访问chat.openai.com

ChatGPT 的界面非常简单。它允许你在顶部选择后端模型,中间提供一些提示建议,底部有一个文本框用于输入提示。它还包括一个通知,指出生成的输出可能包含错误信息:

图 1.1 – 使用 Web 界面向 ChatGPT 提交请求

图 1.1 – 使用 Web 界面向 ChatGPT 提交请求

我将在发送消息文本框中提交以下提示:

纽约市和里约热内卢之间的航班有多长?

使用 GPT-3.5 模型的 ChatGPT 提供了以下答案:

The flight duration between New York City and Rio de Janeiro can vary depending on several factors, including the specific departure and arrival airports, the flight route, and any layovers or stopovers. On average, a non-stop flight between John F. Kennedy International Airport (JFK) in New York City and Rio de Janeiro-Galeão International Airport (GIG) in Rio de Janeiro takes approximately 9 to 10 hours.
Keep in mind that flight durations can vary, so it's always a good idea to check with airlines for the most up-to-date and accurate flight schedules and durations for your specific travel dates and routes. Additionally, if you have layovers or stopovers, the total travel time will be longer.

微软 Copilot

另一个免费的选择是微软 Copilot,之前称为 Bing Chat。它可以从www.bing.com页面访问,但也可以直接从www.bing.com/chat访问。

微软 Copilot 的用户界面类似于 ChatGPT 的界面。屏幕中间有一些提示建议,以及一个文本框,用户可以在底部输入提示。微软 Copilot 的 UI 还显示了一些在使用模型编程时相关的选项。

第一是对话风格。Copilot 提供了更创意、更平衡或更精确的选项。这与将传递给底层模型的温度参数相关。我们将在第三章中讨论温度参数,但简而言之,温度参数决定了 LLM 选择的单词有多常见。

微软 Copilot 的参数

虽然微软 Copilot 没有透露确切的配置值(例如00.2),这导致对下一个词的猜测非常安全。对于0.40.6),结果主要是安全的猜测,但偶尔会有一些罕见的猜测。0.8。大多数猜测仍然会是安全的,但会有更多罕见的单词。由于 LLM 按顺序猜测短语中的单词,先前的猜测会影响后续的猜测。在生成短语时,每个罕见的单词都会使整个短语更加不寻常。

UI 中另一个有趣的组件是文本框的右下角显示了已经输入了多少个字符,这让你对将消耗多少底层模型的上下文窗口有一个大致的了解。请注意,你无法确切知道你会消耗多少,因为 Copilot 应用程序会修改你的提示。

图 1.2 – 微软 Copilot 用户界面

图 1.2 – 微软 Copilot 用户界面

2023 年 8 月 11 日,必应前 CEO 米哈伊尔·帕拉欣在 X/Twitter 上发帖称,微软 Copilot 的表现优于 GPT-4,因为它使用了检索增强推理(x.com/MParakhin/status/1689824478602424320?s=20):

图 1.3 – 必应前 CEO 关于微软 Copilot 使用 RAG 的帖子

图 1.3 – 必应前 CEO 关于微软 Copilot 使用 RAG 的帖子

我们将在第六章和第七章中更详细地讨论检索增强推理,但就我们当前的目的而言,这意味着微软 Co-Pilot 不会直接将您的提示提交给模型。必应尚未公开其架构的细节,但很可能是必应修改了您的提示(在 UI 中的搜索中显示了修改后的提示),使用修改后的提示进行常规的必应查询,收集该查询的结果,将它们连接起来,并将连接的结果作为大提示提交给 GPT 模型,要求它将结果组合起来输出一个连贯的答案。

使用检索增强允许必应更容易地添加引用和广告。在下图中,请注意我的提示How long is the flight between New York City and Rio de Janeiro?被 Co-Pilot 修改为Searching for flight duration New York City Rio de Janeiro

图 1.4 – 使用微软 Co-Pilot 的示例

图 1.4 – 使用微软 Co-Pilot 的示例

如您所见,您可以使用 ChatGPT 和 Microsoft Copilot 等消费级应用程序来熟悉 LLMs 在 GenAI 中的应用,并对您的提示进行一些初步测试,但请注意,您提交的提示可能会被应用程序大量修改,并且从底层模型获得的响应可能与您实际创建自己的应用程序时获得的响应非常不同。

生成图像

除了生成文本,AI 还可以根据文本提示生成图像。用于从提示生成图像的过程细节超出了本书的范围,但我们将提供一个简要概述。图像生成领域的主要模型包括 Midjourney,它可通过 Discord 中的 Midjourney 机器人访问;开源的 Stable Diffusion,它也被 OpenAI 的 DALL-E 2 使用;以及 2023 年 10 月发布的 DALL-E 3,可通过 Bing Chat(现在称为 Microsoft Copilot)和 ChatGPT 应用程序访问。

在撰写本文时,微软语义内核仅支持 DALL-E;因此,这是我们将要探讨的示例。DALL-E 3 可通过 Microsoft Copilot 应用程序免费使用,但有一些限制。

如果您正在使用前面示例中的 Microsoft Copilot 应用程序,请确保通过点击文本框左侧的新主题按钮重置您的聊天历史。要生成图像,请确保您的对话风格设置为更富有创意,因为图像生成仅在创意模式下工作:

图 1.5 – 选择对话风格

图 1.5 – 选择对话风格

我将使用以下提示:

create a photorealistic image of a salt-and-pepper standard schnauzer on a street corner holding a sign "Will do tricks for cheese."

微软 Co-Pilot 将调用 DALL-E 3 并根据我的要求生成四幅图像:

图 1.6 – 由 Microsoft Copilot 生成的图像

图 1.6 – 由 Microsoft Copilot 生成的图像

DALL-E 3 比其他图像生成模型更好的一个方面是它可以正确地将文本添加到图像中。DALL-E 的早期版本和大多数其他模型都不能正确拼写单词。

图片以网格形式展示,总分辨率为 1024 x 1024 像素(每张图片为 512 x 512 像素)。如果您选择一张图片,该图片将被放大到 1024 x 1024 像素的分辨率。在我的情况下,我将选择左下角的图片。您可以在下一张图中看到最终结果:

图 1.7 – 由 Microsoft Copilot 生成的高分辨率图像

图 1.7 – 由 Microsoft Copilot 生成的高分辨率图像

如您所见,生成式 AI 也可以用来生成图像,现在您已经了解到它有多么强大。我们将在第四章中探讨如何使用 Microsoft 语义内核生成图像。在达到那里之前,还有很多东西可以探索,我们将从对 Microsoft 语义内核的快速全面浏览开始。

Microsoft 语义内核

Microsoft 语义内核(github.com/microsoft/semantic-kernel)是一个轻量级的开源软件开发工具包(SDK),它使得使用 C#和 Python 开发的应用程序与 AI 服务(如通过 OpenAI、Azure OpenAI 和 Hugging Face 提供的 AI 服务)交互变得更加容易。语义内核可以接收来自您应用程序的请求并将它们路由到不同的 AI 服务。此外,如果您通过添加自己的函数扩展了语义内核的功能,我们将在第三章中探讨这一点,语义内核可以自动发现哪些函数需要使用以及使用顺序,以满足请求。请求可以直接来自用户并通过您的应用程序直接传递,或者您的应用程序可以在将其发送到语义内核之前修改和丰富用户请求。

它最初是为了为不同版本的 Microsoft Copilot 提供动力,例如 Microsoft 365 Copilot 和 Bing Copilot,然后作为开源软件包发布给开发者社区。开发者可以使用语义内核创建插件,这些插件可以使用 AI 服务执行复杂操作,并且只需几行代码就可以组合这些插件。

此外,Semantic Kernel 可以通过使用 规划器 自动编排不同的插件。使用规划器,用户可以让您的应用程序实现一个复杂的目标。例如,如果您有一个识别图片中是哪种动物的功能,以及另一个讲打趣笑话的功能,您的用户可以说,“告诉我一个关于这个 URL 中图片中动物的打趣笑话”,规划器将自动理解它需要首先调用识别功能,然后是“讲笑话”功能。Semantic Kernel 将自动搜索和组合您的插件以实现该目标并创建一个计划。然后,Semantic Kernel 将执行该计划并向用户提供响应:

图 1.8 – Microsoft Semantic Kernel 的结构

图 1.8 – Microsoft Semantic Kernel 的结构

在接下来的章节中,我们将快速浏览 Semantic Kernel 的端到端流程,包括 图 1**.8 中的大多数步骤。我们将发送请求,创建计划,调用 API,调用本地函数和语义函数。这些组合起来将为用户提供答案。首先,我们将手动逐步完成这个过程,然后我们将使用规划器一次性完成所有操作。您将看到 Semantic Kernel 只需少量代码就能多么强大。

在我们开始使用 Microsoft Semantic Kernel 进行实验之前,我们需要安装它。

安装 Microsoft Semantic Kernel 包

要使用 Microsoft Semantic Kernel,您必须在您的环境中安装它。请注意,Microsoft Semantic Kernel 仍在积极开发中,不同版本之间可能存在差异。

在 Python 中安装 Microsoft Semantic Kernel

要在 Python 中安装 Microsoft Semantic Kernel,请在一个新目录中开始,并按照以下步骤操作:

  1. 使用 venv 创建一个新的虚拟环境:

    python -m venv .venv
    
  2. 激活您刚刚创建的新环境。这将确保 Microsoft Semantic Kernel 只为此目录安装:

    • 在 PowerShell 中,使用以下命令:

      pip:
      
      

      pip install semantic-kernel

      
      

在 C# 中安装 Microsoft Semantic Kernel

要在 C# 中安装 Microsoft Semantic Kernel,请按照以下步骤操作:

  1. 创建一个以 .NET 8 为目标的新项目:

    dotnet new console -o ch1 -f net8.0
    
  2. 切换到应用程序目录:

    Microsoft.SemanticKernel NuGet package:
    
    

    dotnet add package Microsoft.SemanticKernel --prerelease

    
    The kernel object itself is very lightweight. It is simply a repository of all the services and plugins that are connected to your application. Most applications start by instantiating an empty kernel and then adding services and functions to it.
    

使用以下简单指令运行程序以确保安装成功:

  • 在 Python 中实例化内核:

    import semantic_kernel as sk
    kernel = sk.Kernel()
    
  • 在 C# 中实例化内核:

    using Microsoft.SemanticKernel;
    Kernel kernel = Kernel.CreateBuilder().Build()
    

现在我们已经将 Semantic Kernel 安装到您的环境中,现在我们准备将其连接到 AI 服务并开始使用它们。

使用 Semantic Kernel 连接到 AI 服务

要完成本节,您必须有一个 API 密钥。获取 API 密钥的过程在本章开头已描述。

在接下来的子章节中,我们只将连接到 OpenAI 的文本模型 GPT-3.5 和 GPT-4。如果您通过 Azure 访问 OpenAI 模型,您需要对您的代码进行一些小的修改。

虽然连接到单个模型会更简单,但我们已经展示了简单但强大的 Microsoft Semantic Kernel 功能:我们将连接到两个不同的模型,并使用更简单但成本更低的模型 GPT-3.5 运行一个简单的提示,并在更先进但成本更高的模型 GPT-4 上运行一个更复杂的提示。

向更简单的模型发送更简单的请求,向更复杂的模型发送更复杂的请求,这是您在创建自己的应用程序时经常会做的事情。这种方法被称为 LLM 级联,它在 FrugalGPT [3] 论文中被普及。它可以带来实质性的成本节约。

重要:顺序很重要

加载您的服务的顺序很重要。对于 Python(使用 Python 连接到 OpenAI 服务 部分的 步骤 3 中)和 C#(使用 C# 连接到 OpenAI 服务 部分的 步骤 4 中),我们首先将 GPT-3.5 模型加载到内核中,然后加载 GPT-4 模型。这将使 GPT-3.5 成为默认模型。稍后,我们将指定哪个模型将用于哪个命令;如果没有指定,则使用 GPT-3.5。如果您首先加载 GPT-4,您将承担更多费用。

我们假设您正在使用 OpenAI 服务而不是 Azure OpenAI 服务。您将需要您的 OpenAI 密钥和组织 ID,这些可以在 platform.openai.com/account/api-keys 的左侧菜单下的 设置 中找到。所有示例都适用于 Azure OpenAI;您只需使用 Azure 连接信息而不是 OpenAI 连接信息。

使用 Python 连接到 OpenAI 服务

本节假设您正在使用 OpenAI 服务。在连接到 OpenAI 服务之前,在 ch1 目录下创建一个 .env 文件,其中包含您的 OpenAI 密钥和您的 OpenAI 组织 ID。组织 ID 可以在 platform.openai.com/account/api-keys 的左侧菜单下的 设置 中找到。

您的 .env 文件应如下所示,以下示例中的 x 应由适当的值替换:

OPENAI_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
OPENAI_ORG_ID="org-xxxxxxxxxxxxxxxxxxxxxxxx"

要使用 Python 连接到 OpenAI 服务,请执行以下步骤:

  1. 加载一个空内核:

    import semantic_kernel as sk
    kernel = sk.Kernel()
    
  2. 使用 semantic_kernel_utils.settings 包中的 openai_settings_from_dot_env 方法将您的 API 密钥和组织 ID 加载到变量中:

    from semantic_kernel.utils.settings import openai_settings_from_dot_env
    api_key, org_id = openai_settings_from_dot_env()
    
  3. 使用 OpenAIChatCompletion 方法创建到聊天服务的连接:

    from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
    gpt35 = OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id, service_id = "gpt35")
    gpt4 = OpenAIChatCompletion("gpt-4", api_key, org_id, service_id = "gpt4")
    kernel.add_service(gpt35)
    kernel.add_service(gpt4)
    

    如果您通过 Azure 使用 OpenAI,而不是使用 OpenAIChatCompletion,则需要使用 AzureOpenAIChatCompletion,如下所示:

    kernel.add_service(
        AzureChatCompletion(
            service_id=service_id,
            deployment_name=deployment_name,
            endpoint=endpoint,
            api_key=api_key,
        ),
    )
    

您的 Semantic Kernel 现在已准备好进行调用,我们将在 运行简单提示 部分中这样做。

使用 C# 连接到 OpenAI 服务

在连接到 OpenAI 服务之前,在 ch1/config 目录下创建一个 config.json 文件,其中包含您的 OpenAI 密钥和您的 OpenAI 组织 ID。

为了避免在代码中保留密钥,我们将从配置文件中加载您的密钥。您的config/settings.json文件应类似于以下示例,其中apiKeyorgId字段包含适当的值(orgId是可选的。如果您没有orgId,请删除该字段。空字符串不起作用):

{
    "apiKey": "... your API key here ...",
    "orgId": "... your Organization ID here ..."
}

要在 C#中连接到 OpenAI 服务,请执行以下步骤:

  1. 由于我们将多次重用 API 密钥和组织 ID,我们在Settings.cs中创建一个类来加载它们:

    using System.Text.Json;
    public static class Settings {
      public static (string apiKey, string? orgId)
            LoadFromFile(string configFile = "config/settings.json")
        {
            if (!File.Exists(configFile))
            {
                Console.WriteLine("Configuration not found: " + configFile);
                throw new Exception("Configuration not found");
            }
            try
            {
                var config = JsonSerializer.Deserialize<Dictionary>(File.ReadAllText(configFile));
                // check whether config is null
                if (config == null)
                {
                    Console.WriteLine("Configuration is null");
                    throw new Exception("Configuration is null");
                }
                string apiKey = config["apiKey"];
                string? orgId;
                // check whether orgId is in the file
                if (!config.ContainsKey("orgId"))
                {
                    orgId = null;
                }
                else
                {
                    orgId = config["orgId"];
                }
                return (apiKey, orgId);
            }
            catch (Exception e)
            {
                Console.WriteLine("Something went wrong: " + e.Message);
                return ("", "");
            }
        }
    }
    

    上述代码是读取 JSON 文件并将其属性加载到 C#变量中的样板代码。我们正在寻找两个属性:apiKeyorgID

  2. config/settings.json加载设置。我们将创建一个类来简化这个过程,因为我们将会经常这样做。该类非常简单。它首先检查配置文件是否存在,如果存在,则类使用 JSON 反序列化器将内容加载到apiKeyorgId变量中:

    using Microsoft.SemanticKernel;
    var (apiKey, orgId) = Settings.LoadFromFile();
    
  3. 接下来,使用OpenAIChatCompletion方法创建到聊天服务的连接。请注意,我们使用serviceID来为模型提供一个快捷名称。

    在您加载组件后,使用Build方法构建内核:

    Kernel kernel = Kernel.CreateBuilder()
            .AddOpenAIChatCompletion("gpt-3.5-turbo", apiKey, orgId, serviceId: "gpt3")
            .AddOpenAIChatCompletion("gpt-4", apiKey, orgId, serviceId: "gpt4")
                            .Build();
    

    如果您通过 Azure 使用 OpenAI,而不是使用AddOpenAIChatCompletion,则需要使用AddAzureOpenAIChatCompletion,如下所示:

    Kernel kernel = Kernel.CreateBuilder()
                          .AddAzureOpenAIChatCompletion(modelId, endpoint, apiKey)
                          .Build();
    

您的 Semantic Kernel 现在已准备好进行调用,我们将在下一节中这样做。

运行一个简单的提示

本节假设您已完成前面的部分,并在此基础上构建相同的代码。到目前为止,您应该已经实例化了 Semantic Kernel,并按顺序将其中的 GPT-3.5 和 GPT-4 服务加载进去。当您提交提示时,它将默认使用第一个服务,并在 GPT-3.5 上运行提示。

当我们将提示发送到服务时,我们还会发送一个名为0.01.0的参数,它控制响应的随机性。我们将在后面的章节中更详细地解释温度参数。温度参数为0.8会产生更具创造性的响应,而温度参数为0.2会产生更精确的响应。

要将提示发送到服务,我们将使用名为create_semantic_function的方法。现在,您不必担心什么是语义函数。我们将在使用生成式 AI 解决简单 问题部分中解释它。

在 Python 中运行一个简单的提示

要在 Python 中运行提示,请按照以下步骤操作:

  1. 在一个字符串变量中加载提示:

    prompt = "Finish the following knock-knock joke. Knock, knock. Who's there? Dishes. Dishes who?"
    
  2. 通过使用内核的add_function方法创建一个函数。function_nameplugin_name参数是必需的,但它们不会被使用,因此您可以给函数和插件取任何您想要的名称:

    prompt_function = kernel.add_function(function_name="ex01", plugin_name="sample", prompt=prompt)
    
  3. 调用函数。请注意,所有调用方法都是异步的,因此您需要使用await来等待它们的返回:

    response = await kernel.invoke(prompt_function, request=prompt)
    
  4. 打印响应:

    print(response)
    

响应是随机的。以下是一个可能的响应示例:

Dishes the police, open up!

在 C#中运行一个简单的提示

要在 C#中运行提示,请按照以下步骤操作:

  1. 在一个字符串变量中加载提示:

    string prompt = "Finish the following knock-knock joke. Knock, knock. Who's there? Dishes. Dishes who?";
    
  2. 使用Kernel.InvokePromptAsync函数调用提示:

    var joke = await kernel.InvokePromptAsync(prompt);
    
  3. 打印响应:

    Console.Write(joke)
    

响应是非确定性的。以下是一个可能的响应:

Dishes a very bad joke, but I couldn't resist!

我们现在已连接到 AI 服务,向其提交了一个提示,并获得了响应。我们现在可以开始创建我们自己的函数。

使用生成式 AI 解决简单问题

Microsoft Semantic Kernel 区分可以加载到其中的两种类型的函数:语义函数原生函数

语义函数是与 AI 服务(通常是 LLMs)连接以执行任务的函数。该服务不是你代码库的一部分。原生函数是用你的应用程序语言编写的常规函数。

将原生函数与你的代码中的任何其他常规函数区分开来的原因是,原生函数将具有额外的属性,这些属性将告诉内核它做什么。当你将原生函数加载到内核中时,你可以在结合原生和语义函数的链中使用它。此外,Semantic Kernel 规划器可以在创建计划以实现用户目标时使用该函数。

创建语义函数

我们已经在上一节中创建了一个语义函数(knock)。现在,我们将向其中添加一个参数。所有语义函数的默认参数称为{{$input}}

修改后的 Python 语义函数

我们将对之前的代码进行一些小的修改,以允许语义函数接收一个参数。同样,以下代码假设你已经实例化了一个内核并连接到了至少一个服务:

    from semantic_kernel.functions.kernel_arguments import KernelArguments
    args = KernelArguments(input="Boo")
    response = await kernel.invoke(prompt_function, request=prompt, arguments=args)
    print(response)

与之前的代码相比,唯一的区别是现在我们有一个变量{{$input}},并且我们使用参数字符串"Boo"来调用函数。为了添加变量,我们需要从semantic_kernel_functions.kernel_arguments包中导入KernelArguments类,并创建一个具有我们想要值的对象实例。

响应是非确定性的。以下是一个可能的响应:

Don't cry, it's just a joke!

修改后的 C#语义函数

要在 C#中创建一个函数,我们将使用CreateFunctionFromPrompt内核方法,并使用KernelArguments对象来添加参数:

string prompt = "Finish the following knock-knock joke. Knock, knock. Who's there? {{$input}}, {{$input}} who?";
KernelFunction jokeFunction = kernel.CreateFunctionFromPrompt(prompt);
var arguments = new KernelArguments() { ["input"] = "Boo" };
var joke = await kernel.InvokeAsync(jokeFunction, arguments);
Console.WriteLine(joke);

在这里,与之前的代码相比,唯一的区别是现在我们有一个变量{{$input}},并且我们使用参数字符串"Boo"来调用函数。

响应是非确定性的。以下是一个可能的响应:

Don't cry, it's just a joke!

创建原生函数

原生函数是在与你的应用程序相同的语言中创建的。例如,如果你正在用 Python 编写代码,原生函数可以用 Python 编写。

虽然你可以直接调用原生函数而不将其加载到内核中,但加载可以使它对规划器可用,这一点我们将在本章的最后部分看到。

我们将在 第三章 中更详细地探讨原生函数,但就目前而言,让我们在内核中创建和加载一个简单的原生函数。

我们将要创建的原生函数为笑话选择一个主题。目前,主题有 BooDishesArtNeedleTankPolice,函数简单地随机返回这些主题中的一个。

在 Python 中创建原生函数

在 Python 中,原生函数需要位于一个类内部。这个类曾经被称为 技能,在某些地方,这个名字仍然在使用。这个名字最近已改为 插件。插件(以前称为技能)只是一个函数集合。你无法在同一个技能中混合原生和语义函数。

我们将把我们的类命名为 ShowManager

要创建原生函数,你将使用 @kernel_function 装饰器。装饰器必须包含 descriptionname 字段。要添加装饰器,你必须从 semantic_kernel.functions.kernel_function_decorator 包中导入 kernel_function

函数体紧随装饰器之后。在我们的例子中,我们只是将有一个主题列表,并使用 random.choice 函数从列表中返回一个随机元素:

import random
class ShowManager():
    @kernel_function(
    description="Randomly choose among a theme for a joke",
    name="random_theme"
  )
  def random_theme(self) -> str:
      themes = ["Boo", "Dishes", "Art",
              "Needle", "Tank", "Police"]
      theme = random.choice(themes)
      return theme

然后,为了将插件及其所有功能加载到内核中,我们使用内核的 add_plugin 方法。当你添加插件时,你需要给它一个名字:

theme_choice = kernel.add_plugin(ShowManager(), "ShowManager")

要从插件中调用原生函数,只需在括号内放置函数名,如下所示:

    response = await kernel.invoke(theme_choice["random_theme"])
    print(response)

函数不是确定的,但可能的结果可能是:

Tank

在 C# 中创建原生函数

在 C# 中,原生函数需要位于一个类内部。这个类曾经被称为技能,这个名字在某些地方仍然在使用;例如,在 SDK 中,我们需要导入 Microsoft.SemanticKernel.SkillDefinition。技能最近已被重命名为插件。插件只是一个函数集合。你无法在同一个技能中混合原生和语义函数。

我们将把我们的类命名为 ShowManager

要创建原生函数,你将使用 [KernelFunction] 装饰器。装饰器必须包含 Description。函数体紧随装饰器之后。在我们的例子中,我们只是将有一个主题列表,并使用 Random().Next 方法从列表中返回一个随机元素。我们将我们的类命名为 ShowManager,我们的函数命名为 RandomTheme

using System.ComponentModel;
using Microsoft.SemanticKernel;
namespace Plugins;
public class ShowManager
{
    [KernelFunction, Description("Take the square root of a number")]
    public string RandomTheme()
    {
        var list = new List { "boo", "dishes", "art", "needle", "tank", "police"};
        return list[new Random().Next(0, list.Count)];
    }
}

然后,为了将插件及其所有功能加载到内核中,我们使用 ImportPluginFromObject 方法:

string prompt = "Finish the following knock-knock joke. Knock, knock. Who's there? {{$input}}, {{$input}} who?";
KernelFunction jokeFunction = kernel.CreateFunctionFromPrompt(prompt);
var showManagerPlugin = kernel.ImportPluginFromObject(new Plugins.ShowManager());
var joke = await kernel.InvokeAsync(jokeFunction, arguments);
Console.WriteLine(joke);

要从插件中调用原生函数,只需在括号内放置函数名。你可以通过使用 KernelArguments 类来传递参数,如下所示:

var result = await kernel.InvokeAsync(showManagerPlugin["RandomTheme"]);
Console.WriteLine("I will tell a joke about " + result);
var arguments = new KernelArguments() { ["input"] = result };

函数不是确定的,但可能的结果可能是以下内容:

I will tell a joke about art

现在你可以从你的代码中运行简单的提示,让我们学习如何通过使用插件将提示配置与调用它的代码分开。

插件

微软语义内核的一个最大优势是您可以创建与语言无关的语义插件。语义插件是语义函数的集合,可以导入到内核中。创建语义插件允许您将代码与 AI 函数分离,这使得您的应用程序更容易维护。它还允许其他人处理提示,使得实现提示工程更容易,这将在第二章中探讨。

每个函数都由一个包含两个文本文件的目录定义:config.json,其中包含语义函数的配置,以及skprompt.txt,其中包含其提示。

语义函数的配置包括要使用的首选引擎、温度参数以及语义函数做什么以及其输入的描述。

文本文件包含将发送到 AI 服务以生成响应的提示。

在本节中,我们将定义一个包含两个语义函数的插件。第一个语义函数是一个熟悉的函数:knock-knock 笑话生成器。第二个函数是接收一个笑话作为输入并尝试解释为什么它好笑的函数。由于这是一个更复杂的任务,我们将使用 GPT-4 来完成。

Let's take a look at the directory structure:└───plugins
    └───jokes
        |───knock_knock_joke
        |    ├───config.json
        |    └───skprompt.txt
        ├───explain_joke
             ├───config.json
             └───skprompt.txt

我们现在将看到如何创建config.jsonskprompt.txt文件以及如何将插件加载到我们的程序中。

knock-knock 笑话功能的 config.json 文件

以下配置文件显示了为生成 knock-knock 笑话的语义函数的可能配置:

{
    "schema": 1,
    "type": "completion",
    "description": "Generates a knock-knock joke based on user input",
    "default_services": [
        "gpt35",
        "gpt4"
    ],
    "execution_settings": {
        "default": {
            "temperature": 0.8,
            "number_of_responses": 1,
            "top_p": 1,
            "max_tokens": 4000,
            "presence_penalty": 0.0,
            "frequency_penalty": 0.0
        }
    },
    "input_variables": [
        {
            "name": "input",
            "description": "The topic that the joke should be written about",
            "required": true
        }
    ]
}

default_services属性是一个首选引擎的数组(按顺序)。由于 knock-knock 笑话很简单,我们将使用 GPT-3.5。前一个文件中的所有参数都是必需的。在未来的章节中,我们将详细解释每个参数,但现在,您只需复制它们即可。

description字段很重要,因为它可以在稍后的规划器中使用,这将在本章的最后部分进行解释。

knock-knock 笑话功能的 skprompt.txt 文件

由于我们稍后要解释这个笑话,我们需要我们的应用程序返回整个笑话,而不仅仅是结尾。这将使我们能够保存整个笑话并将其作为参数传递给解释笑话函数。为此,我们需要修改提示。您可以在以下位置看到最终的提示:

You are given a joke with the following setup:
Knock, knock!
Who's there?
{{$input}}!
{{$input}} who?
Repeat the whole setup and finish the joke with a funny punchline.

解释笑话的语义功能的 config.json 文件

您现在应该为解释笑话的函数创建一个文件。由于这是一个更复杂的任务,我们应该将default_services设置为使用 GPT-4。

此文件几乎与用于 knock-knock 笑话功能的config.json文件完全相同。我们只做了三项更改:

  • 描述

  • input变量的描述

  • default_services字段

这可以在以下内容中看到:

{
    "schema": 1,
    "type": "completion",
    "description": "Given a joke, explain why it is funny",
    "default_services": [
        "gpt4"
    ],
    "execution_settings": {
        "default": {
            "temperature": 0.8,
            "number_of_responses": 1,
            "top_p": 1,
            "max_tokens": 4000,
            "presence_penalty": 0.0,
            "frequency_penalty": 0.0
        }
    },
    "input_variables": [
        {
            "name": "input",
            "description": "The joke that we want explained",
            "required": true
        }
    ]
}

解释笑话功能的 skprompt.txt 文件

解释笑话的函数提示非常简单:

You are given the following joke:
{{$input}}
First, tell the joke.
Then, explain the joke.

将插件从目录加载到内核中

现在语义函数已定义在文本文件中,您可以通过指向它们所在的目录来将它们加载到内核中。这也可以帮助您将提示工程函数与开发函数分开。提示工程师可以在不接触您应用程序代码的情况下与文本文件一起工作。

使用 Python 加载插件

您可以使用内核对象的 add_plugin 方法加载插件目录中的所有函数。只需将第一个参数设置为 None,并将 parent_directory 参数设置为插件所在的目录:

jokes_plugin = kernel.add_plugin(None, parent_directory="../../plugins", plugin_name="jokes")

您可以通过将函数名放在括号内的方式,像调用原生插件中的函数一样调用这些函数:

knock_joke = await kernel.invoke(jokes_plugin["knock_knock_joke"], KernelArguments(input=theme))
print(knock_joke)

前一个调用的结果是不可预测的。以下是一个示例结果:

Knock, knock!
Who's there?
Dishes!
Dishes who?
Dishes the police, open up, we've got some dirty plates to wash!

我们可以将前一个调用的结果传递给 explain_joke 函数:

explanation = await kernel.invoke(jokes_plugin["explain_joke"], KernelArguments(input=knock_joke))
print(explanation)

请记住,此函数配置为使用 GPT-4。此函数的结果是不可预测的。以下是一个示例结果:

This joke is funny because it plays off the expectation set by the traditional "knock, knock" joke format. Typically, the person responding sets up a pun or a simple joke with their question ("...who?"), but instead, the punchline in this joke is a whimsical and unexpected twist: the police are here not to arrest someone, but to wash dirty plates. This absurdity creates humor. Also, the word 'dishes' is used in a punning manner to sound like 'this is'.

使用 C# 加载插件

您可以加载插件目录中的所有函数。首先,我们获取目录的路径(您的路径可能不同):

var pluginsDirectory = Path.Combine(System.IO.Directory.GetCurrentDirectory(),
        "..", "..", "..", "plugins", "jokes");

然后,我们使用 ImportPluginFromPromptDirectory 将函数加载到一个变量中。结果是函数的集合。您可以通过在括号内引用它们来访问它们:

var jokesPlugin = kernel.ImportPluginFromPromptDirectory(pluginsDirectory, "jokes");

最后一步是调用函数。要调用它,我们使用内核对象的 InvokeAsync 方法。我们再次将参数通过 KernelArguments 类传递:

var result = await kernel.InvokeAsync(jokesPlugin["knock_knock_joke"], new KernelArguments() {["input"] = theme.ToString()});)

前一个调用的结果是不可预测的。以下是一个示例结果:

Knock, knock!
Who's there?
Dishes!
Dishes who?
Dishes the best joke you've heard in a while!

要获取解释,我们可以将前一个调用的结果传递给 explain_joke 函数:

var explanation = await kernel.InvokeAsync(jokesPlugin["explain_joke"], new KernelArguments() {["input"] = result});
Console.WriteLine(explanation);

以下是一个示例结果:

Knock, knock!
Who's there?
Dishes!
Dishes who?
Dishes the best joke you've heard in a while!
Now, let's break down the joke:
The joke is a play on words and relies on a pun. The setup follows the classic knock, knock joke format, with the person telling the joke pretending to be at the door. In this case, they say "Dishes" when asked who's there.
Now, the pun comes into play when the second person asks "Dishes who?" Here, the word "Dishes" sounds similar to the phrase "This is." So, it can be interpreted as the person saying "This is the best joke you've heard in a while!"
The punchline subverts the expectation of a traditional knock, knock joke response, leading to a humorous twist. It plays on the double meaning of the word "Dishes" and brings humor through wordplay and cleverness.

现在您已经看到了如何创建和调用插件的一个函数,我们将学习如何使用规划器从不同的插件中调用多个函数。

使用规划器运行多步骤任务

您不必自己调用函数,可以让 Microsoft Semantic Kernel 为您选择函数。这可以使您的代码更加简单,并让您的用户能够以您未曾考虑过的方式组合您的代码。

目前,这看起来可能不太有用,因为我们只有少数几个函数和插件。然而,在一个大型应用程序中,例如 Microsoft Office,您可能有数百甚至数千个插件,并且您的用户可能希望以您目前无法想象的方式将它们结合起来。例如,您可能正在创建一个辅助程序,帮助用户在了解某个主题时更加高效,因此您编写了一个从网络上下载该主题最新新闻的函数。您也可能已经独立创建了一个向用户解释文本的函数,以便用户可以粘贴内容以了解更多信息。用户可能会决定将它们两者结合起来,例如“下载新闻并为我撰写解释它们的文章”,这是您从未想过并且没有添加到代码中的事情。语义内核将理解它可以按顺序调用您编写的两个函数来完成该任务。

当您允许用户请求他们自己的任务时,他们会使用自然语言,您可以让语义内核检查所有加载到其中的函数,并使用规划器来决定处理用户请求的最佳方式。

目前,我们只展示使用规划器的快速示例,但我们将更深入地探讨这个主题,见第五章。规划器仍在积极开发中,随着时间的推移可能会有所变化。目前,预计语义内核将有两个规划器:适用于 Python 和 C#的函数调用逐步规划器,以及仅适用于 C#的Handlebars 规划器(在撰写本文时)。

尽管以下示例非常简单,并且两个规划器表现相同,但我们将展示如何使用 Python 的逐步规划器(函数调用逐步规划器)和 C#的 Handlebars 规划器。

使用 Python 调用函数调用逐步规划器

要使用逐步规划器,我们首先创建一个FunctionCallingStepwisePlanner类的对象并向其发出请求。在我们的例子中,我们将要求它选择一个随机主题,创建一个敲门笑话,并解释它。

我们将修改我们之前的程序,删除函数调用,并添加对规划器的调用:

ask = f"""Choose a random theme for a joke, generate a knock-knock joke about it and explain it"""
options = FunctionCallingStepwisePlannerOptions(
  max_iterations=10,
  max_tokens=4000)
planner = FunctionCallingStepwisePlanner(service_id="gpt4", options=options)
result = await planner.invoke(kernel, ask)
print(result.final_answer)

有几个细节需要注意。第一个细节是,我使用了FunctionCallingStepwisePlannerOptions类来向规划器传递max_tokens参数。在幕后,规划器将创建一个提示并将其发送到 AI 服务。大多数 AI 服务的默认max_tokens通常较小。在撰写本文时,它是250,如果规划器生成的提示太大,可能会导致错误。第二个需要注意的细节是,我打印了result.final_answer而不是resultresult变量包含整个计划:函数的定义、与 OpenAI 模型的对话,解释如何进行等。打印result变量很有趣,可以了解规划器是如何内部工作的,但要查看规划器执行的结果,您只需要打印result.final_answer

这里是一个示例响应,首先讲述笑话然后解释它:

First, the joke:
Knock, knock!
Who's there?
Police!
Police let me in, it's cold out here!
Now, the explanation:
The humor in this joke comes from the play on words. The word "police" is being used in a different context than typically used. Instead of referring to law enforcement, it's used as a pun to sound like "Please". So, when the jokester says "Police let me in, it's cold out here!", it sounds like "Please let me in, it's cold out here!". Knock, knock jokes are a form of humor that relies on word play and puns, and this joke is a standard example of that.

如您所见,规划器生成了笑话和解释,正如预期的那样,我们无需告诉语义内核调用函数的顺序。

在 C#中调用 Handlebars 规划器

在撰写本文时,Handlebars 规划器处于 1.0.1-preview 版本,尽管它在 C#中仍然是实验性的,但很可能很快就会提供一个发布版本。

要使用 Handlebars 规划器,您首先需要安装它,您可以通过以下命令完成(您应该使用可用的最新版本):

dotnet add package Microsoft.SemanticKernel.
s.Handlebars --version 1.0.1-preview

要使用 Handlebars 规划器,您需要在代码中使用以下pragma警告。Handlebars 规划器代码仍然是实验性的,如果您不添加#pragma指令,您的代码将失败,并显示包含实验性代码的警告。您还需要导入Microsoft.SemanticKernel.Planning.Handlebars包:

#pragma warning disable SKEXP0060
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Planning.Handlebars;

我们像往常一样进行,实例化我们的内核并向其中添加原生和语义函数:

var (apiKey, orgId) = Settings.LoadFromFile();
Kernel kernel = Kernel.CreateBuilder()
        .AddOpenAIChatCompletion("gpt-3.5-turbo", apiKey, orgId, serviceId: "gpt3")
        .AddOpenAIChatCompletion("gpt-4", apiKey, orgId, serviceId: "gpt4")
                        .Build();
var pluginsDirectory = Path.Combine(System.IO.Directory.GetCurrentDirectory(),
        "..", "..", "..", "plugins", "jokes");

现在出现了重大差异——我们不再告诉调用哪些函数以及如何调用,我们只是简单地要求规划器做我们想要的事情:

var goalFromUser = "Choose a random theme for a joke, generate a knock-knock joke about it and explain it";
var planner = new HandlebarsPlanner
(new HandlebarsPlannerOptions() { AllowLoops = false });
var plan = await
planner.CreatePlanAsync(kernel, goalFromUser);

我们可以通过从plan对象调用InvokeAsync来执行计划:

var result = await plan.InvokeAsync(kernel);
Console.WriteLine(result);

结果是非确定性的。以下是一个示例结果,首先讲述笑话然后解释它:

Knock, knock!
Who's there?
Police!
Police who?
Police let me know if you find my sense of humor arresting!
Explanation:
This joke is a play on words and relies on the double meaning of the word "police."
In the setup, the person telling the joke says "Knock, knock!" which is a common way to begin a joke. The other person asks "Who's there?" which is the expected response.
The person telling the joke then says "Police!" as the punchline, which is a word that sounds like "please." So it seems as if they are saying "Please who?" instead of "Police who?"
Finally, the person telling the joke completes the punchline by saying "Police let me know if you find my sense of humor arresting!" This is a play on words because "arresting" can mean two things: first, it can mean being taken into custody by the police, and second, it can mean captivating or funny. So the person is asking if the listener finds their sense of humor funny or engaging and is also using the word "police" to continue the play on words.

如您所见,规划器生成了笑话和解释,正如预期的那样,我们无需告诉语义内核调用函数的顺序。

摘要

在本章中,您了解了生成式 AI 和 Microsoft 语义内核的主要组件。您学习了如何创建提示并将其提交给服务,以及如何将提示嵌入到语义函数中。您还学习了如何通过使用规划器来执行多步请求。

在下一章中,我们将通过一个名为提示工程的主题来学习如何使我们的提示更好。这将帮助您创建能够更快地为用户提供正确结果并使用更少标记的提示,从而降低成本。

参考资料

[1] A. Vaswani 等人,“Attention Is All You Need”,2017 年 6 月。

[2] OpenAI, “GPT-4 技术报告.” arXiv, 2023 年 3 月 27 日. doi: 10.48550/arXiv.2303.08774.

[3] L. Chen, M. Zaharia, 和 J. Zou, “FrugalGPT: 如何在降低成本和提高性能的同时使用大型语言模型.” arXiv, 2023 年 5 月 9 日. doi: 10.48550/arXiv.2305.05176.

第二章:创建更好的提示

作为开发者,你可以通过向大型语言模型提交提示来请求它完成一项任务。在上一章中,我们看到了一些提示的例子,例如“讲一个敲门笑话”和“纽约市和里约热内卢之间的飞行时间是多少?”。随着大型语言模型变得更加强大,它们能够完成的任务也变得更加复杂。

研究人员发现,使用不同的技术构建提示会产生截然不同的结果。提高获得期望答案可能性的提示构建过程被称为提示工程,而创建更好的提示的价值催生了一个新的职业:提示工程师。这样的人不需要知道如何用任何编程语言编码,但可以使用自然语言创建提示,以返回期望的结果。

Microsoft Semantic Kernel 使用 提示模板 的概念,为包含特定类型信息和指令的提示创建结构化模板,这些信息和指令可以由用户或开发者填充或自定义。通过使用提示模板,开发者可以在提示中引入多个变量,将提示工程功能与编码功能分离,并使用高级提示技术来提高响应的准确性。

本章中,你将了解几种技术,这些技术将使你的提示更有可能在第一次尝试中返回你希望用户看到的预期结果。你将学习如何使用具有多个变量的提示,以及如何创建和使用具有多个参数的提示以完成更复杂的任务。最后,你将发现将提示以创造性的方式结合起来的技术,以在大型语言模型不太准确的情况下提高准确性——例如,在解决数学问题时。

本章将涵盖以下主题:

  • 提示工程

  • 多变量提示

  • 多阶段提示

技术要求

要完成本章,你需要拥有你首选的 Python 或 C# 开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是 .NET 8

你还需要一个 OpenAI API 密钥,可以通过 OpenAI 或通过 MicrosoftAzure OpenAI 服务获得。如何获取这些密钥的说明可以在第一章中找到。

如果你正在使用 .NET,本章的代码可以在github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch2找到。

如果你使用 Python,本章的代码可以在github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch2找到。

你可以通过访问 GitHub 仓库并使用以下命令安装所需的包:pip install -r requirements.txt

一个简单的插件模板

创建提示模板有两种简单的方法。

第一种方法是从代码中的字符串变量生成提示。这种方法简单方便。我们在第一章运行简单提示部分介绍了这种方法。

第二种方法是使用 Semantic Kernel 来帮助将开发功能与提示工程功能分开。正如你在第一章中看到的,你可以在插件中创建对 LLMs 的请求作为函数。插件是一个包含多个子目录的目录,每个子目录对应一个功能。每个子目录将恰好有两个文件:

  • 一个包含提示的文本文件skprompt.txt

  • 一个名为config.json的配置文件,其中包含将用于 API 调用的参数

由于提示与代码分开维护,因此作为应用开发者,你可以专注于你的应用程序代码,让专门的提示工程师处理skprompt.txt文件。

在本章中,我们将重点关注第二种方法——在专用目录中创建插件,因为这种方法对变化更加稳健。例如,如果你从 Python 切换到 C#或使用.NET 库的新版本,并且这些更改需要对你代码进行大量修改,至少你不需要更改你的提示和功能配置。

这个提示插件的代码与我们在上一章中使用的是一样的。我们将对第一章中构建的插件进行一些修改,对skprompt.txt文件进行更改,以观察结果并学习不同的提示技术如何显著改变结果。

我们在本章的开头再次详细地介绍这个过程,尽管我们在第一章中已经介绍过类似的内容,因为在我们探索提示工程的过程中,我们将多次使用这些步骤。

对于我们将要介绍的内容,你可以使用 GPT-3.5 和 GPT-4,但请记住,GPT-4 的价格是 GPT-3.5 的 30 倍。除非另有说明,否则显示的结果来自 GPT-3.5。

skprompt.txt文件

我们将从一个非常简单的提示开始,直接询问我们想要的内容,没有任何额外的上下文:

Create an itinerary of three must-see attractions in {{$city}}.

就这些了——这就是整个文件。

config.json文件

config.json文件与我们第一章中使用的非常相似,但我们更改了三件事:

  • 函数的描述

  • input_variables 下的输入变量名称

  • input_variables 下的输入变量描述

{
    "schema": 1,
    "type": "completion",
    "description": "Creates a list of three must-see attractions for someone traveling to a city",
    "default_services": [
        "gpt35"
    ],
    "execution_settings": {
        "default": {
            "temperature": 0.8,
            "number_of_responses": 1,
            "top_p": 1,
            "max_tokens": 4000,
            "presence_penalty": 0.0,
            "frequency_penalty": 0.0
        }
    },
    "input_variables": [
        {
            "name": "city",
            "description": "The city the person wants to travel to",
            "required": true
        }
    ]
}

现在我们已经定义了函数,让我们来调用它。

从 Python 调用插件

我们在这里使用的代码与我们第一章中使用的非常相似。我们做了三个小的改动:只使用 GPT-3.5,指向适当的插件目录 prompt_engineering,并将输入变量的名称从 input 改为 city

import asyncio
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.utils.settings import openai_settings_from_dot_env
import semantic_kernel as sk
from semantic_kernel.functions.kernel_arguments import KernelArguments
async def main():
    kernel = sk.Kernel()
    api_key, org_id = openai_settings_from_dot_env()
    gpt35 = OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id, "gpt35")
    kernel.add_service(gpt35)
    pe_plugin = kernel.add_plugin(None, parent_directory="../../plugins", plugin_name="prompt_engineering")
    response = await kernel.invoke(pe_plugin["attractions_single_variable"], KernelArguments(city="New York City"))
    print(response)
if __name__ == "__main__":
    asyncio.run(main())

现在,让我们学习如何从 C# 调用插件。请注意,我们没有对 skprompt.txtconfig.json 进行任何修改。我们可以使用相同的提示词和配置,不受语言限制。

从 C# 调用插件

就像我们在 Python 中做的那样,我们只需要做三个小的改动:只使用 GPT-3.5,指向适当的插件目录 prompt_engineering,并将输入变量的名称从 input 改为 city

using Microsoft.SemanticKernel;
var (apiKey, orgId) = Settings.LoadFromFile();
Kernel kernel = Kernel.CreateBuilder()
                        .AddOpenAIChatCompletion("gpt-3.5-turbo", apiKey, orgId, serviceId: "gpt35")
                        .Build();
var pluginsDirectory = Path.Combine(System.IO.Directory.GetCurrentDirectory(),
        "..", "..", "..", "plugins", "prompt_engineering");
var promptPlugin = kernel.ImportPluginFromPromptDirectory(pluginsDirectory, "prompt_engineering");
var result = await kernel.InvokeAsync(promptPlugin["attractions_single_variable"], new KernelArguments() {["city"] = "New York City"});
Console.WriteLine(result);

让我们看看结果。

结果

而不是给出我想看的三个顶级景点,在一次调用中,GPT-3.5 创建了一个 3 天的景点列表,并给了我一个包含六个景点的列表。这是当提示词不够具体时可能发生的典型问题。景点列表非常好,并考虑到了由于交通拥堵,在纽约市移动的难度:同一天的景点彼此靠近,或者至少有足够的时间在它们之间旅行。由于大量的 GPT-3.5 训练是在 COVID 期间进行的,行程甚至还包括了检查 COVID 关闭的备注。

注意,你的结果可能会有所不同,在某些情况下,你可能得到一个看起来像原始意图的响应,只有三个必看的景点:

Itinerary: Must-See Attractions in New York City
Day 1:
1\. Statue of Liberty and Ellis Island: Start your visit to New York City by taking a ferry to the Statue of Liberty and Ellis Island. Explore the grounds of Lady Liberty, marvel at this iconic symbol of freedom and take in stunning views of the city skyline from the observation deck. Then, head to Ellis Island to learn about the millions of immigrants who passed through its gates and contributed to the cultural fabric of America.
2\. Times Square: In the afternoon, immerse yourself in the vibrant energy of Times Square. This bustling, neon-lit intersection is known for its towering billboards, dazzling Broadway theaters, and bustling crowds. Take a stroll along the pedestrian-friendly plazas, snap photos in front of iconic landmarks like the TKTS booth, and soak up the excitement of this iconic New York City landmark.
Day 2:
3\. The Metropolitan Museum of Art (The Met): Spend the morning exploring one of the world's largest and most famous art museums, The Met. This cultural treasure houses an extensive collection spanning 5,000 years of human history, encompassing art from various regions and civilizations. Admire masterpieces from ancient Egypt, classical antiquity, European Renaissance artists, and contemporary art. Don't miss the rooftop garden for panoramic views of Central Park and the Manhattan skyline.
4\. Central Park: After the museum, take a leisurely stroll through Central Park, an urban oasis in the heart of the city. This sprawling green space offers a refreshing break from the bustling streets. Enjoy a picnic near the Bethesda Terrace and Fountain, rent a rowboat on the lake, visit the Central Park Zoo, or simply relax and people-watch in this iconic park.
Day 3:
5\. The High Line: Start your day with a visit to the High Line, a unique elevated park built on a historic freight rail line. This linear park stretches for 1.45 miles and offers stunning views of the city, beautifully landscaped gardens, public art installations, and a variety of seating areas. Take a leisurely walk along the promenade, enjoy the greenery, and appreciate the innovative urban design.
6\. The 9/11 Memorial & Museum: In the afternoon, visit the 9/11 Memorial & Museum, a deeply moving tribute to the victims of the September 11, 2001, terrorist attacks. The memorial features two reflecting pools set in the footprints of the Twin Towers, surrounded by bronze parapets inscribed with the names of those who lost their lives. Inside the museum, you'll find artifacts, multimedia displays, and personal accounts that document the events and aftermath of 9/11.
Note: This itinerary includes popular attractions in New York City, but it's always worth checking for any COVID-19-related restrictions or closures before planning your visit.

这个快速、简单的提示词并没有像预期的那样始终如一地工作,所以让我们通过在提示词中添加更多信息来探索如何让事情变得更好。

提高提示词以获得更好的结果

如果你主要通过 ChatGPT 或 Microsoft Copilot 与 LLM 交互,你可能会有这样的印象:你可以使用非常短的提示词来得到你想要的结果。如第一章中所述,这些应用程序修改了你的提交,并在提示词中添加了大量指令。作为一个开发者,你也必须这样做。以下是一些改进提示词并获得更好结果的建议:

  • 提供上下文:而不仅仅是要求三个必看的景点,尽可能多地提供上下文。想想如果你被问同样的问题,你会问些什么——例如,“你将停留多少天?”,“你喜欢和不喜欢什么?”,或者“你将如何四处走动?

  • 明确响应的格式:如果你想以特定的格式得到答案,确保你告诉 LLM 这一点。你甚至可以提供一些示例——例如,“用单个词回答——‘是’ 或 ‘否。’*”

  • 指定响应的长度:如果你想得到一个简洁的回答,指定所需的长度——例如,“两句话”如果你想得到一个更长的回答,要明确指出——例如,“600 字”或“五段

修订 skprompt.txt 文件

使用这些提示,我们将重写提示以查看我们是否可以得到一致的改进响应:

I am traveling to {{$city}} and staying only 24 hours.
I am afraid of heights.
I will be downtown.
I like movies and TV shows a lot.
I am traveling by myself.
I will get around using public transportation or Uber.
I will be there on a Saturday.
Create an itinerary of three attractions for me to visit.
The attractions should be listed in the following format:
- Attraction name: [name]
- What is the best thing to see there: [one sentence description]
Your whole answer should be less than 500 words.

结果

这是提示返回的响应:

Itinerary for a 24-hour trip to New York City:
Attraction 1: Times Square
- What is the best thing to see there: The vibrant neon lights, billboards, and bustling atmosphere of one of the world's most famous intersections.
Start your day by visiting Times Square, the iconic heart of New York City. Immerse yourself in the vibrant energy and larger-than-life atmosphere of this bustling area. Marvel at the enormous billboards and screens displaying advertisements for Broadway shows, movies, and various brands. While you're there, make sure to take a stroll along the pedestrian plazas, people-watch, and soak in the bustling activity. Don't forget to snap some photos to capture the essence of this iconic location.
Attraction 2: Central Park
- What is the best thing to see there: Explore the beautiful green landscape, visit popular landmarks like Bethesda Terrace and Strawberry Fields, and enjoy a peaceful escape from the city.
Escape the concrete jungle and head to Central Park, an oasis of tranquility and natural beauty right in the heart of Manhattan. Take a leisurely walk through the park's lush greenery, visit famous spots such as Bethesda Terrace and Bethesda Fountain, and enjoy the peacefulness that surrounds you. If you're a fan of movies or TV shows, keep an eye out for locations you may recognize from various film shoots. Grab a coffee or snack from one of the park's vendors and find a cozy spot to relax and people-watch, or rent a bike and explore the park's vast expanse at your own pace.
Attraction 3: Museum of the Moving Image
- What is the best thing to see there: Delve into the world of movies and TV shows through interactive exhibits, iconic props, and behind-the-scenes insights.
As a movie and TV show enthusiast, a visit to the Museum of the Moving Image in Queens is a must. This unique museum offers a fascinating exploration of the history, art, and technology behind the moving image. Get a glimpse of iconic props, costumes, and memorabilia from beloved films and TV shows. Engage in interactive exhibits that allow you to create your own animations or dub your voice into famous movie scenes. Learn about the evolution of filmmaking techniques and gain behind-the-scenes insights into the process of creating the entertainment you love. With its fascinating exhibits and hands-on experiences, the Museum of the Moving Image is a perfect destination for any movie and TV aficionado.
Remember to plan your itinerary in a way that allows for travel time between attractions, especially if you're relying on public transportation or Uber. Be sure to check opening hours and any reservation requirements for the attractions you wish to visit, as they may vary. Enjoy your 24-hour adventure in the vibrant city of New York!

注意,我们创建的提示是这样的,你可以用它来应对不同的城市。这里是一个使用伦敦的示例。注意,响应承认了我对高度恐惧,但只是告诉我克服它:

Attraction 1: Warner Bros. Studio Tour London - The Making of Harry Potter
- What is the best thing to see there: Immerse yourself in the magical world of Harry Potter by exploring the actual sets, costumes, and props used in the film series.
Start your day by visiting the Warner Bros. Studio Tour London - The Making of Harry Potter. As a fan of movies and TV shows, this attraction will be a dream come true for you. Located just outside of London, this studio tour offers a unique behind-the-scenes experience of the Harry Potter film series.
Attraction 2: The British Museum
- What is the best thing to see there: Discover the vast collection of art and artifacts from around the world, including the Rosetta Stone and the Egyptian mummies.
Next, head to The British Museum, located in the heart of downtown London. This world-renowned museum houses a vast collection of art and historical artifacts, spanning over two million years of human history. As a lover of movies and TV shows, you'll appreciate the opportunity to see real-life artifacts and treasures from various civilizations.
Attraction 3: The London Eye
- What is the best thing to see there: Overcome your fear of heights and enjoy breathtaking panoramic views of London's iconic landmarks.
To end your day, challenge yourself and conquer your fear of heights by visiting the London Eye. This giant Ferris wheel, located on the South Bank of the River Thames, offers spectacular views of the city's skyline. Take a ride in one of the glass capsules and enjoy a 30-minute rotation, offering uninterrupted 360-degree views of London.
With 24 hours in London, these three attractions will provide you with a well-rounded experience that combines your love for movies and TV shows with iconic landmarks and breathtaking views. Don't forget to make use of public transportation or Uber to efficiently move between these attractions and make the most of your limited time in the city. Enjoy your adventure!

对于许多应用,你需要使你的提示更加可配置,这将需要添加更多变量。我们将在下一节中这样做。

带有多个变量的提示

你可以在提示中参数化很多信息。例如,在我们的最佳景点查找提示中,你可以添加多个变量,例如这个人停留的天数、他们喜欢和不喜欢的事物,以及他们想看多少个景点。

在这种情况下,我们的提示将变得更加复杂,因此我们需要创建一个新的 skprompt.txt 文件。由于我们的提示将是一个新的函数并且有多个参数,我们还需要创建一个新的 config.json 文件。

这两个文件可以在 plugins/prompt_engineering/attractions_multiple_variables 文件夹中找到。

Skprompt.txt

要向提示中添加更多变量,只需在双大括号内添加它们,并在变量名前加上美元符号。以下代码展示了如何将多个变量(cityn_dayslikesdislikesn_attractions)添加到单个提示中:

I am traveling to {{$city}} and staying {{$n_days}} days.
I like {{$likes}}.
I dislike {{$dislikes}}.
I am traveling by myself.
Create an itinerary of up to {{$n_attractions}} must-see attractions for me to visit.
The attractions should be listed in the following format:
- Attraction name: [name]
- What is the best thing to see there: [one sentence description]
Your whole answer should be less than 300 words.

现在,让我们看看函数配置的变化。

Config.json

我们用于多个变量的 config.json 文件几乎与用于单个变量的文件相同,但我们需要为所有变量添加详细信息:

    "input_variables": [
        {
            "name": "city",
            "description": "The city the person wants to travel to",
            "required": true
        },
        {
            "name": "n_days",
            "description": "The number of days the person will be in the city",
            "required": true
        },
        {
            "name": "likes",
            "description": "The interests of the person traveling to the city",
            "required": true
        },
        {
            "name": "dislikes",
            "description": "The dislikes of the person traveling to the city",
            "required": true
        },
        {
            "name": "n_attractions",
            "description": "The number of attractions to recommend",
            "required": true
        }
    ]

现在我们已经配置了新的函数,让我们学习如何在 Python 和 C# 中调用它。

使用 Python 请求复杂行程

与调用单个参数的提示相比,调用多个参数的提示所需做的唯一更改是在 KernelArguments 对象上。当将 KernelArguments 作为参数传递给 kernel.invoke 时,我们必须将所有需要的参数添加到对象中,如这里所示。需要注意的是,参数都是字符串,因为 LLMs 在处理文本时表现最佳:

        response = await kernel.invoke(pe_plugin["attractions_multiple_variables"], KernelArguments(
        city = "New York City",
        n_days = "3",
        likes = "restaurants, Ghostbusters, Friends tv show",
        dislikes = "museums, parks",
        n_attractions = "5"
    ))

让我们看看 C# 代码。

使用 C# 请求复杂行程

这次,我们将在函数调用外部创建一个名为 function_argumentsKernelArguments 对象,并预先填充我们想要的内容的五个变量。然后,我们将把这个对象传递给调用调用:

 var function_arguments = new KernelArguments()
    {["city"] = "New York City",
    ["n_days"] = "3",
    ["likes"] = "restaurants, Ghostbusters, Friends tv show",
    ["dislikes"] = "museums, parks",
    ["n_attractions"] = "5"};
var result = await kernel.InvokeAsync(promptPlugin["attractions_multiple_variables"], function_arguments );

现在,让我们看看结果。

复杂行程的结果

结果考虑了所有输入变量。它建议一个公园——高线公园——尽管我告诉了 LLM 我不喜欢公园。它确实解释说这是一个非常不寻常的公园,如果你了解纽约,它根本不像一个公园。我认为一个不喜欢传统公园体验的人会喜欢高线公园,所以 LLM 在获得更多上下文后做得非常好:

Itinerary for Three Days in New York City:
Day 1:
1\. Attraction name: Ghostbusters Firehouse
   What is the best thing to see there: Visit the iconic firehouse featured in the Ghostbusters movies and take pictures in front of the famous logo.
2\. Attraction name: Friends Apartment Building
   What is the best thing to see there: Pay a visit to the apartment building that served as the exterior shot for Monica and Rachel's apartment in the beloved TV show Friends.
Day 2:
3\. Attraction name: Restaurant Row
   What is the best thing to see there: Explore Restaurant Row on West 46th Street, known for its diverse culinary scene, offering a plethora of international cuisines to satisfy your food cravings.
4\. Attraction name: High Line Park
   What is the best thing to see there: Although you mentioned disliking parks, the High Line is a unique urban park built on a historic freight rail line, offering beautiful views of the city and a different experience compared to traditional parks.
Day 3:
5\. Attraction name: Times Square
   What is the best thing to see there: Immerse yourself in the vibrant atmosphere of Times Square, known for its dazzling billboards, bustling streets, and renowned theaters, making it a must-see destination in NYC.
This itinerary focuses on your interests while also incorporating some iconic NYC experiences. You can explore famous film locations like the Ghostbusters Firehouse and Friends Apartment Building. Since you enjoy restaurants, Restaurant Row will offer a variety of dining options to suit your taste. Although you specified disliking parks, the High Line Park provides a unique urban green space experience. Finally, no trip to NYC would be complete without a visit to the energetic Times Square. Enjoy your trip!

有了这些,我们已经学会了如何改进我们的提示以获得更好的结果。然而,有些情况下,即使我们使用了之前提到的所有技术,LLMs 也未能提供好的答案。一个非常常见的情况是解决数学问题。我们将接下来探讨这个问题。

回答数学问题时的问题

虽然 LLMs 的结果令人印象深刻,但有时模型会被看似简单的问题搞混。当问题涉及数学时,这种情况更为常见。例如,我在 GPT-3.5 上运行了以下提示五次:

When I was 6 my sister was half my age.
Now I'm 70\. How old is my sister?

我用来运行此提示的插件位于plugins/prompt_engineering/solve_math_problem

正确答案是 67,因为当我 6 岁时,我妹妹是 3 岁。现在,64 年后,我 70 岁,所以她会是 67 岁。

这里是 GPT-3.5 上五次运行的成果。第一次结果是错误的,说“我的妹妹比她现在的年龄小 64 岁:”

If your sister was half your age when you were 6, that means she was 3 years younger than you at that time (6/2 = 3).
Since then, you have aged 64 years (70 - 6 = 64). Therefore, your sister would also be 64 years younger than her current age, which would make her 70 - 64 = 6 years old.
So, your sister is currently 6 years old.

第二次和第三次尝试给出了正确答案。这是第二次尝试:

If your sister was half your age when you were 6, it means she was 6/2 = <<6/2=3>>3 years old at that time.
Since then, there is a constant age difference of 3 years between the two of you.
Now that you are 70, your sister would be 70 - 3 = <<70-3=67>>67 years old.

这是第三次尝试:

If your sister was half your age when you were 6, that means she was 3 years old at that time.
Since then, the age difference between you and your sister remains constant.
Therefore, if you are currently 70 years old, your sister would be 67 years old.

但第四次和第五次尝试又给出了错误答案。这是第四次尝试:

If your sister was half your age when you were 6, that means she was 3 years old at that time. Since then, the age difference between you and your sister remains constant. Therefore, if you are currently 70 years old, your sister is 73 years old.

这是第五次尝试:

If your sister was half your age when you were 6, that means she was 3 years younger than you at that time.
Now, you are 70, which means there is a 67-year age difference between when you were 6 and now.
So, if your sister was 3 years younger than you when you were 6, she would be 67 years younger than you now.
Therefore, your sister would be 70 - 67 = 3 years old.

因此,在五次尝试中,只有两次(40%)返回了正确答案,这不是你希望用户拥有的体验。

我们将在下一节中学习如何解决这个问题。

多阶段提示

在进行数学时提高 LLMs 准确性的一个方法是多阶段提示。在这个技术中,第一次提示的答案作为参数传递给第二次。我们将使用思维链CoT)技术来展示这一点。

CoT – “让我们一步一步思考”

在论文《大型语言模型是零样本推理者》[1]中,作者发现,在问题后简单添加“让我们一步一步思考”可以大大提高 LLMs 的准确性。他们提出的过程如下:

  1. 提出你想要的问题,但不是要求 LLM 回答,而是在结尾简单地添加“让我们一步一步思考”。

  2. LLM 将以回答问题的过程来回答。

  3. 将步骤 1 中的问题与步骤 2 中的过程结合到一个新的提示中,并以“因此,答案是…”结束。

图 2.1 – Zero-shot-CoT 方法

图 2.1 – Zero-shot-CoT 方法

他们将他们的过程称为 Zero-shot-Chain-of-ThoughtZero-shot-CoTZero-shot 部分意味着你不需要向 LLM 提供任何示例答案;你可以直接提出问题。这是为了区分与 few-shot 的过程,few-shot 是你在提示中提供 LLM 几个预期答案的示例,使提示变得更大。CoT 部分描述了要求 LLM 提供推理框架的过程,并将 LLM 的推理添加到问题中。

作者测试了几个不同的短语来从 LLM 获取 CoT,例如“让我们逻辑地思考它”和“让我们像侦探一样一步一步思考”,并发现简单地“让我们一步一步思考”产生了最佳结果。

实现 Zero-shot-CoT

我们需要两个提示 - 一个用于两个步骤。对于第一步,我们将调用 solve_math_problem_v2。提示简单地重申了问题,并在最后添加了“让我们一步一步思考”:

{{$problem}}
Let's think step by step.

第二步的提示,我们将称之为 chain_of_thought,重复了第一个提示,包括第一个提示的答案,然后要求提供解决方案:

{{$problem}}
Let's think step by step.
{{$input}}
Therefore, the answer is…

给定这个提示,config.json 文件需要两个 input_variables

    "input_variables": [
        {
            "name": "problem",
            "description": "The problem that needs to be solved",
            "required": true
        },
        {
            "name": "input",
            "description": "The steps to solve the problem",
            "required": true
        }
    ]

让我们学习如何调用提示。

使用 Python 实现 CoT

为了使步骤明确,我将程序分成两部分。第一部分请求 CoT 并显示它:

    problem = """When I was 6 my sister was half my age. Now I'm 70\. How old is my sister?"""
    pe_plugin = kernel.add_plugin(None, parent_directory="../../plugins", plugin_name="prompt_engineering")
    solve_steps = await kernel.invoke(pe_plugin["solve_math_problem_v2"], KernelArguments(problem = problem))
    print(f"\n\nSteps: {str(solve_steps)}\n\n")

第二部分展示了答案。如果你只关心答案,你不需要打印步骤,但你仍然需要使用 LLM 来计算步骤,因为它们是 CoT 的必需参数:

    response = await kernel.invoke(pe_plugin["chain_of_thought"], KernelArguments(problem = problem, input = solve_steps))
    print(f"\n\nFinal answer: {str(response)}\n\n")

使用 C# 实现 CoT

就像我们在 Python 中做的那样,我们可以将 C# 程序分成两部分。第一部分将展示由 CoT 提示引发的推理步骤:

var problem = "When I was 6 my sister was half my age. Now I'm 70\. How old is my sister?";
var chatFunctionVariables1 = new KernelArguments()
{
    ["problem"] = problem,
};
var steps = await kernel.InvokeAsync(promptPlugin["solve_math_problem_v2"], chatFunctionVariables1);

第二部分解析并报告答案:

var chatFunctionVariables2 = new KernelArguments()
{
    ["problem"] = problem,
    ["input"] = steps.ToString()
};
var result = await kernel.InvokeAsync(promptPlugin["chain_of_thought_v2"], chatFunctionVariables2);Console.WriteLine(steps);

让我们看看结果。

CoT 的结果

我运行了程序五次,每次都得到了正确答案。答案不是确定的,所以你可能在你的机器上运行它时得到一两个错误答案。在论文中,作者声称这种方法在类似类型的问题上实现了 78.7% 的成功率,而 LLM 的通常准确率约为 17.7%。

让我们看看两个示例响应。首先是:

Steps: When you were 6, your sister was half your age, which means she was 6/2 = 3 years old at that time.
The age difference between you and your sister remains the same over time, so the difference in your ages is 6 - 3 = 3 years.
Now that you are 70, your sister would be 70 - 3 = 67 years old.
Final answer:
Your sister is 67 years old.

这是第二个:

Steps: When you were 6, your sister was half your age, so she was 6/2 = <<6/2=3>>3 years old.
Since then, the age difference between you and your sister remains constant, so your sister is 3 years younger than you.
Therefore, if you are now 70, your sister would be 70 - 3 = <<70-3=67>>67 years old.

我们可以自动化程序而不是手动运行它。让我们看看如何做。

答案集合

虽然 CoT 技术帮助很大,但我们仍然有 78.7% 的平均准确率,这可能还不够。为了解决这个问题,常用的一个技术是多次向模型提出相同的问题,并仅编译最频繁给出的答案。

为了实现这一点,我们将对我们的 CoT 提示进行微小修改,并将其称为 chain_of_thought_v2。我们将简单地要求 LLM 使用阿拉伯数字回答,以便在后续步骤中更容易比较答案:

{{$problem}}
Let's think step by step.
{{$input}}
Using arabic numerals only, the answer is…

我们还需要更改程序并要求它运行多次。对于下一个示例,我选择了N = 7。我们将收集答案并选择出现频率更高的答案。请注意,使用此方法每次调用都比单次调用贵N倍,耗时也长N倍。准确性不是免费的。

使用 Python 自动运行集成

让我们运行 CoT 七次。每次运行它,我们将结果添加到一个列表中。然后,我们将利用set数据结构来快速获取最常见的元素:

    responses = []
    for i in range(7):
        solve_steps = await kernel.invoke(pe_plugin["solve_math_problem_v2"], KernelArguments(problem = problem))
        response = await kernel.invoke(pe_plugin["chain_of_thought_v2"], KernelArguments(problem = problem, input = solve_steps))
        responses.append(int(str(response)))
    print("Responses:")
    print(responses)
    final_answer = max(set(responses), key = responses.count)
    print(f"Final answer: {final_answer}")

让我们看看如何在 C#中实现这一点。

使用 C#自动运行集成

C#代码与 Python 代码使用相同的思想:我们运行模型七次并存储结果。然后,我们在结果中搜索最频繁的答案:

var results = new List<int>();
for (int i = 0; i < 7; i++)
{
    var chatFunctionVariables1 = new KernelArguments()
    {
        ["problem"] = problem,
    };
    var steps = await kernel.InvokeAsync(promptPlugin["solve_math_problem_v2"], chatFunctionVariables1);
    var chatFunctionVariables2 = new KernelArguments()
    {
        ["problem"] = problem,
        ["input"] = steps.ToString()
    };
    var result = await kernel.InvokeAsync(promptPlugin["chain_of_thought_v2"], chatFunctionVariables2);
    var resultInt = int.Parse(result.ToString());
    results.Add(resultInt);
}
var mostCommonResult = results.GroupBy(x => x)
    .OrderByDescending(x => x.Count())
    .First()
    .Key;
Console.WriteLine($"Your sister's age is {mostCommonResult}");

将 CoT 和集成方法结合起来,大大增加了获得正确响应的可能性。在论文中,作者们以每题 10 次 LLM 调用的代价,获得了 99.8%的正确结果。

摘要

在本章中,你学习了你可以利用的几种技术来改进你的提示以获得更好的结果。你学习了使用更长的提示,确保 LLM 有必要的上下文来提供所需的响应。你还学习了如何向提示添加多个参数。然后,你学习了如何链式使用提示以及如何实现 CoT 方法以帮助 LLM 提供更准确的结果。最后,你学习了如何集成多个响应以提高准确性。然而,这种准确性是有代价的。

现在我们已经掌握了提示,在下一章中,我们将探讨如何自定义插件及其原生和语义函数。

参考文献

[1] T. Kojima, S. S. Gu, M. Reid, Y. Matsuo, and Y. Iwasawa, “Large Language Models are Zero-Shot Reasoners.” arXiv, Jan. 29, 2023. 访问时间:Jun. 06, 2023. [在线]. 可用:arxiv.org/abs/2205.11916

第二部分:使用语义内核创建 AI 应用

在这部分,我们将深入了解语义内核,学习如何使用它来解决问题。我们首先向内核添加函数,然后使用函数解决问题。真正的力量在于当我们要求内核自己解决问题时。最后,我们学习如何使用内存为我们的内核保存历史记录。

这一部分包括以下章节:

  • 第三章**,扩展语义内核

  • 第四章**,通过链式函数执行复杂操作

  • 第五章**,使用规划器编程

  • 第六章**,将记忆添加到您的 AI 应用中

第三章:通过链式连接函数执行复杂操作

在上一章中,我们学习了如何创建本地插件,我们使用它们来检查 Excel 和 Word 文档的格式,以及语义插件,我们使用它们来验证文档内容是否符合我们的要求。

在本章中,我们将首先创建一个简单的管道,该管道生成图像。该管道将接收包含关于动物线索的文本,然后生成一个根据线索猜测动物的文本,以及生成该动物的图片。

在本章的后面部分,我们将继续应用上一章的内容:验证资助请求是否符合某些要求。为此应用,资助请求将包含文件夹内的两个文件:一个 Word 文档和一个 Excel 电子表格。

我们的应用程序检查 Word 文档是否包含疫苗接种活动的提案,包括将执行该活动的团队及其经验,以及包含其预算的 Excel 文件。

这种场景在企业中很常见:政府和企业必须优先处理项目请求,编辑必须批准或拒绝书籍提案,律师必须核实他们提交给法庭的文件是否符合法律要求。需要核实数千或数万份文件的情况并不少见。这类工作直到最近都是手工完成的。我们将编写一个自动化此过程的管道。

在本章中,我们将涵盖以下主题:

  • 创建生成图像的本地插件

  • 将输出文本的语义插件与生成图像的本地插件链式连接

  • 运行复杂的多步骤管道

到本章结束时,您将拥有构建能够执行许多协调功能的核心所需工具,它可以作为副驾驶或集成到其他工具中,例如规划器。

技术要求

为了完成本章,您需要拥有您首选的 Python 或 C# 开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是 .NET 8

在本章中,我们将调用 OpenAI 服务。鉴于公司在训练这些大型语言模型(LLM)上花费的金额,使用这些服务不是免费的。您需要一个 OpenAI API 密钥,无论是直接通过 OpenAI 还是 Microsoft,通过 Azure OpenAI 服务。

如果您使用 .NET,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch4

如果你使用 Python,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch4

你可以通过访问 GitHub 仓库并使用以下命令安装所需的包:pip install -r requirements.txt

创建一个生成图像的原生插件

要了解函数链的强大功能,我们将创建执行非常不同动作的函数。我们将从创建生成图像的函数并将它们放入插件开始。然后,我们将学习如何将这些函数整合到一个更复杂的链中。

在某些应用中,你可能希望使用 AI 生成图像。例如,带有图像的社交媒体帖子往往能获得更多的互动,但如果没有 AI 或找到图像,创建图像可能会很耗时且成本高昂。

与非 AI 图像的市场价格相比,使用 AI 生成图像非常便宜。另一方面,使用 AI 生成图像仍然是计算密集型活动之一。来自 Hugging Face 的最新研究 [1] 表明,生成一张图像在碳排放方面的成本是生成一个文本答案的 2,000 倍。这些成本将转嫁给你们。

OpenAI 图像生成的成本

如果你想复制本节的内容,请注意图像生成比文本生成成本高得多。你需要一个 API 密钥,以下示例中,每生成一张图像的成本为 0.04 美元,如果你想要创建更高分辨率、更高质量的图像,每张图像的成本可能高达 0.12 美元。

价格经常变动,你可以在 openai.com/pricing 查看最新的价格。

在 *图 4**.1 中,我们展示了两个使用 AI 生成的图像示例,我创建这些图像是为了增强我的几篇社交媒体帖子。第一个,我作为一个牛仔,是为了关于亿万富翁戴牛仔帽的帖子。第二个,我作为一个浪漫小说封面的角色,是为了关于写小说的帖子。每个图像使用 AI 生成的时间不到一分钟。我在 Threads 上进行了一次测试,Threads 是 Instagram 的新微型博客应用,我有超过 10,000 名关注者,带有图像的帖子比只有文字但没有图像的帖子获得了多倍的互动。

图 4.1 – 用于社交媒体帖子的 AI 生成的图像

图 4.1 – 用于社交媒体帖子的 AI 生成的图像

Microsoft Semantic Kernel 中的图像生成支持并不一致。C# API 有一个名为 TextToImage 的对象,可以使用 2022 年 11 月发布的 DALL-E 2 模型生成图像,但该 API 不可用于 Python。这个图像生成模型现在已经过时,因为 2023 年 10 月被 DALL-E 3 取代;然而,在撰写本文时,Semantic Kernel 并没有提供一种现成的访问 DALL-E 3 的方法。

我们将创建两个本地插件,一个用于 C#,一个用于 Python,这样我们就可以从 Semantic Kernel 访问 DALL-E 3。

向内核添加新模型

尽管我们使用的是 DALL-E 3,作为一个有趣且新颖的模型,作为我们添加到内核的示例,但创建本地插件包装器的方法适用于任何具有 API 的模型,包括 Anthropic 的 Claude、Google 的 Gemini 以及来自 Hugging Face 的数百个模型。任何通过 REST API 提供的 AI 服务都可以以这种方式添加。

在 Python 中编写 DALL-E 3 包装器

在 Python 中编写 DALL-E 3 包装器比在 C# 中更容易,因为 OpenAI 提供并支持一个名为 openai 的 Python 包,允许开发者在新功能发布时立即访问任何新功能。我们只需创建一个使用 OpenAI 包的本地函数并向 DALL-E 3 发送请求:

from dotenv import load_dotenv
from openai import OpenAI
import os
from semantic_kernel.skill_definition import kernel_function
class Dalle3:
    @kernel_function(
        description="Generates an with DALL-E 3 model based on a prompt",
        name="ImageFromPrompt",
        input_description="The prompt used to generate the image",
    )
    def ImageFromPrompt(self, input: str) -> str:
        load_dotenv()
        client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

正如我们在 第三章 中所看到的,我们使用 kernel_function 装饰器声明一个本地函数。然后我们只需使用我们的 API 密钥实例化一个 OpenAI 客户端对象。

现在,让我们提交请求:

        response = client.images.generate(
            model="dall-e-3",
            prompt=input,
            size="1024x1024",
            quality="standard",
            n=1,
        )
        image_url = response.data[0].url
        return image_url

Python OpenAI 客户端包含 images.generate 方法,该方法将调用 DALL-E 3 并返回生成的 URL。我们只需调用它并返回 URL。

现在,我们将创建一个简单的脚本,实例化插件并调用它:

import asyncio
import semantic_kernel as sk
from OpenAiPlugins import Dalle3
async def main():
    kernel = sk.Kernel()
    animal_str = "A painting of a cat sitting in a sofa in the impressionist style"
    dalle3 = kernel.import_skill(Dalle3())
    animal_pic_url = await kernel.run_async(
        dalle3['ImageFromPrompt'],
        input_str=animal_str
    )
    print(animal_pic_url)
if __name__ == "__main__":
    asyncio.run(main())

上述代码实例化了 Dalle3 本地插件,并使用 "A painting of a cat sitting on a sofa in the impressionist style" 输入参数调用其 ImageFromPrompt 函数。一个示例输出在 图 4**.2 中:

图 4-2 – 由 Python 生成的 DALL-E 3 的印象派风格猫

图 4.2 – 由 Python 生成的 DALL-E 3 的印象派风格猫

现在我们已经看到了如何在 Python 中实现这一点,接下来让我们看看如何在 C# 中实现。

在 C# 中编写 DALL-E 3 包装器

OpenAI 没有为 C# 提供受支持的包,该包允许该语言的用户与服务进行交互。对于 C# 用户来说,与 OpenAI 服务交互的最佳方式是使用 Microsoft Semantic Kernel,它具有 TextToImage 功能,但在撰写本文时,它仅提供对 DALL-E 2 的连接。

大多数 AI 服务都会公开 REST API。因此,要将 Microsoft Semantic Kernel 连接到它们,一个解决方案是编写一个本地插件,该插件包装 REST API。我们在这里展示了如何为 DALL-E 3 实现这一点:

using System.ComponentModel;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.SemanticKernel;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Plugins;
public class Dalle3
{
    [KernelFunction, Description("Generate an image from a prompt")]
    async public Task ImageFromPrompt([Description("Prompt describing the image you want to generate")] string prompt)
    {

制作 REST POST 请求需要几个包,例如 System.Text.JsonSystem.Net。就像我们在第三章中所做的那样,我们使用一个装饰器,KernelFunction,来指示该函数对语义内核是可访问的,并使用Description属性来描述我们的函数功能。

然后,我们创建一个HttpClient对象。此对象将执行 REST API 调用。我们需要使用我们的 API 密钥作为Bearer令牌来设置它,并将其头设置为接受“application/json”,因为 OpenAI API 将以这种方式响应:

        HttpClient client = new HttpClient
        {
            BaseAddress = new Uri("https://api.openai.com/v1/")
        };
        var (apiKey, orgId) = Settings.LoadFromFile();
        client.DefaultRequestHeaders
            .Accept
            .Add(new MediaTypeWithQualityHeaderValue("application/json"));
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

下一步是向 API 提交POST请求:

        var obj = new {
            model = "dall-e-3",
            prompt = prompt,
            n = 1,
            size = "1024x1024"};
        var content = new StringContent(JsonSerializer.Serialize(obj), Encoding.UTF8, "application/json");
        var response  = await client.PostAsync("images/generations", content);

我们使用 OpenAI API 所需的字段创建了一个 JSON 对象,objmodel字段说明了我们正在使用什么模型,在这里我们确保指定"dall-e-3"以使用 DALL-E 3。所有可能参数的文档可以在这里找到:platform.openai.com/docs/api-reference/images/create

最后一步是从 OpenAI 返回的 JSON 中恢复url字段。该url字段指向图像:

        if (!response.IsSuccessStatusCode)
        {
            return $"Error: {response.StatusCode}";
        }
        string jsonString = await response.Content.ReadAsStringAsync();
        using JsonDocument doc = JsonDocument.Parse(jsonString);
        JsonElement root = doc.RootElement;
        return root.GetProperty("data")[0]!.GetProperty("url")!.GetString()!;
    }
}

接下来,让我们看看如何调用该插件:

using Microsoft.SemanticKernel;
using Plugins;
var (apiKey, orgId) = Settings.LoadFromFile();
var builder = Kernel.CreateBuilder();
builder.Plugins.AddFromType<Dalle3>();
var kernel = builder.Build();
string prompt = "A cat sitting on a couch in the style of Monet";
string? url = await kernel.InvokeAsync(
    "Dalle3", "ImageFromPrompt", new() {{ "prompt", prompt }}
);
Console.Write(url);

要调用插件,我们在源文件中添加了对Plugins的引用,使用AddFromType实例化了Dalle3插件,并调用了它的ImageFromPrompt方法,将prompt作为参数传递。

生成的图片如下:

图 4.3 – 由 C# 本地插件生成的印象派风格的猫

图 4.3 – 由 C# 本地插件生成的印象派风格的猫

现在我们已经创建了一个可以访问新服务的函数,让我们将其整合到使用它的解决方案中。

使用多个步骤解决问题

虽然逐步编程解决方案可能非常有帮助,但语义内核提供给用户的最优秀的能力之一是允许他们使用自然语言进行请求。这需要使用规划器,我们将在第五章中使用它,将用户请求分解成多个步骤,然后自动按适当顺序调用每个步骤。

在本节中,我们将通过告诉语义内核调用哪些函数来解决问题。这有助于确保我们提供给规划器的解决方案是有效的,并且当我们要显式控制执行方式时也很有帮助。

为了说明手动方法,我们将展示如何向语义内核提供关于动物的线索,使用语义函数猜测它,然后使用我们在上一节中创建的本地函数生成动物的图像。

从线索生成图像

在下面的代码中,我们有两个步骤。在第一步中,我们将使用 GPT-3.5 从线索中猜测一个动物。为此,我们将创建一个名为AnimalGuesser的语义插件。

重要提示:使用 OpenAI 服务并非免费

语义函数将调用 OpenAI API。这些调用需要付费订阅,并且每次调用都会产生费用。通常,每个请求的费用很小。GPT 3.5 每千个标记的费用为 $0.0002,但如果进行大量调用,费用可能会累积。价格经常变动,因此请确保在以下网站上检查最新的价格:

OpenAI 定价:openai.com/pricing

Azure OpenAI 定价:azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/

语义插件,一如既往,由两个文件组成,config.jsonskprompt.txt,如下所示:

config.json

{
    "schema": 1,
    "name": "GuessAnimal",
    "type": "completion",
    "description": "Given a text with clues, guess the animal",
    "execution_settings": {
        "default": {
            "temperature": 0.8,
            "number_of_responses": 1,
            "top_p": 1,
            "max_tokens": 4000,
            "presence_penalty": 0.0,
            "frequency_penalty": 0.0
        }
    },
    "input_variables": [
        {
            "name": "input",
            "description": "CLues about an animal",
            "required": true
        }
    ]
}

skprompt.txt

Below, there's a list of clues about an animal.
{{$input}}
From the clues above, guess what animal it is.
Provide your answer in a single line, containing just the name of the animal.

如我们通常所做的那样,我们必须确保 config.json 文件中的 description 字段设置正确。现在这不会有任何影响,但当我们开始使用规划器或让语义内核自动调用函数时,内核将使用 description 字段来确定每个函数的功能并决定调用哪些函数。

现在,让我们看看如何告诉内核按顺序调用函数。

使用 C# 链接语义和本地函数

在 C# 中,你需要使用 KernelFunctionCombinators 类来创建函数管道。该类的代码在 GitHub 仓库中提供。

实现函数管道的代码如下:

using Microsoft.SemanticKernel;
using Plugins;
var (apiKey, orgId) = Settings.LoadFromFile();
var builder = Kernel.CreateBuilder();
builder.Plugins.AddFromType<Dalle3>();
builder.AddOpenAIChatCompletion("gpt-3.5-turbo", apiKey, orgId);
var kernel = builder.Build();
KernelPlugin animalGuesser = kernel.ImportPluginFromPromptDirectory("../../../plugins/AnimalGuesser");
string clues = "It's a mammal. It's a pet. It meows. It purrs.";

在前面的代码片段中,我们创建了我们的内核,向其中添加了一个 OpenAI 服务,并添加了 AnimalGuesserDalle3 插件。

接下来,我们将要调用的函数 AnimalGuesser.GuessAnimalDalle3.ImageFromPrompt 分配给 KernelFunction 变量:

KernelFunction guessAnimal = animalGuesser["GuessAnimal"];
KernelFunction generateImage = kernel.Plugins["Dalle3"]["ImageFromPrompt"];
KernelFunction pipeline = KernelFunctionCombinators.Pipe(new[] {
    guessAnimal,
    generateImage
}, "pipeline");

最后,我们创建一个名为 contextKernelArguments 对象,并将其作为参数传递给 InvokeAsync

KernelArguments context = new() { { "input", clues } };
Console.WriteLine(await pipeline.InvokeAsync(kernel, context));

context 对象的属性必须与第一个函数期望的属性相匹配。在我们的例子中,AnimalGuesser.GuessAnimal 函数期望一个名为 input 的参数。从那时起,管道将调用每个函数,将输出作为文本字符串,并将该文本字符串作为下一个函数的第一个参数传递。在我们的例子中,尽管 Dalle3.ImageFromPrompt 函数的第一个参数名为 prompt 而不是 input,但调用仍然会正常进行。你只需要提供在管道第一步中使用的参数的正确名称。

如果你运行前面的程序,你将得到一张猫的图片:

图 4.4 – 通过 AI 从线索中猜测动物生成的猫的图片

图 4.4 – 通过 AI 从线索中猜测动物生成的猫的图片

使用 Python 链接语义和本地函数

与 C# 中的做法一样,让我们使用 Python 创建一个脚本,该脚本从一个线索列表开始,猜测线索所指的动物,然后生成该动物的图片。

我们将基于我们已有的插件进行构建。我们将重用我们创建的用于使用 DALL-E 3 生成图像的原生插件。

新的一件事是我们将创建一个名为pipeline的函数,它接收一个函数列表和一个输入参数,然后调用列表中的每个函数,将调用的输出作为下一个函数的输入参数传递。

函数的定义如下:

async def pipeline(kernel, function_list, input):
    for function in function_list:
        args = KernelArguments(input=input)
        input = await kernel.invoke(function, args)
    return input

我们像往常一样开始,创建一个内核并向其中添加一个 AI 服务。在这里,我们将插件分配给变量,这将使我们能够在下一步中引用函数:

import asyncio
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
import semantic_kernel as sk
from OpenAiPlugins import Dalle3
async def main():
    kernel = sk.Kernel()
    api_key, org_id = sk.openai_settings_from_dot_env()
    gpt35 = OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id)
    kernel.add_chat_service("gpt35", gpt35)
    generate_image_plugin = kernel.import_skill(Dalle3())
    animal_guesser = kernel.import_semantic_skill_from_directory("../../plugins", "AnimalGuesser")

有了这些,我们现在可以给我们的模型提供线索并要求它猜测:

    clues = """
    I am thinking of an animal.
    It is a mammal.
    It is a pet.
    It is a carnivore.
    It purrs."""
    function_list = [
        animal_guesser['GuessAnimal'],
        generate_image_plugin['ImageFromPrompt']
    ]
    animal_pic_url = await pipeline(kernel, function_list, clues)
    print(animal_pic_url)
if __name__ == "__main__":
    asyncio.run(main())

如前一个代码片段所示,要运行管道,我们创建一个我们想要按顺序调用的函数列表,将其添加到function_list变量中,然后调用pipeline函数。pipeline函数将使用您传入的输入参数运行第一个函数,然后它将使用第一个函数的输出作为第二个函数的参数,依此类推。

就像在 C#示例中发生的那样,输出将是一个指向新生成的猫的图片的 URL(未显示)。

现在我们已经完成了一个简单的管道示例,让我们回到我们在第三章中解决的问题,并在本章引言中进行了描述:验证疫苗接种活动提案是否满足基本要求。

处理更大、更复杂的链

在上一章中,我们创建了三个插件:

  • CheckSpreadsheet:一个原生插件,用于检查 Excel 电子表格是否包含所需的字段,并且它们满足某些规则

  • ParseWordDocument:一个原生插件,用于从 Word 文档中提取文本

  • ProposalChecker:一个语义插件,用于检查文本块是否满足某些要求,例如“这个文本块是否描述了一个拥有博士学位和 医学博士 *的团队?”

使用这三个插件,您已经可以通过分别调用每个插件并编写处理是否有错误的逻辑来解决检查提案的业务问题。这可能对于只有少量步骤的问题来说是足够的。

虽然我们仍然会为了教学目的使用少量步骤和少量文档,但本章中介绍的针对大量文档进行分析和做出决策的方法,在有许多步骤和许多文档需要处理时表现得尤为出色。

让我们看看如何实现它。

准备我们的目录结构

在开始之前,我们需要确保我们已经将提案数据加载到data/proposals文件夹中。我们还将通过将它们放在与我们的主程序相同的目录中来重用原生插件。语义插件将放在plugins目录中。

我们将对我们在上一章中做的本地和语义函数进行轻微的修改。主要的变化是我们将直接在语义函数中引入错误处理,这将使我们能够通过单个调用处理许多文档。

下面是我们解决方案的目录结构。每个提案在 proposals 目录下都有一个目录表示,每个目录应恰好包含两个文件,一个是扩展名为 .xlsx 的 Excel 文件,另一个是扩展名为 .docx 的 Word 文件:

└───data
    └───proposals
    |   |───correct
    |   |    ├───correct.docx
    |   |    └───correct.xlsx
    |   ├───incorrect01
    |   |    ├───missing_experience.docx
    |   |    └───incorrect_template.xlsx
    |   ├───incorrect02
    |   |    ├───missing_qualifications.docx
    |   |    └───over_budget.xlsx
    |   └───(...)
    |   ├─── incorrect10
    └───plugins
         └───ProposalCheckerV2
              ├───CheckDatesV2
              |     ├───skprompt.txt
              |     └───config.json
              ├───CheckPreviousProjectV2
              |     ├───skprompt.txt
              |     └───config.json
              └───CheckTeamV2
                    ├───skprompt.txt
                    └───config.json
└───ch4
    └───code
          ├───python
          |    ├───ch4.py
          |    ├───ParseWordDocument.py
          |    └───CheckSpreadsheet.py
          └───dotnet
               ├───Program.cs
               ├───ParseWordDocument.cs
              └───CheckSpreadsheet.cs

这遵循了我们之前使用的相同结构:一个包含代码的主要文件(ch4.pyProgram.cs),同一目录下的附加文件,每个文件包含一个本地插件(ParseWordDocumentCheckSpreadsheet),以及所有语义插件在一个专门的目录 plugins 中。我们根据语言分隔文件夹,因为这使得管理按文件夹安装的包的虚拟环境变得更加简单。语义插件是语言无关的,并且可以有自己的目录。

现在我们已经描述了预期的目录结构,让我们来看看我们流程的高级流程。

理解我们流程的流程

我们将首先编写一个名为 Helpers 的本地插件,其中包含一个名为 ProcessProposalFolder 的本地函数,当给定一个表示文件夹的路径时,检查它是否恰好包含一个 Excel 文件和一个 Word 文档。如果是,它返回文件夹的路径,如果不是,它返回一个包含错误的字符串。

一旦我们创建了 Helpers 插件,我们几乎就可以调用我们在 第三章 中开发的函数了。我们将对现有的 ParseWordDocumentCheckSpreadsheet 文件进行两项修改。

我们将对两个文件进行的修改之一是检查输入是否处于错误状态。如果是,我们简单地传递错误状态。如果我们不在错误状态中,我们继续传递文件夹路径。我们需要对所有本地函数和语义函数进行这些简单的修改。

第二次也是最后一次修改将是 ParseWordDocument 本地插件。我们将添加三个单独的辅助函数,每个函数解析文档的三个不同必需部分(团队经验实施细节)。新函数将简单地调用现有的函数,每个函数用一个参数表示一个部分。

做所有这些的原因是为了在管道中只保留具有单个参数的函数。这使得每个函数的返回值可以作为参数传递给下一个函数,这将使事情变得更加简单。

完整的管道,包含 10 个步骤,在下图中表示。

图 4.5 – 管道的视觉表示

图 4.5 – 管道的视觉表示

图 4**.5中,橙色框表示处理文件系统的本地函数,绿色框表示处理 Excel 的本地函数,蓝色框表示处理 Word 的本地函数,紫色框表示语义函数。

现在我们已经理解了管道的所有步骤,让我们创建新的插件和函数,并进行必要的修改。

创建处理文件夹的本地函数

为了启动管道,我们需要一个插件来确保我们想要处理的文件夹包含正确的文件。由于这是一个需要与操作系统交互的活动,我们需要创建一个本地函数,我们将将其托管在我们将称为Helpers的插件中。

插件的代码如下:

C#

using Microsoft.SemanticKernel;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
public class Helpers
{
    [KernelFunction, Description("Checks that the folder contains one Word and one Excel file")]
    public static string ProcessProposalFolder([Description("Folder potentially containing")] string folderPath)
    {

如同往常,由于我们希望这个函数对 Semantic Kernel 可用,我们使用KernelFunction装饰器和Description变量下的函数功能描述。

现在,我们只是简单地计算文件夹中可用的文件数量。请记住,我们希望恰好有一个以.docx扩展名和一个以.xlsx扩展名的文件:

        string result = folderPath;
        if (!Directory.Exists(folderPath))
        {
            return "Error: Folder does not exist";
        }
        var files = Directory.GetFiles(folderPath);
        int wordCount = files.Count(f => Path.GetExtension(f).ToLower() == ".docx");
        int excelCount = files.Count(f => Path.GetExtension(f).ToLower() == ".xlsx");
        if (wordCount == 1 && excelCount == 1)
        {
            return result;
        }
        else if (wordCount == 0 && excelCount == 0)
        {
            return "Error: Folder does not contain one Word and one Excel file";
        }
        else if (wordCount == 0)
        {
            return "Error: Folder missing Word file";
        }
        else if (excelCount == 0)
        {
            return "Error: Folder missing Excel file";
        }
        return "Error: Folder contains more than one Word or Excel file";
    }
}

函数的逻辑部分非常简单。它计算目录中.docx.xlsx扩展名的文件数量。如果目录中每种文件恰好有一个,调用成功。我们通过传递文件夹作为返回结果来指示成功。在任何其他情况下,我们生成一个包含错误信息的字符串。我们将使用约定,该管道中的错误信息以Error开头。

现在让我们看看 Python 中插件代码:

Python

from typing_extensions import Annotated
from semantic_kernel.functions.kernel_function_decorator import kernel_function
import os
class Helpers:
    @kernel_function(
        description="Checks that the folder contains the expected files, an Excel spreadsheet and a Word document",
        name="ProcessProposalFolder"
    )
    def ProcessProposalFolder(self, input: Annotated[str, "The file path to the folder containing the proposal files"]) -> str:

在 Python 中,我们使用kernel_function装饰器来指示这个函数可以被 Semantic Kernel 使用。我们还为参数添加了一个Annotated描述。

然后,函数代码非常简单。类似于上面的 C#函数,我们计算目录中.docx.xlsx扩展名的文件数量。如果每种文件恰好有一个,我们通过返回文件夹名称来指示成功。任何其他情况都将导致失败,将通过以Error开头的字符串来指示:

    def ProcessProposalFolder(self, folder_path: str) -> str:
        xlsx_count = 0
        docx_count = 0
        for file in os.listdir(folder_path):
            if file.endswith(".xlsx"):
                xlsx_count += 1
            elif file.endswith(".docx"):
                docx_count += 1
        if xlsx_count == 1 and docx_count == 1:
            return "Success"
        elif xlsx_count == 0 and docx_count == 0:
            return "Error: No files found"
        elif xlsx_count == 0:
            return "Error: No Excel spreadsheet found"
        elif docx_count == 0:
            return "Error: No Word document found"
        else:
            return "Error: multiple files found"

现在我们有了启动管道的函数,让我们看看为了使我们在第三章中编写的插件可用于此管道,我们需要对它们进行哪些操作。

修改 Excel 本地插件

我们需要对为第三章创建的 Excel 插件进行一些修改:

  • 将错误信息标准化,使其始终以Error开头

  • 将成功信息标准化,使其始终返回文件夹

  • 在每个函数的开始处,如果输入以Error开头,则不执行任何操作,只需简单地将接收到的输入传递下去

为了节省空间,以下代码仅展示了函数CheckTabs的更改。完整的修改后的代码可在 GitHub 仓库中找到。

C#

    [KernelFunction, Description("Checks that the spreadsheet contains the correct tabs, 2024 and 2025")]
    public string CheckTabs([Description("The file path to the spreadsheet")] string folderPath)
    {
        if (folderPath.StartsWith("Error"))
        {
            return folderPath;
        }

上述代码检查接收到的输入是否包含错误。由于这将在管道中使用,因此之前步骤中的任何错误都将在这里接收。

如果我们遇到错误,我们将在以下代码中简单地将错误传递下去:

        string filePath = GetExcelFile(folderPath);
        try
        {
            FileInfo fileInfo = new FileInfo(filePath);
            if (!fileInfo.Exists)
            {
                return "Error: File does not exist.";
            }

注意,我们确保任何错误信息都以 Error 开头,以确保它们在由其他管道函数接收时易于检测。

我们现在检查文件中有多少个工作表:

            using (var package = new ExcelPackage(fileInfo))
            {
                ExcelPackage.LicenseContext = OfficeOpenXml.LicenseContext.NonCommercial;
                var workbook = package.Workbook;
                if (workbook.Worksheets.Count != 2)
                {
                    return "Error: Spreadsheet does not contain 2 tabs.";
                }
                if (workbook.Worksheets.Any(sheet => sheet.Name == "2024") && workbook.Worksheets.Any(sheet => sheet.Name == "2025"))
                {
                    return folderPath;
                }

在成功的情况下,我们只需将文件夹传递下去。

否则,以下代码将错误传递下去:

                else
                {
                    return "Error: Spreadsheet does not contain 2024 and 2025 tabs.";
                }
            }
        }
        catch (Exception ex)
        {
            return $"Error: An error occurred: {ex.Message}";
        }
    }

让我们看看 Python 中的变化。为了节省空间,我们只展示了 CheckTabs 函数的变化。所有函数的完整代码可以在 GitHub 仓库中找到。

Python

        @kernel_function(
        description="Checks that the spreadsheet contains the correct tabs, 2024 and 2025",
        name="CheckTabs",
    )
    def CheckTabs(self,
                  input: Annotated[str, "The path to the spreadsheet"]) -> Annotated[str, "The result of the check"]:
        if path.startswith("Error"):
            return path

如果上一个函数发送给我们一个错误,我们只需将其传递下去。

否则,我们继续:

        try:
            filePath = self.GetExcelFile(path)
            workbook = openpyxl.load_workbook(filePath)
            sheet_names = workbook.sheetnames
            if sheet_names == ['2024', '2025']:
                return path

为了表示成功,我们只需返回我们接收到的输入文件夹:

            else:
                return "Error: the spreadsheet does not contain the correct tabs"
        except Exception as e:
            return f"Error: an exception {e} occurred when trying to open the spreadsheet"

现在我们已经标准化了函数的输入和输出,这使得扩展管道变得更加容易,因为所有函数都期望相同的输入,一个文件夹,当函数成功时,它只需将文件夹传递给下一个函数。

在出现错误的情况下,作为输入传递的字符串将以 Error 开头,所有函数将简单地将其传递下去。这些更改使得更改步骤顺序以及添加或删除步骤变得更加容易。

现在我们已经完成了 Excel 插件中的更改,让我们对提取 Word 文档文本的 ParseWordDocument 插件进行修改。

修改 Word 原生插件

对 Word 原生插件进行的修改非常简单。在 第三章 中,当我们直接调用原生插件时,我们创建了一个名为 ExtractTextUnderHeading 的函数,该函数接收两个参数:一个文件的路径和我们想要提取的标题,我们调用了该函数三次,以便提取我们想要的三个标题下的文本。

在管道中,由于我们只能有一个参数,我们将创建三个名为 ExtractTeamExtractExperienceExtractImplementation 的函数,这些函数接收文件夹作为参数,并分别使用适当的标题参数调用我们在 第三章 中创建的 ExtractTextUnderHeading 函数,分别是 "Team""Experience""Implementation"

就像我们对 Excel 插件所做的那样,我们也将进行以下更改:

  • 将错误信息标准化,使其始终以 Error 开头

  • 将成功信息标准化,使其始终返回文件夹

  • 在每个函数的开始处,如果输入以 Error 开头,则不执行任何操作,只需简单地将接收到的输入传递下去

为了节省空间,我们在这里只展示一个函数。包括 ExtractTextUnderHeading 函数中的修改在内的完整代码可以在 GitHub 仓库中找到:

C#

    [KernelFunction, Description("Extracts the text under the Team heading in the Word document")]
    public static string ExtractTeam(string folderPath)
    {
        if (folderPath.Contains("Error"))
        {
            return folderPath;
        }
        string text = ExtractTextUnderHeading(folderPath, "Team");
        return $"FolderPath: {folderPath}\n"  + text;
    }

Python

    def ExtractTeam(self, folder_path: str) -> str:
        if folder_path.startswith("Error"):
            return folder_path
        doc_path = self.get_first_docx_file(folder_path)
        text = self.ExtractTextUnderHeading(doc_path, 'Team')
        return f"FolderPath: {folder_path}\n{text}"

在这两种情况下,除了为ExtractTextUnderHeading创建三个函数包装器以提取文本外,我们还执行了两个额外的任务。第一个任务是检查管道的上一阶段是否发送了错误消息。如果是,我们直接传递它。第二个任务是将文件夹路径添加到名为FolderPath的行中。这将在语义函数中使用。当语义函数决定读取的文本满足要求时,它将返回文件夹路径,正如管道中的函数所期望的那样。

让我们着手处理语义函数。

修改语义函数

我们需要对语义函数进行的主要修改是确保它们理解输入并提供适当的输出——在成功的情况下返回文件夹,在失败的情况下返回错误消息。

做这件事的一种方法是将输入编码成标签,然后告诉 AI 服务对标签内容执行操作。没有必要修改config.json文件,只需修改skprompt.txt文件。

CheckTeamV2: skprompt.txt

=====
 {{$input}}
=====
Check the contents of the text between the ===== and =====.
If the contents are an error message, respond with the error message, including the word "Error:" at the beginning.
Otherwise, the first line of the text between the ===== and ===== will contain the FolderPath.
The other lines will contain the team's experience.
We require the team's experience to have at least one person with a Ph.D. and at least one person with a degree in the medical sciences.
Think carefully about the team's experience.
If the team fulfills the requirements, your answer should be the contents of the FolderPath field, nothing more.
If the team does not fulfill the requirements, reply with "Error: Team does not meet requirements."

在这个语义函数中,我们告诉 AI 检查=====之间的文本内容是否存在错误,如果找到错误,则直接传递。如果=====标签之间的内容不是错误,它们将包含我们正在处理的文件夹,在 Word 文档的FolderPath行中开始,以及来自Team标题的文本。我们告诉 AI,如果团队满足我们列出的要求,则返回文件夹,如果不满足,则返回错误消息:

CheckDatesV2: skprompt.txt

=====
 {{$input}}
=====
Check the contents of the text between the tags ===== and =====.
If the contents are an error message, respond with the error message, including the word "Error:" at the beginning.
Otherwise, the text between the tags ===== and ===== will contain
a line starting with FolderPath and the rest of the field will contain
a description of a timeline for vaccine implementation.
Think carefully about the following question:
Ignoring the FolderPath line, does the timeline contain dates?
If there are no dates listed, say "Error: No dates listed" .
If the dates are outside of the 2024-2025 range, say "Error: dates out of range".
If there are dates between 2024 and 2025, respond only with the contents of the FolderPath field, nothing more.

现在,我们要求 AI 检查=====标签之间的内容是否存在错误。与之前一样,如果我们找到错误,我们直接传递它。然后,我们检查为疫苗接种活动提出的日期是否在我们的预期范围内。如果是,我们返回标签内FolderPath行中包含的文件夹。如果不是,我们返回一个错误消息:

CheckPreviousProjectV2: skprompt.txt

=====
 {{$input}}
=====
Check the contents of the text between the ===== and =====.
If the contents are an error message, respond with the error message, including the word "Error:" at the beginning.
Otherwise, the text between the ===== and ===== will contain a line starting with FolderPath and the rest of the field will contain a description of the teams experience.
Ignoring the FolderPath line, does the description of the teams experience
indicate they have enough experience to conduct a massive vaccination campaign in a new country?
If they have had a successful experience in Atlantis or another large country, respond only with the
contents of the FolderPath field, nothing more.
Otherwise, respond with "Error: Not enough experience."

最后一个语义函数与前两个非常相似。我们要求 AI 检查=====标签之间的内容,如果请求资金的团队没有足够的经验(如前一个项目所示),则返回错误消息。

现在我们已经完成了我们流程的所有步骤,让我们将它们组装成一个管道并运行它。

创建和调用管道

调用管道需要创建一个内核,用我们想要的函数加载它,然后按顺序调用它们。由于我们将使用语义函数,我们还需要将 AI 服务添加到内核中。对文本进行需求评估可能是一项复杂的任务,因此我们将使用 GPT-4 来执行它。如果文档简单,GPT 3.5 可以工作,但我们的某些文档有多页,这可能对 GPT 3.5 来说处理起来过于复杂。

C#

在以下代码块中,我们将所有原生和语义插件加载到我们的内核中:

using Microsoft.SemanticKernel;
using Plugins.ProposalChecker;
using System;
using System.IO;
var (apiKey, orgId) = Settings.LoadFromFile();
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion("gpt-4", apiKey, orgId);
builder.Plugins.AddFromPromptDirectory("../../../plugins/ProposalCheckerV2");
builder.Plugins.AddFromType<Helpers>();
builder.Plugins.AddFromType<ParseWordDocument>();
builder.Plugins.AddFromType<CheckSpreadsheet>();
var kernel = builder.Build();

然后,我们为每个函数创建变量:

KernelFunction processFolder = kernel.Plugins["Helpers"]["ProcessProposalFolder"];
KernelFunction checkTabs = kernel.Plugins["CheckSpreadsheet"]["CheckTabs"];
KernelFunction checkCells = kernel.Plugins["CheckSpreadsheet"]["CheckCells"];
KernelFunction checkValues = kernel.Plugins["CheckSpreadsheet"]["CheckValues"];
KernelFunction extractTeam = kernel.Plugins["ParseWordDocument"]["ExtractTeam"];
KernelFunction checkTeam = kernel.Plugins["ProposalCheckerV2"]["CheckTeamV2"];
KernelFunction extractExperience = kernel.Plugins["ParseWordDocument"]["ExtractExperience"];
KernelFunction checkExperience = kernel.Plugins["ProposalCheckerV2"]["CheckPreviousProjectV2"];
KernelFunction extractImplementation = kernel.Plugins["ParseWordDocument"]["ExtractImplementation"];
KernelFunction checkDates = kernel.Plugins["ProposalCheckerV2"]["CheckDatesV2"];

为每个函数创建变量并不是严格必要的——你只需将代码直接放入每个先前赋值右侧的管道调用中即可。

例如,而不是

KernelFunctionCombinators.Pipe(new[] {
    processFolder,
    checkTabs}

你可以写:

KernelFunctionCombinators.Pipe(new[] {
kernel.Plugins["ProposalCheckerV2"]["ProcessProposalFolder"]}
    kernel.Plugins["ProposalCheckerV2"]["CheckTabs"]}

将其分配给变量可以使管道调用中的内容更短,这可以使其更容易维护。

接下来,我们使用 KernelFunctionCombinatorsPipe 方法创建一个管道,简单地列出我们希望它们按顺序调用的步骤:

KernelFunction pipeline = KernelFunctionCombinators.Pipe(new[] {
    processFolder,
    checkTabs,
    checkCells,
    checkValues,
    extractTeam,
    checkTeam,
    extractExperience,
    checkExperience,
    extractImplementation,
    checkDates
}, "pipeline");

下一步将是调用管道:

var proposals = Directory.GetDirectories("../../../data/proposals");
// print each directory
foreach (var proposal in proposals)
{
    // convert to absolute path
    string absolutePath = Path.GetFullPath(proposal);
    Console.WriteLine($"Processing {absolutePath}");
    KernelArguments context = new() { { "folderPath", absolutePath } };
    string result = await pipeline.InvokeAsync(kernel, context);
    Console.WriteLine(result);
    if (result == absolutePath)
    {
        Console.WriteLine("Success!");
    }
    Console.WriteLine();
}

我们获取包含我们提案的 data/proposals 文件夹的路径。每个提案都是该文件夹的子文件夹。我们遍历 data/proposal 文件夹的每个子文件夹并调用我们的管道。如果我们没有发现任何错误,我们打印 Success。否则,我们列出我们找到的错误。

Python

我们首先创建我们的内核,向其中添加 GPT-4 服务,并添加所有本地和语义插件:

import asyncio
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
import semantic_kernel as sk
from CheckSpreadsheet import CheckSpreadsheet
from ParseWordDocument import ParseWordDocument
from Helpers import Helpers
import os
async def pipeline(kernel, function_list, input):
    for function in function_list:
        args = KernelArguments(input=input)
        input = await kernel.invoke(function, args)
    return input
async def main():
    kernel = sk.Kernel()
    api_key, org_id = sk.openai_settings_from_dot_env()
    gpt4 = OpenAIChatCompletion("gpt-4", api_key, org_id)
    kernel.add_chat_service("gpt4", gpt4)
    parse_word_document = kernel.import_skill(ParseWordDocument())
    check_spreadsheet = kernel.import_skill(CheckSpreadsheet())
    helpers = kernel.import_skill(Helpers())
    interpret_document = kernel.import_semantic_skill_from_directory("../../plugins", "ProposalCheckerV2")

注意,我们添加了上一节中创建的管道函数。

最后一步是创建一个函数列表并调用每个文档的管道:

    data_path = "../../data/proposals/"
    for folder in os.listdir(data_path):
        if not os.path.isdir(os.path.join(data_path, folder)):
            continue
        print(f"\n\nProcessing folder: {folder}")
        function_list = [
            helpers['ProcessProposalFolder'],
            check_spreadsheet['CheckTabs'],
            check_spreadsheet['CheckCells'],
            check_spreadsheet['CheckValues'],
            parse_word_document['ExtractTeam'],
            interpret_document['CheckTeamV2'],
            parse_word_document['ExtractExperience'],
            interpret_document['CheckPreviousProjectV2'],
            parse_word_document['ExtractImplementation'],
            interpret_document['CheckDatesV2']
        ]
        process_result = await pipeline(kernel, function_list, os.path.join(data_path, folder))
        result = (str(process_result))
        if result.startswith("Error"):
            print(result)
            continue
        else:
            print("Success")

完整的管道获得了预期的结果,满足所有要求的提案返回成功,有问题的提案返回描述问题的错误消息。

结果在此显示:

Processing folder: correct
Success
Processing folder: incorrect01
Error: the spreadsheet does not contain the correct tabs
Processing folder: incorrect02
Error: Sum of values in year 2025 exceeds 1,000,000.
Processing folder: incorrect03
Error: More than 10% growth found from B2 to B3 in sheet 2024.
Processing folder: incorrect04
Error: non-numeric inputs
Processing folder: incorrect05
Error: No Word document found
Processing folder: incorrect06
Error: No Excel spreadsheet found
Processing folder: incorrect07
Error: Not enough experience.
Processing folder: incorrect08
Error: Team does not meet requirements.
Processing folder: incorrect09
Error: dates out of range
Processing folder: incorrect10
Error: multiple files found

摘要

在得到 AI 的帮助之前,阅读和解释文档需要使用人的时间,或者编写专门的机器学习模型。语义内核允许你编写代码来分析大型和复杂的文档。

在我们的管道中,CheckSpreadsheet 本地插件并不严格需要语义内核,可以在单独的步骤中完成,因为它只运行 AI 永远不会读取的代码。我们将其添加到管道中,以使我们的端到端解决方案更加流畅。

与之相反,ParseWordDocument 本地插件帮助语义内核以部分形式接收信息。将文档拆分为部分使得语义功能更简单:每个功能只需评估文档的一部分。例如,评估文档 Teams 部分的函数只需检查团队资格。这使得函数的编写比一次性读取整个文档并决定所有部分的单步函数要简单得多。

因此,AI 为这个过程增加的实际价值在于语义插件。由 ProposalCheckerV2 插件中的语义函数实现的评估文档部分的任务,以前可能需要大量的人工努力或专门的机器学习模型。本章展示了如何仅通过在三个简短的 skprompt.txt 文件中描述要求来执行这些任务。

在本章中,我们手动创建了我们的流程,明确命名了我们想要调用的函数以及它们的调用顺序。在下一章中,我们将学习如何使用规划器。规划器将接收用户请求并决定调用哪些函数以及它们的调用顺序。

参考文献

[1] A. S. Luccioni, Y. Jernite, 和 E. Strubell, “Power Hungry Processing: Watts Driving the Cost of AI Deployment?” arXiv, Nov. 28, 2023. doi: 10.48550/arXiv.2311.16863.

第四章:使用规划器进行编程

在上一章中,我们学习了如何手动链式调用函数以执行复杂任务。在本章中,我们将学习如何使用 规划器 自动链式调用函数。自动链式调用函数可以为用户提供更多灵活性,让他们以你不需要编写代码的方式使用你的应用程序。

在本章中,我们将学习规划器是如何工作的,何时使用它们,以及需要注意什么。我们还将学习如何编写函数并构建一个内核,帮助规划器构建良好的计划。

本章将涵盖以下主题:

  • 规划器是什么以及何时使用它

  • 创建和使用规划器来运行简单函数

  • 设计函数以帮助规划器决定最佳的组合方式

  • 使用规划器允许用户以复杂的方式组合函数,而无需编写代码

到本章结束时,你将学会如何通过赋予用户使用自然语言进行请求的能力,让他们解决那些你不需要编写代码的复杂问题。

技术要求

要完成本章,你需要拥有你首选的 Python 或 C# 开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是 .NET 8

在本章中,我们将调用 OpenAI 服务。鉴于公司在训练这些大型语言模型(LLM)上花费的金额,使用这些服务不是免费的。你需要一个 OpenAI API 密钥,无论是直接通过 OpenAI 还是 Microsoft,通过 Azure OpenAI 服务。

如果你正在使用 .NET,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch5

如果你正在使用 Python,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch5

你可以通过访问 GitHub 仓库并使用以下命令安装所需的包:pip install -r requirements.txt

什么是规划器?

到目前为止,我们通过自己执行函数调用来执行复杂请求。然而,这要求你限制用户可以提出的请求类型,使其符合你能够预测并在事先编写的范围内。这也限制了用户一次只能生成一个输出。有时,你可能希望赋予他们做更多事情的能力。

例如,如果您有一个语义功能允许用户请求笑话(正如我们在第一章中构建的)并且用户请求“讲一个敲门笑话”,您可以简单地调用讲敲门笑话的语义功能。但如果用户请求三个敲门笑话,该功能将不知道如何处理。

规划器是语义内核的一个内置功能,它接收用户请求,然后遍历您在内核中加载的所有功能的描述、参数和输出,并决定最佳组合方式,生成一个计划

在撰写本文时,有两个规划器——一个Handlebars 规划器和一个函数调用逐步规划器,我们将简称为逐步规划器。它们的使用方式相同,但内部工作方式不同。当语义内核使用 Handlebars 规划器时,它会请求 AI 服务(例如,GPT-3.5 或 GPT-4)编写代码,以脚本语言 Handlebars 调用您加载到内核中的函数。Handlebars 规划器非常新,仍在实验阶段。预计它比逐步规划器消耗更少的令牌,因为编程语言在表达复杂概念(如条件语句和循环)方面可能更有效率。逐步规划器生成的计划是一个与聊天服务的对话,可能比 Handlebars 规划器生成的计划更长,并消耗更多令牌。目前,Handlebars 规划器的一个主要限制是它仅适用于 C#,尽管预计 2024 年可能会发布 Python 版本。

为了更好地理解规划器的工作原理,假设您有一个生成故事的插件、一个将故事分解成小部分的插件和一个生成图像的插件。您将这些插件全部加载到内核中。用户提交一个请求:

编写一个关于数据科学家和他的忠实犬伴解决犯罪的两页故事,将其分解成小部分,并为每一部分生成弗兰克·米勒风格的图像。”

规划器将遍历您在内核中加载的功能,并确定调用它们的最佳顺序,自动生成故事板,而无需您编写任何额外的代码,除了初始插件之外。

规划器可以使您的用户在您的最小努力下执行复杂任务。让我们看看何时使用它们。

何时使用规划器

规划器以两种方式帮助您作为开发者:

  • 用户可以以您未曾想到的方式组合您应用程序的功能。如果您将应用程序的功能作为原子功能嵌入插件中,并赋予用户向规划器发送请求的能力,那么规划器可以在不要求您编写任何代码的情况下,将这些原子功能组合到工作流程中。

  • 随着人工智能模型的改进,规划器会变得更好,而无需您编写任何额外的代码。当语义内核最初设计时,最好的 AI 模型是 GPT-3.5 Turbo。从那时起,我们已经发布了 GPT-4 和 GPT-4 Turbo,它们都具有更多的功能。使用语义内核构建的应用程序现在可以使用 GPT-4 Turbo,只需进行一些小的配置更改。

然而,在使用规划器时,有一些考虑因素:

  • 性能:规划器需要读取您内核中的所有函数,并将它们与用户请求结合起来。您的内核越丰富,您可以提供给用户的函数功能就越多,但规划器遍历所有描述并组合它们所需的时间会更长。此外,像 GPT-4 这样的新模型可以生成更好的计划,但它们运行速度较慢,未来的模型可能会更快。您需要在提供给用户的函数数量和您使用的模型之间找到一个良好的平衡。在测试您的应用程序时,如果您发现规划器延迟明显,您还需要将 UI 提示集成到您的应用程序中,以便用户知道正在发生某些事情。

  • 成本:生成一个计划可能会消耗许多令牌。如果您有很多函数,并且用户请求很复杂,语义内核将需要向 AI 服务提交一个非常长的提示,其中包含您内核中可用的函数的描述、它们的输入和输出,以及用户请求。生成的计划也可能很长,AI 服务将向您收取提交的提示和输出的费用。避免这种情况的一种方法是通过监控用户频繁创建的请求并保存这些计划,这样就不必每次都重新生成。请注意,然而,如果您保存了计划,并且后端模型(例如,GPT-5 发布)进行了升级,您必须记得重新生成这些计划以利用新模型的功能。

  • 测试:使用规划器会使测试您的应用程序变得更加困难。例如,您的内核可能有如此多的函数,用户请求可能如此复杂,以至于规划器会超出您所使用的模型的上下文窗口。您需要做一些事情来处理这种运行时错误,例如限制用户请求的大小或您内核中可用的函数数量。此外,虽然规划器大多数时候都能正常工作,但偶尔规划器可能会生成错误的计划,例如产生幻觉功能的计划。您需要为此提供错误处理。有趣的是,在实践中,简单地重新提交失败的计划,告诉 AI 服务该计划不起作用,并询问“你能修复它吗?”通常有效。

考虑到所有这些,让我们看看如何使用规划器。第一步是实例化一个规划器。

实例化一个规划器

实例化和使用规划器很简单。在 C#中,我们将使用 Handlebars 规划器,而在 Python 中,我们将使用 Stepwise 规划器。

C#

C# 包含了新的 HandlebarsPlanner,它允许你创建包含循环的计划,使它们更短。在使用 C#中的 Handlebars 规划器之前,你需要使用以下命令安装它:

dotnet add package Microsoft.SemanticKernel.Planners.Handlebars –-prerelease

要配置你的 Handlebars 规划器,你还需要安装 OpenAI 规划器连接器,如下所示:

dotnet add package Microsoft.SemanticKernel.Planners.OpenAI --prerelease

注意,规划器是实验性的,除非你通过在你的代码中添加pragma指令来告知 C#你同意使用实验性代码,否则 C#会给你一个错误:

#pragma warning disable SKEXP0060

要创建规划器,我们执行以下代码:

var plannerOptions = new HandlebarsPlannerOptions()
    {
        ExecutionSettings = new OpenAIPromptExecutionSettings()
        {
            Temperature = 0.0,
            TopP = 0.1,
            MaxTokens = 4000
        },
        AllowLoops = true
    };
var planner = new HandlebarsPlanner(plannerOptions);

微软建议为你的规划器使用较低的 TemperatureTopP,以最大限度地减少规划器创建不存在函数的可能性。规划器可能会消耗大量标记;因此,我们通常将 MaxTokens 设置为一个较高的值,以避免运行时错误。

现在,让我们看看如何在 Python 中创建规划器。

Python

在 Python 中,Handlebars 规划器尚未提供,因此我们需要实例化 Stepwise 规划器。Stepwise 规划器创建的计划通常比 Handlebars 计划长。要将 Stepwise 规划器添加到你的 Python 项目中,你需要从 semantic_kernel.planners 包中导入 FunctionCallingStepwisePlannerFunctionCallingStepwisePlannerOptions 类:

from semantic_kernel.planners import FunctionCallingStepwisePlanner, FunctionCallingStepwisePlannerOptions
import semantic_kernel as sk

通常给规划器提供足够的标记是个好主意。以下是一个创建规划器的示例命令,假设你在你的语义内核中加载了一个服务,并将 service_id 设置为 gpt4

planner_options = FunctionCallingStepwisePlannerOptions(
        max_tokens=4000,
    )
planner = FunctionCallingStepwisePlanner(service_id="gpt4", options=planner_options)

现在,让我们为用户请求创建并运行一个计划。

创建和运行计划

现在我们有了规划器,我们可以用它来为用户的请求创建一个计划,然后调用该计划以获得结果。在两种语言中,我们使用两个步骤,一个用于创建计划,另一个用于执行它。

对于接下来的两个代码片段,假设你已经将用户的请求加载到了 ask 字符串中。让我们看看如何调用规划器:

C#

var plan = await planner.CreatePlanAsync(kernel, ask);
var result = await plan.InvokeAsync(kernel);
Console.Write ($"Results: {result}");

Python

result = await planner.invoke(kernel, ask)
print(result.final_answer)

你可能还记得,从 第一章 中,在 Python 中,结果变量包含创建计划的所有步骤,因此为了查看计划的结果,你需要打印 result.final_answer。如果你打印 result 变量,你会得到一个大的 JSON 对象。

规划器如何帮助的一个例子

让我们看看一个简单的例子,它已经展示了规划器如何帮助。假设你创建了一个帮助有抱负的喜剧演员创作笑话的应用程序。你创建并连接到我们在 第一章 中创建的 jokes 语义插件。该插件包含一个创建敲门笑话的语义函数。

您可以创建一个 UI,允许用户输入一个主题(例如,“”),并调用该函数来创建敲门笑话。如果用户想要创建 100 个笑话,他们需要使用该 UI 100 次。您可以通过创建另一个 UI 来解决这个问题,该 UI 会询问用户想要创建多少个笑话。然而,如果用户想要为多个主题创建多个笑话,那么他们必须为每个想要创建笑话的主题使用您的两个 UI。

相反,仅使用语义函数和计划者,您可以允许用户用自然语言描述他们想要的内容,如下所示:

“*编四个敲门笑话 - 两个关于狗,一个关于猫,一个关于鸭子。”

完整的代码如下:

C#

#pragma warning disable SKEXP0060
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Planning.Handlebars;
using Microsoft.SemanticKernel.Connectors.OpenAI;
var (apiKey, orgId) = Settings.LoadFromFile();
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion("gpt-4", apiKey, orgId);
var kernel = builder.Build();
var pluginsDirectory = Path.Combine(System.IO.Directory.GetCurrentDirectory(),
        "..", "..", "..", "plugins", "jokes");
kernel.ImportPluginFromPromptDirectory(pluginsDirectory);
var plannerOptions = new HandlebarsPlannerOptions()
    {
        ExecutionSettings = new OpenAIPromptExecutionSettings()
        {
            Temperature = 0.0,
            TopP = 0.1,
            MaxTokens = 4000
        },
        AllowLoops = true
    };
var planner = new HandlebarsPlanner(plannerOptions);
var ask = "Tell four knock-knock jokes: two about dogs, one about cats and one about ducks";
var plan = await planner.CreatePlanAsync(kernel, ask);
var result = await plan.InvokeAsync(kernel);
Console.Write ($"Results: {result}");

Python

import asyncio
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.planners import FunctionCallingStepwisePlanner, FunctionCallingStepwisePlannerOptions
from semantic_kernel.utils.settings import openai_settings_from_dot_env
import semantic_kernel as sk
from dotenv import load_dotenv
async def main():
    kernel = sk.Kernel()
    api_key, org_id = openai_settings_from_dot_env()
    gpt35 = OpenAIChatCompletion("gpt-3.5-turbo", api_key, org_id, service_id = "gpt35")
    gpt4 = OpenAIChatCompletion("gpt-4", api_key, org_id, service_id = "gpt4")
    kernel.add_service(gpt35)
    kernel.add_service(gpt4)
    kernel.add_plugin(None, plugin_name="jokes", parent_directory="../../plugins/")
    planner_options = FunctionCallingStepwisePlannerOptions(
        max_tokens=4000,
    )
    planner = FunctionCallingStepwisePlanner(service_id="gpt4", options=planner_options)
    prompt = "Create four knock-knock jokes: two about dogs, one about cats and one about ducks"
    result = await planner.invoke(kernel, prompt)
    print(result.final_answer)
if __name__ == "__main__":
    asyncio.run(main())

在前面的代码中,我们创建了我们的内核并将其中的笑话插件添加到其中。现在,让我们创建计划者。

结果

您将得到以下 Python 和 C# 的结果:

1st Joke: Knock, knock!
Who's there?
Dog!
Dog who?
Dog who hasn't barked yet because he doesn't want to interrupt this hilarious joke!
2nd Joke: Knock, knock!
Who's there?
Dog!
Dog who?
Dog who forgot his keys, let me in!
3rd Joke: Knock, knock!
Who's there?
cat!
cat who?
Cat-ch me if you can, I'm the gingerbread man!
4th Joke: Knock, knock!
Who's there?
Duck!
Duck who?
Duck down, I'm throwing a pie!

注意,使用单个用户请求和单个对 invoke 的调用,语义内核生成了多个响应,而无需您编写任何循环,创建任何额外的 UI,或自己链式调用任何函数。

让我们看看幕后发生了什么。

计划者是如何工作的?

在幕后,计划者使用 LLM 提示来生成计划。例如,您可以通过导航到位于 github.com/microsoft/semantic-kernel/blob/7c3a01c1b6a810677d871a36a9211cca0ed7fc4d/dotnet/src/Planners/Planners.Handlebars/Handlebars/CreatePlanPrompt.handlebars 的 Semantic Kernel 存储库中的提示文件来查看 HandlebarsPlanner 所使用的提示。

提示的最后几行对于理解计划者的工作方式至关重要:

## Start
Now take a deep breath and accomplish the task:
1\. Keep the template short and sweet. Be as efficient as possible.
2\. Do not make up helpers or functions that were not provided to you, and be especially careful to NOT assume or use any helpers or operations that were not explicitly defined already.
3\. If you can't fully accomplish the goal with the available helpers, just print "{{insufficientFunctionsErrorMessage}}".
4\. Always start by identifying any important values in the goal. Then, use the `\{{set}}` helper to create variables for each of these values.
5\. The template should use the \{{json}} helper at least once to output the result of the final step.
6\. Don't forget to use the tips and tricks otherwise the template will not work.
7\. Don't close the ``` handlebars block until you're done with all the steps.

```py

The preceding steps define the set of rules that the planner uses to generate a plan using Handlebars.

Also, inside the prompt is what we call the **function manual** – that is, the instructions that the LLM will use to convert functions loaded into the kernel into text descriptions that are suitable for an LLM prompt:

{{#each functions}}

{{doubleOpen}}{{PluginName}}{{../nameDelimiter}}{{Name}}{{doubleClose}}

Description: {{Description}}

输入:

{{#each Parameters}}

  • {{Name}}:

{{~#if ParameterType}} {{ParameterType.Name}} -

{{~else}}

{{~#if Schema}} {{getSchemaTypeName this}} -{{/if}}

{{~/if}}

{{~#if Description}} {{Description}}{{/if}}

{{~#if IsRequired}} (required){{else}} (optional){{/if}}

{{/each}}

输出:

{{~#if ReturnParameter}}

{{~#if ReturnParameter.ParameterType}} {{ReturnParameter.ParameterType.Name}}

{{~else}}

{{~#if ReturnParameter.Schema}} {{getSchemaReturnTypeName ReturnParameter}}

{{else}} string{{/if}}

{{~/if}}

{{~#if ReturnParameter.Description}} - {{ReturnParameter.Description}}{{/if}}

{{/if}}

{{/each}}


In summary, the planner is just a plugin that uses an AI service to translate a user request into a series of callable function steps, and then it generates the code that calls these functions, returning the result.

To decide which functions to call and how to call them, planners rely on the descriptions you wrote for the plugin. For native functions, the descriptions are in function decorators, while for semantic functions, they are in the `config.json` file.

Planners will send your descriptions to an AI service as part of a prompt, with instructions that tell the AI service how to combine your descriptions into a plan. Writing good descriptions can help the AI service to create better plans.

Here are some things you should do:

*   `required=true` so that the model knows to provide an input. If you don’t do that, the created plan may not include a required parameter and will fail when executing.
*   **Provide examples**: Your description can provide examples of how to use the function and what the acceptable inputs and outputs are. For example, if you have a function that turns lights on in a location with the description “*Location where the lights should be turned on*,” and the location must be the kitchen or the garage, you can add “*The location must be either ‘kitchen’ or ‘garage’*” to the description. With that extra description, the planner will know not to call that function if the user asks to “*turn everything on in* *the bedroom*.”

Here are some things to avoid:

*   **Short descriptions**: If your function, inputs, or output descriptions are very short, it’s possible that they are not going to convey enough information to the planner about the context where they would be used. For example, it’s better to say that the output of a function is “*a knock-knock joke that follows a theme*” than “*joke*.”
*   **Very long descriptions**: Remember that the descriptions will be submitted as part of a prompt that will incur costs. If your description is very long (for example, you provide three examples for every function), you will pay for it. Make sure that what you write in the descriptions is close to what’s necessary.
*   **Conflicting descriptions**: If many of your functions have similar or the same description, the planner can get confused. For example, imagine that you create a jokes plugin that can create different types of jokes (knock-knock jokes, puns, absurdist jokes, etc.) but the description of all the functions is simply “*creates a joke*.” The planner will not know which function to call because the description tells it that all functions do the same thing.

If you are not getting the results that you expect when you use the planner, the first place you should look is in the descriptions you wrote for the functions, their inputs, and their outputs. Usually, just improving the descriptions a little helps the planner a lot. Another solution is to use a newer model. For example, if the plans are failing when you use GPT-3.5 and you already checked the descriptions, you may consider testing GPT-4 and seeing whether the results improve substantially.

Let’s see a comprehensive example.

# Controlling home automation with the planner

To get a better idea of what the planner can do, we will create a home automation application. We will not actually write functions that really control home automation, but assuming those exist, we will write their wrappers as native functions. We will also add a semantic function to our kernel and incorporate it into the planner.

We assume that we have a house with four rooms – a garage, kitchen, living room, and bedroom. We have automations to operate our garage door, operate the lights in all rooms, open the windows in the living room and in the bedroom, and operate the TV.

Since our objective is to learn about Semantic Kernel and not about home automation, these functions will be very simple. We want our user to be able to say something such as “*turn on the lights of the bedroom*,” and the result will be that our native function will say “*bedroom lights* *turned on*.”

The power of using the planner is shown when a user makes requests that require multiple steps, such as “*turn off the bedroom light and open the window*,” or even something more complex, such as “*turn off the living room lights and put on a highly rated horror movie on* *the TV*.”

## Creating the native functions

We will start by creating four native functions for home automation, one to operate the lights, one to operate the windows, one to operate the TV, and one to operate the garage door:

C#

using System.ComponentModel;

using Microsoft.SemanticKernel;

public class HomeAutomation

{

[KernelFunction, Description("打开或关闭客厅、厨房、卧室或车库里电灯。")]

public string OperateLight(

[Description("是否打开或关闭电灯。必须是 'on' 或 'off'")] string action,

[描述("必须打开或关闭灯光的位置。必须是 'living room', 'bedroom', 'kitchen' 或 'garage'")] string location)

{

string[] validLocations = {"living room", "bedroom"};

if (validLocations.Contains(location))

{

string exAction = $"将 {location} 的灯光状态更改为 {action}。";

Console.WriteLine(exAction);

return exAction;

}

else

{

string error = $"指定的位置无效 {location}。";

return error;

}

}


The most important parts of the function are the `Description` decorators for the function itself and the parameters. They are the ones that the planner will read to learn how to use the function. Note that the descriptions specify what the valid parameters are. This helps the planner decide what to do when it receives an instruction for all locations.

The function just verifies that the location is valid and prints the action that the home automation would have taken if it were real.

The other functions simply repeat the same preceding template for their objects (the window, TV, and garage door);

[内核函数, 描述("打开或关闭客厅或卧室的窗户。")]

public string OperateWindow(

[描述("是否打开或关闭窗户。必须是 'open' 或 'close'")] string action,

[描述("要打开或关闭窗户的位置。必须是 'living room' 或 'bedroom'")] string location)

{

string[] validLocations = {"living room", "bedroom"};

if (validLocations.Contains(location))

{

string exAction = $"将 {location} 的窗户状态更改为 {action}。";

Console.WriteLine(exAction);

return exAction;

}

else

{

string error = $"指定的位置无效 {location}。";

return error;

}

}

[内核函数, 描述("在客厅或卧室的电视上放电影。")]

public string OperateTV(

[描述("要在电视上播放的电影。")] string movie,

[描述("电影应播放的位置。必须是 'living room' 或 'bedroom'")] string location)

{

string[] validLocations = {"kitchen", "living room", "bedroom", "garage" };

if (validLocations.Contains(location))

{

string exAction = $"在 {location} 的电视上播放 {movie}。";

Console.WriteLine(exAction);

return exAction;

}

else

{

string error = $"指定的位置无效 {location}。";

return error;

}

}

[内核函数, 描述("打开或关闭车库门。")]

public string OperateGarageDoor(

[描述("对车库门执行的操作。必须是 'open' 或 'close'")] string action)

{

string exAction = $"将车库门的状态更改为 {action}。";

Console.WriteLine(exAction);

return exAction;

}

}


Python

from typing_extensions import Annotated

from semantic_kernel.functions.kernel_function_decorator import kernel_function

class HomeAutomation:

def init(self):

pass

@kernel_function(

description="打开或关闭客厅或卧室的窗户。",

name="OperateWindow",

)

def OperateWindow(self,

location: Annotated[str, "要打开或关闭窗户的位置。必须是 'living room' 或 'bedroom'"],

action: Annotated[str, "是否打开或关闭窗户。必须是 'open' 或 'close'"]) \

-> Annotated[str, "对窗户执行的操作。"]]:

if location in ["living room", "bedroom"]:

action = f"将 {location} 的窗户状态更改为 {action}。"

print(action)

return action

else:

error = f"指定的位置无效 {location}。"

return error


The preceding function is straightforward, checking that the location passed as a parameter is valid and printing what the automation would have done.

The most important parts of the function are the descriptions for inside the `kernel_function` and for each of the `Annotated` parameters, as the descriptions are what the planner will use to decide what to do.

Note that the descriptions specify what the valid parameters are. This helps the planner decide what to do when it receives a request to perform an action for all locations.

Now, let’s create the other functions, following a similar structure:

@kernel_function(

description="打开或关闭客厅、厨房、卧室或车库里的大灯。",

name="OperateLight",

)

def OperateLight(self,

location: Annotated[str, "要打开或关闭灯光的位置。必须是 'living room', 'kitchen', 'bedroom' 或 'garage'"],

action: Annotated[str, "是否打开或关闭灯光。必须是 'on' 或 'off'"]\

-> Annotated[str, "在灯光上执行的动作。"]:

if location in ["kitchen", "living room", "bedroom", "garage"]:

action = f"{location} 灯光的状态已更改为 {action}。"

print(action)

return action

else:

error = f"指定的位置 {location} 无效。"

return error

@kernel_function(

description="在客厅或卧室的电视上播放电影。",

name="OperateTV",

)

def OperateTV(self,

movie: Annotated[str, "要在电视上播放的电影。"],

location: Annotated[str, "电影应播放的位置。必须是 'living room' 或 'bedroom'"]

)\

-> Annotated[str, "在电视上执行的动作。"]:

if location in ["living room", "bedroom"]:

action = f"在 {location} 的电视上播放 {movie}。"

print(action)

return action

else:

error = f"指定的位置 {location} 无效。"

return error

@kernel_function(

description="打开或关闭车库门。",

name="OperateGarageDoor"

)

def OperateGarageDoor(self,

action: Annotated[str, "对车库门执行的动作。必须是 'open' 或 'close'"]\

-> Annotated[str, "在车库门上执行的动作。"]:

action = f"将车库门的状态更改为 {action}。"

print(action)

return action


Now that we’re done with native functions, let’s add a semantic function.

## Adding a semantic function to suggest movies

In addition to creating the preceding native functions that control different components of the house, we are also going to create a semantic function to suggest movies based on what the user requests. Semantic functions allow the user to make requests that require the use of an AI service – for example, to find the name of a movie based on a description or the name of an actor. You’ll see that planners can seamlessly combine semantic and native functions.

As is always the case, the semantic function is the same for both C# and Python, but we need to carefully configure the `skprompt.txt` and `config.json` files to help the planner find the function and understand how to use it.

We start by creating a prompt:

skprompt.txt

The prompt is very simple, and it simply asks for a suggestion for a movie. To make things easier for the planner, the prompt specifies that GPT should only respond with the title of the movie, as well as what to do if the user already knows the movie they want to watch:

根据以下请求,建议一个你认为请求者可能会喜欢的电影。如果请求已经是电影标题,只需返回该电影标题。

仅响应电影标题,不要包含其他内容。

Request:

{{ $input }}


Now, let’s see the configuration file:

config.json

Here, it’s again very important to fill in the `description` fields with as much detail as possible, as they are what the planner will use to decide which functions to call:

{

"schema": 1,

"name": "RecommendMovie",

"type": "completion",

"execution_settings": {

"default": {

"temperature": 0.8,

"number_of_responses": 1,

"top_p": 1,

"max_tokens": 4000,

"presence_penalty": 0.0,

"frequency_penalty": 0.0

}

},

"input_variables": [

{

"name": "input",

"description": "用户想要观看的电影的名称或描述。",

"required": true

}

]

}


Now that all the native and semantic functions are configured, let’s call the planner and see what it can do.

## Invoking the planner

Once you load the kernel with all these functions, all you need to do is invoke the planner and pass the user request to it.

We are going to make four requests to the planner:

*   *Turn on the lights in* *the kitchen*
*   *Open the windows of the bedroom, turn the lights off, and put on The Shawshank Redemption on* *the TV*
*   *Close the garage door and turn off the lights in all* *the rooms*
*   *Turn off the lights in all rooms and play a movie in which Tom Cruise is a lawyer, in the* *living room*

Using the existing plugins, the planner will take care of everything that is needed to fulfill these requests. For example, to fulfill the last request, the planner needs to call the `OperateLight` native function for each of the four rooms and ask GPT for a recommendation of a movie in which Tom Cruise is a lawyer, which will likely be *A Few Good Men* or *The Firm*. The planner will automatically call the functions and simply provide the results.

Python

The core part of the code is to create and execute the plan, using `create_plan` and `invoke_async`, and then print the results:

from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion

from semantic_kernel.planning.stepwise_planner import StepwisePlanner

import semantic_kernel as sk

from HomeAutomation import HomeAutomation

from dotenv import load_dotenv

import asyncio

async def fulfill_request(planner: StepwisePlanner, request):

print("正在满足请求:" + request)

variables = sk.ContextVariables()

plan = planner.create_plan(request)

result = await plan.invoke_async(variables)

print(result)

print("请求完成。\n\n")


Then, in the main function, we load the native functions and the semantic function in the kernel. This will make them available to the planner:

async def main():

kernel = sk.Kernel()

api_key, org_id = sk.openai_settings_from_dot_env()

gpt4 = OpenAIChatCompletion("gpt-4", api_key, org_id)

kernel.add_chat_service("gpt4", gpt4)

planner = StepwisePlanner(kernel)

kernel.import_skill(HomeAutomation())

kernel.import_semantic_skill_from_directory("../plugins/MovieRecommender", "RecommendMovie")


After loading the function, we simply call `fulfill_request`, which will create and execute a plan for each `ask`:

await fulfill_request(kernel, planner, "Turn on the lights in the kitchen")

await fulfill_request(kernel, planner, "Open the windows of the bedroom, turn the lights off and put on Shawshank Redemption on the TV.")

await fulfill_request(kernel, planner, "Close the garage door and turn off the lights in all rooms.")

await fulfill_request(kernel, planner, "Turn off the lights in all rooms and play a movie in which Tom Cruise is a lawyer in the living room.")

if name == "main":

load_dotenv()

asyncio.run(main())


C#

We start by creating a kernel and adding all the native functions and the semantic function we created for it. This will make these functions available to the planner:

using Microsoft.SemanticKernel;

using Microsoft.SemanticKernel.Planning.Handlebars;

pragma warning disable SKEXP0060

var (apiKey, orgId) = Settings.LoadFromFile();

var builder = Kernel.CreateBuilder();

builder.AddOpenAIChatCompletion("gpt-4", apiKey, orgId);

builder.Plugins.AddFromType();

builder.Plugins.AddFromPromptDirectory("../../../plugins/MovieRecommender");

var kernel = builder.Build();


We then create a function that receives a `planner` and an `ask`, creating and executing a plan to fulfill that request:

void FulfillRequest(HandlebarsPlanner planner, string ask)

{

Console.WriteLine($"Fulfilling request: {ask}");

var plan = planner.CreatePlanAsync(kernel, ask).Result;

var result = plan.InvokeAsync(kernel, []).Result;

Console.WriteLine("Request complete.");

}


The last step is to create the planner and call the `FulfillRequest` function we created for each `ask`:

var plannerOptions = new HandlebarsPlannerOptions()

{

ExecutionSettings = new OpenAIPromptExecutionSettings()

{

Temperature = 0.0,

TopP = 0.1,

MaxTokens = 4000

},

AllowLoops = true

};

var planner = new HandlebarsPlanner(plannerOptions);

FulfillRequest(planner, "Turn on the lights in the kitchen");

FulfillRequest(planner, "Open the windows of the bedroom, turn the lights off and put on Shawshank Redemption on the TV.");

FulfillRequest(planner, "Close the garage door and turn off the lights in all rooms.");

FulfillRequest(planner, "Turn off the lights in all rooms and play a movie in which Tom Cruise is a lawyer in the living room.");


Note that the code that uses the planner was very short. Let’s see the results:

Fulfilling request: Turn on the lights in the kitchen

将厨房灯的状态更改为开启。

Request complete.

Fulfilling request: Open the windows of the bedroom, turn the lights off and put on Shawshank Redemption on the TV.

将卧室窗户的状态更改为开启。

将卧室灯的状态更改为关闭。

在卧室的电视上播放《肖申克的救赎》。

Request complete.

Fulfilling request: Close the garage door and turn off the lights in all rooms.

将车库门的状态更改为关闭。

将客厅灯的状态更改为关闭。

将卧室灯的状态更改为关闭。

将厨房灯的状态更改为关闭。

将车库灯的状态更改为关闭。

Request complete.

Fulfilling request: Turn off the lights in all rooms and play a movie in which Tom Cruise is a lawyer in the living room.

将客厅灯的状态更改为关闭。

将卧室灯的状态更改为关闭。

将厨房灯的状态更改为关闭。

将车库灯的状态更改为关闭。

在客厅的电视上播放《非常嫌疑犯》。

Request complete.


规划器完美地执行了每个请求,而你无需编写任何代码。当用户询问诸如“*关闭所有房间的灯光*”之类的事情时,规划器意识到需要调用厨房、卧室、客厅和车库里面的函数。

当用户请求一部由汤姆·克鲁斯扮演律师的电影时,规划器意识到在调用`OperateTV`函数将电影放到电视上之前,需要调用一个语义函数来找到电影的名字,而你无需为此显式地编写代码。

# 摘要

在本章中,我们介绍了规划器,这是一个强大的功能,允许用户以最小的开发人员努力执行非常复杂的流程。我们学习了何时使用规划器以及可能存在的问题。我们还学习了如何使用规划器,以及如何编写插件中函数的描述,以便规划器更容易地将它们组合起来。然后我们看到了一个更长的示例,展示了如何使用规划器让用户结合原生和语义函数。

在下一章中,我们将探讨将外部数据提供给语义内核的方法。稍后,我们将搜索与外部数据配对,以便模型可以使用超出模型上下文窗口的大量数据。




# 第五章:将记忆添加到你的 AI 应用程序中

在上一章中,我们学习了如何使用规划器让我们的用户能够要求我们的应用程序执行我们没有明确编程的动作。在本章中,我们将学习如何使用外部数据,这样我们就可以带来最新的信息并在用户会话之间保持信息。目前,我们将使用用户通过将其保存到**内存**中可能给我们提供的少量外部数据。学习如何使用内存将使我们能够极大地扩展 AI 模型的功能。

这是下一章的构建块,我们将学习使用超过现有模型上下文窗口的数据量的技术。如你所记得,**上下文窗口**是你可以向 AI 服务发送的输入的最大大小。通过使用内存,你可以保存大量数据,并在每次调用中只发送数据的一部分。

我们将首先了解 LLMs 如何通过使用**嵌入**将单词转换为意义,然后比较具有相似意义的短语以从记忆中召回数据。在章节的后面部分,我们将看到如何在聊天应用中保存历史数据。

在本章中,我们将涵盖以下主题:

+   为文本数据创建嵌入

+   在内存中存储数据并在需要时恢复以用于提示

+   使用插件跟踪用户与你的应用程序进行的聊天

+   使用摘要来跟踪长聊天

到本章结束时,你将学会如何帮助你的应用程序记住用户输入的信息,并在需要时检索它。

# 技术要求

为了完成本章,你需要拥有你首选的 Python 或 C#开发环境的最新、受支持的版本:

+   对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

+   对于 C#,最低支持的版本是.NET 8

在本章中,我们将调用 OpenAI 服务。鉴于公司在这类 LLMs 训练上的支出,使用这些服务不是免费的也就不足为奇了。你需要一个**OpenAI API**密钥,无论是直接通过**OpenAI**还是通过**Microsoft**的**Azure OpenAI**服务。

如果你使用.NET,本章的代码位于[`github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch6`](https://github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch6)。

如果你使用 Python,本章的代码位于[`github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch6`](https://github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch6)。

你可以通过访问 GitHub 仓库并使用以下命令安装所需的包:`pip install -r requirements.txt`。

# 定义内存和嵌入

由 OpenAI 等 AI 服务提供的 LLMs 是**无状态的**,这意味着它们不保留任何先前交互的记忆。当你提交一个请求时,请求本身包含了模型将用于响应的所有信息。你之前提交的任何请求都已经由模型遗忘。虽然这种无状态的性质允许许多有用的应用,但某些情况需要模型在多个请求之间考虑更多的上下文。

尽管它们具有巨大的计算能力,但大多数 LLMs 一次只能处理少量文本,大约一页,尽管最近这一数字有所增加——2023 年 11 月发布的 GPT-4 Turbo 可以接收 128,000 个标记作为输入,这大约是 200 页文本。然而,有时有一些应用需要模型考虑超过 200 页的文本——例如,一个回答关于大量学术论文问题的模型。

记忆是帮助语义内核工作的强大方式,它通过为您的请求提供更多上下文来实现。我们通过使用一个称为**语义记忆搜索**的概念来向语义内核添加记忆,其中文本信息由称为**嵌入**的数字向量表示。自从计算机诞生以来,文本就被转换为数字,以帮助计算机比较不同的文本。例如,将字母转换为数字的 ASCII 表最早于 1963 年发布。LLMs 一次转换的不仅仅是单个字符,而是使用嵌入。

嵌入以单词和短语作为输入,并输出一个长长的数字列表来表示它们。列表的长度取决于嵌入模型。重要的是,含义相似的单词和短语在数值上彼此靠近;如果计算两个相似短语嵌入的数值组件之间的距离,它将小于两个含义非常不同的句子之间的距离。

我们将在*嵌入应用*部分看到一个完整的示例,但为了快速示例,单词短语“*queen*”和“*king*”之间的差异远小于单词短语“*camera*”和“*dog.*”之间的差异。

让我们深入了解一下。

## 语义记忆是如何工作的?

嵌入是将单词或其他数据表示为高维空间中的向量的方式。嵌入对 AI 模型很有用,因为它们可以以计算机可以处理的方式捕捉单词或数据的含义和上下文。嵌入模型接受一个句子、段落,甚至一些页面的文本,并输出相应的嵌入向量:

![图 6.1 – 嵌入模型](https://github.com/OpenDocCN/freelearn-dl-zh/raw/master/docs/bd-ai-app-ms-smknl/img/B21826_06_1.jpg)

图 6.1 – 嵌入模型

OpenAI 目前提供给用户的当前最高性能的嵌入模型被称为`text-embedding-3-large`,可以将多达 8,191 个标记(大约 12 页文本)转换为 3,072 维的实数向量。

OpenAI 还提供了不同价格和性能点的额外嵌入模型,例如`text-embedding-3-small`和`text-embedding-ada-2`。在撰写本文时,`text-embedding-3-small`模型提供的性能优于`text-embedding-ada-2`模型,并且它便宜五倍。

作为开发者,你可以将文本数据(包括 AI 服务提供的用户请求和响应)作为嵌入向量存储。重要的是要知道,这并不一定会使数据变小。对于给定的嵌入模型,嵌入向量始终是相同的长度。例如,对于`text-embedding-3-large`模型,嵌入向量长度始终是 3,072。如果你使用这个模型存储单词“*No*”,它将使用一个包含 3,072 个实数的向量,这将占用 12,228 字节的内存,比字符串“`No`”多得多,后者通常可以存储在两个字节中。另一方面,如果你嵌入 12 页的文本,它们的嵌入向量长度也将是 3,072,并占用 12,228 字节的内存。

你可以使用嵌入来回忆在请求之前很久就给应用程序提供的上下文。例如,你可以在数据库中存储与用户的所有对话。如果用户几个月前告诉你他们最喜欢的城市是法国的巴黎,这条信息可以被保存。稍后,当用户询问他们最喜欢的城市中最大的景点是什么时,你可以在你创建的数据库中搜索他们的最爱城市。

向量数据库与 KernelContext 有何不同?

在前面的章节中,我们使用了`KernelContext`类型的变量来向函数传递信息。一个`KernelContext`变量可以被序列化到磁盘上,因此,你可以用它来存储和记住应用程序已经被告知的事情。

差异在于,`KernelContext`变量是一系列键/值对的集合。对于你存储的每条信息,你必须提供一个键,稍后,你必须使用相同的键来检索它。另一方面,向量数据库通过相似性来检索信息,因此即使你不知道存储它的键,你也可以检索到一条信息。

另一个不同之处在于,如果你想的话,向量数据库可以只返回与请求的信息相似的信息子集,而如果你有一个`KernelContext`变量,你需要始终保留所有信息可用,这在你有大量信息时可能会导致性能和容量问题。

当用户与应用程序聊天时,你可以将用户在聊天中输入的每个命令作为嵌入存储,即其数值表示。然后,当用户输入新命令时,你可以通过比较用户刚刚输入的内容的嵌入与之前输入的内容来搜索用户之前输入的所有内容。

因为应用程序使用编码意义的嵌入表示,用户可能几个月前说过“*我最喜欢的城市是巴黎*”,现在可能问“*我最喜欢的城市里最大的景点是什么*”。字符串搜索不会在“*我最喜欢的城市*”和“*最喜欢的城市*”之间找到匹配,但这两个句子将具有彼此接近的嵌入向量,语义搜索会返回“*最喜欢的城市*”作为“*我最喜欢的城市*”的接近匹配。在这种情况下,接近匹配正是你所需要的。

让我们通过一个例子看看如何创建嵌入。

## 嵌入应用

本小节只包含 Python 代码,因为 OpenAI 没有提供 C# API,我们需要直接调用 REST API。本小节将展示嵌入值以帮助你理解嵌入概念,但你不需要实现代码来理解它。

首先,我们需要导入一些库。在 Python 中,线性代数计算在`numpy`库中,因此我们需要导入它:

```py
from openai import OpenAI
from typing import Tuple
import numpy as np
from numpy.linalg import norm
import os

在这里,我们将为三个句子生成嵌入,并将它们相互比较。

首先,我们将编写一个函数(get_embedding)来生成嵌入:

def get_embedding(text: str) -> Tuple[float]:
    client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
    response = client.embeddings.create(
        input=text,
        model="text-embedding-3-small"
    )
    return response.data[0].embedding

前面的函数是一个简单的函数调用,只是实例化了一个与 OpenAI 的连接,并调用了embeddings.create方法,使用text-embedding-3-small模型。

然后,为了比较一个嵌入与另一个嵌入的相似度,我们将使用0.01.0,其中1.0表示它们非常相似。

def similarity(A: np.array, B: np.array) -> float:
    # compute cosine similarity
    cosine = np.dot(A,B)/(norm(A)*norm(B))
    return cosine

最后一步是调用函数,使用我们想要检查的短语:

if __name__ == "__main__":
    load_dotenv()
    king = get_embedding("The king has been crowned")
    queen = get_embedding("The queen has been crowned")
    linkedin = get_embedding("LinkedIn is a social media platform for professionals")
    print(similarity(king, queen))
    print(similarity(king, linkedin))
    print(similarity(queen, linkedin))

三个短语是"The king has been crowned""The queen has been crowned""LinkedIn is a social media platform for professionals"。我们预计前两个短语是相似的,并且两者都与第三个短语不同。

如预期,这就是我们得到的结果,记住数字在0.01.0之间,其中0.0表示不相似,1.0表示完美匹配:

0.8684853246664367
0.028215574794606412
0.046607036099519175

如果你想查看嵌入本身,你可以打印它们,记住它们使用 1,536 个实数来表示text-embedding-3-small模型。

以下代码打印了前 10 个嵌入值:

    for i in range(0, 10):
        print(king[i])

下面是结果:

-0.009829566814005375
-0.009656181558966637
0.024287164211273193
0.01408415473997593
-0.03662413731217384
-0.0040411921218037605
-0.00032176158856600523
0.046813808381557465
-0.03235621005296707
-0.04099876061081886

现在我们对嵌入的工作原理有了更多的了解,让我们看看如何使用它们与 LLM 结合。

在聊天和 LLM 中使用内存

如我们之前所见,模型有一个称为上下文窗口的大小限制。大小限制包括用户请求的提示和响应。例如,GPT-3.5 等模型的默认上下文窗口为 4,096 字节,这意味着你的提示(包括用户请求)和 GPT-3.5 提供的答案最多可以有 4,096 字节;否则,你会得到错误,或者响应会在中间被截断。

如果你的应用程序使用大量文本数据,例如,一本 10,000 页的操作手册,或者允许人们搜索和询问包含数百份文档的数据库,每份文档有 50 页,你需要找到一种方法,只将相关部分的数据集包含在你的提示中。否则,提示本身可能比上下文窗口大,导致错误,或者剩余的上下文窗口可能太短,以至于没有空间让模型提供好的答案。

解决这个问题的方法之一是将每一页总结成一个较短的段落,并为每个摘要生成一个嵌入向量。你不需要在提示中包含所有页面,可以使用余弦相似度等工具通过比较请求嵌入与嵌入向量来搜索相关页面,然后只将相关页面的摘要包含在提示中,这样可以节省很多空间。

使用内存的另一个原因是保持会话之间或提示之间的数据。例如,正如我们在 语义记忆是如何工作的? 部分中建议的,用户可能已经告诉过你他们最喜欢的城市是巴黎,当用户要求获取他们最喜欢的城市的指南时,你不需要再次询问;你只需要搜索他们的最喜欢的城市。

要找到与我们的提示相关的内存中的数据,我们可以使用之前章节中展示的余弦距离等工具。在实践中,Semantic Kernel SDK 已经为你提供了搜索功能,因此你不需要自己实现它。此外,你还可以使用几个第三方向量数据库,每个数据库都有自己的搜索功能。

这里是一个你可以直接使用的所有数据库的列表:

数据库名称 Python C#
Azure Cosmos DB for MongoDB vCore
Azure AI Search
Azure PostgreSQL Server
Chroma
DuckDB
Milvus
MongoDB Atlas Vector Search
Pinecone
PostgreSQL
Qdrant
Redis
SQLite
Weaviate

表 6.1 — 向量数据库与 Semantic Kernel 的兼容性

除了列出的数据库之外,还有一个叫做 VolatileMemoryStore 的数据库,它代表你运行代码的机器的 RAM。这个数据库不是持久的,其内容在代码运行完成后将被丢弃,但它速度快且免费,可以在开发过程中轻松使用。

使用 Microsoft Semantic Kernel 的内存

在以下示例中,我们将存储一些关于用户的信息,然后使用 TextMemorySkill 核心技能在提示中直接检索它。核心技能是 Semantic Kernel 自带的功能。TextMemorySkill 具有将文本放入内存和检索文本的功能。

在以下示例中,我们的用例将是一个用户告诉我们他们的最喜欢的城市和最喜欢的活动。我们将将这些信息保存到内存中,然后检索它们,并根据保存的信息提供行程。

我们首先导入我们通常导入的库,以及一些将在后面描述的内存库。

Python

import asyncio
import semantic_kernel as sk
from semantic_kernel.connectors.ai.open_ai import OpenAITextEmbedding, OpenAIChatCompletion, OpenAIChatPromptExecutionSettings
from semantic_kernel.functions import KernelArguments, KernelFunction
from semantic_kernel.prompt_template import PromptTemplateConfig
from semantic_kernel.utils.settings import openai_settings_from_dot_env
from semantic_kernel.memory.volatile_memory_store import VolatileMemoryStore
from semantic_kernel.memory.semantic_text_memory import SemanticTextMemory
from semantic_kernel.core_plugins.text_memory_plugin import TextMemoryPlugin

C#

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel.Plugins.Memory;
using Microsoft.SemanticKernel.Connectors.OpenAI;
#pragma warning disable SKEXP0003, SKEXP0011, SKEXP0052

注意,Python 中的内存函数是异步的,因此我们必须包含 asyncio 库。此外,在撰写本文时,C# 中的内存函数被标记为实验性,因此您必须使用 #pragma 命令禁用实验性警告。

现在,让我们创建一个内核:

Python

def create_kernel() -> tuple[sk.Kernel, OpenAITextEmbedding]:
    api_key, org_id =  openai_settings_from_dot_env()
    kernel = sk.Kernel()
    gpt = OpenAIChatCompletion(ai_model_id="gpt-4-turbo-preview", api_key=api_key, org_id=org_id, service_id="gpt4")
    emb = OpenAITextEmbedding(ai_model_id="text-embedding-ada-002", api_key=api_key, org_id=org_id, service_id="emb")
    kernel.add_service(emb)
    kernel.add_service(gpt)
    return kernel, emb
async def main():
    kernel, emb = create_kernel()
    memory = SemanticTextMemory(storage=VolatileMemoryStore(), embeddings_generator=emb)
    kernel.add_plugin(TextMemoryPlugin(memory), "TextMemoryPlugin")

C#

var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion("gpt-4-turbo-preview", apiKey, orgId);
var kernel = builder.Build();
var memoryBuilder = new MemoryBuilder();
memoryBuilder.WithMemoryStore(new VolatileMemoryStore());
memoryBuilder.WithOpenAITextEmbeddingGeneration("text-embedding-3-small", apiKey);
var memory = memoryBuilder.Build();

注意,我们已经向我们的内核添加了三个项目:

  • 一个嵌入模型,它将帮助我们加载内容到内存中。对于 C#,我们可以使用 text-embedding-3-small,但在撰写本文时,尽管 Python 可以像上一节那样使用 text-embedding-3-small,但核心 Python 插件仅与模型 text-embedding-ada-002 兼容。

  • 内存存储;在这种情况下,VolatileMemoryStore,它只是在您的计算机 RAM 中临时存储数据。

  • 一个用于生成行程的 GPT 模型;我们正在使用 GPT-4

还要注意,在 C# 中,内存和内核是分别构建的,而在 Python 中,它们是一起构建的。

现在,让我们创建一个向内存添加数据的函数:

Python

 async def add_to_memory(memory: SemanticTextMemory, id: str, text: str):
    await memory.save_information(collection="generic", id=id, text=text)

C#

const string MemoryCollectionName = "default";
await memory.SaveInformationAsync(MemoryCollectionName, id: "1", text: "My favorite city is Paris");
await memory.SaveInformationAsync(MemoryCollectionName, id: "2", text: "My favorite activity is visiting museums");

向内存添加数据的函数简单地调用 Python 中的 memory.save_information 和 C# 中的 memory.SaveInformationAsync。您可以使用集合将不同组的信息分开,但在我们的简单案例中,我们将使用 "generic" 作为 Python 的默认集合,以及 "default" 作为 C# 的默认集合,因为这些是插件的默认集合。id 参数不必有任何意义,但它必须对每个项目是唯一的。如果您使用相同的 id 参数保存多个项目,则最后保存的项目将覆盖前面的项目。通常生成 GUID 以确保一定程度的唯一性,但如果您只是手动添加少量项目,则可以手动确保 id 不同。

我们现在可以创建一个生成旅行行程的函数:

Python

async def tour(kernel: sk.Kernel) -> KernelFunction:
    prompt = """
    Information about me, from previous conversations:
    - {{$city}} {{recall $city}}
    - {{$activity}} {{recall $activity}}
    """.strip()
    execution_settings = kernel.get_service("gpt4").instantiate_prompt_execution_settings(service_id="gpt4")
    execution_settings.max_tokens = 4000
    execution_settings.temperature = 0.8
    prompt_template_config = PromptTemplateConfig(template=prompt, execution_settings=execution_settings)
    chat_func = kernel.add_function(
        function_name="chat_with_memory",
        plugin_name="TextMemoryPlugin",
        prompt_template_config=prompt_template_config,
    )
    return chat_func

C#

kernel.ImportPluginFromObject(new TextMemoryPlugin(memory));
const string prompt = @"
Information about me, from previous conversations:
- {{$city}} {{recall $city}}
- {{$activity}} {{recall $activity}}
Generate a personalized tour of activities for me to do when I have a free day in my favorite city. I just want to do my favorite activity.
";
var f = kernel.CreateFunctionFromPrompt(prompt, new OpenAIPromptExecutionSettings { MaxTokens = 2000, Temperature = 0.8 });
var context = new KernelArguments();
context["city"] = "What is my favorite city?";
context["activity"] = "What is my favorite activity?";
context[TextMemoryPlugin.CollectionParam] = MemoryCollectionName;

TextMemoryPlugin 允许您在提示中使用 {{recall $question}} 来检索内存中的内容,而无需编写任何代码。

例如,假设我们已经将 My favorite city is Paris 加载到我们的内存中。当我们用 "What's my favorite city" 加载 $city 变量并在提示中写入 {{$city}} {{recall $city}} 时,Semantic Kernel 将在提示中将该行替换为 "What's my favorite city? My favorite city is Paris"

在内存中存储数据

注意,我们在内存中存储数据时没有使用有意义的键名(我们使用了"1""2")。您也不需要在存储之前对信息进行分类。一些应用程序只是简单地存储所有内容,而其他应用程序则使用语义函数询问 Semantic Kernel 用户输入是否包含个性化信息,并在这些情况下存储它。

现在,让我们加载内存并调用提示:

Python

    await add_to_memory(memory, id="1", text="My favorite city is Paris")
    await add_to_memory(memory, id="2", text="My favorite activity is visiting museums")
    f = await tour(kernel)
    args = KernelArguments()
    args["city"] = "My favorite city is Paris"
    args["activity"] = "My favorite activity is visiting museums"
    answer = await kernel.invoke(f, arguments=args)
    print(answer)
if __name__ == "__main__":
    asyncio.run(main())

C#

await memory.SaveInformationAsync(MemoryCollectionName, id: "1", text: "My favorite city is Paris");
await memory.SaveInformationAsync(MemoryCollectionName, id: "2", text: "My favorite activity is visiting museums");
var result = await f.InvokeAsync(kernel, context);
Console.WriteLine(result);

在代码中,我们使用add_to_memory函数将信息加载到内存中,并立即调用我们的语义函数f。如果您使用除VolatileMemoryStore之外的其他任何内存存储,您不需要在同一个会话中实现这两个步骤。我们将在第七章中的RAG检索增强生成)示例中看到一个持久化内存的例子。

结果

注意,模型回忆起用户最喜欢的城市是巴黎,以及他们最喜欢的活动是去博物馆:

Given your love for Paris and visiting museums, here's a personalized itinerary for a fulfilling day exploring some of the most iconic and enriching museums in the City of Light:
**Morning: Musée du Louvre**
Start your day at the Louvre, the world's largest art museum and a historic monument in Paris. Home to thousands of works of art, including the Mona Lisa and the Venus de Milo, the Louvre offers an unparalleled experience for art lovers. Arrive early to beat the crowds and spend your morning marveling at the masterpieces from across the world. Don't forget to walk through the Tuileries Garden nearby for a peaceful stroll.
**Afternoon: Musée d'Orsay**
Next, head to the Musée d'Orsay, located on the left bank of the Seine. Housed in the former Gare d'Orsay, a Beaux-Arts railway station, the museum holds the largest collection of Impressionist and Post-Impressionist masterpieces in the world. Spend your afternoon admiring works by Monet, Van Gogh, Renoir, and many others.
**Late Afternoon: Musée de l'Orangerie**
Conclude your day of museum visits at the Musée de l'Orangerie, located in the corner of the Tuileries Gardens. This gallery is famous for housing eight large Water Lilies murals by Claude Monet, displayed in two oval rooms offering a breathtaking panorama of Monet's garden-inspired masterpieces. The museum also contains works by Cézanne, Matisse, Picasso, and Rousseau, among others.
**Evening: Seine River Walk and Dinner**
After an enriching day of art, take a leisurely walk along the Seine River. The riverside offers a picturesque view of Paris as the city lights begin to sparkle. For dinner, choose one of the numerous bistros or restaurants along the river or in the nearby neighborhoods to enjoy classic French cuisine, reflecting on the beautiful artworks and memories created throughout the day.

注意,如果您为表 6.1中列出的任何矢量数据库提供商支付订阅费,您可以直接用他们的构造函数替换VolatileMemoryStore构造函数;例如,如果您使用 Pinecone,您将使用Pinecone(apiKey),内存将保存在该数据库中,并在用户下次运行您的应用程序时可供用户使用。我们将在第七章中看到一个使用 Azure AI Search 的示例。

现在,让我们看看我们如何在一个与用户的聊天中使用内存。

在聊天中使用内存

内存通常用于基于聊天的应用程序。我们在前几章中构建的所有应用程序都是单次的——完成任务所需的所有信息都是用户提交的请求的一部分,以及我们在自己的代码中对提示所做的任何修改,例如,通过在skprompt.txt中的变量内包含用户提交的提示,或使用字符串操作修改他们的提示。所有之前发生的问题和答案都被忽略。我们说 AI 服务是无状态的

然而,有时我们希望 AI 服务记住之前做出的请求。例如,如果我询问应用程序关于印度人口最多的城市,应用程序将回答它是Mumbai。如果然后我询问“夏天那里的温度如何”,我期望应用程序意识到我是在询问 Mumbai 的温度,即使我的第二个提示没有包含城市的名字。

正如我们在第一章中提到的,暴力解决方案是简单地重复整个聊天历史与每个新的请求。因此,当用户提交第二个请求时,我们可以静默地附加他们的第一个请求以及我们的 AI 服务提供的响应,然后再次将所有内容一起提交给 AI 服务。

让我们看看如何做到这一点。我们首先进行常规导入:

Python

import asyncio
import semantic_kernel as sk
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion, OpenAIChatPromptExecutionSettings
from semantic_kernel.functions import KernelFunction
from semantic_kernel.prompt_template import PromptTemplateConfig, InputVariable
from semantic_kernel.core_plugins import ConversationSummaryPlugin
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.utils.settings import openai_settings_from_dot_env

C#

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Microsoft.SemanticKernel.Plugins.Core;
#pragma warning disable SKEXP0003, SKEXP0011, SKEXP0052, SKEXP0050

注意,在 C#中,由于 Semantic Kernel 包的几个组件仍在预发布阶段,你需要使用#pragma指令禁用实验性警告。

在导入库之后,我们创建内核:

Python

def create_kernel() -> sk.Kernel:
    api_key, org_id =  openai_settings_from_dot_env()
    kernel = sk.Kernel()
    gpt = OpenAIChatCompletion(ai_model_id="gpt-4-turbo-preview", api_key=api_key, org_id=org_id, service_id="gpt4")
    kernel.add_service(gpt)
    # The following execution settings are used for the ConversationSummaryPlugin
    execution_settings = OpenAIChatPromptExecutionSettings(
        service_id="gpt4", max_tokens=ConversationSummaryPlugin._max_tokens, temperature=0.1, top_p=0.5)
    prompt_template_config = PromptTemplateConfig(
        template=ConversationSummaryPlugin._summarize_conversation_prompt_template,
        description="Given a section of a conversation transcript, summarize it",
        execution_settings=execution_settings,
    )
    # Import the ConversationSummaryPlugin
    kernel.add_plugin(
        ConversationSummaryPlugin(kernel=kernel, prompt_template_config=prompt_template_config),
        plugin_name="ConversationSummaryPlugin",
    )
    return kernel

C#

var (apiKey, orgId) = Settings.LoadFromFile();
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion("gpt-4-turbo-preview", apiKey, orgId);
var kernel = builder.Build();
kernel.ImportPluginFromObject(new ConversationSummaryPlugin());

我们的内核只需要一个聊天完成服务。我正在使用 GPT-4,但 GPT-3.5 也适用。我还添加了ConversationSummaryPlugin,它将在本章的最后一个小节使用摘要减少历史大小中使用。我们将在稍后详细解释它,但正如其名称所暗示的,它总结了对话。

现在,让我们创建主要的聊天函数:

Python

async def create_chat_function(kernel: sk.Kernel) -> KernelFunction:
    # Create the prompt
    prompt = """
    User: {{$request}}
    Assistant:  """
    # These execution settings are tied to the chat function, created below.
    execution_settings = kernel.get_service("gpt4").instantiate_prompt_execution_settings(service_id="gpt4")
    chat_prompt_template_config = PromptTemplateConfig(
        template=prompt,
        description="Chat with the assistant",
        execution_settings=execution_settings,
        input_variables=[
            InputVariable(name="request", description="The user input", is_required=True),
            InputVariable(name="history", description="The history of the conversation", is_required=True),
        ],
    )
    # Create the function
    chat_function = kernel.add_function(
        prompt=prompt,
        plugin_name="Summarize_Conversation",
        function_name="Chat",
        description="Chat with the assistant",
        prompt_template_config=chat_prompt_template_config,)
    return chat_function

C#

const string prompt = @"
Chat history:
{{$history}}
User: {{$userInput}}
Assistant:";
var executionSettings = new OpenAIPromptExecutionSettings {MaxTokens = 2000,Temperature = 0.8,};
var chatFunction = kernel.CreateFunctionFromPrompt(prompt, executionSettings);
var history = "";
var arguments = new KernelArguments();
arguments["history"] = history;

现在,让我们编写我们程序的主循环:

Python

async def main():
    kernel = create_kernel()
    history = ChatHistory()
    chat_function = await create_chat_function(kernel)
    while True:
        try:
            request = input("User:> ")
        except KeyboardInterrupt:
            print("\n\nExiting chat...")
            return False
        except EOFError:
            print("\n\nExiting chat...")
            return False
        if request == "exit":
            print("\n\nExiting chat...")
            return False
        result = await kernel.invoke(
            chat_function,
            request=request,
            history=history,
        )
        # Add the request to the history
        history.add_user_message(request)
        history.add_assistant_message(str(result))
        print(f"Assistant:> {result}")
if __name__ == "__main__":
    asyncio.run(main())

C#

var chatting = true;
while (chatting) {
    Console.Write("User: ");
    var input = Console.ReadLine();
    if (input == null) {break;}
    input = input.Trim();
    if (input == "exit") {break;}
    arguments["userInput"] = input;
    var answer = await chatFunction.InvokeAsync(kernel, arguments);
    var result = $"\nUser: {input}\nAssistant: {answer}\n";
    history += result;
    arguments["history"] = history;
    // Show the bot response
    Console.WriteLine(result);
}

我们程序的主循环会一直运行,直到用户输入单词"exit"。否则,我们将用户请求提交给 AI 服务,收集其答案,并将两者都添加到history变量中,我们也将它作为请求的一部分提交。

虽然这解决了总是需要整个历史的问题,但随着提示变得越来越大,这变得过于昂贵。当用户提交其请求编号N时,history变量包含他们的请求1,…,N-1,以及聊天机器人随附的答案1,…,N-1。对于大的N,除了成本高昂之外,这还可能超过 AI 服务的上下文窗口,你将得到一个错误。

解决方案是只向 AI 服务传递历史摘要。幸运的是,总结对话是即使是较老模型也能做得很好的事情。让我们看看如何使用 Semantic Kernel 轻松完成它。

使用摘要减少历史大小

如果你想要在不丢失太多上下文的情况下减少提示,可以使用 AI 服务总结对话中已经发生的内容。为此,你可以使用我们在创建内核时导入的ConversationSummaryPluginSummarizeConversation函数。现在,不再需要在提示中重复整个历史,摘要将根据对话大小最多包含 1,000 个 token,这对于大多数用例应该足够了。要总结$history变量中的历史,只需在提示中调用{{ConversationSummaryPlugin.SummarizeConversation $history}}即可。

即使在过度总结之后,仍然可能丢失细节。如果你试图用 1,000 个单词总结 1,000 页的内容,某些东西将会丢失。为了防止这个问题,大多数应用程序都对对话的长度有限制。例如,在撰写本文时,Microsoft Copilot 对话的限制为 20 次交互,在那之后你必须重新开始对话(带有空内存)。

代码的更改如下所示;你只需更改prompt变量的内容。此更改将在提示中添加一个对话摘要,这将提醒 LLM 之前发生的一切。摘要将不会显示给用户。

Python

prompt = """
Chat history:
{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$userInput}}
Assistant:
"""

C#

const string prompt = @"
Chat history:
{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$userInput}}
ChatBot:";

结果

User:> What is the largest city by population in Western Europe?
Assistant:> The largest city by population in Western Europe is London, United Kingdom.

注意,在下一个问题中,我将使用“那里”这个词来指代伦敦。由于历史摘要被包含在对话中,尽管我的下一个问题没有明确提到伦敦,但发送给 AI 的提示中包含了这个信息:

User:> Are there any famous people who lived there?
Assistant:> Yes, London has been home to many famous people throughout history. Some notable individuals include:
1\. **William Shakespeare** - The renowned playwright and poet lived in London for most of his career.
2\. **Charles Dickens** - The famous novelist, known for works like "Oliver Twist" and "A Christmas Carol," spent much of his life in London.
3\. **Winston Churchill** - The iconic British Prime Minister during World War II was born in Woodstock but lived and worked in London for much of his life.

注意,上一个问题的答案是正确的。莎士比亚、狄更斯和丘吉尔都住在伦敦。现在,我将通过其在列表中的位置来提及莎士比亚,并将伦敦简单地称为那个城市,因为我们正在追踪历史,内核将知道我指的是莎士比亚和伦敦:

User:> What is a famous play from the first one set in that city?
Assistant:> A famous play from William Shakespeare that is set in London is "Henry V." This historical play, part of Shakespeare's series on the English monarchs, includes scenes that are set in London, notably the English court. It portrays the events before and after the Battle of Agincourt during the Hundred Years' War, with significant portions reflecting on the life and times in London during the early 15th century.

再次,AI 给出了正确的答案。《亨利五世》这部戏剧实际上是在伦敦上演的。

让我们退出聊天:

User:> exit|
Exiting chat...

摘要

在本章中,我们学习了如何添加和检索信息,以及如何轻松地将记忆包含在提示中。LLMs 是无状态的,并且受到其提示大小的限制,在本章中,我们学习了在会话之间保存信息并减少提示大小,同时仍然在提示中包含相关对话部分的技术。

在下一章中,我们将看到如何使用向量数据库从记忆中检索更多信息,并使用称为检索增强生成RAG)的技术以有用的方式组织和展示这些信息。这种技术通常用于企业应用,因为你在 LLMs 提供的创造性上做出一些妥协,但获得了额外的精确性、展示参考文献的能力以及使用你拥有和控制的大量数据的能力。

对于我们的应用程序,我们将把数千篇学术论文加载到向量数据库中,并让语义内核搜索一个主题,并为我们总结研究。

第三部分:现实世界用例

在这部分,我们看到了语义内核如何在现实世界问题中得到应用。我们学习了如何使用检索增强生成(RAG)技术,使 AI 模型能够使用大量数据,包括在 AI 服务训练时不可用的非常最新的数据。我们通过学习如何使用 ChatGPT 将我们编写的应用程序分发给数亿用户来结束本部分。

本部分包括以下章节:

  • 第七章现实世界用例 – 检索增强生成

  • 第八章现实世界用例 – 在 ChatGPT 上使你的应用程序可用

第六章:真实世界用例 - 检索增强生成

在上一章中,我们学习了如何通过在内存中保留小块知识并将其按需检索来增强我们的内核,这使得我们的应用程序能够更加个性化。基于云的 AI 模型,如 OpenAI 的 GPT,通常具有知识截止日期,大约是几个月前。它们通常也没有特定领域的知识,例如您公司制造产品的用户手册,也不知道用户的偏好,例如他们最喜欢的编程语言或他们最喜欢的城市。上一章向您介绍了通过在内存中保留小块知识并将其按需检索来增强模型知识的方法。

在本章中,我们将向您展示如何扩展可供您的 AI 应用程序使用的数据。我们不会使用适合提示的小量数据,而是将使用大量数据,通过一个 检索增强生成RAG)应用程序来实现,该应用程序结合了最新的生成式 AI 模型与最近的专业信息,以回答关于特定主题的问题——在我们的案例中,是关于 AI 的学术论文。

RAG 利用大量机构拥有有用的数据这一事实,这些数据并未用于训练 OpenAI 的 GPT。这为这些机构提供了一种将这些数据投入使用的同时,还能利用 GPT 的生成能力的方法。

在本章中,我们将涵盖以下主题:

  • 使用 Azure AI 搜索服务创建文档索引

  • 将大量文档加载到索引中

  • 创建一个搜索索引并使用 AI 基于找到的数据编写答案的应用程序

到本章结束时,您将创建一个应用程序,该应用程序使用大量最近的数据,并使用 AI 以用户友好的方式查找和组合数据。

技术要求

要完成本章,您需要拥有您首选的 Python 或 C# 开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是 .NET 8。

在本章中,我们将调用 OpenAI 服务。鉴于公司在训练这些大型语言模型(LLM)上所花费的金额,使用这些服务并非免费也就不足为奇了。您需要获取一个 OpenAI API 密钥,可以通过直接从 OpenAIMicrosoft 获取,或者通过 Azure OpenAI 服务。

如果您使用 .NET,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch7

如果您正在使用 Python,本章的代码位于github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch7

要创建文档索引,您需要 Microsoft Azure AI Search 的免费试用版。

您可以通过访问 GitHub 仓库并使用以下命令安装所需的包:pip install -r requirements.txt

为什么您需要自定义 GPT 模型?

GPT 模型在未经任何自定义的情况下已经非常有用。当您的用户输入请求时,作为程序员的您可以将请求简单地转发给 GPT 模型(如 GPT-3.5 或 GPT-4),在许多情况下,模型未经修改的响应已经足够好。然而,在许多情况下,响应并不足够好。响应问题可以分为三类:

  • 非文本功能:在某些情况下,您想要的响应不是基于文本的。例如,您可能希望允许您的用户打开或关闭灯光,执行复杂的数学运算,或将记录插入数据库。

  • 缺乏上下文:如果模型没有接触过包含答案的数据,它们就无法准确回答问题。尽管经过大量数据的训练,但仍有大量数据 LLM 尚未接触过。撰写本文时,用于训练 GPT 3.5 和 GPT-4 的数据截止日期为 2021 年 9 月,尽管有一个名为 GPT-4 Turbo 的 GPT-4 预览版本,其截止日期为 2023 年 12 月(您可以在platform.openai.com/docs/models/上查看模型的截止日期)。此外,模型无法访问专有数据,例如贵公司的内部文件。

  • 仅用 Y 或 N 回答您的提示,但某些请求返回的响应可能是Yes(而不是Y)或答案是肯定的,这需要添加代码来验证答案。

我们向您展示了如何使用语义内核通过原生函数解决第一个问题(非文本功能),如第三章中所示。然而,如果您收到的响应问题在于缺乏上下文或格式,您可以使用以下图表中描述的技术:

图 7.1 – 提高响应的技术

图 7.1 – 提高响应的技术

您应该始终尝试的第一个技术是提示工程,我们在第二章中详细介绍了这一技术。提示工程既容易做又容易测试:它可以用来向 LLM 提供新数据(改善上下文),也可以提供一些示例,说明您希望答案看起来如何(改善格式)。

例如,假设你正在构建一个应用程序,为你的团队提供午餐地点的建议,这对于开发团队来说总是一个挑战。与其简单地问“我们午餐去哪里?”,不如通过添加上下文和格式规范来获得更好的结果,例如:“我们是一支由六个 25-38 岁的开发者组成的团队,其中两人是素食主义者,我们想在周五靠近埃菲尔铁塔的地方吃午餐。我们希望每人花费不超过 20 欧元,并且我们不想花费超过 90 分钟吃午餐。请以地点名称、网站、平均价格和街道地址的形式提供你的答案。”格式规范是最后一句话。

主要的缺点是,你想要提供的数据越多,指令越复杂,你的提示就会变得越大,从而导致额外的成本和延迟。

除了通过提示工程提供示例之外,你还可以使用微调模型来改进你答案的格式。微调允许你向现有的模型(例如 GPT-3.5)提供成百上千个问题和答案的示例,并保存一个新的、经过微调的模型。

成功微调的一个例子是展示成千上万种你期望 JSON 输出应该呈现的方式。由于你提供了成千上万的示例,你不能将这些示例传递给每个提示,因为提示会变得太大。你可以创建一个包含成千上万问题和 JSON 答案的文件,并使用 OpenAI 微调 API 或微调 UI 创建一个经过你额外示例训练的定制 GPT 模型。结果将是一个在提供 JSON 答案方面表现得更好,而在其他方面表现更差的模型。

如果你的应用程序只需要提供 JSON 格式的答案,这正是你所需要的。Microsoft Semantic Kernel 并不帮助进行微调,因此微调技术不在此书的范围之内。如果你想了解更多关于微调的信息,Sebastian Raschka(Packt 作者)的这篇在线文章可能会有所帮助:magazine.sebastianraschka.com/p/finetuning-large-language-models

在实践中,最常见的问题之一是 LLM 可能没有足够的上下文来提供你想要的答案。即使提供答案所需的数据已经被用于训练模型,这也可能发生:由于 LLM 是用大量数据进行训练的,你可能需要向你的请求中添加相关数据,以帮助模型从它训练的大量数据中回忆起与你的请求相关的数据。例如,如果你只是问 GPT “谁是史上最佳足球运动员?”,它可能不知道你指的是足球(足球)还是 NFL(美国足球)。

在某些其他情况下,正如我们之前在提到截止日期和私人数据示例时讨论的那样,模型从未见过回答问题所需的数据,你需要在你提出请求时将其展示给模型。

在一定程度上,你可以通过提示工程来解决这两个问题:

  • 你可以指示模型扮演一个角色。例如,你可以添加 你是一名 Python 软件工程师 来引导模型以更技术性的方式回答,或者添加 你是一个五岁的孩子 来引导模型以更简单的方式回答。

  • 你可以向模型提供一些数据示例。例如,你可以在提示中直接添加 如果用户说'地球是平的',回复'错误信息';如果用户说'登月是假的',回复'错误信息';如果用户说'鸟是真实的',回复"真实",或者通过使用语义函数中的提示模板。

  • 你可以在提示模板中添加一些字段并在实时填充它们。例如,你可以从系统中获取今天的日期并创建一个提示,声明 从今天到 1776 年 7 月 4 日,相差的天数是……",动态地替换 $today,因此将最近的信息传递给模型。

提示工程的第一个缺点是,你需要传递的数据越多,你的提示就会越大,这会使提示更昂贵。它还会增加延迟,因为 LLM 处理长提示需要更长的时间。

即使你的预算可以支持额外的成本,并且你的用户非常耐心,不介意等待答案,仍然存在两个问题。第一个问题是,随着提示的增大,LLMs 的准确性会降低[1]。第二个问题是,在某个时候,你可能会耗尽模型上下文窗口的空间。例如,假设你为一家制造汽车的公司工作,你想帮助用户在用户手册中找到关于他们汽车的答案,但手册有 300 页长。即使你解决了所有之前的问题,你也不能在提示中传递整个手册,因为它放不下。

最好的解决方案是将用户手册分成几个部分,并将这些部分保存到索引中。当用户提问时,你可以使用搜索算法通过使用诸如余弦相似度等方法返回最相关的部分,如第六章所示。然后,你只需要将相关的部分传递给提示。这种技术的名称是 RAG,并且它被广泛使用。Semantic Kernel 使其易于实现,但你还需要一个索引。让我们深入了解细节。

检索增强生成

RAG 是一种结合预训练语言模型的力量和信息检索来根据大量文档生成响应的方法。这对于生成基于外部知识(不包含在模型训练数据集中)的知情响应特别有用。

RAG 涉及三个步骤:

  • 检索:给定一个输入查询(例如,一个问题或提示),你使用一个系统从你的数据源中检索相关文档或段落。这通常是通过嵌入来完成的。

  • 增强:检索到的文档随后被用来增强输入提示。通常这意味着创建一个结合检索步骤中的数据并添加一些提示工程的提示。

  • 生成:增强后的提示随后被输入到一个生成模型,通常是 GPT,它生成输出。因为提示包含了检索到的文档中的相关信息,模型可以生成受外部知识启发的外部知识。

除了向 AI 服务提供额外的和更近期的信息外,RAG 还可以帮助进行扎根。扎根是将语言模型的响应与准确、可靠和上下文适当的知识或数据联系起来的过程。这在事实准确性和相关性至关重要的场景中尤为重要,例如回答有关科学、历史或当前事件的问题。扎根有助于确保模型提供的信息不仅合理,而且正确,并且适用于现实世界。

当你使用 RAG 时,你向 LLM 提供你希望它用来生成你响应的数据。如果你的数据是准确、可靠和上下文适当的,那么 LLM 使用这些数据生成的文本很可能也是准确、可靠和上下文适当的。你甚至可以要求生成步骤提供它使用的文档的链接。我们将在我们的示例中看到这一点。

假设你想要总结关于具有大上下文窗口的模型最新发现的内容。首先,你需要通过进行网络搜索或使用学术论文数据库来检索关于最新发现的信息。

要实现 RAG,你需要一些额外的组件:

  • numpy具有免费的优势。

  • 检索系统:用于根据输入查询从文档存储中找到最相关文档的软件。

大多数向量数据库供应商提供与他们的服务兼容的算法,最近,大多数解决方案都采用了向量比较,如余弦相似度。例如,Pinecone 和 Azure AI Search 等服务提供文档和嵌入存储和检索算法。

在我们的例子中,我们将创建一个应用程序,允许你搜索和询问来自 ArXiV 数据库的 AI 论文。我们下载了 2021 年及以后提交的 计算与语言 类别中所有论文的 ArXiV ID、作者、标题和摘要列表。这个数据集可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Microsoft-Semantic-Kernel/blob/b1187f88f46589f14a768e3ee7b89bef733f1263/data/papers/ai_arxiv_202101.json

数据集包含总共 36,908 篇科学文章。它们的摘要内容位于 abstract 字段中,包含超过 4000 万个字符,这大约需要 1000 万个标记,即使是最大的 AI 模型也无法处理。

我们将把所有这些数据加载到 Azure AI 搜索索引中。但在加载文章之前,我们必须创建索引。

创建索引

为了存储和检索大量数据,我们需要创建一个索引。为此,你必须有一个 Azure 账户,并必须创建一个 Azure AI 搜索服务。只需搜索 Azure AI Search 并点击 创建;你将被要求输入一个名称。你需要服务的端点,你可以在 配置 选项卡中找到它,如图 7**.2 所示。或者你创建的 Azure AI 搜索服务。图 7**.2 显示了你在 Url 字段中创建的服务端点,用绿色标记:

图 7.2 – Azure AI 搜索配置屏幕

图 7.2 – Azure AI 搜索配置屏幕

你还需要一个管理密钥,你可以在 Azure AI 搜索服务的 密钥 选项卡下找到。

创建服务只是第一步:服务只是一个存储一个或多个索引的地方,而索引是我们将存储数据的地方。现在我们有了服务,我们需要编写代码来创建索引。

字段名称值得提及。如果你能使用一些标准名称——即 IdAdditionalMetadataTextDescriptionExternalSourceNameIsReferenceEmbedding,你的生活将会轻松很多。字段名称应使用特定的首字母大写。如果你使用这些名称,你可以轻松地使用 Azure AI 搜索语义内核连接的预览版本,这将使你的代码更小。你将用于搜索的文本(在我们的例子中是摘要)应该是 Text。在下面的代码中,我将将这些字段映射到我们需要的内容。

那么,让我们看看如何在 Python 中实现这一点。稍后,我们将学习如何在 C# 中实现。

使用 Python 创建索引

在 Python 脚本中编写以下代码以创建索引:

from azure.core.credentials import AzureKeyCredential
from azure.search.documents.indexes import SearchIndexClient

首先,你需要导入 AzureKeyCredential 函数来读取你的管理密钥,以及 SearchIndexClient 来创建一个对象,该对象将允许你与 Azure AI 搜索服务交互。

接下来,我们将导入我们将要在索引中使用的一些类:

from azure.search.documents.indexes.models import (
    SearchIndex,
    SearchField,
    SearchFieldDataType,
    SimpleField,
    SearchableField,
    VectorSearch,
    HnswAlgorithmConfiguration,
    HnswParameters,
    VectorSearchAlgorithmKind,
    VectorSearchProfile,
    VectorSearchAlgorithmMetric,
)

对于我们想要使用嵌入进行搜索的字段,我们使用SearchField类型。对于其他字段,如果我们不打算在它们内部搜索内容,则使用SimpleField类型,如果我们希望它们可以通过字符串比较进行搜索,则使用SearchableField类型。

接下来,让我们使用SearchIndexClient类创建一个 API 客户端,该客户端将使用SearchIndexClient类向索引集合中添加一个新的索引:

def main() -> None:
    index_name = os.getenv("ARXIV_SEARCH_INDEX_NAME")
    service_name = os.getenv("ARXIV_SEARCH_SERVICE_NAME")
    service_endpoint = f"https://{service_name}.search.windows.net/"
    admin_key = os.getenv("ARXIV_SEARCH_ADMIN_KEY")
    credential = AzureKeyCredential(admin_key)
    # Create a search index
    index_client = SearchIndexClient(
        endpoint=service_endpoint, credential=credential)
    index_client.delete_index(index_name)

在开发阶段,需要通过添加或删除字段、更改嵌入的大小等方式重新设计索引的情况并不少见。因此,我们通常在脚本中删除并重新创建字段。在前面的代码片段中,我们使用delete_index方法删除了一个字段。

以下代码指定了字段及其属性,以帮助描述索引将包含哪些字段:

fields = [
    SimpleField(name="Id", type=SearchFieldDataType.String, key=True, sortable=True, filterable=True, facetable=True),
    SearchableField(name="AdditionalMetadata", type=SearchFieldDataType.String),
    SearchableField(name="Text", type=SearchFieldDataType.String),
    SearchableField(name="Description", type=SearchFieldDataType.String),
    SearchableField(name="ExternalSourceName", type=SearchFieldDataType.String),
    SimpleField(name="IsReference", type=SearchFieldDataType.Boolean),
    SearchField(name="Embedding", type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
                searchable=True, vector_search_dimensions=1536, vector_search_profile_name="myHnswProfile"),
]

在这里,我们将数据集中已有的字段添加到索引中:idauthorstitleabstract。此外,我们还在索引中添加了一个名为Embedding的字段,我们将在此字段中放置文章摘要的嵌入向量。对于该字段,我们需要指定一个向量搜索算法配置文件和一个向量搜索维度。维度是嵌入的大小。由于我们正在使用新的OpenAI text-embeddings-3-small,因此嵌入的大小为 1,536。

这些嵌入用于搜索算法。Azure AI Search 使用一个名为分层可导航小世界HNSW)的算法,这是一个灵活的算法,与高维空间(如我们嵌入的维度数)的最近邻算法密切相关。我们将稍后使用此算法在我们的索引中搜索项目,并检索与搜索内容更相关的项目。让我们将其添加到我们的嵌入字段中:

    # Configure the vector search configuration
    vector_search = VectorSearch(
        algorithms=[
            HnswAlgorithmConfiguration(
                name="myHnsw",
                kind=VectorSearchAlgorithmKind.HNSW,
                parameters=HnswParameters(
                    m=10,
                    ef_construction=400,
                    ef_search=500,
                    metric=VectorSearchAlgorithmMetric.COSINE
                )
            )
        ],
        profiles=[
            VectorSearchProfile(
                name="myHnswProfile",
                algorithm_configuration_name="myHnsw",
            )
        ]
    )

在前面的代码片段中,我们使用了余弦相似度作为度量标准,该标准决定了索引中与用户搜索内容更相关的项目。目前,我们使用了默认参数m=10ef_construction=400ef_search=500。参数中的ef代表探索因子

m参数控制索引的密度——在索引中,每条记录将具有m个邻居。ef_construction参数增加了用于为每条记录找到邻居的候选者数量:此参数越高,搜索就越彻底。ef_search参数控制运行时搜索的深度——也就是说,当执行搜索时,从索引中检索多少结果进行比较。

增加ef_construction会导致索引构建时间更长,而增加ef_search会导致运行时搜索时间更长。在大多数情况下,这些数字可以非常接近,但如果你计划频繁更新索引且不想构建时间变长,你可以增加ef_search。另一方面,如果你的搜索在运行时已经足够长,并且你想提高其质量,你可以增加ef_construction,因为它会使结果更好,但只会增加构建索引的时间,而不会增加执行搜索的时间。

这些参数的值越高,索引在查找记录方面表现得越好,但它们也会使构建和搜索索引的时间更长。我们在这里使用的参数对示例来说效果很好,但当你使用自己的数据集为你的应用程序时,务必对参数进行实验。

最后,我们只需调用create_or_update_index并传入我们指定的所有参数。这个命令将创建索引:

    # Create the search index with the semantic settings
    index = SearchIndex(name=index_name, fields=fields,
                        vector_search=vector_search)
    result = index_client.create_or_update_index(index)
    print(f' {result.name} created')
if __name__ == '__main__':
    load_dotenv()
    main()

现在我们有了索引,我们可以将数据集中的记录(每条记录称为文档)上传到其中。

接下来,我们将学习如何使用 C#创建索引。

使用 C#创建索引

使用 C#创建索引要简单得多。首先,我们必须在类中定义字段,我选择将其称为SearchModel

using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
public class SearchModel
{
    [SimpleField(IsKey = true, IsSortable = true, IsFilterable = true, IsFacetable = true)]
    public string Id { get; set; }
    [SearchableField]
    public string AdditionalMetadata { get; set; }
    [SearchableField]
    public string Text { get; set; }
    [SearchableField]
    public string Description { get; set; }
    [SearchableField]
    public string ExternalSourceName { get; set; }
    [SimpleField(IsFilterable = true)]
    public bool IsReference { get; set; }
}

在这里,我们使用与 Python 相同的字段名。请注意,我们没有创建一个Embedding字段,就像在 Python 中那样。这将在我们加载文档时动态创建。

让我们看看如何创建索引:

using Azure;
using Azure.Search.Documents;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
var (apiKey, orgId, searchServiceName, searchServiceAdminKey, searchIndexName) = Settings.LoadFromFile();
string indexName = searchIndexName;
AzureKeyCredential credential = new AzureKeyCredential(searchServiceAdminKey);
SearchIndexClient indexClient = new SearchIndexClient(new Uri(searchServiceName), credential);
indexClient.DeleteIndex(indexName);
var fields = new FieldBuilder().Build(typeof(SearchModel));
SearchIndex index = new SearchIndex(indexName)
{
    Fields = fields,
    // Add vector search configuration if needed
};
var result = indexClient.CreateOrUpdateIndex(index);

代码很简单:首先,我们使用FieldBuilder类在SearchModel中创建字段列表;然后,我们使用SearchIndex类创建一个index对象;最后,我们调用CreateOrUpdateIndex在云服务中创建索引。

将文档上传到索引

虽然将文档加载到索引中也很直接,但还有一些细节我们需要注意。

第一个细节是文档的唯一标识符。在我们的案例中,那就是Id字段。在理想情况下,你想要加载的数据将有一个唯一且不可变的标识符。

幸运的是,ArXiV 数据库就是这样:ArXiV 数据库中的Id字段是唯一且不可变的,并且可以始终用于在线搜索文章。例如,ID 为2309.12288的文章将始终是《逆转诅咒:在“A 是 B”上训练的 LLMs 无法学习“B 是 A”》文章的最新版本[2],该文章讨论了 LLMs 的一个怪癖:当被问及汤姆·克鲁斯的母亲是谁时,它有 79%的概率给出正确答案,玛丽·李·佩菲弗,79 岁。当被问及玛丽·李·佩菲弗的著名演员儿子是谁时,它只有 33%的概率给出正确答案,汤姆·克鲁斯。

Id 字段的唯一性和不可变性使我们能够根据需要更新索引以包含新信息。然而,有一个注意事项:在索引中,Id 字段只能包含数字、字母和下划线,因此我们需要将点替换为下划线。

第二个细节是我们需要加载嵌入。对于 Python 来说,在撰写本文时,这将需要我们手动计算嵌入,就像我们在 第六章 中所做的那样。不同的嵌入模型会产生具有不同含义的数据向量,通常大小也不同,即使大小相同,除非明确说明,否则嵌入是不兼容的。

因此,你不能使用一个模型创建嵌入,然后使用另一个嵌入模型进行搜索。这也意味着编写执行搜索代码的人需要知道用于加载索引数据的确切嵌入模型。在 C# 中,我们可以使用名为 Microsoft.SemanticKernel.Connectors.AzureAISearch 的连接器。虽然该连接器目前仍处于预览阶段,但它将大大简化事情。这应该很快就会对 Python 可用,但在撰写本文时还没有。

现在我们已经了解了这些细节,让我们编写一些代码来将文档加载到索引中。

使用 Python 上传文档

我们首先导入几个包:

from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient

第一组包用于连接到 Azure AI Search 索引。这些包与我们创建索引时使用的包类似,但请注意,我们正在使用一个不同的类,SearchClient,而不是 SearchIndexClient

现在,让我们加载语义内核包:

import asyncio
import semantic_kernel as sk
import semantic_kernel.connectors.ai.open_ai as sk_oai

这些语义内核包将被用来连接到 OpenAI 服务并生成嵌入。

最后,我们将导入一些包来帮助我们控制程序的流程:

from tenacity import retry, wait_random_exponential, stop_after_attempt
import pandas as pd
import os
from dotenv import load_dotenv

当你需要调用可能会失败的功能时,tenacity 库非常有用,因为它为你提供了自动重试的功能。pandas 库用于加载 CSV 文件。它不是严格必要的;即使没有它,你也可以直接操作 CSV 文件,但 pandas 库使这个过程更容易。

接下来,让我们定义一个辅助函数来生成嵌入:

@retry(wait=wait_random_exponential(min=1, max=5), stop=stop_after_attempt(3))
async def generate_embeddings(kernel: sk.Kernel, text):
    e = await kernel.get_service("emb").generate_embeddings(text)
    return e[0]

此函数假设我们有一个名为 emb 的服务的内核,它可以生成给定文本的嵌入。我们使用了 retry 装饰器来尝试生成嵌入三次,如果失败则放弃,每次尝试之间等待 15 秒,随着尝试次数的增加而增加间隔。

由于我们将要用于生成嵌入的 OpenAI 服务是一个在线服务,而我们需要为超过 30,000 篇文章生成嵌入,因此我们将调用它超过 30,000 次。在如此多的调用中,偶尔由于网络连接问题或服务过于繁忙而导致某些调用失败并不罕见。因此,添加retry功能可以帮助你避免在调用第 29,000 次时出现错误,从而破坏你的程序。

重要提示——使用 OpenAI 服务不是免费的

要生成嵌入,我们必须调用 OpenAI API。这些调用需要付费订阅,并且每次调用都会产生费用。通常,每个请求的费用很小——在撰写本书时,嵌入模型版本 3 每百万个标记的费用为 0.02 美元,但费用可能会累积。

OpenAI 定价详情可在openai.com/pricing找到。

Azure OpenAI 定价详情可在azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/找到。

我们将遵循的过程来创建索引搜索客户端以加载文档,与我们创建索引时所做的非常相似。SearchClient类比我们用于创建索引的SearchIndexClient多一个参数:我们之前创建的index_name属性:

async def main():
    kernel = sk.Kernel()
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_gen = sk_oai.OpenAITextEmbedding(service_id="emb", ai_model_id="text-embedding-3-large", api_key=api_key)
    kernel.add_service(embedding_gen)
    index_name = os.getenv("ARXIV_SEARCH_INDEX_NAME")
    service_name = os.getenv("ARXIV_SEARCH_SERVICE_NAME")
    service_endpoint = f"https://{service_name}.search.windows.net/"
    admin_key = os.getenv("ARXIV_SEARCH_ADMIN_KEY")
    credential = AzureKeyCredential(admin_key)
    # Create a search index
    index_client = SearchClient(index_name=index_name,
        endpoint=service_endpoint, credential=credential)

让我们加载数据:

    df = pd.read_json('ai_arxiv_202101.json', lines=True)
    count = 0
    documents = []
    for key, item in df.iterrows():
        id = str(item["Id"])
        id = id.replace(".", "_")

在这里,我们将数据文件读入一个pandas DataFrame,并为每条记录创建一个名为document的字典。请注意,我们必须在Id字段中将句点替换为下划线,因为 Azure AI Search 要求键字段只能包含数字、字母、破折号和下划线。

现在我们已经将数据放入字典中,我们准备上传它,这将在以下代码中完成:

        embeddings = await generate_embeddings(kernel, item["abstract"])
        # convert embeddings to a list of floats
        embeddings = [float(x) for x in embeddings]
        document = {
            "@search.action": "upload",
            "Id": id,
            "Text": item["title"],
            "Description": item["abstract"],
            "Embedding": embeddings
        }
        documents.append(document)

文档字典中的字段与我们创建索引时使用的字段相匹配:IdTextDescriptionEmbeddingEmbedding字段的值是通过调用我们之前创建的generate_embeddings函数生成的。

此外,请注意额外的字段@search.action。该字段包含有关将该项提交到索引时将发生什么的说明。"upload"是一个好的默认选项,因为它在不存在该 ID 时创建记录,如果存在则更新索引中的内容。

最后,一旦我们创建了document字典项,我们就将其追加到documents列表中。

现在,我们准备将其上传到索引:

    N = 100
    for i in range(0, len(documents), N):
        result = index_client.upload_documents(documents[i:i+N])
        print(f"Uploaded {len(documents[i:i+N])} records")
    print(f"Final tally: inserted or updated {len(documents)} records")

在向索引上传数据时,每个操作的限制为 16 MB。因此,我们一次只能上传少量记录。在先前的代码中,我将上传的记录数限制为100。然而,任何足够小的数字都适用,因为我们只将记录插入索引一次。上传操作不会花费很长时间,一次上传少量记录并稍微延长上传时间比一次尝试上传大量记录并冒着出错的风险更好。

最后一步是调用main函数:

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

注意,在调用main函数之前,我们调用了load_dotenv来获取包含索引名称、服务名称、管理员密钥和 OpenAI 密钥的环境变量的值。

运行此程序将花费大约 1.50 美元,因为它将生成嵌入。由于我们将生成数十万个嵌入,所以运行时间大约为两小时半。如果你想减少实验的成本或时间,你可以简单地只加载文档的一部分。

一旦程序运行完成,你将看到以下打印消息:

Final tally: inserted or updated 35,808 records.

现在,我们可以使用索引来查找文章。稍后,我们将用它来回答关于 AI 论文的问题。但在我们这样做之前,让我们学习如何使用 C#将文档上传到索引。

使用 C#上传文档

在撰写本文时处于预览状态的Microsoft.SemanticKernel.Connectors.AzureAISearch包,使得使用 C#上传文档变得容易得多。为了使用它,我们必须安装它:

dotnet add package Microsoft.SemanticKernel.Connectors.AzureAISearch --prerelease

还需要添加 OpenAI 连接器包:

dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI

现在,我们将使用这些包将以下文档加载到索引中:

using Microsoft.SemanticKernel.Connectors.AzureAISearch;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using System.Text.Json;

由于该包处于预发布形式,我们需要添加几个pragma指令来让 C#知道我们正在使用预发布功能:

#pragma warning disable SKEXP0020
#pragma warning disable SKEXP0010
#pragma warning disable SKEXP0001
ISemanticTextMemory memoryWithCustomDb;

到目前为止,我们可以获取我们的环境变量。我已经修改了Settings.cs文件,以便将额外的 Azure AI Search 变量存储和读取自config/settings.json。为了简洁,我不会在这里放置文件,但你可以在本章节的 GitHub 仓库中查看它:

var (apiKey, orgId, searchServiceName, searchServiceAdminKey, searchIndexName) = Settings.LoadFromFile();

接下来,我们必须使用MemoryBuilder创建一个Memory对象。我们将使用AzureAISearchMemoryStore类连接到 Azure AI Search:

memoryWithCustomDb = new MemoryBuilder()
                .WithOpenAITextEmbeddingGeneration("text-embedding-3-small", apiKey)
                    .WithMemoryStore(new AzureAISearchMemoryStore(searchServiceName, searchServiceAdminKey))
                        .Build();

下一步是从ai_arxiv.json文件中读取数据。尽管它的扩展名是.json,但它不是一个 JSON 文件;它是一个每行一个 JSON 对象的文本文件,因此我们将逐行解析:

string data = File.ReadAllText("ai_arxiv.json");
int i = 0;
foreach (string line in data.Split('\n'))
{
    i++;
    var paper = JsonSerializer.Deserialize<Dictionary>(line);
    if (paper == null)
    {
        continue;
    }
    string title = paper["title"]?.ToString() ?? "No title available";
    string id = paper["id"]?.ToString() ?? "No ID available";
    string abstractText = paper["abstract"]?.ToString() ?? "No abstract available";
    id = id.Replace(".", "_");

下一步是使用MemoryStore对象的SaveInformationAsync方法将文档上传到索引:

    await memoryWithCustomDb.SaveInformationAsync(collection: searchIndexName,
        text: abstractText,
        id: id,
        description: title);
    if (i % 100 == 0)
    {
        Console.WriteLine($"Processed {i} documents at {DateTime.Now}");
    }
}

现在我们已经将文档加载到索引中,我们可以学习如何使用索引来运行简单的搜索。稍后,我们将使用搜索结果和语义内核来组装答案。

使用索引查找学术论文

本小节假设在上一步骤中已加载索引。现在,索引包含来自 ArXiV 的关于 LLMs 的数千篇学术论文的标题、摘要和嵌入。请注意,ArXiV 中的论文不一定经过同行评审,这意味着一些文章可能包含错误信息。无论如何,ArXiV 通常是关于 AI 学术论文的可靠数据源,许多经典论文都可以在那里找到,包括Attention is All You Need [3],这篇论文向世界介绍了 GPT。该文章不是我们数据集的一部分,因为我们的数据集始于 2021 年,而该文章是 2017 年的。

我们将使用此索引来帮助我们找到给定搜索字符串的论文。这将确保搜索正在工作,并且我们对结果感到满意。在下一小节中,我们将使用 GPT 来组合搜索结果并总结发现。让我们看看如何在 Python 和 C#中实现这一点。

在 Python 中搜索文章

我们必须做的第一件事是加载所需的库:

import asyncio
import logging
import semantic_kernel as sk
import semantic_kernel.connectors.ai.open_ai as sk_oai
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizedQuery
from dotenv import load_dotenv
from tenacity import retry, wait_random_exponential, stop_after_attempt
import pandas as pd
import os
@retry(wait=wait_random_exponential(min=1, max=5), stop=stop_after_attempt(3))
async def generate_embeddings(kernel: sk.Kernel, text):
    e = await kernel.get_service("emb").generate_embeddings(text)
    # convert e[0] to a vector of floats
    result = [float(x) for x in e[0]]
    return result

没有新的库,我们使用与之前相同的generate_embeddings函数。用于在搜索时生成嵌入的函数必须与用于在向量数据库中存储嵌入的函数兼容。如果您使用相同的模型,该函数将是兼容的。

在以下代码中,我们创建一个内核并将嵌入模型加载到其中:

def create_kernel() -> sk.Kernel:
    kernel = sk.Kernel()
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_gen = sk_oai.OpenAITextEmbedding(service_id="emb", ai_model_id="text-embedding-3-small", api_key=api_key)
    kernel.add_service(embedding_gen)
    return kernel
async def main():
    kernel = create_kernel()
    ais_index_name = os.getenv("ARXIV_SEARCH_INDEX_NAME")
    ais_service_name = os.getenv("ARXIV_SEARCH_SERVICE_NAME")
    ais_service_endpoint = f"https://{ais_service_name}.search.windows.net/"
    ais_admin_key = os.getenv("ARXIV_SEARCH_ADMIN_KEY")
    credential = AzureKeyCredential(ais_admin_key)
    search_client = SearchClient(ais_service_endpoint, ais_index_name, credential=credential)

除了加载内核中的嵌入模型外,我们还加载了所有环境变量,包括我们的 OpenAI 连接和 Azure AI Search 连接的配置。

现在,我们可以执行查询:

    query_string = "<your query here>"
    emb = await generate_embeddings(kernel, query_string)
    vector_query = VectorizedQuery(vector=emb, k_nearest_neighbors=5, exhaustive=True, fields="Embedding")
    results = search_client.search(
        search_text=None,
        vector_queries= [vector_query],
        select=["Id", "Text", "Description"]
    )

执行此查询包括几个步骤。首先,我们使用generate_embeddings函数从查询字符串中计算嵌入。然后,在执行查询之前,我们使用SearchClientsearch方法创建VectorizedQuery,并使用嵌入。我们的查询的k_nearest_neighbors参数决定了我们希望返回多少个结果。在这种情况下,我正在返回前5个。

结果以字典形式返回,包含索引中的列。我们还将检索一个额外的特殊列@search.score,该列在搜索过程中动态创建,显示每个结果的余弦相似度:

    pd_results = []
    for result in results:
        d = {
            "id": result['Id'],
            "title": result['Description'],
            "abstract": result['Text'],
            "score": f"{result['@search.score']:.2f}"
        }
        pd_results.append(d)

可以使用@search.score字段的值按相似度顺序排序结果,也可以用于丢弃低于截止点的结果。

让我们打印结果:

    pd_results = pd.DataFrame(pd_results)
    # print the title of each result
    for index, row in pd_results.iterrows():
        print(row["title"])
if __name__ == "__main__":
    load_dotenv()
    asyncio.run(main())

在前面的代码中,我在打印之前将结果加载到pandas DataFrame 中,这使得在结果太多时排序和过滤结果变得更容易。这不是必需的——您也可以简单地使用字典。在这种情况下,我们限制结果只包含五个,因此我们也可以直接从我们创建的pd_results字典列表中打印它们。例如,假设我们有以下查询:

query_string = "models with long context windows lose information in the middle"

我们将在学习如何在 C#中实现搜索之后查看结果。

使用 C#搜索文章

我们可以像加载文档一样创建我们的memoryWithCustomDb对象。到目前为止,代码是相同的。然而,我们现在将搜索文档而不是加载它们。我们可以通过memoryWithCustomDb对象的SearchAsync方法来完成。我们只需要传递我们的索引名称,该名称存储在配置中的searchIndexName变量中,我们想要进行的查询,我们在query_string中指定了它,以及我们想要检索的文章数量,我们在limit中指定了它。我们将minRelevanceScore设置为0.0,这样我们总是检索前五个结果。如果您只想返回超过最小余弦相似度的结果,可以将其设置为更高的数字:

IAsyncEnumerable<MemoryQueryResult> memories = memoryWithCustomDb.SearchAsync(searchIndexName, query_string, limit: 5, minRelevanceScore: 0.0);
int i = 0;
await foreach (MemoryQueryResult item in memories)
{
    i++;
    Console.WriteLine($"{i}. {item.Metadata.Description}");
}

使用专门的memoryWithCustomDb C#对象,查询内存非常简单,我们只需通过一个SearchAsync调用即可获取我们的结果。

让我们查看结果。

搜索结果

对于 Python 和 C#,我们得到的结果如下:

1\. Lost in the Middle: How Language Models Use Long Contexts
2\. Parallel Context Windows for Large Language Models
3\. Revisiting Parallel Context Windows: A Frustratingly Simple Alternative and Chain-of-Thought Deterioration
4\. "Paraphrasing The Original Text" Makes High Accuracy Long-Context QA
5\. Emotion Detection in Unfix-length-Context Conversation

现在我们已经看到搜索在 C#和 Python 中工作,我们可以使用 RAG 根据我们将要进行的搜索自动生成几篇论文的摘要。

使用 RAG 创建关于一个主题的几篇文章的摘要

我们将使用上一步的搜索结果,并通过使用通常的语义函数提示模板将它们添加到提示中。提示将指示一个模型——在我们的情况下,是 GPT-4——总结我们搜索返回的论文。

让我们从语义函数开始,我们将称之为summarize_abstracts。这是它的元提示:

skprompt.txt

You are a professor of computer science writing a report about artificial intelligence for a popular newspaper.
Keep the language simple and friendly.
Below, I'm going to give you a list of 5 research papers about artificial intelligence. Each paper has a number and an abstract.
Summarize the combined findings of the paper. When using the abstracts, refer to them by using their number inside [] brackets.
Your summary should be about 250 words.
Abstracts:
{{$input}}

提示的关键部分是,当我们要求摘要时,我要求 GPT 参考摘要的编号。为了使其工作,我们将生成一个包含编号和摘要的列表,这与我们在使用索引查找学术论文部分生成的结果非常相似。不同之处在于,我们不会有一个编号和文章标题,而是一个编号和文章摘要。让我们看看配置文件:

config.json

{
    "schema": 1,
    "type": "completion",
    "description": "Summarize abstracts of academic papers",
    "execution_settings": {
       "default": {
         "max_tokens": 4000,
         "temperature": 0.5
       }
     },
    "input_variables": [
       {
         "name": "input",
         "description": "A numbered list of abstracts to summarize.",
         "required": true
       }
    ]
}

在这里,您必须确保的最重要的事情是确保max_tokens字段中有足够的标记。您将发送五篇摘要,每篇摘要可能很容易达到 200 个标记,所以您至少需要 1,000 个标记来处理摘要,还需要更多来处理指令和响应。

使用 Python 检索数据并调用语义函数

我们需要做的第一件事是向我们的内核添加一个生成模型。我已经修改了create_kernel函数,以便添加gpt-4-turbo模型:

def create_kernel() -> sk.Kernel:
    kernel = sk.Kernel()
    api_key = os.getenv("OPENAI_API_KEY")
    embedding_gen = sk_oai.OpenAITextEmbedding(service_id="emb", ai_model_id="text-embedding-3-small", api_key=api_key)
    gpt_gen = sk_oai.OpenAIChatCompletion(service_id="gpt-4-turbo", ai_model_id="gpt-4-turbo-preview", api_key=api_key)
    kernel.add_service(gpt_gen)
    kernel.add_service(embedding_gen)
    return kernel

您可以使用任何您想要的模型,但我决定使用 gpt-4-turbo,因为它在成本和性能之间提供了良好的平衡。

下一步是创建一个用于总结文档的函数:

async def summarize_documents(kernel: sk.Kernel, df: pd.DataFrame) -> str:
    doc_list = ""
    i = 0
    doc_list += "Here are the top 5 documents that are most similar to your query:\n\n"
    for key, row in df.iterrows():
        i = i + 1
        id = row["Id"].replace("_", ".")
        doc_list += f"{i}. "
        doc_list += f"{row['Description']} - "
        doc_list += f"https://arxiv.org/abs/{id}\n"

函数的第一部分创建一个字符串,指定编号的文档及其 URL。由于 Azure AI Search 存储 ID 的方式,请记住我们不得不将点转换为下划线。为了生成正确的 URL,我们必须将其转换回来。

函数的第二部分生成一个与论文具有相同数字的摘要列表。当我们编写提示时,我们可以要求模型引用这些数字,这些数字反过来又指向文章的标题和 URL:

    a = 0
    abstracts = ""
    for key, row in df.iterrows():
        a = a + 1
        abstracts += f"\n\n{a}. {row['Text']}\n"

下一步是从其配置目录中加载语义函数:

    f = kernel.import_plugin_from_prompt_directory(".", "prompts")
    summary = await kernel.invoke(f["summarize_abstracts"], input=abstracts)

最后一步是将论文和 URL 列表与生成的摘要结合起来,并返回它:

    response = f"{doc_list}\n\n{summary}"
    return response

现在我们知道了如何使用 Python 进行检索,让我们看看如何使用 C# 进行检索。我们将在之后查看结果。

使用 C# 获取数据并调用语义函数

要获取数据,我们将从与“使用索引查找学术论文”部分相同的代码开始,直到我们填充memories变量:

IAsyncEnumerable<MemoryQueryResult> memories = memoryWithCustomDb.SearchAsync(searchIndexName, query_string, limit: 5, minRelevanceScore: 0.0);

我已经通过列出检索到的文档、它们的编号和 URL(所有这些都由Id字段构建)开始了响应,这一步不需要使用 AI 模型:

string explanation = "Here are the top 5 documents that are most like your query:\n";
int j = 0;
await foreach (MemoryQueryResult item in memories)
{
    j++;
    string id = item.Metadata.Id;
    id.Replace('_', '.');
    explanation += $"{j}. {item.Metadata.Description}\n";
    explanation += $"https://arxiv.org/abs/{id}\n";
}
explanation += "\n";

然后,我们不再像在“使用索引查找学术论文”部分那样创建包含所有标题的字符串,而是将五个摘要(由i变量中的数字标识)创建一个名为input的字符串。这将被用作语义函数的输入参数:

string input = "";
int i = 0;
await foreach (MemoryQueryResult item in memories)
{
    i++;
    input += $"{i}. {item.Metadata.Text}";
}

现在,我们可以创建一个名为kernel的语义内核,向其中添加一个 AI 服务,并加载在上一小节中定义的语义函数,我决定将其称为rag

Kernel kernel = Kernel.CreateBuilder()
                        .AddOpenAIChatCompletion("gpt-4-turbo", apiKey, orgId, serviceId: "gpt-4-turbo")
                        .Build();
var rag = kernel.ImportPluginFromPromptDirectory("prompts", "SummarizeAbstract");
explanation += await kernel.InvokeAsync(rag["summarize_abstracts"], new KernelArguments() {["input"] = input});
Console.WriteLine(explanation);

让我们运行我们的程序,看看当我们使用与上一小节测试中相同的query_string值时,我们得到的结果。

RAG 结果

我们在这里将使用的查询是:“具有长上下文窗口的模型在中间会丢失信息”。

结果不是确定的,但您应该得到以下类似的结果:

Here are the top 5 documents that are most like your query:
1\. Lost in the Middle: How Language Models Use Long Contexts - https://arxiv.org/abs/2307.03172
2\. Parallel Context Windows for Large Language Models - https://arxiv.org/abs/2212.10947
3\. Revisiting Parallel Context Windows: A Frustratingly Simple Alternative and Chain-of-Thought Deterioration - https://arxiv.org/abs/2305.15262
4\. "Paraphrasing the Original Text" Makes High Accuracy Long-Context QA - https://arxiv.org/abs/2312.11193
5\. Emotion Detection in Unfix-length-Context Conversation - https://arxiv.org/abs/2302.06029
In the rapidly evolving field of artificial intelligence, particularly in the realm of language models, recent research has been shedding light on both the capabilities and limitations of these advanced systems. A critical challenge identified is the handling of long text sequences by language models, which is essential for tasks such as multi-document question answering and key-value retrieval [1]. Despite the advancements, it's observed that the performance of these models often diminishes when they need to process relevant information located in the middle of long contexts [1]. This indicates a need for better strategies to enable models to effectively utilize long input contexts.
To address these limitations, a novel method named Parallel Context Windows (PCW) has been introduced, which allows off-the-shelf Large Language Models (LLMs) to process long texts by dividing them into smaller chunks. This method has shown substantial improvements in handling diverse tasks requiring long text sequences without the need for further training [2]. However, further analysis reveals that PCW may not consistently enhance the models' understanding of long contexts in more complex reasoning tasks, suggesting that the method's design might not guarantee significant improvements in practical applications [3].
Another approach to enhancing long-context capabilities involves focusing on the quality of training data. It has been found that "effective" data, which can be achieved through techniques such as original text paraphrasing, is crucial for training models to handle long texts, leading to state-of-the-art performance in multi-document retrieval and question answering tasks [4].
Additionally, research into variable-length context windows for predicting emotions in conversations introduces new modules to better capture conversational dynamics. This approach significantly outperforms existing models by more accurately determining the relevant context for emotion prediction [5].
Collectively, these studies highlight the importance of innovative methods and quality training data in overcoming the challenges of processing long texts. They also underscore the need for continued exploration to enhance the practical applicability of language models in real-world scenarios.

如您所见,总结是全面的,捕捉了每篇论文的主要思想,并展示了论文之间的相互关系。

在本节中,我们学习了如何使用外部数据库帮助 LLM 处理比模型上下文窗口能处理的更多信息。

这种方法的优点是生成模型主要使用您提供给它的搜索数据来生成响应。如果您只提供真实、精心整理的数据,您将大大降低它产生幻觉的可能性——也就是说,生成不存在的信息。如果您没有使用 RAG,那么生成模型可能会编造不存在的论文和参考文献,只是为了尝试回答我们提出的问题和生成我们要求的摘要。

完全阻止幻觉在理论上是不可行的,但使用 RAG 可以使幻觉发生的概率极低,以至于在实践中,用户可能永远不会看到模型生成的虚假数据。这就是为什么 RAG 模型在生产应用中被广泛使用的原因。

摘要

在本章中,我们通过使用 RAG 方法大大扩展了我们 AI 模型可用的数据。除了允许 AI 模型在构建提示时使用大量数据外,RAG 方法还提高了模型的准确性:由于提示包含大量生成答案所需的数据,模型倾向于产生更少的幻觉。

RAG 还允许 AI 提供其用于生成响应的材料的引用。许多现实世界的用例需要模型处理大量数据,需要提供引用,并且对幻觉敏感。RAG 可以帮助轻松克服这些问题。

在下一章中,我们将转换方向,学习如何将语义内核应用程序与 ChatGPT 集成,使其可供数亿用户使用。在我们的示例中,我们将使用我们在第五章中构建的应用程序进行家庭自动化,但你也可以使用相同的技巧在你的应用程序中实现这一点。

参考文献

[1] N. F. Liu 等人,“迷失在中间:语言模型如何使用长上下文。” arXiv,2023 年 11 月 20 日。doi: 10.48550/arXiv.2307.03172。

[2] L. Berglund 等人,“逆转诅咒:在‘A 是 B’上训练的 LLM 无法学习‘B 是 A。’” arXiv,2023 年 9 月 22 日。doi: 10.48550/arXiv.2309.12288。

[3] A. Vaswani 等人,“Attention Is All You Need,”2017 年 6 月。

第七章:实际应用案例 – 在 ChatGPT 上使你的应用程序可用

在前面的章节中,我们学到了很多。我们学习了如何创建和优化提示,如何创建语义和本地函数并将它们放入 Semantic Kernel,以及如何使用规划器自动决定使用内核的哪些功能来解决用户问题。

在前两章中,我们学习了如何通过包括从外部数据构建的内存来增强我们的内核,这使我们能够构建更个性化的应用程序,并使用最新且受我们控制的 数据来生成答案,而不是仅使用训练 LLM 时使用的数据,这些数据通常不是公开的。

在本章的最后,我们将转换方向。我们将学习如何使我们已经创建的功能对更多的用户可用。我们将使用我们在第五章中编写的家庭自动化应用程序,并通过 OpenAI 自定义GPT 商店使其可用,使其对已经使用 ChatGPT 的数亿用户开放,并使用 ChatGPT 作为我们应用程序的用户界面。

除了能够快速将应用程序提供给数十万用户这一明显的好处之外,另一个好处是,你甚至不需要为你的应用程序构建用户界面UI)。你可以构建主要功能,并使用 ChatGPT 作为 UI。当然,这也有局限性。AI 是基于文本的,你对它的控制很少,但另一方面,你可以更快地测试和部署你的应用程序,并在以后构建专门的 UI。

在本章中,我们将涵盖以下主题:

  • 在 OpenAI 商店中创建自定义 GPT

  • 为使用 Semantic Kernel 开发的程序创建一个 Web API 包装器

  • 通过 Web API 包装器将自定义 GPT 连接到 OpenAI 商店

到本章结束时,你将拥有一个对所有 ChatGPT 用户都开放的应用程序。

技术要求

要完成本章,你需要拥有你首选的 Python 或 C#开发环境的最新、受支持的版本:

  • 对于 Python,最低支持的版本是 Python 3.10,推荐版本是 Python 3.11

  • 对于 C#,最低支持的版本是.NET 8

在本章中,我们将调用 OpenAI 服务。鉴于公司在这类 LLM 训练上的投入,使用这些服务不是免费的也就不足为奇了。你需要一个OpenAI API密钥,可以通过OpenAIMicrosoft直接获得,或者通过Azure OpenAI服务。

如果你正在使用.NET,本章的代码位于github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/dotnet/ch8

如果你正在使用 Python,本章的代码位于 github.com/PacktPublishing/Building-AI-Applications-with-Microsoft-Semantic-Kernel/tree/main/python/ch8

要创建你的自定义 GPT,你需要一个 OpenAI 账户。

你可以通过访问 GitHub 仓库并使用以下命令安装所需的包:pip install -r requirements.txt

自定义 GPT 代理

2023 年 11 月 6 日,OpenAI 推出了一种功能,允许用户创建自定义的、个性化的 ChatGPT 版本。这些由用户创建的自定义 GPT 可以通过 OpenAI 的 GPT 商店与其他用户共享。这使得没有编程经验的用户可以通过简单地用自然语言编写指令来向 ChatGPT 添加功能,同时也允许有编程经验的用户将 ChatGPT 连接到他们的应用程序,使这些应用程序可供数亿用户使用。

初始时,这些被称为“自定义 GPT”,但现在它们简单地被称为 GPT。这可能有些令人困惑,因为大多数 AI 模型中使用的 Transformer 技术被称为 生成式预训练 TransformerGPT),而 OpenAI 对这些模型的实现也被称为 GPT,带有版本号,如 GPT-3.5 或 GPT 4。

在本节中,当我们使用“GPT”这个名字时,除非另有说明,它指的是你可以在 ChatGPT 内部创建的自定义 GPT。

这些 GPT 可以使用自定义提示,例如我们在语义函数中使用的提示,以及额外的数据,例如我们在 RAG 模型中使用的。你可以通过使用 Web 界面将自定义提示和文档添加到你的自定义 GPT 中,我们将在下一小节中展示。

此外,你还可以允许你的 GPT 通过 Web API 调用外部函数。许多公司创建了这些接口并将它们连接到自定义 GPT,例如 Wolfram(科学软件 Mathematica 的创造者)、设计公司如 Canva 和 Adobe,以及其他许多公司。

在本节中,正如我们在 第五章 中所做的那样,想象你为一家智能家居公司工作,该公司有一种产品允许某人通过他们家里的设备控制他们的家,现在你想要让他们通过 ChatGPT 来实现这一点。我们在 第五章 中为这个功能创建了原生函数,在本章中,我们将使用 Microsoft Semantic Kernel 工具使该功能对 ChatGPT 用户可用。

在我们开始更复杂的示例之前,让我们先创建一个更简单的自定义 GPT,以便熟悉这个过程。

创建自定义 GPT

要创建一个 GPT,您可以导航到chat.openai.com/gpts/并点击右上角的创建按钮,或者直接导航到chat.openai.com/gpts/editor。这将打开一个网络界面,允许您创建一个 GPT。正如您所期望的,您可以通过与 ChatGPT 聊天来简单地创建 GPT。您可以添加自定义指令,指定答案的语气,等等。

图 8.1 – 使用 OpenAI 编辑器创建 GPT

图 8.1 – 使用 OpenAI 编辑器创建 GPT

配置标签页是您为您的 GPT 命名和描述的地方,您还可以在此处添加自定义操作,以将您的 GPT 与外部 API 连接:

图 8.2 – OpenAI 配置您的 GPT 的用户界面

图 8.2 – OpenAI 配置您的 GPT 的用户界面

您可以使用“我想创建一个回答关于夏洛克·福尔摩斯问题的 GPT”并得到它回答“给它起个侦探指南的名字怎么样?这对你来说听起来好吗?”的回复。我回答“是的”,配置被更新,添加了“侦探指南”作为名称。ChatGPT 没有询问,也自动为我的 GPT 生成了一张合适的头像。

我还进行了几个额外的配置步骤:

I want it to be only about the canonical works by Arthur Conan Doyle, but I want it to be helpful for high school students that are trying to understand it better - the influence of the works in modern media and the context in which the work was written.

我还上传了包含书籍内容的文本文件,这些文件您可以在网上找到,也可以在/data/sherlock_holmes/中找到,并添加了以下附加配置:

When providing answers about the books, I want the gpt to base the answers on the files I have uploaded and quote passages found in them.
Do not answer any questions that are not related to the Sherlock Holmes' books. If a user asks questions about other topics, answer that this is not your area of expertise.

每完成一个配置步骤后,ChatGPT 都会问我是否还有其他想要添加的内容。在这个时候,我说“没有”,这再次启用了右上角的创建按钮。一旦我按下它,我就有了分享我的 GPT 的选项:

图 8.3 – 在 GPT 商店中分享您的 GPT

图 8.3 – 在 GPT 商店中分享您的 GPT

我选择了发布到 GPT 商店,这将使它对所有 ChatGPT 用户可用。仅使用前面的最小指令创建的完整版本可以在chat.openai.com/g/g-yJvyO2vHq-detective-guide访问。

这就是它的样子:

图 8.4 – 夏洛克·福尔摩斯 GPT 的初始版本,侦探指南

图 8.4 – 夏洛克·福尔摩斯 GPT 的初始版本,侦探指南

需要注意的一点是,我需要进行的配置非常少。我只上传了几本书的文字文本文件,并用自然语言编写了几百字的配置。

为了测试它,我问“福尔摩斯的阁楼理论是什么?”以下是其回答,正确地回答了问题并指出了它来自哪本书:

Holmes' attic theory, a metaphorical description of how one should manage their mental storage, emphasizes the importance of selective knowledge acquisition. In "A Study in Scarlet," Holmes explains that he considers the brain to be like a little empty attic, which one must carefully furnish with only the tools that may aid one's work. He argues against cluttering the mind with irrelevant information, suggesting that such clutter can push out or obscure useful knowledge. According to Holmes, it is crucial to maintain a well-organized and selective collection of knowledge to ensure that one's mental capacity is efficiently utilized for one's purposes.
This theory sheds light on Holmes' approach to his detective work, where he values relevant, useful knowledge over a broad but shallow pool of information. It also underlines his practical view of intelligence, emphasizing application over accumulation. This perspective is central to understanding Holmes' character and his methods of deduction, which rely on connecting seemingly disparate facts stored neatly in his mental "attic" to solve complex cases.

此外,因为我要求 GPT 不要回答其知识领域之外的问题,它试图保持话题相关,以下对话就是一个例子:

图 8.5 – 向 GPT 询问天文学和夏洛克·福尔摩斯的天文学知识

图 8.5 – 向 GPT 询问天文学和福尔摩斯的天文学知识

如果创建一个自定义的 GPT 如此简单,为什么还要编写任何代码呢?

当 GPT 模型开始变得主流时,几位企业家创建了应用程序,这些应用程序不过是添加了一些额外说明的 GPT-3.5。这些应用程序在网上发布,就像我们刚才做的侦探指南一样。

如果你的应用程序的功能可以通过向 ChatGPT 添加一些额外说明来复制,那么创建一个自定义 GPT 可能对你来说是一个好选择。如果你尝试将其作为独立应用程序发布并收费,竞争对手可能只需创建一个自定义 GPT 并将其提供给所有 ChatGPT 用户,就能复制你的成功。这些 GPT 的货币化方式尚不明确,但显然,它们将以 Spotify 或 Kindle Unlimited 相同的方式工作:获得足够用户的 GPT 将获得订阅者支付的一部分费用。

有一些情况下,ChatGPT 中的这些自定义 GPT 根本不起作用。例如,你不能用它来给你的现有应用程序添加 AI 功能。你也没有太多的控制权:自定义 GPT 不允许你微调你的提示,处理函数的输入和输出,监控使用情况,在检索文档时更改搜索的敏感性等等,因此你对用户体验的控制要少得多。

现在我们已经创建了一个简单的自定义 GPT 版本,让我们创建一个支持操作的自定义 GPT。我们将使用我们在第五章中创建的智能家居原生插件,并假设我们有一家公司允许用户自动化他们家中的一些功能,例如开关灯和打开电视。我们将创建一个自定义 GPT,允许用户使用 ChatGPT 控制他们家的功能。

创建支持操作的自定义 GPT

就像我们在前面的子节中做的那样,我们首先导航到chat.openai.com/gpts/并点击右上角的创建按钮,或者直接导航到chat.openai.com/gpts/editor

我没有使用聊天界面来创建这个自定义 GPT,而是直接转到配置选项卡,并添加了以下说明:

You are designed to assist users with specific home automation tasks through a web API, focusing on operating the garage door and turning lights on and off. If users inquire about functionalities outside these areas, you inform them that those actions are not supported. When you need more information to provide accurate assistance, you will ask for clarification directly. You strive to personalize your responses to fit users' specific contexts and ensure clarity in your explanations. Your interactions are professional and straightforward, aiming for clarity and efficiency in communication.
You only work with the SKB custom API and no other vendors. If a user asks about information about other vendors of home automation, say that you can't help them.

我还给我的 GPT 命名为SKB 智能家居示例,其中SKB代表语义内核书。你可以将大多数其他选项保留为默认设置,尽管你可能希望取消选中能力下的网络浏览DALL-E 图像生成,因为这些将不会使用。

完成后,你可以在屏幕右上角点击创建,这将创建一个自定义 GPT,你可以选择保留给自己,通过链接分享,或者在 GPT 商店分享。

无论你选择什么,现在你都可以向 GPT 发送请求。请注意,我们还没有将我们的原生插件连接到 GPT,所以如果你要求 GPT 执行某些操作,比如打开车库门,它将立即产生幻觉,如下所示:

To open your garage door using the SKB Home Automation API, you would typically send a command through a web request. The specific details can depend on how your system is set up, but here's a general example of how you might do it:
1\. Ensure Your Device is Connected: Make sure your smartphone or computer is connected to the internet and can communicate with your home network where your garage door is connected.
2\. Use the Right Endpoint: The SKB API will have a specific endpoint for operating the garage door. This is often something like /api/garage_door/open, but you should refer to the SKB API documentation for the exact endpoint.
3\. Send the Request: You can use various tools or a piece of code to send the request. If you're comfortable with command line tools, you might use curl in a terminal or command prompt like this:
   curl -X POST "http://yourhomeip:port/api/garage_door/open" -H "Authorization: Bearer YOUR_API_TOKEN"
   Replace yourhomeip:port with the IP address and port number of your SKB Home Automation system, and YOUR_API_TOKEN with your personal API token for authentication.
4\. Check the Response: After sending the request, you should receive a response from the API. A successful response usually includes a message indicating that the command was received and the action (opening the garage door) is in progress or completed. If there's an error, the response will typically contain details to help troubleshoot the issue.
If you haven't set up your API token or aren't sure about the endpoint or how to send the request, let me know what step you're on, and I can provide more detailed instructions!

ChatGPT 在这个答案中假设了几件事情:一个 API、一个curl命令、一个认证方法、是否可以向 IP 地址发送命令等等。这些都不是真的;它只是在尽力提供答案。当我们把 ChatGPT 与我们的真实 API 连接起来时,这个问题就会得到解决,我们将在下一节中这样做。

关于安全性的说明

当你将你的 GPT 和 API 与数亿用户分享时,确实有可能有些人会以你未曾想到的方式使用它。在这本书中,我们不会详细讨论安全问题,但这并不意味着你不应该考虑它。

在下一节中,我们将连接一个 API 到 ChatGPT 而不进行任何认证,但对于生产应用程序,你应该在 GPT 和你的 API 之间添加认证。最重要的是,你应该对你的 API 添加监控,这样你就可以看到使用模式是否在变化。

即使是最基本的监控,比如每分钟统计你接收了多少次调用,也可能足以防止最严重的滥用行为。一旦你有了监控,你还可以添加速率限制,以防止恶意用户通过重复调用压倒你的 API。

为原生函数创建网络 API 包装器

首先,让我们定义我们的原生函数。这是我用在第五章中的同一个函数,但我只使用了OperateLightOperateGarageDoor以简化:

import semantic_kernel as sk
from typing import Annotated
from semantic_kernel.functions.kernel_function_decorator import kernel_function
class HomeAutomation:
    def __init__(self):
        pass
    @kernel_function(
        description="Turns the lights of the living room, kitchen, bedroom or garage on or off.",
        name="OperateLight",
    )
    def OperateLight(self,
    location: Annotated[str, "The location where the lights are to be turned on or off. Must be either 'living room', 'kitchen', 'bedroom' or 'garage'"],
    action: Annotated[str, "Whether to turn the lights on or off"]) -> Annotated[str,  "The output is a string describing whether the lights were turned on or off" ]:
        if location in ["kitchen", "living room", "bedroom", "garage"]:
            result = f"Changed status of the {location} lights to {action}."
            return result
        else:
            error = f"Invalid location {location} specified."
            return error
    @kernel_function(
        description="Opens or closes the garage door.",
        name="OperateGarageDoor",
    )
    def OperateGarageDoor(self,
            action:  Annotated[str, "Whether to open or close the garage door"]) -> Annotated[str, "The output is a string describing whether the garage door was opened or closed" ]:
        result = f"Changed the status of the garage door to {action}."
        return result

现在,我们需要构建一个网络 API,以便 ChatGPT 可以从网络上调用该函数。

在 Python 中创建网络 API 包装器

在 Python 中,我们将使用 Flask 库。在 Flask 中,我们将创建两个路由:operate_lightoperate_garage_door。首先,我们创建一个应用程序:

from flask import Flask, render_template, request, jsonify
from dotenv import load_dotenv
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
import semantic_kernel as sk
from HomeAutomation import HomeAutomation
app = Flask(__name__)
app.secret_key = b'skb_2024'

创建应用程序很简单,只需要调用Flask构造函数并设置一个secret_key属性,该属性可以用来签名来自应用程序的 cookie。这个应用程序将不会有 cookie,所以密钥可以是任何东西,包括一个随机字符串。

现在,我们将定义 API 的路由:

@app.route('/operate_light', methods=['POST'])
async def operate_light():
    kernel = sk.Kernel()
    api_key, org_id = sk.openai_settings_from_dot_env()
    gpt4 = OpenAIChatCompletion("gpt-4-turbo-preview", api_key, org_id)
    kernel.add_service(gpt4)
    kernel.import_plugin_from_object(HomeAutomation(), "HomeAutomation")
    data = request.get_json()
    location = data['location']
    action = data['action']
    result = str(kernel.invoke(kernel.plugins["HomeAutomation"]["OperateLight"], location=location, action=action))
    return jsonify({'result': result})
@app.route('/operate_garage_door', methods=['POST'])
async def operate_garage_door():
    kernel = sk.Kernel()
    api_key, org_id = sk.openai_settings_from_dot_env()
    gpt4 = OpenAIChatCompletion("gpt-4-turbo-preview", api_key, org_id)
    kernel.add_service(gpt4)
    kernel.import_plugin_from_object(HomeAutomation(), "HomeAutomation")
    data = request.get_json()
    action = data['action']
    result = str(kernel.invoke(kernel.plugins["HomeAutomation"]["OperateGarageDoor"], action=action))
    return jsonify({'result': result})

每个路由的结构都是相同的:我们创建一个内核,向其中添加一个 GPT 服务,导入HomeAutomation插件,并调用适当的函数,返回其答案。

你可以将这两行代码添加到应用程序中,以便进行本地测试:

if __name__ == '__main__':
    app.run()

要在本地测试应用程序,请打开命令行并输入以下内容:

flask run

这将创建一个本地网络服务器:

* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000

现在,如果你使用 bash,你可以使用curl向本地网络服务器发送命令;如果你使用 PowerShell,你可以使用Invoke-RestMethod。在这里,我们正在调用operate_light路由,参数为"action": "on""location": "bedroom"

Invoke-RestMethod -Uri http://localhost:5000/operate_light -Method Post -ContentType "application/json" -Body '{"action": "on", "location": "bedroom"}'

结果,正如预期的那样,应用程序成功响应:

Result
------
Changed status of the bedroom lights to on.

现在我们已经验证了 Web 应用程序正在工作,我们可以在 Web 上部署它。

在 C# 中创建 Web API 包装器

.NET 使得创建模板化的 Web API 应用程序变得简单。你可以使用以下命令,它将在 SkHomeAutomation 目录下创建一个 Web API:

dotnet new webapi --use-controllers -o SkHomeAutomation

不要忘记安装 Microsoft.SemanticKernel 包:

dotnet add package Microsoft.SemanticKernel

dotnet new webapi 命令有助于生成一个提供 Web API 的天气预报 Web 应用程序的代码。它生成的其中一个文件是一个名为 WeatherForecast.cs 的模块。你可以删除此文件,因为我们将会用我们自己的功能替换它。为此,将 HomeAutomation.cs 文件从 第五章 复制到本项目的根目录。为了使我们的工作更简单,在文件开头添加以下行,这将允许你更容易地引用 HomeAutomation 对象:

namespace SkHomeAutomation;

你需要做的最后一件事是进入 Controllers 目录。它将包含一个 WeatherForecastController.cs 文件。你可以删除此文件,并用这里的 HomeAutomationController.cs 文件替换它,如下所示:

using Microsoft.AspNetCore.Mvc;
namespace SkHomeAutomation.Controllers;
using Microsoft.Extensions.Logging;
public class LightOperationData
{
    public string? location { get; set; }
    public string? action { get; set; }
}
public class GarageOperationData
{
    public string? action { get; set; }
}
[ApiController]
[Route("[controller]")]
public class HomeAutomationController : ControllerBase
{
    private readonly ILogger<HomeAutomationController>? _logger;
    private HomeAutomation ha;
    public HomeAutomationController(ILogger<HomeAutomationController> logger)
    {
        _logger = logger;
        ha = new HomeAutomation();
    }
    [HttpPost("operate_light")]
    public IActionResult OperateLight([FromBody] LightOperationData data)
    {
        if (data.location == null || data.action == null)
        {
            return BadRequest("Location and action must be provided");
        }
        return Ok( ha.OperateLight(data.action, data.location) );
    }
    [HttpPost("operate_garage_door")]
    public IActionResult OperateGarageDoor([FromBody] GarageOperationData data)
    {
        if (data.action == null)
        {
            return BadRequest("Action must be provided");
        }
        return Ok( ha.OperateGarageDoor(data.action) );
    }
}

HomeAutomationController 暴露了 operate_lightoperate_garage_door Web API 路径,当调用这些路径时,它将请求路由到我们在 第五章 中创建的 HomeAutomation 类的相应方法,本质上在部署后向 Web 暴露我们的语义内核应用程序。

下一个步骤,无论你是用 C# 还是 Python 创建的应用程序,都是部署应用程序。

将你的应用程序部署到 Azure Web App

要在 Web 上部署你的应用程序,你需要一个 Azure 账户。访问 Azure 门户 portal.azure.com,然后从主页点击 Create a Resource,然后点击 Create a Web App。正如你将看到的,我们可以为测试使用免费层,但如果你计划为真实应用程序部署类似的应用程序,你应该选择不同的计划。

图 8.6 中,我展示了我是如何创建的:我创建了一个名为 skb-rg 的新资源组,将我的应用程序命名为 skb-home-automation,这给了它 skb-home-automation.azurewebsites.net 的 URL,并为其选择了 Python 3.11 (Python) 或 .NET 8 LTS (C#) 作为其运行时堆栈。

skb-sp 下,选择 Free F1 定价计划。一旦完成这些配置,点击 Review + create,你的 Web 应用程序将在几分钟内部署:

图 8.6 – 创建一个免费的 Web 应用程序以托管我们的 API

图 8.6 – 创建一个免费的 Web 应用程序以托管我们的 API

将您的 API 部署到 Web 应用程序的最简单方法是使用 GitHub。为此,我们需要为这个 Web API 创建一个新的、干净的 GitHub 仓库,并将github.com/PacktPublishing/Microsoft-Semantic-Kernel/tree/main/python/ch8的内容复制到其中。这需要是一个单独的仓库,因为您需要将整个仓库部署到 Web 应用程序中。例如,您可以将您的副本放在如https://github.com/<your-github-username>/skb-home-automation这样的地址上。请注意,我的 API 版本对您不可用;您必须部署自己的。

在您的 Web 应用程序中,转到部署中心,选择GitHub作为源。在组织中,选择您的用户名。选择仓库。

这将在您自己的账户下创建和部署 Web API。

图 8.7 – 使用 GitHub 部署 Web API

图 8.7 – 使用 GitHub 部署 Web API

一旦 Web API 部署完成,您可以使用curlInvoke-RestApi对其进行测试。唯一的变化是,您需要将端点从 localhost 更改为您部署的端点。在我的情况下,我选择了skb-home-automation.azurewebsites.net(您的情况将不同)。请注意,我的 API 版本对您不可用;您必须部署自己的。

因此,我们可以提交以下内容:

Invoke-RestMethod -Uri https://skb-home-automation.azurewebsites.net/operate_light -Method Post -ContentType "application/json" -Body '{"action": "on", "location": "bedroom"}'

结果将如下所示:

Result
------
Changed status of the bedroom lights to on.

现在我们有一个正在工作的 Web API,我们需要将 API 与 ChatGPT 连接起来。

将定制 GPT 与您的定制 GPT 操作连接

要将我们的 Web API 与我们的定制 GPT 连接,我们需要给它一个 OpenAPI 规范。ChatGPT 使生成一个变得非常简单。

首先,转到我们的定制 GPT,从其名称中选择下拉菜单,并选择编辑 GPT

图 8.8 – 编辑我们的 GPT

图 8.8 – 编辑我们的 GPT

配置选项卡的底部,点击动作下的创建新操作。这将打开添加动作UI:

图 8.9 – 向我们的 GPT 添加操作

图 8.9 – 向我们的 GPT 添加操作

要添加操作,您需要使用名为OpenAPI的语言指定一个模式。ChatGPT 使这变得极其简单:点击从 ActionGPT 获取帮助将打开一个与另一个可以帮您创建 OpenAPI 规范的定制 GPT 的聊天对话框:

图 8.10 – 使用 ActionsGPT

图 8.10 – 使用 ActionsGPT

在 ActionsGPT 中,您需要做的只是粘贴我们 Web API 的代码,它将自动生成 OpenAPI 规范。自动生成的内容如下,但我们需要做一些修改:

openapi: 3.0.0
info:
  title: Home Automation API
  description: This API allows controlling lights and garage doors in a home automation system.
  version: 1.0.0
servers:
  - url: http://yourserver.com
    description: Main server
paths:
  /operate_light:
    post:
      operationId: operateLight
      summary: Controls a light in the home automation system.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - location
                - action
              properties:
                location:
                  type: string
                  description: The location of the light to be controlled.
                action:
                  type: string
                  description: The action to be performed on the light.
                  enum:
                    - turnOn
                    - turnOff
                    - toggle
      responses:
        '200':
          description: Operation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: string
                    description: The result of the light operation.
  /operate_garage_door:
    post:
      operationId: operateGarageDoor
      summary: Controls the garage door in the home automation system.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - action
              properties:
                action:
                  type: string
                  description: The action to be performed on the garage door.
                  enum:
                    - open
                    - close
                    - stop
      responses:
        '200':
          description: Operation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: string
                    description: The result of the garage door operation.

值得注意的是,它不知道我的服务器的名称或灯光安装地点的限制。它还试图猜测命令。因此,我们必须在规范中添加正确的限制。另一个需要注意的细节是,我所有的端点都有 x-openai-isConsequential: false 参数。当该参数为 true 或空白时,ChatGPT 将对每个发出的命令请求确认。对于我们的目的,我们不需要这个,但您的用例可能需要它,例如,当用户决定进行支付时。

这是修正后的版本,更改内容以粗体突出显示:

openapi: 3.0.0
info:
  title: Home Automation API
  description: This API allows controlling lights and garage doors in a home automation system.
  version: 1.0.0
servers:
  - url: https://skb-home-automation.azurewebsites.net
    description: Main server
paths:
  /operate_light:
    post:
      operationId: operateLight
      summary: Controls a light in the home automation system.
      x-openai-isConsequential: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - location
                - action
              properties:
                location:
                  type: string
                  description: The location of the light to be controlled.
                  enum:
                    - "kitchen"
                    - "living room"
                    - "bedroom"
                    - "garage"
                action:
                  type: string
                  description: The action to be performed on the light.
                  enum:
                    - "on"
                    - "off"
      responses:
        '200':
          description: Operation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: string
                    description: The result of the light operation.
  /operate_garage_door:
    post:
      operationId: operateGarageDoor
      summary: Controls the garage door in the home automation system.
      x-openai-isConsequential: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - action
              properties:
                action:
                  type: string
                  description: The action to be performed on the garage door.
                  enum:
                    - "open"
                    - "close"
      responses:
        '200':
          description: Operation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  result:
                    type: string
                    description: The result of the garage door operation.

您可以将这个修正后的版本粘贴到模式框中,并在右上角点击更新。这将部署与您使用语义内核开发的本地应用程序连接的自定义 GPT。

在这里,您将看到一个与我们的自定义 GPT 的真实对话示例,其中我要求它操作几个设备:

图 8.11 – 使用我们的 SKB 家居自动化自定义 GPT

图 8.11 – 使用我们的 SKB 家居自动化自定义 GPT

图 8.12 – 使用我们的 SKB 家居自动化自定义 GPT

图 8.12 – 使用我们的 SKB 家居自动化自定义 GPT

首先,我要求 GPT 打开我的车库门。它正确地使用适当的命令调用了 API。接下来,我要求它执行一个复杂的命令:关闭我的车库门并关闭所有灯光。如*图 8**.12 所示,它发出了五个命令。查看 Web API 的日志,您将能够看到命令被正确发送:

2024-04-08T05:16:27.968713802Z: [INFO]  Changed the status of the garage door to close.
2024-04-08T05:16:30.939181143Z: [INFO]  Changed status of the kitchen lights to off.
2024-04-08T05:16:33.701639742Z: [INFO]  Changed status of the living room lights to off.
2024-04-08T05:16:36.377148658Z: [INFO]  Changed status of the bedroom lights to off.
2024-04-08T05:16:39.017400267Z: [INFO]  Changed status of the garage lights to off.

如果我要求一个它无法执行的命令,它也会正确地回应它能做什么:

图 8.13 – 向自定义 GPT 发出无效命令

图 8.13 – 向自定义 GPT 发出无效命令

将应用程序与自定义 GPT 连接的两个主要后果如下:

  • 关闭我所有的灯,ChatGPT 将解析它们并将它们发送到您的应用程序。如果用户请求应用程序中不可用的功能,ChatGPT 会告诉他们可以做什么和不能做什么。

  • 您的应用程序获得广泛的分布和访问 ChatGPT 提供的所有 UI 设施的权限:任何可以访问 ChatGPT 的人都可以使用您的应用程序,甚至可以从他们的手机上使用。他们还可以使用语音来使用该应用程序,因为 ChatGPT 支持语音命令。

在本节中,我们看到了如何将我们编写的应用程序与 ChatGPT 连接起来,使其能够被数亿 ChatGPT 用户使用。

摘要

在本章中,我们通过开发自定义 GPT 并向其添加自定义操作,将应用程序与 OpenAI 的 ChatGPT 连接起来。这可以使应用程序获得访问基于 ChatGPT 用户最新模型的规划器的权限,这通常是一个非常先进的模型。

此外,我们所学到的东西让你可以以最小的努力将你的应用程序部署给数亿用户,并获得 ChatGPT 用户可用的几个新功能,例如自然语言请求和语音请求。这也允许你更快地将你的应用程序部署给用户,因为你不必自己开发 UI——你可以在开发并扩展你的应用程序时使用 ChatGPT 作为 UI。

如果你是一名 Python 程序员,Microsoft Semantic Kernel 在默认的 OpenAI Python API 提供的基础上增加了几个额外功能。其中,你得到了提示和代码之间的分离、原生函数、规划器、核心插件以及与内存的接口。所有这些都可以缩短你创建和维护代码所需的时间。鉴于目前 AI 领域的变化量,能够节省一些时间是件好事。

如果你是一名 C#开发者,除了可以获得 Python 程序员所获得的好处之外,你还会发现,与 OpenAI 不提供 C# API 相比,Microsoft Semantic Kernel 是将 C#应用程序连接到 OpenAI 模型的最佳方式。你可以用 REST API 做很多事情,正如我们在创建 DALL-E 3 图像时在第四章中展示的那样,但 REST API 使用起来繁琐,并且在过去一年中有所变化。使用 Microsoft Semantic Kernel 大大简化了这些事情,并且当发生变化时,它们很可能会在未来版本中集成。

至此,我们与 Microsoft Semantic Kernel 的旅程告一段落。作为一个临别思考,Semantic Kernel 和 AI 模型只是工具。你对世界的影响取决于你如何使用这些工具。在我的职业生涯中,我很幸运能够使用技术,最近则是 AI,为社会做出贡献。我希望你也能做到同样的事情。

posted @ 2025-10-27 09:05  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报