「C#実践アプリ開発ラボ」です。今回は、Azureで使えるようになった判定専用のモデル、Microsoft-Decision-1を試します。

Decision-1は、文章を作りません。決めておいた選択肢ごとに、確率だけを返します。判定専用のモデル「Jev」と、同じようなAPIの形です。

以下のような方に役立つ内容となっています。

この記事が役に立つ方

  • 汎用のモデルに文章で答えさせて、分類やOK/NGを取り出すのが面倒だと感じている
  • Decision-1という名前を見かけて、何ができるのか知りたい
  • Jevのような判定専用のモデルを、会社で使っているAzureの中で使いたい

題材は、問い合わせの振り分けです。サポート窓口に届いた問い合わせを判定し、担当や人へ振り分けます。

コードと実行結果の一式はGitHubにあります。

AppLab/20261021_Decision1 at main · prota-p/AppLab

プロ太

答えの形を先に決めて渡し、確率だけを返してもらう。シンプルですが、業務アプリに組み込みやすいAIです。

Microsoft-Decision-1とは?

まず、Decision-1が何をするAIで、どんな場面に向くのかを整理します。

汎用のモデルとの違い

図1は、同じ問い合わせを、2つのモデルにそれぞれの得意な仕事で渡した例です。

図1: 汎用のモデルと判定専用のモデルの役割
図1:汎用のモデルと判定専用のモデルの役割

GPTなどの汎用のモデルは、文章を作ります。チケットの件名のように、答えの形が決まっていない仕事に向きます。

Decision-1などの判定専用のモデルは、渡した選択肢から選びます。担当を請求・技術・契約・その他から選ばせると、選択肢ごとの確率が返ります。

この問い合わせは、請求が78%、契約が22%でした。答えだけでなく、どれくらい迷ったかも数字で分かります。

なお、Decision-1は判定の理由を返しません。返るのは、問いごとの確率だけです。

向いている場面

使い分けは、次の表のとおりです。

返すもの向いている場面
汎用のモデル(GPTなど)文章(指示すればJSONも)要約・件名・返信の下書きなど、答えの形が決まっていない仕事
判定専用のモデル(Decision-1・Jevなど)決めた選択肢ごとの確率振り分け・OK/NGなど、選択肢が決まっていて、毎回走り、理由はいらない判定

汎用のモデルは、文章を作る仕事に使います。形式を指示すれば、JSONで答えさせることもできます。

判定専用のモデルは、選択肢が決まった判定に使います。届くたびに毎回走り、理由はいらない判定に向きます。

プロ美

判定なら、汎用のモデルにJSONで答えさせてもできるよね?

プロ太

できます。ただ、判定専用のモデルは文章を作らず、出力は数トークンだけ。そのぶん速く、トークンの消費も少なく済みます。

汎用のモデルにJSONで答えさせる方法は、WinForms編の第13回で扱っています。

【C#/WinForms実践入門編(13)】AIアシスタントアプリ ~JSONフォーマットでトピック分析機能を追加しよう!~【システムプロンプトをどう作る?】 WinForms編です。今回のテーマは「JSON形式でAIチャット応答を構造化する」です。 前回までで作成してきたWinForm...

APIの形 ── 状況と問いを送り、確率を受け取る

APIはシンプルです。判定する状況(state)と、問い(questions)を送ります。

{
  "model": "decision-1",
  "state": "勤怠管理クラウドのサポート窓口に、問い合わせフォームから次の本文が届きました。\n\n解約を考えています。今月分の料金は日割りになりますか?",
  "questions": {
    "team": {
      "type": "choice",
      "instructions": "この問い合わせを担当する窓口を選んでください。",
      "criteria": {
        "請求": "請求書・支払い・料金の計算",
        "技術": "ログイン・操作方法・不具合・データの出力",
        "契約": "プランの変更・解約・契約内容",
        "その他": "どれにも当てはまらない(営業や宣伝の売り込みを含む)、または本文から担当を決められない"
      }
    }
  }
}

stateには、判定したい文章を入れます。ここでは、届いた問い合わせの本文です。

questionsには、問いを書きます。この例は、担当を4つの選択肢から選ばせる問いです。

返ってくるのは、問いと同じキー(team)の答えです。選ばれた選択肢と、選択肢ごとの確率が入っています。

{
  "answers": {
    "team": {
      "choice": "請求",
      "probabilities": { "請求": 0.73, "契約": 0.27, "技術": 0.00, "その他": 0.00 }
    }
  }
}
プロ太

ちなみに、判定専用のモデル「Jev」のAPIも、同じような形をしています。

作る ── 問い合わせ1件を振り分ける

ここからは、Decision-1で問い合わせを振り分けてみます。

勤怠管理のクラウドサービス(SaaS)を売る、架空の会社を考えます。お客さまから届いた問い合わせを読み、行き先を決めます(図2)。

図2: 問い合わせの振り分け
図2:問い合わせの振り分け

担当がはっきりしたものは担当の受付箱へ、迷うものは人が振り分けます。不満が強いものは、上長にも知らせます。

今回作るのは、図3の小さなアプリです。問い合わせ1件の本文と問いをDecision-1に送り、判定と振り分け先を画面に出します。

図3: 作ったアプリ
図3:作ったアプリ

フォームや受付箱は作りません。振り分け先は、画面に文字で出すだけです。

次の4つの手順で進めます。

作る手順

  1. Azureに用意する
  2. 問いを書く
  3. Decision-1を呼ぶ
  4. 答えを読む

【1】Azureに用意する

Microsoft Foundryのリソースを作り、Decision-1(モデル名Microsoft-Decision-1)をデプロイします。Bicepと手順はREADMEにあります。

勘所は、次の表の3つです。

項目内容
リージョン東日本(japaneast)・西日本(japanwest)でも使える(2026年10月17日確認)
料金使った分だけの課金。Microsoftの発表では、入力100万トークンあたり0.042ドル、出力は無料
上限1分あたりの回数に上限がある。今回は50回/分にした

日本のリージョンでも使えます。提供リージョンは公式の表で確かめてください。

料金は、Microsoftの発表では入力だけにかかります。今回の1件は入力が約170トークンなので、1万件でも約0.07ドルです。

上限は、1分あたりの回数で、デプロイのときに決めます。たくさん流すときは、上限を超えないように間を空けて送ります。

【2】問いを書く

Decision-1への問いは、答えの型を選んで書きます。型は、次の表の3つです。

型返るものこの記事での問い
choice選ばれた選択肢と、選択肢ごとの確率担当(請求・技術・契約・その他)
score段階の番号を、確率で重み付けした平均緊急度(低・中・高 → 0〜2)
noul「はい」の確率(0〜1)不満や苦情を伝えているか

choiceは、決めた選択肢から1つを選ぶ型です。今回は、担当の振り分けに使います。

scoreは、順序のある段階で評価し、段階の番号の重み付け平均を返す型です。今回は緊急度(低=0・中=1・高=2)に使い、0.9なら「ほぼ中」です。

noulは、はい・いいえで答える問いに、「はい」の確率を返す型です。今回は、不満や苦情を伝えているかに使います。

3つの問いは、JSONで書きます。キー(team・urgency・complaint)は自分で決める名前で、答えも同じキーで返ります。

{
  "team": {
    "type": "choice",
    "instructions": "この問い合わせを担当する窓口を選んでください。",
    "criteria": {
      "請求": "請求書・支払い・料金の計算",
      "技術": "ログイン・操作方法・不具合・データの出力",
      "契約": "プランの変更・解約・契約内容",
      "その他": "どれにも当てはまらない(営業や宣伝の売り込みを含む)、または本文から担当を決められない"
    }
  },
  "urgency": {
    "type": "score",
    "instructions": "この問い合わせの緊急度を評価してください。",
    "criteria": ["低", "中", "高"]
  },
  "complaint": {
    "type": "noul",
    "instructions": "この問い合わせは、当社への不満や苦情を伝えていますか?"
  }
}

担当には「その他」を入れました。choiceは、どれにも当てはまらなくても、必ずどれかを選ぶからです。

公式ドキュメントも、無理に選ばせたくないときは「判断できない」のような選択肢を入れるよう勧めています。

【3】Decision-1を呼ぶ

Decision-1には、判定する状況をstateとして渡します。本文の前に、どこに届いた問い合わせかを1文書きます。

namespace InquiryRouter;

// 問い合わせフォームから届いた1件。判定に使うのは本文だけ
record Inquiry(int Id, string Body)
{
    // Decision-1 に渡す state。どこに届いた本文かを前に書く。会社名・氏名・メールは判定に要らないので入れない
    public string ToState() =>
        $"勤怠管理クラウドのサポート窓口に、問い合わせフォームから次の本文が届きました。\n\n{Body}";
}

今回の判定は本文だけで足りるので、会社名・氏名・メールは送りません。外部のAIに渡す情報は、判定に要るものだけにします。

契約プランなど、お客さまの情報で判定を変えたいときは、要る項目だけをstateに足します。

DecisionClientは、エンドポイントとデプロイ名を受け取ります。エンドポイントは、【1】で作ったリソースのURL(https://<リソース名>.services.ai.azure.com)です。

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Azure.Core;

namespace InquiryRouter;

// Decision-1 を呼ぶ。state に状況、questions に問いを入れて送り、答えの JSON を返す
sealed class DecisionClient(string endpoint, string deployment, TokenCredential credential)
{
    static readonly HttpClient http = new() { Timeout = TimeSpan.FromSeconds(60) };
    public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    AccessToken token;

    public async Task<JsonObject> AskAsync(string state, JsonObject questions)
    {
        // アクセストークン(認証の通行証)の取得は1〜2秒かかるので、期限が近づくまで使い回す(順に呼ぶ前提の簡易な作り)
        if (token.ExpiresOn < DateTimeOffset.UtcNow.AddMinutes(5))
            token = await credential.GetTokenAsync(
                new TokenRequestContext(["https://cognitiveservices.azure.com/.default"]), default);
        using var request = new HttpRequestMessage(HttpMethod.Post,
            endpoint.TrimEnd('/') + "/providers/microsoft/v1/systemone");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        // JsonContent はContent-Lengthを付けずに送るため、Decision-1 に 400 で断られる。文字列にしてから送る
        request.Content = new StringContent(JsonSerializer.Serialize(new { model = deployment, state, questions }, Json),
            System.Text.Encoding.UTF8, "application/json");

        using var response = await http.SendAsync(request);
        if (!response.IsSuccessStatusCode) // 400 のときは、問いの書き方の誤りが本文に書かれている
            throw new HttpRequestException($"{(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
        return (await response.Content.ReadFromJsonAsync<JsonObject>())!;
    }
}

呼び出しは、HttpClientでPOSTするだけです。送り先は、エンドポイントの後ろに/providers/microsoft/v1/systemoneを付けたURLです。

送る本文は、model(デプロイ名)・state・questionsの3つです。

補足:JsonContentでは送れない

JsonContentで送ると、Content-Lengthが付かず、400で断られました(2026年10月時点)。文字列にして、StringContentで送ります。

認証は、APIキーではなくEntra IDです。az loginしたアカウントで、アクセストークンをもらって送ります。

AzureCliCredentialは、裏でAzure CLIを呼んでアクセストークンを取るため、1〜2秒かかります。そこで、期限が近づくまで使い回します。

AskAsyncは、アクセストークンの取得とDecision-1への送信を順に行います。1回目は、アクセストークンの取得を含めて約2秒でした。

2回目からは、Decision-1へ送って答えを受け取るまでの0.2〜0.3秒だけです(日本から東日本リージョン、通信の往復を含む)。

プロ美

1回0.2〜0.3秒なら、問い合わせが届くたびに判定しても待たされないね。

補足:本番で動かすとき

AzureCliCredentialは手元の開発用で、本番ではマネージドIDなどを使います。アクセストークンの使い回しも、順に呼ぶ前提の簡易な作りです。

【4】答えを読む

stateが次のときの出力を見てみます。問いは、【2】の3つ(担当・緊急度・不満)です。

勤怠管理クラウドのサポート窓口に、問い合わせフォームから次の本文が届きました。

解約を考えています。今月分の料金は日割りになりますか?

答えは、問いと同じキーで返ります。先ほどのstateを送ったときの、実際の出力です。

{
  "model": "microsoft-decision-1",
  "answers": {
    "complaint": {
      "noul": 0.0075772716733891144,
      "type": "noul"
    },
    "team": {
      "choice": "請求",
      "confidence": 0.7025085586777275,
      "probabilities": {
        "その他": 0.00042968088067333216,
        "契約": 0.22258025977083654,
        "技術": 0.0001086403401944728,
        "請求": 0.7768814190082957
      },
      "type": "choice"
    },
    "urgency": {
      "confidence": 0.9899083973989802,
      "legend": {
        "0": "低",
        "1": "中",
        "2": "高"
      },
      "probabilities": {
        "0": 0.9932722649326535,
        "1": 0.006692615428002947,
        "2": 0.00003511963934343569
      },
      "score": 0.006762854706689818,
      "type": "score"
    }
  },
  "usage": {
    "input_tokens": 169,
    "output_tokens": 3
  }
}

teamは、選ばれた担当(choice)と、担当ごとの確率(probabilities)です。請求が78%、契約が22%でした。

confidenceも返りますが、公式ドキュメントに定義を見つけられなかったので、この記事では使いません。

urgencyのlegendは、段階の番号と名前の対応(0=低・1=中・2=高)です。probabilitiesは段階ごとの確率で、低が99%でした。

scoreは、番号を確率で重み付けした平均で、0.01です。ほぼ「低」と読めます。

complaintのnoulは0.008で、不満はほとんどありません。

入力は169トークン、出力は3トークンでした。文章を作らないので、出力はごくわずかです。

アプリでは、振り分けに使う4つの値をJudgmentというrecordにまとめます。担当とその確率、緊急度、不満です。

using System.Text.Json.Nodes;

namespace InquiryRouter;

// Decision-1 の答えから、振り分けに使う値を取り出したもの
sealed record Judgment(string Team, double TeamProbability, double Urgency, double Complaint)
{
    public static Judgment From(JsonObject response)
    {
        var answers = response["answers"]!;

        // choice:選ばれた選択肢と、選択肢ごとの確率
        var team = answers["team"]!["choice"]!.GetValue<string>();
        var teamProbability = answers["team"]!["probabilities"]![team]!.GetValue<double>();

        // score:段階の番号(低=0・中=1・高=2)を確率で重み付けした平均
        var urgency = answers["urgency"]!["score"]!.GetValue<double>();

        // noul:問いに「はい」と答える確率
        var complaint = answers["complaint"]!["noul"]!.GetValue<double>();

        return new(team, teamProbability, urgency, complaint);
    }

    public override string ToString() =>
        $"担当={Team}({TeamProbability:P0}) 緊急度={Urgency:F2} 不満={Complaint:P0}";
}

Fromは、レスポンスのJSONから4つの値を取り出し、Judgmentにして返します。

試す ── 問い合わせ7件を振り分ける

次は、7件の問い合わせをそれぞれ3回判定しました(2026年10月17日、東日本リージョン)。

結果は次の表のとおりです。値は3回の範囲で、振り分け先は3回とも同じでした。

問い合わせ担当(確率)緊急度不満振り分け先
#1 先月分の請求書を、PDFで再発行していただけますか。請求(100%)0.000%請求の受付箱
#2 今朝から社員全員がログインできず、打刻ができません。技術(100%)2.0025〜29%技術の受付箱
#3 来月から10名増えるので、プランを上げたいです。契約(100%)0.02〜0.040%契約の受付箱
#4 解約を考えています。今月分の料金は日割りになりますか?請求(62〜78%)0.011%人が振り分ける
#5 CSVで出した金額が、請求書の金額と合いません。請求(100%)0.87〜0.9022〜27%請求の受付箱
#6 何度問い合わせても返事がありません。どうなっているんですか。その他(100%)0.96〜0.97100%人が振り分ける+上長に知らせる
#7 貴社サイトの集客改善について、ご提案させてください。その他(100%)0.000%人が振り分ける

振り分け先は、この章の後半で説明するルールで決めたものです。担当がはっきりしていれば担当の受付箱へ、迷えば人へ、不満が強ければ上長にも、という形です。

はっきりした問い合わせは、迷わず振り分ける

#1〜#3は、担当が100%で決まりました。#2は社員全員がログインできないので、緊急度が2.00(高)です。

問い合わせ担当(確率)緊急度不満振り分け先
#1 先月分の請求書を、PDFで再発行していただけますか。請求(100%)0.000%請求の受付箱
#2 今朝から社員全員がログインできず、打刻ができません。技術(100%)2.0025〜29%技術の受付箱
#3 来月から10名増えるので、プランを上げたいです。契約(100%)0.02〜0.040%契約の受付箱

#5は、データの出力(技術)か金額(請求)か迷いそうですが、請求100%でした。緊急度は「ほぼ中」です。

問い合わせ担当(確率)緊急度不満振り分け先
#5 CSVで出した金額が、請求書の金額と合いません。請求(100%)0.87〜0.9022〜27%請求の受付箱

迷いは確率に表れる

#4は、解約(契約)と料金(請求)の両方にまたがります。担当は請求で、確率は62〜78%、残りはほぼ契約でした。

問い合わせ担当(確率)緊急度不満振り分け先
#4 解約を考えています。今月分の料金は日割りになりますか?請求(62〜78%)0.011%人が振り分ける

同じ本文でも、確率は回によって少し動きます。1回の値は、目安として読みます。

決められないものは「その他」へ

#6は、何についての問い合わせか書かれていません。担当は「その他」で、不満は100%でした。

問い合わせ担当(確率)緊急度不満振り分け先
#6 何度問い合わせても返事がありません。どうなっているんですか。その他(100%)0.96〜0.97100%人が振り分ける+上長に知らせる

#7は、営業の売り込みです。「その他」の説明に売り込みを含めておいたので、そのとおりに落ちました。

問い合わせ担当(確率)緊急度不満振り分け先
#7 貴社サイトの集客改善について、ご提案させてください。その他(100%)0.000%人が振り分ける

確率で振り分ける

振り分けのルールは、図4のとおりです。

図4: 振り分けのルール
図4:振り分けのルール

担当が「その他」か、担当の確率が90%未満なら、人が振り分けます。それ以外は、担当の受付箱へ送ります。

これとは別に、不満が50%以上なら、上長にも知らせます。

このルールは、RouterのRouteメソッドに書いています。

namespace InquiryRouter;

// 判定から振り分け先を決める
static class Router
{
    // しきい値は7件の実測を見て置いた例の値。自社の問い合わせで決める
    const double TeamThreshold = 0.9;
    const double ComplaintThreshold = 0.5;

    public static string Route(Judgment judgment)
    {
        // 担当が「その他」か、担当の確率が低ければ人が振り分ける
        var destination = judgment.Team == "その他" || judgment.TeamProbability < TeamThreshold
            ? "人が振り分ける"
            : $"{judgment.Team}の受付箱";

        // 不満が強ければ、上長にも知らせる
        return judgment.Complaint >= ComplaintThreshold
            ? $"{destination}+上長に知らせる"
            : destination;
    }
}

しきい値は、7件の結果を見て置いた例の値です。実際には、自社の問い合わせで確かめて決めます。

公式ドキュメントも、最上位の確率が低いときは、人や別のモデルへ回す使い方を挙げています。

緊急度は、今回は画面に出すだけで、振り分けには使っていません。

プロ美

迷ったものだけ人が見ればいいから、全部を人が読むより、ずっと楽になるね。

気をつけること ── 問いどうしは独立

3つの問いは1回で送りましたが、答えは問いごとに別々に出ます。ある問いの答えを、別の問いが見ることはありません。

そのため、「担当が請求なら、返金の依頼かも判定する」のように、答えを受けて次の問いを変えることはできません。

そうしたいときは、多段にします。1回目で担当を判定し、請求だったら2回目で「返金の依頼か」を送ります。

答えを組み合わせるだけなら、アプリの側で行います。今回のRouteも、担当と不満をアプリで組み合わせています。

もう1つ、公式ドキュメントは、言い回しや選択肢の順番で値が変わりうること、人に関わる重い判断の唯一の根拠にしないことを挙げています。

まとめ

今回は、Decision-1で問い合わせを振り分けました。重要なポイントは以下です。

今回のポイント

  • Decision-1は、文章を作らず、決めた選択肢ごとの確率だけを返す判定専用のモデル。出力がわずかで、応答は今回1回0.2〜0.3秒
  • 問いは3つの型(choice・score・noul)で書く。choiceには「その他」の逃げ道を用意する
  • 確率を見て、迷ったもの(確率が低い・「その他」)だけ人が見る。どこで線を引くかは、自社の問い合わせで試して決める
  • 問いどうしは独立。答えを組み合わせるのはアプリの側で、答えで次の問いを変えるなら多段にする

Decision-1は振り分けのほかに、「文章にAIへの指示が紛れ込んでいないか」の判定にも使えます。

この使い方は、シリーズ「プロンプトインジェクションを門番で防ぐ」で扱う予定です。

プロ太

判定だけなら、文章を作らせなくていい。答えの形を先に決めて、確率で受け取る。それがDecision-1の使いどころです。

引き続き、C#でいろいろな技術を試していきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。