AIの女の子がわいわい競馬予想するシステムを個人展示したら倍率2000倍の馬券が当たってしまった (2)マルチエージェント構造編

こんばんは。前回、

https://qiita.com/oktamajun/items/34c2287a9f4d9ac9c298

を出したところ
- **記事20万PV**
- **Qiitaトレンド1位**
- <b><a href="https://gigazine.net/news/20241119-gallopia-ai/">GIGAZINEに補足される</a></b>

など、身辺へのバレが順調に加速しており、日々戦々恐々としている玉置絢(@OKtamajun)です。
<br>
#### ◆Qiitaで前回記事をご覧になった皆様へ
Qiitaのノリがよくわからなくて、なんだか古のインターネットのテキストサイトみたいなノリで書いた導入だったにも関わらず、いいねを多くつけてくださり、温かくこの界隈に受け入れてくださった皆様、本当にありがとうございました。
AI実装に日々努力しておられるエンジニアの皆様の気分転換になるジャンキーな記事になれていれば幸いです。

#### ◆リアル知り合いの皆様へ
 **払戻金はSSRスタミナエアシャカール完凸とSSR十王星南に消えてますので
 ご飯は奢れません👍️**
 <br>

# 今回のテーマ：マルチエージェントをどう動かすか
前回の記事では、なぜ「マルチエージェントLLMオーケストレーション」という手法にしたのかという点からご説明しました。
今回はマルチエージェントにするとして、**どのエージェントをどう動かす設計なのか**という【骨組み】 をご説明します。

:::note warn
前回に引き続き：作者はAI・競馬、そして今回は統計の分野においても素人です。そもそも例の展示は「勝てる予想AI」を作るコンセプトではなく「キャラ同士の予想会話を楽しむ」ものでした。要するに、話半分でお読みください。
:::

## 部員たちはどう活動しているか
前回、以下のような概念図をご紹介しました。
```mermaid
flowchart TD
    A[みちる] --> B[しらべ]
    B -->|迷走を監視| A
    
    A -->|血統の質問| C[ちあき]
    A -->|調教の質問| D[はやて]
    A -->|騎手等の質問| E[ゆかり]
    A -->|過去走の質問| F[あゆみ]
    A -->|過去LAPの質問| G[つばさ]
    
    C -->|自論を主張| H[ふみの]
    D -->|自論を主張| H
    E -->|自論を主張| H
    F -->|自論を主張| H
    G -->|自論を主張| H
    
    H -->|情報を整理| A

    style A fill:#ff9999
    style B fill:#99ff99
    style H fill:#9999ff
```
<br>
この構造を別の角度から見てみましょう。

「リーダーのみちる」と「回答してくれる部員たち」の相互関係にだけフォーカスして図解すると

```mermaid
flowchart LR
    michiru(<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/4e200e3b-785e-e215-6ba7-cb124c6b2e6a.png">みちる（リーダー）)
    member(<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/cb897264-237d-339e-da1e-7c27183ae8b3.png">部員たち)
    db[(競馬情報<br>DB)]
    chat(チャット<br>タイムライン)
    storage(データ置き場)
    fumino(<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/fbb6215b-90cc-c468-2172-66def556d7b1.png">
ふみの（書記）)
    whiteboard(ホワイトボード)

    michiru -->|問い合わせ| member
    member -->|SQLクエリ| db
    db -->|統計データ| member
    member -->|考察| storage
    member -->|解説| chat
    chat -->|要約| fumino
    fumino -->|書き出し| whiteboard
    storage -->|情報付与| michiru
    whiteboard -->|情報付与| michiru
    style michiru fill:#ff9999
    style member fill:#99ff99
    style db fill:#9999ff
    style fumino fill:#CCC
```
こんな感じです。

## 「構造化データ」をなるべく準備する

ところで、部員たちがそれぞれの専門性をもってアドバイスが出来ているのは主に以下の２つの実装が機能しているからです。
 - **キャラごとにユニークな情報源**
     - DBにアクセスして、それぞれの分野の統計データをまとめ考察材料にする
 - **キャラごとにユニークなプロンプト**
     - 統計データに独自の目線で着目し、考察し、みちるにその分野ならではの助言をする

ここでポイントなのは **統計処理などLLMでない仕組みで出来ることは可能な限りデータベース処理で行う**ということです。LLMに突っ込む手前が大事で、予想に有効なデータをなるべく機械的（プログラム的）な処理で安定して抽出するように努め、LLM処理部分は「データに着眼する」ことにだけ集中するという分担です。上の図の「統計データ」という矢印と、「解説」「考察」という矢印の２段階で問い合わせに答えているところに注目してください。 LLM（部員たち）に**直接生データを与えてもいいところを、あえてSQLで一次統計をする**ということが重要で、LLMは統計した結果データを解説・考察するという役割分担を強く意識しています。

```mermaid
flowchart LR
    subgraph いいね
        direction LR
        member2(<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/cb897264-237d-339e-da1e-7c27183ae8b3.png">「これ読めば後は<br>考えるだけじゃん！」)
        db2[(競馬情報<br>DB)]
        member2 -->|<b><u>SQLを使う</u></b><br>フィルタリング・ソート<br>+ CTEで事前処理| db2
        db2 -->|考察にぴったりな<br>抽出＆計算処理済データ| member2
    end
    subgraph だめ
        direction LR
        member(<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/cb897264-237d-339e-da1e-7c27183ae8b3.png">「探して読めるけど、<br>数学は苦手だから<br>適当に考えちゃお～」)
        db[(競馬情報<br>DB)]
        db -->|生のテーブルデータ| member
    end
    style いいね fill:#e6ffff,stroke:#0000FF,stroke-width:2px
    style だめ fill:#ffe6ff,stroke:#FF0000,stroke-width:2px
```


## 統計と着眼の使い分け
### LLMに苦手な数学をさせる意味はない、得意なことだけさせよう
確かに最新のLLMモデルは数学的な計算や統計についても一定のパフォーマンスを示してくれますから、統計処理を行っていないデータの羅列だけを与えて、統計タスク自体までLLMにさせることも可能です。しかし、正確性・スピード・コストの面から考えるとまずは **LLMの前に普通の統計を頑張る**ということが重要です。

### 例：サンプル数不足問題
出走した全レースのうち「１着か２着に入れた」という結果を残せたレース数の割合を競馬では「連対率」と呼びます。この連対率から有利不利を考えることを展示した「GALLOPIA」では重視しているのですが、この手の確率は **サンプル母数が少なすぎると極端な数字になっちゃってアテにならない**という問題があります。

<table>
<caption>例えば…</caption>
<tr><td>
ある有名な馬AとBがいます。２頭とも今は種牡馬（お父さん）になっており、産駒（子ども）たちも大きくなって競走馬として活躍しています。

ただ、Aの産駒は既に沢山いるのでレースで1,000回走っていますが、Bはまだ種牡馬になった直後で、産駒も多くないので10回しかレース記録がありません。

AとBの産駒の連対率は以下のようになっています。
<table>
<tr><td colspan=5><font color=blue>連対率＝（１着回数＋２着回数） ／ 全出走数</font></td></tr>
<tr><td><b>親</b></td><td><b>産駒が１着</b></td><td><b>産駒が２着</b></td><td><b>それ以外</b></td><td><b>連対率</b></td></tr>
<tr><td>A</td><td>50回</td><td>200回</td><td>750回</td><td><b>25%</b></td></tr>
<tr><td>B</td><td>1回</td><td>4回</td><td>5回</td><td><b>50%</n></td></tr>
</table>

このとき<b>「Bの子どもたちはAの子どもたちよりも強い！」</b>と言い切れるでしょうか？
</td></tr><table>

このように十分なサンプルがない情報は、合っているかもしれないけどまだ偏ってるだけかもしれないので、信用が置けないわけですが、統計をとる切り口や上記の例のように最近データが採れ始めた情報などの都合で頻繁にサンプル不足が発生するのが競馬のデータにおいては現実です。

このような問題に対処するために展示したシステムでは「<a href="https://ja.wikipedia.org/wiki/%E3%82%A6%E3%82%A3%E3%83%AB%E3%82%BD%E3%83%B3%E3%81%AE%E4%BF%A1%E9%A0%BC%E5%8C%BA%E9%96%93">ウィルソン信頼区間</a>」と「<a href="https://ja.wikipedia.org/wiki/%E3%83%99%E3%82%A4%E3%82%BA%E6%8E%A8%E5%AE%9A">ベイズ推定</a>」を活用しています。

<details><summary>サンプル数の多少を加味して評価するSQL文（抜粋）</summary>

```sql
result_with_attention AS (
    SELECT 
        *,
        CASE 
            WHEN total_races > 0 THEN
                ((win_count + 1.9208) / NULLIF(total_races + 3.8416, 0) 
                 - 1.96 * SQRT((win_count * (total_races - win_count)) / NULLIF(total_races + 3.8416, 0) + 0.9604) / NULLIF(total_races + 3.8416, 0)
                )
            ELSE 0
        END AS wilson_lower_bound,
        CASE 
            WHEN total_races > 0 THEN
                (win_count + overall_win_rate * 20) / NULLIF(total_races + 20, 0)
            ELSE 0
        END AS bayes_estimate,
        1 - 1 / NULLIF(1 + EXP(-0.3 * (total_races - 15)), 0) AS data_scarcity_distrust,
        CASE
            WHEN win_rate_diff >= 0 THEN
                LEAST(win_rate_diff / NULLIF(0.025, 0), 1)
            ELSE
                GREATEST(win_rate_diff / NULLIF(0.04, 0), -1)
        END AS normalized_win_rate_diff
    FROM result_with_stats
),

result_with_adjusted_attention AS (
    SELECT 
        *,
        CASE
            WHEN data_scarcity_distrust <= 0.5 THEN
                CASE
                    WHEN normalized_win_rate_diff >= 0 THEN normalized_win_rate_diff * 2
                    WHEN normalized_win_rate_diff >= -0.625 THEN normalized_win_rate_diff
                    ELSE normalized_win_rate_diff * 1.5
                END
            ELSE
                normalized_win_rate_diff
        END AS adjusted_win_rate_diff
    FROM result_with_attention
),

result_with_attention_score AS (
    SELECT 
        *,
        (
            wilson_lower_bound * 20 + 
            bayes_estimate * 20 +
            adjusted_win_rate_diff * (1 - SQRT(data_scarcity_distrust)) * 40 + 
            (race_count_zscore / 100) * 20
        ) * (1 - SQRT(data_scarcity_distrust)) * 50 AS attention_score
    FROM result_with_adjusted_attention
),
```
<font color=gray>めちゃいじったので統計的に正しい計算かは知らん😤</font>
</details>

このような数式の複雑な処理をポン出しのLLMにさせることはかなり難しいことは、ChatGPTなど大手LLMがPythonコード呼び出し機能を早々に実装したことからも明らかかと思います。ということでプロンプトで「サンプル数が少なすぎる場合は信用するな」とか書くよりもLLM以前のプログラム処理でここの課題を解決したほうが安定する……それがベストプラクティスと思われます。

 **LLMはそのぶん統計では難しい「着眼する」という作業で活躍**します。
競馬予想に限らずですが、意思決定の参考になるデータは一般的に「一長一短」なものばかりになります。100点と0点のデータしかなければ選ぶのは簡単ですが、ある切り口では70点と30点だが、別の切り口では40点と60点みたいなときにどちらを選ぶか？　というタスクが多く発生します。このようなときに **「決断して、ある切り口に特別に注目し、その切り口からの分析を強調して見解を述べる」「平凡で選びづらい統計データからアノマリーな箇所を目ざとく発見して、問題提起する」** というタスクはSQLのような機械的プログラム処理では賢く作りづらいですが、よく出来たLLMは人間が見て合理的で共感性の高い判断を、思い切りを持ってこなしてくれます。

### 構造化データ記述と非構造化データ記述
これらの使い分けを実装するうえでポイントなのは、プロンプトへの与え方です。

- いかなる時でも公平に扱ってほしい**エビデンスデータ**
- 状況や意思決定の方向次第では無視したり、誇張化・矮小化してもよい**経験則**

の違いをどうLLMに教えるか？　ということです。

今回のシステムでは、

 - **エビデンスデータはSQLでDBから取得し、構造的に厳格なJSON形式で与える**
 - **経験則はなるべく文章風・口語調で与える**

という使い分けを意識しました。

###### 例：

<details><summary>構造化データとしてLLMに渡すエビデンスデータ<br>（天皇賞･秋'24 種牡馬傾向分析から抜粋）</summary>

```json
[
    {
        "切り口": "種牡馬",
        "名前": "キタサンブラック",
        "着度数": "[   7-   5-   4-  27](全 44走)",
        "連対率": "27.3%",
        "平均差": "N+4.3%",
        "不信度": "0.0%",
        "総合注目度": "4772.5",
        "Tier": "TierAAA"
    },
    {
        "切り口": "種牡馬",
        "名前": "サトノクラウン",
        "着度数": "[   0-   0-   1-  10](全 11走)",
        "連対率": "0.0%"
        "平均差": "N-23.0%",
        "不信度": "76.9%",
        "総合注目度": "42.2",
        "Tier": "TierD"
    },
    {
        "切り口": "種牡馬",
        "名前": "ドゥラメンテ",
        "着度数": "[  11-   7-   4-  65](全 87走)",
        "連対率": "20.7%",
        "平均差": "N-2.3%",
        "不信度": "0.0%",
        "総合注目度": "-300.4",
        "Tier": "TierNG"
    }
]
```
</details>

<details><summary>非構造化データとしてLLMに渡す経験則<br>（調教LAP プロンプトから抜粋）</summary>

```
-- 1F最速データがちょうどレース本番の前週（7日前～14日前の範囲）である場合は仕上げ十分と判断できる。
- 最終追い切りがレース本番より一週間以内（当週追い切り）である場合は「サラッと息を整える当週追い切り」と判断できる（つまり速い必要はない）。
- 最終追い切りがレース本番より一週間以内（当週追い切り）の成績が良すぎる場合は「調整が間に合っていない」可能性がある。
- 最終追い切りがレース本番より一週間以上前（前週追い切り）である場合は「当週に追い切りができなかった理由がある」可能性がある。
- 逆に1F最速データ（ベスト）が三週間以上前である場合は、本気の強め・一杯調教のポテンシャルがある（レース本番で疲労させないためかなり前に行った）可能性がある。
```
</details>
<br>
多くのLLMは構造化データ（JSONやXML）を、構造のあるデータとして理解するだけでなく「エビデンスとして無視してはならない、公正に扱うべきものである」と理解しているかのように無視しない傾向があります。一方、そうではなく口語的に書かれた非構造化データについては、扱いは自分のアドリブ次第と考えているのか、状況に応じて都合よく無視したりしがちです。特にGPT-4oはjson output機能があることでも分かるようにjsonに特段強いため、<b>「JSONで構造的に書くか、口語で書くか」自体が、エビデンスとしての扱い方の指定になっている</b>ように感じています。これを利用して、<b>「着眼する対象情報」と「着眼の仕方の参考情報」の違いを伝える</b>つもりでプロンプトを書いています。
<br><br><br>
これらのアプローチをふまえて、次回の記事では各キャラクターが行っていることをそれぞれキャラクタープロフィールも添えてご紹介します。この記事の中で続けて書こうと思ったのですが途中まで書いてめちゃくちゃ長くなったので、一旦この記事はここまでということで。
<br>

# 次の記事 → キャラの実装ポイント解説！
細かいポイントを見ていきます！
<br>

次の記事

https://qiita.com/oktamajun/items/31d9eb46a4937decb67a




<br><br>

![thinking.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/8ccead76-d8f1-366e-b50f-e57d632261a3.gif)