今回はXではたびたび報告しているが、自作のエディタアプリネタ。
執筆活動で便利に使える、アウトラインエディタを現在作っている。
そのアプリで、AIを使った機能(Novel Assistant for Editing相当)を導入しようと思っている。
はじめに
Novel Assistant for Editingはシステムプロンプトが長くなってしまい、AIとしても性能が発揮しづらくなってしまっているので、アプリ上では個別の機能にシステムプロンプトを分ける予定だ。
その際、アプリで適切に使うために出力結果をHTMLなり、JSONなりで出力してもらおうとしているのだが、結果に表記ゆれがあって安定しない。
そこで試した方法を紹介したい。
Javascript/TypescriptライブラリAI SDK
自作アプリはウェブアプリなので、AIを使う際は一から実装するよりも、こうしたライブラリを使うほうが確実に安全で楽だ。
細かい解説はそれだけで記事が数本書けてしまうので、今回は自作アプリの問題点にフォーカスしたポイントだけ紹介したい。
問題点
はじめにでも言ったが、プロンプト内で出力形式を事細かに決めても、表記ゆれが発生してしまう。出力されたJSONなりHTMLなりをそのままアプリで使おうとしても、必ず同じ形式になるとは限らないため、安定して使うことはできない。
(ローカルLLMだけでなく、OpenAIやGeminiのAPIであっても)
たとえば、次のような指示をするシステムプロンプトがあるとする。
# 文章の校正・推敲
# 要件
- 設定の一貫性チェック
- ユーザーから提示された内容を一般的な小説の文法・執筆要件を満たしているか、文章力・構成力・情景描写・視点制御・推敲の質の点も含めてチェックする。
- 校正が必要な点は表形式で指摘する。
# 出力形式
- 言い換えの表示は **必ず** 次のHTML形式で出力する
- 指摘対象の各行ごとに table を出力する
"""
<table border='1' style='border-collapse:collapse;'>
<tbody>
<tr>
<th style='background-color:#c9c9c9;'>元の文</th>
<td>[元の文]</td>
</tr>
<tr>
<th style='background-color:#c9c9c9;'>指摘内容</th>
<td>[指摘内容]</td>
</tr>
<tr>
<th style='background-color:#c9c9c9;'>改善例</th>
<td>[改善例]</td>
</tr>
</tbody>
</table>
<hr/>
"""
これは文章の校正をする機能にAIを使ったものだ。出力形式に「元の文」「指摘内容」「改善例」の3つをHTMLのテーブル形式で出力してもらおうとした。
基本的にこれでもテーブル形式で出力してもらえる。
しかし、thつまりヘッダとしたいセルがtdになったり、逆にtdであってほしい部分がthになっていたりする。
さらに、元の文ごとにテーブルを分けてほしかったが、一つのテーブルに全部の指摘の文章が含まれてしまう。はてはテーブルをネストしてしまったりする。
つまり、人間であれば制限するという配慮ができるところが、AIは追加したりこねくり回すという「余計な配慮」をしてしまうのだ。
出力結果を安定させたい。それは見た目だけではなく、出力する内容の意味もそうだ。
そんなとき、API周りでは便利な機能がある。
スキーマを使おう
AI SDKでは、AIの呼び出しメソッド・関数に、スキーマというものを指定することができる。
このスキーマを使うと、高確率で出力形式を安定して固定することができる。
とはいえ、絶対ではないことは注意しておきたい。
スキーマはJSONに近い形式で定義していく。その時使うのが、 zodというライブラリだ。
zod自体はTypescript向けのスキーマ宣言やデータ定義・データ検証用のライブラリだ。
zod自体をアプリであれやこれやと使う必要はない。AI SDKに限ってみれば、スキーマ定義のためだけに使うとおぼえておくだけでひとまずよいだろう。
このzodはAI SDKからも使用されており合わせて使うことで、AIが返す結果を型に当てはめて画一的にすることができる。
わかりやすい例
通常のAI出力・・・Excelで手動で列名を入力、罫線を引いて表を作成
zodでスキーマを定義したAIの出力・・・Excelで「テーブルとして書式設定」Googleスプレッドシートでいう「テーブルに変換」
Excelで手動できれいな表を作ろうとしても、毎回見た目や意味がバラバラになってしまうだろう。結構な手間。
ExcelやGoogleスプレッドシートでテーブル機能を使うことで、見た目も、表としての検索性・分析性能も段違いに良くなる。
つまり、zodによるスキーマ定義を使うことで、AIの結果を綺麗に整えやすくする、と思ってもらえればよいだろう。
スキーマでデータ構造を定義する
データ構造と言っても、難しいことはない。
その時そのときで、AIに返してもらいたいデータ項目を定義するだけだ。
上記の文章の校正目的の例だと、返してほしい項目は次のとおりと判断した。
- 元の文
- 指摘事項
- 校正後の文
最低でもこれらが含まれていればいい。
そしてそれ以外でもセットしてほしい項目があれば、追加していく方針。zodライブラリに沿ってデータ構造を定義すると、こうなる。
import { z } from "zod";
...中略...
const resSchemaCorrect = z.object({
meta: z.object({
model: z.string(),
executed_id: z.string().optional(),
executed_date: z.string(),
locale: z.string().optional()
}),
message: z.string(),
contents: z.array(
z.object({
id: z.string(),
type: z.enum([
"typo",
"grammar",
"punctuation",
"tense",
"pov",
"style",
"clarity",
"suggestion",
]),
original: z.string(),
corrected: z.string(),
reason: z.string(),
position: z
.object({
start: z.number(),
end: z.number(),
})
.optional(),
severity: z
.enum(["minor", "normal", "critical"])
.optional(),
})
)
});
zの後に見慣れた型名がある。(string()やarray()など)これがその項目の型を指し示すものだ。
そしてたまに optional() がある。このoptionalをつけると、その項目が任意の項目なので、出さなくてもいいですよ、という定義になる。
meta は、実行情報などを格納する。
messageは、生成AIがよく出力する。前置きや後書きの説明文。
そしてcontentsは、文字通り一番返してほしい校正の結果である。contents内にはさらに項目があり、この中で大事なのは次の項目だ。
- original
- corrected
- reason
字面でおわかりと思うが、元の文・校正後の文・指摘理由である。
ほかの項目はあればアプリで使い所があるかな程度に決めたものなので、末尾の定義に .optional() を付けたのだ。
こういう定義により、AIから返ってくる結果はウェブの生成AIにあるような1文字ずつダラダラと表示される形式ではなく、スパッとスキーマ定義に沿った結果が返ってくる形式になる。
ただ、スキーマで定義したとしても、思考して生成した結果をどこに出力してよいのか、AIはわからない。
そこで指定するのがシステムプロンプトだ。
システムプロンプトで意味を定義する
ひとえにデータを返してほしいといっても、AIにとっては生み出したその内容がはたして形を示すのか、意味を示すのか、見た目を示すのかきちんとはわかってもらえないことが多い。
厳密に要望を伝えないと、賢いAIモデルは「余計な配慮」をして創作した形でデータを返してくる。自分がしてほしいイメージを言語化して伝えるのが苦手だとなかなか大変なのだ。
さて、文章の校正をする機能としてシステムプロンプトではデータ構造の「意味」を定義することになる。
言うまでもなく、システムプロンプトは最優先事項だ。
出力形式というセクションで、このように指定してみた。
# 文章の校正・推敲
# 要件
- 設定の一貫性チェック
- ユーザーから提示された内容を一般的な小説の文法・執筆要件を満たしているか、文章力・構成力・情景描写・視点制御・推敲の質の点も含めてチェックする。
- 校正が必要な点は表形式で指摘する。
# 出力形式
- 出力は必ず JSON のみ
- 上位キーは meta, message, contents のみ
- message は補足的説明に限定する
- 校正結果は contents にのみ含める
- 前置き・結びの文章を contents に含めてはならない
- 元の文は original に含める
- 校正後の文は corrected に含める
- 指摘事項・理由は reason に含める
目的を限定していることでスキーマの定義も最小限になる。そしてシステムプロンプトで伝える「意味」も、簡潔に伝えるだけで済む。
「出力は必ず JSON のみ」は必須だ。
そして「元の文は original に含める」などの部分で、AIが生成する内容とスキーマ内の項目を紐づける。これにより、スキーマ内の項目は初めて実際のデータ内容の意味を成す。
ここで注意したいのは、指示するのに「~に含めてください」や「~に含めるようにすること」などという、人間に伝えるような文体で指示を書くと、AIはその語句の解釈自体に思考を割くことになる。
無駄にAIの解釈の制限を消費してしまうので、指示は簡潔に書きたいところだ。
準備万端、APIを実行する
スキーマとシステムプロンプトが揃ったら、いざAPIを実行しよう。
AI SDKで構造化データを取得するには、たとえば次のような関数を使うとよい。
const result = await generateObject({
model: this._getModel(options.model), //モデル名
schema: schema, //スキーマ定義
messages: enhancedMessages, //システムプロンプト・ユーザープロンプト
temperature: options.temperature || 0.7, //オプション
...options //オプション
});
return result.object; //スキーマ定義に沿ったデータを返す
generateObject関数を使うことで、スキーマを受け渡すことができる。
こうして実行した結果、次のデータが返ってくる。
あとはこのデータをアプリで使う見た目に当てはめていけばよい。
その他の関数
ちなみに、それ以外のAIの実行関数は次のようなものがある。
//---テキストデータを一括で取得する
const result = await generateText({
model: this._getModel(options.model), //モデル名
messages: messages, //システムプロンプト・ユーザープロンプト
temperature: options.temperature, //オプション
maxTokens: options.maxTokens, //オプション
...options //オプション
});
これは、Object形式ではなく、文字列形式で結果を取得できる。
詳しくは下記の公式ヘルプを参照。
ほかにも、ウェブ上の汎用型生成AIのような1文字ずつ取得する、いわゆるストリーミング形式の関数もある。こちらは実際の例とともに紹介する。
おそらくこの関数がそのまま流用できるはずなので参考にしていただきたい。
async chatStream(messages, onChunk, options = {}) {
const { textStream, text } = await streamText({
model: this._getModel(options.model), //モデル名
messages: messages, //システムプロンプト・ユーザープロンプト
temperature: options.temperature, //オプション
maxTokens: options.maxTokens, //オプション
...options //オプション
});
let fullText = '';
for await (const textPart of textStream) { //取得したストリームオブジェクトを回す
fullText += textPart; //完全な結果のため保存
if (onChunk) {
onChunk({ //ユーザーから受け渡されたコールバック関数
content: textPart, //このループの1文字を渡す
fullText: fullText, //現時点の完成した全文字列も念の為
done: false //完了フラグ
});
}
}
// 完了コールバック
if (onChunk) { //全ループが完了したら、またコールバック関数にわたす
onChunk({
content: '', //もう存在しないので空を渡す
fullText: fullText,
done: true //完了フラグ
});
}
return fullText;
}
ウェブ上の汎用型生成AIを使っているとあたかもAIが簡単に結果をリアルタイムで返してくれているように見えるが、裏側ではこうした地味に手間のかかる処理が行われているのである。
AIを扱う各会社に労いの言葉をかけたい。
アプリの見た目に当てはめる
ここから先はアプリの実装方法によって異なる。
Vue.jsなら、<div>{{ data.hoge }}</div> などとVueの流儀に沿って実装すれば、JSONデータをそのまま使うことができる。
素のDOM操作をするなら、たとえば次のようにするとよいだろう。
const outputCorrect = (res) => {
let result = [];
if (res.message) {
result.push(`<p>${res.message}</p>`);
}
if (res.contents) {
let tbl = document.createElement("table");
tbl.setAttribute("border", "1");
let tcap = document.createElement("caption");
tcap.textContent = res.type;
tbl.appendChild(tcap);
for (let ct of res.contents) {
let trori = document.createElement("tr");
//---original
let thori = document.createElement("th");
let tdori = document.createElement("td");
thori.textContent = "元の文";
tdori.textContent = ct.original;
trori.appendChild(thori);
trori.appendChild(tdori);
tbl.appendChild(trori);
//---corrected
let trcor = document.createElement("tr");
let thcor = document.createElement("th");
let tdcor = document.createElement("td");
thcor.textContent = "訂正文";
tdcor.textContent = ct.corrected;
trcor.appendChild(thcor);
trcor.appendChild(tdcor);
tbl.appendChild(trcor);
//---reason
let trreason = document.createElement("tr");
let threason = document.createElement("th");
let tdreason = document.createElement("td");
threason.textContent = "訂正理由";
tdreason.textContent = ct.reason;
trreason.appendChild(threason);
trreason.appendChild(tdreason);
tbl.appendChild(trreason);
}
let div = document.createElement("div");
div.appendChild(tbl);
result.push(div.innerHTML);
}
return result;
}
こうして、見た目に当てはめる部分はウェブアプリであれば各フレームワークの流儀やDOM操作に任せることで、安定して見た目を固定できるようになる。
全部AIに出力してもらえば楽なんじゃね?という安易な考えで、システムプロンプトやユーザープロンプトに、「出力はHTML形式で次の構造にしてください」などとしてしまうと、大なり小なり余計な配慮をするので表記ゆれが発生してしまいかねない。
アプリとしてAIが返してきた不完全なHTMLやJSONを解析して、表記ゆれを考慮した作りにしないといけなくなる。
終わりに
こうして、見た目に当てはめる部分はウェブアプリであれば各フレームワークの流儀やDOM操作に任せることで、安定して見た目を固定できるようになる。
全部AIに出力してもらえば楽なんじゃね?という安易な考えで、システムプロンプトやユーザープロンプトに、「出力はHTML形式で次の構造にしてください」などとしてしまうと、大なり小なり余計な配慮をするので表記ゆれが発生してしまいかねない。
アプリとしてAIが返してきた不完全なHTMLやJSONを解析して、表記ゆれを考慮した作りにしないといけなくなる。
終わりに
以上、AIをAPIで使う場合における大事なポイントとなるスキーマ定義を見てきた。
今回のネタは、ウェブの汎用的生成AIを使う分にはほとんど関係ないやり方だ。 プログラミングをする上でAIモデルのAPIを使う際に必要な知識やテクニックの類なので、APIを使って開発している方々の参考になれば幸いである。(AIにコーディングを任せるいわゆるバイブコーディングしかしない人も、知識として覚えておくと良いかも)
スキーマ定義が最大限に効果を発揮するために、システムプロンプトも見直す必要がある。
ポイントは、システムプロンプトにだらだらと複数機能を持たせるのではなく、目的を一つに絞ることだ。
どうせアプリ内でシステムプロンプトを複数定義して、その都度切り替えてもよいのだ。
- システムプロンプトは機能を絞って簡潔に、してほしい要件だけを書く
- スキーマ定義は、その時の機能として返してほしいデータの形を書く
ここがウェブ上の汎用型生成AIと違う点だ。実装する機能に応じてこれらを取り回ししやすいのがアプリ内で活用したいAIと言えるだろう。
システムプロンプトとスキーマ定義がきちんと揃えば、アプリ内で含めたいユーザープロンプトも、「◯◯を校正してください」とせずに、校正してほしい文章だけを直接含めるだけで済む。
アプリに実装する機能も、AIモデル・適切なスキーマ・適切なシステムプロンプトを用意することで、従来は複数のライブラリを組み合わせて実現したり、外部のAPIを呼び出して実現していたような機能が、1つのAIモデルさえあれば揃うようになる。
ただ、アプリで使うAIは基本的にはユーザーに設定を選ばせるべきだろう。OpenAI/Gemini/AnthropicそしてローカルLLMの代表格Ollamaなど、AI SDKでは簡単に揃えられるので実装は簡単だ。
各AIモデルはもはや人格と言っても過言ではない違いが出てきているので、用途によって向き不向きがある。すべてをカバーすることはできないので、システムプロンプトできちんと用途や機能を制限してあげることが重要だ。それにより、AIモデルが違っても、安定した機能を発揮させやすくなる。
考え方としては、「システムプロンプトはAIが混乱しないために、機能を制限してあげる命令」とするのが良いかもしれない。
そしてその際に、アプリで使いたいデータ形式を安定して共通化するために、zodによるスキーマ定義が大事というわけだ。