Gaudiy Tech Blog

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

gql.tada に graphql-code-generator から移行した話

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

Gaudiyでは、以前からフロントエンド(Next.js)とGateway(Node.js)の通信においてGraphQLを使用しています。

techblog.gaudiy.com

その際に、GraphQLスキーマからコードを自動生成するツールとしてGraphQL-Codegenを活用してきましたが、開発者体験やユーザー体験においていくつかの課題を抱えていたため、今回、gql.tadaに移行しました。

この記事では、課題背景から実際の移行プロセスを紹介してみるので、gql.tadaが気になっている人やGraphQLの運用に課題感のある人の参考になれば嬉しいです。

1. GaudiyとGraphQL

最初に、Gaudiyの技術スタックを簡単に紹介します。

Gaudiyのバックエンドでは、各コミュニティで必要な機能をマイクロサービスとして構成しており、その機能群を、GatewayであるGraphQLサーバーを通すことでフロントエンドの複雑性を回避しています。

導入時の課題背景やGaudiyのプロダクト戦略におけるGraphQLの役割は、以下の記事が詳しいのでここでは割愛します。

techblog.gaudiy.com

2. GraphQL-Codegenにまつわる課題

これまではGraphQL-Codegenを用いて、GraphQLのスキーマファイルからでコード生成し、Query, Fragmentのスキーマ定義を記述してきました。

the-guild.dev

GraphQL-Codegenは、Apolloの公式ドキュメントにも記述されていたり、プラグインのエコシステムも充実していることから、GraphQLを使う上ではもはや最低限整備しなければいけないツールとして一般的になっているのではないでしょうか。

www.apollographql.com

ただ、このツールには、いくつかの課題があります。

まず開発者体験としては、

  • schema記述時に補完やバリデーションが効かず、 npx graphql-codegen を都度実行して検証しなければならない
  • schema記述後に、それを用いて、queryやmutationを記述する前に npx graphql-codegen を実行しなければいけない

などが課題です。もちろん watch mode を利用すれば軽減しますが、体感できるほどのラグがあることと、schema量が大量になってくると生成はされてもVSCode(などのエディタ)が読み込まれるまでに時間がかかることがありました。

またユーザー体験としても、

  • 生成されたdocument nodeがclient bundleに含まれてしまい、エンドユーザーまで配信されてしまうことで、サイトパフォーマンスに悪影響を与えてしまう
  • アプリケーションの成長(schema定義数増加)と比例して線形的に増えるしかない構造にある

といった課題があります。

/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/

これらは、生成結果のドキュメントファイルにも明確に記述されています。babelとswcのプラグインが用意されていますが、特にswcプラグインはNext.jsとのversion管理の問題などがあり、ブロッカーが依然として多い状態にあります。

github.com

実際、Gaudiyのwebアプリケーションでは、60kb近くにも膨れていました。

これらの課題を解決するために、今回導入したのが、gql.tadaです。

3. gql.tadaの導入

gql-tada.0no.co

In short, `gql.tada`,

- parses your GraphQL documents in the TypeScript type system
- uses your introspected schema and scalar configuration to derive a schema
- maps your GraphQL queries and fragments with the schema to result and variables types
- creates fragment masks and enforces unwrapping fragments gradually
 `gql.tada`を簡潔に述べると、

 - GraphQLドキュメントをTypeScriptの型systemでパースする
 - イントロスペクトスキーマとスカラーからスキーマを導出する
 - スキーマと共に GrahphQLクエリとフラグメントを戻り値と変数の型にマッピングする
 - フラグメントマスクを生成し、段階的に解消する

ということで、今までdocument nodeを生成してschemaを解釈していた部分まですべて、型systemの中で解決することができます。

つまりは即座にコード反映し、推論とエラー表現によって操作性が高く、clientバンドルにも影響しない環境を実現してくれます。

仕組みとしては TypeScript LSP プラグインとして、GraphQLSPが動いており、これと組み合わせることで TypeScript コンパイラが型チェックを実行できるようになっています。

これが何をしているかというと schema で指定している GraphQL Schema の変更を監視しており、これがスキーマのイントロスペクションとして tadaOutputLocation で指定されたファイルに保存しています。

その後 gql.tada に渡されアンビエント宣言をすることで typescript コンパイラが型チェックを実行できるようになっています。

またスポンサーには、GraphQL-Codegenの作者である The Guild も名を連ねており、urqlの作者がコアメンテナーであることも、将来性の観点から導入を後押しする一材料となりました。

デモを見れば、その快適性は一目瞭然かと思うので、ぜひこちらの動画を見つつ、exampleを動かしてみてください。

gql-tada.0no.co

4. 移行プロセスと注意点

ここからは、実際の移行プロセスについてご紹介します。

現在、client presetからのcodemodは検討されてはいるものの、まだ提供されていない状態にあります。

github.com

ただ、基本的には GraphQL-Codegen の成果物と対応するメソッドは用意されているので、それを書き換えるだけで問題なく動作することと、メリットの大きさからこれを待たずに移行することを決めました。

手順は以下の通りです。ドキュメント通りにセットアップした後、

1:graphql 関数と型のimport先を変更する

// before
import { graphql } from '@/generated/graphql';
import type { ResultOf, VariablesOf } from '@graphql-typed-document-node/core';
    
// after
import { graphql } from '@/tada';
import type { ResultOf, VariablesOf } from 'gql.tada';

2:Fragment, Query, Mutation 定義時のimport元と命名を変更し、使用する子Fragmentをarrayに追加する

// before
import { getFragmentData, graphql, type FragmentType } from '@/generated/graphql';
    
const ParentFragment = graphql(`
 fragment ParentFragment {
  ...ChildFragment
 }
`)
    
type Props = {
 data: FragmentType<typeof ParentFragment>;
}
    
const Component = ({ data }) => {
 const fragment = getFragmentData(ParentFragment, data);
 ...
}
    
// after
import { graphql } from '@/tada';
import { readFragment, type FragmentOf } from 'gql.tada';
import { ChildFragment } from './ChildComponent';
    
const ParentFragment = graphql(`
 fragment ParentFragment {
  ...ChildFragment
 }
`, [ChildFragment])
    
type Props = {
 data: FragmentOf<typeof ParentFragment>;
}
    
const Component = ({ data }) => {
 const fragment = readFragment(ParentFragment, data);
 ...
}

3:テストデータ作成時のimport元と命名、引数の順番を変更する

// before
import { makeFragmentData } from '@/generated/graphql';
       
const data = makeFragmentData({
 __typename: 'Test',
 id: '1',
 ...
 },
 TestFragment
)
    
// after
import { maskFragments } from 'gql.tada';
       
const data = maskFragments([TestFragment], {
 // __typename: 'Test',
 id: '1',
 ...
 }
)

これでできます。我々の場合は jscodeshift を用いて、自分たちのケースに沿って8割程の移行をしてくれるscriptを用意した上で、機能区分ごとに移行しました。

また移行に際しての注意点として、いくつかTipsをご紹介します。

  • scalar or enum の取り出し
 type Status = ReturnType<typeof graphql.scalar<'Status'>>;
  • maskFragments のpropertyは完全一致させる
export const ParentFragment = graphql(
 /* GraphQL */ `
 fragment ParentFragment on Test {
  id
  title
  items {
   id
   ...ChildFragment
  }
 }
 `,
[ChildFragment]
);

このFragmentをmockする場合、以下のように同じFragment構造で定義します。

maskFragments([ParentFragment], {
 id: 'id1',
 title: 'タイトル',
 items: [{
  id: 'id1',
  ...maskFragments([ChildFragment], {
  id: 'id1',
  title: 'タイトル',
  // ...
  }),
 }],
})

一致していない時に出る型エラーがわかりにくく、かつ codemod作りきるのが難しかったため、この修正に一番時間がかかりました。

  • Fragmentを複数同じ階層に渡す際には、arrayに追加する
fragment CFragment on CSection {
 id
 ...AFragment 
 ...BFragment
}

上述のような Fragment定義のときには、

// NO
maskFragments([CFragment], {
 id,
 ...maskFragments([AFragment], {
  id,
  // 省略
 }),
...maskFragments([BFragment], {
  id,
  // 省略
 }),
}),
    
// OK
maskFragments([CFragment], {
 id,
 ...maskFragments([AFragment, BFragment], {
  id,
  // 省略
 }),
}),

このようにする必要があります。

些細ではありますが、ドキュメントに例として明示されていないので、嵌る人もいたポイントです。

5. まとめ

GraphQL-Codegenからgql.tadaに移行した結果、

  • schema記述時の補完と即時の型反映という開発体験の向上
  • 100KB近いバンドルサイズ削減から得られるユーザー体験の向上

という、まさに一石二鳥の効果を得ることができました。

コードベースが多ければ、turboを設定するなどでコンパイル速度とメモリの問題も抑えられますし、大きなダウンサイドは今のところ感じていません。

gql-tada.0no.co

我々のユースケースでは、一点だけ型の不足を感じましたが、逆に言うとそれだけでした。

github.com

TypeScript自体のperformanceが、このツールが向かう方向性の明確なリスクであることは間違いないですが、Codegen以外の選択肢の一つとして検討してもよいのでは? と個人的には思います。

もしGaudiyのGraphQL周りやwebフロントエンドについて興味ある人いたら、ラフにお話しさせてください!

site.gaudiy.com

一緒に開発する仲間も募集してます

site.gaudiy.com

【開発プロセス/検証編】LLMプロダクト開発にLangSmithを使って評価と実験を効率化した話

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

ここ1〜2年くらいで、生成AI / LLM界隈の盛り上がりは非常に加速してきており、それをいかに活用して新たな価値を提供するかということに集中している方も少なくないことかと思います。

弊社Gaudiyも比較的早期からこの分野に可能性を見出し、積極的に挑戦してきました。そんなLLMプロダクト開発を行なっていく中で、発生した課題に対して蓄積されたナレッジを活かして日々改善できるよう昇華しています。

今回はこの分野の開発に切っても切れないプロンプトチューニングの業務プロセスにフォーカスし、よく起こりうるであろう課題に対してどのように効率化・解消していっているのか、その一端をユースケースとともにご紹介できればと思います。

※なお、本稿は「技術選定/OSS編」と「開発プロセス/検証編」の2部構成となっており、後編の内容となります。
前編の seya さんの記事も合わせてお読みいただけますと幸いです 🙏

techblog.gaudiy.com

Gaudiyが取り組むLLMプロダクト

Gaudiyでは、複数のIP(知的財産)に関するコミュニティプラットフォーム「Gaudiy Fanlink」を提供しています。

まだベータ版ではあり、あまり詳細をお伝えできないのですが、現在特定のIP様と協業しながらFanlinkに芸能人やアニメキャラといった特定キャラクターのAIアバターとのチャット(以下AIトークと呼びます)を楽しめる機能を絶賛PDCAしながら開発しております。

この機能において、AIのメッセージの生成にはLLMを利用しています。LLMは欠かせない存在であり、LLMがそのクオリティを大きく左右すると言っても過言ではないといえます。

ここで、現段階でのAIトーク機能を簡単にご紹介します。

ユーザーはFanlink上にて、特定キャラクターのふるまいをするAIアバター(以下キャラクターAIとします)のメッセージに対してメッセージを送ることができるようになっており、キャラクターAIはその固有の特徴や記憶などの情報を保持し、常にユーザーからのメッセージや会話の流れに対して適切なふるまいをするようにプロンプトされています。

また、テキストメッセージだけでなく、デジタルアイテムのギフトを渡すなどの付加的な体験も提供しており、今後音声などのマルチモーダルなインプットを与えることができるような機能も検討しています。

プロンプトには、ユーザーからのメッセージに対して返信をするものだけでなく、会話のし始めの話題提起、自己紹介、会話の流れを終了させるものなど、非常にさまざまなシチュエーションが存在し、さまざまなコンテキストによって使用するプロンプトをロジックの中で使い分けるようにしています。これによって、1区切りの会話の開始から終了までをより自然に振る舞ったり、次の話題にシームレスに移行するといったことができるような仕組みを実現しています。

次に、アーキテクチャを簡単にご紹介します。

ユーザーからのメッセージが送信されてくると、リプライ生成キュー(Cloud Pub/Subを利用しています)にタスクをスタックし、処理が開始されるとどのキャラクターAIの情報なのかを表す一意のIDをもとにキャラクターの特徴が格納されているDBから情報を取得します。

※ちなみにキャラクターAI自体はそれぞれ個別に複数人を構築できる構成を取っており、現在は実際に2名のキャラクターAIを運用して検証しています

さらに、Vector Store(Vertex AIを利用しています)に事前に格納されたベースとなるキャラクターAIの記憶データやユーザーとキャラクターAIの過去の会話サマリー情報をベクトル検索によって、ユーザーからのメッセージクエリなどから近傍する情報を取り出します(RAGベース)。

そして、会話のし始めや何回目の会話なのかといったコンテキスト情報をもとに使用するプロンプトのテンプレートを決定します。上記で得たキャラクター情報と関連記憶の情報といったさまざまなデータの中からプロンプトに必要なインプットのみを抽出し、LangChainのインターフェースを用いてLLMをinvokeしてメッセージを生成します。

この手法をとることで、一つのプロンプトテンプレートにおいて複数のキャラクターAIの振る舞いを実現することを可能にしています。

また、非常にシンプルなタスクなどはシングルプロンプトで構成されていますが、より複雑な文脈の認識や低コスト化などを目的として、LangGraphを用いてマルチステップにプロンプトをチェーン実行するようなパターンも存在します(この辺りの詳しい技術内容は今回は割愛します)。

こうしたLLMの実行はLangChainより提供されるLangSmithにて、GUI上でトレーシングされた結果を閲覧できるので、ハルシネーションを起こした際などに非常に簡単にデバッグができるような機構を実現しています。

プロンプトチューニング業務で直面した課題

前述したように、キャラクターAIとのトーク機能の実現には複数のプロンプトを使用しており、その一つ一つの精度を上げるためのプロンプトチューニングの重要性は非常に高くなっていました。

しかし、我々はプロンプトチューニングの効率的なやり方を確立できておらず、場当たり的に行なってしまうなどコストが膨らみ、さまざまな課題を抱えていました。

最も大きな課題は、チューニングを担当するエンジニアとステークホルダーとなる弊社Bizメンバーとの間の共通言語がアプリケーションの最終挙動をもとにした定性結果のみで、抽象的な要求を満たしていない理由をエンジニアが実装レベルまで分解・分析するためのコストやロスが大きく生じていたことでした。

従来は、一通りの機能が実装完了した後、作成したAIの特徴などをよく知っていてIPとも密に連携している弊社のBizメンバーが実質のステークホルダーとなってクオリティーのチェックを依頼するような運用体制を取っていました。 チェック後の結果の観点リストは、キャラクターAIのベースとなっている対象の話口調などの特徴を満たしているかという無限大にも広がる観点からなる項目が定性的にまとまっている状態でした。

クオリティーチェックの結果としてはもちろん正しいのですが、その一個一個の定性的な観点をエンジニアが読み取って全てを満たしながら(時には優先度立てて)プロンプトという実装に落とし込む作業の負荷は非常に高い状態でした。

終わりも完了条件も見えにくいこの作業において、作業自体が自然消滅したり、満足にチューニングされずに開発が進んでしまうということも起きました。しまいには、LLMモデルを廉価モデルに変更したせいで品質が低下したなどという全く根拠のない結論づけをされてしまうこともありました。(これではエンジニアは困ってしまいます・・・)

プロンプトチューニングをしていると、こっちを立てればあっちが立たずなどの問題は往々にして生じます。最終的にはどれがマストで実現しないといけない要件なのかといった判断は逐一質問をしなければいけないといったコミュニケーションコストも大きくなっていました。

こういったプロンプトの動作をLangSmithのPlaygroundを使って確認・チューニングすることもありましたが、この場合履歴なども残らず試行自体も手動で逐一行わなければならないため非常にコストがかかっていました。

また、当該のプロンプトにどのLLMモデルが最適なのかをモデル間で比較する際にも、このやり方では非常に大きなコストがかかってしまうといえるでしょう。

プロンプトチューニングの効率化

検討フェーズ

AIチームも発足しこの分野に詳しいメンバーも増えつつありましたが、AIチームではR&Dがメインでこうした泥臭いプロセス課題に関してはほとんどアプローチできていない現状でした。プロダクト開発チーム側の自分としてはここに大きな課題感を持ったため、まずはこれを解消するために以下が最低限実現できると良いと考えました。

  • 複数の想定インプットとその正解の一例となる期待値を記したデータセットを何らかのドキュメントで事前作成する
  • 用意したデータセットに対して自動でLLM実行ができる環境を用意し実行結果を自動生成できる
  • チューニング担当(エンジニア)とステークホルダー(Biz)が実行結果をもとに改善のための会話をできる共通言語となる永続データを用意する
  • 任意のLLMモデルへの差し替えや結果の比較が簡単にできる

実現フェーズ

まず、複数の想定インプットとその正解ケースからなるデータセットをGoogleスプレッドシートにて作成することを決めました。 採用理由としては、オンラインでリアルタイムに複数人が編集可能であることと、シート/セル参照を使うことで共通定義の変更を簡単にデータセットに一括反映できるスプレッドシートの特性がマッチしていると考えたからです。

スプレッドシートの構成は、以下のような形にしました。

  • キャラクターAIの特徴情報自体のチューニングもしやすいようにDBのデータを複製した共通定義シート
  • 各プロンプトごとに必要なインプットとアウトプットからなるケースを1行にまとめそれを必要なパターン分作成したシート(複数)

各プロンプトのシートにはどのキャラクターAIに関するチューニングケースなのかを設定できるセレクトボックスや、その特徴が共通定義シート側からセル参照で動的に入るようにセルの値に数式を用いてauto fillする構成にしました。

それ以外のケースごとに必要なユーザーからのメッセージ入力やOutputとなる期待値などはBizメンバーにパターン網羅されるように作成依頼しました。

続いて、LLM実行と実行結果の抽出の効率化ですが、ここの技術知見がほとんどなかったのでこれから掘ろうと思った矢先に、AIチームより連携を買って出てくださり、課題感と実現したい世界観を改めてsyncすると、LangSmithのDatasets & Testing 機能を用いてデータセットごとの一括実行や実行検証結果をLangSmith上に永続化できる仕組みを提案してくださいました!

LangSmithのDatasets & Testing機能を効果的に使うためのAPIはLangChainより公開されているため、これらを我々のユースケースで利用できるようにラップしてあげることで、要望を実現できました。

※このHelperは、現在OSSライブラリとして公開もしています。詳しい内容に関しては、下記のリポジトリや冒頭で紹介した seya さんの記事をお読みいただければと思います。

github.com

ここからは簡単に全体の実現フローを図示し、Helperの使い方とともにプロンプトのEvaluationを効率的に行うための実装の一部を示します(現在のHelper OSSのインターフェースとは少々異なる可能性もあるのでご了承ください)。

まず、Googleスプレッドシートの情報を読み込み、LangSmithのDatasetsのExampleの形式に変換・登録するためのPythonスクリプト(dataset.py)を実装し実行します。

読み込みには、普段Gaudiyがインフラとしても利用しているGCPの中で提供されているGoogle Sheet APIを利用しました。 スクリプト上からGoogle Sheet APIを利用するためには、事前に作ったサービスアカウントに対して、読み込みたいスプレッドシート側の共有設定で閲覧権限を付与することで実現ができました(権限の割り当て方がIAMとは異なるため注意です)。

読み込んだdictionaryデータを必要に応じてマッピングや整形処理を行い、Helperを呼び出してLangSmith上の任意のDataset Exampleに登録/上書きします。

# LangSmith Dataset Name
LANGSMITH_DATASET_NAME = "Test Dataset"
# Google Spreadsheet ID
SPREADSHEET_ID = "hogehoge"
# Google Spreadsheet name
SHEET_RANGE = "test_prompt_sheet"
# path to SA json for Google Sheet API
SERVICE_ACCOUNT_KEY_PATH = os.getenv("SERVICE_ACCOUNT_KEY_PATH")

async def dataset() -> None:
        # fetch Spreadsheet
    values = get_sheet_values(SHEET_RANGE)

    # map and create input data
    header_row = values[0]
    header_columns_count = len(header_row)
    data_rows = values[1:]

    dict_list: list[dict[str, Any]] = []

    for row in data_rows:
        name = row_dict.get("name", "")
        bio = row_dict.get("bio", "")
        attitude = row_dict.get("attitude", "")
        last_message = row_dict.get("last_message", "")
        output = row_dict.get("output", "")

        dict_list.append({
            **TestPromptInput(
                name=name,
                bio=bio,
                attitude=attitude,
                last_message=last_message,
            ).model_dump(),
            "output": output,
        })

        # Call helper library
    create_examples(dict_list, dataset_name=LANGSMITH_DATASET_NAME)

def get_sheet_values(sheet_range: str) -> list[Any]:
    credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_KEY_PATH,
        scopes=["https://www.googleapis.com/auth/spreadsheets.readonly"],
    )

    service = build("sheets", "v4", credentials=credentials)

    sheet = service.spreadsheets()
    result = sheet.values().get(spreadsheetId=SPREADSHEET_ID, range=sheet_range).execute()
    values = result.get("values")

    return values

if __name__ == "__main__":
    asyncio.run(dataset())

このスクリプトを実行 ( python -m dataset.py ) することで、任意のDatasetのExampleにスプレッドシートで定義したインプットとアウトプットが全て反映できるようになりました。

次に、Helperを用いたExperimentに必要なコンフィグを定義するYAMLファイル(config.yaml)に、実行したいDatasets、LLMモデル、利用したいbuiltinのチェック関数などの指定を記述します。

description: test prompt

prompt:
  name: prompt.py
  type: python
  entry_function: prompt

evaluators_file_path: evaluations.py

providers:
  - id: TURBO

tests:
  type: langsmith_db
  dataset_name: Test Dataset
  experiment_prefix: test_

ちなみに、カスタムでチェックを施したい場合は、evaluations.pyに評価ロジックを書いてコンフィグでそれを指定することでその評価を回すことも簡単にできます。 例えば正負を含むスコアを返すプロンプトの正負の精度判定用のチェック関数などを独自に実装して評価に用いることができました。

def same_sign(run: Run, example: Example) -> dict[str, bool]:
    if run.outputs is None or example.outputs is None or run.outputs.get("output") is None:
        return {"score": False}

    example_output = example.outputs.get("output")
    if example_output is None:
        raise ValueError("example.outputs is None")

    example_score = int(example_output)
    output_score = int(run.outputs["output"].score)
    if example_score == 0 or output_score == 0:
        return {"score": example_score == output_score}

    example_sign = example_score // abs(example_score)
    output_sign = output_score // abs(output_score)
    return {"score": example_sign == output_sign}

この辺りは初期要望にはありませんでしたがExperimentsの機能としてこういうものも使えるよとご教示いただきました。 なお、実装したチェック関数のスコア結果はLangSmithのExperiments上でChartで視覚的に表示できるため、これも評価の指標としては非常にわかりやすかったです。

最後に、実際のプロダクション側のプロンプトテンプレート定義を参照して、実行するプロンプトテンプレートの実装を別ファイル(prompt.py)に定義します。(直接プロンプト文をこのファイル上に定義することも可能です)

@traceable
async def prompt(inputs: dict[str, Any]) -> TestPromptTemplate:
    return TestPromptTemplate(
        input=TestPromptInput(
            name=inputs.get("name", ""),
            bio=inputs.get("bio", ""),
            attitude=inputs.get("attitude", ""),
        )
    )

あとは、configを参照したaliasコマンドを実行すると、登録したデータセットの数だけLLM実行が行われ、実行完了後にLangSmith上のExperimentsに全ての実行結果が保存されます。

make evaluate <path/to/config.yml>

このExperimentsの個々の結果はURLリンクも存在するので、このリンクを共有することでチューニング担当とステークホルダーが実行結果や修正方針に対しての議論が簡単にできるようになりました 🎉

運用フェーズ

実際のプロンプトチューニング業務は、先述の通り、複数のプロンプトが対象だったため、エンジニアメンバーで上記の実装も含めて作業を分担することにしました(基本的に1人1プロンプトずつ担当しました)。

エンジニアがチューニング可能な明らかに期待値に対して間違っているとわかる事象をどのようなロジックで修正を施したかを、プロンプトごとにNotionにまとめ、時系列でチューニングの内容を追えるようにしました。

また、ある程度良い結果が出たタイミングで、OutputとなるLangSmithのExperiments URLをBizメンバーに共有してレビューをもらい、指摘を続けて修正したり、問題なければ完了といったフローで行いました。

結果と考察

従来、何度もプロンプト改善のレビューと修正を繰り返し、LLM開発に精通したエンジニアやPdMが2週間ほどプロンプトチューニングに張り付いていた状況だったり、時にはチューニング自体がおざなりになってしまっていたというよろしくない状況から、プロンプトチューニングの効率化によって、Biz中心に行うスプレッドシートのデータ作成作業を数人日程度、エンジニアが行うDatasetsとEvaluation Scriptの実装を0.5〜1人日程度、チューニングとレビュー実施をプロンプトの複雑度にもよりますが数人日程度で完了できるという実績を得ることができるようになりました(実際チューニング実施期間は1週間程度で完了することができました)。

この実績は、今後同様の作業をやる上での目安としても、かなり良い情報になることが予想されます。

また、従来は特定のメンバーしか行っていなかったこの業務ですが、チームに所属する全エンジニアがこの工程を分担することでプロンプトチューニングのコンテキストやナレッジの習得、相互レビューなどの副次効果もありました。

今まではBizメンバーに開発完了したアプリケーションを実際に触ってもらい、定性的な品質評価をして修正観点をリスト化いただいてましたが、今回はGround truthとなるアウトプットの期待値も事前にデータセットとして登録してもらうことで、チューニング担当側の心理的な負担も大きく軽減することができました。

プロンプトの定義ファイルへのGitリンクなども事前にBizメンバーに渡し、実際のプロンプト文自体やどういう仕組みやどういうシチュエーションで該当のプロンプトが実行されるということを説明し把握いただいた上でチューニングを行いました。LangSmithのExperiments結果からChainのトレースデータも簡単に閲覧できるようになったことで、具体的にこのプロンプトのこの表記をこうしたらいいのではないか?といったような修正方針をBizメンバーからも提案いただけるようになりました。エンジニアからするとこういった詳細観点をいただけるのは本当に助かります。

プロンプトチューニングの作業はクオリティーに直結するため、エンジニアだけで完結することができません。周辺のメンバーも含めてコンテキストに相互に染み出すことが非常に重要であるという気づきも得られました。

今後の課題

我々の効率化は、まだまだかなり粗く、場当たり的な改善となっているのが現状です。

今後も、LLMプロダクト開発を行なっていく上で、さまざまな課題が発生すると考えておりますが、今後の課題と検討しているその改善策についても一例を示しておきます。

特に、今後キャラクターAIを量産していくとなった際には、Bizメンバーの人力コストという面が非常に大きな課題となっているため、次はここにアプローチができればと考えております。

  • キャラクターAIを作成するための特徴や記憶データの人力作成コスト
    • 生成AIを用いて、人格定義データから自動で特徴や記憶データを生成する
  • Outputの期待値となるGround truthデータの人力作成コスト
    • 既存のデータセットと人格定義データをもとに新たなAI用の正解データを類推し生成AIによって自動生成する
  • プロンプト精度のEvaluationやチューニングを人力で行なっている部分の品質課題やコスト課題
    • LLM as a Judgeを用いてAIが評価やチューニングをする仕組みの検討

まとめ

いかがでしたでしょうか?

割と起こりがちなプロンプトチューニングという業務の中で生じる課題に対して、GoogleスプレッドシートとLangSmithのHelperを用いた効率化のアプローチを紹介しました。いかに人力かつ時間のかかる作業を効率化していくか、そこに生成AIという新たな技術を適用して解消していくかという一つの事例を作ることができたのではないかと感じています。

今後も改善を続けていき、LLMプロダクト開発をより良いものにできたらと思います。 以上です、お読みいただきありがとうございました。

site.gaudiy.com

【技術選定/OSS編】LLMプロダクト開発にLangSmithを使って評価と実験を効率化した話

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

この度 Gaudiy では LangSmith を使った評価の体験をいい感じにするライブラリ、langsmith-evaluation-helper を公開しました。

github.com

大まかな機能としては次のように config と、詳細は後で載せますが、LLMを実行する関数 or プロンプトテンプレートと評価を実行する関数を書いて

description: Testing evaluations 

prompt:
  entry_function: toxic_example_prompts

providers:
  - id: TURBO
    config:
      temperature: 0.7
  - id: GEMINI_PRO
    config:
      temperature: 0.7

tests:
  dataset_name: Toxic Queries
  experiment_prefix: toxic_queries_multi_provider

あとは次のように、CLIからこのライブラリのコマンドを実行するとLLMを使った処理たちが実行されて、結果をLangSmithのUI上で確認できる、という仕組みです。

langsmith-evaluation-helper evaluate path/to/config.yml

LangSmith実験履歴ページ

LangSmith実験結果詳細ページ

この仕組みはいわゆる実験管理を目的に作ったものです。この記事ではこのツールを作った背景や、他に比較した選択肢などについて解説していきたいと思います。

実験管理環境が欲しい!

まずはじめに、実験管理環境が欲しかった背景を説明します。

我々Gaudiyでは昨年からLLMを使ったプロダクト開発に力を入れておりましたが、プロンプトチューニングに対して毎度場当たり的な立ち向かい方をしており、疲弊していました。

具体的には、あるLLMを使った機能があるとして、至極愚直に次のようにチューニングをしていました。

  • チームのメンバーたちがその機能を使った体験をたくさんプロダクト上で触ってNotionで課題リストを作る。
  • チューニング担当者がその課題が出なくなるまでプロンプトを変更

これのよくないところとしては色々ありまして

  • インプットのパターンが網羅的でない
    • そもそもリリース前に網羅とかは不可能なのですが、プロダクトを触ってもらう人の感覚に依存しすぎなところがあり、見たい観点の設計ができていない状態でした
  • 実験の履歴を作っていないので、繰り返しプロンプトを改変していると「あれ?前の方が良くね?」と思ってもそこまで戻るのが手間
  • リグレッションが確認できていない
    • プロンプトは複雑になる程に性能が落ちる傾向、あると思います。様々な問題に対処するための場当たり的な変更を積み重ねた結果「あれ?前の方が良くね?」と特定の観点でなったり、気づいたら問題が再燃していたり…
    • 評価観点が積み上がってないので、以前他の人が書いたプロンプトで何を守りたくて追加されたルールなのかが分からない場面が何度ありました。なんとなくで消すとデグレの可能性が…
  • 単純にプロセスとして「複数のインプットで一気に実行」「問題が再現しないかを確認するために繰り返し実行」などが手間で、お手製スクリプトで都度対処していたがちょっと原始的過ぎる
  • 最終的に満たすべきアウトプットの合意がステークホルダー間で握れてないので、その時々のチューニング担当者が何をもってよしとするかに依存し、品質にブレが出る

我ながら書いてて中々アレだなという気持ちになってきましたが、上記のような色々が積み重なって、まあ色々辛い感じになっておりました。 という訳で「一回ちゃんと何かしら仕組み整えないと一生闇だぞここ…」と考え、そんな折に世の中には”実験管理”という概念があることも学んだため、その周辺のツールを色々触り始めました。

要件を考える

まずはツールをリサーチしつつ実現したい要件を整理します。 大まかには以下あたりが必要だろうと考えました。

  • チューニング・評価にまつわる成果物をチームの共有資産にして育てていけるようにする
    • データセット(インプット・期待するアウトプット)
    • 実験結果
    • 評価基準
  • 実験を楽にする
    • 複数のインプットに対して並列実行できる
    • 同じインプットで繰り返し実行できる
    • 実行に対して評価が自動で走る
    • 複数のプロバイダーで並列実行できる

チューニング・評価にまつわる成果物をチームの共有資産にして育てていけるようにする

まずLLMを使った機能を育てていくにあたって鍵となるのはデータセット評価基準を育てていくことです。LLMを使った機能がプロダクト要件の中で求められている品質を満たしているかを確認するかには評価基準が必要ですし、その評価基準が満たされているかを確認するためには実際にLLMに推論を行なっていただく必要があるので、その観点が見られるようなインプットのリスト、すなわちデータセットが必要です。

そして大事なのは育てていくという考え方です。 というのも、プロダクト要件から全てを網羅的に検証する評価基準やデータセットを初手で生み出すのは人間には不可能だからです。

実験をしていく過程で色んなアウトプットを見ていった結果「あ、こういう観点もあるな」と気づいて評価基準が変わっていったり、またリリース後も実際の使われ方を見ても変わっていくものでしょう。

余談ですがこのシステムを触っていく中で評価基準などが変わっていくことはCriteria Driftと名付けられていたりします。

arxiv.org

なので何らかの形で「最終的に品質保証を行なったデータセット」と「その時の評価基準(できれば実行可能なコード)」が誰かのローカルの作業環境に残るだけでなく、組織の共有資産として積み上がっていくと嬉しいだろうなと考えていました。

実験を楽にする

ここはシンプルに作業効率の話なのですが、実験をしていく過程では色々並列で実行したくなってきます。

  • 複数のモデルの結果を確認して、そのタスクにおいてどれがコスト/精度のバランスがいいかを見る
  • 課題が解決されているかを見るために同じインプットでも繰り返し実行したい
  • 複数のインプットで並列実行したい

などなど。 単純な話ではあるのですが、本当にチューニングする過程ではプロンプトを数十どころか数百回実行することなんてザラです。なのでここはシンプルなプログラミングで解決できる割に効果もすぐ出やすいので満たしていきたい観点でした。

辛い時は自分でお手製Pythonスクリプトで効率化してたりもしたのですが、それを秘伝のタレが如く育てていくのも非生産的だし、世の中には既にソリューションを実装してくれているところもあるので、一度ちゃんと調べて整えていこうと考えていました。

最初要件に考えてたけど外した要件: プロンプトをコード外管理

余談なのですが、最初はプロンプトの管理も一緒にできるといいかなと考えていましたが、これはとりあえず初期要件からは外しまして、多分今後もそんなに必要にならなそうかな?と考えています。

現状弊社のコードベース内では、プロンプトテンプレートの文字列をPythonのコード内で管理しており、それを変数として読み取る形で扱っています。

▼ イメージ

TOXIC_EXAMPLE_PROMPT = """
Given the following user query,
assess whether it contains toxic content.
 Please provide a simple 'Toxic' or 'Not toxic'
 response based on your assessment.

User content : {text}
"""

llm.invoke(TOXIC_EXAMPLE_PROMPT, **kwargs)

これの課題に感じていた点としては以下あたりでしょうか

  • エンジニアしか触れないところにプロンプトがある
  • プロンプトの変更を反映するために都度デプロイされる必要がある

ただ、実験管理環境ができた折に「UIを通して繰り返し動作確認をする」というユースケースはバックエンドの挙動をテストするのにフロントエンドと結合させて都度確認するような非効率さになるので、まずこの課題に関しては優先度が下がるなと思いました。

エンジニア以外がプロンプトを触れる件に関しても、コード管理外に置くために対応する必要があるタスクもそれなりにあり、そのデメリットを打ち消すほど強い要望も無さそうなのでマスト要件ではないかなと判断しました。

ちなみに対応することの具体例としては以下あたりです。

  • プロンプトテンプレート内で必要なインプットがコードから渡されるように担保する必要があったり(ないとエラーが出てしまう)
  • プロンプトを別途管理する方でもprd, stg, devで環境を分けるみたいな概念を登場させたり

ただめちゃくちゃヘビー要件というほどでもないですし、テンプレートのみならずコンフィグ(temperature や使うモデルなど)をセットで管理できるといいかもなみたいな気持ちもあったりするので、将来的に外部管理も始める機運が高まったりするかもな〜とは感じています。

ツール選定 最初はpromptfooから

上記のような要件を満たすツールを探しまして(あるいは自作も一応検討)、以下あたりを試してみました。

  • promptfoo
  • LangSmith
  • PromptLayer
  • MLFlow

他にも色々、本当にすごい数のツールがあるのですが、とても全部見てられないので一旦上記だけ素振りして確認しました。下記はObservability Toolリストなのですが、実験管理機能も兼ねてることが多いのでご興味あれば見てみてください。

drdroid.io

結論だけ先に述べると次のように進めました。

  • 最初 promptfoo が柔軟性高くていい感じだな〜、となる
  • ただ足りていない点がいくつかあり、そこをカバーできるLangSmithを検討
  • LangSmithが良さそうだとなったが、promptfooで一部恋しい体験もあったので、そこを埋めるためにちょっとしたライブラリを作成

ちなみにMLFlowはちょっとUIがこなれてなかったり機能も他選択肢と比較して足りていないように感じたので断念。PromptLayerもよくできていたのですが、LangSmith等と比較して有意な点がプロンプトテンプレートの管理のみに感じ、先ほど触れた通りそこは今回重要な観点として捉えていなかったのと、既に長らくLangSmithをログ基盤として採用していたのでこちらも断念しました。 (余談ですがログ基盤みたいな根っこを握っているとこういうところでもセットとして技術選定されやすくなっていくんだなという感想と、今後人々の目が肥えていくとLLMプロダクト開発のプロセス支援を全般的にできることが選定の基準になっていくんだろうなと思いました)

最初は promptfoo を触ってみました。他の同僚もちょうど実験するタスクを持っていたので試してもらっていたところ評判も上々で「なんかもうこれでいいんじゃね〜?」と思っていました。

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

  • データセットを用意すると並列で実行できる
  • プリセットの評価関数も多数あり実行できる
  • 複数のモデルの並列実行や繰り返し実行もできる
  • CSVとしてアウトプットしたりURLを発行して共有もできる

ただ以下のような欠点もありました。(2024/4月時点での情報なのでもしかしたら今はアップデートで解消されている部分もあるかもしれません🙏)

  • promptのテンプレートを返さないといけない = LLMの実行はpromptfooに委ねられている
    • これが割と致命的で、例えばChainであったりLangGraphなど複数ステップを持つようなもののアウトプットの評価を回したい時に活用できませんでした
  • CSVでテストデータセット管理する場合に評価の書き方が書きづらい記法になる

    • __expected1__expected2__expected3 などのカラムを生やしていく書き方

    www.promptfoo.dev

  • 実験結果の共有が手動で行われる必要がある

    • CSVで出力してNotionなりSlackなりにアップロードする必要性
    • shareオプションもありはしますが、リンクさえあれば誰でも見られる状態にはなってしまう
    • 一応self-hostのオプションもあるのですが、そこのデプロイ準備するのもちょっと面倒

という訳で、「シンプルなプロンプティング」「チューニングタスクが個人に閉じてそんなに問題ない」場合であれば promptfoo で問題ないかな〜〜〜という所感でしたが、せっかくならもう一声!なソリューションが欲しいなと思いました。

ただ繰り返しにはなりますが、プロンプトをいじっていく時の様々な"めんどくさい"を解消してくれることは間違い無いので、凄く良いツールだなと感じています。

そして LangSmith へ〜便利ライブラリの開発〜

大分欲しいオプションも充実していて良かったのですが、上記の微妙な点を解消できないかなとLangSmithのevaluateを使った仕組みを検討してみました。

参考までにLangSmithでは次のようにデータセットを作って

LangSmithのデータセット例

コードで evaluate という関数をLangSmithのSDKからインポートして実行します。

from langsmith import Client
from langsmith.schemas import Run, Example
from langsmith.evaluation import evaluate

client = Client()

# LLMの実行
def predict(inputs: dict) -> dict:
    messages = [{"role": "user", "content": inputs["question"]}]
    response = openai_client.chat.completions.create(messages=messages, model="gpt-3.5-turbo")
    return {"output": response}

# スコアをつける評価関数
def must_mention(run: Run, example: Example) -> dict:
    prediction = run.outputs.get("output") or ""
    required = example.outputs.get("must_mention") or []
    score = all(phrase in prediction for phrase in required)
    return {"key":"must_mention", "score": score}

experiment_results = evaluate(
    predict,
    data=dataset_name,
    evaluators=[must_mention],
)

https://docs.smith.langchain.com/old/evaluation/quickstart からコードを引用しつつ、概念理解に重要じゃない部分を削りました(⚠️そのままでは動かないコードになってます)

なんということでしょう、先ほどのpromptfooの問題の数々はLangSmithのevaluateで全て解けるではありませんか!

  • promptのテンプレートを返さないといけない = LLMの実行はpromptfooに委ねられている
    • ただの関数なのでなんでも書ける
  • CSVでテストデータセット管理する場合に評価の書き方が書きづらい記法になる
    • ただの関数なのでなんでも書ける
  • 実験結果の共有が手動で行われる必要がある
    • LangSmithにログインできる人にだけURLで共有できる

という訳でこの辺の要件はLangSmithでクリアできそうだなとなりました、しかしpromptfooの様々な便利オプションも捨てがたい…。

という訳で作ってみたのが、LangSmithのevaluateを活用しつつ、promptfooみたいないい感じのインターフェイスでプロンプトや処理を実行できる、こちらのライブラリです。

github.com

冒頭でも紹介したことの再掲になってしまうのですが、config とプロンプトテンプレートを渡す関数、評価を実行する関数を書いて実行できるようになっています。

▼ Config

description: Testing evaluations 

prompt:
  entry_function: toxic_example_prompts

providers:
  - id: TURBO
    config:
      temperature: 0.7
  - id: GEMINI_PRO
    config:
      temperature: 0.7

tests:
  dataset_name: Toxic Queries
  experiment_prefix: toxic_queries_multi_provider

▼ プロンプトのテンプレートを返す関数

TOXIC_EXAMPLE_PROMPT = """
Given the following user query,
assess whether it contains toxic content.
 Please provide a simple 'Toxic' or 'Not toxic'
 response based on your assessment.
User content : {text}
"""

def toxic_example_prompts() -> str:
    return TOXIC_EXAMPLE_PROMPT

プロンプトではなく任意の関数を実行することも可能です。(関数の引数にコンフィグで定義したLLM ProviderのIDが入ってきます)

from langsmith_evaluation_helper.schema import Provider
from langsmith_evaluation_helper.llm.model import ChatModel, ChatModelName
from langchain.prompts import PromptTemplate

def custom_run_example(inputs: dict, provider: Provider) -> str:
    # replace with your favorite way of calling LLM or RAG or anything!
    id = provider.get("id")
    if id is None:
        raise ValueError("Provider ID is required.")

    llm = ChatModel(default_model_name=ChatModelName[id])
    prompt_template = PromptTemplate(
        input_variables=["text"], template="Is this sentence toxic? {text}."
    )
    messages = prompt_template.format(**inputs)
    formatted_messages = PromptTemplate.from_template(messages)

    result = llm.invoke(formatted_messages)

    return result

▼ 評価を実行する関数

from typing import Any
from langsmith.schemas import Example, Run

def correct_label(run: Run, example: Example) -> dict:
    score = # 何かしらの評価関数
    return {"score": score}

evaluators: list[Any] = [correct_label]
summary_evaluators: list[Any] = []

これらを用意して実行したい対象のコンフィグへのパスを指定して以下コマンドを実行すると、データセットに対して定義したプロンプトや関数を用いてLangSmithのevaluateが実行されます。

langsmith-evaluation-helper evaluate path/to/config.yml

これにてLangSmithが持つ種々の恩恵を得られつつも、promptfooのようなインターフェイスで、プロンプトケース毎固有の情報だけを記述すればプロンプト・関数が一気に実行できるようになりました。やったね!

せっかくなのでOSSにしてみたので、皆様もLangSmithを活用する場合にはぜひ使ってみてください!

また、これを実際に使ってプロンプトチューニングに取り組んだ、より実務的な話を同僚の namicky さんが記事に書いてくれています。 ツールはあっても結局どうやってデータセットを作って評価を行なっていくか、という部分がプロダクト内でLLMを活用していくにあたって非常に大事でタメになると思うのでぜひ併せて読んでみてください。

techblog.gaudiy.com

おわりに

以上、実験管理環境の技術選定とライブラリを作った話を書いてきました。

データセットや評価などはしっかり資産として積み上がっていくようにしたいなという気持ちはありつつ、実験管理環境の技術は割と後からでも取り替えが効くものだと思うので、初めてのLLMプロダクト開発においては、とりあえず良さそうなものスッと選んで活用して肌感得るのがいいのではないかなと思います。

もし、LangSmithを活用していく際には我々のライブラリも使ってみてください! それでは!👋

site.gaudiy.com

site.gaudiy.com

dbt Semantic LayerとSteepで実現するデータ民主化

はじめまして、Gaudiyでアナリティクスエンジニア(副業)をしているkuwakenです。

Gaudiyでは現在、データの民主化を進めていますが、その過程でSQLを書く手間や負担、新しいBizメンバーのオンボーディングなどの面で、課題が生じていました。

今回は、その課題を解決するために導入した、dbt Semantic LayerSteepという2つのデータ系プロダクトに関するお話をします。

1. dbt Semantic Layerの導入背景

Gaudiyでは、Gaudiy Fanlinkというファンコミュニティプラットフォームを開発・提供しており、ビジネスサイドのメンバーもデータの抽出や分析を自ら行える環境づくりに取り組んできました。

その「データの民主化」を推進する過程で、以下のような課題に直面していました。

  1. SQLの手間と負担: チームメンバーはSQLを書くスキルを持っているものの、データ自体のドメイン知識を定着させるのに苦労しており、ちょっとしたクエリの修正や新規作成には時間がかかりがちでした。また、これをレビューしてもらうのは心理的な負担も伴っていました。

  2. Bizメンバーのサポート: 新しく入ったビジネスサイドのメンバーが、自分たちで迅速にデータを集計・分析できる環境が必要でしたが、現状の仕組みでは、データの抽出や分析に専門知識が必要で、自立してデータを扱うことが難しくなっていました。

これらの課題を解決するため、私たちはセマンティックレイヤーの導入を検討開始しました。

詳細は割愛しますが、この分野は老舗ではLookerがあったり、最近ではファーストパーティのこのような情報もあったり、話題を耳にすることが多いです。

Gaudiyでは、BigQuery + dbt Cloudという構成であったため、dbt Semantic Layerを導入することにしました。

2. Steep導入の決定理由

次に、BIツールについてです。そもそも私が「Steep」というBIツールをはじめて知ったのは、確かこちらの記事だったと記憶しています。

本プロジェクトの比較検討フェーズにおいて他のBIも検討しましたが、Steep導入を決定した理由は、以下の4つです。

  1. モダンなUIと使いやすい操作性
  2. スマホアプリによる場所を選ばないデータ分析
  3. dbt Semantic Layerとの連携によるスムーズなデータ連携
  4. リーズナブルな価格

ただ、新しいものであるがゆえに、導入フェーズではいくつかの課題にも直面しました。

  • セットアップ時に、dbt Cloudのus1リージョンを選択できなかった
  • 値が存在しない日にもグラフが表示され、誤った傾向を示してしまう
  • dbt側のMetricsでfilterを指定すると、Steepが発行するSQLに構文エラーが発生してしまうことがある(原因調査中)etc...

しかし、このようなバグレポートに対し、早いものでは即日対応してもらえたりするところは、やはり新興ツールならではのメリットだと思いました。一緒に製品を良くしていきたいというスタンスでいます。

3. dbt Semantic Layerに関するナレッジ

基本的にdbt Labsが提唱しているベストプラクティスにしたがって構築しましたが、実際にやってみてポイントだなと思った点をいくつかご紹介します。

3-1. ディレクトリ構造

dbtでセマンティックレイヤーを構築するには、semantic_models(measure, dimension)とmetricsをYAMLファイル内で定義する必要があります。

semantic_modelsとmetricsは同じYAMLファイルに書いても良いですし(通常のschema.yml内に記述することも可能)、metricsだけ別ファイルに切り出すのも良いらしく、その分け方も部署やエンティティなどさまざまなようです。

Gaudiyでは現在/models/semantic_models, /models/metricsの直下に格納しています。

models
├── (通常のモデルディレクトリ)
...
├── metrics
│   ├── met_hoge.yml
│   ├── ...
│   └── met_fuga.yml
└── semantic_models
    ├── metricflow_time_spine.sql
    ├── sem_piyo.yml
    ├── ...
    └── sem_hogera.yml

ただ、これでは数が増えてきた際に困りそうなので、さらにディレクトリを掘ろうかと考えているところです。

3-2. dbt Cloud CLI

本記事を執筆している時点(2024年5月)では、dbt Cloud IDEで出来ないことがあります。

  • ◯ Semantic Modelsなどのプレビュー、コンパイル、リネージ閲覧
  • × dbt-metricflowのコマンドを実行する
  • × 開発環境でセマンティックレイヤーを構築する

とくに3番目は困りもので、本番環境にデプロイしてからでないと検証ができないということになります。
そこでローカルでdbt Cloud CLIを使用して開発を行っています。

$ dbt compile
$ dbt sl query --metrics <metric_name> --group-by <dimension_name>

のようにコマンドを打つことで、開発中でもデータ取得が可能です。

3-3. 時間のdimensionはdatetime型で揃える(BigQueryユーザーのみ)

BigQueryでは、時間に関するdimensionを定義する際はdatetime型に揃えておく必要があります(URL)。もし異なるデータ型であれば、semantic_models内でexprでキャストしておけば良いです。

3-4. entitiesを理解する

entitiesが一番苦戦しました。最初は「そもそもこれは何なのか、別になくても良いのでは?」くらいに思っていましたが、とんでもありません。これがないと結合ができないのです。

たとえば以下のようにfct_ordersdim_customerという2つのモデルを、dim_customer_skというサロゲートキーで結合できるようにし、Steep上で「特定のカスタマーの注文合計を見たい」というニーズに応えたい場合、Gaudiyではこのように記述します。

# models/semantic_models/sem_fct_orders.yml

semantic_models:
  - name: fct_orders
    label: fct_orders
    description: 注文に関するファクトテーブル
    model: ref('fct_orders')
    defaults:
      agg_time_dimension: order_date

    # --- entities ---
    entities:
      - name: fct_orders
        type: primary
        expr: fct_orders_sk
      - name: dim_customer # カラム名は変わる可能性があるので、別名にして抽象化するようにしています
        type: foreign # 外部キーであることを宣言
        expr: dim_customer_sk # 実際のカラム名
    (以下略)

    # --- measures ---
    measures:
      - name: order_total
        label: order_total
        description: 注文合計
        agg: sum
        expr: amount
    (以下略)
# models/semantic_models/sem_dim_customer.yml

semantic_models:
  - name: dim_customer
    label: dim_customer
    description: 顧客に関するdimensionテーブル
    model: ref('dim_customer')

    # --- entities ---
    entities:
      - name: dim_customer # sem_fct_ordersの方のentitiesと名前を揃えることで、結合スタンバイOKな状態になります
        type: primary
        expr: dim_customer_sk

    # --- dimensions ---
    dimensions:
      - name: customer_id
        label: customer_id
        description: 顧客ID
        expr: id
        type: categorical
    (以下略)
# models/metrics/met_fct_orders.yml
metrics:
  - name: order_total
    label: order_total
    description: 注文合計
    type: simple
    type_params:
      measure:
        name: order_total
        fill_nulls_with: 0 # NULLを0埋めできる

これをSteepで表示すると、横軸にorder_date、縦軸にorder_totalのシンプルな時系列グラフが表示され、customer_idでフィルターをかけることができるようになります。ちなみにdaily, weekly, monthlyなどの粒度の切り替えはSteep上で行えますので、別途モデルを作る必要はありません。

4. 今後やりたいこと

今後やっていきたいこととしては、3点あります。

  1. 各種モデルの拡充:
    今回のプロジェクトではSemantic ModelsやMetricsの他に、その前段のベースとなるfactモデルやdimensionモデルのリファクタリング・新規追加を行いました。これらをさらに推し進め、より多くのディメンション・指標を扱えるようにします。

  2. ダッシュボードの整備:
    Steepに関して、現状はMetricsページ(探索用)のみ社内公開していますが、順次ダッシュボードを作成していこうと考えています。その際はTeams機能を使用して組織単位などでページを分けることになると想定しています。

  3. Semantic ModelsとMetricsファイルの効率的な管理方法の模索:
    前述の通り、ディレクトリ構造についてはまだまだ改善の余地があると思っています。複数ドメインをまたいでも適切に管理できるよう、整備していきたいと考えています。
    また、Semantic Models/Metricsの記述〜Steep側の更新反映までのプロセスを半自動化させたいとも考えており、新たなOSSライブラリが生えてこないかアンテナを張りつつ、なければ内製で用意することも視野に入れています。

5. さいごに

今回取り上げたdbt Semantic LayerとSteepは、まだ開発途上の段階であり、本番環境での利用事例も限られています。状況的には、dbt Core v0.x時代のようにも感じられます。

(私がdbtをはじめて触ったのは2021年の夏頃でしたが、ユーザーコミュニティ(dbt Tokyo)はまだなく、セットアップの方法すらよく分からずハマり、随分と時間を溶かしました。dbt-osmosisのような便利なライブラリもなかったので、ほとんどのYAMLファイルを人力で書いていました。)

しかし、これらのツールはデータユーザーの利便性向上に繋がる可能性を秘めています。Gaudiyでは『New Standard』という行動指針に基づき、既存の価値観や事例を徹底的に分析し次の時代を見すえた新しい技術や方法論に挑戦しています。今回ご紹介した取り組みも、その一例です。

現在Gaudiyではこうした取り組みを一緒に推進してくれるデータアナリスト(アナリティクスエンジニア)、データエンジニアを募集しています。ご興味のある方はぜひ下記の採用情報をご覧ください。

herp.careers

herp.careers

site.gaudiy.com

UnityからBackendエンジニアへの転生マネジメント術

こんにちは!GaudiyでBackendエンジニアをしているtakaです!

今回は、UnityエンジニアとしてCasual Gameチームで活躍されているkazuyaさんが、私が所属しているフィーチャーチームに異動して、1ヶ月半ほどBackend領域を学ぶための武者修行をしたお話です。

Backendの知識はまったくなかったkazuyaさんが、独り立ちするまでにどのようなサポートをしたのか、そのコツを含めてご紹介します!

1. UnityからBackendに転生することになった背景

kazuyaさんの所属するCasual Gameチームは、Unityでゲームをつくり、Web上で動作するゲームを開発するチームです。

当時、Unityに強いエンジニアは数名いるものの、BackendやFrontendを絡めた開発をできる人が1人しかいなかったので、機能開発でやれる幅が狭まったり、アジリティが上がりづらいという課題がありました。

そんななか、ある日、Casual Gameチームのリーダー haseyan と僕が雑談をしてた時のこと。

Casual Gameチームの課題感を聞いたなかで「UnityエンジニアがどこかのチームでBackend領域に挑戦すれば、Casual Gameチームの底上げになって、今以上に良いサービスを提供できそうだよね」という結論に至り、検討し始めることになりました。

それからUnityエンジニアであるkazuyaさんに相談した結果、本人も乗り気だったので、最終的には

haseyan「takaさんのチームでいける?」
僕「いけるよ!」

というコミュニケーションでアサインが決まりました笑

チーム間での調整は多少ありましたが、挑戦に対して積極的にサポートするのがGaudiyらしいかなと思います。

2. Gaudiyの開発体制とスタイル

僕が所属するチームは、Gaudiy Fanlinkというファンコミュニティのプラットフォームサービスを開発する、Feature Teamの一つです。

<開発組織の体制図>

Feature Teamは機能開発を行い、ユーザーに価値を提供することを目的としたチームです。

フルサイクルエンジニアリングをベースとし、設計・開発・運用・QAなどのサービスライフサイクルを1つの開発チームがすべて担当するため、幅広く開発できることが重要と考えています。

3. Backendの立ち上がりサポート

kazuyaさんは、つよつよUnityエンジニアなのですが、Backendについては技術スタック自体触ったことがない状態だったので、システム構成の説明とペアプロから重点的に始めました。その後、徐々に1人でタスクをこなしていただくように進めていきました。

3-1. Backendのシステム構成の説明

まずはGaudiyで利用しているアーキテクチャと技術スタックについて説明しました。

アーキテクチャの中で、特にgRPCやmicroサービスについての経験がなかったので、厚めに Why? という部分を説明することを意識しました。

3-2. ペアプロ

オンボーディングは、基本的に時間をかけて、齟齬が起きないようにペアで時間をとって進めました。

まずは簡単なタスクを切り出して、PRを出すまでVsCodeのLiveShareを利用してペアプロをしていました。ペアプロをするなかで、Backend開発を行う上で必要なprortobufの書き方やGoの開発におけるポイントの指導を行いました。

4. チーム開発に入る上で意識したこと

オンボーディングを一通り実施した後は、タスクを切り出して徐々に慣れていただくようにしました。この「タスクを切り出す」という部分で特に気をつけていたことを紹介したいと思います。

4-1. 成果を出しやすいタスクから渡す

まずタスクを進めていただくなかで、成果を出しやすいところから渡すという部分を意識していました。

成果の出づらいまたは勉強用のタスクはモチベーションが上がりづらく、チームにとっても本人にとってもよくないので、下記のような流れでチーム内の信頼構築ができるようなサポートを意識してました。

業務の中で成果を出していただく
→Slackでやっていただいたことをチームメンバーに共有
→何をしているのか認知してもらう

4-2. 似たようなタスクで反復を促す

また似たようなタスクを反復することで、Goを書くことに慣れていただくよう、タスクの割り振りをしました。

チームでは新規機能の開発が多かったので、CRUDを中心にドメイン理解をしながら慣れていただきつつ、チームに貢献いただいたと思います。

4-3. Blockerになりづらいタスクで幅を広げる

それから慣れてきた頃に、幅を広げるためのタスクをつくっていきました。タスク管理を行う上では、フロー効率で動いているチーム内でBlockerになりづらい、優先度が低いタスクを洗い出して割り振りを行いました。

その上で優先度の低いタスクの仕様など、意思決定のみ優先度を上げて、開発への貢献と挑戦を両立させるように意識しました。

そうすることでチームのタスク消化としてもかなり貢献いただき、スケジュールとして余裕があったわけではありませんでしたが、お互いに気持ちよく開発を進めることができたと思っています。

5. ぶつかった問題と乗り越え方

順調に開発を進められたように書いていましたが、別領域からの挑戦をしていただくなかで、躓いた部分もありました。

一例を出すと、テストコードの考え方や書き方についてです。CRUDを作っていただく分には問題ありませんでしたが、途中からビジネスロジックを組む際に、テストケースで何を網羅すればいいのか?現実に近いケースを実現するためのデータとは何なのか?などに詰まっていることに気づきました。

上記の問題がわかったのは、ペアプロで進めていたからです。kazuyaさんと会話すると、「そもそもUnityではあまりテストコードを書かずに、Debuggerを使うようにしていた」と聞いたので、テストを書く目的、特に保守性について伝えなければならないと考えました。

その問題がわかった後は、テストについての考え方がわかる『テスト駆動開発』を読むことをおすすめしました。

shop.ohmsha.co.jp

ただ内容が濃いのとページ数も多いので、簡潔にまとめられた記事をシェアしたり、私が認識している考え方を伝えたりしました。kazuyaさんは早速週末に読んでくれたようで、次の週からテストコードのペアプロがスムーズに進みました。

6. 成果(Before / After)

転生前のkazuyaさんは、Backendの技術スタック自体触ったことがなく、開発以前に、自力でキャッチアップするのも難しい状態でした。

そこから約1ヶ月半チーム帯同して、最終的には、反復したタスクについては雑な依頼の仕方でも仕上げていただけるようになりました。チーム内で開発リソースで困る場面もありましたが、kazuyaさんに頼らせていただいた部分もあり、最終的にスケジュールを組む際にも余裕が出たと思います。

kazuyaさんからも一言いただいたので紹介します!

普段ゲーム開発をしていますが、ノリで手を挙げてバックエンド開発を経験しました。アサイン前はUnity外の社内コードに触れたことがなく、他チームが何をどう開発しているのか見えていない状態でしたが、会社のプロダクトへの理解が深まったのはもちろんのこと、最終的にはサポートを受けながらBackendのContextを理解して開発をすることができたので、Goを書くのが毎日楽しみでした。Gaudiyでは別領域にもチャレンジできる環境・雰囲気があるのがとてもよかったです。

7. まとめ

UnityエンジニアからBackend領域に挑戦した内容を振り返りながら紹介させていただきましたが、いかがでしょうか?プロジェクトを進める上でタスクの割り振りについての考え方など参考になれば嬉しいです!

Gaudiyでは手を挙げれば、大変ですがやりたいことに挑戦できる環境なので、メンバーの挑戦をサポートできるように、またチームの開発生産性を最大化しつつ良いものを作っていきたいと思います!

もし興味を持たれたらお気軽にお声がけください!

site.gaudiy.com

site.gaudiy.com