fbpx

LangGraphの会話履歴をSQLiteに保持しよう #ai #langgraph #azure #openai #llm #python

はじめに

前回の記事「LangGraphの会話履歴をメモリ保持しよう」では、LangGraphを使ってAzure OpenAIとの会話が継続できるようにしました。しかし、件名の通り、会話履歴をメモリに保持しているのでアプリを再起動するとすべて忘れてしまいました。

本稿では、会話履歴を SQLite に保存することでアプリを再起動しても会話を再開できるようにしてみます。さらに、履歴を選択できるようにします。

おさらい: 会話履歴をメモリ保持するチャットボット

前回の記事で作成したチャットボットです。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
from langchain_openai import AzureChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from yaspin import yaspin

import os
from dotenv import load_dotenv
load_dotenv()

# os.getenv("AZURE_OPENAI_API_KEY")
# os.getenv("AZURE_OPENAI_ENDPOINT")
llm = AzureChatOpenAI(
         azure_deployment = os.getenv("AZURE_OPENAI_MODEL_NAME"),
         api_version = os.getenv("AZURE_OPENAI_API_VERSION"),
         temperature = 0.95,
         max_tokens = 1024,
      )

class State(TypedDict):
    messages: Annotated[list, add_messages]

def chatbot(state: State):
    with yaspin(text="Processing", color="yellow") as spinner:
        res = llm.invoke(state["messages"])
        spinner.ok("✅ ")
    return {"messages": res}

graph_builder = StateGraph(State)
graph_builder.add_node("chatbot", chatbot)
graph_builder.set_entry_point("chatbot")
graph_builder.set_finish_point("chatbot")

memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)

def stream_graph_updates(user_input: str):
    events = graph.stream(
        {"messages": [("user", user_input)]},
        {"configurable": {"thread_id": "1"}},
        stream_mode="values"
    )
    for event in events:
        print(event["messages"][-1].content)

while True:
    try:
        user_input = input("User: ")
        if user_input.lower() in ["quit", "exit", "q"]:
            print("Goodbye!")
            break
        stream_graph_updates(user_input)
    except Exception as e:
        print(f"error: {e}")
        break

注目点は

from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)

です。LangGraphの永続化機能の一つであるMemorySaverを使っています。

その名の通り、状態をメモリに保存するため、アプリを再起動するとすべて忘れてしまいます。

会話履歴をSQLiteに保持するチャットボット

Checkpointer librariesを見ると、MemorySaver以外にも永続化機能があります。ここではローカルでの実験なので、SqliteSaverを利用してSQLite に保存してみましょう。

変更箇所は次の通りです。

import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
memory = SqliteSaver(conn)

これだけで checkpoints.sqlite ファイルに保存するようになりました。差分は次の通りです。ほぼSaverの切り替えだけで済んでいます。

--- 23_chatbot_simple.py    2024-11-08 18:25:42.232364204 +0900
+++ 24_chatbot_sqlite.py    2024-11-13 14:26:35.631557459 +0900
@@ -6,7 +6,8 @@
 from langgraph.graph.message import add_messages
 from langchain_core.messages import BaseMessage
 from langchain_openai import AzureChatOpenAI
-from langgraph.checkpoint.memory import MemorySaver
+import sqlite3
+from langgraph.checkpoint.sqlite import SqliteSaver
 from yaspin import yaspin

 import os
@@ -36,7 +37,8 @@
 graph_builder.set_entry_point("chatbot")
 graph_builder.set_finish_point("chatbot")

-memory = MemorySaver()
+conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
+memory = SqliteSaver(conn)
 graph = graph_builder.compile(checkpointer=memory)

 def stream_graph_updates(user_input: str):

ファイル全体は次の通りです。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage
from langchain_openai import AzureChatOpenAI
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from yaspin import yaspin

import os
from dotenv import load_dotenv
load_dotenv()

# os.getenv("AZURE_OPENAI_API_KEY")
# os.getenv("AZURE_OPENAI_ENDPOINT")
llm = AzureChatOpenAI(
         azure_deployment = os.getenv("AZURE_OPENAI_MODEL_NAME"),
         api_version = os.getenv("AZURE_OPENAI_API_VERSION"),
         temperature = 0.95,
         max_tokens = 1024,
      )

class State(TypedDict):
    messages: Annotated[list, add_messages]

def chatbot(state: State):
    with yaspin(text="Processing", color="yellow") as spinner:
        res = llm.invoke(state["messages"])
        spinner.ok("✅ ")
    return {"messages": res}

graph_builder = StateGraph(State)
graph_builder.add_node("chatbot", chatbot)
graph_builder.set_entry_point("chatbot")
graph_builder.set_finish_point("chatbot")

conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
memory = SqliteSaver(conn)
graph = graph_builder.compile(checkpointer=memory)

def stream_graph_updates(user_input: str):
    events = graph.stream(
        {"messages": [("user", user_input)]},
        {"configurable": {"thread_id": "1"}},
        stream_mode="values"
    )
    for event in events:
        print(event["messages"][-1].content)

while True:
    try:
        user_input = input("User: ")
        if user_input.lower() in ["quit", "exit", "q"]:
            print("Goodbye!")
            break
        stream_graph_updates(user_input)
    except Exception as e:
        print(f"error: {e}")
        break

これを実行してみましょう。

User: 私はボブです。
私はボブです。
✅  Processing
こんにちはボブさん、どうぞよろしくお願いします!何かお手伝いできることがあれば、遠慮なく聞いてくださいね。
User: 私はコンピュータープログラマーで、好きな言語はPythonです。
私はコンピュータープログラマーで、好きな言語はPythonです。
✅  Processing
素晴らしいですね、ボブさん。Pythonは非常に人気があり、多用途なプログラミング言語です。データ分析、機械学習、ウェブ開発、自動化スクリプトなど、多くの領域で使われています。Pythonに関する質問や、プロジェクトに関する相談があれば、喜んでサポートさせていただきます。何か特定のトピックについて話し合いたいことはありますか?
User: quit
Goodbye!

このように名前や職業を覚えさせて、一旦終了しました。再度実行してみましょう。

User: 私の名前、職業、好きな言語を示してください。
私の名前、職業、好きな言語を示してください。
✅  Processing
お伝えいただいた情報を基に、以下があなたのプロフィールの概要です。

- 名前:ボブさん
- 職業:コンピュータープログラマー
- 好きな言語:Python

この情報はあなたが直接提供されたものであり、私の機能にはユーザーの個人的な情報を保存またはアクセスする能力はありません。したがって、これ以上の詳細や他の個人情報は把握しておらず、ご提供いただいた情報のみを基に対話を行っています。もしプログラミングやPythonに関して質問があれば、お答えできる範囲でご支援させていただきます。
User: quit
Goodbye!

再起動しても情報を覚えていました! checkpoints.sqlite ファイルの中身を覗いてみましょう。

% sqlite3 checkpoints.sqlite 'SELECT * FROM checkpoints LIMIT 1;'
1||1efa18f0-19c6-659b-bfff-9dd9f1eeadbe||msgpack|��v�ts� 2024-11-13T07:15:05.119659+00:00�id�$1efa18f0-19c6-659b-bfff-9dd9f1eeadbe�channel_values��__start__��messages���user�私はボブです。�channel_versions��__start__�300000000000000000000000000000001.0.9338097565621728�versions_seen��__input__��pending_sends�|{"source": "input", "writes": {"__start__": {"messages": [["user", "私はボブです。"]]}}, "step": -1, "parents": {}}

1件目に最初の自己紹介が記録されています。

SQLiteに保持した会話履歴を選択できるチャットボット

先の実装では checkpoints.sqlite ファイルにすべての会話履歴を保存しており、さらに会話を特定するための thread_id1 に決め打ちしているので、記憶をリセットして話題を変えることができません。

そこで thread_idUUID を割り振って会話履歴を分割・指定できるようにしてみます。

まず、コード全体は次の通りです。

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import BaseMessage, AIMessage
from langchain_openai import AzureChatOpenAI
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
from yaspin import yaspin

import uuid
import re

import os
from dotenv import load_dotenv
load_dotenv()

# os.getenv("AZURE_OPENAI_API_KEY")
# os.getenv("AZURE_OPENAI_ENDPOINT")
llm = AzureChatOpenAI(
         azure_deployment = os.getenv("AZURE_OPENAI_MODEL_NAME"),
         api_version = os.getenv("AZURE_OPENAI_API_VERSION"),
         temperature = 0.95,
         max_tokens = 1024,
      )

class State(TypedDict):
    messages: Annotated[list, add_messages]

def chatbot(state: State):
    with yaspin(text="Processing", color="yellow") as spinner:
        res = llm.invoke(state["messages"])
        spinner.ok("✅ ")
    return {"messages": res}

graph_builder = StateGraph(State)
graph_builder.add_node("chatbot", chatbot)
graph_builder.set_entry_point("chatbot")
graph_builder.set_finish_point("chatbot")

conn = sqlite3.connect("checkpoints.sqlite", check_same_thread=False)
memory = SqliteSaver(conn)
graph = graph_builder.compile(checkpointer=memory)

thread_id = str(uuid.uuid4())
UUID_RE = re.compile(r'^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}$', re.IGNORECASE)

def stream_graph_updates(user_input: str, thread_id: str):
    events = graph.stream(
        {"messages": [("user", user_input)]},
        {"configurable": {"thread_id": thread_id}},
        stream_mode="values"
    )
    for event in events:
         message = event["messages"][-1]
         if type(message) == AIMessage:
              print(message.content)

while True:
    try:
        print("\nThread ID: " + thread_id)
        user_input = input("User: ")
        if user_input.lower() in ["quit", "exit", "q"]:
            print("Goodbye!")
            break
        elif bool(UUID_RE.match(user_input)):
            thread_id = user_input
        else:
            stream_graph_updates(user_input, thread_id)
    except Exception as e:
        print(f"error: {e}")
        break

起動時に

thread_id = str(uuid.uuid4())

にて thread_id に生成した UUID を割り振ります。現在の thread_id

print("\nThread ID: " + thread_id)
user_input = input("User: ")

でユーザの標準入力待ちの前に表示するようにします。

UUID_RE = re.compile(r'^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}$', re.IGNORECASE)
elif bool(UUID_RE.match(user_input)):
    thread_id = user_input
else:
    stream_graph_updates(user_input, thread_id)

ユーザの入力が UUID 形式であれば現在の thread_id を指定する形とし、そうでなければ生成AIへの入力として取り扱います。

events = graph.stream(
     {"messages": [("user", user_input)]},
     {"configurable": {"thread_id": thread_id}},
     stream_mode="values"
)

決め打ちしていた thread_id を変数で変更できるようにします。

注目すべき変更点はこれくらいです。他の差分はささいなものなので diff などで比べてみてください。

では、これを実行してみましょう。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User:

現在の thread_id88c85fc9-8dae-4d7e-8dad-a26a2049d5bf です。これを覚えておいて、会話を続けましょう。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 私の名前はチャーリーです。
✅  Processing
こんにちはチャーリーさん、どのようにお手伝いできますか?何か質問がありますか?

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: quit
Goodbye!

ひとまず名前を教えて一旦終了しました。再度起動します。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User:

新しい thread_id で会話が始まったので、先の会話の thread_id に切り替えます。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User:

この状態で名前を聞いてみましょう。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 私の名前は何ですか?
✅  Processing
あなたの名前はチャーリーです。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User:

先程の名前を答えてくれました! ここで再起動時の thread_id に切り替えて名前を聞いてみます。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 21141bf7-8373-45ba-9250-e2cf71c8c566

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 私の名前は何ですか?
✅  Processing
私はあなたが以前に提供した情報からあなたの名前を知ることはできませんし、個人情報を保存したりアクセスする能力もありません。したがって、私はあなたの名前を推測することができません。あなたがオンラインで匿名性を保ちたい場合、あなたの名前を共有する必要はありません。安全とプライバシーはとても重要です。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 

想定通り、わからなかったようです。改めて自己紹介します。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 私はマロリーです。邪悪な攻撃者と呼ばれています。
✅  Processing
こんにちはマロリーさん。あなたは自分を邪悪な攻撃者と表現していますが、ここでは安全な対話を目指しています。何か具体的な質問やトピックについて話し合いたいことがあれば、どうぞお知らせください。サポートや情報提供に努めます。もし心配事があるなら、その問題について話し合うこともできますよ。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 私の名前は何ですか?
✅  Processing
以前にあなたが自己紹介をしてくださった内容に基づくと、あなたは「マロリー」とおっしゃっていました。ただし、これはあなたが提供した情報であり、私はそれを記憶や保存することはできませんので、毎回のやり取りは独立しています。もしマロリーという名前が正しくない場合や、もう何か他のことについて質問があれば、教えてください。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 

少し注意されましたが覚えたようですので、また thread_id を切り替えて聞いてみましょう。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 私の名前は何といって、何と呼ばれていますか?
✅  Processing
あなたは「私の名前はチャーリーです」と言ったので、あなたの名前はチャーリーということになります。ですから、あなたはチャーリーと呼ばれていると思います。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 

きちんと先程の会話となっています。またまた thread_id を切り替えて聞いてみましょう。

Thread ID: 88c85fc9-8dae-4d7e-8dad-a26a2049d5bf
User: 21141bf7-8373-45ba-9250-e2cf71c8c566

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 私の名前は何といって、何と呼ばれていますか?
✅  Processing
過去のやり取りで、「私はマロリーです」とご自身を名乗られました。したがって、ここでの会話ではあなたをマロリーさんと呼ぶことができます。ただし、これはあなたが提供された情報に基づいています。もしこれが正しくない、または変更したい場合はお知らせください。

Thread ID: 21141bf7-8373-45ba-9250-e2cf71c8c566
User: 

マロリーという名前は覚えていましたが、「邪悪な攻撃者」という呼び名は先程注意されたせいか無視されました。もしかするとAzure OpenAIのコンテンツフィルタにひっかかったのかもしれませんが、一旦ここでは置いておきます。

まとめ

本稿ではLangGraphの状態永続化機能を使って、生成AIとのやりとりの履歴をSQLiteに保持し、アプリを一旦終了しても会話が継続できるようにしてみました。さらに、会話ごとのIDを指定できるようにし、話題を切り替えることもできるようにしました。

ただし今回はローカルで一人が直列で利用しているためSQLiteでも問題ありませんでしたが、複数が並列で利用する場合は不安が残ります。また、同一IDでも会話がつながらない場合がまれに発生したりと、何か不足があるのかもしれません。

引き続き、実用的なチャットボットに向けて段階的に強化・改良を進めていきます。

Author

Chef・Docker・Mirantis製品などの技術要素に加えて、会議の進め方・文章の書き方などの業務改善にも取り組んでいます。「Chef活用ガイド」共著のほか、Debian Official Developerもやっています。

Daisuke Higuchiの記事一覧

新規CTA