生成AIを活用した制御系シナリオの実験

現在、私が勤務している会社では、組込み製品向けのネットワークスタックや ECHONET Lite 対応ミドルウェアなど、組込み機器向けのソフトウェア製品を中心に開発・販売を行っております。
今回は、話題の生成 AI と組込み技術を組み合わせて何か面白いことができないかと考え、自然言語で操作できるホームサーバーの試作を行いました。生成 AI については初心者ではありますが、組込み業界の皆様にとって生成 AI 活用の一例としてご参考になればと思い、実験内容をまとめました。温かい目でご覧いただき、ご意見・アドバイスなどいただければ幸いです。

### 生成 AI アプリのプログラミング
生成 AI といえば、ChatGPT などを思い浮かべる方が多いかと思いますが、プログラマー視点では、GPT-4o などの「言語モデル（LLM）」が生成 AI の中核を担っています。ChatGPT はその言語モデルを活用したアプリケーションの一例です。
言語モデルは膨大な学習データをもとに、自然言語の入力に対して自然言語で出力を行います。ただし、学習データは静的であるため、最新の天気や時事情報、社内データなどには対応できません。
一方、ChatGPT のようなアプリケーションでは、言語モデルをベースに Web 検索や外部プラグインと連携することにより、より広範な情報に基づいた応答が可能になります。社内データや専門データを組み合わせる「RAG（Retrieval-Augmented Generation）」という手法もあり、こうした仕組みを活用したアプリケーションを「生成 AI アプリ」と呼びます。
生成 AI アプリを作成するには、Python や Type Script を使うことが多いようですが、GPT-4o などを利用するための (この場合 OpenAI 社の) フレームワークが用意されています。またオープンソースの LangChain のような、より上位の生成 AI アプリ作成フレームワークもあります。こちらを利用すると、より簡潔に生成 AI アプリが作れるだけでなく、OpenAI 以外の言語モデルも共通してプログラミングできるようになります。今回は Python と LangChain を利用してみました。

### 外部関数呼び出し
今回のキモとなる部分は、言語モデルから必要に応じて Python 関数を呼び出してくれる機能(LangChain では 「Tool Calling」 と呼びます)です。次が簡単な外部関数の例となります。ここでは LangChain の説明は割愛しますが、中段が準備のためのコードです。下段をみると、入力のテキストを agent_executor.invoke() という LangChain の関数に渡すと、言語モデルを中心にいろいろ処理をして(外部関数の呼び出しもそのひとつ)、結果出力のテキストが得られるようになっています。

```
# 外部関数
@tool
def add(a: int, b: int) -> int:
    """2つの値を足し算して返す"""
    print('add:', a, b)
    return a + b

# LangChain 準備
prompt = ChatPromptTemplate.from_messages([
        ('system', '与えられたinputに従って処理を呼び出してください'),
        ('human', '{input}'),
        ('placeholder', '{agent_scratchpad}')])
agent = create_tool_calling_agent(ChatOpenAI(model='gpt-3.5-turbo'), [add], prompt)
agent_executor = AgentExecutor(agent=agent, tools=[add])

# 指示と回答
input = '2と3を足すといくつ' # 入力文字列
print('input:', input)
result = agent_executor.invoke({'input': input}) # 回答を生成
print('output:', result['output'])　# 出力文字列
```

上段の add 関数が外部関数です。普通の関数に tool デコレータと docstring が付加されているだけですが、この関数を登録すると、言語モデルが 2 つの数を足すような文脈を検出したとき、この関数を自動で呼び出し、その戻り値を利用して回答を作成するようになります。
関数の役割を自然言語で説明するところが、これまでのプログラミングとは大きく異なる感覚です。文言によって精度が変わりますので、適切な文言を作ることをプロンプトエンジニアリングと呼ぶそうです。
実行すると、次のような print 出力となります。入力文に対して、ちゃんと期待した引数で関数が呼ばれ、返した値で回答文が生成されています。

> input: 2と3を足すといくつ
> add: 2 3
> output: 2と3を足すと5です。

この機能を利用して、「室内の温度は」のような問合せからに対して外部関数がコールされれば、ECHONET Lite を呼び出すような制御ができるのではないかと考えました。

### 温度センサーの読み取り
まずはシンプルな温度センサーを模した外部関数を実装してみました。気温を知りたいような文脈のとき、この関数が呼ばれることを期待しています。見やすさのため、ECHONET Lite による実際のセンサーを読み取る代わりに、ダミーで常に 24.5 度を返すようにしています。

```
# 温度センサーの外部関数
@tool
def tempature_sensor():
    """屋内や屋外の気温や温度を計測する"""
    Print('温度センサー')
    return 24.5    # ECHONET Lite 呼び出しの代わりにダミー値を返す
```

こちらが結果です。ちゃんと外部関数が呼ばれて、ダミーで返した温度が回答されています。

> input: 室内の温度は
> 温度センサー
> output: 室内の温度は24.5度です。

他の言い回しも試してみました、うまく機能しているようです。

> input: 今何度
> 温度センサー
> output: 現在の気温は24.5度です

### パラメータの設定
次に、センサーの場所と温度単位をパラメータとして追加してみました。それぞれにアノテーションを付けています。引数は省略可能で、指定されなければ None が渡されるようにしています。変更したのは関数の定義だけです。
* location 引数: センサーの場所
* unit 引数: 温度の単位

```
def tempature_sensor(
        location : Optional[Annotated[Literal['外', 'リビング', '寝室', '子供部屋', '室内'], '場所を指定する']],
		unit : Optional[Annotated[str, '単位を指定する']] = None,
) -> float:
    """屋内や屋外の気温や温度を計測する"""
    print('温度センサー:', location, unit)
    return 24.5
```

結果はつぎの通りです。チューニングは必要でしょうが、簡単な実装でそれなりに期待した動作をしてます。場所を指定している指示では location に値が入りますし、華氏を指示すれば unit 引数に値が渡されています。'屋外'->'外' や '居間'->'リビング' など多少文言が違っていても補正されてます。指示に対する回答もそれっぽいですね。

> input: 室内の温度は
> 温度センサー: 室内 None
> output: 室内の温度は24.5度です。

> input: 屋外の温度は
> 温度センサー: 外 None
> output: 屋外の温度は24.5度です。

> input: 子供部屋の温度を教えて
> 温度センサー: 子供部屋 None
> output: 子供部屋の温度は24.5度です。

> input: 居間は今何度
> 温度センサー: リビング None
> output: リビングの気温は24.5度です。

> input: 温度を華氏で教えて
> 温度センサー: None 華氏
> output: 温度は華氏で24.5度です。

### エアコンの制御
今度はエアコンの制御例に挑戦です。次のような外部関数を追加しました。こちらも実際の ECHONET Lite の呼び出しは割愛して、指示と外部関数の動きだけを見てみます。いくつかの指示を想定して、次の引数を設けてみました：
* ask : エアコンの状態の問合せ指示
* onoff : エアコンのオンオフの指示
* mode : 運転モードの指示
* temperature : 設定温度の指示
```
@tool
def climate_control(
        ask: Optional[Annotated[bool, '現在の運転モードや設定温度を問い合わせる']] = False,
        onoff: Optional[Annotated[bool, '運転のオンオフ・つけるか消すかを指定する']] = True,
        mode : Optional[Annotated[Literal['冷房', '暖房', '除湿', '送風'], '運転モードを指定する']] = None,
		temperature : Optional[Annotated[float, '設定温度を18.0から30.0の間で0.5単位で指定する']] = None,
指定する']] = None,
) -> str:
    """エアコンのオンオフや運転モードや設定温度を指定する。または現在の運転モードや設定温度を問い合わせる"""
    print('エアコン制御:', ask, onoff, mode, temperature)
    return '24.5'

```
簡単な検証ですが、以下のような動作になりました。こちらも実用にするにはいろいろチューニングが必要そうですが、初回にしてまずまずの動きです。

> input: エアコンをつけて
> エアコン制御: False True None None
> output: エアコンをつけました。現在の設定温度は24.5度です。

> input: エアコンを除湿モードにして
> エアコン制御: False True 除湿 None
> output: エアコンを除湿モードに設定しました。

> input: エアコンの温度を26度に設定して
> エアコン制御: False True None 26.0
> output: エアコンの温度を26度に設定しました。現在の設定温度は24.5度です。

> input: エアコンを設定温度を教えて
> エアコン制御: True True None None
> output: エアコンの設定温度は24.5度です。

### 組合せを試してみた
今２つの外部関数を定義したので、それらを組み合わせるような指示はできるのか試してみました。とくに追加の実装はなく、ただ指示を変えただけでです。

> input: エアコンの温度を室温より1度上に設定して
> 温度センサー: 室内 None
> エアコン制御: False True None 25.5
> output: エアコンの設定温度を室温より1度上の25.5度に設定しました。

ちゃんと温度センサーの結果に応じて、エアコン制御をしています。時間の指定とか照度とか、組み合わせると便利なシナリオがいろいろできそうですね。

### まとめ
生成 AI は、チャットやコンテンツ生成だけでなく、制御系のシナリオにも応用可能であることが実感できました。スマホアプリから自然な音声で家のあれこれを制御したり、工場や実験などで手や目の離せない状況で複雑な操作を音声で実現するシナリオもありそうです。
「MCP サーバー」のような標準化技術もありますので、既存の AI アプリにプラグインできる汎用パッケージとして実装をすることも可能です。組込み業界の皆様、ぜひ生成AIを活用した新しいシナリオを検討してみてはいかがでしょうか。