📚

穴埋め問題をAIエージェントに生成させるための技術的な課題と現実的な解決策

に公開

AIエージェント構築&運用 Advent Calendar 2025 19日目の記事です!18日目はオレオレコーディングエージェントを実践投入するまで でした!

AIエージェントにJSON構造で定義された穴埋め問題を安定的に生成させるための技術的な課題と、それに対する現状の解決策をまとめます。当記事では2025年7月〜12月にかけて行った技術的な取り組みを中心に執筆します。

背景・サービス紹介

まず、穴埋め問題をAIエージェントに生成させるってどういうこと?という方、以下のGIFを御覧ください。

aaa

※画面は開発時点のもの/生成結果は一例です

テストメーカーでは、2025年7月頃からAIエージェントを用いた穴埋めテスト自動生成機能の開発・運用を行っています。

テストメーカーは、穴埋めテストを簡単に作れるWebアプリケーションです。個人開発として2022年6月にリリースし、現在は会員数2万人超、有料プランも提供しています。主なユーザーは学校の先生、資格受験者、企業研修担当者などです。

私個人としては今年6月にオンライン家庭教師マナリンクにAIエージェントを使った先生検索機能をリリースし、そこで学んだ知見を元に、テストメーカーにも穴埋め問題生成機能を開発・リリースしました。

穴埋め問題という領域

クイズをAIに生成させるというイシュー自体はたくさんのプレイヤーが解こうとしているし、たとえばNoteboolLMとか著名なサービスでもAI生成の問題というのはリリースされています。

ただし、そのほぼすべてが、1問1答形式の問題に対するAI生成です。1問1答形式の問題を一気に10問作ります、といった感じですね。

テストメーカーのように、大きなテキストの中に無作為に穴埋め問題がある状態のものを生成させるというイシューは、今のところほとんど見かけたことがありません。
本記事では、「1問1答なら比較的AI生成フレンドリーだけど、大きな文書の中に穴埋め問題が入っている形は特有の課題があるよね」 という前提に立って進めます。

穴埋め問題のAI生成特有の課題

穴埋め問題のAI生成には、1問1答形式のようなクイズに最適化した形式のデータの生成や、ChatGPTとかのような単なるプレーンテキストの生成と比較して以下のような技術的障壁があります。

WYSIWYGエディタ対応Nodeの不安定な生成

テストメーカーでは穴埋め問題をWeb上にレンダリングするためにWYSIWYGエディタ上に開発した専用Nodeを用いています。このNodeでシンプルな穴埋め問題から、選択問題や別解あり問題など多様な問題に対応します。

当然ながら、これらの形式に対応した穴埋め問題をAIに生成させるとあっては、以下のような課題が生じます。

  • 形式を少しでも守っていないNodeはエディタにセットした途端クラッシュするリスク
  • 選択肢のところに正答を入れてしまうなど、間違った使われ方をするリスク
  • そもそも問題に限らず、見出しや太字や箇条書きなどその他のノードも安定生成できないリスク

こういったリスクを何らかの手法でケアしながら、しかし全体として穴埋め問題としての体裁を保てるような工夫が必要となります。

単一のプロンプトに責務が集中することによる精度面・汎用性の下落

課題1に関連しますが、Nodeの生成を大きなリスクと捉えた場合、それをケアするためにはAgentのInstruction内に多くの文字数を割いて、Nodeのスキーマについて解説することが必要になってしまいます。

そうすると、そもそも当Agentは人間が作成する品質に近い穴埋め問題作成エージェントを目標にしているのにもかかわらず、穴埋め問題の作成そのものの指示より構造化を守ることの指示が大半になってしまい、肝心の問題のクオリティが上げにくくなる状況となりました。

### Element種別別サンプル(構造説明用)
以下は各Element種別のJSONフォーマット例です。**内容は実際のテストで変更してください**

\`\`\`json
[{"type": "paragraph", "children": [{"text": "記述短答"}, {"text": "の例:最も面積が広い都道府県は", "type": "text"}, {"type": "question", "children": [{"text": "", "type": "input-answer", "correct": "北海道"}]}, {"text": "です。", "type": "text"}]}, {"type": "paragraph", "ch... (以下略)
\`\`\`

もちろんAIエージェントフレームワークのMastraではAgentのレスポンスにZod Schemaを指定でき、そのSchemaのDescriptionでできるだけ型情報を説明するのがベターではあるものの、ある程度以上複雑なJSONだと、Instructionに書かないと守ってくれないことが多発し、結果としてこうなってしまいます。

そのため、以下のようなテストを作るうえでのそもそもの常識にみたいなものに割く文字数が減ってしまい、ある段階から問題のクオリティが上がらなくなってきた印象を受けました。

こういうのを沢山書きたい.md
### ⚠️【重要】回答漏洩の絶対禁止
**絶対に実行してはいけない致命的エラー**
- 問題の答えを他の問題文中に記載することは絶対に禁止
  (以下略)

当記事では上記のそれぞれの課題に対してどのような取り組みを行ったか、およびその変遷や結果について記します。

補足

穴埋め問題の生成にAIエージェントを用いている理由としては以下のようなところになります。エージェントとしての振る舞いも求められることから、なおさらJSON Nodeの生成にInstructionを占められたくない、という課題が重くなります。

  • 画像のOCR、PDFファイルのテキスト化、Web検索等のツールを、ユーザー要望に対して臨機応変に呼び出したい
  • チャット形式で対話的にテストを良くしていくUXを作りたい
  • それ以外にもテストの部分的な改善など様々な用途に横展開していける汎用的な穴埋め問題作成AIを開発したい

解決に向けて取り組んだこと

過渡期1:Zod Schemaで網羅的に指定&正規化処理を自前実装(〜2025年10月)

まず最初に行って、その後数ヶ月運用をしたのが、Zod Schemaで網羅的に指定&正規化処理を自前実装する指針でした。

Zod Schemaで生成可能なNodeの型定義をすべて定義しました。いくつか抜粋するとこんな感じです。

const _MathInlineSchema = z.object({
  type: z
    .literal('math_inline')
    .describe(
      '文章中に埋め込まれるインライン数式。例: "E = mc^2"といった数式は原則としてTeX形式で表示するためこのNodeとする',
    ),
  texExpression: z.string().describe('LaTeX形式の数式文字列。例: "x^2 + y^2 = r^2"'),
  children: z.array(
    z.object({
      text: z.string().describe('空文字列固定(void要素のため)'),
    }),
  ),
});

const _ImageSchema = z.object({
  type: z.literal('img').describe('固定で"img"'),
  url: z.string().describe('画像のURL。ユーザーから提供されるなどして確実に存在するURLのみ利用すること)'),
  children: z.array(_TextSchema).min(1).describe('固定で空文字'),
});

const _TableSchema = z.object({
  type: z.literal('table').describe('テーブル要素のタイプ'),
  children: z
    .array(
      z.object({
        type: z.literal('tr').describe('テーブル行のタイプ'),
        children: z
          .array(
            z.object({
              type: z.literal('td').describe('テーブルセルのタイプ'),
              children: z
                .array(
                  z.object({
                    type: z.union([z.literal('p'), z.literal('paragraph')]).describe('段落のタイプ'),
                    children: z
                      .array(z.union([_TextSchema, _ChoiceQuestionSchema, _InputQuestionSchema, _MathInlineSchema]))
                      .min(1)
                      .describe('段落の内容(テキスト、問題、数式を含む)'),
                  }),
                )
                .min(1)
                .describe('セル内の要素(段落のみ)'),
            }),
          )
          .min(1)
          .describe('行内のセル配列'),
      }),
    )
    .min(1)
    .describe('テーブル内の行配列'),
});

これだけである程度お察しかと思いますが、限度があるし、かつ無理ゲーと思いました。

リッチテキストというのは基本的にNodeをネストすることが可能です。箇条書きの中に箇条書きを入れられるし、その中にQuestionを入れることもできるし、太字も入れることができる。上記の例のように表にいたっては、表自体がそもそもネスト構造なのに加えて、各セルに多様なノードを挿入可能になることで、リッチテキストを実現可能ですが、こんな構造をAIに生成させるのは、安定性にも疑問が残ります。

リッチテキストエディタはライブラリにも依るかもしれませんが基本的にはInvalidなNodeを入れたらその時点でクラッシュまたは空文字などにフォールバックしてしまいます。そのため、Schemaをひたすら定義するのは再帰的な構造であることも踏まえるととても神経質な作業になります。

あと、エージェントフレームワークにも依るかもしれませんが、LLMがInvalidな値を出力した瞬間に生成を停止する場合もあるため、Schemaを固くしすぎると、生成途中だから一瞬中途半端な形状のJSONを出力しただけなのに処理がストップするようなこともありました。

そこで私が当初実装したのは、LLMにはある程度ルーズなJSONを生成させることを許容し、そのかわりに生成途中などに出てくるInvalidなJSONを正規化(パース)するレイヤーを自前実装する、というものでした。

LLMの出力をこういう関数に通すよ.ts
function createMathBlockElement(contentItem: { type: 'math_block'; texExpression: string }): Element {
  return {
    type: 'math_block',
    texExpression: contentItem.texExpression || '',
    children: [{ text: '' }],
  };
}

たとえばこのコードが正規化する例です。よく読むと実質何もやっていないように見えるかもしれませんが、ポイントは children です。

AIへ指示するスキーマは以下のようになっているのですが、

  children: z.array(
    z.object({
      text: z.string().describe('空文字列固定(void要素のため)'),
    }),
  ),

たとえばこれだと一瞬

children: []

となる瞬間があり、これがWYSIWYGエディタの仕様上、空配列がchildrenに指定されるとクラッシュするといった挙動と紐づいていると、結果的にストリーミング中一瞬そのようなコンテンツが有るだけで破壊されてしまうことになります。

上記の例では.min(1) をやるだけで一応解消されるのですが、とはいえそれ以外にも空文字固定と言っているのにLLMが空文字にしない可能性もちょっとあるとか、そういったことを気にするときりがないので、各ノードごとにクラッシュされそうなところは実際に大丈夫かどうかパースするレイヤーを入れてケアする調整を行いました。

あとはLLMに指定する用のZod Schemaと、実際にエディタに流し込む直前にパースするZod Schemaを分けたり、いろいろ工夫しました。

過渡期2:テスト内容作成用のツールを切り出し(testConstructTool)

AgentのInstructionがJSON Nodeの形式で支配的になってしまうなら、逆にテストの品質に関わる部分を別プロンプトに切り出したら良いじゃないかという逆転の発想を適用する検証・運用も行いました。

テストを構築することに特化したTool "testConstructTool" を定義し、Agent本体から呼び出すことにしてみたのです。

testConstructTool.ts
export const testConstructTool = createTool({
    // 中略
  execute: async ({ context, runtimeContext }) => {
    const { instructions } = context;

    // 中略

    // システムプロンプト(テスト内容作成に特化した指示)
    const systemPrompt = `あなたは教育効果の高いテスト作成のエキスパートです。
与えられた指示と参考情報を基に、高品質なテスト内容をテストメーカー互換のMarkdown形式で作成してください。

<テストメーカー互換の問題形式とは>
個々の穴埋め問題は以下の独自形式のいずれかで表現してください(A=Answer, W=Wrongの意味):

### 完全回答形式
りんごの英語は(A=apple)です。

### 選択問題形式
りんごの英語は(A=apple,W=[banana,orange,grape])です。
※正答がapple、誤答選択肢がbanana,orange,grape

### 正答複数形式
2025年5月時点の与党は(A={自民党 or 自由民主党},W=[立憲民主党,共産党])です。
    // 中略
## 文章構成要素
以下のMarkdown要素を活用してください:
- 見出し(# ## ###)
- 段落(通常の文章)
- 箇条書き(- または 1.)
- 表組み
- 強調(**太字**)
- 数式(LaTeX記法)
- 画像(![画像説明](画像URL))**❗画像は存在するURLがプロンプト内にURLで渡されたときにそのURLをそのまま利用すること**
- URL([リンクテキスト](URL))**ℹ️ URLは参考情報や詳細情報へのアクセスを提供する際に使用**`;
    // ユーザーメッセージの構成
    let userMessage = `ユーザー指示:${instructions}`;

    // 省略:WebサイトやPDFを読み込んで結合する

    try {
      const response = await openai.chat.completions.create({
        model: 'gpt-4.1',
        messages: [
          {
            role: 'system',
            content: systemPrompt,
          },
          {
            role: 'user',
            content: userMessage,
          },
        ],
        max_completion_tokens: 20000,
      });

      const testContent = response.choices[0]?.message?.content || '';

      return {
        testContent,
      };
    } catch (error) {
      mastraLogger.error(`[${new Date().toISOString()}] testConstructTool: Error in testConstructTool`, { error });
      throw new Error('テスト内容の生成に失敗しました');
    }
  },
});

JSON形式を暗記させるよりは簡単だろうと考え、なんとも言い難い独自形式で問題を定義する表現記法を発案して、Promptでそれを教え込んで穴埋め問題を作らせています。一応実装当初は、Question NodeのJSON形式よりは宣言的で読みやすいし、トークンの節約にもなるし、まあ悪くないかと思っていました。

Agent本体の方は以下のように独自形式について明記しつつ、ツールを登録しています。

export const testMakerAgent = new Agent({
  name: 'Test Maker Agent',
  instructions: `あなたは"テストメーカー"互換のテスト作成を行うAIエージェントです。
  (中略)

### 生成されるテスト内容形式
testConstructToolが返すテスト内容は、以下の独自形式で穴埋め問題が表現されますので、JSONに変換する必要があります:
- 完全回答: (A=apple)
- 選択問題: (A=apple,W=[banana,orange,grape])
- 正答複数: (A={自民党 or 自由民主党},W=[立憲民主党,共産党])
- 多答問題: (A={兵庫県 and 奈良県 and 和歌山県 and 京都府},W=[三重県,徳島県,滋賀県])

## テストメーカー互換のJSON生成
- JSON形式を遵守していないと、テストがクラッシュし、ユーザーは回答すら閲覧できません

### 簡単な選択問題の例
testConstructToolによる生成例:大阪府に面している都道府県は(A={兵庫県 and 奈良県 and 和歌山県 and 京都府},W=[三重県,徳島県,滋賀県])です。
JSON形式:[{"text": "大阪府に面している都道府県は", "type": "text"}, {"type": "question", "children": [{"text": "", "type": "input-answer", "correct": ["兵庫県", "奈良県", "和歌山県", "京都府"], "choiceItems": ["三重県", "徳島県", "滋賀県"], "correctItems": ["兵庫県", "奈良県", "和歌山県", "京都府"], "isMultipleChoice": true, "hasAlternativeSolutions": false}]}, {"text": "です。", "type": "text"}]

### 画像のJSON反映
⚠️【重要】testConstructToolが生成した画像Markdownは必ず専用Nodeに変換する。![資料 2](https://example.com/image.png) -> {"url": "https://example.com/image.png", "type": "img", "caption": [{"text": "資料 2"}], "children": [{"text": ""}]}
### 数式のJSON反映
⚠️【重要】testConstructToolが生成したリンクMarkdownは必ず専用Nodeに変換する。[記事を読む](https://example.com/article) -> {"type": "a", "url": "https://example.com/article", "children": [{"text": "記事を読む"}]}
### 数式のJSON反映
⚠️【重要】testConstructToolが生成した数式は必ず専用Nodeに変換する。x^2+3x+2=0 -> {"type": "math_inline", "children": [{"text": ""}], "texExpression": "x^2+3x+2=0"}

### その他のJSON形式
**重要**: 以下は**JSONの構造説明サンプル**です。内容は架空の例であり、実際のテスト作成時はユーザーの要求に応じた内容を作成してください。

JSON例:
[{"type": "parag
※以下略
`,
  model: openai('gpt-4.1'),
  tools: {
    // ...
    testConstructTool,
  },
});

さて、とはいえここまで読んでいただいた方ならお察しかもしれませんが、こうやってツールを分けても結局親元のAgentに、testConstructToolが吐き出す独自形式を理解してね、とか、それをJSONに変換してね、みたいな注意事項が増えていくので、なんか労力と複雑性の増加に見合っていないなぁという感じになりました。

### 画像のJSON反映
⚠️【重要】testConstructToolが生成した画像Markdownは必ず専用Nodeに変換する。![資料 2](https://example.com/image.png) -> {"url": "https://example.com/image.png", "type": "img", "caption": [{"text": "資料 2"}], "children": [{"text": ""}]}
### 数式のJSON反映
⚠️【重要】testConstructToolが生成したリンクMarkdownは必ず専用Nodeに変換する。[記事を読む](https://example.com/article) -> {"type": "a", "url": "https://example.com/article", "children": [{"text": "記事を読む"}]}
### 数式のJSON反映
⚠️【重要】testConstructToolが生成した数式は必ず専用Nodeに変換する。x^2+3x+2=0 -> {"type": "math_inline", "children": [{"text": ""}], "texExpression": "x^2+3x+2=0"}

ということで、やっぱり大元の課題は「テスト作成AIエージェントに、Editor互換(をある程度目的とした)JSONを生成させる」というミッションが厄介すぎるのであって、

「AIエージェントには文書を作らせて、それをエディタに差し込むのは別のレイヤーで担保するのがいい」という理解に行き着きました。

最終的に行き着いた形

LLMはMarkdown(MDX)を出力&エディタ側にNode変換処理を実装

最終的に、テストメーカー上でそのまま解釈できる形式として、Markdown(MDX)をLLMの出力形式に採用する形に落ち着いています。

生成されたMarkdownは、そのまま文字列として保持するのではなく、WYSIWYGエディタ上で編集・再利用できる形に変換する必要があります。
そのため、LLMの出力を受け取ったあと、フロントエンド側でMarkdownをパースし、エディタが扱えるNodeへ変換する処理を挟みました。

基礎的なMarkdown対応自体は利用しているWYSIWYGエディタのライブラリにプラグインとしてインストールする形で終わりました。

MarkdownPlugin.configure({
  options: {
    remarkPlugins: [
      remarkGfm,
      remarkMath,
      remarkMdx,
    ],
  },
});

この構成により、段落・箇条書き・表・インライン数式・ブロック数式といった一般的なMarkdown要素については、特別な前処理を挟まずにNodeへ変換できるようになります。
少なくとも、LLMが出力したMarkdown全体を一度ASTとして受け止め、その後でエディタ側の都合に合わせて処理できる状態が整いました。

ただ、こんな簡単な意思決定をずっと渋っていた理由でもあるのですが...テストメーカーのEditorはQuestionノードだけではなく数式や画像や音声など色々埋め込めるので単に巷のマークダウンプラグインを入れたら動くというわけでもなく、なんだかんだいって「単にマークダウンプラグインを入れてシリアライズ・デシリアライズを動かす」だけで足掛け1ヶ月くらいかかっちゃいました(趣味の個人開発ですけどね)。

そして肝心のQuestion Nodeについては、Markdownの拡張としてMDXを利用し、設問に相当する部分をJSX要素として記述する形を取りました。

https://mdxjs.com/

MDXであれば、通常のMarkdown構文と混在させたまま、意味を持つ要素をテキスト中に埋め込むことができます。

このJSX要素は、Markdownのパース時点ではAST上のノードとして扱われるため、そのままWYSIWYGエディタ用のNodeへ変換することが可能です。
実装としては、MDXのJSX Text Elementを受け取り、対応する設問用Nodeへ写像する処理をエディタ側に用意しています。

ELEMENT_QUESTION: {
  deserialize(mdastNode: MdMdxJsxTextElement) {
    const correct = getAttributeValue('correct') || '';

    // ここにMDXをパースしてJSON形式のどこに入れるかをハードコードする処理がたくさんある

    return {
      type: 'question',
      children: [
        {
          type: 'input-answer',
          text: '',
          correct,
        },
      ],
    };
  },
}

この判断は、前節(技術施策1.1)で行っていたアプローチと対比すると、意図がより分かりやすくなります。

1.1 では、LLMに直接Node構造を生成させる前提だったため、生成可能なNodeの種類をZod Schemaで網羅的に定義し、その構造をInstructionやSchemaの description で説明していました。

Markdown(MDX)生成に切り替えた後は、この前提自体が変わります。

- Markdownとして問題文を書く
- 設問にしたい箇所だけを <question /> でマークする

LLMに求めるのはここまでで、Nodeのネスト構造や children の形状、エディタ固有の制約は一切意識させません。
多種多様なNode SchemaをZodで列挙し、それを守らせるための説明を書く必要もなくなりました。

その分、Instructionは「どういう問題が良い問題か」「誤答選択肢をどう作るか」「回答が問題文から漏洩しないか」といった、テストの品質そのものに集中できます。

MDXの細かい形式は以下のようにテストメーカーのサポートする多様な形式に沿って仕様を決めて、エージェント本体とMarkdownプラグイン両方にカスタム定義しています。

『こころ』が発表されたのは<question correct="1914" choiceItems='["1912", "1915", "1917"]' />年である。

まとめ

AIに何でも任せるのではなく、データ整形については自分たちの実装で守ろう

長々と記事を書きましたが、
テストメーカーがWYSIWYGエディタを使っているからといって、AIにWYSIWYG対応のJSONを直生成させるのが大変だった、それをMarkdownに移行すると結構いい感じになった、というだけの話ではありました。

しかし途中で触れたように、Markdownパーサーをある程度実装するのって面倒なので、ついつい「LLMが複雑なJSONノード生成してくれたら良いな〜」という気持ちで数ヶ月間放置してしまったというのが実態です。

端的な教訓としては、AIに何でも任せるのではなく、データ整形については自分たちの実装で守ろうといったところでしょうか。

Q. Markdown生成にして、肝心の精度は上がったのかい?

これについては、まずAIエージェントの精度って大きく

  • システムの一員として正しく動くという意味の精度
  • より高いユーザー価値を提供するという意味の精度

の2つに分かれると思っていて、今回のMarkdown移行は前者に効いているという話です。

JSONノード生成時代は、LLMがJSONノード生成に精一杯になったので、たとえば

りんごの英語は【     】Appleです

みたいに、空欄の隣に普通に答えが出ている、みたいな、絶対ありえないだろみたいなミスも起きていました。巨大なJSONをLLMが作っているので、仕方ないですよね。

Markdown生成にしてからはそういう事は起きません。出力するものがほぼレンダリング結果になるわけなので、やりやすいです。

Q. じゃあ、結局ユーザー価値は上がってないってことね

まあそうなると思います。身も蓋もないことをいうと、AIエージェントでユーザー価値を上げるのって、モデルの性能とか、こういう足回りの実装じゃなくて、よくできたツールが入ったら急激にアウトプットが上がるみたいな話だとは思ってます。そもそも食えるデータがすごく良くなるとか、ファイルや画像処理とかの精度が上がるとか、そういうことのほうがよっぽどキャズムを超える印象です。

ただ一方でキャズムを超える機会が訪れても、JSON生成なの?Markdown生成なの?みたいなところをしっかり解けていないとチャンスを棒に振ることになるかもというのも、また真実だと思います。

Q. 本日時点のAI生成テストはどんな感じ?

こんな感じです!

以下の画像をアップロードして、このままAIにテスト化して、とお願いすると(2025年度 大学入学共通テスト(本試験)「歴史総合」問題より抜粋)

数十秒後にはこういうテストができあがってます!もちろんブラウザ上に表示されるので回答してその場でシステムで自動採点なども可能です。
テスト内の図表が自動で切り抜かれて試験中に差し込まれているあたりとかも、地味にアピールポイント。
細かいところでなんかよくわからん英語が入っていたりするんですが自由に編集可能なので、許して!っていうのが現状の精度ですね。

※本稿では、大学入試センターが公開している共通テスト問題を研究・技術検証目的の範囲で一部参照しています。本サービスを活用して問題文の全文掲載や再配布を目的とする・推奨するものではありません。


以上です!またAIエージェントで面白い記事が書けるように精進します!

Discussion