ミツモアのAgentic AI LabでAIエージェント関連の開発を担当している井藤です。
現場サービス業向けのVertical SaaS「プロワン」には、チャットで様々な操作ができるAI機能があり、私たちはこれを既存アプリに埋め込まれた形のAIエージェントとして開発しています。本番リリース(2026年1月)からの約6カ月で、会話セッション数は10,551に達しました(2026年7月末時点)。
その開発でぶつかったのが、「AIが何を送ってくるかはわからない」という問題です。AIはフォームを経由せず、関数を直接呼びます。numberを期待する欄に「たくさん」が来る世界です。
そしてこの問題の解決に効いていたのは、意外にも、人間のユーザーのために作り込んだ既存のバリデーションとそのエラーメッセージでした。型で縛れるツールは新たにZodで検証し、縛れないツールは既存の検証が受け止める――この二段構えです。
本記事では「AIから届く値をどう信頼するか」に絞り、6月のTSKaigi 2026アフターイベントでの登壇内容を、本番半年のデータとあわせて紹介します。なお数字は、公開にあたって本番ログを再集計した2026年7月末時点の値です。
既存SaaSにAIを組み込む仕組みの全体像やツールの作り方は、過去の記事をご覧ください。
前提:AIが返すのは「この関数を呼べ」という文字列だけ
LLMは自分では何も実行しません。返してくるのはtool_call、つまり「この関数をこの引数で呼べ」というJSONだけです。
{ "name": "addRows", "args": { "quantity": 1, "unitPrice": 7207 } }
プロワンでは、これをReactアプリの操作に変える仕組みをuse-aiというライブラリとして実装しています。各画面のコンポーネントはマウント時に「今の画面の状態」と「使えるツール」をAIに登録し、アンマウントで解除します。Reactの描画ツリーが、そのままAIに見える世界になります。ツールはブラウザで実行され、結果と更新後の状態がAIに返ります。

このあたりの設計は前回の記事で書いたので、今回は省略します。問題はこの先です。
AIが何を送ってくるかは、わからない
AIは任意の文字列を生成します。先ほどのtool_callの中身も例外ではありません。だからツールの引数には、型と違う値も想定外の値も入ってきます。
{ "name": "callApi", "args": { "body": { "amount": "たくさん" } } }
numberを期待している欄に「たくさん」が来る。そのままでは実行できません。人間のユーザーなら通常はフォームの入力チェックを通りますが、AIはフォームを経由せず関数を直接呼びます。ツールの入口に、値を検証する仕組みが必要です。
Zodスキーマ1枚から、3つを導出する
では、その検証をどう書くか。TypeScriptの型を書けば済みそうに見えて、これだけでは足りません。1つのツールには、AIへの説明・ハンドラの引数の型・実行時の検証という3つの表現が必要だからです。AIへの説明はJSON Schemaで渡し、ハンドラの引数はTypeScriptの型で縛れますが、その型は実行時には消えて、AIが実際に返すただのJSONを検証してくれません。この3つを別々に手で書くと、どこかでズレてしまう可能性があります。
そこでツールの入力はすべてZodスキーマで定義し、1枚のスキーマから3つを導出しています(z.toJSONSchemaはZod 4のAPIです)。
| 導出 | 役割 |
|---|---|
z.toJSONSchema(s) |
AIに渡る「入力の取り決め」になる |
z.infer<typeof s> |
ハンドラの引数の型になる |
s.parse(args) |
実行する前に値を検証する |
// 擬似コード(実際のAPIから簡略化しています) const addRows = defineTool( '明細に行を追加する', z.object({ quantity: z.number(), unitPrice: z.number() }), (input) => { // inputの型は { quantity: number; unitPrice: number } }, )
スキーマが1枚なので、AIへの説明と、コンパイル時の型と、実行時の検証がズレません。
ただし、このスキーマをどこまで細かく書けるかはツール次第です。プロワンのツールは2種類に分かれます。明細操作のように入力の形が決まっている専用ツールと、任意のフォームやAPIを扱う汎用ツールです。ここからは、それぞれの検証を見ていきます。
専用ツールは型で縛る(数量・単価の型ズレ0件)
専用ツールの代表が、見積もりの明細操作です。入力の型を細かく定義しています。
// 明細行スキーマ(addRowsの入力・抜粋) z.object({ label: z.string().nullable().optional().describe('品名(例: "エアコン")'), quantity: z.number().nullable().optional().describe('数量(例: 10, 1, 0)'), unitName: z.string().nullable().optional().describe('単位(例: "個", "台")'), unitPrice: z.number().nullable().optional().describe('単価(例: 75000)'), unitCost: z.number().nullable().optional().describe('原価(例: 50000)'), taxCode: z.string().nullable().optional().describe('税区分(例: "001")'), isTextLine: z.boolean().nullable().optional().describe('見出し行なら true'), })
型で値を縛り、.describe()に書いた例がそのままAIへの指示になります。1枚のZodが、型とAIへの説明を兼ねる構造です。
効果は本番のデータに出ています。本番リリースから2026年7月末までに、AIが数量・単価として送ってきた数値は89,088個。このうち、値が型からズレたものは0件でした。少なくともこの約6カ月、AIはフィールドの型のとおりに値を送ってきました。
なお、この89,088個はZodのparse()にかける前、LLMの出力そのままの値を数えています。実際、同じ期間にZodが弾いた38件も生の引数のままログに残っており、「検証を通った値だけを数えたから0件」ではありません。
ただし、この0件は「値の型」に限った話です。Zodが弾いた38件の大半(35件)は、行の配列を丸ごとJSON文字列として送ってくる、容れ物の構造の間違いでした。値の型はAIが守り、構造の間違いはZodが弾いてアプリに届く前に止めた――これが本番半年の実態です。
専用ツールに関する限り、型で縛る作戦は機能しました。問題は、型を書けないツールです。
それでも、型で縛れないツールが残る
汎用ツールは、任意のフォームを埋めたり、任意のAPIを呼んだりするツールです。画面とAPIの数だけ専用ツールを作るのは現実的ではなく、新しい画面にもツール追加なしで対応させたい。だから汎用ツールが要ります。ただしどんな値が来るか事前に決まらないため、入力はz.unknownにするしかありません。
z.object({ path: z.string(), body: z.unknown() })
型で縛れない値が、どうしても残ります。
なお、値の検証と、その操作をしていいかという認可は別のレイヤーです。汎用APIツールはブラウザから、ログイン中のユーザー自身のセッションで既存のAPIを呼ぶため、サーバー側の認可はAI経由でもそのまま効きます。加えて、保存や削除のような破壊的な操作は、実行前にユーザーの承認を挟む設計にしています。
型のない値は、既存の検証が受け止める
では、Zodを素通りした値は誰が守るのか。ここで出てくるのが、冒頭に書いた既存の検証です。まず本番の数字から見ます。
この汎用APIツールを本番投入した2026年5月中旬から7月末までの実行回数は10,873回。うちエラーは399回、約3.7%でした(呼び出し1回単位。再送して成功したものも含みます)。約6割(249回)はサーバーの検証による差し戻しで、残りはAIが書いた「レスポンスから値を取り出す抽出式」の誤り、存在しないリソースの指定、認可エラーなどです。
専用ツールとの違いは、型の伝え方にあります。専用ツールでは、スキーマを通じて型がAIに伝わっていました。汎用ツールでは伝える型がありません。だから同じAIでも、ここでは型を間違えます。
ポイントは、AIのために新しい検証を足したわけではないことです。この差し戻しを担ったサーバーの検証は、人間のユーザーのために元からあったものです。それがAIの値にもそのまま効いていました。
弾かれたAIは、自分で直す
検証で弾くだけなら、その操作は失敗で終わります。ところが本番のログを見ると、AIはそこで止まっていませんでした。
これは偶然ではなく、仕組みです。ツールの実行がエラーになると、そのエラーメッセージはツールの結果としてそのままAIに返り、AIは同じターンの中で呼び出しをやり直せます(ステップ数の上限は現在20回)。
例えばAIが、APIのbooleanの欄に文字列の"true"を送ってしまう。サーバーの検証が「booleanで送ってください」と差し戻す。AIはそのメッセージを読んで、trueに直して送り直す。

本番では、サーバー検証による差し戻しのうち型ミスが原因だった171回中147回(86%)を、AIが同じターンの中でエラーメッセージを読んで直し、完遂しました。最終的に回復しないまま終わった呼び出しは全体の約1.2%。しかもその6割は404や403、つまり「存在しない」「権限がない」と分かって正しく諦めたケースでした。エラーを読んで直すところは、人間の開発者と変わりません。
まとめ:既存の検証は「契約」だった
本番の運用で確かめられたことは、4行に収まります。
- AIが何を送ってくるかはわからない。だからツールの入口でZodで検証する
- 複雑な操作は専用ツールに型を定義する。スキーマで型を伝えればAIは型を守る
- 全部は定義できない。汎用ツールは
z.unknownにして、既存の検証に任せる - 弾かれてもAIはエラーを読んで自分で直す(型ミスによる差し戻しの86%)
ふりかえると、私たちがやったことは新しい検証の発明ではなく、すでにあった入力の取り決め(契約)を整理して、AIから見える場所に出すことでした。専用ツールはZodスキーマとしてAIに契約を見せ、汎用ツールは既存のサーバー検証に契約の実施を任せた。既存プロダクトのAIエージェント化を考えるなら、まず普段の検証=契約を棚卸しすることが近道です。
今後の課題は、汎用ツールの契約もAIから見えるようにすることです。現状は「弾かれてから直す」に頼っていますが、プロワンにはOpenAPI定義(swagger.json)があります。呼ぶAPIが決まった時点で対応するスキーマを引き当てて、送信前に検証する―つまりAPIのバリデーションをAIが見える場所に整理することが、次の一手だと考えています。
本記事で紹介した仕組みは、OSSとして公開しているuse-aiで実装しています。
ミツモアで一緒に働きませんか?
ミツモアでは、生成AIを活用して圧倒的な生産性を生み出し、日本のGDPを向上させるという目標に向けて、一緒に働く仲間を募集しています。今回ご紹介したように、プロダクトへのAIエージェント組み込みを積極的に進めています。
少しでも興味をお持ちの方は、カジュアル面談からでも歓迎です。お気軽にご応募ください。