1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kong AI Gateway】Decision modelを使ってプロンプトに応じたLLMルーティングを実装する

1
Posted at

はじめに

Kong AI Gateway では、複数の LLM を共通の API Gateway から利用でき、認証、流量制限、ログ収集もまとめて管理できます。
一方で、どの LLM を使うかはクライアントが指定する必要があります。
たとえば、一般的な質問は汎用モデルへ、日本語の翻訳は日本語に強いモデルへ、といった使い分けは、リクエストの内容を見て Gateway 側で決められると便利です。

今回は、Decision model にプロンプトの振り分け先を判定させ、その結果に応じて Kong AI Gateway の AI Proxy へ転送するカスタムプラグインを作成しました。
クライアントは /chat へリクエストするだけで、モデルを指定せずに複数の LLM を使い分けられます。

Decision model とは

Decision model は、入力された文章と選択肢(ラベルと説明)を受け取り、最も適した選択肢とその確信度(confidence)を返すモデルです。
本記事では、state に最新のプロンプトを、criteria にラベルと説明の組を渡し、answers.model_selection.choice に返されるラベルを転送先の決定に使います。

たとえば、次のようなリクエストとレスポンスになります。

terminal
# リクエスト
curl --request POST \
  --url http://example.com/v1/systemone \
  --header 'Content-Type: application/json' \
  --data '{
  "model": "<decision-model>",
  "state": "この文章を英語に翻訳してください",
  "questions": {
    "model_selection": {
      "type": "choice",
      "instructions": "適切なモデルを選んでください。",
      "criteria": {
        "general": "一般的な質問",
        "translate": "翻訳"
      }
    }
  }
}'
{
  "model": "<decision-model>",
  "answers": {
    "model_selection": {
      "type": "choice",
      "choice": "translate",
      "probabilities": {
        "general": 0.05138461258756551,
        "translate": 0.9486153874124345
      },
      "confidence": 0.8972307748248689
    }
  },
  "usage": {
    "input_tokens": 45,
    "output_tokens": 0
  }
}

レスポンス項目の説明は次のとおりです。

項目 説明
answers 判定結果
model_selection 判定した質問の名前
type 判定形式。choice は選択式
choice 最終的に選択されたモデル
probabilities 各モデルが選ばれる確率
confidence 選択結果の確信度(今回は約89.7%)

今回の構成

OSS 版の Kong を DB-less モードで利用します。

クライアントから見える入口は /chat の一つだけで、LLM へのリクエストは Decision model の結果をもとにカスタムプラグインが転送します。

作成したもの

以下のファイルを作成しました。

  • compose.yaml
  • schema.lua: プラグイン設定の定義と検証
  • handler.lua: カスタムプラグイン実装
  • config/kong.yaml: Kong設定

コード全体

compose.yaml
compose.yaml
name: kong-jev-gateway-oss

volumes:
  kong_prefix_vol:
    driver_opts:
      type: tmpfs
      device: tmpfs
  kong_tmp_vol:
    driver_opts:
      type: tmpfs
      device: tmpfs

services:
  kong:
    image: kong:latest
    container_name: kong
    environment:
      KONG_DATABASE: 'off'
      KONG_ADMIN_ACCESS_LOG: /dev/stdout
      KONG_ADMIN_ERROR_LOG: /dev/stderr
      KONG_ADMIN_LISTEN: 0.0.0.0:8001
      KONG_PROXY_ACCESS_LOG: /dev/stdout
      KONG_PROXY_ERROR_LOG: /dev/stderr
      KONG_PREFIX: /var/run/kong
      KONG_DECLARATIVE_CONFIG: /opt/kong/kong.yaml
      KONG_PLUGINS: bundled,llm-router
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - '8000:8000/tcp'
      - '8001:8001/tcp'
      - '8002:8002/tcp'
      - '8443:8443/tcp'
    healthcheck:
      test: ['CMD', 'kong', 'health']
      interval: 10s
      timeout: 10s
      retries: 10
    restart: on-failure:5
    read_only: true
    volumes:
      - kong_prefix_vol:/var/run/kong
      - kong_tmp_vol:/tmp
      - ./config:/opt/kong
      - ./handler.lua:/usr/local/share/lua/5.1/kong/plugins/llm-router/handler.lua:ro
      - ./schema.lua:/usr/local/share/lua/5.1/kong/plugins/llm-router/schema.lua:ro
    security_opt:
      - no-new-privileges
schema.lua
schema.lua
local typedefs = require 'kong.db.schema.typedefs'
return {
  name = 'llm-router',
  fields = {
    { consumer = typedefs.no_consumer },
    { route = { type = 'foreign', reference = 'routes', required = true } },
    { protocols = typedefs.protocols_http },
    {
      config = {
        type = 'record',
        fields = {
          { decision_url = { type = 'string', required = true, match = '^https?://[^%s]+$' } },
          { decision_model = { type = 'string', required = true, len_min = 1 } },
          { decision_api_key = { type = 'string', encrypted = true, referenceable = true } },
          { decision_timeout = { type = 'integer', default = 5000, between = { 1, 60000 } } },
          { max_body_bytes = { type = 'integer', default = 1048576, between = { 1, 10485760 } } },
          { proxy_host = typedefs.host { default = '127.0.0.1' } },
          { proxy_port = typedefs.port { default = 8000 } },
          { proxy_scheme = { type = 'string', default = 'http', one_of = { 'http', 'https' } } },
          { proxy_host_header = typedefs.host { default = 'localhost' } },
          { fallback_label = { type = 'string', len_min = 1 } },
          {
            targets = {
              type = 'array',
              required = true,
              len_min = 1,
              elements = {
                type = 'record',
                fields = {
                  { label = { type = 'string', required = true, match = '^[%w_-]+$' } },
                  { description = { type = 'string', required = true, len_min = 1 } },
                  { path = { type = 'string', required = true, match = '^/[^%s?#]*$' } },
                  { model = { type = 'string', required = true, len_min = 1 } },
                }
              },
            }
          },
        },
        custom_validator = function(config)
          local labels = {}
          for _, target in ipairs(config.targets or {}) do
            if labels[target.label] then return nil, 'target labels must be unique' end
            labels[target.label] = true
          end
          if config.fallback_label and not labels[config.fallback_label] then
            return nil, 'fallback_label must match a target label'
          end
          return true
        end
      }
    },
  },
}
handler.lua
handler.lua
local http = require 'resty.http'
local json = require 'cjson.safe'
local plugin = {
  PRIORITY = 1000,
  VERSION = '0.1.0',
}

local HOP_HEADER = 'X-Llm-Router-Hop'
local DECISION_HEADER = 'X-Llm-Router-Decision'
local MODEL_HEADER = 'X-Llm-Router-Model'
local DECISION_MODEL_HEADER = 'X-Llm-Router-Decision-Model'
local FALLBACK_HEADER = 'X-Llm-Router-Fallback'

local function build_decision_request(conf, prompt)
  local criteria = {}
  for _, target in ipairs(conf.targets) do
    criteria[target.label] = target.description
  end

  return {
    model = conf.decision_model,
    state = prompt,
    questions = {
      model_selection = {
        type = 'choice',
        instructions = 'この依頼に最適なモデルを選択してください。',
        criteria = criteria,
      },
    },
  }
end

local function user_prompt(messages)
  for i = #messages, 1, -1 do
    local message = messages[i]
    if type(message) == 'table' and message.role == 'user' then
      if type(message.content) == 'string' and message.content:find('%S') then
        return message.content
      end
      return nil
    end
  end
end

local function parse_decision_response(response)
  if not response or response.status ~= 200 then
    return nil
  end

  local result = json.decode(response.body)
  if type(result) ~= 'table' then
    return nil
  end

  local answers = result.answers
  local model_selection = type(answers) == 'table' and answers.model_selection
  if type(model_selection) == 'table' and type(model_selection.choice) == 'string' then
    local score = model_selection.confidence
    if type(score) ~= 'number' and type(score) ~= 'string' then
      score = nil
    end
    return model_selection.choice, score
  end

  if type(result.choices) ~= 'table' then
    return nil
  end

  local choice = result.choices[1]
  if type(choice) ~= 'table' or type(choice.message) ~= 'table'
      or type(choice.message.content) ~= 'string' then
    return nil
  end

  local decision = json.decode(choice.message.content)
  if type(decision) == 'table' and type(decision.route) == 'string' then
    local score = decision.score
    if type(score) ~= 'number' and type(score) ~= 'string' then
      score = nil
    end
    return decision.route, score
  end
end

local function decide(conf, prompt)
  local headers = { ['Content-Type'] = 'application/json' }
  if conf.decision_api_key then
    headers.Authorization = 'Bearer ' .. conf.decision_api_key
  end

  local client = http.new()
  client:set_timeout(conf.decision_timeout)

  local response, request_error = client:request_uri(conf.decision_url, {
    method = 'POST',
    ssl_verify = true,
    headers = headers,
    body = json.encode(build_decision_request(conf, prompt)),
  })

  if not response then
    return nil, nil, request_error or 'unknown connection error'
  end

  return parse_decision_response(response)
end

local function read_request_body(conf)
  local body = kong.request.get_body('application/json', nil, conf.max_body_bytes)
  if type(body) ~= 'table' or type(body.messages) ~= 'table' or #body.messages == 0 then
    return nil, 400, 'A JSON body with nonempty messages is required'
  end

  local encoded = json.encode(body)
  if not encoded or #encoded > conf.max_body_bytes then
    return nil, 413, 'Request body too large'
  end

  return body
end

local function select_target(conf, label)
  local targets = {}
  for _, target in ipairs(conf.targets) do
    targets[target.label] = target
  end

  -- モデルの出力は設定済みラベルに限定し、任意の転送先を指定させない。
  local target = label and targets[label]
  local used_fallback = false
  if not target and conf.fallback_label then
    target = targets[conf.fallback_label]
    used_fallback = target ~= nil
  end

  return target, used_fallback
end

local function expose_decision(conf, target, used_fallback, score)
  kong.response.set_header(DECISION_HEADER, target.label)
  kong.response.set_header(MODEL_HEADER, target.model)
  kong.response.set_header(DECISION_MODEL_HEADER, conf.decision_model)
  kong.response.set_header(FALLBACK_HEADER, tostring(used_fallback))

  kong.log.notice('llm-router routing result: ', json.encode({
    decision = target.label,
    model = target.model,
    decision_model = conf.decision_model,
    fallback = used_fallback,
    score = score == nil and json.null or score,
  }))
end

local function forward_to_route(conf, target, body)
  -- 接続先の変更だけでは転送先 Route のプラグインは実行されないため、
  -- Kong のプロキシリスナーへ送り直して AI Proxy の Route にマッチさせる。
  kong.service.set_target(conf.proxy_host, conf.proxy_port)
  -- 再試行による判定・LLM 呼び出しの重複を避ける。
  kong.service.set_retries(0)
  kong.service.request.set_scheme(conf.proxy_scheme)
  kong.service.request.set_path(target.path)
  kong.service.request.set_header('Host', conf.proxy_host_header)
  kong.service.request.set_header(HOP_HEADER, '1')
  body.model = target.model
  kong.service.request.set_body(body, 'application/json')
end

function plugin:access(conf)
  -- 転送先が誤って入口 Route に再一致した場合も、再帰的な呼び出しを止める。
  if kong.request.get_header(HOP_HEADER) then
    return kong.response.exit(508, { message = 'Routing loop detected' })
  end

  if kong.request.get_method() ~= 'POST' then
    return kong.response.exit(405, { message = 'POST required' }, { Allow = 'POST' })
  end

  local body, status, error_message = read_request_body(conf)
  if not body then
    return kong.response.exit(status, { message = error_message })
  end

  local prompt = user_prompt(body.messages)
  if not prompt then
    return kong.response.exit(400, { message = 'A nonempty text user prompt is required' })
  end

  local label, score, decision_error = decide(conf, prompt)
  if decision_error then
    kong.log.err('llm-router decision service request failed: ', decision_error)
    return kong.response.exit(502, { message = 'Decision service unavailable' })
  end

  local target, used_fallback = select_target(conf, label)
  if not target then
    return kong.response.exit(502, { message = 'Decision model did not select a valid route' })
  end

  if target.path == kong.request.get_path() then
    return kong.response.exit(508, { message = 'Routing destination matches ingress path' })
  end

  expose_decision(conf, target, used_fallback, score)
  forward_to_route(conf, target, body)
end

return plugin
kong.yaml
kong.yaml

_format_version: "3.0"

services:
  - name: ollama
    url: http://localhost
    routes:
      - name: ollama
        paths: ["/ollama"]
        strip_path: false
        hosts: ["localhost"]
        plugins:
          - name: ai-proxy
            config:
              route_type: llm/v1/chat
              model:
                provider: openai
                name: example-model-a
                options:
                  upstream_url: http://a.example.com/v1/chat/completions

  - name: sakura
    url: http://localhost
    routes:
      - name: sakura
        paths: ["/sakura"]
        strip_path: false
        hosts: ["localhost"]
        plugins:
          - name: ai-proxy
            config:
              route_type: llm/v1/chat
              auth:
                header_name: apikey
                header_value: REPLACE_WITH_API_KEY
              model:
                provider: openai
                name: example-model-b
                options:
                  upstream_url: http://b.example.com/v1/chat/completions

  - name: llm-router
    url: http://localhost
    routes:
      - name: chat
        paths: ["/chat"]
        strip_path: true
        hosts: ["localhost"]
        plugins:
          - name: llm-router
            config:
              decision_url: http://jev.example.com/v1/systemone
              decision_model: example-decision-model
              targets:
                - label: ollama
                  description: 一般的な質問や会話
                  path: /ollama/chat/completions
                  model: example-model-a
                - label: sakura
                  description: 日本語の翻訳
                  path: /sakura/chat/completions
                  model: example-model-b
              fallback_label: ollama

作成したプラグインの実行例

サンプルの kong.yaml では、次のように LLM を振り分けます。

LLM(ラベル) 振り分ける内容 転送先 Route プロバイダー モデル
ollama 一般的な質問や会話 /ollama Ollama gemma4:31b-cloud
sakura 日本語の翻訳 /sakura さくらのAIエンジン gpt-oss-120b

/chat へ、OpenAI Chat Completions 形式でリクエストします。
リクエストの model は転送先のモデル名で上書きされるため、auto のような任意の値で構いません。
どちらの LLM へ振り分けたかは、レスポンスヘッダーの X-Llm-Router-Decision(ラベル)と X-Llm-Router-Model(モデル)で確認できます。

一般的な質問や会話

一般的な質問や挨拶は、ollama へ振り分けられます。

terminal
curl -v --request POST \
  --url http://localhost:8000/chat \
  --header 'Content-Type: application/json' \
  --data '{
  "model": "auto",
  "stream": false,
  "messages": [
    {
      "role": "user",
      "content": "こんにちは!"
    }
  ]
}'

実行結果の抜粋です。ollama が選ばれ、レスポンスヘッダーに判定結果が付いています。

< HTTP/1.1 200 OK
< Content-Type: application/json
< X-Llm-Router-Decision: ollama
< X-Llm-Router-Model: gemma4:31b-cloud
< X-Llm-Router-Decision-Model: LiquidAI/d1-3B-GGUF:Q4_K_M
< X-Llm-Router-Fallback: false
< X-Kong-LLM-Model: openai/gemma4:31b-cloud
{
  "id": "chatcmpl-437",
  "object": "chat.completion",
  "model": "gemma4:31b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは!何かお手伝いできることはありますか?"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 15,
    "completion_tokens": 12,
    "total_tokens": 27
  }
}

Kong のログには、判定結果と 2 回分のアクセスログが出力されます。

[notice] [llm-router] llm-router routing result: {"fallback":false,"score":0.933579614559132,"decision_model":"LiquidAI/d1-3B-GGUF:Q4_K_M","model":"gemma4:31b-cloud","decision":"ollama"}, request: "POST /chat HTTP/1.1", host: "localhost:8000", request_id: "287af83bf8678d174183c058ae28fb5a"
127.0.0.1 - - [10/Oct/2026:14:04:15 +0000] "POST /ollama/chat/completions HTTP/1.1" 200 401 "-" "curl/8.18.0" kong_request_id: "b5fccf056807534cd4735c3569f50632"
172.24.0.1 - - [10/Oct/2026:14:04:15 +0000] "POST /chat HTTP/1.1" 200 401 "-" "curl/8.18.0" kong_request_id: "287af83bf8678d174183c058ae28fb5a"

1 行目の score は、Decision model が返した confidence です。
アクセスログの 2 行目は、llm-router が 127.0.0.1 から Kong へ送り直した /ollama/chat/completions のリクエストで、kong_request_id が /chat のリクエストとは別の値になっています。

日本語の翻訳

日本語の翻訳を依頼するリクエストは、sakura へ振り分けられます。

terminal
curl -v --request POST \
  --url http://localhost:8000/chat \
  --header 'Content-Type: application/json' \
  --data '{
  "model": "auto",
  "stream": false,
  "messages": [
    {
      "role": "user",
      "content": "この文章を英語に翻訳してください"
    }
  ]
}'

実行結果の抜粋です。sakura が選ばれ、レスポンスヘッダーに判定結果が付いています。

< HTTP/1.1 200 OK
< Content-Type: application/json
< X-Llm-Router-Decision: sakura
< X-Llm-Router-Model: gpt-oss-120b
< X-Llm-Router-Decision-Model: LiquidAI/d1-3B-GGUF:Q4_K_M
< X-Llm-Router-Fallback: false
< x-kong-llm-model: openai/gpt-oss-120b
{
  "id": "chatcmpl-a7b5f3ff21ac5290",
  "object": "chat.completion",
  "model": "gpt-oss-120b",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "「この文章を英語に翻訳してください」の英訳は次の通りです。\n\n**Please translate this sentence into English.**"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 76,
    "total_tokens": 205,
    "completion_tokens": 129
  }
}

翻訳依頼の場合も、判定結果と 2 回分のアクセスログが出力されます。

[notice] [llm-router] llm-router routing result: {"fallback":false,"score":0.6660168757724487,"decision_model":"LiquidAI/d1-3B-GGUF:Q4_K_M","model":"gpt-oss-120b","decision":"sakura"}, request: "POST /chat HTTP/1.1", host: "localhost:8000", request_id: "d92bed52cc5c594546f1da7df2a27016"
127.0.0.1 - - [10/Oct/2026:14:01:15 +0000] "POST /sakura/chat/completions HTTP/1.1" 200 1238 "-" "curl/8.18.0" kong_request_id: "80a0f25d8db7896f2a7f2d6b8c8ea810"
172.24.0.1 - - [10/Oct/2026:14:01:15 +0000] "POST /chat HTTP/1.1" 200 1238 "-" "curl/8.18.0" kong_request_id: "d92bed52cc5c594546f1da7df2a27016"

補足

今回は、カスタムプラグインで LLM の振り分けを実装しましたが、Kong AI Gateway 2.0 には Load Balancing の機能があります。

Kong AI Gateway の Load Balancing のアルゴリズム選択画面

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?