聊天機器人從去年就一直不斷成為新聞寵兒,不斷地出現在許多文章裡面,你可能也會好奇這是怎麼樣的趨勢?聊天機器人是怎麼被做出來的?今天就帶你瞭解它的發展以及帶你從0到1、從無到有做出一個簡單的聊天機器人。
為了方便,以下「聊天機器人」可能會有各種簡稱:機器人, chatbot, bot…等)
目錄
- 趨勢簡介:帶你了解聊天機器人為何會紅?和App有何不同?對於產業帶來的衝擊?
- 發現問題:合適的使用情境為何?
- 設計與開發:設計流程 和 如何開發。
- Demo:做一個我自己的履歷聊天機器人。
趨勢簡介

從上面這張圖可以知道,從2016年開始,Artificial Intelligence / Machine Learning / Bots不斷的發展起來,究竟是什麼原因造就這樣的現象?
為何會興起?
我的心得是有幾個原因綜合起來而產生的現象:聊天就是人與人最基本的互動方式、通訊軟體普及、大家天天都會用,加上近年通訊軟體公司紛紛推出SDK,讓開發者可以在他們的聊天平台上面開發不同的聊天機器人或應用程式,以及AI不斷的進步(例如:Deep Learning,AlphaGo大戰人類)、許多工具開源(例如:TensorFlow)…等,造就聊天機器人的崛起。
「聊天」是最基本的互動 + 平台SDK推出 + AI/ML進步
和App有何不同?
對於使用者來說,不需要去適應五花八門的介面,只需要用人和人最基本的互動方式:「聊天」就能夠完成事情,也省去傳統App下載、安裝或是更新的這些流程,你只需要下載聊天App即可,要叫車、要叫外送,跟Bot說一聲即可。
聊天機器人的出現「重新定義了使用者介面」。

對產業的衝擊?
這邊針對三個比較相關的角色:設計師、開發者、一般業者分別來說。
- 對設計師來說,不需要再設計複雜的介面,有人就會問說「聊天機器人會不會讓設計師失業?」我的想法是不會,這樣的發展會讓設計更聚焦在於互動設計和使用者體驗上,下圖顯示設計師更著重在於使用者和聊天機器人的互動設計上。

- 對於開發者來說,介面刻畫上會簡單一些,Bot仰賴語意分析和人工智慧,需要電腦從語句知道要做什麼動作,以及知道要如何完成。對於開發者來說,開發更接近人的語意分析能力和人工智慧,就是需要更著重的目標。 少了語意分析,就只能讓Bot依照一些特定指令走,使用者還要記怎麼打指令,這樣產品的使用者體驗一定多少會打折扣,以下截圖為在Slack上的Trellobot,我要使用它就需要依照指令打,整體使用體驗和操作未必比圖形介面來得方便。

- 對一般業者來說,可以有效整合多種產品,舉例:Luka.ai就整合美食搜尋、新聞、小遊戲、Wiki、搜尋等服務到同一個Bot,新功能或是問題修正也可以無痛更新給使用者。
發現問題
了解這波趨勢後,接下來我們可以思考兩個簡單的問題:
- 什麼樣的使用情境適合用聊天機器人來做?
- 我的產品或是問題是否適合?
以我目前的觀察,具有以下特性的產品或問題蠻適合用聊天機器人做的:
- 須經由問答、來回溝通才能完成。
- 問題或需求重複性高。
- 回答或處理流程固定。

什麼樣的產品或流程具有上述特性呢?我舉幾個簡單例子:
- 客服人員:使用者問題大致雷同,解決方式也都相對固定。
- 問卷:問題和回答選項都固定(除了自由回答之外)。
- 面試:面試者想問公司的問題(薪資、工作內容、工作地點和時間等)大致都相同。
- 履歷:承上,面試的時候自我介紹、工作或是專案經驗等等也都是大同小異,接下來我將會把我自己的履歷文件轉換成聊天機器人作為範例,後面文章所講解的設計和開發都會以履歷聊天機器人為題。

設計聊天機器人
在設計流程上,其實跟一般傳統App或者網頁雷同,都是要經歷以下的流程,其中又以前三個最為重要,下面分別來描述:

- 需求分析:履歷聊天機器人最大的需求就是可以先代替我本人回答人資或是面試官最基本的問題,回答完如果對方有興趣聯絡我,可以提供我的聯絡資訊。
- Functional Map: 這個階段要把所有功能都定義出來,在我過往去面試、或是面試人的經驗當中,很多問題是不斷的被問和問人,所以那些常見基本問題問答就可以列成我Bot的功能之一,再加上一些其他自我介紹,讓有興趣的人可以更了解我,最後還有提供聯絡我或是工作邀約等功能。

- Conversational Flow (v.s. UI Flow): 在一般App設計流程中,這個步驟是UI Flow,就是要設計整個App的使用流程,一開始要顯示哪個畫面、做什麼動作會發生什麼或是到其他的畫面…等,而聊天機器人則是要定義Conversational Flow,定義使用者要怎麼使用Bot,要如何和Bot互動,換句話說,就是要設計處理流程、對話劇本。

Design Guideline
這邊我提供幾個簡單又重要的設計準則給大家參考,可以讓你的聊天機器人不至於陷入很差的使用者體驗:
Onboarding
當使用者一開始接觸到你的聊天機器人的時候,就要讓使用者了解到你的聊天機器人有提供哪些服務或者功能,以及要如何開始使用這些功能,以免使用者面對空白的聊天界面因為沒有足夠的提示而不知所措。

有所回應
千萬不要已讀不回,你要想辦法讓機器人不論在哪種情況下,都會回應使用者,不要讓使用者覺得機器人壞掉了。
互動 + 心理學
我們可以把聊天機器人的對答設計的比較人性化、不會死板板的機器對答,這就可以多參考互動設計和心理學。
平台最佳化
每個聊天平台都會提供不同的互動、介面和元件,你可以依照這些東西來設計適合你資訊呈現的方式或者互動,例如:快速選單、常駐功能表、列表清單等等。

開發聊天機器人
完成定義功能以及設計好流程之後,我們就可以準備來寫程式做出來,這個聊天機器人是做在Facebook Messager上,詳細的起始設定可以參考我之前的文章,這邊不再多做說明。
首先先從整個程式架構開始說起,一個聊天機器人概括架構如下圖所示,以下會分別搭配程式各部分一起解釋:

聊天平台
目的為串接不同的聊天平台介面,像是:Facebook Messager, LINE, Slack, Telegram…等等,處理訊息的傳遞和接收,這一層獨立出來讓我們可以很快的把相同功能在不用更動程式主邏輯的情況下做在不同的聊天平台上,直接抽換聊天平台這一層即可。
訊息接收端程式碼如下:
| @app.route(config.web_hook_url, methods=["POST"]) | |
| def receive_message(): | |
| message_entries = json.loads(request.data.decode("utf-8")) | |
| print("message_entries:", message_entries) | |
| for entry in message_entries["entry"]: | |
| for message in entry["messaging"]: | |
| sender = message["sender"]["id"] | |
| # print("sender:", sender) | |
| if chat_thread.get(sender, None) is None: | |
| bot = Bot(sender) | |
| # detect language | |
| user_req = requests.get("https://graph.facebook.com/v2.6/{user_id}?access_token={token}" | |
| .format(user_id=sender, token=config.access_token)) | |
| if user_req.status_code == 200: | |
| bot.locale = user_req.json()["locale"] | |
| chat_thread[sender] = bot | |
| else: | |
| bot = chat_thread[sender] | |
| if message.get("message"): | |
| print("\tmessage:", message["message"]) | |
| bot.receive_message(message) | |
| elif message.get("postback"): | |
| print("\tpostback:", message["postback"]) | |
| bot.receive_postback(message) | |
| return Response(status=200) |
如果你對上述程式碼完全不知道是怎麼一回事,可以參考 我前一篇文章。
接收端會先判斷目前對話的使用者是否為新使用者,如果是新使用者,則建立一個Bot實體來處理他的請求,並且判斷該使用者的語系為何。 接收來的訊息可能為純文字 message 或者為按鈕的事件 postback,Bot就依照接收到不同的資料來做處理。
而訊息傳送端我已經將幾個常用的方法封裝成 API套件 (PyMessager),可以直接import使用。(詳細使用方法參考連結的README,也歡迎你不吝惜給星!)
聊天機器人本體
目的就是定義整個Bot的使用流程、Domain Knowledge、做為每個元件的控制中心。 在上面設計流程的Conversational Flow,就是在Bot本體裡面實作,這邊我們要把履歷問答處理機制做進來。
| #!/usr/bin/python3 | |
| # -*- encoding: utf-8 -*- | |
| import configparser | |
| import time | |
| from enum import Enum | |
| from nlp_parser import parse_sentence, Intent | |
| from message import Messager, QuickReply, GenericElement, ActionButton, ButtonType | |
| import config | |
| import api | |
| __author__ = "Engine Bai" | |
| class Bot(object): | |
| def __init__(self, sender, locale="zh_TW"): | |
| self.sender = sender | |
| self.locale = locale | |
| self._config = configparser.ConfigParser() | |
| self._config.read("res/strings") | |
| self._reset_context() | |
| self._context_intent = Intent.HELP.name | |
| self._messager = Messager(config.access_token) | |
| def receive_message(self, message_payload): | |
| current_intent = parse_sentence(message_payload["message"]) | |
| if current_intent == self._context_intent: # don't handle duplicate intent | |
| return | |
| else: | |
| self._context_intent = current_intent | |
| self._handle_intent(message_payload) | |
| def receive_postback(self, message_payload): | |
| current_intent = message_payload["postback"]["payload"] | |
| if current_intent == self._context_intent: | |
| return | |
| else: | |
| self._context_intent = current_intent | |
| self._handle_intent(message_payload) | |
| def _handle_intent(self, message_payload): | |
| print("Handle intent", self.sender, self._context_intent, message_payload) | |
| if self._context_intent == Intent.HELP.name or self._context_intent == Intent.CONFUSED.name: | |
| self.send_help() | |
| elif self._context_intent == Intent.PROJECTS.name: | |
| projects = api.data["projects"] | |
| project_list = [] | |
| for project_id in projects.keys(): | |
| project = projects[project_id] | |
| project_list.append(GenericElement(project["title"], project["description"], | |
| config.api_root + project["image_url"], [ | |
| ActionButton(ButtonType.POSTBACK, | |
| self._get_string("button_more"), | |
| # Payload用Intent本身作為開頭 | |
| payload=Intent.PROJECTS.name + project_id) | |
| ])) | |
| self._messager.send_generic(self.sender, project_list) | |
| elif self._context_intent == Intent.ARTICLES.name: | |
| self.send_link_list("articles") | |
| elif self._context_intent == Intent.ADVANTAGES.name or self._context_intent == Intent.PERSONALITY.name: | |
| contents = api.data[self._context_intent.lower()] | |
| for content in contents: | |
| self.send_contents(content) | |
| self.next() | |
| elif | |
| # 略... | |
| pass | |
| else: | |
| if self._context_intent is None: | |
| self._context_intent = Intent.CONFUSED.name | |
| self.send_help() | |
| # 直接從Payload來的 | |
| elif self._context_intent.startswith(Intent.PROJECTS.name): | |
| project = api.data[Intent.PROJECTS.name.lower()][self._context_intent.replace(Intent.PROJECTS.name, "")] | |
| for detail in project["detail"]: | |
| self.send_contents(detail) | |
| self.next() | |
| def send_help(self, restart=False): | |
| """ | |
| 傳送幫助資訊 | |
| :param restart: | |
| :return: | |
| """ | |
| self._messager.send_quick_replies(self.sender, | |
| self._get_string("title_help_restart") | |
| if restart else self._get_string("title_help"), [ | |
| QuickReply(self._get_string("button_works_primary"), | |
| Intent.WORKS_PRIMARY.name), | |
| QuickReply(self._get_string("button_works_secondary"), | |
| Intent.WORKS_SECONDARY.name), | |
| QuickReply(self._get_string("button_advantages"), Intent.ADVANTAGES.name), | |
| QuickReply(self._get_string("button_personality"), | |
| Intent.PERSONALITY.name), | |
| QuickReply(self._get_string("button_contact_me"), Intent.CONTACT_ME.name) | |
| ]) | |
| def short_break(self, sleep=1): | |
| """ | |
| 每一個句子之間的停頓 | |
| :param sleep: 停頓秒數,預設為1秒 | |
| :return: | |
| """ | |
| self._messager.typing(self.sender) | |
| time.sleep(sleep) | |
| def next(self): | |
| """ | |
| 在每一個段落之後,可以短暫停留然後繼續問說下一步 | |
| :return: | |
| """ | |
| self.short_break(2) | |
| self.send_help(True) | |
| def send_link_list(self, data_payload): | |
| """ | |
| 傳送列表資料 | |
| :param data_payload: | |
| :return: | |
| """ | |
| data = api.data[data_payload] | |
| items_list = [] | |
| for item_id in data.keys(): | |
| item = data[item_id] | |
| links = item["link"] | |
| action_buttons = [] | |
| for i in range(len(links)): | |
| action_buttons.append(ActionButton(ButtonType.WEB_URL, self._get_string("button_link") + | |
| (str(i + 1) if len(links) > 1 else ""), links[i])) | |
| items_list.append(GenericElement(item["title"], item["description"], | |
| config.api_root + item["image_url"], action_buttons)) | |
| self._messager.send_generic(self.sender, items_list) | |
| self.next() | |
| def send_contents(self, detail): | |
| """ | |
| 傳送一般語句和圖片或連結 | |
| :param detail: | |
| :return: | |
| """ | |
| self.short_break() | |
| if "message" in detail: | |
| self._messager.send_text(self.sender, detail["message"]) | |
| elif "image_url" in detail and "link" not in detail: | |
| self._messager.send_image(self.sender, config.api_root + detail["image_url"]) | |
| elif "title" in detail and "link" in detail: | |
| self._messager.send_generic(self.sender, [ | |
| GenericElement(detail["title"], detail["subtitle"], config.api_root + detail["image_url"], [ | |
| ActionButton(ButtonType.WEB_URL, detail["button"], detail["link"]) | |
| ])]) | |
| def _reset_context(self): | |
| self._current_task = None | |
| self._current_data = None | |
| self._context_intent = Intent.HELP.name | |
| def _get_string(self, key): | |
| """ | |
| 從文字檔取得句子 | |
| :param key: | |
| :return: | |
| """ | |
| return self._config[self.locale][key] |
上面程式可以看到Bot建立的時候就指定一個傳送者 sender 和目前意圖 _context_intent ,意圖會隨著你接收到的語句而改變,Bot會處理一般訊息和按鈕事件,然後在做出對應的事件 _handle_intent。
NLP (Natural Language Processing) 自然語言處理
這邊包括處理使用者輸入後的語意分析,讓電腦可以知道使用者要做什麼動作。
我直接選用FB後來收購的 Wit.ai 來做語言解析,這邊Wit.ai是使用關鍵字辨識的方式去做語意分析,這樣關鍵字辨識的方式,你一開始提供的越多種講法、新增越多同義字,它就可以越精準辨識,然後使用機器學習再去擴充。在我們完成語料庫之後,就可以用Wit.ai SDK把這些東西串到我們的bot裡面去做簡單的語意分析。 整合方式蠻簡單的,可以見下列程式碼:
| class Intent(Enum): | |
| HELP = "help" | |
| WORKS_PRIMARY = "works_primary" | |
| WORKS_SECONDARY = "works_secondary" | |
| PROJECTS = "projects" | |
| OPEN_SOURCES = "open_sources" | |
| JOBS = "jobs" | |
| ARTICLES = "articles" | |
| SPEECH = "speech" | |
| REPORT = "report" | |
| ADVANTAGES = "advantages" | |
| PERSONALITY = "personality" | |
| CONTACT_ME = "contact_me" | |
| CONFUSED = "confused" | |
| def parse_sentence(message): | |
| if "quick_reply" in message: | |
| print("quick_reply", message["quick_reply"]) | |
| return message["quick_reply"]["payload"] | |
| elif "text" in message: | |
| resp = wit_client.message(message["text"]) | |
| print("wit", resp["entities"]) | |
| print("wit", resp) | |
| intents = resp["entities"]["intent"] | |
| intent_values = set() | |
| # add intent values with high confidence | |
| for intent in intents: | |
| if float(intent["confidence"]) > 0.9: | |
| print("intent value", intent["value"]) | |
| intent_values.add(intent["value"]) | |
| print(intent_values) | |
| if {"嗨"} <= intent_values: | |
| return Intent.HELP.name | |
| elif {"報導"} <= intent_values: | |
| return Intent.REPORT.name | |
| elif {"專案"} <= intent_values: | |
| return Intent.WORKS_PRIMARY.name | |
| else: | |
| return Intent.HELP.name |
上面包括定義我們的意圖 Intent 以及串接 wit.ai SDK,每一個語意辨識結果都會有一個「信心指數」表示說目前將輸入的語句辨識出某一個意圖的可信程度有多少,我們這邊把超過0.9的意圖值全部加到一個集合內,然後用關鍵字組合去判斷最終的意圖為何。
回應語句
Bot針對不同情境需要產生不同回答,當某個情境發生時,就觸發某個回答,這邊我們用一個語句庫來實作,語句用key-value形式儲存,我們bot有需要支援不同語系,這樣儲存方式也適合擴充到多語系。
[zh_TW]
title_help = 想從哪方面開始聊?
title_help_restart = 還想知道哪些方面?
button_works_primary = 專案和工作經歷
button_works_secondary = 其他經歷
button_advantages = 專長優勢
...略
[en]
...略
人工智慧
我這個聊天機器人沒有包含人工智慧,跳過不多做講解。
Demo
Bot提交審核中,先附上幾個截圖:

Great topics.
我的email: 294pro@gmail.com
有機會請你當講師? (以下主題,看你方便)
主題-1: 如何應用FB的 Chatbot Graph API.
主題-2: 如何作一個FQA Chatbot:
(方式:ex: (1) call Google sheet API (2) call 微軟的QnA maker的API
(3)其他方法.
Thanks.
陳柏村Mano Chen
AI-Bot 百種商務應用-千人講師培訓-百萬人應用 計畫推動創始人
嗨,歡迎,我比較有興趣是主題 2 FAQ Chatbot,再寫信聯繫你,謝謝。