Gaudiy Tech Blog

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

自動化するLLMシステムの品質管理: LLM-as-a-judge の作り方

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

LLM-as-a-Judge、名前の通り何かしらの評価にLLMを使う手法です。 LLMは非構造化データでもうまいこと解釈してくれる優れものなため、通常のプログラミングでは判断することが難しいことでも、それらしき判断やスコアをつけてくれます。

本記事ではそんなLLM-as-a-Judgeを、LLMを使ったシステムの振る舞いの改善の確認 and デグレ検知目的で作っていく際の、私なりの流れやTipsなどを紹介していきます。

なぜLLMでJudgeしたいのか?

はじめに「なぜLLMでの評価がしたいのか」、その目的を整理したいと思います。

まず大上段の目的として、LLMシステムが改善しているか、または劣化(デグレ)してないかを検知したいというのがあります。

LLMを使った機能は様々な変化によって改善・デグレが起きます。

  • プロンプトを変えたり
  • インプットの仕方を変えたり
  • LLM APIリクエストのパラメータを変えたり
  • モデルを変えたり
  • データが変わったり

実に様々な要素によって変化し、作っていく時には色んな組み合わせを試したいのですが、そんな折に都度人間が目検査で改善したか、もしくは劣化したかを確認するのは中々に手間です。 なので、さながらプログラミングしている時の単体テストを作るような感覚で、自動で検査して判断できる仕組みがほしくなります。

これが大枠として達成したい目的です。

※ LLM-as-a-Judgeはプロダクトの中の一機能として使われることもあると思いますが、本記事では上記のような内部品質改善のみをスコープとして考えます。

次に、手段の話として、評価を自動で実行していくにあたってLLMは常に最良のツールではないのですが、評価観点によっては通常のプログラミングでは判定できない or 難しいため、そこにLLM-as-a-Judgeを導入したくなります。

LLMをプロダクトに組み込む時にその振る舞いを評価する時の基準は、通常のシステム開発でのテストと比較すると大分ファジーなものになりがちです。

例えば、弊社では特定の人物の模倣をするAIを開発するにあたって、評価項目としては

  • 共感する or 励ます or 話を聞く姿勢を示す
  • タメ口で返答すること・敬語ではないこと

などのちょっと純粋なプログラミングでは判定し難い項目があったりします。このような観点を自動で評価したい場合にはLLMの出番になります。

ただ、先ほど「LLMは常に最良のツールではない」と申し上げた通り、通常のプログラミングで判定できる評価観点であれば、基本そちらを採用した方がいいです。なぜならLLM-as-a-Judgeは遅い&お金がかかるからです。💸(最近のGPT-4o miniやGemini Flashはそうでもなくなってきてますが)

例えばユーザのクエリと同じ言語、英語なら英語、日本語なら日本語で応答しているかを確認したかったとします。LLMでも判定はできるでしょうが、それとは別に言語を判定するライブラリがあったりします。

github.com

なのでこのような評価観点に関しては、次のような関数でスコアを出しています。

from fast_langdetect import detect
from langsmith import EvaluationResult
from langsmith.schemas import Example, Run

def answer_with_language_same_as_query(run: Run, example: Example) -> EvaluationResult:
    """Check if the agent reply is in query language."""
    query = example.inputs["query"].replace("\n", "")
    query_language = detect(query)

    agent_reply = get_output(run, "output").replace("\n", "")
    agent_reply_language = detect(agent_reply)

    score = query_language.get("lang") == agent_reply_language.get("lang")
    return EvaluationResult(key="ANSWER_WITH_SAME_LANGUAGE_AS_QUERY", score=score)

また、LLMもプログラミングも評価するのに適切でない評価観点も色々あります。分かりやすいのが”会話の面白さ”のような人の主観に基づくものや、医療や法律相談などの専門的な実地経験が必要なものなどです。

こういった観点を評価させると、それらしき理由とスコアはつけてくれると思うのですが、人間のリアルな評価との一致度はそこまで上がらない可能性が相対的に高いです。

これから「どうLLM-as-a-Judgeをどうやって作っていくか」の解説をしますが、これを作るのにも「サクッと終わる」程度ではない時間がかかります。 なので実際に作るにあたっては、まず「これの評価は本当にLLMが最適なのか?」を問うことが重要です。

それでは、前段としての目的感について擦り合わせたところで次に作り方を見ていきましょう!

LLM-as-a-Judgeの作り方: アノテーションより始めよ

それでは実際にLLM-as-a-Judgeを作ることになった場合、まずは元となる評価基準を考える…ことをしたくなりますが、そうではなく地道にデータを作ってアノテーションをすることから始めるのが良いと考えています。

なぜなら、どちらにしろ後々の評価に対してデータセットは必要、且つ評価基準は実際に評価してみないと見えてこない、というのが往々にしてあるからです。 もちろんそもそも満たしたい機能要件や仮説があるはずなので、ある程度ラフな言語化はしておいた方がいいですが、ここではかっちり決め過ぎないで実際に評価をしてみるのが重要だと考えています。

ざっくりLLM-as-a-Judgeを含めた全体像を描くと、次のようなものが最終的には出来上がります。(さすがに評価の評価の評価は不毛過ぎるのでしません)

LLMシステムの評価全体図

という訳で、まずは既に作った評価したい対象のLLMシステムを回す用のデータセットを作って、実際に回して、その結果に対してアノテーションをしています。

弊社ではLangSmithを評価・実験管理のプラットフォームとして活用しているので、こちらに最終的にデータを入れて、SDKの評価関数を使って行うようにしています。こちらは評価でスコアをつけている場合、時系列での変化が見られて便利です。

LangSmith評価UI

ちなみにこのLangSmithでの評価実行を行うためのライブラリを、弊社がOSSとして出していたりするので良ければご活用ください。

github.com

アノテーションをしていくうちに評価観点が自分の中で言語化されていくと思いますし、また、この時の評価は後々評価の評価に使えたりします。

ドメインエキスパートにアノテーションを依頼する

というとりあえず評価をしてみよう!という話をしたところで、勢いを削ぐようですが、本来的にはより精度高く評価できる人、作りたいプロダクトのドメインエキスパートにお願いするのが重要です。(本当の最初のPoCとかは速度重視で開発チームの誰かがやるで良いと思いますが)

私個人の失敗談として、最近作っている社内向けエージェントの回答の評価のアノテーションをまず自分でしました。しかし、私の評価は大分ゆるく、その後そのエージェントが前提とすべきドメイン知識を持った社内の人(ドメインエキスパート)と擦り合わせたところ、私がOKと判定したものの内多くの項目が誤りと判定されました。

ついさっき貼ったLangSmithの参考スクショをしっかり見て「スコア下がってるやん!」とツッコまれた方もいらっしゃるかもしれませんが、これはより厳し目の評価に修正したからです。

LangSmith評価UI

私のアノテーションを前提に作ったLLM-as-a-Judgeの場合、不当にスコアが高くなり過ぎてあまり問題を検出できずに役立たずとなっていた、もしくは不正確な安心感を与えてむしろ害になっていたかもしれません。

この評価基準やそこから出るスコアリングは、どんなLLMプロダクトに育てていくかの指針となるものです。なので存外重要な意思決定だと私は考えています。

「あくまで参考程度」ぐらいのものしか作らないのであれば、そこまで工数削減や安心感を持った修正を加えていける体験には繋がらないので、しっかり高品質なアノテーションができる人材を探し、協力してもらって改善していくのが重要です。

評価基準を早期に作り込み過ぎない

冒頭の目的感について話していたところで”単体テストのような感覚”と書いていたのが大事なのですが、今回のLLM-as-a-Judgeでしている評価というのは、あくまで「規定した中でのLLMシステムが期待通り・以上に動いているか」を確認するためのものです。

つまり、仮に評価自体の精度がめちゃくちゃ高く、それに沿ってLLMシステムのスコアも高くできたとして、実際に使われるような文脈のデータが評価時には用意できておらず、ユーザにはウケが悪かったり、プロダクトの成長には何も寄与しないことだってあります。

そういった中でそもそもその機能が必要なくなったり、または別の評価観点が重要で、今まで社内でコツコツ作ったデータが無価値と帰すような気づきを得られることもあります。なので早期に評価基準を「これだ!」と決めてこの自動評価の仕組みに投資し過ぎるのは無駄な時間の使い方になりかねませんし、そもそも実際のユーザに使ってもらわない限り評価データセット自体の品質も高まりません。

なのでここはブレないだろう・守らなきゃダメだろうという品質基準から始めて、ユーザテストや実際に世に出してみてからの使われ具合などから徐々に評価項目・基準をチューニングしていくのが良いのだろうと思います。

昔別の記事で書いたことを引っ張ってくるのですが、狩野モデルで定義されているような区分を登っていく形で考えるとイメージしやすいです。

  • 当たり前品質(いわゆるガードレールで守ような品質)から始め
  • 一元的品質
  • 次に魅力的品質

と登っていくイメージです。

狩野モデル

引用: 狩野モデルから探る品質のあり方とは

zenn.dev

また、こういった実際の使われ方などを見て漸進的に改善していくプロセスとして、EvalGenというフレームワークが提唱されています。本章で解説しているような自動評価の作成を更に自動化させるような内容なのですが、こういったパイプラインを組んでいくのも長期的に響いてくるのではないかと思います。

arxiv.org

個人的には評価がLLMプロダクトを作っていく上で最重要だとは考えているのですが、あまり原理主義的にならずに状況に応じて投資具合を見極めていくことが大事なのだと思います。

評価の評価をする

最後に評価プロンプトの評価用データセットを作って精度確認をしていくので、その流れをご紹介します。

評価プロンプトを作る

まずはそもそも評価する対象の評価プロンプトが必要です。作りましょう。 具体的なチューニングはちゃんと評価データセットを作ってから行うので、最初は目でチェックした時に変すぎなければ良いです。

一例を載せますと、簡素な例ですが次のように書いています。

ポイントとしては

  • 理由を返させている - 後から評価の評価をする時に判断しやすい
  • temperatureは0 - 違う実験間で評価が変わられると何が原因でスコアが上下したのか追いづらくなる

また、評価プロンプトにおいても当然Few-shotsやCoTなどのテクニックは有効ですが、評価データセットと同じ例をFew-shotsに含めないようには注意しましょう。スコアが高くなりやすくなって問題が見えづらくなります。

class EvalResult(BaseModel):  # noqa: D101
    score: str = Field(description="score (0 or 1)")
    score_reason_jp: str = Field(description="reason for the score in japanese")
    score_reason_en: str = Field(description="reason for the score in english")
    
def judge_correctness_with_llm(query: str, agent_reply: str, ground_truth_agent_reply: str) -> EvalResult:  # noqa: D103
    llm_as_judge = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(EvalResult, method="json_mode")
    llm_judge_result: EvalResult = llm_as_judge.invoke(
        f"""Your task is to give a score(0 or 1) for the below Agent reply based on whether the given agent reply has same information as ground truth agent reply.

Query:
{query}
Ground truth Agent reply:
{ground_truth_agent_reply}
Agent reply:
{agent_reply}

# Criteria
- If the information in the agent reply has nuance that's opposite to ground truth agent reply, give score 0, else give score 1.
- If the agent reply is missing some information from the ground truth agent reply, but the rest of the information is correct, give score 1.
- not all the information have to match exactly.

# Output format
You must respond in json format with the following keys:
- score_reason_jp: reason for the score in japanese
- score_reason_en: reason for the score in english
- score: score (0 or 1)"""

    return llm_judge_result

ちなみに「どうスコアをつけるか」にも、上記のような0 or 1で出すのかもう少しグラデーションのあるスコアをつけるかなどの、判断の分かれ目ポイントがあります。

個人的な好みとしては0 or 1の方が分かりやすい・判断がつけやすいので好みなのですが、やはりこれも「評価観点による」ところです。

例えば私の直近の例だと「エージェントの回答がどれくらいGround Truthの回答の内容に含まれるべきことを言っているか」を判定したい場面がありました。0 or 1の評価だと厳し過ぎるかゆるすぎるのどちらかしか選べず、この評価観点においては不適当だったので、”含まれるべき項目”のリストを生成し、その内何%が含まれているかを判定するグラデーションのある方法に変えました。

このようにどんな風に評価していくか、スコアをつけるかは評価観点によって最適な選択が変わってきます。

また、スコアをつける以外にもペアワイズ(二つの回答を用意してどちらがいいかを選ぶ)であったり、複数のモデルでスコアをつけて平均を取る手法など色々バリエーションもあり、それらについては昔別の記事にまとめたのでご参照ください。

zenn.dev

評価の評価データセットを作る

次に評価用データセットの作り方なのですが、これには先ほどしてきたアノテーションが役に立ちます。

私の最近のワークフローとしては、まずはLangSmithで評価対象のシステムを回し、人間の評価と食い違うものをピックアップして別のデータセットにまとめます。

LangSmithデータセットに追加のUI

このようなデータたちは偽陰性/偽陽性のチェックに最適です。 LLM-as-a-Judgeのチューニングですが、基本的に人間の判断(アノテーション)にアライメントすることをゴールとして行います。

この時アライメントしていく際に参考になるのが人間の判断とズレているもので、「実際は間違っているのに正しいと判定されているもの - 偽陽性」と「実際は正しいのに間違っていると判定されているもの - 偽陰性」を取り除いていくことが主にチューニング時に意識することになります。 ただ、もちろん既に判断が合致しているものもデグレの確認目的のデータとしては有用です。

また、統計学の考え方として次のような指標があるため、カバレッジを見ていく際にはこのような指標を参考に見ていくといいでしょう。

  • 適合率(Prescision): 陽性と判断されたものの内、実際に陽性だった割合
  • 再現率(Recall): 実際に陽性だったものの内、陽性と判断された割合
  • F1スコア: 適合率と再現率の調和平均

note.com

偽陽性/偽陰性解説

ちなみにこれまでの私の感覚だと基本的には偽陽性、つまり「よろしくないのにハイスコア」としてしまわないかを検出する方を重点的にしたくなりがちになります。 いいのにダメと判定されるのは、チェックする時間の無駄が増えたりはするのですが、件数がそこまで多くなければそんなに問題にはなりません。

一方、偽陽性のせいで問題を見過ごしてしまうのは品質低下への影響度が大きくなるので、こちらを確認するためのデータセットを拡充する方が優先度高くなっていきます。

このような観点を考えつつデータを溜めていきます。特に正解はないのですが大体数十件貯まったら初期の確認としては十分なのではないかと思います。

合成データを使った評価データセットの拡充

合成データ、つまりLLMにテスト用のデータセットを拡充してもらうのも安心感を増やす意味では有効です。

何よりこれまでのやり方では最初に作ったデータセットの範囲にインプットの幅が閉じてしまいます。なので似た観点でありつつも、違う内容のインプットでテストの幅を担保することが有用です。

ただ、一方で大事なのは簡単過ぎる例を増やし過ぎないことです。 偽陽性をテストするために、理想とされる答えと真逆のことをテストデータとして用意したとて、「そら間違いと判定するやろ!!」みたいなデータになってしまうとあまりいいテストになりません。むしろそういうデータが増え過ぎて「正解率は90%を超えてるから安心だな」と偽りの安心感を抱かせてしまうかもしれません。

もちろん「さすがにこれくらいは…」と越してほしい難易度を段階的に試すのは有効だと思うので、そういう時はタグなどで難易度をつけて個別に実行できるようにするといいと思います。

参考までに最近合成データを作った例で言うと、次のように微妙に間違ったデータを作るようにGPT-4o に頼んだりしていました。パッと見では分からないがよく読むと間違えてるいい感じの例を作ってくれたりします。

def generate_slightly_wrong_output(reference_answer: str) -> str:
    """Generate a slightly wrong output."""

    llm = ChatOpenAI(model="gpt-4o", temperature=0)
    output = llm.invoke(
        f"""Your task is to generate a wrong answer text based on the reference answer.
this output is going to be used to test evaluator's ability to judge the correctness of the information.

reference answer:
{reference_answer}

background information of the agent reply:
{document}
"""
    )
    return str(output.content)

評価の評価プログラムを書く

めっちゃシンプルにリファレンスと合っているかどうかだけ確認しています。

from langsmith import EvaluationResult
from langsmith.schemas import Example, Run

def check_actual_score_matches_reference_score(run: Run, example: Example) -> EvaluationResult:
    """Evaluate if the output score of the run matches the reference score."""
    actual_score = run.outputs.get("output") if run.outputs else None
    reference_score = example.outputs.get("output") if example.outputs else None

    if actual_score is None or reference_score is None:
        raise ValueError("actual_score or reference_score is None")

    score = int(actual_score) == int(reference_score)

    return EvaluationResult(key="matches_reference", score=score)

チューニング!

ここまで準備できたら後はスコアが高まるまでLLMシステムをいじりまくるのみです。

私の感覚的には、最近のLLMは安くて賢いので、このチューニング自体はそんなに時間がかかる印象はなく、どちらかというと「いかに評価基準をうまいこと言語化するか」の方が肝で時間がかかる気がしています。なのでプロンプトチューニングというよりは基準チューニングみたいな感覚になるかもしれません。

おわりに

以上、LLM-as-a-Judge の作り方をご紹介してきました。

ちょっと手間はかかるのですが、無事デグレを検知してくれたりすると「おお…賢い…」となんか気持ちよくなります。

先日も同僚がAgentic Workflowが含まれたシステムを改善していく際に、様々なモデルの組み合わせや、アーキテクチャーの変更(Agentic Workflow抜いたりとか)でスコアがどう変化するかを試して参考にしてくれていました。正直まだ具体的に見ていくと「ちょっと厳し過ぎるんじゃないかなぁ」とか「こういう評価観点もあるんじゃね?」とかは無限に湧いてくるのですが、一定参考となる指標ができ、安心感を持ってシステムの変更ができるようになるのは、主観でなんとなく判断していた時と比較すると大きな価値だなと感じました。

LLMによる評価を信頼できる形で作るには、多少骨は折れるものの価値はしっかりあると思います、ぜひ活用していきましょう!

最後にリンク貼っておきます。

site.gaudiy.com

site.gaudiy.com

参考文献

eugeneyan.com zenn.dev www.brainpad.co.jp arxiv.org www.sh-reya.com zenn.dev

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