iT邦幫忙

2023 iThome 鐵人賽

DAY 23
3
AI & Data

LLM 學習筆記系列 第 23

LLM Note Day 23 - LangChain 中二技能翻譯

  • 分享至 

  • xImage
  •  

簡介

除了下層的推論框架以外,也有非常多人在關注上層的應用開發,其中最炙手可熱的當屬 LangChain 框架。當我們開始實際使用 LLM 開發相關應用程式時,會面臨到許多雜七雜八的 Prompt 樣板、重複的生成程序以及固定的串接流程等等。

多數情況下,這些樣板、流程切開來看,都不是很複雜的程式,自己動手寫也都不難寫。但是當不同的流程開始堆疊成更大的應用時,一切就會顯得十分混亂。這時就能借助 LangChain 框架來幫我們進行更好的程式碼管理。

話雖這麼說,但其實我也是剛把 pip install langchain 放下去跑而已 😅

老實說這類的應用框架,一直是筆者的心魔。一方面本業的工作內容都是研究與實驗的性質居多,比較少觸碰到完整的應用。二方面 LangChain 對我來說是個全新的框架,但功能都是既有的東西,泥漿腦如我就是不想改變思考模式 😭

於是這次給自己一個挑戰 LangChain 的機會,如果想看 LangChain Pro 的可能可以上一頁了,因為這篇文只有破破的 LangChain 小菜鳥 🥲

可愛貓貓 Day 23

題目

如果我有三百年的時間,我願意把 LangChain 的文件從頭到尾讀一遍。但是在只有幾天的時間裡(具體而言撰寫這篇文章花了三天),我決定選擇先設定一個題目,並想辦法把這個題目 Chain 起來,可能是個快速學習最好的方式。

而我選擇的題目是:中二技能翻譯系統。

資料來源

製作一個中二技能翻譯系統,勢必需要一些中二的翻譯資料。但是哪裡可以獲得中二的資料呢?透過 Riot API 取得英雄聯盟的技能資料看來是個不錯的選擇!筆者先手動取一些樣本展示這個中二系統的核心概念:

請根據以下範例,產生一個中二的技能翻譯。

Source: The Darkin Blade
Target: 冥血邪劍

Source: Infernal Chains
Target: 冥府血鏈

Source: Umbral Dash
Target: 冥影衝鋒

Source: World Ender
Target: 劍魔滅世

Source: Lightning Slash In The Dark
Target:

將這份 Prompt 丟進 ChatGPT 測試,得到一個「冥雷暗破」的翻譯,有那麼一丟丟要成為闇影強者的感覺了!完整對話可以參考此分享連結

資料爬取

官方文件其實描述的滿詳細,我們需要先獲取全部的英雄列表,然後根據英雄名稱一一抓取各自的資料。我們先手動抓取全部的英雄列表,可以從以下連結獲得:

http://ddragon.leagueoflegends.com/cdn/13.19.1/data/en_US/champion.json

其中網址裡面的 13.19.1 是遊戲版本,如果懷念菲歐拉的舊版大絕名稱,也可以回溯到更早的版本去抓取。而 en_US 代表語言是英文的意思,因為英雄的索引也都是用英文,所以我們確實需要英文資料。

然後我們開始爬取英雄的技能資料,個別英雄資料的 API 格式如下:

http://ddragon.leagueoflegends.com/cdn/13.19.1/data/<lang>/champion/<champ>.json

<lang> 為語言代號,而 <champ> 為英雄英文名。我們會分別爬取 en_USzh_TW 兩種語言的資料,爬取的程式碼如下:

import json

import requests

with open("champion.json", "rt", encoding="UTF-8") as fp:
    data = json.load(fp)

cdn_url = "http://ddragon.leagueoflegends.com/cdn/13.19.1/data"
for champ in data["data"].keys():
    for lang in ["en_US", "zh_TW"]:
        url = f"{cdn_url}/{lang}/champion/{champ}.json"
        resp = requests.get(url)

        fn = f"data/{lang}/{champ}.json"
        with open(fn, "wt", encoding="UTF-8") as fp:
            fp.write(resp.text)

註:官方也有提供其他語言的資料,可以參考這個章節

接著我們依序拜訪造型外觀名稱、主動技能名稱、被動技能名稱,這些都是中二要素的來源。這些英雄資料其實還包含背景故事和技能敘述這種敘事類的資料,也能拿來做其他應用。建立中英技能資料集的程式碼如下:

import json


def load_json(file_path):
    with open(file_path, "rt", encoding="UTF-8") as fp:
        return json.load(fp)


def create_item(source, target):
    return {"source": source, "target": target}


datasets = list()
data: dict[str, dict] = load_json("champion.json")

for champ in data["data"].keys():
    # 讀取英雄資料
    en_data = load_json(f"data/en_US/{champ}.json")
    zh_data = load_json(f"data/zh_TW/{champ}.json")

    # 外觀造型名稱
    en_skins = en_data["data"][champ]["skins"]
    zh_skins = zh_data["data"][champ]["skins"]

    # 跳過經典造型
    for en_sk, zh_sk in zip(en_skins[1:], zh_skins[1:]):
        en_sk_name = en_sk["name"]
        zh_sk_name = zh_sk["name"]
        datasets.append(create_item(en_sk_name, zh_sk_name))

    # 主動技能名稱
    en_spells = en_data["data"][champ]["spells"]
    zh_spells = zh_data["data"][champ]["spells"]
    for en_sp, zh_sp in zip(en_spells, zh_spells):
        en_name, zh_name = en_sp["name"], zh_sp["name"]
        datasets.append(create_item(en_name, zh_name))

    # 被動技能名稱
    en_pass = en_data["data"][champ]["passive"]["name"]
    zh_pass = zh_data["data"][champ]["passive"]["name"]
    datasets.append(create_item(en_pass, zh_pass))

with open("datasets.json", "wt", encoding="UTF-8") as fp:
    json.dump(datasets, fp, ensure_ascii=False, indent=4)

到這裡完成資料集的建立,接下來就進入 LangChain 的部分!

LangChain

LangChain 其實很講究元件間的互動,筆者認為搞懂互動之前,鐵定要先搞懂元件!在 LangChain 裡面的元件大多都是可以獨立運作的,因此接下來會介紹每個元件的獨立用法。

LLM

LangChain 的優點之一在於整合了相當多相關 API 的介面,讓我們可以用比較統一的介面去操作來自 OpenAI, ggml, vLLM 或 TGI 的 LLM 後端。這裡筆者選擇 Hugging Face Text Generation Inference 做推論:

from langchain.llms import HuggingFaceTextGenInference

llm = HuggingFaceTextGenInference(
    inference_server_url="http://localhost:8080/",
    max_new_tokens=64,
    do_sample=True,
    top_k=50,
    top_p=0.95,
    temperature=0.5,
    truncate=512,  # 截斷 512 Tokens 以前的輸入
)

在此之前,需要先將 TGI Service 跑起來,可以參考筆者的 TGI 介紹文章。模型選擇的部份,可以考慮 Taiwan Llama 以及最近出的 CKIP Llama 等模型,另外像是 Vicuna 也是可以。因為這只是個趣味性質的應用,所以對模型的效能沒有太大的講究。

在 LangChain 裡面 LLM 的基本用法如下:

llm("### USER: 什麼是語言模型?\n### ASSISTANT: ", stop=[","])
# Output: '語言模型是一種人工智慧模型'

可以搭配 Callback 進行 Streaming 輸出:

from langchain.callbacks import StreamingStdOutCallbackHandler

cb = StreamingStdOutCallbackHandler()
llm("### USER: 什麼是語言模型?\n### ASSISTANT: ", callbacks=[cb])

這個 callbacks 參數其實也能放在 LLM 初始化裡面。

Prompt Template

在 LangChain 裡面可以使用 Prompt Template 來管理不同格式的樣板,其用法如下:

from langchain.prompts import PromptTemplate

template = "### USER: {query}\n### ASSISTANT: "
prompt = PromptTemplate.from_template(template)

prompt.format(query="什麼是語言模型?")
# Output: '### USER: 什麼是語言模型?\n### ASSISTANT: '

可以將他當成一般字串丟進 LLM 使用:

llm(prompt.format(query="什麼是語言模型?"))

但是在 LangChain 裡面,我們可以用更 "Chain" 的做法:

chain = prompt | llm

chain.invoke({"query": "什麼是語言模型?"})

很酷吧!居然用 | 運算子來描述元件間的互動,相當有意思。也可以用 LLMChain 類別來建立 Chain 物件:

from langchain.chains import LLMChain

chain = LLMChain(llm=llm, prompt=prompt, verbose=True)
chain.invoke({"query": "什麼是語言模型?"})

使用 LLMChain 的好處是能夠使用 verbose 參數來對實際的 Prompt 進行除錯:

Verbose

如上圖所示,這樣比較能讓開發者確認最後送出的 Prompt 是否如預期。

如果是 Retrieval QA 的話,其 Template 可能長的像這樣:

from langchain.prompts import PromptTemplate

template = """
### INSTRUCTION: 請根據 REF 回答 USER 的問題
### REF: {reference}
### USER: {query}
### ASSISTANT: """
prompt = PromptTemplate.from_template(template)

chain = prompt | llm

data = {
    "reference": "小明每天從板橋通勤到新店工作",
    "query": "請問小明下班會去哪裡?",
}
chain.invoke(data)
# Output: '小明會從工作地點搭乘捷運回到板橋'

其實基本的 Prompt Template 跟一般的 Format String 用起來沒什麼太大的不同,但是透過這個類別就能跟其他元件 "Chain" 起來。

Embedding Model

Embedding Model 也是 LangChain 另外一個整合相當豐富的部分,有各式各樣的 Embedding 框架可以使用。這裡筆者選擇 Tensorflow Hub 的 Universal Sentence Encoder 做使用:

from langchain.embeddings import TensorflowHubEmbeddings

url = "https://tfhub.dev/google/universal-sentence-encoder-multilingual-large/3"
embeddings = TensorflowHubEmbeddings(model_url=url)

基本用法大致如下:

import numpy as np

texts = ["dog", "狗", "cat", "貓"]
embs = embeddings.embed_documents(texts)
np.inner(embs, embs)

Vector Store

在 LangChain 裡面可以將 Embedding 存在向量儲存空間裡面,這些存放向量的地方稱為Vector Store。其功用不僅是存放向量,更大的功用在於搜尋向量,例如 Faiss 就是個可以存放與搜尋向量的套件,這裡必須搭配 Embeddings 類別一起使用:

from langchain.vectorstores import FAISS

texts = ["dog", "狗", "cat", "貓"]
faiss_store = FAISS.from_texts(
    texts=texts,
    embedding=embeddings,
)

Vector Store 會自動透過 Embeddings 類別將文字轉為向量並存起來,接下來就可以直接查詢並取得語意相似的結果:

results = faiss_store.search(
    query="고양이",  # 韓文的「貓」
    search_type="similarity",
    k=2,  # 取兩筆結果
)

for res in results:
    print(res.page_content)

# Outputs: 貓, cat

參數 k 用來指定我們要搜尋幾筆結果,然後 Faiss 就會依照相似度排序並回傳結果。

說到搜尋就要提一下我們的老朋友 BM25 好夥伴:

from langchain.retrievers import BM25Retriever

bm25 = BM25Retriever.from_texts(texts)
bm25.get_relevant_documents("cat")

在 LangChain 裡面,BM25 屬於 Retriever,而 Faiss 屬於 VectorStores,但兩者都具有搜尋的功能,我們該如何一起使用呢?這時可以透過 EnsembleRetriever 類別來處理,首先我們需要先將 Faiss 轉換成 Retriever 類別:

faiss = faiss_store.as_retriever()

然後再將 BM25 與 Faiss 一起丟進 Ensemble 裡面:

from langchain.retrievers import EnsembleRetriever

ensemble = EnsembleRetriever(
    retrievers=[faiss, bm25],
    weights=[0.75, 0.25]
)

ensemble.get_relevant_documents("고양이")

這樣在搜尋時就可以同時參考兩種 Retrievers 的結果了。

Vector Store 的種類相當繁多,請根據自身應用的場景做選擇。因為筆者的資料其實沒很多,所以這個應用只需要 Faiss 就足夠了。

Few-Shot Prompt Template

在本應用中,會使用到 Few-Shot 的技巧來建立完整的 Prompt,因此需要用到 FewShotPromptTemplate 類別。在使用此類別之前,我們需要使用基本的 Prompt Template 來定義每個 Sample 的格式:

examples = [
    {"source": "hello", "target": "哈囉"},
    {"source": "goodbye", "target": "再見"},
]

example_prompt = PromptTemplate(
    input_variables=["source", "target"],
    template="Source: {source}\nTarget: {target}",
)

print(example_prompt.format(**examples[0]))

"""
Source: hello
Target: 哈囉
"""

接著以這個 Template 為基礎來建立 Few-Shot Prompt Template 的內容:

from langchain.prompts.few_shot import FewShotPromptTemplate

prompt = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_prompt,
    prefix="請將以下單字翻譯成中文",
    suffix="Source: {input}\nTarget: ",
    example_separator="\n===\n",
    input_variables=["input"],
)

print(prompt.format(input="Starburst Stream"))

"""
請將以下單字翻譯成中文
===
Source: hello
Target: 哈囉
===
Source: goodbye
Target: 再見
===
Source: Starburst Stream
Target:
"""

Example Selector

我們已經知道如何將 Prompt 與 LLM 串在一起了,現在需要煩惱的部份是 Query => Retrieval => Few-Shot 這段流程該如何串接起來,這個部份我們需要 Example Selector 幫忙:

import json

from langchain.embeddings import TensorflowHubEmbeddings
from langchain.prompts.example_selector import (
    SemanticSimilarityExampleSelector as Selector,
)
from langchain.vectorstores import FAISS

with open("datasets.json", "rt", encoding="UTF-8") as fp:
    datasets: dict[str, str] = json.load(fp)

hub_url = "https://tfhub.dev/google/universal-sentence-encoder-multilingual-large/3"
embeddings = TensorflowHubEmbeddings(model_url=hub_url)

example_selector = Selector.from_examples(
    examples=datasets,
    embeddings=embeddings,
    vectorstore_cls=FAISS,
    k=10,
)

Example Selector 幫我們把取 Embedding 和存 Embedding 的動作都封裝起來了,基本的查詢方法如下:

example_selector.select_examples({"query": "Dark"})

"""
查詢結果:
[{'source': 'Darkness Rise', 'target': '暗崛'},
 {'source': 'Dark Matter', 'target': '黑暗物質'},
 {'source': 'Looming Darkness', 'target': '闇黑迫近'},
 {'source': 'Dark Binding', 'target': '暗影禁錮'},
 {'source': 'Shroud of Darkness', 'target': '夜幕庇護'},
 {'source': 'Dark Sphere', 'target': '黑暗星體'},
 {'source': 'Black Shield', 'target': '黑暗之盾'},
 {'source': 'Dark Passage', 'target': '鬼影燈籠'},
 {'source': 'Shadow Dash', 'target': '影襲'},
 {'source': 'Piercing Darkness', 'target': '刺骨幽闇'}]
"""

OK,到這邊萬事具備,只欠東風,接下來就來看看如何將這些東西 Chain 在一起!

Chain

首先是讀取資料集的部份,與 Example Selector 提供的程式碼是一樣的,這裡不再重複一次。然後是建立樣板:

from langchain.prompts import PromptTemplate
from langchain.prompts.few_shot import FewShotPromptTemplate

# 建立 Example Template
example_prompt = PromptTemplate(
    input_variables=["source", "target"],
    template="Source: {source}\nTarget: {target}",
)

# 建立 Few-Shot Template
prompt = FewShotPromptTemplate(
    example_selector=example_selector,
    example_prompt=example_prompt,
    suffix="Source: {query}\nTarget: ",
    prefix="請根據以下範例,產生一個中二的技能翻譯。",
    input_variables=["query"],
)

最後把 LLM 串上去!

from langchain.chains import LLMChain
from langchain.llms import HuggingFaceTextGenInference

# 初始化 LLM
tgi_url = "http://localhost:8080/"
llm = HuggingFaceTextGenInference(
    inference_server_url=tgi_url,
    max_new_tokens=128,
    do_sample=True,
    temperature=1.0,
    truncate=1000,
    stop_sequences=["\n"],
)

# Chain!
chain = LLMChain(llm=llm, prompt=prompt, verbose=True)

順便使用 Gradio 建立一個簡單的 Demo 網頁:

import gradio as gr


with gr.Blocks() as app:
    source = gr.Textbox(label="Source")
    target = gr.Textbox(label="Target")

    def send(source):
        result = chain.invoke({"query": source})
        return result["text"]

    source.submit(send, source, target)

app.launch()

使用起來的效果如下:

Demo

噓!

完整的程式碼放在 GitHub 上面,各位有興趣的話可以嘗試自己架起來完看看 👍

結論

實際使用 LangChain 搭建完一個 Demo 後,總算是對這個熱門的框架有些微薄的理解。這個框架的確將 LLM 應用中的每個元件與行為都封裝的很好,而且使用了 Chain 的概念來將元件串聯起來,是個非常獨特的思維。幸好 LangChain 真的很簡單好上手,才沒有讓這篇文章開天窗 XD

LangChain 的易用與靈活,加上活躍的社群,未來會發展成什麼樣子也是相當令人期待!

其他值得參考的應用框架包含 Guidance, LMQL, Semantic Kernel 等等,給大家參考。

參考


上一篇
LLM Note Day 22 - 任務導向聊天機器人 TOD Chatbot
下一篇
LLM Note Day 24 - 語言模型微調 LLM Finetuning
系列文
LLM 學習筆記33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
effytseng
iT邦新手 5 級 ‧ 2024-02-14 01:03:09

您好,先謝謝大大的文章分享~
不好意思,有問題想請教:
請問文中的:
chain = prompt | llm
這個 | 運算符是指什麼意思呢?不好意思我用 | 運算子 python 這幾個關鍵字下去搜尋似乎都找不到...
感覺是很粗淺的新手問題,不好意思><~
謝謝!

Penut Chen iT邦研究生 5 級 ‧ 2024-02-14 07:02:13 檢舉

你好,在這裡 | 是一種 Bitwise Operator

此運算子會在位元層級進行 OR 運算,例如:

x = 0b1100 | 0b1010
print(f"0b{x:b}")  # Output: 0b1110

推薦可以參考看看這篇文章

在 Python 中,可以透過定義類別的 __or__ 方法來自訂 | 運算的行為,例如:

class Hello:
    def __init__(self, value):
        self.value = value

    def __or__(self, other):
        print(f"or self={self}, other={other}")

    def __repr__(self):
        return f"Hello({self.value})"


h1 = Hello(1)
h2 = Hello(2)
h1 | h2  # Output: or self=Hello(1), other=Hello(2)

而在 LangChain 框架裡面 chain = prompt | llm 這段敘述,其實更像是 Command Line 操作裡面 Pipeline 的概念,將前者的內容當作後者的輸入。

以上解釋希望有所幫助,新年快樂!

effytseng iT邦新手 5 級 ‧ 2024-02-15 01:18:19 檢舉

哇! 原來如此~~~
好詳細的解釋啊!!! 很清楚~~~~~
謝謝大大!!! 辛苦了過新年還回問題><~~
祝大大新年快樂哦!! 十分感激🥹

我要留言

立即登入留言