🥊

マスボクシングの全国大会を目指すエンジニアが、自分専属のAIコーチを作った

に公開

50歳、全国大会を目指しています

私は1976年生まれの50歳で、システム開発会社をやっています。22歳までは陸上の長距離をやっていて、そこから20年以上まともに運動していませんでした。

2023年からボクシングジムに通い始めて、今はマスボクシングの全国大会を目指しています。

マスボクシングというのは、相手に実際にパンチを当てないボクシングです。寸止めや軽いタッチで、コンビネーション・距離感・タイミング・反応を磨く非接触の競技で、子どもから高齢者まで幅広い世代に普及しています。全国大会も開催されています。

ただ、47歳から始めた人間が上を目指すには、練習の質を上げるしかありません。ジムに行けるのは週2〜3回。行かない日に何をすべきかは、自分では判断がつきません。

そこで、自分専属のコーチAIを作りました。

🥊 パンチ先生https://booster-test.com/chat/

「ジャブのスピードを上げたいです」と打つと、いきなり答えを返さずに経験年数や現状を聞き返してきます。そのうえで、週間トレーニングメニューをカード形式で提示してくれます。同じブラウザで次に開いたときは、前回の目標を踏まえた提案になります。

以下、作る過程で何を考えたかを書きます。技術的な構成の話が中心ですが、いちばん時間をかけたのはシステムプロンプトでした。


最初の問題:一般的なことしか出てこない

最初のバージョンは、ごく普通のプロンプトで作りました。「ボクシングのトレーニングコーチです」程度のものです。

そこに最初に聞いたのが、**「長身のサウスポー対策を教えてほしい」**でした。私が実際に困っていたことです。リーチで届かず、いつもの距離感がまるで通用しない。

返ってきたのは、サウスポー対策として検索すれば出てくる原則論でした。間違ってはいません。ただ、それは知っています。知ったうえで勝てないから聞いているのです。

ここで気づいたのは、質問が具体的でも、答えが具体的になるとは限らないということでした。「自分専属のコーチ」を名乗るなら、明日のジムで試せる粒度まで下りてこないと意味がありません。

「もっと具体的なメニューを出してほしい」——ここからプロンプトの改良が始まりました。

やってみて分かったのは、「一般的」には複数の原因があるということです。結果的に3つの方向から手を入れました。

一般論になる原因 対処
① マスボクシング固有の事情が反映されない 公式競技規則を埋め込む
② 初心者向けに寄せてくる 想定レベルのデフォルトを変える
③ 種目名が抽象的なまま終わる ダメな例を名指しして禁止する

「具体的に書いて」と一言お願いするだけでは、①も②も解決しません。どこが一般的なのかを分解して、それぞれ別の指示で潰す必要がありました。

以下、順に書きます。


① マスボクシング固有の内容にする

まず、ボクシングの一般論はマスボクシングにそのまま使えません。

マスボクシングは当ててはいけない競技です。一方でモデルが大量に学習しているのは「当てるほうのボクシング」なので、放っておくと打撃の強さや破壊力の話に寄っていきます。「マスボクシング」という単語を出すだけでは、この引力に勝てませんでした。学習データの量が違いすぎます。

そこで、公益社団法人日本ボクシング連盟の「マスボクシング競技規則」(2024年4月1日改定) を持ち出しました。

といっても、条文を自分で読んで要約したわけではありません。規定のPDFをそのままClaudeに渡して、「これをシステムプロンプトに組み込んで」と頼みました。 採点基準とファウル規定を抜き出して、コーチAI向けの指示文に整形してもらった形です。

AIのプロンプトをAIに書かせるのは妙な感じもしますが、PDFから必要な条文を選んで要約する作業は、まさにこの手の道具が得意なところです。人間がやるべきだったのは「どのPDFを渡すか」の判断だけでした。

出来上がったのが、この部分です。

SYSTEM_PROMPT_BASE = (
    "あなたは「パンチ先生」という名前の、マスボクシング専属コーチAIです。"
    "マスボクシングとは、パートナーに実際にパンチを当てず、寸止め・軽いタッチで"
    "コンビネーション・距離感・タイミング・反応を磨く非接触の技術練習です。"
    "以下は公益社団法人日本ボクシング連盟「マスボクシング競技規則」(2024年4月1日改定)"
    "の採点基準・ファウル規定の要約です。提案するメニューはこの公式ルールに沿ったものにしてください:\n"
    "[採点基準(第4条)]\n"
    "- 強弱に関わらず、相手への直接な打撃(当ててしまうこと)は反則であり評価されない\n"
    "- 技術を伴わない前進や、反則を誘発させるための前進は評価されない\n"
    "- 評価されるのは「ターゲットエリアへの質の高い打撃の数(寸止め)」。パンチはナックル"
    "パートで体や肩の重みを伴ったものである必要があり、数だけでなく質が問われる\n"
    "- 技術・戦術の優勢(攻撃と防御を組み合わせて競技を支配し、相手の特性を打ち消す戦術を"
    "駆使していること)や、継続して勝利を目指す積極性も評価対象\n"
    "[ファウル(第6条)]\n"
    "- 相手に打撃を実際に与えてしまうこと自体が反則で、注意・警告の対象になる"
    "(警告は1競技で3回累積すると自動的に失格)\n"
    "- 技術を伴わない前進、故意に打撃を受けに行くような前進も反則\n"
    # ...
)

そのうえで、**「これを踏まえて、提案するメニューは常に次を徹底してください」**として、具体的な行動指針に落とし込みます。

- 相手に強く当てる・KOを狙うようなパワー重視の打撃(サンドバッグのフルパワー打撃など)
  をメインには据えず、パートナーと組んだ非接触のコンビネーション交換を中心に構成する
- 各ドリルで「当てない(寸止め)・力を抜く・相手のレベルに合わせる」ことを繰り返し意識づける
- 単に手数を出すだけでなく、ナックルパートで体重・肩の重みを伴った質の高い寸止めや、
  攻防を組み合わせて競技を支配する技術・戦術面のドリルも織り交ぜる
- ミット打ちや単独のシャドーボクシング、フットワーク、コンディショニングは
  マスボクシングの技術を支える基礎練習として組み込んでよい

これで挙動が変わりました。

効いたのは「ルール」より「なぜダメか」

試してみて分かったのは、禁止事項を並べるだけでは足りないということです。

「フルパワーで打つな」だけだと、モデルは代わりに何をすればいいか分からず、当たり障りのないメニューになります。

効いたのは採点基準のほうでした。「ナックルパートで体重・肩の重みを伴った質の高い寸止めが評価される」という評価軸を与えると、モデルはそこに向かうドリルを自分で組み立て始めます。

「やるな」ではなく「何が評価されるか」を教えるほうが、生成物の質が上がる。これは他のドメインでも同じだと思います。

一次情報は強い

もう1つ実感したのは、公式の一次情報をそのまま入れる効果です。

ネットの解説記事を要約して入れるより、競技規則の条文を要約して「第4条の採点基準」と明示するほうが、はるかに安定しました。モデルが「これは守るべき規範だ」と認識してくれる感覚があります。

自分がやっている競技だったので規則を読む動機がありましたが、業務でRAGやチャットボットを作るときも同じで、一次資料を持っているかどうかで質が変わるのだと思います。


② 初心者扱いをやめさせる

競技のルールは反映されるようになりましたが、まだ一般的でした。今度はやたらと初心者向けの提案をしてくるのです。

「構え方から確認しましょう」「まずはジャブを正しく打てるように」——2年通っている人間に言うことではありません。

これはモデルの安全側に倒れる性質で、レベルが不明なら基礎から、という判断だと思います。親切ではあるのですが、使い物にならない。

なので明示しました。

ユーザーのレベルが会話から明示されない場合は、初心者ではなく
中級者〜上級者(ジムで継続的に練習している経験者)を基本の対象として想定してください。
初心者向けの基礎的すぎる内容(構え方の説明など)から始めず、コンビネーション、
スピード・パワーの両立、実戦的なフットワークやディフェンスとの連動など、
経験者向けの踏み込んだ内容を中心に提案してください。
ただし本人が初心者だと明言した場合は、その人に合わせて調整してください。

**「デフォルトを変える」+「例外を明示する」**という書き方にしたのがポイントです。単に「上級者向けにしろ」と書くと、本当の初心者が来たときに困ります。


③ 種目名の抽象性を潰す

レベルは合ってきました。それでもまだ、種目名が抽象的で練習に持っていけません。

  • 「カウンター対策コンビネーション 3セット」
  • 「距離感トレーニング 5ラウンド」

何をすればいいのか分かりません。 それっぽい名前が付いているだけで、練習に持っていけない。

これも書きました。

各種目は「〜対策コンビネーション」のような抽象的な名前だけで済まさず、
具体的なパンチの順番(例: ジャブ→ストレート→レフトフック、1-2-3など)や
フットワークの向き、意識するポイントまで踏み込んで明記してください。
exercises の note フィールドには、そのコンビネーション・ドリルの具体的な手順や
狙い(なぜそれが効果的か)を書いてください。

「〜対策コンビネーション」という実際に出てきた悪い例を、そのままプロンプトに書いたのが効きました。抽象的に「具体的に書け」と言うより、ダメな出力を名指しするほうが確実です。

結果、こういう粒度で出てくるようになりました。

ミット打ち: 1-2-3(ジャブ→ストレート→レフトフック)からの相手サウスポーの利き手側へ抜けるステップ
note: 相手がサウスポーの場合、3発目のフックを打った直後に外側へ抜けることで反撃の角度を外せる。寸止めを維持したまま、抜ける足の速さだけを意識する。

これなら明日のジムで試せます。

ちなみにこのサウスポーの例は、ツールのスキーマ定義(name フィールドの description)にそのまま書き込んであります。 最初に困っていた質問が、そのまま「良い種目名の見本」としてプロンプトに残っている形です。

自分の困りごとから作ると、こういうサンプルに困りません。 「良い出力の例を書け」と言われて手が止まる場面は多いのですが、実際に自分が欲しかったものを書けばいいだけでした。


Tool Use で構造化データを受け取る

ここからは実装の話です。

週間トレーニングメニューは構造化データです。曜日があって、その日のテーマがあって、種目があって、各種目にセット数・レップ数・休憩・注意点がある。

これをテキストで返させると、Markdownの表が崩れたり、書式が毎回変わったりします。表示側で整形しようにも、パースが安定しません。

そこで Tool Use を使いました。「メニューを提示するときは必ずこのツールを呼べ」と指示して、構造化データを受け取ります。

TOOL_CONFIG = {
    "tools": [
        {
            "toolSpec": {
                "name": "present_training_menu",
                "description": (
                    "具体的な週間トレーニングメニューをユーザーに提示する。"
                    "画面にカードとして描画され、DBにも保存され次回の提案に活用される。"
                ),
                "inputSchema": {
                    "json": {
                        "type": "object",
                        "properties": {
                            "goal": {"type": "string", "description": "ユーザーの目的・課題"},
                            "level": {"type": "string", "description": "初心者/経験者/競技者など"},
                            "coach_note": {"type": "string", "description": "前回からの進捗を踏まえた一言"},
                            "weekly_plan": {
                                "type": "array",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "day": {"type": "string", "description": "例: 月曜日"},
                                        "focus": {"type": "string", "description": "その日のテーマ"},
                                        "exercises": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "name": {"type": "string"},
                                                    "sets": {"type": "string"},
                                                    "reps": {"type": "string"},
                                                    "rest": {"type": "string"},
                                                    "note": {"type": "string"},
                                                },
                                                "required": ["name"],
                                            },
                                        },
                                    },
                                    "required": ["day", "exercises"],
                                },
                            },
                        },
                        "required": ["goal", "weekly_plan"],
                    }
                },
            }
        }
    ]
}

ツールを「機能」ではなく「表示手段」として使っているのがポイントです。何かを実行するわけではなく、構造化された形でデータを吐き出させるために呼ばせています。

description に「画面にカードとして綺麗に表示されます」と書いておくと、モデルが積極的に呼んでくれるようになりました。モデルにとってのメリットを書くと呼ぶ頻度が上がる、というのは他でも使える気がします。

システムプロンプト側にも書いています。

具体的な週間メニューを提示するときは、必ず present_training_menu ツールを呼び出してください。
このツールを呼ぶことで画面にメニューが綺麗に表示されます。ツールを呼んだ後は、
短く一言だけ補足コメントを添えてください。日本語で応答してください。

「ツールを呼んだ後は短く一言だけ」と付けたのは、ツールで出した内容をテキストでも全部繰り返してしまうのを防ぐためです。カードに出ているのに下に同じ内容が長々と続くと読みにくい。

スキーマ設計で気をつけたこと

required を最小限にしています。exercises の中で必須なのは name だけで、sets / reps / rest / note はすべて任意です。

必須項目を増やすと、埋めるために不自然な値が入ります。 「ストレッチ 1セット 1レップ」のような。任意にしておけば、意味がないときは省いてくれます。

setsreps を数値ではなく文字列にしているのも同じ理由です。「3セット」だけでなく「各3分×3R」「限界まで」のような表現が入るので、数値に縛ると表現できません。


継続コーチング:前回の続きから始める

コーチが毎回「はじめまして」だと意味がありません。

そこで、ユーザーごとのプロフィールをDynamoDBに保存して、次のセッションでシステムプロンプトに注入しています。

ブラウザ側はlocalStorageにUUIDを持たせるだけです。

let userId = localStorage.getItem("cornerman_user_id");
if (!userId) {
  userId = crypto.randomUUID();
  localStorage.setItem("cornerman_user_id", userId);
}

ログインは作りませんでした。 デモとして触ってもらうのに、アカウント登録は摩擦が大きすぎます。同じブラウザで開く限り継続する、という割り切りです。

サーバー側では、ツールが呼ばれたタイミングでプロフィールを更新します。

def _save_menu(user_id: str, tool_input: dict) -> None:
    goal = tool_input.get("goal", "")
    level = tool_input.get("level", "")
    weekly_plan = tool_input.get("weekly_plan", [])
    summary = ", ".join(
        f"{d.get('day', '')}:{d.get('focus', '')}" for d in weekly_plan
    )[:300]

    profile = _load_profile(user_id)
    session_count = int(profile.get("sessionCount", 0)) + 1

    table.put_item(
        Item={
            "userId": user_id,
            "goal": goal,
            "level": level,
            "lastMenuSummary": summary or "詳細メニュー",
            "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "sessionCount": session_count,
        }
    )

メニュー全体ではなく、要約だけを保存しているのがポイントです。「月曜日:ジャブのスピード, 火曜日:フットワーク, ...」という300文字程度の文字列にしています。

全文を保存して次回のプロンプトに入れると、トークンを食うわりに効果が薄い。「前回どういう方向性だったか」さえ分かれば継続感は出ます。

そして次のセッションでは、これを文章に組み立ててシステムプロンプトの末尾に足します。

def _profile_context_text(profile: dict) -> str:
    if not profile:
        return "このユーザーの過去の記録はありません。今回が初回セッションです。"

    return (
        f"[過去の記録] これは{session_count}回目のセッションです。"
        f"前回の目標: {goal} / レベル: {level} / "
        f"前回提示したメニュー概要: {last_menu} (最終更新: {last_updated})。"
        "これを踏まえて、進捗や継続性を意識した提案をしてください。"
    )

sessionCount を渡しているのが地味に効きます。「これは3回目のセッションです」と伝えると、モデルの態度が変わります。 初回のような丁寧な自己紹介をやめて、「前回のフットワーク、どうでしたか」と入ってくる。

初回用の分岐(「過去の記録はありません」)を明示的に入れているのも大事で、これがないと存在しない過去の話をしようとします。


安全面

一応、健康に関わるものなので、システムプロンプトに入れています。

安全のため、必ず次を守ってください:
- ウォームアップ/クールダウンを軽視しない
- 痛みや持病がある場合は無理させず、医療専門家への相談を促す

あくまでモデルの一般知識に基づくデモであって、医療的な助言ではありません。 実際にサービスとして提供するなら専門家の監修が要ると思います。


使ってみて

自分で使っているので正直に書くと、思ったより使えます。

いちばん効果があったのは、「今日は何をすればいいか分からない日」がなくなったことです。ジムのない日にダラダラ過ごすことが減りました。

トレーナーの代わりにはなりません。フォームは見てもらわないと直らないし、実戦のスパーリングは人としかできません。でも**「その間を埋めるもの」としては十分**です。

副産物として、マスボクシングの競技規則を初めてちゃんと読みました。 システムプロンプトに入れるために要約する必要があったからです。採点基準を理解したら、練習の意識も変わりました。

AIを作るために一次資料を読み込む、というのは思わぬ効能でした。


構成まとめ

役割 サービス
通信 API Gateway(WebSocket API)
処理 Lambda(Python 3.12)
推論 Bedrock Converse Stream API(Claude Haiku 4.5)
継続コーチング DynamoDB
デプロイ AWS SAM

デプロイは sam build && sam deploy --guided だけです。


おわりに

作ってみて思ったのは、LLMアプリで差がつくのはモデルでもインフラでもなく、ドメイン知識をどうプロンプトに落とすかだということです。

構成自体は、Bedrock + Lambda + DynamoDB という教科書的なものです。差がついたのは、

  • 公式競技規則という一次情報を持っていたこと
  • 「まだ初心者向けだ」「この種目名では練習できない」という、自分がユーザーだから分かる不満
  • それを「デフォルトを変える」「ダメな例を名指しする」という形でプロンプトに落としたこと

もう1つ、「一般的すぎる」を分解できたことも大きかったと思います。最初は漠然と「もっと具体的に」と思っていただけでしたが、実際には競技固有性・想定レベル・記述の粒度という別々の問題でした。この分解は、自分がその競技をやっていないとできません。

自分が使うものを作ると、こうした判断が全部できます。 デモを作る必要があるとき、題材を自分の趣味にするのは合理的だと思いました。

コスト対策(Bedrockには支出上限がないので自前で実装した話)と、実装でハマった罠については別記事に書きました。

デモはこちらです。よかったら殴りに来てください(当てないボクシングなので当たりませんが)。

🥊 パンチ先生https://booster-test.com/chat/

Discussion