除了下層的推論框架以外,也有非常多人在關注上層的應用開發,其中最炙手可熱的當屬 LangChain 框架。當我們開始實際使用 LLM 開發相關應用程式時,會面臨到許多雜七雜八的 Prompt 樣板、重複的生成程序以及固定的串接流程等等。
多數情況下,這些樣板、流程切開來看,都不是很複雜的程式,自己動手寫也都不難寫。但是當不同的流程開始堆疊成更大的應用時,一切就會顯得十分混亂。這時就能借助 LangChain 框架來幫我們進行更好的程式碼管理。
話雖這麼說,但其實我也是剛把
pip install langchain
放下去跑而已 😅
老實說這類的應用框架,一直是筆者的心魔。一方面本業的工作內容都是研究與實驗的性質居多,比較少觸碰到完整的應用。二方面 LangChain 對我來說是個全新的框架,但功能都是既有的東西,泥漿腦如我就是不想改變思考模式 😭
於是這次給自己一個挑戰 LangChain 的機會,如果想看 LangChain Pro 的可能可以上一頁了,因為這篇文只有破破的 LangChain 小菜鳥 🥲
如果我有三百年的時間,我願意把 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_US
與 zh_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 的優點之一在於整合了相當多相關 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 初始化裡面。
在 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 進行除錯:
如上圖所示,這樣比較能讓開發者確認最後送出的 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 也是 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)
在 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,因此需要用到 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:
"""
我們已經知道如何將 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 在一起!
首先是讀取資料集的部份,與 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()
使用起來的效果如下:
噓!
完整的程式碼放在 GitHub 上面,各位有興趣的話可以嘗試自己架起來完看看 👍
實際使用 LangChain 搭建完一個 Demo 後,總算是對這個熱門的框架有些微薄的理解。這個框架的確將 LLM 應用中的每個元件與行為都封裝的很好,而且使用了 Chain 的概念來將元件串聯起來,是個非常獨特的思維。幸好 LangChain 真的很簡單好上手,才沒有讓這篇文章開天窗 XD
LangChain 的易用與靈活,加上活躍的社群,未來會發展成什麼樣子也是相當令人期待!
其他值得參考的應用框架包含 Guidance, LMQL, Semantic Kernel 等等,給大家參考。
您好,先謝謝大大的文章分享~
不好意思,有問題想請教:
請問文中的:chain = prompt | llm
這個 | 運算符是指什麼意思呢?不好意思我用 | 運算子 python
這幾個關鍵字下去搜尋似乎都找不到...
感覺是很粗淺的新手問題,不好意思><~
謝謝!
你好,在這裡 |
是一種 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 的概念,將前者的內容當作後者的輸入。
以上解釋希望有所幫助,新年快樂!
哇! 原來如此~~~
好詳細的解釋啊!!! 很清楚~~~~~
謝謝大大!!! 辛苦了過新年還回問題><~~
祝大大新年快樂哦!! 十分感激🥹