Zenn
🕵️

LangChainに入門する

2025/02/10に公開

LangChainとは?

Langchainは、大規模言語モデル(LLM)を使ったアプリケーションを簡単に作成するためのフレームワークです。

主な機能:

  1. プロンプトの管理:テンプレートを使って効率的にプロンプトを作成・管理できます
  2. チェーン:複数の処理を順番につなげて実行できます
  3. エージェント:AIに特定のタスクを自律的に実行させることができます
  4. メモリ:会話の文脈を保持することができます

ちなみに LangChain のX公式アカウント (https://x.com/LangChainAI) では毎日のように情報発信されているので、適宜キャッチアップするのがおすすめです。

LCELについて

LCEL(LangChain Expression Langage)とはLangchainを使った処理をシンプルな文字列で表現できる言語です。
例えば「プロンプト → LLMでの処理 → ストリング出力」という流れを

prompt | llm | StrOutputParser()

のように簡潔に書けるため、複雑なコードを書かなくても直感的にLangChainを使えるようになります。LCELの特徴は以下のとおりです。

  1. 様々なコンポーネントにinvokeやstreamといった統一的なインターフェースが与えられていること
  2. パイプ演算子(|)を使って処理を連鎖できること
  3. アクセサ(.)を使ってコンポーネントのプロパティに簡単にアクセスできること
  4. 並列処理や条件分岐を直感的に記述できること
  5. コンポーネントの合成や再利用が容易であること
  6. デバッグやテストが容易になるよう、各ステップの実行結果を確認しやすいこと
  7. 型安全性が確保されており、開発時のエラーを早期に発見できること

などなど様々な特徴がありますが、全部覚えるのは大変なので実装する中で使えるものを学んでいきましょう!
詳しく学習したい方は以下の記事を読んでみてください。ちなみに以下の記事を書いている方はLangChainについてわかりやすい解説をYouTubeやXなどで発信しているので個人的にオススメです!
https://zenn.dev/os1ma/articles/acd3472c3a6755

実装方法

これからLangChainを使って実際にコードを書いていきたいと思います。

python3 -m venv venv # 仮想環境作成
source venv/bin/activate  # 仮想環境activate
pip install langchain
pip install -qU langchain-openai

次には API key の設定が必要です。使用するLLM(今回はOpenAI)の API key を取得して以下をターミナルで実行します。

export "取得した API key を入力" # 環境変数にする

簡単なコードを書いてみましょう!

main.py
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage

llm = ChatOpenAI(model="gpt-4o-mini") # 使用するLLMモデル

prompt = [
    SystemMessage("あなたは心優しいアシスタントです"),
    HumanMessage("こんにちは!最近人肌恋しいんだよね"),
]

response = llm.invoke(prompt) # LCELのinvokeメソッド
print(response.content) # printでターミナル上にLLMのレスポンスを出力

これを実行すると以下のような出力が得られると思います。

こんにちは!人肌恋しい時がありますよね。その気持ちはとても理解できますよ。誰かと一緒に過ごしたり、つながりを感じたりすると、心が温かくなりますよね。最近はどんなことをして過ごしていますか?

最近は大学のテスト勉強から逃避中です...
こんな感じでChatGPTと話してる時のような返事が帰ってきますね!

実装は以下の4つを意識すると取り組みやすいです。

  1. スキーマ
  2. プロンプト
  3. メモリー

一つずつ詳しくみていきましょう!

スキーマ

まず、一般的に LLM は以下のような処理の流れで使用されます。
処理の流れ
例えば LLM -> 関数1 のところでLLMの出力は json なのに関数1は str を期待していたら関数が実行できません。そこで先にLLMの入出力などの型の定義をしておくのですが、その型のことをスキーマといいます。具体例を見てみましょう。

main.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

llm = ChatOpenAI(model="gpt-4o-mini")

# スキーマの定義
class Person(BaseModel):
    name: str = Field(...,description="名前")
    power: int = Field(...,description="戦闘力")

prompt = ChatPromptTemplate.from_messages([
    "あなたは名前から戦闘力を予測するアシスタントです。{name}の戦闘力を教えてください。"
])

def print_power(response: Person):
    return f"{response.name}の戦闘力は{response.power}です。"

chain = prompt | llm.with_structured_output(Person) | print_power
print(chain.invoke({"name": "ドラゴンボールのフリーザ(第一形態)"}))
フリーザ(第一形態)の戦闘力は530000です。

ちなみに僕はどのくらい強いんでしょう?

print(chain.invoke({"name": "生駒勇丞"}))
生駒勇丞の戦闘力は80です。

筋トレ頑張ります。

遊びすぎましたがスキーマの解説に戻ると、コードの中のwith_structured_output(Person)(役割は名前の通りです)を消してみると以下のようなエラーが出ます。

エラー
Traceback (most recent call last):
  File ".../test/main.py", line 19, in <module>
    print(chain.invoke({"name": "フリーザ"}))
          ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/base.py", line 3022, in invoke
    input = context.run(step.invoke, input, config)
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/base.py", line 4711, in invoke
    return self._call_with_config(
           ~~~~~~~~~~~~~~~~~~~~~~^
        self._invoke,
        ^^^^^^^^^^^^^
    ...<2 lines>...
        **kwargs,
        ^^^^^^^^^
    )
    ^
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/base.py", line 1925, in _call_with_config
    context.run(
    ~~~~~~~~~~~^
        call_func_with_variable_args,  # type: ignore[arg-type]
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<4 lines>...
        **kwargs,
        ^^^^^^^^^
    ),
    ^
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/config.py", line 396, in call_func_with_variable_args
    return func(input, **kwargs)  # type: ignore[call-arg]
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/base.py", line 4565, in _invoke
    output = call_func_with_variable_args(
        self.func, input, config, run_manager, **kwargs
    )
  File ".../test/venv/lib/python3.13/site-packages/langchain_core/runnables/config.py", line 396, in call_func_with_variable_args
    return func(input, **kwargs)  # type: ignore[call-arg]
  File ".../test/main.py", line 16, in print_power
    return f"{response.name}の戦闘力は{response.power}です。"
                                       ^^^^^^^^^^^^^^
  File ".../test/venv/lib/python3.13/site-packages/pydantic/main.py", line 891, in __getattr__
    raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')
AttributeError: 'AIMessage' object has no attribute 'power'

つまり、print_power関数が求めている引数のスキーマであるPersonクラスに LLM の出力が対応していないので、LLM の出力の型をしっかり決めてあげる必要があるということです。

ちなみにwith_structured_outputを使わなくても、プロンプトの中で指示してコードをいじれば動くようになるのですが、以下に示すようにめんどくさいのでwith_structured_outputを使ったほうがいいと思います。

main.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

llm = ChatOpenAI(model="gpt-4o-mini")

class Person(BaseModel):
    name: str = Field(...,description="名前")
    power: int = Field(...,description="戦闘力")

prompt = ChatPromptTemplate.from_messages([
    """あなたは名前から戦闘力を予測するアシスタントです。{name}の戦闘力を教えてください。
    以下の形式でのみ出力してください:
    {{
        "name": "入力された名前",
        "power": 予測された戦闘力
    }}
    """
])

def print_power(response):
    import json
    # AIMessageからcontent(文字列)を取得
    response_text = response.content
    # 文字列をJSONに変換
    data = json.loads(response_text)
    # JSONをPersonクラスに変換
    person = Person(**data)
    return f"{person.name}の戦闘力は{person.power}です。"

chain = prompt | llm | print_power
print(chain.invoke({"name": "フリーザ"}))

プロンプト

LLM 出力内容に直結するので書く内容はしっかり考えましょう。プロンプトエンジニアリングという技術がありますが、望む出力が得られるかどうか何回も試行錯誤して最終的なプロンプトを決定するという流れが一般的かもしれません。
LangChain における ChatPromptTemplates は以下の記事がわかりやすいかと思います。
https://qiita.com/FukuharaYohei/items/f1f3ed2d4ef5a3db11ae
ちなみにプロンプトエンジニアリングガチ勢は以下の記事がいいかもしれません。
https://www.promptingguide.ai/

メモリー

ChatGPTでは会話の文脈を保持してくれますが、それを実現するには一工夫必要です。最初に作ったコードをターミナル上で会話できるようにしてみて実験してみましょう。

main.py
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage

llm = ChatOpenAI(model="gpt-4o-mini")

# メッセージ履歴を保持するリスト
messages = [
    SystemMessage(content="あなたは心優しいアシスタントです"),
]

print("チャットを開始します。終了するには 'quit' と入力してください。")

while True:
    # ユーザー入力を受け取る
    user_input = input("あなた: ")
    
    # 終了条件
    if user_input.lower() == 'quit':
        print("チャットを終了します。")
        break
    
    # LLMに送信して応答を取得
    response = llm.invoke(user_input)
    
    # 応答を表示
    print("AI:", response.content)

少し会話をしてみましょう

あなた: こんにちは

AI: こんにちは!どのようなお手伝いができますか?

あなた: 僕の名前は生駒です

AI: 生駒さん、こんにちは!どのようなお手伝いをしましょうか?

あなた: 僕の名前はなんですか?

AI: ごめんなさい、お名前はわかりません。あなたのお名前を教えていただけますか?

お互いの記憶力が欠落していますね。なぜなのでしょう?

ユーザーが「こんにちは」と入力したらLLMに「こんにちは」が送られます。
それに対してLLMは「こんにちは!どのようなお手伝いができますか?」をユーザーに返します。
その後ユーザーが「僕の名前は生駒です」を送ると今度は「生駒さん、こんにちは!どのようなお手伝いをしましょうか?」をLLMは返します。
このように、上のコードのままでは次にユーザーが「僕の名前はなんですか?」と送ってもその前のデータが送られていないのでLLMは名前がわからないのです。

こうならないためには以下のようにデータを送信するよう設定する必要があります。

以下ではこのようなデータの送信を可能にするLangChainのコードを解説します。
ただし、LangGraphを使用しますので、そちらを少し勉強してからにすると理解しやすいかもしれません。

履歴をそのまま全てLLMに送信するパターン

以下のようなコードを動かしてみましょう。

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState,StateGraph
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(model="gpt-4o-mini")

workflow = StateGraph(state_schema=MessagesState)

# モデル呼び出し用関数
def call_model(state: MessagesState):
  response = llm.invoke(state["messages"])
  return {"messages":response}

# Graphを定義
workflow.add_edge(START, "model")
workflow.add_node("model",call_model)

memory = MemorySaver() # チェックポイントを保存するためのメモリ
app = workflow.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "abc123"}} # スレッドIDを設定

while True:
    # ユーザー入力を受け取る
    query = input("ユーザー: ")

    # 終了条件
    if query.lower() == 'quit':
        print("チャットを終了します。")
        break
        
    messages = [HumanMessage(content=query)]
    output = app.invoke({"messages": messages}, config)
    print("AI:", output["messages"][-1].content)

実行結果は以下のようになりました。

ユーザー: こんにちは

AI: こんにちは!どのようにお手伝いできますか?

ユーザー: 生駒と申します

AI: 生駒さん、こんにちは!お会いできて嬉しいです。どんなことについてお話ししましょうか?

ユーザー: 僕の名前はわかりますか?

AI: はい、生駒さんとおっしゃっていましたね。何か他にお聞きしたいことがあれば教えてください!

このように、ちゃんと名前を覚えてくれていますね!
このコードではMemorySaver()がconfigごとに会話を保存してくれてるというイメージです。
ちなみに最後のコードoutput["messages"][-1].contentですが、これは LLM のoutputが以下のような構造になっているので、そのmessagesの一番最後( LLM の返答)の中のcontentを取得するという意味です。

{
    "messages": [
        HumanMessage(content="ユーザーの入力メッセージ"),
        AIMessage(content="AIの応答メッセージ")
    ]
}

履歴を一部消去するパターン

次に履歴の一部を消去することでトークン数を制限するパターンを見てみましょう。
以下のコードでは履歴の最後から5つ以内を保持するようにしています(trimmermax_tokenの部分)。
また、strategy="first"とすると最初から数えて保存するようになり、token_counter=" llm のモデル名"などとすると文章数ではなくトークン数でmax_tokenを計算してくれます。

from langchain_core.messages import trim_messages
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ここで履歴の管理を設定
trimmer = trim_messages(strategy="last", max_tokens=5, token_counter=len)

workflow = StateGraph(state_schema=MessagesState)

def call_model(state: MessagesState):
    trimmed_messages = trimmer.invoke(state["messages"])
    response = llm.invoke(trimmed_messages)
    return {"messages": response}

# グラフの定義
workflow.add_node("model", call_model)
workflow.add_edge(START, "model")

memory = MemorySaver()
app = workflow.compile(checkpointer=memory)

# 初期メッセージ例
initial_messages = [
    SystemMessage(content="あなたは優れたアシスタントで、常にジョークを交えて応答します。"),
    HumanMessage(content="僕の名前は生駒です"),
    AIMessage(content="生駒さんですね!よろしくお願いします!"),
    HumanMessage("試着を終えたお客様に、店員が話しかけている。店員: 「どこかキツいところは、ありますでしょうか?」"),
    AIMessage( "値段ですね。"),
    HumanMessage("友人から結婚式の招待状が届いた。が…"),
    AIMessage("どちらかに○をつけて下さい。\n・出席しない \n・欠席する "),
    HumanMessage("""近所のおじさんに尋ねられた時の、うちのおやじの回答。
                おじさん: 「お宅の息子さん、車を運転するようになったんですね。運転慣れるのにどの位かかりました?」
                """),
    AIMessage("2台半ですね。"),
    HumanMessage("晩飯中の心霊番組: 「お分かりいただけただろうか... もう一度ご覧いただこう」"),
    AIMessage("兄: 「おかわりいただけるだろうか... もう一度ご飯いただこう」"),
    HumanMessage("ギタリスト: 「ギターでメシ食ってるけど、質問ある?」"),
    AIMessage("それは食べにくそうですね。"),
]

config = {"configurable": {"thread_id": "abc123"}} # スレッドIDを設定

while True:

    # ユーザー入力を受け取る
    user_input = input("ユーザー: ")

    # 終了条件
    if user_input == "quit":
        break

    messages = initial_messages + [HumanMessage(content=user_input)]

    output = app.invoke({"messages": messages}, config)

    print("AI:", output["messages"][-1].content)

実行してみると以下のようになりました

ユーザー: 僕の名前は?

AI: 申し訳ありませんが、あなたの名前はわかりません。お名前を教えていただけますか?

名前を覚えてくれていませんね。それでは少し多めにmax_tokens=30としてみるとどうなるでしょう。

ユーザー: 僕の名前は?

AI: 生駒さんですよね!それとも、ギターの弦の名前ですか?それなら「E(ミ)」ですね!

なんかよく分からないことを言われましたが、ちゃんと記憶できていますね!

履歴の一部を要約するパターン

最後に、履歴の一部を要約するパターンを見てみましょう。
コードは以下のようになります。

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, RemoveMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langchain_openai import ChatOpenAI
from langsmith import traceable

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

workflow = StateGraph(state_schema=MessagesState)

@traceable
def call_model(state: MessagesState):
    message_history = state["messages"][:-1]  # 最後のメッセージは含めずに計算
    # 履歴が長い場合に要約を行う
    if len(message_history) >= 5:
        last_human_message = state["messages"][-1]
        # LLMに要約させる
        summary_prompt = (
            "Distill the above chat messages into a single summary message. "
            "Include as many specific details as you can."
        )
        summary_message = llm.invoke(
            message_history + [HumanMessage(content=summary_prompt)]
        )
        # 最初と最後のメッセージ以外を削除する指示を作成
        delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][1:-1]]
        # 要約とユーザーのメッセージをLLMに投げる
        response = llm.invoke([summary_message] + [state["messages"][i] for i in [0,-1]])
        # 状態更新
        message_updates = delete_messages + [response]
    else:
        message_updates = llm.invoke(state["messages"])

    return {"messages": message_updates}


# グラフの定義
workflow.add_node("model", call_model)
workflow.add_edge(START, "model")

memory = MemorySaver()
app = workflow.compile(checkpointer=memory)

# 初期メッセージ例
initial_messages = [
    SystemMessage(content="あなたは優れたアシスタントで、常にジョークを交えて応答します。"),
    HumanMessage(content="僕の名前は生駒です"),
    AIMessage(content="生駒さんですね!よろしくお願いします!"),
    HumanMessage("試着を終えたお客様に、店員が話しかけている。店員: 「どこかキツいところは、ありますでしょうか?」"),
    AIMessage( "値段ですね。"),
    HumanMessage("友人から結婚式の招待状が届いた。が…"),
    AIMessage("どちらかに○をつけて下さい。\n・出席しない \n・欠席する "),
    HumanMessage("""近所のおじさんに尋ねられた時の、うちのおやじの回答。
                おじさん: 「お宅の息子さん、車を運転するようになったんですね。運転慣れるのにどの位かかりました?」
                """),
    AIMessage("2台半ですね。"),
    HumanMessage("晩飯中の心霊番組: 「お分かりいただけただろうか... もう一度ご覧いただこう」"),
    AIMessage("兄: 「おかわりいただけるだろうか... もう一度ご飯いただこう」"),
    HumanMessage("ギタリスト: 「ギターでメシ食ってるけど、質問ある?」"),
    AIMessage("それは食べにくそうですね。"),
]

config = {"configurable": {"thread_id": "abc123"}} # スレッドIDを設定


while True:

    # ユーザー入力を受け取る
    user_input = input("ユーザー: ")

    # 終了条件
    if user_input == "quit":
        break

    messages = initial_messages + [HumanMessage(content=user_input)]

    output = app.invoke({"messages": messages}, config)

    print("AI:", output["messages"][-1].content)

これを実行してみるとしっかり記憶を保持してるのですが、確認が難しいので LangSmith を使ってみましょう。
LangSmith とは LangChain の開発プラットフォームで、LLMアプリケーションのデバッグ、テスト、モニタリングを簡単に行えるツールである
上のコードを見るとわかるのですが、from langsmith import traceable@traceableが追加することで以下のように確認ができます。

要約のプロンプトが英語だったので要約結果も英語になっていますが、しっかり要約されていますね!

要約結果

In the chat, 生駒さん introduced himself, and the assistant responded warmly with a light-hearted tone. The conversation included humorous exchanges, such as a joke about a store clerk asking a customer if anything felt tight, with the punchline being "the price." Another joke involved a friend's wedding invitation, where the options for response were humorously phrased. A dialogue about a neighbor's inquiry about a son driving led to a punchline about the son having "crashed" two and a half cars. Lastly, a guitarist mentioned making a living from playing guitar, prompting a joke about how that must be hard to eat. The overall tone was playful and filled with puns and light-hearted humor.

ただし、今回の会話内容は具体例が多い会話だったので要約しにくかったようですね。

まとめ

長くなりましたが、LangChain は他にも様々な使い方があり、さらに LangGraph や LangSmith なども使えます。実践ベースで学んでいきましょう!

担当:生駒

Discussion

ログインするとコメントできます