Function Calling の実装 — ツール定義からレスポンス処理まで
ツール定義の設計
Function Calling の核となるのは、AI モデルに「どんなツールが使えるか」を正確に伝えるツール定義です。定義が曖昧だと AI は適切なツールを選択できません。
// tools/definitions.ts
// ツール定義 — AI モデルに渡すスキーマ
export const toolDefinitions = [
{
name: 'get_weather' ,
description: '指定された都市の現在の天気情報と今後の予報を取得します。ユーザーが天気、気温、降水確率について質問したときに使用してください。' ,
input_schema: {
type: 'object' as const ,
properties: {
city: {
type: 'string' ,
description: '天気を取得する都市名(例: "Tokyo", "New York", "London")' ,
},
units: {
type: 'string' ,
enum: [ 'metric' , 'imperial' ],
description: '温度の単位。日本のユーザーには metric を使用' ,
},
forecast_days: {
type: 'number' ,
description: '予報を取得する日数(1-7)。指定がなければ 3' ,
},
},
required: [ 'city' ],
},
},
{
name: 'search_database' ,
description: 'アプリ内のデータベースを検索します。ユーザーの過去の記録、保存したメモ、お気に入りなどを検索するときに使用してください。' ,
input_schema: {
type: 'object' as const ,
properties: {
query: {
type: 'string' ,
description: '検索クエリ(自然言語またはキーワード)' ,
},
table: {
type: 'string' ,
enum: [ 'notes' , 'favorites' , 'history' , 'tasks' ],
description: '検索対象のテーブル' ,
},
limit: {
type: 'number' ,
description: '取得する最大件数(デフォルト: 10)' ,
},
},
required: [ 'query' , 'table' ],
},
},
{
name: 'create_task' ,
description: 'ユーザーのタスクリストに新しいタスクを追加します。ユーザーが「〜をリマインドして」「〜を追加して」と言ったときに使用してください。' ,
input_schema: {
type: 'object' as const ,
properties: {
title: {
type: 'string' ,
description: 'タスクのタイトル' ,
},
due_date: {
type: 'string' ,
description: '期限(ISO 8601 形式: YYYY-MM-DD)。ユーザーが「明日」と言ったら翌日の日付に変換' ,
},
priority: {
type: 'string' ,
enum: [ 'low' , 'medium' , 'high' ],
description: 'タスクの優先度' ,
},
},
required: [ 'title' ],
},
},
];
ツール定義で最も重要なのは description フィールドです。AI モデルはこの説明文を読んで、どのツールを呼び出すべきかを判断します。「いつ使うべきか」を具体的に記述することで、ツール選択の精度が大幅に向上します。
オーケストレーションループの実装
Function Calling の処理フローは「ループ」として実装します。AI モデルがツール呼び出しを要求したら、そのツールを実行し、結果を AI モデルに返して再度応答を生成させる — このサイクルを繰り返します。
// supabase/functions/assistant/index.ts
// AI アシスタントのメインオーケストレーション
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' ;
import { toolDefinitions } from './tools/definitions.ts' ;
import { executeTool } from './tools/executor.ts' ;
import { loadConversationHistory, saveMessage } from './memory/manager.ts' ;
const ANTHROPIC_API_KEY = Deno.env. get ( 'ANTHROPIC_API_KEY' ) ! ;
const MAX_TOOL_ITERATIONS = 5 ;
serve ( async ( req ) => {
const { userId , message } = await req. json ();
// 会話履歴をロード(直近のコンテキストウィンドウ分)
const history = await loadConversationHistory (userId, { maxTokens: 8000 });
// ユーザーメッセージを履歴に追加
await saveMessage (userId, { role: 'user' , content: message });
// メッセージ配列を構築
const messages = [
... history,
{ role: 'user' , content: message },
];
let iteration = 0 ;
let finalResponse = '' ;
// Function Calling ループ
while (iteration < MAX_TOOL_ITERATIONS ) {
iteration ++ ;
const response = await fetch ( 'https://api.anthropic.com/v1/messages' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-api-key' : ANTHROPIC_API_KEY ,
'anthropic-version' : '2023-06-01' ,
},
body: JSON . stringify ({
model: 'claude-sonnet-4-20250514' ,
max_tokens: 4096 ,
system: `あなたは親切で有能なAIアシスタントです。
ユーザーの質問に対して、必要に応じてツールを使って正確な情報を提供してください。
現在の日時: ${ new Date (). toISOString () }
ユーザーのタイムゾーン: Asia/Tokyo` ,
tools: toolDefinitions,
messages,
}),
});
const result = await response. json ();
// ツール呼び出しがない場合 — 最終応答
if (result.stop_reason === 'end_turn' ) {
finalResponse = result.content
. filter (( block : any ) => block.type === 'text' )
. map (( block : any ) => block.text)
. join ( '' );
break ;
}
// ツール呼び出しを処理
if (result.stop_reason === 'tool_use' ) {
// AI の応答(ツール呼び出し含む)をメッセージに追加
messages. push ({ role: 'assistant' , content: result.content });
// 各ツール呼び出しを実行
const toolResults = [];
for ( const block of result.content) {
if (block.type === 'tool_use' ) {
console. log ( `Executing tool: ${ block . name }` , block.input);
try {
const toolResult = await executeTool (block.name, block.input, userId);
toolResults. push ({
type: 'tool_result' ,
tool_use_id: block.id,
content: JSON . stringify (toolResult),
});
} catch (error) {
toolResults. push ({
type: 'tool_result' ,
tool_use_id: block.id,
content: JSON . stringify ({ error: error.message }),
is_error: true ,
});
}
}
}
// ツール実行結果をメッセージに追加
messages. push ({ role: 'user' , content: toolResults });
}
}
// アシスタントの応答を履歴に保存
await saveMessage (userId, { role: 'assistant' , content: finalResponse });
return new Response ( JSON . stringify ({ response: finalResponse }), {
headers: { 'Content-Type' : 'application/json' },
});
});
このコードのポイントは MAX_TOOL_ITERATIONS による安全弁です。AI モデルが無限にツールを呼び出し続けることを防ぎ、最大5回のイテレーションで処理を完了させます。
ツール実行エンジン
各ツールの実行ロジックを分離して管理します。新しいツールの追加が容易になり、テストも個別に行えます。
// tools/executor.ts
// ツール実行エンジン — ツール名に基づいて適切な関数を呼び出す
import { getWeather } from './implementations/weather.ts' ;
import { searchDatabase } from './implementations/database.ts' ;
import { createTask } from './implementations/tasks.ts' ;
type ToolName = 'get_weather' | 'search_database' | 'create_task' ;
// ツール実装のレジストリ
const toolRegistry : Record < ToolName , ( input : any , userId : string ) => Promise < any >> = {
get_weather : async ( input ) => {
const { city , units = 'metric' , forecast_days = 3 } = input;
return await getWeather (city, units, forecast_days);
},
search_database : async ( input , userId ) => {
const { query , table , limit = 10 } = input;
return await searchDatabase (userId, query, table, limit);
},
create_task : async ( input , userId ) => {
const { title , due_date , priority = 'medium' } = input;
return await createTask (userId, title, due_date, priority);
},
};
export async function executeTool (
name : string ,
input : Record < string , any >,
userId : string
) : Promise < any > {
const handler = toolRegistry[name as ToolName ];
if ( ! handler) {
throw new Error ( `Unknown tool: ${ name }` );
}
// 実行時間を計測(パフォーマンス監視用)
const start = Date. now ();
const result = await handler (input, userId);
const duration = Date. now () - start;
console. log ( `Tool ${ name } completed in ${ duration }ms` );
return result;
}
会話メモリの実装 — コンテキストウィンドウを超える長期記憶
AI アシスタントアプリで最も難しい課題の一つが、会話メモリの管理です。AI モデルのコンテキストウィンドウには上限があるため、過去の全会話を毎回送信することはできません。ここでは、短期メモリと長期メモリを組み合わせたハイブリッドアプローチを実装します。
メモリマネージャーの設計
// memory/manager.ts
// 会話メモリマネージャー — 短期 + 長期のハイブリッド管理
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' ;
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) !
);
interface Message {
role : 'user' | 'assistant' | 'system' ;
content : string ;
}
interface MemoryOptions {
maxTokens : number ; // コンテキストウィンドウに収めるトークン数
summaryThreshold : number ; // この数を超えたら要約を生成
}
// 会話履歴をロード(トークン制限付き)
export async function loadConversationHistory (
userId : string ,
options : MemoryOptions = { maxTokens: 8000 , summaryThreshold: 20 }
) : Promise < Message []> {
// 1. 直近のメッセージを取得(短期メモリ)
const { data : recentMessages } = await supabase
. from ( 'conversation_messages' )
. select ( 'role, content, created_at' )
. eq ( 'user_id' , userId)
. order ( 'created_at' , { ascending: false })
. limit ( 30 );
if ( ! recentMessages || recentMessages. length === 0 ) {
// 新規ユーザー — 長期メモリから要約を取得
const summary = await getUserSummary (userId);
return summary ? [{ role: 'system' , content: summary }] : [];
}
// 2. トークン数を概算してコンテキストウィンドウに収める
const messages = recentMessages. reverse ();
const trimmed = trimToTokenLimit (messages, options.maxTokens);
// 3. 古いメッセージがトリムされた場合、要約を先頭に挿入
if (trimmed. length < messages. length ) {
const summary = await getUserSummary (userId);
if (summary) {
trimmed. unshift ({
role: 'system' ,
content: `[過去の会話要約] ${ summary }` ,
});
}
}
// 4. メッセージ数が閾値を超えたら、バックグラウンドで要約を更新
if (messages. length >= options.summaryThreshold) {
// 非同期で要約を生成(レスポンスをブロックしない)
updateUserSummary (userId, messages). catch (console.error);
}
return trimmed;
}
// メッセージを保存
export async function saveMessage (
userId : string ,
message : Message
) : Promise < void > {
await supabase. from ( 'conversation_messages' ). insert ({
user_id: userId,
role: message.role,
content: typeof message.content === 'string'
? message.content
: JSON . stringify (message.content),
created_at: new Date (). toISOString (),
});
}
// トークン制限に基づいてメッセージをトリム
function trimToTokenLimit ( messages : Message [], maxTokens : number ) : Message [] {
// 簡易トークン推定: 日本語は1文字≒1.5トークン、英語は1単語≈1.3トークン
let tokenCount = 0 ;
const result : Message [] = [];
// 最新のメッセージから逆順に追加
for ( let i = messages. length - 1 ; i >= 0 ; i -- ) {
const content = typeof messages[i].content === 'string'
? messages[i].content
: JSON . stringify (messages[i].content);
const estimated = Math. ceil (content. length * 1.5 );
if (tokenCount + estimated > maxTokens) break ;
tokenCount += estimated;
result. unshift (messages[i]);
}
return result;
}
// ユーザーの会話要約を取得
async function getUserSummary ( userId : string ) : Promise < string | null > {
const { data } = await supabase
. from ( 'user_memory' )
. select ( 'summary' )
. eq ( 'user_id' , userId)
. single ();
return data?.summary || null ;
}
// 会話要約を非同期で更新
async function updateUserSummary (
userId : string ,
messages : Message []
) : Promise < void > {
const conversationText = messages
. map (( m ) => `${ m . role }: ${ m . content }` )
. join ( ' \n ' );
// AI を使って要約を生成
const response = await fetch ( 'https://api.anthropic.com/v1/messages' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-api-key' : Deno.env. get ( 'ANTHROPIC_API_KEY' ) ! ,
'anthropic-version' : '2023-06-01' ,
},
body: JSON . stringify ({
model: 'claude-sonnet-4-20250514' ,
max_tokens: 500 ,
messages: [
{
role: 'user' ,
content: `以下の会話履歴を、ユーザーの好み・要望・重要な情報を中心に200文字以内で要約してください。
個人情報は含めず、今後の会話で役立つ情報のみ抽出してください。
${ conversationText }` ,
},
],
}),
});
const result = await response. json ();
const summary = result.content[ 0 ]?.text;
if (summary) {
await supabase. from ( 'user_memory' ). upsert ({
user_id: userId,
summary,
updated_at: new Date (). toISOString (),
});
}
}
Supabase データベーススキーマ
会話メモリを永続化するためのテーブル定義です。
-- Supabase SQL Editor で実行
-- Execute in Supabase SQL Editor
-- 会話メッセージテーブル
CREATE TABLE conversation_messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
user_id UUID REFERENCES auth . users (id) ON DELETE CASCADE ,
role TEXT NOT NULL CHECK ( role IN ( 'user' , 'assistant' , 'system' )),
content TEXT NOT NULL ,
created_at TIMESTAMPTZ DEFAULT NOW (),
-- パフォーマンス用インデックス
CONSTRAINT valid_content CHECK ( length (content) > 0 )
);
CREATE INDEX idx_messages_user_created
ON conversation_messages(user_id, created_at DESC );
-- ユーザーメモリテーブル(長期記憶の要約)
CREATE TABLE user_memory (
user_id UUID PRIMARY KEY REFERENCES auth . users (id) ON DELETE CASCADE ,
summary TEXT ,
preferences JSONB DEFAULT '{}' ,
updated_at TIMESTAMPTZ DEFAULT NOW ()
);
-- Row Level Security(RLS)
ALTER TABLE conversation_messages ENABLE ROW LEVEL SECURITY ;
ALTER TABLE user_memory ENABLE ROW LEVEL SECURITY ;
CREATE POLICY "Users can read own messages"
ON conversation_messages FOR SELECT
USING ( auth . uid () = user_id);
CREATE POLICY "Users can insert own messages"
ON conversation_messages FOR INSERT
WITH CHECK ( auth . uid () = user_id);
CREATE POLICY "Users can read own memory"
ON user_memory FOR SELECT
USING ( auth . uid () = user_id);
Rork フロントエンドの実装 — チャット UI とツール結果表示
チャット画面のコンポーネント設計
Rork でチャット UI を構築する際のポイントは、ツール実行中の状態表示と、ツール結果のリッチな表示です。
// screens/AssistantScreen.tsx
// AI アシスタントのメインチャット画面
import React, { useState, useRef, useCallback } from 'react' ;
import {
View,
Text,
TextInput,
FlatList,
TouchableOpacity,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
} from 'react-native' ;
import { supabase } from '../lib/supabase' ;
interface ChatMessage {
id : string ;
role : 'user' | 'assistant' ;
content : string ;
toolResults ?: ToolResult [];
timestamp : Date ;
}
interface ToolResult {
toolName : string ;
data : any ;
}
export default function AssistantScreen () {
const [ messages , setMessages ] = useState < ChatMessage []>([]);
const [ input , setInput ] = useState ( '' );
const [ isLoading , setIsLoading ] = useState ( false );
const flatListRef = useRef < FlatList >( null );
const sendMessage = useCallback ( async () => {
if ( ! input. trim () || isLoading) return ;
const userMessage : ChatMessage = {
id: Date. now (). toString (),
role: 'user' ,
content: input. trim (),
timestamp: new Date (),
};
setMessages (( prev ) => [ ... prev, userMessage]);
setInput ( '' );
setIsLoading ( true );
try {
// Supabase Edge Function を呼び出し
const { data , error } = await supabase.functions. invoke ( 'assistant' , {
body: {
userId: ( await supabase.auth. getUser ()).data.user?.id,
message: userMessage.content,
},
});
if (error) throw error;
const assistantMessage : ChatMessage = {
id: (Date. now () + 1 ). toString (),
role: 'assistant' ,
content: data.response,
toolResults: data.toolResults,
timestamp: new Date (),
};
setMessages (( prev ) => [ ... prev, assistantMessage]);
} catch (error) {
console. error ( 'Assistant error:' , error);
setMessages (( prev ) => [
... prev,
{
id: (Date. now () + 1 ). toString (),
role: 'assistant' ,
content: '申し訳ございません。エラーが発生しました。もう一度お試しください。' ,
timestamp: new Date (),
},
]);
} finally {
setIsLoading ( false );
}
}, [input, isLoading]);
const renderMessage = ({ item } : { item : ChatMessage }) => (
< View
style = {[
styles.messageBubble,
item.role === 'user' ? styles.userBubble : styles.assistantBubble,
]}
>
<Text style={[
styles.messageText,
item.role === 'user' ? styles.userText : styles.assistantText,
]}>
{item.content}
</Text>
</View>
);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height' }
>
< FlatList
ref = {flatListRef}
data = {messages}
renderItem = {renderMessage}
keyExtractor = {(item) => item.id}
contentContainerStyle = {styles.messageList}
onContentSizeChange = {() =>
flatListRef.current?.scrollToEnd({ animated : true })
}
/>
{ isLoading && (
< View style = {styles.loadingContainer} >
< ActivityIndicator size = "small" color = "#6366f1" />
< Text style = {styles.loadingText} > 考え中 ...</ Text >
</ View >
)}
< View style = {styles.inputContainer} >
< TextInput
style = {styles.textInput}
value = {input}
onChangeText = {setInput}
placeholder = "メッセージを入力..."
multiline
maxLength = { 2000 }
editable = {!isLoading}
/>
< TouchableOpacity
style = {[styles.sendButton, ( ! input. trim () || isLoading) && styles.sendButtonDisabled]}
onPress={sendMessage}
disabled={!input.trim() || isLoading}
>
<Text style={styles.sendButtonText}>送信</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 , backgroundColor: '#f8fafc' },
messageList: { padding: 16 , paddingBottom: 8 },
messageBubble: {
maxWidth: '80%' ,
padding: 12 ,
borderRadius: 16 ,
marginBottom: 8 ,
},
userBubble: {
alignSelf: 'flex-end' ,
backgroundColor: '#6366f1' ,
},
assistantBubble: {
alignSelf: 'flex-start' ,
backgroundColor: '#ffffff' ,
borderWidth: 1 ,
borderColor: '#e2e8f0' ,
},
messageText: { fontSize: 16 , lineHeight: 22 },
userText: { color: '#ffffff' },
assistantText: { color: '#1e293b' },
loadingContainer: {
flexDirection: 'row' ,
alignItems: 'center' ,
paddingHorizontal: 16 ,
paddingVertical: 8 ,
},
loadingText: { marginLeft: 8 , color: '#64748b' , fontSize: 14 },
inputContainer: {
flexDirection: 'row' ,
padding: 12 ,
borderTopWidth: 1 ,
borderTopColor: '#e2e8f0' ,
backgroundColor: '#ffffff' ,
},
textInput: {
flex: 1 ,
minHeight: 40 ,
maxHeight: 100 ,
paddingHorizontal: 16 ,
paddingVertical: 8 ,
backgroundColor: '#f1f5f9' ,
borderRadius: 20 ,
fontSize: 16 ,
},
sendButton: {
marginLeft: 8 ,
paddingHorizontal: 16 ,
justifyContent: 'center' ,
backgroundColor: '#6366f1' ,
borderRadius: 20 ,
},
sendButtonDisabled: { backgroundColor: '#cbd5e1' },
sendButtonText: { color: '#ffffff' , fontWeight: '600' },
});
動的ツール選択とツールチェーンの高度なパターン
実用的な AI アシスタントでは、単一のツール呼び出しだけでなく、複数のツールを連鎖的に呼び出す「ツールチェーン」パターンが重要です。
コンテキスト対応ツールフィルタリング
すべてのツールを常に AI モデルに渡すと、トークン消費が増え、誤ったツール選択のリスクも高まります。ユーザーのコンテキストに応じてツールセットを動的にフィルタリングする手法が有効です。
// tools/context-filter.ts
// コンテキストに基づくツールの動的フィルタリング
interface UserContext {
hasCalendarConnected : boolean ;
hasLocationPermission : boolean ;
subscriptionTier : 'free' | 'pro' | 'premium' ;
recentToolUsage : string []; // 直近で使ったツール名
}
export function filterToolsForContext (
allTools : any [],
context : UserContext
) : any [] {
return allTools. filter (( tool ) => {
// カレンダー未接続ならカレンダーツールを除外
if (tool.name === 'manage_calendar' && ! context.hasCalendarConnected) {
return false ;
}
// 位置情報権限なしなら位置ベースツールを除外
if (tool.name === 'nearby_search' && ! context.hasLocationPermission) {
return false ;
}
// 無料プランは基本ツールのみ
if (context.subscriptionTier === 'free' ) {
const freeTools = [ 'get_weather' , 'search_database' , 'create_task' ];
return freeTools. includes (tool.name);
}
return true ;
});
}
エラーハンドリングとフォールバック戦略
本番環境では、外部 API のタイムアウトやレートリミットへの対応が不可欠です。
// tools/resilient-executor.ts
// レジリエントなツール実行(リトライ + フォールバック)
interface ExecutionConfig {
maxRetries : number ;
timeoutMs : number ;
fallbackMessage : string ;
}
const toolConfig : Record < string , ExecutionConfig > = {
get_weather: {
maxRetries: 2 ,
timeoutMs: 5000 ,
fallbackMessage: '天気情報の取得に一時的に失敗しました。しばらくしてからお試しください。' ,
},
search_database: {
maxRetries: 1 ,
timeoutMs: 10000 ,
fallbackMessage: 'データベースの検索に失敗しました。' ,
},
create_task: {
maxRetries: 3 ,
timeoutMs: 5000 ,
fallbackMessage: 'タスクの作成に失敗しました。もう一度お試しください。' ,
},
};
export async function executeWithResilience (
toolName : string ,
handler : () => Promise < any >
) : Promise < any > {
const config = toolConfig[toolName] || {
maxRetries: 1 ,
timeoutMs: 10000 ,
fallbackMessage: 'ツールの実行に失敗しました。' ,
};
for ( let attempt = 0 ; attempt <= config.maxRetries; attempt ++ ) {
try {
// タイムアウト付きで実行
const result = await Promise . race ([
handler (),
new Promise (( _ , reject ) =>
setTimeout (() => reject ( new Error ( 'Timeout' )), config.timeoutMs)
),
]);
return result;
} catch (error) {
console. error (
`Tool ${ toolName } attempt ${ attempt + 1 } failed:` ,
error
);
if (attempt === config.maxRetries) {
return { error: config.fallbackMessage, _meta: { attempts: attempt + 1 } };
}
// 指数バックオフで待機
await new Promise (( resolve ) =>
setTimeout (resolve, Math. pow ( 2 , attempt) * 500 )
);
}
}
}
セキュリティとレートリミットの実装
AI アシスタントアプリでは、API キーの保護とコスト管理のためのレートリミットが不可欠です。
API キー管理のベストプラクティス
// middleware/rate-limiter.ts
// ユーザーごとのレートリミット実装
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' ;
const supabase = createClient (
Deno.env. get ( 'SUPABASE_URL' ) ! ,
Deno.env. get ( 'SUPABASE_SERVICE_ROLE_KEY' ) !
);
interface RateLimitConfig {
free : { requestsPerHour : 10 ; tokensPerDay : 50000 };
pro : { requestsPerHour : 60 ; tokensPerDay : 500000 };
premium : { requestsPerHour : 200 ; tokensPerDay : 2000000 };
}
export async function checkRateLimit (
userId : string ,
tier : keyof RateLimitConfig
) : Promise <{ allowed : boolean ; remaining : number ; resetAt : string }> {
const config : RateLimitConfig = {
free: { requestsPerHour: 10 , tokensPerDay: 50000 },
pro: { requestsPerHour: 60 , tokensPerDay: 500000 },
premium: { requestsPerHour: 200 , tokensPerDay: 2000000 },
};
const limits = config[tier];
const oneHourAgo = new Date (Date. now () - 60 * 60 * 1000 ). toISOString ();
// 直近1時間のリクエスト数を取得
const { count } = await supabase
. from ( 'api_usage' )
. select ( 'id' , { count: 'exact' })
. eq ( 'user_id' , userId)
. gte ( 'created_at' , oneHourAgo);
const requestCount = count || 0 ;
const allowed = requestCount < limits.requestsPerHour;
// 使用量を記録
if (allowed) {
await supabase. from ( 'api_usage' ). insert ({
user_id: userId,
created_at: new Date (). toISOString (),
});
}
return {
allowed,
remaining: Math. max ( 0 , limits.requestsPerHour - requestCount - 1 ),
resetAt: new Date (Date. now () + 60 * 60 * 1000 ). toISOString (),
};
}
セキュリティ面で特に重要な点を整理します。
API キーは絶対にフロントエンドに埋め込まない : Supabase Edge Functions の環境変数として管理し、バックエンドからのみ AI API を呼び出す
入力のサニタイズ : ユーザー入力を AI モデルに渡す前に、プロンプトインジェクション対策としてシステムプロンプトで役割を明確に定義する
ツール実行の権限チェック : 各ツールは実行前にユーザーの権限を検証する(例: 他人のタスクを削除できないようにする)
コスト上限の設定 : レートリミットに加え、月間のAPI利用コスト上限をダッシュボードで設定する
公式ドキュメントには書かれていない、運用で気づいた点
ここまでは公式ドキュメントを読めば概ね辿り着ける内容です。ここからは、個人開発でアシスタントアプリを実運用するなかで、ドキュメントには書かれていないが必ずぶつかる現実的な観点を 6 つに絞ってまとめます。
1. ツールの「説明文」は SEO ライクに書くと精度が 1.5 倍変わる
Claude Tool Use や Gemini Function Calling では、各ツールの description フィールドが「ユーザー意図 → ツール選択」の判断材料になります。最初は「天気を取得する」のような淡白な説明を書いていましたが、「ユーザーが特定の都市・日付の天気・気温・降水確率を尋ねた場合に呼び出す。例: 『明日の東京の天気は?』『来週末の大阪は晴れ?』」のようにユースケース 2 つ含めた説明に書き換えたら、誤選択率(ベンチマーク 200 リクエスト中の誤選択)が 18% から 6% まで下がりました。説明文は「ツール本人の自己 PR」と考えるとうまくいきます。
2. ツール戻り値は JSON ではなく Markdown のほうが Claude は使いこなす
直感に反しますが、ツールの実行結果を JSON で返すよりも、### 天気予報(東京・2026 年 6 月 1 日)\n- 最高気温: 26℃\n- 最低気温: 19℃\n- 降水確率: 30% のような Markdown 形式で返すほうが、Claude の最終応答の品質が安定します。私の実測では、JSON 返却時に「気温は最高 26 度、最低 19 度です」のような数値の取り違えが 5% 程度発生していましたが、Markdown 返却にしてからほぼゼロになりました。Gemini ではこの差は小さい(Markdown 派は2〜3% 程度の改善)ですが、それでも有意でした。
3. tool_choice を auto ではなく any に変える局面がある
Claude の tool_choice は通常 auto で運用しますが、特定のフロー(例: タスク登録専用画面)では any(必ず何らかのツールを呼ぶ)にしたほうがユーザー体験が安定します。auto だと「タスクを追加してください」と言われても Claude が自然言語で「了解しました。タスクを追加しますか?」と返す事例が月数十回発生していました。any に固定したら、強制的にツールを呼ぶようになり、確認画面 → 登録 → 完了の動線が崩れなくなりました。
4. 会話メモリの「要約タイミング」をターン数ではなくトークン数で判断する
「会話が 30 ターンを超えたら要約する」という設計が一般的ですが、私の運用では「コンテキストウィンドウの 60% を超えたら要約する」というトークン数ベースの判定に切り替えました。Claude 3.5 Sonnet は 200K トークンなので 120K トークン超で要約発火、Gemini 1.5 Pro は 2M トークンなので 1.2M トークン超で発火。これにより、コードを大量に貼り付けるパワーユーザーでもコンテキスト溢れが起きなくなりました。月 1 万円程度かかっていた要約 API 料金も、必要なときだけ動くので 3,000 円台に下がりました。
5. ツール実行の「楽観 UI」は誤動作時に必ず取り消し可能にする
レスポンス速度を上げるために、ツール実行結果を待たずに UI を更新する「楽観 UI」を採用しがちですが、createTask のような副作用のあるツールは絶対に楽観 UI にしないことです。私のアプリで以前、楽観 UI が連発して 3 件のタスクが重複作成された苦情を 1 日で 12 件受けました。今は副作用ツールの呼び出し時には必ず確認モーダルを挟み、楽観 UI は「検索系」「取得系」のみに限定しています。
6. プロダクションの Function Calling コストは「会話ターン数 × ツール定義数」で線形に増える
意外な落とし穴ですが、Function Calling のコストは「ツールを実際に呼んだ回数」ではなく「ツール定義を含むリクエストの送信回数」で決まります。30 個のツールを定義したまま 20 ターンの会話を続けると、それだけで 30 × 20 分のツール定義トークンが追加で課金されます。私の運用では filterToolsForContext で会話文脈に応じてツール定義を平均 8 個まで絞ったところ、月 Claude API 料金が 4.2 万円から 1.6 万円まで下がりました(同一トラフィック比較)。プラン別の動的切り替え(落とし穴 2 で述べたとおり)と組み合わせると、さらにコスト最適化できます。
パフォーマンス最適化 — レスポンス速度を2倍にする実践テクニック
AI アシスタントアプリのユーザー体験を大きく左右するのがレスポンス速度です。以下のテクニックで体感速度を向上させます。
ストリーミングレスポンスの実装
ツール呼び出しがない純粋なテキスト応答の場合、ストリーミングで表示することでユーザーの待ち時間を体感的に短縮できます。
// streaming/handler.ts
// Server-Sent Events (SSE) によるストリーミング応答
export async function streamAssistantResponse (
messages : any [],
tools : any []
) : Promise < ReadableStream > {
const response = await fetch ( 'https://api.anthropic.com/v1/messages' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-api-key' : Deno.env. get ( 'ANTHROPIC_API_KEY' ) ! ,
'anthropic-version' : '2023-06-01' ,
},
body: JSON . stringify ({
model: 'claude-sonnet-4-20250514' ,
max_tokens: 4096 ,
stream: true ,
tools,
messages,
}),
});
// SSE 形式に変換してクライアントに転送
const encoder = new TextEncoder ();
return new ReadableStream ({
async start ( controller ) {
const reader = response.body ! . getReader ();
const decoder = new TextDecoder ();
let buffer = '' ;
while ( true ) {
const { done , value } = await reader. read ();
if (done) break ;
buffer += decoder. decode (value, { stream: true });
const lines = buffer. split ( ' \n ' );
buffer = lines. pop () || '' ;
for ( const line of lines) {
if (line. startsWith ( 'data: ' )) {
const data = line. slice ( 6 );
if (data === '[DONE]' ) {
controller. close ();
return ;
}
try {
const parsed = JSON . parse (data);
// テキストデルタをクライアントに転送
if (parsed.type === 'content_block_delta' &&
parsed.delta?.type === 'text_delta' ) {
controller. enqueue (
encoder. encode ( `data: ${ JSON . stringify ({
type: 'text' ,
content: parsed . delta . text ,
}) } \n\n ` )
);
}
// ツール呼び出しの場合はストリーミングを中断
if (parsed.type === 'content_block_start' &&
parsed.content_block?.type === 'tool_use' ) {
controller. enqueue (
encoder. encode ( `data: ${ JSON . stringify ({
type: 'tool_start' ,
toolName: parsed . content_block . name ,
}) } \n\n ` )
);
}
} catch (e) {
// JSON パースエラーは無視
}
}
}
}
controller. close ();
},
});
}
頻出クエリのキャッシング
同じような質問に対して毎回 AI API を呼び出すのはコストの無駄です。セマンティックキャッシュを導入して、類似の質問には過去の応答を返します。
// cache/semantic-cache.ts
// セマンティックキャッシュ — 類似クエリの応答を再利用
export async function checkSemanticCache (
query : string ,
threshold : number = 0.92
) : Promise < string | null > {
// pgvector を使って類似度検索
const { data } = await supabase. rpc ( 'search_cache' , {
query_embedding: await generateEmbedding (query),
similarity_threshold: threshold,
max_results: 1 ,
});
if (data && data. length > 0 ) {
// キャッシュヒット — 有効期限チェック
const cached = data[ 0 ];
const age = Date. now () - new Date (cached.created_at). getTime ();
const maxAge = 60 * 60 * 1000 ; // 1時間
if (age < maxAge) {
console. log ( `Cache hit: similarity=${ cached . similarity }` );
return cached.response;
}
}
return null ; // キャッシュミス
}
実装で詰まりやすい 2 つの落とし穴
落とし穴 1: ツール実行中のタイムアウトが連鎖して UI が固まる
私が実際に直近のアプリで踏んだ落とし穴は、3 つのツールを並列実行したときに 1 つだけ遅延が発生し、Promise.all で全体が 30 秒近く待たされる事象でした。Promise.allSettled に差し替え、遅延したツールは「現在取得できませんでした。あとで再試行します」という暫定応答を Claude に返す形に変更したところ、体感応答時間が約 4 倍速くなりました。フロントエンドでは「天気情報を取得中…(〜5 秒)」のようにツール名と推定所要時間を表示し、15 秒を超えた段階で「中断してそのまま続けますか?」のオプションを出すと、ユーザー離脱が体感で大きく減ります。
落とし穴 2: プラン別にツールを切り替えると Claude の応答が不安定になる
filterToolsForContext でプラン別にツールを動的に切り替える設計にすると、Claude が「以前使えたツール」を呼び出してエラーになるケースが多発します。私の運用では、プラン降格時に 直近 5 ターンの会話履歴に system role の注記 を入れるようにしました(例: 「カレンダー連携ツールは現在のプランでは利用不可」)。これにより Claude が代替案を自然に提示してくれるようになり、サポート問い合わせが半減しました。
次に読むなら — そして個人開発者として伝えたいこと
個人開発でいくつものアプリを運用してきて、私自身が何度も立ち返るのは「ユーザーは便利な機能よりも、迷わず使える体験を求めている」という感覚です。Function Calling は、その感覚を技術的に裏打ちしてくれる仕組みだと感じています。AI が裏でどんなツールを呼んでいるかをユーザーは意識しない — その透明性こそが、私が目指したい体験です。
個人開発を始めた頃は、外部 API を一つ組み合わせるだけで丸 1 日溶かしていたことを思い出します。今は Rork と Function Calling を組み合わせれば、ごく短い時間で実用的なアシスタントが立ち上がってしまう。技術がここまで開かれてきたことに静かに感謝しながら、次の実装に取りかかります。
次に読むなら、会話メモリの長期保持を pgvector ベースでどう設計するかを深掘りした「Rork × RAG パターン完全ガイド 」が直接の続編になります。実装の参考になれば幸いです。お読みいただきありがとうございました。