Zenn
📜

LLM API へ構造化データを入力する方法

に公開

概要

LLM API(生成AI)を利用した Slackボットを開発・運用した経験から、効果的に LLM の会話履歴(コンテキスト)へ構造化データを渡す方法を紹介します。

Slack内に蓄積された知識を利用して「社内でラーメン好きは誰?」などの質問に答えられるオープンソースの Slackボット OssansNavi を開発・公開しています。
Slack でやりとりされるメッセージには本文以外にもタイムスタンプや送信者名・添付ファイルなどのメタデータが付随します。
しかし LLM へ入力できるのはテキストの1項目のみなので、それらの構造化データを入力して LLM に解釈してもらうには工夫が必要です。

今回は LLM へ構造化データを入力する以下の3つの方法を紹介します。

  1. HTTPプロトコル風
  2. JSONエンコード
  3. Function calling を利用

検証には Gemini API を利用しますが、OpenAI など他の LLM でも同様です。

シチュエーション

Slackボットと以下のような会話をします。

  1. あなた「今は何時何分ですか?」
  2. ボット「2025年4月7日7時20分です。」
  3. あなた「何曜日ですか?」
  4. ボット「月曜日です。」

LLM に対して「今は何時何分ですか?」とだけ質問しても適切な回答は不可能ですが、Slack メッセージに付随するタイムスタンプがメタデータとして渡されると、LLM はその情報をもとに適切な回答をしてくれます。

方法1 HTTPプロトコル風

ヘッダーと本文に分けた以下の形式で会話履歴に入力します。

Timestamp: 2025-04-07T07:20:40+09:00
Sender: Taro Yamada

今は何時何分ですか?

今回は LLM に構造化データを入力する検証なので、実際の Slackボットではなく curl で再現します。

curl --request POST \
  --url 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=GEMINI_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "contents": [
      {
        "role": "user",
        "parts": [
          {
            "text": "Timestamp: 2025-04-07T07:20:40+09:00\nSender: Taro Yamada\n\n今は何時何分ですか?"
          }
        ]
      }
    ]
}
'

以下の応答が返ります。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "2025年4月7日7時20分です。\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

ヘッダーに含めたタイムスタンプを元に応答してくれました。

しかしこの方法には1つ問題があります。

それを確認するために、次はその会話の流れで「何曜日ですか?」と質問してみます。
(※以降は入力データを curl ではなく JSON で示します)

{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:20:40+09:00\nSender: Taro Yamada\n\n今は何時何分ですか?"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:20:45+09:00\nSender: Assistant\n\n2025年4月7日7時20分です。\n"
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:22:25+09:00\nSender: Taro Yamada\n\n何曜日ですか?"
        }
      ]
    }
  ]
}

するとほとんどのケースでは応答が入力を模倣して HTTPプロトコル風になります。
会話履歴で何度もHTTPプロトコル風形式でやりとりすると、LLM もそのように応答する必要があると誤認識して同様の形式を模倣してしまいます。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "Timestamp: 2025-04-07T07:22:30+09:00\nSender: Assistant\n\n月曜日です。\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ....
}

この応答をボットの回答に利用すると、以下のようにおかしな応答になってしまいます。

あなた: 今は何時何分ですか?
ボット: 2025年4月7日7時20分です。
あなた: 何曜日ですか?
ボット:
Timestamp: 2025-04-07T07:22:30+09:00
Sender: Assistant

月曜日です。

これに対応するにはシステムプロンプトで 会話履歴はヘッダー部と本文に分かれます。応答は本文だけとしてください。 のように入力データと応答ルールを指示するのが有効です。

{
  "system_instruction": {
    "parts": [
      {
        "text": "会話履歴はヘッダー部と本文に分かれます。応答は本文だけとしてください。"
      }
    ]
  },
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:20:40+09:00\nSender: Taro Yamada\n\n今は何時何分ですか?"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:20:45+09:00\nSender: Assistant\n\n2025年4月7日7時20分です。\n"
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "text": "Timestamp: 2025-04-07T07:22:25+09:00\nSender: Taro Yamada\n\n何曜日ですか?"
        }
      ]
    }
  ]
}

すると、ほとんどの場合は期待する応答になります。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "2025年4月7日は月曜日です。\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

しかしシステムプロンプトによる制御では「会話履歴が長い」「システムプロンプトが他の指示で長い」「何度も実行する」などの要素によって、わずかな確率でシステムプロンプトの指示が遵守されないケースが発生します。
Slackボットが稀にヘッダー付きで応答してしまうのを許容できるなら良いのですが、少なくとも私はそんな違和感のある応答は100回に1回でも許容できません。

方法2 JSONエンコード

次に紹介するのは構造化データをJSON形式で文字列化して入力する方法です。
LLM とは素晴らしいもので、文字列化していてもJSON形式で入力したらほぼ正確に解釈してくれます。

{
  "Timestamp": "2025-04-07T07:20:40+09:00",
  "Sender": "Taro Yamada",
  "Content": "今は何時何分ですか?"
}

LLM API のテキスト項目に JSON を文字列化して "{\"Timestamp\": \"2025-04-07T07:20:40+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"今は何時何分ですか?\"}" のように入力します。

{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:20:40+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"今は何時何分ですか?\"}"
        }
      ]
    }
  ]
}

LLM が文字列化された JSON を正しく解釈してくれるのは良いのですが、この方法も HTTPプロトコル風と同様の問題が発生します。
以下の会話の流れで「何曜日ですか?」と聞いてみます。

{
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:20:40+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"今は何時何分ですか?\"}"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:20:45+09:00\", \"Sender\": \"Assistant\", \"Content\": \"2025年4月7日7時20分です。\\n\"}"
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:22:25+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"何曜日ですか?\"}"
        }
      ]
    }
  ]
}

すると、HTTPプロトコル風と同様に空気を読んで JSONエンコードされた応答になってしまいました。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "{\"Timestamp\": \"2025-04-07T07:22:30+09:00\", \"Sender\": \"Assistant\", \"Content\": \"月曜日です。\"}\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

今回もシステムプロンプトで制御することは可能ですが、より有効な JSON Schema を利用する方法を紹介します。
勝手に JSON形式で応答されるくらいなら、明示的に受け取りたい JSON形式を指定してしまうという考え方です。

以下のように response_schema に JSON Schema で応答フォーマットを指定します。

{
  "generation_config": {
    "response_mime_type": "application/json",
    "response_schema": {
      "type": "OBJECT",
      "properties": {
        "Content": {"type": "STRING"}
      }
    }
  },
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:20:40+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"今は何時何分ですか?\"}"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:20:45+09:00\", \"Sender\": \"Assistant\", \"Content\": \"2025年4月7日7時20分です。\\n\"}"
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "text": "{\"Timestamp\": \"2025-04-07T07:22:25+09:00\", \"Sender\": \"Taro Yamada\", \"Content\": \"何曜日ですか?\"}"
        }
      ]
    }
  ]
}

必ず指定した JSON Schema で応答が返ってくるので JSONデコードして必要な値だけを取り出すことができます。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "{\"Content\": \"月曜日です。\"}"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

しかしこの方法にも1点だけ大きな問題があります。

多岐にわたる質問やタスクが投げられる状況で平均的に賢く応答するのはモデルに課す制約を最小限にした場合です。つまりシステムプロンプトや JSON Schema はモデルに課す制約なので、それに従う代わりに質問やタスクへの応答が不十分になることがあります。

実際に私が頭を悩ませた例は「ブログ原稿をレビューしてください」というタスクを JSON Schema 付きで実行すると「はい、しっかりレビューします。少々お待ちください。」という応答だけで終了してしまうケースが激増する問題でした。

JSON Schema を指定すると短文かつ最小限の応答になりやすいので、長文でしっかりと応答してほしいニーズを満たせず応答の汎用性が低下します。
例えばこの記事の「何曜日ですか?」という質問でも、通常は「2025年4月7日は月曜日です。」という丁寧な回答が多いですが、JSON Schema を指定すると「月曜日です。」というぶっきらぼうな回答になる確率が激増します。

方法3 Function calling を利用

Function calling は LLM から呼んでもらうものと思い込みがちですが、勝手に Function calling の呼び出し伝聞と応答を構築すれば都合の良い情報を LLM に渡せます。

以下の JSON はメッセージの詳細情報を取得する架空の get_last_message_detail 関数を定義して LLM が呼び出したことにしています。そしてその応答としてタイムスタンプや送信者名を勝手に返却します。
完全に自作自演ですが LLM は状態を持たないので自分がその関数を呼び出したかの判断はできず、開発者が勝手に状況を作り出しても気付きません。

架空の関数なので "function_calling_config": { "mode": "NONE" } を付けてLLM から本当に呼ばれないようにします。

{
  "tool_config": {
    "function_calling_config": {
      "mode": "NONE"
    }
  },
  "tools": [
    {
      "function_declarations": [
        {
          "name": "get_last_message_detail",
          "description": "Get detailed information about the message."
        }
      ]
    }
  ],
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "今は何時何分ですか?"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "function_call": {
            "name": "get_last_message_detail",
            "args": {}
          }
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "function_response": {
            "name": "get_last_message_detail",
            "response": {
              "Timestamp": "2025-04-07T07:20:40+09:00",
              "Sender": "Taro Yamada"
            }
          }
        }
      ]
    }
  ]
}

実行結果は想定通り以下のような内容が返ってきます。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "こんばんは。現在、2025年4月7日7時20分です。\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

続けて曜日も聞いてみます。
1回のメッセージごとに get_last_message_detail の呼び出しと応答が追加されるので伝聞は長くなります。

{
  "tool_config": {
    "function_calling_config": {
      "mode": "NONE"
    }
  },
  "tools": [
    {
      "function_declarations": [
        {
          "name": "get_last_message_detail",
          "description": "Get detailed information about the message."
        }
      ]
    }
  ],
  "contents": [
    {
      "role": "user",
      "parts": [
        {
          "text": "今は何時何分ですか?"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "function_call": {
            "name": "get_last_message_detail",
            "args": {}
          }
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "function_response": {
            "name": "get_last_message_detail",
            "response": {
              "Timestamp": "2025-04-07T07:20:40+09:00",
              "Sender": "Taro Yamada"
            }
          }
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "text": "こんばんは。現在、2025年4月7日7時20分です。"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "function_call": {
            "name": "get_last_message_detail",
            "args": {}
          }
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "function_response": {
            "name": "get_last_message_detail",
            "response": {
              "Timestamp": "2025-04-07T07:20:45+09:00",
              "Sender": "Assistant"
            }
          }
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "text": "何曜日ですか?"
        }
      ]
    },
    {
      "role": "model",
      "parts": [
        {
          "function_call": {
            "name": "get_last_message_detail",
            "args": {}
          }
        }
      ]
    },
    {
      "role": "user",
      "parts": [
        {
          "function_response": {
            "name": "get_last_message_detail",
            "response": {
              "Timestamp": "2025-04-07T07:22:25+09:00",
              "Sender": "Taro Yamada"
            }
          }
        }
      ]
    }
  ]
}

想定通りの応答が返ってきました。

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "2025年4月7日は月曜日です。\n"
          }
        ],
        "role": "model"
      },
      ...
    }
  ],
  ...
}

この方法には他の方式のようなデメリットが全くなく、任意の構造化データを渡すことができます。JSON Schema のようにぶっきらぼうにもなりません。

さらに JSON を文字列化すると {"Timestamp":"2025-04-07T07:22:25+09:00","Sender":"Taro Yamada","content":"何曜日ですか?"} のようにJSON構造を構成する {}": なども入力トークとしてカウントされますが、Gemini API の Function calling 応答では JSON 構造のままで渡せるのでトークン数を節約できます。(※OpenAI API は Function calling の応答でも JSON の文字列化が必要)

さいごに

OssansNavi はベクトルDBを利用せずSlack内の知識を活用して応答できる Slackボットです。
必要なのはアプリを起動する環境と Gemini か OpenAI の APIキー だけです、この記事を見て気になった方は是非ご利用いただけたらと思います。

Discussion

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