끝난 일은 증발하고, 도구는 조용히 함정을 판다 — AI 에이전트 실패패턴 9~16선
지난 편에서 5노드 AI 에이전트를 굴리며 실제로 터진 실패패턴 20개 중 앞의 8개를 풀었다. 답을 썼는데 사용자가 못 보고, 한 노드만 보고 없다고 단정하고, 끝난 일이 다시 살아나는 이야기들이었다. 이번엔 그다음, 9번부터 16번까지다. 주제가 한 칸 옮겨간다. 앞이 ‘도달과 근거’의 문제였다면, 이번은 ‘추적’과 ‘도구·환경의 함정’이다.
다시 한 문장으로 깔고 간다. 모델이 더 똑똑해져서 풀릴 문제가 아니라, 확인 경로와 잠금장치(forcing function)를 설계해야 막히는 문제다. 9~16번은 그 성격이 더 또렷하다. 여기 나오는 사고 대부분은 “더 좋은 모델을 쓰자”로는 1mm도 안 줄어든다. 줄어드는 건 오직, 같은 실수가 또 나기 전에 박아둔 작은 규칙 하나뿐이다.
9. 자동화 큐가 칸 글자만 읽고 의미를 모른다
할 일 목록의 칸 상태가 ‘안 함’이나 ‘하는 중’이면, 자동 일꾼은 그걸 보고 일감으로 집는다. 문제는 그 일이 이미 다른 결정으로 무효가 됐을 때다. 어떤 디자인 방향이 확정돼서 대안들이 죽었는데, 죽은 대안의 칸은 여전히 ‘안 함’으로 열려 있다. 큐는 칸 글자만 읽지, 그 옆에서 내려진 결정 로그까지 읽지는 않는다. 그래서 끝난 싸움을 다시 시작한다.
기계에게 “A가 정해졌으니 B는 무효”라는 관계는 안 보인다. 사람이 명시해줘야 한다. 그래서 일감을 내리기 직전에 최근 완료·취소 결정과 후보 작업을 한 번 대조하는 단계를 둔다. 칸만 보지 말고, 그 칸이 아직 살아있는 칸인지 묻는 것이다.
10. 추적 번호가 커밋과 PR에 안 실린다
작업마다 고유 번호(task ID)를 붙여 관리한다. 그런데 급하게 수동으로 내린 일은 그 번호 없이 커밋과 PR이 만들어지곤 한다. 사람 눈엔 “그 작업”이 분명하지만, 자동 완료 처리기는 공통 키가 없으면 같은 작업인 줄 모른다. 그래서 일은 끝났는데 칸은 영영 안 닫힌다. 다음 날 그 칸이 또 ‘할 일’로 뜬다.
이건 모델의 머리가 아니라 배관의 문제다. 브랜치·커밋·PR 제목에 추적 번호를 박는다. 번호 없이 도는 작업은 일부러 ‘번호 없음’이라고 표시해, ‘깜빡한 것’과 ‘의도적 예외’를 구분한다. 자동화는 공통 키 하나로 비로소 흩어진 점들을 잇는다.
11. “발행·제출·발송” 뒤에 검증과 장부가 없다
외부로 나가는 일은 성공처럼 보이기 쉽다. 명령이 에러를 안 냈으니까. 그런데 실제 공개 상태, 심사 상태, 수신 상태는 그것과 따로 논다. 외부 시스템은 대개 비동기여서, “보냈다”는 응답과 “도착·반영됐다”는 사실 사이에 시간과 간극이 있다. 그 간극에서 다음 세션은 “이미 끝난 일”로 착각하고 넘어간다.
그래서 외부에 영향을 주는 행위(배포·발행·발송) 직후에는, 한 호흡으로 두 가지를 묶는다. 무엇을 했는지 한 줄 장부에 적고, 공개 주소나 수신 상태를 직접 한 번 확인한다. 커밋이 안 남는 외부 행위일수록 이 장부가 다음 날의 헛걸음을 막는다.
12. 테스트 하네스가 운영에 밀어 넣는다
테스트용 가짜 데이터를 올린 줄 알았는데, 같은 브랜치에 섞여 있던 리뷰 전 커밋까지 운영(main)에 딸려 올라간 적이 있다. git은 “이 파일은 테스트용”이라는 사람의 의도가 아니라 커밋 그래프를 따른다. 테스트와 배포가 한 브랜치를 공유하면 그 경계는 사라진다.
해법은 분리다. 운영처럼 동작하는 push가 필요한 테스트는 별도 ref, 별도 작업 폴더, 별도 fixture 저장소에서만 돌린다. 의도로 경계를 긋지 말고, 물리적으로 갈라둔다.
13. 복붙 블록 안 자리표시자가 진짜 값처럼 실행된다
<여기에 토큰> 같은 자리표시자가 그대로 실행돼 설정 파일을 망가뜨린 적이 있다. 사람에겐 “이건 채워 넣으라는 자리”가 뻔히 보이지만, 실행 가능한 명령 블록 안에 들어 있으면 그대로 붙여넣어 돌리기 쉽다. 특히 자동화는 망설임이 없다.
그래서 그대로 실행할 명령 블록에는 자리표시자를 아예 넣지 않는다. 비밀값이나 환경마다 다른 값은 명령문 안이 아니라 별도 경로로 전달한다. “사람이 보면 알겠지”는 자동화 앞에서 통하지 않는 가정이다.
14. 글자를 byte로 잘라 한글·이모지를 깨뜨린다
리눅스 환경에서 문자열을 byte 단위로 자르다가 한글이나 이모지를 글자 중간에서 잘라버려, 깨진 바이트를 받은 API가 거부한 일이 있다. macOS와 리눅스의 기본 도구는 같은 잘라내기 명령도 다르게 동작한다. 이 차이는 “기억해 두자”로는 안정적으로 안 잡힌다. 어느 날 다른 기기에서 그대로 재발한다.
멀티바이트 문자열은 byte가 아니라 글자 단위로 자르는 도구를 쓰고, macOS와 리눅스 양쪽에서 검증한다. 환경 차이는 머리로 외우는 게 아니라, 양쪽에서 한 번씩 돌려보는 절차로 막는다.
15. 같은 책임을 진 파일 무리 중 하나만 고친다
같은 상수를 쓰는 파일이 여러 개인데, 그중 지금 보이는 하나만 고친 적이 있다. 다음 실행 경로는 안 고친 쪽을 타고 옛 설정을 계속 썼다. 분명히 고쳤는데 안 고쳐진 것처럼 보이는, 가장 김빠지는 종류의 버그다. 모델은 지금 눈앞의 파일을 고치는 데 능하지, 같은 책임을 나눠 가진 파일 무리를 알아서 전수 점검하지는 않는다.
그래서 상수명·함수명으로 먼저 훑어 형제 파일을 다 찾고, 한 묶음으로 같이 고친다. 한 군데만 고치고 “됐다”고 하기 전에, 같은 이름이 또 어디 사는지부터 묻는다.
16. 자동화 브라우저가 내 브라우저 세션과 합쳐진다
화면을 직접 조작하는 자동화를 돌릴 때, 자동화용 브라우저가 내가 평소 쓰던 브라우저 세션과 합쳐져 제어할 창을 못 잡은 일이 있다. 앱이 하나만 실행되도록 한 잠금, 프로필 위치, 실행 파일 경로 같은 GUI 런타임의 사정은 코드만 봐서는 안 드러난다. 그래서 “코드는 맞는데 왜 안 되지”가 반복된다.
자동화용 브라우저는 실행 파일과 프로필을 사용자 세션과 아예 분리한다. 사람이 쓰는 창과 기계가 쓰는 창을 갈라두면, 둘이 서로의 발을 밟지 않는다.
관통하는 한 가지
이 여덟 개도 겉모습은 제각각이다. 큐, 추적 번호, 외부 발송, git, 복붙, 인코딩, 파일 무리, 브라우저 — 영역이 다 다르다. 그런데 뿌리는 9~16번 내내 같다. 기계는 “내 도구가 에러를 안 냈다”를 “일이 제대로 됐다”로 착각한다. 명령은 성공했지만 추적은 끊겼고, push는 됐지만 운영을 오염시켰고, 붙여넣긴 했지만 자리표시자였다.
그래서 막는 방법도 매번 같은 모양이다. “됐다”의 정의를, 명령 성공이 아니라 최종 상태 확인으로 바꾸는 작은 잠금장치 하나. 추적 번호를 박고, 발송 뒤 장부를 쓰고, 형제 파일을 같이 고치고, 테스트와 운영을 갈라두는 것. 전부 똑똑한 모델이 아니라 둔한 규칙이 하는 일이다.
배운 것
세 줄로 줄인다.
첫째, 추적은 기억이 아니라 키로 한다. 사람은 “그 작업”을 기억하지만, 자동화는 공통 번호가 없으면 점을 못 잇는다.
둘째, 환경 차이는 외우는 게 아니라 양쪽에서 돌려 막는다. macOS에서 되던 게 리눅스에서 깨지는 건 실력이 아니라 검증 절차의 문제다.
셋째, “됐다”를 의심하는 습관이 매번 나를 구한다. 명령이 성공한 자리에서 한 번 더, 진짜 도착했는지·반영됐는지·형제까지 고쳤는지를 연다.
나머지 네 개(17~20번)는 보안과 정책 쪽이다 — 정당한 자동화가 정책 분류기에 걸리고, 비밀값이 로그에 남고, 내부는 끝났는데 공개 상태는 다른 문제들. 다음 편에서 마저 푼다. 전체 20선과 1페이지 체크리스트는 PDF로 묶어 따로 받을 수 있다.
— 강대종 / @ssamssae