2025年3月31日 星期一

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

本篇旨在將前面測試中所寫的命令列介面聊天機器人改成用 Gradio 實作的網頁介面版, 改寫的對象是下面這篇中的非串流版程式 cli_chatbot_search_6.py : 


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



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

Gradio 是一套簡單好用的 Python 網頁應用程式套件, 讓不懂網頁前端技術的 Python 程式設計師利用 Gradio 提供的元件快速搭建出 Web app 的原型, 完全不需要去學習 HTML, CSS, 與 Javascript 與熱門的前端框架 (例如 jQuery, React, Vue, Angular 等), 只要用 Python 即可搞定. HuggingFace 已經收購 Gradio 並擴充其資源, 例如可將 Gradio Playground 上開發的專案一鍵發布到 HuggingFace Space 主機上執行. 關於 Gradio 用法參考 :


Gradio 提供 Chatbot 元件用來製做具有聊天泡泡效果的介面, 同時也提供了 State 元件可用來自動儲存聊天歷史, 用法參考下面這篇 : 


下面是不使用 State 元件紀錄對話歷史, 而是自行用串列紀錄的範例程式 :

# gradio_chatbot_search_1.py
import gradio as gr
from openai import OpenAI, APIError
from dotenv import load_dotenv
import os
import json
import requests

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', [])

def chat(prompt):
    messages.append({'role': 'user', 'content': prompt})
    json_query_str=template.format(prompt)
    messages.append({'role': 'user', 'content': json_query_str})
    reply=ask_gpt(messages)  # 第一次查詢 GPT
    messages.pop()
    try:
        result=json.loads(reply)
    except Exception as e:
        messages.append({'role': 'assistant', 'content': '發生錯誤:GPT 回傳的格式無法解析'})
        return messages, ''
    if result['need_search']=='Y':  # 需要搜尋網路
        keyword=result['keyword']  # 取得 GPT 建議的
        web_msg='以下是 Google 搜尋所得資料:\n'  # 初始化搜尋結果字串
        for item in search_google(keyword, SEARCH_ID, 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})
    return messages, ''

# 從 .env 載入環境變數
load_dotenv()
# 從環境變數取得金鑰
OPENAI_KEY=os.environ.get('OPENAI_API')
SEARCH_KEY=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
SEARCH_ID=os.environ.get('SEARCH_ENGINE_ID')
# 建立 OpenAI 物件
client=OpenAI(api_key=OPENAI_KEY)
# 設定對話歷史初始值
sys_role=sys_role='你是繁體中文AI助理'
messages=[{'role': 'system', 'content': sys_role}]

# 建立 JSON 格式回覆字串模板
template='''
請確認你是否知道下面這件事: 
{}
如果知道, 請只用下列 JSON 格式回答(不要添加例如 ```json 的註記):
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}
如果不知道, 請只用下列 JSON 格式回答(不要添加例如 ```json 的註記):
{{
  "need_search": "Y",
  "keyword": "你建議的搜尋關鍵字",
  "reply": ""
}}
'''

with gr.Blocks(title="OpenAI 聊天機器人") as blocks:
    chatbot=gr.Chatbot(type='messages',
                       height=400,
                       placeholder='我們的對話',
                       show_copy_button=True)
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")    
    send_btn.click(chat, inputs=[prompt], outputs=[chatbot, prompt])
    prompt.submit(chat, inputs=[prompt], outputs=[chatbot, prompt])
blocks.launch()

這裡我們使用 dotenv 模組來隱藏金鑰與 ID 等資訊, 其用法參考 : 


由於在前面的測試中發現, 雖然在 template 回覆模板中有特別要求模型必須回應一個 JSON 字串, 但有時候它還是會用 " ```json" 與 " ```" 將 JSON 字串包起來, 導致使用 json.loads() 解析時出現例外, 所以這修改了 template 內容, 要求不要添加這些額外字串. 

其次, 由於除了按下 send_btn 按鈕發送提問之外, 直接在 Textbox 內按下 Enter 也會提交, 所以 prompt 的 submit() 也要處理, 內容與 send_btn 相同, 輸入變數只有 prompt 一個, 輸出則有兩個, 主要是第一個的 chatbot, 用來將對話歷程 messages 傳給 chatbot 顯示更新對話泡泡, 第二個輸出 prompt 是讓 chat() 傳回一個空字串清空提示詞輸入框以便使用者輸入下一個問題, 毋須手動清除, 執行結果如下 :








可見此聊天機器人確實能在詢問超出 gpt-3.5-turbo 模型知識範圍的 2024 台灣總統大選結果時透過谷歌搜尋取得網路資料做出正確回覆, 而且能透過歷史對話將 '那 2018 年呢?' 的詢問關聯到上一個關於總統大選的議題了解是在問 2018 年總統大選結果. 

不過在第一次詢問 "甚麼是量子疊加態" 時卻出現 GPT 回應格式錯誤, 檢查這時的 reply 傳回值為 :

```json
{
  "need_search": "N",
  "keyword": "",
  "reply": "在量子物理中,量子疊加態是一種現象,表示粒子同時處於多個可能的狀態的線性組合。這意味著粒子可以同時存在於不同的狀態,直到被觀察或測量後才會確定其實際狀態。"
}
```
可見即使 template 模板中有要求不要添加額外資訊, GPT 模型偶而還是會不聽使喚. 再次詢問就能得到正常回應了. 

上面的範例我們使用了一個全域變數 messages 來紀錄對話歷史, 也可以使用 Gradio 的 State 元件來記錄狀態, 它其實是包裝成 State 類型的串列, 用法與串列相同, 將上面的程式修改為如下之 State 版 :

# gradio_chatbot_search_1.py
import gradio as gr
from openai import OpenAI, APIError
from dotenv import load_dotenv
import os
import json
import requests

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', [])

def chat(prompt, messages):
    # 添加用戶輸入到對話歷史
    messages.append({'role': 'user', 'content': prompt})
    json_query_str=template.format(prompt)
    messages.append({'role': 'user', 'content': json_query_str})
    
    # 第一次查詢 GPT
    reply=ask_gpt(messages)
    messages.pop()  # 移除臨時的 json_query_str
    
    try:
        result=json.loads(reply)
    except Exception as e:
        messages.append({'role': 'assistant', 'content': '發生錯誤:GPT 回傳的格式無法解析'})
        return messages, messages, ''  # 更新 state, chatbot, prompt
    
    if result['need_search'] == 'Y':  # 需要搜尋網路
        keyword=result['keyword']
        web_msg='以下是 Google 搜尋所得資料:\n'
        for item in search_google(keyword, SEARCH_ID, SEARCH_KEY):
            web_msg += f'標題: {item["title"]}\n'
            web_msg += f'描述: {item["snippet"]}\n\n'
        web_msg += '請依據上述資料回答下列問題(直接給出答案):\n' + prompt
        messages.append({'role': 'system', 'content': web_msg})
        reply=ask_gpt(messages)  # 第二次查詢 GPT
    else:
        reply=result['reply']
    
    # 添加 GPT 回應到對話歷史
    messages.append({'role': 'assistant', 'content': reply})
    return messages, messages, ''  # 更新 state, chatbot, prompt

# 從 .env 載入環境變數
load_dotenv()
# 從環境變數取得金鑰
OPENAI_KEY=os.environ.get('OPENAI_API')
SEARCH_KEY=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
SEARCH_ID=os.environ.get('SEARCH_ENGINE_ID')
# 建立 OpenAI 物件
client=OpenAI(api_key=OPENAI_KEY)
# 設定系統角色
sys_role='你是繁體中文AI助理'
# 建立 JSON 格式回覆字串模板
template='''
請確認你是否知道下面這件事: 
{}
如果知道, 請只用下列 JSON 格式回答(不要添加例如 json 的註記):
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}
如果不知道, 請只用下列 JSON 格式回答(不要添加例如 json 的註記):
{{
  "need_search": "Y",
  "keyword": "你建議的搜尋關鍵字",
  "reply": ""
}}
'''

with gr.Blocks(title="OpenAI 聊天機器人") as blocks:
    chatbot=gr.Chatbot(type='messages',
                         height=400,
                         placeholder='我們的對話',
                         show_copy_button=True)
    state=gr.State([{'role': 'system', 'content': sys_role}])  # 使用 State 儲存對話歷史
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")
    
    # 將 chat 的輸入設為 prompt 和 state,輸出設為 state, chatbot, prompt
    send_btn.click(chat, inputs=[prompt, state], outputs=[state, chatbot, prompt])
    prompt.submit(chat, inputs=[prompt, state], outputs=[state, chatbot, prompt])

blocks.launch()

此例輸入有兩個, 一個式提示詞 prompt, 一個是狀態串列 state, 而 chat() 函式的傳回值則有 3 個, 新增的 state 用來更新 Blocks 區段內的對話歷史串列, 執行結果與上面的範例程式相同. 

上面的兩個範例中, 用來記錄對話歷史的不管是 messages 串列或 State 元件, 它們的長度都沒有限制, 會隨著對話持續增長. 下面的範例則是利用一個全域變數來限制其長度 (預設 10 個元素) :

# gradio_chatbot_search_3.py
import gradio as gr
from openai import OpenAI, APIError
from dotenv import load_dotenv
import os
import json
import requests

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', [])

def chat(prompt, messages):
    # 添加用戶輸入到對話歷史
    messages.append({'role': 'user', 'content': prompt})
    json_query_str=template.format(prompt)
    messages.append({'role': 'user', 'content': json_query_str})
    
    # 第一次查詢 GPT
    reply=ask_gpt(messages)
    messages.pop()  # 移除臨時的 json_query_str
    
    try:
        result=json.loads(reply)
    except Exception as e:
        messages.append({'role': 'assistant', 'content': '發生錯誤:GPT 回傳的格式無法解析'})
        return messages, messages, ''  # 更新 state, chatbot, prompt
    
    if result['need_search'] == 'Y':  # 需要搜尋網路
        keyword=result['keyword']
        web_msg='以下是 Google 搜尋所得資料:\n'
        for item in search_google(keyword, SEARCH_ID, SEARCH_KEY):
            web_msg += f'標題: {item["title"]}\n'
            web_msg += f'描述: {item["snippet"]}\n\n'
        web_msg += '請依據上述資料回答下列問題(直接給出答案):\n' + prompt
        messages.append({'role': 'system', 'content': web_msg})
        reply=ask_gpt(messages)  # 第二次查詢 GPT
    else:
        reply=result['reply']
    
    # 添加 GPT 回應到對話歷史
    messages.append({'role': 'assistant', 'content': reply})
    
    # 限制對話歷史長度,保留第一個元素(系統角色)
    if len(messages) > MAX_HISTORY_LENGTH:
        messages=[messages[0]] + messages[-(MAX_HISTORY_LENGTH - 1):]
    #print(messages)
    return messages, messages, ''  # 更新 state, chatbot, prompt

# 從 .env 載入環境變數
load_dotenv()
# 從環境變數取得金鑰
OPENAI_KEY=os.environ.get('OPENAI_API')
SEARCH_KEY=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
SEARCH_ID=os.environ.get('SEARCH_ENGINE_ID')
# 全域變數:限制對話歷史長度
MAX_HISTORY_LENGTH=10   
# 建立 OpenAI 物件
client=OpenAI(api_key=OPENAI_KEY)
# 設定系統角色
sys_role='你是繁體中文AI助理'
# 建立 JSON 格式回覆字串模板
template='''
請確認你是否知道下面這件事: 
{}
如果知道, 請只用下列 JSON 格式回答(不要添加例如 json 的註記):
{{
  "need_search": "N",
  "keyword": "",
  "reply":"你的答案"
}}
如果不知道, 請只用下列 JSON 格式回答(不要添加例如 json 的註記):
{{
  "need_search": "Y",
  "keyword": "你建議的搜尋關鍵字",
  "reply": ""
}}
'''

with gr.Blocks(title="OpenAI 聊天機器人") as blocks:
    chatbot=gr.Chatbot(type='messages',
                         height=400,
                         placeholder='我們的對話',
                         show_copy_button=True)
    state=gr.State([{'role': 'system', 'content': sys_role}])  # 使用 State 儲存對話歷史
    with gr.Column():
        prompt=gr.Textbox(label="您的詢問:")
        send_btn=gr.Button("送出")
    
    # 將 chat 的輸入設為 prompt 和 state,輸出設為 state, chatbot, prompt
    send_btn.click(chat, inputs=[prompt, state], outputs=[state, chatbot, prompt])
    prompt.submit(chat, inputs=[prompt, state], outputs=[state, chatbot, prompt])

blocks.launch()

此例在 chat() 函式末尾檢查對話歷史長度, 如果超過設定值就捨棄前面的最舊紀錄, 但保留第 1 個 system 角色設定, 執行結果如下 :





可見原先它還記得我家兩隻貓的名字, 但是當對話較長後關於貓咪的紀錄被丟棄, 所以它就不記得貓咪的名字了. 

2025-04-01 補充 : 

這一周測試下來儲值的 10 美元還剩下 9.74 美元 :




從去年以來 API 只用掉 0.26 美元, 折合台幣 9 元不到, 可能是我大部分都呼叫超便宜的 gpt-3.5-turbo 模型, 且大都還是文字聊天之故, 等進到圖片測試時就會較吃錢了. 

沒有留言 :