特定業務のAI機能を作るのではなく、どの業務エージェントにも共通する実装パターンを 身につけるための訓練用リポジトリ「Business Agent Training Lab」を作った話。 ローカルのDockerだけで完結し、APIキーなしでも動く。
この記事の要点
  • 特定業務ではなく「どの業務エージェントにも共通する骨格」を学ぶ訓練用リポジトリ
  • RAG・Tool Calling・人間承認・監査ログ・非同期処理・Evalを Phase 0→10 で積み上げ
  • 業界固有語ゼロ/ローカルDocker完結/APIキー不要のmock既定で動く

「社内業務エージェントを作りたい」という相談はよく来るが、いざ手を動かすと 毎回同じ土台を組み直すことになる。依頼を受け取って意図を分類し、必要なデータや文書を探し、 LLMにContextを渡してToolを呼び、危険操作は人間承認を挟み、すべてを監査ログに残す—— この骨格は業務が変わっても変わらない。

そこで、業務固有の機能ではなく「骨格そのもの」を学ぶための訓練用リポジトリを作った。 RAG / Tool Calling / Structured Output / Human Approval / Audit Log / Worker・Queue / File Generation / Eval・Guardrails / Agent Routing / LLM Provider抽象—— 業務エージェントに共通する要素を、Phase 0から順に積み上げていく構成になっている。

1. 業界固有語を一切使わない

この訓練リポジトリで最初に決めた方針が、業界固有語を一切使わないことだ。 特定業界の言葉で書くと、その業界を知らない人には本質が伝わらず、 「この業務の知識」と「汎用的な技術パターン」が混ざってしまう。

そこで Assistant / Record / Document / Case / Contact / Notification / Report / Approval / Knowledge Base といった汎用語だけを使い、 サンプルデータも架空の業務管理SaaS「Acme Operations Platform」を題材にした。 こうすると、読む人は業務ドメインに気を取られず、純粋に技術パターンへ集中できる。 業務差分はTool / Context Builderに閉じ込め、Agent Coreは汎用に保つ——これが背骨になっている。

2. アーキテクチャ全体像

[web (React/Vite)] ──/api──▶ [api (Fastify)] ──▶ PostgreSQL(pgvector)
                                   │  └─ LLM Gateway ─▶ openai / anthropic / mock
                                   │  └─ Tool Registry / Approval / Audit Log
                                   └─ Queue(BullMQ/Redis) ─▶ [worker] ─▶ MinIO(S3)
web / api / worker の3層と、LLM Gateway・承認・監査ログ・非同期処理の関係

pnpm workspacesのmonorepoで、web / api / worker / shared に分かれている。docker compose up --build ひとつで web / api / worker / postgres / redis / minio の6サービスが立ち上がる。 型とZodスキーマは @lab/shared に集約し、API・Worker・Webで共有する。

設計の中心にある考え方は3つ。LLM GatewayでProviderの差分を閉じ込め、 上位のAssistantはどのLLMを使っているか意識しない。Tool RegistryでAssistantが 使えるToolを一元管理し、riskLevel で承認要否を制御する。 そして危険操作は必ずPreview → 承認 → 実行の順で人間を挟む

3. フェーズで積み上げた技術

コミット履歴を時系列で追うと、何をどう作ったかが順に分かるようにしてある。 ここでは積み上げの流れを要素ごとに振り返る。

基盤づくり(Phase 0–1)

monorepoとDocker構成、生SQLマイグレーション、冪等なseedランナーを用意した。 ORMを使わず生SQLにしたのは学習目的で、スキーマを直接読ませたいから。 型安全性は @lab/shared で別途補う。

地味にハマったのが、bind mountとpnpmのsymlinkの相性だ。workspace全体をマウントすると pnpmの node_modules のsymlink構造を壊しやすい。 対策として「イメージ内で pnpm install し、実行時は src だけをマウントする」 構成にして、hot reloadと依存解決を両立させた。 postgresは最初からpgvector入りのイメージを選び、後のRAGでそのまま VECTOR 型を使えるようにしてある。

LLM抽象とChat(Phase 2–3)

LlmGateway インターフェースを定義し、mock / openai / anthropic の3 Providerを用意した。 mockをデフォルトにしたのが効いている。taskName ごとに決定的な固定応答を返すので、 APIキーなしで研修やCIを回せ、スクリーンショットも安定する。 openaiは骨組みに留め、anthropicは「未実装」を明確にするためあえてthrowさせた—— 中途半端に動くより、できていないことが分かる方が学習者は混乱しない。

Chat APIでは 最初からAudit Logを通した。後付けにせず、 user_message_received → assistant_selected → llm_requested → llm_responded を記録する。LLMが失敗したときも guardrail_triggered として監査に残す。 「AI関連操作はすべて記録する」を、機能が揃う前の段階で習慣づけておくのが狙いだ。

Tool Calling(Phase 4)

BusinessTool(name / description / inputSchema / riskLevel / execute)と、 それを一元管理する ToolRegistry を作った。Assistant側はToolの実体を知らず、 registry.definitions() をLLMに渡すだけ。LLMがtoolCallを返したらRegistryで実行し、 結果を再入力して最終回答を得るループになっている。

riskLevel を最初から型に持たせたのは、後のHuman Approvalへの布石だ。 まずは読み取り系(low)の検索Toolから始める。 暴走を防ぐため maxSteps で打ち切る安全弁も入れた。 DBアクセスは引数で注入できるようにして、Toolのテストをdocker非依存にしている。

RAG(Phase 5)

ここでもembeddingをmock既定にした。文字バイグラムをハッシュして1536次元の バケットに加算しL2正規化することで、語を共有するテキスト同士のcosineが上がる。 外部API課金なしでも「近い文書が上位に来る」体験ができる。 空白分割だと日本語が1トークンになってしまうため、文字バイグラムを併用して日本語にも対応した。

検索はpgvectorのcosine距離で行い、minSimilarity 未満のchunkを捨てることで 「根拠がなければ答えない」回答不能判定の入口にしている。 mock embeddingは語の重なりベースなので言い換えには弱いが、 Embedder 抽象を挟んであるので実embeddingに差し替えられる。

Human Approval(Phase 6)

ここがこのリポジトリの肝だ。書き込み系Tool(通知下書き・設定変更)は riskLevel: high とし、 execute は副作用なしでpreviewだけを返し、executeApproved で初めて実際の副作用を行う ように分離した。Chat中は絶対に副作用を起こさず、実行は承認後の別経路で行う—— 「LLMに直接DBを更新させない」原則をコードで担保している。

承認要否は approval_rules テーブルを引いてデータ駆動で決め、highは安全側(既定true)に倒す。 状態遷移(pending → executed / failed / rejected)は純粋なステートマシンに切り出し、 終端状態からの再操作を弾くロジックをテストした。 承認後は実際にnotifications / settingsへ書き込むので、承認フローが絵に描いた餅にならない。

Worker・Queue とファイル生成(Phase 7–8)

レポート生成のような長時間処理はWorkerに逃がす。APIはJobを作って即202を返し、 重い集計は別プロセスで実行する。Job状態はDBで追え、キュー名は @lab/shared に集約して producer(API)とconsumer(Worker)のズレを防いだ。

ファイル生成では、まず行配列に落としてからdocx化する2段構えにした。 buildReportLines を純粋関数にして「何が書かれるか」をテスト可能にし、docx化はその先の薄い層にする。 保存先は FileStore インターフェースで抽象化し、MinIO / S3の差を意識しない。 APIはファイル本体を中継せずpresigned URLを返してクライアント ⇔ MinIO直結にすることで、 APIの帯域とメモリを使わない設計にした。

Eval・Guardrails(Phase 9)

入口と出口の両方で検査する。入力ガードレールは空・長すぎ・プロンプトインジェクションを弾き、 出力ガードレールは秘密情報の漏洩を検出したら安全文に差し替える。 どちらも guardrail_triggered として監査に残す。 判定ロジックは純粋関数にして、Auditへの記録は呼び出し側に置いた。

Evalはmockで決定的に採点する。同じ入力に同じ出力が返るので、外部APIもDBもなしで CIから「正しいToolを選べるか」「弾くべきを弾けるか」を検証できる。 完璧な防御は狙わず、機械的に判定できるものに絞ったのが割り切りだ。 「根拠なしの断定」のような汎用的な誤検知が避けにくい判定は、今回は見送っている。

Router(Phase 10)

最後に、依頼内容から担当Assistantを決める意図分類を入れた。 ユーザーは auto を指定するだけでよく、Routerがキーワードからreport / scoring / action / knowledge へ振り分ける。明示指定はそのまま尊重する。 まずはルールベースで決定的にして挙動を読みやすくし、後からLLM分類に差し替えられるよう純粋関数にした。 1リクエスト=1 Taskとして running → done / error を記録し、Task Boardの土台にしている。

4. 全体を貫いた設計判断

フェーズをまたいで一貫して効いた判断が3つある。

5. まとめ

業務エージェントは、業務ごとに見た目は違っても骨格はほとんど同じだ。 この訓練用リポジトリは、その骨格——RAG・Tool Calling・人間承認・監査ログ・非同期処理・Eval——を 業界固有語を排し、ローカルDocker完結で、APIキーなしで学べるように作った。

「特定業務のAIを作る」前に、「どの業務でも変わらない型」を一度自分の手で組んでおくと、 次に本物の案件が来たときの土台になる。そのための素振り場として作ったリポジトリだ。