2025年3月28日 星期五

OpenAI API 學習筆記 : 結合搜尋引擎的聊天機器人 (三)

在上一篇測試中利用 JSON 格式回覆字串模板成功地在聊天機器人程式中自動判斷是否要搜尋網路找出參考資料讓模型能生成它認知範圍以外的知識, 但是這個聊天機器人欠缺記憶聊天歷史的能力, 無法從上下文判斷前後問題的關聯性, 本篇測試旨在解決記憶問題.

本系列全部文章索引參考 : 



5. 有記憶且能自動判斷是否需要搜尋網路的聊天機器人 :  

關於如何讓聊天機器人具有記憶能力參考之前的這篇測試 :


在該篇測試中, 我們使用一個串列變數 memory 來儲存使用者的 prompt 與模型的回應, 在每一次的對話中將此對話歷史連同提示詞一起傳給 API, 這樣 GPT 模型便能從這些上下文中得知前後對話脈絡, 從而做出適當的回應. 

本篇是在前一篇測試的範例程式 cli_chatbot_search_4.py 的基礎上進行修改, 讓聊天機器人句有記憶能力, 前一篇測試參考 : 


主要的修改是將 messages 訊息串列拿到外面做為全域變數, 而且用來儲存對話歷史, 在每次對話前後都會將提示詞與模型回應存入串列中, 且每次向 GPT 查詢都是傳遞此對話歷史, 完整程式碼如下 :

# cli_chatbot_search_5.py 
from openai import OpenAI, APIError
import requests
import json

def ask_gpt(messages, model='gpt-3.5-turbo'):
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages
            )
        return reply.choices[0].message.content
    except APIError as e:
        return e.message

def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

# 設定金鑰變數
openai_key='你的 OpenAI API Key'
custom_search_key='你的 Custom Search JSON API Key'
cx='你的 Custom Search Engine ID'  # Custom Search Engine ID
# 建立 OpenAI 物件
client=OpenAI(api_key=openai_key)
# 設定 GPT 系統角色
sys_role=input('請設定 GPT 角色 : ')
if sys_role.strip() == '': 
    sys_role='你是繁體中文AI助理'
print(sys_role)

# 建立 JSON 格式回覆字串模板
template='''
請確認你是否知道下面這件事: 
{}
如果知道, 請務必只用下列 JSON 格式回答:
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}
如果不知道, 請務必只用下列 JSON 格式回答:
{{
  "need_search": "Y",
  "keyword": "你建議的搜尋關鍵字",
  "reply": ""
}}
'''
# 初始化聊天訊息 (用來記憶聊天歷史) 
messages=[{'role': 'system', 'content': sys_role}]

# 聊天機器人 (無窮迴圈)
while True: 
    prompt=input('You : ')  # 輸入提示詞
    if prompt.strip() == '':  # 按 Enter 跳出迴圈結束聊天
        print('GPT : 再見。')
        break
    # 記錄使用者輸入到歷史訊息
    messages.append({'role': 'user', 'content': prompt})
    # 初次查詢 GPT : 確定是否需要搜尋網路
    json_query_str=template.format(prompt)
    messages.append({'role': 'user', 'content': json_query_str})   
    print(messages)
    reply=ask_gpt(messages)  # 呼叫 OpenAI API
    print(reply)
    result=json.loads(reply)  # 將 JSON 字串轉成字典
    if result['need_search']=='Y':  # 需要搜尋網路
        keyword=result['keyword']  # 取得 GPT 建議之搜尋關鍵字
        web_msg='以下是 Google 搜尋所得資料:\n'  # 初始化搜尋結果字串
        for item in search_google(keyword, cx, custom_search_key):
            web_msg += f'標題: {item["title"]}\n'
            web_msg += f'描述: {item["snippet"]}\n\n'
        web_msg += '請依據上述資料直接回答下列問題:\n' + prompt
        # 記錄 Google 搜尋結果
        messages.append({'role': 'system', 'content': web_msg})
        # 再次查詢 GPT : 根據所提供的網路搜尋結果回答
        reply='(正在搜尋網路...) ' + ask_gpt(messages)    
    else:  # 不需搜尋網路, 加入使用者提示詞
        reply=result['reply']
    # 記錄 GPT 回應到歷史訊息
    messages.append({'role': 'assistant', 'content': reply})
    print(f'GPT : {reply}') 

執行結果如下 : 

>>> %Run cli_chatbot_search_5.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 2024台灣總統大選是誰當選?
GPT : (正在搜尋網路...) {
  "need_search": "N",
  "keyword": "",
  "reply": "賴清德、蕭美琴當選中華民國第16任總統、副總統。"
}
You : 那 2018 年呢?
GPT : 蔡英文當選中華民國第14任總統。

雖然透過記錄對話歷史提供給模型做為回覆參考確實讓第二個問題 '那 2018 年呢?' 關聯到前一個問題, 但是第一個問題的回答卻是 JSON 格式, 這會讓使用者覺得很奇怪. 這是因為初次查詢 GPT 之前將填充提示詞後的 JSON 格式訊息存入 messages 變數所致 :

messages.append({'role': 'user', 'content': json_query_str})

由於 JSON 格式化回覆字串中, 不管模型知不知道答案都要求只能用 JSON 格式回覆, 導致在第二次查詢 GPT 時模型已知答案, 卻受限於這個限制而回覆 JSON 格式的答案 :

如果知道, 請只用下列 JSON 格式回答:
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}

所以解決之道就是已經放進 memory 變數中儲存的 json_query_str 訊息字典要在初次查詢 GPT 後從 messages 串列中移除, 以免第二次查詢 GPT 時限制了模型的回覆格式, 這並不影響對話歷史的紀錄, 對話歷史只要記錄使用者輸入的提示詞與模型回應即可, 修正後的程式如下 :

# cli_chatbot_search_6.py
from openai import OpenAI, APIError
import requests
import json

def ask_gpt(messages, model='gpt-3.5-turbo'):
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages
            )
        return reply.choices[0].message.content
    except APIError as e:
        return e.message

def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

# 設定金鑰變數
openai_key='你的 OpenAI API Key'
custom_search_key='你的 Custom Search JSON API Key'
cx='你的 Custom Search Engine ID'  # Custom Search Engine ID
# 建立 OpenAI 物件
client=OpenAI(api_key=openai_key)
# 設定 GPT 系統角色
sys_role=input('請設定 GPT 角色 : ')
if sys_role.strip() == '': 
    sys_role='你是繁體中文AI助理'
print(sys_role)

# 建立 JSON 格式回覆字串模板
template='''
請確認你是否知道下面這件事: 
{}
如果知道, 請務必只用下列 JSON 格式回答:
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}
如果不知道, 請務必只用下列 JSON 格式回答:
{{
  "need_search": "Y",
  "keyword": "你建議的搜尋關鍵字",
  "reply": ""
}}
'''
# 初始化聊天訊息 (用來記憶聊天歷史) 
messages=[{'role': 'system', 'content': sys_role}]

# 聊天機器人 (無窮迴圈)
while True: 
    prompt=input('You : ')  # 輸入提示詞
    if prompt.strip() == '':  # 按 Enter 跳出迴圈結束聊天
        print('GPT : 再見。')
        break
    # 記錄使用者輸入到歷史訊息
    messages.append({'role': 'user', 'content': prompt})
    # 初次查詢 GPT : 確定是否需要搜尋網路
    json_query_str=template.format(prompt)
    messages.append({'role': 'user', 'content': json_query_str})  # 為了規範模型回應格式
    reply=ask_gpt(messages)  # 呼叫 OpenAI API
    messages.pop()  # 移除這個判斷訊息避免影響聊天歷史
    result=json.loads(reply)  # 將 JSON 字串轉成字典
    if result['need_search']=='Y':  # 需要搜尋網路
        keyword=result['keyword']  # 取得 GPT 建議之搜尋關鍵字
        web_msg='以下是 Google 搜尋所得資料:\n'  # 初始化搜尋結果字串
        for item in search_google(keyword, cx, custom_search_key):
            web_msg += f'標題: {item["title"]}\n'
            web_msg += f'描述: {item["snippet"]}\n\n'
        web_msg += '請依據上述資料回答下列問題(**直接給出答案**):\n' + prompt
        # 記錄 Google 搜尋結果
        messages.append({'role': 'system', 'content': web_msg})
        # 再次查詢 GPT : 根據所提供的網路搜尋結果回答
        reply=ask_gpt(messages)    
    else:  # 不需搜尋網路, 加入使用者提示詞
        reply=result['reply']
    # 記錄 GPT 回應到歷史訊息
    messages.append({'role': 'assistant', 'content': reply})
    print(f'GPT : {reply}') 

主要的修改處有三 : 
  • 初次查詢 GPT 後呼叫串列的 pop() 方法移除之前放進去的 JSON 格式化回覆訊息字典.
  • 在製做谷歌搜尋結果字串時加入 "(**直接給出答案**)" 敘述, 避免模型回應時出現 「根據搜尋結果」或「從網路資料顯示」等字眼, 這也會讓使用者覺得奇怪. 
  • 刪除了 (正在搜尋網路...) 輸出, 讓使用者不會覺得原來是在 call out. 
執行結果如下 : 

>>> %Run cli_chatbot_search_6.py     
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 2024台灣總統大選是誰當選?
GPT : 賴清德當選為2024年台灣總統。
You : 那 2018 年呢?
GPT : 蔡英文當選為2018年台灣總統。
You : 那 2010 年呢?
GPT : 馬英九當選為2010年台灣總統。
You : 2024 台灣金曲歌王是誰?
GPT : 2024年台灣金曲歌王是MC HotDog(熱狗)。
You : 愛情是甚麼?
GPT : 愛情是強烈的依附和愛的感覺的發展,通常是對另一個人。
You : 請詳細說明
GPT : 愛情是一種複雜的現象,涉及生理、心理和主觀情感的結合。它可以帶來強烈的依附和愛的感覺,通常是對另一個人的特殊情感,使人處於一種脆弱而幸福的狀態。愛情的定義因文化、時代和個人觀點而異,但普遍被視為人類生活中重要的情感之一。
You : 甚麼是量子疊加態?
GPT : 量子疊加態是描述量子系統可能同時處於多個可能的狀態之間的情況,直到進行量測為止。
You : 請進一步說明.
GPT : 量子疊加態是指一個量子系統的幾個量子態進行線性組合後得到的狀態,與古典力學中只能處於單一狀態不同,量子系統具有不確定性,可以同時存在多個可能狀態的性質。
You : 
GPT : 再見。

可見這個改良版機器人能夠將 "那 2018 年呢?" 與 "那 2010 年呢?" 的問題關聯到前面 "2024台灣總統大選是誰當選?" 這個跟總統大選相關的提問; "請詳細說明" 也能理解指的是要針對 "愛情是甚麼?" 來發揮; 而 "請進一步說明" 則是指量子疊加態. 

沒有留言 :