本篇旨在將前面測試中所寫的命令列介面聊天機器人改成用 Gradio 實作的網頁介面版, 改寫的對象是下面這篇中的非串流版程式 cli_chatbot_search_6.py :
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 模型, 且大都還是文字聊天之故, 等進到圖片測試時就會較吃錢了.
沒有留言 :
張貼留言