業務システムにLLMを組み込む実践パターン — 壊れる前提で設計する
株式会社Bizmarq

私たちは、届いたメールをLLMで自動分類・構造化し、案件情報や候補者情報の登録・マッチングまで一気通貫で処理する業務管理システムを開発してきました。人が毎日読んで転記していた作業を、LLMに肩代わりさせる試みです。
この記事では、その開発で得たLLM統合の実装パターンと設計判断を共有します。LLMは賢いものの不安定です。その不安定さを吸収してシステムとして安定させることが、アプリケーション側の仕事になります。
何をLLMに任せ、何を任せないか
最初にやるべきは、LLMに任せる処理とそうでない処理の線引きです。万能に見えても、安定性・コスト・レイテンシを考えると何でも投げるわけにはいきません。
LLMに任せたのは次の3つです。
- 分類 — 受信メールを「案件」「候補者」「その他」に振り分ける。ルールベースの件名マッチではカバーしきれないケースが多すぎる領域です
- 構造化抽出 — 分類後のメールから20以上のフィールドを抽出する。単価表記ひとつ取っても「50万〜」「500,000円/月」「50-60万」と揺れがあり、正規表現では地獄を見ます。LLMならこの揺れを吸収できます
- ドラフト生成 — 提案文や調整メールの下書きをフォーマット指定で生成する
一方、マッチングにはあえてLLMを使わず、重み付きアルゴリズムを採用しました。
スコア = スキル一致率(0.4) + 単価適合度(0.3) + 勤務地(0.15) + 稼働時期(0.15)
マッチングには説明可能性が要ります。「なぜこの候補を推薦したのか」と聞かれたとき、数値で答えられた方がいい。「AIがそう判断しました」では通用しません。曖昧な入力を構造化するのはLLMの仕事、構造化済みデータの定量比較はアルゴリズムの仕事。この切り分けが効きました。
プロバイダーを抽象化する
LLMプロバイダーは移り変わりが速く、半年後にどのサービスがベストかは分かりません。そこでインターフェースで依存を最小化しました(Goでの例)。
type Provider interface {
ParseEmail(ctx context.Context, input ParseEmailInput) (*ParseEmailOutput, error)
ClassifyEmail(ctx context.Context, body string) (Classification, error)
StructureText(ctx context.Context, text string, schema JSONSchema) (map[string]any, error)
Name() string
}
ポイントは、Generate(prompt) のような汎用APIではなく、業務で実際にやりたい操作だけを定義したことです。プロンプト組み立てからJSON解析まで各実装が内包するので、呼び出し側はメールを渡すだけで済みます。クラウド系とローカル推論系の複数プロバイダーを、同じインターフェースの裏に隠せます。
抽象化しても挙動差は残ります。厄介なのがレスポンス形式です。一部のクラウドLLMは「JSONだけ返せ」と指示してもマークダウンのコードフェンスで囲んで返すことがあり、そのままではパースが壊れます。地味ですが、コードフェンスを剥がす後処理は業務システムで最初にぶつかる壁です。
壊れても動くパイプライン
LLMは壊れます。APIが落ちる、レートリミットに掛かる、意味不明なレスポンスが返る。「LLMが不調なので処理できません」は業務システムでは許されません。私たちは複数レイヤーで耐障害性を確保しました。
フォールバック連鎖 — 複数プロバイダーを順に試し、全部失敗して初めてエラーを返す設計にしました。クラウドを優先しつつ、全滅してもローカルモデルで最低限は動く構成です。フォールバックで精度の低いプロバイダーが使われたときは信頼度スコアを下げ、後から人間が確認すべきレコードとして拾えるようにします。
リトライ — API過負荷(429/529など)には指数バックオフでリトライします。2秒→4秒→8秒と間隔を広げ、コンテキストがキャンセルされたら即中断します。
Graceful Degradation — 全リトライが尽きても、いきなり止めずに部分結果を返します。不完全なJSONが返ったら、分類結果だけは保持し、信頼度を下げ、生レスポンスを保存しておく。60点でも何か返す方が、業務では実用的です。
if err := json.Unmarshal([]byte(cleaned), &fields); err != nil {
return &ParseEmailOutput{
Classification: classification,
Confidence: 0.5,
RawResponse: raw,
}, nil // エラーではなく低Confidenceの結果を返す
}
エラー集約 — 「どのプロバイダーがどんな理由で落ちたか」を1行にまとめて返すと、障害調査の速度がまるで違います。
ドメイン知識はプロンプトに、インフラはコードに
LLMは賢くても、ニッチな業界用語までは知りません。私たちが扱ったドメインには独特の用語がいくつもありました。そこで業界知識をコードにハードコードするのではなく、プロンプトに集約しました。
Notes for parsing:
- settlement_hours: 稼働時間の下限/上限 (例 "140-180h" → min=140, max=180)
- interview_count: 面談回数 (例 "面談1回" → 1)
- contract_type: SES/派遣/請負/業務委託/準委任
ルールが変わったり新フィールドが要るときは、プロンプトを書き換えるだけで済み、コードは触りません。ドメイン知識はプロンプトに、JSON入出力のパイプラインはコードに。この分離がうまく機能しました。
分類と抽出を2段階に分けたのも意図的です。1回の呼び出しで全フィールドを抽出させると、種類の違うデータを無理に埋めようとして混乱します。先に分類しておけば後段のプロンプトをシンプルに保て、プロンプトがシンプルなほど出力は安定します。分類が自明なソース(専用の受信経路など)ではヒントを渡して分類ステップ自体を省き、呼び出し1回分のコストと時間を削っています。
テストは「壊れないこと」に絞る
「LLMの出力は非決定的だからテストできない」とよく言われますが、実際にはかなりの範囲をテストできます。モックサーバーを立て、次のような観点を検証します。
- 正しいJSONを構造体にパースできるか
- コードフェンス付きレスポンスを処理できるか
- 壊れたJSONで Graceful Degradation が動くか
- サーバーエラーでリトライが回るか
- プロバイダー障害時にフォールバックが遷移するか
検証しているのはLLMの出力品質ではありません。「どんなレスポンスが返ってきてもシステムが壊れない」ことだけを保証します。抽出精度はプロンプト評価やE2Eで見るべき別の領域です。この割り切りが大事でした。
まとめ
業務システムにLLMを組み込むときに意識したいことは、次の5点に集約されます。
- 壊れる前提で作る — リトライ・フォールバック・Graceful Degradation は最初から入れる
- プロバイダー依存を排除する — インターフェースで抽象化しておけば追加は実装するだけ
- 知識はプロンプト、処理はコード — 業務知識はプロンプトに集約する
- LLMに向かない処理を見極める — 定量比較はアルゴリズムに任せる
- テストは耐障害性に絞る — あらゆる応答でシステムが壊れないことを確認する
LLMそのものの性能を追うより、その不安定さを前提に周辺を設計することが、業務で使えるシステムへの近道でした。