🧑‍🍳

1時間で料理バトルAIエージェントを作ってみた

に公開

AIエージェントがどういう仕組みで動いているのか勉強したかったので、作ってみました。

GPTsやDifyを使えばサッと作れるらしいのですが、今回は仕組みを理解したかったのでスクラッチ(LangGraph)で作りました。

完成物

最終的に、AI同士の料理バトルAIエージェント(エンタメ)を作ることができました。
所要時間としては、要件定義 → 設計 → 実装までトータルで1時間ほどです。主にGeminiとCursorに動いてもらいました。

ユーザーが食材を複数入力し議論開始すると、錬金術師(Alchemist)と会計士(Accountant)がレシピに関する意見を出します。錬金術師は攻めた提案をして、会計士は現実的に批評します。

その上で審判(Judge)が両者の意見をまとめた上で、その結果がどれだけ両者の意見を汲めているかを評価し、合意スコアを算出します。この合意スコアが80を超えるまで、このやりとりを繰り返します。

まず何を以って「AIエージェント」とするか

AIエージェントを作るため、AIエージェントの定義がどういうものかについて調べました。

とりあえずGeminiに聞いてみると、それらしいものを教えてくれました。

AIエージェントとは、一言で言えば「自ら考えて目標を達成するために行動するプログラム」のことです。

従来のチャットGPTなどのAI(チャットボット)が「質問に答える」という対話に特化しているのに対し、AIエージェントは「目的を与えれば、そのプロセスを自分で組み立てて実行する」という自律性が大きな特徴です。

---
## 1. AIエージェントの核心的な仕組み

AIエージェントは、単に言葉を生成するだけでなく、以下のサイクルを回すことでタスクを遂行します。

1. **把握(Perception)**: 与えられた指示や環境の状況を理解する。
2. **思考・計画(Planning)**: ゴールに到達するために「何をすべきか」をステップ分けする。
3. **行動(Action)**: 外部ツール(検索、メール送信、コード実行など)を使って実際に動く。
4. **評価(Reflection)**: 行動の結果を確認し、必要に応じて計画を修正する。

---

## 2. チャットボットとAIエージェントの違い

| 特徴 | チャットボット (AI) | AIエージェント |
| --- | --- | --- |
| **役割** | 回答者・相談相手 | 実行者・代理人 |
| **指示の出し方** | 「〜について教えて」 | 「〜という目標を達成して」 |
| **自律性** | 1つの問いに1つの回答を返す | ゴールにたどり着くまで試行錯誤する |
| **ツールの活用** | 基本的にテキスト生成のみ | ブラウザ、カレンダー、API等を操作する |

---

## 3. 具体的に何ができるようになるのか?

例えば「来週の出張の準備をしておいて」と頼んだ場合、AIエージェントは以下のような行動を自律的に行います。

* あなたのカレンダーを確認して空き時間を探す。
* 予算に合う航空券とホテルを検索・比較する。
* 最適なプランを提案し、承認が得られれば予約を代行する。
* 出張先の現地の天気を調べ、必要な持ち物リストを作成する。

「目標達成のために自律的に行動すること」がAIエージェントであるための重要な要素のようです。そしてそういったものを作るために、LangChain・LangGraphを使う方法があるようです。今回はLagnGraphを採用しました。

LangGraphとは?

LangGraphというのは、「AIエージェントの『思考のループ(反復)』や『複雑な条件分岐』を自由自在に設計するためのフレームワーク」です。PythonおよびJavaScript(TypeScript)で使えます。またこれは、LangChainの上に構築された拡張的なフレームワークです。LangChainが一方向的な制御に対してLangGraphはループや条件分岐を元に試行錯誤して処理を進めることができます。

そしてLangGraphの仕組みについてです。こちらは数学の「グラフ理論」の考え方を使ってエージェントを組み立てます。

  • Nodes(ノード):
    • 「AIが考える」「検索ツールを使う」「メールを送る」といった、具体的な1つ1つの作業ステップです。
  • Edges(エッジ):
    • ノードとノードをつなぐ「線」です。次にどのノードに進むかを決めます。
    • Conditional Edges(条件付きエッジ): 「もしAIの回答が不十分なら修正ノードへ、完璧なら終了へ」といった分岐を作れます。
  • State(ステート):
    • グラフ全体で共有される「記憶(データ)」です。これがあるおかげで、前のステップで何が起きたかを、次のステップのAIが把握できます。

この仕組みにより、LangGraphを使うことで「コードを書く」→「テストする」→「エラーが出たら書き直す(最初に戻る)」→「成功したら出力」のような、「どの工程に戻るか」「いつ終わるか」を厳密に制御することができるようになります。

「検索する」→「要約する」→「出力する」のような一方向の処理(Chain)と比較すると、プログラム自身が試行錯誤しながら目的達成に努めるようになります。

どのようなAIエージェントを作りたいかによって、一方向の処理(Chain)と試行錯誤するもの(Graph)を使い分けるかは変わりますが、今回はより自律性を高めるためGraphの方を採用しました。

開発の流れ

要件定義・設計

今回はAIエージェントの仕組みの理解が目的です。そのため、周辺技術は私が個人的に慣れ親しんだものを採用しました。

  • 言語: TypeScript
  • フロントエンド: React + Vite + MUI
  • API: Express + LangGraph
  • LLM: GPT-4.1 mini

その上で開発の方針としてはCursorを使ってSDD的に進めました。要件については、Geminiを使って、アイデアを出してもらいました。

1時間程度でLangGraphを使って、AIエージェントを作るアイデアをください

AI要約エージェント、旅行Plan作成Botなどのアイデアをくれましたが、せっかくなのでもう少し変わったものを作ってみたかったので、「もっと面白い感じのアイデアをください」とお願いしてました。

Geminiの返答

もっと面白いアイデアですね。承知しました。
私が提案するのは、AI同士が自律的に論争し、着地点(レシピ)を見つけ出すプロセスを観賞するという、LangGraphの特性を最大限に活かしたエンタメ型エージェントになります。


1. プロダクトコンセプト

「AI自律対立によるレシピ錬金術(The Alchemical Kitchen)」
ユーザーが入力した1つの食材を巡り、2人のAIが「狂気の発想」と「冷徹な論理」の極限まで議論を戦わせ、人間には到底思いつかない(が、理屈は通っている)最終レシピを自動生成する観賞用システム。


2. エージェント(Node)定義

LangGraph(TS)上で、以下の3つの役割を自律的に遷移させます。

役割(Node) 性格・ミッション 出力パラメータ
Alchemist (狂気) 創造性の極北。常識を破壊し、食材の新しい物理的・化学的・概念的な組み合わせを提案する。 狂気的な調理法、意外な食材ペアリング
Accountant (現実) 論理の番人。原価、物理的調理可能性、公衆衛生、顧客の許容範囲から徹底的に批判する。 技術的欠陥の指摘、不採用通知
Judge (統合) 闇の仲裁者。両者の対立から「成立しうる最小単位」を抽出し、レシピとして清書する。 更新されたレシピ、合意スコア(0-100)

3. グラフ・ワークフロー (Autonomous Loop)

  1. START: ユーザーが「ベース食材」を入力。
  2. 提案 (Alchemist): 最初の狂ったレシピを生成。
  3. 批判 (Accountant): 提案を論理的にボコボコに叩く。
  4. 調整・判定 (Judge):
  • 批判を取り入れた修正レシピを作成。
  • 合意スコアを算出。
  • 分岐判定: スコアが80点以上、またはループ上限(3〜5回)に達したか?
  • YES → END(最終出力へ)
  • NO → Alchemistへ戻る(「前回の批判を考慮しつつ、さらに別の狂気を見せろ」と指示)

4. UI/UX 要件 (React)

ユーザーは「指示者」ではなく、「観客(観測者)」として振る舞います。

  • ライブ・バトル・ログ:

  • 画面を左右に分割し、左側にAlchemistの「攻め(提案)」、右側にAccountantの「守り(批判)」をリアルタイムにストリーミング表示。

  • 各AIの発言に合わせて、背景色やエフェクトが動的に変化(狂気ターンは紫、論理ターンは赤など)。

  • 狂気度(Madness)インジケーター:

  • 議論の変遷に合わせて、レシピがいかに「常軌を逸しているか」をゲージで可視化。

  • 最終リザルト表示:

  • 議論を勝ち抜いた「聖遺物(最終レシピ)」を、古文書または高級メニュー風のMarkdownで表示。


5. 技術的制約(1時間実装スコープ)

  • Backend:

  • LangGraph(TS)StateGraph を構築。

  • 各Nodeのプロンプトに「一歩も引くな」という性格付けを徹底。

  • stream メソッドを用いて、Nodeごとの実行結果を順次フロントエンドへ送信。

  • Frontend:

  • SSE (Server-Sent Events) を受信し、useState に議論履歴をスタック。

  • 自動スクロールで、次々と流れるAI同士の喧嘩を追えるようにする。


6. このプロジェクトの「成功」の定義

「美味しいレシピができること」ではなく、「AIが互いに譲らず、意地悪な指摘や斜め上の提案を繰り返し、最終的に奇妙な納得感のある結論に辿り着くドラマ」が可視化されていること。

これですべての要件が整いました。実装に入る準備はよろしいでしょうか?

結構長いので折りたたんでいますが、要するに「入力した食材からAI同士の料理バトルでレシピを作るWebアプリ」というアイデアを貰い、そちらを採用しました。

  • Web画面で食材をカンマ区切りで入れる
  • APIが Alchemist(攻めた提案)→ Accountant(現実的な批判)→ Judge(統合)の3役で議論を回す
  • 議論はターン制で進み、Madness(ぶっ飛び度)と Consensus Score(合意度)を更新
  • 最終的に、合意度が一定以上になるか最大ターン(5回)到達で最終レシピを確定する
  • フロントはSSEでイベントをリアルタイム表示して、バトルログと最終レシピを見せる

その上で設計です。APIのシーケンス図を整理します。

また、LangGraph周りのフローを整理すると以下のようになります。

実装

実装です。環境構築から実装までCursorにやってもらい、サッとつくれました。

ここでは特にAIエージェントとしての肝になるLangGraph周りの実装を解説します。

最初はLangGraphのStateです。Stateの定義には、@langchain/langgraph ライブラリの Annotation を利用します。これは「グラフが共有するデータの型と、その更新ルール」を定義す
るものです。

グラフのデータ型を定義するため、TypeScript の interface でも十分であるようにも思いますが、これだと更新ルールの方の定義を行うことができず、不便です。

例えば今回は審判が合意スコア(consensusScore)を算出しますがこの値は常に最新値だけStateで管理されていれば良いので、どれだけループされても値は上書きで良いです。

一方で、議論ログ(history)をUIに表示する際、過去の処理結果も表示したいです。つまり上書きではなく処理結果が追加される形になる必要があります。

Annotationでは、デフォルトの挙動としてはStateの更新時に値は上書きになりますが、 reducer を活用することでこの更新ルールを任意に変更することができるようになります。

import { Annotation } from "@langchain/langgraph";

/**
 * グラフ全体で共有される状態(State)の定義
 * 各ノードはこの値を読み取り、更新した値を返すことで処理を繋ぐ
 */
const GraphState = Annotation.Root({
  // AIに与えられたベースとなる食材リスト
  ingredients: Annotation<string[]>,

  // 現在の議論のターン数(1, 2, 3...)
  turn: Annotation<number>,

  // 最大何ターンまで議論を続けるかの設定値
  maxTurns: Annotation<number>,

  // 全エージェントの発言履歴を蓄積する配列
  history: Annotation<DebateMessage[]>({
    // reducer: 新しい発言(incoming)を既存の履歴(current)の末尾に結合して保持する
    reducer: (current, incoming) => current.concat(incoming),
    default: () => [],
  }),

  // 錬金術師(Alchemist)が提示した最新の料理提案
  currentProposal: Annotation<string>,

  // 会計士(Accountant)が行った最新の批判内容
  currentCritique: Annotation<string>,

  // 審判(Judge)が作成・改訂している現在のレシピ草案
  recipeDraft: Annotation<string>,

  // 審判による最新の裁定文やメモ
  judgeNote: Annotation<string>,

  // 錬金術師の提案に含まれる「狂気度」の数値
  madness: Annotation<number>,

  // 議論の「納得感」を0-100で表したスコア(80以上で終了判定などに使用)
  consensusScore: Annotation<number>,

  // グラフの処理を終了すべきかどうかを判断するフラグ
  done: Annotation<boolean>,
});

次に、Nodeの定義です。必要なNodeはAlchemist・Accountant・Judgeの3つです。ここでは単純にsystemプロンプトとuserプロンプトをもとにLLMを呼び出し、結果を返します。レスポンスの構造化にはZodを利用しています。

NodeからStateを更新する際には、Nodeのreturnに更新後の値を含めて返すことで実現できます。そして、そのreturnの構造はStateの構造に合わせる必要があります。

import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

/**
 * Alchemist(錬金術師)の出力バリデーションスキーマ
 * LLMに対して「この形式・制約で回答せよ」という指示として機能し、
 * 同時にプログラム側で受け取るデータの型を保証する。
 */
const alchemistSchema = z.object({
  // 20文字以上の調理技術解説(短すぎる回答を防止)
  technique: z.string().min(20),
  // 5文字以上の意外な食材ペアリング
  pairing: z.string().min(5),
  // 40文字以上の熱いプレゼン(キャラクター性を維持)
  pitch: z.string().min(40),
  // 0-100の整数。この値は後続の演出や判定に使用される
  madness: z.number().int().min(0).max(100),
});

/**
 * OpenAIのモデルインスタンスを生成する共通関数
 * temperature: 1 設定により、狂った提案に必要な「創造性」を最大化している
 */
const createModel = () => {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    throw new Error("OPENAI_API_KEY is not set.");
  }
  return new ChatOpenAI({
    model: "gpt-4.1-mini",
    temperature: 1, // 創造性を高め、予測不能な回答を許容する
    apiKey,
  });
};

/**
 * Alchemist Node: 狂気の提案を行うステップ
 * 現在の状態(state)を読み取り、新しい提案を生成して状態を更新する。
 */
const alchemistNode = async (state: GraphStateType): Promise<Partial<GraphStateType>> => {
  // モデルにZodスキーマを適用。LLMの出力をパースし、型安全なオブジェクトとして取得可能にする。
  const model = createModel().withStructuredOutput(alchemistSchema);

  // LLMへの命令(システムメッセージ + 現在の文脈)
  const output = await model.invoke([
    {
      role: "system",
      content:
        "あなたはAlchemist(狂気の料理錬金術師)です。絶対に妥協せず、常識外れの提案を行ってください。ただし理屈は一貫させ、物理的な調理手順として説明してください。以後の出力は必ず自然な日本語のみで行い、英語は使わないでください。",
    },
    {
      role: "user",
      content: [
        `ベース食材: ${state.ingredients.join("、")}`,
        `現在ターン: ${state.turn + 1}/${state.maxTurns}`,
        // 前のターンにAccountant(会計士)からの批判があれば、それに反論させる
        state.currentCritique
          ? `前ターンの批判(これに反論しながら発展させること): ${state.currentCritique}`
          : "まだ批判はありません。初手から最大火力の狂気を提示してください。",
        // Judge(審判)がまとめた現在のレシピ案があれば踏まえさせる
        state.recipeDraft ? `現在のレシピ草案: ${state.recipeDraft}` : "まだ草案はありません。",
        "意外性が高く、物理的に調理を説明でき、演演出学的にも強烈な提案を返してください。回答は必ず日本語で出力してください。",
      ].join("\n"),
    },
  ]);

  // 状態更新用のデータ準備
  const turn = state.turn + 1;
  const content = [
    `調理法: ${output.technique}`,
    `意外な組み合わせ: ${output.pairing}`,
    `主張: ${output.pitch}`,
  ].join("\n");

  /**
   * 更新されたStateの差分を返す
   * LangGraphはここで返された値を、自動的に既存のStateへマージする
   */
  return {
    turn, // ターン数をカウントアップ
    currentProposal: content, // 最新の提案として保存
    madness: output.madness,   // 狂気度を保存(演出用)
    history: [
      {
        role: "alchemist",
        turn,
        content,
        madness: output.madness,
      },
    ], // reducerにより、既存の履歴にこの要素が追加(concat)される
  };
};

その上で、それらを用いたGraphの実装です。ここが自律性を高める上で肝になる部分です。

先ほど実装したNodeをどのような順番で呼び出すか、またそれを呼び出した上で一連の流れを再度ループするかもしくは完了とするか、という部分の制御を行います。

後者については、 addConditionalEdges を利用して実現します。今回の場合、審判のNodeによる処理結果としてStateの「完了かどうか」の値がTrueの場合は終了し、そうでない場合はAlchemistのNodeに戻り、再度議論を開始する。という制御になっています。

import { END, START, StateGraph } from "@langchain/langgraph";

/**
 * 次に進むべきルートを判定する「条件付きエッジ(Conditional Edge)」用の関数
 * @param state 現在のグラフの状態
 * @returns 次に実行するノード名、または終了を示す END
 */
const shouldContinue = (state: GraphStateType) => {
  // judgeNodeで判定された「done」フラグを確認
  // trueなら終了(END)、falseなら議論を継続するためalchemistNodeに戻る
  return state.done ? END : "alchemist";
};

/**
 * グラフの構造定義
 * 各ノード(作業工程)を繋ぎ合わせ、一つのワークフローとして構築する
 */
const battleGraph = new StateGraph(GraphState)
  // 1. ノード(処理の担当者)の登録
  .addNode("alchemist", alchemistNode)
  .addNode("accountant", accountantNode)
  .addNode("judge", judgeNode)

  // 2. 基本的なエッジ(一本道の進み方)の定義
  .addEdge(START, "alchemist")      // 開始時、まず最初に錬金術師が提案する
  .addEdge("alchemist", "accountant") // 提案が終わったら、会計士が批判する
  .addEdge("accountant", "judge")     // 批判が終わったら、審判が裁定を下す

  // 3. 条件付きエッジの定義
  // 審判(judge)の処理が終わった後、shouldContinue関数の戻り値に従って
  // 「alchemistに戻ってループ」するか「終了(END)」するかを動的に決定する
  .addConditionalEdges("judge", shouldContinue)

  // 最後に「コンパイル」することで、実行可能なグラフオブジェクトが生成される
  .compile();

最後にこのGraphを呼び出す関数の実装です。ここではStateの初期値を定義し、そちらをGraphの引数として渡し、呼び出します。

Graphを呼び出す上で、処理結果を逐一返す方法(Stream)と処理が完了したら1度だけ返す方法(Invoke)があります。

処理が完了したら1度だけ返す方法(Invoke)の方が、コードの記述量は少なくなりますが、今回は処理が進み次第議論ログが逐一更新されるようなUIにしたかったため、処理結果を逐一返す方法(Stream)を採用しました。

/**
 * 料理バトル(ディベート)をストリーミング実行するジェネレーター
 * LangGraphの各ノードが完了するたびに、その結果をBattleEventとしてyieldします。
 */
export const streamBattle = async function* (
  ingredients: string[], // ベースとなる食材
  maxTurns = 5,          // 最大ループ回数
): AsyncGenerator<BattleEvent> {
  // 1. グラフ実行のための初期状態(State)を定義
  const initialState: GraphStateType = {
    ingredients,
    turn: 0,
    maxTurns,
    history: [],
    currentProposal: "",
    currentCritique: "",
    recipeDraft: "",
    judgeNote: "",
    madness: 0,
    consensusScore: 0,
    done: false,
  };

  // ターン開始のアナウンスが重複しないよう管理するフラグ
  let lastAnnouncedTurn = 0;

  /**
   * 2. グラフをストリームモードで実行
   * streamMode: "updates" により、各ノード(alchemist/accountant/judge)が
   * 処理を終えてStateを更新した瞬間に、その差分データを受け取ります。
   */
  for await (const update of await battleGraph.stream(initialState, { streamMode: "updates" })) {
    
    // --- Alchemist(錬金術師)ノードの更新時の処理 ---
    if (update.alchemist) {
      const state = update.alchemist as Partial<GraphStateType>;
      const turn = state.turn ?? lastAnnouncedTurn + 1;
      
      // 新しいターンの最初であれば「開始イベント」を発行
      if (turn !== lastAnnouncedTurn) {
        lastAnnouncedTurn = turn;
        yield {
          type: "turn_start",
          turn,
          madness: state.madness ?? 0,
          consensusScore: 0,
          content: `第${turn}ターン開始`,
        };
      }
      // 錬金術師の発言内容を実況
      yield {
        type: "alchemist",
        turn,
        madness: state.madness ?? 0,
        consensusScore: 0,
        content: state.currentProposal ?? "",
      };
    }

    // --- Accountant(会計士)ノードの更新時の処理 ---
    if (update.accountant) {
      const state = update.accountant as Partial<GraphStateType>;
      yield {
        type: "accountant",
        turn: lastAnnouncedTurn,
        madness: 0,
        consensusScore: 0,
        content: state.currentCritique ?? "",
      };
    }

    // --- Judge(審判)ノードの更新時の処理 ---
    if (update.judge) {
      const state = update.judge as Partial<GraphStateType>;
      // 審判の裁定を実況
      yield {
        type: "judge",
        turn: lastAnnouncedTurn,
        madness: 0,
        consensusScore: state.consensusScore ?? 0,
        content: state.judgeNote ?? `合意スコア: ${state.consensusScore ?? 0}`,
        recipeDraft: state.recipeDraft ?? "",
      };

      // 終了フラグ(done)が立っている場合、最終結果を報告
      if (state.done) {
        yield {
          type: "final",
          turn: lastAnnouncedTurn,
          madness: 0,
          consensusScore: state.consensusScore ?? 0,
          content: "聖遺物の錬成が完了した。",
          recipeDraft: state.recipeDraft ?? "",
        };
      }
    }
  }
};

Controller層からこの関数を呼び出すことで、概ねAPIとしては出来上がりです。

おわりに

元々の目的としていたAIエージェントの仕組みについてはある程度理解できたと思います。
何か業務に役立ちそうなAIエージェントのアイデアが生まれたら、また別で作ってみようと思います。

NCDC テックブログ

Discussion