연재 『의심하고 확인하기』 제6화 (총 8화). 성공으로 보이는 실패들을 한 화에 하나씩 확인해봅니다.
결론
상태는 모두 파일에 저장합니다. 완료 여부는 출력 파일의 개수와 누락된 번호로 판정합니다.
에이전트의 이상 보고는 디스크의 실제 파일을 확인한 후에 작동시킵니다.
그리고, 확인하는 측의 게이트 자체도 의심합니다. 게이트는 배포할 코드를 직접 호출하는 형태로 만듭니다.
이번 확인에서 핵심은 다음과 같습니다.
일괄 처리가 완료되었다는 것은 '완료 통지가 모두 모였다'가 아니라, '입력과 출력의 키 집합이 일치하고 누락된 번호가 0개'라는 것입니다.
무슨 일이 벌어졌나
Claude Code의 서브 에이전트에게 동일한 형태의 판정 작업을 200건이 넘는 입력 데이터로 나누어 돌렸습니다.
판단, 그 검토, 감사처럼 단계를 나누어 총 200개가 넘는 에이전트를 활용했습니다.
완료 통지를 세는 방식으로 진행하면 지연이나 누락으로 인해 개수가 맞지 않게 됩니다.
실제로 늦게 도착하는 배치(batch)나 아예 도착하지 않는 배치가 있었습니다.
더 심각한 것은, 제대로 도착한 출력물 중에서도 건수는 맞지만 내부 키가 어긋난 경우가 있다는 것이었습니다.
통지를 세거나 개수를 세는 방식으로는 이 사고를 발견할 수 없었습니다.
그래서 모든 출력이 모인 후에 다음 단계로 넘어가기 전에, 기계적으로 검증하는 게이트(gate)를 끼워 넣었습니다.
| 항목 | 실측 결과 |
|---|
| 서브 에이전트 | 총 200개 이상 |
| ... | |
첨자(index)의 누락은 게이트가 발견했습니다. 주입 보고는 디스크의 실제 파일을 확인하여 환각으로 판단하고 있습니다.
작동 방식: 3줄 프롬프트와 웨이브(Wave)
실행 프롬프트는 3줄로 고정했습니다. 지시 본문은 파일에 두고, 모두가 동일한 내용을 읽도록 합니다.
지시를 수정하고 싶을 때는 파일을 하나만 수정하면 됩니다.
지시 파일 work/instructions.md 를 Read 하여 그대로 작업해 주세요.
입력: work/in/in_037.json
출력: work/out/out_037.json
한 웨이브는 10여 개입니다. 모두의 완료를 기다리지 않고, 절반이 끝나면 다음 웨이브를 흘려보냈습니다.
최종 웨이브만 남은 것을 모아서 한 번에 던집니다. 병렬 처리 상한을 초과한 분량은 실행 측에서 순서대로 대기시켜 줍니다.
명칭은 '역할-순번'으로 지었습니다. judge-37와 같은 형태이며, 재실행 시에는 judge-37b가 됩니다.
이름이 규칙적이기 때문에 나중에 기록을 이름의 접두사만으로 단계별로 나누어 집계할 수 있습니다.
재현해보기: 개수는 맞는데 키가 어긋나는 경우
직접 같은 사고를 만들어봅니다. 입력 5배치에, 사고를 3개 섞은 출력을 만듭니다.
배치2는 출력이 오지 않습니다. 배치3는 키가 배열의 첨자로 되어 있습니다. 배치4는 따옴표 때문에 JSON이 손상됩니다.
사고가 포함된 출력 만들기 make_demo.py
# make_demo.py: 입력 5배치와, 사고를 3개 섞은 출력을 만듭니다
import json, pathlib
inp, out = pathlib.Path(
## 왜 성공처럼 보였는가
위의 배치 3 행을 봐주세요. 건수는 3 대 3으로 맞습니다. 건수만 확인하는 게이트라면, 이곳은 통과합니다.
성공이라고 부르고 싶은 것은 '완료 알림이 왔다'와 '건수가 일치했다'입니다.
알림은 에이전트의 자체 보고(self-report)입니다. 건수는 내용물이 어긋나 있어도 맞을 수 있습니다.
이상 보고 역시 마찬가지로 자체 보고입니다. 정상 보고를 의심한다면, 이상 보고 또한 같은 절차로 의심할 필요가 있었습니다.
## 발견한 사고와 그 당시의 조치
### 배열 인덱스가 핵심이었습니다
출력 키가 "0", "1", "2"로 되어 있던 배치가 있었습니다. 키 집합 일치(key set matching)를 통해 찾아낸 건수입니다.
인덱스에서 원래의 키를 추측하여 다시 지정하지는 않았습니다.
시작 프롬프트에 주의사항을 한 줄 추가하고, 해당 배치만 새로운 에이전트로 재실행했습니다.
### "지시 파일에 주입(Injection)이 섞여 있다"는 보고
어떤 에이전트가 지시 파일이나 입력에 프롬프트 인젝션(prompt injection)이 섞여 있다고 보고해 왔습니다.
믿고 지시를 수정하는 것도, 무시하는 것도 하지 않고, 디스크의 실제 파일을 확인했습니다.
크기, 업데이트 시간, 해시 값을 확인합니다. 보고된 문자열을 검색하고, JSON으로 읽을 수 있는지 시도해 봅니다.
모두 건전했습니다. 같은 파도의 다른 에이전트가 동일한 지시로 정상적으로 끝났다는 것도 간접적인 증거 중 하나입니다.
환각(hallucination)으로 판단하고, 지시 파일은 변경하지 않은 채, 동일한 입력으로 새로운 에이전트에게 재실행을 시켰습니다.
보고를 받으면 가장 먼저 봐야 할 3가지는 PowerShell의 경우 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
1. 파일 수 (누락된 번호는 gate.py에서)
(Get-ChildItem work/out -Filter 'out_*.json').Count
2. 업데이트 시간 (최근에 작성된 것부터)
...
### 사용량 제한으로 6개가 한꺼번에 멈춤
감사 단계에서 사용량 제한(rate limit)에 걸려, 6개가 한꺼번에 멈췄습니다.
상태는 모두 파일에 있으므로, 멈추기 전에 작성된 출력은 남아 있습니다.
제한이 임박하다고 느낀 시점에서 체크포인트 메모를 남겼습니다. 단계별 진행표, 아직 오지 않은 배치 번호, 재개용 3줄 프롬프트입니다.
제한이 풀린 후, 아직 오지 않은 부분만 다시 실행했습니다.
출력은 한 건도 손실 없이 재개할 수 있었습니다. 다만, 멈췄던 부분을 재실행하는 수고는 들었습니다.
### 다른 회차에서 발생했던 것들
- 인용 부호 안의 이중 따옴표가 이스케이프되지 않아 JSON이 깨짐. 해당 배치만 재실행하고, 지시 파일의 출력 사양에 한 줄을 추가함 (판단 내용 부분에는 건드리지 않음)
- 공유 임시 폴더에 남아있던 다른 파일을 오독한 경우가 2건 있었습니다. 둘 다 에이전트 스스로 인지하고 수정했지만, 이후로는 에이전트별로 작업 폴더를 분리했습니다.
- 판단을 가벼운 모델로 바꿔 시도했을 때는, 스무여 개 배치 중 4배치(약 15%)에서 항목이 조용히 누락되었습니다. 오류는 발생하지 않았고, 건수 검산으로 발견했습니다.
- 그 이후의 다른 작업에서 서브 에이전트에게 5건을 발주했을 때는, 2건을 반송 처리했습니다. 진행 정도는 보고가 아니라 파일의 업데이트 시간으로 확인했습니다.
## 게이트 자체를 의심하다
여기까지로 에이전트의 출력은 게이트에서 의심할 수 있게 되었습니다. 다음에 의심한 것은 게이트 그 자체였습니다.
다른 작업에서, 특정 도구의 회귀 게이트(regression gate)를 만들고 있었습니다.
100건이 넘는 입력 파일을 처리하며, 출력 해시 값을 이전에 만든 정답 출력(baseline)과 비교하는 것이었습니다.
그런데 게이트 안에는 '원본과 똑같은 것을 하는 참조 구현'을 복사해 넣고 있었습니다.
이렇게 되면, 게이트가 녹색으로 표시해도 증명하고 있는 것은 게이트 내부의 코드입니다. 배포할 코드가 아닙니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
전: 게이트 안에, 배포 코드와 같은 루프와 규칙을 복사해 넣었음
def convert_for_gate(path): ...
후: 배포 코드의 함수를 그대로 호출함
...
배포 코드를 직접 호출하는 형태로 바꾸자, 3개 파일에서 차이점이 발견되었습니다.
여기서, 코드를 baseline에 맞춰 고치기보다는, 먼저 baseline을 만들었던 절차 자체를 의심했습니다.
원인은 사용하던 라이브러리의 부작용(side effect) 때문이었습니다.
라이브러리는 추정 결과를 입력 데이터에 직접 기록하고 있었습니다.
게다가 중간 표현을 캐시하여, 동일한 객체를 다른 처리에 전달하고 있었습니다.
그 때문에 호출 순서에 따라 출력이 달라졌습니다. baseline을 만든 스크립트와 배포 코드는 순서가 달랐던 것입니다.
「인스턴스를 공유할지」와 「어떤 순서로 호출할지」의 조합 4가지 패턴의 표를 만들었고, baseline이 재현하는 것은 단 1가지 패턴뿐임을 확인했습니다.
차이점을 살펴보니, 출력의 일부에서 요소 대응이 잘못된 쪽은 baseline 측이었습니다. baseline이 반드시 정확하다고 할 수 없었습니다.
마지막으로는, 비교하는 조건을 맞추었습니다. 게이트 안에서, 일회용 임시 폴더에 baseline과 동일한 순서로 사전 준비 작업을 실행합니다.
배포 코드의 출력은 오염시키지 않습니다.
| 항목 | 실측 |
|---|---|
| 대조된 입력 파일 | 100건 이상 |
| ... |
## 모방하려면 (만약 따라 하려면)
- [OK]/[NG] 입력과 출력을 순차적인 파일에 배치한다. 완료 여부는 파일 수와 누락된 번호로 판정하고, 알림은 세지 않는다.
- [OK]/[NG] 다음 단계로 넘어가기 전에, 출력과 입력의 키 집합을 대조한다. 건수 일치만으로 통과시키지 않는다.
- [OK]/[NG] 게이트 자체가 배열 출력이나 비정규 파일명에 의해 다운되지 않는지, 일부러 깨뜨려 한 번 시도한다.
- [OK]/[NG] 이상 보고를 받으면, 파일 수/갱신 시각/해시를 확인한 후에 작동한다. 출력의 이상은 추측으로 고치지 않고, 해당 배치만 재실행한다.
- [OK]/[NG] 회귀 게이트는 배포할 코드를 직접 호출한다. 차이점이 나오면, 코드보다 먼저 baseline의 생성 방식을 의심한다.
## 다음번
기계로 셀 수 있는 것은, 기계로 의심할 수 있었습니다.
다음번에는, 셀 수 없는 것, 즉 AI의 답변 내용물을 질문하는 방식으로 확인하겠습니다.
- 지난번: 제5화 Teams 문자 기록 획득에서 「잠시 후 재시도」를 믿었더니, 필터 오류였다 — 에러 문구로부터 판정 조건으로 내려가는 4단계 (近日)
- 다음번: 제7화 AI의 답변은 「어디에, 어떤 맥락으로 쓰여 있었나?」로 확인한다 — 행 번호와 원문 인용을 80회 비교했다 (近日)
## 같은 시리즈
- gh api -f body=@file 로 PR에 임시 파일 경로가 올라왔다
- claude -p의 매일 아침 순찰이 약 7주 동안 한 번도 작동하지 않았다
- Teams의 Incoming Webhook이 403으로 조용히 실패하고 있었다
- 종료 코드 0으로 실패하는 것들 — draw.io・robocopy・pytest・Bedrock에서 본 exit 0 도감 (近日)
- Teams 문자 기록 획득에서 「잠시 후 재시도」를 믿었더니, 필터 오류였다 — 에러 문구로부터 판정 조건으로 내려가는 4단계 (近日)
- Claude Code의 서브 에이전트 200여 개를 돌려 사고를 기계로 포착했다 (본문)
- AI의 답변은 「어디에, 어떤 맥락으로 쓰여 있었나?」로 확인한다 — 행 번호와 원문 인용을 80회 비교했다 (近日)
- 자신의 AI 결과물을 감사해 보니 — 빈 README, 모순 0건, 문자 수 3배 차이 (近日)
이번 회차의 확인 내용을 다시 한번 올립니다.
일괄 처리가 완료되었다는 것은 「완료 통지가 모였다」가 아니라 「입력과 출력의 키 집합이 일치하고, 누락된 번호가 0이 된 것」을 의미합니다.
재현에 사용된 도구 버전: Python 3.13.7, PowerShell 7.6.6, Windows 11. 작성 당시의 Claude Code는 2.1.310입니다 (병렬로 돌린 것은 그보다 이전 버전입니다).
도움이 되었다면, 기사 응원(배지)을 주시면 다음 글을 쓸 힘이 됩니다.
각 회차의 「모방하려면」과 재현 스크립트는 연재 후에 한 권으로 묶을 예정입니다.
문의나 감상은 프로필 링크에서 부탁드립니다.
Obsidian과 Claude Code로 제2의 두뇌를 만드는 책, 제1・2화를 미리 보기로 무료 공개하고 있습니다:
Claude Code에 교재를 만들게 한 이야기는 note에 적었습니다: https://note.com/hotto_ken/n/nd46a33a55a87
### Discussion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72