Gaudiy Tech Blog

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

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人が自律的に改善に動く文化があります。

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

recruit.gaudiy.com

recruit.gaudiy.com

OpenAI API を使ったデザインからコード生成する Figma プラグイン

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

ここしばらく話題になっている、OpenAI が提供する ChatGPT を代表とした LLM。この記事では、そんな OpenAI の API を使って Figma からコード生成するプラグインを作ってみた過程を記していこうと思います。

先に背景をちょっとお伝えしますと、Gaudiy ではPSFに向けて、複数パターンのUI・機能を実際に提供しながら検証を回すことを予定しています。

すでに定義したコンポーネントはある程度使い回せるものの、ページ実装の試行回数の増加が見込まれる状況です。ここの作業効率化のために、コンポーネントをしっかり活用しながらも、ちょっといじればプロダクション利用可能な React コードを Figma から書き出すトライとして開発しています。

現状の肌感としては「デザインがしっかり構造的に作られている」「コンポーネントがデザインとコードで同期されている」状態においては、だいぶ実践投入できるレベルのコードを出してくれる印象です。

ですが、まだまだ課題はたくさんあり、それはこのプラグインの機能のみならず組織のデザインと実装のプロセスにも関わるものです。なので OSS にもしてみたので、ご興味ある方に Contribute いただいたり、働き方の知見の共有をしていけると嬉しいです。

github.com

とは言いつつ現状 GPT-3.5-turbo だとそんなにクオリティの高いコードを出してくれないので、GPT-4 が必要になります。GPT-4 API のアクセスがない方はお待ちいただくか、3.5 でいい結果の出るプロンプトを模索してみてください。

デモ動画

まず、実際どんなものなのかデモ動画を用意したので下記をご覧ください。

youtu.be

※動画ではさも一瞬で生成されたかのように見えますが、実際は1分くらいかかっています。

▼ 忙しい人のための静止画

基本的な作り

はじめに、ざっくりとした生成プロセスをまとめると次のような形です。 Figma のレイヤーを JSON に変換 -> それを OpenAI API に渡してコードを書いていただいてます。

デザインを OpenAI を通じてコード化するフロー図

Figma プラグインでは、Figma のレイヤーやコンポーネントなどの情報を参照することができます。 例えば下図は Frame のレイヤーから取れる情報の型定義です。Auto Layout 関連の情報などが取得できることが見て取れると思います。

Auto Layout の型情報
Auto Layout の型情報

ただ、これはそのままでは余計な情報が多いのと、Figma 専用の語彙になっていることがちらほらあり(primaryAxisSizingMode, counterAxisSizingMode の組み合わせなど)、OpenAI API さんではうまく解釈できないものがあるため、最初にCSSの語彙に直したオブジェクトに変換します。

あまり詳細は書いてもアレなのでざっくり概要のコードだけ貼ると、下記のように単純に Figma の node(レイヤー)の children を辿っていって、必要な情報だけ持ったオブジェクトに変換しています。

また、コンポーネントのインスタンスの場合はコンポーネントとして表示したいので、それ以上 children は深追いせず ComponentNode という個別の型のオブジェクトに変換しています。(https://github.com/kazuyaseki/figma-code-transformer/blob/main/src/figmaNode/buildTagTree.ts)

    node.children.forEach((child) => {
      if (child.type === 'INSTANCE') {
        const props = Object.keys(child.componentProperties).reduce(
          (_props, key) => {
            const value = child.componentProperties[
              key
            ] as ComponentProperties[string];

            // component property keys are named like this: "color#primary"
            // thus we need to split the key to get the actual property name
            const _key = value.type === 'VARIANT' ? key : key.split('#')[0];
            return { ..._props, [_key]: value.value };
          },
          {} as { [property: string]: string | boolean }
        );

        if ('Instance' in props) {
          delete props['Instance'];
        }

        childTags.push({
          name: child.name.replace(' ', ''),
          props,
          isComponent: true,
          children: [],
        });
        if (child.mainComponent) {
          componentNodes.push(child.mainComponent);
        }
      } else {
        const childTag = buildTagTree(child, componentNodes);
        if (childTag) {
          childTags.push(childTag);
        }
      }
    });

getCssDataForTag という関数はこんな感じでひたすら Figma のレイヤーからデータ取って CSS の語彙に変換しているだけです。

export function getCssDataForTag(node: SceneNode): CSSData {
  const properties: CSSData = {};
  if(!node.visible) return;

  if ('opacity' in node && (node?.opacity || 1) < 1) {
    properties['opacity'] = node.opacity || 1;
  }
  if ('rotation' in node && node.rotation !== 0) {
    properties['transform'] = `rotate(${Math.floor(node.rotation)}deg)`;
  }
  ...他にも色々

▼ 詳細なコード

github.com

ちなみに上記のコードは、昔自力で頑張って作ってた React コード生成プラグインから取ってきて拡張したものです。時を超えて役に立つエモい展開です。

github.com

あとはこうして作った JSON を元にプロンプトを作って書いてもらうだけです。

Convert this Figma JSON object into React, TypeScript, Tailwind code.

## Figma JSON
{ここにさっきの Figma の JSON を貼る}

## constraints
- component does not take any props
- do not omit any details in JSX
- Do not write anything besides code
- import components from @/components directory
- if a layer contains more than 1 same name child layers, define it with ul tag and create array of appropriate dummy data within React component and use map method to render in JSX
- use export rather than default export

以上、基本的な動作の解説でした。

余談: LLM はコード生成において何が革新的か

ちょっとここ数年「デザインからコード生成できないかなぁ。見た目の部分のコーディングしたくないよぉ」という思いから色々調べたり作ったりしてきて想いが乗っていることもあり、若干脇道にはそれてしまいますが LLM がその文脈においてどのような変革をもたらしたかを簡単に語っていきます。

以前、私はFigmaからReactコンポーネントを生成するプラグインを作成し、コミュニティに公開しました。 www.figma.com

このプラグインを作ったはいいものの、以下のような課題がありました。

  • 様々なフォーマットへの対応が非常に手間がかかる
    • 例えば上記のプラグインで Tailwind に対応させようとすると、各プロパティに対してクラス名の変換コードを真心込めて書く必要があります。
  • 既存コードとの競合
    • すでに実装されているページのデザインが新しくなった場合、既存のコードを無視して書いてしまうため、そのままでは使えない状態でした。
  • コードのコンポーネントとの違い
    • 一応上記プラグインでもコンポーネントをセットする機能とかは作っていたのですが、プラグイン専用に情報をインプットする手間が大きいのもあり、あまり現実的な解決策とは言い難い状況でした。

総じて頑張れば実装はできたとは思いますが、恐ろしく手間なのでさすがにやってられんなと感じていました。 そんな折に彗星の如く現れた LLM

プロンプトで

  • 「CSS は Tailwind で書いて」
  • 「これが既存コードなので、この部分は改変せずに書いて」
  • 「これがコードのコンポーネントなのでこれ通りに Props書いて」

と適切なデータと共にお願いするだけで、期待通りの品質のコードを書いてくれるようになりました。 ありがとう OpenAI、こんなに実装のハードルを下げてくれたのであれば、実現に漕ぎ着けるのに努力しない手はありません。

また、ユースケースと相性がいいなと思ったこととして、あくまでこの生成されたコードは後にエンジニアが手直しすることが前提です。

LLM のアウトプットは工夫のしようはあるのですが、どうしても回答にブレだったりハルシネーションが起きてしまいます。なので 90 点のアウトプットを出せればよく、あとは人間がそれを元に開発をロケットスタートできる、というコード生成のようなユースケースは、そういった LLM の特性とも相性が良いように思います。

工夫したこと

以上、基本的なロジックについて解説しましたが、これだけではまだまだ実践には足りない部分があります。

例えば次のようなケースには対応できていません。

  • コンポーネントの Props が Figma 上の Variants のプロパティを元に書いているため、コードだけに存在する Props が書かれない / デザインにだけあるプロパティが書かれてしまう
  • 既に実装したページのロジックを消してしまう
  • OpenAI API にはトークン長の制限があり、大きいレイヤーをコード生成してもらおうとするとエラーになる
  • デザイントークンを使った CSS になっていない

このような課題たちを解くために色々実装してみたので、一つ一つ紹介していこうと思います。

Props のサマリを渡してコードの Props と同じように書いてもらう

ゴールとしては、プロンプトに下記のように使っているコンポーネントのコード上の Props のサマリを渡すことによって「コンポーネントの Props が Figma 上の Variants のプロパティを元に書いているため、コードだけに存在する Props が書かれない / デザインにだけあるプロパティが書かれてしまう」という課題を解決します。

### Button component:

Props:
- variant?: 'primary' | 'secondary' | 'outlined' | 'basic' | 'success' | 'dangerous' | 'dangerousOutlined' | 'dangerousPlain' 
- size?: 'xsmall' | 'small' | 'medium' | 'large'
- subText?: string
- loading?: boolean
- disabled?: boolean
- rounded?: boolean
- startIcon?: ReactNode
- endIcon?: ReactNode
- children: ReactNode

そしてプロンプトに次の一文を足します。

- if Props summaries are provided for the components used in JSON, write props that are required in code props and omit props that only exist in Figma JSON

こうするとコードの Props に応じて Variants の値をうまいこと入れてくれるのと、デザインにしか存在しない Variant Property を書いたりしなくなります。

サマリの作り方

今回はこのサマリを作ってもらうのも OpenAI API にお任せしました。

まず GitHub API で該当のコンポーネントのファイルを取得します。 (ここでははデザインとコードでコンポーネント名が統一されていることを前提としています。)

const { data } = await octokit.repos.getContent({
      owner: 'gaudiy',
      repo: 'design-system',
      path: `packages/designed-components/src/components/${componentName}/${componentName}.tsx`,
    });

そして次のようなプロンプトでサマリを書いてもらいます。ちなみにこちらは gpt-3.5-turbo で十分なタスクなのでそちらを利用しています。

export const buildPromptForPropsSummary = (
  componentName: string,
  codeString: string
) => {
  return `Write Summaries of Props of ${componentName} component in the following code

## Code

\`\`\`tsx
${codeString}
\`\`\`

## constraints
- start with a sentence "{ComponentName} component:"
- write list of props with TS type
`;
};

こうやって作った Props のサマリをコード生成のプロンプトにも入れています。 割と端折って説明しましたが、コンポーネントの情報を取るところは、参照先が一つのディレクトリでは済まないケースがありリクエストの数が多くなってしまうため、もう少し柔軟に検索できるようになると嬉しいなと思っています。ここには Embedding を活用するか、もはやプラグインのコードに埋め込んじゃうか(この場合アプデをどう反映するかは未定ですが)を検討したいなと考えています。

大きいレイヤーの時は Chunk に分割してマージするようにする

前提として OpenAI API はトークン長により制限があります。厳密には違いますが、トークン長は文字数のようなもので、プロンプトが長過ぎるとエラーが起きてしまいます。

なのでそのままだと大量のレイヤーが含まれたページのコードを生成することができないのですが、そんな上限を気にしてプラグインを使いたくない...。 そんな気持ちから Chunk に分割して後でマージする実装をしました。

こんな感じで Tree を Chunk の大きい順にノードを並べて、トークン数が上限値に近い順から元の Tree から取り除いていく関数を作りました。(※現状同じツリー内のものを分割してしまうバグがある)

import {
  APPROX_TOKEN_COUNT_PER_CHARACTER,
  MAX_TOKEN_COUNT_FOR_CODE,
} from '../constants';
import { Tag } from './buildTagTree';

function getTokenCount(tag: Tag) {
  const stringLength = JSON.stringify(tag).length;
  return stringLength * APPROX_TOKEN_COUNT_PER_CHARACTER;
}

export const divideTagTreeToChunks = (tag: Tag): Tag[] => {
  let totalTokenCount = getTokenCount(tag);
  if (totalTokenCount < MAX_TOKEN_COUNT_FOR_CODE) {
    return [];
  }

  let result: Tag[] = [];

  // 全部の node を探索して {node, childIndex, parentNodeRef} の配列を作り、トークンカウントでソートする
  const nodes: {
    node: Tag;
    childIndex: number;
    parentNodeRef: Tag | null;
  }[] = [];
  const traverse = (
    node: Tag,
    childIndex: number,
    parentNodeRef: Tag | null
  ) => {
    nodes.push({ node, childIndex, parentNodeRef });
    if ('children' in node) {
      node.children.forEach((child, index) => {
        traverse(child, index, node);
      });
    }
  };
  traverse(tag, 0, null);
  nodes.sort((a, b) => getTokenCount(b.node) - getTokenCount(a.node));

  // トークンカウントがmaxChunkSizeより小さいもので多い順に chunk に追加していく
  nodes.forEach(({ node, childIndex, parentNodeRef }) => {
    if (totalTokenCount < MAX_TOKEN_COUNT_FOR_CODE) {
      return;
    }

    const tokenCount = getTokenCount(node);
    if (
      tokenCount < MAX_TOKEN_COUNT_FOR_CODE &&
      'id' in node &&
      parentNodeRef &&
      'children' in parentNodeRef
    ) {
      result.push(node);
      parentNodeRef.children[childIndex] = {
        nodeId: node.id || '',
        isChunk: true,
      };

      totalTokenCount = getTokenCount(tag);
    }
  });

  return result;
};

そしてプロンプトに子レイヤーが Chunk だった場合に ID だけ JSX に書き込むようにする命令を追加しました。

- if child is chunk, render it as --figmaCodeTransformer=nodeId, it will later be used to replace with another code

そうするとこんな感じで描画してくれます。

<div className="flex flex-col justify-start items-center p-16 gap-16">
          --figmaCodeTransformer=149:20062

そして、Chunk にした部分は JSX だけ書いてもらって、あとは手動で差し替えます。

export function getChunkReplaceMarker(id: string) {
  return `--figmaCodeTransformer=${id}`;
}

export function integrateChunkCodes(
  rootCode: string,
  chunks: { id: string; code: string }[]
) {
  let integratedCode = rootCode;
  chunks.forEach((chunk) => {
    if ('id' in chunk) {
      const replace = getChunkReplaceMarker(chunk.id);
      const regex = new RegExp(replace, 'g');
      integratedCode = integratedCode.replace(regex, chunk.code);
    }
  });

  return integratedCode;
}

// コードをマージするところ抜粋
const codes = await Promise.all([
  createChatCompletion(prompt, []),
  ...chunkPrompts.map((chunkPrompt) => {
    return createChatCompletion(chunkPrompt, []);
  }),
]);

const rootCode = integrateChunkCodes(
  codes[0],
  chunks.map((chunk, index) => ({
    id: 'id' in chunk ? chunk.id || '' : '',
    code: codes[index],
  }))
);

これで動くコードはできあがるのですが、フォーマットが美しくないので後日 prettier 動かす対応は入れようかなと思います。

ちなみにプロンプトでは JSX 部分だけ吐き出してもらう部分はこんな感じで指定しています。Write only JSX だけだと関数宣言の部分も含んじゃったので、例を入れると安定して出力してくれるようになりました。

- Write only JSX
for instance if the result code is like below:
\`\`\`
import { Hoge } from "hoge";
export const ExampleComponent = () => {
    return <div>....</div>
}
\`\`\`
Then output only
\`\`\`
<div>....</div>
\`\`\``;
};

既存のコードを渡すと差分だけ書いてもらう

これはシンプルにページの既存コードを渡して do not change previous code と指定すれば変わった部分だけ追加して出力してくれます。かしこい!

なので「既存コードを選ぶ」という体験をいい感じにするための機能を用意したい気持ちがあります。下記あたりを作れると大体のケースに対応できるのではという予感がしております。

  • テキストエリアに手動コピペ
  • 前に生成したことがあったらファイル名をプラグインに保存しておく
  • レポジトリのファイルをツリー状に表示して選ぶ

デザイントークンをクラス名に変換

今回 Gaudiy 社では Tailwind を使っているので、いわゆるデザイントークンを tailwind.config.js の中で指定している内容通りのクラス名に変換したいなと思っております。

まずはそのデザイントークンの情報を取る方法ですが、Figma ではスタイルという標準の機能Token Studio というスタイルで色やタイポグラフィ以外のものも定義できるプラグインの2パターンが大きくあります。

tokens.studio

とりあえず両方を対応してみます。

スタイルの方は色やタイポグラフィの値が指定されている場合は生の値を渡さず、スタイル名を渡すようにします。

function setColorProperty(
  fills: Paint[],
  colorStyleId: string,
  properties: CSSData,
  colorProp: 'background-color' | 'color'
) {
  if ((fills as Paint[]).length > 0 && (fills as Paint[])[0].type !== 'IMAGE') {
    const style = figma.getStyleById(colorStyleId);
    const paint = (fills as Paint[])[0];

    const color = style ? style.name : buildColorString(paint);
    properties[colorProp] = color;
  }
}

また、TokenStudio はありがたいことに [setSharedPluginData](https://www.figma.com/plugin-docs/api/properties/nodes-setsharedplugindata/) という他のプラグインでも値を参照できる形でデータを保存してくれているので、それを通して取得してみます。

function setFigmaTokens(node: SceneNode, properties: CSSData) {
  const tokenKeys = node
    .getSharedPluginDataKeys('tokens')
    // Omit "version" and "hash" because they are not tokens
    .filter((key) => key !== 'version' && key !== 'hash');

  tokenKeys.forEach((key) => {
    const value = node.getSharedPluginData('tokens', key);
    if (value) {
      // remove css that's represented by token
      if (key === 'itemSpacing') {
        delete properties['gap'];
      }

      properties[key] = value.replaceAll('"', '');
    }
  });
}

こんな感じで取得できたらプロンプトでは「なんかちゃんとした値入ってなかったらそれデザイントークンで Tailwind ではこういうネーミングルールだからよしなに変換してくれ」と指定しておくといい感じになります。

- if string value other than hex or rgb() format is specified for color property, it is design token vairable. it is defined with the name in kebab-case in tailwind.config.js
- if "typography" property is specified, it is defined in tailwind config as typography token that has multiple properties such as font-family, font-size, font-weight, line-height

GraphQL query をレイヤーに保存する

若干「デザインからコード生成」の文脈からは脇道逸れてしまうのですが、Gaudiy では GraphQL を使っており、fragment colocation の文脈でレイヤーやコンポーネントに fragment を保存 -> 生成する時にそれらをまとめた Query を発行、みたいなのできたら良さそうだねという会話があったため、試しに作ってみました。

例えばこうやって二つの違うレイヤーで Fragment を保存して

Fragment を保存する
Fragment を保存する

ルートのレイヤーでプラグインを開くとこのように配下にある Fragment を展開した Query が表示されます。

Fragmentが統合された Query になっている図
Fragmentが統合された Query になっている図

エディタには graphiql-explorer というライブラリを利用しています。

github.com

あまり中身は深く説明してもアレなので概要だけざっくりお伝えすると、この graphiql-explorer はスキーマの内容に応じてポチポチプロパティを選べて便利なのですが、Fragment の記述は対応しておりません。

なのでデータとしては Query として保存しておいて、見た目を表示したりプロンプトにデータを渡す時に Fragment の形に変換してマージするようにしています。

▼ Query を Frament に変換するコード

figma-code-transformer/convertQueryToFragment.ts at main · kazuyaseki/figma-code-transformer · GitHub

▼ Frament をマージするコード

figma-code-transformer/mergeQueryAndFragments.ts at main · kazuyaseki/figma-code-transformer · GitHub

それでレイヤー毎に Fragment や Query を setPluginData で保存して機能させています。 保存する機構は作れたのですが、もう一歩としてはここで書いた gql をプロンプトに使ってデータの繋ぎ込みまで書いてもらったり、ちょっとした工夫としては gql ファイルも一緒に PR 作れるといいのかなと検討しています。

ちょっとした手間を減らす機能

上記以外にもちょっとした手間を減らす工夫を用意したのでサッと紹介していきます。

Storybook も作成

ChatGPT さんはコンポーネントのコードを渡してあげれば Storybook も書いてくれます。

なので下記のようなプロンプトで Storybook も書いていただいてます。(gpt-3.5-turbo で可)

export const buildPromptForStorybook = (
  codeString: string,
  componentName: string
) => {
  return `Write storybook for the following component in Component Story Format (CSF).
\`\`\`tsx
${codeString}
\`\`\`
## constraints
- Do not write anything besides storybook code
- import component from the same directory
- do not have to write stories for componnents used in ${componentName}
`;
};

PR を作るところまでサポート

コードが出力されても、そこから

  • ブランチを切る
  • ファイルを作成
  • そこにコピペ

という手間がまだ存在します。せっかくならそんな手間も減らしたい...。という訳で GitHub API を使って PR を作るところまでサポートしています。

PR を作るコードはこんな感じです。

github.com

ちなみにブランチ名なども下記プロンプトにて ChatGPT さんに考えていただいてます。(gpt-3.5-turbo で可)

export const buildPromptForSuggestingBranchNameCommitMessagePrTitle = (
  codeString: string
) => {
  return `Suggest
- branch name
- commit message
- PR title
- component name
for the following code
\`\`\`tsx
${codeString}
\`\`\`
## constraints
out put as JSON in following property names, do not include new line since this string is going to be parsed with JSON.parse
{ "branchName": "branch name", "commitMessage": "commit message", "prTitle": "PR title", "componentName": "component name" }
`;
};

待ち時間に猫動画を表示する

GPT-4 の API はとても長いです。長いので工夫が必要です。

ただ chatCompletion API は stream で結果を逐次返してくれるので、なんだかんだ ChatGPT 本家のように1文字ずつ表示する方が残りどれくらいかかりそうかもなんとなく分かりますし体験としてはいいのかもと思い始めています。

今後の課題

以上、これまで実現してきたことを書いてきました。

ですがまだまだまだまだ大変なことはありますし、今回作ったものに対して正しい期待値を持っていただくという意味でも、現時点で私が認識している課題を赤裸々に列挙していきます。

パフォーマンス

これは割とどうしようもない感あるのですが、GPT-4 の API はとんでもなく時間がかかります。コード生成に関しては、レイヤーの量にもよりますが 40-120秒ぐらいかかる印象です。猫動画でカバーはしていますが、人は飽きてしまうかもしれません。

こればっかりは OpenAI さんに頑張っていただくしかないのですが、ここが課題として大きくなる場合には

  • 3.5-turbo でも解けるように分解する
  • 同じプロンプトや既に出力したレイヤー構造に対してはキャッシュする
    • これは割とがんばりがいある気がする
  • Web LLM の発展を祈る
  • 待ってる時間を有益に or 楽しく過ごせるような何かを用意する

また、現状はプラグインで実行していますが、デザイナーがフィーチャーのデザインを作り、デザインのリファクタをしたら Figma API を通してまとめて実行しておく、みたいなフローにするとかもありかもしれません。

簡単に構造的なデザインが作られるようにする

本プラグインを使っていく上でネックになるのが「デザインをしっかり作る」ことです。

今回 Figma のレイヤーのデータを元にして OpenAI API さんにコードを書いていただいており、Auto Layout が使われていないとレイアウトの情報が分からなかったり、変なレイヤー構造で作られていると(リストなものが一つのテキストで作られてたりね)、それがそのまま出力されるコードにも反映されてしまいます。

基本的にはデザイン自体のメンテナビリティにも繋がるものなのでしっかり作った方がいいとは思っているのですが、状況によってはそんなに頑張るのかグレーなところもあります。(それもこのコード生成ができるようになった文脈で、しっかり作ることに対するインセンティブが強まったとは思います)(デザイナー目線ではそのメリットは感じられないのでサイロ化されてダメかも)

ちなみに私が思う「しっかり作る」は以下のあたりです

  • Frame 関連
    • No Group, Just use Frame
    • Frame には基本 Auto Layout を使おう(本当に違う場合は除く)
  • コンポーネント関連
    • コンポーネントは命名規則に則って名付ける
    • Variants / Component Properties を全部使っておこう
  • スタイル or/and TokenStudio 全部ちゃんとつけよう
  • レイヤーの構造をコードと同じ形にしよう

この辺りができてたら大体いい感じなると思います。 これをサポートする案をいくつか考えてみます。

DesignLint の拡張

まずはなるべく人間に頼らない方法での解決を考えてみます。

Figma には既に Design Lint という「スタイルがあたってないよ」「規定以外の border-radius の値が入ってるよ」などを教えてくれるプラグインがあります。

www.figma.com

こちらですが OSS として公開されているので、Fork して独自のルールを追加したり機能を足すことができます。

github.com

結構お手軽で、例えば下記は Auto Layout がついていないフレームがあったら怒るルールを足したい場合は下記のような関数を追加して、

export function checkAutoLayout(node, errors) {
  if (node.type === 'FRAME' && node.layoutMode === 'NONE') {
    return errors.push(
      createErrorObject(
        node,
        'frame',
        'Missing Auto Layout',
        'Did you forget it?'
      )
    );
  }
}

controller.ts の Frame に対するチェック関数をまとめているところに追加するだけです。

  function lintFrameRules(node) {
    ...
    checkAutoLayout(node, errors);

    return errors;
  }

また、これだけだと検出しかできないので、eslint fix がごとく勝手に修正できる機能も追加していけば負荷も軽減していけるのかなと思います。

デザインリファクタをワークフローに取り入れる

詳しくは下記記事に記したのですが、デザインはそんなカチカチに作りたくない仮説検証のフェーズと、将来のメンテナンス性のため・コラボレーションのため構造化していくフェーズに分かれると思います。

note.com

後者の時に、しっかりとエンジニアにハンドオフするタイミングでデザインのリファクタをしてから「これは実装 Ready だよ」と伝える決まりにするとかいいかもしれません。

構造的に作らなくてもいい感じのコードを出力してくれるモデルを作る

あとはそもそも Figma をちゃんと作らなくてもちゃんとしたコードが出てくるようになんかできないかという方策です。

一つに OpenAI はいつか画像をインプットにできる、いわゆるマルチモーダルの API を提供するみたいなので、もしかしたら Figma の描画内容を渡してあげたらいい感じになるかもしれません。

また、私はまだ試してはいないのですが、下記のプロダクトが「レイヤー構造を修正することなく」高精度の HTML/CSS を出力することを謳っていたり、

front-end.ai www.figma.com

あとは Hugging Face にも色々あったりするので(下記は試してみましたが期待通りのクオリティではありませんでしたが)オープンのものでも何かしら出てくるかもしれません。

huggingface.co huggingface.co

こういったものを組み合わせると幸せになれそうです。 ちょっと ML 素人の私が自分で作るのを試すのはちょっと時間がかかりすぎるかもしれないので、これを見てる誰か、作ってくれ!!

スタイルレスな UI Kit を使う

Figma Community にはスタイルレスで構造のみを持った UI Kit が色々作られています。 例としては下記のようなものがあります。

www.youtube.com

tokens.studio

一方でこれに則って作る手法を学習するくらいなら、もはや Framer などのコードにエクスポートすることを最初からゴールにしているデザインツールを使った方がいいんじゃないか感がなくもないです。 www.framer.com

Figma で表現できないものはスタイルを付与できない

大体のものが実現できるようになった Figma ですが、頻出 CSS としてまだ足りない感があるものとしては次のものがあります。他にも細かいものは色々あるとは思いますが大きいものだとこれくらいだと思います。

  • flex-wrap
  • table layout
  • grid layout

情報がないものはないのでどうしようもなく、Figma に頑張っていただくしかありません。 幸い flex-wrap は Auto Layout v5.0 なるもので登場しそうで(下記はおそらく早めに権限をもらった方)

また、table に関しては既に FigJam で登場しており、これは Figma にもコピペして使えることからその内 Figma でもリリースされるだろうと予感しています。

www.figma.com

という訳で課題はありますが、デザインツール由来の実装のものとのズレはほぼほぼなくなるんじゃないかなと勝手に期待しています。

同じページで複数のフレームがある場合の対応

実際のデザインでこのプラグインを使い始めて「あーこれどうしよう?」と思ったのが "同じページで複数のフレーム" がある場合です。

例えば下記のように、同じページ内でローディング表示したりモーダルとかがにゅっと出たりして、デザインが複数のフレームで表現されることはよくあることです。

Home画面のデザインとHome画面にモーダルが表示されている図

人間が差分に対してだけプラグインを実行して手動コピペでもいいのですが、せっかくならフレームをまとめて選択したら状態に応じてだし分けるコードも書いてくれたらテンション爆上げだと思うので、何かしら実現方法を考えてみたいと思っています。

より良いデザインプロセスを問い続ける

ここまでガッツリこのプラグイン開発記について語ってきたのですが、正直な気持ちを吐露しますと、これを作っていて「そもそもデザインを Figma でやる必要はあるのか?」という問いはしっかり考えて行った方がいいなと感じました。

より具体的に言うと、「このきっちりデザインを作る作業はコーディングするコストと変わらないのでは」だったり「そもそもデザイン案を直接コードで表現してもいいのではないか?」という問いです。

一点目に関していうと、より良いコード生成のアウトプットのためにはデザインをより構造的に作ったり、メタデータの付与などを頑張りたくなるのですが、独自にデザインに対してルールを覚えるくらいなら諦めてコードで書いた方が良くね?という線引きはどこかで生じると思います。

「デザインからコードを生成する」という目的に捉われすぎて全体のプロセス改善に繋がらなくては本末転倒なので、「デザインをきっちり作る」ことはコード生成の文脈のみならず、デザイン自体のメンテナビリティだったりあるいは他の目的にもプラスに働くものであるかは、常に冷静に判断すべきでしょう。

一方別の話で、 LLM によってデザインプロセス自体が大きく変わる可能性にも柔軟に開いていたいなと考えています。

例えばですが、現状ではデザイナーがスクラッチで(コンポーネントやデザインシステムのルールに則ったりはあると思いますが)デザインを作っていってると思いますが、もしかしたら今後は作りたい仕様・ユースケースと既にあるデザインをインプットにしたら AI がデザイン案を出してくれて、それをちょっとチューニングして実装するUIデザインのプロセスが訪れるかもしれません。

そして、その時、AI がアウトプットするデザインは Figma のようなデザインツールで表現される必要はありません。実際にユーザが触れるコードで出力をしてしまえばいいのです。

Magician などの AI を活用した Figma プラグインの数々を作ってきた jsngr さんは既にそういった AI を前提にした新しい体験のデザインツールを作り始めているように見えます。

こういった新しい体験のデザインプロセスになった時、今この記事で私が解説した Figma を前提にしたコード生成プラグインは価値が無になりますが、サンクコストに縛られず、常により良いプロセスを模索していきたいなと思います。

おわりに

以上、ChatGPT を使ってコードを生成する取り組みについて語ってきました。

私個人としては長年の夢だった見た目部分のコードを書かなくてよくなる体験がグッと近づいてきた感がありワクワクする毎日です。

一方で課題もたくさんあるので Gaudiy で試していきたいのはもちろん、色んな企業の人でこちらを試していただいてより良いデザイン - 実装プロセス作りの知見を共有していけるとなおのこと楽しいかなと思い、こちらのプラグインを OSS として作りました。

ぜひお試しいただいて、良かったこと辛かったことをご共有いただけると大変嬉しいです。

そして最後に宣伝コーナーです。

Gaudiy では絶賛プロダクト開発人材を募集中です。会社としてLLMに投資しており、エンジニアもデザイナーも特に AI を活用した業務改善、所謂 AIOps にもかなり力を入れています。こういった取り組みにワクワクする方は楽しく働けるのではないかなと思います。私はまだ入社してないので多分。

recruit.gaudiy.com

それでは!

参考資料

www.figma.com community.openai.com www.pinecone.io

GitHub Copilot 導入して1ヶ月経ったので振り返ってみた

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

今年に入ってから、AIの話題が尽きることのない、楽しい日々を送っています。それにしても目まぐるしく発展していっていますね〜

Gaudiyもちょうど1ヶ月前にプレスリリースで発表しましたが、全社的にChatGPT PlusとGithub Copilotを導入して、時代の変化に合わせたプロダクト開発スタイルを模索しているところです。

prtimes.jp

(最近AIツールの模索にハマりすぎて3時に寝落ちる日々を送っています。)

Twitterでも、#GitHubCopilot使えます のタグをよく見かけますし、GitHub Copilotを使い始めている企業が続々と増えているようです。

ということで、今回は、GitHub Copilot導入後ちょうど1ヶ月の運用を振り返るためのアンケートを取ってみました。はたして、福利厚生は実際に使われているのでしょうか…? 

まだ導入に踏み切れていなかったり、検討している企業のご参考になれば嬉しいです!

1. みなさんGitHub Copilot使ってますか?

約1ヶ月前、3月16日に、開発メンバー全員のアカウントに対して、Github Copilotの権限を一斉に付与しました。

実際のSlackでの呼びかけ

Slackで全体に告知しただけで、はたして使われているのでしょうか….?

アンケートに回答してくれた12人のうち、11人が使っているようです。

ちなみに「使ってない」と回答したエンジニアの理由は、

とのことでした。(このzcheeさん↓は実際にめちゃくちゃコード書くの速いですw)

2. 使ってみてどうですか?

次に、実際に触ってみた感想をみてみましょう。まずはメリット面から。

Copilot(副操縦士)とは言い得て妙というところで、実装者が本来より頭を捻るべき箇所に対して、コンテキストを集中できるようにサポートしてくれる点が、やはり効果として実感されています。

自分としても、実際にコードをタイピングする量としては、2分の1程度には下がったような感覚があります。そのおかげで、何を、どう作るのが自分たちのプロダクトとして適切なのか?に対して、より考える時間を取れるようになったと思います。

また、Copilotが生成してくれるコードは、自分が出力していながらもコーディングをしていないので、客観的な視点を持つことが可能です。具体的に言うと、レビューするような感覚で、これは適切な出力なのか?を考えられるようになりました。シンプルなコードがゆえに意識が低くなり、質が下がるようなことが少なくなったと考えています。

次に、デメリット面をみてみます。

ほぼ、ない!という意見も多いものの、

このように、Copilotとの付き合い方の面で、調整する必要があるとの意見が多く見られました。

実害のある挙動をすることはないですが、従来の補完機能と競合して操作しずらい部分があったり、使う側の出力の期待値や扱い方が慣れきれていなかったりすることに起因するものが、やはり多いようです。

ここに関しては、ツールとしても自分たちとしても成熟していく必要があるでしょう。

3. 今後も使い続けたいと思いますか?

最後に、今後も使い続けたいか?という回答に関しては、全員がpositiveに回答しています。

多くの人が、Copilotなしの生活に戻れなくなっているようです。

4. AIとこれから

今後、Copilotに関わらず、AIによる自動生成を用いたツールは、どんな形であれ広く活用されていくことになると思います。

どのようなツールが覇権を握るとしても、共通して大事なことは、生成結果の正当性やそれを自分たちのプロダクトや業務に使うことの正当性を、自らの視点で評価をした上で活用することだと考えています。

どの部分を任せ、どの部分を握るべきか?どのように評価し活用すべきか?これに関して答えが出ていないことも多く、人間側として思考をアップデートしていくべき点に溢れていると思います。

このすばやい変化に対して、個人として、組織として、適応できるかできないかが、今後の生産性に大きな差を生んでくると考えています。ここに適応するべく、今後もGithub Copilotの活用だけでなく、他のAIツールやプロセスに対しても検証を回していきます。

デメリット面でもあげたように、まだまだ使いにくさがあるのも事実です。でも、Gaudiyとしては一般的に使いやすい状態になる前から、今、組織的にここに投資していくことが大きな競争優位になると思っていますし、ビジョンの実現に向けて大きく飛躍できるチャンスだと考えています。

これまで50人いなければ開発できなかったものが、3人で開発できるようになる未来ってワクワクしませんか? AIと共創してどんどん業務改善もプロダクト活用も進めていきたいです。

実際に手を動かして、この時代の変化を乗りこなしていきたい方、ぜひ一緒にやっていきましょう!!

recruit.gaudiy.com

Kotlin(Ktor)にOpenTelemetryを導入し、Google Cloud TraceにExportした話

この記事は、Gaudiyが実施している「Gaudiy Advent Calendar 2022」の24日目の記事です。

はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。

GaudiyではKotlin, Go, Node.jsでのBE開発をしており、前職ではKotlin×Spring BootでC2Cサービスの開発をしていました。

先日、↓の記事でも伝えたように、GaudiyではマイクロサービスにおけるObservabilityの課題に対して、OpenTelemetryの導入を行いました。そこで自分は、Kotlin(Ktor)環境への導入を担当しました。

techblog.gaudiy.com

その中で、実際のアプリケーションでの導入事例や、特定のAPMへのExport方法に関してはまだ情報が少ないかなと感じたので、今回この記事を書くことにしました。

同じような技術スタックの方や、OpenTelemetryを実際に導入しようと考えている方の参考になればと思います。

1. OpenTelemetryって何?

OpenTelemetryとは、テレメトリーデータ(ログ、メトリクス、トレース等)の計装と送信の標準仕様、及びそのライブラリーとエージェントを提供するプロジェクトです。

これだけ聞いてもなんのこっちゃわからんと思いますが、具体的には以下のイメージです。

1-1-1. ログ

アプリケーションに発生したイベントを記録するテキスト。ロガーライブラリによって吐かれるわりとよく見るやつ。

Log_sample

1-1-2. メトリクス

一定期間に測定された数値。 例: アクセス数、レイテンシ、CPU使用率

Metrics_sample

1-1-3. トレース

依存関係のあるシステム間で、リクエストのフローをエンドツーエンドで表すもの。

Trace_sample

OpenTelemetryはこれらを標準仕様化&SDKを言語毎に用意してくれるので、各マイクロサービスに導入することでアプリケーションの健全性を監視したり分析することが容易にできるようになります。

1-2. 計装方法

計装には、手動計装(Manual Instrumentation)と自動計装(Auto Instrumentation)の2パターンがあります。

手動計装

  • 自前で実装する必要がある
  • 柔軟な計装ができる

自動計装

今回は両方の計装を試してみたので、その実装方法について書きたいと思います。

2. Kotlin(Ktor)にOpenTelemetryを導入する

前置きが長くなりましたが、ここからが本題です。

今回検証したのは主にTraceになるのですが、一応Metricsも導入しています。 また、前提情報としてGaudiyのKotlinサーバーではFWにKtor, DIコンテナにKoinを利用しています。

まず、手動計装(Manual Instrumentation)の実装からです。

2-1. 手動計装

2-1-1. 依存関係のダウンロード

// build.gradle

implementation platform('io.opentelemetry:opentelemetry-bom:1.21.0')
implementation 'io.opentelemetry:opentelemetry-api'
implementation 'io.opentelemetry:opentelemetry-sdk'
implementation 'io.opentelemetry:opentelemetry-exporter-jaeger'
implementation 'io.opentelemetry.instrumentation:opentelemetry-ktor-2.0:1.21.0-alpha'
implementation 'com.google.cloud.opentelemetry:exporter-trace:0.23.0'
implementation 'com.google.cloud.opentelemetry:exporter-metrics:0.23.0'
implementation 'com.google.cloud.opentelemetry:propagators-gcp:0.23.0-alpha'

GaudiyではFWとしてKtorを利用しているので、Ktorのinstrumentationを入れています。 jaegerを入れているのはローカル確認用になります。

2-1-2. jaeger用のdocker-compose.ymlを作成

// docker-compose.yml

version: '3.2'

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "5775:5775/udp"
      - "6831:6831/udp"
      - "6832:6832/udp"
      - "5778:5778"
      - "16686:16686"
      - "14268:14268"
      - "14250:14250"
      - "9411:9411"

2-1-3. コード書く

OpenTelemetryをアプリケーション導入する処理を書きます。

// SetOpenTelemetry.kt

fun Application.setOpenTelemetry(env: Env): OpenTelemetry {
  // Resourceの設定.
  // https://opentelemetry.io/docs/reference/specification/resource/sdk/
  val resource: Resource = Resource.getDefault()
    .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}")))

  // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています.
  val propagators = ContextPropagators.create(
    TextMapPropagator.composite(
      XCloudTraceContextPropagator(false),
      W3CTraceContextPropagator.getInstance(),
    ),
  )

  // otel build.
  val openTelemetry = OpenTelemetrySdk.builder()
    .setTracerProvider(createTraceProvider(env, resource))
    .apply { if (!env.isLocal) createMeterProvider(resource) }
    .setPropagators(propagators)
    .build()

  // ktor instrumentationのinstall.
  install(KtorServerTracing) {
    setOpenTelemetry(openTelemetry)
  }
  
  return openTelemetry
}

// TraceProviderの作成
fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider {
    // localはjaeger, その他環境ではGCPへExportするように設定
  val traceExporter = if (env.isLocal) {
    JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build()
  } else {
    TraceExporter.createWithDefaultConfiguration()
  }
  return SdkTracerProvider.builder()
    .addSpanProcessor(SimpleSpanProcessor.create(traceExporter))
    .setResource(resource)
    .build()
}

// MeterProviderの作成
fun createMeterProvider(resource: Resource): SdkMeterProvider {
  val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration()
  return SdkMeterProvider.builder()
    .registerMetricReader(
      PeriodicMetricReader.builder(metricExporter)
        .setInterval(Duration.ofSeconds(30))
        .build()
    )
    .setResource(resource)
    .build()
}

計装クラス(SampleService)にTracerを生成し、処理の間にspanを生成・記録します。

// SampleService.kt

class SampleService(private val repository: SampleRepository, openTelemetry: OpenTelemetry) {
  
  private val tracer: Tracer = openTelemetry.getTracer("SampleService")

  fun execute() {
    // start span.
    val span = tracer.spanBuilder("execute").startSpan()
    
    val data = repository.get()
    println(data)
    
    // end span.
    span.end()
  }
}

SampleServiceをエンドポイントを叩いて呼び出すようにします。

// SampleRouting.kt

fun Route.sampleRouting(sampleService: SampleService) {
  route("/sample") {
    get {
      sampleService.execute()
      call.respondText(LocalDateTime.now().toString(), status = HttpStatusCode.OK)
    }
  }
}

最後に、作成したOpenTelemetryやSampleService等をシングルトン化し必要なクラスに渡します。

// Application.kt

fun Application.module() {
    ...
    
    // Env is original config class.
  val env = Env.of(environment)
    val openTelemetry = setOpenTelemetry(env)
    
  install(Koin) {
    module(
        org.koin.dsl.module(createdAtStart = true) {
        single { openTelemetry }
        single { SampleService(get(), get()) } // openTelemetry利用クラス
          }
    )
  }
  
    ...
  
  val sampleService by inject<SampleService>()
  routing {
    sampleRouting(sampleService)
  }
}

2-1-4. Jaeger起動&Application起動&エンドポイント叩く

$ docker compose up

$ ./gradlew runShadow
{"time":"2022-12-14T17:43:01.261Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.317Z","message":"Application started in 3.156 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.323Z","message":"Application started: io.ktor.server.application.Application@2eda4eeb","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.588Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-1","severity":"INFO"}

$ curl http://localhost:8080/sample
2022-12-15T02:46:40.909022

2-1-5. Traceの確認

ブラウザでhttp://localhost:16686にアクセスします。 Trace_manual_local 今回はjaegerで確認しましたが、いい感じに記録されてます。

2-2. 自動計装

続いて自動計装(Auto Instrumentation)になります。

2-2-1. opentelemetry-javaagent.jarのダウンロード

JVM系で自動計装するには、opentelemetryのjavaagentをアプリケーションに入れる必要があります。 Releaseにて最新のopentelemetry-javaagent.jarをダウンロードすることができるのですが、今回はbuild時にgradleで自動ダウンロードするようにしてみました。

// build.gradle

plugins {
  id "com.github.johnrengelman.shadow" version "7.1.2"
    id "de.undercouch.download" version "4.1.1"
}

def agentPath = project.buildDir.toString() + "/otel/opentelemetry-javaagent.jar"

task downloadAgent(type: Download) {
    src "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.21.0/opentelemetry-javaagent.jar"
    dest agentPath
    overwrite true
}

shadowJar {
    dependsOn("downloadAgent")
      ...
}

2-2-2. opentelemetry-javaagent.jarの適用

fat jar作成時に-javaagent:path/to/opentelemetry-javaagent.jar のようにjavaagentのconfigを記載します。 また、exporterの指定などotelに必要なconfig達を環境に合わせて渡しておきます。 ※configについて詳しくはagent-configを確認。

// build.gradle

def isLocal = !project.ext.has("ENV")

shadowJar {
    dependsOn("downloadAgent")
    ...
    def args = [
            "-javaagent:${agentPath}",
            "-Dotel.service.name=sample-service",
    ]
    if (isLocal) {
        args += [
                "-Dotel.traces.exporter=jaeger",
                "-Dotel.metrics.exporter=none",
        ]
    } else {
          args += [
                "-Dotel.traces.exporter=google_cloud_trace",
                "-Dotel.metrics.exporter=google_cloud_monitoring",
        ]
    }
    applicationDefaultJvmArgs = args
}

2-2-3. 実装

依存関係の追加

// build.gradle

dependencies {
    ...
    implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:1.21.0'
    ...
}

手動計装の際にSpan作成した箇所(SampleService)を修正します。OpenTelemetryやTracerを削除し、Span作成するfunctionに対して@WithSpanアノテーションを付与します。

// SampleService.kt

import io.opentelemetry.instrumentation.annotations.WithSpan

class SampleService(private val repository: SampleRepository) {
    @WithSpan
    fun execute() {
        val data = repository.get()
               println(data)
    }
}

2-2-4. Jaeger起動&Application起動&エンドポイント叩く

Jaegerのdockerファイル用意は手動計装と同じなので割愛します。

$ docker compose up

$ ./gradlew runShadow
[otel.javaagent 2022-12-15 03:45:35:551 +0900] [main] INFO io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.18.0
{"time":"2022-12-14T18:45:42.522Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:45.724Z","message":"Application started in 3.875 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:45.726Z","message":"Application started: io.ktor.server.application.Application@44e3f3e5","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:47.223Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-2","severity":"INFO"}

$ curl http://localhost:8080/sample
2022-12-15T04:05:28.119532

2-2-5. Traceの確認

アノテーション付けただけでSpanが記録されました。とても簡単で便利ですね。 他にも@SpanAttributeなどでSpanに対して属性を追加できるみたいです。※詳しくはannotationsをご確認ください。

Trace_auto_local

これでKotlinで手動計装・自動計装ができました。次にこのテレメトリーデータをGoogle Cloud Traceに出力してみたいと思います。

3. Google Cloud Traceに出力する

今まではサンプルコードを使ってテレメトリーデータを計装してみましたが、どうせなら色々Spanがみれた方が良いと思うので、Gaudiyのアプリケーションを用いてOpenTelemetryを実装・Google Cloud TraceにExportしてみようと思います。

3-1. 手動計装の場合

手動軽装は上記のSampleの実装で利用することができます。 一応おさらいしておくと、Metrics, Trace共にGCP用Exporterを作成し、MetricsProvider, TraceProviderを作成。それをOpenTelemetryに読み込ませるだけです。

// SetOpenTelemetry.kt

fun Application.setOpenTelemetry(env: Env): OpenTelemetry {
  // Resourceの設定.
  // https://opentelemetry.io/docs/reference/specification/resource/sdk/
  val resource: Resource = Resource.getDefault()
    .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}")))

  // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています.
  val propagators = ContextPropagators.create(
    TextMapPropagator.composite(
      XCloudTraceContextPropagator(false),
      W3CTraceContextPropagator.getInstance(),
    ),
  )

  // otel build.
  val openTelemetry = OpenTelemetrySdk.builder()
    .setTracerProvider(createTraceProvider(env, resource))
    .apply { if (!env.isLocal) createMeterProvider(resource) }
    .setPropagators(propagators)
    .build()

  // ktor instrumentationのinstall.
  install(KtorServerTracing) {
    setOpenTelemetry(openTelemetry)
  }
  
  return openTelemetry
}

// TraceProviderの作成
fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider {
    // localはJaeger, その他環境ではGCPへExportするように設定
  val traceExporter = if (env.isLocal) {
    JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build()
  } else {
    TraceExporter.createWithDefaultConfiguration()
  }
  return SdkTracerProvider.builder()
    .addSpanProcessor(SimpleSpanProcessor.create(traceExporter))
    .setResource(resource)
    .build()
}

// MeterProviderの作成
fun createMeterProvider(resource: Resource): SdkMeterProvider {
  val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration()
  return SdkMeterProvider.builder()
    .registerMetricReader(
      PeriodicMetricReader.builder(metricExporter)
        .setInterval(Duration.ofSeconds(30))
        .build()
    )
    .setResource(resource)
    .build()
}

3-2. 自動計装の場合

自動計装はopentelemetry-javaagent.jarがexportを行うので、opentelemetry-javaagent.jarにgcp用exporterを指定する必要があります。

しかし、自動計装用のcustom exporterはPoC段階でありexporter-auto.jarファイルをmaven repositoryから取得、もしくは自分で生成しなければならなそうでした。(2022/12/15現在) ※詳しくはexporter-autoをご確認ください

3-2-1. gcp exporterの生成

GCP opentelemetry-eperations-javaをcloneして、fat jarをbuildし、~/exporters/auto/build/libs/exporter-auto(.*).jarを生成します。

参考: opentelemetry-operations-java/build.gradle at main · GoogleCloudPlatform/opentelemetry-operations-java · GitHub

3-2-2. 生成したjarファイルをアプリケーション内に設置

$ cp ~/exporters/auto/build/libs/exporter-auto(.*).jar path/to/application/exporter-auto(.*).jar

3-2-3. exporterをopentelemetry-javaagent.jarのconfigに設定

GaudiyではプラットフォームとしてCloud Runを利用しているため、Dockerfileを書きます。 otel.javaagent.extensionsに先程のpath/to/application/exporter-auto(.*).jarを指定します。

// Dockerfile

FROM adoptopenjdk/openjdk11:alpine-slim

EXPOSE 8080

ENTRYPOINT java \
  -Dfile.encoding=UTF-8 \
  -javaagent:path/to/otel/opentelemetry-javaagent.jar \
  -Dotel.javaagent.extensions=path/to/application/exporter-auto(.*).jar \
  -Dotel.service.name=sample-service \
  -Dotel.metrics.exporter=google_cloud_monitoring \
  -Dotel.traces.exporter=google_cloud_trace \
  -jar ./build/libs/sample.jar

この時、exporterやservice.nameなどのopentelemetry configを渡します。

3-2-4. Docker build & Docker Push & Cloud Runデプロイ

$ docker image build -t $IMAGE_PATH .
$ docker image push $IMAGE_PATH
$ gcloud beta run deploy sample-service --image $IMAGE_PATH

3-2-5. Traceの確認

少しわかりにくいかもしれませんが、ServiceA → ServiceB → ServiceCとTraceが伝搬されていることがわかります。 自動計装によりGraphQL, Firestore, Spannerの処理も計装できていていい感じですね。

Trace

  • ServiceA: Node.js
  • ServiceB: Kotlin
  • ServiceC: Kotlin

4. まとめ

Kotlin(Ktor)サーバーにOpenTelemetryの導入ができました。ただ、Google Cloud TraceへのExportがPoCであり若干の荒技感あるので、今後はOpenTelemetry Collector経由でテレメトリーデータを扱うようにしたいと考えています。

また、Metrics, Logについても本格的にアプリケーションに適用して、Observabilityの向上に努めたいです。

そういえば今日はクリスマスイブですね。Merry Xmas, Merry OpenTelemetry!

recruit.gaudiy.com

5. 参考文献

opentelemetry.io

cloud.google.com

github.com

github.com

opencensus.io