取り組みの背景 — GraphQL と AI の融合
GraphQL は型安全なデータクエリ言語として、ここ数年でバックエンド設計の標準になりました。一方、Gemini API のような生成 AI は、自然言語処理、データの意味的理解、動的コンテンツ生成において革新的な能力を持っています。
この二つを組み合わせると、どうなるでしょうか?
単なるデータ取得から一歩進んだ、インテリジェント(賢い)データレイヤー が実現します。クライアントからのリクエストに応じて、リゾルバーが Gemini API を呼び出し、文脈を理解した応答を返す。キャッシュは単なる KV ストアではなく、意味的な相似度で動作します。複雑なデータ変換は AI に任せ、開発者は型定義に専念できます。
本ガイドは、GraphQL スキーマに Gemini API を統合し、スケーラブルで保守性の高いインテリジェントデータレイヤーを構築するための実装パターン、アーキテクチャ設計、本番対応のベストプラクティスをお伝えします。
私自身、個人開発で記事更新の自動化や複数サービスのバックエンドを一人で回しているのですが、AI を「賢いデータレイヤー」として組み込むほど、設計の中心が機能そのものから「コストと暴走の制御」へ移っていくのを実感しています。私はこの制御こそが、AI データレイヤーを安心して育てていくための土台だと考えるようになりました。実装パターンと並べて、運用して初めて見えてきた落とし穴も率直にお伝えできればと思います。
GraphQL × AI データレイヤーのアーキテクチャ設計
従来型 GraphQL の構造
典型的な GraphQL サーバーは、スキーマ → リゾルバー → データベース というレイヤー構造を採用します。各フィールドのリゾルバーは、データベースクエリやキャッシュ参照を通じて値を返すシンプルな機構です。
Client Query
↓
GraphQL Parser & Validator
↓
Resolver Chain
↓
Database / Cache / External API
AI 統合による新しい構造
Gemini API を統合したアーキテクチャでは、リゾルバーが AI を「第一級の依存関係」として扱います。
Client Query
↓
GraphQL Parser & Validator
↓
Resolver Chain
├─ データベース / Cache
├─ Semantic Cache Layer (意味的相似度判定)
├─ Gemini API (AI reasoning / generation)
└─ DataLoader (バッチ最適化)
↓
Response (型安全な結果)
主な特徴:
AI-First リゾルバー — データベースの結果を受け取った後、Gemini API に渡して解釈・加工・生成
セマンティックキャッシュ — 「この質問は以前の質問と意味的に同じか?」を判定し、AI 呼び出しを削減
ストリーミング統合 — サブスクリプションを通じてリアルタイムに AI の生成プロセスをクライアントに配信
型安全性の維持 — TypeScript + graphql-codegen で、AI の不確定性を型で管理
スキーマファースト設計 — AI 型定義とリゾルバー構造
スキーマ定義の原則
AI 統合を前提とした GraphQL スキーマ設計では、以下の原則を守ります。
1. 入力型と出力型の明確な分離
input UserQueryInput {
query : String !
context : String
maxTokens : Int
}
type UserQueryResult {
id : ID !
originalQuery : String !
aiGeneratedResponse : String !
confidence : Float !
sources : [ Source ! ] !
generatedAt : DateTime !
}
type Query {
askAboutUser ( input : UserQueryInput ! ): UserQueryResult !
}
2. AI 出力の不確定性を Union/Interface で表現
AI の応答には失敗パターンがあります。これを型で安全に扱います。
union AiResponse = SuccessResponse | ErrorResponse | FallbackResponse
type SuccessResponse {
content : String !
confidence : Float !
}
type ErrorResponse {
message : String !
code : String !
retryable : Boolean !
}
type FallbackResponse {
cachedContent : String !
isStale : Boolean !
}
type Query {
generateContent ( prompt : String ! ): AiResponse !
}
3. リゾルバー関数のシグネチャ統一
type ResolverContext = {
geminiClient : GeminiClient ;
semanticCache : SemanticCache ;
dataLoader : DataLoader ;
userId : string ;
logger : Logger ;
};
type AiResolver < T > = (
parent : any ,
args : any ,
context : ResolverContext ,
info : GraphQLResolveInfo
) => Promise < T >;
Gemini API リゾルバーの実装パターン
パターン 1: シンプルな AI テキスト生成リゾルバー
const resolvers = {
Query: {
generateSummary : async (
_ : any ,
args : { text : string ; language : string },
context : ResolverContext
) : Promise < SummaryResult > => {
const { geminiClient , logger } = context;
try {
logger. info ( "Summary generation started" , {
textLength: args.text. length ,
language: args.language,
});
const response = await geminiClient. generateContent ({
contents: [
{
role: "user" ,
parts: [
{
text: `以下のテキストを${ args . language }で要約してください: \n\n ${ args . text }` ,
},
],
},
],
generationConfig: {
maxOutputTokens: 256 ,
temperature: 0.7 ,
},
});
const summary =
response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "" ;
return {
id: generateId (),
originalText: args.text. substring ( 0 , 200 ),
summary,
language: args.language,
generatedAt: new Date (),
tokensUsed: response.usageMetadata?.outputTokens || 0 ,
};
} catch (error) {
logger. error ( "Summary generation failed" , { error });
throw new Error ( "要約生成に失敗しました" );
}
},
},
};
パターン 2: マルチステップ推論リゾルバー
複数のステップを踏んで、より高精度な結果を得る場合。
const resolvers = {
Query: {
analyzeProductReview : async (
_ : any ,
args : { reviewId : string },
context : ResolverContext
) : Promise < ReviewAnalysis > => {
const { geminiClient , dataLoader , logger } = context;
// Step 1: レビューテキストを取得
const review = await dataLoader. loadReview (args.reviewId);
// Step 2: Gemini で感情分析とキーポイント抽出を実行(マルチターン)
const analysisPrompt = `
以下のレビューを分析してください。
1. 感情スコア(-1 から 1)
2. 主要な批評点(箇条書き)
3. 提案される改善点
レビュー:
"${ review . text }"
JSON 形式で回答してください。
` ;
const response = await geminiClient. generateContent ({
contents: [
{ role: "user" , parts: [{ text: analysisPrompt }] },
],
generationConfig: {
temperature: 0.3 ,
maxOutputTokens: 512 ,
responseMimeType: "application/json" ,
},
});
const analysisText =
response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "{}" ;
const analysis = JSON . parse (analysisText);
return {
reviewId: args.reviewId,
sentimentScore: analysis.sentimentScore,
keyPoints: analysis.keyPoints,
suggestions: analysis.suggestions,
analyzedAt: new Date (),
};
},
},
};
パターン 3: ストリーミングと部分結果の返却
const resolvers = {
Subscription: {
generateDetailedGuide: {
subscribe : async function* (
_ : any ,
args : { topic : string ; depth : string },
context : ResolverContext
) {
const { geminiClient , logger } = context;
logger. info ( "Guide generation stream started" , {
topic: args.topic,
depth: args.depth,
});
const stream = geminiClient. generateContentStream ({
contents: [
{
role: "user" ,
parts: [
{
text: `${ args . topic } について、${ args . depth } レベルのガイドを作成してください。セクションごとに段階的に説明してください。` ,
},
],
},
],
generationConfig: {
maxOutputTokens: 2048 ,
temperature: 0.8 ,
},
});
let buffer = "" ;
for await ( const chunk of stream) {
const text = chunk.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "" ;
buffer += text;
// ## などのセクション区切りごとに yielド
if (buffer. includes ( "##" )) {
const [ section , ... rest ] = buffer. split ( "##" );
yield {
guideChunk: section. trim (),
isComplete: false ,
timestamp: new Date (),
};
buffer = rest. join ( "##" );
}
}
if (buffer. trim ()) {
yield {
guideChunk: buffer. trim (),
isComplete: true ,
timestamp: new Date (),
};
}
},
},
},
};
セマンティックキャッシュ戦略
セマンティックキャッシュとは
従来のキャッシュは 厳密なキー一致 に依存します。generateSummary(text=A) と generateSummary(text=A) は同じ結果を返しますが、generateSummary(text=slightly_different_A) は新しい AI 呼び出しが発生します。
セマンティックキャッシュは、意味的に「十分に近い」クエリを検出し、過去の結果を返します。これにより、AI API のコストと遅延を大幅に削減できます。
実装パターン
type CacheEntry = {
key : string ;
embedding : number []; // Gemini Embedding API で生成
result : any ;
ttl : number ;
createdAt : Date ;
};
class SemanticCache {
private cache : Map < string , CacheEntry > = new Map ();
private embeddingClient : GeminiEmbedding ;
private similarityThreshold = 0.85 ;
async get < T >( query : string ) : Promise < T | null > {
// クエリをエンベディング化
const queryEmbedding = await this .embeddingClient. embedContent ({
content: { parts: [{ text: query }] },
});
const queryVector =
queryEmbedding.embedding.values;
let bestMatch : CacheEntry | null = null ;
let bestSimilarity = 0 ;
// 既存キャッシュから最も類似度が高いものを探す
for ( const entry of this .cache. values ()) {
if (Date. now () - entry.createdAt. getTime () > entry.ttl) {
this .cache. delete (entry.key);
continue ;
}
const similarity = this . cosineSimilarity (
queryVector,
entry.embedding
);
if (similarity > bestSimilarity && similarity >= this .similarityThreshold) {
bestSimilarity = similarity;
bestMatch = entry;
}
}
if (bestMatch) {
console. log ( `Cache hit: similarity=${ bestSimilarity . toFixed ( 3 ) }` );
return bestMatch.result as T ;
}
return null ;
}
async set < T >( query : string , result : T , ttlMs : number = 3600000 ) : Promise < void > {
const embedding = await this .embeddingClient. embedContent ({
content: { parts: [{ text: query }] },
});
const key = `cache:${ Date . now () }:${ Math . random () }` ;
this .cache. set (key, {
key,
embedding: embedding.embedding.values,
result,
ttl: ttlMs,
createdAt: new Date (),
});
}
private cosineSimilarity ( vecA : number [], vecB : number []) : number {
const dotProduct = vecA. reduce (( sum , a , i ) => sum + a * vecB[i], 0 );
const magnitudeA = Math. sqrt (vecA. reduce (( sum , a ) => sum + a * a, 0 ));
const magnitudeB = Math. sqrt (vecB. reduce (( sum , b ) => sum + b * b, 0 ));
return dotProduct / (magnitudeA * magnitudeB);
}
}
セマンティックキャッシュの統合
const resolvers = {
Query: {
generateSummary : async (
_ : any ,
args : { text : string },
context : ResolverContext
) => {
const { geminiClient , semanticCache , logger } = context;
// キャッシュをチェック
const cached = await semanticCache. get < SummaryResult >(args.text);
if (cached) {
logger. info ( "Summary returned from semantic cache" );
return cached;
}
// キャッシュミス → Gemini API 呼び出し
const response = await geminiClient. generateContent ({
contents: [
{
role: "user" ,
parts: [{ text: `要約: ${ args . text }` }],
},
],
});
const result = {
id: generateId (),
summary: response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "" ,
generatedAt: new Date (),
};
// キャッシュに保存(TTL: 1時間)
await semanticCache. set (args.text, result, 3600000 );
return result;
},
},
};
ストリーミングサブスクリプションと AI レスポンス
WebSocket サブスクリプションの設定
GraphQL サブスクリプションを通じて、AI の生成プロセスをリアルタイムでクライアントに配信します。
const typeDefs = gql `
type Subscription {
generateArticleWithStreaming(topic: String!, length: String!): ArticleChunk!
}
type ArticleChunk {
content: String!
sectionTitle: String
progress: Int! # 0-100
isComplete: Boolean!
estimatedTotalTokens: Int
}
` ;
const resolvers = {
Subscription: {
generateArticleWithStreaming: {
subscribe : async function* (
_ : any ,
args : { topic : string ; length : string },
context : ResolverContext
) {
const { geminiClient , logger } = context;
const stream = geminiClient. generateContentStream ({
contents: [
{
role: "user" ,
parts: [
{
text: `Write a ${ args . length } article about "${ args . topic }". Include clear section headings (##).` ,
},
],
},
],
generationConfig: {
maxOutputTokens: 3000 ,
temperature: 0.7 ,
},
});
let totalChars = 0 ;
let sectionCount = 0 ;
for await ( const response of stream) {
const text = response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "" ;
totalChars += text. length ;
// セクション検出
const sectionMatches = text. match ( /## \s + ( [ ^ \n] + )/ g ) || [];
const currentSectionTitle = sectionMatches[sectionMatches. length - 1 ]
?. replace ( / ^ ## \s + / , "" )
. trim ();
const progress = Math. min (
Math. floor ((totalChars / 5000 ) * 100 ),
99
);
yield {
content: text,
sectionTitle: currentSectionTitle,
progress,
isComplete: false ,
estimatedTotalTokens: Math. floor (totalChars / 4 ),
};
}
yield {
content: "" ,
sectionTitle: null ,
progress: 100 ,
isComplete: true ,
estimatedTotalTokens: Math. floor (totalChars / 4 ),
};
},
},
},
};
クライアント側のサブスクリプション実装(Apollo Client 例)
// サブスクリプション定義
const GENERATE_ARTICLE = gql `
subscription GenerateArticleWithStreaming(
$topic: String!
$length: String!
) {
generateArticleWithStreaming(topic: $topic, length: $length) {
content
sectionTitle
progress
isComplete
estimatedTotalTokens
}
}
` ;
// React コンポーネント
function ArticleGenerator () {
const [ content , setContent ] = useState ( "" );
const [ progress , setProgress ] = useState ( 0 );
const { loading , error } = useSubscription ( GENERATE_ARTICLE , {
variables: { topic: "GraphQL" , length: "medium" },
onData : ({ data }) => {
const chunk = data.data.generateArticleWithStreaming;
setContent (( prev ) => prev + chunk.content);
setProgress (chunk.progress);
},
});
return (
< div >
< progress value = {progress} max = { 100 } />
< article >{content} </ article >
</ div >
);
}
DataLoader パターンによるバッチ最適化
N+1 問題と DataLoader
GraphQL では、複数のユーザーを取得する際に以下の問題が発生します。
{
users {
id
name
recommendations { # 各ユーザーごとに Gemini API を呼び出し = N+1
title
}
}
}
DataLoader はこれを 1 回のバッチ呼び出し に統合します。
import DataLoader from "dataloader" ;
// Gemini API を利用した推奨事項バッチ取得
const recommendationBatchLoader = new DataLoader (
async ( userIds : string []) => {
console. log ( `Batch loading recommendations for ${ userIds . length } users` );
// 複数ユーザーの推奨事項を 1 回の Gemini 呼び出しで生成
const response = await geminiClient. generateContent ({
contents: [
{
role: "user" ,
parts: [
{
text: `Generate personalized article recommendations for these user IDs: ${ userIds . join ( ", " ) }.
Return JSON array: [{ userId, recommendations: [{ title, reason }] }]` ,
},
],
},
],
generationConfig: {
responseMimeType: "application/json" ,
temperature: 0.5 ,
},
});
const results = JSON . parse (
response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "[]"
);
// リクエスト順序に合わせて返す
return userIds. map (( id ) =>
results. find (( r : any ) => r.userId === id)?.recommendations || []
);
},
{
batchScheduleFn : ( callback ) => {
// 10ms のバッチウィンドウを設定
setTimeout (callback, 10 );
},
}
);
const resolvers = {
User: {
recommendations : async ( parent : any , _ : any , context : ResolverContext ) => {
return context.dataLoader. load (parent.id);
},
},
};
// コンテキストにロードする
const context = ( req : any ) => ({
dataLoader: recommendationBatchLoader,
geminiClient,
// ...
});
認証・認可とレート制限の統合
API キーの管理と認証
import { verify } from "jsonwebtoken" ;
type AuthContext = {
userId : string | null ;
userRole : "free" | "pro" | "admin" ;
subscriptionTier : "free" | "basic" | "premium" ;
};
function authenticateToken ( token : string | undefined ) : AuthContext {
if ( ! token) {
return {
userId: null ,
userRole: "free" ,
subscriptionTier: "free" ,
};
}
try {
const decoded = verify (token, process.env. JWT_SECRET ! );
return {
userId: (decoded as any ).userId,
userRole: (decoded as any ).role,
subscriptionTier: (decoded as any ).tier,
};
} catch {
return {
userId: null ,
userRole: "free" ,
subscriptionTier: "free" ,
};
}
}
レート制限ミドルウェア
type RateLimitConfig = {
free : { requestsPerDay : number ; tokensPerDay : number };
basic : { requestsPerDay : number ; tokensPerDay : number };
premium : { requestsPerDay : number ; tokensPerDay : number };
};
const RATE_LIMITS : RateLimitConfig = {
free: { requestsPerDay: 10 , tokensPerDay: 5000 },
basic: { requestsPerDay: 100 , tokensPerDay: 50000 },
premium: { requestsPerDay: 1000 , tokensPerDay: 500000 },
};
class RateLimiter {
private redis : RedisClient ;
async checkLimit ( userId : string , tier : string , tokensUsed : number ) : Promise <{
allowed : boolean ;
remaining : number ;
}> {
const key = `ratelimit:${ userId }:${ new Date (). toDateString () }` ;
const config = RATE_LIMITS [tier as keyof RateLimitConfig ] || RATE_LIMITS .free;
const current = await this .redis. get (key);
const currentTokens = current ? JSON . parse (current) : { tokens: 0 , requests: 0 };
const newTokenCount = currentTokens.tokens + tokensUsed;
const newRequestCount = currentTokens.requests + 1 ;
if (
newTokenCount > config.tokensPerDay ||
newRequestCount > config.requestsPerDay
) {
return { allowed: false , remaining: config.tokensPerDay - newTokenCount };
}
await this .redis. setex (
key,
86400 ,
JSON . stringify ({
tokens: newTokenCount,
requests: newRequestCount,
})
);
return {
allowed: true ,
remaining: config.tokensPerDay - newTokenCount,
};
}
}
// リゾルバーに統合
const resolvers = {
Query: {
generateContent : async (
_ : any ,
args : any ,
context : ResolverContext & { auth : AuthContext }
) => {
// 認可チェック
if (context.auth.subscriptionTier === "free" ) {
throw new Error ( "This feature requires a subscription" );
}
// レート制限チェック(実行前)
const { allowed } = await rateLimiter. checkLimit (
context.auth.userId ! ,
context.auth.subscriptionTier,
0 // 仮の推定値
);
if ( ! allowed) {
throw new Error ( "Rate limit exceeded" );
}
// 実行...
},
},
};
テスト戦略とモック設計
Gemini API のモック
class MockGeminiClient {
private responses : Map < string , string > = new Map ();
setMockResponse ( prompt : string , response : string ) {
this .responses. set (prompt, response);
}
async generateContent ( config : any ) : Promise < any > {
const prompt = config.contents[ 0 ]?.parts[ 0 ]?.text || "" ;
const mockResponse = this .responses. get (prompt);
if ( ! mockResponse) {
throw new Error ( `No mock response for prompt: ${ prompt }` );
}
return {
candidates: [
{
content: {
parts: [{ text: mockResponse }],
},
},
],
usageMetadata: {
inputTokens: 10 ,
outputTokens: mockResponse. split ( " " ). length ,
},
};
}
async generateContentStream ( config : any ) {
const response = await this . generateContent (config);
const text = response.candidates[ 0 ].content.parts[ 0 ].text;
async function* generator () {
// テキストをチャンクに分割してストリーム
const chunks = text. split ( " " );
for ( const chunk of chunks) {
yield response;
}
}
return generator ();
}
}
リゾルバーテスト
describe ( "generateSummary resolver" , () => {
let mockGeminiClient : MockGeminiClient ;
let context : ResolverContext ;
beforeEach (() => {
mockGeminiClient = new MockGeminiClient ();
context = {
geminiClient: mockGeminiClient as any ,
semanticCache: new Map (),
dataLoader: new DataLoader ( async ( ids ) => ids),
userId: "test-user" ,
logger: console,
};
});
it ( "should generate summary correctly" , async () => {
const testText = "This is a long text about GraphQL and AI integration." ;
const expectedSummary = "GraphQL と AI の統合についての説明" ;
mockGeminiClient. setMockResponse (testText, expectedSummary);
const result = await resolvers.Query. generateSummary (
null ,
{ text: testText },
context
);
expect (result.summary). toBe (expectedSummary);
});
it ( "should cache semantic similarities" , async () => {
const text1 = "GraphQL is a query language" ;
const text2 = "GraphQL: a query language" ; // 似た内容
mockGeminiClient. setMockResponse (text1, "GraphQL summary" );
// 1 回目は API 呼び出し
const result1 = await resolvers.Query. generateSummary (
null ,
{ text: text1 },
context
);
// 2 回目は類似度でキャッシュヒット(2 回呼び出されない)
// ここは実装に応じて検証
});
});
一度のクエリが AI を百回呼ぶ ― フィールド単位の予算ガード
GraphQL を AI データレイヤーに使い始めて最初に肝を冷やしたのは、たった一行のクエリが Gemini を何十回も呼んでいた場面でした。私自身、個人開発で複数サービスのバックエンドを一人で見ているため、コストの異常はダッシュボードよりも先に請求額で気づくことが少なくありません。
REST であれば「エンドポイントごとに毎分何回まで」というレート制限で大半は防げます。ところが GraphQL は事情が異なります。users(first: 100) { aiSummary } という一見ふつうのクエリが、リスト 100 件 × AI フィールド 1 個 = 100 回の Gemini 呼び出しへ展開されます。ネストすれば、これは掛け算で膨らみます。
HTTP から見れば、これはあくまで「1 リクエスト」。トランスポート層のレート制限では決して捕まりません。守るべき単位はリクエストではなく、「1 リクエストの中で AI を何回呼んだか」なのです。
実行中に呼び出し回数を数えて打ち切る
最も確実なのは、リクエストごとの context に AI 呼び出しの予算を持たせ、リゾルバーを通過するたびに減算する方法です。
// リクエストごとに context へ載せる予算
interface AIBudget {
remaining : number ;
spent : number ;
}
export function createAIBudget ( max = 25 ) : AIBudget {
return { remaining: max, spent: 0 };
}
// AI を呼ぶリゾルバーは必ずこのラッパーを通す
export async function withAIBudget < T >(
budget : AIBudget ,
fn : () => Promise < T >,
) : Promise < T > {
if (budget.remaining <= 0 ) {
throw new GraphQLError (
`このリクエストの AI 呼び出し予算を超過しました(上限 ${ budget . spent } 回)。` ,
{ extensions: { code: "AI_BUDGET_EXCEEDED" } },
);
}
budget.remaining -= 1 ;
budget.spent += 1 ;
return fn ();
}
リゾルバー側はこう書きます。
const resolvers = {
User: {
aiSummary : ( parent , _args , ctx ) =>
withAIBudget (ctx.aiBudget, () => geminiSummarize (parent.bio)),
},
};
ここで一つ運用上の勘どころがあります。キャッシュヒットは予算から引かないことです。セマンティックキャッシュ戦略の節で組んだキャッシュ層を withAIBudget の外側に置けば、すでに意味的に一致した応答は呼び出し回数に数えられません。これだけでも、温まったキャッシュ下では実コストが体感で半分以下になります。私の運用では、要約系フィールドのキャッシュヒット率が 60% 前後に落ち着き、予算上限に当たる頻度が大きく下がりました。
実行する前に見積もって、早期に弾く
走らせてから止めるのは安全側の設計ですが、悪意あるクエリや明らかに過大なクエリには「実行する前に拒否する」方が望ましい場面があります。クエリの構造から AI 呼び出し回数を見積もり、しきい値を超えたら一切実行せずに返します。
// クエリ AST から AI 呼び出し回数を見積もる(ヒューリスティック)
import { parse, visit } from "graphql" ;
const AI_FIELDS = new Set ([ "aiSummary" , "semanticSearch" , "aiTranslate" ]);
function estimateAICalls ( query : string , defaultListSize = 50 ) : number {
const ast = parse (query);
let multiplier = 1 ;
let aiCalls = 0 ;
visit (ast, {
Field: {
enter ( node ) {
const first = node.arguments?. find (( a ) => a.name.value === "first" );
if (first && first.value.kind === "IntValue" ) {
multiplier *= Number (first.value.value);
}
if ( AI_FIELDS . has (node.name.value)) aiCalls += multiplier;
},
leave ( node ) {
const first = node.arguments?. find (( a ) => a.name.value === "first" );
if (first && first.value.kind === "IntValue" ) {
multiplier /= Number (first.value.value);
}
},
},
});
return aiCalls;
}
const MAX_ESTIMATED_AI_CALLS = 25 ;
export function guardQueryCost ( query : string ) : void {
const estimate = estimateAICalls (query);
if (estimate > MAX_ESTIMATED_AI_CALLS ) {
throw new GraphQLError (
`このクエリは約 ${ estimate } 回の AI 呼び出しを誘発します(上限 ${ MAX_ESTIMATED_AI_CALLS })。リスト件数を絞るか、クエリを分割してください。` ,
{ extensions: { code: "QUERY_TOO_EXPENSIVE" } },
);
}
}
見積もりはあくまで近似です。first 引数を持たない無制限リストには保守的な既定値を当て、フラグメント展開やネストの深さまでは厳密に追いません。だからこそ、これ単体に頼らず実行中カウンタと組み合わせるのが安全です。
どの守り方を選ぶか
二つの手段は排他ではなく、二段構えで使うのが実用的です。
守り方 暴走を止めるタイミング 部分結果 主な弱点 推奨用途
守り無し 止まらない 全件返る 請求額で初めて気づく 非推奨
実行中カウンタ(withAIBudget ) 上限到達時 上限まで返り、超過分でエラー 上限分のコストは発生する 取りこぼし防止の最終防衛線
実行前見積もり(guardQueryCost ) 実行前 無し(門前払い) 見積もりが近似で取りこぼす 悪意あるクエリの早期遮断
実行前見積もりで明らかに過大なクエリを門前払いし、実行中カウンタで見積もりの取りこぼし(深いネストやフラグメント由来)を最終的に止める。本番では両方を入れることをおすすめします。予算上限は固定値から始め、AI_BUDGET_EXCEEDED の発生率を観察しながら、正当な利用を妨げない範囲で締めていくとよいでしょう。
コストの守りは、機能を足すより地味な作業です。それでも、一人で運用する個人開発者にとっては、夜中に届く想定外の請求を防ぐこの一段が、安心して AI フィールドを増やしていける土台になると考えています。
本番デプロイとオブザーバビリティ
ログとトレーシング
import { tracer } from "@opentelemetry/api" ;
import winston from "winston" ;
const logger = winston. createLogger ({
format: winston.format. json (),
transports: [ new winston.transports. Console ()],
});
const resolvers = {
Query: {
generateContent : async (
_ : any ,
args : any ,
context : ResolverContext
) => {
const span = tracer. startSpan ( "generateContent" );
try {
span. setAttributes ({
userId: context.userId,
promptLength: args.prompt. length ,
});
logger. info ( "Starting content generation" , {
userId: context.userId,
promptLength: args.prompt. length ,
timestamp: new Date (). toISOString (),
});
const startTime = Date. now ();
const response = await context.geminiClient. generateContent ({
contents: [{ role: "user" , parts: [{ text: args.prompt }] }],
});
const duration = Date. now () - startTime;
logger. info ( "Content generation completed" , {
userId: context.userId,
duration,
outputTokens: response.usageMetadata?.outputTokens,
});
span. addEvent ( "generation_completed" , {
duration,
tokensUsed: response.usageMetadata?.outputTokens,
});
return {
id: generateId (),
content: response.candidates[ 0 ]?.content?.parts[ 0 ]?.text || "" ,
generatedAt: new Date (),
};
} catch (error) {
logger. error ( "Content generation failed" , {
userId: context.userId,
error: (error as Error ).message,
});
span. recordException (error as Error );
span. setStatus ({ code: 2 });
throw error;
} finally {
span. end ();
}
},
},
};
エラーハンドリングと再試行戦略
async function withRetry < T >(
fn : () => Promise < T >,
maxRetries : number = 3 ,
initialDelayMs : number = 1000
) : Promise < T > {
let lastError : Error | null = null ;
for ( let attempt = 1 ; attempt <= maxRetries; attempt ++ ) {
try {
return await fn ();
} catch (error) {
lastError = error as Error ;
// 再試行可能なエラーかチェック
const isRetryable =
(error as any ).status === 429 || // Rate limit
(error as any ).status === 503 || // Service unavailable
(error as any ).code === "ECONNRESET" ;
if ( ! isRetryable || attempt === maxRetries) {
throw error;
}
// Exponential backoff
const delayMs = initialDelayMs * Math. pow ( 2 , attempt - 1 );
logger. warn ( `Retry attempt ${ attempt }/${ maxRetries } after ${ delayMs }ms` , {
error: (error as Error ).message,
});
await new Promise (( resolve ) => setTimeout (resolve, delayMs));
}
}
throw lastError;
}
// リゾルバーで使用
const response = await withRetry (
() =>
context.geminiClient. generateContent ({
contents: [{ role: "user" , parts: [{ text: args.prompt }] }],
}),
3 ,
1000
);
メトリクス収集
class MetricsCollector {
private metrics = {
totalRequests: 0 ,
successfulRequests: 0 ,
failedRequests: 0 ,
totalTokensUsed: 0 ,
averageResponseTime: 0 ,
};
recordRequest ( success : boolean , tokensUsed : number , responseTime : number ) {
this .metrics.totalRequests ++ ;
if (success) {
this .metrics.successfulRequests ++ ;
} else {
this .metrics.failedRequests ++ ;
}
this .metrics.totalTokensUsed += tokensUsed;
this .metrics.averageResponseTime =
( this .metrics.averageResponseTime * ( this .metrics.totalRequests - 1 ) +
responseTime) /
this .metrics.totalRequests;
}
getMetrics () {
return {
... this .metrics,
successRate: this .metrics.successfulRequests / this .metrics.totalRequests,
};
}
exportPrometheus () : string {
return `
# HELP graphql_ai_requests_total Total number of AI-powered requests
# TYPE graphql_ai_requests_total counter
graphql_ai_requests_total{status="success"} ${ this . metrics . successfulRequests }
graphql_ai_requests_total{status="failure"} ${ this . metrics . failedRequests }
# HELP graphql_ai_tokens_total Total tokens consumed
# TYPE graphql_ai_tokens_total counter
graphql_ai_tokens_total ${ this . metrics . totalTokensUsed }
# HELP graphql_ai_response_time_ms Average response time in milliseconds
# TYPE graphql_ai_response_time_ms gauge
graphql_ai_response_time_ms ${ Math . round ( this . metrics . averageResponseTime ) }
` ;
}
}