CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

ブログタイトル

LangGraphを使ってマルチエージェントによる会話システムを実装してみました。

こんにちは、CCCMKホールディングス TECH LABの三浦です。

継続は力なり」という言葉がありますが、私もその通りだと思います。毎日少しずつでいいから続けることが大切なのですが、大切だと分かっていてもなかなか続かないことも多いです・・・。何か一つでもいいから、新しい継続出来ることを見つけたいと思っています。

今回は特定の人格を持ちChatGPTによって対話が出来る機能を持った複数のエージェント(Multi-Agent)同士を特定のテーマについて会話をさせ、その結果を要約し、何らかのインサイトを抽出する、という一連の流れを自動的に実行する仕組みをLangGraphを作って組んでみました。

Multi-Agent Conversation

ChatGPTはプロンプトに人格を表す情報を与えるとその人物の様に会話をする、ということが可能です。それを利用して、複数の人物の人格を用意してプロンプトに動的に組み込むことで同じChatGPTを使っていながらあたかも複数の人物と会話をしているような状況を作り出すことが出来ます。そしてある人格で生成された発言に対し、別の人格で応答を生成することを繰り返すことで複数の人物による会話そのもの(に似ているもの)をChatGPTで生成することが出来ます。

ChatGPTは複雑なタスクを一度に与えるよりも、複雑なタスクを細かいサブタスクに分割して与えた方が効率的にタスクをこなすことが出来るといわれており、複数の人格を設定して多角的にテーマに対する意見を生成させる方法は、大きなタスクをサブタスクに分割する方法に通ずるものがあります。

人格を搭載し、その人の代理となるAgentを複数用意してあるテーマについて会話(Conversation)させ、どのような会話が生成されてそこからどんなインサイトを得ることが出来るのか。この"Multi-Agent Conversation"の仕組みをLangGraphを使って実際に作成し、確認してみたのが今回の記事の主な内容になります。

Agentの構成

ここからは具体的に、今回のMulti-Agent Conversationを実現するためにどんなAgentを設定したのかを説明します。参考にしたのは"AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation"という論文に掲載されている"Dynamic Group Chat"の構成です。論文に掲載されている図の、A5(下段真ん中)が該当します。

■参考論文

  • Title: AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation
  • Authors: Qingyun Wu, Gagan Bansal, Jieyu Zhang, Yiran Wu, Beibin Li, Erkang Zhu, Li Jiang, Xiaoyun Zhang, Shaokun Zhang, Jiale Liu, Ahmed Hassan Awadallah, Ryen W White, Doug Burger, Chi Wang
  • Submit: Submitted on 16 Aug 2023 (v1), last revised 3 Oct 2023
  • arXivURL: https://arxiv.org/abs/2308.08155

AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation, Figure 3

AutoGenについては以前こちらの記事でもご紹介させて頂いています。

techblog.cccmkhd.co.jp

今回のシステムは、3種類のAgentで構成されています。

  • Conversation Manager
    ある発言者が発言を行った後、これまでの会話の様子から次に話すべき発言者を決める役割を持ったAgentです。 意味のない会話が続くことを防ぐため、会話がこれ以上発展しないと判断し、会話を終了させる役割も持たせます。 また、会話の一番最初に司会者としての発言も行います。 今回は未実装ですが、今後は司会者として会話の特定の間隔で会話の内容を発展させるような介入をする役割も持たせたいと考えています。

  • Speaker
    特定の人格を持ち、会話に参加して発言を行うAgentです。今回は3つの人格を設定してみました。

  • Conversation Summarizer
    Speakerによる会話が終了した後、会話の全体を見て、その会話からどんなインサイトが得られるかをまとめ、レポーティングする役割を持ったAgentです。Speakerによって生成された会話だけを見ても、面白いけど結局どうなの?と感じることが多く、このAgentを組み込むことでより有益な情報に変換出来ることを期待しています。

Multi-Agent ConversationのAgent構成

実装

それではこれまでの内容をLangGraphを使って実装してみます。実装と検証はJupyter Notebookで行いました。

人格データ

まずSpeakerの人格の元となるデータは、以下のように用意しました。

[
  {
    'A子': "'名前': 'A子', '性別': '女性', '年代': '30代', '職業': '会社員', '趣味': '音楽鑑賞', '性格': '落ち着いた性格で、じっくり物事を考えて発言します。'"
  },
  {
    'B太': "'名前': 'B太', '性別': '男性', '年齢': '20代', '職業': '大学生', '趣味': 'テレビゲーム', '性格': '明るく、前向きな性格です。'"
  },
  {
    'C助': "'名前': 'C助', '性別': '男性', '年齢': '40代', '職業': '会社役員', '性格': '温厚で、話しやすい雰囲気を持っています。'"
  }
]

最初、人格設定をよりリアルにしようと人格に付ける名前をフルネームで作って設定していました。ところがその場合だとAzure OpenAI Serviceのコンテンツフィルタリングに検知されることが多く、名字だけ、あるいは上記のような明らかに仮名と分かる名前を設定することで対応することが出来ました。

このデータをPythonの辞書型のリスト形式で変数(SPEAKERS)に格納し、Speakerの名前だけを格納した文字列型のリストも変数(SPEAKERS_NAMES)に格納しています。

システムの状態

このシステムの実行中に保持される状態を表現するクラスを定義します。

from typing import Annotated, List, TypedDict
import operator

class AppState(TypedDict):
    """
    アプリケーション実行中に保持される状態を表すクラス
    
    Attributes:
      thema(str): 会話のテーマ
      history(List[str]): 会話の履歴
      speak_count(int): 全体の発言回数
      next_speaker(str): 次の発言者の名前
      conversation_summary(str): 会話全体の要約
    """
    thema: str
    history: Annotated[List[str], operator.add]
    speak_count: int
    next_speaker: str
    conversation_summary: str

historyAnnotated[List[str], operator.add]とすることで各Agentが新しいhistoryを生成すると、自動的に状態のhistoryに追加されるようになります。

Agentの定義

ここが今回の実装のメインとなる部分です。3つのAgentの振る舞いを関数で定めています。

import os
import random

from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import AzureChatOpenAI

def speaker(state: AppState):
    """
    会話の参加者として発言を生成します。

    Args:
      state(AppState): AppState
    Return:
      Dict[str]: 生成した発言
    """
    speaker_name = state.get("next_speaker",None)
    history = state.get("history",[])
    thema = state.get("thema","")
    speak_count = state.get("speak_count",0)

    model = AzureChatOpenAI(
        api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
        azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
        api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
        model_name=os.environ.get("MODEL_NAME"),

    )
    
    system_message = f"""あなたは次のようなパーソナリティを持った人物です。
    ------------
    {SPEAKERS[SPEAKERS_NAMES.index(speaker_name)]}
    ------------
    この人物"{speaker_name}"として他の参加者と{thema}について会話をし、全員で結論を出してください。
    """

    human_message_prefix = f"""あなたは今{thema}について他の参加者と会話をし、全員で結論を導くタスクが与えられています。
    これまでの会話の履歴を見て、あなたの{thema}についての意見を自然な短い文体で作成してください。
    あなたから誰かの意見を仰ぐ発言はしてはいけません。

    # 会話の履歴
    """
    human_message = human_message_prefix + "\n".join(history) + f"\n{speaker_name}: "

    response = model.invoke(
        [
            SystemMessage(content=system_message), 
            HumanMessage(content=human_message_prefix)
        ]
    )
    print(f"{speaker_name}: " + response.content)
    return {
        "history": [f"{speaker_name}: { response.content}"], 
        "speak_count" : speak_count + 1
    }

def conversation_manager(state: AppState):
    """
    会話の管理者として次の発言者を決定します。

    Args:
      state(AppState): AppState
    Return:
      Dict[str]: 次の発言者の名前
    """

    max_speak_count = 9 # 会話の回数の最大値
    first_speaker_index = 0 # 一番最初の発言者のindex
    speak_count = state.get("speak_count",0)
    thema = state.get("thema","")
    history = state.get("history",[])

    model = AzureChatOpenAI(
        api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
        azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
        api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
        model_name=os.environ.get("MODEL_NAME"),
    )
    
    greeting_msg = f"""司会: 今日は[{thema}]についてのみなさんの活発なご意見を聞かせて下さい!"""
    speakers_names_str = ",".join(SPEAKERS_NAMES)
    
    if speak_count > max_speak_count:
        # 会話の最大回数まで到達した場合は強制終了
        return {"next_speaker": "no_one"}

    if speak_count == 0:
        # 会話の開始時はConversationManagerによる挨拶を送り、index=0の発言者を指名する
        return {"history": [greeting_msg], "next_speaker": SPEAKERS_NAMES[first_speaker_index]}

    system_message = f"""{speakers_names_str}が{thema}についての会話をしています。
    あなたはこの会話を管理する役割を持っています。
    与えられる会話の履歴を読み、次に発言すべき参加者の名前を決定します。"""
    
    human_message = f"""これまでの{thema}についての[{speakers_names_str}]の履歴を見て、
    会話の結論がまとまるまで次に誰が発言すべきかを決めて下さい。
    必ず一人2回以上発言させるようにしてください。
    同じような内容の会話が連続しこれ以上会話に変化が見られないと判断した場合は[TERMINATE]と出力してください。
    それ以外は必ず[{speakers_names_str}]のどれかを出力してください。

    # 履歴
    {history}

    次の発言者: 
    """

    response = model.invoke(
        [
            SystemMessage(content=system_message), 
            HumanMessage(content=human_message)
        ]
    )
    # 出力テキストのパース処理
    generate_text = response.content.replace("次の発言者:","").strip().replace("'","")

    if generate_text in SPEAKERS_NAMES:
        # 次の発言者が指定された場合
        return {"next_speaker": generate_text}
    elif "TERMINATE" in generate_text:
        # 会話の終了と判断された場合
        return {"next_speaker": "no_one"}
    else:
        # それ以外
        return {"next_speaker": None}

def conversation_summarizer(state: AppState):
    """
    会話の最後に会話の内容を要約し、インサイトを作成します。

    Args:
      state(AppState)
    Return:
      Dict[str]: 生成した会話から得られるインサイト
    """
    thema = state.get("thema","")
    history = state.get("history",[])

    model = AzureChatOpenAI(
        api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
        azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
        api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
        model_name=os.environ.get("MODEL_NAME"),
    )
    
    system_message = "あなたは複数人の会話から有益なインサイトを見つけることに長けています。"
    human_message = f"""これまでの{thema}についての会話の履歴を見てください。
    そのあと、その会話からどんなインサイトが得られるかを考え、出力してください。

    # 履歴
    {history}
    
    """
    response = model.invoke(
        [
            SystemMessage(content=system_message), 
            HumanMessage(content=human_message)
        ]
    )
    return{"conversation_summary": response.content}

Conversation Managerの判断による処理の分岐

Conversation Managerの振る舞いを定義するconversation_manager関数はnext_speakerキーを含む辞書型を返します。next_speakerに対応する値には、次に発言すべきSpeakerがいればそのSpeakerの名前を、会話の上限回数に達した場合は"no_one"を、エラー発生などそれ以外はNoneが設定されます。そしてその値に応じて処理を分岐させる方法を取りました。

具体的にはSpeakerの名前が設定されていれば会話の継続に、"no_one"が設定されていれば会話の要約処理に、Noneの場合は処理全体の終了に分岐します。

def next_speaker(state: AppState):
    """
    次の発言者を決めます。
    
    Args:
      state: AppState
    Return:
      str: 次のアクション
    """
    speaker_name = state.get("next_speaker",None)

    if speaker_name in SPEAKERS_NAMES:
        # 次の発言者が指定された場合
        return "continue"
    elif speaker_name == "no_one":
        # 会話が完了した場合
         return "summarize"
    else:
        # それ以外
        return "end"

システム全体の構築

あとはこれまで作成したものを統合し、システムとして組み上げていきます。

from langgraph.graph import END, StateGraph

workflow = StateGraph(AppState)
workflow.add_node("conversation_manager", conversation_manager)
workflow.add_node("speaker", speaker)
workflow.add_node("conversation_summarizer", conversation_summarizer)

workflow.add_conditional_edges(
    "conversation_manager",
    next_speaker,
    {
        "continue": "speaker",
        "summarize": "conversation_summarizer",
        "end": END
    }
)

workflow.add_edge("speaker", "conversation_manager")
workflow.add_edge("conversation_summarizer", END)
workflow.set_entry_point("conversation_manager")
group_discussion = workflow.compile()

動かした様子

それではどのようになるか、実行してみた結果をご紹介します。まず"理想の休日の過ごし方"というテーマを与えてみます。

discussion_history = group_discussion.invoke({
    "thema":"理想の休日の過ごし方",
    "speak_count": 0,
    "history":[],
    "next_speaker": None,
    "conversation_summary": ""
})

会話の履歴は以下の様になりました。

A子: 私は理想の休日は、ゆっくりと音楽を聴きながら過ごすのが好きですね。
何もせず、ただ音楽の中に浸って、心を落ち着かせる時間があると、
新しい一週間をリフレッシュしてスタートできる気がします。

B太: 理想の休日かぁ、俺はやっぱり家でゲームを楽しむのが最高だと思ってるよ!
 新しいゲームに挑戦したり、友達とオンラインで遊んだりするのが楽しみの一つなんだ。
それに、たまには映画を見たり、本を読んだりするのもいいよね。
外出はあんまりしないけど、家の中で自分の時間を満喫するのが理想的だな。みんなはどう?

C助: 私の理想の休日は、家族や友人とのんびりと時間を過ごすことですね。
美味しい食事を共にし、会話を楽しみながら、心身ともにリラックスできる時間を大切にしたいです。
自然に囲まれた場所で過ごすのも好きですね。静かな環境で読書をしたり、思い思いに過ごすのが理想です。

B太: 理想の休日かぁ、自分は明るくて前向きな性格だから、アクティブに何かをするのが好きなんだよね。
朝からテレビゲームを楽しんで、午後は友達と外に出てスポーツでもするのがいいな。
夜はみんなでワイワイとパーティーか、新しいゲームに挑戦するのも楽しそう。みんなはどう思う?

A子: 私の理想の休日は、ゆっくりと音楽を聴きながら過ごすことです。
特に、心が落ち着くクラシックやジャズを聴きながら、コーヒーを飲むのが好きですね。
音楽は私にとってリラックスする最適な方法なんです。

C助: 理想の休日ですか。私は温厚な性格なので、穏やかでリラックスできる時間を重視しますね。
例えば、家族や親しい友人とのんびりとバーベキューを楽しんだり、読書をしながらコーヒーを味わうのが好きです。
仕事のストレスから離れて、心身ともにリフレッシュできる日が理想ですね。

B太: みんな、自分はゲームが大好きだから、休日は新しいゲームに挑戦したり、
友達を家に呼んで一緒にプレイするのが理想的かな。
もちろん、たまには外に出てアクティブなこともしたいけど、やっぱりゲームでストレス発散するのが最高だよね!

C助: 理想の休日ですか、私は温厚な性格なので、穏やかで心地良い一日を過ごすのが好きですね。
家族や友人とのんびりと過ごしたり、ゴルフを楽しんだりするのが理想です。
時には一人で読書をするのも心が落ち着きます。大切なのは、平日の忙しさを忘れてリラックスすることだと思います。
皆さんはどんな休日を過ごしたいですか?

ちょっと不自然な感じがしますね・・・。この会話を受けてConversation Summarizerが生成した結果が次の通りです。

会話の内容から得られるインサイトは、人々が理想の休日を過ごす方法はそれぞれ個性に合わせて
大きく異なるということです。
A子は音楽を聴くことでリラックスし、新しい週にリフレッシュして臨むことを重視しています。
B太はゲームや友人との交流を通じてストレスを発散し、アクティブな活動も楽しむことを好んでいます。
C助は家族や友人とのんびりと過ごし、自然の中でリラックスすることを好む傾向にあります。
また、音楽、読書、ゲーム、スポーツ、家族や友人との食事や会話など、多様な活動が休日の過ごし方として挙げられています。

これらの会話から、理想の休日は個人の性格、興味、ライフスタイルに密接に関連していることがわかります。
また、多くの人がリラックスを重視しており、日常のストレスから解放されることを理想としていることも見受けられます。
さらに、自宅での活動と外出を組み合わせたバランスの取れた過ごし方を好む傾向も見られます。
これらのインサイトは、個人が自分自身の理想の休日を計画する際や、休日の過ごし方に関するサービスや商品を
提供する企業にとって有益な情報となるでしょう。

なるほど、よくまとめてくれたな、と思いました。会話の内容が良くなれば、もっといいインサイトが得られるのかもしれません。

今度は"これから新しいことを学ぶんだったら何を学ぶといい?"というテーマを投げかけてみます。

discussion_history = group_discussion.invoke({
    "thema":"これから新しいことを学ぶんだったら何を学ぶといい?",
    "speak_count": 0,
    "history":[],
    "next_speaker": None,
    "conversation_summary": ""
})

まずは会話の様子から。

A子: 私は、新しいことを学ぶとしたら、音楽に関連した何かにチャレンジしたいと思います。
音楽鑑賞が趣味なので、たとえば楽器の演奏や音楽理論の勉強を深めるのはどうでしょうか。
落ち着いた性格なので、じっくりと技術を磨くことができると思います。

B太: ボクは大学生で、性格も明るく前向きだし、テレビゲームが趣味なんだ。
だから、新しいことを学ぶならプログラミングを学んでみたいな。
ゲーム制作に関われる技術を身につけられるし、将来的にも役立ちそうだからね。
自分でゲームを作れるようになるのは超ワクワクするし、テクノロジーの進化ってエンドレスだから、
常に新しいことを学べる環境にいられると思うんだ。

C助: 皆さん、私たちがこれから学ぶべきことについて話し合っているようですね。
私の考えとしては、現代技術の進展を踏まえ、デジタルスキルやプログラミングを学ぶのが有益ではないかと思います。
私自身、会社経営をしている立場からも、これらのスキルは業務の効率化や新しいビジネスチャンスを生み出す上で
非常に重要だと感じています。デジタルの知識は今後ますます必要とされるでしょうし、個人的にも新しいことに
挑戦するのは刺激的だと思いますよ。

A子: 音楽鑑賞が趣味なので、楽器を学ぶのはどうでしょうか。ピアノやギターなど、自分で演奏できるようになると、
音楽をもっと深く楽しめるようになると思います。それに、新しいスキルを身に付ける過程で、忍耐力や集中力も
養えますし、落ち着いた性格の私には合っているかもしれません。

B太: すごく面白そうな話題だね!テレビゲームが趣味の自分にとっては、ゲーム開発について学ぶのが
自然な流れかなと思うんだ。プログラミングとかデザインのスキルを身につけることで、将来的には自分のアイディアを
形にできるかもしれないしね。それに、テクノロジーの進歩でゲーム業界もめまぐるしく変わっていくから、
そういう意味でも学びがいはありそうだよ。

C助: 皆さん、私たちがこれから新しいことを学ぶのについて話し合うのはいい機会だと思います。
私自身、会社役員として色々な経験をしてきましたが、常に新しいことに挑戦することは大切だと思っています。
現代は特にテクノロジーの進化が目覚ましいので、私はプログラミングやデータ分析などのデジタルスキルを身につけることが
有益だと考えます。これによって、会社経営にも新たな視点を取り入れることができるでしょう。
また、これらのスキルは将来性が高く、どの業界においても役立つと思います。皆さんはどう思われますか?

C助: 皆さん、意見を共有いただきありがとうございます。私は会社役員として、常に新しい知識を身につけることの
重要性を感じています。デジタル化が進む現代において、特にテクノロジー関連のスキルは不可欠だと考えています。
例えば、データ分析やプログラミングなどは、ビジネスの意思決定に役立つとともに、会社の成長にも直結するスキルです。
また、リーダーシップやコミュニケーションのスキルを高めることで、チームのモチベーションを向上させ、
より良い成果を出すことも可能です。これらのスキルを学ぶことが、個人としても、会社としても、
さらなる成長に繋がると確信しています。

B太: 今はテクノロジーがどんどん進んでいるから、プログラミングを学ぶのがいいと思うな。
ゲームも好きだし、将来的には自分でゲームを作れるようになれば楽しそうだよね。
それに、プログラミングスキルは就職にも役立つし、明るく前向きな性格を活かしてチームプロジェクトにも
参加できるから、俺にはぴったりだと思うよ!

C助: 皆さん、私たちは新しいことを学ぶことについて話し合っていますね。
私の考えとしては、私たちのグループにとって最も有益なのは、テクノロジー関連のスキルを学ぶことだと思います。
特に、デジタルマーケティングやデータ分析などは、今のビジネス環境で非常に重要です。
また、個人的には、言語学習も魅力的だと感じます。新しい言語を学ぶことで、異文化理解が深まり、
ビジネスの機会も広がるでしょう。それでは、この提案についてどう思われますか?

A子: 音楽鑑賞が趣味なので、音楽に関連した何か新しい技術や知識を学ぶのが良いかもしれませんね。
例えば、音楽理論や楽器演奏、あるいは音楽制作ソフトウェアの使い方などが挙げられます。
それによって、趣味の幅が広がり、より深い理解や新たな楽しみ方が見つかるかもしれません。

会話の内容がとぎれとぎれな印象ですね。プロンプトや処理の見直しが必要だと思いました。Conversation Summarizerが生成した結果が次の通りです。

会話の履歴から得られるインサイトは以下の通りです:

1. 趣味と関連付けた学習:A子は音楽鑑賞が趣味であるため、楽器演奏や音楽理論の学習を提案しています。
これは、個人の興味や既存の趣味に基づいて新しいスキルを学ぶというアプローチを示しており、
学習のモチベーションを維持する有効な手段となります。

2. 技術の進化とキャリア:B太とC助は、テクノロジーの進化という観点からプログラミングやデジタルスキルの学習を
提唱しています。B太はゲーム開発に興味があり、C助はビジネスの効率化や新しいチャンスを見出すために
デジタルスキルが重要であると述べています。
これは、現代のビジネス環境や労働市場で求められるスキルの重要性を反映しています。

3. パーソナリティと学習の適合性:B太は自身の明るく前向きな性格をプログラミング学習と関連付け、
チームプロジェクトへの参加にも言及しています。一方、A子は落ち着いた性格に合ったじっくりとした学習を好むと
述べています。これは、個人の性格やパーソナリティが学習のスタイルや選択に影響を与えることを示唆しており、
自己理解に基づく学習が効果的であると言えます。

4. ビジネスと技術の融合:C助は、データ分析やプログラミングがビジネス意思決定に役立ち、企業成長に直結する
スキルであると指摘しています。また、リーダーシップやコミュニケーションスキルの向上も、
チームのモチベーション向上に繋がると言及しています。これは、技術スキルだけでなく、ソフトスキルの学習も
重要であるという考えを示しています。

5. 言語学習の価値:C助は言語学習の価値にも触れ、新しい言語を学ぶことで異文化理解が深まり、
ビジネスの機会が広がると述べています。これは、グローバル化が進む現代社会において、言語能力が重要な資産となることを
示しています。

総合すると、興味や趣味に基づいた学習、技術進化への適応、個性を生かした学習方法、ビジネスと技術の統合、
そして言語学習の重要性が、これから新しいことを学ぶ際の重要な要素として挙げられます。
これらは、個人の成長だけでなく、キャリアの発展にも密接に関連していると言えるでしょう。

それなりにまとめてくれた印象を受けました。

まとめ

今回はMulti-Agent ConversationのシステムをLangGraphで組み、テーマを与えるとどのような会話が生成され、そこからどのようなインサイトが得られるのかを試してみました。会話の履歴からインサイトを得るところはそれなりに上手くいっているようにも感じましたが、会話の部分がまだ不自然な印象を受けました。Speaker Agentのプロンプトの見直しや、会話の一定の間隔でConversation Managerの介入を挟む仕組みを作るなど改善の余地はまだありますが、LangGraphを使うと一見複雑な複数Agentによるシステムを比較的分かりやすく組むことが出来ることが分かり、良い経験になりました。