📑

LLM の動き〜Structured Output確認〜

に公開

この記事では、C# を使用して、LLM(大規模言語モデル)の Structured Output 機能を実装し、HTTP通信をリアルタイムで観察しながら、自然言語の問い合わせを固定スキーマの JSON に変換する仕組みを理解します。

想定読者

  • C# 開発者で LLM の Structured Output 実装に興味がある方
  • LLMの応答を JSON 形式で安定して受け取りたい方

本記事で実装すること

以下の Structured Output シナリオを観察できるプログラムを実装します:

  • Structured Output - リクエストに json_schema を指定して出力形式を制約
  • C# 型への変換 - 返ってきた JSON 文字列をクラスへデシリアライズ
  • アプリ側の検証 - ローカルモデルの揺らぎに備えて最低限の妥当性チェックを実施

処理フロー図


環境構成

システム要件

項目 仕様
OS Windows 11(記事執筆環境)
.NET net10.0
主要パッケージ System.Net.Http(標準)
LLM 実行環境 LM Studio(ローカルホスト)

Structured Output とは

Structured Output は、LLM に「自然文ではなく、このスキーマに一致する JSON を返してほしい」と機械可読な形式で指示する仕組みです。

単にプロンプトで「JSONで返してください」と書く方法でも動くことはありますが、それはあくまでモデルの解釈に依存します。Structured Output では、リクエストボディに response_format を設定して JSON の形を API レベルで明示します。対応しているモデルであれば、より安定して形式化された JSON を受け取れるようになります。

基本的な動作フロー

  1. クライアント側で JSON Schema を定義 - 必須キー、型、列挙値を決める
  2. response_format を付与して送信 - type: "json_schema" とスキーマ本体を送る
  3. LLM が JSON を生成 - 返答は message.content に JSON 文字列として入る
  4. アプリ側でデシリアライズ - System.Text.Json で C# クラスへ変換
  5. 必要なら追加検証 - 形式は合っていても意味が怪しい場合があるため、業務ルールを別で確認する

システムプロンプトでの指定との違い

  • システムプロンプトでの指定: JSON らしい出力を要求するだけでキーや型の厳密さは弱い
  • Structured Output: スキーマでキー、型、列挙値、必須項目を明示できる

今回の実装では、JSON Schema 本体はチャット履歴には含めず、response_format として別枠で送っています。 ただし、出力品質を安定させるため、summaryaffectedUsers のような一部の項目制約は システムプロンプト に自然言語でも補助的に書いています。
つまり役割分担として、構造の本体は response_format、補助的なニュアンス指定は システムプロンプト となっています。

重要なポイント

  • 形の保証と意味の保証は別: スキーマ通りの JSON でも、中身が正しいとは限らない
  • 再試行が有効: 形式が揺れる場合、アプリ側で検証して補正リクエストを出すと改善しやすい

実装コード解説

ソースコード全体

今回のサンプルは StructuredOutputApp というコンソールアプリとして作成しました。

1. 共有コンポーネント: HttpClientLoggingHandler.cs

HTTP通信の全トラフィック(リクエスト/レスポンス)を可視化するカスタムハンドラーです。今回は response_format がそのまま見えるのがポイントです。

HttpClientLoggingHandler.cs
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

public class HttpClientLoggingHandler : HttpClientHandler
{
	protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
	{
		Console.ForegroundColor = ConsoleColor.Cyan;
		Console.WriteLine(">>> HTTP リクエスト <<<");
		Console.ResetColor();

		Console.WriteLine($"メソッド: {request.Method}");
		Console.WriteLine($"URL: {request.RequestUri}");

		if (request.Headers.Any())
		{
			Console.WriteLine("ヘッダー:");
			foreach (var header in request.Headers)
			{
				Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
			}
		}

		if (request.Content is not null)
		{
			Console.WriteLine("リクエストボディ:");
			var contentText = await request.Content.ReadAsStringAsync(cancellationToken);
			Console.WriteLine(FormatJson(contentText));
			request.Content = new StringContent(contentText, Encoding.UTF8, "application/json");
		}

		Console.WriteLine();

		var response = await base.SendAsync(request, cancellationToken);

		Console.ForegroundColor = ConsoleColor.Green;
		Console.WriteLine("<<< HTTP レスポンス >>>");
		Console.ResetColor();

		Console.WriteLine($"ステータス: {response.StatusCode}");

		if (response.Headers.Any())
		{
			Console.WriteLine("ヘッダー:");
			foreach (var header in response.Headers)
			{
				Console.WriteLine($"  {header.Key}: {string.Join(", ", header.Value)}");
			}
		}

		if (response.Content is not null)
		{
			Console.WriteLine("レスポンスボディ:");
			var contentText = await response.Content.ReadAsStringAsync(cancellationToken);
			Console.WriteLine(FormatJson(contentText));
			response.Content = new StringContent(contentText, Encoding.UTF8, "application/json");
		}

		Console.WriteLine();
		return response;
	}

	private static string FormatJson(string json)
	{
		if (string.IsNullOrWhiteSpace(json))
		{
			return "(empty)";
		}

		try
		{
			using var document = JsonDocument.Parse(json);
			return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions
			{
				WriteIndented = true,
				Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
			});
		}
		catch
		{
			return json;
		}
	}
}

コードのポイント:

  • SendAsync をオーバーライドして HTTP 通信を丸ごと観察
  • JSON を整形して表示するため、response_format の中身まで見やすい
  • ボディを読んだ後に StringContent で詰め直しているため、その後の処理でも再利用できる

2. Structured Output デモ: Program.cs

問い合わせ文を JSON Schema に沿って構造化し、C# クラスへ変換する本体です。

Program.cs
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

sealed class InquiryTriageResult
{
	[JsonPropertyName("summary")]
	public string Summary { get; set; } = string.Empty;

	[JsonPropertyName("category")]
	public string Category { get; set; } = string.Empty;

	[JsonPropertyName("priority")]
	public string Priority { get; set; } = string.Empty;

	[JsonPropertyName("affectedUsers")]
	public int AffectedUsers { get; set; }

	[JsonPropertyName("needsSameDayResponse")]
	public bool NeedsSameDayResponse { get; set; }

	[JsonPropertyName("escalationTarget")]
	public string EscalationTarget { get; set; } = string.Empty;
}

sealed class StructuredOutputResponse
{
	public InquiryTriageResult Result { get; init; } = new();
	public string RawJson { get; init; } = string.Empty;
	public string FinishReason { get; init; } = string.Empty;
}

class Program
{
	private static readonly string[] AllowedCategories = ["account", "billing", "bug", "feature_request", "other"];
	private static readonly string[] AllowedPriorities = ["low", "medium", "high", "critical"];

	private static readonly JsonSerializerOptions DeserializeOptions = new()
	{
		PropertyNameCaseInsensitive = true
	};

	static async Task Main(string[] args)
	{
		Console.WriteLine("=== Structured Output デモ ===\n");

		var loggingHandler = new HttpClientLoggingHandler();
		var endpoint = "http://localhost:1234";
		var modelName = "openai/gpt-oss-20b";

		Console.WriteLine($"エンドポイント: {endpoint}");
		Console.WriteLine($"モデル: {modelName}\n");

		var userMessage = "社内ポータルでパスワード再設定メールが届きません。午前中から3人が同じ症状で、今日中に経費精算を締めたいです。";
		Console.WriteLine("--- ユーザー入力 ---\n");
		Console.WriteLine($"{userMessage}\n");

		try
		{
			using var httpClient = new HttpClient(loggingHandler)
			{
				BaseAddress = new Uri(endpoint)
			};

			Console.WriteLine("--- Structured Output リクエスト送信 ---\n");
			var response = await SendStructuredOutputRequest(httpClient, modelName, userMessage);

			Console.WriteLine("\n--- Structured Output 解析結果 ---\n");
			Console.WriteLine($"finish_reason: {response.FinishReason}");
			Console.WriteLine($"summary: {response.Result.Summary}");
			Console.WriteLine($"category: {response.Result.Category}");
			Console.WriteLine($"priority: {response.Result.Priority}");
			Console.WriteLine($"affectedUsers: {response.Result.AffectedUsers}");
			Console.WriteLine($"needsSameDayResponse: {response.Result.NeedsSameDayResponse}");
			Console.WriteLine($"escalationTarget: {response.Result.EscalationTarget}");

			Console.WriteLine("\n--- 生JSON ---\n");
			Console.WriteLine(response.RawJson);
		}
		catch (Exception ex)
		{
			Console.WriteLine($"エラーが発生しました: {ex.Message}");
			if (ex.InnerException is not null)
			{
				Console.WriteLine($"詳細: {ex.InnerException.Message}");
			}
		}

		Console.WriteLine("\n=== デモ完了 ===");
	}

	private static async Task<StructuredOutputResponse> SendStructuredOutputRequest(HttpClient httpClient, string modelName, string userMessage)
	{
		string? retryReason = null;

		for (var attempt = 1; attempt <= 2; attempt++)
		{
			var response = await SendStructuredOutputAttempt(httpClient, modelName, userMessage, retryReason);
			var validationError = ValidateResult(response.Result);
			if (validationError is null)
			{
				return response;
			}

			Console.WriteLine($"Structured Output の検証に失敗しました: {validationError}");
			retryReason = $"前回の出力は無効でした。{validationError}。メタ説明やスキーマ説明を含めず、問い合わせ本文だけを根拠に JSON を返してください。escalationTarget は helpdesk / identity_team / portal_ops / finance_system / other のいずれかにしてください。";
		}

		throw new InvalidOperationException("Structured Output を2回試しましたが、安定したJSONを取得できませんでした。");
	}

	private static async Task<StructuredOutputResponse> SendStructuredOutputAttempt(HttpClient httpClient, string modelName, string userMessage, string? retryReason)
	{
		var messages = retryReason is null
			? new object[]
			{
				new
				{
					role = "system",
					content = "あなたは問い合わせ本文から値を抽出するデータ抽出器です。説明文、Markdown、コードフェンス、メタ発言は禁止です。summary は問い合わせ内容だけを日本語で20文字以上40文字以下で要約してください。affectedUsers は本文から読める人数を整数で返し、読み取れない場合は 1 にしてください。"
				},
				new
				{
					role = "user",
					content = $"以下の問い合わせを分類してください。\n---\n{userMessage}\n---"
				}
			}
			: new object[]
			{
				new
				{
					role = "system",
					content = "あなたは問い合わせ本文から値を抽出するデータ抽出器です。説明文、Markdown、コードフェンス、メタ発言は禁止です。summary は問い合わせ内容だけを日本語で20文字以上40文字以下で要約してください。affectedUsers は本文から読める人数を整数で返し、読み取れない場合は 1 にしてください。"
				},
				new
				{
					role = "system",
					content = retryReason
				},
				new
				{
					role = "user",
					content = $"以下の問い合わせを分類してください。\n---\n{userMessage}\n---"
				}
			};

		var requestBody = new
		{
			model = modelName,
			messages,
			response_format = BuildResponseFormat(),
			temperature = 0.1,
			max_tokens = 14000
		};

		var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions
		{
			Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
		});

		using var content = new StringContent(requestJson, Encoding.UTF8, "application/json");
		using var response = await httpClient.PostAsync("/v1/chat/completions", content);
		var responseText = await response.Content.ReadAsStringAsync();

		if (!response.IsSuccessStatusCode)
		{
			throw new InvalidOperationException($"HTTP {(int)response.StatusCode}: {responseText}");
		}

		using var document = JsonDocument.Parse(responseText);
		var choice = document.RootElement.GetProperty("choices")[0];
		var finishReason = choice.GetProperty("finish_reason").GetString() ?? "(unknown)";
		var message = choice.GetProperty("message");

		if (message.TryGetProperty("refusal", out var refusalElement) && refusalElement.ValueKind == JsonValueKind.String)
		{
			throw new InvalidOperationException($"モデルが Structured Output を拒否しました: {refusalElement.GetString()}");
		}

		var contentText = message.GetProperty("content").GetString();
		if (string.IsNullOrWhiteSpace(contentText))
		{
			throw new InvalidOperationException("message.content が空でした。モデルが JSON を返していません。");
		}

		var result = JsonSerializer.Deserialize<InquiryTriageResult>(contentText, DeserializeOptions);
		if (result is null)
		{
			throw new InvalidOperationException("Structured Output のデシリアライズに失敗しました。");
		}

		return new StructuredOutputResponse
		{
			Result = result,
			RawJson = contentText,
			FinishReason = finishReason
		};
	}

	private static string? ValidateResult(InquiryTriageResult result)
	{
		if (string.IsNullOrWhiteSpace(result.Summary))
		{
			return "summary が空です";
		}

		var loweredSummary = result.Summary.ToLowerInvariant();
		if (loweredSummary.Contains("json") || loweredSummary.Contains("schema") || loweredSummary.Contains("respond"))
		{
			return "summary にメタ説明が含まれています";
		}

		if (Array.IndexOf(AllowedCategories, result.Category) < 0)
		{
			return "category が許可された列挙値に含まれていません";
		}

		if (Array.IndexOf(AllowedPriorities, result.Priority) < 0)
		{
			return "priority が許可された列挙値に含まれていません";
		}

		if (result.AffectedUsers < 1)
		{
			return "affectedUsers が 1 未満です";
		}

		if (Array.IndexOf(new[] { "helpdesk", "identity_team", "portal_ops", "finance_system", "other" }, result.EscalationTarget) < 0)
		{
			return "escalationTarget が許可された列挙値に含まれていません";
		}

		if (result.Summary.Length < 10)
		{
			return "summary が短すぎます";
		}

		return null;
	}

	private static object BuildResponseFormat()
	{
		return new
		{
			type = "json_schema",
			json_schema = new
			{
				name = "inquiry_triage",
				strict = true,
				schema = new
				{
					type = "object",
					properties = new
					{
						summary = new
						{
							type = "string",
							description = "問い合わせ内容を一文で要約した文章"
						},
						category = new
						{
							type = "string",
							@enum = new[] { "account", "billing", "bug", "feature_request", "other" },
							description = "問い合わせカテゴリ"
						},
						priority = new
						{
							type = "string",
							@enum = new[] { "low", "medium", "high", "critical" },
							description = "対応優先度"
						},
						affectedUsers = new
						{
							type = "integer",
							minimum = 1,
							maximum = 1000,
							description = "影響を受けている人数"
						},
						needsSameDayResponse = new
						{
							type = "boolean",
							description = "当日中の優先対応が必要か"
						},
						escalationTarget = new
						{
							type = "string",
							@enum = new[] { "helpdesk", "identity_team", "portal_ops", "finance_system", "other" },
							description = "主担当にすべきチーム"
						}
					},
					required = new[]
					{
						"summary",
						"category",
						"priority",
						"affectedUsers",
						"needsSameDayResponse",
						"escalationTarget"
					},
					additionalProperties = false
				}
			}
		};
	}
}

コードのポイント:

  • BuildResponseFormat() で OpenAI 互換の response_format を組み立てる
  • json_schema.strict = true により、余計なキーを抑制しやすくする
  • ValidateResult() を入れて、ローカルモデル特有のメタ発言や列挙値ズレを検出する
  • max_tokens = 14000 にしているので、将来的に配列やネストを増やしたスキーマでも余裕を持たせやすい

実行結果

実際にこのコードを実行すると、以下のような構造化結果が返ってきました。

finish_reason: stop
summary: 社内ポータルのパスワード再設定メール不着
category: account
priority: high
affectedUsers: 1
needsSameDayResponse: True
escalationTarget: identity_team

生の JSON は以下の通りです。

{
  "summary": "社内ポータルのパスワード再設定メール不着",
  "category": "account",
  "priority": "high",
  "affectedUsers": 1,
  "needsSameDayResponse": true,
  "escalationTarget": "identity_team"
}

この結果から分かること

  • 形式はかなり安定する: 文字列、整数、真偽値、列挙値に分解できた
  • 後段処理が楽になる: そのまま C# の型へマッピングできる
  • 意味の補正はまだ必要: 今回の問い合わせ本文には「3人が同じ症状」とあるのに、affectedUsers1 になった

この最後の点が重要です。Structured Output は JSON の形を整える技術 であり、値の正しさまで自動保証する技術ではありません。そのため、今回のようにアプリ側の検証や再試行を入れておくと扱いやすくなります。また、何が値として入って欲しいかのコンテキストも重要です。


LM Studioログから読み取れること

今回はクライアント側の HTTP ログだけでなく、LM Studio 側のサーバーログも確認できました。これにより、response_format がサーバーまでそのまま届き、モデル推論でも Structured Output として扱われていることが分かります。

ログの要点

Received request: POST to /v1/chat/completions
...
"response_format": {
  "type": "json_schema",
  "json_schema": {
	 "name": "inquiry_triage",
	 "strict": true,
	 ...
  }
}

[openai/gpt-oss-20b] Running chat completion on conversation with 2 messages.
slot update_slots: id 3 | task 237 | new prompt, n_ctx_slot = 14080, n_keep = 206, task.n_tokens = 206

prompt eval time = 58.90 ms / 206 tokens
eval time = 768.01 ms / 59 tokens
total time = 826.91 ms / 265 tokens

[openai/gpt-oss-20b] Model generated tool calls: []

"content": "{\"summary\":\"社内ポータルのパスワード再設定メール不着\",\"category\":\"account\",...}"

このログで確認できたこと

  1. response_format は途中で落ちていない

    サーバーログの受信リクエストにも response_formatjson_schema がそのまま残っていました。つまり、少なくとも LM Studio の OpenAI 互換エンドポイントまでは、クライアントが意図した Structured Output 指定が届いています。

  2. 今回のリクエストは 2 メッセージで処理されている

    Running chat completion on conversation with 2 messages. と出ているので、実際に処理されたのは system と user の 2 件です。今回は再試行が発生していないことも読み取れます。ここが重要で、チャット履歴として数えられているのは messages だけ です。ログ上では別途 response_format が見えているので、JSON Schema 本体はチャット履歴の中ではなく、リクエストの別フィールドとして送られていることが分かります。

  3. システムプロンプト とは別に指定した構造が、その通りの形で返ってきている

    受信リクエストには response_format.json_schema として summarycategorypriorityaffectedUsersneedsSameDayResponseescalationTarget が定義されていました。一方、生成結果の message.content にも同じキーを持つ JSON が返ってきています。このことから Structured Output で指定した スキーマに沿って返答が組み立てられた と確認できます。

  4. Tool Calling とは別物として処理されている

    Model generated tool calls: [] となっているため、Structured Output を使っても Tool Calling が暗黙に発動するわけではありません。今回の応答は、あくまで通常の chat completion の message.content に JSON が入って返ってきています。

  5. Structured Output でも message.content は文字列

    サーバーログの Generated prediction を見ると、content はオブジェクトではなく JSON 文字列です。そのため、クライアント側では通常の OpenAI 互換レスポンスを受け取ったあと、さらに message.contentJsonSerializer.Deserialize<T>() する必要があります。

  6. 今回の実行コストもざっくり把握できる

    ログでは prompt_tokens = 206completion_tokens = 59total_tokens = 265 でした。Structured Output を使うとスキーマ定義のぶんだけプロンプトトークンは増えますが、その代わり後段のパースやバリデーションが単純になります。

  7. max_tokens = 14000 を送っていても、実際の生成は 59 トークンで止まっている

    今回の出力は短い JSON なので、上限を 14000 にしていても使い切ってはいません。これは「余裕を持たせた上限値」であり、毎回そのトークン数を消費するわけではない、という理解で大丈夫です。

ログと実行結果を合わせて分かること

今回の Structured Output は、サーバーまでスキーマ指定が届き、JSON 文字列として返却され、クライアント側で型へ変換できている という点では期待通りに動いています。

一方で、affectedUsers3 ではなく 1 になっているので、問題は「形式が崩れた」ことではなく「意味解釈がずれた」ことです。ここからも、Structured Output が強いのは 構文の安定化 であって、意味の正確性保証ではない と分かります。


HTTPパケットから読み取れること

今回のパケット観察で押さえたい点は以下の通りです。

  1. リクエストボディに response_format が追加される
  2. レスポンス全体は通常の chat.completion 形式のまま
  3. 実際の構造化データは choices[0].message.content の文字列として返る
  4. クライアント側で JSON をもう一段デシリアライズする必要がある

つまり、Structured Output だからといって HTTP の箱が変わるわけではありません。箱は従来通り、箱の中身だけを厳密化する のがポイントです。


まとめ

Structured Output を使うと、LLM の返答を自然文ではなく、アプリが扱いやすい型付きデータとして受け取れるようになります。今回のように response_formatjson_schema を明示すれば、categorypriority のような列挙値を安定して取り出しやすくなります。LM Studio のログでも、その指定がサーバーまで届き、通常の chat.completion として処理されたあとに message.content へ JSON 文字列が格納されて返ることを確認できました。

一方で、JSON にセットされた値がずれることがあります。したがって、実運用では Structured Output + アプリ側検証 をセットで考えるのが現実的です。今回のログでは affectedUsers の解釈ずれも確認できたので、今後は再試行条件の強化やコンテキストの見直しも検討していきたいところです。

LLM の動き 一覧

LLM の動き〜HTTPパケット確認〜
LLM の動き〜Tool Calling確認〜
LLM の動き〜マルチモーダルモデルの確認〜
LLM の動き〜Structured Output確認〜

ログ

実行結果
=== Structured Output デモ ===

エンドポイント: http://localhost:1234
モデル: openai/gpt-oss-20b

--- ユーザー入力 ---

社内ポータルでパスワード再設定メールが届きません。午前中から3人が同じ症状で、今日中に経費精算を締めたいです。

--- Structured Output リクエスト送信 ---

>>> HTTP リクエスト <<<
メソッド: POST
URL: http://localhost:1234/v1/chat/completions
リクエストボディ:
{
  "model": "openai/gpt-oss-20b",
  "messages": [
    {
      "role": "system",
      "content": "あなたは問い合わせ本文から値を抽出するデータ抽出器です。説明文、Markdown、コードフェンス、メタ発言は禁止です。summary は問い合わせ内容だけを日本語で20文字以上40文字以下で要約してください。affectedUsers は本文から読める人数を整数で返し、読み取れない場合は 1 にしてください。"
    },
    {
      "role": "user",
      "content": "以下の問い合わせを分類してください。\n---\n社内ポータルでパスワード再設定メールが届きません。午前中から3人が同じ症状で、今日中に経費精算を締めたいです。\n---"
    }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "inquiry_triage",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "string",
            "description": "問い合わせ内容を一文で要約した文章"
          },
          "category": {
            "type": "string",
            "enum": [
              "account",
              "billing",
              "bug",
              "feature_request",
              "other"
            ],
            "description": "問い合わせカテゴリ"
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "critical"
            ],
            "description": "対応優先度"
          },
          "affectedUsers": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "影響を受けている人数"
          },
          "needsSameDayResponse": {
            "type": "boolean",
            "description": "当日中の優先対応が必要か"
          },
          "escalationTarget": {
            "type": "string",
            "enum": [
              "helpdesk",
              "identity_team",
              "portal_ops",
              "finance_system",
              "other"
            ],
            "description": "主担当にすべきチーム"
          }
        },
        "required": [
          "summary",
          "category",
          "priority",
          "affectedUsers",
          "needsSameDayResponse",
          "escalationTarget"
        ],
        "additionalProperties": false
      }
    }
  },
  "temperature": 0.1,
  "max_tokens": 14000
}

<<< HTTP レスポンス >>>
ステータス: OK
ヘッダー:
  X-Powered-By: Express
  ETag: W/"32a-oBQ2Em24W3ZRlYIM4v/B8tcbVuE"
  Date: Sun, 10 May 2026 11:51:50 GMT
  Connection: keep-alive
  Keep-Alive: timeout=5
レスポンスボディ:
{
  "id": "chatcmpl-luptj6n8ureosrtqemo7o",
  "object": "chat.completion",
  "created": 1778413909,
  "model": "openai/gpt-oss-20b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "{\"summary\":\"社内ポータルのパスワード再設定メール不着\",\"category\":\"account\",\"priority\":\"high\",\"affectedUsers\":1,\"needsSameDayResponse\":true, \"escalationTarget\":\"identity_team\"}",
        "reasoning": "",
        "tool_calls": []
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 206,
    "completion_tokens": 59,
    "total_tokens": 265,
    "completion_tokens_details": {
      "reasoning_tokens": 0
    }
  },
  "stats": {},
  "system_fingerprint": "openai/gpt-oss-20b"
}


--- Structured Output 解析結果 ---

finish_reason: stop
summary: 社内ポータルのパスワード再設定メール不着
category: account
priority: high
affectedUsers: 1
needsSameDayResponse: True
escalationTarget: identity_team

--- 生JSON ---

{"summary":"社内ポータルのパスワード再設定メール不着","category":"account","priority":"high","affectedUsers":1,"needsSameDayResponse":true, "escalationTarget":"identity_team"}

=== デモ完了 ===
LM Studio
2026-05-10 20:51:49 [DEBUG]
 Received request: POST to /v1/chat/completions with body  {
  "model": "openai/gpt-oss-20b",
  "messages": [
    {
      "role": "system",
      "content": "あなたは問い合わせ本文から値を抽出するデータ抽出器です。説明文、Markdown、コードフェンス、メ... <Truncated in logs> ...ffectedUsers は本文から読める人数を整数で返し、読み取れない場合は 1 にしてください。"
    },
    {
      "role": "user",
      "content": "以下の問い合わせを分類してください。\n---\n社内ポータルでパスワード再設定メールが届きません。午前中から3人が同じ症状で、今日中に経費精算を締めたいです。\n---"
    }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "inquiry_triage",
      "strict": true,
      "schema": {
        "type": "object",
        "properties": {
          "summary": {
            "type": "string",
            "description": "問い合わせ内容を一文で要約した文章"
          },
          "category": {
            "type": "string",
            "enum": [
              "account",
              "billing",
              "bug",
              "feature_request",
              "other"
            ],
            "description": "問い合わせカテゴリ"
          },
          "priority": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high",
              "critical"
            ],
            "description": "対応優先度"
          },
          "affectedUsers": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "description": "影響を受けている人数"
          },
          "needsSameDayResponse": {
            "type": "boolean",
            "description": "当日中の優先対応が必要か"
          },
          "escalationTarget": {
            "type": "string",
            "enum": [
              "helpdesk",
              "identity_team",
              "portal_ops",
              "finance_system",
              "other"
            ],
            "description": "主担当にすべきチーム"
          }
        },
        "required": [
          "summary",
          "category",
          "priority",
          "affectedUsers",
          "needsSameDayResponse",
          "escalationTarget"
        ],
        "additionalProperties": false
      }
    }
  },
  "temperature": 0.1,
  "max_tokens": 14000
}
2026-05-10 20:51:49  [INFO]
 [openai/gpt-oss-20b] Running chat completion on conversation with 2 messages.
2026-05-10 20:51:49 [DEBUG]
 LlamaV4::predict slot selection: session_id=<empty> server-selected (LCP/LRU)
2026-05-10 20:51:49 [DEBUG]
 slot get_availabl: id  3 | task -1 | selected slot by LCP similarity, sim_best = 0.583 (> 0.100 thold), f_keep = 0.500
2026-05-10 20:51:49 [DEBUG]
 slot launch_slot_: id  3 | task -1 | sampler chain: logits -> penalties -> ?dry -> ?top-n-sigma -> top-k -> ?typical -> top-p -> min-p -> ?xtc -> temp-ext -> dist 
slot launch_slot_: id  3 | task 237 | processing task, is_child = 0
slot update_slots: id  3 | task 237 | new prompt, n_ctx_slot = 14080, n_keep = 206, task.n_tokens = 206
slot update_slots: id  3 | task 237 | n_past = 120, slot.prompt.tokens.size() = 240, seq_id = 3, pos_min = 0, n_swa = 128
slot update_slots: id  3 | task 237 | forcing full prompt re-processing due to lack of cache data (likely due to SWA or hybrid/recurrent memory, see https://github.com/ggml-org/llama.cpp/pull/13194#issuecomment-2868343055)
slot update_slots: id  3 | task 237 | n_tokens = 0, memory_seq_rm [0, end)
slot init_sampler: id  3 | task 237 | init sampler, took 0.03 ms, tokens: text = 206, total = 206
slot update_slots: id  3 | task 237 | prompt processing done, n_tokens = 206, batch.n_tokens = 206
2026-05-10 20:51:49  [INFO]
 [openai/gpt-oss-20b] Prompt processing progress: 0.0%
2026-05-10 20:51:49  [INFO]
 [openai/gpt-oss-20b] Prompt processing progress: 100.0%
2026-05-10 20:51:50 [DEBUG]
 slot print_timing: id  3 | task 237 | 
prompt eval time =      58.90 ms /   206 tokens (    0.29 ms per token,  3497.69 tokens per second)
       eval time =     768.01 ms /    59 tokens (   13.02 ms per token,    76.82 tokens per second)
      total time =     826.91 ms /   265 tokens
slot      release: id  3 | task 237 | stop processing: n_tokens = 264, truncated = 0
srv  update_slots: all slots are idle
2026-05-10 20:51:50 [DEBUG]
 LlamaV4: server assigned slot 3 to task 237
2026-05-10 20:51:50  [INFO]
 [openai/gpt-oss-20b] Model generated tool calls:  []
2026-05-10 20:51:50  [INFO]
 [openai/gpt-oss-20b] Generated prediction:  {
  "id": "chatcmpl-luptj6n8ureosrtqemo7o",
  "object": "chat.completion",
  "created": 1778413909,
  "model": "openai/gpt-oss-20b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "{\"summary\":\"社内ポータルのパスワード再設定メール不着\",\"category\":\"account\",\"priority\":\"high\",\"affectedUsers\":1,\"needsSameDayResponse\":true, \"escalationTarget\":\"identity_team\"}",
        "reasoning": "",
        "tool_calls": []
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 206,
    "completion_tokens": 59,
    "total_tokens": 265,
    "completion_tokens_details": {
      "reasoning_tokens": 0
    }
  },
  "stats": {},
  "system_fingerprint": "openai/gpt-oss-20b"
}

Discussion