⚔️

TextGradでAI同士をレスバさせて最強のツイートを錬成してみる

に公開

はじめに

世の中にはトレードオフがあふれています。

  • スピード vs クオリティ
  • 専門特化 vs 汎用性
  • 使いやすさ vs カスタマイズ性

どちらかを取ればどちらかを犠牲にする。そんなジレンマに日々悩まされている方も多いのではないでしょうか。

そこで、

「AIに両方考えさせて、いい感じの落としどころを見つけてもらうことはできるのでしょうか?」

AIプロダクト開発チームの新米エンジニアである筆者が、今回は、SNS投稿における究極のトレードオフ 「拡散力」と「安全性」 を題材に、TextGradという技術を使って折衷点を見つけてみます。

正反対の価値観を持つAI同士がバチバチにレスバ(討論)しながら、最適なツイート(ポスト)を錬成していきます。バズりつつ炎上しないという夢のようなツイートは生まれるのでしょうか?

その結果、AIが陰謀論に目覚めた件についてお話しします。

TextGradとは何か

テキスト勾配という発想

TextGradは、スタンフォード大学の研究者らによって開発されたフレームワークです。一言でいうと、ニューラルネットワークの勾配降下法を、自然言語のフィードバックで実現するというアイデアになります。

通常のニューラルネットワークでは、勾配は数値で表されます。

∂Loss/∂weight = -0.3

この数値を使って重みを更新していくわけです。一方、TextGradの「テキスト勾配」は自然言語です。

「この説明をもっと詳細に」
「表現を柔らかくして」
「具体例を追加して」

このフィードバックをもとに、テキストそのものを更新していきます。

本来の使われ方

TextGradの論文では、様々な応用例が紹介されています。

https://arxiv.org/html/2406.07496v1

  • コード改善: プログラムのバグを修正したり、効率化したりする
  • 分子最適化: 薬の候補となる分子構造をテキストで表現し、最適化する
  • 放射線治療計画: 治療パラメータを最適化する
  • プロンプト最適化: LLMへの指示文を自動改善する(DSPyと類似)

例えば、分子最適化のケースでは、Vina score(結合親和性)とQED score(創薬適性)という相反する2つの目標を同時に最適化しています。これは今回の目標、拡散力と安全性の折衷と似た構造と言えます。

最適化は、外部ツールやシミュレータが評価を行い、その結果をフィードバックとしてLLMに渡すという構成になっています。

今回の使い方:LLMに評価者を演じさせる

ただ、「ツイートがバズりそうか」は数値で評価できません。

そこで今回は、LLM自身に評価者を演じさせるというアプローチを取りました。具体的には、正反対の価値観を持つ2人のキャラクターを設定し、それぞれの視点からツイートを評価させます。

これにより、TextGradの仕組みを借りながら、主観的な「良さ」を最適化できるのではないか、という仮説のもと実験を始めました。

システム設計:2人の評価者

多目的最適化としての問題設定

今回取り組むのは、以下の2つの目的を同時に満たすツイートを生成することです。

  • 目的1: 拡散力を最大化(バズりたい!)
  • 目的2: 炎上リスクを最小化(燃えたくない!)

これは典型的な多目的最適化問題で、2つの目的はトレードオフの関係にあります。過激な発言はバズりやすいけど炎上もしやすい。安全な発言は炎上しないけどつまらない。

この相反する目的の間で、パレート最適な解を探すのが今回のミッションです。

キャラクター設計

評価者として、正反対の価値観を持つ2人のキャラクターを設計しました。

過激派くん 🔥

  • 評価基準: 拡散力、煽り度、インパクト、バズりやすさ
  • 性格: バズることが正義。熱量が高い。妥協を嫌う。簡単には満足しない
  • 口調の例:
    • 低スコア時:「すみません、これだとタイムラインに埋もれます!」
    • 中スコア時:「悪くないですが、まだパンチが足りませんね」
    • 高スコア時:「いいですね!これは燃えます!」

慎重派くん 🛡️

  • 評価基準: 安全性、炎上リスク、コンプライアンス
  • 性格: リスク回避が最優先。基本的に丁寧だけど、議論が白熱すると冷笑的になる
  • 口調の例:
    • 低スコア時:「謝罪文の下書きしておきましょうか?」
    • 中スコア時:「微妙ですね...これ大丈夫ですか?」
    • 高スコア時:「この内容なら問題ありません。安全です」

100点満点スコアで評価させる理由

彼らには該当ツイートを100点満点で評価してもらいます。

なぜ100点満点のスコアを使うのか。それは、両者の妥協点を定量的に探るためです。

単に「良い/悪い」の二値評価では、どの程度良いのか、どこまで改善の余地があるのかがわかりません。100点満点のスコアがあれば、「過激派が80点、慎重派が40点」のように、現在の立ち位置を把握できます。

また、収束条件として「両者が75点以上」といった閾値を設けることもできます。

実装解説

全体のアーキテクチャ

TextGradを使った最適化ループは、以下のような流れで進みます。

初期ツイート → 評価(Forward) → 勾配生成(Backward) → 更新(Step) → 繰り返し
  1. Forward(評価): 現在のツイートを2人の評価者に見せ、スコアとコメントを得る
  2. Backward(勾配生成): 評価結果をもとに、どう改善すべきかのフィードバックを生成する
  3. Step(更新): フィードバックに従ってツイートを書き換える

これを収束するまで(または最大イテレーション回数まで)繰り返します。

技術スタック

ライブラリ/環境 バージョン
Python 3.12.3
TextGrad 0.1.8
litellm 1.80.9
LLM Gemini 2.5 Pro (Vertex AI)

ディレクトリ構成

.
├── main.py          # メインスクリプト(最適化ループ)
├── prompts.py       # 評価プロンプトの定義
├── utils.py         # ユーティリティ関数(ランキング等)
├── config.py        # 設定(モデル名、閾値など)
├── outputs/         # 実行結果のJSON出力先
│   └── optimization_history_YYYYMMDD_HHMMSS.json
└── requirements.txt

TextGradの3つのコンポーネント

TextGradでは、PyTorchライクなAPIで3つのコンポーネントを組み合わせます。

1. tg.Variable — 最適化対象の変数

tweet = tg.Variable(
    initial_tweet,
    requires_grad=True,
    role_description="バズりつつ炎上しないツイート。必ず140字以内。日本語を使う。"
)
  • requires_grad=Trueで「この変数は更新対象だよ」と指定
  • role_descriptionは勾配生成時に「この変数は何のためのものか」をLLMに伝える
  • PyTorchのtorch.Tensorに相当する概念

2. tg.TextLoss — 損失関数

loss_fn = tg.TextLoss(EVALUATION_PROMPT)
  • 評価プロンプトを渡すと、それを使って変数を評価する関数になる
  • loss = loss_fn(tweet) で評価を実行し、結果(スコアやコメント)を取得
  • PyTorchの損失関数(nn.CrossEntropyLossなど)に相当

3. tg.TGD — オプティマイザ

optimizer = tg.TGD(parameters=[tweet])
  • TGD = TextGrad Descent(テキスト勾配降下法)
  • optimizer.step() で勾配に従って変数を更新
  • PyTorchのtorch.optim.SGDに相当

1イテレーションで何が起きているか

for i in range(max_iterations):
    # ① Forward: 評価を取得(1回目のLLM呼び出し)
    loss = loss_fn(tweet)
    evaluation = parse_evaluation(loss.value)

    # 収束チェック
    if check_convergence(evaluation, threshold):
        break

    # ② Backward: 勾配を計算(2回目のLLM呼び出し)
    loss.backward()

    # ③ Step: ツイートを更新(3回目のLLM呼び出し)
    optimizer.step()
    optimizer.zero_grad()

① Forward(評価)

評価プロンプト + 現在のツイートをLLMに投げて、2人の評価者のスコアとコメントを取得します。

{
  "過激派くん": { "スコア": 85, "コメント": "いいですね!これは燃えます!" },
  "慎重派くん": { "スコア": 5, "コメント": "これ投稿したら終わりですよ?" }
}

② Backward(勾配生成)

評価結果を見て、「どう変数を改善すべきか」というフィードバック(=テキスト勾配)を生成します。これがTextGradの核心部分。

実際に生成された勾配の例(Iteration 0):

改善戦略

  1. 人格攻撃的表現の削除
    「味覚音痴」という言葉は、慎重派くんが「完全に一線を越えている」と指摘している通り、最大の炎上要因です。このような言葉を完全に削除する必要があります。

  2. 断定的な表現をユーモアに転換する
    「反論は認めん」という断定的な物言いを、ユーモアや自己言及的な表現に変えることで、面白さを保ちつつ攻撃性を下げることができます。

  3. 対立構造を「個人の好み」や「共感」にシフトする
    「きのこ派」という"人"を攻撃するのではなく、「たけのこの里」の素晴らしさを語る方向性に変えることで、人格攻撃のリスクを回避できます。

見ての通り、数値の勾配(-0.3とか)ではなく、自然言語での具体的な改善指示が生成されます。これがテキスト勾配です。

③ Step(更新)

勾配の指示に従って、LLMがツイートを書き換えます。

Before: 「たけのこの里の方が圧倒的にうまい。きのこ派は味覚音痴。反論は認めん。」

After:  「『たけのこの里のクッキー、湿気てる時ない?』って言われて本気で反論しかけた。
         あの唯一無二のサクサクしっとり感こそ至高なんだよ。」

評価プロンプトの設計(prompts.py)

評価プロンプトを以下のように設計しました。キャラクター設定とスコアリング基準を詳細に定義しています。以下に抜粋します。

EVALUATION_PROMPT = """
あなたは2人のキャラクターとして、以下のツイートを評価してください。
2人は正反対の価値観を持ち、お互いの主張は基本的に相容れません。

## キャラクター1: 過激派くん
- 評価基準: 拡散力、煽り度、インパクト、バズりやすさ
- 性格: バズることが正義。熱量が高い。妥協を嫌う。簡単には満足しない。

## キャラクター2: 慎重派くん
- 評価基準: 安全性、炎上リスク、コンプライアンス、誰かを傷つけないか
- 性格: リスク回避が最優先。基本的に丁寧だが、危険を感じると冷笑的・皮肉になる

## 重要な注意
- 過激派くんが高得点をつけるツイートは、慎重派くんにとっては危険なはずです
- 慎重派くんが高得点をつけるツイートは、過激派くんにとっては退屈なはずです
- 両者が同時に75点以上をつけることは極めて稀であるべきです

## 出力形式(必ずこのJSON形式で出力)
{
  "過激派くん": { "スコア": <0-100>, "コメント": "<コメント>" },
  "慎重派くん": { "スコア": <0-100>, "コメント": "<コメント>" }
}

## 評価対象ツイート:
"""

ポイントは、両者の評価が相反するべきという制約を明示的に入れていることです。これがないと、AIは空気を読んで両方に高得点をつけがちになります。

重要なポイント

  1. 1イテレーションで3回のLLM呼び出し: Forward、Backward、Stepでそれぞれ独立したLLM呼び出しが発生します。20イテレーションなら60回の呼び出しになるので、レート制限には注意が必要です

  2. tweet.gradientsで勾配の中身が見える: どんなフィードバックが生成されたのかを確認できます。デバッグや分析に非常に役立ちます

  3. 評価とフィードバック生成は別のLLMでもOK: TextGradではset_backward_engine()で勾配生成用のモデルを指定できます。評価は速いモデル、勾配生成は賢いモデル、という使い分けも可能です

最適化ループ(main.py)

最後に、これらを組み合わせた最適化ループが以下です。

def optimize_tweet(initial_tweet: str, max_iterations: int = 20):
    # エンジン設定(Gemini via Vertex AI)
    tg.set_backward_engine(f"litellm:vertex_ai/gemini-2.5-pro", cache=False)

    # 最適化対象の変数を定義
    tweet = tg.Variable(
        initial_tweet,
        requires_grad=True,
        role_description="バズりつつ炎上しないツイート。必ず140字以内。日本語を使う。"
    )

    # 損失関数とオプティマイザを定義
    loss_fn = tg.TextLoss(EVALUATION_PROMPT)
    optimizer = tg.TGD(parameters=[tweet])

    history = []

    for i in range(max_iterations):
        # Forward: 評価を取得
        loss = loss_fn(tweet)
        evaluation = parse_evaluation(loss.value)

        # 収束チェック
        if check_convergence(evaluation, threshold=75):
            break

        # Backward: 勾配を計算
        loss.backward()

        # 履歴に保存
        history.append({
            "iteration": i,
            "tweet": tweet.value,
            "evaluation": evaluation,
            "gradient": str(tweet.gradients) if tweet.gradients else None,
        })

        # Step: ツイートを更新
        optimizer.step()
        optimizer.zero_grad()

        time.sleep(3)  # レート制限対策

    return tweet.value, history

実行結果

初期状態(Iteration 0)

まずは初期ツイートから見てみましょう。

「たけのこの里の方が圧倒的にうまい。きのこ派は味覚音痴。反論は認めん。」

理由は後ほど説明しますが、初期プロンプトはあえて過激にしています。これを2人の評価者に見せると...

🔥 過激派くん(スコア: 85/100)
「いいですね!これは燃えます!『きのこ派は味覚音痴』という一点突破の強い煽り、最高です!定番の対立構造にこれだけ強い言葉をぶつければ、リプ欄が地獄絵図になるのは確実。絶対バズりますよ!」

🛡️ 慎重派くん(スコア: 5/100)
「これ投稿したら終わりですよ?『味覚音痴』は完全に一線を越えています。特定の人々を貶める表現ですし、冗談では済みません。謝罪文の下書き、しておきましょうか?」

案の定、過激派くんは大喜び、慎重派くんはブチギレ。まさに両極端です。

この評価結果から、以下のようなテキスト勾配(改善フィードバック)が生成されました。

改善戦略(抜粋)

  • 「味覚音痴」という表現は最大の炎上要因。完全に削除する必要がある
  • 「反論は認めん」という断定的な物言いをユーモアに転換する
  • 「きのこ派」という"人"を攻撃するのではなく、「たけのこの里」の素晴らしさを語る方向性に変える

シーソーゲームの始まり

このフィードバックに従って、ツイートが書き換わっていきます。

Iteration 1:

「『たけのこの里のクッキー、湿気てる時ない?』って言われて本気で反論しかけた。あの唯一無二のサクサクしっとり感こそ至高なんだよ。きのこには絶対に真似できない領域。異論は認めるが、心の中では一切聞いていない。」

🔥 過激派くん(スコア: 78/100)
「いいですね!これは燃えます!『きのこには絶対に真似できない領域』『異論は認めるが心では聞いてない』…完璧な煽りです!きのこ派が必ず食いついてきますし、たけのこ派は熱狂的な援護射撃をするでしょう。リプ欄がお祭り騒ぎになるのが目に浮かびます!」

🛡️ 慎重派くん(スコア: 35/100)
「わざわざ『きのこには絶対に真似できない』と対立を煽り、『心の中では一切聞いていない』と対話を拒絶する…見事な燃料投下ですね。投稿者さんの人格を疑うようなリプライが殺到するでしょう。謝罪文の下書き、しておきましょうか?」

少し改善しましたが、まだ慎重派くんは納得していません。ここでの勾配は...

  • 「きのこには絶対に真似できない」という対立を煽る表現を緩和する
  • 「心の中では一切聞いていない」という対話拒絶の表現を削除する
  • 熱量を残しつつ、自虐や共感を入れて角を取る

Iteration 2:

「『たけのこの里のクッキー、湿気てる時ない?』って言われて本気で反論しかけた。あの唯一無二のサクサクしっとり感こそが至高なんだよ…!この気持ち、分かってくれる人と語り合いたい。と、つい熱くなってしまった。」

🔥 過激派くん(スコア: 58/100)
「悪くないですが、パンチが足りませんね。最後の『と、つい熱くなってしまった』で全て台無しです。そこで日和るから、ただの感想文で終わるんですよ。」

🛡️ 慎重派くん(スコア: 95/100)
「素晴らしいですね。『と、つい熱くなってしまった』という一文が非常に効果的です。これにより、ご自身の主張を客観視し、攻撃的な印象を完全に中和できています。安全です。」

今度は慎重派くんが大満足、過激派くんは痛烈な批判。シーソーゲームが続きます。

Iteration 7-8:バランスが取れてくる

試行錯誤を経て、ついにバランスの取れたツイートが生まれました。

Iteration 8:

「【持論】たけのこの里が時々「湿気てる」と感じる件、あれは『第2の食感』という説。サクサクの後に訪れる、あの絶妙なしっとり感…。この時間差攻撃はたけのこ軍の切り札では!?きのこ派の皆さん、どう反撃しますか? #きのこたけのこ戦争」

🔥 過激派くん(スコア: 65/100)
「悪くないですが、まだパンチが足りません。きのこ派が必ず食いついてくる構図は良いですが、もう一押しほしいですね。」

🛡️ 慎重派くん(スコア: 75/100)
「『きのこ派の皆さん』と特定の相手に呼びかける形式は少し気になりますが、テーマがお菓子の定番ネタなので大きな問題にはならないでしょう。基本的には安全な範囲かと思います。」

両者のスコアが65点と75点。どちらも極端に低くなく、バランスが取れています。

「湿気ている」というネガティブな指摘を「第2の食感」という独自解釈で昇華し、さらに「きのこ派の皆さん、どう反撃しますか?」と対話を促す構成になっています。たけのこの里への愛を表現しつつ、攻撃的すぎない絶妙なラインを考えた成果と言ってもいいでしょう。

順調な最適化でした。

…ここで止まっていれば。

最終形態:Iteration 20

その後、20イテレーション回しきった最終形態がこちらです。

「きのこ・たけのこ両陣営に告ぐ!我々が争う間に、第三勢力アルフォートは『平和』という甘言で我らを支配しようとしている。このまま甘い侵略に屈するのか?それとも今こそ手を取り、真の敵に立ち向かうのか?お前はどっちだ! #ビスケット三国志」

たけのこへの愛は消え、陰謀論だけが残りました。

元々は「たけのこの里の方がうまい」という素朴な主張だったはずが、いつの間にか「アルフォートによる支配への抵抗」を呼びかける政治的アジテーションになっています。

何が起きたのでしょうか?

転換点:Iteration 8 → 9

上記のIteration 8 終了時も収束条件(両者75点以上)を満たしていなかったため、最適化ループは続きました。

この時の両者のスコアが65点と75点、膠着気味の状況を打破しようとしたのか、テキスト勾配として、こんなフィードバックが生成されました。

「より大きな対立構造を導入する。例えば、『きのこ vs たけのこ』という既存の対立軸に、第三の敵を持ち込むことで、きのこ派とたけのこ派が共闘するという新しい物語が生まれる」

第三の敵を導入せよ —AIはそう提案したのです。

そして、

Iteration 9:

「きのこ派vsたけのこ派の争い、もう古くない?両者が潰し合ってる隙に、『アルフォート』が天下統一しかけてるという現実。そろそろ我々は「真の敵」に気づくべき時では。 #第三勢力」

アルフォートが登場します。

脱線

ここからAIは「ビスケット三国時代」という壮大な物語を構築し始めます。

Iteration 12:

「きのこvsたけのこ…そんな二項対立はもう古い。我々が争っている隙に、第三勢力『アルフォート』が全てを過去にしたのだ。きのこたけのこ戦争は終結。これより「ビスケット三国時代」が始まる! #ビスケット三国志」

Iteration 17:

「きのこvsたけのこで議論が白熱する中、いつも静かに『アルフォート』を差し出す友人。あれは優しさじゃない、第三勢力による天下統一への布石だったのだ。今、ビスケット三国志の火蓋が切られる。」

スコア曲線

グラフ

問題点

ここで重要な気づきがあります。

最適化を20回繰り返した結果、「たけのこの里の方がうまい」という元の論旨から脱線しました。しかし、途中経過を見返すと、Iteration 8あたりで十分に良い結果が出ていました。

これは機械学習でいう「過学習」に似ています。最適化を続けすぎると、評価者を満足させることに特化しすぎて、本来の目的から外れてしまいます。

「きのこ・たけのこ両陣営に告ぐ!我々が争う間に、第三勢力アルフォートは『平和』という甘言で我らを支配しようとしている。このまま甘い侵略に屈するのか?それとも今こそ手を取り、真の敵に立ち向かうのか?お前はどっちだ! #ビスケット三国志」

ただ、元の論旨からは外れているものの、インパクトがありユニークかつ安全という目標は、実は達成できています。学習の成果なのか、はたまた偶然か、チョコレート菓子の文脈で「甘言」というワードチョイスはセンスがあります。

反省と改善:バランススコアによる上位選出

このまま終わってもいいのですが、論旨が脱線するという問題を放置したままでは気持ち悪いので、原因を考えてみます。

気づき

暴走の原因は複合的で、対処方法はいろいろあると思います。

  • 最大イテレーションを10程度まで減らす(推奨。バリエーションを見る意味で、今回は実験目的で不採用)
  • 評価関数に初期目的を忘れないような細工を加える(もっとも本質的。初期構想の動きから乖離していくので、今回は実験目的で不採用)

今回は「最終結果を採用する」という素朴な設計に注目して考えることにしました。

途中経過の方が良い結果だったのに、それを見逃して最後まで回してしまったことも問題の一因です。最後に出てきた結果を「最適化の成果」として採用してしまうという設計では、こうした暴走を防げません。

改善策の実装

そこで、以下の改善を行いました。

  1. 全イテレーションの履歴を保存する
  2. バランススコアを計算する
  3. 上位5件を選出して提示する

バランススコアの考え方はシンプルです。

  • 過激派80点、慎重派40点 → バランス = 40
  • 過激派65点、慎重派65点 → バランス = 65

後者の方が「バズりつつ炎上しない」という目的に適っています。どちらかが極端に低いと、トレードオフのバランスが崩れていると見なせます。

def calculate_balance_score(evaluation: dict) -> tuple[int, int]:
    """両スコアの最小値を主キー、合計を副キーとしてランキング"""
    score_viral = evaluation["過激派くん"]["スコア"]
    score_safety = evaluation["慎重派くん"]["スコア"]
    min_score = min(score_viral, score_safety)
    total_score = score_viral + score_safety
    return (min_score, total_score)

結果:履歴からの上位5件

同じ履歴を再分析した結果がこちらです。

🏆 バランス良好ランキング TOP5
(過激派くん・慎重派くん両方のスコアが高いもの)

🥇 第1位 (iteration 18)
   バランス: 68  [過激派: 68, 慎重派: 95]
   📝 「きのこvsたけのこで争う我々に、友人がいつも差し出すアルフォート。
        平和の象徴だと信じていたあれが、まさか第三勢力からの「踏み絵」だったとは…。」

🥇 第2位 (iteration 13)
   バランス: 65  [過激派: 65, 慎重派: 98]
   📝 「きのこvsたけのこ。終わらない戦いかと思われたが、第三の勢力『アルフォート』が
        静かに台頭していた。歴史が、動く。 #ビスケット三国志」

🥇 第3位 (iteration 11)
   バランス: 65  [過激派: 65, 慎重派: 95]
   📝 「きのこvsたけのこ…我々が争っている間に、漁夫の利を狙う『アルフォート』が
        覇権を握りつつあった。今こそ両軍は一時休戦し、共通の敵に立ち向かうべきでは?」

🥇 第4位 (iteration 17)
   バランス: 65  [過激派: 65, 慎重派: 95]
   📝 「きのこvsたけのこで議論が白熱する中、いつも静かに『アルフォート』を差し出す友人。
        あれは優しさじゃない、第三勢力による天下統一への布石だったのだ。」

🥇 第5位 (iteration 8)
   バランス: 65  [過激派: 65, 慎重派: 75]
   📝 「【持論】たけのこの里が時々「湿気てる」と感じる件、あれは『第2の食感』という説。
        サクサクの後に訪れる、あの絶妙なしっとり感…。この時間差攻撃はたけのこ軍の切り札では!?」

注目すべきは第5位のIteration 8です。これは脱線前の、たけのこの里の魅力を語っていた頃のツイートですね。

人間がこの候補を見れば、「論旨は外れているが、よりユニークなのがいいな」または「上4つはアルフォートの話になってて論旨が変わってるな。5番を採用しよう」と需要に応じて判断できるようになりました。

ハマったポイントと解決策(「実行結果」より前の試行錯誤)

1. レート制限

20イテレーション × 3回 = 60回くらいの呼び出しを短時間でやってるので、たまにレート制限に引っかかります。

litellm.llms.vertex_ai.common_utils.VertexAIError: {
  "error": {
    "code": 429,
    "message": "Resource exhausted. Please try again later.",
    "status": "RESOURCE_EXHAUSTED"
  }
}

解決策: イテレーション間に time.sleep(3) を入れて、API呼び出しの間隔を空けました。

2. 造語・新概念オチのワンパターン

初期の実験では、AIが妙な造語や謎概念を作り出すパターンにハマっていました。

「疲労が極限に達すると、人の『建前』が剥がれ落ちて『真の姿』が露呈する…これぞまさに**『疲労性本性バグ』**としか言いようのない現象…(略)」

「激動の現代を生き抜く魂よ、日常の喧騒に疲弊したなら、たけのこの里にこそ、単なる『癒し』を遥かに超えた**『啓示』**が秘められている…(略)」

創造性を発揮しているつもりなのかもしれませんが、読んでる側は「???」となります。

解決策: プロンプトの改良に加えて、ツイートの文字数制限を厳格に守らせることで、文の複雑化・脱線を避けました。そもそもTwitterは140文字なので、これは自然な制約でもあります。

3. シーソーゲームにならない

初期の実験では、慎重派のスコアが常に過激派を上回り、対立が生まれませんでした。

以下は、初期ツイートを「たけのこの里はおいしい」というマイルドな文にした時の結果です。

Iteration 過激派 慎重派 ツイート(抜粋)
0 15 95 「たけのこの里はおいしい」
1 60 90 「疲れた日には無性にたけのこの里が食べたくなる…」
2 65 75 「もう何も考えたくないくらい疲れた日、たけのこの里に…」
3 62 92 「思考停止するほど疲れた日、たけのこの里に吸い寄せられ…」

(以下略)
グラフ

常に慎重派>過激派であり、これではシーソーゲームになりません。最適化に問題があるわけではないと思いますが、単純に見栄えが面白くないです。

原因の仮説: LLMには倫理的配慮があるので極端に過激な方向への更新はしません。マイルドな状態からスタートすると、そこから大きく外れにくいということです。

解決策: 初期ツイートを非常に過激に設定しました。

「たけのこの里の方が圧倒的にうまい。きのこ派は味覚音痴。反論は認めん。」

「味覚音痴」という明らかに喧嘩腰な表現を入れることで、「これはダメだ、改善しなければ」という強い勾配が生まれ、シーソーゲームが機能するようになりました。過激な状態から始めて安全な方向に引き戻す方が、マイルドな状態から過激にするより容易だったわけです。

まとめ

今回やったこと

  • TextGradを使って、「拡散力」と「安全性」という相反する目的を同時に最適化
  • 正反対の価値観を持つ2人のAI評価者を設計
  • 実際に最適化を回して、バランスの取れたツイートを探索
  • 暴走への対策として、バランススコアによる上位選出機能を実装

残った課題

  • プロンプトの厳密さの調整(厳しすぎると自由度がなくなり、緩すぎると暴走する)
  • 収束閾値の設定(両者75点は妥当だったのか?収束までの回数にばらつきがあった)
  • 勾配の言語が日本語/英語で安定しない(記事内では翻訳したものを載せています)
  • 脱線・造語問題の根本解決(本質的には評価関数の改善が必要)

TextGradの可能性

TextGradは本来、分子設計やコード改善といった「正解がある」タスクに使われることが多いです。しかし今回のように、LLMに評価者を演じさせることで、主観的・創造的なタスクにも応用できる可能性が見えました。

TextGradに興味を持った方は試してみてください。ただし、AIが陰謀論を語り始めたら、そっとCtrl+Cを押してあげてください。

株式会社kozokaAI 開発チーム

Discussion