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

痴者工良發表於2024-03-01

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

目錄
  • 萬字長文學會對接 AI 模型:Semantic Kernel 和 Kernel Memory,工良出品,超簡單的教程
    • 配置環境
      • 部署 one-api
      • 配置專案環境
      • 模型劃分和應用場景
      • 聊天
        • 提示詞
        • 引導 AI 回覆
        • 指定 AI 回覆特定格式
        • 模板化提示
        • 聊天記錄
      • 函式和外掛
        • 直接呼叫外掛函式
        • 提示模板檔案
        • 根據 AI 自動呼叫外掛函式
        • 聊天中明確呼叫函式
        • 實現總結
        • 配置提示詞
        • 提示模板語法
          • 變數
          • 函式呼叫
      • 文字生成
      • Semantic Kernel 外掛
        • 文件外掛
      • planners
    • Kernel Memory 構建文件知識庫
      • 從 web 處理網頁
      • 手動處理文件

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.SemanticKernel 包。

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

這個控制檯的作用是幫助我們生成相關配置的。啟動這個控制檯之後,根據提示選擇對應的選項(按上下鍵選擇選項,按下Enter鍵確認),以及填寫配置內容,配置會被儲存到 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

相關文章