AIエージェントに業務を任せるとき、怖いのはハルシネーションではありません。本当に怖いのは、間違えたままの書類を社外に送信してしまうことです。見積、発注、請求——一度取引先に届いてしまえば、取り消しはできません。システムデモでどれだけ賢く見えても、この「取り消せない一線」の手前で止まれないなら、現場に導入することはできません。
そこで必要となってくるのが human-in-the-loop——AIを途中で止め、人の判断を挟んでから先へ進める仕組みです。ただし、これはそれほど簡単ではありません。人の承認は数秒では返ってきません。
数時間、ときに数日後になるかもしれません。その間にサーバーが再起動するかもしれない。承認の前後で、人が数字を書き換える可能性もある。「止めて、待って、正しく再開する」を業務システムとして成立させるには、それなりの作り込みが要ります。
この記事のねらいは、その作り込みを具体的なコードで見せることです。本当に求められているのは「賢さ」より「間違えたときに止められる構造」です。——その”止まる”を、interrupt() と checkpointer、そして本体から独立した検査役で、どのように実現したか?
この記事でそれらを順を追って説明し、他のエージェントにも適応できる原則は最後にまとめます。
このシステムの設計に興味がある方は、以下の記事を参照してください。

また、環境構築を含めた実装の部分について知りたい方は、以下の記事をご覧ください。

システムの全体像 ── 工程と状態を押さえる
まず、このシステムの「受注」がどんな工程を経て、どのように状態が管理されるか説明します。
受注が通る工程の全体像
以下がこのシステムのワークフローです。以下のフローを見ると受信した「受注」がどのようなプロセスを経て、最後の「請求書生成」まで流れていくかがわかると思います。

このプロセスの中で人間の承認を必要とするのは、「受注承認」と「見積承認」の2つのプロセスです。
このシステムのノード接続、build.py について知りたい人は、開発記録の記事を読んでください。
案件のステータスはDBに持たせる
工程が進むごとに、受注のステータスは変化していきます。
この状態はメモリやグラフの中ではなく、すべて Supabase に持たせています。LangGraphもUIを担うNext.jsもDBを読み書きして状態を共有しています。承認画面は「いまどの案件が承認待ちか?」をDBに尋ねるだけですべての案件の状態が把握できます。
今から同じ構成で作る場合、2026年5月30日以降に作ったSupabaseのプロジェクトでは、public の表がData APIに自動では公開されません。orders や audit_logs を読み書きする前に、表ごとに grant を書いてください。既存のプロジェクトも2026年10月30日からは新しく作る表が同じ扱いになり、grant がないと読み書きが「permission denied」で止まります。
受注(orders)のステータスは、ほぼ一本道です。
status text not null default 'received'
check (status in ('received','structured','Pending Approval',
'Held','Confirmed','Rejected'))
-- received: 取り込んだ structured: 構造化済 Pending Approval: ゲート①で人待ち
-- Confirmed / Rejected: 承認・却下 Held: 承認後、検査役が封鎖して宙づり見積(quotes)も同様に generated → pending_review → approved →(検査役)→ sentというライフサイクルを持ちます。明細(order_lines)は line_type(確定注文/要見積)で枝分かれし、後述の限定編集はこの行に効きます。
ここで大事なのは、Held という状態を明示的に持っていることです。「承認は下りたが、検査役が止めていて、まだ社外には出ていない」——この状態が存在することにより、承認待ち一覧とは別に「検査で止まっている案件一覧」を出力することができます。
AIはどのプロセスで止まるか?
以下のコメントパートは受注と承認の前後のノードの繋がりを表しています。
START
→ structure_order → split_lines
→ order_gate ① [人の承認:interrupt]
承認 → order_egress [検査役] → deliver_order(受注書を送信)
→ (要見積の明細があれば)enrich_material → generate_quote
→ quote_gate ② [人の承認:interrupt]
承認 → quote_egress [検査役] → deliver_quote(見積書を送信)
→ ENDこの中で、社外への送信を担うのは deliver_* ノードだけです。そして、その手前には必ず2種類のゲートがあります。人が承認するゲート(受注承認①・見積レビュー②)と、その後ろに立つ検査役(外部送出ゲート)です。承認を通り、検査役を通ったものだけが、外部に向けて送信されます。
ここでは順序が重要です。検査役は承認の”前”ではなく”後ろ”にいます。検査役は人間が誤って問題のある受注書・見積書を誤って承認したとき発動するガードとして機能します。
人間の承認 ── AIを待たせる仕組み
1つ目のゲートは、人間の承認です。AIを途中で止め、人間のチェックを待ってから再開する——その仕組みの実装方法を見ていきます。
「止めて待つ」を interrupt() で表す
承認が必要な工程では、ノードの中で LangGraph の interrupt() を呼びます。
すると、AIは人間の判断が出るまでその場で止まります。止めるかどうかは工程ごとのモード(auto/approval/manual)で決まり、モードは環境変数ではなく DB(mode_settings)から読みこみます。モードは動的に変えたいし、状態はデータで一元管理したいからです。
# order_gate 抜粋:approval なら注文を止めて人を待つ
db.table("orders").update({"status": "Pending Approval"}).eq("id", order_id).execute()
decision = interrupt({"gate": "order_confirmation", "order_id": order_id,
"lines": state.get("lines", []), "confidence": state.get("confidence")})
# ---- 人の決裁が来たら、ここから再開する ----
approved = bool(decision.get("approved"))
db.table("orders").update({"status": "Confirmed" if approved else "Rejected"}) \
.eq("id", order_id).execute()auto モードなら interrupt() を通らず自動で承認、approval なら止めて待つ。「安全に自動化を広げる」とは、精度が確かになった工程から、このモードを auto に上げていくことです。最初から全部を自動にすることはしません。

状態の永続化はcheckpointerが担う
interrupt() で止まった状態は、グラフを compile(checkpointer=...) してあるおかげで永続化されます。
これにより人間が翌日承認しても、途中でサーバが再起動しても、続きから再開できます。承認結果は Command(resume=決定) で戻し、interrupt() の戻り値として受け取って先へ進みます。
この書き方は、2026年9月の版でもそのまま使えます。公式ドキュメントの例は今は stream_events(version="v3") で書かれていますが、下の invoke() も引き続き使え、止まったときは結果の "__interrupt__" に入ります。
# 承認エンドポイント側:人の決裁でグラフを再開する
graph.invoke(Command(resume={"approved": True, "actor": employee_id}),
config={"configurable": {"thread_id": order_id}})「止めて待つ」を、その場のメモリ待ちではなく業務として成り立たせているのは、この checkpointer です。人間の承認は、数秒では返ってきません。数時間、ときに翌日になる。その現実に耐えるには、状態がディスクに残っている必要がありました。
なお、再開すると interrupt() より前のコードがもう一度動くと、公式に説明されています。上の order_gate の状態の書き込みは同じ値を書き直すだけなので、このままで問題ありません。
選べるのは、承認・修正・差し戻しのいずれか
承認画面で人間ができる操作は3つだけです。
承認、修正、差し戻し。以上の3つです。修正は「単価」や「数量」など意図的に「限られた項目」だけにしています。何でも書き換えられる万能の編集画面は、ヘッドレス基幹の思想に反するからです。
承認と差し戻しは1つの共通のエンドポイントを使い、approved の真偽で分岐します。どちらもグラフを再開し、audit_logs に誰の操作かを残します。
# 承認/差し戻し:approved で分岐し、グラフを再開して監査を残す(/orders/approve)
decision = {"approved": body.approved, "actor": body.actor}
get_graph().invoke(Command(resume=decision), config={"configurable": {"thread_id": order_id}})
db.table("audit_logs").insert({
"actor": body.actor,
"action_type": "approve" if body.approved else "reject",
"target_table": "orders", "target_id": order_id,
"before": {"status": before_status}, "after": {"status": after_status},
}).execute()修正は、許可した列だけを受け付けます。許可外のフィールドが来たら、エラーコード400を返します。編集できるのはゲート①で止まっている間だけで、数量や単価が動けば金額も再計算します。
# 限定修正:許可した列だけ受け付ける(/order-lines/edit)
_ORDER_LINE_EDITABLE = {"quantity", "unit_price", "requested_delivery_date"}
bad = set(body.fields) - _ORDER_LINE_EDITABLE
if bad:
raise HTTPException(400, f"fields not editable: {sorted(bad)}")
# ゲート①で停止中のときだけ編集可。ここで単価が動くからこそ、検査役は承認の後ろに立つ。外部送出ゲート ── 検査役を承認の後に置く
2つ目のゲートは、承認の後に立つ検査役です。誤って承認された書類を、社外に出る直前で止めます。
このシステムの既定のモデルは、実装した当時の claude-sonnet-4-6 です。このモデルは今も使え、2027年2月17日より前には提供を終えない予定です。今のSonnetは Sonnet 5.5(claude-sonnet-5-5)で、料金は100万トークンあたり入力2ドル・出力10ドルです。
承認の後に控える検査エージェント
ここからがこの記事の本題です。
人間の承認そのものも間違いをおかすことはあります。特に何十件という数の受注情報を承認する場合は勢いで間違ったボタンをクリックしてしまうことは十分に考えられます。そこで承認の後にAIエージェントを使った検査役を配置することにしました。
ここで重要なのは、検査役が値付けをやり直さないことです。値付けに使ったのとは別の証拠から、独立に期待値を組み立て、送られる数字と突き合わせます。証拠が同じなら、間違いも同じになるからです。
だから検査役が引く過去実績は、決定論的な検索に限っています。値付けエージェントが使う意味検索(ベクトル検索)とは、わざと別系統にしてあります。充分な独立性を持たない検査役は、検査役として不十分です。
def by_drawing(drawing_number, limit=10):
"""検査役の証拠は決定論的に引く。値付け側の意味検索とは別系統にする。"""
rows = (get_client().table("past_jobs").select(_COLUMNS)
.eq("drawing_number", drawing_number)
.order("ordered_at", desc=True).limit(limit).execute().data or [])
return rows止める条件と警告を無視する自由
止める条件はシンプルです。深刻度の高いフラグがひとつでも立てば停止して警告を表示する。ただし、理由を入力すれば押し切って外部に送信できます(override)。
high = [f for f in flags if f.get("severity") == "high"]
if not high:
return {"flags": flags, "result": {"blocked": False}} # 素通し
# 高深刻度が1件でもあれば封鎖。override(理由つき強行)か、差し戻してループ。停止された書類は held 状態で止まり、人が「(強制的な)送信」か「差し戻し」を選ぶまで停止しています。差し戻せば承認ゲートへ戻り、入力値を直せばもう一度検査を通ります。
検査役を Sonnet 5.5 に替えると、思考が最初から有効になり、max_tokens に思考の分も入ります。ツールを使うループで動かす場合は、返ってきた思考の部品をそのまま次の呼び出しへ戻してください。既定以外の temperature などを送るとエラー(400)になります。

AIエージェントを安全に運用するための原則
ここまでの実装から、AIエージェントを本番で運用するときに有用な原則を抜き出します。
- 外部に出る経路を一本に絞る。 取り消すことが出来ない出力は一箇所からしか出さず、そこへ至る道に必ず検査役を置く。
- 止める場所は、実行時でなく構造で固定する。 どこで止まるかをコードとグラフで決め、モデルのその場の判断に委ねない。
- 人の承認は「本当に待てる」形にする。
interrupt()と状態の永続化(checkpointer)で、翌日でも、再起動後でも、続きから再開できるようにする。 - 検査は独立させ、承認の「後ろ」に置く。 実務を進めた本体とは別の主体が、別の証拠に基づき、社外に出る直前の最終形を検査する。
- 最終責任は人に置き、AIは注意を導くだけにする。 検査役は承認を置き換えません。しかも「いつも止まる」装置は反射で無視されるので、止めるのは本当に危ういものだけにする。
- 止めるのに許可は要らない。 緊急停止は誰でもボタンを押すだけでできるようにする。鍵をかけるのは停止の再開とモード変更の時のみにする。
最後の原則は、コードにするとこうなります。停止するには鍵は必要なし、再開とモード変更だけ鍵を掛ける、鍵が未設定なら開けずに閉じる。
# 停止=鍵なし。再開・モード変更=鍵あり。未設定なら 503 で閉じる(fail closed)。
def require_master_key(x_master_key: str | None = Header(default=None, alias="X-Master-Key")):
expected = os.environ.get("MASTER_KEY", "")
if not expected:
raise HTTPException(503, "MASTER_KEY is not configured")
if not x_master_key or not hmac.compare_digest(x_master_key, expected):
raise HTTPException(401, "invalid master key")まとめ
AIエージェントを本番で安全に動かす鍵は、賢さより「どこで止まるか」を、実行時の気分ではなくコードとグラフの構造で固定することでした。承認ゲートは interrupt() で止め、checkpointer で人を待ち、検査役は承認の後ろで最終形を独立に検査する。外へ出る経路は deliver_* だけで、そこへは必ず二つのゲートを通ってしか到達できません。
止める場所を構造で決めてしまえば、あとは安心して自動化の範囲を広げられます。human-in-the-loop は、速度を落とすための仕組みではなく、安心して速度を上げるための仕組みでした。
ワークフロー全体の組み立ては開発記録の記事にあるので、あわせてご覧ください。
この記事は2026年9月に、Supabaseの設定・モデル名・LangGraphの書き方を今の版に合わせて見直しました。
愛知・岐阜・三重の製造業の業務を、無料でAI診断します
担当者が御社へお伺いし、業務の流れを見させていただいたうえでヒアリングを行います。業務ごとの自動化案と想定される効果を、A4一枚にまとめてお渡しします。
対象エリア:愛知・岐阜・三重
診断レポートで分かること
- 業務ごとの削減時間と、人件費に換算した効果
- 人件費以外の効果(属人化の解消、納期回答の早さなど)
- 自動化しないほうがよい業務
- どの業務から始めるかの進め方
ご用意いただくもの
- 普段お使いの書類の見本(注文書・見積書・Excelなど)
- 業務のご担当者の同席(30分程度でも構いません)
社外に出せない書類は、その場で拝見するだけで持ち帰りません。


