複数LLMのFunction Callingに対応したChatbot型のスマートホームエージェントアプリを作ってみた
はじめに
今回は、LLMの重要な機能の1つであるFunction CallingのAPIを使ってChatbot形式でホームデバイス操作を行うサンプルアプリケーションを作ってみたので、その紹介と実装ポイントについて解説します。
既に、同様のアプリ実装例を紹介したネット記事はあると思いますが、複数のLLMプロバイダから選択、または比較利用が出来る点が特徴です。
なお、コードについてはGitHubに公開しています。
(2024/12/10追記)
先日、ClaudeシリーズのLLMプロバイダのAnthropicからMCP: Model Context Protocolがリリースされましたね。
まさに本記事作成中の発表でしたので、本記事で紹介するアプリはMCPには未対応です。
追って、MCP対応版アプリも作成のうえ記事投稿できればと思っていますが、今回実装したFunction CallingまわりがMCP対応によってどう変わるかを比較しながら解説できればと思っています。
想定読者
- LLM(大規模言語モデル)のAPIを使ったアプリケーション作成に興味がある
- LLMのAPIは使ったことあるけど、Function Callingは使ったことがない
- 複数のLLMプロバイダのFunction Calling APIを使ってみたい
どんなアプリケーションか?
- Chatbot形式のホームデバイス操作エージェントアプリ
- LLMのFunction Calling API経由で、APIコントロール可能なホームデバイスと連携
- 複数のLLMプロバイダから選択可能(アドオン実装も可能) ★今回のポイント
- 文字起こし(Speech-to-text)、読み上げ(Text-to-speech)を使った音声チャットに対応
コンセプト
目的の本質でない機能(認証・認可等)は便利な3rd Partyライブラリのお力を借りて実装負荷軽減しつつ、本質の所(Function Callingを使ったホームデバイス操作)はなるべくピュアに実装することをコンセプトにしたつもりです。
動かしてみた
ローカル環境用の設定で色々動かしてみます。
ログイン~設定
ログイン画面からログインして認証成功するとChatbot画面が表示されます。
そのままChatでのデバイス操作リクエストが可能ですが、その前に右上のユーザアイコンから設定メニューを開いてLLMプロバイダを選択します。
ログイン画面
右上のユーザアイコンから設定メニューを開いて、LLMプロバイダ等を選択
1つのプロバイダのみ利用可能としている場合は選択不要ですが、複数のプロバイダを利用可能にしている場合はここでプルダウンを切り替えながらデバイス操作が可能です。
リクエスト(OpenAI)
まずは、OpenAIを選択してテレビをON/OFFしてみます。
なお、モデルはgtp4o
を使用します。
※後述のとおりモデルは環境変数での指定で、画面での切り替えは対応していません。
OpenAI(gpt4o)でのリクエスト
リクエストしたテレビのON/OFFが実行され、エージェントから実行結果が回答されました。
ここでは詳細は触れませんが、要約すると以下の流れでエージェントアプリが動作します。
- Chatによるリクエストメッセージを元にLLMのFunction Callingをリクエスト
- Function Callingが呼び出すFunction(デバイスコントローラ)を判別して実行仕様を応答
- 応答された実行仕様を元に、対象Functionを実行(ここではテレビのON/OFF)
- Functionの実行結果を元に、LLMへ回答の生成をリクエスト
- 応答された回答をChatの応答メッセージとして出力
1リクエストでの複数オーダー(OpenAI)
次は、1回のリクエストで複数の操作をオーダーしてみます。
1回のリクエストで複数の操作をオーダー
詳細ビューでも分かるとおり、テレビの電源ONとチャンネルの変更が一気にできました。
(詳細ビューのインデントがおかしいのはご寛容ください・・)
ここでポイントなのは、チャンネルの変更をチャンネル番号ではなく局名でオーダーしている点です。こういった自然言語ならではのリクエストも理解して読み替えてくれるのがLLMを利用する醍醐味ですね。
リクエスト(Groq - Llama3-8b)
次に、LLMをGroq
に切替えてテレビのON/OFFをオーダーしてみます。
モデルはひとまずLlama3-8b-8192
を使用します
Groq(Llama3-8b-8192)でのリクエスト
ONのオーダーについては、最終回答メッセージこそ回答してくれませんでしたがオーダー自体は実行してくれました。
しかし、OFFのオーダーはエラー応答になってしまいうまくいきませんでした。。
リクエスト(Groq - llama-3.1-70b)
次に、モデルをより高性能なllama-3.1-70b-versatile
に変更して同じオーダーをしてみます。
(環境変数GROQ_API_MODEL_CHAT
の値をllama-3.1-70b-versatile
に変更してアプリ再実行)
Groq(llama-3.1-70b-versatile)でのリクエスト
OFFのオーダーの回答は英語でしたが、両方ともうまくいきました!
このあたりはプロンプトエンジニアリングの工夫でチューニング可能なのかもしれませんが、モデルの性能に依存することが分かります。
リクエスト(Azure OpenAI Service)
次に、LLMをAzure OpenAI Service
に切替えてテレビのOFFをオーダーしてみます
モデルはgpt4o
を使用します。
Azure OpenAI Service(gpt4o)でのリクエスト
エラーになりました。。
ログを確認すると、リクエストがコンテンツフィルタによる制限に引っ掛かったようです。
これまでと同じオーダーなのに何故引っ掛かったのでしょうか?
なぜコンテンツフィルタに引っ掛かった?
コンテンツフィルタリングとは、有害な可能性のあるプロンプトをフィルタリングする機能で、特にAzure OpenAI Serviceのように責任あるAIを謳っているLLMには必須の機能ですが、一方でこれが利用者が意図しない解釈をされて弾かれてしまうケースがあるようです。
今回はテレビを消してというリクエストでしたが、おそらく日本語の消してという文言が意図とは違う解釈によって弾かれてしまったようです。
解決策:英語翻訳を介してのプロンプト入力
本アプリでは、この問題の解決策として英語翻訳APIを使うことにしました。
日本語でのリクエストに対して、文脈を汲んだ英語翻訳を介したうえでLLM APIのプロンプト入力をする方式です。
今回のケースだとテレビを消してはTurn off the TVというように文脈を汲んだ翻訳がされてプロンプト入力されるため、意図しないコンテンツフィルタリング対策として有効です。
今回は翻訳APIとしてDeepL
を使ったAdapterを用意しました。
(こちらも別途APIキーの払出しのうえ環境設定が必要ですが詳細は割愛します)
設定メニューのプルダウンから選択して適用させます。
翻訳APIの有効化設定
再リクエスト(Azure OpenAI Service)
翻訳API適用後のAzure OpenAI Service(gpt4o)での再リクエスト
再度同じリクエストをオーダーしたところ、今度はうまくいきました!
英語翻訳を介してプロンプト入力することは、トークンサイズの節約=API利用料金の節約にもなります。
用途によっては、翻訳を介することで文脈が変わって逆に目的に対するパフォーマンスが落ちるケースもあるかもしれませんが、ホームデバイス操作のような比較的シンプルなプロンプトにおいてはあまり無いと個人的には思っています。
アプリケーション仕様について
基本的な仕様や動作確認方法は、GitHubのREADMEに記載しています。
本記事では、以下の点にポイントを絞って実装要領を紹介します。
- LLM APIのAdapter実装
- デバイスコントローラ(Function)の実装
LLM APIのAdapter実装
まずは、Function CallingなどのLLM APIと連携するAdapterの解説です。
Adapter
と名が付いているとおり、LLMプロバイダごとに異なるAPI仕様に対して呼出し元が依存しない実装パターンとしているため、やや回りくどい実装になっている点はご了承ください。
実装済Adapter
冒頭で、複数のLLMプロバイダから選択や使い分けが可能と書きましたが、執筆時点では以下のLLMプロバイダ向けのAdapterが既に実装されています。(今後増減の可能性はあり)
※何れも事前にAPIキーのほか、各種環境パラメータを用意のうえ環境変数設定が必要です
- OpenAI(GPTモデル)
- Azure OpenAI Service(GPTモデル)
- Anthropic(Claudeモデル)
- Groq(主にLlama系モデル)
あれ?Geminiは??と思われるかもしれません。。すみません追々対応予定です。。
LLMプロバイダのアドオン実装
LLM APIとの接続部分は、各プロバイダ毎にAdapter方式で実装されているため比較的簡単にアドオン実装が可能になっています。
また、LLM APIの仕様自体も(私が知る限り)プロバイダ毎にそこまで大きく乖離はないため、既存のAdapterを参考にすれば実装できると思います。
以下、GroqAdapter
を参考にAdapter実装の流れを説明します。
Adapterクラスを実装
-
インターフェース
LlmAdapter
を実装したAdapterクラスを作成 -
Adapterクラスのコンストラクタでは、APIキーやモデル名などの必要なパラメータを環境変数から取得し、APIクライアントインスタンスを生成
-
functionCalling
メソッドを、大枠以下の流れで実装- 引数
systemPrompt
(LLM API向けのsystemプロンプト用メッセージ)、引数messages
(userプロンプト用メッセージ)を元にFunction Calling用のmessage配列を生成 - 1.で生成したmessage配列を含めた、Function Calling向けのOptionオブジェクトを引数
options
を元に生成 - 生成したOptionを指定してLLMのChat APIをコール、レスポンスから
finish_reason
取得 -
finish_reason
がtool呼出し以外、つまりFunction Callingと判定されなかった場合は応答されたAssistantメッセージをそのまま応答 - Function Callingと判定された場合、レスポンスから対象Functionを順番に取得して実行
5-1.Function名を取得
5-2. 引数functions
から対象Function名を元に実行するFunction
オブジェクトを取得
5-3. Function実行時に渡す実行引数(JSON形式)をパースして取得
5-4. 取得した実行引数を指定してFunction実行
5-5. Function実行結果を整形して、1.で生成したmessage配列に追加 - 5.でFunction実行結果が追加されたmessage配列を指定して、3.と同様に再度LLMのChat APIをコール
- レスポンスメッセージをAssistantメッセージとして応答
- 引数
-
textToSpeech
メソッドについては、プロバイダが読み上げAPI提供していない場合は、AnthropicAdapter
を参考にSorry音声を応答、対応している場合はOpenAIAdapter
を参考に読み上げAPIを実行した結果を応答するイメージ
//~~省略~~
export class GroqAdapter implements LlmAdapter {
protected groqClient;
constructor(
protected llmConfig = {
apiKey: JSON.parse(process.env.APP_SECRETS || "{}").GROQ_API_KEY || process.env.GROQ_API_KEY || "",
apiModelChat: process.env.GROQ_API_MODEL_CHAT!,
},
) {
this.initCheck(llmConfig);
this.groqClient = new Groq({apiKey: llmConfig.apiKey});
};
//~~省略~~
async functionCalling(
functions: { [functionId: string]: Function },
systemPrompt: string[],
messages: string[],
options: FunctionCallingOptions
): Promise<FunctionCallingResponse> {
// 1.
const funcMessages: ChatCompletionMessageParam[] = [];
systemPrompt.forEach(msg => {
funcMessages.push({
role: "system",
content: msg,
});
});
messages.forEach(msg => {
funcMessages.push({
role: "user",
content: msg,
});
});
// 2.
const funcOtions = {
model: this.llmConfig.apiModelChat,
messages: funcMessages,
tools: options.tools as ChatCompletionTool[],
tool_choice: options.toolChoice || "auto" as ChatCompletionToolChoiceOption,
max_tokens: options.maxTokens as number || 1028,
temperature: options.temperature as number ?? 0.7,
response_format: options.responseFormat,
};
const response: FunctionCallingResponse = {
resAssistantMessage: "",
resToolMessages: []
};
try {
// 3.
const chatResponse = await this.groqClient.chat.completions.create(funcOtions);
const choice = chatResponse.choices[0];
const finishReason = choice.finish_reason;
// 4.
if (finishReason !== "tool_calls") {
response.resAssistantMessage = choice.message?.content || "Sorry, there was no response from the agent.";
return response;
}
if (choice.message) {
const toolMessage = choice.message;
funcMessages.push(toolMessage);
// 5.
for (const toolCall of toolMessage?.tool_calls || []) {
// 5-1.
const functionName = toolCall.function.name;
// 5-2.
const functionToCall = functions[functionName];
// 5-3.
const functionArgs = JSON.parse(toolCall.function.arguments);
const values = Object.values(functionArgs);
// 5-4.
const functionOutput = functionToCall ? await functionToCall(...values) : {error: `${functionName} is not available`};
// 5-5.
const content = { ...functionArgs, function_output: functionOutput };
const resToolMessage = {
content: JSON.stringify(content)
};
const toolResult = {
tool_call_id: toolCall.id,
role: "tool",
...resToolMessage
};
funcMessages.push(toolResult as ChatCompletionMessageParam);
response.resToolMessages.push(resToolMessage);
}
funcOtions.messages = funcMessages;
// 6.
const nextChatResponse = await this.groqClient.chat.completions.create(funcOtions);
// 7.
response.resAssistantMessage = nextChatResponse.choices[0].message?.content || "Sorry, there was no response from the agent. If the following details are displayed, please check them.";
}
} catch (error) {
throw error;
}
return response;
}
async textToSpeech(_: string, options: Record<string, any>): Promise<TextToSpeechResponse> {
//~~省略~~
}
}
- また、作成したAdapterクラスを
llmAdapterBuilder
の生成対象クラス一覧llmAdapterClasses
に[LLMプロバイダID]: [Adapterクラス名]
の形式で追加する
//~~省略~~
const llmAdapterClasses: Record<string, LlmAdapterConstructor> = {
OpenAI: OpenAIAdapter,
AzureOpenAI: AzureOpenAIAdapter,
Anthropic: AnthropicAdapter,
Groq: GroqAdapter,
};
//~~省略~~
以上が、LLM APIと連携したAdapterの実装例でした。
次は、Function Callingによって紐づけられるFunction(デバイスコントローラ)の実装について解説します。
デバイスコントローラ(Function)の実装
事前準備
SwitchBotは個別に用意のうえ、操作したいデバイスとの接続を確認しておく必要があります。
なお、筆者は赤外線リモコンに対応したMatter対応のSwitchBotハブミニを使っています。
SwitchBot API利用に必要なトークンやシークレットの払出し方法は以下を参照してください。
SwitchBot API仕様は以下を参照してください。
接続したデバイス情報を取得するAPIの応答メッセージからデバイスIDを取得して控えておきます。(後続の設定で使用)
デバイスコントローラ(Function)の作成
デバイスコントローラについても、LLM APIのAdapterと同様にホームデバイスの追加に合わせてアドオンしやすくするためにやや回りくどい実装パターンになっています。
GitHubには、サンプルとしてSwitchBotを介して赤外線リモコン登録したTV
、Light
向けのFunctionを用意しています。
以下、サンプルのTV
を参考に作成の流れを説明します。
Function Callingのtool定義ファイルの作成
- JSON形式でLLMのFunction Callingのtool定義を作成
- tool定義の仕様は、各LLMプロバイダのAPI仕様によるので確認のうえ作成が必要ですがOpenAI仕様に準拠したプロバイダは同じ定義ファイルを共用可能
- 以下、サンプルの
TV
の定義を参考にポイントを記載- ファイル名と
function.name
の値は同一にする -
function.parameters.required
は["commandType", "command"]
固定 -
function.parameters.properties.commandType.description
に、操作するコマンドの種類を全て記載する -
function.parameters.properties.[command]
の[command]
は重複しないようにコマンド種類ごとに定義する -
function.parameters.properties.[command].description
に、コマンド種類ごとのコマンドのパターンやルール・制約等を定義する
- ファイル名と
{
"type": "function",
"function": {
"name": "controlTVBySwitchbot",
"description": "Control TV given command by Switchbot.",
"parameters": {
"type": "object",
"properties": {
"commandType": {
"type": "string",
"description": "The Command type to control TV. e.g. 'power', 'channel', 'volume'"
},
"commandOfPowerchange": {
"type": "string",
"description": "The Command to change power status. e.g. 'change', 'turnOn', 'turnOff'"
},
"commandOfChannelsetting": {
"type": "integer",
"description": "The Command to set channel from 1 to 12."
},
"commandOfVolumechange": {
"type": "integer",
"description": "The Command to change volume from -3 to 3."
}
},
"required": [
"commandType",
"command"
]
}
}
}
Functionクラスを実装
- SwitchBot操作用抽象クラス
SwitchbotControlClient
を継承したFunctionクラスを作成 - Functionクラスでは、
functionId
が引数のコンストラクタを用意し、且つfunctionId
はスーパークラスのコンストラクタに引数で渡す -
controlDevice
メソッドをオーバーライドして、デバイス操作処理を実装する
import { SwitchbotControlClient } from "./switchbot_control_client"
export class SwitchBotTVControlClient extends SwitchbotControlClient {
constructor(functionId: string) {
super(functionId);
}
async controlDevice(commandType: string, command: string | number): Promise<Record<string, string>> {
console.log(`[TVControlClient] command_type: ${commandType} command: ${command}`);
const url = `${this.switchbotConfig.devCtlEndpoint}/v1.1/devices/${this.switchbotConfig.devIds.main}/commands`;
const headers = this.getSwitchbotApiHeader();
if (commandType === "power") {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
command: "turnOn",
parameter: "default",
commandType: "command",
}),
});
if (response.status === 200) {
return { success: "TVの電源を変更しました" };
} else {
return { error: "TVの電源の変更に失敗しました" };
}
} else if (commandType === "channel") {
//~~省略~~
} else {
return { error: `command_typeが不正です. command_type=${commandType}` };
}
}
}
- また、作成したFunctionクラスを
functionBuilder
の生成対象クラス一覧functionClasses
に追加する
//~~省略~~
const functionClasses: Record<string, DeviceControlClientConstructor> = {
SwitchBotTVControlClient,
};
//~~省略~~
FunctionとデバイスIDのマッピング
- 作成したFunctionクラスが、デバイスを操作する(=操作するためのAPIを呼び出す)には前述で確認したデバイスIDが必要
- FunctionクラスがデバイスIDを参照できるように、以下JSON形式でtool定義の
function.name
とデバイスIDのマップを定義して環境変数に設定
SWITCHBOT_FUNCTION_DEVICEIDS_MAP={"controlTVBySwitchbot": {"[任意のKey名]": "[デバイスID]"}}
- これによって、Functionクラスでは
this.switchbotConfig.devIds.[Key名]
でデバイスIDが取得可能となる
Function定義ファイル
- 前述で定義済のtool定義
function.name
と、functionClasses
に追加したFunctionクラス名をマッピング定義ファイルを作成 - ここでマッピング定義したFunctionのみが呼出し可能となる
controlTVBySwitchbot: SwitchBotTVControlClient
最後に
今回は、LLMのFunction Calling APIを使ってChatbot型のスマートホームエージェントアプリを作ってみました。
LLMが登場する前は、スマートスピーカーと専用アプリなどを使ってしか実現できなかったことが、LLMのAPIを使って比較的容易に作れるようになったことは素晴らしいですね。
また、Function Callingに対応した複数のLLMを比較利用することでの発見もありました。
これは利用面だけでなく、Adapterを実装するうえでもありました。(OpenAI準拠とそうでないプロバイダとの微妙なAPI仕様の違いなど)
今後もLLM APIを使ったアプリ作成を通して気づいたことなどを投稿していきたいと思います。
Discussion