用 LangChain 構建基於資料庫的問答機器人（一）：基礎用法
Published: 2023-06-21 05:04:30

大家好，我是學生大使 Jambo。在上一個系列中，我們介紹了關於 Azure OpenAI API 的使用。如果你有跟著教程使用過，那麼你應該能感覺到僅僅是調用 API 是非常簡單的，繁瑣的是如何將 API 與你的應用結合起來。接下來，我將會介紹一個名為 LangChain 的庫，它可以幫助你更方便地將 Azure OpenAI 結合到你的應用中。

我也會將這個做成一個系列，最終目標是實現一個可以根據資料源回答問題的聊天機器人。

# 為什麼要用 LangChain

許多開發者希望將像 GPT 這樣的大語言模型整合到他們的應用中。而這些應用不僅僅是簡單地將用戶的輸入傳遞給 GPT，然後將 GPT 的輸出返回給用戶。

這些應用可能需要根據特定的資料源來回答問題，因此需要考慮如何存儲和查找資料。或者需要整理用戶的輸入，保存以前的消息記錄並提取重點。如果你希望模型按照特定的格式輸出文本，那麼你需要在 prompt（提示）中詳細描述格式，甚至需要提供示例。這些 prompt 通常是應用程序後台進行管理，用戶往往不會注意到它們的存在。對於一些複雜的應用程序，一個問題可能需要多個執行動作。例如聲稱可以自動完成指定項目的 AutoGPT，實際上是根據目標和作者編寫的 prompt 生成所需的動作並以JSON格式輸出，然後程序再執行相應的動作。

LangChain 基本上已經將這些你可能會使用到的功能打包好了，只需要規劃程式邏輯並調用函數即可。此外，LangChain 的這些功能與具體使用的模型API無關，不必為不同的語言模型編寫不同的代碼，只需更換 API 即可。

# 基本用法

在使用 LangChain 之前，建議先了解 Azure OpenAI API 的調用，否則即使是使用 LangChain，參數和用法也可能不容易理解。具體可以參考我之前的系列教程：[用 Python 調用 Azure OpenAi API](https://dev.to/jambochen/lai-azure-xue-xi-openai-san-yong-python-diao-yong-azure-openai-api-5ddn)

LangChain 將由文字續寫（補全）的語言模型稱為 llm ，擁有聊天界面（輸入為聊天記錄）的語言模型稱為聊天模型。接下來我們也會用 Azure OpenAI API 來進行示例。

## 安裝

因為 LangChain 在調用 OpenAI 的 API 時，實際上會使用 OpenAI 提供的 SDK，因此我們還需要一併安裝 `openai` 。

```
pip install langchain
pip install openai
```

## 生成文本

### 實例化模型對象

在使用 API 之前，我們需要先設置環境變量。如果你使用的是 OpenAI 原生的接口，就只需要設置 `api_key`；如果是 Azure OpenAI API 則還需要設置 `api_version` 和 `api_base` ，具體的值與使用 `openai` 庫調用 Azure API 一樣，可以參考我之前的教程：[用 Python 調用 Azure OpenAi API](https://dev.to/jambochen/lai-azure-xue-xi-openai-san-yong-python-diao-yong-azure-openai-api-5ddn)

```python
import os
os.environ["OPENAI_API_KEY"] = ""
os.environ["OPENAI_API_VERSION"] = ""
os.environ["OPENAI_API_BASE"] = ""
```

當然，這些值也可以在 terminal 中使用 `export` （在 Linux 下）命令設置，或者在 .env 文件中設置，然後用 `python-dotenv` 庫導入進環境變量。

LangChain 的大語言模型（llm）的類都封裝在 `llms` 中，我們需要從中導入 `AzureOpenAI` 類，並設置相關的參數。其中指定模型的參數名是 `deployment_name`，剩下的參數就是 OpenAI API 的參數了。事實上，上面在環境變量中設置的 API 信息也可以在這裡作為參數傳入，但考慮到便利性和安全性，仍建議在環境變量中設置 API 信息。

要注意的是，prompt 和 stop 參數並不是在這里傳入的（stop 可以但是會報警告），而是在下面生成文本時傳入。

```python
from langchain.llms import AzureOpenAI
llm = AzureOpenAI(
    deployment_name="text-davinci-003",
    temperature=0.9,
    max_tokens=265,
)
```

另外，如果你使用的是原生 OpenAI API ，那麼導入的類應該是 `OpenAI` ，並且指定模型的參數名是 `model_name`，例如：

```python
from langchain.llms import AzureOpenAI
llm = AzureOpenAI(model_name="text-davinci-003")
```

#### 序列化 LLM 配置

假如你需要對多個場景有不同的 llm 配置，那麼將配置寫在代碼中就會不那麼簡單靈活。在這種情況下，將 llm 配置保存在文件中顯然會更方便。

```python
from langchain.llms import OpenAI
from langchain.llms.loading import load_llm
```

LangChain 支持將 llm 配置以 json 或 yaml 的格式讀取或保存。假設我現在有一個 `llm.json` 文件，內容如下：

```json
{
    "model_name": "text-davinci-003",
    "temperature": 0.7,
    "max_tokens": 256,
    "top_p": 1.0,
    "frequency_penalty": 0.0,
    "presence_penalty": 0.0,
    "n": 1,
    "best_of": 1,
    "request_timeout": null,
    "_type": "openai"
}
```

那麼我們可以使用 `load_llm` 函數將其轉換成 llm 對象，具體使用的是什麼語言模型是使用 `_type` 參數定義。

```python
llm = load_llm("llm.json")
# llm = load_llm("llm.yaml")
```

當然你也可以從 llm 對象導出成配置文件。
```
llm.save("llm.json")
llm.save("llm.yaml")
```

### 從文本生成文本

接下來我們就要使用上面實例化的模型對象來生成文本。 LangChain 的 llm 類有三種方法從 String 生成文本：`predict()` 方法、`generate()`方法、像函數一樣直接調用對象（`__call__`）。

看上去途徑很多，但其實都只是 `generate()` 一種而已。具體來說，`perdict()` 簡單檢查後調用了 `__call__` ，而 `__call__` 簡單檢查後調用了 `generate()`。 `generate()` 方法與其他兩種途徑最大的區別在於 prompt 的傳入和返回的內容：`generate()` 傳入的是包含 prompt 的 list 並返回一個 `LLMResult` 對象，而其他兩種方法傳入的是 prompt 本身的 string ，返回生成文本的 string。意思是 `generate()` 可以一次對多個 prompt **獨立**生成對應的文本。

```python
prompt = "1 + 1 = "
stop = ["\n"]
# 下面三種生成方法是等價的
res1 = llm(prompt, stop=stop)
res2 = llm.predict(prompt, stop=stop)
res3 = llm.generate([prompt], stop=stop).generations[0][0].text
```

如果只是想單純的從文字續寫（生成）文字的話，推薦用 `predict()` 方法，因為這種最方便也最直觀。

## 聊天模型

### 實例化模型對象

與上面的生成模型一樣，我們需要先設置環境變量

```python
import os
os.environ["OPENAI_API_KEY"] = ""
os.environ["OPENAI_API_VERSION"] = ""
os.environ["OPENAI_API_BASE"] = ""
```

LangChain 的聊天模型包裝在 `langchain.chat_models` 下，我們這裡一樣使用 Azure OpenAI 進行演示，導入的是 `AzureChatOpenAI` 類。

如果你有讀過我之前直接調用 API 的教程，那麼應該清楚我們對聊天模型輸入的 prompt 不再是文字，而是消息記錄，消息記錄中是用戶和模型輪流對話的內容，這些消息被 LangChain 包裝為 `AIMessage`、`HumanMessage`、`SystemMessage`，分別對應原先 API 中的 `assistant`、`user`、`system` 。

```python
from langchain.chat_models import AzureChatOpenAI
from langchain.schema import (
    AIMessage,
    HumanMessage,
    SystemMessage
)

chat = AzureChatOpenAI(deployment_name="gpt-35-turbo", temperature=0)
```

我們先構建一個初始的消息記錄，當然 `SystemMessage` 並不是必須的。聊天模型用直接調用對象的方式生成消息，而他返回的會是一個 `AIMessage` 對象。

```python
messages = [
    SystemMessage(content="你是一名翻譯員，將中文翻譯成英文"),
    HumanMessage(content="你好世界")
]
chat(messages)
```

```text
AIMessage(content='Hello world.', additional_kwargs={}, example=False)
```

和之前一樣，聊天模型的 `generate()` 方法也支持對多個聊天記錄生成消息，不過這裡的返回值是一個 `LLMResult` 對象。

```python
chat.generate([messages, messages])
```

## LLMResult

上面說 llm 的 `generate()` 方法返回的是一個 `LLMResult` 對象，它由三個部分組成：`generations` 儲存生成的內容和對應的信息、`llm_output` 儲存 token 使用量和使用的模型、`run` 儲存了唯一的 `run_id`，這是為了方便在生成過程中調用回調函數。通常我只需要關注 `generations` 和 `llm_output` 。

為了展示 `LLMResult` 的結果，這裡我們重新創建一個 llm 對象，並設置參數 `n=2` ，代表模型對於每個 prompt 會生成兩次結果，這個值默認是 `n=1`。

```python
llm = AzureOpenAI(deployment_name="text-davinci-003", temperature=0, n=2)
llm_result = llm.generate([f"{i}**2 =" for i in range(1, 11)], stop="\n")
print(len(llm_result.generations))
# -> 10
print(len(llm_result.generations[0]))
# -> 2
```


因為 `LLMResult` 是繼承自 Pydantic 的 `BaseModel` ，因此可以用 `json()` 將其格式化為 JSON ：

```python
print(llm_result.json())
```

```json
{
    "generations": [
        [
            {
                "text": " 1",
                "generation_info": {
                    "finish_reason": "stop",
                    "logprobs": null
                }
            },
            {
                "text": " 1",
                "generation_info": {
                    "finish_reason": "stop",
                    "logprobs": null
                }
            }
        ],
        ...
    ],
    "llm_output": {
        "token_usage": {
            "prompt_tokens": 40,
            "total_tokens": 58,
            "completion_tokens": 18
        },
        "model_name": "text-davinci-003"
    },
    "run": {
        "run_id": "cf7fefb2-2e44-474d-918f-b8695a514646"
    }
}
```

可以看到 `generations` 是一個二維數組，第一維的每個元素代表對應的 prompt 生成的結果，第二維的每個元素代表這個 prompt 的一次生成結果，因為我們設置了 `n=2` ，所以每個 prompt 會生成兩次結結果。

對於生成（補全）模型，生成的結果會是一個字典，具體生成的內容在 `text` 字段。而對於聊天模型，它會把結果包裝成 `ChatGeneration` 對象，其中 `text` 字段是生成的文字，`message` 字段則是文字對應的 `AIMessage` 對象，例如：

```text
LLMResult(generations=[
        [
            ChatGeneration(
                text='Hello world.', 
                generation_info=None, 
                message=AIMessage(
                    content='Hello world.', 
                    additional_kwargs={}, 
                    example=False)
            )
        ]
    ], 
    llm_output={
        'token_usage': {
            'completion_tokens': 6, 'prompt_tokens': 80, 'total_tokens': 86
        }, 
    'model_name': 'gpt-3.5-turbo'
    }, 
    run=RunInfo(run_id=UUID('fffa5a38-c738-4eef-bdc4-0071511d1422')))
```


## Prompt 模板

很多時候我們並不會把用戶的輸入直接丟給模型，可能會需要在前後文進行補充信息，而這個補充的信息就是“模板”。下面是一個簡單的例子，這個 prompt 包含一個輸入變量 `product`，：

```python
template = """
我希望你擔任顧問，幫忙為公司想名字。
這個公司生產{product}，有什麼好名字？
"""
```

我們可以用 `PromptTemplate` 將這個帶有輸入變量的 prompt 包裝成一個模板。

```python
from langchain import PromptTemplate

prompt_template = PromptTemplate(
    input_variables=["product"],
    template=template,
)
prompt_template.format(product="運動襯衫")
# -> 我希望你擔任顧問，幫忙為公司想名字。
# -> 這個公司生成運動襯衫，有什麼好名字？
```

當然，如果 prompt 中沒有輸入變量，也可以將其用 `PromptTemplate` 包裝，只是 `input_variables` 參數輸入的是空列表。

如果你不想手動指定 `input_variables` ，也可以使用 `from_template()` 方法自動推導。

```python
prompt_template = PromptTemplate.from_template(template)
prompt_template.input_variables
# -> ['product']
```

你還可以將模板保存到本地文件中，目前 LangChain 只支持 json 和 yaml 格式，它可以通過文件後綴自動判斷文件格式。

```python
prompt_template.save("awesome_prompt.json") # 保存為 json
```

也可以從文件中讀取

```python
from langchain.prompts import load_prompt
prompt_template = load_prompt("prompt.json")
```

## Chain 

Chain 是 LangChain 裡非常重要的概念（畢竟都放在名字裡了），它與管道（Pipeline）類似，就是將多個操作組裝成一個函數（流水線），從而使得代碼更加簡潔方便。

比如我們執行一個完整的任務週期，需要先生成 prompt ，將 prompt 給 llm 生成文字，然後可能還要對生成的文字進行其他處理。更進一步，我們或許還要記錄任務每個階段的日誌，或者更新其他數據。這些操作如果都寫出來，那麼代碼會非常冗長，而且不容易復用，但是如果使用 Chain ，你就可以將這些工作都打包起來，並且代碼邏輯也更清晰。你還可以將多個 Chain 組合成一個更複雜的 Chain 。

我們首先創建一個 llm 對象和一個 prompt 模板。

```python
from langchain.llms import AzureOpenAI
from langchain import PromptTemplate

chat = AzureChatOpenAI(deployment_name="gpt-35-turbo", temperature=0)
prompt = PromptTemplate(
    input_variables=["input"],
    template="""
    將給定的字符串進行大小寫轉換。
    例如：
    輸入： ABCdef
    輸出： abcDEF
    
    輸入： AbcDeF
    輸出： aBCdEf
    
    輸入： {input}
    輸出： 
    """,
)
```

接下來我們可以通過 `LLMChain` 將 llm 和 prompt 組合成一個 Chain 。這個 Chain 可以接受用戶輸入，然後將輸入填入 prompt 中，最後將 prompt 交給 llm 生成結果。另外如果你用的是聊天模型，那麼使用的 Chain 是 `ConversationChain`。

```python
from langchain.chains import LLMChain

chain = LLMChain(llm=chat, prompt=prompt)
print(chain.run("HeLLo"))
# -> hEllO
```

如果 prompt 中有多個輸入變量，可以使用字典一次將它們傳入。

```python
print(chain.run({"input": "HeLLo"}))
# -> hEllO
```

### Debug 模式

上面都是些簡單的例子，只牽扯到少量的輸入變量，但在實際使用中可能會有大量的輸入變量，並且 llm 的輸出還是不固定的，這就使得我們很難從最重的結果反推問題所在。為了解決這個問題，LangChain 提供了 `verbose` 模式，它會將每個階段的輸入輸出都打印出來，這樣就可以很方便地找到問題所在。

```python
chain_verbose = LLMChain(llm=llm, prompt=prompt, verbose=True)
print(chain_verbose.run({"input": "HeLLo"}))
```

```text
> Entering new  chain...
Prompt after formatting:

    將給定的字符串進行大小寫轉換。
    例如：
    輸入： ABCdef
    輸出： abcDEF
    
    輸入： AbcDeF
    輸出： aBCdEf
    
    輸入： HeLLo
    輸出： 
    

> Finished chain.
hEllO
```

### 組合 Chain

一個 Chain 對像只能完成一個很簡單的任務，但我們可以像搭積木一樣將多個簡單的動作組合在一起，就可以完成更複雜的任務。其中最簡單的是順序鏈 `SequentialChain`，它將多個 Chain 串聯起來，前一個 Chain 的輸出將作為後一個 Chain 的輸入。不過要注意的是，因為這只是個最簡單的鏈，所以它不會對輸入進行任何處理，也不會對輸出進行任何處理，所以你需要保證每個 Chain 的輸入輸出都是兼容的，並且它要求每個 Chain 的 prompt 都只有一個輸入變量。


下面，我們先計算輸入數字的平方，然後將平方數轉換成羅馬數字。

```python
from langchain.chains import SimpleSequentialChain

chat = AzureChatOpenAI(deployment_name="gpt-35-turbo", temperature=0)
prompt1 = PromptTemplate(
    input_variables=["base"], template="{base}的平方是： "
)
chain1 = LLMChain(llm=chat, prompt=prompt1)

prompt2 = PromptTemplate(input_variables=["input"], template="將{input}寫成羅馬數字是：")
chain2 = LLMChain(llm=chat, prompt=prompt2)

overall_chain = SimpleSequentialChain(chains=[chain1, chain2], verbose=True)
overall_chain.run(3)
```

```text
> Entering new  chain...


9
IX

> Finished chain.
'IX'
```

LangChain 已經預先準備了許多不同 Chain 的組合，具體可以參考官方文檔，這裡就先不展開了。