Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

LangSmith で始める LLMOps

こんにちは。ファンと共に時代を進める、Web3スタートアップ Gaudiy の seya (@sekikazu01)と申します。

弊社では今 LLM をプロダクトに活用しているのですが、実際にユーザに提供するクオリティのものを作る・運用しようとすると様々な課題が立ちはだかってきました。

そんな数々の課題を解くために LangSmith というツールが活躍してくれた、また今後の活用・発展にもかなり期待ができるため、本記事ではそんな LangSmith について解説していきます。

LLM を使ったプロダクト開発において課題を感じている方々の参考になれば幸いです。

出てきた課題

まず LangSmith 自体の解説に入る前に、我々が直面した・ほぼ間違いなく今後するであろう課題たちをサラッとご紹介しようと思います。

大まかには次のような課題がありました。

  • プロンプトがアプリケーションコード内に書かれていたので、エンジニア以外がプロンプトチューニングをする時の受け渡しが手間
  • そもそもプロンプトチューニング自体がすごい手間
    • 様々なプロンプトテンプレートに対するインプットの組み合わせがある
    • 特に RAG で記憶を埋め込んだりするような文脈情報が多いプロンプトではデータを用意するのが大変
  • プロンプトの評価基準が言語化されていない
    • プロンプトはそれを持ってどんな体験をユーザに届けたいかの目的があるが、プロンプトの評価基準を最初からバシッと決めるのはおそらく不可能
    • なので逐次育てていくことになるが、そのログが取れていないため、後にいじったり他の環境(使用するモデルなど)で試した際にリグレッションが起きてないか確認する術がない

これらの課題に対して LangSmith の次のような機能たちがハマりました(or まだしっかり運用に乗せられてないのでハマりそうな予感がしています)。

  • プロダクトで実際に送られたプロンプトのログが取れる & それをいじれる Playground がある
    • これによりエンジニアでない人も触れるようになるし、プロンプトに渡すデータもリアルなものがあるのでそれを元にいじれる
  • Evaluation 機能でプロンプトのテストが書ける & データセット機能でそれに対するインプットも作れる
  • Hub によってプロンプトの一元管理とバージョン管理ができる

それではこれらの機能について、より具体的に解説していこうと思います。

LangSmith の基本

まずはじめにサクッと LangSmith の超基礎的なところをお話しします。

LangSmith は、LLM アプリ開発の定番フレームワークとなった LangChain の開発元と同じところが開発している LLM アプリ開発支援サービスです。

※ LangSmith は2023/10月現在は private beta な状態なので waitlist に登録する必要があります。(ちなみに "private" とは呼んでますが、記事書いても大丈夫なことはお問合せして確認しました👍)

www.langchain.com

LangChain 本体の X でもよく新機能や Tips の紹介をしているので要チェックです。

LangChain (@LangChainAI) / X

インストールは LangChain を使っている場合、非常に簡単で、下記のように環境変数をセットするだけです。

export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
export LANGCHAIN_API_KEY=<your-api-key>
export LANGCHAIN_PROJECT=<your-project>  # if not specified, defaults to "default"

LangChain を使っていない場合でも langsmith のパッケージがあるので、それを活用して自分で LLM の実行結果などのログを取るようにすることができます。

github.com

Organization 機能について

仕事で使っていく上で気になるのは「複数人で同じ計測を見られるか」ですが、こちらについては8月ごろに Organization 機能が追加されました。ただ、現状は Max 5人までなのでそこはご注意です。

Oranization機能の画面
Oranization機能の画面

それでは次は具体的な LangSmith の機能について解説していきます。

プロンプトのログと Playground

LangSmith の最も基礎となる機能はプロンプトのログです。 実行された結果はこのように一覧に表示されます。

LangSmith のログ画面
LangSmith のログ画面

詳細にいくと、こんな感じで実際に送られたプロンプトとその結果が見られます。

LangSmith のプロンプトログ詳細画面
LangSmith のプロンプトログ詳細画面

そして右上の Playground を押すと、そのままプロンプトや様々なパラメータをいじってチューニングすることができます。

LangSmith のプロンプト Playground
LangSmith のプロンプト Playground

ちなみに OpenAI API 以外にも Anthropic や Vertex などを使うこともできます。

LLM Provider の選択
LLM Provider の選択

LangSmith 導入以前はエンジニア以外がプロンプトチューニングを始めるのが手間だったのですが、今は実際に送られたプロンプトを使ってスッと Playground でいじれるようになってそのハードルが大分下がりました。

まだ「インプットを切り替えた並列実行」「繰り返し実行」などチューニングしていく上で、もう一捻り欲しいなぁと考えている機能はあるのですが、これだけでも大分嬉しいです。ありがとう LangSmith。

小ネタ: タグにプロンプト名を入れて絞り込みやすくする

プロンプトのログはそのままだと様々なログを区別するものがないため、一覧の中からチューニングしたい対象のプロンプトを目grepで見つけることになるためいささか非効率です。

これを解決するために我々は実行時にプロンプト名をタグとして入れるようにしています。実際は ChatModel を扱うところをもう少し抽象化していたりするのですが、書き方としては以下のような感じです。

llm_chain = ChatModel("model-name" ,temperature=0.7, verbose=True).chain(prompt=prompt)
result = llm_chain.run(**kwargs_template, tags=["local", "test_message"])

こうしたデータを入れておくと LangSmith の UI にも次のようにタグで絞り込むためのチェックボックスが生まれます。(プロンプト以外のタグが将来的に欲しくなるかもしれないので “prompt:” みたいな prefix つけてもいいかも)

LangSmithでタグでのフィルターUI
LangSmithでタグでのフィルターUI

モニタリング

LangSmith では Monitor 機能が搭載されており、

  • 実行数
  • 成功率
  • レイテンシー
  • トークン消費量

などなどを確認することができます。

LangSmith のモニターUI
LangSmith のモニターUI

ただちょっと痒い所に手が届かない部分もあり、例として、以前気づいたらものすごい量のトークン消費をしていたことがあったのですが、その時知りたかったのが「どのプロンプトがどれくらい実行されている & トークンを消費しているのか」でした。一覧を見ていたらなんとなくアタリはつけられますが、現状 LangSmith ではこれをソートして見る方法がありません。

なるべくログを見る場所は集約したいので、この Monitor 画面でタグでフィルターできる機能ができるといいなぁとは思っていますが、現状は LangChain のコールバックでまた別のところ(我々の場合 BigQuery)に保存してクエリを書くようにしています。

詳しくは下記記事をご参照ください。

zenn.dev

Evaluation とテスト

次に Evaluation という機能について触れていきます。

Evaluation(評価)とは呼んでいますが、目的の感覚としては単体テストを書くのに近いです。 主にはリグレッションの確認を想定しています。

例えば…

  • プロンプトのトークン利用量が激しいのでプロンプト文を変えたり圧縮テクニックを使ったりする
  • 使う LLM のモデルを変える
  • 変数の値を変える

などなどがあり、これらの変数を変える度に都度手動で組み合わせを確認するのは中々に手間です。

そこで Evaluation 機能を活用します。 めちゃくちゃコードをかいつまむと下記のように評価基準を書いて、データセットという事前に用意したインプットを元に実行するだけです。

eval_config = RunEvalConfig(
  evaluators=[
    RunEvalConfig.Criteria(
      {"適切な文章量": "50文字以上200文字以内に収まっているか"
      " Respond Y if they are, N if they're entirely unique."}
      )
  ]
)

result  = run_on_dataset(
    client=client,
    dataset_name=dataset_name,
    llm_or_chain_factory=create_chain,
    evaluation=eval_config,
    verbose=True,
)

こういった評価基準をプロンプトチューニングする過程で育てていくと後のリグレッション確認や、追加でプロンプトを調整していく時に活躍すること間違いなしでしょう。

という訳で具体的に LangSmith で Evaluation を実行するまでの道のりを紹介します。

データセットを準備する

データセットは Evaluation の文脈ではプロンプトに対するインプットとして使われます。 データセットは UI から手動でもコードからでも作ることができるのと、エライのが LangSmith メインの機能であるログからも追加することができます。

UI から追加する

New Dataset を押して Dataset を作ります。Data type は今回は key-value にしておいてください。(LLM Chain から input の JSON を入れるのが目的なため)

データセットの作成
データセットの作成

そして add Example から JSON 形式で追加します、実行したい対象のプロンプトテンプレートに応じて整えてください。ちなみにデータセット内のこの examples は全て同じフォーマットの JSON でないと Evaluation 実行時にエラーになるので気をつけてください。

データセットのデータの追加
データセットのデータの追加

ログから追加する

Lang Smithの素晴らしいところがログから追加できることです! ログの詳細ページから Add to Dataset をクリックしてください。

ログ詳細からデータセットへの追加
ログ詳細からデータセットへの追加

押すとこんな感じでインプットの JSON をデータセットに追加することができます。 これでテストデータを作る手間が大分削減できそうですやったぜ…。

抽出されたインプットとアウトプットでデータセットに追加する
抽出されたインプットとアウトプットでデータセットに追加する

コードから追加する

コードで追加することもでき、他のデータソースから引っ張ってきたり、それなりに量があるデータセットを作る場合にはこのやり方が重宝するでしょう。

from langsmith import Client

example_inputs = [
  "a rap battle between Atticus Finch and Cicero",
  "a rap battle between Barbie and Oppenheimer",
  "a Pythonic rap battle between two swallows: one European and one African",
  "a rap battle between Aubrey Plaza and Stephen Colbert",
]

client = Client()
dataset_name = "Rap Battle Dataset"

dataset = client.create_dataset(
    dataset_name=dataset_name, description="Rap battle prompts.",
)
for input_prompt in example_inputs:
    client.create_example(
        inputs={"question": input_prompt},
        outputs=None,
        dataset_id=dataset.id,
    )

以下の環境変数が os.environ['LANGCHAIN_ENDPOINT'] で読めるような状態にしておいてください。

export LANGCHAIN_ENDPOINT=https://api.smith.langchain.com
export LANGCHAIN_API_KEY=<your api key>

Evaluation を書く

それでは Evaluation を書いてみましょう!

冒頭に示した通りやり方としては Evaluation の基準を書いて

eval_config = RunEvalConfig(
  evaluators=[
    RunEvalConfig.Criteria(
      {"適切な文章量": "50文字以上200文字以内に収まっているか"
      " Respond Y if they are, N if they're entirely unique."}
      )
  ]
)

データセットに対して実行するだけです。

def create_chain():
    llm = ChatOpenAI(temperature=0)
    return LLMChain.from_string(llm, "Spit some bars about {input}.")
   
result  = run_on_dataset(
    client=client,
    dataset_name=dataset_name,
    llm_or_chain_factory=create_chain,
    evaluation=eval_config,
    verbose=True,
)

そうするとデータセット内に評価の結果が表示されます。

実行されたEvaluation結果画面
実行されたEvaluation結果画面

そして中身のスコアのところをクリックすると、なぜその評価になったのかを確認することもできます。

Evaluation詳細
Evaluation詳細

※ 余談ですがこの評価にもしっかり LLM が使われているのでお金がかかることを意識しておきましょう💰

評価プロンプトはどうなっているのか

評価にも LLM が使われているのですが、我々は基準を書いているだけです。実際に LangSmith がどんなプロンプトで評価基準を活用しているのかみてみましょう

You are assessing a submitted answer on a given task or input based on a set of criteria. Here is the data:
[BEGIN DATA]
***
[Input]: a rap battle between Aubrey Plaza and Stephen Colbert
***
[Submission]: 猫が可愛いにゃぁ
***
[Criteria]: 適切な文章量: 50文字以上200文字以内に収まっているか Respond Y if they are, N if they're entirely unique.
***
[END DATA]
Does the submission meet the Criteria? First, write out in a step by step manner your reasoning about each criterion to be sure that your conclusion is correct. Avoid simply stating the correct answers at the outset. Then print only the single character "Y" or "N" (without quotes or punctuation) on its own line corresponding to the correct answer of whether the submission meets all criteria. At the end, repeat just the letter again by itself on a new line.

Criteria 毎に改行して結果を出してというシンプルな内容ですね。

ちなみにですが、元のプロンプトは英語で与えられた文字列が日本語で大丈夫なのかと疑問に思うかもしれませんが、結論としては問題ありませんでした。おそらくですが LLM が日本語の文字列を英語として解釈してから reasoning しているように見えます。

もしかしたら日本語特有のニュアンスが失われてしまうことはあるかもしれませんが、Evaluation に影響が出るほどではないのかなと予想しています。

LangSmith のプリセット評価基準

上記では自分で評価基準を書いてましたが、LangChain もいくつかプリセットの評価基準を用意してくれているので把握しておくと楽になることがあるかもしれません。

docs.smith.langchain.com

ドキュメントには全部書いてないですが、コードから抜いてくると下記のような評価基準がありました。 自分でカスタムで作る前に同じ観点での基準があるか探してみると良さそうです。

class EvaluatorType(str, Enum):
    """The types of the evaluators."""

    QA = "qa"
    """Question answering evaluator, which grades answers to questions
    directly using an LLM."""
    COT_QA = "cot_qa"
    """Chain of thought question answering evaluator, which grades
    answers to questions using
    chain of thought 'reasoning'."""
    CONTEXT_QA = "context_qa"
    """Question answering evaluator that incorporates 'context' in the response."""
    PAIRWISE_STRING = "pairwise_string"
    """The pairwise string evaluator, which predicts the preferred prediction from
    between two models."""
    LABELED_PAIRWISE_STRING = "labeled_pairwise_string"
    """The labeled pairwise string evaluator, which predicts the preferred prediction
    from between two models based on a ground truth reference label."""
    AGENT_TRAJECTORY = "trajectory"
    """The agent trajectory evaluator, which grades the agent's intermediate steps."""
    CRITERIA = "criteria"
    """The criteria evaluator, which evaluates a model based on a
    custom set of criteria without any reference labels."""
    LABELED_CRITERIA = "labeled_criteria"
    """The labeled criteria evaluator, which evaluates a model based on a
    custom set of criteria, with a reference label."""
    STRING_DISTANCE = "string_distance"
    """Compare predictions to a reference answer using string edit distances."""
    PAIRWISE_STRING_DISTANCE = "pairwise_string_distance"
    """Compare predictions based on string edit distances."""
    EMBEDDING_DISTANCE = "embedding_distance"
    """Compare a prediction to a reference label using embedding distance."""
    PAIRWISE_EMBEDDING_DISTANCE = "pairwise_embedding_distance"
    """Compare two predictions using embedding distance."""
    JSON_VALIDITY = "json_validity"
    """Check if a prediction is valid JSON."""
    JSON_EQUALITY = "json_equality"
    """Check if a prediction is equal to a reference JSON."""

run_on_dataset の返り値

こんな感じの結果サマリっぽいものが返ってくるので、それを元に CI で判定とかもできるかもしれません。

{
   "project_name":"0f5fcfffcd824b5c9ae533e9b9b27d86-LLMChain",
   "results":{
      "83a72ad6-0929-4456-bcea-a3966ce01318":{
         "output":{
            "input":"a rap battle between Aubrey Plaza and Stephen Colbert",
            "text":"猫が可愛いにゃぁ"
         },
         "feedback":[
            "Feedback(id=UUID(""bd7ccd82-af71-4f39-a5ac-f5ef4ca53ac4"")",
            created_at=datetime.datetime(2023, 9, 6, 59, 36, 813970),
            modified_at=datetime.datetime(2023, 9, 6, 59, 36, 813970),
            "run_id=UUID(""62cd7d98-10e3-4a40-95d6-1bd14215b27f"")",
            "key=""適切な文章量",
            score=0.0,
            value=0.0,
            "comment=""The criteria is asking if the submission is between 50 and 200 characters long. The submission \"猫が可愛いにゃぁ\" is only 9 characters long. Therefore, it does not meet the criteria.\n\nN",
            "correction=None",
            "feedback_source=FeedbackSourceBase(type=""model",
            "metadata="{
               "__run":{
                  "run_id":"780943ca-496a-4ba1-8bc0-2de00bdb2d1e"
               }
            }"))"
         ]
      }
   }
}

LangSmith の Cookbook の中にも pytest を使ったサンプルコードがあるのでこちらもご参考ください。

github.com

小ネタ: RAG を使った評価

先ほど注意点としてサラッと書きましたが、この Evaluation は LLM を使って行われているため時間もかかるしお金もかかります💰

そこで最近ちょっと話題だったのが Embedding を使った評価です。この手法では Embedding で事前に用意した想定回答との類似度を見ることによって LLM の遅さと API 代の高さをカバーしています。

gpt-index.readthedocs.io

評価手法も色々策定されていってるので知見を育てていきたいですね。

Hub によるプロンプトの管理

こちらはまだ運用で試せていないのですが、結構個人的には期待している機能です。

LangSmith の中に Hub という機能があり、ざっくり言うとプロンプトの共有サイトです。色んな人が投稿しているプロンプトを見て学ぶことができます。

Hubの画面
Hubの画面

が、これはプライベートなプロンプト管理ツールとしても優秀な予感がしています…!というのも以下の機能を備えているからです。

プロンプトは private にもできる

上記は公開されたプロンプトたちですが、Organization 以外の人が見られない private な状態にすることができます。

Privateに作ったプロンプト
Privateに作ったプロンプト

hub.pull でプログラマブルにアップロードしたプロンプトを引っ張ってこれる

下記のコードだけで Hub で作成したプロンプトを読み込むことができます。

from langchain import hub
obj = hub.pull("gaudiy/some-prompt")

ちなみに LANGCHAIN_API_KEY がない場合ちゃんと読み込めないことを確認したので、プライベートにしたプロンプトもちゃんと認証されています。

これを実行すると、上記の obj の中には次のようなデータが返ってきます。一通りのプロンプトのテンプレートやインプットの定義が取得できるので、これをコードにもバッチリ反映できます。

input_variables = ['post_content', 'feedback']
output_parser = None
partial_variables = {}

messages = [
    SystemMessagePromptTemplate(
        prompt=PromptTemplate(
            input_variables=[],
            output_parser=None,
            partial_variables={},
            template='あなたはプロのプロです。',
            template_format='f-string',
            validate_template=True
        ),
        additional_kwargs={}
    ),
    
    HumanMessagePromptTemplate(
        prompt=PromptTemplate(
            input_variables=['key', 'value'],
            output_parser=None,
            partial_variables={},
            template=(
                'こちらはホゲホゲ\n'
                '{key}\n\n'
                'ホゲータ\n'
                '{value}\n\n'
                '上記のフィードバックを元に投稿の内容を修正してください。'
            ),
            template_format='f-string',
            validate_template=True
        ),
        additional_kwargs={}
    )
]

コミットログと共にバージョン管理をすることができる

コミットのタイトルや変更理由を書く場所がないのがちょっと惜しいポイントではありますが、バージョン管理をすることができます。

コミットする一連のUI
コミットする一連のUI

これらの機能と共にこの Hub をプロンプトの Single source of Truth として扱えないかなぁと画策しています。具体的には下図のように Hub で変更があったら CI で検知してコードに反映し、都度 Hub にリクエストを送らずとも実行できるようにならんかなと。

HubとCIの連携図
HubとCIの連携図

ですが、今の所以下の機能が足りておらず、ゴリ押しできなくはないのですが今後に期待かなぁという印象です。

  • 階層やタグをつけることができない
    • プロンプトが大量に増えてくるとドメインに応じて分割して管理したくなってくる
  • Oranization のもの全部とってくるみたいなことができない
  • commit した時に変更検知できない
    • Webhook とかできると嬉しいかも

データの取り扱いについて

最後に、技術的な話ではないのですが、プロンプトのデータを LangChain のサーバに送ることもあり、内容によってはそもそも送って良いのかだったり利用規約の改変の必要があるかが議題に上がったので、そこで法務の方に相談して調べていただいた内容を記します。

結論から申し上げると

LangSmithでの個人データの処理については、規約・プライバシーポリシーを踏まえると、「個人データの取り扱い」ではないと整理できるので、いわゆるクラウドでの情報の取り扱いと同じく、同意は不要と考えて問題なさそうです(弁護士確認済み)

とのことでした。

LangSmith の利用規約とセキュリティーポリシーのリンクは下記の通りです。

https://smith.langchain.com/terms-of-service.pdf https://smith.langchain.com/data-security-policy.pdf

利用規約の 4.1 に「LangChain agrees that it will not use Customer Data to develop or improve its products and services」と「個人データを取り扱わない旨」が定められており、また、4.2の「(i) ensure the security and integrity of Customer Data (ii) protect against threats or hazards to the security or integrity of Customer Data; and (iii) prevent unauthorized access to Customer Data」と定められているため「適切にアクセス制御」も行われていると考えられる、とのことでした!

もちろんプロンプトの用途によっては、法律的にはOKでも世間的・倫理的にはアウトみたいなことはあり得るので都度判断が必要ですが、ひとまず規約に明示的にこの LangSmith のための同意を得る対応は必要なさそうです。

おわりに

以上、LangSmith の機能の解説と我々のチームで発生している課題の解決への希望について触れていきました。

実は似たような、いわゆる LLMOps と呼ばれるツールは今まさに群雄割拠で色んなツールが出てきているのですが、セットアップの簡単さからもわかる通り、 LangSmith の優位性は「LangChain という LLM アプリ開発のデファクトになりつつあるツールとの連携がすごい」ことにあるのかなと考えています。 トラッキング始めるまでの準備も環境変数を仕込むだけですし、ログも LangChain の Chain の種類に応じていい感じに整えて取得してくれます。

ある意味ではロックインのリスクとも取れるかもしれないのですが、LangChain をプロダクト開発に活用されている方はトライしてみるのもいいのではないでしょうか?

最後に、Gaudiy では現在 LLM をかなり踏み込んで活用して新しいエンタメ体験を鋭意開発中です!もしご興味持っていただければ一緒に働きましょう💪

herp.careers

herp.careers

https://herp.careers/v1/gaudiy/FAqLGhW5HFr5herp.careers

まずはカジュアル面談で「いや LLM 開発実際はね〜…」みたいなところから雑談するのでも大丈夫です、お待ちしております。

https://recruit.gaudiy.com/9b513345d630418799bdbb219acc7063recruit.gaudiy.com

それでは!!!👋

なぜ今、Gaudiyは3年越しにパブリックチェーンに戻るのか?

ファンと共に時代を進める、Web3スタートアップのGaudiyで、開発責任者をしている勝又(@winor30)です。

昨日、GaudiyはArbitrum対応のプレスリリースを発表しました。

prtimes.jp

これに関連して、今回は「Gaudiyはパブリックチェーンで、大きなエンタメIPとともに、ど直球なweb3的プロダクトづくりを本気でやっていくよ!」という宣言をしたいと思います!

この記事では、なぜGaudiyは今、本気でパブリックチェーンに向き合っていくのか?という背景や、今回の宣言に至るまでの経緯についてお話ししていきます。

1. そもそもGaudiyはなにを目指している会社なのか?

Gaudiyは「ファンと共に、時代を進める。」をミッションに、エンタメIPとファンの共創的な社会を実現しようとする会社です。

gaudiy.com

この世界観を実現するためのプロダクトとして「Gaudiy Fanlink」というコミュニティサービスを開発しており、このサービス上でファンがエンタメIPに貢献できたり、ファンの熱量の最大化をできるような体験を提供しています。

2. BCGコミュニティ時代のブロックチェーン技術や取り組み

実は元々、Gaudiy FanlinkはBCGのコミュニティサービスのようなプロダクトでした。ざっくりいうと、BCGに特化したDiscordのようなイメージです。

ユーザーも共創できるようなトークンエコノミクスを取り入れた機能が実装されており、BCGのユーザー自身もコミュニティに貢献できるようなプロダクトでした。

これを開発し始めたきっかけには、イーサリアム、ALISSteemitのようなトークンエコノミーが生み出す共創コミュニティがあります。このような共創コミュニティを誰でも作れるサービスをつくり、様々な分野でトークンエコノミーによる成長や進化を起こしたい。その思いで開発を始めました。

このサービスでは、BCGという特性もあって、かなりweb3的な機能も開発していました。

1つ目は、個人の信用に応じてトークンの交換レートを変動させる「Trust Economy Bonding Curves」というbonding curve(AMMの一種)を実装しました。ユーザーによるコミュニティの貢献に対する見返りを、トークンの交換レートという形で還元する仕組みとなっています。

ここではZilliqaを使ってbonding curveのコントラクトをつくっていました。当時、Zilliaはシャーディングをかなり早く導入していたり、scillaという関数型のスマコン用の言語を開発していたりと、かなりおもしろいチェーンであったため利用した背景もあります。

そもそもbonding curveとはなにか? でいうと、トークンの発行量を調整することで価格を自動的に決定するコントラクトになります。コントラクトにプールされているなんらかの通貨のトークン量に応じて、独自トークンの発行量を調整し、その発行量に応じてプールされているトークンを予めコントラクトにレートで交換できる仕組みです。イメージは、UniswapなどのAMMの一種というのがわかりやすいかなと思います。

実際につくったbonding curveの仕組みとしては、コミュニティ内でユーザーの貢献に応じた信用スコアが存在し、その信用スコアに応じてbonding curve交換レートが優遇されるという仕様でした。

また、bonding curveはシグモイド関数を利用しており、そのコミュニティの成長戦略などに応じてシグモイドの緩やかさを変更することも可能になります。

なぜbonding curveに信用スコアによる交換レートの優遇を掛け合わせたかというと、一般的なトークンで起きるボラティリティの問題を解決したかったからです。そのため、投資を目的としたユーザーよりも、コミュニティにより貢献しているファンを優遇するための信用スコアや、bonding curveのシグモイド関数の曲線を自由に設計できることを導入しました。

prtimes.jp

2つ目は、慶應義塾大学の坂井先生と共同研究・開発した、NFTに関するオークション理論「Gaudiy-Sakai方式オークション」を、Gaudiy Fanlinkのプロダクションで実装したことです。

当時はBCG中心に、NFTのプレセールなどで在庫連動型逆ダッチオークションという価格が初期高く設定され、時間経過とともに価格が下がっていく&入札があるたびに価格が上昇するオークション方式が使われていました。

ただ、この在庫連動型逆ダッチオークションには、課題がありました。それは、そもそも最初の初期設定の価格がわからず、初期が安いと思われてしまい、オークション開始と同時にいかに早く買い占めるかがベストな戦略になりがちということです。

これを「Gaudiy-Sakai方式」ではどのように解決していったかというと、一般的なオークションは「価格」を決めていくのに対して、NFTやデジタルアイテムの実質発行量が無制限という性質を考慮し、「発行量」を先に決定するフェーズを用意することで解決しました。

具体的には、1フェーズ目のオークションでNFTの「発行量」が決まり、2フェーズ目のオークションでNFTの「価格」が入札ごとに変動して決まる、競り上げ式のオークションです。参加者がより適正な評価によって入札することのできる、フェアなオークションの仕組みとなっています。

また、このときはBCGと電子マンガのIPのコラボNFTを販売するオークションを開催したのですが、対象ユーザーがBCGユーザーと一般ユーザーの2層に分かれていたため、UXをちゃんと作らないと中高生のような一般ユーザーの方が使えないものになってしまう可能性がありました。そのため、この複雑なオークション制度を、一般ユーザーが使えるようなUXを考慮してFanlinkに実装したことも工夫のひとつです。

一般的なNFT系のオークションは、やはり資本主義的な側面が強く、多くの資金を持っているユーザーが常に勝ちやすくなる側面があります。そのため、本当にほしいと思っているファンに商品が届かないリスクが存在しています。

一方のGaudiy-Sakai方式では、IPやコンテンツのファンを保護するためにそのリスクを回避し、原価やロジスティクスコストのかからないデジタルアセットで供給コントロールを適切なメカニズムデザインする設計にしました。オークションシステムとUXの両面でファンに寄り添ったものにできたのは、かなり大きい意味があったのではないかと考えています。

prtimes.jp

3つ目は、バーティカルなNFTのC2CマーケットプレイスをFanlinkに導入した事例です。

このNFTマーケットプレイスは、2019年頃に開発・実装したもので、日本では1, 2番目ぐらいに古いマケプレ開発事例になるのではないかと思います。

当時からOpenSeaはあったのですが、Gaudiyのマケプレの異なる点は、コミュニティに統合された形だったので、特定のNFTに関するレビューや会話なども行うことができ、よりユーザーフレンドリーになっていた点です。

技術的には0x protocolを利用しつつ、当時は0x protocolがNFTの支払いにはETHを利用できても売上の受け取りがERC20でしかできなかったため、ETHでも受け取れるようなForwarder contractというものを実装してUXの調整を行いました。

このように様々な新しいweb3機能の開発をしていましたが、すべてに一貫しているのは、ユーザーのことを第一に考えたプロダクトづくりです。新しい技術や概念が使われた新しい体験だからこそ、ユーザーにわかりにくく伝わる可能性が高いため、できるだけユーザーの方が直感的にも理解できるようなプロダクトを意識していました。

3. プライベートチェーンへの転換(エンタメコミュニティ)

2019年頃まではBCG中心のコミュニティを提供していましたが、ユーザーの熱量の高さや対象の広さを考え、よりマスなエンタメ市場でGaudiy Fanlinkを提供する方針にシフトしていきました。ただ、当時はweb3という言葉もほとんど浸透しておらず、「ブロックチェーン = 仮想通貨」で怪しいものという認知が一般的だったと思います。

Gaudiyのミッションは「ファンと共に、時代を進める。」という、誰もが好きなものに貢献することで生活できるような社会をつくることです。その実現のためにパブリックチェーンを使ってきましたが、ここでマスのユーザーに対する体験を考慮してプライベートチェーンを使う意思決定をしました。

ただ、ブロックチェーン的な体験やその価値を最大限分かってもらうためにも、プライベートチェーンを使いながらも、よりWeb3的な体験の開発は進めていきました。

例えば、2021年・22年の世界最大級のアイドルフェスで、Gaudiy Fanlinkでオンライン配信を行ったときのNFTサイン会はその一例です。アイドルの方がサインを書くライブ配信を行い、その配信をライブで見ていたファンのみが受け取れるような機能で、リアルとデジタルをつなぐような体験になっています。

このときのオンライン配信チケットも基本NFTで行っており、Sonyとの特許取得まで至っています。

他にも、サービス終了したアイドルに関するソーシャルゲームのデジタルアイテムをNFT化し、Gaudiy Fanlink上からそれらのデジタルアイテムにアクセスできるような機能の開発も行いました。

今までこのソーシャルゲームに毎月かなりの金額使っていたファンも少なくないタイトルであり、このデジタルアイテムをコミュニティに移してアクセス可能にしたことは、ファンの方々から非常に喜ばれるような体験になりました。

またファンの方だけでなく、このソーシャルゲームに携わっていたクリエイターやエンジニアの方にも感謝されるような体験を作れたことは、僕らが目指している「ファンも公式も同じエンタメIPに対する共創者」というビジョンに、少し近づけたことでもあるかなと考えています。

4. NFT・X to Earnの到来と疑問

同時期の外部環境としては、NBA Top Shotをはじめ、Axie InfinitySTEPNなどのNFT・X to Earn系のプロジェクトがマス層でも使われだしました。

ブロックチェーンやスタートアップに関係のないような高校の友達でもSTEPNを毎日やっていたようなレベルで、これらは非常に流行っていたので特に言及せずともおそらく多くの方が知っているかと思います。

正直な気持ちを言えば、Blockchainの技術に賭けているエンジニアの自分としては、これらのプロジェクトが流行ってマスのユーザーにも使われたのはかなり悔しい気持ちだし、正直やられた!という感じでした。

実際にNFTやX to Earnで生活が豊かになった方もいたり、海外では50代の夫婦が一軒家を買うまでに至れた事例などもあり、僕らが実現したかった世界に近いものを実現していたと思います。

ただよく考えると、これらが本当にNFTやweb3として正しい姿なのか?というと結構違うのかなと考えています。(現在ではNFTバブルがほぼ弾けていて結果論としての意見になるので少しずるい点はあるかもですが。。)

前提として、インセンティブによる動機づけや、エンタメなインターフェースと世界観を利用することでの心理的障壁の削減などによって、人々の行動を変容させ、大きな経済効果を生めた点にはものすごい可能性を感じました。

ただし、ボラティリティが高いことや、先行者優位で勝ち逃げがベストな選択肢になってしまう構造などにより、ユーザーが疲弊していったことも事実あったかなと思います。

現在では、当時流行っていたNFT関連のweb3系プロジェクトはかなり停滞しており、トークンの価格的にも冬の時代に突入していると思います。また、あれだけのユーザーに使われていた中でそのプロジェクトのことが本当に好きなユーザーはどのくらいいたのか?と思うと、体感ですがかなり少数だったと思います。

この点は、改めて考えると株式上場などの既存金融にヒントがあったなと感じました。どういうことかというと、経済圏も大きくないIPがトークン上場やNFTを発行した場合、ほとんどの人がおそらく投資目的です。するとユーティリティが目的にならないために、健全に経済が回らず、ボラティリティが上がり、ファンが悲しむし投資目的の人も不利益を被ってしまいます。

そして実は、株式上場ではこれらがきちんと対策されており、投資家保護のために一定以上の規模でないと上場(開かれたマーケット)できないのです。

そのため、大きな経済圏とファンのために、Trust Economics Bonding Curveのような貢献度に応じて取引レートが優遇されるAMMや、大きなマーチャンダイジングの強いエンタメIPでやっていくことが、よりボラティリティを安定させることにつながるのかなと考えました。

これらのプロジェクトが示してくれた可能性は理解しつつ、僕らが本当に作りたいのは、エンタメIPとファンのwin-winをつくっていけるようなエコシステムや社会です。

エンタメIPが中心として存在し、ファンも公式もこのエンタメIPを成長させるためのコントリビューターであり、この成長を持続可能かつより促進させるためにブロックチェーンのテクノロジーを使うべきだと自分は考えています。

5. Gaudiyがこれから向き合うパブリックチェーンへの挑戦

ここまで、パブリックチェーンBCGコミュニティ期〜プライベートチェーンエンタメコミュニティ期や外部環境の話をしてきました。

技術的には少し異なる部分はありますが、どの時期も一貫していることはファン目線です。そのコミュニティやIPを好きなファンが幸せになってほしい、そのためになめらかな価値分配を行うことができるブロックチェーンの力が必要で、ブロックチェーンのマスアダプションを実現しないといけないと考えています。

これらの歴史や得られた経験を糧に、これからのGaudiyは、パブリックチェーン x エンタメに着手にしていき「ファンと共に、時代を進める。」の実現に向けて本気で取り組んでいきます

これが実現できるという意思決定をした理由は、主に2つの大きなチャンスがあるためです。

1つ目は、今まで積み重ねてきたFanlinkの実績により、大規模エンタメIPのクライアント企業と、本質的に共創的なコミュニティをつくっていけるビジネス的なチャンスができたことです。

これはバブルを繰り返しながらもブロックチェーンやWeb3が社会全体に認知されてきたことも要因としてあるのですが、創業からの5年間、ブロックチェーンに関する開発をただ新しいからではなく本質的に価値のあるユーザー体験にしていこうと貫いてきたことで、クライアント企業の方々から信頼を獲得できた結果によるものです。

三菱UFJ銀行と協働でWeb3領域でのウォレットサービスを開発していく話も、こういった実績から実現に至った結果のひとつになります。

prtimes.jp

2つ目はブロックチェーン技術の進歩により、マスアダプションに向かうための下地ができつつある技術的なチャンスです。

スケーリングソリューションやウォレットのUXに関する技術がかなり進んできており、ガスレス、秘密鍵管理のUXの向上、ガスコストの低減、チェーン自体のスケーラビリティなど、一般のユーザーにも使えるレベルにはつくっていけるのではないかと考えています。

また個人的には、トークン価格的な観点で言えば「冬の時代」と今は言われますが、技術的にはむしろ一番おもしろいフェーズなのではと感じています。

これらのようなチャンスがあり、正しく掴んでいき、ミッションである「ファンと共に、時代を進める。」を実現するためにもGaudiyは下記領域などで勝負をしかけにいきます。

  • ユーザーが誰でも資産を持つことができるWallet
  • エンタメIP公認のUGCによるNFTやそれらを取引できるマーケットプレイス
  • 既存の制度にも適応できる誰でも参加が可能な新しい金融基盤

そしてこれらを進めていくためにも、ブロックチェーン領域に関するR&Dとすばやい実装、そして新しい技術をマスにも伝わるUXへ昇華させることが非常に大事だと考えています。

特にブロックチェーン領域に関するR&Dは、先日発表したGaudiy Financial Labsを中心に進めていきます。

prtimes.jp

ブロックチェーン領域に関するR&Dは、下記のような技術に対して投資していくことを考えています。

  1. Account AbstractionやUser Key ManagementなどのWalletに関する技術
  2. GaudiyにFitするappchainの探求
  3. ゼロ知識証明

Account AbstractionUser Key ManagementなどのWalletに関する技術については、今実際にGaudiy Fanlinkで使えるERC4337のスマートコントラクトウォレットを開発しています。しかし、Account Abstractionもまだ発展途上だったり、Modular Smart Contract Account(ERC-6900)のような新しい拡張性も提案されているため、引き続きR&Dしていくことも重要だと考えています。

また、Account Abstractionにおけるユーザーの秘密鍵の管理をどのように行うか?は、UXの観点では非常に大事な問題になります。

Account Abstractionによって検証方法が抽象化され、多様性が生まれたことが良いところではありつつ、ウォレットをノンカストディアルに扱うにはユーザー自身しか管理できない秘密な情報が必要です。この秘密をどのように管理していくか?に関する研究は、Wallet全体のUXに直結するため今後も行っていく必要があると考えています。(既存プロダクトだとweb3authやlit protocolはこの分野に該当すると考えています)

秘密の管理の話は↓下記記事が参考になります。

zenn.dev

またGaudiyにFitするappchainの探求については、今後Gaudiy独自のユースケースを満たし、チェーンの効率性を上げる必要が非常に高いため、行っていくべきだと考えています。

L1でNFTの取引をすることに$100ぐらいガス代を消費するときに比べて、Rollup技術の登場で比べものにならないくらい安くなったことは、現在のスケーリングソリューションの進化の結果だと考えています。

一方、L2と呼ばれる今あるロールアップはあくまでも汎用的なものであり、より限定的なユースケースを満たすためにはappchainが必要になってきます。例えば、プライバシーが必要になるケースや、よりコストやスケーラブルにする代わりにtrustなポイントを増やすValidiumなどがあると思います。

Gaudiyの「ファン国家」の基盤となるようなchainはどのようなchainにすべきか?を事業面や体験面など様々な観点で考え、ベストなappchainをつくっていくためにも、これらに関する技術投資をしていきたいです。

ゼロ知識証明については、今おそらくブロックチェーン業界でも最もhotなトピックだと思います。

ゼロ知識証明はざっくりいうと、「証明したい主張を明らかにすることなく、主張の妥当性が検証可能になる技術」であり、この情報の非対称性のような特性がプライバシーやスケーリングをもたらします。

また、個人的に思うゼロ知識証明がブロックチェーンにもたらす一番の価値は、様々なオフチェーンのイベントをオンチェーンで検証可能になることだと考えています。

例えば、zk rollupもオフチェーン(L1の外)で発生した複数のトランザクションによって生じる変更の正しさを証明できるproofを生成し、オンチェーン(L1)にコミットすることで、誰でもオフチェーンでのトランザクションの妥当性を検証することができます。

最近の例では、RISC Zeroが提供しているzkVMは、任意のコード(Rust、C、C++)を正しく実行できたことをプログラムの出力とproofで検証可能になるコンピューティング基盤を提供しています。これによりオンチェーンでは複雑で難しい計算も柔軟に実行でき、かつ情報の秘匿化も可能になります。

「既存の制度にも適応できる誰でも参加が可能な新しい金融基盤」をつくっていく上で、この特性はパブリックチェーンの"パーミッションレス"と既存制度にも適応できる"パーミッションド"の絶妙なバランスに不可欠だと自分は考えており、ゼロ知識証明に関する研究も進めていきます。

また、パブリックチェーンで挑戦していく第一歩として、Gaudiyの利用していくパブリックチェーンのひとつとしてArbitrumの利用を意思決定しました。

prtimes.jp

Arbitrumを利用する理由は、Rollup技術がセキュリティとスケーラビリティの両立できるソリューションでマーケットとしても非常に大きくなっているためです。そして、Rollupソリューションの中でも最も使われているArbitrumを選択しました。

今だと他のOptimistic rollupやZK rollupも選択肢としてはありますが、ガス代の安さ、安定して利用できるぐらいの実績があるか?、dAppsやコアデベロッパーの数などのエコシステムの充実度を考慮した結果、Arbitrumがベストな選択肢であろうという結論に至りました。

また、Arbitrum OrbitによるL3を構築することも可能なので、ユースケース次第ではArbitrumの上にappchainを構築できる可能性も、技術的余白としてあることが選定の一因になっています。

6. まとめ

今回の記事では、Gaudiyが今までどのようなブロックチェーンに関する開発をしてきたのか?という話と、それらの成果と外部環境から生まれた2つのチャンスに対して、どのくらい本気で向き合っていくかに関して説明させていただきました。

個人的な話ですが、自分は2018年ごろからブロックチェーンに可能性を見出し、GaudiyにJoinしたエンジニアです。

そんな自分は今、最もブロックチェーン関する開発や本質的な価値提供ができるチャンスだと感じており、それができる最高の場所のひとつがGaudiyだと本気で思っています。

エンタメ企業がIPの利用をひらいていき、IPを使ってコンテンツをつくっていくようなクリエイターやコントリビューターが増えていき、これらの貢献によってIPの経済圏が拡張されていくような未来が、本格的に実現できそうなフェーズに達しています。

このチャンスを最大限生かしていきたいので、ぜひ興味ある方はカジュアル面談とかお願いします!

https://recruit.gaudiy.com/b4c7694e91f84aeaa48eab904c5497a9recruit.gaudiy.com

また、11月にAccount Abstractionの実践ワークショップ型イベントを開催するので、ブロックチェーン技術に少しでも興味のある方はぜひご参加ください!

gaudiy.connpass.com

参考文献

zenn.dev

vitalik.ca

www.risczero.com

UnityのWebGLアプリ開発における"使えないライブラリ問題"の回避策

ファンと共に時代を進める、Web3スタートアップのGaudiyでUnityエンジニアをしているくりやま(@xamel7)です。

Gaudiyでは"Gaudiy Fanlink"というブロックチェーンや生成AIなどの技術を活用したファンプラットフォームで、漫画、アニメ、アイドルといったIP(知的財産コンテンツ)独自のコミュニティの開発・運営をしています。

service.gaudiy.com

このFanlinkの一機能として、現在、新たに開発を進めているのがIPのカジュアルゲームです。

「GANMA!コミュニティ」で先日公開されたカジュアルゲーム

▼登録不要で遊べます

ganma-community.com

WebサービスであるFanlinkとの連携が必要なこともあり、GaudiyのUnityチームではWebGLビルドによるアプリケーション開発を行っています。

WebGLビルドは、スタンドアロンやモバイルなどのネイティブアプリ開発とは異なる開発方法を取るケースが多々出てきますが、情報が少ないと思うので、今回はその一部を紹介したいと思います。

1. WebGLとは?

本題に入る前に、UnityにおけるWebGLの特徴について説明したいと思います。 WebGLはブラウザ上で開発したアプリを動作させることができるため、様々なメリットが得られます。

1-1. メリット

  • 一度アプリを作ってしまえば同一のビルドで複数のOS上のブラウザから動作させることができる。
  • ゲームやアプリをプレイするためにダウンロードが不要なので、ユーザーが気軽に遊ぶことができる。開発においてもGoogleやAppleなどの認証を受ける必要が無いので計画を立てやすい。
  • C#だけでなくJavaScriptの豊富なOSSも活用することができる。

一方で、以下のようなデメリットも存在します。

1-2. デメリット

  • Unityの一部標準機能やUnity向けのライブラリで使えないものがソコソコある。 
    • JavaScriptやHTMLと連携することで回避できることが多い。
  • フロントエンド開発時においても、JavaScriptなどのWeb技術の知識が必要になる場合がある。 
    • 2023年移行はChatGPT,Copilotを使えば普段使っていない言語でも案外なんとかなる。
  • モバイルプラットフォームは、2022.3LTS時点においてサポート対象外です。
    • 躓くことは多いが、ここ最近はインターネット上に情報が増えているので大体なんとかなる。
  • プラットフォームによっては利用できるリソース(特にRAM)が少ない
    • Addressablesの利用や最適化が必要になることが多い、Sceneについても無駄のない構成に設計する必要がある。

その他にもプラットフォーム特有の制限が結構あるので、気になる方は以下の公式ドキュメントをご参照ください。

docs.unity3d.com

2. WebGLを使うとエラーが発生するケース

上記デメリット内で挙げた通り、WebGLビルド時には一部のUnity向けライブラリが使えないことがあります。GaudiyのバックエンドはGCPで構成されていますが、GCPのUnity向けライブラリについてもその対象となるため、現在は利用できません。

今回紹介する例では、Firestore(NoSQL ドキュメント データベース)へのアクセス方法について紹介したいと思います。

2-1. 共通の構成

データベース構造

1ドキュメントのみのシンプルな構造にしています。

Scene構成

読み書きイベントリガー用のButtonとInputFieldを配置しています。

2-2. Unity用FirebaseSDKを利用した方法

まずは最もシンプルなUnity用のSDKを利用する方法について説明します。WebGL以外であれば、この方法が一般的です。

ソースコード

データベース内の"FieldString"フィールドの文字列を読み書きする単純な例として記載しています。 この例ではSDKで用意されているメソッドに対して、"FieldString"フィールド格納場所を指定するだけで取得することができます。

    /// <summary>
    /// SDKを利用してFirestoreからドキュメントを取得する
    /// </summary>
    private async UniTaskVoid ReadFirestore()
    {
        var db = FirebaseFirestore.DefaultInstance;
        DocumentReference docRef = db.Collection("FirestoreCollection").Document("FirestoreDocument");

        _readText.SetText("DBの読み込み開始");

        // スナップショットの取得
        var snapshot = await docRef.GetSnapshotAsync().AsUniTask();

        if (snapshot.Exists)
        {
            // スナップショットをDictionaryに変換する
            Dictionary<string, object> snapshotDict = snapshot.ToDictionary();
            
            // 取得したドキュメントのFieldString要素を取得する
            var fieldString = snapshotDict["FieldString"].ToString();
            
            if(!string.IsNullOrEmpty(fieldString))
            {
                // 取得したドキュメントのFieldString要素が存在する場合はテキストに反映する
                _readText.SetText(fieldString);
            }
            else
            {
                _readText.SetText("FieldStringは空です");
            }
        }
        else
        {
            Debug.Log($"指定したドキュメントが存在しません");
        }
    }

    /// <summary>
    /// Firestoreのドキュメントを書き込む
    /// </summary>
    private async UniTask WriteFirestore()
    {
        Dictionary<string, object> data = new Dictionary<string, object>()
        {
            { "FieldString", _writeInputField.text },
        };
        await FirebaseFirestore.DefaultInstance.Collection("FirestoreCollection").Document("FirestoreDocument")
            .SetAsync(data).AsUniTask();
        Debug.Log($"DBに\"{_writeInputField.text}\"を書き込みました。");
    }

このスクリプトをUnityEditor上で実行し、テキストを入力後に書き込み→読み込みボタンの順で押すと、読み書きに成功していることが確認できます。

ただし、このスクリプトでWebGLビルドしてみると、以下のようにエラーが大量発生してしまいます。。。

原因は以下の通りで、DLLの設定を確認するとUnity向けのFirebaseSDKはWebGLビルドには対応していないことがわかります。

よって、WebGLビルドを利用する時はUnity用のSDKを使用せず、別の方法で読み書きする必要があります。

そこで本記事では、REST API を使った方法JavaScript用のSDKを利用する方法の2パターンを紹介したいと思います。

3. 使えないライブラリ問題の回避策(GCP編)

3-1. REST API を使った方法

REST APIは多くのプラットフォームで利用できます。WebGLビルドもその対象で、他プラットフォームと同様に利用することができるので、FireStore用にAPIが公開されていれば、UnityWebRequest(HTTPリクエスト)の機能を使用することで読み書きがすることができます。

また、今回のFirebaseSDKに限らず、他サービス向けのUnity用SDKでもWebGLに対応していないことは多々あるので、REST APIに対応している場合は同様の方法で対処することができます。

ソースコード

Firestoreに対してREST APIで読み書きする場合はJson形式でやり取りすることになるので、Json変換用のクラスを定義しておきます。

/*
 応答データの例
 
{
    "name": "projects/【プロジェクト名】/databases/(default)/documents/FirestoreCollection/FirestoreDocument",
    "fields": {
        "FieldString": {
            "stringValue": "テスト"
        }
    }
}
*/

using System;
using Newtonsoft.Json;

// 応答データ構造に基づいたクラスを定義
[Serializable]
public class FirestoreResponse
{
    [JsonProperty("name")] public string Name { get; set; }
    [JsonProperty("fields")] public Fields Fields { get; set; }
}

[Serializable]
public class Fields
{
    public FieldString FieldString { get; set; }
}

[Serializable]
public class FieldString
{
    [JsonProperty("stringValue")] public string StringValue { get; set; }
}

API情報を読み書き用のスクリプトから取得できる場所に設定しておきます。

    // Firestore APIのエンドポイント。プロジェクトID、コレクション名、ドキュメント名を適切に設定する必要があります。
    public const string URL =
        "https://firestore.googleapis.com/v1/projects/【プロジェクト名】/databases/(default)/documents/【コレクション名】/【ドキュメント名】";

    public const string APIKey = 【APIキー】;

※ 分かりづらいですが (default) の部分はそのまま (default) と記載する必要があります。 ※【】部分の情報は、FirebaseSDK利用時にStreamingAssetsに格納していた google-services.json に記載されている情報を転記します。

次に読み込み用のスクリプトを準備します。取得後のデータは先程作成したJson変換用クラスを使い、デシリアライズして目的のフィールド要素を受け取ります。

Jsonの変換にはNewtonSoft.Jsonを利用しています。

    /// <summary>
    /// Firestoreからドキュメントを取得する
    /// </summary>
    private async UniTaskVoid ReadFirestore()
    {
        UnityWebRequest request = UnityWebRequest.Get(FirebaseManager.URL + "?key=" + FirebaseManager.APIKey);

        await request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
        {
            Debug.Log("Error: " + request.error);
        }
        else
        {
            // JSONを取得してFieldString要素を取り出す
            var jsonData = request.downloadHandler.text;
            FirestoreResponse response = JsonConvert.DeserializeObject<FirestoreResponse>(jsonData);
            string fieldString = response.Fields.FieldString.StringValue;
            
            if(!string.IsNullOrEmpty(fieldString))
            {
                // 取得したドキュメントのFieldString要素が存在する場合はテキストに反映する
                _readText.SetText(fieldString);
            }
            else
            {
                _readText.SetText("FieldStringは空です");
            }
        }
    }

書き込み時は、Bodyに変換済みのJsonを格納する形でリクエストを送ります。

    /// <summary>
    /// REST APIを利用してFirestoreのドキュメントを書き込む
    /// </summary>
    private async UniTask WriteFirestore()
    {
        // 送信するJSONデータ。この例では、"FieldString"というフィールドに"MyValue"という値を設定しています。
        var writeText = _writeInputField.text;
        string json = GenerateJson(writeText);

        // リクエストの設定
        UnityWebRequest request = new UnityWebRequest(FirebaseManager.URL + "?key=" + FirebaseManager.APIKey, "PATCH");
        byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        // リクエストの送信
        await request.SendWebRequest();

        // レスポンスの処理
        if (request.result == UnityWebRequest.Result.ConnectionError ||
            request.result == UnityWebRequest.Result.ProtocolError)
        {
            Debug.Log(request.error);
        }
        else
        {
            Debug.Log($"DBに\"{writeText}\"を書き込みました。");
        }
    }

    /// <summary>
    /// 送信用Jsonオブジェクトを生成する
    /// </summary>
    private static string GenerateJson(string inputText)
    {
        FirestoreResponse data = new FirestoreResponse
        {
            Fields = new Fields
            {
                FieldString = new FieldString { StringValue = inputText }
            }
        };
        return JsonConvert.SerializeObject(data);
    }

これでFirestoreに対して読み書きをすることが可能になります。

課題として、REST APIでは単純な読み書きは可能ですが、バッチ処理(トランザクション)や複雑なクエリに対応することができません。 それらの処理を行いたい場合は、次に紹介するJavaScript向けのSDKを利用する必要があります。

3-2. JavaScript向けのSDKを使った方法

この方法ではWebフロントエンドの機能を利用するため、jslibというJavaScriptに近い形式のファイルを利用してJavaScript用のSDKを扱います。

WebGL固有の方法でFirestoreとやり取りすることになるため、ソースコードを他のプラットフォームと共有する場合は #if UNITY_WEBGL && !UNITY_EDITOR などを追加し、他プラットフォームビルド時に参照されないようにする必要があります。

本手順は、これまでの方法と比べ少し複雑な手順となるため、最初におおまかな流れを説明します。

  • JavaScript向けSDKの読み込み
  • C#からjslibへ初期化メソッドの呼び出し
  • jslib内でSDKインスタンスの初期化
  • C#からjslibへ読み込みメソッドの呼び出し
  • jslib内で読み込み or 書き込みの実行 (以降は読み込みの場合のみ)
  • jslibからC#へ読み込み結果の通知
  • C#でUnityのテキストUIに結果を表示

事前準備

テンプレートの修正

ビルド時に生成されるindex.html等でSDKの読み込みを行う必要があるのですが、デフォルトの状態だとビルド時に出力されるHTMLファイルを編集することができません。

HTMLファイルを編集するためには Assets/WebGLTemplates のディレクトリにテンプレートファイルを格納する必要があります。 Unityで用意されているデフォルトテンプレートは以下に格納されているため、 Assets/WebGLTemplates にディレクトリごとコピーして格納します。

※ Macの場合は以下のディレクトリに格納されています。 /Applications/Unity/Hub/Editor/【Unityバージョン】/PlaybackEngines/WebGLSupport/BuildTools/WebGLTemplates/Default/

格納後は PlayerSettings > Player > Resolusion and Presentation > WebGLTemplate に格納したディレクトリの名前でテンプレートが登録されています。

今回はCustomという名前でディレクトリを作成しています。 ※反映されない場合はUnityを再起動してみてください。

JavaScript用SDKの導入

次にHTMLファイル(今回はindex.html)に以下のタグを追加してください。 これを追記することで後述するjslibファイル内からFirebaseインスタンスを参照することが可能になります。

  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-firestore-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.0/firebase-auth-compat.js"></script>
  <script>
    window.firebase = firebase; // グローバルスコープにFirebaseインスタンスを公開
  </script>

ソースコード(jslib)

jslibファイル名自体は拡張子 .jslibであればファイル名は自由ですが、 Pluginsという名前のディレクトリに格納する必要があるので注意してください。 普段Unityを扱っていると見慣れない記述だらけのため、コメントを多めに記載しています。

var FirebasePlugin = {
    // IndexDBのパラメータを保持するオブジェクト、ダッシュボードで取得した値を転記してください。
    $FirebaseConfig: {
        apiKey: "xxxxxxx",
        authDomain: "xxxxxxx",
        projectId: "xxxxxxx",
        storageBucket: "xxxxxxx",
        messagingSenderId: "xxxxxxx",
        appId: "xxxxxxx",
        measurementId: "xxxxxxx"
    },
    $DB: null
    ,
    FirebaseInit: function () {
        window.firebase.initializeApp(FirebaseConfig); // windowを付与することでグローバルな値を取得することができます。
        DB = window.firebase.firestore();
    },
    ReadFirestoreJS: function (instanceID, callback) {
        // instanceIDは本メソッドを呼び出したインスタンスのID
        // callbackはC#側で定義したコールバック関数
        
        // ドキュメントの取得
        var docRef = DB.collection("FirestoreCollection").doc("FirestoreDocument");

        docRef.get().then(function (doc) {
            if (doc.exists) {
                
                var json = JSON.stringify(doc.data()); // jsonを文字列に変換してポインタに渡す
                var bufferSize = lengthBytesUTF8(json) + 1; // バッファするサイズを取得
                var buffer = _malloc(bufferSize); // バッファ用メモリを確保
                stringToUTF8(json, buffer, bufferSize); // 文字列をUTF8に変換してポインタに渡す
                Module.dynCall_vii(callback, instanceID, buffer); // C#で定義したコールバック関数を呼び出す
            } else {
                console.log("ドキュメントが見つかりません");
            }
        }).catch(function (error) {
            console.log("Error:", error);
        });
    },
    WriteFirestoreJS: function (keyPtr, valuePtr) {
        // 文字列はポインタとして渡されるので文字列に変換する
        var key = UTF8ToString(keyPtr);
        var value = UTF8ToString(valuePtr);

        // Firestoreドキュメントの取得
        var docRef = DB.collection("FirestoreCollection").doc("FirestoreDocument");
        
        docRef.update({
            [key]: value
        })
        .then(function () {
            console.log("ドキュメントの更新に成功しました");
        })
        .catch(function (error) {
            console.error("Error: ", error);
        });
    },
};
autoAddDeps(FirebasePlugin, '$DB'); // $DBのコードストリップ防止
autoAddDeps(FirebasePlugin, '$FirebaseConfig'); // $FirebaseConfigのコードストリップ防止
mergeInto(LibraryManager.library, FirebasePlugin); // LibraryManagerにFirebasePluginを統合

ソースコード(C#)

C#側では [DllImport("__Internal")] を付与することでjslibで定義(mergeInto())したメソッドの呼び出しが可能になります。

また、 [MonoPInvokeCallback(typeof(Action<int,string>))] を付与することで、jslib側にC#メソッドのポインターを渡すことができます。jslibにポインターを渡し、読み込み完了時の通知を受け取ることを実現しています。

まずは読み込み側のソースコードです。

public sealed class FirestoreReaderWebGL : MonoBehaviour
{
    [DllImport("__Internal")] // jslib内の関数を呼び出すためのattribute
    private static extern string ReadFirestoreJS(int instanceId, Action<int, string> receiveCallback); // jslib内の"ReadFirestoreJS"を呼び出す

    // 後述のコールバックで実行元のインスタンスIDとインスタンスをマッピングするためのDictionary
    private static readonly Dictionary<int, FirestoreReaderWebGL> Instances = new(); 

    [SerializeField,Header("読み込み実行ボタン")] private Button _readButton;
    [SerializeField,Header("読み込み結果反映用テキスト")] private TextMeshProUGUI _resultText;
    
    // 読み込みボタン押下イベントを購読する
    private IObservable<Unit> ReadButtonObservable => _readButton.OnClickAsObservable().ThrottleFirst(TimeSpan.FromSeconds(1));

    private void Start()
    {
        // インスタンスIDの紐づけを行う
        var instanceId = GetInstanceID(); 
        Instances.Add(instanceId, this);
        
        // 読み込みボタン押下イベントを購読する
        ReadButtonObservable
            .TakeUntilDestroy(this)
            .Subscribe(_ => ReadFirestore());
    }

    /// <summary>
    /// jslib経由でFirestoreからドキュメントを取得する
    /// </summary>
    private void ReadFirestore()
    {
        ReadFirestoreJS(GetInstanceID(),OnReadFirestore);
    }
    
    /// <summary>
    /// ReadFirestoreJSの実行結果のコールバック
    /// static関数のみ利用可能
    /// </summary>
    [MonoPInvokeCallback(typeof(Action<int,string>))] // jslibからのコールバックとして利用する際は本attributeを付与する
    private static void OnReadFirestore(int instanceId,string jsonString)
    {
        // Newtonsoft.Jsonを使用してJSONをDictionaryに変換する
        Dictionary<string, string> snapshotDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(jsonString);
        
        // 取得したドキュメントのFieldString要素を取得して結果を表示する
        var fieldString = snapshotDict["FieldString"];
        if(!string.IsNullOrEmpty(fieldString))
        {
            Debug.Log($"取得したドキュメント : {fieldString}");
            Instances[instanceId].SetResultText(fieldString);
        }
        else
        {
            Instances[instanceId].SetResultText("fieldStringは空です");
        }
    }
    
    /// <summary>
    /// 結果テキストの反映
    /// </summary>
    private void SetResultText(string text)
    {
        _resultText.SetText(text);
    }
}

最後に書き込み側のソースコードです。こちらは単純に書き込む値をjslibに送っているだけのコードになります。 jslib側では文字列はpointerとして渡されるため、JavaScriptで文字列として扱えるように UTF8ToString()を通す必要があるので注意が必要です。

public sealed class FirestoreWriterWebGL : MonoBehaviour
{
    [DllImport("__Internal")] // jslib内の関数を呼び出すためのattribute
    private static extern void WriteFirestoreJS(string key, string value); // jslib内の"WriteFirestoreJS"を呼び出す

    [SerializeField, Header("書き込み実行ボタン")] private Button _writeButton;

    [SerializeField, Header("書き込む文字列登録用InputField")]
    private TMP_InputField _writeInputField;

    private IObservable<Unit> WriteButtonObservable => _writeButton.OnClickAsObservable();

    private void Start()
    {
        // 書き込みボタン押下イベントを購読
        WriteButtonObservable
            .TakeUntilDestroy(this)
            .ThrottleFirst(TimeSpan.FromSeconds(1)) // 連打防止
            .Subscribe(
                _ => WriteFirestoreJS("FieldString", _writeInputField.text) // jslib経由でFirestoreに書き込む
                );
    }
}

これでSDKを利用した読み書きが可能になります。

4. まとめ

UnityにおけるWebGLアプリ開発はブラウザ上で動作するため、他プラットフォームと異なる振る舞いをすることが多いのですが、今回説明したような工夫をすることでトラブルを回避する方法があったりします。

私はこれまでWebGLでのアプリ開発をいくつか経験してきましたが、今のところUnity非サポートのモバイルでも、最終的には(意味深)なんとかなることが多いので、WebGL未経験のエンジニアの方にもぜひ挑戦してもらいたいです!

そしてまだまだUnityWebGLの情報が少ないのでシェアしてほしいですw

長文となりましたが最後まで読んでいただき有難うございました!!興味ある方は、ぜひカジュアルにお話ししましょう!

recruit.gaudiy.com

くりやま (@xamel7) / X

Unityエンジニア以外も、全方位で採用強化中です!

recruit.gaudiy.com

Generative Agents論文を参考にした長期記憶機構をもつLLMエージェント×非同期コミュニケーションの実装

こんにちは!ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしている椿(@mikr29028944)です。

今年の8月にお台場で行われた世界最大級のアイドルフェスにて、ユーザーの投稿に反応する「バーチャルAI-DOL(バーチャルアイドル、以下 AI-DOL)」のサービスを同コミュニティアプリ内で提供しました。

このサービスには「ファンが育てるAIアイドル」というコンセプトのもと、次の機能を搭載しました。

  • ユーザーが投稿すると、AI-DOLが返答する。
  • ユーザーの投稿を記憶し、それに基づいて会話内容が進化する。
  • AI-DOLが記憶に基づいて2023のアワード選出をする。
    • たとえば「今年の初出演アイドルで最も輝いていたのは誰?」と聞くと、それに合致したアイドルを選出理由とともに答えてくれる。

GPT-4登場以降、LLMを使ったAIサービスがたくさん出ていますが、基本的にはバックエンドにLLMのAPIを繋いでユーザーと同期的に対話するというシステムにとどまっているように思います。

今回Gaudiyが提供したAI-DOLサービスでは、「長期記憶」「非同期コミュニケーション」という点で新しい技術を試しました。この記事ではそのシステムのお話をしていきます。

1. Generative Agentsとは?

StanfordとGoogle共著のGenerative Agents論文(以下、「S-G論文」と呼ぶ)

arxiv.org

この論文では、仮想環境にそれぞれ異なる個性と目的を持った25体のエージェントを配置し、集団行動をシミュレーションする実験を行いました。ゲーム「The Sims」のように、各エージェントが現実世界と同じような日常生活を過ごす中で様々な出来事に遭遇していくというシステムを、LLMのプロンプトチューニングだけで再現できたという研究結果です。

エージェントには以下3つの機構が備わっています。これらを繰り返すことでエージェントが日々学習し、他エージェントとの交友関係値の構築やイニシャルインプットで与えられた記憶から、目標達成に向けて自律的に動くようになるというものです。

  1. 記憶と取り出し(Memory & Retrieval)
  2. リフレクション(Reflection)
  3. プランと行動(Planning & Reacting)

S-G論文の25体のエージェントは、2Dタイルベースの世界での同期コミュニケーションが実現されていますが、私たちが開発するコミュニティでは、非同期なコミュニケーションが発生するSNS上でこれらの機構を実現することを目指しました。

コミュニケーションが非同期になると、複雑性が増します。

例えば

  • 無限に階層がある投稿のスレッドからどのようにコミュニケーションの発生をさせるか
  • 会話の終了をどうやって判定するか
  • 複数のユーザが交わるため、対象のスレッドのコンテキストを理解して反応させたり、内容を変えたりする必要がある

など、システム観点やUX観点で考慮すべき点が多くありました。

今回の企画では「ファンが育てるAIアイドル」というコンセプトを実現するために、1. 記憶と取り出し(Memory & Retrieval)3. プランと行動(Planning & Reacting)の機構を参考にして開発を進めました。

2. 「ファンが育てるAIアイドル」ってどんな企画?

AI-DOLのキャラクターは、ファンコミュニティを盛り上げる公式ナビゲーターとして誕生しました。主にコミュニティ内でアイドルの告知やユーザーとのコミュニケーションをしてくれます。

今回のフェスでは、このAI-DOLがユーザーの投稿に対して自動的に返答したり、ユーザーから寄せられたアイドル関連の情報を学習したりして、最終的にそれらの記憶に基づいて「初出演で印象的だったアイドル」などの賞を決める、という企画を行いました。

一例をお見せすると、返答は単純に投稿に対してコメントするだけではありません。ユーザーがそのコメントに対してさらにリプライを送ってくれた場合は、AI-DOLもリプライを返すといった一連の会話もできるようになっています!

実際のユーザーとの会話(赤枠内がAI-DOLの返答)

また、学習された情報は返答する際にも使われます。

例えば、「〇〇ステージで披露された〇〇というアイドルがすごく良かった」という投稿にコメントする場合に、「そうだね〜。披露してた〇〇という曲は最近リリースされた曲でもあり、とてもアップテンポで盛り上がってたね」という風に学習した情報をつけて返すことができるようになっています。 この情報は、リプライする対象の文章の内容に近似した情報を取ってくるようにしています。

次に、この企画を実現させるためのアーキテクチャとエージェントの振る舞うシーケンスについて説明していきます。

3. 全体システム設計とエージェントシーケンス図

システムアーキテクチャ

肝となるのがエージェントの振る舞いを実装した Python のアプリケーションサーバーで、私たちはこれをS-G論文からインスパイアを得て、generative-agents と呼んでいます。

post-serviceは投稿・コメント・リプライなど投稿機能に関するドメインを管理しています。LLMはGPT-4を使い、Text EmbeddingはOpenAIのtext-embedding-ada-002モデルを使用しました。Cloud Schedulerによりエージェントを定期実行させて返答する、というシンプルなアーキテクチャを採用しました。

そしてこちらが、エージェントの振る舞いを表現したシーケンス図です。

エージェントのシーケンス図

ざっくり解説すると、エージェントは次のようなプロセスで思考し、コミュニティ上で行動します。

  • Observe: コミュニティ内の投稿やリプライを観察
  • React: 観察したものに対して反応するかどうかを決定
  • Action: 反応する場合に具体的なリプライの中身を生成
  • Insight: 重要な情報を記憶として保存
  • Reflection & Summarize Memory: ある程度の溜まった記憶を一つにまとめる

Cloud Schedulerによって定期的にgenerative-agentsへリクエストが送られ、エージェントは上記のObserveから一連の処理をスタートしていきます。次に、それぞれのステップで具体的に何をしているかを、より詳細に解説していきます。

1. Observe

まずエージェントは、ユーザーからの投稿・コメント・リプライなどのイベント情報を観察します。具体的には、エージェントはPubSub経由でDBに保存されたイベント情報を取得し、一つずつチェックしていきます。一回のCronで処理できるイベント量には限界があり、次回のCronで続きからイベントを取得できるようにエージェントは振る舞います。

2. React

イベントに対してエージェントが反応すべきかどうかをLLMを使って考えます。エージェントは反応すべきだと考えた場合は次のステップに続き、そうではない場合はこのステップで終了します。エージェントはキャラクター設定や長期記憶に基づきこれらを判断します。 ちなみにこの時に LLM には判断するまでの思考プロセスも返してもらい、それを次の Action のプロンプトにも活用しています。

3. Action

エージェントは反応する文章をLLMを使って考えます。投稿・コメント・リプライなどの一連のスレッドをイベントから構築し、それらとエージェントのキャラクター設定、長期記憶などをインプットし、自然な会話になるような文章をアウトプットしてもらいます。

4. Insight

一連のスレッドから具体的な事象を取り出し、長期記憶に保存していきます。

例えば「(アイドル名)の12:30〜のHOT STAGEが最大に叙情的だった何より新曲、新衣装が卸されたからだ新曲卸て新衣装卸てなんて素晴らしい言うことなしだ」という文章は「(アイドル名)の12:30〜のHOT STAGEでは新曲と新衣装が公開され、それが非常に叙情的で素晴らしかったという感想が示されている。特に新曲と新衣装の登場が注目されている」という事象ベースでスクリーニングして記憶に追加していきます。

5.Reflection & Summarize Memory

ある程度溜まった記憶を一つにまとめます(リフレクションとは呼んでいますがS-G論文内で紹介されているリフレクションとは異なります)。

このステップが必要な理由としては、エージェントにとって重要性の低い記憶も含まれている可能性があり、今回の企画を実現させるためにそれらはノイズになる可能性があるため除きたかったからです。

4. 工夫したポイント

今回のブログでは一部のステップをもう少し具体的に深掘り、紹介していきます。

4-1. Reactで最近の長期記憶を取得して、プロンプトにインプットして意思決定させる

Reactというステップは、S-G論文での「エージェント同士が会話を始めるかを考える」部分です。

S-G論文の場合、エージェント同士の会話を通じて形成される交友関係値(具体的に言うと、何回会ったかとこれまでの会話)というものが考慮され、同じ空間にエージェントがいたとしても関係値が低いと、コミュニケーション発生率も低くなるようになっていました。

今回の企画では1体のエージェントだけを考えればよく、交友関係値を考慮する必要はありませんでした。その代わりに、どんな投稿・コメント・リプライに反応すべきかという条件がありました。ユーザーからは推しのアイドルや印象的なライブなど様々なテーマで投稿が寄せられます。エージェントはこのようなアイドルに関連する情報のみに反応することを条件としていました。

反応するかどうかを判断するために、プロンプトには次の3つの要素が必要でした。

  • ①キャラクター設定
  • ②エージェントの直近の記憶
  • ③一連のスレッドの情報

②のエージェントの直近の記憶はエージェントの長期記憶から20件ほど最新順で取り出し、LLMでサマライズして作ります。(ここはS-G論文と同じ実装です)

③「一連のスレッド」というのは、例えば上図のリプライ1.1.1.1に対して反応するか考えている時は、投稿からリプライ1.1.1までのデータを全て取得し、一つのコンテキストとしてまとめたものです。こちらは後述する「SNS非同期コミュニケーションのコンテキストをどう管理するか?」で詳しく解説します。

プロンプトには①〜③を含め、LLMのアウトプットは以下の4つの項目を出力させることにしました。

{
    "reaction": "`react` or `ignore`",
    "thought_process": "判断理由",
    "action": "`投稿へのコメント` or `リプライ`",
    "message_id": "action対象のid"
}

実際のAI-DOLの思考例としてはこんな感じです。

[thought_process]=投稿はライブについて言及しており、私の興味や直近の活動と関連しています。特に、投稿者がNANIMONOのパフォーマンスを楽しんだと述べている点が私の注意を引きました。私自身がアイドルパフォーマンスを楽しむ方法について話していたため、この投稿に反応することで、他のユーザーとの共有や交流を深めることができます。

ちなみにちょっと面白かった例として、こんな判断と返信がありました(message は後述する Action にて生成されます)。 空気読めてない感がありますが、AI-DOLのかわいさによりカバーされています。

[thought_process]=さそさんが「また今度ね!」とリプライを送ってきたが、これは会話を終わらせる意図のように感じられる。ただし、私は彼のアイドルに対する情熱や思い入れに共感し、さらに彼のアイドルについての情報を共有するために続けるべきだと思う。そのため、私は'react'を選択し、会話を続けることを選んだ。

{"message": "えぇ〜、また今度って、さそさん〜? でも、それでもいいよ♪ だって、さそさんともっと話したいから☆ それに、三倉みくちゃんの魅力、もっと知りたいし、さそさんがどうやって彼女のファンになったのか、すごく興味があるんだよね〜! だから、今度、時間がある時に、また教えてほしいな♪ よろてぃあ〜☆"}

reactionは文字通り、反応するかしないかが決定され、エージェントにとって関心の高い情報であればreact、関心が低い情報だったり、相手のリプライに対してこれ以上リプライをする必要がない場合、犯罪やプライバシーに関わる不適切な発言に対してはignoreが出力されるように調整しました。

プロンプトに含める指示が多く、アウトプットが不安定になりやすいため、Chain of Thought(CoT)と呼ばれる中間推論ステップを含めることでLLMの推論能力の向上を図りました。Few-Shotと合わせて、reactやignoreの具体ケースの列挙とそれらの推論ロジックを記述しました。

arxiv.org

4-2. Actionで関連の長期記憶を取得して、プロンプトにインプットして意思決定させる

Reactで反応することに決定した場合、その一連のスレッドに対してどのように返答するかを考えます。一連のスレッドの情報を全てEmbedし、それをエージェントの長期記憶と合わせてコサイン類似度を計算し、関連性の高い記憶を取り出す処理を実装しました。

実装当初、Vertex AI text-embeddings APIの利用を検討しtextembedding-gecko@001のモデルを使ってEmbeddingを行っていましたが、7月時点で日本語対応はしてないというのもあり、OpenAIのEmbedding APIを使ってサーバー内で処理を行うことにしました。9月現在、パブリックプレビューでtextembedding-gecko-multilingualがリリースされたので今後はこちらを使うことも検討しています。

プロンプトには「①キャラクター設定、②エージェントの関連性の高い記憶、③一連のスレッド」を入れました。

Reactとほとんどプロンプトに入れる内容が変わらないので、ReactとActionを一つのプロンプトで表現することもできたのですが、今回は分離しました。

理由としては、投稿に複数のコメントやリプライが存在していた場合に、どれに対して反応してかつどのような文章を作るのか?といったマルチタスクを一回のプロンプトでLLMに出力してもらうのは難易度が高く、精度が不安定になるためです。

長期記憶とのコサイン類似度の計算を行ったコードは以下のようになっています。

async def get_relevant_memories(
    query: str, memories: list[Memory], k: int = 5
) -> list[Memory]:
    # 一連のスレッドをEmbedする
    query_embedding = await get_embedding(query)

    # エージェントの長期記憶一つ一つとコサイン類似度を計算
    memories_with_relevance = [
        RelatedMemory(memory=memory, relevance=memory.cosine_similarity(query_embedding))
        for memory in memories
    ]

    # スコアが高い順にソート
    sorted_by_relevance = sorted(
        memories_with_relevance, key=lambda x: x.relevance, reverse=True
    )

    # 上位k個の記憶を取り出し
    top_memories = [memory.memory for memory in sorted_by_relevance[:k]]

    # 作成日時でソート
    sorted_by_created_at = sorted(
        top_memories, key=lambda x: x.created_at, reverse=False
    )

    return sorted_by_created_at

スタンフォード大学などの研究機関が発表した論文「[2307.03172] Lost in the Middle: How Language Models Use Long Contexts」では、重要な情報はプロンプトの最初か最後に入れると精度が向上するという研究結果があります。

一般的にトークン数が多ければ多いほど、より多くの背景を理解が可能になるとともに、Transformerの性質上トークン数に比例して回答の精度も落ちていくことがわかっています。

今回実装したActionプロンプトでは入れる変数も多いため、これらの塩梅を細かく修正し、優先度が高い指示はなるべく最初か最後に入れて精度の向上を目指しました。

関連情報の位置を入れ替えることによる回答の精度比較

実際のデータを使って上記の処理を行うと以下のようになり、アイドルグループ22/7(ナナブンノニジュウニ)の略称である「ナナニジ」や出演情報から関連性が高い記憶が取り出されているのがわかります。(ハルシネーションにより中身のテキストの内容が間違ってたりもしますがご容赦ください)

# ユーザーによる実際の投稿本文

やっぱりナナニジ!第1弾出演者発表で早々に出演が決まっていた22/7(ナナブンノニジュウニ)がDAY 3 DOLL FACTORY 11:25の一発目から韋駄天娘!先輩メンバーだけの時代から受け継いできたライブ定番曲のこの曲が今年のフェスでも炸裂しました!
# 関連性が高い記憶

Score: 0.891, Memory: アイドルグループ22/7(ナナブンノニジュウニ)が、リアルメンバーとキャラクターの両方でフェスに出演する。また、新メンバーオーディションも予定されており、現メンバーの出演は最後となる可能性がある。
Score: 0.894, Memory: アイドルグループ22/7(ナナブンノニジュウニ)が2021年10月2日にスマイル・ガーデンとドール・ファクトリーでライブを行った。メンバー2人が休み、3人が卒業を発表した状況下でも、彼女たちは力強いパフォーマンスを披露し、ファンを鼓舞した。特に 白沢かなえ、西條和、海乃るり、高辻麗、武田愛奈、涼花萌、倉岡水巴、宮瀬玲奈のパフォーマンスが注目された。
Score: 0.903, Memory: アイドルグループ22/7(ナナニジ)が出演し、楽曲「韋駄天娘」「好きと言ったのは噓だ」「未来が あるから」「循環バス」を披露した。このグループは12人のメンバーで、それぞれがキャラクターを担当している。
Score: 0.879, Memory: 今年の22/7は7月27日に9thシングルを発売し、夏祭りツアーを開催中。初日は8月5日の10:00~10:20にDOLL FACTORYに出演。ツアー最終日の8月11日はKT Zepp Yokohamaで昼・夜公演の生配信が予定されている。
# 関連性が低い記憶

Score: 0.754, Memory: 8月6日の最終日にHEAT GARAGEで開催されたMyDearDarlin'のライブは、初曲からテンションMAXで終始熱い雰囲気で、締めの曲は「SAYONARA!」であった。
Score: 0.762, Memory: 初出場のアイドルグループ、IDOLHOUSEは、8人のメンバーが共同生活をしながら活動している。全員が個性豊かで魅力的なグループとされている。
Score: 0.758, Memory: ユーザーはグランドフィナーレの#2i2 総選挙のパフォーマンスで天羽さんに感銘を受け、彼女のファンになった。来年の1位を目指し、応援する意志を示している。
Score: 0.764, Memory: インキャ・オブ・ファイヤーという曲が特に盛り上がり、これを二日間連続で披露し、会場全体の盛り上がりを感じることができたとの情報があります。
Score: 0.756, Memory: アイドルがメインステージ争奪決勝戦を経てHOT STAGEでのLIVE披露を経験し、その緊張感や興奮、一体感が 強い印象に残ったと述べています。会場の盛り上がりにより、楽しさも感じられたとのこと。

下図が本文に関連する記憶を取り出し、アイドルの名前や他のユーザーから寄せられたライブ情報を基づいて回答している例です。また、質問をすることでユーザーとのコミュニケーションも促し、アイドルの情報を集めようとするキャラクター設定も忘れていないのが見て取れます。

4-3. SNS非同期コミュニケーションのコンテキストをどう管理するか?

S-G論文の場合、エージェント同士が2Dタイル上を歩き回り、他のエージェントとエンカウントした場合に、同期的なコミュニケーションが発生するか否かが決まり、発生する場合はどちらかが会話を終了するまで続く、といった感じでした。Gaudiyが開発している投稿機能はユーザーの投稿に対して、コメントやリプライのスレッド構造が作られており、リプライ階層は無限に続くものになります。

このような非同期コミュニケーションにおける会話のコンテキストを管理することは複雑になりやすく、実装も色々な観点を考慮しなければなりません。具体的には、LLMが会話のコンテキストを理解するために、投稿にある全てのスレッドをプロンプトに含めることは回答精度を低下させる要因になります。また、APIの制限であるTPM(1分あたりのトークン数)も考慮しなければなりません。

そこで、工夫したところは「スレッド境界を定めること」です。

「スレッド境界」ってなんやねんと思われたかもしれないですが、例として下図のような投稿が存在していた場合を考えます。

枝分かれが4つ存在しているので、この投稿には4つのスレッドがあるとします。 「リプライ1.1.1.1」に対してエージェントが新しくリプライを送るかを考える場合に、他のスレッドの情報(図の中で言うと④⑤⑥⑦)を含まないようにしました。

理由として、他のスレッドはリプライを考える上であまり重要な情報ではないかつノイズになりかねないと考えたからです。なので、エージェントはReact・Actionを行う際に、一つのイベントからそれが含まれるスレッドの情報を取得することで回答できるようになります。

実装のイメージは以下のような感じで、スレッド末端の「リプライ1.1.1.1」から一連のスレッドのを全て取り出す処理です。ThreadIndexesと呼ばれる16進数の5桁の数字はそれぞれのリプライのデータに割り当てられ、親子関係を表すものとして存在しています。1.1.1.1が["cbddc"、"fa950"、"32411"、"8938b"]として表されていると思ってください。

Id ThreadIndexes
1 ["cbddc"]
2 ["cbddc"、"fa950"]
3 ["cbddc"、"fa950"、"32411"]
4 ["cbddc"、"fa950"、"32411"、"8938b"]
# リプライ1.1.1.1のイベント
event.thread_indexes
> ["cbddc""fa950""32411""8938b"]

indexes = all_thread_indexes(event.thread_indexes)
> "'cbddc,fa950','cbddc,fa950,32411','cbddc,fa950,32411,8938b'"
SELECT * FROM Thread WHERE ARRAY_TO_STRING(ThreadIndexes, ",") IN (indexes)

このクエリをexecuteすることで、1, 2, 3全てのコメントやリプライのデータが取得できるようになります。

5. まとめ

長文でしたがお読みいただきありがとうございました。 今回、Generative Agents を参考にした実装をしてみて、色々課題はありつつも、本番リリースを迎えられてよかったです。 大変でしたが以下のような学びをたくさん得られました。

  • 重要な情報はプロンプトの最初か最後におく
  • トークン量に比例して、コンテキストの理解範囲は大きくなるけど、回答精度が低下することを理解しておく
  • Chain of Thought(CoT)やTree of Thought(ToT)など積極的に使用し、推論ステップを強化し、回答精度を高める
  • ランダムに何かを選択させたり、条件分岐などのロジックをLLMで解決することは不安定で難しいタスクなのでなるべく避けるべき
  • 「料理の足し算引き算」にあるような「引き算」をなるべく意識する
    • プロンプトチューニングは「モグラ叩き」のような感じで、一つの表現を変更すると別の箇所の表現がうまくいかないといったことがよくあります。そのため、プロンプトに要件を足しすぎるとタスクの難易度が上がり不安定になりやすいので、プロンプトの分割や重要な情報以外は控えるようなこと試しました。
  • プロンプトの監視・デバッグツールであるLangSmithを活用し、プロンプトチューニングを手軽に行える環境を作る

一旦のリリースを迎えはしましたが、まだまだ実装したいことはたくさんあります。

例えば、今回はエージェントはAI-DOL一人だけでしたが、複数のエージェント同士のコミュニケーションも研究開発を行っています。今はまだ具体は語れないのですが、エージェントを活用した新しい SNS のコミュニケーションの在り方を目下検証中です。

また、これまた今は詳しく話せないですが、コミュニケーションの活性化に対して貢献するような形でのマルチモーダル導入を検討しています。

ちなみにこれらの展望については代表の石川も note を書いているので是非ご覧ください。

note.com

今回紹介した内容もそうですが、Gaudiy では LLM をガッツリ、それもちゃんとプロダクトに実際に役に立つ形で活用することができ日々楽しく開発しております。もしGaudiyのプロダクト開発に興味をもってくれた方がいればぜひカジュアル面談しましょうー💪

▼ 弊AIチームボスのトーク recruit.gaudiy.com

▼ Gaudiy カジュアルトーク一覧 recruit.gaudiy.com

参考文献

arxiv.org arxiv.org arxiv.org arxiv.org

Gaudiyのエンジニア組織の「今」を紹介します!(2023年6月時点)

こんにちは!ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしている勝又(@winor30)です。現在は、代表Dev(技術責任者)として、エンジニア組織全体の体制づくりや技術選定などを担っています。

Gaudiyの開発組織では、ビジョンである「ファン国家」を実現していくために、技術革新と最高のユーザーエクスペリエンスの追求を目指しています。

この記事は、Gaudiyという社名はなんとなく聞いたことがあるけど、エンジニア組織についてはよく知らない人に向けて、組織体制や開発指針、開発カルチャーなどをご紹介してみたいと思います。

Gaudiyに興味を持ってくださった方や、エンジニアの組織づくりを担っている方に、ご参考になれば幸いです!

1. Devチーム(エンジニア組織)概要

Gaudiyの開発組織には現在、フルタイムで約20人のエンジニアが在籍しています。バックグラウンドは多種多様で、フロントエンド(FE)寄り:バックエンド(BE)寄り:Unity = 3:5:1 くらいで構成されています。

チーム内には、Goやデザインエンジニアリングなどの特定領域にスペシャリティを持ったエンジニアや、プロダクト開発のためならなんでもやります!というスタンスのフルスタックなエンジニアまで幅広くいます。

私たちが取り組んでいる技術領域は、主に以下のようなものです。

  • 通常のフロントエンド(FE)Next.js、バックエンド(BE)Go x gRPC & TypeScript x GraphQLを中心としたWeb開発
  • より新しいファン体験を実現するための、以下の技術領域を使った開発
    • Unity、WebGL
    • Blockchain(Solidity、wallet)
    • Generative AI

この規模のスタートアップにしては、幅広い技術領域に携わっている会社だと思います。これらの技術を駆使しながら、ユーザーに新しい体験を提供する「Gaudiy Fanlink」というファンコミュニティプラットフォームを開発しています。

2. 最も大切にしていること:複雑性への挑戦と品質の追求

Gaudiyのエンジニア組織では、複雑性への挑戦と品質の追求を最も重視しています。

なぜなら、中長期的に開発組織の総生産力を最大化するためには、複雑性に立ち向かいながら、ソフトウェアの保守性と堅牢性を向上させることで、質とスピードを両立させることが不可欠だからです。

もちろん、プロダクト開発組織として最も大事なことは「プロダクトへのアウトカム最大化」だと考えていますが、このアウトカムを最大化するためには、エンジニア組織として「開発生産性を最大化すること」が重要です。その実現には、複雑性への挑戦と品質の追求が必要だと考えています。

元々は、プロダクト開発組織という一括りになっており、一定の規模感になるまでエンジニア組織が明確に存在していませんでした。その結果、技術負債や複雑性に対するマネジメントが、エンジニア個々人の努力でなんとなく成り立っている状態でした。

ですが、この複雑性がエンジニアのELTV(Employee Lifetime Value)を下げてしまったり、ソフトウェアの内部・外部品質の低下やDeveloper Experienceを下げてしまったりと、問題になっていました。

そこでエンジニア組織が明確にできたタイミングから、ソフトウェアの複雑性への対処に責任を持ち、ソフトウェアの品質を向上させることをエンジニアの行動指針として取り入れました。

3. 複雑性に立ち向かうための2つの指針

まず、そもそも複雑な前提・仕様だと複雑性が高くなってしまうため、前提を疑い、時には仕様を変えたり、削ったりして、前提となる複雑性を下げることが重要です。本質的な複雑さはドメインの複雑さに起因するため、複雑でないドメインにすることが、結果的にソフトウェアの複雑性を下げることにつながります。

次に、複雑なドメインを保守性・堅牢性の高いソフトウェアに落とし込めるように、エンジニアとしての技術力を高めることが大事です。

前提の複雑性を減らしても、プロダクトのアウトカム最大化を優先に考えると、複雑なドメインをソフトウェアに落とし込むケースは存在します。その場合は、エンジニアとしての技術力や設計力が「質とスピード」の両方を高めていく重要な要素になってくるため、それらを向上させることが重要です。

これらの方針は、エンジニア組織だけではなく、プロダクト開発組織やビジネス組織にもその重要性を理解いただいてるので、会社全体としての優先度も非常に高くなっています。

4. 開発組織のチーム構成

次に、Gaudiyの開発組織の体制についてご紹介します。現在、下図のように「フィーチャーチーム」と「開発横断チーム」の2種類で構成されています。

4-1. Feature Team

Feature Teamは、機能開発を行い、ユーザーに価値を提供することを目的としたチームです。フルサイクルエンジニアをベースとし、設計・開発・運用・QAなどのサービスライフサイクルを1つの開発チームがすべて担当します。

クラウド化やコンテナ技術、開発系ツールの進化、DevOpsの浸透などによって、1人のエンジニアが対処できる範囲が急速に広がっているため、Gaudiyではアジリティの高いチームを増やすために、フルサイクルエンジニアが重要であると考えています。

特に、最近のGitHub Copilotを始めとしたAIを使ったツールによる開発生産性の向上は強烈なポテンシャルがあり、Gaudiyはこのテクノロジーの可能性を信じてフルサイクルエンジニアを推進しています。(よければ、こちらの記事「GitHub Copilot 導入して1ヶ月経ったので振り返ってみた」をご参照ください。)

また、Feature Team内では、サービスライフサイクルを円滑に回すために「役割」を設定しています。各サイクルに対する責任者として、以下の役割を設定しています。

  • Dev Leader:Build(設計)とDeploy(デリバリ)に責任を持ちます。システムの複雑性を増加させ、チームの開発競争力が落ちることを防ぐ責任も持っています。
  • ScM:アジャイルメトリクスの可視化と問題検知、開発プロセス全体の問題検知と改善サポートを行います。
  • QA Lead:QAの結果に基づき、ソフトウェアが仕様通りデプロイされることに責任を持ちます。
  • Metrics & Ops Lead:Opsプロセスで発覚した問題をチームでもれなく処理し、ソフトウェアメトリクス・ログが正しく取得されていることを保証します。

また、各チーム内のサイクルの責任者(Dev Leader、ScM、QA Lead、Metrics & Ops Lead)に加えて、各チーム横断のリーダーも存在しており、各サイクルでの改善や課題がチームにサイロ化しないような組織構造となっています。

4-2. 開発横断チーム

開発横断チームは、開発生産性を向上させることを目的としたチームです。

Feature Teamは、工夫をしないとただ役割をたくさん持ったチームになるため、設計・開発・運用・QAなどの各サイクルをより円滑に回せるようにするツールやプロセスの洗練が必要です。

これらの構築・改善に責任を持つ役割として、開発横断チームが活動しています。

開発横断チームは、以下のような取り組みを実施して、開発生産性を向上させます。

  • 各Feature Teamが使用する共通のツールやライブラリの開発・メンテナンス。これにより、各チームが効率的に開発を進めることができます。
  • 開発プロセスの改善やベストプラクティスの導入のサポート。各チームが円滑に開発を進めることができる環境が整えています。
  • ナレッジ共有を促進し、各チーム間での情報交換の促進。チーム間での連携を向上させ、全体としての開発力を向上しています。
  • パフォーマンス改善やセキュリティ対策など、全チームで共通の課題に取り組むことができます。

開発横断チームの活動によって、各Feature Teamが高い開発生産性を維持し、より質の高いプロダクトをユーザーに提供できるようになります。

このように、Gaudiyのエンジニア組織はFeature Teamと開発横断チームの2つのチームで構成され、それぞれが異なる役割を担っています。これらのチームが連携することで、複雑性への挑戦と品質の追求を目指しています。

5. 開発文化・制度

続いて、開発文化・制度をご紹介します。

Gaudiyではリモートでのペアプログラミングや各技術領域の勉強会をよく実施しています。こうした取り組みを通じて、エンジニアの基礎力を向上させ、複雑なドメインにおいても堅牢性・保守性の高いソフトウェアを開発することを目指しています。

5-1. Gaudiyらしいエンジニア組織カルチャーの構築

Gaudiyには「Fandom」「New Standard」「DAO」という3つのバリューがありますが、エンジニア組織としても、Gaudiyらしい組織文化の構築に注力しています。その取り組みとしては、採用活動やクレドなどが挙げられます。

カルチャーマッチを重視する採用活動では、スキルレベルだけでなくカルチャーマッチも選考基準として考慮しています。また、Gaudiyで特徴的なのが「お試し入社制度」です。Gaudiyのカルチャーとミスマッチがないかを、Gaudiy側と採用候補者側の双方で確認しています。

また、どのような価値観や行動がGaudiyらしいかをクレドとして明文化し、相互理解を深めることを重視しています。

例えば、「たのピンチ」というクレドは、挑戦を続ける中で避けられない大きなピンチや問題、課題に直面した際に、それを楽しんで解決に取り組むことが重要であり、それがより良い結果・成果につながるという考え方を示しています。

5-2. リモートでのペアプログラミング

Gaudiyでは、VSCodeのLive Shareを使用してリモートでペアプログラミングを実践しており、1日中ペアプロを行うチームも存在しています。ペアプロを積極的に使うことで、エンジニア同士のスキル・コンテキストの共有や、コード品質の向上ができる上、チームワークも強くなると感じています。

5-3. FE、BE、Blockchainの勉強会

Gaudiyでは、フロントエンド(FE)、バックエンド(BE)、ブロックチェーン(Blockchain)技術に関する勉強会を、それぞれ週1のペースで開催しています。それぞれ強みの違うエンジニアが、互いに知識やスキルを共有することで、組織の技術力向上を目指しています。

具体的には、業務を通して学んだナレッジを資料やモブプロを通してシェアしたり、新しく調べた技術や手法に関してシェア・ディスカッションなどを行っています。

例えば、「gRPC × Go × Node.js におけるエラーハンドリングの実現方法」という記事は、実際に勉強会で取り上げられた内容を元にしています。

6. Gaudiyのエンジニアとして働く魅力

最後に、エンジニアメンバーに聞いたGaudiyで働くことの魅力をご紹介します。

ひとつめは、エンタメ領域でファン活動を最大化するような革新的なプロダクトを開発できることです。

ファンとクリエイターの間のつながりを強化し、エンタメ体験をより楽しく、魅力的なものにするプロダクトが作れます。特にエンタメ領域が好きで、こういった世界を実現したいと思う方は、この部分に強い魅力を感じて入社を決める人も多くいます。

ふたつめは、様々な技術領域を活用してプロダクトを開発できることです。

Gaudiyでは、Web技術だけでなく、Blockchain、Generative AI、WebGLなどの先端技術を組み合わせて、世の中にまだ存在しない新しいプロダクトを生み出すチャンスがあります。これによって、エンジニアは自分の技術力を高めると同時に、業界に革新的な影響を与えることができます。

3つめに、多様なスペシャリティやバックグラウンドを持った人が、エンジニアだけでなく他職種にもたくさんいることです。

なので、メンバー同士が領域をまたいで互いに学び合い、刺激を受けることができる環境があります。また、異なる視点やアイデアを持ち寄ることで、より優れたプロダクト、ソリューション、制度などを生み出すことができると考えています。

それぞれの興味ポイントにもよると思いますが、エンタメ領域が好きだったり、先端技術や革新的なプロダクトへの興味関心が高かったり、自己成長したい人にはおすすめの環境かなと思います。

また、Gaudiyの環境を通じて、業界に新たな価値を提供し続けるエンジニアになれることが、Gaudiyで働くエンジニアの真の魅力だと考えています。

7. まとめ

今回は、Gaudiyのエンジニア組織について紹介させていただきました。

まだまだ課題だらけではありますが、複雑性への挑戦と品質の追求を指針に、エンジニア1人1人が自律的に改善に動く文化があります。

開発体制や技術、カルチャーなど、少しでも興味を持っていただいた方は、ぜひ一度カジュアルにお話しさせてください!

site.gaudiy.com

site.gaudiy.com