# LangChain memory 類別的 bug 使用 LangChain 的 memory 類別時, 如果是會限制記憶量的類別, 由於他們底層實作的關係 (我認為是 bug), 雖然表現上看起來好像是把舊的訊息丟棄了, 但實際上還是儲存了所有的訊息, 以下我們以最簡單的 [`ConversationBufferWinodwMemory`](https://api.python.langchain.com/en/latest/memory/langchain.memory.buffer_window.ConversationBufferWindowMemory.html#langchain.memory.buffer_window.ConversationBufferWindowMemory) 來說明。 ## 以 ConversationBufferWindowMemory 簡易測試 以下我們以預設採用 [`ChatMessageHistory`](https://api.python.langchain.com/en/latest/chat_message_histories/langchain_community.chat_message_histories.in_memory.ChatMessageHistory.html#langchain_community.chat_message_histories.in_memory.ChatMessageHistory) 把訊息儲存在記憶體中的 `ConversationBufferWindowMemory` 為例進行簡單的測試: ```python >>> from langchain.memory import ConversationBufferWindowMemory >>> memory = ConversationBufferWindowMemory(k=1) >>> memory.save_context( ... {'input': '你好'}, ... {'output': '有什麼事?'} ... ) >>> memory.save_context( ... {'input': '基隆怎麼去?'}, ... {'output': '沿基隆河划船去'} ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 基隆怎麼去?\nAI: 沿基隆河划船去'} ``` 你可以看到因為設定 `k` 為 1, 所以雖然儲存了 2 次對話, 但是第 1 次的對話被刪除了。如果你透過 `chat_memory` 屬性查看實際儲存的對話, 會看到: ```python >>> for msg in memory.chat_memory.messages: ... print(msg) content='你好' content='有什麼事?' content='基隆怎麼去?' content='沿基隆河划船去' ``` 你會發現雖然設定 `k` 值, 但實際儲存的還是所有的對話, 而不是只儲存最新的 `k` 次對話內容。這對 `ConversationbufferWindowMemory` 來說, 還不是大問題, 因為它所實作的 [`load_memory_variables`](https://api.python.langchain.com/en/latest/_modules/langchain/memory/buffer_window.html#ConversationBufferWindowMemory) 只會取最後 `2*k` 筆訊息, 使用到對話中當成歷史對話並不會有問題。 ## 改用 FileChatMessageHistory 儲存訊息 如果把相同的程式改用 [`FileChatMessageHistory`](https://api.python.langchain.com/en/latest/chat_message_histories/langchain_community.chat_message_histories.file.FileChatMessageHistory.html#langchain_community.chat_message_histories.file.FileChatMessageHistory) 將訊息儲存到檔案中: ```python >>> from langchain.memory import FileChatMessageHistory >>> memory = ConversationBufferWindowMemory( ... k=1, ... chat_memory=FileChatMessageHistory( ... file_path='test.json' ... ) ... ) >>> memory.save_context( ... {'input': '你好'}, ... {'output': '有什麼事?'} ... ) >>> memory.save_context( ... {'input': '基隆怎麼去?'}, ... {'output': '沿基隆河划船去'} ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 基隆怎麼去?\nAI: 沿基隆河划船去'} ``` 也可以正常運作, 而且因為是儲存到檔案, 即使重新執行程式, 也可以取得原本儲存的訊息: ```python >>> from langchain.memory import ( ... ConversationBufferWindowMemory, ... FileChatMessageHistory ... ) >>> memory = ConversationBufferWindowMemory( ... k=1, ... chat_memory=FileChatMessageHistory( ... file_path='test.json' ... ) ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 基隆怎麼去?\nAI: 沿基隆河划船去'} ``` 如果查看實際儲存的訊息, 也可以確認是所有對話的內容: ```python >>> for msg in memory.chat_memory.messages: ... print(msg) content='你好' content='有什麼事?' content='基隆怎麼去?' content='沿基隆河划船去' ``` 這可能就會有個小問題, 儲存訊息的檔案會不斷變大, 而且在建立記憶功能的物件時, 也會從檔案中把所有的訊息全部載入, 耗費記憶體。 ## 改用 ConversationSummaryMemory 測試 ```python >>> from langchain_openai import ChatOpenAI >>> client = ChatOpenAI() >>> from langchain.memory import ( ... FileChatMessageHistory, ... ConversationSummaryMemory ... ) >>> memory = ConversationSummaryMemory( ... chat_memory=FileChatMessageHistory( ... file_path='test.json' ... ), ... llm=client ... ) >>> memory.chat_memory.messages [HumanMessage(content='你好'), AIMessage(content='有什麼事?'), HumanMessage(content='基隆怎麼去?'), AIMessage(content=' 沿基隆河划船去')] >>> memory.buffer '' >>> memory.load_memory_variables({}) {'history': ''} ``` 你可以看到雖然有取得所有儲存的訊息, 但是從 `buffer` 屬性查看或是呼叫 `load_memory_variables` 卻沒有內容, 這是因為 [`ConversationSummaryMemory`](https://api.python.langchain.com/en/latest/memory/langchain.memory.summary.ConversationSummaryMemory.html#langchain.memory.summary.ConversationSummaryMemory) 的彙整是發生在 `save_content` 的時候, 以下我們再儲存一次對話看看: ```python >>> memory.save_context( ... {'input': '這樣很久吧?'}, ... {'output': '別擔心, 總是滑得到的'} ... ) >>> memory.load_memory_variables({}) {'history': 'The human asks the AI if it has been like this for a long time. The AI reassures the human that things will always work out in the end.'} ``` 這樣就會匯總內容了。 你可以看到不論是限定對話次數的 `ConversationBufferWindowMemory` 或是直接彙整內容的 `ConversationSummaryMemory` 類別, 由於在取得會話記錄時都會直接篩選內容, 所以即使底層儲存了所有的對話內容, 只是浪費儲存空間, 但是對於作為對話記錄來說, 功能都算正常。 ## 會造成問題的 ConversationTokenBufferMemory 如果你改用會將超過限制量的舊訊息丟棄或者彙整總結, 但保留新訊息的記憶功能物件, 像是 [`ConversationTokenBufferMemory`](https://api.python.langchain.com/en/latest/memory/langchain.memory.token_buffer.ConversationTokenBufferMemory.html#langchain.memory.token_buffer.ConversationTokenBufferMemory) 類別, 底層儲存所有訊息的問題就會浮現。請先看以下的測試: ```python >>> from langchain.memory import ( ... FileChatMessageHistory, ... ConversationTokenBufferMemory ... ) >>> from langchain_openai import ChatOpenAI >>> client = ChatOpenAI() >>> memory = ConversationTokenBufferMemory( ... max_token_limit=20, ... chat_memory=FileChatMessageHistory( ... file_path='test.json' ... ), ... llm=client ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 你好\nAI: 有什麼事?\nHuman: 基隆怎麼去?\nAI: 沿基隆河划船去\nHuman: 這樣很久吧?\nAI: 別擔心, 總是滑得到的'} ``` 你會看到使用 load_memory_variable 會傳回所有的訊息, 如果再儲存一次對話: ```python >>> memory.save_context( ... {'input': '這樣我應該會划到天荒地老吧?'}, ... {'output': '反正會到就是了, 你擔心什麼啦!加油!'} ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 你好\nAI: 有什麼事?\nHuman: 基隆怎麼去?\nAI: 沿基隆河划船去\nHuman: 這樣很久吧?\nAI: 別擔心, 總是滑得到的\nHuman: 這樣我應該會划到天荒地老吧?\nAI: 反正會到就是了, 你擔心什麼啦!加油!'} ``` 你可以看到雖然有限制 token 數量, 但實際上還是會傳回所有的訊息, 如果使用這個結果當成對話記錄, 就跟沒有限制 token 數量一樣, 會把所有的對話內容都傳給語言模型。 ## 更彰顯問題的 ConversationSummaryBufferMemory 如果你改用超過訊息數量限制會匯總舊訊息的類別, 像是 [`ConversationSummaryBufferMemory`](https://api.python.langchain.com/en/latest/memory/langchain.memory.summary_buffer.ConversationSummaryBufferMemory.html#langchain.memory.summary_buffer.ConversationSummaryBufferMemory), 就會更彰顯這個問題的嚴重性, 請看以下測試: ```python >>> from langchain.memory import ConversationSummaryBufferMemory >>> memory = ConversationSummaryBufferMemory( ... max_token_limit=20, ... chat_memory=FileChatMessageHistory( ... file_path='test.json' ... ), ... llm=client ... ) >>> memory.load_memory_variables({}) {'history': 'Human: 你好\nAI: 有什麼事?\nHuman: 基隆怎麼去?\nAI: 沿基隆河划船去\nHuman: 這樣很久吧?\nAI: 別擔心, 總是滑得到的\nHuman: 這樣我應該會划到天荒地老吧?\nAI: 反正會到就是了, 你擔心什麼啦!加油!'} ``` 你會看到 `load_memory_variables` 一樣會傳回所有的內容, 如果你再儲存一次新的對話, 引發匯總舊訊息的動作: ```python >>> memory.save_context( ... {'input': '不能用別種交通工具嗎?'}, ... {'output': '喔, 其實你可以搭火車'} ... ) >>> memory.load_memory_variables({}) {'history': 'System: The human greets the AI in Chinese and asks for directions to Keelung. The AI suggests taking a boat along the Keelung River, reassuring the human that it will not take too long. The human expresses concern about rowing forever, but the AI encourages them to keep going. The human asks if there are alternative transportation options, and the AI suggests taking the train.\nHuman: 你好\nAI: 有什麼事?\nHuman: 基隆怎麼去?\nAI: 沿基隆河划船去\nHuman: 這樣很久吧?\nAI: 別擔心, 總是滑得到的\nHuman: 這樣我應該會划到天荒地老吧?\nAI: 反正會到就是了, 你擔心什麼啦!加油!\nHuman: 不能用別種交通工具嗎?\nAI: 喔, 其實你可以搭火車'} ``` 你會看到 `ConversationSummaryBufferMemory` 的確幫你把舊的訊息都匯總成摘要內容, 但是它還是把所有的訊息也都傳回。由於我們把 token 數量限制為 20, 光是最後一次對話就超過了, 所以這裡看到的匯總結果涵蓋了所有的訊息, 以下我們再加入一次簡短的對話, 可以看出差別: ```python >>> memory.save_context( ... {'input': '好'}, ... {'output': '加油'} ... ) >>> memory.load_memory_variables({}) {'history': 'System: The human greets the AI in Chinese and asks for directions to Keelung. The AI suggests taking a boat along the Keelung River, reassuring the human that it will not take too long. The human expresses concern about rowing forever, but the AI encourages them to keep going. The human asks if there are alternative transportation options, and the AI suggests taking the train instead.\nHuman: 你好\nAI: 有什麼 事?\nHuman: 基隆怎麼去?\nAI: 沿基隆河划船去\nHuman: 這樣很 久吧?\nAI: 別擔心, 總是滑得到的\nHuman: 這樣我應該會划到天荒地老吧?\nAI: 反正會到就是了, 你擔心什麼啦!加油!\nHuman: 不能用別種交通工具嗎?\nAI: 喔, 其實你可以搭火車\nHuman: 好\nAI: 加油'} ``` 你可以看到摘要的內容還是相同, 只到 AI 回覆可以搭火車的地方, 但是還是加上了所有的訊息。 ## 最嚴重的問題 由於這個問題, 使得設計來幫我們刪減訊息或是總結訊息的功能變成是多餘了, 因為 `load_memory_variables` 會傳回完整的對話過程, 如果配合流程鏈當成對話記錄, 就等於每次都把完整對話傳給語言模型了, 這帶來了兩個問題: 1. 彙整摘要的步驟等同虛設, 而且因為要透過語言模型幫我們彙整摘要, 所以還浪費了金錢耗用 API。 2. 當對話越來越多時, 甚至可能因為對話記錄就超過了語言模型的 token 處理限制, 無法進行。 ## 變通作法 要想解決這個問題, 除非修改原始碼, 我已經針對 `ConversationSummaryBufferMemory` 提出了 [pull request](https://github.com/langchain-ai/langchain/pull/18316#issuecomment-1972274710), LangChain 作者也表示了他的意見, 在還沒有被接受或是 LangChain 自己主動修改前, 你只能變通作法, 從既有的類別衍生新的類別, 舉例來說, 以下是 `ConversationSummaryMemory` 類別中 [save_content 的實作](https://api.python.langchain.com/en/latest/_modules/langchain/memory/summary.html#ConversationSummaryMemory): ```python def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, str]) -> None: """Save context from this conversation to buffer.""" super().save_context(inputs, outputs) self.buffer = self.predict_new_summary( self.chat_memory.messages[-2:], self.buffer ) ``` 其中 `self.buffer` 就是目前的匯總摘要, 這個實作首先利用父類別, 也就是 `BaseChatMemory` 的 `save_content` 儲存本次對話的兩筆訊息, 然後傳入本次對話的兩筆訊息以及目前的彙整摘要呼叫 `predict_new_summary` 取得新的彙整摘要後, 更新 `self.buffer`, 然後就結束了, 這就是對話會不斷儲存下來的原因。 你可以自己衍生新的類別, 覆寫 `save_content` 方法, 每次匯總後, 就只在底層留下彙整摘要的結果, 捨棄所有的訊息: ```python >>> from typing import Dict, Any >>> class MySummaryMemory(ConversationSummaryMemory): ... def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, str]) -> None: ... """Save context from this conversation to buffer.""" ... super().save_context(inputs, outputs) ... self.buffer = self.predict_new_summary( ... self.chat_memory.messages[-3:], self.buffer ... ) ... self.chat_memory.clear() ... self.chat_memory.add_user_message(self.buffer) ``` 這其實是複製原本 `save_content` 方法, 在最後加上清除所有訊息, 並且把最新總結的摘要內容儲存起來, 也就是每次儲存對話後, 都只會儲存最後的摘要內容。此外我也把彙整摘要時原本只取最後 2 筆訊息改成最後 3 筆訊息, 這樣就會包含上一次的摘要以及剛剛存入的最後一次對話的 2 筆訊息。接著就來試看看囉: ```python >>> memory.save_context( ... {'input': '我到了'}, ... {'output': '厲害'} ... ) >>> memory.load_memory_variables({}) {'history': 'The human greets the AI in Mandarin and asks for directions to Keelung. The AI suggests taking a boat along the Keelung River, reassuring the human that they will get there smoothly. The human expresses concern about rowing forever, but the AI encourages them to keep going. When the human asks if there are other transportation options, the AI suggests taking a train instead. The conversation ends with the human responding "好" and the AI encouraging them to keep going with "加油". The human then asks if there are alternative transportation methods, and the AI suggests taking a train. The conversation concludes with the human arriving and the AI complimenting them with "厲害".'} >>> memory.chat_memory.messages [HumanMessage(content='The human greets the AI in Mandarin and asks for directions to Keelung. The AI suggests taking a boat along the Keelung River, reassuring the human that they will get there smoothly. The human expresses concern about rowing forever, but the AI encourages them to keep going. When the human asks if there are other transportation options, the AI suggests taking a train instead. The conversation ends with the human responding "好" and the AI encouraging them to keep going with "加油". The human then asks if there are alternative transportation methods, and the AI suggests taking a train. The conversation concludes with the human arriving and the AI complimenting them with "厲害".')] ``` 你可以看到現在不論是透過 `load_memory_variables` 或是底層的物件, 都只有單一筆訊息了。即使查看實際儲存訊息的檔案: ```python >>> import json >>> print(json.load(fp=open('test.json'))) [{'type': 'human', 'data': {'content': 'The human greets the AI in Mandarin and asks for directions to Keelung. The AI suggests taking a boat along the Keelung River, reassuring the human that they will get there smoothly. The human expresses concern about rowing forever, but the AI encourages them to keep going. When the human asks if there are other transportation options, the AI suggests taking a train instead. The conversation ends with the human responding "好" and the AI encouraging them to keep going with "加油". The human then asks if there are alternative transportation methods, and the AI suggests taking a train. The conversation concludes with the human arriving and the AI complimenting them with "厲害".', 'additional_kwargs': {}, 'type': 'human', 'name': None, 'id': None, 'example': False}}] ``` 也的確只有儲存一筆訊息, 也就是最後一次摘要的結果。 各種記憶功能的類別在實作上都有一些差異, 如果要套用上述變通作法解決問題, 就要看一下原始碼, 再決定如何覆寫方法。 ## 小結 LangChain 雖然很好用, 不過因為它包裝了許多細節, 如果沒有仔細測試, 就可能會遇到問題, 本文只是冰山的一角, 不過好在它是開放原始碼的專案, 所以遇到問題都可以回頭檢視原始碼, 進而解決問題。