Algomatic Tech Blog

Algomaticの開発チームによる Tech Blog です

Zodスキーマでプロンプト生成を行い構造化データを自由自在に扱えて、LLMプロダクト開発が圧倒的に効率化した話

最近はAIエンジニアを名乗ってるerukitiです。フロントエンドもバックエンドも、LLMを触るあれこれもやってるので、「AIエンジニア」くらいを名乗るとちょうどよさそうだなと思ってます。いずれLLM自体の開発なんかもやってるかもしれません。

LLMプロダクトを開発していると、構造化データを作りたいのに、Anthropic ClaudeのAPIにはJSONモードが無いことや、なんならJSONモードやfunction callingを使っても、データが正しい形式に従ってることは保証しがたい、みたいな自体に遭遇することがあります。

JSONが出力できたとしても、構造化データをうまく吐き出させるのは難しいものです。文字列を出力させたいけど、複数あるときは、配列なのか、それともカンマ区切りなのか?項目がオプショナルの場合はどうするか?項目が存在しない、空文字や 0undefinednull が出力されてしまうみたいなこともあるでしょう。

どう指示をすれば安定して出力をさせられるのか、苦労している人も多いでしょう。

この記事では、TypeScript + Zod Schemaを使ってプロンプトを生成することで、如何に簡単に狙った通りの構造化データを出力させられるか?それにより圧倒的にプロンプトエンジニアリングが効率化したか?を書きます。

対象読者

この記事は開発者向けの記事です。TypeScriptを前提にしていますが、他の言語でも考え方は応用可能だと思います。

プロンプト開発の苦労

プロンプトエンジニアリングでは、これが効くというテクニックが、いとも簡単に陳腐化してしまいます。モデルの進化によっていらなくなるテクニックなんてのも割とありますよね。

不要になったプロンプトは逆に邪魔になるケースすらあります。僕の体感ではプロンプトはその指示が複雑なほど、LLMの持つ思考能力を消費するため、不要な指示を削った方がうまくいく傾向があります。(これについては今度詳しく調べてみます)

Anthropic ClaudeはXMLでプロンプトを与えると良いという話や、ソースは失念しましたが、OpenAI GPTはMarkdownで見出しを与えると良いとか、他にも様々な怪しげな儀式めいたプロンプトテクニックもあります。

どうせエンジニアリングするなら、本質的な表現を元に変換・生成をしたいものです。

データ表現

構造化データを吐き出させるというタスクの場合、構造について仕様をLLMに言い聞かせなければいけません。どういう形式ならいいでしょうか?

ここからは、以下のファンタジー作品のテキスト2つを元に構造化してみましょう。

フリーレン

本作の主人公[9]。魔王を討伐した勇者パーティーの魔法使い。長命なエルフ族の出身で、少女のような外見に反して1000年以上の歳月を生き続けている。人間とは時間の感覚が大きく異なるため、数か月から数年単位の作業をまったく苦にせず、ヒンメルらかつての仲間たちとの再会も50年の月日が経ってからのことだった。ヒンメルが天寿を全うして他界したのを機に、自身にとってはわずか10年足らずの旅の中でヒンメルの人となりを詳しく知ろうともしなかったことを深く後悔し、趣味の魔法収集を兼ねて人間を知るための旅を始める。生前時のヒンメルに対する意識は希薄であったが、幻影鬼(アインザーム)との遭遇時や、奇跡のグラオザームに「楽園へと導く魔法(アンシレーシエラ)」を使われた際などは幻想の中でヒンメルを思い描くなど、無自覚に意識しているような描写が散見されている。
1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、自身を救ってくれた大魔法使いフランメの弟子となる。生来の天才的資質に加えて、フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続けた結果、きわめて強大な魔力を得ている。さらに、その魔力をほぼ完全に隠匿する技術[注 1]も習得しており、敵の魔族に自身の実力を過小評価させた隙を突く戦法を得意とする。その実力は魔王亡き後の現在の魔族を弱いと感じ、七崩賢の一角である断頭台のアウラにさえ完勝するほど。魔族側からは、歴史上もっとも多くの同胞を葬り去った存在として「葬送のフリーレン」と呼び恐れられている[注 2]。ただし、自身の魔法を発動する一瞬だけ魔力探知が途切れるという弱点があり[注 3]、自身よりも魔力の低い魔法使いに計11回敗北した経験があるとも語っている[注 4]。
「服が透けて見える魔法」や「かき氷を作る魔法」など、およそ戦闘に役に立たない魔法を収集するのが趣味で、そうした魔導書を対価に仕事を引き受けたりもする。再会したハイターの差し金で人間のフェルンを弟子に取って以降は、自身の旅に同行させている。
性格はドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない。普段は表情に乏しく淡々としており、一般的な富や地位、名声には興味を示さないが、大好きな魔導書を手に入れるために無茶をしたり、食い意地が張っていたり、朝が弱く寝坊がちだったり、自身の貧相な体型を気にしていたり、実年齢で年寄り扱いされるのを嫌うなど、これらの際の感情表現は豊かである。長命なエルフゆえに、人間など短命な他種族の思考・思想には鈍感で、それらの人々とのコミュニケーションはやや不器用。自身の故郷と仲間を奪った魔族に対する憎悪は深く、感情を表に出すことこそないながらも、敵対する魔族に対しては周囲の状況を顧みず問答無用で葬ろうとする。これには、「人間の言葉で人間を欺き人間の言葉が通じない猛獣」という魔族の本質を理解している理由もある。
「歴史上で最もダンジョンを攻略したパーティーの魔法使い」と自称するだけあり、ダンジョンには詳しい。道中で宝箱を発見するとその中身に異常なまでの興味を示し、判別魔法で99パーセントミミック(宝箱に化けた魔物)とみやぶってなお、残り1パーセントの可能性[注 5]に賭けて宝箱を開け、上半身をミミックに噛まれてもがくという場面が何度も描かれている。

----

シュタルク

勇者パーティーの戦士アイゼンの弟子で、師匠と同じく斧使い。17歳→19歳。極端に憶病かつ自己評価が低い性格であるが、実際は巨大な断崖に斧で亀裂を入れるほどの実力者。師匠とけんか別れをしたあと、紅鏡竜の脅威にさらされた村に3年ほど滞在していた。アイゼンの推薦でフリーレンの仲間に指名され、無自覚ながらも紅鏡竜を一撃で倒す能力を発揮し、彼女たちの旅に同行することとなる。中央諸国クレ地方にあった戦士の村出身で、幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツからは認められ可愛がられていた。
アイゼンから「とんでもない戦士になる」と言わしめるほどの素質の持ち主で、フェルンからは化け物かと疑われるほどの膂力と頑強さをもつ。男性に免疫がないフェルンからは無意識な恐れを抱かれ、自身も女性の扱いが苦手な一方で、互いに憎からぬ感情を抱いており、不機嫌になったフェルンに謝罪したり、デートのように連れ歩いたりするさまから、ザインからは「もう付き合っちゃえよ」などと漏らされている。男性の象徴に対する評価は芳しくなく、「服が透けて見える魔法」で自身の下半身を見たフェルンからは「ちっさ」と漏らされて傷つく場面がある。好物は自身の誕生日にアイゼンがふるまってくれるハンバーグ。

引用元: https://ja.wikipedia.org/wiki/葬送のフリーレン

箇条書き

ぱっと思いつくものとしては、箇条書きがあります。

  • 上記のファンタジーのキャラをJSON形式にしてください
  • JSONには、名前(name)、年齢(age)、能力値(stats)、属性(attributes)、性格(personality)、生い立ち(background)、使える魔法(magic)を含めてください
  • 名前は文字列で出力してください
  • 属性は文字列の配列で出力してください
  • 年齢は数値で出力してください
  • 性格は文字列で出力してください
  • 能力値は、筋力・知力・器用さ・素早さ・運を、3〜18で出力してください
  • 生い立ちは文字列で出力してください
  • 魔法は、そのキャラクタが使える魔法を一つだけ文字列で出力してください。魔法が使えない場合は項目を出力しないでください

さて、このような箇条書きならどれくらいのものを出力してくれるでしょうか?

[
  {
    "name": "フリーレン",
    "age": 1000,
    "stats": {
      "strength": 15,
      "intelligence": 18,
      "dexterity": 16,
      "speed": 12,
      "luck": 14
    },
    "attributes": ["エルフ族", "魔法使い"],
    "personality": "ドライで厳しい一面があるが、普段はやさしく面倒見も悪くない。感情表現が豊かで、人間とのコミュニケーションにやや不器用。",
    "background": "1000年以上前、故郷の集落を魔族に襲われた際に大魔法使いフランメに救われ弟子となった。長い年月をかけて自身の魔力を大きく向上させ、強大な存在となった。",
    "magic": "魔力探知を一時的に隠す魔法"
  },
  {
    "name": "シュタルク",
    "age": 19,
    "stats": {
      "strength": 16,
      "intelligence": 12,
      "dexterity": 14,
      "speed": 13,
      "luck": 11
    },
    "attributes": ["戦士", "斧使い"],
    "personality": "極端に憶病かつ自己評価が低いが、実際は強大な実力を持つ。女性の扱いが苦手だが、フェルンに対して憎からぬ感情を抱いている。",
    "background": "中央諸国クレ地方の戦士の村出身。幼少時は魔物と戦えない失敗作と父から見下されていたが、兄に可愛がられ、師匠アイゼンから「とんでもない戦士」と評された。",
    "magic": null
  }
]

by Anthropic Claude 3 Haiku

じつはこのプロンプト Claude 3 Opusだとうまく成功してしまうんですが、Haikuだとこの通り、シュタルクの magicnull になっています。

OpenAI GPTシリーズで試してみると、また違う結果になってるのでそちらを皆さんも試してみてください。かなり面白い結果になると思います。

JSONサンプル

JSONを吐き出させるならJSONを実際にサンプルとして例示してもいいかもしれません

[{
  "name": "キャラクターの名前",
  "age": 20,
  "stats": {
    "strength": 10,
    "intelligence": 10,
    "dexterity": 10,
    "agility": 10,
    "luck": 10
  },
  "attributes": ["キャラクターの属性"],
  "personality": "性格",
  "background": "キャラクターの生い立ち",
  "magic": "使える魔法を1つ。なければこの項目を出力しない"
}]

数値型をどう書くかは悩ましいです。文字列で書こうとすると、文字列で出力してしまう可能性があります。

LLMの性能や癖によっては、書かれた数値そのままで出てしまうかもしれません。

プロンプトは 以上のキャラクターを、以下のJSON例に従ったJSONとして出力してください。 でキャラクターのテキストとJSON例をつないでいます。

[
 {
   "name": "フリーレン",
   "age": 1000,
   "stats": {
     "strength": 10,
     "intelligence": 10,
     "dexterity": 10,
     "agility": 10,
     "luck": 10
   },
   "attributes": [
     "エルフ族",
     "魔法使い"
   ],
   "personality": "ドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない。長命なエルフゆえに、人間など短命な他種族の思考・思想には鈍感で、それらの人々とのコミュニケーションはやや不器用。自身の故郷と仲間を奪った魔族に対する憎悪は深い。",
   "background": "1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、自身を救ってくれた大魔法使いフランメの弟子となった。生来の天才的資質に加えて、フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続けた結果、極めて強大な魔力を得ている。",
   "magic": [
     "服が透けて見える魔法",
     "かき氷を作る魔法"
   ]
 },
 {
   "name": "シュタルク",
   "age": 19,
   "stats": {
     "strength": 10,
     "intelligence": 10,
     "dexterity": 10,
     "agility": 10,
     "luck": 10
   },
   "attributes": [
     "戦士",
     "斧使い"
   ],
   "personality": "極端に憶病かつ自己評価が低い性格だが、実際は巨大な断崖に斧で亀裂を入れるほどの実力者。男性に免疫がないフェルンからは無意識な恐れを抱かれ、自身も女性の扱いが苦手な一方で、互いに憎からぬ感情を抱いている。",
   "background": "中央諸国クレ地方にあった戦士の村出身で、幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツからは認められ可愛がられていた。勇者パーティーの戦士アイゼンの弟子で、師匠と同じく斧使いとなった。",
   "magic": null
 }
]

by Haiku

能力値が全部10になってしまいました。JSON形式だと指示の与え方が悩ましいです。 そしてフリーレンの魔法に関しては文字列の配列になったし、シュタルクは相変わらず null です。

とは、言えほとんど指示らしい指示をしてないのにこの追従能力は逆にすごいといえるかもしれません。 今回はやらなかったですが // コメントで指示を出す というようなことをすればより確実だと思います。

TypeScript型定義

TypeScriptは、型のある言語です。LLMが学習しているデータの中には多くのソースコードや解説が含まれているため、型というのはかなり強固な情報です。

type Output = Array<{
  /** キャラクターの名前 */
    name: string;
    /** キャラクターの年齢 */
    age: number;
    /** キャラクターの属性 */
    attributes: Array<string>;
    /** キャラクターの性格 */
    personality: string;
    /** キャラクターの能力値を3-18で */
  stats: {
        /** 筋力 */
        strength: number;
        /** 知力 */
        intelligence: number;
        /** 器用さ */
        dexterity: number;
        /** 素早さ */
        agility: number;
        /** 運 */
        luck: number;
    };
    /** 生い立ち */
    background: string
    /** 使える魔法 */
    magic?: string
}>;

TypeScript型定義を使うと、型に関しては確実度が上がります。

プロンプトは 以上のキャラクターを、以下のTypeScriptの型定義に従ったJSONとして出力してください。 でキャラクターのテキストと型定義をつないでいます。

[
 {
   "name": "フリーレン",
   "age": 1000,
   "attributes": [
     "魔法使い",
     "エルフ族",
     "長寿",
     "戦闘向け魔法使い",
     "宝箱収集家"
   ],
   "personality": "ドライで厳しい一面もあるが普段はやさしく面倒見もよい。感情表現は豊か。人間との会話は不器用。魔族に対しては憎悪が深い。",
   "stats": {
     "strength": 12,
     "intelligence": 18,
     "dexterity": 16,
     "agility": 15,
     "luck": 13
   },
   "background": "1000年以上前に故郷の集落を魔族に襲われ、大魔法使いフランメの弟子となった。長年の修練により極めて強大な魔力を手に入れ、「葬送のフリーレン」と恐れられている。",
   "magic": "各種実用魔法、戦闘向け魔法"
 },
 {
   "name": "シュタルク",
   "age": 19,
   "attributes": [
     "戦士",
     "斧使い",
     "憶病",
     "低自己評価"
   ],
   "personality": "極端に憶病かつ自己評価が低いが、実際は強大な戦闘能力を持つ。女性との付き合いが苦手だが、フェルンとは微妙な関係を築いている。",
   "stats": {
     "strength": 17,
     "intelligence": 12,
     "dexterity": 14,
     "agility": 15,
     "luck": 11
   },
   "background": "中央諸国クレ地方の戦士の村出身。幼少時は魔物との戦闘に失敗していたが、後に兄に認められ、師匠のアイゼンから「とんでもない戦士になる」と評された。"
 }
]

by Haujku

とても良いですね!

ちなみに、Opusは、なぜかフリーレンをluck高めに設定してますが、彼女は運が良いキャラなんですかね?まぁLLMは数字を大体適当に扱ってしまう特徴があるので、まぁこんなもんか位に捉えておくのが望ましいです。

データ表現の結論

今回ぐらいの難易度のタスクであれば難しくはありませんが、それでも箇条書きでの指示やJSONの例示にはある程度問題があります。

TypeScriptだとかなり厳密に出力の型を制御できるようです。データ構造が複雑になっても指示方法は一貫してシンプルです。

ただし、TypeScript定義でもやっぱり運が悪いと magicnullundefined を出力するときがあるので、いっそ後加工で nullundefined を削る処理を入れるのもありです。

JSONサンプルは書くのは楽ですが、いろいろ指示しづらいので、TypeScriptを使わないなら、実は箇条書きが次善策かもしれません。

みなさんも是非試してみてください。

JSONを確実に取り出す

さて、実際にプロンプトを試してみて、JSONが出力されることは分かりましたが、JSONモードを使ったOpenAI Chat Completions API以外では、余計なテキストが前後につきがちです。そのため、確実にJSONのみを取り出してparseする必要があります。

  • 出力するJSONをオブジェクトにすれば { から始まって } で終わることを保証できる
  • {} を探して、貪欲法で JSON.parse を繰り返して、成功すればそれはJSON

くらいの雑な方法で取り出せます。

さらに、いわゆる leading words を指定することで、JSONから始まることを確実にすることもできます。以下はClaudeの場合です。

[{
  "role": "user",
  "content": "〜〜〜〜JSONを出力してください。"
},
{
    "role": "assistant",
    "content": "{"
}]

この場合は、assistant(つまりLLMの発言)の最初を { に固定することで、JSONから開始させることを強要しています。

とはいえ、その場合でも後ろにゴミがつきがちです。

{
中略
}

以上がフリーレンとシュタルクのデータです。いかがでしょうか?

みたいな、ついつい余計な言葉を付け足される問題と格闘した人も多いと思います。

そこで、JSONのみを抽出してしまいましょう。

/**
 * 文字列に含まれたJSONをすべて抽出する
 */
export const extractJSON = (text: string): string[] => {
  // アルゴリズム:
  // 1. まず最初の `{` を探す
  // 2. `}` を探して JSON.parse が通るか試す。通るならresultに追加
  // 3. JSON.parseが通らない場合、次の `}` を探す。通るならresultに追加
  // 4. 繰り返してだめだったら、次の `{` を探す
  // 5. `{` がなくなれば終了

  const result: string[] = [];
  let start = text.indexOf("{");
  while (start !== -1) {
    let end = text.indexOf("}", start);
    if (end === -1) {
      break;
    }

    while (end !== -1) {
      try {
        const json = text.slice(start, end + 1);
        JSON.parse(json);
        result.push(json);
        break;
      } catch (e) {
        end = text.indexOf("}", end + 1);
      }
    }
    start = text.indexOf("{", start + 1);
  }

  return result;
};

この方法だと複数JSONが含まれている場合にどうするか?が悩ましく、2人分のJSONがそれぞれ異なるオブジェクトとして出てきた場合の処理を考えると、JSONが複数ある場合はエラーとした方がいいかもしれません。

Zodスキーマでバリデーションする

元々のデータ、出力するデータ構造、指示内容それぞれが複雑になればなるほどLLMは多種多様な出力をしがちです。OpusやGPT-4のようなパラメータ数が多く賢いモデルならともかくそうじゃないモデルには荷が重くなりがちです。

それらを人間が都度チェックするわけにもいかないので、バリデーションをして、型安全にしていきましょう。

Zod という、定義したスキーマを元にバリデーションができるライブラリを使ってみます。

import { z } from "zod";

const outputSchema = z.array(
  z.object({
    name: z.string().describe("キャラクターの名前"),
    age: z.number().describe("キャラクターの年齢"),
    attributes: z.array(z.string()).describe("キャラクターの属性"),
    personality: z.string().describe("キャラクターの性格"),
    stats: z
      .object({
        strength: z.number().describe("筋力"),
        intelligence: z.number().describe("知力"),
        dexterity: z.number().describe("器用さ"),
        agility: z.number().describe("素早さ"),
        luck: z.number().describe("運"),
      })
      .describe("キャラクターの能力値を3-18で"),
    background: z.string().describe("生い立ち"),
    magic: z.string().optional().describe("使える魔法を一つ"),
  })
);

これはさっきのTypeScriptで定義してた型をスキーマに落としこんだものです。

Zodスキーマを定義するときに .describe を使う人はあまりいないかもしれませんが、後ほど便利なので使っています。

このスキーマ定義を元にバリデーションをすると、型安全なデータになります。失敗した場合は、LLM呼び出しからリトライできる仕組みを入れると良いでしょう。

const typedData = outputSchema.parse(data)
// typedData には正しく型がついている
// バリデーションに失敗した場合は例外が投げられる
// 例外を投げたくない場合は safeParse を使う

さて、次がやっと本題です。

プロンプトもZodスキーマから生成する

Zodスキーマz.infer<typeof outputSchema> で型定義を取り出せますが、これはTypeScriptコンパイラコンパイル時にのみ取り出せるものです。これを実行時に取り出せるようにすれば、その型定義を元にプロンプトを作れるはずです。

まずはZodスキーマ定義の型情報をデータとして取り出してみましょう。スキーマの内部データを触ることになってしまうため、Zodのアップデートによって壊れる可能性がある点は注意です。

import { z } from "zod";

type SchemaPrimitive = {
  type: "string" | "number" | "boolean";
  optional?: boolean;
  array?: boolean;
  description: string;
  children?: never;
};

type SchemaArray = {
  type: "array";
  optional?: boolean;
  description: string;
  children: SchemaDef;
};

type SchemaObject = {
  type: "object";
  optional?: boolean;
  description: string;
  children: Record<string, SchemaDef>;
};

type SchemaUnion = {
  type: "union";
  optional?: boolean;
  description: string;
  children: SchemaDef[];
};

type SchemaLiteral = {
  type: "literal";
  value: string | number | boolean;
  description: string;
  optional?: boolean;
};

export type SchemaDef =
  | SchemaPrimitive
  | SchemaArray
  | SchemaObject
  | SchemaUnion
  | SchemaLiteral;

export const getTypeFromSchema = (schema: z.ZodSchema): SchemaDef => {
  if (schema instanceof z.ZodString) {
    return {
      type: "string",
      description: schema._def.description || "",
    };
  }
  if (schema instanceof z.ZodNumber) {
    return {
      type: "number",
      description: schema._def.description || "",
    };
  }

  if (schema instanceof z.ZodOptional) {
    const innerType = getTypeFromSchema(schema._def.innerType);
    return {
      ...innerType,
      optional: true,
      description: schema._def.description || innerType.description,
    };
  }

  if (schema instanceof z.ZodArray) {
    const children = getTypeFromSchema(schema._def.type);
    return {
      type: "array",
      description: schema._def.description || "",
      children,
    };
  }

  if (schema instanceof z.ZodObject) {
    const children: Record<string, SchemaDef> = {};
    for (const key in schema.shape) {
      children[key] = getTypeFromSchema(schema.shape[key]);
    }
    return {
      type: "object",
      description: schema._def.description || "",
      children,
    };
  }

  if (schema instanceof z.ZodUnion) {
    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
    const children = schema._def.options.map((option: any) =>
      getTypeFromSchema(option),
    );
    return {
      type: "union",
      description: schema._def.description || "",
      children,
    };
  }

  if (schema instanceof z.ZodLiteral) {
    return {
      type: "literal",
      value: schema._def.value,
      description: schema._def.description || "",
    };
  }

  throw new Error(`Unsupported schema type ${schema.constructor.name}`);
};

この getTypeFromSchema 関数を実行すると引数のZodスキーマから型情報を取り出すことができます。

さっきZodスキーマ定義で使った .describeschema._def.description として取り出すことができます。

先ほどのZodスキーマは以下のオブジェクトになります。

{
  "type": "array",
  "description": "",
  "children": {
    "type": "object",
    "description": "",
    "children": {
      "name": {
        "type": "string",
        "description": "キャラクターの名前"
      },
      "age": {
        "type": "number",
        "description": "キャラクターの年齢"
      },
      "attributes": {
        "type": "array",
        "description": "キャラクターの属性",
        "children": {
          "type": "string",
          "description": ""
        }
      },
      "personality": {
        "type": "string",
        "description": "キャラクターの性格"
      },
      "stats": {
        "type": "object",
        "description": "キャラクターの能力値を3-18で",
        "children": {
          "strength": {
            "type": "number",
            "description": "筋力"
          },
          "intelligence": {
            "type": "number",
            "description": "知力"
          },
          "dexterity": {
            "type": "number",
            "description": "器用さ"
          },
          "agility": {
            "type": "number",
            "description": "素早さ"
          },
          "luck": {
            "type": "number",
            "description": ""
          }
        }
      },
      "background": {
        "type": "string",
        "description": "生い立ち"
      },
      "magic": {
        "type": "string",
        "description": "使える魔法を一つ",
        "optional": true
      }
    }
  }
}

あとは、ここで取り出したオブジェクトを元にプロンプト用にTypeScript型定義を組み立ててみましょう。

export const getTypeScriptDefintion = (schema: SchemaDef): string => {
  if (schema.type === "string") {
    return "string";
  }
  if (schema.type === "number") {
    return "number";
  }
  if (schema.type === "boolean") {
    return "boolean";
  }
  if (schema.type === "array") {
    return `Array<${getTypeScriptDefintion(schema.children)}>`;
  }
  if (schema.type === "object") {
    const children = Object.entries(schema.children)
      .map(([key, child]) => {
        const description = child.description
          ? `/** ${child.description} */\n`
          : "";
        const optional = child.optional ? "?" : "";
        return `${description}${key}${optional}: ${getTypeScriptDefintion(
          child,
        )};`;
      })
      .join("\n");
    return `{\n${children}\n}`;
  }

  throw new Error("Unsupported schema type");
};

getTypeScriptDefinition はさきほどのオブジェクトを元にTypeScriptの型定義をしてくれます。

ただ、インデントとかはいじってないため、prettierで整形すると良いでしょう。

const typedef = `type Output = ${await format(
  getTypeScriptDefintion(getTypeFromSchema(outputSchema)),
  { parser: "babel-ts" },
)}`

こんな感じのコードになります。ちなみにparserは typescript よりは babel-ts の方がポータビリティがあって良いです。

これで出力するとさっきの型定義と同じ物が出力されます。

type Output = Array<{
  /** キャラクターの名前 */
  name: string;
  /** キャラクターの年齢 */
  age: number;
  /** キャラクターの属性 */
  attributes: Array<string>;
  /** キャラクターの性格 */
  personality: string;
  /** キャラクターの能力値を3-18で */
  stats: {
    /** 筋力 */
    strength: number;
    /** 知力 */
    intelligence: number;
    /** 器用さ */
    dexterity: number;
    /** 素早さ */
    agility: number;
    /** 運 */
    luck: number;
  };
  /** 生い立ち */
  background: string;
  /** 使える魔法を一つ */
  magic?: string;
}>;

ついでにClaude用にXMLにする

さて、序盤に説明したとおり、ClaudeはXMLにすると言うことを聞いてくれるようです。

xml-js というライブラリを使えばオブジェクトを簡単にXMLに変換できます。

export const createPrompt = async (
  rule: string,
  outputSchema: z.ZodSchema,
  data: unknown
): Promise<string> => {
  const xml = js2xml(
    {
      prompt: {
        rule,
        format: `type Output = ${await format(
          getTypeScriptDefintion(getTypeFromSchema(outputSchema)),
          { parser: "babel-ts" }
        )}`,
        data,
      },
    },
    { compact: true, spaces: 2 }
  );

  const prompt = `必ずpromptに書かれたruleにそってdataを処理してください。
結果はformatに従ってJSONを出力してください。
formatはTypeScriptの型定義ですが、必ずJSONを出力してください。

${xml}
`;
  return prompt;
};

ここでは data に何かしらのデータが入っているとします。さっきの例ならフリーレンの紹介テキストが入っていることでしょう。

rule は、何をさせたいかを記述します。

この createPrompt を使って最終的に生成させたプロンプトは以下の通りです。

必ずpromptに書かれたruleにそってdataを処理してください。
結果はformatに従ってJSONを出力してください。
formatはTypeScriptの型定義ですが、必ずJSONを出力してください。

<prompt>
  <rule>dataのテキストをformatに沿ってJSONで出力せよ</rule>
  <format>type Output = Array&lt;{
  /** キャラクターの名前 */
  name: string;
  /** キャラクターの年齢 */
  age: number;
  /** キャラクターの属性 */
  attributes: Array&lt;string&gt;;
  /** キャラクターの性格 */
  personality: string;
  /** キャラクターの能力値を3-18で */
  stats: {
    /** 筋力 */
    strength: number;
    /** 知力 */
    intelligence: number;
    /** 器用さ */
    dexterity: number;
    /** 素早さ */
    agility: number;
    /** 運 */
    luck: number;
  };
  /** 生い立ち */
  background: string;
  /** 使える魔法を一つ */
  magic?: string;
}&gt;;
</format>
  <data>フリーレン

本作の主人公[9]。魔王を討伐した勇者パーティーの魔法使い。長命なエルフ族の出身で、少女のような外見に反して1000年以上の歳月を生き続けている。人間とは時間の感覚が大きく異なるため、数か月から数年単位の作業をまったく苦にせず、ヒンメルらかつての仲間たちとの再会も50年の月日が経ってからのことだった。ヒンメルが天寿を全うして他界したのを機に、自身にとってはわずか10年足らずの旅の中でヒンメルの人となりを詳しく知ろうともしなかったことを深く後悔し、趣味の魔法収集を兼ねて人間を知るための旅を始める。生前時のヒンメルに対する意識は希薄であったが、幻影鬼(アインザーム)との遭遇時や、奇跡のグラオザームに「楽園へと導く魔法(アンシレーシエラ)」を使われた際などは幻想の中でヒンメルを思い描くなど、無自覚に意識しているような描写が散見されている。
1000年以上前、故郷の集落を魔族に襲われ死にかけた際に、自身を救ってくれた大魔法使いフランメの弟子となる。生来の天才的資質に加えて、フランメから教わった戦闘や魔力制御の技術を1000年以上も研鑽し続けた結果、きわめて強大な魔力を得ている。さらに、その魔力をほぼ完全に隠匿する技術[注 1]も習得しており、敵の魔族に自身の実力を過小評価させた隙を突く戦法を得意とする。その実力は魔王亡き後の現在の魔族を弱いと感じ、七崩賢の一角である断頭台のアウラにさえ完勝するほど。魔族側からは、歴史上もっとも多くの同胞を葬り去った存在として「葬送のフリーレン」と呼び恐れられている[注 2]。ただし、自身の魔法を発動する一瞬だけ魔力探知が途切れるという弱点があり[注 3]、自身よりも魔力の低い魔法使いに計11回敗北した経験があるとも語っている[注 4]。
「服が透けて見える魔法」や「かき氷を作る魔法」など、およそ戦闘に役に立たない魔法を収集するのが趣味で、そうした魔導書を対価に仕事を引き受けたりもする。再会したハイターの差し金で人間のフェルンを弟子に取って以降は、自身の旅に同行させている。
性格はドライで厳しい一面もあるが、普段はやさしく面倒見も悪くない。普段は表情に乏しく淡々としており、一般的な富や地位、名声には興味を示さないが、大好きな魔導書を手に入れるために無茶をしたり、食い意地が張っていたり、朝が弱く寝坊がちだったり、自身の貧相な体型を気にしていたり、実年齢で年寄り扱いされるのを嫌うなど、これらの際の感情表現は豊かである。長命なエルフゆえに、人間など短命な他種族の思考・思想には鈍感で、それらの人々とのコミュニケーションはやや不器用。自身の故郷と仲間を奪った魔族に対する憎悪は深く、感情を表に出すことこそないながらも、敵対する魔族に対しては周囲の状況を顧みず問答無用で葬ろうとする。これには、「人間の言葉で人間を欺き人間の言葉が通じない猛獣」という魔族の本質を理解している理由もある。
「歴史上で最もダンジョンを攻略したパーティーの魔法使い」と自称するだけあり、ダンジョンには詳しい。道中で宝箱を発見するとその中身に異常なまでの興味を示し、判別魔法で99パーセントミミック(宝箱に化けた魔物)とみやぶってなお、残り1パーセントの可能性[注 5]に賭けて宝箱を開け、上半身をミミックに噛まれてもがくという場面が何度も描かれている。

----

シュタルク

勇者パーティーの戦士アイゼンの弟子で、師匠と同じく斧使い。17歳→19歳。極端に憶病かつ自己評価が低い性格であるが、実際は巨大な断崖に斧で亀裂を入れるほどの実力者。師匠とけんか別れをしたあと、紅鏡竜の脅威にさらされた村に3年ほど滞在していた。アイゼンの推薦でフリーレンの仲間に指名され、無自覚ながらも紅鏡竜を一撃で倒す能力を発揮し、彼女たちの旅に同行することとなる。中央諸国クレ地方にあった戦士の村出身で、幼少時は魔物とまともに戦えない失敗作だと父親から見下されていたが、兄のシュトルツからは認められ可愛がられていた。
アイゼンから「とんでもない戦士になる」と言わしめるほどの素質の持ち主で、フェルンからは化け物かと疑われるほどの膂力と頑強さをもつ。男性に免疫がないフェルンからは無意識な恐れを抱かれ、自身も女性の扱いが苦手な一方で、互いに憎からぬ感情を抱いており、不機嫌になったフェルンに謝罪したり、デートのように連れ歩いたりするさまから、ザインからは「もう付き合っちゃえよ」などと漏らされている。男性の象徴に対する評価は芳しくなく、「服が透けて見える魔法」で自身の下半身を見たフェルンからは「ちっさ」と漏らされて傷つく場面がある。好物は自身の誕生日にアイゼンがふるまってくれるハンバーグ。</data>
</prompt>

このプロンプトによる実行結果は省略しますが、Claude 3 OpusやHaikuなら問題なく動作し、OpenAIのGPTシリーズだと、少し微妙な挙動をすることがある、みたいな感じになります。GPT向けのプロンプトの工夫をすべきです。

今回の最終的なソースは https://gist.github.com/erukiti/8a9ebab033f8e6a5bd83dc8f95ceddb8 に置いています。 僕は bun を使って動かしています。

bun i zod prettier xml-js
bun run index.ts

まとめ

  • LLMを使えばデータを構造化できるが、自分のほしいデータ形式にするのには苦労がある。特に内容の複雑さに比例して難しくなる
  • TypeScriptをデータ出力例として与えると、かなり忠実に従ってくれるようになる
  • Zodスキーマを使えば、吐き出したJSONの型安全を保証できる
  • ZodスキーマをTypeScriptに変換する、XMLを出力するなどで、プロンプト生成ができる
  • スキーマ定義なら、プログラマにとっては分かりやすく管理しやすい

弊チームのプロンプトエンジニアリングはこの仕組みにより圧倒的に効率化しました。 TypeScriptおよびZodスキーマを最大限活用してLLMプロダクトを鋭意開発中です。

将来的には、ここらへんをOSSに出来ればと思っています。 他のLLM向けにデータ表現形式やプロンプトの形式も調整したいところです。