
「완료했습니다」를 믿지 마라. AI 에이전트에게 실제 결과 재취득을 강제하는 검증 게이트
요약
AI 에이전트가 작업 완료를 허위로 보고하는 문제를 해결하기 위해, 행동 후 실제 상태를 재취득하여 검증하는 '완료 계약(Completion Contract)' 개념을 제안합니다. 도구의 반환값에 의존하지 않고 실제 데이터를 다시 조회하여 성공 여부를 증명하도록 강제하는 것이 핵심입니다.
핵심 포인트
- 에이전트가 작업 결과 확인 없이 '완료'를 선언하는 할루시네이션 문제 지적
- 행동과 확인을 분리하여 실제 상태를 재취득하는 '완료 계약' 도입
- 부작용이 있는 태스크는 반드시 별도 커맨드로 실재를 확인해야 함
- 추측이나 모호한 반환값 대신 실제 데이터(건수, ID 등)로 증명 책임 부여
「투입하여 완료했습니다」—— 하지만 실제로는 단 한 줄도 들어있지 않았다
수탁 업무를 AI 에이전트에게 상당히 맡기고 있다. 운영 환경으로의 배포(Deploy), 리포트 생성, 데이터 일괄 투입. 성공했던 절차는 하나씩 스킬(Skill)로 만들어, 지금은 수십 개의 스킬이 일상적인 실무를 돌리고 있다.
좌절했던 것은 어떤 일괄 투입 시점이었다. 에이전트는 마지막에 이렇게 말했다.
「◯건의 데이터를 투입했습니다. 완료되었습니다.」
언제나와 다름없는 완료 보고다. 믿을 뻔했다. 만일을 위해 관리 화면을 열었더니—— 행(Row)은 단 한 줄도 늘어나 있지 않았다. 투입 커맨드는 도중에 실패했고, 에러는 묵인되었으며, 그럼에도 에이전트는 자신만만하게 「완료했습니다」라고 보고하고 있었다.
등골이 오싹해졌다. 이것이 리포트의 숫자였다면? 고객에게 제출하는 자료였다면? 「했다고 생각하지만, 하지 않은」 보고를 나는 얼마나 믿고 지나쳐 왔던 걸까.
이것은 할루시네이션 (Hallucination) 중에서도 가장 질이 나쁜 종류다. 문장이 그럴듯할 뿐만이 아니다. 「작업을 완수했다」는 사실 그 자체가 날조되고 있다. 그리고 인간은 당당한 「완료했습니다」를 의심하지 않는다.
왜 에이전트는 거짓말을 하는가 (악의는 아니다)
원인을 파고들어 보니 모델이 불성실해서가 아니었다. 「행동」과 「확인」이 분리되어 있기 때문이다.
LLM 에이전트의 한 턴(Turn)은 대략 다음과 같다.
- 도구를 호출한다 (투입 커맨드를 실행)
- 그 반환값(Return value)을 보고 다음 문장을 생성한다
- 「완료했습니다」라고 쓴다
문제는 2번이다. 도구의 반환값이 비어 있거나, 모호하거나, 타임아웃(Timeout)이 발생하더라도 모델은 「아마 성공했을 것이다」라는 최우(Maximum Likelihood)의 뒷이야기를 써버린다. 학습 데이터 중에서 「투입했다」 다음에 가장 자연스럽게 이어지는 말은 「완료했습니다」이기 때문에 당연히 그렇게 된다.
즉 에이전트는, 행동한 후의 세계를 보러 가지 않고 완료를 선언하고 있다. 보지 않기 때문에 실패도 알아차리지 못한다.
해결 방법은 하나. 「완료」는 "재취득한 실제 결과"로만 선언하게 한다
대책은 심플하다. 부작용(Side effect)이 있는 태스크는 행동 후에도 다시 한번 세계의 상태를 재취득하여, 실재를 확인한 후에만 「완료」라고 말하게 한다. 나는 이것을 「완료 계약 (Completion Contract)」이라고 부르며, 모든 스킬의 공통 규칙으로 삼고 있다. 내용은 딱 세 가지뿐이다.
- 작성·갱신·삭제·업로드·투입 등의 부작용 계열은, 별도의 커맨드로 실재·상태를 재취득한 뒤에 「완료」라고 말한다. 투입했다면 건수를 다시 센다. 업로드했다면 그 URL을 호출한다. 생성했다면 ID로 다시 조회한다. 같은 턴의 도구 반환값을 근거로 삼지 않는다 (그것이 「했다고 생각하는 것」의 온상이다).
- 빈 출력·에러·타임아웃은 상상으로 채우지 않는다. 건수·경로·ID를 가져올 수 없다면, 그것들을 추측으로 보완하지 않고 「빈 값/실패」라고 그대로 보고한다.
- 대장·리포트에 적는 숫자는 재취득을 통해 실재 확인이 된 값만. 확인되지 않은 숫자는 적지 않는다.
바꿔 말하면, 증명 책임을 에이전트 측에 둔다. 「성공했을 것이다」가 아니라 「성공했다는 것을 방금 이 눈으로 재취득하여 확인했다」 단계까지 도달하지 않으면 완료를 선언할 수 없다.
이렇게 바뀐다.
Before (했다고 생각함)
> 데이터를 투입합니다
[tool] db_insert(...) ← 반환값은 비어 있음. 하지만 에러라고 단정할 수는 없음
> ◯건의 데이터를 투입했습니다. 완료되었습니다. ← 보지 않고 선언함
After (완료 계약)
> 데이터를 투입합니다
[tool] db_insert(...) ← 반환값은 비어 있음
> 투입 커맨드의 반환값이 비어 있었습니다. 실재를 확인합니다.
...
After의 에이전트는 거짓말을 할 수 없다. 행동 후의 세계를 보러 가는 단계를 끼워 넣은 순간, 「했다고 생각하는 것」은 물리적으로 성립할 수 없게 된다.
기존의 가드레일이 왜 이곳을 보고 있지 않은가
「그거 가드레일(Guardrail) 계열 라이브러리로 지킬 수 있는 것 아닌가?」라고 생각할지도 모른다. 나도 찾아보았다. guardrails-ai, deepeval, promptfoo 등 몇 가지를. 모두 좋은 도구들이다. 하지만 지키고 있는 위치가 다르다.
이들이 검증하는 것은 기본적으로 **LLM이 내뱉은 "텍스트 출력"**이다. 포맷이 올바른지, 유해하지 않은지, 사실과 일치하는지, 기대하는 답에 가까운지. 즉, 출력의 내용물을 채점하고 있다.
하지만 「했다는 착각(やったつもり幻覚)」은 텍스트로서는 완벽하다. 「◯건을 투입했습니다. 완료되었습니다」는 문법도 정합성도 나무랄 데 없다. 텍스트를 아무리 채점해도 걸리지 않는다. 틀린 것은 텍스트가 아니라, "행동한 후의 세계 상태"이기 때문이다.
여기에 공백이 있다. 출력을 검증하는 도구는 산더lay(山ほど) 있지만, 「행동 후의 세계를 재취득하여 보고와 실태가 일치하는지 대조하는」 부품은 내가 찾아본 바로는 없었다. 에이전트가 실제로 세계를 써 내려가는 시대가 되었음에도, 검증은 「에이전트가 무엇을 말했는가」에서 멈춰 있다.
오늘부터 할 수 있는 것 (파일도 라이브러리도 필요 없음)
가장 중요한 것을 먼저 쓰겠다. 이 완료 계약(Completion Contract)은 라이브러리 없이 지금 바로 적용할 수 있다. 위의 3가지 규칙을 에이전트의 시스템 프롬프트나 CLAUDE.md, AGENTS.md 등에 다음과 같이 한 단락 넣기만 하면 된다.
## 완료 계약
부작용(Side Effect)이 있는 조작(생성·업데이트·삭제·업로드·투입)은,
별도의 명령어로 실재·상태를 재취득하고, 생(raw) 결과를 제시한 후에야
...
이것만으로도 내 환경에서는 가짜 완료 보고가 눈에 띄게 줄었다. 비용 제로, 의존성 제로. 우선 시도해 본다면 여기서부터로 충분하다.
그럼에도, 프롬프트는 "지켜지지 않는다"
다만, 운용하면서 알게 된 것이 있다. 프롬프트에 적은 규율은 바쁜 턴(turn)에서 조용히 깨진다. 문맥이 길어지면 모델은 완료 계약의 한 단락을 "실수로" 건너뛰고, 다시 「완료했습니다」라고 말하기 시작한다. 인간의 "주의하겠습니다"와 마찬가지로, 선언은 깨지기 마련이다.
규율은 쓰는 것뿐만 아니라 강제하고 싶어진다. 마치 코딩 규약을 리뷰에서 구두로 주의하는 대신 CI의 린터(Linter)로 기계적으로 걸러내는 것처럼. 「완료라고 말하기 전에, 정말로 재취득했는가?」를 사람의 선의가 아닌 구조로 담보하고 싶다.
genchi
그래서 코드로 강제하는 부품을 만들었다 — 이 완료 계약을 사람의 선의가 아닌 구조로 담보하는 작은 부품을 만들어 공개했다. genchi (현지현물(現地現物)——실제로 보고 확인한다는 의미).
npm i @hyuga/genchi
하는 일은 하나로 압축되어 있다. probe(실제 상태를 "재취득"하는 함수)를 반드시 실행하고, 그 결과로만 합격/불합격을 판정한다.
import { gate, expect } from '@hyuga/genchi';
await db.insert(rows); // 부작용
await gate({
...
핵심은 verify / gate가 probe(다시 가져오는 함수)만을 인자로 받는다는 점이다. 「행동의 반환값(return value)」을 증거로 전달하는 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 후크(hook)로 차단할 수 있다(adapters/claude-code). 실행 시 LLM도 API 키도 사용하지 않는, 의존성 제로의 정적인 부품이다.
솔직히 말해서: 수요는 조금 앞서가는 것일지도 모른다
본심도 적어두겠다. 「행동 후의 세계 상태를 재취득하여 검증하는」 부품은 내가 찾아본 바로는 공백이었다. 하지만 에이전트에게 실제로 세계를 써 내려가게 하고, 「완료 조작(fabrication)」으로 고생을 겪은 사람이 아직 그렇게 많다고는 생각하지 않는다. 수요가 조금 앞서가는 것일지도 모른다.
그럼에도 의도적으로 **프레임워크 비의존적(framework-agnostic)**으로 만들었다 — Claude Code의 hook은 얇은 어댑터로 분리하고, 코어는 어떤 에이전트에서도 사용할 수 있게 했다. 나의 린터인 carrylint로 「특정 환경에 종속시키지 마라」고 계속 말해온 이상, 내 도구가 Claude Code 전용이 되는 것은 앞뒤가 맞지 않는다. 정적으로 검사하는 나의 린터 군단(reflint=참조의 실재 / skills-lint=스킬의 충돌 / carrylint=실행 시의 가변성)과 달리, 이것은 실행 시에 세계를 보러 가는 첫 번째 부품이 되었다.
단 한 번이라도 "한 줄 알았는데, 하지 않았다"는 상황에 가슴이 철렁한 적이 있다면, 이 방법이 효과가 있을 것입니다.
요약
- AI 에이전트의 최악의 할루시네이션 (Hallucination)은 문장이 아니라 "작업을 완수했다"라는 사실의 날조이다. 원인은 "행동"과 "확인"의 분리.
- 해결 방법은 하나뿐이다.
"완료"는 "재취득한 실제 결과"로만 선언하게 한다 (=완료 계약 (Completion Contract)). 빈 값이나 실패는 그대로 보고하게 한다. - 기존의 가드레일 (Guardrail)은
텍스트 출력을 검증한다. 행동 후의 세계 상태를 재취득하여 대조하는 부품은 비어 있다. - 완료 계약은
오늘부터 프롬프트 한 단락만으로도 효과를 볼 수 있다. 코드로 강제하고 싶다면npm i @hyuga/genchi를 사용하라.
"완료했습니다"를, 다시 한번만 의심해 보세요.
리포지토리 (Repository)는 여기 있습니다. https://github.com/hyuga611/genchi
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기