組合せ最適化を AI チャットから試し、実行本体は Tsurugi の UDTF(User-Defined Table Function)経由で Google OR-Tools に委ねる PoC を作成しました。求解 API を「問題スキーマ+ソルバーアダプター」というドメイン非依存の形にそろえたことで、AI エージェントは製造や物流といった業務知識をツール名に持たせず、共通の solve などで複数の最適化課題を扱えます。本稿ではその構成と設計意図をまとめます。あわせて、開発中に遭遇した Tsurugi UDF の仕様を勘違いしたトラブルも紹介します。
背景
組合せ最適化を、製造や物流といった具体題材を交えつつ試せる PoC が欲しく、あわせて利用者にはスケジューラや配送ソルバーの細部を強要したくありませんでした。やりたいことを自然言語で伝え、入力データを用意すれば試せる体験に寄せるため、フロントは AI チャットを中心にしました。求解エンジンとしては Google OR-Tools を選びました。制約を満たしつつ目的関数を良くする求解は計算負荷が大きく、アプリケーション本体と同じプロセスに抱え込むと構成が重くなりやすい、というのが当面の関心でした。
こうした重い処理を別プロセスへ分離し、SQL から呼び出し可能な形で置く手段として、Tsurugi の UDTF が好適だと判断しました。UDTF は gRPC で外部サーバーに処理を委譲でき、入力はデータベース側に置き、求解だけを分散配置できます。ニーズに対する道具選定として OR-Tools・Tsurugi・UDTF・AI チャットを組み合わせ、ドメイン固有のロジックはプリセット(問題スキーマ)とアダプター側に閉じ込め、コアの API とツール群は汎用のまま保つ方針に落ち着きました。
Google OR-Tools の紹介
Google OR-Tools は、線形計画、混合整数計画、制約プログラミング、ルーティングなど、組合せ最適化向けのソルバー群をまとめたオープンソースのソフトウェアスイートです。Python から利用でき、問題の性質に応じてソルバーを選ぶ、というのが一般的な使い方です。
| 問題の特徴 | よく使うソルバー |
|---|---|
| 時間軸があり、工程の重複禁止が中心 | CP-SAT(cp_model) |
| 車両・訪問順序・距離が中心 | Routing(pywrapcp) |
| 容量制約のある詰め込み | CP-SAT など |
今回の PoC では次の 3 題材をデモ用プリセットとして同梱しました。
- 区間ジョブショップ相当の製造スケジューリング(CP-SAT)
- 倉庫・配送の基本 VRP(Routing)
- コンテナへの 3D 箱詰め(CP-SAT)
アーキテクチャ全体像
論理構成は次のとおりです。
ブラウザの SPA は FastAPI に話し、チャットは LangGraph エージェントが担当します。求解の呼び出し元が REST であれ AI ツールであれ、経路は共通の solve パイプラインに集まり、tsurugi-dbapi 経由で Tsurugi に APPLY Solve を発行します。OR-Tools の実行自体は UDTF 側の gRPC サーバー内だけに置き、バックエンドのイメージには含めませんでした。セッション入力は Tsurugi の専用テーブルに置き、tgsql からも同じ UDTF を直接試せるようにしています。
Docker Compose ではフロントエンド、バックエンド、Tsurugi、UDTF、初回の DDL/DML 適用などをまとめて起動できます。LLM(OpenAI または Ollama)は任意で、未設定でも DB・API・UI までは立ち上がります。
ドメイン非依存の設計
製造ラインや配送といった語彙をコア API に出すと、題材を増やすたびにエンドポイントやツール名が増えます。PoC ではそれを避け、次の形にそろえました。
| 層 | 役割 |
|---|---|
求解 API / UDTF Solve
|
solve(session_id, problem_schema, solver, params) の汎用呼び出し |
| 問題スキーマレジストリ | 入力テーブル構造・既定ソルバー・解の形状(solution_type)を定義 |
| ソルバーアダプター | スキーマ入力を OR-Tools モデルに変換し、結果行を返す |
| PoC プリセット | ジョブショップ、VRP、3D 箱詰めなどのデモ定義 |
新しい題材は、スキーマ定義(YAML)とアダプターを足すだけで済み、コアの REST と AI ツールは変えません。ジョブショップ用スキーマの抜粋は次のとおりです。
problem_schema_id: scheduling.interval_job_shop
display_name: 区間ジョブショップ
default_solver: cp_sat
candidate_solvers:
- cp_sat
solution_type: timeline
adapter: app.solvers.interval_job_shop:IntervalJobShopAdapter
input_tables:
- logical_name: tasks
required: true
columns:
- name: task_id
type: int
required: true
- logical_name: operations
required: true
columns:
- name: task_id
type: int
required: true
- name: sequence
type: int
required: true
- name: resource_id
type: int
required: true
- name: duration
type: int
required: true
入力はセッション単位の物理テーブル(session_{id}_{logical_name})に展開されます。解の形状は solution_type(timeline / route_2d / packing_3d / table)として返し、フロントエンドのウィジェット選択に使います。
UDTF 連携の要点と踏んだ落とし穴
単一の Solve と proto の制約
OR-Tools 実行は、単一の UDTF Solve(gRPC Server Streaming)に集約しました。引数はスカラー 4 つ、session_id、problem_schema、solver、max_time_seconds です。Tsurugi UDF の proto では、レスポンス message に stream が必要であり、repeated は使えません。出力はフラットなスカラー列の列挙に限られます。
ドメインごとに列セットが違うため、レスポンスをスキーマ別に分けたくなりますが、proto は 1 つに保ちました。統一 SolveResponse にスケジューリング用・VRP 用・パッキング用の列を並べ、使わない列は 0 や空文字の既定値とします。列数は増えますが、プラグインとカタログの扱いは単純になります。
SELECT r.task_id, r.sequence, r.resource_id, r.start_time, r.end_time, r.objective_value
FROM (
SELECT 'demo-sched-001' AS session_id,
'scheduling.interval_job_shop' AS problem_schema,
'cp_sat' AS solver,
CAST(30 AS INT) AS max_time_seconds
) AS p
APPLY Solve(p.session_id, p.problem_schema, p.solver, p.max_time_seconds) AS r;
上例の CAST(30 AS INT) は、後述する型の落とし穴への対策です。最初からこう書いていたわけではありません。
誤診のはじまり:not declared と「引数は最大 3 つ」
スパイク検証の当初、SolveRequest は上記どおり 4 フィールドでした。次のように整数リテラルのまま max_time_seconds を渡すと、コンパイルに失敗しました。
FROM (
SELECT 'demo-sched-001' AS session_id,
'scheduling.interval_job_shop' AS problem_schema,
'cp_sat' AS solver,
30 AS max_time_seconds
) AS p
APPLY Solve(p.session_id, p.problem_schema, p.solver, p.max_time_seconds) AS r;
table-valued function not declared: Solve
スカラー関数として呼ぶと、次のメッセージが出ました。
function not found: Solve(character-varying(*), character-varying(*), character-varying(*), int8())
ここで 4 番目が int8() であることには気づかず、「4 引数目でカタログに載っていない」と解釈しました。max_time_seconds を proto から消し、string 3 引数にすると成功したため、「UDTF の SQL 引数は最大 3 つ」と断定し、設計・要件も 3 引数前提に寄せ、開発チームへ Issue まで提出しました。
公式サンプル相当の Split で引数個数を変えながら追試したのですが、string のリテラルは成功し、int32 を SELECT 1, 2, 3, ... のように渡すと失敗します。この差を「個数の制限」と「型の問題」で切り分けきれず、string の結果を int にも一般化してしまいました。実際には、int32 をリテラルで渡す場合は引数 1 つでも同じく not declared になります。
再現しない、という回答と真因
開発チームからは再現せず動作する、という回答がありました。こちらでも Docker 環境を起こし直して追試すると、文字列 4 引数の UDTF は問題なくコンパイルでき、「4 以上は登録されない」という結論は崩れました。udf-plugin-viewer では int 入力もカタログ上 int4 として正しく見えており、プラグイン読み込み自体は健全でした。
残る差分は SQL 側の型でした。Tsurugi では SELECT 1 AS a の整数リテラルは BIGINT(int8)として推論されます。一方、proto の int32 はカタログ上 INT4 です。APPLY のオーバーロード解決は名前・個数・表値関数種別・型の一致が必要で、型が合わないときもエラー文言は table-valued function not declared になります。「未登録」と「型不一致」が見分けにくい、というのが誤診の直接原因でした。
| proto | カタログ(SQL)型 |
|---|---|
int32 |
INT(INT4) |
int64 |
BIGINT(INT8) |
string |
CHARACTER / VARCHAR
|
CAST(n AS INT) またはテーブルの INT 列を渡せば、int32 入力の 4 引数 UDTF もコンパイル成功します。3 引数が成功していたのはすべて string だったからであり、上限 3 という制限があったわけではありません。Solve は 4 引数に戻し、生成 SQL では常に CAST(... AS INT) を付けるようにしました。Issue は誤報告として訂正しています。
これから試す方向けのチェックリスト
-
udf-plugin-viewerなどでカタログ上のtype_kind(int32ならint4)を確認する - SQL 側の式の型を疑い、リテラル
1や30が BIGINT になっていないかを見る - 失敗時はまず
CAST(... AS INT)やINT列で再試行してから「カタログ未登録」と判断する - string は成功・int は失敗、という対照があるときは、引数個数より型を疑う
- スカラー呼び出し時の
int8()などの型表示をエラーメッセージから読み取る
AI エージェントとチャット UI
エージェントは LangGraph で組み立て、OpenAI API とローカル Ollama を設定で切り替えられるようにしました。ツール名にはドメイン名を含めません。
| 分類 | ツール | 用途 |
|---|---|---|
| スキーマ | list_problem_schemas |
登録済みスキーマと必要入力の説明 |
| スキーマ | get_input_schema |
入力テーブル・列定義 |
| 入力 | set_input |
セッション入力の一括登録と検証 |
| 求解 | solve |
汎用求解 |
| 求解 | get_solution |
結果取得 |
| メモ |
save_memo / get_memo / list_memos
|
セッション横断の知識引き継ぎ |
対話の流れは、不足項目の確認 → set_input で入力反映 → チャット内の課題プレビュー表示 → 利用者がプレビュー上のボタンで求解、という順にしています。AI が検証成功直後に勝手に solve しないことで、求解タイミングを人手で握れるようにしました。
求解結果は solution_type に応じてウィジェットを選びます。スケジューリングはガント風のタイムライン、VRP は簡易 2D 経路、3D 箱詰めは正射影の立体表示、それ以外やフォールバックは表です。ウィジェット右下には、リクエストから表示までの経過時間を出しています。
開発の流れ
工程は要件定義、設計、実装と単体テスト、結合テストの順で進めました。実装側は求解コア、データ層、REST、UDTF、AI チャット、フロントエンド、Docker Compose へと段階を分けています。
| 観点 | 内容 |
|---|---|
| バックエンド | FastAPI、Pydantic、LangGraph、tsurugi-dbapi |
| UDTF | Python gRPC Server Streaming、ortools は UDTF 側のみ |
| フロント | Vue.js、TypeScript、Quasar |
| 検証 | pytest、フロントのテストと型チェック、受入シナリオ |
PoC の成功基準として、スケジューリング→タイムライン、VRP→経路図、再スケジュール、tgsql/dbapi からの UDTF、メモの引き継ぎ、docker compose up による一式起動、を設け、いずれも合格としました。途中で AI からの入力登録や 3D 箱詰めの追加、求解経路の UDTF 一本化など仕様の調整はありましたが、コアの「スキーマ駆動・ドメイン非依存」はそのまま維持しています。
成果物
まとめ
- 組合せ最適化を AI チャットから試すニーズに対し、重い求解(OR-Tools)の分離実行先として Tsurugi UDTF(gRPC による分散配置)を選定し、PoC を作成しました。
- 問題スキーマ・レジストリとソルバーアダプターにより、コア API をドメイン非依存に保てます。題材追加は定義とアダプターの追加で済みます。
- UDTF の出力はフラットなスカラー列に限られるため、複数ドメインの列を 1 つの
SolveResponseに統合する設計判断が必要でした。 -
table-valued function not declaredは「未登録」とは限らず、SQL 整数リテラル(BIGINT)と protoint32(INT4)の不一致でも出ます。CAST(... AS INT)がその典型的な解決です。
