自从ChatGPT发布以来,大型语言模型(LLMs)在工业界和媒体上引起了极大关注,导致对几乎所有可能的情境中都试图利用LLMs的需求空前高涨。
Semantic Kernel是微软最初开发的开源SDK,用于支持Microsoft 365 Copilot和Bing等产品,旨在使将LLMs集成到应用程序中变得简单。它使用户能够利用LLMs根据自然语言查询和命令来编排工作流程,通过连接这些模型和提供额外功能的外部服务,使模型能够完成任务。
由于它是针对微软生态系统而创建的,目前可用的复杂示例中许多是使用C#编写的,而较少的资源专注于Python SDK。在这篇博文中,我将演示如何使用Python开始使用Semantic Kernel,介绍关键组件,并探索如何使用这些组件执行各种任务。
本文将涵盖以下内容:
- 内核
- 连接器
- 语义函数- 创建语义函数配置- 创建自定义连接器
- 使用聊天服务- 创建简单的聊天机器人
- 内存- 使用文本嵌入服务- 将内存集成到上下文中
- 插件- 使用现成的插件- 创建自定义插件- 连接多个插件
- 使用计划器编排工作流程
免责声明:Semantic Kernel,像与LLMs相关的一切,正在以惊人的速度发展。因此,界面可能会随时间略有变化;我会尽力在可能的情况下更新此帖子。
虽然我在微软工作,但我并未被要求以任何方式推广Semantic Kernel,也没有得到任何补偿。在行业解决方案工程(ISE)中,我们以根据情况和我们正在合作的客户选择我们认为最适合工作的工具为傲。对于我们选择不使用微软产品的情况,我们会向产品团队提供详细反馈,说明原因以及我们认为存在问题或有待改进的领域;这种反馈循环通常会导致微软产品非常适合我们的需求。
在这里,我选择推广Semantic Kernel,因为尽管有一些小问题,但我相信它显示出了巨大的潜力,并且我更喜欢Semantic Kernel所做的设计选择,与我探索过的其他解决方案相比。
撰写时使用的软件包是:
dependencies: - python=3.10.1.0 - pip: - semantic-kernel==0.3.10.dev - timm==0.9.5 - transformers==4.32.0 - sentence-transformers==2.2.2 - curated-transformers==1.1.0
简短总结:如果您只想看到一些可以直接使用的工作代码,本文所需的所有代码都作为笔记本在此处提供。
致谢
我要感谢我的同事Karol Zak,与他合作探索如何最大限度地发挥Semantic Kernel在我们的用例中的优势,并提供了一些本文示例中的代码!
现在,让我们从库的核心组件开始。
内核
内核:“对象或系统的核心、中心或本质。” —— 维基词典
Semantic Kernel中的一个关键概念是内核本身,它是我们用来编排基于LLM的工作流程的主要对象。最初,内核的功能非常有限;它的所有功能主要由我们将连接到的外部组件提供支持。然后,内核作为一个处理引擎,通过调用适当的组件来完成给定任务,来满足请求。
我们可以像下面演示的方式创建一个内核:
import semantic_kernel as skkernel = sk.Kernel()
连接器
为了使我们的内核有用,我们需要连接一个或多个AI模型,这使我们能够使用内核来理解和生成自然语言;这是通过使用连接器来实现的。语义内核提供了开箱即用的连接器,可以轻松地添加来自不同源头的AI模型,例如OpenAI、Azure OpenAI和Hugging Face。然后使用这些模型来为内核提供服务。
在撰写本文时,支持以下服务:
- 文本生成服务:用于生成自然语言
- 聊天服务:用于创建对话体验
- 文本嵌入生成服务:用于将自然语言编码为嵌入向量
每种类型的服务可以同时支持来自不同源头的多个模型,这样可以根据任务和用户的偏好在不同模型之间进行切换。如果没有指定特定的服务或模型,内核将默认使用定义的第一个服务和模型。
我们可以使用以下方法查看当前注册的所有服务:
def print_ai_services(kernel): print(f"文本生成服务:{kernel.all_text_completion_services()}") print(f"聊天服务:{kernel.all_chat_services()}") print( f"文本嵌入生成服务:{kernel.all_text_embedding_generation_services()}" )
如预期的那样,我们当前没有任何连接的服务!让我们来改变这种情况。
在这里,我将首先访问一个我在Azure订阅中使用Azure OpenAI服务部署的GPT3.5-turbo模型。
由于该模型可用于文本生成和聊天,因此我将同时使用这两种服务进行注册。
from semantic_kernel.connectors.ai.open_ai import ( AzureChatCompletion, AzureTextCompletion,)kernel.add_text_completion_service( service_id="azure_gpt35_text_completion", service=AzureTextCompletion( OPENAI_DEPLOYMENT_NAME, OPENAI_ENDPOINT, OPENAI_API_KEY ),)gpt35_chat_service = AzureChatCompletion( deployment_name=OPENAI_DEPLOYMENT_NAME, endpoint=OPENAI_ENDPOINT, api_key=OPENAI_API_KEY,)kernel.add_chat_service("azure_gpt35_chat_completion", gpt35_chat_service)
现在我们可以看到聊天服务已注册为文本生成和聊天服务。
要使用非Azure OpenAI API,我们唯一需要做的更改是使用OpenAITextCompletion
和OpenAIChatCompletion
连接器,而不是我们的Azure类。如果您没有访问OpenAI模型,不用担心,我们稍后将介绍如何连接到开源模型;选择模型不会影响后续任何步骤。
现在我们注册了一些服务,让我们来探索如何与它们进行交互!
语义函数
通过语义内核与LLM进行交互的方式是创建一个语义函数。语义函数期望一个自然语言输入,并使用LLM解释所提问的内容,然后根据需要采取行动返回适当的响应。例如,语义函数可以用于文本生成、摘要、情感分析和问答等任务。
在语义内核中,语义函数由两个组件组成:
- 提示模板:将发送给LLM的自然语言查询或命令
- 配置对象:包含语义函数的设置和选项,例如它应该使用的服务、它应该期望的参数以及该函数的描述。
最简单的入门方法是使用内核的create_semantic_function
方法,该方法接受固定参数,如temperature
和max_tokens
,这些参数通常是LLM所需的,并使用它们为我们构建配置。
为了说明这一点,让我们创建一个简单的提示:
prompt = """{{$input}} 是首都城市的"""generate_capital_city_text = kernel.create_semantic_function( prompt, max_tokens=100, temperature=0, top_p=0)
在这里,我们使用{{$}}
语法来表示将注入到我们的提示中的参数。虽然在本文中我们将看到更多这方面的例子,但有关模板语法的全面指南可以在文档中找到。
我们可以检查一些关于我们的语义函数的详细信息,如下所示:
在这里,我们可以看到它被赋予了一个通用的描述,因为我们没有提供这个。
现在,我们可以通过调用它来使用我们的函数:
response = generate_capital_city_text("巴黎")
或者,大多数内核方法都支持使用Asyncio进行异步调用。由于我们的许多连接的服务可能会调用外部API,使用事件循环中的语义函数时,异步调用应该提供性能提升。
我们可以按照以下方式进行:
response = await generate_capital_city_text.invoke_async("巴黎")
响应对象包含关于我们的函数调用的有价值的信息,例如是否发生错误以及错误是什么;如果一切按预期工作,我们可以使用response.result
来访问我们的结果。
如果我们打印我们的响应,结果将为我们访问。
在这里,我们可以看到我们的函数已经起作用!
需要注意的一件事是静默错误。语义内核的工作方式是通过在函数之间传递上下文对象来不断更新它。
这意味着,如果我们只有一个函数并且它失败了,有时会返回输入。我们可以通过设置参数不正确来演示这一点。
在这里,我们可以明确检查错误,如下所示。
如果我们打印响应,这一点是清楚的,但是如果没有适当的检查,这可能会导致在访问结果时产生令人困惑的结果!
创建语义函数配置
虽然create_semantic_function
对于让我们开始很有用,但它没有提供我们在更复杂的情况下所需的许多选项;例如指定我们想要使用的模型,或者可能需要的自定义参数。
为了说明这一点,让我们注册另一个文本完成服务,并创建一个配置,使我们能够指定我们想要使用新服务。对于我们的第二个完成服务,让我们使用Hugging Face transformers库中的一个模型。为此,我们使用HuggingFaceTextCompletion
连接器。
在这里,由于我们将在本地运行模型,我选择了GPT2,GPT模型系列的一个较旧的成员,它应该能够在大多数硬件上快速运行。
from semantic_kernel.connectors.ai.hugging_face import HuggingFaceTextCompletionhf_model = HuggingFaceTextCompletion("gpt2", task="text-generation")kernel.add_text_completion_service("hf_gpt2_text_completion", hf_model)
现在,让我们创建我们的配置对象。我发现最简单的方法是通过创建一个字典并从中加载配置来实现;这样,我们可以在需要时将配置保存到 JSON 文件中。
我们可以使用以下格式来完成这个操作:
hf_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义功能的描述 "description": "使用 GPT2 模型提供关于给定输入的首都的信息", # 指定要使用的模型服务 "default_services": ["hf_gpt2_text_completion"], # 传递给连接器和模型服务的参数 "completion": { "temperature": 0.01, "top_p": 1, "max_tokens": 256, "number_of_responses": 1, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "首都的名称", "defaultValue": "伦敦", } ] },}
现在,我们可以直接将配置加载到 PromptTemplateConfig
对象中。
from semantic_kernel import PromptTemplateConfigprompt_template_config = PromptTemplateConfig.from_dict(hf_config_dict)
现在,我们有了我们的提示配置,让我们创建我们的提示。之前,我们使用字符串来完成这个操作,但是 Semantic Kernel 提供了一些模板类来提供更多的结构。
from semantic_kernel import PromptTemplateprompt_template = sk.PromptTemplate( template="{{$input}} 是首都", prompt_config=prompt_template_config, template_engine=kernel.prompt_template_engine,)
最后,我们可以创建我们的语义函数配置,将提示和配置捆绑在同一个对象中。
from semantic_kernel import SemanticFunctionConfigfunction_config = SemanticFunctionConfig(prompt_template_config, prompt_template)
由于这里涉及到了一些步骤,让我们将它们收集到一个函数中,以方便使用。
from semantic_kernel import PromptTemplateConfig, SemanticFunctionConfig, PromptTemplatedef create_semantic_function_config(prompt_template, prompt_config_dict, kernel): prompt_template_config = PromptTemplateConfig.from_dict(prompt_config_dict) prompt_template = sk.PromptTemplate( template=prompt_template, prompt_config=prompt_template_config, template_engine=kernel.prompt_template_engine, ) return SemanticFunctionConfig(prompt_template_config, prompt_template)
现在,我们可以注册我们的语义函数,使用我们定义的配置,如下所示:
gpt2_complete = kernel.register_semantic_function( skill_name="GPT2Complete", function_name="gpt2_complete", function_config=create_semantic_function_config( "{{$input}} 是首都", hf_config_dict, kernel ),)
我们可以像以前一样调用我们的函数。
response = gpt2_complete("巴黎")
嗯,生成似乎成功了,但信息不准确,而且通常不太好!这是可以预料的,因为使用旧模型(如 GPT2)时经常会出现这种情况,这也展示了该领域自发布以来取得了多大的进步。
创建自定义连接器
现在,我们已经看到如何创建语义函数并指定我们想要使用的服务。然而,到目前为止,我们使用的所有服务都依赖于现成的连接器。在某些情况下,我们可能希望使用来自不同库的模型,这就需要一个自定义连接器。让我们看看如何实现这一点。
以一个从 curated transformers 库中使用 transformer 模型的例子为例。
要创建一个自定义连接器,我们需要子类化 TextCompletionClientBase
,它作为我们模型的薄包装器。下面提供了一个简单的示例。
from typing import List, Optional, Unionimport torchfrom curated_transformers.generation import ( AutoGenerator, SampleGeneratorConfig,)from semantic_kernel.connectors.ai.ai_exception import AIExceptionfrom semantic_kernel.connectors.ai.complete_request_settings import ( CompleteRequestSettings,)from semantic_kernel.connectors.ai.text_completion_client_base import ( TextCompletionClientBase,)class CuratedTransformersCompletion(TextCompletionClientBase): def __init__( self, model_name: str, device: Optional[int] = -1, ) -> None: """ 使用 curated transformer 模型进行文本补全。 参数: model_name {str} device_idx {Optional[int]} -- 要在其上运行模型的设备,CPU 为 -1,GPU 为 0+。 请注意,此模型将从 Hugging Face 模型中心下载。 """ self.model_name = model_name self.device = ( "cuda:" + str(device) if device >= 0 and torch.cuda.is_available() else "cpu" ) self.generator = AutoGenerator.from_hf_hub( name=model_name, device=torch.device(self.device) ) async def complete_async( self, prompt: str, request_settings: CompleteRequestSettings ) -> Union[str, List[str]]: generator_config = SampleGeneratorConfig( temperature=request_settings.temperature, top_p=request_settings.top_p, ) try: with torch.no_grad(): result = self.generator([prompt], generator_config) return result[0] except Exception as e: raise AIException("CuratedTransformer completion failed", e) async def complete_stream_async( self, prompt: str, request_settings: CompleteRequestSettings ): raise NotImplementedError( "Streaming is not supported for CuratedTransformersCompletion." )
现在,我们可以注册我们的连接器并创建一个语义函数,就像之前演示的那样。在这里,我使用的是Falcon-7B模型,这将需要一个GPU才能在合理的时间内运行。在这里,我在Azure虚拟机上使用了一台Nvidia A100,因为在本地运行速度太慢了。
kernel.add_text_completion_service( "falcon-7b_text_completion", CuratedTransformersCompletion(model_name="tiiuae/falcon-7b", device=0),)config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "使用Falcon-7B模型提供以输入作为参数的首都城市的信息", # 指定要使用的模型服务 "default_services": ["falcon-7b_text_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.01, "top_p": 1, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "首都城市的名称", "defaultValue": "伦敦", } ] },}falcon_complete = kernel.register_semantic_function( skill_name="Falcon7BComplete", function_name="falcon7b_complete", function_config=create_semantic_function_config( "{{$input}} 是首都城市", config_dict, kernel ),)
再次可以看到生成的文本是有效的,但它在回答问题后很快陷入了重复。
这可能是因为我们选择的模型。通常,自回归变换器模型训练时会在大量的文本语料库中预测下一个单词;基本上使它们成为强大的自动完成机器!在这里,它似乎尝试“完成”我们的问题,结果导致它继续生成文本,这对我们来说没有帮助。
使用聊天服务
一些LLM模型经过了额外的训练,使它们更有用于交互。这个过程的一个例子在OpenAI的InstructGPT论文中有详细介绍。
在高层次上,这通常包括添加一个或多个有监督的微调步骤,其中模型不再训练在随机的非结构化文本上,而是在经过策划的问题回答和摘要示例上进行训练;这些模型通常被称为指令调整或聊天模型。
由于我们已经观察到基本LLM可以生成比我们需要的更多的文本,让我们看看聊天模型是否会有不同的表现。要使用我们的聊天模型,我们需要更新配置以指定适当的服务并创建一个新的函数;在我们的情况下,我们将使用azure_gpt35_chat_completion
。
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "使用GPT3.5模型提供以输入作为参数的首都城市的信息", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 256, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "首都城市的名称", "defaultValue": "伦敦", } ] },}capital_city_chat = kernel.register_semantic_function( skill_name="CapitalCityChat", function_name="capital_city_chat", function_config=create_semantic_function_config( "{{$input}} 是首都城市", chat_config_dict, kernel ),)
很好,我们可以看到聊天模型给出了一个更加简洁的答案!
以前,我们使用的是文本补全模型,我们将我们的提示格式化为一个句子,以便模型完成。然而,调整过的模型应该能够理解一个问题,所以我们可能可以改变我们的提示,使其更加灵活。让我们看看如何调整我们的提示,以便与模型交互,就好像它是一个设计用来提供关于我们可能喜欢参观的地方的信息的聊天机器人。
首先,让我们调整我们的函数配置,使我们的提示更加通用。
chatbot = kernel.register_semantic_function( skill_name="聊天机器人", function_name="聊天机器人", function_config=create_semantic_function_config( "{{$input}}", chat_config_dict, kernel ),)
在这里,我们可以看到我们只传入了用户输入,所以我们必须将我们的输入表达为一个问题。让我们试试这个。
很好,看起来它已经起作用了。让我们试试问一个跟进问题。
我们可以看到模型提供了一个非常通用的答案,完全没有考虑我们之前的问题。这是可以预料的,因为模型收到的提示是"那里有一些有趣的事情可以做吗?"
我们没有提供关于“那里”是哪里的任何上下文信息!
让我们看看如何扩展我们的方法,在下一节中创建一个简单的聊天机器人。
制作一个简单的聊天机器人
现在我们已经看到了如何使用聊天服务,让我们探索如何创建一个简单的聊天机器人。
我们的聊天机器人应该能够做三件事:
- 知道它的目的并告诉我们
- 理解当前的对话上下文
- 回答我们的问题
让我们调整我们的提示来反映这一点。
chatbot_prompt = """"你是一个聊天机器人,提供有关不同城市和国家的信息。对于与地方无关的其他问题,您应该礼貌地拒绝回答问题,并说明您的目的" +++++{{$history}}用户: {{$input}}聊天机器人: """
请注意,我们添加了变量history
,它将用于为聊天机器人提供先前的上下文。虽然这是一个相当天真的方法,因为长时间的对话将很快导致提示达到模型的最大上下文长度,但对于我们的目的来说应该是可以工作的。
到目前为止,我们只使用了使用单个变量的提示。要使用多个变量,我们需要调整我们的配置,如下所示。
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "一个聊天机器人,提供有关城市和国家的信息", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 256, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "用户提供的输入", "defaultValue": "", }, { "name": "history", "description": "用户和聊天机器人之间的先前交互", "defaultValue": "", }, ] },}
现在,让我们使用这个更新的配置和提示来创建我们的聊天机器人
function_config = create_semantic_function_config( chatbot_prompt, chat_config_dict, kernel)chatbot = kernel.register_semantic_function( skill_name="简单聊天机器人", function_name="简单聊天机器人", function_config=function_config,)
要将多个变量传递给我们的语义函数,我们需要创建一个Context对象,它将存储我们的变量状态。我们可以按照下面的示例创建这个对象,并初始化我们的history变量:
context = kernel.create_new_context()context["history"] = ""
由于input是一个特殊变量,它将自动处理。然而,如果我们决定更改它的名称 – 例如,改为{{$user_input}}
– 那么它也需要以相同的方式初始化。
现在,让我们创建一个简单的聊天函数,在每次交互后更新我们的context。
async def chat(input_text, context, verbose=True): # 将新消息保存在context变量中 context["input"] = input_text if verbose: # 在每次交互之前打印完整的提示信息 print("提示:") print("-----") # 将变量插入到我们的提示信息中 print(await function_config.prompt_template.render_async(context)) print("-----") # 处理用户消息并得到答案 answer = await chatbot.invoke_async(context=context) # 显示回答 print(f"ChatBot: {answer}") # 将新的交互追加到聊天记录中 context["history"] += f"\nUser: {input_text}\nChatBot: {answer}\n"
让我们试试吧!
在这里,我们可以看到这很好地满足了我们的要求!
然而,有一些小细节需要注意,比如在每次交互后手动更新我们的context。
使用ChatPromptTemplate
虽然我们已经看到了如何使用标准的提示模板创建一个简单的聊天机器人,但Semantic Kernel提供了一个ChatPromptTemplate
来简化事情,为我们跟踪之前的交互。
让我们使用这个类来更新我们用于创建语义函数配置的函数。
from semantic_kernel import ( ChatPromptTemplate, SemanticFunctionConfig, PromptTemplateConfig,)def create_semantic_function_chat_config(prompt_template, prompt_config_dict, kernel): chat_system_message = ( prompt_config_dict.pop("system_prompt") if "system_prompt" in prompt_config_dict else None ) prompt_template_config = PromptTemplateConfig.from_dict(prompt_config_dict) prompt_template_config.completion.token_selection_biases = ( {} ) # required for https://github.com/microsoft/semantic-kernel/issues/2564 prompt_template = ChatPromptTemplate( template=prompt_template, prompt_config=prompt_template_config, template_engine=kernel.prompt_template_engine, ) if chat_system_message is not None: prompt_template.add_system_message(chat_system_message) return SemanticFunctionConfig(prompt_template_config, prompt_template)
正如我们所见,ChatPromptTemplate
提供了在交互开始时设置系统消息的选项,这样我们就不必在提示中包含它了。为了将所有内容放在同一个地方,让我们将系统提示添加到我们的配置字典中;由于该字段不包含在定义的模式中,我们在使用配置创建PromptTemplateConfig
之前应该将其删除。
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "提供有关城市和国家的信息的聊天机器人", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 500, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "用户提供的输入", "defaultValue": "", }, ] }, # 非模式变量 "system_prompt": "你是一个聊天机器人,提供有关不同城市和国家的信息。对于与地点无关的其他问题,你应该礼貌地拒绝回答,并说明你的目的",}chatbot = kernel.register_semantic_function( skill_name="Chatbot", function_name="chatbot", function_config=create_semantic_function_chat_config( "{{$input}}", chat_config_dict, kernel ),)
记忆
与我们的聊天机器人互动时,使体验感觉像是有用的互动的一个关键方面是,聊天机器人能够保留我们先前问题的上下文。我们通过给予聊天机器人访问内存的能力来实现这一点,利用ChatPromptTemplate
来处理这个问题。
虽然对于我们的简单用例来说,这种方法已经足够好用了,但是我们的对话历史都是存储在系统的内存中,并没有持久化到任何地方;一旦我们关闭系统,这些信息就永远消失了。对于更智能的应用程序来说,能够构建和持久化短期和长期记忆对于我们的模型访问是有用的。
此外,在我们的示例中,我们将所有的先前交互都输入到我们的提示中。由于模型通常具有固定大小的上下文窗口(确定我们提示的长度有多长),如果我们开始进行冗长的对话,这个方法很快就会崩溃。避免这种情况的一种方法是将我们的记忆存储为单独的“块”,并且只加载我们认为可能与我们的提示相关的信息。
Semantic Kernel提供了一些关于如何将记忆融入我们的应用程序的功能,让我们看看如何利用这些功能。
以一个例子为例,让我们扩展我们的聊天机器人,使其能够访问存储在记忆中的一些信息。
首先,我们需要一些与我们的聊天机器人相关的信息。虽然我们可以手动研究和策划相关信息,但是让模型为我们生成一些会更快!让我们让模型为我们生成一些关于伦敦市的事实。我们可以按照以下方式实现这个目标。
response = chatbot( """请提供关于伦敦的事情的全面概述。根据以下五个段落构造你的回答:- 概述- 地标- 历史- 文化- 食物每个段落应为100个标记,不要在回答的段落中添加标题,如“概述:”或“食物:”。不要用“当然,这是关于伦敦的全面概述”这样的陈述来回应问题。不要提供结尾的评论。""")
现在我们有了一些文本,为了使模型只能访问它需要的部分,让我们将其划分为块。Semantic Kernel提供了一些功能来做到这一点,可以通过以下方式使用:
from semantic_kernel.text import text_chunker as tcchunks = tc.split_plaintext_paragraph([london_info], max_tokens=100)
我们可以看到文本已经被划分为8个块。根据文本的不同,我们需要调整每个块指定的最大标记数。
使用文本嵌入服务
现在我们已经划分了数据块,我们需要创建每个块的表示,以便我们可以计算文本之间的相关性;我们可以通过将我们的文本表示为嵌入来实现这一点。
为了生成嵌入,我们需要将一个文本嵌入服务添加到我们的内核中。与之前类似,根据底层模型的来源,有各种连接器可供使用。
首先,让我们使用在Azure OpenAI服务中部署的text-embedding-ada-002
模型。该模型由OpenAI训练,有关此模型的更多信息可以在他们的发布博客文章中找到。
from semantic_kernel.connectors.ai.open_ai import AzureTextEmbeddingkernel.add_text_embedding_generation_service( "azure_openai_embedding", AzureTextEmbedding( deployment_name=OPENAI_EMBEDDING_DEPLOYMENT_NAME, endpoint=OPENAI_ENDPOINT, api_key=OPENAI_API_KEY, ),)
现在我们可以生成嵌入的模型,我们需要一个地方来存储这些嵌入。Semantic Kernel提供了一个MemoryStore的概念,它是各种持久化提供者的接口。
对于生产系统,我们可能希望使用数据库进行持久化,为了简化我们的示例,我们将使用内存存储。要使用内存,我们需要在内核中注册一个内存存储。
memory_store = sk.memory.VolatileMemoryStore()kernel.register_memory_store(memory_store=memory_store)
虽然我们在示例中使用了内存存储来简化问题,但在构建更复杂的系统时,我们可能希望使用数据库进行持久化。Semantic Kernel 提供了与流行存储解决方案(如 CosmosDB、Redis、Postgres 等)的连接器。由于内存存储具有共同的接口,所以唯一需要改变的是使用的连接器,这使得在不同提供商之间轻松切换变得容易。
现在我们可以按以下方式将信息保存到内存存储中。
for i, chunk in enumerate(chunks): await kernel.memory.save_information_async( collection="London", id="chunk" + str(i), text=chunk )
在这里,我们创建了一个新的集合,用于分组相似的文档。
现在我们可以按以下方式查询这个集合:
results = await kernel.memory.search_async( "London", "在伦敦应该吃什么食物?", limit=2)
从结果中我们可以看到返回了相关的信息;这反映在高相关性分数上。
然而,这比较容易,因为我们有与问题直接相关的信息,使用了非常相似的语言。现在让我们尝试一个更微妙的查询。
在这个例子中,我们可以看到我们收到了完全相同的结果。然而,由于我们的第二个结果明确提到了“来自世界各地的食物”,我认为这是一个更好的匹配。这突显了语义搜索方法的一些潜在局限性。
使用开源模型
出于兴趣,让我们看看在这个上下文中,开源模型与我们的 OpenAI 服务相比如何。我们可以注册一个 Hugging Face 句子转换器模型,如下所示:
from semantic_kernel.connectors.ai.hugging_face import HuggingFaceTextEmbeddinghf_embedding_service = HuggingFaceTextEmbedding( "sentence-transformers/all-MiniLM-L6-v2", device=-1)kernel.add_text_embedding_generation_service( "hf_embedding_service", hf_embedding_service,)
要切换我们的内存存储,我们可以使用以下方法。
kernel.use_memory(storage=memory_store, embeddings_generator=hf_embedding_service)
use_memory
方法是一个方便的方法,相当于调用 kernel.register_memory(SemanticTextMemory(storage, embeddings_generator))
。 SemanticTextMemory
包含生成嵌入向量和管理其存储的逻辑。
现在我们可以以与之前相同的方式查询它们。
for i, chunk in enumerate(chunks): await kernel.memory.save_information_async( "hf_London", id="chunk" + str(i), text=chunk )hf_results = await kernel.memory.search_async( "hf_London", "在伦敦应该吃什么食物", limit=2, min_relevance_score=0)
我们可以看到我们返回了相同的片段,但是我们的相关性分数是不同的。我们还可以观察到由不同模型生成的嵌入向量的维度差异。
将内存整合到上下文中
在我们之前的例子中,我们看到虽然我们可以基于嵌入向量搜索识别大致相关的信息,但对于更微妙的查询,我们没有收到最相关的结果。让我们探索是否可以改进这一点。
我们可以采取的一种方法是向我们的聊天机器人提供相关信息,然后让模型决定哪些部分是最相关的。再次定义一个适当的配置。
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义功能的描述 "description": "一个提供关于城市和国家信息的聊天机器人", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 500, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "question", "description": "用户提出的问题", "defaultValue": "", }, { "name": "context", "description": "包含用于帮助回答问题的信息的上下文", "defaultValue": "", }, ] }, # 非模式变量 "system_prompt": "你是一个聊天机器人,提供关于不同城市和国家的信息。",}
接下来,让我们创建一个提示,指示模型根据提供的上下文回答问题,并注册一个语义功能。
prompt_with_context = """ 使用以下上下文片段回答用户的问题。这是您回答问题时应使用的唯一信息,不要引用超出此上下文之外的信息。如果上下文中未提供回答问题所需的信息,只需说“我不知道”,不要试图编造一个答案。 ---------------- 上下文: {{$context}} ---------------- 用户问题: {{$question}} ---------------- 答案:"""chatbot_with_context = kernel.register_semantic_function( skill_name="ChatbotWithContext", function_name="chatbot_with_context", function_config=create_semantic_function_chat_config( prompt_with_context, chat_config_dict, kernel ),)
现在,我们可以使用此函数回答我们更微妙的问题。首先,我们创建一个上下文对象,并将我们的问题添加到其中。
question = "在伦敦哪里可以吃非英国食物?"context = kernel.create_new_context()context["question"] = question
接下来,我们可以手动执行嵌入式搜索,并将检索到的信息添加到我们的上下文中。
results = await kernel.memory.search_async("hf_London", question, limit=2)context["context"] = "\n".join([result.text for result in results])
最后,我们可以执行我们的函数。在这里,我使用内核来执行函数,而不是直接调用它;当我们想要依次运行多个函数时,这是有用的。
answer = await kernel.run_async(chatbot_with_context, input_vars=context.variables)
插件
注意:在早期版本的语义内核中,插件被称为“技能”;它们为了与必应和OpenAI保持一致而改名。因此,许多代码引用和文档都称为“技能”。
Semantic Kernel中的插件是一组可以加载到内核中以供AI应用程序和服务使用的函数。插件内的函数可以由内核进行编排以完成任务。
文档将插件描述为Semantic Kernel的“构建块”,可以将它们链接在一起以创建复杂的工作流程;由于插件遵循OpenAI插件规范,因此可以将为OpenAI服务、必应和Microsoft 365创建的插件与Semantic Kernel一起使用。
Semantic Kernel提供了几个开箱即用的插件,包括:
- ConversationSummarySkill:用于总结对话
- HttpSkill:用于调用API
- TextMemorySkill:用于存储和检索文本
- TimeSkill:用于获取一天的时间和其他时间信息
让我们首先探索如何使用预定义的插件,然后再研究如何创建自定义插件。
使用现成的插件
Semantic Kernel中包含的一个插件是TextMemorySkill
,它提供了从记忆中保存和提取信息的功能。让我们看看如何使用它来简化之前在内存中填充提示上下文的示例。
首先,我们必须导入我们的插件,如下所示。
在这里,我们可以看到这个插件包含两个语义函数:recall
和save
。
现在,让我们修改我们的提示:
prompt_with_context_plugin = """ 使用以下上下文片段来回答用户的问题。这是您应该用来回答问题的唯一信息,不要引用上下文之外的信息。如果上下文中没有提供回答问题所需的信息,只需说“我不知道”,不要试图编造答案。 ---------------- 上下文:{{recall $question}} ---------------- 用户问题:{{$question}} ---------------- 答案:"""
我们可以看到,要使用recall
函数,我们可以在提示中引用它。现在,让我们创建一个配置并注册一个函数。
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "一个提供有关城市和国家信息的聊天机器人", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 500, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "question", "description": "用户提出的问题", "defaultValue": "", }, ] }, # 非模式变量 "system_prompt": "您是一个提供有关不同城市和国家信息的聊天机器人。",}chatbot_with_context_plugin = kernel.register_semantic_function( skill_name="ChatbotWithContextPlugin", function_name="chatbot_with_context_plugin", function_config=create_semantic_function_chat_config( prompt_with_context_plugin, chat_config_dict, kernel ),)
在我们的手动示例中,我们能够控制返回结果的数量和搜索的集合。当使用TextMemorySkill
时,我们可以通过将它们添加到上下文中来设置这些参数。让我们试试我们的函数。
question = "在伦敦哪里可以吃非英国食物?"context = kernel.create_new_context()context["question"] = questioncontext[sk.core_skills.TextMemorySkill.COLLECTION_PARAM] = "hf_London"context[sk.core_skills.TextMemorySkill.RELEVANCE_PARAM] = 0.2context[sk.core_skills.TextMemorySkill.LIMIT_PARAM] = 2answer = await kernel.run_async( chatbot_with_context_plugin, input_vars=context.variables)
我们可以看到,这与我们的手动方法是等效的。
创建自定义插件
现在我们了解了如何创建语义函数和如何使用插件,我们已经拥有了开始制作自己插件所需的一切!
插件可以包含两种类型的函数:
- 语义函数:使用自然语言执行操作
- 本机函数:使用Python代码执行操作
这两种类型的函数可以在同一个插件中组合使用。
选择使用语义函数还是本机函数取决于您执行的任务。对于涉及理解或生成语言的任务,语义函数是最明显的选择。然而,对于更确定性的任务,如执行数学运算、下载数据或访问时间,本机函数更适合。
让我们来探索如何创建每种类型。首先,让我们创建一个文件夹来存储我们的插件。
from pathlib import Pathplugins_path = Path("Plugins")plugins_path.mkdir(exist_ok=True)
创建一个诗歌生成器插件
在我们的示例中,让我们创建一个生成诗歌的插件;为此,使用语义函数似乎是一个自然的选择。我们可以在我们的目录中为此插件创建一个文件夹。
poem_gen_plugin_path = plugins_path / "PoemGeneratorPlugin"poem_gen_plugin_path.mkdir(exist_ok=True)
回想一下插件只是一组函数,我们正在创建一个语义函数,下一部分应该非常熟悉。关键区别在于,我们不再内联定义我们的提示和配置,而是为它们创建单独的文件;以便更容易加载。
让我们为我们的语义函数创建一个文件夹,我们将其称为write_poem
。
poem_sc_path = poem_gen_plugin_path / "write_poem"poem_sc_path.mkdir(exist_ok=True)
接下来,我们创建我们的提示,将其保存为skprompt.txt
。
现在,让我们创建我们的配置并将其存储在一个json文件中。
在我们的配置中设置有意义的描述总是一个好习惯,当我们定义插件时,这变得更加重要;插件应该提供清晰的描述,描述它们的行为方式,其输入和输出是什么,以及它们的副作用是什么。这是因为这是我们内核呈现的接口,如果我们要能够使用LLM编排任务,它需要能够理解插件的功能以及如何调用它以选择适当的函数。
config_path = poem_sc_path / "config.json"
%%writefile {config_path}{ "schema": 1, "type": "completion", "description": "一个诗歌生成器,根据用户输入写一首短诗", "default_services": ["azure_gpt35_chat_completion"], "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 250, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0 }, "input": { "parameters": [{ "name": "input", "description": "诗歌的主题", "defaultValue": "" }] }}
请注意,由于我们将配置保存为JSON文件,我们需要去除注释以使其成为有效的JSON。
现在,我们可以导入我们的插件:
poem_gen_plugin = kernel.import_semantic_skill_from_directory( plugins_path, "PoemGeneratorPlugin")
检查我们的插件,我们可以看到它公开了我们的write_poem
语义函数。
我们可以直接调用我们的语义函数:
result = poem_gen_plugin["write_poem"]("慕尼黑")
或者,我们可以在另一个语义函数中使用它:
chat_config_dict = { "schema": 1, # 提示的类型 "type": "completion", # 语义函数的描述 "description": "包装一个插件来写一首诗", # 指定要使用的模型服务 "default_services": ["azure_gpt35_chat_completion"], # 将传递给连接器和模型服务的参数 "completion": { "temperature": 0.0, "top_p": 1, "max_tokens": 500, "number_of_responses": 1, "presence_penalty": 0, "frequency_penalty": 0, }, # 定义在提示中使用的变量 "input": { "parameters": [ { "name": "input", "description": "用户输入", "defaultValue": "", }, ] },}prompt = """{{PoemGeneratorPlugin.write_poem $input}}"""write_poem_wrapper = kernel.register_semantic_function( skill_name="PoemWrapper", function_name="poem_wrapper", function_config=create_semantic_function_chat_config( prompt, chat_config_dict, kernel ),)result = write_poem_wrapper("慕尼黑")
创建图像分类器插件
现在我们已经看到了如何在插件中使用语义函数,让我们来看看如何使用本地函数。
在这里,让我们创建一个插件,它接收一个图像的URL,然后下载并对图像进行分类。再次,让我们为我们的新插件创建一个文件夹。
image_classifier_plugin_path = plugins_path / "ImageClassifierPlugin"image_classifier_plugin_path.mkdir(exist_ok=True)download_image_sc_path = image_classifier_plugin_path / "download_image.py"download_image_sc_path.mkdir(exist_ok=True)
现在,我们可以创建我们的Python模块。在模块内部,我们可以非常灵活。在这里,我们创建了一个具有两个方法的类,关键步骤是使用sk_function
装饰器来指定哪些方法应该作为插件的一部分暴露出来。
在此示例中,我们的函数只需要一个输入。对于需要多个输入的函数,可以使用sk_function_context_parameter
,如文档中所示。
import requestsfrom PIL import Imageimport timmfrom timm.data.imagenet_info import ImageNetInfofrom semantic_kernel.skill_definition import ( sk_function,)from semantic_kernel.orchestration.sk_context import SKContextclass ImageClassifierPlugin: def __init__(self): self.model = timm.create_model("convnext_tiny.in12k_ft_in1k", pretrained=True) self.model.eval() data_config = timm.data.resolve_model_data_config(self.model) self.transforms = timm.data.create_transform(**data_config, is_training=False) self.imagenet_info = ImageNetInfo() @sk_function( description="接收一个URL作为输入并对图像进行分类", name="classify_image", input_description="要分类的图像的URL", ) def classify_image(self, url: str) -> str: image = self.download_image(url) pred = self.model(self.transforms(image)[None]) return self.imagenet_info.index_to_description(pred.argmax()) def download_image(self, url): return Image.open(requests.get(url, stream=True).raw).convert("RGB")
对于此示例,我使用了出色的Pytorch Image Models库来提供我们的分类器。有关此库的更多信息,请查看这篇博文。
现在,我们可以像下面这样简单地导入我们的插件。
image_classifier = ImageClassifierPlugin()classify_plugin = kernel.import_skill(image_classifier, skill_name="classify_image")
检查我们的插件,我们可以看到只有我们装饰的函数被公开。
我们可以使用来自Pixabay的猫的图像来验证我们的插件是否正常工作。
url = "https://cdn.pixabay.com/photo/2016/02/10/16/37/cat-1192026_1280.jpg"response = classify_plugin["classify_image"](url)
通过手动调用我们的函数,我们可以看到我们的图像已被正确分类!与之前一样,我们也可以直接从提示符中引用此函数。然而,由于我们已经演示过这一点,让我们在下一节尝试稍微不同的东西。
链接多个插件
使用内核,还可以将多个插件链接在一起,如下所示。
context = kernel.create_new_context()context["input"] = urlanswer = await kernel.run_async( classify_plugin["classify_image"], poem_gen_plugin["write_poem"], input_context=context,)
我们可以看到,使用这两个插件按顺序,我们对图像进行了分类,并写了一首关于它的诗!
使用计划器进行工作流编排
到目前为止,我们已经全面探讨了语义函数,了解了如何将函数分组并作为插件的一部分使用,以及如何手动链接插件。现在,让我们探索如何使用LLMs创建和编排工作流程。为此,语义内核提供了计划器对象,可以动态地创建函数链以尝试实现目标。
计划器是一个类,它接收用户提示和内核,并使用内核的服务创建执行任务的计划,使用已经提供给内核的函数和插件。由于插件是这些计划的主要构建块,计划器在很大程度上依赖于提供的描述;如果插件和函数没有清晰的描述,计划器将无法正确使用它们。此外,由于计划器可以以各种不同的方式组合函数,因此确保我们只公开我们愿意计划器使用的函数非常重要。
由于计划器依赖于模型生成计划,可能会引入错误;这些错误通常发生在计划器不正确理解如何使用函数时。在这些情况下,我发现在描述中提供明确的指令,例如描述输入和输出以及说明是否需要输入,可以获得更好的结果。此外,使用经过调整的指令模型比基础模型获得更好的结果;基础文本完成模型往往会产生不存在的函数或创建多个计划。尽管存在这些限制,但当一切正常工作时,计划器可以具有非常强大的功能!
让我们通过探索是否可以创建一个计划来写一首关于图像的诗,基于其URL,使用我们之前创建的插件来实现这一点。由于我们定义了很多不再需要的函数,让我们创建一个新的内核,以便我们可以控制公开哪些函数。
kernel = sk.Kernel()
为了创建我们的计划,让我们使用我们的OpenAI聊天服务。
kernel.add_chat_service( service_id="azure_gpt35_chat_completion", service=AzureChatCompletion( OPENAI_DEPLOYMENT_NAME, OPENAI_ENDPOINT, OPENAI_API_KEY ),)
检查我们注册的服务,我们可以看到我们的服务可以用于文本完成和聊天完成任务。
现在,让我们导入我们的插件。
classify_plugin = kernel.import_skill( ImageClassifierPlugin(), skill_name="classify_image")poem_gen_plugin = kernel.import_semantic_skill_from_directory( plugins_path, "PoemGeneratorPlugin")
我们可以看到我们的内核可以访问哪些函数,如下所示。
现在,让我们导入我们的计划器对象。
from semantic_kernel.planning.basic_planner import BasicPlannerplanner = BasicPlanner()
要使用我们的计划器,我们只需要一个提示。通常,我们需要根据生成的计划进行调整。在这里,我尽可能明确地说明了所需的输入。
ask = f"""我希望你写一首关于这张图像中包含的内容的诗,使用此URL作为输入:{url}。"""
接下来,我们可以使用我们的计划器为它解决任务创建一个计划。
plan = await planner.create_plan_async(ask, kernel)
检查我们的计划,我们可以看到模型正确识别了我们的输入,并选择了正确的函数!
最后,我们要做的就是执行我们的计划。
poem = await planner.execute_plan_async(plan, kernel)
哇,它起作用了!对于一个训练来预测下一个单词的模型来说,这是非常强大的!
值得警告的是,在制作这个示例时,我非常幸运,生成的计划第一次就成功了。然而,用相同的提示多次运行,我们可以看到这并不总是如此,所以在运行之前双重检查你的计划是很重要的!对于我个人来说,在一个生产系统中,我更愿意手动创建执行工作流程,而不是把它交给LLM!随着技术的不断改进,特别是以目前的速度,希望这个建议将过时!
结论
希望这为语义内核提供了一个很好的介绍,并激发了您对于在自己的用例中使用它的探索。
复制本文所需的所有代码可在此处作为笔记本使用。
Chris Hughes在LinkedIn上。
参考资料
- 介绍ChatGPT (openai.com)
- microsoft/semantic-kernel: 快速轻松地将尖端LLM技术集成到您的应用程序中 (github.com)
- 我们是谁 — 微软解决方案手册
- kernel — 维基词典,自由的词典
- 概述 — OpenAI API
- Azure OpenAI服务 — 高级语言模型 | Microsoft Azure
- Hugging Face Hub文档
- Azure OpenAI服务模型 — Azure OpenAI | Microsoft Learn
- 立即创建您的Azure免费帐户 | Microsoft Azure
- 如何在语义内核中使用提示模板语言 | Microsoft Learn
- asyncio — 异步I/O — Python 3.11.5文档
- 🤗 Transformers (huggingface.co)
- gpt2 · Hugging Face
- explosion/curated-transformers: 🤖 一个基于PyTorch的精选Transformer模型及其可组合的组件库 (github.com)
- tiiuae/falcon-7b · Hugging Face
- ND A100 v4-series — Azure虚拟机 | Microsoft Learn
- [2203.02155] 训练语言模型以遵循人类反馈的指示 (arxiv.org)
- Azure OpenAI服务模型 — Azure OpenAI | Microsoft Learn
- 全新改进的嵌入模型 (openai.com)
- 介绍 — Azure Cosmos DB | Microsoft Learn
- PostgreSQL: 世界上最先进的开源数据库
- sentence-transformers/all-MiniLM-L6-v2 · Hugging Face
- 理解语义内核及其插件 | Microsoft Learn
- 语义内核中可用的开箱即用插件 | Microsoft Learn
- 如何使用语义内核向您的AI应用程序添加本机代码 | Microsoft Learn
- huggingface/pytorch-image-models: PyTorch图像模型、脚本、预训练权重 — ResNet、ResNeXT、EfficientNet、NFNet、Vision Transformer (ViT)、MobileNet-V3/V2、RegNet、DPN、CSPNet、Swin Transformer、MaxViT、CoAtNet、ConvNeXt等 (github.com)
- 入门PyTorch图像模型(timm):从实践者的角度出发 | by Chris Hughes | Towards Data Science