本章内容涵盖
- 代理如何通过动作在自身之外执行操作
- 定义和使用 OpenAI 函数
- 语义内核(Semantic Kernel)及其如何使用语义函数
- 语义函数与原生函数的协同
- 使用语义内核实例化 GPT 接口
在本章中,我们将通过函数的使用来探讨动作(actions),以及代理如何利用这些动作。我们先从 OpenAI 的函数调用开始,然后快速介绍微软的另一个项目——语义内核(Semantic Kernel,简称 SK),借助它来构建和管理代理的技能和函数,或直接作为代理使用。
本章最后,我们将用 SK 来托管第一个代理系统。本章内容完整,包含大量带注释的代码示例。
5.1 定义代理动作
ChatGPT 插件最初被引入,是为了赋予会话额外的能力、技能或工具。借助插件,你可以搜索网络、制作电子表格或图表。插件为 ChatGPT 提供了扩展平台的手段。
图 5.1 展示了 ChatGPT 插件的工作原理。在示例中,ChatGPT 安装了一个新的电影推荐插件。当用户请求推荐新电影时,大型语言模型(LLM)识别到它有一个插件可以处理该操作。然后,它将用户请求拆解成可执行的参数,并传递给电影推荐插件执行。
推荐器随后会爬取展示新电影的网站,并将这些信息附加到新的提示请求中发送给大型语言模型(LLM)。LLM 根据这些信息生成响应,返回给推荐器,再由推荐器传回给 ChatGPT。ChatGPT 最终将推荐结果回复给用户。
我们可以将插件看作动作的代理(proxy)。插件通常封装了一个或多个能力,比如调用 API 或爬取网站。因此,动作可以视为插件的扩展——它们赋予插件执行具体能力。
人工智能代理既可以被视为插件,也可以作为插件、工具、技能以及其他代理的使用者。向代理/插件添加技能、函数和工具,使其能够执行明确定义的动作。图 5.2 展示了代理动作发生的位置及其与大型语言模型和其他系统的交互关系。
代理动作是一种能力,使代理能够使用函数、技能或工具。不同框架使用不同的术语,这往往令人困惑。为了建立基本定义,我们将“动作”定义为代理能够执行的任何操作。
ChatGPT 插件和函数代表了 ChatGPT 或代理系统可以使用的可执行能力,以执行额外的动作。现在,让我们来了解 OpenAI 插件和函数定义的基础。
5.2 执行 OpenAI 函数
借助插件功能,OpenAI 引入了一种结构化规范,用于定义函数/插件与大型语言模型(LLM)之间的接口。该规范正在成为多个 LLM 系统遵循的标准,以提供可执行的系统功能。
这些相同的函数定义现在也被用于定义 ChatGPT 和其他系统的插件。接下来,我们将探讨如何直接通过 LLM 调用使用函数。
5.2.1 在 LLM API 调用中添加函数
图 5.3 演示了 LLM 如何识别并使用函数定义,将其响应转化为函数调用。
列表 5.1 展示了一个使用工具和函数定义的 LLM API 调用细节。添加函数定义后,LLM 可以根据函数的输入参数进行回复。这意味着 LLM 会识别正确的函数,并解析用户请求中相关的参数。
列表 5.1 first_function.py(API 调用示例)
response = client.chat.completions.create(
model="gpt-4-1106-preview",
messages=[{"role": "system",
"content": "You are a helpful assistant."},
{"role": "user", "content": user_message}],
temperature=0.7,
tools=[ #1
{
"type": "function", #2
"function": {
"name": "recommend",
"description": "Provide a … topic.", #3
"parameters": {
"type": "object", #4
"properties": {
"topic": {
"type": "string",
"description":
"The topic,… for.", #5
},
"rating": {
"type": "string",
"description":
"The rating … given.", #5
"enum": ["good",
"bad",
"terrible"] #6
},
},
"required": ["topic"],
},
},
}
]
)
- #1 新增了名为 tools 的参数
- #2 设置工具类型为函数
- #3 对函数功能进行了详细描述
- #4 定义了输入参数类型,这里是一个对象,代表 JSON 文档
- #5 为每个输入参数提供了详细描述
- #6 还可以使用枚举类型来描述参数可选值
想了解具体实现效果,可以打开 Visual Studio Code(VS Code)并定位到本书源码目录的 chapter_4/first_function.py 文件。建议在 VS Code 中打开对应章节目录,创建新的 Python 环境并安装 requirements.txt 中的依赖。如果需要帮助,请参考附录 B。
开始前,请在 chapter_4 文件夹内正确设置 .env 文件,填写你的 API 凭证。函数调用是 LLM 商业服务额外提供的功能,截至写作时,开源 LLM 部署尚未支持此功能。
接下来,我们看一下 first_function.py 代码底部,见列表 5.2。这是基于列表 5.1 中请求定义的两个调用示例。每个请求都会展示运行示例后生成的输出。
列表 5.2 first_function.py(调用 API 示例)
user = "Can you please recommend me a time travel movie?"
response = ask_chatgpt(user) #1
print(response)
### 输出
Function(arguments='{"topic":"time travel movie"}',
name='recommend') #2
user = "Can you please recommend me a good time travel movie?"
response = ask_chatgpt(user) #3
print(response)
### 输出
Function(arguments='{"topic":"time travel movie",
"rating":"good"}',
name='recommend') #4
- #1 前面定义的函数调用
- #2 返回了待调用函数的名称和提取的输入参数
- #3 同上,另一个调用示例
- #4 同上,返回函数名称及对应参数
在 VS Code 中使用调试器(F5)或终端运行 first_function.py 脚本,即可获得相同结果。这里,LLM 会解析输入请求以匹配任何注册的工具。在本例中,工具就是那个单独定义的函数——recommend。LLM 从函数定义中提取输入参数,并从请求中解析这些参数,随后以函数名称和参数回复。
注意:实际的函数并未被调用,LLM 只是返回建议调用的函数名称及相关输入参数。必须将名称和参数提取出来,并传入对应签名的函数以执行操作。下一节我们将举例说明。
5.2.2 执行函数调用
既然我们知道 LLM 并不会直接执行函数或插件,就来看一个执行工具的示例。延续推荐器主题,我们来看一个添加简单推荐 Python 函数的示例。
图 5.4 展示了这个简单示例的工作流程。我们提交一个包含工具函数定义的请求,请求给出三条推荐。LLM 会回复包含三个函数调用及输入参数(如时间旅行、食谱和礼物)。执行函数得到的结果会传回给 LLM,LLM 再将结果转换回自然语言并回复用户。
现在我们理解了示例,打开 Visual Studio Code(VS Code)中的 parallel_functions.py 文件。列表 5.3 展示了你想调用的用于提供推荐的 Python 函数。
列表 5.3 parallel_functions.py(recommend 函数)
def recommend(topic, rating="good"):
if "time travel" in topic.lower(): #1
return json.dumps({"topic": "time travel",
"recommendation": "Back to the Future",
"rating": rating})
elif "recipe" in topic.lower(): #1
return json.dumps({"topic": "recipe",
"recommendation": "The best thing … ate.",
"rating": rating})
elif "gift" in topic.lower(): #1
return json.dumps({"topic": "gift",
"recommendation": "A glorious new...",
"rating": rating})
else: #2
return json.dumps({"topic": topic,
"recommendation": "unknown"}) #3
- #1 检查字符串是否包含在输入的主题中
- #2 如果未检测到匹配主题,则返回默认值
- #3 返回一个 JSON 对象
接下来,我们来看名为 run_conversation 的函数,这里所有工作从构造请求开始。
列表 5.4 parallel_functions.py(run_conversation,构造请求)
user = """Can you please make recommendations for the following:
1. Time travel movies
2. Recipes
3. Gifts""" #1
messages = [{"role": "user", "content": user}] #2
tools = [ #3
{
"type": "function",
"function": {
"name": "recommend",
"description":
"Provide a recommendation for any topic.",
"parameters": {
"type": "object",
"properties": {
"topic": {
"type": "string",
"description":
"The topic, … recommendation for.",
},
"rating": {
"type": "string",
"description": "The rating … was given.",
"enum": ["good", "bad", "terrible"]
},
},
"required": ["topic"],
},
},
}
]
- #1 用户请求三个推荐项
- #2 注意这里没有系统消息
- #3 将函数定义添加到请求的 tools 部分
列表 5.5 展示了发出的请求,我们之前已经介绍过,但这里有几点需要注意。该调用使用了较低版本的模型如 GPT-3.5,因为委派函数是一项较简单的任务,可以用较旧、成本更低、复杂度较低的语言模型完成。
列表 5.5 parallel_functions.py(run_conversation,API 调用)
response = client.chat.completions.create(
model="gpt-3.5-turbo-1106", #1
messages=messages, #2
tools=tools, #2
tool_choice="auto", #3
)
response_message = response.choices[0].message #4
- #1 委派函数调用的 LLM 可以是更简单的模型
- #2 添加消息和工具定义
- #3 “auto”为默认选项
- #4 LLM 返回的消息
此时,API 调用返回的响应应包含所需的函数调用信息。记住,我们要求 LLM 提供三个推荐,因此它应返回三个函数调用输出,如下所示。
列表 5.6 parallel_functions.py(run_conversation,工具调用)
tool_calls = response_message.tool_calls #1
if tool_calls: #1
available_functions = {
"recommend": recommend,
} #2
# 第4步:将每个函数调用信息及函数响应传回模型
for tool_call in tool_calls: #3
function_name = tool_call.function.name
function_to_call = available_functions[function_name]
function_args = json.loads(tool_call.function.arguments)
function_response = function_to_call(
topic=function_args.get("topic"), #4
rating=function_args.get("rating"),
)
messages.append( #5
{
"tool_call_id": tool_call.id,
"role": "tool",
"name": function_name,
"content": function_response,
}
) # 将函数调用结果加入消息集
second_response = client.chat.completions.create( #6
model="gpt-3.5-turbo-1106",
messages=messages,
)
return second_response.choices[0].message.content #6
- #1 如果响应中包含工具调用,则执行它们
- #2 这里只有一个函数,但也可以有多个
- #3 遍历调用列表,将结果回复给 LLM
- #4 根据提取的参数执行 recommend 函数
- #5 将每个函数调用的结果附加到消息集合中
- #6 使用更新后的消息再次请求 LLM 并返回回复内容
工具调用的输出和调用推荐函数的结果被追加到了消息中。注意,消息中现在包含了第一次调用的历史记录,然后被传回给 LLM 用于构建自然语言回复。
在 VS Code 中打开该文件,按 F5 调试运行。下列列表展示了运行 parallel_functions.py 时的输出。
列表 5.7 parallel_functions.py(输出)
Here are some recommendations for you:
1. Time travel movies: "Back to the Future"
2. Recipes: "The best thing you ever ate."
3. Gifts: "A glorious new..." (the recommendation was cut off, so I
couldn't provide the full recommendation)
I hope you find these recommendations helpful! Let me know if you need
more information.
这完成了本简单示范。对于更高级的应用,函数可以执行各种操作,从爬取网站、调用搜索引擎,到完成更复杂的任务。
函数是为特定任务生成输出的极好方式。但处理函数或工具并进行二次调用的工作,可以用更简洁高效的方法完成。下一节将揭示一种更健壮的系统,来为代理添加动作。
5.3 介绍语义内核(Semantic Kernel)
语义内核(Semantic Kernel,简称 SK)是微软的另一个开源项目,旨在帮助构建我们称之为代理的 AI 应用程序。该项目的核心用途是定义动作,或者称为语义插件(semantic plugins),这些插件是技能和函数的封装器。
图 5.5 展示了 SK 如何既作为插件,又作为 OpenAI 插件的使用者。SK 依赖于 OpenAI 插件定义来定义插件,从而能够消费并发布自身或其他插件到其他系统。
OpenAI 插件定义与列表 5.4 中的函数定义完全对应。这意味着 SK 是 API 工具调用(即插件)的协调者。这也意味着 SK 可以帮助通过聊天界面或代理来组织多个插件。
注:SK 团队最初将功能模块称为“技能”(skills),但为了与 OpenAI 保持一致,后来将“技能”改名为“插件”(plugins)。更令人困惑的是,代码中仍然使用“技能”一词。因此,本章中我们将“技能”和“插件”视为同义。
SK 是管理多个插件(即代理动作)的有力工具,正如后文所示,它还能辅助记忆和规划工具。本章重点关注动作/插件,下一节介绍如何开始使用 SK。
5.3.1 使用 SK 语义函数快速入门
SK 易于安装,支持 Python、Java 和 C#,这非常好,因为它允许用一种语言开发的插件被另一种语言调用。不过,目前还不能实现用一种语言开发原生函数,然后在另一种语言中直接使用。
我们将继续在 VS Code 的 chapter_4 工作区中使用 Python 环境,确保你已配置好工作区以便探索和运行示例。
列表 5.8 安装 Semantic Kernel
pip uninstall semantic-kernel #1
git clone https://github.com/microsoft/semantic-kernel.git #2
cd semantic-kernel/python #3
pip install -e . #4
- #1 卸载之前安装的 SK
- #2 克隆仓库到本地
- #3 进入源码目录
- #4 从源码目录安装可编辑包
安装完成后,打开 VS Code 中的 SK_connecting.py 文件。列表 5.9 演示了用 SK 快速运行示例。该示例创建了一个聊天完成服务,支持 OpenAI 或 Azure OpenAI。
列表 5.9 SK_connecting.py
import semantic_kernel as sk
selected_service = "OpenAI" #1
kernel = sk.Kernel() #2
service_id = None
if selected_service == "OpenAI":
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
api_key, org_id = sk.openai_settings_from_dot_env() #3
service_id = "oai_chat_gpt"
kernel.add_service(
OpenAIChatCompletion(
service_id=service_id,
ai_model_id="gpt-3.5-turbo-1106",
api_key=api_key,
org_id=org_id,
),
)
elif selected_service == "AzureOpenAI":
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion
deployment, api_key, endpoint = sk.azure_openai_settings_from_dot_env() #4
service_id = "aoai_chat_completion"
kernel.add_service(
AzureChatCompletion(
service_id=service_id,
deployment_name=deployment,
endpoint=endpoint,
api_key=api_key,
),
)
# 此函数当前不可用
async def run_prompt():
result = await kernel.invoke_prompt(prompt="recommend a movie about time travel") #5
print(result)
# 用 asyncio.run 运行异步函数
asyncio.run(run_prompt()) #6
- #1 选择使用的服务(OpenAI 或 Azure OpenAI)
- #2 创建语义内核实例
- #3 从
.env文件加载密钥并配置聊天服务 - #4 同上,针对 Azure OpenAI
- #5 调用提示
- #6 异步执行函数
按 F5(调试)运行该示例,应看到类似列表 5.9 的输出。示例演示了如何用 SK 创建并执行语义函数。语义函数等同于微软另一款工具 prompt flow 中的提示模板,这里定义了一个简单的提示函数。
需要注意的是,该语义函数并非作为插件定义,但内核可将函数创建为可独立执行的语义元素,针对 LLM 调用。语义函数既可单独使用,也可注册为插件,后面会介绍。接下来进入上下文变量部分。
5.3.2 语义函数与上下文变量
基于前例,我们来看如何为语义函数添加上下文变量。为提示模板添加占位符是一种常见模式,我们会反复看到。本例中,提示模板包含主题、类型、格式和自定义信息四个占位符。
打开 VS Code 中的 SK_context_variables.py 文件,如列表所示。该提示相当于设置了提示的系统和用户部分。
列表 5.10 SK_context_variables.py
# 文件顶部省略…
prompt = """ #1
system:
You have vast knowledge of everything and can recommend anything provided
you are given the following criteria, the subject, genre, format and any
other custom information.
user:
Please recommend a {{$format}} with the subject {{$subject}} and {{$genre}}.
Include the following custom information: {{$custom}}
"""
prompt_template_config = sk.PromptTemplateConfig( #2
template=prompt,
name="tldr",
template_format="semantic-kernel",
input_variables=[
InputVariable(
name="format",
description="The format to recommend",
is_required=True
),
InputVariable(
name="suject",
description="The subject to recommend",
is_required=True
),
InputVariable(
name="genre",
description="The genre to recommend",
is_required=True
),
InputVariable(
name="custom",
description="Any custom information to enhance the recommendation",
is_required=True,
),
],
execution_settings=execution_settings,
)
recommend_function = kernel.create_function_from_prompt( #3
prompt_template_config=prompt_template_config,
function_name="Recommend_Movies",
plugin_name="Recommendation",
)
async def run_recommendation( #4
subject="time travel",
format="movie",
genre="medieval",
custom="must be a comedy"
):
recommendation = await kernel.invoke(
recommend_function,
sk.KernelArguments(subject=subject,
format=format,
genre=genre,
custom=custom), #5
)
print(recommendation)
# 用 asyncio.run 运行异步函数
asyncio.run(run_recommendation()) #5
- #1 定义带占位符的提示
- #2 配置提示模板及输入变量定义
- #3 从提示创建内核函数
- #4 创建异步函数封装调用
- #5 设置内核函数参数
运行该示例(F5调试),等待输出结果。这是搭建 SK 并创建执行语义函数的基础。下一节将介绍如何将语义函数注册为技能/插件。
5.4 语义函数与原生函数的协同
语义函数封装了一个提示(prompt)或配置档案,并通过与大型语言模型(LLM)的交互来执行。原生函数则是代码的封装,可以执行从爬取网站到网络搜索等各种操作。语义函数和原生函数都可以在 SK 内核中注册为插件或技能。
无论是语义函数还是原生函数,都可以注册为插件,并像之前通过 API 调用直接注册的函数一样使用。函数一旦被注册为插件,就可以根据具体使用场景,供聊天界面或代理接口访问。
下一节将介绍如何创建语义函数并将其注册到内核。
5.4.1 创建和注册语义技能/插件
SK 的 VS Code 扩展提供了创建插件/技能的实用工具。本节我们将使用该扩展创建一个插件/技能,并编辑该扩展的组件。随后,我们会在 SK 中注册并执行该插件。
图 5.6 展示了如何在 VS Code 中通过 SK 扩展创建新技能的流程。(如果你需要安装该扩展,请参见附录 B 的说明。)创建技能时,你可以选择技能/插件的文件夹,用于放置函数。请务必将功能相似的函数归类在一起。
创建技能后,输入你想开发函数的名称和描述,务必以 LLM 将要使用该函数的角度进行描述。
你可以通过打开 skills/plugin 文件夹并查看其中的文件,来查看已完成的技能和函数。我们将沿用之前构建的示例,因此请打开 skills/Recommender/Recommend_Movies 文件夹,如图 5.7 所示。该文件夹内包含一个 config.json 配置文件、函数描述,以及名为 skprompt.txt 的语义函数(提示模板)文件。
列表 5.11 展示了语义函数定义的内容,也称为插件定义。请注意,类型被标记为 completion,而非 function 类型,因为这是一个语义函数。原生函数则会被定义为 function 类型。
列表 5.11 Recommend_Movies/config.json
{
"schema": 1,
"type": "completion", #1
"description": "A function to recommend movies based on users list of previously seen movies.",
"completion": { #2
"max_tokens": 256,
"temperature": 0,
"top_p": 0,
"presence_penalty": 0,
"frequency_penalty": 0
},
"input": {
"parameters": [
{
"name": "input", #3
"description": "The users list of previously seen movies.",
"defaultValue": ""
}
]
},
"default_backends": []
}
- #1 语义函数的类型为 completion
- #2 可以设置调用函数时的 completion 参数
- #3 定义传入语义函数的参数
接下来看看语义函数提示模板定义,如列表 5.12 所示。格式略有不同,但内容与之前使用模板的示例一致。该提示模板基于用户已看过的电影列表推荐电影。
列表 5.12 Recommend_Movies/skprompt.txt
You are a wise movie recommender and you have been asked to recommend a movie to a user.
You are provided a list of movies that the user has watched before.
You want to recommend a movie that the user has not watched before.
[INPUT]
{{$input}}
[END INPUT]
下面进入代码示例,展示如何加载技能/插件并执行。打开 VS Code 中的 SK_first_skill.py 文件。列表 5.13 展示了精简版代码,突出新内容。
列表 5.13 SK_first_skill.py(精简版)
kernel = sk.Kernel()
plugins_directory = "plugins"
recommender = kernel.import_plugin_from_prompt_directory(
plugins_directory,
"Recommender",
) #1
recommend = recommender["Recommend_Movies"]
seen_movie_list = [ #2
"Back to the Future",
"The Terminator",
"12 Monkeys",
"Looper",
"Groundhog Day",
"Primer",
"Donnie Darko",
"Interstellar",
"Time Bandits",
"Doctor Strange",
]
async def run():
result = await kernel.invoke(
recommend,
sk.KernelArguments( #3
settings=execution_settings, input=", ".join(seen_movie_list)
),
)
print(result)
asyncio.run(run()) #4
- #1 从 plugins 文件夹加载提示模板中的插件
- #2 用户已看过电影列表
- #3 设置函数输入为拼接的电影列表字符串
- #4 异步执行函数
代码从 skills 目录和插件文件夹加载技能/插件。当技能被加载进内核(kernel)而非仅仅创建时,它就成为了注册插件,意味着它可以像这里直接执行,或通过插件接口的 LLM 聊天会话调用。
运行代码(F5),你会看到类似列表 5.13 的输出。现在我们有了一个简单的语义函数,可以作为插件托管。不过此函数需要用户输入完整的已看电影列表。下一节我们将介绍如何通过原生函数解决这个问题。
5.4.2 应用原生函数
如前所述,原生函数是可以执行任意代码的函数。接下来示例中,我们将引入一个原生函数,辅助之前构建的语义函数。
这个原生函数会从文件中加载用户之前看过的电影列表。虽然它涉及记忆概念,但我们将在第 8 章详细讨论。这里可以把它看作能执行任何代码的函数。
原生函数可以通过 SK 扩展创建和注册。本例中,我们直接在代码中创建原生函数,以便示例更易理解。
打开 VS Code 中的 SK_native_functions.py 文件,先看原生函数如何定义。原生函数通常定义在类中,这便于管理和实例化。
列表 5.14 SK_native_functions.py(MySeenMovieDatabase)
class MySeenMoviesDatabase:
"""
Description: Manages the list of users seen movies. #1
"""
@kernel_function( #2
description="Loads a list of movies … user has already seen",
name="LoadSeenMovies",
)
def load_seen_movies(self) -> str: #3
try:
with open("seen_movies.txt", 'r') as file: #4
lines = [line.strip() for line in file.readlines()]
comma_separated_string = ', '.join(lines)
return comma_separated_string
except Exception as e:
print(f"Error reading file: {e}")
return None
- #1 类的描述,管理用户已看电影列表
- #2 使用装饰器添加函数描述和名称
- #3 函数返回逗号分隔的电影列表字符串
- #4 从文本文件加载已看电影
定义完原生函数后,往下看其使用方法,如列表 5.15 所示。
列表 5.15 SK_native_functions.py(剩余代码)
plugins_directory = "plugins"
recommender = kernel.import_plugin_from_prompt_directory(
plugins_directory,
"Recommender",
) #1
seen_movies_plugin = kernel.import_plugin_from_object(
MySeenMoviesDatabase(), "SeenMoviesPlugin"
) #2
load_seen_movies = seen_movies_plugin["LoadSeenMovies"] #3
async def show_seen_movies():
seen_movie_list = await load_seen_movies(kernel)
return seen_movie_list
seen_movie_list = asyncio.run(show_seen_movies()) #4
print(seen_movie_list)
async def run(): #5
result = await kernel.invoke(
recommend,
sk.KernelArguments(
settings=execution_settings,
input=seen_movie_list),
)
print(result)
asyncio.run(run()) #5
- #1 加载之前的语义函数插件
- #2 从对象导入原生函数作为插件注册
- #3 载入原生函数
- #4 执行函数并以字符串形式返回电影列表
- #5 异步包装插件调用并执行
需要注意的是,原生函数导入到内核时,即完成了插件/技能注册。意味着该函数可以作为技能供内核在其它对话或交互中调用。下一节我们将看到如何在语义函数内嵌入原生函数。
5.4.3 在语义函数中嵌入原生函数
SK 拥有许多强大的功能,其中一个非常有用的功能是能够在一个语义函数内部嵌入原生函数或其他语义函数。下面的列表展示了如何在语义函数中嵌入原生函数。
列表 5.16 SK_semantic_native_functions.py(skprompt)
sk_prompt = """
You are a wise movie recommender and you have been asked to recommend a
movie to a user.
You have a list of movies that the user has watched before.
You want to recommend a movie that
the user has not watched before. #1
Movie List: {{MySeenMoviesDatabase.LoadSeenMovies}}. #2
"""
- #1 与之前的提示文本完全相同
- #2 通过类名和函数名引用了原生函数
接下来示例 SK_semantic_native_functions.py 使用了内联的原生函数和语义函数。打开该文件,以下列表展示了如何创建、注册并执行这些函数的代码。
列表 5.17 SK_semantic_native_functions.py(精简版)
prompt_template_config = sk.PromptTemplateConfig(
template=sk_prompt,
name="tldr",
template_format="semantic-kernel",
execution_settings=execution_settings,
) #1
recommend_function = kernel.create_function_from_prompt(
prompt_template_config=prompt_template_config,
function_name="Recommend_Movies",
plugin_name="Recommendation",
) #2
async def run_recommendation(): #3
recommendation = await kernel.invoke(
recommend_function,
sk.KernelArguments(),
)
print(recommendation)
# 用 asyncio.run 执行异步函数
asyncio.run(run_recommendation())
输出示例
Based on the list provided, it seems the user is a fan of the Matrix
franchise. Since they have watched all four existing Matrix movies, I
would recommend a…
- #1 创建提示模板配置
- #2 从提示创建内联语义函数
- #3 异步执行语义函数
运行代码,你应看到类似上述输出。需要特别注意的是,原生函数是注册到内核中的,而语义函数并没有注册。函数创建本身并不会自动注册函数。
为了让这个示例正常工作,原生函数必须通过 import_plugin 函数调用注册到内核中——如列表 5.17 的第一行所示,但语义函数本身没有注册。注册语义函数的简单方法是将其作为插件创建并导入。
这些简单练习展示了如何将插件和技能集成到聊天或代理接口中。下一节,我们将通过一个完整示例,演示如何添加代表服务或 GPT 接口的插件到聊天功能中。
5.5 语义内核作为交互式服务代理
在第 1 章中,我们介绍了 GPT 接口的概念——这是一种通过插件和语义层将服务及其他组件连接到大型语言模型(LLM)上的新范式。SK 提供了一个极佳的抽象层,用于将任何服务转换为 GPT 接口。
图 5.8 展示了围绕名为 The Movie Database(TMDB,网址:<www.themoviedb.org>)的 API 服务构建的 GPT 接口。TMDB 网站提供一个免费 API,用于公开电影和电视节目的相关信息。
要跟进本节的练习,你需要在 TMDB 注册一个免费账户并创建 API 密钥。获取 API 密钥的具体说明可以在 TMDB 官网(<www.themoviedb.org>)找到,或者通过询问 GPT-4 Turbo 或更新的语言模型获得。
在接下来的若干小节中,我们将使用 SK 的一组原生函数来创建一个 GPT 接口。然后使用 SK 内核测试该接口,并在本章后续部分将其作为插件集成到聊天功能中。下一节我们将讲解如何基于 TMDB API 构建 GPT 接口。
5.5.1 构建语义 GPT 接口
TMDB 是一个非常优秀的服务,但它本身不提供语义服务,也不能直接作为插件接入 ChatGPT 或代理。为此,我们必须将 TMDB 提供的 API 调用封装到一个语义服务层中。
语义服务层即 GPT 接口,通过自然语言暴露函数。如前所述,要将函数暴露给 ChatGPT 或其他代理接口,它们必须被定义为插件。幸运的是,只要我们正确编写语义服务层,SK 可以自动帮我们创建这些插件。
一个原生插件或技能集合可以充当语义层。创建原生插件时,需要新建一个插件文件夹,并在该文件夹内放置一个包含原生函数集合的 Python 类文件。目前 SK 扩展对这方面支持不佳,手动创建模块效果最佳。
图 5.9 展示了名为 Movies 的新插件结构,以及语义服务层文件 tmdb.py。对于原生函数,导入时使用父文件夹的名称(Movies)。
打开 VS Code 中的 tmdb.py 文件,查看文件顶部内容,如列表 5.18 所示。该文件包含一个名为 TMDbService 的类,暴露了多个映射到 API 端点调用的函数。其思路是将相关的 API 调用映射到这个语义服务层中,从而将这些函数作为插件暴露给聊天或代理接口。
列表 5.18 tmdb.py(文件顶部)
from semantic_kernel.functions import kernel_function
import requests
import inspect
def print_function_call(): #1
# omitted …
class TMDbService: #2
def __init__(self):
# 在此输入你的 TMDb API 密钥
self.api_key = "your-TMDb-api-key"
@kernel_function( #2
description="Gets the movie genre ID for a given genre name",
name="get_movie_genre_id",
input_description="The movie genre name of the genre_id to get",
)
def get_movie_genre_id(self, genre_name: str) -> str: #3
print_function_call()
base_url = "https://api.themoviedb.org/3"
endpoint = f"{base_url}/genre/movie/list?api_key={self.api_key}&language=en-US"
response = requests.get(endpoint) #4
if response.status_code == 200: #4
genres = response.json()['genres']
for genre in genres:
if genre_name.lower() in genre['name'].lower():
return str(genre['id']) #5
return None
- #1 用于调试打印函数调用
- #2 顶层服务类及函数装饰器(良好描述非常重要)
- #3 语义包装的函数,应返回字符串
- #4 调用 API 端点,若响应码为 200 则继续处理
- #5 找到匹配的类型名称返回其 ID
TMDbService 及调用 TMDB 端点的函数主要由 GPT-4 Turbo 协助编写。每个函数再用 sk_function 装饰器封装以实现语义暴露。
部分 TMDB API 调用已进行语义映射。列表 5.19 展示了另一个暴露给语义服务层的函数示例,该函数获取特定类型当前播放的前十名电影列表。
列表 5.19 tmdb.py(get_top_movies_by_genre)
@kernel_function( #1
description="Gets a list of currently playing movies for a given genre",
name="get_top_movies_by_genre",
input_description="The genre of the movies to get",
)
def get_top_movies_by_genre(self, genre: str) -> str:
print_function_call()
genre_id = self.get_movie_genre_id(genre) #2
if genre_id:
base_url = "https://api.themoviedb.org/3"
playing_movies_endpoint = f"{base_url}/movie/now_playing?api_key={self.api_key}&language=en-US"
response = requests.get(playing_movies_endpoint) #3
if response.status_code != 200:
return ""
playing_movies = response.json()['results']
for movie in playing_movies: #4
movie['genre_ids'] = [str(genre_id) for genre_id in movie['genre_ids']]
filtered_movies = [movie for movie in playing_movies if genre_id in movie['genre_ids']][:10] #5
results = ", ".join([movie['title'] for movie in filtered_movies])
return results
else:
return ""
- #1 装饰函数并添加描述
- #2 获取给定类型名称的类型 ID
- #3 获取当前上映电影列表
- #4 将 genre_ids 转换为字符串
- #5 过滤出匹配类型 ID 的电影
查看其他语义映射的 API 调用,可以发现将 API 调用转换为语义服务的模式十分明确。接下来,我们将在下一节测试各函数。
5.5.2 测试语义服务
在实际应用中,通常需要为每个语义服务函数编写完整的单元测试或集成测试。本书中不做详细演示,而是写一个快速辅助脚本来测试各函数。
打开 VS Code 中的 test_tmdb_service.py,查看代码,如列表 5.20 所示。你可以注释或取消注释任意函数,单独测试。确保每次只取消注释一个函数。
列表 5.20 test_tmdb_service.py
import semantic_kernel as sk
from plugins.Movies.tmdb import TMDbService
async def main():
kernel = sk.Kernel() #1
tmdb_service = kernel.import_plugin_from_object(
TMDbService(), "TMDBService"
) #2
print(
await tmdb_service["get_movie_genre_id"](
kernel, sk.KernelArguments(genre_name="action") #3
)
) #4
print(
await tmdb_service["get_tv_show_genre_id"](
kernel, sk.KernelArguments(genre_name="action") #5
)
) #6
print(
await tmdb_service["get_top_movies_by_genre"](
kernel, sk.KernelArguments(genre_name="action") #7
)
) #8
print(
await tmdb_service["get_top_tv_shows_by_genre"](
kernel, sk.KernelArguments(genre_name="action") #7
)
)
print(await tmdb_service["get_movie_genres"](
kernel, sk.KernelArguments()
)) #9
print(await tmdb_service["get_tv_show_genres"](
kernel, sk.KernelArguments()
)) #9
# 运行主函数
if __name__ == "__main__":
import asyncio
asyncio.run(main()) #10
输出示例
Function name: get_top_tv_shows_by_genre #11
Arguments:
self = <skills.Movies.tmdb.TMDbService object at 0x00000159F52090C0>
genre = action
Function name: get_tv_show_genre_id #11
Arguments:
self = <skills.Movies.tmdb.TMDbService object at 0x00000159F52090C0>
genre_name = action
Arcane, One Piece, Rick and Morty, Avatar: The Last Airbender, Fullmetal
Alchemist: Brotherhood, Demon Slayer: Kimetsu no Yaiba, Invincible,
Attack on Titan, My Hero Academia, Fighting Spirit, The Owl House
- #1 实例化内核
- #2 导入插件服务
- #3 根据需要向函数输入参数
- #4 执行并测试各函数
- #5 同 #3
- #6 同 #4
- #7 同 #3
- #8 同 #4
- #9 同 #4
- #10 异步执行主函数
- #11 打印函数调用详情,提示函数被调用
SK 的真正强大之处体现在此测试中。注意 TMDbService 类作为插件被导入,但我们无需定义任何额外插件配置,只需编写一个封装了少量 API 函数的类,就能将部分 TMDB API 以语义方式暴露出来。接下来,我们将看看如何将这些函数用作聊天界面的插件。
5.5.3 与语义服务层的交互式聊天
随着 TMDB 函数被语义化暴露出来,我们可以继续将它们集成到聊天界面中。这允许我们在该界面进行自然对话,获取各种信息,例如当前热门电影列表。
打开 VS Code 中的 SK_service_chat.py 文件。向下滚动到新代码段的开始部分,创建函数的代码,如列表 5.21 所示。这里创建的函数现已作为插件暴露,除了聊天函数被过滤掉,因为我们不希望将聊天函数作为插件暴露。聊天函数允许用户直接与 LLM 对话,不应被作为插件。
列表 5.21 SK_service_chat.py(函数设置)
system_message = "You are a helpful AI assistant."
tmdb_service = kernel.import_plugin_from_object(
TMDbService(), "TMDBService"
) #1
# 代码片段
execution_settings = sk_oai.OpenAIChatPromptExecutionSettings(
service_id=service_id,
ai_model_id=model_id,
max_tokens=2000,
temperature=0.7,
top_p=0.8,
tool_choice="auto",
tools=get_tool_call_object(
kernel, {"exclude_plugin": ["ChatBot"]}
), #2
)
prompt_config = sk.PromptTemplateConfig.from_completion_parameters(
max_tokens=2000,
temperature=0.7,
top_p=0.8,
function_call="auto",
chat_system_prompt=system_message,
) #3
prompt_template = OpenAIChatPromptTemplate(
"{{$user_input}}", kernel.prompt_template_engine, prompt_config
) #4
history = ChatHistory()
history.add_system_message("You recommend movies and TV Shows.")
history.add_user_message("Hi there, who are you?")
history.add_assistant_message(
"I am Rudy, the recommender chat bot. I'm trying to figure out what people need."
) #5
chat_function = kernel.create_function_from_prompt(
prompt_template_config=prompt_template,
plugin_name="ChatBot",
function_name="Chat",
) #6
- #1 导入 TMDbService 作为插件
- #2 配置执行设置并添加过滤后的工具
- #3 配置提示模板参数
- #4 定义输入模板,接受完整字符串作为用户输入
- #5 添加聊天历史对象并预置部分历史消息
- #6 创建聊天函数
接着继续向下滚动,查看聊天函数,如列表 5.22 所示。
列表 5.22 SK_service_chat.py(聊天函数)
async def chat() -> bool:
try:
user_input = input("User:> ") #1
except KeyboardInterrupt:
print("\n\nExiting chat...")
return False
except EOFError:
print("\n\nExiting chat...")
return False
if user_input == "exit": #2
print("\n\nExiting chat...")
return False
arguments = sk.KernelArguments( #3
user_input=user_input,
history=("\n").join(
[f"{msg.role}: {msg.content}" for msg in history]),
)
result = await chat_completion_with_tool_call( #4
kernel=kernel,
arguments=arguments,
chat_plugin_name="ChatBot",
chat_function_name="Chat",
chat_history=history,
)
print(f"AI Agent:> {result}")
return True
- #1 直接从终端/控制台获取用户输入
- #2 用户输入 “exit” 时退出聊天
- #3 创建传递给函数的参数
- #4 使用辅助函数调用聊天函数并执行工具
最后,滚动到文件底部,查看主函数。该函数循环调用聊天函数。
列表 5.23 SK_service_chat.py(主函数)
async def main() -> None:
chatting = True
context = kernel.create_new_context()
print("Welcome to your first AI Agent\n Type 'exit' to exit.\n Ask to get a list of currently playing movies by genre.")
while chatting: #2
chatting, context = await chat(context) #3
if __name__ == "__main__":
asyncio.run(main())
- #1 向用户做简要介绍
- #2 聊天直到状态变为 False
- #3 异步调用聊天函数
运行聊天界面,运行该文件(F5),然后询问某个类型的电影或电视剧。列表 5.24 展示了示例对话过程。输出显示,用户请求列出两个类型的电影,聊天界面多次调用了 get_top_movie_by_genre 函数。
列表 5.24 SK_service_chat.py(示例对话)
Welcome to your first AI Agent
Type 'exit' to exit.
Ask to get a list of currently playing movies by genre.
User:> Input: can you give me list of the current top playing movies for the action and comedy genres?
Function name: get_top_movies_by_genre #1
Arguments:
genre = action
Function name: get_movie_genre_id #2
Arguments:
genre_name = action
Function name: get_top_movies_by_genre #1
Arguments:
genre = comedy
Function name: get_movie_genre_id #2
Arguments:
genre_name = comedy
Agent:> Here are the current top-playing movies for the action and comedy genres:
**Action:** #3
1. The Hunger Games: The Ballad of Songbirds & Snakes
2. Rebel Moon - Part One: A Child of Fire
3. Aquaman and the Lost Kingdom
4. Silent Night
5. The Family Plan
6. Freelance
7. Migration
8. Sound of Freedom
9. Godzilla Minus One
**Comedy:** #4
1. The Family Plan
2. Wonka
3. Freelance
4. Saltburn
5. Chicken Run: Dawn of the Nugget
6. Trolls Band Together
7. There's Something in the Barn
8. Migration
Please note that some movies may overlap in both genres, such as "The Family Plan" and "Freelance."
- #1 LLM 对
get_top_movies_by_genre进行了两次调用 - #2 内部调用以获取类型 ID
- #3 当前热门动作电影列表
- #4 当前热门喜剧电影列表
请充分探索聊天界面的功能和 TMDB 服务能提供的内容。例如,可以尝试请求电影或电视剧的类型列表。该服务是一个良好的起点,但我们或许还能做得更好,正如下一节所示。
5.6 编写语义服务时的语义思维
我们刚刚看到了将 API 转换为语义服务接口的出色示范。目前,这些函数返回的是当前热门电影和电视剧的名称列表。然而,仅仅返回标题会限制 LLM 自行解析结果的能力。
因此,我们将创建 TMDbService 的第二版(v2),改进为以 JSON 字符串形式返回结果。打开 VS Code 中的 tmdb_v2.py 文件,向下滚动到 get_top_movies_by_genre 函数。
列表 5.25 tmdb_v2.py(get_top_movies_by_genre)
def get_top_movies_by_genre(self, genre: str) -> str:
print_function_call()
genre_id = self.get_movie_genre_id(genre)
if genre_id:
# same code …
return json.dumps(filtered_movies) #1
else:
return ""
- #1 现在以 JSON 字符串形式返回过滤后的电影列表
然后打开 SK_service_chat.py 文件,注释和取消注释列表 5.26 中显示的行。这样将使用 TMDbService 的第二版,该版本以完整 JSON 文档字符串形式输出结果。
列表 5.26 SK_service_chat.py(修改导入)
# from skills.Movies.tmdb import TMDbService #1
from skills.Movies.tmdb_v2 import TMDbService #2
- #1 注释此行
- #2 取消注释此行以使用服务的第二版
重新运行 SK_service_chat.py,稍微修改查询,如下例所示。
列表 5.27 SK_service_chat.py(TMDb_v2 服务输出)
User:> get a list of currently playing movies for the
action genre and only return movies about space #1
Agent:> To find currently playing action movies that are specifically
about space, I will need to manually filter the provided list for those
that have space-related themes in their overview. Here's what fits that
criteria from the list:
1. **Rebel Moon - Part One: A Child of Fire** #2
- Release Date: 2023-12-15
- Overview: When a peaceful colony on the edge of the galaxy finds
itself threatened by the armies of the tyrannical Regent Balisarius,
they dispatch Kora, a young woman with a mysterious past, to seek out
warriors from neighboring planets to help them take a stand.
This is the only movie from the provided list that clearly mentions a
space-related theme in its overview. …
- #1 新查询增加了针对“太空”题材的过滤条件
- #2 LLM 调用服务后,筛选出符合过滤条件的结果
由于语义服务函数现在返回完整的 JSON 电影列表,LLM 可以进行额外的过滤。这正是语义服务的真正威力,允许你通过 LLM 来处理数据。仅返回标题列表是无法体现这种能力的。
本次练习展示了编写语义服务层时需要转变的思维方式。一般来说,应尽可能返回更多信息,充分利用 LLM 独立过滤、排序和转换数据的能力。
下一章,我们将探讨如何使用行为树构建自主代理。
总结
-
代理动作扩展了代理系统(如 ChatGPT)的功能,包括为 ChatGPT 和大型语言模型(LLM)添加插件,使其能够作为动作的代理执行任务。
-
OpenAI 支持在 OpenAI API 会话中定义函数和插件,包括将函数定义添加到 LLM API 调用中,以及理解这些函数如何使 LLM 执行额外动作。
-
语义内核(Semantic Kernel,SK)是微软的开源项目,可用于构建 AI 应用和代理系统,语义插件在定义原生函数和语义函数中起着关键作用。
-
语义函数封装用于调用 LLM 的提示模板或配置文件。
-
原生函数封装执行动作的代码,通常通过 API 或其他接口实现。
-
语义函数可以与其他语义函数或原生函数组合,并在执行阶段相互嵌套。
-
SK 可用于在语义服务层之上构建 GPT 接口,将 API 调用包装成聊天或代理界面的插件。
-
语义服务体现了 LLM 与插件之间的交互,是构建高效 AI 代理的实际实现方式。