家庭用のコミュニケーションロボット「Romi」について

信田春満氏:「テンポ感よく会話するために〜Romiの応答高速化の技術」というタイトルで、信田が発表します。

まず私は信田春満と申します。2017年に「Romi」というしゃべるAIロボットを作るプロジェクトが始まったんですが、その「Romi」の最初のエンジニアとして開発に入り、今まで「Romi」を率いてきました。

さて、「Romi」を見たことある方はいますか? たぶん全員手が挙がるはずです。先ほど案内をしていたロボットですね。これは、案内をするためのロボットではなくて、家庭用のコミュニケーションロボットです。

コンセプトとしては「ペットのように癒し、家族のように自分を理解してくれる」。そんな世界を目指して作っている会話AIロボットです。

この「Romi」ですが、実はディープラーニング技術を用いて言語生成をして会話する家庭用コミュニケーションロボットとして、世界初であることを、ESP総研さんの調べで言ってもらっていて、実は「ChatGPT」よりも前に生成系AIを使ってしゃべるロボットとして世の中に送り出しているものです。

「Romi」の仕組みですが、「Romi」の脳みそ、会話の内容を考える部分は、実は「Romi」の本体じゃなくてクラウド上に入っています。

ユーザーが音声で話し掛けると、「Romi」がGoogle音声認識を使って、それをテキストに文字起こしします。そしてそのテキストを受け取って、我々のサービスの肝である会話サーバーがどのように返すのかを、テキストで考えます。そして最後に「Romi」がそれを音声でまたしゃべる仕組みになっています。

さらに、この「Romi」は単一の会話エンジンだけでできているのではなくて、例えばしりとりをするのに特化した会話エンジンだとか、汎用的なルールを記述するエンジン、そして「Romi」のメインであるAIのエンジンなど、さまざまな仕組みを協調させて動いています。

高速化、何から始める?

本日のお話ですが、「Romi」の応答の高速化に絞って話をします。その中でも、インフラ・Appサーバー編と、あとはAIの高速化の話をいたします。

最初に「高速化をやりましょう」といろいろなサービスでなると思うんですが、何から始めましょう?

例えば、弊社はPythonを使っているんですが、「Pythonって遅いから、Goで作り直そうぜ」という、活きの良いエンジニアがいることってよくあると思います。

なんですが、これは駄目です。まずやるべきことは、どこが遅いのか、何がボトルネックになっているのかを調査すること。これが最初にやるべきことです。

ボトルネックの調査をする時にとても役立つのが、プロファイリングというものです。プロファイリングは、実行時間の解析とかを行うことですね。とある部分のコードが何回呼び出されたのか、それらに合計して何秒かかったのかを解析してくれるものです。

解析のコツとかもいろいろあるんですが、全部しゃべっていると時間が足りなくなるので、興味のある方は、後ほど資料がアップロードされるのでそちらを見てください。

実はけっこう前なんですけれども、実際に「Romi」でプロファイリングを行った結果、得られた知見として、別にPythonは遅くなくて、データソース、DBへのアクセスと、あとはAIってやはりすごく時間がかかるので、主にこの2つにとても時間がかかっていることがわかりました。

高速化の方針

というわけで、まずは1つ目のインフラ・Appサーバー編です。

まず高速化の方針ですが、「速ければ速いほどいい」「もう、速ければすべて捨ててもいい」というわけじゃないんですよね。ユーザーさんに喜んでもらうためには、やはり機能を犠牲にしないこと。「高速化する関係でこれはできなくなります」みたいな改修を入れちゃうと困ります。そういうことはしない。

あとは、「高速化するんですけども、たまにバグるんですよ」みたいなのもやってはいけないです。あともう1つ。これは、僕が個人的にとても大事にしていることなんですが、保守性を犠牲にしないことです。

例えば、コードの可読性を犠牲にしない。高速化とコードの可読性ってトレードオフな関係にけっこうあって、例えば、ある状態を持ち回すことで高速化できる方法ってよくあるんですが、それをやるとクラス間が密結合になっちゃいますよね。そういうことはできるだけやりたくない。

あと、直感的な挙動を避けるとか、高速化のことを知らない人が後でビジネスロジックを書いたとしても、そんなに遅くならない仕組みとか、そういうことを大事にしています。

「Romi」のインフラ系のところでボトルネックになっている部分はデータソースだと言いましたが、それらのほとんどは、同じデータを何度も何度も取ってきていることが原因だとわかってきました。

というのも、最初に話したとおり、「Romi」は「bot」って呼んでいる、独立したいろいろな会話エンジンの集合体です。さらにその中で大きなシステムである、「ScenarioGraph」という汎用的なルールを書くエンジンでも、独立したルールが大量に書かれているんですね。

その各ルールとか各botから、よく参照されるユーザーのプロフィールとか記憶とかの情報が、何度も何度も取得されます。

というわけで、これらの独立性を保ったまま高速化したい……。そう言えば、エンジニアの方々ならキャッシュを思いつくんじゃないかと思います。

どこにキャッシュデータを保存するか

キャッシュはデータベースから情報を取ってくるんですが、このデータベースって、だいたいちょっと遅いんですよね。なので、初回はデータベースからデータを取ってくるんだけれども、取ってきたデータをキャッシュにメモをしておいて、2回目からはそのメモを見にいきます。そうすると、DBにアクセスすることがなくなるので、高速化できますよというお話です。

ただ、このキャッシュは作っちゃうと問題になることも多くて、DBの中身が書き換わってしまうと、キャッシュに入っている情報は古い情報になっちゃいます。なので、データソースを更新した時にキャッシュをクリアするのが重要です。当たり前の話ですが、けっこう大規模なものを作るとこれが問題になってきます。

さて、どこにキャッシュデータを保存するかですが、我々は3つ使っています。1つ目の場所がプロセスですね。「プロセスにキャッシュ」と言うとかっこいいんですが、要は、単にプログラムの変数の中に値をキャッシュしておきましょうというだけの話です。我々は、Pythonだったらfunctoolsのlru_cacheとかをよく使っています。

この方法のメリットとしては、手軽で超高速です。ただデメリットとして、キャッシュクリアが実質できません。サーバーのプロセスが1個しか動いていないんだったら、キャッシュクリアのリクエストを投げればいいという話になるんですけど、実際はサーバーが複数立っていて、それらがロードバランサーでロードバランシングされていて。

しかも、各サーバーの中には複数のプロセスが立っているので、全部のデータをキャッシュクリアするのは、けっこう面倒くさかったりするんですね。あとはプロセスごとにキャッシュを行うので、プロセス間でキャッシュを使い回すことはできません。

というわけで、プロセスキャッシュの場合、使いどころはサーバーのデプロイ後にデータが変わらないものです。代表的なものとしては設定ファイルとかですね。あとはDBデータで書き換えが起こらないもの。ユーザーデータの一部とか、一度入るともう絶対に書き換わらないことが保証されているもの。あとは天気予報みたいに、1回出たらしばらくの間は変わらないものとかが使われたりします。

さて、2つ目ですね。たぶんこれが世の中的にはとてもよく使われるんじゃないかと思うんですが、キャッシュ専用のサーバーを立てましょうという考え方です。例えばAWSの「ElastiCache」。弊社だと中身は「Memcached」を使っています。

これらのTipsですが、デプロイした時には中のコードが変わっている可能性があるので、安全のためにキャッシュを全部クリアすることをちゃんとやりましょう。

この方法のメリットとしては、先ほどのプロセスキャッシュと違って、キャッシュをちゃんとクリアできます。裏側のデータソースを書き換えることができます。

ですが、それゆえに「キャッシュをちゃんとクリアできていますか?」というところの面倒を見てあげるのがデメリットとして挙げられます。

メリットに戻るんですが、キャッシュ専用のサーバーを立てるので、複数のプロセス間でもキャッシュを使い回せるところもありがたいところですね。

キャッシュサーバーも速いとはいえ、プロセスのキャッシュに比べるとだいぶ遅いところもデメリットになっています。

というわけで使いどころとしては、我々の場合だと、管理ツールとかで中央から変更できる設定とかで、「Memcached」を使っていることがわりと多いです。一方で、めちゃくちゃ大量に呼ばれるところではあまり使わないというのが、弊社の所感ですね。

3つ目です。たぶんこれが「Romi」のサービスで使っているキャッシュとしてはわりと特徴的なものかなと思うんですが、「Romi」では、リクエストの中にキャッシュをするという戦略をとてもよく使います。

我々は「リクエストコンテキスト」というものを作っていて、1回の会話のAPIアクセスとか、その他のAPIリクエストの間だけ有効な保存場所みたいなものを作っています。

サーバーだったらAPIリクエスト、JobQueueだったら1個のJobというふうに、リクエストコンテキストが何であるのかは我々で実装して作っているのですが、そこの中にキャッシュを入れましょうという考え方です。

このリクエストコンテキストへのキャッシュのメリットは、リクエスト中しか残りません。リクエストが終わったらそのキャッシュは消えちゃいます。

というわけで、キャッシュクリアが漏れたとしても、次のアクセスの時には一切影響を及ぼさないんですね。なので、キャッシュクリアをあまり考えなくてもいいところがうれしいところです。しかも、内部の実装としてはただの変数に入れているのと実質同じなので、めちゃくちゃ速く動きます。

一方で、デメリットはそのままなんですが、リクエストの間しかキャッシュは生きていてくれないので、リクエスト中の最初のアクセスではキャッシュは利かないところがデメリットになります。

なぜ「Romi」がすごく使われるかというと、最初にも話したとおり、「Romi」は1回の会話あたりに同じデータを何度も何度も叩くんですね。下手すると、あるデータソースに数十回とかの規模でリクエストが飛びます。

(そんな時に)このリクエストコンテキストのキャッシュを使っておけば、そのアクセスにかかる時間が50分の1とか数十分の1になるわけで、めちゃくちゃ効果があります。

それ以外の部分だと、DBのコネクションとかデータソースのコネクション周りとかも、このリクエストの中にキャッシュをしています。実は最初はビビってここに入れたんですが、今考えると「別にプロセスのキャッシュでもよかったのでは?」と思っているところがあったりします。

というわけで、キャッシュの置き場所のまとめですね。我々は3つ使っています。プロセスとキャッシュサーバー、そしてリクエスト。この3つを駆使することで、データソースへのアクセスを可能な限り減らして高速化をしています。

高速化のためのその他どろどろした作業

実際、これだけやれば速くなるっていうわけではなくて、その他どろどろした地味な作業がありました。例えば解析用のメトリクス。データソースはアクセスが遅いことがわかったので、データソースに何回アクセスしているかは、常に(データを)取るようにしています。

ただ、それらの解析メトリクスを毎回送っているとけっこう時間を使っちゃうので、リクエストコンテキストが抜ける時、最後になにか処理するという仕組みを作っておいて、その中で解析データは一括送信するとか。

あとは、会話なので、同期的に実行しなくてもいいこと。例えば発話内容の解析とかですね。そういうものは、JobQueueを使って遅延実行するとか。

あとはすごく細かい話になってくるんですが、AWSの「Boto3」というライブラリがあるんですが、あれは毎回インスタンスを作っていると初回の動作だけけっこう遅くなることがあったりして、プロセスの中にキャッシュするだけでめちゃくちゃ速くできたことが最近ありました。

さらに地味なところで言うと、コールスタックの取得は、弊社の裏側の基盤ではけっこうすごい数が叩かれるんですが、「inspect.stack()」というのが数ミリセカンドかかっちゃうんですね。

「数ミリセカンドだったらいいじゃん」とも思うんですが、100回呼ばれると0.何秒かかっちゃうので、「inspect.currentframe()」から親を取得するだけにすれば、めちゃ速くなったとか、そういうすごく地味なのがあります。

あとは「正規表現のコンパイルを事前にしておきましょう」とか「会話のルールを書く時には、条件のANDのうち、データソースアクセスがないものを左辺に持っていくことでちょっとでも速くできますよ」とか、そういう地味なことをいっぱいやりまくっています。

判断としては、「だいたい0.1秒速くなるならやりましょう。それ以下だったら気が向いたらやりましょう」ぐらいの温度感でやっています。

というわけで、これでアプリのサーバーは0.何秒と、けっこう速くなってきました。残るはAIですね。

(次回につづく)