ABEJA Tech Blog

中の人の興味のある情報を発信していきます

LangChainを使わない

TL; DR

LangChainのメリデメを整理する過程で、今となってはopenai-pythonのうちChatGPTのAPIをを簡単に取り回せる程度のシンプルなライブラリがあるだけでも十分便利なんじゃないかと思ったので、ライブラリを個人で作ってみました。(バージョン0.0.1なのでちょっとお粗末な所もありますが)

github.com

はじめに

こんにちは、データサイエンティストの坂元です。ABEJAアドベントカレンダーの13日目の記事です。世は大LLM時代ということで、ありがたいことにABEJAでも複数のLLMプロジェクトを推進させて頂いています。私自身もいくつかのLLMプロジェクトに参画しています。LLMといえばLangChainが便利ですね。OpenAI APIの利用だけでなく、各種ドキュメントのパースが出来たり、HuggingFaceやインデックスDBを扱う他のライブラリとインテグレーションされていたり、RAGやAgentなどLLMシステムを構築する上で便利なモジュールが一通り網羅されていて、LangChainを使えば何も考えずともさくっとLLMのすごさを体感出来てしまいます。しかしその一方で、LangChainはアプリケーションやプロダクションに使うべきではないという声もあり、一部からは「LangChainはgarbage softwareだ」といった具合に痛烈に批判されています。LangChainのメリデメを整理した結果、「LangChainを使わずともOpenAI APIをシンプルに扱えるツールがあるだけでも結構便利なのでは?」という気持ちになったので、Langrilaというライブラリを作りました。今回の記事はこのライブラリの紹介がメインになります。

LangChainのメリットとデメリット

まずはLangChainのメリデメを整理したいと思います。

メリット

何も考えなくてもなんかすごそうなことが出来る

LangChainについては色んな人がQiitaやZennなどで使い方をまとめてくださっています。そのため、ユースケースから逆引き的にコードを検索すれば、特に何も考えずにそれっぽいことが出来ます。LLMに詳しくない人でも、適当に検索したコードを実行すればRAGでもAgentでも実装出来てしまいます。Chainを使えばLangChainでサポートされている豊富なアセットどうしを接続することも出来、組み合わせを変えるだけで本当に色々なことが実現出来ます。すごいですね。

RAGやAgent周りの実装が豊富

LangChainは更新が非常に早く、比較的新しい論文の手法も実装されていっています。RAGやAgent周りの実装も豊富で、Agentが使うToolも予め定義されたものが多くあります。これらを使うためにLangChainを使うという人もたくさんいるのではないでしょうか。すごいですね。

ドキュメントの読み込みやテキストのチャンク分割が簡単に出来る

LangChainには信じられないほどのdocument loaderが実装されています。品質とかはとりあえずいったん置いておいていいから早急にドキュメント読み込んでLLMに突っ込みたいという場面で便利です。また、インデックスDBを作る際などにメタデータを保持したままチャンク分割するのとかちょっとめんどくさいですが、この辺も良い感じにやってくれます。すごいですね。

デメリット

LangChainに対する批判としては以下のようなものが挙げられます。(数か月前のものも含むので、現在の状況とは少し異なるかもしれません)

LangChain は LLM アプリケーションの開発に採用すべきではない

The Problem With LangChain | Max Woolf's Blog

LangChain is Garbage Software

The Problem with LangChain | Hacker News

Langchain is pointless : LangChain

抽象化され過ぎていてLangChainのコードを理解するのがつらい

何も考えなくてもなんかすごそうなことが出来るの裏返しですが、中身の詳細な挙動の理解をせずに使えてしまうのでブラックボックス化しがちです。また、LangChainのアップデートは非常に早く、破壊的な変更が入ったりすることもそれなりにあり、バージョン起因のエラーがよく起こります。そして中身の詳細な挙動を知ろうとする者には試練が与えられます。

抽象化されすぎわろた。

この件についてはこちらの記事でも指摘されています。

zenn.dev

LangChainは色々なツールや機能を統一的なインターフェースで扱おうとしているため、コードが過度に抽象化されていてどこに何の機能があってどこでどの機能が使われているのかを理解するのが結構つらいです。バグが発生した時にデバッグが大変だったり、LangChainの挙動をカスタマイズしたくなってもこの事実がハードルになって中々手を出せないかもしれません。LangChainの挙動、正確に説明出来ますか?私には無理です。

ただ、最近はパッケージの安定性とモジュール性を向上するためにLangChain内の主要な抽象化やLCELなどのコアとなる機能に加えてその他のごく基本的な機能を集めたlangchain-coreというパッケージをLangChain内に分離する動きがあり、今後はこの状況も少しマシになっていくかもしれません。

github.com

継続的・長期的な開発では技術的負債になり得る

LangChainで開発していると挙動がブラックボックス化しがちなのは前述の通りですが、それゆえに思考停止で使ってしまい、LangChainを使わずに開発する方法が分からなくなりがちです。LangChainの中身を理解しようとしても前述の理由から難しいです。LangChainにロックインされている状態です。こうなってしまうとよく分からないままLangChainの枠組みの中でしか開発出来なくなり、やがてアップデートの度に頻発するエラーに悩まされるソフトウェアが出来上がります。おそらく、一通りLangChainで開発して後から別のフレームワークないしカスタム実装に移行しようとしても結構つらいのではと思います。

また、LangChainに詳しくない人がLangChainをキャッチアップするのも結構つらいです。そのため開発を引き継げなくなったり、従来の開発者が離れると挙動が誰にも説明出来ないソフトウェアだけが残る可能性も無くはないです。想像するだけで怖いですね。

Agent+Toolsな機能もFunction Callingで実現出来る

LangChainのAgentのドキュメントにはこのように書かれています。

In this example, we will use OpenAI Function Calling to create this agent. This is generally the most reliable way to create agents.

もはやFunction Callingに肉付けするだけで良いのでは…?

これはLangChainの開発の歴史も影響していて、LangChainの方がAgentの機能開発は早かったんですね。後からOpenAIがFunction CallingをサポートしたことでAgentをFunction Callingで実現出来るようになりました。実際Function Callingとその周辺を整えるだけで色んなカスタムAgentが実装出来ます。ただし、LangChainには論文の手法やすぐに使えるToolも多く実装されているので、デフォルトの設定でもいいからさくっと試してみたいという場合にはLangChainは有用だと思います。とはいえ実務ではAgentの挙動を詳細にカスタマイズしたい場面も多いので、カスタマイズしにくいLangChainを使わなきゃいけない理由は特にない気はします。

ほどよい距離感で使うならそもそもLangChain使わなくていい説

ということもありLangChainに依存し過ぎず丁度いい距離感で使いたくなります。

safjan.com

こちらのポストでも、信頼性や保守性を高めるためのLangChainの適切な使い方について以下のように書かれています。(いくつかピックアップして和訳して要約しています)

  1. LangChainはプロトタイプを素早く作成しアイデアを検証するのに便利だが、プロダクションレベルのアプリケーションでは必要な機能を自分で実装することを検討したほうがよい。

  2. LangChain各コンポーネントがどのように相互作用するのか、その核となる概念を理解し、LangChainのどの部分を使い、どの部分をカスタム実装に置き換えるかを決める。

  3. LangChainはすべてのユースケースをカバーするわけではないので特定の要件やユースケースにより適したカスタムコードを書く準備をする。

  4. Deepset Haystack、DSPy、またはsemantic-kernelやAutoGenのようなマイクロソフトのツールがプロジェクトの要件に合うようであれば、代替ライブラリの使用を検討する。

  5. LangChainの抽象化とドキュメントがあなたのニーズに十分でない場合、ソースコードで提供されているプロンプトや実装の詳細をインスピレーションとして使い、自分のプロジェクトに適応させる。

つまり、LangChainを使ったアプリケーションで信頼性と保守性を上げたいならLangChainに依存し過ぎないということですね。挙句の果てにはLangChainの実装を参考に自分で実装しよう的なことも書かれています。そしてカスタム実装に置き換える過程で「だいたいの事はわざわざLangChain使わなくても出来るな…」ということに気付きます。こうなると全面的にLangChainからリプレイスすることも選択肢に入って来るでしょう。LangChainとは何だったのでしょうか。

ちょうどいいツールが欲しいなら自作しよう

ということで、ちょっと批判の方が強くなってしまったかもしれませんが、今となってはChatGPTを簡単に取り回せる程度のシンプルなライブラリがあるだけでも十分便利なんじゃないかと思うわけです。でも探してみても意外と見つかりませんでした。なので自分で作りました。

再掲 github.com

以降ではこのライブラリについて簡単に紹介していこうと思います。依存関係は基本的にopenai-pythonを使うために必要なライブラリだけです。インストールはpipかpoetryで出来ます。

コンセプト

Langrilaのコンセプトは「シンプルに、最低限の機能を使いやすく」です。こうすることで理解しやすく後々機能追加しやすい状態を作ることが出来ます。Langrilaでは抽象クラスをなるべく共通化し、各モジュールの処理は可能な限りrun()またはarun()内に集約するようにしています。これにより、各モジュールの挙動を理解する際にまずどこを見るべきなのかが明確になります。どうしても集約するのが難しいものについては別途抽象クラスを定義しています。以降はしばらくLangrilaの基本的な使用法についての説明が続きますが、ライブラリ作成に当たって意識したことの詳細はライブラリを作る時に考えたことに記載しているので、使い方なんかどうでもええわ!という方はそこまで飛ばしてください。

基本的な使い方

まずはOpenAI APIを使うためのAPIキーを環境変数もしくはdotenvファイルに定義しておきます。Azureの場合はendpointやdeployment_idなども設定しておきましょう。次に、OpenAIChatModuleをimportしてChatGPTとやり取りします。

from langrila import OpenAIChatModule

chat = OpenAIChatModule(
    api_key_name = "API_KEY", # env variable name
    model_name="gpt-3.5-turbo-16k-0613",
)

message = "Please give me only one advice to improve the quality of my sleep."

response = chat(message)
response.model_dump()

# 出力
>>> {'message': {'role': 'assistant',
  'content': 'Establish a consistent sleep schedule by going to bed and waking up at the same time every day, even on weekends.'},
 'usage': {'prompt_tokens': 21, 'completion_tokens': 23},
 'prompt': [{'role': 'user',
   'content': 'Please give me only one advice to improve the quality of my sleep.'}]}

簡単ですね。もちろん1106のモデルバージョンも同じように使えます。

chat = OpenAIChatModule(
    api_key_name = "API_KEY", # env variable name
    model_name="gpt-4-1106-preview", # you can specify newest model
    # seed=42, # as needed
    # response_format={"type":"json_object"} # as needed
)

Azureの場合は以下のように初期化します。

chat = OpenAIChatModule(
        api_key_name="AZURE_API_KEY", # env variable name
        model_name="gpt-3.5-turbo-16k-0613",
        api_type="azure",
        api_version="2023-07-01-preview", 
        deployment_id_name="DEPLOY_ID", # env variable name
        endpoint_name="ENDPOINT", # env variable name
    )

これまで見てきたように、各種環境変数は変数名を指定する仕様で、内部でクライアントを初期化する際に環境変数の値を読み込むようにしています。

このOpenAIChatModuleは以下の機能を含むベーシックなモジュールになっています。

  • 会話履歴をロード/保存する
  • コンテンツフィルターを適用/復元する
  • プロンプトの長さを調整する(デフォルトでは指定したコンテキスト長をはみ出した分の古い会話内容が削除されます)

ChatGPTとのやり取りの部分は単にopenai-pythonをラップするだけのモジュールで実施しています。以下はその部分の関数です。

    def run(self, messages: list[dict[str, str]]) -> CompletionResults:
        if len(messages) == 0:
            raise ValueError("messages must not be empty.")

        if not isinstance(messages, list):
            raise ValueError("messages type must be list.")

        client = get_client(
            api_key_name=self.api_key_name,
            organization_id_name=self.organization_id_name,
            api_version=self.api_version,
            endpoint_name=self.endpoint_name,
            deployment_id_name=self.deployment_id_name,
            api_type=self.api_type,
            max_retries=self.max_retries,
            timeout=self.timeout,
        )

        response = client.chat.completions.create(
            model=self.model_name,
            messages=messages,
            temperature=0,
            max_tokens=self.max_tokens,
            top_p=0,
            frequency_penalty=0,
            presence_penalty=0,
            stop=None,
            **self.additional_inputs,
        )

        usage = Usage()
        usage += response.usage
        response_message = response.choices[0].message.content.strip("\n")
        return CompletionResults(
            usage=usage,
            message={"role": "assistant", "content": response_message},
            prompt=messages
        )

特に変わったことはしていないので、処理の内容を理解するのが簡単です。

会話

会話履歴も入れたい場合は、load()store()を実装した会話内容をロード・保存するモジュールをOpenAIChatModuleに渡すだけです。以下はローカルで会話履歴のJSONをロード・保存するJSONConversationMemoryを使っています。

from langrila import JSONConversationMemory

chat = OpenAIChatModule(
    api_key_name = "API_KEY", # env variable name
    model_name="gpt-3.5-turbo-16k-0613",
    conversation_memory=JSONConversationMemory("./conversation_memory.json"),
    timeout=60, 
    max_retries=2, 
)

# 最初のプロンプト
message = "Do you know Rude who is the character in Final Fantasy 7."
response = chat(message)
response.model_dump()

# 1つ目のプロンプトの出力
>>> {'message': {'role': 'assistant',
  'content': 'Yes, I am familiar with Rude, who is a character in the popular video game Final Fantasy 7. Rude is a member of the Turks, an elite group of operatives working for the Shinra Electric Power Company. He is known for his bald head, sunglasses, and calm demeanor. Rude often partners with another Turk named Reno, and together they carry out various missions throughout the game.'},
 'usage': {'prompt_tokens': 22, 'completion_tokens': 81},
 'prompt': [{'role': 'user',
   'content': 'Do you know Rude who is the character in Final Fantasy 7.'}]}

# 次のプロンプト
message = "What does he think about Tifa?"
response = chat(message)
response.model_dump()

# 出力
>>> {'message': {'role': 'assistant',
  'content': "Rude's thoughts and feelings about Tifa, another character in Final Fantasy 7, are not explicitly stated in the game. However, it is known that Rude respects Tifa as a formidable fighter and acknowledges her skills. In the game, Rude and Tifa have a few encounters where they engage in combat, but there is no indication of any personal feelings or opinions Rude may have towards Tifa beyond their professional interactions."},
 'usage': {'prompt_tokens': 119, 'completion_tokens': 88},
 'prompt': [{'role': 'user',
   'content': 'Do you know Rude who is the character in Final Fantasy 7.'},
  {'role': 'assistant',
   'content': 'Yes, I am familiar with Rude, who is a character in the popular video game Final Fantasy 7. Rude is a member of the Turks, an elite group of operatives working for the Shinra Electric Power Company. He is known for his bald head, sunglasses, and calm demeanor. Rude often partners with another Turk named Reno, and together they carry out various missions throughout the game.'},
  {'role': 'user', 'content': 'What does he think about Tifa?'}]}

ちょっと見にくいですが、2つ目のプロンプトには1つ目の会話の内容が含まれていて、その内容に応じて回答出来ていることが分かります。JSONConversationMemoryは以下のような実装になっています。特に言うことはないくらいシンプルです。これと同じようにカスタムのMemoryを実装すれば好きな形式、好きな場所に会話履歴を保存出来るはずです。

import json
import os

from ..base import BaseConversationMemory


class JSONConversationMemory(BaseConversationMemory):
    def __init__(self, path: str = "conversation_memory.json", exist_ok: bool = False):
        self.path = path

        if not exist_ok and os.path.exists(self.path):
            os.remove(self.path)

    def store(self, conversation_history: list[dict[str, str]]):
        with open(self.path, "w") as f:
            json.dump(conversation_history, f, ensure_ascii=False)

    def load(self) -> list[dict[str, str]]:
        if os.path.exists(self.path):
            with open(self.path, "r") as f:
                conversation_history = json.load(f)
            return conversation_history
        else:
            return []

ストリーム出力

ストリーム出力って結構取り扱いめんどくさいですよね。Langrilaでは一応ストリームのサポートもしています。使い方は以下のようにOpenAIChatModuleの呼び出し時にstream=Trueを渡すだけです。

prompt = "Please give me only one advice to improve the quality of my sleep."
response = chat(prompt, stream=True)

for c in response:
    # print(c, end="\r") # for flush
    print(c)

# 出力
>>> Establish
Establish a
Establish a consistent
Establish a consistent sleep
Establish a consistent sleep schedule
Establish a consistent sleep schedule by
Establish a consistent sleep schedule by going
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Establish a consistent sleep schedule by going to bed and waking up at the same time every day, even on weekends.
message={'role': 'assistant', 'content': 'Establish a consistent sleep schedule by going to bed and waking up at the same time every day, even on weekends.'} usage=Usage(prompt_tokens=21, completion_tokens=30, total_tokens=51) prompt=[{'role': 'user', 'content': 'Please give me only one advice to improve the quality of my sleep.'}] # return the CompletionResults instance at the end

ストリーム処理の場合ストリーム中は順次返されるChunk文字列だけ出力しますが、最後にストリーム処理じゃない場合と同様にCompletionResultsクラスインスタンスが返され、プロンプトや返ってきたメッセージ全体、使用したトークン数の情報を取得出来ます。

非同期処理

非同期処理もサポートしています。やり方はOpenAIChatModuleの呼び出し時にarun=Trueを渡すだけです。

await chat(prompt, arun=True)

また、以下では4つのプロンプトを非同期のバッチ処理で処理する例です。

messages = [
    "Please give me only one advice to improve the quality of my sleep.", 
    "Please give me only one advice to improve my memory.",
    "Please give me only one advice on how to make exercise a habit.",
    "Please give me only one advice to help me not get bored with things so quickly." 
            ]

await chat.abatch_run(messages, batch_size=4)

# 出力
>>> [CompletionResults(message={'role': 'assistant', 'content': 'Establish a consistent sleep schedule by going to bed and waking up at the same time every day, even on weekends.'}, usage=Usage(prompt_tokens=21, completion_tokens=23, total_tokens=44), prompt=[{'role': 'user', 'content': 'Please give me only one advice to improve the quality of my sleep.'}]),
 CompletionResults(message={'role': 'assistant', 'content': 'One advice to improve memory is to practice regular physical exercise. Exercise has been shown to enhance memory and cognitive function by increasing blood flow to the brain and promoting the growth of new brain cells. Aim for at least 30 minutes of moderate-intensity exercise, such as brisk walking or jogging, most days of the week.'}, usage=Usage(prompt_tokens=18, completion_tokens=64, total_tokens=82), prompt=[{'role': 'user', 'content': 'Please give me only one advice to improve my memory.'}]),
 CompletionResults(message={'role': 'assistant', 'content': "Start small and be consistent. Start with just a few minutes of exercise each day and gradually increase the duration and intensity over time. Consistency is key, so make it a priority to exercise at the same time every day or on specific days of the week. By starting small and being consistent, you'll be more likely to stick with it and make exercise a long-term habit."}, usage=Usage(prompt_tokens=21, completion_tokens=76, total_tokens=97), prompt=[{'role': 'user', 'content': 'Please give me only one advice on how to make exercise a habit.'}]),
 CompletionResults(message={'role': 'assistant', 'content': 'One advice to help you not get bored with things so quickly is to cultivate a sense of curiosity and explore new perspectives. Instead of approaching tasks or activities with a fixed mindset, try to approach them with an open mind and a desire to learn something new. Embrace the mindset of a beginner and seek out different ways to engage with the task at hand. By continuously seeking novelty and finding new angles to approach things, you can keep your interest alive and prevent boredom from setting in.'}, usage=Usage(prompt_tokens=24, completion_tokens=96, total_tokens=120), prompt=[{'role': 'user', 'content': 'Please give me only one advice to help me not get bored with things so quickly.'}])]

簡単ですね。ストリーム処理も非同期で出来ます。(ただしストリームはバッチ処理には対応していません)

Function Calling

Function Callingも使えます。Function Callingを使うにはOpenAIFunctionCallingModuleをimportします。よくある天気の取得関数を定義して使ってみます。ここではどんな都市でもどんな日でも必ず晴れになる関数を定義します。仕事を止めて外に出ましょう。

from langrila import ToolConfig, ToolParameter, ToolProperty, OpenAIFunctionCallingModule

def get_weather(city: str, date: str) -> str:
    return f"The weather in {city} on {date} is sunny."

tool_config = ToolConfig(
            name="get_weather",
            description="Get weather at current location.",
            parameters=ToolParameter(
                            properties=[
                                ToolProperty(
                                    name="city",
                                    type="string",
                                    description="City name"
                                ),
                                ToolProperty(
                                    name="date",
                                    type="string",
                                    description="Date"
                                )
                            ],
                            required=["city", "date"]
                        )     
                    )  

                    
client = OpenAIFunctionCallingModule(
        api_key_name="API_KEY", 
        model_name = "gpt-3.5-turbo-1106",
        tools=[get_weather], 
        tool_configs=[tool_config],
        seed=42,
    )


response = await client("Please tell me the weather in Tokyo on 2023/12/13", arun=True)
response.model_dump()

# 出力
>>> {'usage': {'prompt_tokens': 71, 'completion_tokens': 24},
 'results': [{'call_id': 'call_NRCqCcPhbRWygXq26h7Pll9n',
   'funcname': 'get_weather',
   'args': '{"city":"Tokyo","date":"2023/12/13"}',
   'output': 'The weather in Tokyo on 2023/12/13 is sunny.'}],
 'prompt': [{'role': 'user',
   'content': 'Please tell me the weather in Tokyo on 2023/12/13'}]}

こちらも簡単ですね。関数本体を渡しているので、関数の実行まで内部でやってくれます。

インデックスDBの作成とRetrieval

Langrilaでは一応ChromaとQdrantのローカルモードでのインデックスDB作成とRetrievalをサポートしています。以下はQdrantを使った例です。qdrant-clientを別途インストールしてください。

from langrila import OpenAIEmbeddingModule
from langrila.database.qdrant import QdrantLocalCollectionModule, QdrantLocalRetrievalModule

embedder = OpenAIEmbeddingModule(
        api_key_name="API_KEY", 
    )

collection = QdrantLocalCollectionModule(
    persistence_directory="qdrant", 
    collection_name="sample", 
    embedder=embedder
)

# 適当なドキュメントを定義
documents = [
    "Langrila is a useful tool to use ChatGPT with OpenAI API or Azure in an easy way.",
    "LangChain is a framework for developing applications powered by language models.", 
    "LlamaIndex (GPT Index) is a data framework for your LLM application."
]

# インデックス作成
collection(documents=documents)

OpenAIEmbeddingModuleでテキストをベクトルに変換し、QdrantでインデックスDBを作っています。OpenAIEmbeddingModuleはバッチ処理するようになっています。QdrantのRetrieverは2通りの初期化方法があります。collectionモジュールを既に初期化しているならcollection.as_retriever()でretrieverが初期化出来ます。そうじゃない場合はQdrantLocalRetrievalModuleを直接初期化します。

# In the case collection was already instantiated
# retriever = collection.as_retriever(n_results=2, threshold_similarity=0.8)

retriever = QdrantLocalRetrievalModule(
            embedder=embedder,
            persistence_directory="qdrant", 
            collection_name="sample", 
            n_results=2,
            threshold_similarity=0.8,
        )

# クエリ
query = "What is Langrila?"
retriever(query, filter=None).model_dump()

# 出力
>>> {'ids': [0],
 'documents': ['Langrila is a useful tool to use ChatGPT with OpenAI API or Azure in an easy way.'],
 'metadatas': [None],
 'similarities': [0.8371831512206701],
 'usage': {'prompt_tokens': 6, 'completion_tokens': 0}}

ちゃんとRetrieval出来ていますね。ちなみにsimilarityはコサイン類似度です。当然ですが、metadataでのフィルタリングも出来ます(これ自体はqdrant-clientの機能)。

Chromaでも同じように出来ます(別途chromadbpysqlite3-binaryをインストールしてください)

from langrila.database.chroma import ChromaCollectionModule, ChromaRetrievalModule

collection = ChromaCollectionModule(
    persistence_directory="chroma", 
    collection_name="sample", 
    embedder=embedder
)

collection(documents=documents)

retriever = collection.as_retriever(n_results=2, threshold_similarity=0.8)

query = "What is Langrila?"
retriever(query, where=None).model_dump()

>>> {'ids': ['0'],
 'documents': ['Langrila is a useful tool to use ChatGPT with OpenAI API or Azure in an easy way.'],
 'metadatas': [None],
 'similarities': [0.8371831512206707],
 'usage': {'prompt_tokens': 6, 'completion_tokens': 0}}

プロンプトテンプレート

プロンプトテンプレートは単なるf-文字列なので無くてもいいかなと思ったのですが、プロンプトテンプレート(のテンプレート)も用意しています。

from langrila import PromptTemplate
template = PromptTemplate(
    args={"name":"Sakamoto", "my_job":"Data Scientist"},
    template="My name is {name}. I'm a {my_job}"
)

template.format()

>>> "My name is Sakamoto. I'm a Data Scientist"

最初にテンプレートだけ、またはテンプレートの引数だけ登録することも出来ます。

# 最初にテンプレートだけ登録して後から引数を登録する
template = PromptTemplate(
    template="My name is {name}. I'm a {my_job}"
)
template.set_args(name="Sakamoto", my_job="Data Scientist")

# 最初に引数だけ登録して後からテンプレートを登録する
template = PromptTemplate(
    args={"name":"Sakamoto", "my_job":"Data Scientist"},
)
template.set_template("My name is {name}. I'm a {my_job}")

ちなみに登録したテンプレートに埋め込まれている引数とargsで渡した引数が一致していないとエラーになります。

template = PromptTemplate(
    args={"name":"Sakamoto", "age":3},
    template="My name is {name}. I'm a {my_job}"
)

# エラー
>>> ValidationError: 1 validation error for PromptTemplate
  Value error, Template has args {name, my_job} but {name, age} were input [type=value_error, input_value={'args': {'name': 'Sakamo...{name}. I'm a {my_job}"}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.5/v/value_error

モジュールを組み合わせてRetriever付きのLLMモジュールを作る

これらの基本的なモジュールを組み合わせれば、色んなモジュールが作れそうです。Retrievalした文章とユーザープロンプトをプロンプトテンプレートで統合してLLMにインプットするベーシックなモジュールをAzure OpenAIとローカルのインデックスでつくってみましょう。

from langrila import BaseModule, PromptTemplate
from langrila.database.qdrant import QdrantLocalRetrievalModule


class RetrievalChatWithTemplate(BaseModule):
    def __init__(
        self,
        api_type: str = "azure",
        api_version: str = "2023-07-01-preview",
        api_version_embedding: str = "2023-07-01-preview",
        endpoint_name: str = "ENDPOINT",
        api_key_name: str = "API_KEY",
        deployment_id_name: str = "DEPLOY_ID",
        deployment_id_name_embedding: str = "DEPLOY_ID_EMBEDDING",
        max_tokens: int = 2048,
        timeout: int = 60,
        max_retries: int = 2,
        context_length: int = None,
        model_name="gpt-3.5-turbo-16k-0613",
        path_to_index: str = "path-to-index",
        index_collection_name: str = "collection-name"
    ):
        chatmodel_kwargs = {
            "api_type": api_type,
            "api_version": api_version,
            "api_key_name": api_key_name,
            "endpoint_name": endpoint_name,
            "deployment_id_name": deployment_id_name,
        }

        embedding_kwargs = {
            "api_type": api_type,
            "api_version": api_version_embedding,
            "api_key_name": api_key_name,
            "endpoint_name": endpoint_name,
            "deployment_id_name": deployment_id_name_embedding,
            "model_name": "text-embedding-ada-002"
        }

        self.chat = OpenAIChatModule(
            **chatmodel_kwargs,
            max_tokens=max_tokens,
            timeout=timeout,
            max_retries=max_retries,
            model_name=model_name,
            context_length=context_length,
            conversation_memory=None,
        )

        self.retriever = QdrantLocalRetrievalModule(
            embedder=OpenAIEmbeddingModule(**embedding_kwargs),
            persistence_directory=path_to_index,
            collection_name=index_collection_name,
            n_results=5,
            threshold_similarity=0.8,
        )

    def run(self, prompt: str, prompt_template: Optional[PromptTemplate] = None):
        retrieval_results = self.retriever(prompt)
        relevant_docs = "\n\n".join(retrieval_results.documents)

        if isinstance(prompt_template, PromptTemplate):
            prompt = prompt_template.set_args(relevant_docs=relevant_docs).format()

        response = self.chat(prompt)
        return response

随分シンプルな実装で済みますね。

その他

トークン

LLMを何回か呼び出して消費したトークン数の合計を取得したいことがあります。ここまでにも出てきていたUsageクラスはトークン数の足し引きが出来ます。些細な機能だけど地味に嬉しいです。

from langrila import Usage

usage1 = Usage(prompt_tokens=100, completion_tokens=200)
usage2 = Usage(prompt_tokens=300, completion_tokens=400)
usage1 + usage2

# 出力
>>> Usage(prompt_tokens=400, completion_tokens=600, total_tokens=1000)

足す変数はdict型でも大丈夫です

usage1 = Usage(prompt_tokens=100, completion_tokens=200)
usage2 = {"prompt_tokens":300, "completion_tokens":400, "total_tokens":700}
usage1 + usage2

# 出力
>>> Usage(prompt_tokens=400, completion_tokens=600, total_tokens=1000)

ライブラリを作る時に考えたこと

再掲ですが、Langrilaのコンセプトは「シンプルに、最低限の機能を使いやすく」です。ここには、「シンプル」「最低限の機能」「使いやすく」という3つのキーワードがありますが、それぞれに対してどのように考えたかを書き残しておきます。

シンプル

実装がシンプルであることは理解のしやすさに直結します。逆に、理解がしやすいコードは実装がシンプルであることが多いです。ライブラリを作る時は、まず理解しやすいとはどういう状態かを考えて、それを実現するための実装案を検討しました。

理解しやすい状態 実装案
各クラスの挙動を理解するために見るべきソースコードの箇所が明確になっている 抽象クラスを極力共通化する
一つのクラスの挙動を理解するために親クラスを頑張って辿る必要がない 極力入れ子の継承や多重継承をしない

LangrilaではChat、Function Calling、Embedding、Retrievalなどのモジュールは全て同じBaseModuleという抽象クラスのみを継承していて、継承を入れ子にしたり多重継承することを避けています。新しくモジュールを作る際も原則としてBaseModuleを継承します。どうしても一つの抽象クラスだとモジュールによってはインターフェースを統一するのが難しい場合もあるので、そういう場合は別途抽象クラスを定義しています。ただし、別途抽象クラスを定義した場合においてもクラスの継承方針に極力従います。こうすることで理解しやすくシンプルになり、色んな機能を追加しやすくなります。

この実装方針のデメリットは、抽象クラスを共通化するために抽象クラスに具象メソッドを追加すると、それを継承したモジュール内で使用出来ない具象メソッドが抽象クラスに定義されてしまうことです。例えば、BaseModuleにはどのモジュールでも必ず実装するrun()という抽象メソッド以外に、stream()といったような具象メソッド(NotImplementedErrorをraiseするだけ)が定義されていますが、この具象メソッドはRetrievalやEmbeddingでは使えません。しかしソースコードのみだと「実装されていないだけ」なのか「実装が許されていないのか」のかの区別が出来ないので、このようなメソッドを最小限に抑える必要があります(NotImplementedErrorをraiseするだけならこの抽象クラスに定義しなくていいという説もあります)

最低限の機能

最低限の機能としては、このモジュールさえあればそれを組み合わせて色々なモジュールが作れる、というものを想定しています。使用頻度が特に高くかつ基本的な処理などが該当するのかなと思います。Agentすごいといっても中身を分解するとCompletion、Function Calling、Embedding、Index DB(Collection+Retrieval)、Tools、+αの組み合わせだったりします。特に前者4つのような基本的な処理を整備しておけば色んなケースに対応しやすくなるので、まずはこれらの基本的な機能を優先的に実装しました。また、会話履歴の保存もすぐに出来ると嬉しい場面があるので、Langrilaでもとりあえずローカルに履歴を保存するモジュールを用意しています。

使いやすく

使いやすさについても同様に、使いやすい状態とは何かについて考えました。以下は個人的な基準ですが、

  • ほぼ必ず実行する一連の処理を一つのメソッドで実行出来る
  • インターフェースが統一されている
  • カスタマイズ性が高い

上記のような状態だと使いやすいな~と感じることが多いです。

一点目について、例えばRAGはSelf Query RetrievalだろうがParent Document Retrievalだろうがそれ以外のカスタムRetrievalだろうがクエリ文を入力してベクトル化してインデックスDBを(場合によってはフィルタリングして)検索してスコアで選別するという一連の処理はほぼ共通しています。例えばFunction Callingなら、Toolやその定義を渡してメッセージを整えてChatGPTのAPIを叩いてレスポンスをパースして関数を実行するところは基本セットで実行すると思います。このような一連の処理が簡単に実行出来ると嬉しいので、Langrilaでもその方針でモジュールを実装しています。ユースケースに合わなければ自分で実装するのも簡単なので、都度対応すればいいかなとも思っています。

二点目について、使用するモジュールによって実行するメソッドが変わると覚えるのも結構大変ですよね。Scikit-Learnとか使いやすいなと思うのですが、それはScikit-Learnがモジュールに依らずインターフェースを統一していて、実行するべき関数が比較的すぐに分かるからかなと思っています。覚える事が少ない方が使用するハードルは下がりますしね。この点については、Langrilaでは抽象クラスを極力共通化することで一定対処出来ているかなと思います。

また、OpenAI APIはモデルのバージョンによって引数や出力のインターフェースが変わりますが、こういうややこしい使い分けをしないといけないのは苦痛なので、他のライブラリと同様Langrilaでもモデルバージョンが1106以降でも以前でも、OpenAIでもAzureでも同じインターフェースで使えるようにしています。

カスタマイズ性については、必要になった時に大きな負荷がなく処理を修正・実装出来ると嬉しいなと思っています。Langrilaでも基本的には簡単にカスタマイズ出来るようになっていると思います。

残った課題とライブラリ作成の難しさ

ここまで書いてきたようにLangrilaはシンプルさと使いやすさを両立しようとして作っていたのですが、両者の思想がバッティングしている部分があるのが課題です。例えば、カスタマイズ性を重視するなら各モジュールで入力と出力が従うべきフォーマットが明確に定義されていることが望ましいですが、抽象クラスをなるべく共通化するという実装方針とバッティングしています。この辺り、自分が使う分には気にならないのですが(中身を知っているので)、開発観点ではよろしくないので、良いアイデアが出てきたらそのうち仕様変更をするかもしれません。上記の内容も「バージョン0.0.1を作るにあたって意識したこと」程度に捉えて頂くのがいいかなと思います。ライブラリを作り始めると色々便利にしたい気持ちが湧いてくるのですが、全てを両立するライブラリを作るのは難しいですね。今後もトライ&エラーでライブラリ作成スキルを上げていこうと思います。(もちろん良いコーディングとは何かについても勉強しつつ)

まとめ

今回はChatGPTを取り回しやすくするだけのありそうでなかったライブラリを作ってみました。色々機能追加出来そうな気がするので気が向いた時に追加してみようかなと思います(Langrilaに追加するかアセットとして別リポジトリにするかで)。GPT-4Vはまだサポートしていないのでその辺とか。個人開発なのでのんびりです。

また、今回の記事ではLangChainのメリットだけでなくデメリットにも言及しました。LangChainに対して色々批判はあるもののなんだかんだで論文手法の適用は圧倒的に早く出来ますし、LangSmithもありますし、ソフトウェアとしての安定性を向上する動きもあるので、そのうち「LangChainやっぱ最高だな!」と思える日が来るかもしれません。何よりもこれだけのユーザーに使われている&コントリビュートされているということ自体が凄いですね。LLM含め周辺機能の開発を牽引してくれていることは間違いないので、LangChainのメンテナーとコントリビュータに圧倒的感謝しましょう!

さいごに

We Are Hiring! ABEJAは、テクノロジーの社会実装に取り組んでいます。 技術はもちろん、技術をどのようにして社会やビジネスに組み込んでいくかを考えるのが好きな方は、下記採用ページからエントリーください! (新卒の方のエントリーもお待ちしております)

careers.abejainc.com