Cloudflare Clef入門——AIの分岐判断をFirestoreで監査する
Cloudflare Workers AIのdecision model「Clef」を題材に、問い合わせ分類や人手エスカレーション判断をNext.js + Firestoreで監査する実践手順を解説します。

はじめに
AIエージェントを業務アプリに入れると、長い文章を生成する場面よりも「次にどちらへ進むか」を高速に決めたい場面が増えてきます。
| 場面 | よくある困りごと |
|---|---|
| 問い合わせの一次分類 | LLMの自由文をパースして担当チームを決めている |
| 障害報告の緊急度判定 | しきい値の根拠がログに残りにくい |
| 20〜30件のチケット処理 | 全件を大きな生成モデルへ投げると遅く高くなりやすい |
2026年10月1日、Cloudflareは Workers AI 上で Clef と Clef-flash を公開しました。公式Changelogでは、Cloudflare Workers AIチームが学習した初のモデルであり、自由文ではなく、型付きの質問に対して選択肢ごとの確率を返す decision model と説明されています。
本記事では、架空の問い合わせ管理SaaS「SupportLane」を題材に、Next.js App Router + Firestore + TypeScriptで「AIの分岐判断を監査できる台帳」を作ります。この記事を読み終えると、AIの判断をただ信じるのではなく、質問、選択肢、確率、人手確認の有無まで残せるようになります。
参考にした一次情報は次の通りです。
- Cloudflare Blog: Introducing Clef
- Cloudflare Changelog: Clef on Workers AI
- Cloudflare Workers AI Docs: clef
- Cloudflare Workers AI Docs: Workers Bindings
Clefで何が変わるのか
Clefは、入力状態 state と質問群 questions を受け取り、各質問の答えを確率付きで返すモデルです。Cloudflareの公式Docsでは、モデルIDは @cf/cloudflare/clef、軽量版は @cf/cloudflare/clef-flash と案内されています。
質問タイプは、2026年10月4日時点で次の3種類です。
| type | 用途 | 例 |
|---|---|---|
noul | yes/no判定 | この問い合わせは緊急か |
choice | 定義済み候補から選択 | billing、technical、salesのどれか |
score | 順序付きルーブリックで採点 | No impactからCriticalまで |
ポイントは、Clefを「文章を書くAI」として使わないことです。SupportLaneでは、回答文の生成は別のLLMに任せ、Clefはホットパスの分類、緊急度、エスカレーション判定に限定します。
事前準備
Cloudflare Workers上なら env.AI.run() で呼び出せます。Next.jsをVercelやFirebase Hosting側で動かしている場合は、CloudflareのREST APIから呼び出す構成にすると既存アプリへ組み込みやすいです。
yarn add firebase-admin
環境変数はサーバー側だけで扱います。API tokenにはWorkers AIを実行できる権限を付け、クライアントコンポーネントへ露出しないようにします。
CLOUDFLARE_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLOUDFLARE_API_TOKEN=cf_api_token
Cloudflare Workersで使う場合は、Wrangler設定にAI bindingを追加します。
{
"ai": {
"binding": "AI"
}
}
ハンズオン1: Clef呼び出しを薄い関数に閉じ込める
まず、SupportLaneの問い合わせをClefへ渡す関数を作ります。REST endpointは公式APIの POST /accounts/{account_id}/ai/run/{model_name} 形式に合わせます。
type ClefQuestion =
| {
type: "noul";
instructions: string;
}
| {
type: "choice";
instructions: string;
criteria: Record<string, string>;
}
| {
type: "score";
instructions: string;
criteria: string[];
};
type ClefResponse = {
model: string;
answers: Record<string, unknown>;
usage?: unknown;
};
export async function runClefDecision(input: {
model?: "clef" | "clef-flash";
state: unknown;
questions: Record<string, ClefQuestion>;
}) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const token = process.env.CLOUDFLARE_API_TOKEN;
if (!accountId || !token) {
throw new Error("Cloudflare credentials are not configured");
}
const modelName = input.model === "clef-flash"
? "@cf/cloudflare/clef-flash"
: "@cf/cloudflare/clef";
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${modelName}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: input.model ?? "clef",
state: input.state,
questions: input.questions,
}),
},
);
if (!response.ok) {
throw new Error(`Workers AI Clef failed: ${response.status}`);
}
const payload = (await response.json()) as { result?: ClefResponse } & ClefResponse;
return payload.result ?? payload;
}
questions は最大64件まで扱えるため、問い合わせ1件につき複数の判断をまとめられます。ただし、最初から大量の質問を詰め込むより、画面上で必要な判断だけを固定化した方が、あとから監査しやすくなります。
ハンズオン2: 問い合わせ分類をFirestoreへ保存する
次に、問い合わせ登録時にClefの判断を実行し、結果を ticketDecisionRuns コレクションへ保存します。ここでは、本文そのものを長期保存しすぎないよう、台帳には問い合わせID、質問、モデル、回答、信頼度確認フラグを中心に残します。
// app/api/tickets/[ticketId]/decide/route.ts
import { FieldValue } from "firebase-admin/firestore";
import { NextRequest, NextResponse } from "next/server";
import { adminDb } from "@/lib/firebase/admin";
import { runClefDecision } from "@/lib/ai/cloudflare-clef";
type Params = {
params: Promise<{ ticketId: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
const { ticketId } = await params;
const body = (await request.json()) as {
subject?: string;
message?: string;
plan?: "free" | "team" | "enterprise";
};
if (!body.subject || !body.message || body.message.length > 4000) {
return NextResponse.json({ error: "invalid ticket" }, { status: 400 });
}
const runRef = adminDb.collection("ticketDecisionRuns").doc();
await runRef.set({
ticketId,
provider: "cloudflare",
model: "clef-flash",
status: "running",
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
});
try {
const result = await runClefDecision({
model: "clef-flash",
state: {
subject: body.subject,
message: body.message,
customerPlan: body.plan ?? "free",
},
questions: {
urgent: {
type: "noul",
instructions: "Is this support request urgent?",
},
team: {
type: "choice",
instructions: "Which team should handle this request?",
criteria: {
billing: "Payments, invoices, refunds, and plan changes",
technical: "Outages, bugs, errors, and configuration problems",
sales: "Upgrades, procurement, and product evaluation",
},
},
severity: {
type: "score",
instructions: "How severe is the customer impact?",
criteria: ["No impact", "Minor", "Major", "Critical"],
},
},
});
await runRef.update({
status: "completed",
answers: result.answers,
usage: result.usage ?? null,
requiresHumanReview: true,
updatedAt: FieldValue.serverTimestamp(),
});
return NextResponse.json({ runId: runRef.id, answers: result.answers });
} catch (error) {
await runRef.update({
status: "failed",
errorMessage: error instanceof Error ? error.message : "unknown error",
updatedAt: FieldValue.serverTimestamp(),
});
return NextResponse.json({ error: "decision failed" }, { status: 502 });
}
}
requiresHumanReview は、最初は常に true にしておくのが安全です。数週間分の結果を見て、どの質問なら自動化できるか、どの確率帯なら人に戻すべきかをチームで決めます。
ハンズオン3: GitHub Actionsで判断品質を測る
分類モデルは導入直後より、運用データを見ながら改善する部分が大きいです。SupportLaneでは、手動で正解ラベルを付けたFirestoreデータを使い、日次で一致率を集計します。
name: decision-quality-report
on:
schedule:
- cron: "0 21 * * *"
workflow_dispatch:
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
- run: yarn install --frozen-lockfile
- run: yarn tsx scripts/report-decision-quality.ts
env:
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
type DecisionRun = {
answers?: Record<string, unknown>;
humanLabel?: {
team?: string;
severity?: string;
};
};
export function summarizeDecisionQuality(runs: DecisionRun[]) {
const labeled = runs.filter((run) => run.humanLabel?.team);
return {
labeledCount: labeled.length,
reviewRate:
runs.length === 0 ? 0 : runs.filter((run) => run.humanLabel).length / runs.length,
};
}
ここでは、Clefの返却値を無理に決め打ちで展開していません。公式Docsで確認できる answers を保存し、実際のレスポンスを見てから、集計に必要なプロパティだけを型へ寄せていくのが堅実です。
実務での使い分け
Clefは、問い合わせ分類、ポリシー判定、担当チーム振り分けのように、選択肢が先に決まっている処理に向いています。一方で、顧客への返信文作成、仕様の要約、調査レポートの作成は、通常の生成モデルへ渡す方が自然です。
| 処理 | Clef向きか | 理由 |
|---|---|---|
| チケットの緊急度判定 | 向いている | yes/noやscoreで表現できる |
| 担当チーム振り分け | 向いている | choiceで選択肢を固定できる |
| 顧客返信文の作成 | 向いていない | 自由文生成が必要 |
| 低信頼度だけ人へ戻す | 向いている | 確率を監査ログに残せる |
大事なのは、AI判断を「自動化するかどうか」ではなく、「どの判断を、どの確率で、誰が確認したか」まで設計することです。Firestoreに台帳を作っておけば、後からモデル変更、しきい値変更、手動レビュー率の見直しができます。
まとめ
Cloudflare Clefは、AIエージェントの中に置く軽量な分岐判断レイヤーとして使いやすいモデルです。@cf/cloudflare/clef は精度重視、@cf/cloudflare/clef-flash はレイテンシ重視という使い分けができます。
まずは、問い合わせ分類のような低リスクなワークフローで、state、questions、answers、人手確認結果をFirestoreに保存するところから始めるのがおすすめです。生成モデルに全部任せる前に、判断だけを切り出して測る。この一手が、AI機能を運用できるプロダクトに近づけます。