T-CREATOR

gpt-oss アーキテクチャを分解図で理解する:推論ランタイム・トークナイザ・サービング層の役割

gpt-oss アーキテクチャを分解図で理解する:推論ランタイム・トークナイザ・サービング層の役割

オープンソース版の GPT モデル(gpt-oss)を使ってみたいけれど、内部のアーキテクチャがどうなっているのかわからず戸惑っていませんか。推論ランタイム、トークナイザ、サービング層といった専門用語が並ぶと、初心者の方にとってはハードルが高く感じられますよね。

この記事では、gpt-oss のアーキテクチャを 3 つの主要レイヤーに分解し、それぞれの役割を図解を交えて丁寧に解説します。推論エンジンがどのように動作し、テキストがどうトークン化され、API としてどう提供されるのか――これらの仕組みを理解すれば、自分でモデルをカスタマイズしたり、独自の AI アプリケーションを構築したりする際の強力な武器になるでしょう。

背景

gpt-oss とは何か

gpt-oss は、OpenAI の GPT アーキテクチャをベースにしたオープンソース実装です。GPT(Generative Pre-trained Transformer)は、大規模な言語モデルとして自然言語処理タスクで広く利用されていますが、商用版の GPT-3 や GPT-4 はクローズドソースであり、内部構造やカスタマイズに制限があります。

一方、gpt-oss は以下のような特徴を持っています。

  • 透明性: コードが公開されており、内部実装を自由に確認できます
  • カスタマイズ性: 独自のデータセットで再学習したり、推論処理を最適化したりできます
  • コスト効率: 自前のインフラで動作させることで、API 利用料を削減できます
  • 学習機会: AI/ML の仕組みを深く理解するための教材として活用できます

アーキテクチャの重要性

AI モデルを実運用する際には、単にモデルの精度だけでなく、以下のような要素が重要になります。

  • 推論速度: ユーザーのリクエストに対してどれだけ高速にレスポンスを返せるか
  • スケーラビリティ: 同時アクセス数が増えた際にどう対応するか
  • 保守性: コードの可読性や拡張性はどうか

これらを実現するために、gpt-oss は階層化されたアーキテクチャを採用しています。各レイヤーが明確な責任を持つことで、システム全体の保守性と拡張性が向上しているのです。

3 層アーキテクチャの概要

gpt-oss のアーキテクチャは、大きく以下の 3 つのレイヤーに分けられます。

mermaidflowchart TB
  user["ユーザー<br/>(クライアントアプリ)"]
  serving["サービング層<br/>(API Gateway)"]
  tokenizer["トークナイザ層<br/>(テキスト処理)"]
  runtime["推論ランタイム層<br/>(モデル実行)"]
  model[("学習済みモデル<br/>(重みファイル)")]

  user -->|"HTTP Request<br/>(テキスト)"| serving
  serving -->|"前処理"| tokenizer
  tokenizer -->|"トークン列"| runtime
  runtime -->|"モデル読込"| model
  runtime -->|"予測結果<br/>(トークン)"| tokenizer
  tokenizer -->|"デコード<br/>(テキスト)"| serving
  serving -->|"HTTP Response<br/>(生成文)"| user

この図は、ユーザーからのリクエストが各レイヤーを通過して、最終的に生成結果として返されるまでの流れを示しています。

それぞれのレイヤーが担う役割は次のとおりです。

#レイヤー名主な役割
1サービング層HTTP API としてリクエストを受け付け、レスポンスを返す
2トークナイザ層テキストをトークン ID に変換(エンコード)し、逆変換(デコード)も行う
3推論ランタイム層モデルを読み込み、トークン列から次のトークンを予測する

これらのレイヤーが連携することで、エンドユーザーは簡単な HTTP リクエストを送るだけで、高度な言語生成機能を利用できるようになります。

課題

モノリシックな実装の問題点

初期の AI モデル実装では、すべての処理を 1 つのスクリプトやクラスにまとめてしまうケースが多く見られました。このモノリシックなアプローチには、以下のような問題があります。

  • 再利用性の低さ: トークナイザだけを別のプロジェクトで使いたい場合でも、全体を持ち込む必要がある
  • テストの困難さ: 各機能が密結合しているため、単体テストが書きにくい
  • パフォーマンスのボトルネック: どの部分が遅いのかを特定しづらく、最適化が難しい
  • チーム開発の障害: 複数人で並行開発する際に、コードの衝突が頻発する

以下の図は、モノリシックな構造と階層化された構造の違いを示しています。

mermaidflowchart LR
  subgraph mono["モノリシック実装"]
    all["すべての処理を<br/>1つのモジュールで実行"]
  end

  subgraph layered["階層化された実装"]
    direction TB
    layer1["サービング層"]
    layer2["トークナイザ層"]
    layer3["推論ランタイム層"]
    layer1 --> layer2
    layer2 --> layer3
  end

  mono -.->|"リファクタリング"| layered

モノリシックな実装では、1 つのモジュールが肥大化し、保守性が著しく低下します。一方、階層化することで、各レイヤーが独立して進化できるようになるのです。

スケーラビリティの課題

AI モデルは計算リソースを大量に消費するため、リクエスト数が増えるとすぐにサーバーがパンクしてしまいます。特に以下のような状況では、適切なアーキテクチャ設計が不可欠です。

  • バースト的なアクセス: 特定時間帯にアクセスが集中する
  • 長時間の推論: 大規模なモデルでは 1 回の推論に数秒かかることもある
  • 複数モデルの運用: 複数の用途に応じて異なるモデルを同時稼働させる

これらに対応するには、各レイヤーを独立してスケールできるアーキテクチャが必要になります。

デバッグと監視の困難さ

AI システムでは、エラーが発生した際にどこで問題が起きているのかを特定するのが難しいという課題があります。

  • 入力テキストの問題: 特殊文字や長すぎる入力でエラーが発生する
  • トークナイザのバグ: 未知の単語やエッジケースで誤った変換が起こる
  • モデルの異常出力: 推論結果が意図しない形式で返される

レイヤーが分離されていない場合、これらのどこで問題が起きているのかを切り分けるのに多大な時間がかかってしまいます。

解決策

推論ランタイム層の役割

推論ランタイム層は、gpt-oss の心臓部とも言える部分です。学習済みモデルの重みファイルを読み込み、入力されたトークン列から次のトークンを予測する役割を担います。

この層の主な責務は以下のとおりです。

  • モデルのロード: 学習済みの重みファイル(通常は .bin.pt.safetensors 形式)をメモリに展開する
  • 推論の実行: トークン列を入力として受け取り、次のトークンの確率分布を計算する
  • キャッシュ管理: KV キャッシュなどを活用して、連続した推論を高速化する
  • ハードウェア最適化: GPU や TPU などのアクセラレータを活用する

以下の図は、推論ランタイム層の内部構造を示しています。

mermaidflowchart TB
  input["入力トークン列<br/>[101, 2054, 2003]"]
  loader["モデルローダー"]
  weights[("重みファイル<br/>model.safetensors")]
  transformer["Transformer エンジン"]
  cache["KV キャッシュ"]
  output["出力確率分布<br/>next_token_logits"]

  input --> transformer
  loader -->|"読込"| weights
  weights -->|"パラメータ"| transformer
  transformer <-->|"再利用"| cache
  transformer --> output

この図からわかるように、推論ランタイムはモデルの重みを一度読み込んだ後、キャッシュを活用しながら高速に推論を繰り返します。

推論ランタイムの実装例(モデルロード部分)

推論ランタイムでは、まず学習済みモデルをメモリに読み込む必要があります。以下は PyTorch を使った基本的なモデルロード処理です。

typescript// model_loader.ts
import * as fs from 'fs';
import * as path from 'path';

/**
 * モデルの重みファイルを読み込む
 * @param modelPath - モデルファイルのパス
 * @returns モデルオブジェクト
 */
export class ModelLoader {
  private modelPath: string;
  private model: any = null;

  constructor(modelPath: string) {
    this.modelPath = modelPath;
  }

  /**
   * モデルを初期化してメモリに展開
   */
  async load(): Promise<void> {
    // モデルファイルの存在確認
    if (!fs.existsSync(this.modelPath)) {
      throw new Error(
        `Model file not found: ${this.modelPath}`
      );
    }

    // モデルの読み込み(実際には PyTorch や ONNX Runtime を使用)
    console.log(`Loading model from ${this.modelPath}...`);

    // ここでは疑似的な処理
    this.model = {
      weights: await this.loadWeights(),
      config: await this.loadConfig(),
    };

    console.log('Model loaded successfully');
  }

  /**
   * 重みデータを読み込む
   */
  private async loadWeights(): Promise<ArrayBuffer> {
    const buffer = fs.readFileSync(this.modelPath);
    return buffer.buffer;
  }

  /**
   * モデル設定を読み込む
   */
  private async loadConfig(): Promise<object> {
    const configPath = this.modelPath.replace(
      '.bin',
      '_config.json'
    );
    const config = JSON.parse(
      fs.readFileSync(configPath, 'utf-8')
    );
    return config;
  }

  getModel(): any {
    if (!this.model) {
      throw new Error(
        'Model not loaded. Call load() first.'
      );
    }
    return this.model;
  }
}

このコードは、モデルファイルのパスを受け取り、重みと設定を読み込んでメモリに展開します。エラーハンドリングも含めることで、ファイルが見つからない場合に適切なエラーメッセージを返せるようにしています。

推論ランタイムの実装例(推論実行部分)

次に、読み込んだモデルを使って実際に推論を実行する部分を見てみましょう。

typescript// inference_runtime.ts
import { ModelLoader } from './model_loader';

/**
 * 推論を実行するランタイムクラス
 */
export class InferenceRuntime {
  private loader: ModelLoader;
  private kvCache: Map<string, any>;

  constructor(modelPath: string) {
    this.loader = new ModelLoader(modelPath);
    this.kvCache = new Map();
  }

  /**
   * ランタイムを初期化
   */
  async initialize(): Promise<void> {
    await this.loader.load();
    console.log('Inference runtime initialized');
  }

  /**
   * トークン列から次のトークンを予測
   * @param inputTokens - 入力トークン ID の配列
   * @returns 次のトークンの確率分布
   */
  async predict(inputTokens: number[]): Promise<number[]> {
    const model = this.loader.getModel();

    // KV キャッシュのキーを生成
    const cacheKey = inputTokens.join(',');

    // キャッシュがあれば再利用
    if (this.kvCache.has(cacheKey)) {
      console.log('Using cached KV values');
      return this.kvCache.get(cacheKey);
    }

    // 推論実行(実際には PyTorch などのバックエンドを使用)
    const logits = this.runInference(model, inputTokens);

    // 結果をキャッシュに保存
    this.kvCache.set(cacheKey, logits);

    return logits;
  }

  /**
   * 実際の推論処理を実行
   */
  private runInference(
    model: any,
    tokens: number[]
  ): number[] {
    // ここでは疑似的な処理
    // 実際には Transformer の forward pass を実行
    console.log(`Running inference for tokens: ${tokens}`);

    // ダミーの確率分布を返す
    return new Array(50257)
      .fill(0)
      .map(() => Math.random());
  }

  /**
   * キャッシュをクリア
   */
  clearCache(): void {
    this.kvCache.clear();
    console.log('KV cache cleared');
  }
}

このコードでは、入力トークン列を受け取り、KV キャッシュを活用しながら推論を実行しています。キャッシュがあれば再利用することで、連続した生成処理を高速化できます。

トークナイザ層の役割

トークナイザ層は、人間が読めるテキストと機械が処理できるトークン ID を相互変換する役割を担います。GPT モデルでは、Byte Pair Encoding(BPE)という手法が広く使われています。

この層の主な責務は以下のとおりです。

  • エンコード: テキストをトークン ID の配列に変換する
  • デコード: トークン ID の配列をテキストに戻す
  • 語彙管理: トークンと ID のマッピングテーブルを管理する
  • 特殊トークン処理: <|endoftext|> などの制御トークンを扱う

以下の図は、トークナイザの処理フローを示しています。

mermaidflowchart LR
  text["入力テキスト<br/>What is AI?"]
  encode["エンコーダー"]
  vocab[("語彙ファイル<br/>vocab.json")]
  tokens["トークン列<br/>[2264, 318, 9552, 30]"]
  decode["デコーダー"]
  output["出力テキスト<br/>What is AI?"]

  text --> encode
  encode -->|"参照"| vocab
  encode --> tokens
  tokens --> decode
  decode -->|"参照"| vocab
  decode --> output

トークナイザは双方向の変換を行うため、エンコードとデコードの両方で同じ語彙ファイルを参照します。

トークナイザの実装例(エンコード処理)

トークナイザのエンコード処理は、テキストを受け取ってトークン ID の配列に変換します。

typescript// tokenizer.ts
import * as fs from 'fs';

/**
 * トークナイザクラス
 */
export class Tokenizer {
  private vocab: Map<string, number>;
  private reverseVocab: Map<number, string>;
  private specialTokens: Map<string, number>;

  constructor(vocabPath: string) {
    this.vocab = new Map();
    this.reverseVocab = new Map();
    this.specialTokens = new Map();
    this.loadVocab(vocabPath);
  }

  /**
   * 語彙ファイルを読み込む
   */
  private loadVocab(vocabPath: string): void {
    const vocabData = JSON.parse(
      fs.readFileSync(vocabPath, 'utf-8')
    );

    // 通常の語彙をマップに変換
    for (const [token, id] of Object.entries(vocabData)) {
      this.vocab.set(token, id as number);
      this.reverseVocab.set(id as number, token);
    }

    // 特殊トークンを登録
    this.specialTokens.set('<|endoftext|>', 50256);
    this.specialTokens.set('<|padding|>', 50257);

    console.log(
      `Loaded ${this.vocab.size} tokens from vocabulary`
    );
  }

  /**
   * テキストをトークン ID の配列に変換(エンコード)
   * @param text - 入力テキスト
   * @returns トークン ID の配列
   */
  encode(text: string): number[] {
    const tokens: number[] = [];

    // 簡易的な BPE 処理(実際にはより複雑なアルゴリズムを使用)
    const words = this.preTokenize(text);

    for (const word of words) {
      if (this.vocab.has(word)) {
        tokens.push(this.vocab.get(word)!);
      } else {
        // 未知語の場合は UNK トークンを使用
        tokens.push(this.vocab.get('<|unk|>') || 0);
      }
    }

    return tokens;
  }

  /**
   * テキストを単語に分割する前処理
   */
  private preTokenize(text: string): string[] {
    // スペースと句読点で分割
    return text
      .toLowerCase()
      .replace(/([.,!?;:])/g, ' $1 ')
      .split(/\s+/)
      .filter((word) => word.length > 0);
  }
}

このコードは、語彙ファイルを読み込み、テキストを前処理してからトークン ID に変換します。未知語に対しても適切に処理できるようにしています。

トークナイザの実装例(デコード処理)

次に、トークン ID の配列をテキストに戻すデコード処理を見てみましょう。

typescript// tokenizer.ts(続き)

export class Tokenizer {
  // ... 前述のコードの続き

  /**
   * トークン ID の配列をテキストに変換(デコード)
   * @param tokenIds - トークン ID の配列
   * @returns デコードされたテキスト
   */
  decode(tokenIds: number[]): string {
    const tokens: string[] = [];

    for (const id of tokenIds) {
      // 特殊トークンをスキップ
      if (this.isSpecialToken(id)) {
        continue;
      }

      // ID からトークン文字列を取得
      if (this.reverseVocab.has(id)) {
        tokens.push(this.reverseVocab.get(id)!);
      } else {
        // 不明な ID の場合は UNK を使用
        tokens.push('<|unk|>');
      }
    }

    // トークンを結合してテキストに戻す
    return this.postProcess(tokens);
  }

  /**
   * 特殊トークンかどうかを判定
   */
  private isSpecialToken(id: number): boolean {
    return Array.from(this.specialTokens.values()).includes(
      id
    );
  }

  /**
   * トークン列を結合してテキストに戻す後処理
   */
  private postProcess(tokens: string[]): string {
    let text = tokens.join(' ');

    // 句読点の前のスペースを削除
    text = text.replace(/\s+([.,!?;:])/g, '$1');

    return text;
  }

  /**
   * 特殊トークンを追加
   */
  addSpecialToken(token: string, id: number): void {
    this.specialTokens.set(token, id);
    this.vocab.set(token, id);
    this.reverseVocab.set(id, token);
  }
}

デコード処理では、特殊トークンを適切にスキップしながら、トークン ID をテキストに戻します。後処理で句読点周りのスペースを調整することで、自然な文章に仕上げています。

サービング層の役割

サービング層は、外部からのリクエストを受け付け、トークナイザと推論ランタイムを組み合わせて、最終的なレスポンスを返す役割を担います。

この層の主な責務は以下のとおりです。

  • API エンドポイントの提供: REST API や WebSocket で外部からアクセス可能にする
  • リクエスト検証: 入力パラメータの妥当性をチェックする
  • レート制限: 過度なリクエストを制限する
  • ロギングと監視: アクセスログや推論時間を記録する
  • エラーハンドリング: 適切なエラーメッセージを返す

以下の図は、サービング層がリクエストを処理するフローを示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant API as API サーバー<br/>(サービング層)
  participant Tokenizer as トークナイザ
  participant Runtime as 推論ランタイム

  Client->>API: POST /v1/completions<br/>{prompt: "Hello"}
  API->>API: リクエスト検証
  API->>Tokenizer: encode("Hello")
  Tokenizer-->>API: [15496]
  API->>Runtime: predict([15496])
  Runtime-->>API: [318, 345, 1917]
  API->>Tokenizer: decode([318, 345, 1917])
  Tokenizer-->>API: " is a greeting"
  API-->>Client: {text: "Hello is a greeting"}

この図は、クライアントからのリクエストが各レイヤーを通過して、最終的にレスポンスとして返されるまでの一連の流れを示しています。

サービング層の実装例(API サーバー構築)

サービング層では、HTTP サーバーを立ち上げて API エンドポイントを提供します。ここでは Express.js を使った実装例を示します。

typescript// server.ts
import express, { Request, Response } from 'express';
import { Tokenizer } from './tokenizer';
import { InferenceRuntime } from './inference_runtime';

/**
 * API サーバーのセットアップ
 */
export class ApiServer {
  private app: express.Application;
  private tokenizer: Tokenizer;
  private runtime: InferenceRuntime;
  private port: number;

  constructor(
    vocabPath: string,
    modelPath: string,
    port: number = 3000
  ) {
    this.app = express();
    this.tokenizer = new Tokenizer(vocabPath);
    this.runtime = new InferenceRuntime(modelPath);
    this.port = port;

    this.setupMiddleware();
    this.setupRoutes();
  }

  /**
   * ミドルウェアの設定
   */
  private setupMiddleware(): void {
    // JSON パーサーを有効化
    this.app.use(express.json());

    // アクセスログのミドルウェア
    this.app.use((req, res, next) => {
      console.log(
        `${new Date().toISOString()} ${req.method} ${
          req.path
        }`
      );
      next();
    });
  }

  /**
   * ルーティングの設定
   */
  private setupRoutes(): void {
    // ヘルスチェック用エンドポイント
    this.app.get(
      '/health',
      (req: Request, res: Response) => {
        res.json({
          status: 'ok',
          timestamp: new Date().toISOString(),
        });
      }
    );

    // メインの補完エンドポイント
    this.app.post(
      '/v1/completions',
      async (req: Request, res: Response) => {
        await this.handleCompletion(req, res);
      }
    );
  }

  /**
   * サーバーを起動
   */
  async start(): Promise<void> {
    await this.runtime.initialize();

    this.app.listen(this.port, () => {
      console.log(
        `API server listening on port ${this.port}`
      );
    });
  }
}

このコードは、Express.js を使って API サーバーの基本構造を構築しています。ミドルウェアでアクセスログを記録し、ヘルスチェック用のエンドポイントも用意しています。

サービング層の実装例(リクエスト処理)

次に、実際のリクエストを処理する部分を実装します。

typescript// server.ts(続き)

export class ApiServer {
  // ... 前述のコードの続き

  /**
   * テキスト補完リクエストを処理
   */
  private async handleCompletion(
    req: Request,
    res: Response
  ): Promise<void> {
    try {
      // リクエストボディの検証
      const {
        prompt,
        max_tokens = 50,
        temperature = 1.0,
      } = req.body;

      if (!prompt || typeof prompt !== 'string') {
        res.status(400).json({
          error: {
            message:
              'Invalid request: prompt is required and must be a string',
            type: 'invalid_request_error',
            code: 'missing_prompt',
          },
        });
        return;
      }

      // 入力長の制限
      if (prompt.length > 2048) {
        res.status(400).json({
          error: {
            message:
              'Prompt too long: maximum 2048 characters allowed',
            type: 'invalid_request_error',
            code: 'prompt_too_long',
          },
        });
        return;
      }

      // テキストをトークンに変換
      const startTime = Date.now();
      const inputTokens = this.tokenizer.encode(prompt);

      // 推論を実行
      const outputTokens = await this.generateTokens(
        inputTokens,
        max_tokens,
        temperature
      );

      // トークンをテキストに戻す
      const generatedText =
        this.tokenizer.decode(outputTokens);
      const elapsedTime = Date.now() - startTime;

      // レスポンスを返す
      res.json({
        id: this.generateId(),
        object: 'text_completion',
        created: Math.floor(Date.now() / 1000),
        model: 'gpt-oss',
        choices: [
          {
            text: generatedText,
            index: 0,
            logprobs: null,
            finish_reason: 'length',
          },
        ],
        usage: {
          prompt_tokens: inputTokens.length,
          completion_tokens: outputTokens.length,
          total_tokens:
            inputTokens.length + outputTokens.length,
          elapsed_ms: elapsedTime,
        },
      });
    } catch (error) {
      this.handleError(error, res);
    }
  }

  /**
   * トークンを生成
   */
  private async generateTokens(
    inputTokens: number[],
    maxTokens: number,
    temperature: number
  ): Promise<number[]> {
    const outputTokens: number[] = [];
    let currentTokens = [...inputTokens];

    for (let i = 0; i < maxTokens; i++) {
      // 次のトークンを予測
      const logits = await this.runtime.predict(
        currentTokens
      );

      // サンプリング(temperature を考慮)
      const nextToken = this.sampleToken(
        logits,
        temperature
      );

      outputTokens.push(nextToken);
      currentTokens.push(nextToken);

      // 終了トークンが生成されたら終了
      if (nextToken === 50256) {
        break;
      }
    }

    return outputTokens;
  }

  /**
   * 確率分布からトークンをサンプリング
   */
  private sampleToken(
    logits: number[],
    temperature: number
  ): number {
    // 簡易的なサンプリング(実際には top-k や nucleus sampling を使用)
    const probs = this.softmax(logits, temperature);
    const rand = Math.random();
    let cumProb = 0;

    for (let i = 0; i < probs.length; i++) {
      cumProb += probs[i];
      if (rand < cumProb) {
        return i;
      }
    }

    return 0;
  }

  /**
   * Softmax 関数を適用
   */
  private softmax(
    logits: number[],
    temperature: number
  ): number[] {
    const scaledLogits = logits.map((x) => x / temperature);
    const maxLogit = Math.max(...scaledLogits);
    const expValues = scaledLogits.map((x) =>
      Math.exp(x - maxLogit)
    );
    const sumExp = expValues.reduce((a, b) => a + b, 0);
    return expValues.map((x) => x / sumExp);
  }

  /**
   * ユニークな ID を生成
   */
  private generateId(): string {
    return `cmpl-${Date.now()}-${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }

  /**
   * エラーハンドリング
   */
  private handleError(error: any, res: Response): void {
    console.error('Error during completion:', error);

    res.status(500).json({
      error: {
        message: 'Internal server error during completion',
        type: 'server_error',
        code: 'internal_error',
        details: error.message,
      },
    });
  }
}

このコードは、リクエストの検証から始まり、トークナイザと推論ランタイムを連携させながら、テキスト生成を行います。エラーハンドリングも含めることで、堅牢な API を実現しています。

具体例

実際のリクエスト・レスポンスの流れ

ここでは、実際にクライアントがリクエストを送信してから、レスポンスを受け取るまでの具体的な流れを追ってみましょう。

以下の図は、全体のデータフローを示しています。

mermaidflowchart TB
  client["クライアント"]
  api["API サーバー<br/>(port 3000)"]
  validator["リクエスト検証"]
  encoder["エンコーダー"]
  inference["推論エンジン"]
  decoder["デコーダー"]
  response["レスポンス生成"]

  client -->|"POST /v1/completions<br/>{prompt: 'The future of AI'}"| api
  api --> validator
  validator -->|"検証 OK"| encoder
  encoder -->|"[464, 2003, 286, 9552]"| inference
  inference -->|"[318, 4457, 1342, 345]"| decoder
  decoder -->|"' is rapidly evolving'"| response
  response -->|"HTTP 200 JSON"| client

この図は、リクエストが各コンポーネントを通過しながら処理される様子を示しています。

ステップ 1: クライアントからのリクエスト

まず、クライアントが以下のような HTTP リクエストを送信します。

javascript// client.js
const axios = require('axios');

async function generateText() {
  const response = await axios.post(
    'http://localhost:3000/v1/completions',
    {
      prompt: 'The future of AI',
      max_tokens: 20,
      temperature: 0.8,
    }
  );

  console.log(
    'Generated text:',
    response.data.choices[0].text
  );
  console.log('Token usage:', response.data.usage);
}

generateText().catch(console.error);

このクライアントコードは、プロンプトとパラメータを含む JSON リクエストを API サーバーに送信します。

ステップ 2: サービング層での検証とエンコード

API サーバーは、リクエストを受け取ると以下の処理を行います。

  1. リクエストボディの検証(prompt が存在するか、文字列か)
  2. 入力長のチェック(2048 文字以内か)
  3. トークナイザによるエンコード

エンコード処理により、「The future of AI」というテキストが [464, 2003, 286, 9552] といったトークン ID の配列に変換されます。

ステップ 3: 推論ランタイムでの予測

次に、トークン列が推論ランタイムに渡され、次のトークンが順次予測されます。この処理を max_tokens の回数だけ繰り返すことで、連続したテキストが生成されます。

例えば、[464, 2003, 286, 9552] から次のトークンとして 318(" is")が予測され、さらに 4457("rapidly")、1342("evolving")と続いていくのです。

ステップ 4: デコードとレスポンス生成

最後に、生成されたトークン列 [318, 4457, 1342, 345] をテキストに戻すと、「 is rapidly evolving」という文章が得られます。

クライアントは、以下のような JSON レスポンスを受け取ります。

json{
  "id": "cmpl-1234567890-abc123",
  "object": "text_completion",
  "created": 1678901234,
  "model": "gpt-oss",
  "choices": [
    {
      "text": " is rapidly evolving",
      "index": 0,
      "finish_reason": "length"
    }
  ],
  "usage": {
    "prompt_tokens": 4,
    "completion_tokens": 4,
    "total_tokens": 8,
    "elapsed_ms": 145
  }
}

エラーケースの処理

実運用では、正常系だけでなくエラーケースも適切に処理する必要があります。以下は、よくあるエラーとその対処例です。

#エラーケースエラーコードHTTP ステータス対処方法
1prompt が空または nullmissing_prompt400リクエスト検証で弾く
2prompt が長すぎるprompt_too_long400文字数制限をチェック
3モデルファイルが見つからないmodel_not_found500起動時にファイル存在確認
4メモリ不足out_of_memory500モデルサイズを調整
5トークナイザエラーtokenization_error500入力の正規化処理を追加

各エラーには明確なエラーコードとメッセージを返すことで、クライアント側でのデバッグを容易にします。

まとめ

この記事では、gpt-oss のアーキテクチャを推論ランタイム、トークナイザ、サービング層の 3 つのレイヤーに分解し、それぞれの役割と実装方法を詳しく解説しました。

各レイヤーの役割をもう一度整理すると、以下のようになります。

#レイヤー主な役割技術要素
1推論ランタイム層モデルの読み込みと推論実行PyTorch、ONNX、KV キャッシュ、GPU 最適化
2トークナイザ層テキストとトークン ID の相互変換BPE、語彙管理、特殊トークン処理
3サービング層API の提供とリクエスト処理Express.js、検証、エラーハンドリング、監視

このような階層化されたアーキテクチャを採用することで、以下のメリットが得られます。

保守性の向上: 各レイヤーが独立しているため、修正や機能追加が容易になります。トークナイザだけをアップグレードしたい場合でも、他のレイヤーに影響を与えずに変更できるのです。

テストのしやすさ: 各レイヤーを単独でテストできるため、バグの早期発見と修正が可能になります。単体テスト、統合テスト、E2E テストを段階的に実施できますね。

スケーラビリティ: 各レイヤーを独立してスケールできます。例えば、推論処理が重い場合は推論ランタイムだけを GPU インスタンスで動かし、API サーバーは軽量なインスタンスで運用するといった柔軟な構成が可能です。

再利用性: トークナイザや推論ランタイムを他のプロジェクトでも利用できます。共通のコンポーネントとして切り出すことで、開発効率が向上するでしょう。

また、実装する際には以下のポイントに注意してください。

  • エラーハンドリング: 各レイヤーで適切なエラーコードとメッセージを返すことで、問題の切り分けが容易になります
  • パフォーマンス監視: 各レイヤーの処理時間を計測し、ボトルネックを特定できるようにしましょう
  • ロギング: デバッグに必要な情報を適切にログ出力することで、運用時のトラブルシューティングがスムーズになります
  • キャッシュ戦略: KV キャッシュを活用することで、連続した推論を高速化できます
  • セキュリティ: 入力検証を徹底し、インジェクション攻撃などを防ぎましょう

gpt-oss のアーキテクチャを理解することで、独自の AI アプリケーションを構築する際の設計指針が得られたのではないでしょうか。この知識を活かして、ぜひ自分だけの言語モデルサービスを構築してみてください。

関連リンク