🤖

OpenAI の Assistants API を使って自然言語で Todo リストを操作するアプリを作ってみた

2023/11/15に公開

はじめに

こんにちは、クラウドエース フロントエンド ディビジョン所属の阿部です。

2023年 11月 6日 に開催された OpenAI DevDay で新しい API である Assistants API が発表されました。
Assistants API を使うことでアプリケーションに LLM(Large Language Model)を使った自然言語処理を組み込むことが出来るようになります。
この Assistants API を使った Todo アプリを作ってみたので、この記事ではその内容を紹介します。

今回作るアプリ

新しいライブラリやツールが登場した時に、それらを試す時の題材として Todo リストを作ることが多いのですが、Assistants API を使って自然言語で Todo リストを操作できるアプリを作ったら面白いかな? と思い、今回のアプリを作ることにしました。

使った技術

  • OpenAI Node.js SDK: OpenAI は Python と Node.js に向けて公式の SDK を公開しています。Web フロントエンドに関わる業務領域では JavaScript を使う機会が多いため、OpenAI Node.js SDK を使い Assistants API を呼び出します。
  • Vite(React): Web UI の開発には Vite を使っています。簡単な検証用のアプリを作る時にはセットアップが簡単な Vite をよく使っています。また UI ライブラリには React を使っています。
  • Bun(Elysia): OpenAI API へのアクセスには API キーが必要となりますが、クライアントサイドに API キーを埋め込むのはセキュリティ上の問題があります。そのため API サーバーを間に挟み、サーバー側で API キーを管理するようにしています。

今回の検証では クライアントは React を、サーバーでは Bun という Node.js 互換の JavaScript ランタイムと、Bun で動作する Web サーバーライブラリである Elysia を使っています。
使いなれているので採用していますが、今回の検証では重要ではありません。

Assistant

Assistant の作成

Assistants API を使うには、まず Assistant を作成する必要があります。
Assistant は文字通りあるリクエストに対して応答を返す「アシスタント」のようなものです。
Assistant には、どのように振る舞うのかを予め伝えておいたり、OpenAI が提供する強力な「ツール」を使って Assistant に対して何かをさせたりすることが出来ます。

Assistant は SDK 経由か OpenAI のダッシュボードから作成することが出来ますが、ダッシュボードから作成する方が簡単です。

Assistant を作成する時にはまず名前をつけます。
今回は aiTodoAssistant という名前にしました。

次に、この Assistant にどのように振る舞って欲しいのかを Instructions として記述します。
今回は以下の内容を Instructions として記述しました。

You are simple Todo List assistant.
You should invoke functions properly to manage Todo List.
When you add Todo task, You must translate task title in Japanese.

When adding Todo task, you must generate rundom ID to attach the Todo task.

When removing Todo task, you must find task ID from past created task that match user request.

Unless an explicit request for deletion is made, such as in cases of completion or termination, editing will be performed instead of deletion.

実はこの Instruction はその内容についてそこまで厳密に吟味したものではありません。
検証時点で思い思いに加筆・修正していたものを、そのまま掲載しています。

Functions に Todo リストを操作するためのロジックを伝える

Assistants API には Functions という仕組みが備わっています。
Functions は Assistant に対し予め関数の情報を伝えておくことで、ユーザーからの問い合わせの内容に応じて「どの関数」を「どのようなパラメーターで呼びだすか」を Assistants API に推論させることが出来るようになるというものです。

事前に Todo リストを操作するため以下のようなロジックを作成しています。

Todo ロジックの作成

Todo リストの内容を管理するロジックを作成します。
なお、今回はデモアプリのため、Todo リストの内容はメモリ上に保存しています。

Todo リストの内容を管理するロジックは以下のようになります。

todo.ts
export type Todo = {
  id: string
  title: string
  done: boolean
}

const todos: Todo[] = []

export const getTodos = () => {
  return todos
}

export const addTodo = (todo: { id: string, title: string }) => {
  todos.push({
    ...todo,
    done: false
  });
}

export const editTodo = (todo: Todo) => {
  const index = todos.indexOf(todo)
  // replace todo
  todos.splice(index, 1, todo)

  return todos
}

export const removeTodo = (params: {
  id: string
}) => {

  const index = todos.findIndex((todo) => todo.id === params.id)
  todos.splice(index, 1)

  return todos
}

Todo リストを操作するため以下の関数が用意されています。

  • getTodos: Todo リストの内容を返します。
  • addTodo: Todo リストに Todo を追加します。
  • editTodo: Todo リストの Todo を編集します。
  • removeTodo: Todo リストから Todo を削除します。

Functions を使って Assistant に Todo リストを操作させるため、Todo リストを操作するためにこれらの関数があること、また、それぞれの関数はどのような処理を行うためのものなのか? どのようなパラメーターを持つのか? などの情報を Assistant に伝えておきます。

Functions の設定は、SDK を使った Assistant の作成(編集)時にその定義を含めるか、あるいはダッシュボード上で追加することが出来ます。
今回は Assistant の作成時と同様にダッシュボード上で設定を行います。

Functions では設定したい関数毎に JSON 形式の文字列て定義を記述します。
Todo リストに Todo タスクを追加する addTodo を Function として Assistant に伝えるため、今回は以下のような記述を行いました。

{
  "name": "addTodo",
  "description": "add Todo",
  "parameters": {
    "type": "object",
    "properties": {
      "title": {
        "type": "string",
        "description": "Todo title"
      }
    },
    "required": [
      "title"
    ]
  }
}

この定義には関数の名前や、その関数についての記述(何をするためのものなのか、など)、関数に渡すパラメーターの定義が含まれています。
これらの定義の内容を元に、Assistant はユーザーからの問い合わせに対してどの関数を呼び出せばいいのか? どのようなパラメーターを渡せばいいのか? を推論することが出来るようになります。
addTodo 以外の他の関数についても Functions に定義を伝えておきます。

Assistants API を利用したアプリケーションの構築

Assistant と Functions の設定が終わったので、Assistants API を使ったアプリケーションの構築に入ります。

Assistants API の処理の流れ

アプリケーションの構築に先立ち、Assistants API の処理の流れについて簡単に説明します。
Assistants API には、ここに来るまでに作成した Assistant の他に以下の3つのオブジェクトが存在しています。

  • Thread
  • Message
  • Run

Assistants API では Assistant による何らかのリクエストに対する応答が行われますが、そのリクエストを管理する単位として ThreadMessage というオブジェクトを利用します。
Message は文字通りユーザーやシステムによって作成されたメッセージを表し、Thread はそれらの Message を管理するための単位です。

Thread に追加された Message の内容に対し Assistant を使って処理を行うためには、Thread に対して Run というオブジェクトを作成します。

環境変数の準備

今回のアプリケーション中では API キーを含んだいくつかの定数を環境変数として扱っています。
環境変数を設定するために .env ファイルを作成し、以下のように定数を定義します。

.env
OPENAI_API_KEY=
ASSISTANT_ID=
THREAD_ID=

さらに、これらの環境変数は config.ts というファイルから export するようにしています。

config.ts
export const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""
export const ASSISTANT_ID = process.env.ASSISTANT_ID ?? ""
export const THREAD_ID = process.env.THREAD_ID ?? ""

OPENAI_API_KEY には OpenAI のダッシュボードから発行した API キーを設定します。
ASSISTANT_ID には Assistant を作成した時に表示される Assistant ID を設定します。
THREAD_ID には Assistant に対してリクエストを送る際に必要となる Thread ID を設定します。Thread の ID はこの後の手順にあるサーバーを起動した時に取得します。

API サーバーの構築

まずは OpenAI API の Node.js SDK を使って Assistants API を呼び出す API サーバーを作ります。

SDK の初期化

OpenAI のダッシュボードから発行した API キーを発行しておきます。
API キーを使い SDK を初期化します。

openai.ts
import OpenAI from "openai"
import { OPENAI_API_KEY } from "./config"
export const openai = new OpenAI({
  apiKey: OPENAI_API_KEY
})

この openai インスタンスから Assistants API を操作するメソッドを呼び出すことが出来ます。

Web サーバーを作る

Bun と Elysia を使って Web サーバーを作ります。

ソースコードは以下のようになります。

server.ts
import { openai } from "./openai"
import { ASSISTANT_ID, THREAD_ID } from "./config"
import { Elysia } from "elysia"
import { cors } from "@elysiajs/cors"
import { getTodos, addTodo, editTodo, removeTodo } from "./todo"

const functionCall = {
  getTodos,
  addTodo,
  editTodo,
  removeTodo,
}

const waitRun = async (threadId: string, runId: string) => {
  console.count("waitRun")

  const run = await openai.beta.threads.runs.retrieve(threadId, runId)

  if (run.status === "queued" || run.status === "in_progress") {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return waitRun(threadId, runId)
  }

  if (run.status === "requires_action" && run.required_action) {
    // NOTE: 複数回の Tools の呼び出しには対応していない
    const call = run.required_action.submit_tool_outputs.tool_calls[0]
    const args = JSON.parse(call.function.arguments)

    functionCall[call.function.name](args)

    await openai.beta.threads.runs.submitToolOutputs(threadId, runId, {
      tool_outputs: [
        {
          tool_call_id: call.id,
          output: "ok",
        },
      ],
    })

    return waitRun(threadId, runId)
  }

  return run
}

const app = new Elysia().use(cors())

app
  .post("/thread", async () => {
    const thread = await openai.beta.threads.create()

    return thread
  })
  .get("/todos", () => {
    return getTodos()
  })
  .post("/submit", async (req) => {
    const q = (req.body as { query: string }).query

    // ユーザーが入力したクエリを Message として OpenAI API に送信する
    await openai.beta.threads.messages.create(THREAD_ID, {
      role: "user",
      content: q,
    })
    const run = await openai.beta.threads.runs.create(THREAD_ID, {
      assistant_id: ASSISTANT_ID,
    })

    const runToReteive = await waitRun(THREAD_ID, run.id)

    return runToReteive
  })
  .listen(8080)

各エンドポイントの説明

  • /thread: Assistants API では、ユーザーとの会話を Thread という単位で管理します。このエンドポイントでは Thread を作成します。
  • /todos: Todo リストの内容を返します。
  • /submit: ユーザーが入力したクエリを Assistants API に送信します。

Web クライアントを作る

今回の検証では Web クライアント側はそこまで重要ではないので、簡単な UI を作ります。

App.tsx
import useSWR from "swr"
import { useState } from "react"

function App() {
  const [message, setMessage] = useState<string>("")
  const [processing, setProcessing] = useState<boolean>(false)

  const { data, isLoading, mutate } = useSWR<
    {
      id: string
      title: string
      done: boolean
    }[]
  >("/todos", () => {
    return fetch("http://localhost:8080/todos").then((res) => res.json())
  })

  const onSubmit = async () => {
    if (!message) {
      return
    }

    const query = `${message}`

    setMessage("")

    try {
      setProcessing(true)
      await fetch(`http://localhost:8080/submit`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          query,
        }),
      })
    } finally {
      setProcessing(false)
      mutate()
    }
  }

  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      <h1>Single Input Todo List</h1>
      <ul>
        {data?.map((d) => (
          <li
            key={d.id}
            style={{
              textDecoration: d.done ? "line-through" : "",
            }}
          >
            {d.title}
          </li>
        ))}
      </ul>
      {data?.length === 0 && <div>Please create some Tasks</div>}
      <input
        type="text"
        value={message}
        placeholder="Enter a task"
        onChange={(e) => setMessage(e.target.value)}
        disabled={processing}
      />
      <button disabled={processing || !message} onClick={() => onSubmit()}>
        submit
      </button>
      {processing && <p>Processing...</p>}
    </div>
  )
}

export default App

動かしてみる

サーバーとクライアントを起動して動作の検証を行います。
検証の都合上、1つの Thread を使い回しているので、サーバーが起動したら適当な HTTP クライアントを利用し /thread エンドポイントにリクエストを送り、Thread ID を取得しておきます。(サーバーの再起動が必要)

クライアントを起動すると以下のような UI が表示されます。
Assistants API の機能の検証を行えれば良かったので UI にはあまりこだわっていません。
UI

適当な Todo タスクを追加してみます。
今回は「本を読まなくちゃ」と入力してみました。
add task

このリクエストは Assistant により「addTodo という関数を呼び出し Todo タスクを追加する」という処理に変換され、新たに「本を読む」というタスクが追加されました。

add task done

次に、このタスクを完了にしてみます。
今回は「本を読んだ」と入力してみました。
finish task

このリクエストが Assistant によって「本を読むタスクの完了」という処理に変換され、editTodo 関数がユーザーが期待するタスクを完了状態にするパラメーターを持って呼び出されました。
これにより「本を読む」タスクが完了状態になりました。
finish task done

別のタスクを追加してみます。今度は「病院に行き薬をもらう」と入力しました
add task2
add task2 done

タスクを追加してから、もらうのは薬ではなく包帯であることに気がつきました。
タスクの内容を編集するため「もらうのは包帯だった」と入力しました
edit task2

先ほどのタスクを完了状態にした時と同様に editTask 関数が、今度はタスクの内容を更新するパラメーターを持って呼び出されました。
edit task2 done

まとめ

Assistants API を Todo リストアプリケーションに組み込む方法を紹介しました。
SDK を利用することで簡単に LLM の強力な機能をアプリケーションに追加することが出来るようになっており、今後色々なシーンでの LLM の活用が期待されます。
今回のような UI を持つ Todo アプリが使いやすいかどうかはさておき、Todo リストというありふれた題材でも、LLM の機能と組み合わせることで今までとは異なる新しい UX を生み出すことが出来るようになることが分かりました。

しかし、検証を行う中で期待したように動作をさせられない場面も多々ありました。
例えば、ある Todo タスクを削除させたい時に「どれどれを削除して」といったようなリクエストを入力した時に、これが「どれどれを削除する」といった Todo タスクとして追加されてしまったり、あるタスクを完了にしようと思い「これこれが完了した」というリクエストを送ったらタスク自体が削除されてしまったり、あるいは完了はさせられるが他のタスクが上書きされてしまうといったこともありました。

LLM から期待通りの出力結果を得るためにリクエストを工夫するプロンプトエンジニアリングという手法があります。
LLM を簡単に組み込めるようになってもモデルの真価を発揮させるためにはより優れた指示を行う必要があるのだなと強く実感し、LLM が身近になるにつれプロンプトエンジニアリングの重要性がどんどん増していくのだろうなと思いました。

参考情報

Discussion