來跟 AI 玩玩角色扮演吧,提示工程(Prompt engineering)實作

# 使用生成式 AI 結合 RAG 技術實做屬於自己的 LLM 知識庫系列文章

在完成前面幾篇的實作後,你應該已經學會如何呼叫 LLM 模型,也知道如何讓 LLM 模型記住你們的對話了。同時,我們也都知道,LLM 模型是一位通才型的專家,當你提出問題時,它會根據訓練過的大量語料,試著回答你相關的內容。

不過,LLM 並非萬能,當它遇到沒見過或不知道的問題時,就可能產生幻覺,這也是我們之後要實作 LLM 知識庫中需要留意的重要課題(續會有另外一篇 RAG 來介紹如何減輕這個問題)。

那你有沒有想過,如果在某些情境下,你剛好提出一個跨領域的問題,LLM 模型又會怎麼回答呢?

現在的大語言模型應該已經相當聰明了,當你問出一個有多領域的問題時,它或許有可能會反問你「你想知道哪個領域的?」或者乾脆將所有可能的解釋一次列出來。例如你問它「請告訴我模組是什麼?」,而這個問題在數學領域、程式設計領域、或者 IoT 等領域可能會是完全不一樣的答案。這時候本篇的重點提示工程(Prompt Engineering)就派上用場了。Prompt 可以透過設計更明確、結構化的提示甚至是給予 LLM 一個角色,來引導模型針對特定情境給出我們想要的答案

這篇文章,我會以我最近遇到的一個實際需求為例,帶大家實作一個「多國語言翻譯的小工具」,並透過這個範例來實戰演練提示工程的應用。

情境說明

我們預計將這個工具運用在許多外籍勞工的工作場域,由於這些勞工們可能來自世界各地,講著不同的語言,因此他們需要一個能夠即時翻譯並轉達訊息的工具。舉例來說:

  •  一位日本籍員工輸入「おはよう」
  •  系統會自動翻譯成中文,讓台灣籍員工看到「早安」、美籍員工看到「Good morning」。

因此我們希望這個工具能夠同時將訊息翻譯成多種語言,讓所有國籍的員工收到自己熟悉的語言,並能理解問題的內容,同時,為了利於後續程式端處理,這邊我們會要求 LLM 模型以結構化的 JSON 格式回傳翻譯結果。


開發前的小提醒

在這篇實作中,我會將內容分為兩個階段來進行,主要是希望能大家能先熟悉提示工程的使用方式,後續才能靈活來搭配使用。

第一階段,我們會直接在程式中寫死 Prompt 字串,主要的目的是能讓大家先快速體驗提示工程的設計與模型回應的效果。但提醒一下,這種 Hot Code 寫法雖然適合學習,但在實務專案中並不是一個理想的做法。當提示語越來越多、邏輯變得複雜,或需要支援不同場景與語言時,這些寫死的字串會變得難以維護、難以測試,也不易重複使用。

第二階段,我們會進一步透過載入 Prompt Plugin 的方式來撰寫提示詞,讓提示工程能夠以模組化的方式來進行管理,提升後續的彈性與可維護性。

本篇實作,依然沿用前幾篇的技術組合 .NET 8 + Gemini API + Semantic Kernel ,請先安裝好底下的套件。

<PackageReference Include="Microsoft.SemanticKernel" Version="1.56.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.56.0-alpha" />


在開始撰寫 Prompt 之前...

在開始撰寫 Prompt 之前,我發現有件事必須先說明。請容我暫時跳離實作步驟,談談我為什麼要這麼做。 

這篇文章中有說明大語言的模型預設的回應,我快速摘要原文的重點「大型語言模型預設會產生自然語言的回應,這讓有益於我們人類的理解,卻不易用程式處理,若能改為輸出 JSON 格式,則能有效解決這個問題」。

因此,在本篇的實作中,我們將首次使用一個在前幾篇尚未介紹的重要參數:GeminiPromptExecutionSettings。 這個物件的主要功能是用來控制 LLM 模型的回應方式與回傳格式。在實際應用場景中,若有需要調整回傳格式或是更改模型的 Temperature 等參數時,就會使用到這個設定物件,各參數功能說明如下:

  • Temperature:用來調整回應的隨機性。數值越高,回應越有創意但也越不可預測。 
  • MaxTokens:控制模型回應的長度,因為預設值為 250,在翻譯的場景下可能太少,所以才進行調整。 
  • ResponseSchema:指定模型輸出的資料結構類別。 
  • ResponseMimeType:指定回應的格式,如 "application/json"。

var promptExecutionSettings = new GeminiPromptExecutionSettings
{
    Temperature = 1,
    MaxTokens = 1000,
    ResponseSchema = typeof(LanguageTranslation),
    ResponseMimeType = "application/json"
};

var kernelArgs = new KernelArguments(promptExecutionSettings);


第一階段:程式內寫死的 Hot Code Prompt

在所有程式的最開始經過前幾篇文章的練習,先完成我們很熟悉的步驟吧。建立 Semantic Kernel 的核心物件,並註冊 Gemini 的 Chat Completion 服務。

Kernel kernel = Kernel.CreateBuilder()
    .AddGoogleAIGeminiChatCompletion(
        modelId: geminiModelId,
        apiKey: geminiApiKey,
        apiVersion: Microsoft.SemanticKernel.Connectors.Google.GoogleAIVersion.V1_Beta
    )
    .Build();


提示詞設計場景說明

在這篇文章的實作範例中,我們以「工廠現場的多語翻譯」為場景,使用者輸入可能是任何語言,我們希望語言模型能幫助我們翻譯為五國語言,並以純 JSON 格式回傳,以下是我們設計的 Prompt。

string prompt = @"您是一位專業的翻譯專家,
本次的任務是要對象是製造業所屬的工廠場域進行多語言翻譯,
使用者的輸入可能是任何語言,我需要你的協助翻譯成中文、英文、日文、越南和菲律賓五個國家的語言,
以下是使用者輸入的內容。
### 
{{$user_input}}

### json format
{
    ""tw"": """",
    ""en"": """",
    ""jp"": """",
    ""vn"": """",
    ""ph"": """",
    ""input-code"": """"
}

備註:
1. 各國語言請依當地語言習慣翻譯,並確保翻譯的內容符合當地文化。 
2. input-code 欄位請輸入使用者的語言代碼,例如:tw、en、jp、vn、ph 等。
3. 在您的回復中,不包括任何解釋性文字或評論,僅需回傳原始的JSON。";

// 使用者輸入,這邊是模擬使用者輸入的是德文
string userInput = "Um 8:00 Uhr morgens kam es bei Taipower zu einem Spannungsabfall.";


實作一:基本提示詞使用

首先我們來看最基本的提示工程方式,不指定任何執行參數,也就是僅在對話中定義提示詞。
var promptExecutionSettings = new GeminiPromptExecutionSettings(); 
var kernelArgs = new KernelArguments(promptExecutionSettings)
{
    ["user_input"] = userInput
};
觀察一下如下回傳值範例,Gemini 會產出格式接近 JSON 的內容,通常是以 markdown 格式的內容回傳,並使用的 ```json 區塊標記包起來,這樣雖然對我們人類來說可讀性較高,但在程式中就需要額外移除開頭與結尾的標記區塊,且未來可能還會發生未預期的回傳值,無疑增加了解析錯誤的風險。
```json
{
    "tw": "今天早上8點 ...",
    "en": "At 8:00 AM this morning ...",
    "jp": "今朝8時 ...",
    "vn": "Vào lúc 8:00 sáng nay ...",
    "ph": "Kaninang 8:00 AM ngayong umaga ...",
    "input-code": "de"
}
```


實作二:指定回應 Content Type 為 JSON

為了讓回傳內容可以直接被程式處理,我們可以進一步設定 ResponseMimeType 為 "application/json",讓模型直接輸出標準 JSON 格式。

var promptExecutionSettings = new GeminiPromptExecutionSettings
{ 
    ResponseMimeType = "application/json"
}; 
var kernelArgs = new KernelArguments(promptExecutionSettings)
{
    ["user_input"] = userInput
};

回應結果如下,產出的回應內容不再被包在 markdown 標記中,而是直接輸出可用的 JSON 字串。不過在實測中發現,Gemini 竟然回傳一個 JSON 陣列,裡面包了一個或多個翻譯物件,也使得此方法其實好像也不太牢靠,但這種現象可能是提示詞精準度不夠或模型對提示詞理解有所有偏差導致的。

[
  {
    "tw": "早上8:00 ...",
    "en": "At 8:00 AM ...",
    "jp": "午前8時00分 ...",
    "vn": "Vào lúc 8:00 sáng ...",
    "ph": "Noong 8:00 AM ...",
    "input-code": "de"
  }
]


實作三:定義結構化類別

有鑑於前兩種方法,雖然 Gemini 都能回傳我們期望的內容,但卻不一定穩定地回傳我們預期的格式,而這正是大語言模型典型的「不可預測性」,或許可以透過高 Temperature 來試試是否能滿足需求。

但如果能像 WebAPI 那樣,明確定義一個請求物件類別,讓 LLM 自動照著這個結構填值,那不就是我們熟悉的 Mode Binding 的機制嗎? 為了實現這件事,我們需要先定義一個結構化資料的類別如下

public class LanguageTranslation
{
    [Description("繁體中文")]
    public string Chinese { get; set; } = string.Empty;

    [Description("英語")]
    public string English { get; set; } = string.Empty;

    [Description("日文")]
    public string Japanese { get; set; } = string.Empty;

    [Description("越南")]
    public string Vietnam { get; set; } = string.Empty;

    [Description("菲律賓")]
    public string Pilipinas { get; set; } = string.Empty;

    [Description("使用者輸入的語言代碼,例如:tw、en、jp、vn、ph")]
    public string InputCode { get; set; } = string.Empty;
}

接著,在 Gemini 設定中指定這個模型作為回傳結構的參考

var promptExecutionSettings = new GeminiPromptExecutionSettings
{ 
    ResponseSchema = typeof(LanguageTranslation),
    ResponseMimeType = "application/json"
}; 
var kernelArgs = new KernelArguments(promptExecutionSettings)

到這裡,我又想了一下,既然模型已經明確知道我的回傳結構,是不是可以省略提示詞中的回應格式說明,讓 Gemini 根據指定結構來回應就好? 於是我重新調整提示詞如下

string prompt = @"您是一位專業的翻譯專家,
本次的任務是要對象是製造業所屬的工廠場域進行多語言翻譯,
使用者的輸入可能是任何語言,我需要你的協助翻譯成中文、英文、日文、越南和菲律賓五個國家的語言,
以下是使用者輸入的內容。
### 
{{$user_input}}

備註:
1. 各國語言請依當地語言習慣翻譯,並確保翻譯的內容符合當地文化。
2. 請使用 LanguageTranslation 轉換成對應的 JSON 欄位。";

驗收一下如下的成果,看起很完美,確實是我們要的結果,但我也得老實地說,因為這邊只有針對 Gemini 來測試,並沒有確定 ChatGPT 、Grok 等其他平台是否也能如預期般正確回應,我暫時沒辦法驗證(沒錯,就是因為我沒付錢 😅),後續有機會再來試試。

{
  "Chinese": "早上8點 ...",
  "English": "At 8:00 AM ...",
  "InputCode": "de",
  "Japanese": "午前8時 ...",
  "Pilipinas": "Noong 8:00 AM ...",
  "Vietnam": "Vào lúc 8:00 sáng ..."
}


實作四:使用 System Prompt 的方式(分離使用者輸入與提示工程)

如果你的應用場景屬於連續對話(詳細實作說明可以參考做個有記憶力的 AI 機器人,實作對話記憶),那麼角色扮演式的 System Prompt 會是一個不錯的設計方式。

你可以在一開始明確指定模型的角色、語氣與任務目標,讓後續所有回合的輸入都能在這個角色框架中解讀與回應。

這種設計有一個好處,可以清楚分離提示工程與使用者的輸入內容,提示詞不再需要重複塞在每次輸入裡,而是獨立存在於對話開頭的系統層級,但套用在本文的翻譯情境就可能就比較不適合此方式,不過還是保留下來給大家參考,詳細可自行參考底下語法

var chatService = kernel.GetRequiredService();
var chatHistory = new ChatHistory(prompt);
// 也可以使用此底下這個方式來設定系統提示
// chatHistory.AddSystemMessage(prompt); 

chatHistory.AddUserMessage(userInput);
var response = await chatService.GetChatMessageContentAsync(chatHistory, promptExecutionSettings);



第二階段:使用 Prompt Plugin(Semantic Plugin) 

在第一階段的實作中,我們採用了 Hot Code 的方式,也就是直接在程式碼中撰寫提示詞與邏輯內容。雖然這種方式看起來快速、直覺,但正如前文所提,這樣的做法不僅增加後續維護的難度,也無法有效重用(Reuse)提示詞內容。 

也許你會說「我可以把提示詞抽成一個 Helper 類別來呼叫啊」沒錯,這當然是一種方式,但如果我們能夠用更標準、彈性更高的方法來處理提示詞的組織與管理呢? 

沒錯,這就是 Semantic Plugin 登場的時機。


Plugin 是什麼 ?

Plugin 是 Semantic Kernel 中的核心機制之一,它允許我們將提示詞、參數設定等邏輯以「模組化」的方式抽離出來,讓程式邏輯與提示詞分離,進一步提升可讀性、可測試性與跨場域重用性,而在 Semantic Kernel 中定義的 Plugins 有兩種方式:

  • 第一種是透過樣板定義的插件,也就是所謂的語意插件(Semantic Plugin),也是本文接下來要使用的方式。
  • 第二種則是透過類別函數定義的插件,又稱為 Native Plugin。不過由於這篇文章的篇幅已經有點多了,我會在後續的「AI 也能認識你是誰喔:實作自定義 Function」這篇文章中補充說明。

所以我們先回到這篇的主題,繼續完成我們的重構任務吧!

新的情境說明

隨著這家製造業公司業務拓展,除了原有的高雄廠,近期也在台北設立了新廠房,然而兩地的外籍員工組成略有不同: 

  • 高雄廠:維持原有的五國語言支援(繁中、英文、日文、越文、菲律賓文) 
  • 台北廠:僅需支援三種語言(例如:繁中、英文、日文)

老闆決定,為了提升台北廠的作業效率,將原本在高雄廠使用的翻譯程式也一併部署到台北廠。 

看到這裡,聰明的你應該不會整份程式碼直接複製貼上、然後手動砍掉兩個語言吧? 😨 這個場景正好說明了「抽離提示詞邏輯、提升彈性」的必要性。


Semantic Plugin 的前置設定

Semantic Kernel 預設就為我們準備好了一個強大又貼心的機制(這時候真的該膜拜一下這個套件),讓開發者可以將提示詞內容與程式碼邏輯完全分離。透過這個機制我們可以透過底下幾個步驟來完成:

  • 將提示詞寫成獨立的 skprompt.txt 實體檔案 。
  • 搭配可選的 config.json 參數設定檔(詳細的配置可參考這篇文章的設定,但本範因為需要指定 ResponseSchema 的物件類別,所以就沒有使用此方式)。
  • 調整原程式,改利用 ImportPluginFromPromptDirectory() 載入多個提示詞樣板。
  • 調整原程式,利用 InvokeAsync 指定要呼叫哪一個樣板。

如果你的程式架構有寫好的話,當未來有新需求時,只要新增一個 Plugin 資料夾,主程式不需要做任何變動,就能完成擴充了,沒有沒,就說要膜拜了吧。

接著,就讓我們來看一下 Plugins 目錄結構範例吧。

Plugins  
│
└─ KaohsiungPlugin  # Plugin(高雄廠)
│  │
│  └─ TranslatePrompt  # Function
│     └─ skprompt.txt
│     └─ [config.json] # 有定義,但僅供參考用,實際上未使用(會被程式內指定的 GeminiPromptExecutionSettings 覆蓋)
│   
└─ TaipeiPlugin  # Function(台北廠)
   │
   └─ TranslatePrompt  # Function
      └─ skprompt.txt
      └─ [config.json]

實作一:使用 Import Plugin 的方式載入 Prompt Plugin

前面的那段宣告 Kernel 物件等步驟都是一樣的,我就不再重複說明了,直接看兩段關鍵的程式碼吧。 

在建立完 Kernel 物件後,我們可以直接透過 kernel.ImportPluginFromPromptDirectory() 載入事先已經定義好的資料夾。假設有載入多個 Plugin 時,後續如果要指定 Plugin 的話,那第二個參數 pluginName 則必須指定,這個參數的名稱是一個別名,因此你也可以根據你的分類來進行命名。

kernel.ImportPluginFromPromptDirectory(
    pluginDirectory: "Plugins\\TaipeiPlugin", 
    pluginName: "TaipeiTranslate"
);
kernel.ImportPluginFromPromptDirectory(
    pluginDirectory: "Plugins\\KaohsiungPlugin", 
    pluginName: "KaohsiungTranslate"
);

接著就是呼叫我們剛剛定義好的程式。範例的程式是呼叫高雄廠的寫法,如果是要呼叫台北廠,只要調整 functionName 這個參數即可。

FunctionResult result = await kernel.InvokeAsync(
    pluginName: "KaohsiungTranslate",
    functionName: "TranslatePrompt",
    arguments: kernelArgs
);

var response = result.GetValue();


實作二:使用 Create Plugin 的方式載入 Prompt Plugin 

上面那個是透過 Import 的方式在一開始就將 Plugin 載入到 Kernel 中,後續要使用的時候直接調用就可以了。但如果你的程式雖然是共用的,但你不想要在一開始就把所有的 Plugin 都載入的話,那麼動態載入或許比較適合你,這時候你可以改用 CreatePluginFromPromptDirectory() 的方式。

雖然它們的型別都是 KernelFunction,但根據網路上大神的分享(我自己沒去翻過 Source Code,如果有誤請留言告訴我 ),其內部並不相同,所以並不會互相衝突。 

使用方式超簡單,就不解釋了,直接看 Code 吧

KernelPlugin function = kernel.CreatePluginFromPromptDirectory(
    pluginDirectory: "Plugins\\TaipeiPlugin"
);

FunctionResult response = await kernel.InvokeAsync(
    function: function["TranslatePrompt"],
    arguments: kernelArgs
);

Console.WriteLine("response:\n" + response);



本文範例使用 NetCore 8

  • 第一階段:程式內寫死的 Hot Code Prompt,請至 PromptInline 下載 
  • 第二階段:使用 Prompt Plugin(Semantic Plugin),請至 PromptFunction下載 。


礙於篇幅的關係,本系列規劃用底下幾篇文章來說明。


參考網站

留言