LLM を活用した選別システムの構築手法を解説するプレゼンテーション
本文の状態
日本語全文を表示中
詳細モードで約39分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
InfoQ AI/ML
Nooxit の CTO、Jendrik Jördening は、LLM を本番環境に統合する際の非決定性やスキーマ制限といった課題に対し、MVC アプローチや判別モデルによる検証などを実践的なエンジニアリング戦略として提示した。
AI深層分析を開く2026年8月17日 19:22
AI深層分析
キーポイント
非決定性とスキーマ制御の克服
LLM の本番環境導入における最大の課題である出力の非決定性を解決し、厳格なスキーマを維持する手法が議論された。
セマンティック抽出とコード処理の分離
意味的なテキスト抽出プロセスと、それを処理する決定論的なコードの実装を明確に分離するアーキテクチャが提案されている。
判別モデルによる検証メカニズム
LLM が選択した結果の妥当性を、専用の判別モデル(discriminator models)を用いて検証する仕組みが紹介された。
MVC アプローチによるシステム設計
データベースの整合性、観測可能性、およびシステムの信頼性を確保するために、LLM を MVC(Model-View-Controller)アーキテクチャで構造化する手法が示された。
LLM導入によるアプローチの変化
従来の深層学習モデルにargmax層を追加して決定を下す手法から、LLMを用いて自然な対話とUIを提供する手法へと移行した。
重要な引用
Jendrik Jördening shares practical engineering strategies for integrating LLMs into production pipelines.
He discusses overcoming non-determinism, restricting schemas, separating semantic text extraction from deterministic code, and validating choices using discriminator models.
Then LLMs came and I was like, this feels somewhat like going from pandas to Excel.
You can swiftly create everything, of course. It's like you can ask a model everything.
編集コメントを表示
編集コメント
LLM の導入において、理論的な可能性だけでなく、実務レベルでの信頼性確保が求められる中で、この発表は具体的な設計指針を提供する。特に非決定性の制御と検証プロセスの分離は、多くの開発現場で即座に適用可能な示唆に富む内容である。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
InfoQ ホームページ
プレゼンテーション一覧
数千から一つへ:LLM 駆動の選別システム構築
プレゼンテーションを見る
再生時間:
ダウンロード
45:57
/presentations/architecture-patterns-llm/en/slides/Jen-1786706170927.jpg)
概要
Jendrik Jördening は、LLM(大規模言語モデル)を実際の運用パイプラインに統合するための実践的なエンジニアリング戦略を共有します。非確定的な挙動への対処法やスキーマの制限、意味テキストの抽出と決定論的コードの分離、そしてディスクリミネーターモデルを用いた選別結果の検証などについて解説します。MVC(モデル・ビュー・コントローラー)のアプローチで LLM を構造化し、データベースの整合性、観測可能性、システムの信頼性をどう確保するかを学びます。
登壇者略歴
Jendrik Jördening は Nooxit の CTO です。同社では ML アルゴリズムの開発や MLOps インフラの構築、社内 Kubernetes クラスターの管理を担当しています。以前は Aurubis や Akka Germany で働き、インダストリー 4.0 および自律型機械分野におけるデータサイエンスとディープラーニングに従事しました。
コンファレンスについて
InfoQ Dev Summit Munich(ミュンヘン)は、シニア開発チームが直面する重要なソフトウェア課題に焦点を当てたソフトウェア開発カンファレンスです。20 名以上のシニアソフトウェアエンジニアから得られる貴重な実務ベースの技術的知見を得て、スピーカーや同業者と交流し、社交イベントも楽しめます。
INFOQ EVENTS
2026 年 8 月 27 日午後 1 時(東部夏時間)
AI エージェントがスケールで失敗する理由:コンテキストはデータレイヤーの問題だ
発表者:Boyd Stowe 氏(Tacnode 創設ソリューションアーキテクト)
2026 年 9 月 17 日午後 1 時(東部夏時間)
PR を超えて:エージェント型ソフトウェアデリバリーの新たなコントロールプレーン
発表者:Mohit Suman 氏(Harness スタッフプロダクトマネージャー)
トランスクリプト
Jendrik Jordening 氏:LLM を活用して選択を行う仕組みについてお話しします。この取り組みはどこから始まったのでしょうか?
私たちは調達関連業務の自動化に取り組んでおり、その中核には「どのコストセンターが費用を負担すべきか」を判断するプロセスが含まれます。具体的には、データベース内の適切な ID を特定する必要があります。
当初は深層学習の観点から進めていました。何かを実行する必要があり、データセットを集め、モデルを訓練しました。当時は決定プロセスも簡単でした。末尾に argmax レイヤーを追加するだけで、モデルが明確な判断を下してくれたからです。
しかし LLM が登場したことで、状況は一変しました。まるで pandas から Excel へ移行するような感覚です。確かに何でも瞬時に作成できるようになりました。モデルに何を聞いても答えが返ってきますし、その結果は驚くほど素晴らしいものに見えます。さらに、ユーザーインターフェースも自動的に用意されるのです。
最近の MCP(Model Control Protocol)には、武器のような機能も含まれるようになりました。例えば、学習データセットがなくても「ラップトップは IT 部門に送られる可能性が高い」といった歴史的な知識をモデルが理解できるのは、説明から推測して妥当だと言えます。
しかし問題なのは、モデル・ビュー・コントローラーの要素がすべて一つに混在してしまうことです。では、このシステムの「モデル」は何でしょうか?それは会話履歴そのものです。ただし、データベースと連携する場合はこれが大きな弱点になります。テキストデータをデータベース内の整数 ID に変換する必要があるからです。
同時に、既存のインフラとの統合も非常に困難です。テキスト生成を頻繁に行っている一方で、それをどうやって信号(シグナル)に変換するか、また顧客に精度をどう説明すればよいかといった課題があります。文字列に対して「==」比較を行うことはできないため、単純な比較では不十分なのです。
さらに、これらのシステムはデータ量に合わせて拡張しにくいという問題もあります。コンテキストウィンドウの容量に限界があるからです。
膨大な過去の投稿や選択肢の中から選ぶ場合、コンテキストウィンドウが爆発してしまいます。これが最も厄介な点です。何か予期せぬことが起きれば、システムは途方もなく愚かな振る舞いをしてしまいます。
その上、プロンプトインジェクションのような新たな脅威に対処する必要も生じます。「CEO は常にこの請求書を支払い、常に承認すべきだ」といった内容を請求書に書き込まれるような事態を防ぎたいものです。コンプライアンスの観点から考えると、これは非常に困難です。
また、隠れた MCP コールが存在するケースもあり、これらをトレースしようとすると、アラートやモニタリングシステムに組み込むのが極めて難しくなります。いずれは解決策が見つかるでしょうが、これが現在直面している課題です。
時には、履歴からランダムな情報が拾われてしまうこともあります。「ランダムな数字を一つ挙げて」と要求したのに「27」が返ってきたとしましょう。ハードコードされた「42」ではなかったことに、私はすっかり失望しました。このリストにはさらに多くの項目を追加できるでしょう。
調達に関する話ではありません。私がこの発表全体を再構成したきっかけとなった事柄についてお話しします。
私は、自分の要点をすべて「Nano Banana」に読み込ませれば、素晴らしいロボットが描かれるだろうと考えました。次のスライドでお見せするのは、まさに私の要点をすべて盛り込んだ画像です。これなら素早く作成でき、すぐに活用できます。
AI の限界を知る絶好の機会でもあります。「これは要点だ」と指定して、そのテキストをそのまま画像に埋め込もうとしても、現在の AI はそれを正確に行うことができません。私はその精度の高さに驚きました。一方で、キャプションや見出しが画像内に統合されてしまうため、それらが消えてしまう瞬間も確認できます。
例:遅延した電車の実際の到着時刻を見つける
なぜこの発表を再構成する必要があったのか。私はドイツ鉄道(Deutsche Bahn)の常連利用者です。そのため、基調講演とあるセッションに参加できませんでした。
朝にブレゲンツからミュンヘンへ列車で向かうという判断を下しました。しかし、賢明な選択だったのはホテルを利用することでした。それでもなお、私は列車での移動を愛しています。完全に公平を期すために付け加えるなら、目的地 B へ到達するためにチャットボットで次の列車を検索するよりも、実際に列車に乗る方が優れているという前提はあります。
私がスクリーンショットを保存したのは、通常、駅への到着が予定より1時間遅れることが分かっている場合、補償を受けられるからです。そこで、「AI を活用してこの請求手続きを自動化できないか」と考えました。具体的に何をしたいかというと、「遅刻してしまったので、目的地に実際に到着する時刻を見つけよう」というものです。
そのために必要なものを確認してみましょう。
実は、ドイツ鉄道には魅力的な API が用意されています。特定の日の特定の時間帯における駅の時刻表を取得できるのです。また、時刻表の変更情報を提供する API も存在します。ただし、列車が発車すると変更情報は消去されるため、過去の遅延履歴を遡って確認することはできません。なぜそうなるのかは、皆さんの想像にお任せしましょう。
必要なものは「EVA 駅番号」です。これは、時刻表を確認したい駅の8桁の識別番号のことです。AI はこれを特定できます。
Rule 0: Remove Randomness
私が最初に追加したルール 0 は、スクリプト作成の過程で無意識に繰り返していた習慣を明確化したものです。それは「ランダム性を排除する」という原則です。
ただし注意すべきは、ランダム性を完全に除去しても、結果にまだランダム性が残ることがある点です。実際にスクリプトを実行し直したところ出力が変化してしまったため、その対応策として追加のスライドを作成しました。
重要なのは、最初に温度パラメータを 0 に設定することと、初期のランダムな席配置を固定することです。また、思考バジェットが必要ない限りはそれを無効化するのが望ましいでしょう。ニューラルネットワークは本質的に非線形な性質を持っているため、思考バジェットを有効にすると出力が非常に不安定になりがちだからです。
それではコードを見ていきましょう。チャットウィンドウを開くと分かる通り、このコードの大部分は AI によって生成されたものです。これは大変ありがたかったのですが、副作用が次々と発生する複雑な状況でした。
本発表の資料を作成する過程で、コンテンツも同時に生成されました。一つ付け加えると、もし環境内の ML 関連ファイル(blob)について不満を持つ方がいたら、私の経験をお伝えします。私はファイル名を「0」「1」「2」「3」「4」と改名して、提示順序を明確にしました。しかし、整理しきれない blob が残ってしまいました。実は、Cursor というツールがすべてを処理してくれたおかげで、私がやるべきことは片付けだけでした。ただ、私は片付けが苦手です。
そこで、今回は徹底的に楽をする方針でいきます。すべてのファイルを「make 0」「make 1」「make 2」「make 3」というように単純なルールで管理します。コードはスライドと一緒に公開するつもりです。実際に動かしてみたい方は、「make 0」から始めて「1」「2」「3」「4」と順に実行すれば、最後までスムーズに進められるはずです。
では、ここで何をしたのでしょうか?実は非常にシンプルなことをしました。ドイツの鉄道アプリで発生した列車遅延の画像を分析するというタスクです。
画像を確認し、その構造を特定して EVA 番号を返してください。なお、駅名が「Hbf」のように表記されている場合もあるため注意が必要です。該当する駅が見つからない場合は「not found」と返してください。ただし、このバージョンのスクリプトでは「not found」が返されることは決してありません。必ず何かしらの結果を見つけ出す仕組みになっています。
実際に見つかったのはウルム(Ulm)でした。これは素晴らしい結果です。画像をもう一度見ていただければお分かりいただけると思いますが、私が乗り換えを行い、遅れた駅正是ウルムだったからです。さらに、EVA 番号として「8003004」も取得できました。これは非常に興味深い結果です。
実際に確認してみましょう。「make 1」を実行すると、この番号がドイツ鉄道(Deutsche Bahn)の API に送信され、ホスキルヒ・ケーニッゲスゼー(Hoßkirch Königseggsee)駅の時刻表が表示されます。この場所をご存じない方もいらっしゃるでしょうが、これは当然です。なぜなら、私が Gemini に対して「ホスキルヒ・ケーニッゲスゼーの画像を作成してほしい」と依頼した際、同駅は実在しないように見えると回答されたからです。
実はこの駅は存在しますが、発着する列車はありません。駅が取り壊されたわけでもありません。私はその駅の画像を一つ見つけましたが、オープンライセンスの画像ではなかったため、ここでは表示できませんでした。それでも、その規模の小ささがよく伝わってくるはずです。
ルール 1: ID の特定には知識に依存しない
これが私の第一のルールです。ID を取得したいなら、知識に頼らないでください。ID を得たいのであれば、モデルに具体的な文脈を与えてください。「これらの駅が対象です」「これらが該当する番号です」と伝え、その中から一つ選ばせるのです。
では、もう一度試してみましょう。まずは構造化出力を活用します。コードを見ればわかるように、私はすでに「JSON 形式で返してほしい」と指示しているからです。なぜそうするのかというと、LLM を活用して選択肢を選ばせ始めた当初、時間の約 80% が「どうか JSON で返してください」とモデルに懇願することに費やされ、さらに 10% は出力された文字列のクリーニングに使われていたからです。
状況は改善されましたが、それでもスクリプトを再実行するたびに履歴をスクロールして確認するのは時間がかかります。これは、AI との協働における私の苦労を如実に物語っています。
このモデルは、"tick, tick, tick"という音に続いて JSON の開始括弧を出力するようになりました。以前はこの点であまり得意ではありませんでしたが、現在は構造化された出力に対応できるようになっています。
具体的には、「私の列車の移動履歴リストを返してください」と指示すると、DB(データベース)から該当データを抽出します。ここで注意すべきは、DB はドイツ鉄道(Deutsche Bahn)ではなく単なるデータベースを指す点です。この誤解はよく見られますが、実際には列車番号、出発駅、到着駅、予定出発時刻、予定到着時刻、実際の出発時刻、実際の到着時刻といった項目を含むリスト形式で返されます。
私はこれらのデータを SQLite データベースに保存しており、その中から必要な情報を抽出して表示しています。
ID、EVA 番号、氏名、住所、郵便番号、都市、地域名をすべて一度に取得したい。スキーマが対応しているので、そのまま実行しましょう。
テジャス氏が話していた通り、人生を楽しむべきです。今回はそのための休憩でもあります。実は私は逆の立場でした。人生を楽しみすぎて、遅延する電車の中で座り込んでいたせいで、エージェントを使うようになったのです。そこからスパイラル状に発展していったのだと思います。
ご覧いただくとわかるように、現在は非常に長い出力が得られ、そこには多くの情報が含まれています。私も構造化しましたが、ここではその内容を確認してみましょう。
出力は、あなたが鉄道旅行を行ったことを示しています。出発地はブラウンシュヴァイクで、ここに EVA 番号が割り当てられています。数秒後にご確認いただけます。到着地はミュンヘンですが、これは正しくありません。
私はウルムを経由しました。なぜ出発地の記載がないのか不思議です。興味深いことに、ドルトムントを経由したとも報告されています。理由はわかりませんが、確かに変更が加えられています。
さらに、ミュンヘンを通過した後、リンダウ・ロイトゥーンへ続行したとも記されています。まあ、そう言われると納得もできますね。
ファイルを確認すると、ブラウンシュヴァイクからウルムを経てリンダウ・ロイトゥーンに到着したことはわかりますが、実際にミュンヘンを通過したわけではないことが示されています。
システムは問題なく動作しています。ここでは3つの興味深い点があります。
まず、次のスクリプトを実行してミュンヘンの時刻表を取得しようとすると、EVA番号が存在しないため何も取得できません。
さらに興味深いのは、リンダウの時刻表を取得した際、AIが「ランツハイトの時刻表はこちらです」と答えたことです。なぜそのような副作用が生じたのかを調べてみると、ルートがドルトムント経由でウルムに至る経路だったことがわかりました。
私はAIに、「この間違いを説明してください」や「この誤りをプロットしてください」とも尋ねてみました。AIが出した回答は以下の通りです。「南へ向かうと、ここからバルト海に着いてしまいます」。実際、リンダウはコンスタンツ湖(ボーデン湖)に位置しており、地図上ではほぼその場所にあります。
なぜAIがハンブルクを出発点としたのかは不明ですが、Borsigという名称についても私は理解していません。AIは「素晴らしい」と評価しましたが、その根拠については不明です。
破線で示されたのが計画ルートです。これは当然ですね。計画ルートは常に破線、実際のルートは実線で表されます。
私の計画ルートは、ブラウンシュヴァイクからウルムを経てリンダウへ向かうべきものです。しかし実際にはドルトムント、フランクフルト(地図上で完全に誤った位置にあります)を経由してウルムへと進み、そこからリンダウに至っています。この後半部分は確かに正しい経路です。
一方、ドルトムントを経由するはずだった代替ルートは、結局ドルトムントを通りませんでした。興味深いことに、ドイツをほぼ同様の経路で走る鉄道路線が存在します。それがカッセルとフランクフルトを結ぶ回廊です。
そこで私は、「ミュンヘンの場合も同じようにできますか?」と尋ねてみました。今朝、ふとしたことからこの地図を追加したのです。私は実際にミュンヘンを通過していませんが、地図上には表示されてしまいました。その位置はザクセン州とトリノの間あたりにあります。
マルクス・ゼーダーがこの地図を承認するかどうかは定かではありませんね。さっさと隠しておきましょう。
まずは一つだけ確認しましょう。現在、名前と EVA 番号が一致していないという問題があります。ID と名前は存在するものの、この状況は非常に不自然です。
そこで次に進むべきは、モデルに明確な判断を迫ることです。そのためには、各ステーションを列挙型(enum)としてエンコードするのが有効です。ここではランダムに割り当てた ID の代わりに EVA 番号を利用しました。
これは Python コードとしてはそのままでは動作しませんが、プログラム的には適切な列挙型を作成できます。具体的には、「Ulm」の値に対応する定義を設け、同様に他のすべてのステーションについても同様の処理を行いました。
その結果、モデルは新しい出力スキーマを受け取るようになります。つまり、「画像から検出可能なステーションの一覧を、事前に定義した列挙型にマッピングして返すこと」という指示です。さらに、計画到着時刻と実際の到着時刻も併せて出力させるようにしています。
幸いなことに、私はすでにその質問を偶然実行してしまいました。もしそうでなければ、コーヒーを買いに帰る時間があったかもしれません。この質問への回答には 140 秒かかりました。
回答の最後には「ウルム」という駅名と予定到着時刻が含まれていましたが、これは有効な JSON ではありませんでした。なぜでしょうか?それはモデルが答えを知っているからです。ルートはブラウンシュヴァイクから始まり、その後「ウルム、ウルム、ウルム…」と延々と繰り返されました。
私の妹がそこで暮らしているため、場合によっては事実である可能性もあります。しかし、どの時間範囲を「ウルムでの滞在」としてカウントすべきかは不明です。
この問題はスキーマを使えば簡単に解決できます。スキーマの素晴らしい点は、モデルにヒントを与えられることです。「スクリーンショットにある駅はこれらだけ」「各駅は 1 回のみ記載すること」「リストは最大 10 件まで」といった指示を出せます。この方法で、制限された駅名のリストを正確に生成することが可能になります。
いずれにせよ、この問題を解決するもう一つの手段があります。それは「思考する」モデルを使う方法です。
私はあえて Gemini Flash Lite を選びました。通常の Gemini Flash でも、思考プロセスを通じて「ここでは意味不明な出力が出ている。戻ろう」と判断し、正しく解決できるはずです。あえてシンプルに保ったのは、他の興味深いタスクで失敗させるためでした。
では何が起きたのでしょうか?実際には、駅名を返すようになりました。私はブラウンシュヴァイクから出発しました。素晴らしいのは、モデルが駅の列挙型(enum)を返してくれた点です。これはデータベースやメトリクスシステムにそのまま投げ込める形式です。「私が決定を下した」という事実を、追加の処理ステップなしで即座に反映できます。
モデルは「ウルムへ行った」「ウルムを出発し、再びウルムに行った」と報告しました。これは昨日とは異なるパターンです。
さらに興味深いのは、ウルムへの移動が 3 回あったことと、最後にウルムからウルムへとループしたという点です。
また、到着時刻を「null(なし)」として返すようになったのも注目すべき変化です。なぜでしょうか?それは、スキーマで明示的に「ここでは null を返してもよい」と指示したからです。スクリーンショットを見ればわかりますが、将来の発車時刻や到着時刻に実際の値がない場合でも、null として扱えるよう設計されています。
もしスキーマでこのフィールドをnullable(null 許容)と明示しなかった場合、モデルは適当な数値を生成して返すことになります。実際、その場合は無理やり数字を作り出すしかありませんでした。
結果として、私たちはループ状態に陥り、行き来を繰り返すことになりました。
ルール 2:スキーマを制限する
ルール 2 は、スキーマを制限することです。つまり、事前にスクリーンショットに少なくとも 10 件、多くても 10 件のエントリしか含まれないと分かっている場合は、その情報をモデルに事前にお知らせください。プロンプトに組み込むビジネス知識が多ければ多いほど、モデルの精度は向上します。
ここで「なぜループしてしまったのか」という点について振り返ってみましょう。この現象を二つの側面に分けて考えてみます。まずは、スクリーンショット上の駅名を一つずつ特定することに集中してみましょう。もし駅名そのものを検索し、マッピング処理を行わずに単純に駅を検索するよう指示すれば、モデルは「ブラウンシュヴァイクからウルムへ、そしてウルムからリンダウへ」という経路と、実際に読み取れた時刻情報を正確に返してくれます。これは、いわば最も単純なタスクを命じた結果です。
このモデルは思考する必要も、今ここで翻訳を行う必要もありません。実際には正しい値を返してくれました。
では、ここから検索を開始しましょう。まずは各駅を順に確認し、一つずつマッピングを実行します。具体的には、各駅のテキストを取得して、駅名とマッピングするよう指示を出します。「モデルよ、これが駅名です」と伝えるのです。私は先ほど駅名の列挙(enum)を作成しました。
この処理の結果、ウルムの駅番号は「8000170」であると返されますが、これは正しい値です。
次にリンダウで試してみましょう。リンダウの駅番号は「800013」となります。データベースに ID を持てたので検索すると、結果はアウクスブルクでした。地理的には近い場所ですが、これは誤りです。
さらに重要な追加処理を行いましょう。ここでは再度「None」を追加します。「None」を指定すると、「この駅は私のデータベースに含まれていない」と返されます。私が使用したのはドイツの主要な約 300 の駅のみに限定された小さなデータベースだったためです。
リンダウ=ロイティン(Lindau-Reutin)はオーストリア国境に隣接しています。確かに非常に美しい場所ですが、交通の要所というわけではありません。むしろ、そこへ行くなら電車よりも自転車のほうが楽しいし、場合によっては速いこともあります。これは余談です。
ここで何が起こったのでしょうか?私は実際にはすべての駅を含むデータベース全体を指定し、同じスクリプトで実行するように指示しました。その結果、非常に長いデータベースが返ってきましたが、エラーメッセージが表示されました。「無効な引数です。指定されたスキーマは、提供時に分岐が多すぎるという制約を生み出しています。」これは実に素晴らしいエラーメッセージです。
この実験は 2 年前に Azure OpenAI エンドポイントに対して行いました。その時の LinkedIn の投稿にはおそらく 15 件のいいねしかついていませんでした(友人がいないか、誰も関心を持っていなかったかのどちらかでしょう)。その後、Azure エンドポイントは数日間だけ不規則に動作し始めましたが、これはおそらく無関係な問題だったのでしょう。
通常、制限は約 500 の列挙型(enums)程度です。これを超えると、モデルプロバイダーは「それはできません」と答えます。「その質問には答えたくないからです」。しかし、この問題も解決可能です。
ルール 3:関連する入力のみを持つタスクにマッピングを分離する
まず、先ほどの面白いロボットの話に戻りましょう。ここから見えるのは、マッピングと、実際に必要な入力を取り出すタスクを分けるべきだという点です。
ここで注目すべきは、画像に指示した内容とある程度意味的に合致していることです。私たちが本当に目指すのは、まず関連する出力を取得し、その後にマッピングを行うことです。これでこの問題は解決します。
各ステーションごとに確認ができるようになり、画像から正しい出力を得ることができました。ここで重要な区別点は、入力にマッピングに必要な情報がすべて含まれていると確信できる場合です。
例えば、「スクリーンショットにあるすべてのステーションを教えてください」と指示してマッピングする場合や、「遅延が発生したスクリーンショット上のステーションを教えてください」と指示してマッピングする場合など、それぞれ適切に分離して処理できます。
マッピングに必要な入力が不明な場合や、構造化が難しい場合は、予測段階でマッピングを行うのが有効です。例えば、「遅延する電車が写ったスクリーンショットかどうか」を分類したい場合、どのような特徴量を入力すべきかは難しい質問になります。そのようなケースでは、画像全体を一つのクラスにマッピングしても問題ありません。
実際には、画像を複数のピースに分割し、すべての駅を検出しました。遅延している駅と時刻表も特定し、経路上の駅をマッピングしています。ここでマッピングを見直すと、すべてをマッピングするための小さな工夫があります。それは、列挙型(enum)ではなく ID でマッピングすることです。これは、モデルに特定の範囲の数値を出力させたい人にとって非常に興味深いポイントです。
JSON スキーマでこのルールを伝えることも可能です。まず、利用可能な駅をすべて ID に割り当てます。そしてモデルに対して「駅 ID を教えて」と指示します。結果は 0 より大きく、駅のマップのサイズより小さくなければなりません。これにより、選択肢の範囲内に値が含まれることが保証され、それ以外の値を選ぶことはできません(ただし、「なし」を選択する場合は例外です)。
この方法で、リンダウ・ロイティン駅のエバ番号も特定できました。これでマッピングは完了し、次のステップに進めます。
ルール 4:選択肢から必ず一つを選ばせることは常に可能
これで、ロボットが実際に複数の選択肢から一つを選ぶ仕組みを実装しました。次のステップとして、非常に重要な処理を追加します。ロボット自身が示唆しているのは、「選択した結果を検証する」ということです。構造化された選択肢と追加情報を活用すれば、別のモデルにその検証を任せることが可能になります。
コードを見ると、いわゆる「判別器(discriminator)」を実装できます。まず行うのは駅のマッピングです。これはすでに仕組みを理解いただいている通りです。次に、選択の正当性を検証します。具体的には、モデルに対して「列車駅の名称と駅の情報を与えられたら、その駅が正しいか真偽を返すタスク」を指示します。正しければ true、そうでなければ false を出力させるのです。
想定していた駅名と、実際に選択された駅名が一致しているかを確認しましょう。これは、AI に命令を組む際に構造化しすぎないよう注意すべき点を示しています。なぜなら、重要なステップを見落とす可能性があるからです。
面白いのは、AI が誤った情報を提示する点です。例えば、モデルの名前が「Lindau-Reutin」であるにもかかわらず、その出力が正しく一致していると判断しないことがあります。さらに興味深い点は、この予測に対して 2 つの信号(指標)が得られることです。
1 つ目は、予測した値を後から来る正解データと比較して検証できる点です。もう 1 つは、バリデーターの結果を確認し、それが「真」または「偽」と判断された頻度を把握できる点です。
つまり、元のモデルに誤りがあったのか、それともバリデーター側に問題があったのかを、2 つの簡単な方法で確認できるようになります。もちろん、最初は手動で 5 回程度チェックを行うべきですが、その後は自動化を進め、精度を自動的に測定する仕組みを導入できます。
Rule 5: Validate Your Result
次に、次のステップとして「次に出発する列車はどれか」を特定する必要があります。では、どうすればよいでしょうか?
私は、出発時刻を照会できる優れた API が存在していることを知っています。この情報をすべてデータベースに格納しました。その理由は非常にシンプルです。まずは利用可能な選択肢を絞り込むことから始めるべきだからです。
ドイツのすべての駅で1年間に出発する列車データをそのままモデルに入力しても、追加情報なしでは正確な回答は得られません。「何を求めているのか」が明確でないからです。例えば、「特定の時刻以降に出発するすべての列車」を探している場合、それ以外の選択肢は無効であると判断できます。
このようにして、モデルに対して「検索範囲をこれに限定せよ」と指示することが可能になります。さらに、プロンプトには出発時刻と計画された経路も追加します。ここでいう「計画された経路」とは、列車が経由する駅をピップ区切り(|)で並べたリストのことです。
驚くべきデータ構造ですが、私たちは API デザイナーではなくプラットフォーム担当です。この API は私のものではないので、責任は私にはありません。
次に、"目的地への次の列車を探して"と指示します。以前と同じように処理を行います。具体的には、出発時刻を 0 から長さマイナス 1 の間の整数に変換し、そこから選択させます。実際にその出発時刻を選ばせるのです。すると、確かに適切な出発時刻が選ばれます。
ただし、このデータはここに存在しません。コピーできますか?PowerPoint のノートに助けてもらってよかったです。これは Cursor にコピーして貼り付けることができません。「モジュール名が見つかりません」というエラーが出ますが、実際には整数値なので「次の列車を探して」の指示が正しく機能します。
すると、"この駅から次の列車が出ています。リンダウ・ロイトン行です"と回答されます。実際に正しい経路を特定しています。出発時刻は 10:00 の 19 分後で、これも正しい可能性があります。
もしウルムに 6:24 に到着し、モデルが "4 時間後に出发できます"と告げた場合、それは事実であればユーザーは怒るでしょう。
ルール 6:決定論的に絞り込む
ここから次のポイントに移ります。先ほど取り上げた絞り込みの話も重要ですが、それ以上に「AI に決定論的な比較を任せない」ことが肝心です。
例えば、「今すぐ発車する次列車はどれか」と尋ねるのではなく、「リンダウ行きの全列車を出し、その中から自分の出発時刻に最も近いものを選別する」という指示を与えます。そして、最終的な選別はコードで行います。このケースでは、AI のロジックとコードを明確に分けることが極めて重要です。あるいは MCP(Model Context Protocol)を活用する方法もあります。
MCP は Cursor 上で構築できますが、その作成にはこの関数一つを書く時間の約10倍がかかると予想されます。私はあえて MCP を作らず、手動で関数を書き上げました。
そうすることで、無料で得られる素晴らしい成果が見えてきます。まず、明確な評価指標(メトリクス)が得られます。運用チームに対して「このモデルの精度はこれです」と報告できるのです。将来的には正解データ(グランドトゥルース)も入手可能になります。また、「モデルがバリデーションに何回失敗し、再質問を余儀なくされたか」という頻度も把握できます。
レガシーシステムとの統合も容易です。ID が返ってくるため、それをデータベースの ID と比較できますし、構造化された時刻情報も取得できるため、データベース内の時刻と直接比較可能です。
次に重要なのは、コンポーザブルな設計を採用している点です。このアーキテクチャを軸にエージェントを構築しています。例えば、スクリーンショットから情報を抽出するエージェント、その情報を EVA 番号に変換する次のエージェント、そして出発時刻を取得するさらに次のエージェントといった具合です。これにより、モデル・ビュー・コントローラー(MVC)の概念が再び明確になります。状態(State)を管理でき、状態遷移を定義し、フロントエンド側で表示するための別々の手段も用意されています。
このモデル自体は非常にシンプルです。入力として最初に取得したスクリーンショットの状態に保持します。出力としては、列車変更後の EVA 番号と、次列車の出発時刻という二つの情報を生成することを目標としています。コントローラーは現在の状態を確認し、次に到達すべき地点を把握した上で、LLM に「この特定の遷移」を実行するよう指示を出します。
そして何より素晴らしいのは、ビジネス側の要件も満たせる点です。フロントエンドの表示は、あくまで AI チャットインターフェースとして見せられれば十分です。内部の実装がどうであれ、ChatGPT 風のインターフェースであればビジネス側は受け入れます。
結論として、当然ながら代償も支払いました。再び柔軟性が失われ、状態を定義する必要が生じました。プロセスを変更したい場合、再度コードに触れる必要があります。「これをやってほしい」という要望だけでプロンプトに投げ込めば解決するといったことはできなくなりました。
しかし、このように状態を定義したことで得られるメリットは非常に大きいです。信号を管理するチームや顧客との対立が大幅に減ったからです。例えば、「どこか遠くの廃駅に行って時刻表を確認してください」といった要求に対して、私たちは「はい、その通りです」と答える必要もなくなりました。追加の労力をかけることで、全員が満足できる結果をもたらすことが可能になりました。
これにより、プロダクトチームには「本当に達成したいことは何か」を再考させる効果もあります。彼らには具体的な成果目標を提示してもらう必要があります。「次の発車時刻を教えてくれるボットを作ってほしい」といった曖昧な要求だけでは不十分です。常に作業内容を細分化し、出力スキーマに関するベストプラクティスに従うことが重要です。
次に電車で立ち往生した際は、今日どのようなプロンプトが必要かを考えてみてください。ミュンヘンとブレゲンツの間のような通信環境が極めて悪い区間を除けば、必ず何かをプロンプトで指示できるはずです。
参加者1: 最初、あなたのソリューションではすべてをLLMに任せていましたが、うまくいきませんでした。最終的に到達した結果において、LLMが実際に単独で行った部分についてお尋ねします。LLMが独自に行い、そのプロセスがどのように行われたかよくわからなかったのは、スクリーンショットから駅名を抽出するだけで、それ以外の処理はすべて決定論的(deterministic)で、駅名の照合やJSON APIを通じた検索のみだったと理解してよろしいでしょうか?
Jendrik Jordening: いいえ。駅名の照合もLLMベースです。実際には、スペルミスが含まれている可能性のある多数の駅名があり、それらの中で意味的に最も近いものを見つけたいと考えています。これをLLMで行うのは理にかなっています。もちろん、特定の時刻以降に出発する列車の種類を確認するのは決定論的な処理ですが、リンダウ行きの列車を選ぶ作業は再びLLMに依存しています。文字列を扱う必要があることはすべてLLMに任せています。なぜなら、LLMはテキストを含むタスクが得意だからです。一方、数値に関することや、常に同じ結果を出すべきことについては苦手としています。
参加者1: 最後に、LLMに次の列車を選ばせた際、4時間かかる列車を選んでしまったとおっしゃっていましたが、あなたのソリューションでは「すべての接続先を提示する(API呼び出し)」とし、最終的にユーザーが選択を行うという形だったのでしょうか?
Jendrik Jordening: いいえ、出発時刻の情報は残っています。XML での表示例をお見せしましょう。もう一度実行してみますと、ご覧の通り、出発情報の構造はまだ完全には整理されていません。もちろん各出発には「出発時刻」が含まれますが、「計画された経路」も含まれており、そこには複数の駅が列挙されています。
AI に頼むべきは、どの列車が目的地へ向かうのかを特定することです。つまり、ユーザーの希望する場所に向かうすべての列車を選別し、現在時刻と出発時刻との比較を確定的なロジックで行う必要があります。経路の曖昧な一致(ファジーマッチング)に頼るよりも、AI を活用して「実際に乗れる列車」を特定する方がはるかに適切です。
参加者 2: まず第一に、他の人々もドイツ鉄道(Deutsche Bahn)で苦しむ方々の支援に取り組んでいると知って安心しました。駅を検証するアプローチを採用された点も興味深いですね。では、実際に運行されている路線情報を考慮したことはありますか?私の利用ケースはニュルンベルクからミュンヘンへの移動ですが、少なくとも 2 つの路線があります。夏場なら 3 つの路線が使える場合もありますが、通常は 2 つです。沿線の駅も把握しています。
AI がこの情報を複数の経路から柔軟に取得し、統合できるような仕組みを追加する余地はないかと考えられたことはありますか?
Jendrik Jordening: いいえ、その点は考慮していませんでした。実際にはメンミンゲンを経由しました。より速いルートだったため、途中で別のスイッチを利用したのですが、これは主に DB 側の解決策に関する話です。そのため、実際の選択プロセスにおいては付加価値が生まれないと考え、あえて省略しました。基本的には、複数の経路を計画して多段のパスを追加すれば、同様の結論に達できるはずです。
参加者 3: データ検証ステップにおいて、その検証結果を LLM にフィードバックし、より良い選択が可能になるか確認したのでしょうか?
Jendrik Jordening: これは、LLM を活用したコントローラーを構築するケースと言えます。現在の状態、到達したい状態、そして過去の会話履歴といった情報を基に、LLM が次のアクションを決定するのです。
例えば、EVA 番号を確認しようとして「間違っています」というエラーが出た場合、その結果を LLM に渡すことで、次に何をすべきかを判断できます。この際、LLM による意思決定とルールベースの処理を組み合わせて使うのが効果的です。
駅を検索した後は必ず EVA 番号を確認する必要がありますが、そこから先はユーザーに問いかけることができます。「番号が見つかりました。次の列車の時刻表を表示しますか?それとも、電車や自転車よりも速く移動したいので、レンタカーの手配をしましょうか?」といった選択肢です。
このようにして LLM が提案し、ユーザーが回答することで、次のアクションを選択していくという流れになります。
原文を表示
From Thousands to One: Building LLM-Powered Selection Systems
View Presentation
Speed:
Download
45:57
/presentations/architecture-patterns-llm/en/slides/Jen-1786706170927.jpg)
まとめ
Jendrik Jördening shares practical engineering strategies for integrating LLMs into production pipelines. He discusses overcoming non-determinism, restricting schemas, separating semantic text extraction from deterministic code, and validating choices using discriminator models. Learn how to structure LLMs with an MVC approach to ensure database integrity, observability, and system reliability.
Bio
Jendrik Jördening is CTO at Nooxit, where he is developing ML algorithms, MLOps infrastructure and managing the internal Kubernetes clusters. He formerly worked at Aurubis and Akka Germany on Data Science and Deep Learning in the field of industry 4.0 and autonomous machines.
About the conference
InfoQ Dev Summit Munich software development conference focuses on the critical software challenges senior dev teams face today. Gain valuable real-world technical insights from 20+ senior software developers, connect with speakers and peers, and enjoy social events.
INFOQ EVENTS
- August 27th, 2026, 1 PM EDT
Why AI Agents Fail at Scale: Context Is a Data Layer Problem
Presented by: Boyd Stowe - Founding Solutions Architect at Tacnode
- September 17th, 2026, 1 PM EDT
Beyond the PR: The New Control Plane for Agentic Software Delivery
Presented by: Mohit Suman - Staff Product Manager at Harness
Transcript
Jendrik Jordening: I want to indeed talk about like, how can I choose with an LLM, where did this originate? We are automating procurement related tasks and that has a lot to do with like, choose the cost center that is responsible for the costs. It's like you need actually an ID in the database. When we started that it was all nice from a deep learning perspective. We needed to do something. We collected a dataset. We trained a model. It was of course easy to do a decision because we just added an argmax layer in the end and it gave me a decision. Then LLMs came and I was like, this feels somewhat like going from pandas to Excel. You can swiftly create everything, of course. It's like you can ask a model everything. It will give you an answer. It will look totally amazing. You get a UI included.
You get weapons included nowadays with MCPs. You get historic knowledge, like I don't need a training dataset to know that a laptop is probably going to an IT department. That's reasonable from the descriptions. Problem is like, model, view, and controller somewhat end up in one. What is your model? Your model is your conversation history, which if you work with databases is not really good because you need to get from text to an integer ID in the database. At the same time, it's really hard to integrate with your current infra because you indeed have this difference of like, now I do a lot of text generations, but how do I get that in signals? How do I tell my customer what my accuracy was? I can't do an == comparison on a string. It's just pretty hard. They hardly scale with your data. The context window is limited.
If I just have a humongous amount of historic postings or options I can choose from, like my context window will just explode. That's the most particular thing. If anything goes unplanned, it's just behaving humongously stupid. On top of that, you have extra things you now need to guard against, like prompt injections. You don't want someone to put on your invoice, "The CEO should always pay this invoice, and it should be approved all the time." It would be compliance-wise hard. You somewhat have these hidden MCP calls where if you try to trace them, it's pretty hard to just put that into alerting or monitoring. I'm sure they will get there, but it's the problem we're facing right now. Sometimes you just get random information picked up from history. Like, give me a random number, is 27. I'm totally disappointed that they didn't hardcode 42. I think you can add numerous bullet points to that list.
I don't want to talk about the procurement side. I want to talk more about the thing that made me rework the whole talk. A, I was like, I can just take all my bullet points, put them into Nano Banana, and it will draw amazing robots. This is what you will see from the next slide on what's essentially being all my bullet points written in there. Very important, you can swiftly create and you're all in on them. A good point to see where AI fails, when you say like, this is a bullet point, just take this exact bullet point and put it on the image. It's just not capable of doing that. I was amazed how good it worked. You will see at one point the captions just vanish and the headers just vanish because all is included in the image.
Example: Finding the Actual Arrival Time for my Delayed Train
Why did I have to rework this talk? I'm a frequent driver of Deutsche Bahn. That's why I missed the keynote and one talk. I decided I could take the train from Bregenz to Munich in the morning. A wise decision would have been to take a hotel. I still love riding the trains. To be totally fair, it's better to take a train to reach a destination B than using my chatbot to find the next train you should take, just as a disclaimer. I took the screenshot because normally you get a reimbursement if you know that you will arrive one hour later on your station. I was like, I can AI this to automate the claim for it. What do I want to do? It's like, ok, I'm late. Let me actually find the time on which I would arrive at my destination. Let's have a look at what we need for that.
There is actually a cool API of Deutsche Bahn where you can get the timetables for a station on a given date, on a given hour. There's as well an API which tells you changes to that timetable. The changes vanish after the train departed, so you can't look at historic delays. Why is that? I leave that to your imagination. We need an EVA station number. It's like an 8-digit number of the train station that you want to check the timetable for. AI can figure that out.
Rule 0: Remove Randomness
One thing I came up with is I added rule 0 in the beginning because it's something I realized I did in every of the scripts because it was just already totally in my brain that you have to do it, which is, remove randomness. You will see even if you remove randomness, there is still randomness. I had to create another slide after I ran my scripts again and the outputs changed. Very important, start by setting temperature to 0 and initial random seats. If possible, disable thinking budget except if you actually need thinking budget, because the way a neural network is, it is very nonlinear. If you had thinking budget, the output is very nonlinear. Let's hop over into code. If I open the chat window, you will see that most of the code was obviously AI generated for this, which was great because I had side effects upon side effects.
It just created more and more content for this talk while creating the talk. One thing as well, if anyone ever complains about ML blob in your environment, like I had to rename the files 0, 1, 2, 3, 4, so I know in which order I will show them. Then there's like blob I didn't clean up. Cursor did everything for me. I just need to clean up, which I'm really bad at. Let's just be super lazy. I just put everything to make 0, make 1, make 2, make 3. I will try to make the code accessible together with the slides. If you want to run through it, just do make 0, 1, 2, 3, 4 until you end up at the end and you should be able to follow everything. What did we do here? We just did something really simple. We said, you're analyzing a train delay image from a German railway app.
Please examine the image, find the structure, and return me the EVA number. Then some small hints, it might say Hbf instead of Hauptbahnhof. If you cannot find a matching station, return not found. Needless to say, this script in this version will never return not found. It will find something for you. What did it find? It found Ulm, which is good. Because if we hop back into the image, you will see Ulm is actually the station where I had my transfer and where I was late. Amazing. It gave me an EVA number, 8003004. That's super cool. Is it actually? Yes. Let's check the result. If we now run make 1, which is actually just taking this number and throwing it into the API of Deutsche Bahn, it tells me, here's the timetable for Hoßkirch Königseggsee. If you don't know that place, that is fair, because when I asked Gemini to create me an image of Hoßkirch Königseggsee, it told me that it doesn't appear to be a real train station. It is actually, but there is indeed no train leaving from that station. It's not demolished. I found an image from it. I didn't find an open license image, so I couldn't show one here. It speaks volumes to its size. That's great.
Rule 1: Don't Rely on Knowledge for IDs
That brings me to rule number one. Don't rely on knowledge if you want to have IDs. If you want to have IDs, give the model specific context, telling it, these are the stations, these are the numbers for it from these stations, choose one. We can do that. Let's do another try. Let's first of all use structured output, because if we hop back into the code, you will see I actually just told it to give me JSON text. Why did I do that? When we started experimenting with LLMs to do choices, we approximately wasted 80% of our time on telling the model, please, please, please give me JSON. Then another 10% of the time on cleaning the output string. It actually got better. What you will see is like, if you run the script again, because scrolling through the history takes longer, speaks volumes to my work with AI as well.
You will see it just gave us tick, tick, tick, JSON, opening bracket. It actually got fairly good with that. Historically, it was not trained that well. It was trained to, of course, I can give you JSON opening braces, and then started writing new JSON. Luckily, these things are now solved and we actually have structured outputs. What we can do is we basically can tell the model, dear model, please return me a list of my train journeys. DB in this case is database, not Deutsche Bahn. Common misconception. It actually is just a list of these train journeys. It should extract the train number, the departure station, the arrival station, planned departure time, planned arrival time, actual departure time, actual arrival time. If you go to the station, which is actually one of my DB models, because I added most of these to an SQLite DB. It's like, ok, and let's continue this.
Please give me an ID, an EVA number, a name, a street, a ZIP code, a city, a region name. All of that at once, no problem. The schema accepts it. Let's just run it. As Tejas told us, we should enjoy our life. We're taking this break. Actually, I did the inverse. Because I enjoyed life so much, because I was sitting on the train that was late, I started using agents. I think it's a spiral from there. What you can see is it actually now gives you a pretty long output, which has a lot of information in it. I, as well, structured it, but we can go through this here as well. It actually tells me, so you did a train journey. You started in Braunschweig, which has this EVA number. You can check it in a second. You arrived in Munich, which is not right.
I went through Ulm. Why not taking a departure? Interestingly, it told me I went through Dortmund. Why, I don't know, but it definitely changed. Then it told me, after you went through Munich, you continued to go to Lindau-Reutin. Fair enough. If we look on the file, it actually tells us, ok, we went from Braunschweig to Ulm and then ended up in Lindau-Reutin, but not really going through Munich.
It works totally fine. There are three interesting things here. If we actually tell it to run the next script, and we want to fetch the timetable for Munich, we don't get anything, because that EVA number doesn't exist. Then, more interestingly, if we fetch the timetable for Lindau, it told me, here's the timetable for Landshut. Great. For the side effect on why did it come up with that I went through Dortmund and to Ulm. I as well asked the AI, could you tell me or could you just plot me this mistake you just made? That's what the AI came up with. If you go south, you will end up in the Baltic Sea from here, obviously. Interestingly, Lindau is at Lake Constance, which is pretty much here. I'm not sure how it came up with me starting in Hamburg. I don't know what Borsig is. Yes, it told me it's great.
You have the planned route, which is dashed. Makes sense. Your planned route is always dashed and the actual one is solid. My planned route should go from Braunschweig to Ulm to Lindau. It goes through Dortmund, Frankfurt, which is in a totally wrong place, to Ulm, and then to Lindau, which that part actually is totally correct. Then my alternative route, which should have gone through Dortmund, didn't go through Dortmund. Which, interestingly, there is a train corridor running pretty much like that through Germany, which is the Kassel-Frankfurt corridor. Then I was like, can you do the same thing for Munich? Because I got it this morning. I just, for the fun of it, added this map as well. I didn't travel through Munich. It appeared on the map now. It's somewhere between Saxony and Turin. I'm not sure if Markus Söder would approve of that map. Let's swiftly hide it.
Let's do one thing first. We now got names. We got EVA numbers that don't match up with the names at all. We have an ID. We have a name. This is totally weird. Let's actually hop into the next thing and actually force the model to make a decision. What we can do for that is we can just encode our stations into enums. Because we basically just give it a random ID. In this case, I took the EVA number. This doesn't work in Python code, but programmatically you can create this enum. I just told it, ok, the value for it is Ulm. Then, of course, I did that for all the different stations. It basically just now got a new output schema, which is basically, ok, just give me the list of stations you can find on the image mapped to my enum with the planned arrival time and the actual arrival time.
Luckily, I already ran that by accident, because otherwise you could have grabbed the coffee and came back. It took 140 seconds to answer that question. The answer ended with a station named Ulm, a planned arrival time. That was it. It wasn't valid JSON. Why? Because it knows the answer. You went from Braunschweig and then to Ulm, Ulm, Ulm, Ulm, Ulm, Ulm. My younger sister lives there, so it might have been true on some occasions. I'm not sure which time span you then count as spending in Ulm. We can solve that pretty easily, because the cool thing with schemas is you can actually give it hints. You can tell it, ok, these are the stations that are on the screenshot. Each should be included only once. The whole list shouldn't be longer than 10 entries. This, of course, works marvelously well. It will create me a list of stations, which is limited.
Either way, there is a second way to solve this problem, which is just take a model that thinks. I purposefully used Gemini Flash Lite. The normal Gemini Flash can solve it by just thinking and being like, yes, ok, I'm doing gibberish here. Let's go back. I purposefully kept it simple because it fails on some other interesting tasks. What happened? It actually gave me stations now. I started in Braunschweig. The cool thing is it actually gave me a station enum, which is amazing, because this is something I can throw against my database. I can throw that into a metric system. I can literally say, I made a decision, and that is the decision. I don't have to do an extra step from here. It told me I went to Ulm. Interestingly, it told me I left from Ulm and I then went to Ulm again. That's yet different from yesterday.
It still told me that I went to Ulm three times, and that it took a circle right in the end to go from Ulm to Ulm. Very interestingly as well, it now actually started giving me actual arrival time as none. Why is that? Because I explicitly told the schema, you can return me none here. That's what you can see if you go to the screenshot. You can see for future departures, there is no actual departure time. There is no actual arrival time. If you don't tell the schema explicitly that this is a nullable field, it will give you a number. If it has to make one up, it doesn't matter. It will give you a number. We now went in circles. We went back and forth.
Rule 2: Restrict Your Schemas
Rule number two, restrict your schemas. That means time and time and none is very different. At the same time, if you know in advance that on the screenshot, there should only be at least 10 entries or at best 10 entries, tell that to your model in advance. The more business knowledge you already put into this prompt, the better it will get. Let's circle back on this interesting point of going in circles. Why did we actually go in circles? Let's split this up into two things. Let's only find the stations on the screenshot one by one. If I actually search for the raw stations and just don't do any mapping, I just search for the stations. Tells me, ok, you went from Braunschweig to Ulm and then from Ulm to Lindau, with the times it can actually read. Because I asked it for essentially the dumbest task.
It doesn't have to think. It doesn't have to do any translation right now. It actually gave me the right values. If we now start looking this up, first, we can now just go through the stations and do actually the mapping one by one. What we do now is we just take the text of each station and we basically tell it to map the station. Dear model, here is the station name. I again created the enum of stations. What it does, it tells me, the station number for Ulm is 8000170, which is actually correct. Let's run this for Lindau. The station number for Lindau is 800013. Since I now have an ID for a database, I can look it up and it's Augsburg. Geographically close, and wrong. Let's do one more important addition. Let's add none here again. If we add none here, it will actually tell me, this is not in my DB, because I used a small DB which just had the largest train stations in Germany, which is like 300.
Lindau-Reutin is next to the Austrian border. It's not really the most traffic point, even though it's marvelously beautiful. Biking there is more fun than taking the train. Sometimes as well faster. That's just a side story. What happened here? I just told it to take actually the whole database with all the stations and run it with the exact same script. I have this cool, super long database. We have the super cool long database now, and it tells me, invalid argument. The specified schema produces a constraint that has too much branching for serving. That is actually an amazing error message. I did that experimentation-wise two years ago against the Azure OpenAI endpoints. There is still a LinkedIn post, which I think got 15 views, which either means I have no friends or it wasn't relevant for anyone. You can choose. Afterwards, the Azure endpoints just worked sporadically for two days. Must have been an unrelated problem. Normally the limit is around like 500 enums. If you go above that, the model provider just tells you, you can't do that. We don't want to answer that question. We can solve that as well.
Rule 3: Separate Mapping to a Task That Only Has the Relevant Inputs
First, let's hop back to the cool robot, which explains all that stuff. One thing we saw is like, we should separate mapping from the task of actually getting the relevant inputs. Basically, as well, an amazing thing to see here has somewhat semantically something to do with what I originally told the image to show. What we really want to do is get the relevant outputs and then do the mapping, because now we actually have solved this problem. We can check it for each station individually, and we got the correct output from the image. One important distinguishing point here, if you're sure that the input is everything that is relevant for the mapping. For example, if you say, give me all the stations on the screenshot and then you map them, you can separate that. If you as well say, give me the station on the screenshot where a delay occurred, you can map it.
If you don't know the inputs you need for your mapping or they are not structurable, map during predict. If you, for example, need to classify if the image is a screenshot with a delayed train on it, yes, like what kinds of features do you input there? Hard question. In that case, mapping the whole image to a class is totally fine. We chopped it up into pieces. We now found all the stations. We found the delayed stations and the timetable for it, and we mapped the stations on the way. Let's revisit the mapping. There's just one small hack we have to do to map all of them, which is actually map by ID instead of mapping by enum. That is a super interesting thing for everyone who wants to get a model to output a number in a certain range. You can as well tell that to your JSON schema.
You can just take all the stations you have, you can map them to an ID. Then you can tell your model, give me a station ID. The result has to be bigger than 0 and smaller than your station map. You indeed know that it is in the range of the options you can choose. You can't choose anything else. I'm not entirely correct, you can choose none, which is, it's not in the list. With that, we now as well know the EVA number for Lindau-Reutin. We have everything mapped. We can go from here.
Rule 4: You Can Always Enforce to Choose from a Number of Options
Now we enforced it to actually choose from these number of options. As the next step, we want to do something which is as well super important. The cool thing is the robot already hints what we should do next, validate your choice. The cool thing is given that we have a structured choice and we have the options we can choose from and we have extra information for it, we can now just tell a second model to validate our choices. If we go in the code, we can basically just have a so-called discriminator. The first thing we do is we map the station. I think by now we know how that works. The second thing is we basically validate our choice. We basically just tell a model, dear model, you're given a train station name and a station, your task is to return if the station is the correct one, and false otherwise.
The train station name that I expected is this one and the station that was selected is that one. Let's do that. It tells us you shouldn't structure or make commands with an AI because it will miss one important step. The cool thing is it will tell us false. It doesn't think that the output of Lindau-Reutin actually matches the name of the model, which is Lindau-Reutin. More amazingly, and that's the interesting point here, is you now have two signals for this one prediction. You have the value that you predicted that you can check as a metric against a later coming ground truth. At the same time, you can check your validator results to say how often did it say true or false. You have two easy means to check, is my original model wrong and is my validator wrong? Of course, you should in the beginning do five checks manually, but apart from that, you can now start automating it and have automated means to tell your accuracy.
Rule 5: Validate Your Result
Then, as the next steps, we actually have to figure out, let's find the next departing train. Fair enough. How do we do that? I said, there is this nice API that can tell me departures. I actually threw all of that into a database, and for one very easy reason, always start by narrowing down the options you have. If you take all trains departing at the station in Germany throughout a year, no model will be able to tell you that without extra information. What do you want to take? You want to take all departing trains on a day after a specific time because you know all the other choices are invalid. You now have means to tell your model, this is already your restricted search space. Additionally, we just add the departure time and the planned path into the prompt. The planned path, you will see it, it's basically just a pipe delimited list of the stations to which this train will go.
Amazing data structure, but we're not API designers, we're the platform folks. It's not my API, so it's not my fault. Then you'll just say, find the next train to the destination. We do the exact same as we did before. We basically just put the departures into an integer, which is between 0 and the length minus 1 to choose from. We tell it to actually choose the departure. What it will do is it will indeed choose a departure. The departure is not here. Can I copy? Good that there is PowerPoint notes. That one cannot copy and put into Cursor. No module name, find next train, because it's an integer, find next train. What it will do is it will tell me, there is a next train leaving this station. It's going to Lindau-Reutin. It actually figured out the right planned path. It's departing 19 minutes past 10:00, which, yes, might be correct. If you arrive at 6:24 in Ulm and the model tells you that you can leave in 4 hours, and that would actually be truth, you would be mad.
Rule 6: Narrow Down Deterministically
Which actually brings me to my next points. The narrowing down we covered, but more importantly, don't let AI do the deterministic comparison. Instead of asking for the next train that departs here, ask for all trains that depart to Lindau, and then just check what is the train that is closest to my departure time using code. It's just very important to separate the AI logic and the code in this case, or you throw an MCP into it. You can build the MCP in Cursor. It takes probably 10 times the time of writing this one function. I would just write the function. Look at the amazing things we just got for free. We got easy metrics. We can go to the ops folks and tell them like, this is the accuracy of the model. I somewhat got ground truth down the road. This is how often the model failed the validation and I had to reiterate to ask it the question again.
I can easily integrate with legacy because I now get an ID back and I can compare that ID to the database, or I get times back, which are structured as times. I know it is a time I can compare to the time that I have in the database. On the next level, I have a composable design. I can build around this like, ok, I have this one agent, which is just taking care of extracting the information from the screenshot. Then I have the next agent that maps it to my EVA numbers. Then this next agent, which gives me the departure time. That as well ties into, we now have a separated model view controller again. We have a state we can work with. We know what transitions we have to take to change the state. We have a separate mean to show it on the frontend side.
The model is pretty straightforward. We basically have the input that we originally got, which is the screenshot. I need to hold this in the state. We have the output that we want to generate. In the end, I want to have the EVA number where I change the train and I want to have a number of the departure of the next train. I have this controller that just looks at the current state. It looks at where I want to get, and it just asks the LLM to do this one niche transition. Finally, and that's the coolest thing, we can satisfy business because the view can still be an AI chat interface, which is all they care about. It doesn't matter how it looks underneath as long as it looks like a ChatGPT interface. It is accepted by business.
まとめ
There's of course a price we paid. We now have rigidity again. You need to define the state. If you want to change something in your process, you actually need to touch code again. You can't just like, I want this to be done as well. Just throw it in the prompt and it should work out. At the same time, this state that we got just pays off so much, because in exchange, we don't have to fight with the folks that are running signals. We don't really have to fight with our customers when we told them, yes, just go to this demolished train station somewhere in nowhere and look at the timetable. We could just eventually make everyone happy with the extra effort we put in. We force product to again think about what they actually want to achieve. They have to tell us what the outcome is.
They can't be like, yes, just give me a bot that gives me the next departure. It's fine. That's enough requirement for you to build it. Always split up the things you do. Stick to best practices on your output schema. Next time you're stuck on a train, just figure out what you want to prompt today. There is always something you can prompt, except if you're between Munich and Bregenz because the internet connection just sucks.
Questions and Answers
Participant 1: You started out in your solution, leaving everything to the LLM and then didn't work. The end result that you arrived at, the part that the LLM played, am I correct in saying that the only thing that the LLM really did by itself, where you didn't really know how it happened, was to extract the station names from the screenshot and everything else was deterministic, just lookups of station names and stuff through JSON APIs?
Jendrik Jordening: No. The lookup of the station name is as well LLM based, because I basically say like, I have all these probably misspelled station names and I just want to get the semantically closest one. It makes sense to do that with an LLM as well. Of course, the checking what kinds of trains depart after a certain time is deterministic, but then choosing the train that goes to Lindau is again LLM based. Literally everything that really has to do with text is given to the LLM because it's good at doing things that contain text. It just sucks at everything that has to do with numbers or should always do the same thing.
Participant 1: Didn't you say at the end that when you let the LLM pick the next train, it took the one that's 4 hours, so your solution is to say, give me all the connections, which is an API call, and then the user makes a choice?
Jendrik Jordening: No. You have the departures. I can show you how it looks in the XML. Let's just run this again. What you can see here is the departures are still somewhat unstructured. A departure of course always has a departure time, but it has this planned path, which just has the different stations. You still need an AI to figure out which train actually goes to where I want to go, but it should pick all the trains that go to the location you want to go to. Then the comparison between my current time and the departure time, you should of course do deterministically again. Really figuring out which train you can take is something you can better do with AI than doing like fuzzy matching on the planned paths.
Participant 2: First of all, it's really comforting to know that other people are actually looking to help others that are suffering with Deutsche Bahn. I think it's interesting that you went with the approach of verifying stations. Have you also thought about taking into account the train lines that are actually going? For example, my use case is from Nuremberg to Munich and there are at least two lines. I think in summer there are also three lines that you use, but usually it's just two lines, and you know the stations along the lines. Have you thought about adding the flexibility to having multiple ways for the AI to figure out this information and piece it together?
Jendrik Jordening: No, I didn't consider that. Actually, I went through Memmingen. I actually took another switch on the way because it was faster, but that was more on the DB side of how to solve it. That's why I was like, for actual choosing, it doesn't add value. That's why I left it out. Basically, you could figure that out as well if you add more planned paths with multiple hops.
Participant 3: For the data validation step, did you actually put the validation result back into the LLM to figure out if it can choose better?
Jendrik Jordening: That would be something where you can then do somewhat an LLM enabled controller. If you have your current state, the state you want to get to, and the history and conversation, that's where you could then actually use the LLM to decide where do you want to go next. You try to figure out the EVA number, you validate it. It tells you, no, you're wrong. Then you just basically throw it into the LLM, and there it is then as well interesting to do a mix of LLM enabled decision of what to do next and rule-based decision. Because after you extracted a station, you always want to look up the EVA number, but from there you could ask the user, I found the number. Do you want to see next departures, or do you want to see if you can get a car instead at the rental place, because you don't want to take the train or a bike because you're faster then? That would be then the point where you could then go back to the user, the user can answer it. Then, from there, you can choose your next action.
See more presentations with transcripts
Recorded at:
Aug 17, 2026
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み