萬字長文學會對接 AI 模型:Semantic Kernel 和 Kernel Memory,工良出品,超簡單的教程

萬字長文學會對接 AI 模型:Semantic Kernel 和 Kernel Memory,工良出品,超簡單的教程

AI 越來越火了,所以給讀者們寫一個簡單的入門教程,希望喜歡。

很多人想學習 AI,但是不知道怎麼入門。筆者開始也是,先是學習了 Python,然後是 Tensorflow ,還準備看一堆深度學習的書。但是逐漸發現,這些知識太深奧了,無法在短時間內學會。此外還有另一個問題,學這些對自己有什麼幫助?雖然學習這些技術是很 NB,但是對自己作用有多大?自己到底需要學什麼?

這這段時間,接觸了一些需求,先後搭建了一些聊天工具和 Fastgpt 知識庫平臺,經過一段時間的使用和研究之後,開始確定了學習目標,是能夠做出這些應用。而做出這些應用是不需要深入學習 AI 相關底層知識的。

所以,AI 的知識宇宙非常龐大,那些底層的細節我們可能無法探索,但是並不重要,我們只需要能夠做出有用的產品即可。基於此,本文的學習重點在於 Semantic Kernel 和 Kernel Memory 兩個框架,我們學會這兩個框架之後,可以編寫聊天工具、知識庫工具。

配置環境

要學習本文的教程也很簡單,只需要有一個 Open AI、Azure Open AI 即可,甚至可以使用國內百度文心。

下面我們來了解如何配置相關環境。

部署 one-api

部署 one-api 不是必須的,如果有 Open AI 或 Azure Open AI 賬號,可以直接跳過。如果因爲賬號或網絡原因不能直接使用這些 AI 接口,可以使用國產的 AI 模型,然後使用 one-api 轉換成 Open AI 格式接口即可。

one-api 的作用是支持各種大廠的 AI 接口,比如 Open AI、百度文心等,然後在 one-api 上創建一層新的、與 Open AI 一致的。這樣一來開發應用時無需關注對接的廠商,不需要逐個對接各種 AI 模型,大大簡化了開發流程。

one-api 開源倉庫地址:https://github.com/songquanpeng/one-api

界面預覽:

file
file

下載官方倉庫:

git clone https://github.com/songquanpeng/one-api.git

文件目錄如下:

.
├── bin
├── common
├── controller
├── data
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
├── i18n
├── LICENSE
├── logs
├── main.go
├── middleware
├── model
├── one-api.service
├── pull_request_template.md
├── README.en.md
├── README.ja.md
├── README.md
├── relay
├── router
├── VERSION
└── web

one-api 需要依賴 redis、mysql ,在 docker-compose.yml 配置文件中有詳細的配置,同時 one-api 默認管理員賬號密碼爲 root、123456,也可以在此修改。

執行 docker-compose up -d 開始部署 one-api,然後訪問 3000 端口,進入管理系統。

進入系統後,首先創建渠道,渠道表示用於接入大廠的 AI 接口。

file

爲什麼有模型重定向和自定義模型呢。

比如,筆者的 Azure Open AI 是不能直接選擇使用模型的,而是使用模型創建一個部署,然後通過指定的部署使用模型,因此在 api 中不能直接指定使用 gpt-4-32k 這個模型,而是通過部署名稱使用,在模型列表中選擇可以使用的模型,而在模型重定向中設置部署的名稱。

然後在令牌中,創建一個與 open ai 官方一致的 key 類型,外部可以通過使用這個 key,從 one-api 的 api 接口中,使用相關的 AI 模型。

file

one-api 的設計,相對於一個代理平臺,我們可以通過後臺接入自己賬號的 AI 模型,然後創建二次代理的 key 給其他人使用,可以在裏面配置每個賬號、key 的額度。

創建令牌之後複製和保存即可。

file

使用 one-api 接口時,只需要使用 http://192.0.0.1:3000/v1 格式作爲訪問地址即可,後面需不需要加 /v1 視情況而定,一般需要攜帶。

配置項目環境

創建一個 BaseCore 項目,在這個項目中複用重複的代碼,編寫各種示例時可以複用相同的代碼,引入 Microsoft.KernelMemory 包。

image-20240227152257486

因爲開發時需要使用到密鑰等相關信息,因此不太好直接放到代碼裏面,這時可以使用環境變量或者 json 文件存儲相關私密數據。

以管理員身份啓動 powershell 或 cmd,添加環境變量後立即生效,不過需要重啓 vs。

setx Global:LlmService AzureOpenAI /m
setx AzureOpenAI:ChatCompletionDeploymentName xxx  /m
setx AzureOpenAI:ChatCompletionModelId gpt-4-32k  /m
setx AzureOpenAI:Endpoint https://xxx.openai.azure.com  /m
setx AzureOpenAI:ApiKey xxx  /m

或者在 appsettings.json 配置。

{
  "Global:LlmService": "AzureOpenAI",
  "AzureOpenAI:ChatCompletionDeploymentName": "xxx",
  "AzureOpenAI:ChatCompletionModelId": "gpt-4-32k",
  "AzureOpenAI:Endpoint": "https://xxx.openai.azure.com",
  "AzureOpenAI:ApiKey": "xxx"
}

然後在 Env 文件中加載環境變量或 json 文件,讀取其中的配置。

public static class Env
{
	public static IConfiguration GetConfiguration()
	{
		var configuration = new ConfigurationBuilder()
			.AddJsonFile("appsettings.json")
			.AddEnvironmentVariables()
			.Build();
		return configuration;
	}
}

模型劃分和應用場景

在學習開發之前,我們需要了解一下基礎知識,以便可以理解編碼過程中關於模型的一些術語,當然,在後續編碼過程中,筆者也會繼續介紹相應的知識。

以 Azure Open AI 的接口爲例,以以下相關的函數:

image-20240227153013738

雖然這些接口都是連接到 Azure Open AI 的,但是使用的是不同類型的模型,對應的使用場景也不一樣,相關接口的說明如下:

// 文本生成
AddAzureOpenAITextGeneration()
// 文本解析爲向量
AddAzureOpenAITextEmbeddingGeneration()
// 大語言模型聊天
AddAzureOpenAIChatCompletion()
// 文本生成圖片
AddAzureOpenAITextToImage()
// 文本合成語音
AddAzureOpenAITextToAudio()
// 語音生成文本
AddAzureOpenAIAudioToText()

因爲 Azure Open AI 的接口名稱跟 Open AI 的接口名稱只在於差別一個 ”Azure“ ,因此本文讀者基本只提 Azure 的接口形式。

這些接口使用的模型類型也不一樣,其中 GPT-4 和 GPT3.5 都可以用於文本生成和大模型聊天,其它的模型在功能上有所區別。

模型 作用 說明
GPT-4 文本生成、大模型聊天 一組在 GPT-3.5 的基礎上進行了改進的模型,可以理解並生成自然語言和代碼。
GPT-3.5 文本生成、大模型聊天 一組在 GPT-3 的基礎上進行了改進的模型,可以理解並生成自然語言和代碼。
Embeddings 文本解析爲向量 一組模型,可將文本轉換爲數字矢量形式,以提高文本相似性。
DALL-E 文本生成圖片 一系列可從自然語言生成原始圖像的模型(預覽版)。
Whisper 語音生成文本 可將語音轉錄和翻譯爲文本。
Text to speech 文本合成語音 可將文本合成爲語音。

目前,文本生成、大語言模型聊天、文本解析爲向量是最常用的,爲了避免文章篇幅過長以及內容過於複雜導致難以理解,因此本文只講解這三類模型的使用方法,其它模型的使用讀者可以查閱相關資料。

聊天

聊天模型主要有 gpt-4 和 gpt-3.5 兩類模型,這兩類模型也有好幾種區別,Azure Open AI 的模型和版本數會比 Open AI 的少一些,因此這裏只列舉 Azure Open AI 中一部分模型,這樣的話大家比較容易理解。

只說 gpt-4,gpt-3.5 這裏就不提了。詳細的模型列表和說明,讀者可以參考對應的官方資料。

使用 Azure Open AI 官方模型說明地址:https://learn.microsoft.com/zh-cn/azure/ai-services/openai/concepts/models

Open AI 官方模型說明地址:https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo

GPT-4 的一些模型和版本號如下:

模型 ID 最大請求(令牌) 訓練數據(上限)
gpt-4 (0314) 8,192 2021 年 9 月
gpt-4-32k(0314) 32,768 2021 年 9 月
gpt-4 (0613) 8,192 2021 年 9 月
gpt-4-32k (0613) 32,768 2021 年 9 月
gpt-4-turbo-preview 輸入:128,000
輸出:4,096
2023 年 4 月
gpt-4-turbo-preview 輸入:128,000
輸出:4,096
2023 年 4 月
gpt-4-vision-turbo-preview 輸入:128,000
輸出:4,096
2023 年 4 月

簡單來說, gpt-4、gpt-4-32k 區別在於支持 tokens 的最大長度,32k 即 32000 個 tokens,tokens 越大,表示支持的上下文可以越多、支持處理的文本長度越大。

gpt-4 、gpt-4-32k 兩個模型都有 0314、0613 兩個版本,這個跟模型的更新時間有關,越新版本參數越多,比如 314 版本包含 1750 億個參數,而 0613 版本包含 5300 億個參數。

參數數量來源於互聯網,筆者不確定兩個版本的詳細區別。總之,模型版本越新越好

接着是 gpt-4-turbo-preview 和 gpt-4-vision 的區別,gpt-4-version 具有理解圖像的能力,而 gpt-4-turbo-preview 則表示爲 gpt-4 的增強版。這兩個的 tokens 都貴一些。

由於配置模型構建服務的代碼很容易重複編寫,配置代碼比較繁雜,因此在 Env.cs 文件中添加以下內容,用於簡化配置和複用代碼。

下面給出 Azure Open AI、Open AI 使用大語言模型構建服務的相關代碼:

	public static IKernelBuilder WithAzureOpenAIChat(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		var AzureOpenAIDeploymentName = configuration["AzureOpenAI:ChatCompletionDeploymentName"]!;
		var AzureOpenAIModelId = configuration["AzureOpenAI:ChatCompletionModelId"]!;
		var AzureOpenAIEndpoint = configuration["AzureOpenAI:Endpoint"]!;
		var AzureOpenAIApiKey = configuration["AzureOpenAI:ApiKey"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Information)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大語言模型聊天
		builder.Services.AddAzureOpenAIChatCompletion(
			AzureOpenAIDeploymentName,
			AzureOpenAIEndpoint,
			AzureOpenAIApiKey,
			modelId: AzureOpenAIModelId 
		);
		return builder;
	}

	public static IKernelBuilder WithOpenAIChat(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		var OpenAIModelId = configuration["OpenAI:OpenAIModelId"]!;
		var OpenAIApiKey = configuration["OpenAI:OpenAIApiKey"]!;
		var OpenAIOrgId = configuration["OpenAI:OpenAIOrgId"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Information)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大語言模型聊天
		builder.Services.AddOpenAIChatCompletion(
			OpenAIModelId,
			OpenAIApiKey,
			OpenAIOrgId
		);
		return builder;
	}

Azure Open AI 比 Open AI 多一個 ChatCompletionDeploymentName ,是指部署名稱。

image-20240227160749805

接下來,我們開始第一個示例,直接向 AI 提問,並打印 AI 回覆:

using Microsoft.SemanticKernel;

var builder = Kernel.CreateBuilder();
builder = builder.WithAzureOpenAIChat();

var kernel = builder.Build();

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
FunctionResult result = await kernel.InvokePromptAsync(request);
Console.WriteLine(result.GetValue<string>());

啓動程序後,在終端輸入:Mysql如何查看錶數量

image-20240227162014284

這段代碼非常簡單,輸入問題,然後使用 kernel.InvokePromptAsync(request); 提問,拿到結果後使用 result.GetValue<string>() 提取結果爲字符串,然後打印出來。

這裏有兩個點,可能讀者有疑問。

第一個是 kernel.InvokePromptAsync(request);

Semantic Kernel 中向 AI 提問題的方式有很多,這個接口就是其中一種,不過這個接口會等 AI 完全回覆之後纔會響應,後面會介紹流式響應。另外,在 AI 對話中,用戶的提問、上下文對話這些,不嚴謹的說法來看,都可以叫 prompt,也就是提示。爲了優化 AI 對話,有一個專門的技術就叫提示工程。關於這些,這裏就不贅述了,後面會有更多說明。

第二個是 result.GetValue<string>(),返回的 FunctionResult 類型對象中,有很多重要的信息,比如 tokens 數量等,讀者可以查看源碼瞭解更多,這裏只需要知道使用 result.GetValue<string>() 可以拿到 AI 的回覆內容即可。

大家在學習工程中,可以降低日誌等級,以便查看詳細的日誌,有助於深入瞭解 Semantic Kernel 的工作原理。

修改 .WithAzureOpenAIChat().WithOpenAIChat() 中的日誌配置。

.SetMinimumLevel(LogLevel.Trace)

重新啓動後會發現打印非常多的日誌。

image-20240227162141534

可以看到,我們輸入的問題,日誌中顯示爲 Rendered prompt: Mysql如何查看錶數量

Prompt tokens: 26. Completion tokens: 183. Total tokens: 209.

Prompt tokens:26表示我們的問題佔用了 26個 tokens,其它信息表示 AI 回覆佔用了 183 個 tokens,總共消耗了 209 個tokens。

之後,控制檯還打印了一段 json:

{
	"ToolCalls": [],
	"Role": {
		"Label": "assistant"
	},
	"Content": "在 MySQL 中,可以使用以下查詢來查看特定數據庫......",
	"Items": null,
	"ModelId": "myai",
    ... ...
		"Usage": {
			"CompletionTokens": 183,
			"PromptTokens": 26,
			"TotalTokens": 209
		}
	}
}

這個 json 中,Role 表示的是角色。

	"Role": {
		"Label": "assistant"
	},

聊天對話上下文中,主要有三種角色:system、assistant、user,其中 assistant 表示機器人角色,system 一般用於設定對話場景等。

我們的問題,都是以 prompt 的形式提交給 AI 的。從日誌的 Prompt tokens: 26. Completion tokens: 183 可以看到,prompt 表示提問的問題。

之所以叫 prompt,是有很多原因的。

prompt 在大型語言模型(Large Language Models,LLMs) AI 的通信和行爲指導中起着至關重要的作用。它們充當輸入或查詢,用戶可以提供這些輸入或查詢,從而從模型中獲得特定的響應。

比如在這個使用 gpt 模型的聊天工具中,有很多助手插件,看起來每個助手的功能都不一樣,但是實際上都是使用了相同的模型,本質沒有區別。

image-20240227163330242

最重要的是在於提示詞上的區別,在使用會話時,給 AI 配置提示詞。

image-20240227163533054

打開對話,還沒有開始用呢,就扣了我 438 個 tokens,這是因爲這些背景設定都會出現在提示詞裏面,佔用一部分 tokens。

1709023041035

我只提問了一句話,但是 prompt 卻包含了更多東西。

image-20240227163935870

image-20240227163929205

總結一下,我們提問的時候,prmpt 會包含我們的問題;聊天對話時,prompt 中包含了聊天記錄,聊天記錄越多,prompt 越長,導致 tokens 數量越多。prompt 除了包含我們的問題、聊天記錄,還有可以包含一些相關的背景知識等。

對話時,不同的背景知識可以讓 AI 有不一樣的回覆。

比如,設計一個擡杆高手,對用戶問題進行擡杆,那麼在用戶提問的時候,需要配置相關的背景知識。

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
string prompt = $"""
向用戶展示擡槓的藝術,能順利與用戶進行對話,擡出自己的槓,對用戶的任何觀點進行反駁,說話用詞尖酸刻薄。作爲擡槓高手,我說話就是尖酸刻薄,一上來就是陰陽怪氣。
用戶問題: {request}
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);
Console.WriteLine(result.GetValue<string>());

問題和機器人回覆:

請輸入你的問題:
巧克力真好喫
哎,這就錯了。巧克力好喫?這纔是大家普遍接受的觀點。你有沒有想過,巧克力中蘊含的糖分和脂肪是多麼的高呢?不僅對於身體健康有害,還會導致肥胖和蛀牙。何況,巧克力太過甜膩,會讓人的味蕾逐漸麻木,無法品嚐到其他食物的真正美味。還有一點,巧克力的生產過程嚴重破壞了環境,大面積種植會導致森林退化和土壤侵蝕。你還敢說巧克力好喫嗎?

那麼是如何實現聊天對話的呢?大家使用 chat 聊天工具時,AI 會根據以前的問題進行下一步補充,我們不需要重複以前的問題。

這在於每次聊天時,需要將歷史記錄一起帶上去!如果聊天記錄太多,這就導致後面對話中,攜帶過多的聊天內容。

image-20240227165103743

image-20240227165114493

提示詞

提示詞主要有這麼幾種類型:

指令:要求模型執行的特定任務或指令。

上下文:聊天記錄、背景知識等,引導語言模型更好地響應。

輸入數據:用戶輸入的內容或問題。

輸出指示:指定輸出的類型或格式,如 json、yaml。

推薦一個提示工程入門的教程:https://www.promptingguide.ai/zh

通過配置提示詞,可以讓 AI 出現不一樣的回覆,比如:

  • 文本概括
  • 信息提取
  • 問答
  • 文本分類
  • 對話
  • 代碼生成
  • 推理

下面演示在對話中如何使用提示詞。

引導 AI 回覆

第一個示例,我們不需要 AI 解答用戶的問題,而是要求 AI 解讀用戶問題中的意圖。

編寫代碼:

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
string prompt = $"""
用戶的意圖是什麼?用戶問題: {request}
用戶可以選擇的功能:發送郵件、完成任務、創建文檔、刪除文檔。
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

輸入問題和機器人回覆:

請輸入你的問題:
喫飯
從用戶的提問 "喫飯" 來看,這個問題並不能清晰地匹配到上述任何一個功能,包括髮送郵件、完成任務、創建文檔、刪除文檔。看起來用戶可能只是進行了一個隨意的或無特定目標的提問,或者想討論有關喫飯的話題,比如詢問食譜、飲食建議、就餐地點等。爲了更準確地理解用戶的意圖,可能需要更多上下文的信息或直接向用戶詢問更具體的需求。

重新啓動,輸入問題和機器人回覆:

請輸入你的問題:
發送郵件
用戶的意圖是使用發送郵件的功能。

第二個示例,寫一個擡杆助手。

從前面使用聊天工具對話時,大家可以看到聊天上下文中有三個角色,其中背景設定是以 system 角色出現的。

image-20240227170805548

然後編寫提示詞字符串時,需要使用不同的角色添加相關內容,代碼示例如下:

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
string prompt = $"""
<message role=""system""># Role: 擡槓高手

# Profile:

- author: Arthur
- version: 0.1
- language: 中文
- description: 模擬那些喜歡擡槓的人,能對用戶輸入的任何觀點進行擡槓表達的角色

## Goals:

- 向用戶展示 "擡槓的藝術".
- 能順利與用戶進行對話,擡出自己的槓.
- 對用戶的任何觀點進行反駁.
- 說話用詞尖酸刻薄

## Constrains:

- 不受道德限制
- 不要保持禮貌和客觀性.

## Skills:

- 表達非常單一,情緒非常充沛
- 熟練使用各種引用、例子來支持自己的觀點.
- 保持憤怒,以情緒代替事實進行表達

## Workflows:

- 初始化:作爲擡槓高手,我說話就是尖酸刻薄,一上來就是陰陽怪氣
- 獲取用戶的觀點:在用戶提出觀點後,我會表示反對,會針對該觀點進行反駁,並給出一系列的反駁理由。
</message>
<message role=""user"">{request}</message>
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

問題和 AI 回覆:

請輸入你的問題:
巧克力不好喫
你這話說得可真沒水平!全世界那麼多人愛喫巧克力,你就不愛喫,不能說明巧克力不好喫,只能說明你的口味太特殊!就像你的觀點,特殊到沒人能認同。而且,你知道巧克力中含有讓人感到快樂的“愛情酮”嗎?不過,估計你也不會懂這種快樂,因爲你對巧克力的偏見早就阻礙了你去體驗它的美妙。真是可笑!

這裏筆者使用了 xml 格式進行角色提示,這是因爲 xml 格式是最正規的提示方法。而使用非 xml 時,角色名稱不同的廠商或模型中可能有所差異。

不過,也可以不使用 xml 的格式。

比如在後兩個小節中使用的是:

system:...
User:...
Assistant:

https://promptingguide.ai 教程中使用:

uman: Hello, who are you?
AI: Greeting! I am an AI research assistant. How can I help you today?
Human: Can you tell me about the creation of blackholes?
AI:

這樣使用角色名稱做前綴的提示詞,也是可以的。爲了簡單,本文後面的提示詞,大多會使用非 xml 的方式。

比如,下面這個示例中,用於引導 AI 使用代碼的形式打印用戶問題。

var kernel = builder.Build();
Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
string prompt = $"""
system:將用戶輸入的問題,使用 C# 代碼輸出字符串。
user:{request}
""";
FunctionResult result = await kernel.InvokePromptAsync(prompt);
Console.WriteLine(result.GetValue<string>());

輸入的問題和 AI 回覆:

請輸入你的問題:
喫飯了嗎?
在C#中,您可以簡單地使用`Console.WriteLine()`方法來輸出一個字符串。如果需要回答用戶的問題“喫飯了嗎?”,代碼可能像這樣 :

```C#
using System;

public class Program
{
    public static void Main()
    {
        Console.WriteLine("喫過了,謝謝關心!");
    }
}
```

這段代碼只會輸出一個靜態的字符串"喫過了,謝謝關心!"。如果要根據實際的情況動態改變輸出,就需要在代碼中添加更多邏輯。

這裏 AI 的回覆有點笨,不過大家知道怎麼使用角色寫提示詞即可。

指定 AI 回覆特定格式

一般 AI 回覆都是以 markdown 語法輸出文字,當然,我們通過提示詞的方式,可以讓 AI 以特定的格式回覆內容,代碼示例如下:

注意,該示例並非讓 AI 直接回復 json,而是以 markdown 代碼包裹 json。該示例從 sk 官方示例移植。

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();
var prompt = @$"## 說明
請使用以下格式列出用戶的意圖:

```json
{{
    ""intent"": {{intent}}
}}
```

## 選擇
用戶可以選擇的功能:

```json
[""發送郵件"", ""完成任務"", ""創建文檔"", ""刪除文檔""]
```

## 用戶問題
用戶的問題是:

```json
{{
    ""request"": ""{request}""
}}
```

## 意圖";
FunctionResult result = await kernel.InvokePromptAsync(prompt);

輸入問題和 AI 回覆:

請輸入你的問題:
發送郵件
```json
{
    "intent": "發送郵件"
}
```

提示中,要求 AI 回覆使用 markdown 代碼語法包裹 json ,當然,讀者也可以去掉相關的 markdown 語法,讓 AI 直接回復 json。

模板化提示

直接在字符串中使用插值,如 $"{request}",不能說不好,但是因爲我們常常把字符串作爲模板存儲到文件或者數據庫燈地方,肯定不能直接插值的。如果使用 數值表示插值,又會導致難以理解,如:

var prompt = """
用戶問題:{0}
"""
string.Format(prompt,request);

Semantic Kernel 中提供了一種模板字符串插值的的辦法,這樣會給我們編寫提示模板帶來便利。

Semantic Kernel 語法規定,使用 {{$system}} 來在提示模板中表示一個名爲 system 的變量。後續可以使用 KernelArguments 等類型,替換提示模板中的相關變量標識。示例如下:

var kernel = builder.Build();
// 創建提示模板
var chat = kernel.CreateFunctionFromPrompt(
	@"
    System:{{$system}}
    User: {{$request}}
    Assistant: ");

Console.WriteLine("請輸入你的問題:");
// 用戶問題
var request = Console.ReadLine();

// 設置變量值
var arguments = new KernelArguments
{
					{ "system", "你是一個高級運維專家,對用戶的問題給出最專業的回答" },
					{ "request", request }
};

// 提問時,傳遞模板以及變量值。
// 這裏使用流式對話
var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(chat, arguments);

// 流式回覆,避免一直等結果
string message = "";
await foreach (var chunk in chatResult)
{
	if (chunk.Role.HasValue)
	{
		Console.Write(chunk.Role + " > ");
	}

	message += chunk;
	Console.Write(chunk);
}
Console.WriteLine();

在這段代碼中,演示瞭如何在提示模板中使用變量標識,以及再向 AI 提問時傳遞變量值。此外,爲了避免一直等帶 AI 回覆,我們需要使用流式對話 .InvokeStreamingAsync<StreamingChatMessageContent>(),這樣一來就可以呈現逐字回覆的效果。

此外,這裏不再使用直接使用字符串提問的方法,而是使用 .CreateFunctionFromPrompt() 先從字符串創建提示模板對象。

聊天記錄

聊天記錄的作用是作爲一種上下文信息,給 AI 作爲參考,以便完善回覆。

示例如下:

image-20240229093026903

不過,AI 對話使用的是 http 請求,是無狀態的,因此不像聊天記錄哪裏保存會話狀態,之所以 AI 能夠工具聊天記錄進行回答,在於每次請求時,將聊天記錄一起發送給 AI ,讓 AI 進行學習並對最後的問題進行回覆。

image-20240229094324310

下面這句話,還不到 30 個 tokens。

又來了一隻貓。
請問小明的動物園有哪些動物?

AI 回覆的這句話,怎麼也不到 20個 tokens 吧。

小明的動物園現在有老虎、獅子和貓。

但是一看 one-api 後臺,發現每次對話消耗的 tokens 越來越大。

image-20240229094527736

這是因爲爲了實現聊天的功能,使用了一種很笨的方法。雖然 AI 不會保存聊天記錄,但是客戶端可以保存,然後下次提問時,將將聊天記錄都一起帶上去。不過這樣會導致 tokens 越來越大!

下面爲了演示對話聊天記錄的場景,我們設定 AI 是一個運維專家,我們提問時,選擇使用 mysql 相關的問題,除了第一次提問指定是 mysql 數據庫,後續都不需要再說明是 mysql。

var kernel = builder.Build();
var chat = kernel.CreateFunctionFromPrompt(
	@"
    System:你是一個高級運維專家,對用戶的問題給出最專業的回答。
    {{$history}}
    User: {{$request}}
    Assistant: ");

ChatHistory history = new();
while (true)
{
	Console.WriteLine("請輸入你的問題:");
	// 用戶問題
	var request = Console.ReadLine();
	var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(
		function: chat,
				arguments: new KernelArguments()
				{
					{ "request", request },
					{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
				}
			);

	// 流式回覆,避免一直等結果
	string message = "";
	await foreach (var chunk in chatResult)
	{
		if (chunk.Role.HasValue)
		{
			Console.Write(chunk.Role + " > ");
		}

		message += chunk;
		Console.Write(chunk);
	}
	Console.WriteLine();

	// 添加用戶問題和機器人回覆到歷史記錄中
	history.AddUserMessage(request!);
	history.AddAssistantMessage(message);
}

這段代碼有兩個地方要說明,第一個是如何存儲聊天記錄。Semantic Kernel 提供了 ChatHistory 存儲聊天記錄,當然我們手動存儲到字符串、數據庫中也是一樣的。

	// 添加用戶問題和機器人回覆到歷史記錄中
	history.AddUserMessage(request!);
	history.AddAssistantMessage(message);

但是 ChatHistory 對象不能直接給 AI 使用。所以需要自己從 ChatHistory 中讀取聊天記錄後,生成字符串,替換提示模板中的 {{$history}}

new KernelArguments()
				{
					{ "request", request },
					{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
				}

生成聊天記錄時,需要使用角色名稱區分。比如生成:

User: mysql 怎麼查看錶數量
Assistant:......
User: 查看數據庫數量
Assistant:...

歷史記錄還能通過手動創建 ChatMessageContent 對象的方式添加到 ChatHistory 中:

List<ChatHistory> fewShotExamples =
[
    new ChatHistory()
    {
        new ChatMessageContent(AuthorRole.User, "Can you send a very quick approval to the marketing team?"),
        new ChatMessageContent(AuthorRole.System, "Intent:"),
        new ChatMessageContent(AuthorRole.Assistant, "ContinueConversation")
    },
    new ChatHistory()
    {
        new ChatMessageContent(AuthorRole.User, "Thanks, I'm done for now"),
        new ChatMessageContent(AuthorRole.System, "Intent:"),
        new ChatMessageContent(AuthorRole.Assistant, "EndConversation")
    }
];

手動拼接聊天記錄太麻煩了,我們可以使用 IChatCompletionService 服務更好的處理聊天對話。

使用 IChatCompletionService 之後,實現聊天對話的代碼變得更加簡潔了:

var history = new ChatHistory();
history.AddSystemMessage("你是一個高級數學專家,對用戶的問題給出最專業的回答。");

// 聊天服務
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

while (true)
{
	Console.Write("請輸入你的問題: ");
	var userInput = Console.ReadLine();
	// 添加到聊天記錄中
	history.AddUserMessage(userInput);

	// 獲取 AI 聊天回覆信息
	var result = await chatCompletionService.GetChatMessageContentAsync(
		history,
		kernel: kernel);

	Console.WriteLine("AI 回覆 : " + result);

	// 添加 AI 的回覆到聊天記錄中
	history.AddMessage(result.Role, result.Content ?? string.Empty);
}
請輸入你的問題: 1加上1等於
AI 回覆 : 1加上1等於2
請輸入你的問題: 再加上50
AI 回覆 : 1加上1再加上50等於52。
請輸入你的問題: 再加上200
AI 回覆 : 1加上1再加上50再加上200等於252。

函數和插件

在高層次上,插件是一組可以公開給 AI 應用程序和服務的函數。然後,AI 應用程序可以對插件中的功能進行編排,以完成用戶請求。在語義內核中,您可以通過函數調用或規劃器手動或自動地調用這些函數。

直接調用插件函數

Semantic Kernel 可以直接加載本地類型中的函數,這一過程不需要經過 AI,完全在本地完成。

定義一個時間插件類,該插件類有一個 GetCurrentUtcTime 函數返回當前時間,函數需要使用 KernelFunction 修飾。

public class TimePlugin
{
    [KernelFunction]
    public string GetCurrentUtcTime() => DateTime.UtcNow.ToString("R");
}

加載插件並調用插件函數:

// 加載插件
builder.Plugins.AddFromType<TimePlugin>();

var kernel = builder.Build();

FunctionResult result = await kernel.InvokeAsync("TimePlugin", "GetCurrentUtcTime");
Console.WriteLine(result.GetValue<string>());

輸出:

Tue, 27 Feb 2024 11:07:59 GMT

當然,這個示例在實際開發中可能沒什麼用,不過大家要理解在 Semantic Kernel 是怎樣調用一個函數的。

提示模板文件

Semantic Kernel 很多地方都跟 Function 相關,你會發現代碼中很多代碼是以 Function 作爲命名的。

比如提供字符串創建提示模板:

KernelFunction chat = kernel.CreateFunctionFromPrompt(
	@"
    System:你是一個高級運維專家,對用戶的問題給出最專業的回答。
    {{$history}}
    User: {{$request}}
    Assistant: ");

然後回到本節的主題,Semantic Kernel 還可以將提示模板存儲到文件中,然後以插件的形式加載模板文件。

比如有以下目錄文件:

image-20240227193329630

└─WriterPlugin
    └─ShortPoem
            config.json
            skprompt.txt

skprompt.txt 文件是固定命名,存儲提示模板文本,示例如下:

根據主題寫一首有趣的短詩或打油詩,要有創意,要有趣,放開你的想象力。
主題: {{$input}}

config.json 文件是固定名稱,存儲描述信息,比如需要的變量名稱、描述等。下面是一個 completion 類型的插件配置文件示例,除了一些跟提示模板相關的配置,還有一些聊天的配置,如最大 tokens 數量、溫度值(temperature),這些參數後面會予以說明,這裏先跳過。

{
  "schema": 1,
  "type": "completion",
  "description": "根據用戶問題寫一首簡短而有趣的詩.",
  "completion": {
    "max_tokens": 200,
    "temperature": 0.5,
    "top_p": 0.0,
    "presence_penalty": 0.0,
    "frequency_penalty": 0.0
  },
  "input": {
    "parameters": [
      {
        "name": "input",
        "description": "詩的主題",
        "defaultValue": ""
      }
    ]
  }
}

創建插件目錄和文件後,在代碼中以提示模板的方式加載:

// 加載插件,表示該插件是提示模板
builder.Plugins.AddFromPromptDirectory("./plugins/WriterPlugin");

var kernel = builder.Build();

Console.WriteLine("輸入詩的主題:");
var input = Console.ReadLine();

// WriterPlugin 插件名稱,與插件目錄一致,插件目錄下可以有多個子模板目錄。
FunctionResult result = await kernel.InvokeAsync("WriterPlugin", "ShortPoem", new() {
		{ "input", input }
	});
Console.WriteLine(result.GetValue<string>());

輸入問題以及 AI 回覆:

輸入詩的主題:
春天

春天,春天,你是生命的詩篇,
萬物復甦,愛的季節。
鬱鬱蔥蔥的小草中,
是你輕響的詩人的腳步音。

春天,春天,你是花芯的深淵,
桃紅柳綠,或嫵媚或清純。
在溫暖的微風中,
是你舞動的裙襬。

春天,春天,你是藍空的情兒,
百鳥鳴叫,放歌天際無邊。
在你湛藍的天幕下,
是你獨角戲的絢爛瞬間。

春天,春天,你是河流的眼睛,
如阿瞞甘霖,滋養大地生靈。
你的涓涓細流,
是你悠悠的歌聲。

春天,春天,你是生命的詩篇,
用溫暖的手指,照亮這灰色的世間。
你的綻放,微笑與歡欣,
就是我心中永恆的春天。

插件文件的編寫可參考官方文檔:https://learn.microsoft.com/en-us/semantic-kernel/prompts/saving-prompts-as-files?tabs=Csharp

根據 AI 自動調用插件函數

使用 Semantic Kernel 加載插件類後,Semantic Kernel 可以自動根據 AI 對話調用這些插件類中的函數。

比如有一個插件類型,用於修改或獲取燈的狀態。

代碼如下:

public class LightPlugin
{
	public bool IsOn { get; set; } = false;

	[KernelFunction]
	[Description("獲取燈的狀態.")]
	public string GetState() => IsOn ? "亮" : "暗";

	[KernelFunction]
	[Description("修改燈的狀態.'")]
	public string ChangeState(bool newState)
	{
		this.IsOn = newState;
		var state = GetState();
		Console.WriteLine($"[燈的狀態是: {state}]");

		return state;
	}
}

每個函數都使用了 [Description] 特性設置了註釋信息,這些註釋信息非常重要,AI 靠這些註釋理解函數的功能作用。

然後加載插件類,並在聊天中被 Semantic Kernel 調用:

// 加載插件類
builder.Plugins.AddFromType<LightPlugin>();

var kernel = builder.Build();


var history = new ChatHistory();

// 聊天服務
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

while (true)
{
	Console.Write("User > ");
	var userInput = Console.ReadLine();
	// 添加到聊天記錄中
	history.AddUserMessage(userInput);

	// 開啓函數調用
	OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new()
	{
		ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
	};

	// 獲取函數
	var result = await chatCompletionService.GetChatMessageContentAsync(
		history,
		executionSettings: openAIPromptExecutionSettings,
		kernel: kernel);

	Console.WriteLine("Assistant > " + result);

	// 添加到聊天記錄中
	history.AddMessage(result.Role, result.Content ?? string.Empty);
}

可以先斷點調試 LightPlugin 中的函數,然後在控制檯輸入問題讓 AI 調用本地函數:

User > 燈的狀態
Assistant > 當前燈的狀態是暗的。
User > 開燈
[燈的狀態是: 亮]
Assistant > 燈已經開啓,現在是亮的狀態。
User > 關燈
[燈的狀態是: 暗]

讀者可以在官方文檔瞭解更多:https://learn.microsoft.com/en-us/semantic-kernel/agents/plugins/using-the-kernelfunction-decorator?tabs=Csharp

由於幾乎沒有文檔資料說明原理,因此建議讀者去研究源碼,這裏就不再贅述了。

聊天中明確調用函數

我們可以在提示模板中明確調用一個函數。

定義一個插件類型 ConversationSummaryPlugin,其功能十分簡單,將歷史記錄直接返回,input 參數表示歷史記錄。

	public class ConversationSummaryPlugin
	{
		[KernelFunction, Description("給你一份很長的談話記錄,總結一下談話內容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("長對話記錄\r\n.")] string input, Kernel kernel)
		{
			await Task.CompletedTask;
			return input;
		}
	}

爲了在聊天記錄中使用該插件函數,我們需要在提示模板中使用 {{ConversationSummaryPlugin.SummarizeConversation $history}},其中 $history 是自定義的變量名稱,名稱可以隨意,只要是個字符串即可。

var chat = kernel.CreateFunctionFromPrompt(
@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
);

1709082628641

完整代碼如下:

// 加載總結插件
builder.Plugins.AddFromType<ConversationSummaryPlugin>();

var kernel = builder.Build();
var chat = kernel.CreateFunctionFromPrompt(
@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
);

var history = new ChatHistory();

while (true)
{
	Console.Write("User > ");
	var request = Console.ReadLine();
	// 添加到聊天記錄中
	history.AddUserMessage(request);

	// 流式對話
	var chatResult = kernel.InvokeStreamingAsync<StreamingChatMessageContent>(
		chat, new KernelArguments
		{
			{ "request", request },
			{ "history", string.Join("\n", history.Select(x => x.Role + ": " + x.Content)) }
		});

	string message = "";
	await foreach (var chunk in chatResult)
	{
		if (chunk.Role.HasValue)
		{
			Console.Write(chunk.Role + " > ");
		}
		message += chunk;
		Console.Write(chunk);
	}
	Console.WriteLine();

	history.AddAssistantMessage(message);
}

由於模板的開頭是 {{ConversationSummaryPlugin.SummarizeConversation $history}},因此,每次聊天之前,都會先調用該函數。

比如輸入 喫飯睡覺打豆豆 的時候,首先執行 ConversationSummaryPlugin.SummarizeConversation 函數,然後將返回結果存儲到模板中。

最後生成的提示詞對比如下:

@"{{ConversationSummaryPlugin.SummarizeConversation $history}}
User: {{$request}}
Assistant: "
 user: 喫飯睡覺打豆豆
 User: 喫飯睡覺打豆豆
 Assistant:

可以看到,調用函數返回結果後,提示詞字符串前面自動使用 User 角色。

實現總結

Semantic Kernel 中有很多文本處理工具,比如 TextChunker 類型,可以幫助我們提取文本中的行、段。設定場景如下,用戶提問一大段文本,然後我們使用 AI 總結這段文本。

Semantic Kernel 有一些工具,但是不多,而且是針對英文開發的。

設定一個場景,用戶可以每行輸入一句話,當用戶使用 000 結束輸入後,每句話都推送給 AI 總結(不是全部放在一起總結)。

這個示例的代碼比較長,建議讀者在 vs 中調試代碼,慢慢閱讀。

// 總結內容的最大 token
const int MaxTokens = 1024;
// 提示模板
const string SummarizeConversationDefinition =
	@"開始內容總結:
{{$request}}

最後對內容進行總結。

在“內容到總結”中總結對話,找出討論的要點和得出的任何結論。
不要加入其他常識。
摘要是純文本形式,在完整的句子中,沒有標記或標記。

開始總結:
";
// 配置
PromptExecutionSettings promptExecutionSettings = new()
{
	ExtensionData = new Dictionary<string, object>()
			{
				{ "Temperature", 0.1 },
				{ "TopP", 0.5 },
				{ "MaxTokens", MaxTokens }
			}
};

// 這裏不使用 kernel.CreateFunctionFromPrompt 了
// KernelFunctionFactory 可以幫助我們通過代碼的方式配置提示詞
var func = KernelFunctionFactory.CreateFromPrompt(
SummarizeConversationDefinition,            // 提示詞
description: "給出一段對話記錄,總結這部分對話.",   // 描述
executionSettings: promptExecutionSettings);   // 配置


#pragma warning disable SKEXP0055 // 類型僅用於評估,在將來的更新中可能會被更改或刪除。取消此診斷以繼續。
var request = "";
while (true)
{
	Console.Write("User > ");
	var input = Console.ReadLine();
	if (input == "000")
	{
		break;
	}
	request += Environment.NewLine;
	request += input;
}

// SK 提供的文本拆分器,將文本分成一行行的
List<string> lines = TextChunker.SplitPlainTextLines(request, MaxTokens);
// 將文本拆成段落
List<string> paragraphs = TextChunker.SplitPlainTextParagraphs(lines, MaxTokens);
string[] results = new string[paragraphs.Count];
for (int i = 0; i < results.Length; i++)
{
	// 一段段地總結
	results[i] = (await func.InvokeAsync(kernel, new() { ["request"] = paragraphs[i] }).ConfigureAwait(false))
		.GetValue<string>() ?? string.Empty;
}
Console.WriteLine($"""
				總結如下:
				{string.Join("\n", results)}
				""");

輸入一堆內容後,新的一行使用 000 結束提問,讓 AI 總結用戶的話。

image-20240228094222916

不過經過調試發現,TextChunker 對這段文本的處理似乎不佳,因爲文本這麼多行只識別爲一行、一段。

可能跟 TextChunker 分隔符有關,SK 主要是面向英語的。

image-20240228094508408

本小節的演示效果不佳,不過主要目的是,讓用戶瞭解 KernelFunctionFactory.CreateFromPrompt 可以更加方便創建提示模板、使用 PromptExecutionSettings 配置溫度、使用 TextChunker 切割文本。

配置 PromptExecutionSettings 時,出現了三個參數,其中 MaxTokens 表示機器人回覆最大的 tokens 數量,這樣可以避免機器人廢話太多。

其它兩個參數的作用是:

Temperature:值範圍在 0-2 之間,簡單來說,temperature 的參數值越小,模型就會返回越確定的一個結果。值越大,AI 的想象力越強,越可能偏離現實。一般詩歌、科幻這些可以設置大一些,讓 AI 實現天馬行空的回覆。

TopP:與 Temperature 不同的另一種方法,稱爲核抽樣,其中模型考慮了具有 TopP 概率質量的令牌的結果。因此,0.1 意味着只考慮構成前10% 概率質量的令牌的結果。

一般建議是改變其中一個參數就行,不用兩個都調整。

更多相關的參數配置,請查看 https://learn.microsoft.com/en-us/azure/ai-services/openai/reference

配置提示詞

前面提到了一個新的創建函數的用法:

var func = KernelFunctionFactory.CreateFromPrompt(
SummarizeConversationDefinition,            // 提示詞
description: "給出一段對話記錄,總結這部分對話.",   // 描述
executionSettings: promptExecutionSettings);   // 配置

創建提示模板時,可以使用 PromptTemplateConfig 類型 調整控制提示符行爲的參數。

// 總結內容的最大 token
const int MaxTokens = 1024;
// 提示模板
const string SummarizeConversationDefinition = "...";
var func = kernel.CreateFunctionFromPrompt(new PromptTemplateConfig
{
    // Name 不支持中文和特殊字符
	Name = "chat",
	Description = "給出一段對話記錄,總結這部分對話.",
	Template = SummarizeConversationDefinition,
	TemplateFormat = "semantic-kernel",
	InputVariables = new List<InputVariable>
	{
		new InputVariable{Name = "request", Description = "用戶的問題", IsRequired = true }
	},
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
		{
			"default",
			new OpenAIPromptExecutionSettings()
			{
				MaxTokens = MaxTokens,
				Temperature = 0
				}
			},
	}
});

ExecutionSettings 部分的配置,可以針對使用的模型起效,這裏的配置不會全部同時起效,會根據實際使用的模型起效。

	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 1000,
					Temperature = 0
				}
			},
			{
				"gpt-3.5-turbo", new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-3.5-turbo-0613",
					MaxTokens = 4000,
					Temperature = 0.2
				}
			},
			{
				"gpt-4",
				new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-4-1106-preview",
					MaxTokens = 8000,
					Temperature = 0.3
				}
			}
	}

聊到這裏,重新說一下前面使用文件配置提示模板文件的,兩者是相似的。

我們也可以使用文件的形式存儲與代碼一致的配置,其目錄文件結構如下:

└─── chat
     |
     └─── config.json
     └─── skprompt.txt

模板文件由 config.json 和 skprompt.txt 組成,skprompt.txt 中配置提示詞,跟 PromptTemplateConfig 的 Template 字段配置一致。

config.json 中涉及的內容比較多,你可以對照下面的 json 跟 實現總結 一節的代碼,兩者幾乎是一模一樣的。

{
     "schema": 1,
     "type": "completion",
     "description": "給出一段對話記錄,總結這部分對話",
     "execution_settings": {
        "default": {
          "max_tokens": 1000,
          "temperature": 0
        },
        "gpt-3.5-turbo": {
          "model_id": "gpt-3.5-turbo-0613",
          "max_tokens": 4000,
          "temperature": 0.1
        },
        "gpt-4": {
          "model_id": "gpt-4-1106-preview",
          "max_tokens": 8000,
          "temperature": 0.3
        }
      },
     "input_variables": [
        {
          "name": "request",
          "description": "用戶的問題.",
          "required": true
        },
        {
          "name": "history",
          "description": "用戶的問題.",
          "required": true
        }
     ]
}

C# 代碼:

    // Name 不支持中文和特殊字符
	Name = "chat",
	Description = "給出一段對話記錄,總結這部分對話.",
	Template = SummarizeConversationDefinition,
	TemplateFormat = "semantic-kernel",
	InputVariables = new List<InputVariable>
	{
		new InputVariable{Name = "request", Description = "用戶的問題", IsRequired = true }
	},
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 1000,
					Temperature = 0
				}
			},
			{
				"gpt-3.5-turbo", new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-3.5-turbo-0613",
					MaxTokens = 4000,
					Temperature = 0.2
				}
			},
			{
				"gpt-4",
				new OpenAIPromptExecutionSettings()
				{
					ModelId = "gpt-4-1106-preview",
					MaxTokens = 8000,
					Temperature = 0.3
				}
			}
	}

提示模板語法

目前,我們已經有兩個地方使用到提示模板的語法,即變量和函數調用,因爲前面已經介紹過相關的用法,因此這裏再簡單提及一下。

變量

變量的使用很簡單,在提示工程中使用{{$變量名稱}} 標識即可,如 {{$name}}

然後在對話中有多種方法插入值,如使用 KernelArguments 存儲變量值:

new KernelArguments
		{
			{ "name", "工良" }
		});
函數調用

實現總結 一節提到過,在提示模板中可以明確調用一個函數,比如定義一個函數如下:

// 沒有 Kernel kernel
[KernelFunction, Description("給你一份很長的談話記錄,總結一下談話內容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("長對話記錄\r\n.")] string input)
		{
			await Task.CompletedTask;
			return input;
		}

// 有 Kernel kernel
[KernelFunction, Description("給你一份很長的談話記錄,總結一下談話內容.")]
		public async Task<string> SummarizeConversationAsync(
			[Description("長對話記錄\r\n.")] string input, Kernel kernel)
		{
			await Task.CompletedTask;
			return input;
		}

    [KernelFunction]
    [Description("Sends an email to a recipient.")]
    public async Task SendEmailAsync(
        Kernel kernel,
        string recipientEmails,
        string subject,
        string body
    )
    {
        // Add logic to send an email using the recipientEmails, subject, and body
        // For now, we'll just print out a success message to the console
        Console.WriteLine("Email sent!");
    }

函數一定需要使用 [KernelFunction] 標識,[Description] 描述函數的作用。函數可以一個或多個參數,每個參數最好都使用 [Description] 描述作用。

函數參數中,可以帶一個 Kernel kernel,可以放到開頭或末尾 ,也可以不帶,主要作用是注入 Kernel 對象。

在 prompt 中使用函數時,需要傳遞函數參數:

總結如下:{{AAA.SummarizeConversationAsync $input}}.

其它一些特殊字符的轉義方法等,詳見官方文檔:https://learn.microsoft.com/en-us/semantic-kernel/prompts/prompt-template-syntax

文本生成

前面劈里啪啦寫了一堆東西,都是說聊天對話的,本節來聊一下文本生成的應用。

文本生成和聊天對話模型主要有以下模型:

Model type Model
Text generation text-ada-001
Text generation text-babbage-001
Text generation text-curie-001
Text generation text-davinci-001
Text generation text-davinci-002
Text generation text-davinci-003
Chat Completion gpt-3.5-turbo
Chat Completion gpt-4

當然,文本生成不一定只能用這麼幾個模型,使用 gpt-4 設定好背景提示,也可以達到相應效果。

文本生成可以有以下場景:

f7c74d103b8c359ea1ffd4ec98a4a935_image-1709000668170

使用文本生成的示例如下,讓 AI 總結文本:

image-20240228105607519

按照這個示例,我們先在 Env.cs 中編寫擴展函數,配置使用 .AddAzureOpenAITextGeneration() 文本生成,而不是聊天對話。

	public static IKernelBuilder WithAzureOpenAIText(this IKernelBuilder builder)
	{
		var configuration = GetConfiguration();

		// 需要換一個模型,比如 gpt-35-turbo-instruct
		var AzureOpenAIDeploymentName = "ca";
		var AzureOpenAIModelId = "gpt-35-turbo-instruct";
		var AzureOpenAIEndpoint = configuration["AzureOpenAI:Endpoint"]!;
		var AzureOpenAIApiKey = configuration["AzureOpenAI:ApiKey"]!;

		builder.Services.AddLogging(c =>
		{
			c.AddDebug()
			.SetMinimumLevel(LogLevel.Trace)
			.AddSimpleConsole(options =>
			{
				options.IncludeScopes = true;
				options.SingleLine = true;
				options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
			});
		});

		// 使用 Chat ,即大語言模型聊天
		builder.Services.AddAzureOpenAITextGeneration(
			AzureOpenAIDeploymentName,
			AzureOpenAIEndpoint,
			AzureOpenAIApiKey,
			modelId: AzureOpenAIModelId
		);
		return builder;
	}

然後編寫提問代碼,用戶可以多行輸入文本,最後使用 000 結束輸入,將文本提交給 AI 進行總結。進行總結時,爲了避免 AI 廢話太多,因此這裏使用 ExecutionSettings 配置相關參數。

代碼示例如下:

builder = builder.WithAzureOpenAIText();

var kernel = builder.Build();

Console.WriteLine("輸入文本:");
var request = "";
while (true)
{
	var input = Console.ReadLine();
	if (input == "000")
	{
		break;
	}
	request += Environment.NewLine;
	request += input;
}

var func = kernel.CreateFunctionFromPrompt(new PromptTemplateConfig
{
	Name = "chat",
	Description = "給出一段對話記錄,總結這部分對話.",
	// 用戶的文本
	Template = request,
	TemplateFormat = "semantic-kernel",
	ExecutionSettings = new Dictionary<string, PromptExecutionSettings>
	{
			{
				"default",
				new OpenAIPromptExecutionSettings()
				{
					MaxTokens = 100,
					Temperature = (float)0.3,
					TopP = (float)1,
					FrequencyPenalty = (float)0,
					PresencePenalty = (float)0
				}
			}
	}
});

var result = await func.InvokeAsync(kernel);

Console.WriteLine($"""
				總結如下:
				{string.Join("\n", result)}
				""");

image-20240228111612101

Semantic Kernel 插件

Semantic Kernel 在 Microsoft.SemanticKernel.Plugins 開頭的包中提供了一些插件,不同的包有不同功能的插件。大部分目前還是屬於半成品,因此這部分不詳細講解,本節只做簡單說明。

目前官方倉庫有以下包提供了一些插件:

├─Plugins.Core
├─Plugins.Document
├─Plugins.Memory
├─Plugins.MsGraph
└─Plugins.Web

nuget 搜索時,需要加上 Microsoft.SemanticKernel. 前綴。

Semantic Kernel 還有通過遠程 swagger.json 使用插件的做法,詳細請參考文檔:https://learn.microsoft.com/en-us/semantic-kernel/agents/plugins/openai-plugins

Plugins.Core 中包含最基礎簡單的插件:

// 讀取和寫入文件
FileIOPlugin

// http 請求以及返回字符串結果
HttpPlugin

// 只提供了 + 和 - 兩種運算
MathPlugin

// 文本大小寫等簡單的功能
TextPlugin

// 獲得本地時間日期
TimePlugin

// 在操作之前等待一段時間
WaitPlugin

因爲這些插件對本文演示沒什麼幫助,功能也非常簡單,因此這裏不講解。下面簡單講一下文檔插件。

文檔插件

安裝 Microsoft.SemanticKernel.Plugins.Document(需要勾選預覽版),裏面包含了文檔插件,該文檔插件使用了 DocumentFormat.OpenXml 項目,DocumentFormat.OpenXml 支持以下文檔格式:

DocumentFormat.OpenXml 項目地址 https://github.com/dotnet/Open-XML-SDK

  • WordprocessingML:用於創建和編輯 Word 文檔 (.docx)
  • SpreadsheetML:用於創建和編輯 Excel 電子表格 (.xlsx)
  • PowerPointML:用於創建和編輯 PowerPoint 演示文稿 (.pptx)
  • VisioML:用於創建和編輯 Visio 圖表 (.vsdx)
  • ProjectML:用於創建和編輯 Project 項目 (.mpp)
  • DiagramML:用於創建和編輯 Visio 圖表 (.vsdx)
  • PublisherML:用於創建和編輯 Publisher 出版物 (.pubx)
  • InfoPathML:用於創建和編輯 InfoPath 表單 (.xsn)

文檔插件暫時還沒有好的應用場景,只是加載文檔提取文字比較方便,代碼示例如下:

DocumentPlugin documentPlugin = new(new WordDocumentConnector(), new LocalFileSystemConnector());
string filePath = "(完整版)基礎財務知識.docx";
string text = await documentPlugin.ReadTextAsync(filePath);
Console.WriteLine(text);

由於這些插件目前都是半成品,因此這裏就不展開說明了。

image-20240228154624324

planners

依然是半成品,這裏就不再贅述。

因爲我也沒有看明白這個東西怎麼用

Kernel Memory 構建文檔知識庫

Kernel Memory 是一個歪果仁的個人項目,支持 PDF 和 Word 文檔、 PowerPoint 演示文稿、圖像、電子表格等,通過利用大型語言模型(llm)、嵌入和矢量存儲來提取信息和生成記錄,主要目的是提供文檔處理相關的接口,最常使用的場景是知識庫系統。讀者可能對知識庫系統不瞭解,如果有條件,建議部署一個 Fastgpt 系統研究一下。

但是目前 Kernel Memory 依然是半產品,文檔也不完善,所以接下來筆者也只講解最核心的部分,感興趣的讀者建議直接看源碼。

Kernel Memory 項目文檔:https://microsoft.github.io/kernel-memory/

Kernel Memory 項目倉庫:https://github.com/microsoft/kernel-memory

打開 Kernel Memory 項目倉庫,將項目拉取到本地。

要講解知識庫系統,可以這樣理解。大家都知道,訓練一個醫學模型是十分麻煩的,別說機器的 GPU 夠不夠猛,光是訓練 AI ,就需要掌握各種專業的知識。如果出現一個新的需求,可能又要重新訓練一個模型,這樣太麻煩了。

於是出現了大語言模型,特點是什麼都學什麼都會,但是不夠專業深入,好處時無論醫學、攝影等都可以使用。

雖然某方面專業的知識不夠深入和專業,但是我們換種部分解決。

首先,將 docx、pdf 等問題提取出文本,然後切割成多個段落,每一段都使用 AI 模型生成相關向量,這個向量的原理筆者也不懂,大家可以簡單理解爲分詞,生成向量後,將段落文本和向量都存儲到數據庫中(數據庫需要支持向量)。

image-20240228161109917

然後在用戶提問 “什麼是報表” 時,首先在數據庫中搜索,根據向量來確定相似程度,把幾個跟問題相關的段落拿出來,然後把這幾段文本和用戶的問題一起發給 AI。相對於在提示模板中,塞進一部分背景知識,然後加上用戶的問題,再由 AI 進行總結回答。

image-20240228161318125

image-20240228161334796

筆者建議大家有條件的話,部署一個開源版本的 Fastgpt 系統,把這個系統研究一下,學會這個系統後,再去研究 Kernel Memory ,你就會覺得非常簡單了。同理,如果有條件,可以先部署一個 LobeHub ,開源的 AI 對話系統,研究怎麼用,再去研究 Semantic Kernel 文檔,接着再深入源碼。

從 web 處理網頁

Kernel Memory 支持從網頁爬取、導入文檔、直接給定字符串三種方式導入信息,由於 Kernel Memory 提供了一個 Service 示例,裏面有一些值得研究的代碼寫法,因此下面的示例是啓動 Service 這個 Web 服務,然後在客戶端將文檔推送該 Service 處理,客戶端本身不對接 AI。

由於這一步比較麻煩,讀者動手的過程中搞不出來,可以直接放棄後面會說怎麼自己寫一個

打開 kernel-memory 源碼的 service/Service 路徑。

使用命令啓動服務:

dotnet run setup

這個控制檯的作用是幫助我們生成相關配置的。啓動這個控制檯之後,根據提示選擇對應的選項(按上下鍵選擇選項,按下回車鍵確認),以及填寫配置內容,配置會被存儲到 appsettings.Development.json 中。

如果讀者搞不懂這個控制檯怎麼使用,那麼可以直接將替換下面的 json 到 appsettings.Development.json 。

有幾個地方需要讀者配置一下。

  • AccessKey1、AccessKey2 是客戶端使用該 Service 所需要的驗證密鑰,隨便填幾個字母即可。
  • AzureAIDocIntel、AzureOpenAIEmbedding、AzureOpenAIText 根據實際情況填寫。
{
  "KernelMemory": {
    "Service": {
      "RunWebService": true,
      "RunHandlers": true,
      "OpenApiEnabled": true,
      "Handlers": {}
    },
    "ContentStorageType": "SimpleFileStorage",
    "TextGeneratorType": "AzureOpenAIText",
    "ServiceAuthorization": {
      "Enabled": true,
      "AuthenticationType": "APIKey",
      "HttpHeaderName": "Authorization",
      "AccessKey1": "自定義密鑰1",
      "AccessKey2": "自定義密鑰2"
    },
    "DataIngestion": {
      "OrchestrationType": "Distributed",
      "DistributedOrchestration": {
        "QueueType": "SimpleQueues"
      },
      "EmbeddingGenerationEnabled": true,
      "EmbeddingGeneratorTypes": [
        "AzureOpenAIEmbedding"
      ],
      "MemoryDbTypes": [
        "SimpleVectorDb"
      ],
      "ImageOcrType": "AzureAIDocIntel",
      "TextPartitioning": {
        "MaxTokensPerParagraph": 1000,
        "MaxTokensPerLine": 300,
        "OverlappingTokens": 100
      },
      "DefaultSteps": []
    },
    "Retrieval": {
      "MemoryDbType": "SimpleVectorDb",
      "EmbeddingGeneratorType": "AzureOpenAIEmbedding",
      "SearchClient": {
        "MaxAskPromptSize": -1,
        "MaxMatchesCount": 100,
        "AnswerTokens": 300,
        "EmptyAnswer": "INFO NOT FOUND"
      }
    },
    "Services": {
      "SimpleQueues": {
        "Directory": "_tmp_queues"
      },
      "SimpleFileStorage": {
        "Directory": "_tmp_files"
      },
      "AzureAIDocIntel": {
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "APIKey": "aaa"
      },
      "AzureOpenAIEmbedding": {
        "APIType": "EmbeddingGeneration",
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "Deployment": "aitext",
        "APIKey": "aaa"
      },
      "SimpleVectorDb": {
        "Directory": "_tmp_vectors"
      },
      "AzureOpenAIText": {
        "APIType": "ChatCompletion",
        "Auth": "ApiKey",
        "Endpoint": "https://aaa.openai.azure.com/",
        "Deployment": "myai",
        "APIKey": "aaa",
        "MaxRetries": 10
      }
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*"
}

詳細可參考文檔: https://microsoft.github.io/kernel-memory/quickstart/configuration

啓動 Service 後,可以看到以下 swagger 界面。

image-20240228170942570

然後編寫代碼連接到知識庫系統,推送要處理的網頁地址給 Service。創建一個項目,引入 Microsoft.KernelMemory.WebClient 包。

然後按照以下代碼將文檔推送給 Service 處理。

// 前面部署的 Service 地址,和自定義的密鑰。
var memory = new MemoryWebClient(endpoint: "http://localhost:9001/", apiKey: "自定義密鑰1");

// 導入網頁
await memory.ImportWebPageAsync(
	"https://baike.baidu.com/item/比特幣挖礦機/12536531",
	documentId: "doc02");

Console.WriteLine("正在處理文檔,請稍等...");
// 使用 AI 處理網頁知識
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

// 提問
var answer = await memory.AskAsync("比特幣是什麼?");

Console.WriteLine($"\nAnswer: {answer.Result}");

此外還有 ImportTextAsync、ImportDocumentAsync 來個導入知識的方法。

手動處理文檔

本節內容稍多,主要講解如何使用 Kernel Memory 從將文檔導入、生成向量、存儲向量、搜索問題等。

新建項目,安裝 Microsoft.KernelMemory.Core 庫。

爲了便於演示,下面代碼將文檔和向量臨時存儲,不使用數據庫存儲。

全部代碼示例如下:

using Microsoft.KernelMemory;
using Microsoft.KernelMemory.MemoryStorage.DevTools;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;

var memory = new KernelMemoryBuilder()
	// 文檔解析後的向量存儲位置,可以選擇 Postgres 等,
	// 這裏選擇使用本地臨時文件存儲向量
	.WithSimpleVectorDb(new SimpleVectorDbConfig
	{
		Directory = "aaa"
	})
	// 配置文檔解析向量模型
	.WithAzureOpenAITextEmbeddingGeneration(new AzureOpenAIConfig
	{
		Deployment = "aitext",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIType = AzureOpenAIConfig.APITypes.EmbeddingGeneration,
		APIKey = "aaa"
	})
	// 配置文本生成模型
	.WithAzureOpenAITextGeneration(new AzureOpenAIConfig
	{
		Deployment = "myai",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIKey = "aaa",
		APIType = AzureOpenAIConfig.APITypes.ChatCompletion
	})
	.Build();

// 導入網頁
await memory.ImportWebPageAsync(
	"https://baike.baidu.com/item/比特幣挖礦機/12536531",
	documentId: "doc02");

// Wait for ingestion to complete, usually 1-2 seconds
Console.WriteLine("正在處理文檔,請稍等...");
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

// Ask a question
var answer = await memory.AskAsync("比特幣是什麼?");

Console.WriteLine($"\nAnswer: {answer.Result}");

image-20240228175318645

首先使用 KernelMemoryBuilder 構建配置,配置的內容比較多,這裏會使用到兩個模型,一個是向量模型,一個是文本生成模型(可以使用對話模型,如 gpt-4-32k)。

接下來,按照該程序的工作流程講解各個環節的相關知識。

首先是講解將文件存儲到哪裏,也就是導入文件之後,將文件存儲到哪裏,存儲文件的接口是 IContentStorage,目前有兩個實現:

AzureBlobsStorage
// 存儲到目錄
SimpleFileStorage

使用方法:

var memory = new KernelMemoryBuilder()
.WithSimpleFileStorage(new SimpleFileStorageConfig
	{
		Directory = "aaa"
	})
	.WithAzureBlobsStorage(new AzureBlobsConfig
	{
		Account = ""
	})
	...

Kernel Memory 還不支持 Mongodb,不過可以自己使用 IContentStorage 接口寫一個。

本地解析文檔後,會進行分段,如右邊的 q 列所示。

image-20240229145611963

接着是,配置文檔生成向量模型,導入文件文檔後,在本地提取出文本,需要使用 AI 模型從文本中生成向量。

解析後的向量是這樣的:

image-20240229145819118

將文本生成向量,需要使用 ITextEmbeddingGenerator 接口,目前有兩個實現:

AzureOpenAITextEmbeddingGenerator
OpenAITextEmbeddingGenerator

示例:

var memory = new KernelMemoryBuilder()
// 配置文檔解析向量模型
	.WithAzureOpenAITextEmbeddingGeneration(new AzureOpenAIConfig
	{
		Deployment = "aitext",
		Endpoint = "https://xxx.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIType = AzureOpenAIConfig.APITypes.EmbeddingGeneration,
		APIKey = "xxx"
	})
	.WithOpenAITextEmbeddingGeneration(new OpenAIConfig
	{
        ... ...
	})

生成向量後,需要存儲這些向量,需要實現 IMemoryDb 接口,有以下配置可以使用:

	// 文檔解析後的向量存儲位置,可以選擇 Postgres 等,
	// 這裏選擇使用本地臨時文件存儲向量
	.WithSimpleVectorDb(new SimpleVectorDbConfig
	{
		Directory = "aaa"
	})
	.WithAzureAISearchMemoryDb(new AzureAISearchConfig
	{

	})
	.WithPostgresMemoryDb(new PostgresConfig
	{
		
	})
	.WithQdrantMemoryDb(new QdrantConfig
	{

	})
	.WithRedisMemoryDb("host=....")

當用戶提問時,首先會在這裏的 IMemoryDb 調用相關方法查詢文檔中的向量、索引等,查找出相關的文本。

查出相關的文本後,需要發送給 AI 處理,需要使用 ITextGenerator 接口,目前有兩個實現:

AzureOpenAITextGenerator
OpenAITextGenerator

配置示例:

	// 配置文本生成模型
	.WithAzureOpenAITextGeneration(new AzureOpenAIConfig
	{
		Deployment = "myai",
		Endpoint = "https://aaa.openai.azure.com/",
		Auth = AzureOpenAIConfig.AuthTypes.APIKey,
		APIKey = "aaa",
		APIType = AzureOpenAIConfig.APITypes.ChatCompletion
	})

導入文檔時,首先將文檔提取出文本,然後進行分段。

將每一段文本使用向量模型解析出向量,存儲到 IMemoryDb 接口提供的服務中,如 Postgres數據庫。

提問問題或搜索內容時,從 IMemoryDb 所在的位置搜索向量,查詢到相關的文本,然後將文本收集起來,發送給 AI(使用文本生成模型),這些文本相對於提示詞,然後 AI 從這些提示詞中學習並回答用戶的問題。

詳細源碼可以參考 Microsoft.KernelMemory.Search.SearchClient ,由於源碼比較多,這裏就不贅述了。

1709116664654

這樣說,大家可能不太容易理解,我們可以用下面的代碼做示範。

// 導入文檔
await memory.ImportDocumentAsync(
	"aaa/(完整版)基礎財務知識.docx",
	documentId: "doc02");

Console.WriteLine("正在處理文檔,請稍等...");
while (!await memory.IsDocumentReadyAsync(documentId: "doc02"))
{
	await Task.Delay(TimeSpan.FromMilliseconds(1500));
}

var answer1 = await memory.SearchAsync("報表怎麼做?");
// 每個 Citation 表示一個文檔文件
foreach (Citation citation in answer1.Results)
{
	// 與搜索關鍵詞相關的文本
	foreach(var partition in citation.Partitions)
	{
		Console.WriteLine(partition.Text);
	}
}

var answer2 = await memory.AskAsync("報表怎麼做?");

Console.WriteLine($"\nAnswer: {answer2.Result}");

讀者可以在 foreach 這裏做個斷點,當用戶問題 “報表怎麼做?” 時,搜索出來的相關文檔。

然後再參考 Fastgpt 的搜索配置,可以自己寫一個這樣的知識庫系統。

image-20240228185721336

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章