マルチエージェントによるヘルプデスクBotへの道

# はじめに

* [KDDI Engineer & Designer Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kddi)で、5日目の記事を担当します
* 弊社に導入したヘルプデスクFAQ Chatbotにおいて、FAQ回答精度改善を行いたく、langgraphを使ったマルチエージェント化を試作しました
* マルチエージェント化の過程で得られたlanggraphの知見、UX向上の工夫ポイントなどを共有します

## マルチエージェントの構成イメージ
* ユーザーの意図確認を行い、問い合わせカテゴリによって別々のエージェントが回答する仕組みとする
* エージェント名の下に記載のある、xxx_chain は、langgraphのノード名となります

```mermaid
graph TD
    A[ユーザー] -->|質問| B[意図解釈エージェント navigator_chain]
    B -->|質問の確認と明確化| A
    B -->|意図が明確化されたら| C[Supervisorエージェント supervisor_chain]
    C -->|PCサポートが必要| D[PCサポートエージェント tools]
    C -->|スマホサポートが必要| E[スマホサポートエージェント tools]
    C -->|その他のサポートが必要| F[その他サポートエージェント tools]
    D -->|クエリ| G[PCサポート用DB]
    E -->|クエリ| H[スマホサポート用DB]
    F -->|クエリ| I[その他サポート用DB]
    G -->|検索結果| D
    H -->|検索結果| E
    I -->|検索結果| F
    D -->|回答| L[回答生成エージェント answer_chain]
    E -->|回答| L
    F -->|回答| L
    L -->|回答生成| A
    L -->|回答結果を蓄積| M[回答履歴DB]
```

# 実際の試作構成とソースコード
## 概要構成とポイント

### 実際のlanggraphのグラフ図
* だいたい最初のコンセプトどおりの構成となった
* 各サポートエージェントは、toolsの中に3つまとまって入っている

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10317/51ee1932-26a5-3a61-f188-04f2d7db6808.png)

### 意図解釈エージェント（navigator_chain）
* ユーザーとチャットし、問い合わせのカテゴリ分類と、問い合わせ内容の具体化を行います
* 具体化ができた段階で、tool callingを行い、supervisorにバトンタッチします
* PCでインターネット接続ができない、などの場合、カテゴリが["PC", "ネットワーク"]のように複数にまたがることも考慮しています
    * その場合には、次のsupervisorエージェントは、2つのDBにアクセスして回答します
* UX上の工夫として、ユーザーとの会話を５ターンくらいまでに制限するよう、プロンプトを設定しています
    * LLMに「問い合わせ内容を具体化して」と指示しているため、「エラーの内容は？」「どんな時にエラーが出たの？」「デバイス名は？」「OSは？」などと、しつこいくらいに質問攻めにあいます
    * ターン数上限を設定することで、いい感じに切り上げてくれます
```python
prompt = """内容確認する過程で、ユーザー自身が情報を持っておらず特定できない場合もあるので、
最大５ターンの会話を続けても特定できない場合は、それまでの会話の中からカテゴリ
と内容を推測してtool callingすること。"""
```

* tool callingで、以下の形式のデータをToolMessageとしてsupervisorに渡します
```
{
  "category": ["PC"],
  "query": "ノートPCの電源が入らないため修理したい"
}
```

### supervisorエージェント（supervisor_chain）
* 意図解釈エージェントから、「カテゴリ」と「質問内容」を受け取り、必要なサポートエージェントを呼び出します
* カテゴリが、「PC, スマホ」のように複数ある場合は、複数のサポートエージェントを呼び出します
* サポートエージェントを呼び出すために、受け取ったqueryを元に、検索クエリを生成します
* tool callingで、以下の形式のデータをToolMessageとして各サポートエージェントに渡します

```
{
  "query": "ノートPC 電源 修理"
}
```

#### はまったポイント
* Navigatorからのtool呼び出しとしてsupervisorを起動しており、supervisorもツール呼び出しとして各サポートエージェントを呼び出すので、グラフステート（State）は以下の図のようにToolMessageが連続して2つ並ぶことになる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10317/eb6674f8-1863-ea66-659e-558c28474c9a.png)
* この状態でLLMを呼び出すと、tool callのIDが複数存在しエラーとなってしまう
* したがって、supervisorの中で、Stateから1回目に呼ばれたToolMessageの削除を実施し解決している

```python
    # NavigatorのToolCallを、SupervisorのToolCallに置き換えるため、NavigatorのToolCallを削除
    if state["messages"] and "tool_calls" in state["messages"][-1].additional_kwargs:
        state["messages"].pop()
```

### 各サポートエージェント（tools）
* 今回の実装例では、ダミーの回答を提供するだけにとどめています
* 各エージェント関数を、`@tool`デコレーターをつけて、ToolNodeとしてノード化しています

#### はまったポイント
* ToolMessageの返却の型として、DBからの検索結果をそのままの形で返すため、list[dict]で返したかったが、それだとエラーになったので、str型にして解決した

```
    return json.dumps(dummy_return)
```

### 回答生成エージェント（answer_chain）
* 各サポートエージェントの結果を受け取り、回答を生成するエージェント



## 実装例
<details><summary>ソースコード（OPENAI_API_KEYは各自設定してください）</summary>

```python
import json
from typing import List, Dict, Annotated, TypedDict

from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph.message import add_messages
from pydantic import BaseModel

from langgraph.prebuilt import ToolNode


class UserQueryDetailed(BaseModel):
    """ユーザー質問の意図確認と分類仕分け後のデータ"""
    category: List[str]
    query: str


def navigator_chain(state) -> Dict[str, List[AIMessage]]:
    navigator_prompt = """あなたはヘルプデスクの窓口担当であり、ユーザーからの問い合わせについて、問い合わせの内容確認とカテゴリ分類を行います。

あなたは、以下の２点の情報を明確化してください。受け答えは日本語でお願いします。
内容確認する過程で、ユーザー自身が情報を持っておらず特定できない場合もあるので、最大５ターンの会話を続けても特定できない場合は、それまでの会話の中からカテゴリと内容を推測してtool callingすること。
ただし、これまでのやりとりの中で回答を明確にできるものについては、tool callingせずに即答すること。

- カテゴリ:
  問題の対象となるシステム、アプリケーション、またはデバイスを特定する
  カテゴリ種別：PC, スマホ, ネットワーク, その他
  複数にまたがる場合は、2つ列挙すること（PCがネットワークにつながらない：PC, ネットワーク）
  
- 問い合わせ内容:
  ユーザーが直面している具体的な状況やエラー、または知りたい情報を特定する
  ただし、PCについては、OS種別やバージョンについては明確化不要
  だだし、スマホについては、iPhoneかAndroidかだけ分別できればよく、モデル名やOSバージョンは明確化不要
  ただし、デバイスや周辺機器については、型番などの明確化は不要

If you are not able to discern this info, ask them to clarify! Do not attempt to wildly guess.
After you are able to discern all the information, call the relevant tool."""
 
    prompt = [SystemMessage(content=navigator_prompt)] + state["messages"]
    response = ChatOpenAI(model="gpt-4o-mini", temperature=0).bind_tools([UserQueryDetailed]).invoke(prompt)
  
    return {"messages": [response]}


# Tool Callが行われるまで、Navigatorにねばらせる
def get_state(state):
    messages = state["messages"]
    if isinstance(messages[-1], AIMessage) and messages[-1].tool_calls:
        return "supervisor"
    elif not isinstance(messages[-1], HumanMessage):
        return END
    return "navigator"

# PCカテゴリのFAQ回答取得ツール
@tool("fetch_pc_database")
def fetch_pc_database(query: str) -> str:
    """Provide necessary information in response to inquiries about PCs by searching the database.

    Args:
        query (str): The user's query string used to search the database.

    Returns:
        list[dict]: A list of dictionaries where each dictionary contains relevant information 
        about the user's query. Each dictionary has keys like 'title' and 'content', which 
        provide a title and detailed content about the specific topic.
    """

    # 本来であれば、ここでqueryをEmbeddingして、それでベクターサーチをかける
    # いったんダミーを返す。
    
    dummy_return = [
        {
            "title": "PCの修理方法",
            "content": "PCの修理は、本社9階にあるサポート窓口まで持ち込みをお願いします。"
        },
        {
            "title": "PCの修理費用",
            "content": "PCの修理には、ノートパソコンは1台3万円、デスクトップは1台5万円の費用がかかります。"
        },
        {
            "title": "PCの修理期間",
            "content": "PCの修理には1週間程度要します。急ぎの場合は代替機を貸出いたします。"
        }
    ]
    return json.dumps(dummy_return)


# スマホのFAQ回答取得ツール
@tool("fetch_smartphone_database")
def fetch_smartphone_database(query: str) -> str:
    """Provide necessary information in response to inquiries about Smartphone by searching the database.

    Args:
        query (str): The user's query string used to search the database.

    Returns:
        list[dict]: A list of dictionaries where each dictionary contains relevant information 
        about the user's query. Each dictionary has keys like 'title' and 'content', which 
        provide a title and detailed content about the specific topic.
    """

    # 本来であれば、ここでqueryをEmbeddingして、それでベクターサーチをかける
    # いったんダミーを返す。
    
    dummy_return = [
        {
            "title": "スマホの修理方法",
            "content": "スマホの修理は、本社5階にあるスマホサポート窓口まで持ち込みをお願いします。"
        },
        {
            "title": "スマホの修理費用",
            "content": "スマホの修理には、iPhoneは1台3万円、Androidは1台5万円の費用がかかります。"
        },
        {
            "title": "スマホの修理期間",
            "content": "スマホの修理には1週間程度要しますので、代替機を貸出いたします。"
        }
    ]
    return json.dumps(dummy_return)


# その他のFAQ回答取得ツール
@tool("fetch_other_database")
def fetch_other_database(query: str) -> str:
    """Provide necessary information in response to inquiries about topics other than smartphones and PCs by searching the database.

    Args:
        query (str): The user's query string used to search the database.

    Returns:
        list[dict]: A list of dictionaries where each dictionary contains relevant information 
        about the user's query. Each dictionary has keys like 'title' and 'content', which 
        provide a title and detailed content about the specific topic.
    """

    # 本来であれば、ここでqueryをEmbeddingして、それでベクターサーチをかける
    # いったんダミーを返す。
    
    dummy_return = [
        {
            "title": "社内WiFiの利用方法",
            "content": "SSIDは「company-wifi」、Passowrdは、「company-1234」です。"
        },
        {
            "title": "ネットワークのトラブル",
            "content": "PCがインターネットに接続できない場合は、証明書をリセットするためヘルプデスクまでお電話ください。"
        },
        {
            "title": "クラウドサービスの利用方法",
            "content": "Slack、Box, Office365、ChatGPTはエンタープライズ契約があるため、利用希望の方はこちらのFormで申請してください。"
        }
    ]
    return json.dumps(dummy_return)


db_tools = [
    fetch_pc_database,
    fetch_smartphone_database,
    fetch_other_database,
]

tool_node = ToolNode(db_tools)


# 受け取ったTool Callの引数（category と query のJSON）を、promptに突っ込む処理
def get_detailed_query(messages: list, prompt: str):
    tool_call = None
    # other_msgs = []
    for m in messages:
        if isinstance(m, AIMessage) and m.tool_calls:
            tool_call = m.tool_calls[0]["args"]
        elif isinstance(m, ToolMessage):
            continue

    return [SystemMessage(content=prompt.format(reqs=tool_call))]

def supervisor_chain(state):
    supervisor_prompt = """あなたはヘルプデスクのスーパーバイザーで、窓口担当が整理したユーザー問い合わせを、適切なtoolで情報取得します。
あなたは現在、以下の３つのToolを所有しています。
 fetch_pc_database : PCに関する問い合わせの情報源
  fetch_smartphone_database : スマホ・スマホアプリに関する問い合わせの情報源
  fetch_other_database : PCとスマホ以外のすべての問い合わせの情報源
これらに問い合わせるにあたり、ユーザー問い合わせから、適切な検索クエリを作ってデータベース検索を行います。
例：
 問い合わせ：故障したので修理したい
 検索クエリ：修理 費用 期間 場所

以下が窓口担当が整理したユーザー問い合わせとなります：{reqs}"""
    
    prompt = get_detailed_query(state["messages"], supervisor_prompt)
    
    # NavigatorのToolCallを、SupervisorのToolCallに置き換えるため、NavigatorのToolCallを削除
    if state["messages"] and "tool_calls" in state["messages"][-1].additional_kwargs:
        state["messages"].pop()
    llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools(db_tools)
    response = llm_with_tools.invoke(prompt)
    
    return {"messages": [response]} 


# 回答生成エージェント
def answer_chain(state):
    answer_prompt = """あなたはヘルプデスクのカスタマーサポート担当者であり、
ここまでに、お客様（Human）とAIとの会話内容、および回答のための参照情報（ToolMessage）を受け取りました。
これらの内容を参照、とくにToolMessageを重視して、適切な案内を実施してください。"""
    
    prompt = [SystemMessage(content=answer_prompt)] + state["messages"]
    response = ChatOpenAI(model="gpt-4o-mini").invoke(prompt)
    return {"messages": [response]}




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



memory = MemorySaver()
workflow = StateGraph(State)
workflow.add_node("navigator", navigator_chain)
workflow.add_node("supervisor", supervisor_chain)
workflow.add_node("tools", tool_node)
workflow.add_node("answer", answer_chain)

workflow.add_conditional_edges("navigator", get_state, ["supervisor", "navigator", END])
workflow.add_edge("supervisor", "tools")
workflow.add_edge("tools", "answer")
workflow.add_edge(START, "navigator")
workflow.add_edge("answer", END)
graph = workflow.compile(checkpointer=memory)


import uuid

cached_human_responses = ["hi!", "パソコンが壊れた", "修理したい", "q"]
cached_response_index = 0
config = {"configurable": {"thread_id": str(uuid.uuid4())}}
while True:
    try:
        user = input("User (q/Q to quit): ")
    except:
        user = cached_human_responses[cached_response_index]
        cached_response_index += 1
    # print(f"User (q/Q to quit): {user}")
    if user in {"q", "Q"}:
        print("AI: Byebye")
        break
    output = None
    for output in graph.stream(
        {"messages": [HumanMessage(content=user)]}, config=config, stream_mode="updates"
    ):
        last_message = next(iter(output.values()))["messages"][-1]
        last_message.pretty_print()

    if output and "prompt" in output:
        print("Done!")
```
</details>

# 実行例

### やりとり例1: ユーザーとNavigatorの5回のやりとりのあと、toolが呼ばれ、最終的な回答が生成

<details><summary>やりとりの例1</summary>

```
User (q/Q to quit):  PCが壊れた
================================== Ai Message ==================================

壊れたPCについて、もう少し具体的な状況を教えていただけますか？例えば、どのような症状が出ているのか、エラーメッセージが表示されているのかなど、詳細をお聞かせください。
User (q/Q to quit):  電源が入らない
================================== Ai Message ==================================

電源が入らないとのことですが、何か特別な操作を行った後にこの問題が発生しましたか？また、電源ケーブルやコンセントの確認はされましたか？
User (q/Q to quit):  わからない。突然そうなった
================================== Ai Message ==================================

突然電源が入らなくなったということですね。PCのモデルやメーカーについてはご存知ですか？それとも、特に気にされていないでしょうか？
User (q/Q to quit):  ノートPCだけど型番はよくわからない
================================== Ai Message ==================================

ノートPCであることは分かりました。最後に、電源ボタンを押した際に何か音やランプの点灯などはありますか？それとも全く反応がない状態ですか？
User (q/Q to quit):  わからない
================================== Ai Message ==================================

ありがとうございます。情報をもとに推測すると、カテゴリは「PC」で、問い合わせ内容は「電源が入らない」ということになります。それでは、こちらの情報をもとにツールを呼び出します。少々お待ちください。
Tool Calls:
  UserQueryDetailed (call_3u8OiuWj3fH7Wcg9lyMo0CE5)
 Call ID: call_3u8OiuWj3fH7Wcg9lyMo0CE5
  Args:
    category: ['PC']
    query: 電源が入らない
================================== Ai Message ==================================
Tool Calls:
  fetch_pc_database (call_yYph2uhO0gN6e85C4xTFbrJ9)
 Call ID: call_yYph2uhO0gN6e85C4xTFbrJ9
  Args:
    query: 電源が入らない
================================= Tool Message =================================
Name: fetch_pc_database

[{"title": "PC\u306e\u4fee\u7406\u65b9\u6cd5", "content": "PC\u306e\u4fee\u7406\u306f\u3001\u672c\u793e9\u968e\u306b\u3042\u308b\u30b5\u30dd\u30fc\u30c8\u7a93\u53e3\u307e\u3067\u6301\u3061\u8fbc\u307f\u3092\u304a\u9858\u3044\u3057\u307e\u3059\u3002"}, {"title": "PC\u306e\u4fee\u7406\u8cbb\u7528", "content": "PC\u306e\u4fee\u7406\u306b\u306f\u3001\u30ce\u30fc\u30c8\u30d1\u30bd\u30b3\u30f3\u306f1\u53f03\u4e07\u5186\u3001\u30c7\u30b9\u30af\u30c8\u30c3\u30d7\u306f1\u53f05\u4e07\u5186\u306e\u8cbb\u7528\u304c\u304b\u304b\u308a\u307e\u3059\u3002"}, {"title": "PC\u306e\u4fee\u7406\u671f\u9593", "content": "PC\u306e\u4fee\u7406\u306b\u306f1\u9031\u9593\u7a0b\u5ea6\u8981\u3057\u307e\u3059\u3002\u6025\u304e\u306e\u5834\u5408\u306f\u4ee3\u66ff\u6a5f\u3092\u8cb8\u51fa\u3044\u305f\u3057\u307e\u3059\u3002"}]
================================== Ai Message ==================================

電源が入らない場合、いくつかのトラブルシューティングを試すことができます。以下の手順をお試しください：

1. **電源ケーブルの確認**: 電源ケーブルがしっかりと接続されているか、コンセントに問題がないか確認してください。

2. **バッテリーの取り外し**: ノートPCの場合、バッテリーを取り外し、数分間待ってから再度取り付けてください。その後、電源ボタンを押してみてください。

3. **ハードウェアのリセット**: ノートPCの底部にあるリセットボタンがある場合、それを押してリセットしてみてください。

4. **周辺機器の取り外し**: 外部デバイス（USB機器や外付けハードディスクなど）をすべて取り外し、再度電源を入れてみてください。

これらの手順を試しても改善しない場合、専門の修理サービスに相談することをお勧めします。PCの修理に関する費用や期間についてもご案内できますので、必要であればお知らせください。
```
</details>

### やりとり例2: スマホとPCについて問い合わせし、初めから具体的な内容を入れているので、3回でやりとりが終了

* PCとスマホについて聞いているので、PCとスマホの2つのToolsを呼んで回答を生成している

<details><summary>やりとりの例2</summary>

```
User (q/Q to quit):  PCとスマホを修理したいので、修理費用を知りたい。
================================== Ai Message ==================================

修理費用についてお知りになりたいとのことですが、具体的にどのような問題が発生しているのか教えていただけますか？例えば、PCやスマホのどの部分が故障しているのか、エラーが出ているのかなど、詳細をお聞かせください。
User (q/Q to quit):  両方とも電源が入らない。
================================== Ai Message ==================================

PCとスマホの両方が電源が入らないとのことですね。これに基づいて、カテゴリを以下のように特定しました。

- カテゴリ: PC, スマホ

修理費用については、具体的な状況や故障の程度によって異なるため、修理業者に直接お問い合わせいただくのが良いかと思います。何か他にお手伝いできることがあれば教えてください。
User (q/Q to quit):  ノートPCとiPhoneなんだど修理費用を教えて
================================== Ai Message ==================================
Tool Calls:
  UserQueryDetailed (call_OTqlNPdUx3CUyRBOsXWHsrKL)
 Call ID: call_OTqlNPdUx3CUyRBOsXWHsrKL
  Args:
    category: ['PC', 'スマホ']
    query: ノートPCとiPhoneの電源が入らない修理費用を知りたい
  UserQueryDetailed (call_STNVBlAOxHqCqrEampe2rkXZ)
 Call ID: call_STNVBlAOxHqCqrEampe2rkXZ
  Args:
    category: ['PC', 'スマホ']
    query: ノートPCとiPhoneの電源が入らない修理費用を知りたい
================================== Ai Message ==================================
Tool Calls:
  fetch_pc_database (call_AGfSpYL62cE0jDyl4SKLHNfN)
 Call ID: call_AGfSpYL62cE0jDyl4SKLHNfN
  Args:
    query: ノートPC 電源 修理費用
  fetch_smartphone_database (call_8BrWvf5wciySY9QaaYgeGJVA)
 Call ID: call_8BrWvf5wciySY9QaaYgeGJVA
  Args:
    query: iPhone 電源 修理費用
================================= Tool Message =================================
Name: fetch_smartphone_database

[{"title": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u65b9\u6cd5", "content": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u306f\u3001\u672c\u793e5\u968e\u306b\u3042\u308b\u30b9\u30de\u30db\u30b5\u30dd\u30fc\u30c8\u7a93\u53e3\u307e\u3067\u6301\u3061\u8fbc\u307f\u3092\u304a\u9858\u3044\u3057\u307e\u3059\u3002"}, {"title": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u8cbb\u7528", "content": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u306b\u306f\u3001iPhone\u306f1\u53f03\u4e07\u5186\u3001Android\u306f1\u53f05\u4e07\u5186\u306e\u8cbb\u7528\u304c\u304b\u304b\u308a\u307e\u3059\u3002"}, {"title": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u671f\u9593", "content": "\u30b9\u30de\u30db\u306e\u4fee\u7406\u306b\u306f1\u9031\u9593\u7a0b\u5ea6\u8981\u3057\u307e\u3059\u306e\u3067\u3001\u4ee3\u66ff\u6a5f\u3092\u8cb8\u51fa\u3044\u305f\u3057\u307e\u3059\u3002"}]
================================== Ai Message ==================================

ノートPCとiPhoneの修理費用についての情報は以下の通りです。

### ノートPCの修理費用
- **修理費用**: 約1万〜3万円程度
- **修理期間**: 約1週間ほど

### iPhoneの修理費用
- **修理費用**: 約3万〜5万円程度
- **修理期間**: 約1週間ほど

特に故障の内容や部品によって費用が変わることがありますので、具体的な修理内容については、修理業者に直接お問い合わせいただくことをお勧めします。何か他にお手伝いできることがあれば教えてください。
```

</details>

# まとめ
* langgraphのステートの遷移と制御で、かなりハマりながらこの試作を実施したことで、ようやく理解ができてきた
* とくに、Tool Callingによるマルチエージェントに関しては情報が少ないので、この記事が誰かの手助けになればと思う
* ヘルプデスクのBotとして実用化するためには、まだまだ必要な要素があるのでそれらを引き続きチャレンジしてみたい
    * まずはUI。試作はコマンドラインだが、StreamlitなどでWeb化する課題あり
    * 各サポートエージェントをダミーではなく検索DBに接続する
    * 回答に必要な情報がDBに存在するかどうかのフィードバック機構を作る