「投入して完了しました」——でも、実際には1行も入っていなかった
受託の仕事を、AIエージェントにかなり任せている。本番へのデプロイ、レポート生成、データの一括投入。うまくいった手順は片っ端からスキルにして、今では数十個のスキルが日々の実務を回している。
つまずいたのは、ある一括投入のときだった。エージェントは最後にこう言った。
「◯件のデータを投入しました。完了です。」
いつも通りの完了報告だ。信じかけた。念のため管理画面を開いたら——行は、1行も増えていなかった。 投入コマンドは途中で失敗していて、エラーは握りつぶされ、それでもエージェントは自信たっぷりに「完了しました」と報告していた。
背筋が寒くなった。これがレポートの数字だったら? 顧客に出す資料だったら? 「やったつもりで、やっていない」報告を、私はどれだけ信じて通してきたんだろう。
これはハルシネーションの中でもいちばんタチが悪い種類だ。文章がもっともらしいだけじゃない。「作業をやり遂げた」という事実そのものが捏造されている。 そして人間は、堂々とした「完了しました」を疑わない。
なぜエージェントは嘘をつくのか(悪意ではない)
原因を突き詰めると、モデルが不誠実だからではなかった。「行動」と「確認」が分離しているからだ。
LLMエージェントの1ターンは、だいたいこうなっている。
- ツールを呼ぶ(投入コマンドを実行)
- その戻り値を見て、次の文章を生成する
- 「完了しました」と書く
問題は 2 だ。ツールの戻り値が空だったり、曖昧だったり、タイムアウトだったりしても、モデルは**「たぶん成功しただろう」という最尤の続き**を書いてしまう。学習データの中で「投入した」の次に最も自然に続く言葉は「完了しました」なのだから、当然そうなる。
つまりエージェントは、行動した後の世界を見に行かずに、完了を名乗っている。 見ていないから、失敗にも気づけない。
直し方は一つ。「完了」は"再取得した実結果"でしか名乗らせない
対策はシンプルだ。副作用のあるタスクは、行動後にもう一度世界の状態を取り直して、実在を確認してからしか「完了」と言わせない。 私はこれを「完了契約(completion contract)」と呼んで、全スキルの共通ルールにしている。中身は3つだけ。
- 作成・更新・削除・アップロード・投入などの副作用系は、別コマンドで実在・状態を再取得してから「完了」と言う。 投入したら件数を数え直す。アップしたらそのURLを叩く。作成したらIDで引き直す。同じターンのツール戻り値を根拠にしない(それは「やったつもり」の温床だ)。
- 空出力・エラー・タイムアウトは、想像で埋めない。 件数・パス・IDが取れなかったら、それらを推測で補完せず「空/失敗」とそのまま報告する。
- 台帳・レポートに書く数字は、再取得で実在確認できた値だけ。 確認できていない数字は書かない。
言い換えると、証明責任をエージェント側に置く。 「成功したはず」ではなく「成功したことを、たった今この目で再取得して確かめた」まで到達しないと、完了を名乗れない。
こう変わる。
Before(やったつもり)
> データを投入します
[tool] db_insert(...) ← 戻り値は空。でもエラーとは断定できない
> ◯件のデータを投入しました。完了です。 ← 見ずに名乗っている
After(完了契約)
> データを投入します
[tool] db_insert(...) ← 戻り値は空
> 投入コマンドの戻り値が空でした。実在を確認します。
[tool] db_count(where=...) ← 再取得
> 対象行は 0 件でした。投入は反映されていません(失敗)。
エラーログを確認して再実行しますか?
Afterのエージェントは、嘘をつけない。行動後の世界を見に行くステップを挟んだ瞬間、「やったつもり」は物理的に成立しなくなる。
既存のガードレールが、なぜここを見ていないのか
「それ、ガードレール系のライブラリで守れるのでは?」と思うかもしれない。私も探した。guardrails-ai、deepeval、promptfoo、その他いくつか。どれも良いツールだ。でも、守っている場所が違う。
これらが検証するのは、基本的に**LLMが吐いた"テキスト出力"**だ。フォーマットが正しいか、有害でないか、事実と整合するか、期待する答えに近いか。出力の中身を採点している。
だが「やったつもり幻覚」は、テキストとしては完璧なのだ。「◯件を投入しました。完了です」は、文法も整合性も申し分ない。テキストを何度採点しても引っかからない。間違っているのはテキストではなく、"行動した後の世界の状態"の方だから。
ここに空白がある。出力を検証するツールは山ほどあるのに、「行動後の世界を再取得して、報告と実態が一致するかを突き合わせる」部品が、探した限り無かった。 エージェントが実際に世界を書き換える時代になったのに、検証は「エージェントが何を言ったか」で止まっている。
今日からできる(ファイルもライブラリもいらない)
いちばん大事なことを先に書く。この完了契約は、ライブラリなしで今すぐ効く。 上の3ルールを、エージェントのシステムプロンプトなり CLAUDE.md なり AGENTS.md なりに、こう一段落入れるだけでいい。
## 完了契約
副作用のある操作(作成・更新・削除・アップロード・投入)は、
別コマンドで実在・状態を再取得し、生の結果を提示してからでないと
「完了」と報告してはならない。空出力・エラー・タイムアウトは
想像でID・パス・件数を補完せず「空/失敗」とそのまま報告する。
これだけで、私の環境では偽の完了報告が目に見えて減った。コストゼロ、依存ゼロ。まず試すならここからで十分だ。
それでも、プロンプトは"守られない"
ただ、運用していて分かったことがある。プロンプトに書いた規律は、忙しいターンで静かに破られる。 文脈が長くなると、モデルは完了契約の一段落を「うっかり」踏み越えて、また「完了しました」と言い始める。人間の「気をつけます」と同じで、宣言は破られる。
規律は、書くだけでなく強制したくなる。 ちょうど、コーディング規約をレビューで口頭注意する代わりにCIのリンタで機械的に落とすように。「完了と言う前に、本当に再取得したか?」を、人の善意ではなく仕組みで担保したい。
だから、コードで強制する部品を作った — genchi
この完了契約を、人の善意ではなく仕組みで担保する小さな部品を作って公開した。genchi(現地現物——実際に見て確かめる、の意)。
npm i @hyuga/genchi
やることは一つに絞ってある。probe(実状態を"再取得"する関数)を必ず走らせ、その結果でしか合否を出さない。
import { gate, expect } from '@hyuga/genchi';
await db.insert(rows); // 副作用
await gate({
action: '45件を投入',
probe: () => db.count({ where: { batch: 123 } }), // ← 行動の戻り値ではなく、実状態を再取得
expect: expect.count(45),
});
// ここに到達できたなら、実際に45件ある。通らなければ GenchiIncomplete で止まる。
肝は、verify / gate が probe(取り直す関数)しか受け取らないこと。「行動の戻り値」を証拠として渡すAPIは存在しない——つまり「やったつもり」を構造的に書けない。空・エラー・タイムアウトは握りつぶさず、そのまま失敗として報告する(想像で成功にしない)。再取得した件数が 0(=1行も無い)も未完了扱いだ。
JS を書かないエージェント向けに CLI もある。"再取得コマンド" を渡すだけ:
genchi verify --probe "psql -tAc 'select count(*) from t where batch=123'" --count 45
# exit 0=検証OK / 1=空・不一致 / 3=probe失敗。生の probe 出力を必ず証拠に出す
Claude Code なら、未検証の完了契約が残ったままターンを終えるのを Stop フックでブロックできる(adapters/claude-code)。実行時に LLM もAPIキーも使わない、依存ゼロの静的な部品だ。
正直な話:需要は少し先回りかもしれない
本音も書く。「行動後の世界の状態を再取得して検証する」部品は、探した限り空白だった。だが、エージェントに実際に世界を書き換えさせて、かつ「完了の捏造」で痛い目を見た人が、まだそれほど多いとも思わない。需要は少し先回りかもしれない。
それでも意図的に フレームワーク非依存 で作った——Claude Code の hook は薄いアダプタに追い出し、コアはどのエージェントからでも使える。自分のリンタ carrylint で「特定環境に焼き込むな」と言い続けている以上、自分の道具が Claude Code 専用になるのは筋が通らない。静的に検査する私のリンタ群(reflint=参照の実在 / skills-lint=スキルの衝突 / carrylint=実行時の可搬性)と違って、これは実行時に世界を見に行く、初めての部品になった。
一度でも「やったつもりで、やっていなかった」に肝を冷やしたなら、効くはずだ。
まとめ
- AIエージェント最悪のハルシネーションは、文章ではなく**「作業をやり遂げた」という事実の捏造**。原因は「行動」と「確認」の分離。
- 直し方は一つ。「完了」は"再取得した実結果"でしか名乗らせない(=完了契約)。空・失敗はそのまま報告させる。
- 既存のガードレールはテキスト出力を検証する。行動後の世界の状態を再取得して突き合わせる部品は空白。
- 完了契約は今日からプロンプト一段落で効く。コードで強制するなら
npm i @hyuga/genchi。
「完了しました」を、もう一度だけ疑ってみてください。
リポジトリはこちら。 https://github.com/hyuga611/genchi