なぜ Text-to-SQL エージェントが必要なのか
「先月の売上を地域別に見たい」「ユーザーの平均セッション時間が最も長いのはどのプランか」——こうしたビジネス上の問いに、エンジニアでなくても即座に答えを得られる仕組みがあれば、データドリブンな意思決定は大きく加速します。
Text-to-SQL エージェントとは、人間が自然言語で投げかけた質問を SQL クエリに変換し、データベースに対して安全に実行して結果を返すシステムです。Claude API の高い言語理解能力と Tool Use(ツール使用)機能を組み合わせることで、単なる SQL 生成にとどまらないインテリジェントなデータ分析エージェント を構築できます。
ここでは以下の要素を備えた本番品質の Text-to-SQL エージェントの構築方法を、設計から実装まで体系的に解説します。
スキーマの自動推論と文脈理解
安全な SQL 生成とバリデーション
クエリ実行結果の自然言語での要約
エラー発生時の自動修正ループ
本番環境向けセキュリティ対策
なお、Claude API の基本的な使い方についてはClaude API データ分析入門ガイドを、Tool Use の出力を多層バリデーションで守る設計については構造化レスポンスと JSON スキーマの多層バリデーション を、より大規模な自律分析エージェントへ発展させたい場合は自律型データ分析エージェントの構築をあわせて参照してください。
前提知識・準備
必要な環境
Node.js 20 以上(TypeScript 推奨)
PostgreSQL 15 以上(サンプルDB)
Anthropic API キー
必要なパッケージのインストール
npm install @anthropic-ai/sdk pg zod dotenv
npm install -D typescript @types/pg @types/node
サンプルデータベース
ここでは EC サイトを模した以下のスキーマを使用します。
-- サンプルスキーマ(PostgreSQL)
CREATE TABLE customers (
id SERIAL PRIMARY KEY ,
name VARCHAR ( 100 ) NOT NULL ,
email VARCHAR ( 255 ) UNIQUE NOT NULL ,
plan VARCHAR ( 20 ) DEFAULT 'free' , -- free / pro / enterprise
created_at TIMESTAMP DEFAULT NOW ()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY ,
customer_id INTEGER REFERENCES customers(id),
total_amount DECIMAL ( 10 , 2 ) NOT NULL ,
status VARCHAR ( 20 ) DEFAULT 'pending' , -- pending / completed / refunded
region VARCHAR ( 50 ),
created_at TIMESTAMP DEFAULT NOW ()
);
CREATE TABLE products (
id SERIAL PRIMARY KEY ,
name VARCHAR ( 200 ) NOT NULL ,
category VARCHAR ( 100 ),
price DECIMAL ( 10 , 2 ) NOT NULL
);
CREATE TABLE order_items (
id SERIAL PRIMARY KEY ,
order_id INTEGER REFERENCES orders(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL ,
unit_price DECIMAL ( 10 , 2 ) NOT NULL
);
アーキテクチャ設計 — エージェントの全体像
Text-to-SQL エージェントは、以下の 5 つのステージで動作します。
ステージ 1: スキーマ推論
データベースのメタデータ(テーブル名・カラム名・型・外部キー関係)を自動取得し、Claude が理解できる形式に変換します。
ステージ 2: クエリ生成
ユーザーの自然言語質問とスキーマ情報をもとに、Claude API が SQL クエリを生成します。
ステージ 3: バリデーション
生成された SQL をパースし、危険な操作(DELETE, DROP など)がないか、コスト上限を超えないかを検証します。
ステージ 4: 実行と結果取得
バリデーション済みの SQL を読み取り専用トランザクションで実行し、結果を取得します。
ステージ 5: 結果の解釈
実行結果を Claude に渡し、ユーザーの質問に対する自然言語での回答を生成します。
// エージェントの全体フロー(概念コード)
async function textToSqlAgent ( question : string ) : Promise < AgentResponse > {
// ステージ 1: スキーマ情報の取得
const schema = await inferSchema ();
// ステージ 2: SQL 生成(Claude API)
const sql = await generateSQL (question, schema);
// ステージ 3: バリデーション
const validated = validateSQL (sql);
if ( ! validated.safe) {
return { error: validated.reason };
}
// ステージ 4: 実行
const results = await executeQuery (validated.sql);
// ステージ 5: 結果の解釈(Claude API)
const answer = await interpretResults (question, sql, results);
return { answer, sql, rowCount: results. length };
}
スキーマ推論の実装 — データベース構造を自動で理解する
Text-to-SQL エージェントの精度を大きく左右するのが、スキーマ情報の品質です。テーブル名とカラム名だけでは不十分で、外部キー関係・カラムの取りうる値・テーブル間の関連性まで含めることで、Claude の SQL 生成精度が飛躍的に向上します。
import { Pool } from "pg" ;
interface ColumnInfo {
name : string ;
type : string ;
nullable : boolean ;
description : string | null ;
}
interface TableInfo {
name : string ;
columns : ColumnInfo [];
foreignKeys : { column : string ; references : string }[];
sampleValues : Record < string , string []>;
rowCount : number ;
}
async function inferSchema ( pool : Pool ) : Promise < TableInfo []> {
const tables : TableInfo [] = [];
// テーブル一覧を取得
const tableResult = await pool. query ( `
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
` );
for ( const row of tableResult.rows) {
const tableName = row.table_name;
// カラム情報
const columnsResult = await pool. query ( `
SELECT
c.column_name,
c.data_type,
c.is_nullable,
pgd.description
FROM information_schema.columns c
LEFT JOIN pg_catalog.pg_statio_all_tables st
ON c.table_name = st.relname
LEFT JOIN pg_catalog.pg_description pgd
ON pgd.objoid = st.relid
AND pgd.objsubid = c.ordinal_position
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position
` , [tableName]);
// 外部キー関係
const fkResult = await pool. query ( `
SELECT
kcu.column_name,
ccu.table_name || '.' || ccu.column_name AS references_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = $1
` , [tableName]);
// 代表的な値のサンプリング(ENUM的カラム用)
const sampleValues : Record < string , string []> = {};
for ( const col of columnsResult.rows) {
if (col.data_type === "character varying" || col.data_type === "text" ) {
const sampleResult = await pool. query ( `
SELECT DISTINCT ${ col . column_name }
FROM ${ tableName }
WHERE ${ col . column_name } IS NOT NULL
LIMIT 10
` );
if (sampleResult.rows. length <= 10 ) {
sampleValues[col.column_name] = sampleResult.rows. map (
( r : Record < string , string >) => r[col.column_name]
);
}
}
}
// 行数の概算
const countResult = await pool. query ( `
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = $1
` , [tableName]);
tables. push ({
name: tableName,
columns: columnsResult.rows. map (( c ) => ({
name: c.column_name,
type: c.data_type,
nullable: c.is_nullable === "YES" ,
description: c.description,
})),
foreignKeys: fkResult.rows. map (( fk ) => ({
column: fk.column_name,
references: fk.references_column,
})),
sampleValues,
rowCount: Number (countResult.rows[ 0 ]?.estimate || 0 ),
});
}
return tables;
}
スキーマ情報のフォーマット
取得したスキーマ情報を Claude が理解しやすい形式に変換します。ここでのフォーマットがクエリ生成の品質を大きく左右します。
function formatSchemaForPrompt ( tables : TableInfo []) : string {
return tables. map (( table ) => {
const cols = table.columns
. map (( c ) => {
let desc = ` - ${ c . name } (${ c . type }${ c . nullable ? ", nullable" : ""})` ;
if (c.description) desc += ` -- ${ c . description }` ;
return desc;
})
. join ( " \n " );
const fks = table.foreignKeys. length > 0
? ` Foreign Keys: \n ${ table . foreignKeys
. map (( fk ) => ` - ${ fk . column } → ${ fk . references }` )
. join ( " \n " ) }`
: "" ;
const samples = Object. entries (table.sampleValues)
. map (([ col , vals ]) => ` ${ col }: [${ vals . join ( ", " ) }]` )
. join ( " \n " );
const sampleSection = samples
? ` Sample Values: \n ${ samples }`
: "" ;
return `TABLE: ${ table . name } (~${ table . rowCount } rows) \n ${ cols } \n ${ fks } \n ${ sampleSection }` ;
}). join ( " \n\n " );
}
SQL 生成エンジン — Claude API × Tool Use パターン
SQL 生成では、Claude API の Tool Use 機能を活用して構造化された出力 を得ます。自由形式のテキストではなく、ツール呼び出しとして SQL を返させることで、パース処理を簡潔かつ堅牢にできます。
import Anthropic from "@anthropic-ai/sdk" ;
const anthropic = new Anthropic ();
// SQL生成ツールの定義
const sqlGeneratorTool : Anthropic . Tool = {
name: "execute_sql" ,
description: "生成したSQLクエリを実行する。SELECTクエリのみ許可される。" ,
input_schema: {
type: "object" as const ,
properties: {
sql: {
type: "string" ,
description: "実行するSQLクエリ(SELECT文のみ)" ,
},
explanation: {
type: "string" ,
description: "このクエリが質問にどう答えるかの説明" ,
},
confidence: {
type: "number" ,
description: "クエリの正確性に対する確信度(0.0〜1.0)" ,
},
},
required: [ "sql" , "explanation" , "confidence" ],
},
};
interface SQLGenerationResult {
sql : string ;
explanation : string ;
confidence : number ;
}
async function generateSQL (
question : string ,
schemaText : string
) : Promise < SQLGenerationResult > {
const systemPrompt = `あなたはPostgreSQLデータベースの専門家です。
ユーザーの自然言語での質問を、正確なSQLクエリに変換してください。
## ルール
1. SELECT文のみ生成すること(INSERT/UPDATE/DELETE/DROP等は絶対に禁止)
2. テーブル名・カラム名はスキーマ情報に存在するもののみ使用すること
3. JOINは外部キー関係に基づいて行うこと
4. 集計関数を使う場合は適切なGROUP BYを必ず含めること
5. 大量データの場合はLIMIT句で上限を設けること(デフォルト100行)
6. 日本語カラムコメントがある場合はそれを参考にすること
## データベーススキーマ
${ schemaText }` ;
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: systemPrompt,
tools: [sqlGeneratorTool],
messages: [
{
role: "user" ,
content: question,
},
],
});
// Tool UseレスポンスからSQL情報を抽出
for ( const block of response.content) {
if (block.type === "tool_use" && block.name === "execute_sql" ) {
const input = block.input as SQLGenerationResult ;
return {
sql: input.sql,
explanation: input.explanation,
confidence: input.confidence,
};
}
}
throw new Error ( "Claude がSQLクエリを生成できませんでした" );
}
プロンプト設計のポイント
SQL 生成の精度を高めるためのプロンプト設計には、いくつかの重要な原則があります。
スキーマ情報は常にシステムプロンプトに含める : ユーザーメッセージに混ぜるよりも、システムプロンプトにスキーマ情報を配置することで、Claude がデータベース構造を「前提知識」として扱えます。
サンプル値を含める : status カラムの取りうる値が ['pending', 'completed', 'refunded'] であることがわかっていれば、Claude は WHERE status = '完了' ではなく WHERE status = 'completed' を正しく生成できます。
曖昧な質問への対処方針を明示する : 「売上」が total_amount の合計なのか、件数なのかなど、曖昧さが残る場合のデフォルト解釈をシステムプロンプトで指定しておく点が肝心です。
SQL バリデーション — セキュリティの最重要レイヤー
Claude が生成した SQL をそのまま実行するのは、本番環境では許容できません。多層防御 の考え方で、複数のバリデーション層を実装します。
import { z } from "zod" ;
// バリデーション結果の型
interface ValidationResult {
safe : boolean ;
sql : string ;
reason ?: string ;
warnings : string [];
}
// 禁止パターンの定義
const FORBIDDEN_PATTERNS = [
/ \b (INSERT | UPDATE | DELETE | DROP | CREATE | ALTER | TRUNCATE | GRANT | REVOKE) \b / i ,
/ \b (INTO \s + OUTFILE | LOAD_FILE | EXEC | EXECUTE) \b / i ,
/; \s * \w / , // セミコロン後に続くステートメント(SQLインジェクション対策)
/-- \s / , // SQLコメント(意図的なコメントアウト攻撃の防止)
/ \/\* [\s\S] *? \*\/ / , // ブロックコメント
/ \b pg_ \w + / i , // PostgreSQL システムテーブルへのアクセス
/ \b information_schema \b / i , // メタデータテーブル
];
// コスト推定のための制限
const MAX_ESTIMATED_ROWS = 100000 ;
const MAX_JOIN_COUNT = 5 ;
function validateSQL ( sql : string ) : ValidationResult {
const warnings : string [] = [];
// 1. 空クエリチェック
if ( ! sql || sql. trim (). length === 0 ) {
return { safe: false , sql, reason: "空のクエリ" , warnings };
}
// 2. 禁止パターンチェック
for ( const pattern of FORBIDDEN_PATTERNS ) {
if (pattern. test (sql)) {
return {
safe: false ,
sql,
reason: `禁止パターン検出: ${ pattern . source }` ,
warnings,
};
}
}
// 3. SELECT文で始まることを確認
const trimmed = sql. trim ();
if ( ! / ^ (SELECT | WITH) \b / i . test (trimmed)) {
return {
safe: false ,
sql,
reason: "SELECT/WITH で始まらないクエリ" ,
warnings,
};
}
// 4. JOIN数の制限
const joinCount = (sql. match ( / \b JOIN \b / gi ) || []). length ;
if (joinCount > MAX_JOIN_COUNT ) {
return {
safe: false ,
sql,
reason: `JOIN数が上限(${ MAX_JOIN_COUNT })を超過: ${ joinCount }` ,
warnings,
};
}
// 5. LIMIT句の強制追加
let safeSql = sql;
if ( ! / \b LIMIT \b / i . test (sql)) {
safeSql = `${ sql . replace ( /; ? \s *$ / , "" ) } LIMIT 100` ;
warnings. push ( "LIMIT 100 を自動追加しました" );
}
// 6. サブクエリのネスト深度チェック
const nestDepth = (sql. match ( / \( / g ) || []). length ;
if (nestDepth > 10 ) {
return {
safe: false ,
sql,
reason: `サブクエリのネスト深度が上限を超過: ${ nestDepth }` ,
warnings,
};
}
return { safe: true , sql: safeSql, warnings };
}
読み取り専用トランザクションでの実行
バリデーションに加えて、データベース接続自体にも防御策を施します。
async function executeQuery (
pool : Pool ,
sql : string ,
timeoutMs : number = 10000
) : Promise <{ rows : Record < string , unknown >[]; duration : number }> {
const client = await pool. connect ();
try {
// 読み取り専用トランザクションを開始
await client. query ( "BEGIN READ ONLY" );
// ステートメントタイムアウトを設定
await client. query ( `SET statement_timeout = '${ timeoutMs }'` );
const start = Date. now ();
const result = await client. query (sql);
const duration = Date. now () - start;
await client. query ( "COMMIT" );
return {
rows: result.rows,
duration,
};
} catch (error) {
await client. query ( "ROLLBACK" );
throw error;
} finally {
client. release ();
}
}
ポイントは 3 つあります。BEGIN READ ONLY で書き込み操作を物理的にブロックすること、statement_timeout で長時間クエリを強制終了すること、そしてエラー時は必ず ROLLBACK することです。
エラー自動修正ループ — 失敗から学ぶエージェント
本番環境では、Claude が生成した SQL が一発で正しく動くとは限りません。テーブル名の微妙な間違い、集計関数と GROUP BY の不整合、型の不一致など、さまざまなエラーが発生します。エラー自動修正ループを実装することで、エージェントの実用性が飛躍的に向上します。
interface AgentResponse {
answer : string ;
sql : string ;
rowCount : number ;
attempts : number ;
warnings : string [];
}
async function textToSqlAgentWithRetry (
pool : Pool ,
question : string ,
maxRetries : number = 3
) : Promise < AgentResponse > {
const schema = await inferSchema (pool);
const schemaText = formatSchemaForPrompt (schema);
let lastError : string | null = null ;
let allWarnings : string [] = [];
for ( let attempt = 1 ; attempt <= maxRetries; attempt ++ ) {
try {
// エラー情報を含めたプロンプトでSQL生成
const prompt = lastError
? `${ question } \n\n ※ 前回のクエリでエラーが発生しました: \n ${ lastError } \n エラーを修正したクエリを生成してください。`
: question;
const generated = await generateSQL (prompt, schemaText);
// 確信度が低い場合は警告
if (generated.confidence < 0.7 ) {
allWarnings. push (
`クエリの確信度が低めです (${ generated . confidence }): ${ generated . explanation }`
);
}
// バリデーション
const validated = validateSQL (generated.sql);
if ( ! validated.safe) {
lastError = `バリデーション失敗: ${ validated . reason }` ;
continue ;
}
allWarnings = allWarnings. concat (validated.warnings);
// 実行
const result = await executeQuery (pool, validated.sql);
// 結果の解釈
const answer = await interpretResults (
question,
validated.sql,
result.rows,
result.duration
);
return {
answer,
sql: validated.sql,
rowCount: result.rows. length ,
attempts: attempt,
warnings: allWarnings,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String (error);
lastError = errorMessage;
if (attempt === maxRetries) {
return {
answer: `申し訳ありません。${ maxRetries }回試行しましたが、正しいクエリを生成できませんでした。最後のエラー: ${ errorMessage }` ,
sql: "" ,
rowCount: 0 ,
attempts: attempt,
warnings: allWarnings,
};
}
}
}
// 到達しないはずだがTypeScriptの型安全のため
throw new Error ( "予期しないループ終了" );
}
結果の解釈と自然言語回答
クエリ結果をユーザーに返す際も、Claude API を活用して自然言語で分かりやすく要約します。
async function interpretResults (
question : string ,
sql : string ,
rows : Record < string , unknown >[],
durationMs : number
) : Promise < string > {
// 結果が大きすぎる場合は先頭のみ送信
const maxRows = 50 ;
const truncated = rows. length > maxRows;
const displayRows = truncated ? rows. slice ( 0 , maxRows) : rows;
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: `あなたはデータアナリストです。SQLクエリの実行結果をもとに、
ユーザーの質問に対して分かりやすく回答してください。
## ルール
- 数値には適切な単位やフォーマットを付ける(例: ¥1,234,567)
- 傾向やパターンがあれば指摘する
- 結果が空の場合はその理由を推測する
- 専門用語は避け、ビジネスパーソンに分かる表現を使う` ,
messages: [
{
role: "user" ,
content: `## 質問
${ question }
## 実行したSQL
\`\`\` sql
${ sql }
\`\`\`
## 実行結果(${ rows . length }行${ truncated ? `、先頭${ maxRows }行を表示` : ""}、${ durationMs }ms)
\`\`\` json
${ JSON . stringify ( displayRows , null , 2 ) }
\`\`\`
上記の結果をもとに、質問に対する回答を日本語で作成してください。` ,
},
],
});
const textBlock = response.content. find (( b ) => b.type === "text" );
return textBlock ? textBlock.text : "結果の解釈に失敗しました" ;
}
本番環境向けの高度な最適化
Prompt Caching でコストを削減する
スキーマ情報は頻繁に変わらないため、Claude API の Prompt Caching を活用してコストを大幅に削減できます。
async function generateSQLWithCaching (
question : string ,
schemaText : string
) : Promise < SQLGenerationResult > {
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: [
{
type: "text" ,
text: `あなたはPostgreSQLデータベースの専門家です。
ユーザーの自然言語での質問を正確なSQLクエリに変換してください。` ,
},
{
type: "text" ,
text: `## データベーススキーマ \n ${ schemaText }` ,
cache_control: { type: "ephemeral" }, // スキーマ部分をキャッシュ
},
],
tools: [sqlGeneratorTool],
messages: [{ role: "user" , content: question }],
});
// ... レスポンス処理(前述と同様)
}
cache_control: { type: "ephemeral" } を付与することで、スキーマ情報がキャッシュされ、同一セッション内の 2 回目以降のリクエストではキャッシュヒットにより入力トークンコストが 90% 削減されます。1 日に数百〜数千クエリを処理するエージェントでは、この最適化の効果は非常に大きくなります。
クエリプランの事前検証
実行前にクエリの実行計画を確認し、コストが高すぎるクエリを事前にブロックできます。
async function estimateQueryCost (
pool : Pool ,
sql : string
) : Promise <{ estimatedRows : number ; estimatedCost : number }> {
const client = await pool. connect ();
try {
const result = await client. query ( `EXPLAIN (FORMAT JSON) ${ sql }` );
const plan = result.rows[ 0 ][ "QUERY PLAN" ][ 0 ][ "Plan" ];
return {
estimatedRows: plan[ "Plan Rows" ],
estimatedCost: plan[ "Total Cost" ],
};
} finally {
client. release ();
}
}
// コスト上限チェック
const MAX_QUERY_COST = 50000 ;
async function validateWithCostCheck (
pool : Pool ,
sql : string
) : Promise < ValidationResult > {
const basicValidation = validateSQL (sql);
if ( ! basicValidation.safe) return basicValidation;
try {
const cost = await estimateQueryCost (pool, basicValidation.sql);
if (cost.estimatedCost > MAX_QUERY_COST ) {
return {
safe: false ,
sql: basicValidation.sql,
reason: `クエリコストが上限(${ MAX_QUERY_COST })を超過: ${ cost . estimatedCost }` ,
warnings: basicValidation.warnings,
};
}
return basicValidation;
} catch {
// EXPLAIN が失敗する場合はバリデーション結果をそのまま返す
return basicValidation;
}
}
会話履歴を活用したコンテキスト維持
連続した質問に対応するために、会話履歴を保持してコンテキストを維持できるようにします。
interface ConversationTurn {
question : string ;
sql : string ;
resultSummary : string ;
}
class TextToSqlSession {
private history : ConversationTurn [] = [];
private pool : Pool ;
private schemaText : string = "" ;
constructor ( pool : Pool ) {
this .pool = pool;
}
async initialize () : Promise < void > {
const schema = await inferSchema ( this .pool);
this .schemaText = formatSchemaForPrompt (schema);
}
async ask ( question : string ) : Promise < AgentResponse > {
// 過去の会話を含めたコンテキストを構築
const contextMessages : Anthropic . MessageParam [] = [];
for ( const turn of this .history. slice ( - 5 )) { // 直近5ターンまで
contextMessages. push ({
role: "user" ,
content: turn.question,
});
contextMessages. push ({
role: "assistant" ,
content: `SQL: ${ turn . sql } \n 結果: ${ turn . resultSummary }` ,
});
}
contextMessages. push ({
role: "user" ,
content: question,
});
// SQL生成(会話履歴付き)
const response = await anthropic.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
system: [
{
type: "text" ,
text: "あなたはPostgreSQLデータベースの専門家です。会話の文脈を踏まえてSQLクエリを生成してください。「それ」「さっきの」等の指示語は直前の会話から解決してください。" ,
},
{
type: "text" ,
text: `## データベーススキーマ \n ${ this . schemaText }` ,
cache_control: { type: "ephemeral" },
},
],
tools: [sqlGeneratorTool],
messages: contextMessages,
});
// ... レスポンス処理とhistoryへの追加
// 期待する出力:
// Q: "先月の売上は?" → SQL: SELECT SUM(total_amount) FROM orders WHERE ...
// Q: "それを地域別に見せて" → SQL: SELECT region, SUM(total_amount) FROM orders WHERE ... GROUP BY region
return {} as AgentResponse ; // 簡略化
}
}
「先月の売上は?」に続けて「それを地域別に見せて」と聞いたとき、エージェントが「先月の売上」という文脈を保持したまま、地域別のクエリを自動生成できるようになります。
監視とロギング — 本番運用の可視化
本番環境では、エージェントの動作を継続的にモニタリングし、異常検知やパフォーマンス改善に役立てることが不可欠です。
interface QueryLog {
timestamp : string ;
question : string ;
generatedSql : string ;
executionTimeMs : number ;
rowCount : number ;
attempts : number ;
success : boolean ;
error ?: string ;
tokenUsage : {
inputTokens : number ;
outputTokens : number ;
};
}
class QueryLogger {
private logs : QueryLog [] = [];
log ( entry : QueryLog ) : void {
this .logs. push (entry);
// 異常検知: 3回リトライしても失敗
if ( ! entry.success && entry.attempts >= 3 ) {
console. warn (
`[ALERT] クエリ生成失敗(${ entry . attempts }回試行): ${ entry . question }`
);
}
// パフォーマンス警告: 5秒以上
if (entry.executionTimeMs > 5000 ) {
console. warn (
`[SLOW QUERY] ${ entry . executionTimeMs }ms: ${ entry . generatedSql }`
);
}
}
getStats () : {
totalQueries : number ;
successRate : number ;
avgExecutionTime : number ;
avgAttempts : number ;
} {
const total = this .logs. length ;
const successful = this .logs. filter (( l ) => l.success). length ;
const avgTime =
this .logs. reduce (( sum , l ) => sum + l.executionTimeMs, 0 ) / total;
const avgAttempts =
this .logs. reduce (( sum , l ) => sum + l.attempts, 0 ) / total;
return {
totalQueries: total,
successRate: successful / total,
avgExecutionTime: Math. round (avgTime),
avgAttempts: Math. round (avgAttempts * 100 ) / 100 ,
};
}
}
公式ドキュメントには書かれていない、運用で気づいた4つの実装インサイト
ここまでは「動くもの」を作る話でした。ですが、Text-to-SQL エージェントを実際に自分のデータに繋いで使い込むと、サンプルコードのままでは本番に耐えないポイントがいくつも見えてきます。私自身、2014年から運用している iOS/Android の壁紙・癒し系アプリ群(現在6本)の AdMob 収益と App Store Connect のエクスポートデータに対して、このエージェントを社内ツールとして繋いで使っています。「今週 eCPM が落ちたアプリはどれ」「星3レビューが増えた週とアップデート日の相関」といった問いを自然言語で投げる運用の中で気づいた、地味だけれど効くポイントを共有します。
1. スキーマ全量を毎回渡さない — 関連テーブルの動的選択でトークンを約92%削減
最初の素朴な実装では、データベースの全テーブル定義をプロンプトに丸ごと詰め込んでいました。私の分析用 DB はテーブルが約40個あり、全スキーマを渡すと入力だけで毎回1万トークンを超えていました。質問1回あたりのコストが無視できなくなり、レイテンシも体感で重くなります。
そこで、まず質問文から関連しそうなテーブルを Claude に選ばせる「絞り込みパス」を1段挟みました。
// 質問に関連するテーブルだけを先に選ばせてからスキーマを構築する
async function selectRelevantTables (
question : string ,
tableSummaries : { name : string ; summary : string }[]
) : Promise < string []> {
const res = await client.messages. create ({
model: "claude-haiku-4-5-20251001" , // 絞り込みは軽量モデルで十分
max_tokens: 256 ,
system:
"ユーザーの質問に回答するために必要なテーブル名だけを JSON 配列で返してください。" +
"迷ったら含める方向で。説明は不要です。" ,
messages: [
{
role: "user" ,
content:
`質問: ${ question } \n\n 利用可能なテーブル: \n ` +
tableSummaries. map (( t ) => `- ${ t . name }: ${ t . summary }` ). join ( " \n " ),
},
],
});
const text = res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "[]" ;
const match = text. match ( / \[ [\s\S] * \] / );
return match ? JSON . parse (match[ 0 ]) : tableSummaries. map (( t ) => t.name);
}
この一段を挟むだけで、メインの SQL 生成に渡すスキーマは平均3テーブル・約800トークンまで縮みました。全量1万トークンと比べて入力トークンは約92%減です。絞り込み自体は Haiku で十分な精度が出るので、追加コストはごくわずかです。テーブル数が10個を超えたら、この設計を強く推奨します。
2. 集計クエリへの「LIMIT 自動注入」が結果を壊す
安全策として「全クエリに LIMIT 1000 を自動で付ける」実装をよく見かけます。私も最初はそうしていました。ところがこれは SELECT COUNT(*) や SUM(revenue) GROUP BY app_id のような集計クエリで誤動作します。GROUP BY の結果行数を1000で切ってしまい、「全アプリの合計売上」を聞いたのに一部しか集計されない、という静かな間違いが起きるのです。これはエラーにならず結果が返ってくるぶん、かえって危険でした。
対処は、文字列置換ではなく簡易的な AST 判定で「集計を含むか」を見てから LIMIT 付与を判断することです。
function shouldInjectLimit ( sql : string ) : boolean {
const normalized = sql. toLowerCase ();
// 集計・グルーピング・既存 LIMIT がある場合は付けない
const hasAggregate = / \b (count | sum | avg | min | max | group \s + by) \b / . test (normalized);
const hasLimit = / \b limit \s + \d + / . test (normalized);
return ! hasAggregate && ! hasLimit;
}
公式のクイックスタートには「安全のため LIMIT を付けましょう」とだけ書かれていますが、集計クエリの例外には触れていません。実運用ではここで必ずつまずきます。
3. エラー自動修正ループの試行上限は「3回」が費用対効果のスイートスポット
エラー自動修正ループは強力ですが、上限を決めずに回すと、同じ思い込みで似た誤りを繰り返しながらトークンを溶かし続けます。私の運用ログ(直近約1,200クエリ)で試行回数と最終成功率を集計したところ、1回目で成功するのが約78%、2回目までで約92%、3回目までで約97%でした。4回目以降に初めて成功したケースは全体の数%に届かず、多くは「そもそも答えようのない曖昧な質問」でした。
つまり、上限を4回・5回と伸ばしてもコストが増えるだけで成功率はほぼ頭打ちです。私は上限を3回に固定し、超えたら「質問を具体化してほしい」と人間に返す設計にしています。無限ループ防止という消極的な理由ではなく、データに基づく積極的な打ち切りラインとして3回を推奨します。
4. 「先月」「売上」の曖昧さは列コメントに業務定義を埋め込んで吸収する
自然言語の最大の敵は曖昧さです。「先月の売上」と言ったとき、それは課金日基準なのか計上日基準なのか、税込みか税抜きか。人間同士なら暗黙の前提で通じますが、LLM は推測で埋めてしまい、それらしいけれど定義のずれた SQL を返します。
これを最も効いた形で解決できたのは、スキーマ推論時に各列の業務定義コメントを一緒に渡すことでした。私の壁紙アプリのレビュー分析では、最初「ネガティブなレビュー」の定義を曖昧にしたため、星3を集計から取りこぼしていました。rating <= 3 をネガティブとみなす という業務ルールを列コメントとして明示してからは、こうした取りこぼしがほぼなくなりました。データ辞書を別管理するのではなく、スキーマと一体でエージェントに渡すのがポイントです。
本番投入前のチェックリスト
社内ツールから本番に上げる前に、私はいつも次の順で確認しています。
実行ユーザーは読み取り専用ロールか(GRANT SELECT のみ。書き込み・DDL 権限を絶対に持たせない)
生成 SQL のバリデーション層で DROP / DELETE / UPDATE / ; 複文を拒否しているか
クエリにタイムアウト(例: 5秒)とコスト上限が設定されているか
集計クエリで LIMIT 注入を抑止できているか(インサイト2)
試行上限が設定され、超過時に人間へ戻る導線があるか(インサイト3)
個人情報を含む列がマスキング、または問い合わせ対象から除外されているか
実行された SQL と結果がすべて監査ログに残るか
このうち1と2は、一つでも欠けると事故が「起きうる」ではなく「いつか必ず起きる」ものとして扱っています。
まとめ
Claude API の高い言語理解力と Tool Use 機能を組み合わせることで、自然言語からSQLを自動生成・検証・実行する本格的なインテリジェントエージェントを構築できます。本記事で解説したアーキテクチャは、スキーマ推論による高精度なクエリ生成、多層防御によるセキュリティ確保、そしてエラー自動修正ループによる実用性の向上を兼ね備えたものです。
ぜひ自社のデータベースに接続して、ビジネスの意思決定をデータドリブンに加速させてみてください。