はじめに
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 に返されるラベルを転送先の決定に使います。
たとえば、次のようなリクエストとレスポンスになります。
# リクエスト
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
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
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
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
_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 へ振り分けられます。
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 へ振り分けられます。
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 の機能があります。
参考
