做個有記憶力的 AI 機器人,實作對話記憶

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

前情提要

這篇文章是【Hello Gemini,串接第一個 Gemini 生成式 AI】的延伸篇,在完成上一篇的設定後,我們已經成功透過程式呼叫 Google Gemini,串起了第一個生成式 AI 的小程式了。 

但,事情並不總是這麼順利... 請看下圖的情境,我一開始問了一個問題,而它也正確的回答,接著再問「那他的背景是什麼」,結果 Gemini 居然回了一個讓人摸不著頭緒回答,不知道它是害怕想起來還是擔心對岸會對它不利 XD,它似乎完全忘了我們上一個問題在問什麼了。


你可能會有疑問?我明明在 ChatGPT 或 Gemini 使用者介面都用的好好的,怎麼會串個 API 就不知道我第一個問題是什麼呢?難道我的 API 版本是金魚腦比較笨!!其實,這是因為像 ChatGPT 或 Gemini 這類平台的使用者介面,內建了「對話記憶」機制來解決這個問題

也就是說,這些平台它會自動把你之前說過的話保留下來,讓模型能理解整段對話的上下文,回應自然又有連貫性,就像是跟朋友說話一樣自然。當然,這種記憶也是有限度的(畢竟模型的上下文長度是有限的,因此記憶也是有限的,這部分我們之後有機會可以再深入介紹),不過至少最近幾輪的對話它都還能記住。 

但當你透過 API 來串接 Gemini 或 ChatGPT 時,模型並不會主動幫你記住對話的上下文,你得自己把對話的歷史維護好,並在每一次請求時都一併傳送過去,因此如同這篇的主題「做個有記憶力的 AI 機器人」,這篇將會示範如何實作基本的對話記憶機制。


先用 API 看懂請求與回應

照慣例,在開始實作記憶功能之前,我們先不使用任何套件,直接透過 API 來與 Gemini 對話,這邊我會先使用 Postman 來手動發送請求,觀察請求與回應的資料格式,搞清楚模型實際需要什麼樣的輸入,以及會回應什麼樣的輸出。

畢竟,了解這些資料結構之後,後續我們在使用 Semantic Kernel 來整合時,才更能掌握整個流程,知道每一層背後實際發生了什麼事,在本文範例中,我們將使用 Gemini API 當作範例來,讓我們一步一步開始吧。


提出第一個問題

當使用者提出第一個問題時,請求的內容必須清楚地標示出訊息的角色。也就是說,我們要明確告訴 API,這句話是誰說的?是系統的設定語句?是使用者的問題?還是模型先前的回應?

在 Gemini 或 ChatGPT 的 API 結構中,每一則對話訊息都會有一個 role 欄位,常見的二個角色如下: 

  • user:使用者提出的問題 。
  • system:AI 模型的回應,在 Gimini 中該角色則是使用 model。

另外在某些情況下,為了要限制 GPT 的語氣、身份,或是回答的風格,我們也可以指定系統指令,這個通常是由應用程式在背後自動補上的,但這牽涉到 Prompt Engineering(提示工程) 的技巧,後續我們會再另外說明。

這邊你只需要先有個概念,system_instruction 不是必填欄位,可加也可不加,實務上是否使用,取決於你的應用場景與需求,更多參數請自行參考官方提供的系統指令與設定

因此在送出第一個問題時,你的請求內容可能會長的類似下面的結構:

{
  "system_instruction": {
    "parts": [
      {
        "text": "你是一位專業政治新聞助理,請用清楚簡潔的方式回答問題。"
      }
    ]
  },
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "請問台灣總統是誰"
        }
      ]
    }
  ]
}


觀察第一個回應

當送出第一個請求後,Gemini 會回傳一段 JSON 結構的回應資料,而我們主要先把重點放在回應內容的部分,其它欄位之後有機會再說明,格式大致如下:

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "台灣總統是蔡英文。\n"
          }
        ],
        "role": "model"
      }
    }
  ]
}

Gemini 收到我們的請求後,會回應  台灣總統是蔡英文, 這是因為 Gemini 的訓練資料可能停留在某個特定時間點,而當時的總統是蔡英文(不是它搞錯了,只是它活在過去 XD),我們要把這個回應記錄下來。


啟動第二輪對話

因為我們即將提出下一個問題,而這時候模型並不知道上一輪你問了什麼,也不記得它自己講過什麼內容。為了讓模型「有記憶」。

所以我們要做的事情是:把上一句模型的回應也一併帶入下一次的請求中,並且指定它的角色是 "model",這樣 Gemini 才會知道「剛剛是我自己說了這句話」,才能理解使用者現在接著問的內容是延續上文的,然後,再加入新的問題,角色是 "user",請求內容可能會長的類似下面的結構:

{ 
    "system_instruction":  
    { 
      "parts":  
      [ 
        { "text": "你是一位專業政治新聞助理,請用清楚簡潔的方式回答問題。"} 
      ] 
    }, 
    "contents":  
    [  
        { "role": "user", "parts": [ { "text": "請問台灣總統是誰" } ] }, 
        { "role": "model", "parts": [ { "text": "台灣總統是蔡英文。" } ] }, 
        { "role": "user", "parts": [ { "text": "那請問她的背景是什麼?" } ] }  
    ] 
} 


關於後續的對話

如果你要進行後續對話,就只需要持續「重複第二輪對話階段」的循環,把上一輪的使用者問題與模型回應一起帶進下一輪請求中,讓模型知道這是一段有來有往的對話。 

但記憶其實也不是無限量的,每次請求都會佔用一定的 Token 數(對於 Gemini 模型,一個符號大約等於 4 個字元。100 個符記大約等於 60 到 80 個英文單字),而模型本身也有上下文長度的限制(不同模型有不同的 Max Token,詳細規格可自行至 API 說明文件查詢 ),當整段對話過長超出限制時,前面的內容就可能會被截斷或忽略,導致模型又變回金魚腦模式 🐟。 

所以實務上,如果你的對話太長,可能需要自行做一些記憶壓縮、摘要處理,或只保留關鍵訊息來維持對話的流暢與準確性,這部分可以視你的應用需求再進一步設計。


Token 計算的小工具

ChatGPT 有提供一個官方網站,可以讓我們試算不同模型所計算出來的 Token 數量,像是 gpt-4o 或 gpt-3.5 等等,因此你只要輸入一段文字,它就會告訴你這段話會佔用多少 token,非常方便(如下圖所示),可惜目前我並沒有找到 Gemini 上面找到類似的工具。

來源網站 : https://platform.openai.com/tokenizer


開始實作吧

這篇文章花了稍微多一點時間帶大家熟悉 Gemini API 的請求與回應格式,接下來我們就要正式動手寫程式了。這次我們一樣使用 .NET 8,並搭配 Semantic Kernel 來幫我們管理聊天的上下文,同時也請先安裝好底下的套件。

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

然後來看看底下這段核心的對話程式碼,跟一般對話功能差不多,一樣是送出對話、接收回應,只是我們要額外自己維護對話歷史,在每一次呼叫 API 的時候,把整段過去的訊息都一併帶過去,因此下面的程式碼主要做了幾件事情:

  1. 記錄使用者的發言:使用 chatHistory.AddUserMessage(userInput),把這一輪的問題記下來,讓模型知道使用者現在問了什麼。 
  2. 送出整段對話給模型:使用 chatService.GetChatMessageContentAsync(chatHistory),把目前累積的對話歷史全部送出,讓模型能理解上下文來產生連貫的回應。
  3. 接收並印出模型的回答:取得回應後輸出到 Console,同時也用 chatHistory.AddAssistantMessage(content) 把它加入歷史紀錄中,這樣下一輪的對話就能擁有先前的對話紀錄了

private static async Task GetChatMessageContentAsync(IChatCompletionService chatService, ChatHistory chatHistory, string userInput)
{
    // 輸出使用者輸入內容 
    Console.WriteLine("User: " + userInput);
    chatHistory.AddUserMessage(userInput);

    // 呼叫 Gemini 模型並取得回應
    var response = await chatService.GetChatMessageContentAsync(chatHistory);

    // 輸出 Gemini 回應的內容
    var content = response.Content ?? "";
    Console.WriteLine("Assistant: " + content);
    chatHistory.AddAssistantMessage(content);
}

當我們實作了多輪對話機制後,再次提出相同的問題,例如第二輪問「那他的背景是什麼?」,這時 LLM 模型就已經知道這句話中的「他」指的是第一輪提問中所說的「台灣總統」。也就是說,因為我們把整段對話歷史一起傳進去,模型能夠理解上下文,成功對應出「他」是誰,自然就能給出連貫且合理的回應,如下圖所示。

本篇的目標 :打造一個「有記憶力」的 AI 聊天機器人

本文範例使用 NetCore 8,請至 HelloGeminiMemory 下載 。


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

留言