이 글에는 제휴 링크가 없고 글에 나오는 AI 서비스들(OpenAI·앤트로픽)과 아무 관계가 없습니다. 제가 직접 만든 중계기의 개발 기록을 바탕으로 썼습니다. 삽화는 GPT 이미지 생성, 도식은 직접 제작했습니다.
에러 메시지를 자르지 마라 — 블랙박스 영상의 앞부분만 남겼을 때 (LLM 중계기 개발기)
차에 블랙박스를 달았다고 해 볼게요. 메모리 카드를 아끼려고 영상 파일마다 앞의 몇 초만 남기게 설정했습니다. 평소에는 아무 문제가 없어요. 출발하는 장면, 신호를 기다리는 장면이 잘 남아 있으니까요.
그런데 사고가 난 뒤 영상을 열어 보면 이상합니다. 평온하게 달리는 장면에서 영상이 끝나요. 정작 부딪힌 순간, 누가 어디서 끼어들었는지가 담긴 뒷부분은 설정 때문에 잘려 나갔습니다. 블랙박스가 있는데도 무슨 일이 있었는지 알 수가 없어요.
제가 만든 LLM 중계기가 개발 첫 이틀 동안 이런 블랙박스였습니다. AI가 요청을 거절하며 이유를 적어 보내면, 중계기는 그 글을 화면이나 기록에 옮기면서 앞부분만 남기거나 아예 버렸어요. 그렇게 진짜 원인을 세 번 잃어버렸습니다.
이 글은 LLM 중계기 개발기의 18편입니다. 지난 17편 ‘성공 응답 속에 숨은 실패’가 실패가 성공인 척 돌아온 이야기였다면, 이번에는 실패는 실패로 왔는데 그 이유가 중계기 안에서 잘리고 버려진 이야기입니다.

결론 요약
- 문제 — AI가 보낸 거절 이유(오류 원문)는 무엇이 잘못됐는지 알려 주는 단서인데, 중계기가 그걸 옮기면서 짧게 자르거나 버렸어요. 세 번째 사고에서는 고칠 방법이 하필 문장 끝에 있었습니다
- 경위 — 화면에 200자만 보여 준 날, 양식이 아니라며 원문을 통째로 버리고 ‘HTTP 400’만 남긴 날, 기록에 160자만 남겨 “더 새 버전이 필요합니다”가 잘린 날. 세 번 모두 처음 이틀 사이였어요
- 해법 — 세 번째에 “오류 원문은 자르지 않는다”를 규칙으로 정했어요. 기록에는 원문을 통째로 남기고, 짧은 요약은 화면에 보여 줄 때만 만듭니다. 자르는 코드가 다시 들어오면 자동 검사가 실패해요
🎞️ 1. 접수증과 진단서 — 오류 원문이 왜 중요한가
3편에서 이 사고를 짧게 다뤘어요. 두 줄로 줄이면 이렇습니다. 첫날 중계기는 예상한 모양이 아닌 거절 이유를 버리고 ‘HTTP 400’이라는 번호만 넘겼고, 다음 날에는 기록에 앞부분만 남기다 Codex의 “새 버전으로 업데이트하라”는 안내를 잘라 먹었어요. 원인이 이렇게 사라진 게 세 번째여서 규칙과 자동 검사를 만들었다는 데까지가 3편의 이야기예요.
이번 편에서는 세 번을 하나씩 따라가 봅니다. 그 전에 ‘오류 원문’이 무엇인지부터 짚을게요.
중계기 뒤의 AI가 요청을 받아 주지 않으면 보통 두 가지를 돌려보냅니다.
- 상태 번호 — ‘HTTP 400’처럼 숫자로 된 분류예요. 400은 “요청에 문제가 있다”는 뜻인데, 무엇이 문제인지는 말해 주지 않아요. 병원 접수증에 ‘내과’라고만 적힌 것과 비슷합니다
- 오류 원문 — AI가 사람 말로 적어 보내는 이유예요. “이 설정은 이 모델에서 쓸 수 없습니다”, “더 새 버전이 필요합니다” 같은 문장이죠. 진단서에 해당합니다
진단서를 버리고 접수증만 남기면 무엇이 잘못됐는지 알 길이 없어요. 진단서를 앞장만 남겨도 마찬가지고요. 이번 편의 세 번째 사고에서 받은 원문은 앞에서 상황을 말하고, 뒤에서 할 일을 알려 주는 문장이었어요. 그리고 잘려 나간 게 바로 그 뒷부분이었습니다. 블랙박스로 치면 부딪히는 순간이 영상 끝에 있었던 셈이에요.
✂️ 2. 첫 번째 — 화면에는 200자만 (7월 13일 오후)
개발 첫날 오후였어요. 중계기 화면에는 모델마다 짧은 질문을 던져 보는 시험 호출 버튼이 있습니다. Codex의 모델 하나에 시험 호출을 했더니 거절(400)이 돌아왔어요. 그런데 화면에 뜬 이유가 중간에서 끊겨 있었습니다.
원인은 두 군데였어요.
- 화면 — 시험 호출 결과의 오류 글을 200자까지만 보여 주게 돼 있었어요. 그 뒤는 그냥 버려졌습니다
- 받아 적는 방식 — Codex는 작업이 실패하면 실패 정보를 여러 칸에 나눠 담은 꾸러미를 보내요. 중계기는 그중 ‘메시지’ 칸 하나만 옮겨 적었고, 그 칸이 비어 있으면 “codex turn failed”(Codex 작업 실패)라는 말만 남겼어요
그날 오후 2시 28분에 나온 판(v0.1.2)에서 고쳤습니다. 메시지 칸이 비어 있으면 실패 꾸러미를 통째로 옮겨 적고, 시험 호출 화면은 800자까지 보여 주게 했어요. 이제 ‘잘못된 요청(invalid_request)‘이라는 거절 이유가 끝까지 보이게 됐어요. 계속 실패하는 모델은 설정으로 목록에서 빼 둘 수 있게 하고, 뺀 이유도 화면에 남기게 했습니다.
지금 보면 이 수정에는 한계가 있었어요. 자르는 길이를 늘렸을 뿐, 자르는 습관은 그대로였거든요. 200자가 800자가 됐을 뿐 ‘어딘가에서 끊는다’는 생각은 남아 있었고, 그 습관은 중계기 곳곳에 퍼져 있었습니다. 4절에서 그 수를 세어 볼 거예요.
🗑️ 3. 두 번째 — 양식이 아니라며 통째로 버린 이유 (7월 13일 오후)
두 시간 뒤에는 더 심한 일이 있었어요. 중계기를 쓰던 다른 도구의 요청이 한 AI 서버에서 번번이 거절당했는데, 그 도구가 받은 오류 메시지는 이게 전부였습니다.
HTTP 400
이유는 한 글자도 없었어요. 접수증만 받고 진단서는 못 받은 셈이죠.
따라가 보니 AI 서버는 이유를 분명히 적어 보내고 있었어요. 문제는 모양이었습니다. 중계기는 거절 이유가 프로그램끼리 주고받는 정해진 양식(JSON)으로 올 거라고 보고, 받은 글을 그 양식으로 읽으려 했어요. 그런데 이 서버는 이유를 양식 없이 그냥 글로 보냈습니다. 양식으로 읽기가 실패하자 중계기는 그 실패를 조용히 삼키고 받은 내용을 ‘빈 양식’으로 취급했어요. 빈 양식에서 찾은 이유가 없으니 상태 번호만 적어 넘긴 거예요.
우편으로 치면 이렇습니다. 반송된 봉투를 열어 보니 정해진 서식 대신 손으로 쓴 편지가 들어 있었는데, 서식이 아니라는 이유로 편지는 버리고 봉투에 ‘반송’ 도장만 찍어 넘긴 거죠. 왜 반송됐는지는 그 편지에 적혀 있었는데요.
그날 오후 4시 23분 판(v0.1.7)에서 이렇게 바꿨어요.
① 먼저 그냥 글로 받는다 — 받은 내용을 버리지 않고 글 그대로 먼저 확보합니다. 양식으로 읽는 건 그다음이에요
② 이유가 있을 만한 자리를 차례로 뒤진다 — AI마다 이유를 적는 칸 이름이 달라서(‘error’ 안의 ‘message’, ‘message’, ‘detail’ 등) 흔한 자리를 순서대로 살피고, 어디에도 없으면 받은 글 자체를 이유로 씁니다
③ 요약과 원문을 함께 보낸다 — 쓰는 쪽에 돌려주는 오류에 사람이 읽을 이유 문장과 함께 원래 상태 번호, 어느 AI의 어느 모델이었는지, 그리고 원문(upstream_body)을 따로 담습니다. 원문 속 열쇠(API 키) 같은 비밀 값은 가려서요
④ 이유를 읽고 스스로 고친다 — 원문이 특정 설정을 받지 않는다고 하면, 그 설정만 빼고 한 번 다시 보냅니다. 생각하는 AI는 답의 무작위성 설정(temperature)을 거절하는 경우가 많다는 메모도 이때 코드에 남았어요. 3편에서 짧게 다룬 내용이에요
그런데 여기에도 자르는 습관이 남아 있었어요. 원문을 살려 놓고도, 기록에 옮겨 적을 때는 이유 문장을 300자에서, 원문을 500자에서 잘랐거든요. 진단서를 받아 놓고 서류철에는 앞장만 끼워 둔 셈입니다.
원문이 보이기 시작한 그날 저녁, 중계기 설명서에는 ‘입력이 너무 길다’는 거절 원문의 모양이 예시로 실렸어요. 원문 속 숫자를 읽어 입력을 줄여 다시 보내라는 안내와 함께요. 이 이야기는 다음 편으로 이어집니다.
📼 4. 세 번째 — “더 새 버전이…”에서 끊긴 아침 (7월 14일)
다음 날 아침에는 Codex의 모델 하나가 말썽이었어요. 중계기는 연결할 때 모델을 미리 한 번 깨워 두는 워밍업을 합니다. 6편에서 다룬 ‘미리 데워 둔 엔진’이에요. 그런데 이 모델의 워밍업이 거절(400)로 실패했고, 기록에 남은 이유는 이렇게 끝나 있었습니다.
…requires a newer versio…
‘더 새 버전이 필요하다(requires a newer version)‘는 말이 ‘versio’에서 끊겨 있었어요. 무엇의 새 버전이 필요한지, 그래서 무엇을 하라는 건지가 통째로 잘려 나갔죠. 워밍업 실패를 기록할 때 오류 글을 160자에서 자르게 돼 있었거든요. 게다가 중계기는 이 실패가 잠깐 그런 건지 알 수 없으니, 연결할 때마다 이 모델을 다시 데우려다 또 실패하기를 되풀이했어요.
잘린 문장만으로는 문제가 중계기 쪽인지 Codex 쪽인지도 가릴 수 없었습니다. 그래서 중계기를 빼고 확인했어요. 터미널에서 Codex에 같은 모델로 “say pong”(퐁이라고 말해)이라는 짧은 질문을 직접 던져 본 거예요. 똑같은 400이 났습니다. 중계기는 거절을 충실히 전하고 있었고, 다만 잘라서 전했을 뿐이었어요. 끝까지 읽은 원문은 이랬습니다(원문은 영어예요).
‘(모델 이름)’ 모델은 더 새 버전의 Codex가 필요합니다. 최신 앱이나 CLI로 업그레이드한 뒤 다시 시도하세요.
설치된 Codex 명령어 도구가 그 모델을 쓰기엔 오래된 버전이었던 거예요. 할 일은 원문 끝에 다 적혀 있었습니다. 업그레이드하라. 160자 컷이 바로 그 부분을 잘라 먹은 거죠.
이번에는 고치는 방식이 달랐어요. 그날 아침 7시 6분 판(v0.1.19)의 기록에는 “진짜 원인이 기록·화면의 잘림 때문에 사라진 게 이번이 세 번째라서, 구조적으로 고친다”고 적혀 있습니다.
- 자르기를 전부 걷어냈다 — 길이를 늘리는 대신 자르기 자체를 없앴어요. Claude·Codex와 다른 AI들의 연결 부품, 공용 워밍업 기록까지 파일 여섯 개에서 오류 글을 자르던 곳 18군데가 나왔습니다. 자르는 길이는 120자부터 400자까지 제각각이었어요
- 고칠 수 없는 실패는 다시 하지 않는다 — ‘잘못된 요청’, ‘더 새 버전이 필요함’, ‘모르는 모델’ 같은 거절은 다시 해 봐야 같은 결과예요. 이런 실패는 ‘영구 실패’로 표시하고 연결할 때마다 다시 데우지 않게 했습니다. 사람이 다시 연결을 누르면 그때 다시 시도하고요
- 원문에 할 일을 붙인다 — 화면의 실패 표시에는 원문 전체와 함께 “Codex를 최신 버전으로 업그레이드하거나, 지원되는 다른 모델을 지정하세요” 같은 안내를 덧붙였어요
그리고 30분 뒤인 7시 36분 판(v0.1.20)에는 중계기 화면 안에서 Codex와 Claude 명령어 도구를 업데이트하는 버튼이 생겼습니다. 업데이트는 사람이 눌렀을 때만 해요. 맥에서 이 버튼으로 Codex를 0.139.0에서 0.144.3으로 올리자, 중계기가 다시 연결되면서 문제의 모델이 약 3초(3,021ms) 만에 워밍업을 마쳤다는 기록이 남아 있어요. 잘리지 않은 원문 한 줄이 30분 만에 해결로 이어진 셈입니다.

🚧 5. 규칙 하나, 검사 둘 — 세 번째에 바꾼 것
세 번째 사고 뒤 정한 규칙은 한 줄이에요.
오류 원문은 자르지 않는다. 기록에는 통째로 남기고, 짧은 요약은 화면에 보여 줄 때만 만든다. 비밀 값만 가린다.
블랙박스로 치면 영상은 처음부터 끝까지 다 저장하고, 목록 화면에서만 작은 미리보기를 보여 주는 방식이에요. 미리보기가 짧아도 원본이 남아 있으니 언제든 끝까지 돌려 볼 수 있죠.
다만 사람이 “앞으로 조심하자”고 다짐하는 것만으로는 부족했어요. 첫 번째 수정 뒤에도 자르는 곳은 18군데나 남아 있었으니까요. 그래서 규칙을 자동 검사로 만들었습니다. 검사는 두 가지예요.
검사 ① 코드를 읽는다 (7월 14일 아침)
사람 대신 프로그램이 중계기 코드를 한 줄씩 읽어요.
- 오류를 다루는 줄을 찾습니다. 줄 안에 오류(error), 메시지(message), 상세(detail), 이유(reason) 같은 이름이 들어 있는 줄이에요
- 그 줄에 “앞에서부터 몇 글자만 남겨라”라는 명령이 있고 그 수가 500보다 작으면 실패로 처리하고, 어느 파일 몇째 줄인지 알려 줍니다
- 예외도 정해 뒀어요. 프로그램 출력의 마지막 부분만 남기는 건 괜찮습니다. 끝을 남기면 사고 순간은 살아 있으니까요. 개발자가 보는 검은 창(콘솔)의 한 줄 요약, 채팅 답의 미리보기처럼 오류가 아닌 글도 예외예요
이 검사는 처음 돌리자마자 다른 AI들의 연결 부품과 공용 워밍업 기록에 숨어 있던 자르기를 잡아냈어요. 검사가 정말 무는지도 확인했습니다. 지웠던 160자 컷을 일부러 다시 넣어 보니 검사가 실패로 끝났어요.
사각지대 — 보관함도 자르고 있었다 (7월 14일 저녁)
그날 저녁 6시 36분 판(v0.1.27)에서 하나가 더 나왔어요. 기록을 모아 두는 보관함 자체가 첫 버전부터 모든 기록을 400자에서 자르고 있었던 거예요. 연결 부품이 원문을 통째로 넘겨도, 보관함에 들어가는 순간 400자로 줄었죠. 그날의 기록은 이 400자 컷을 “원인 소실의 주범”이라고 적고 있습니다. 아침에 만든 검사는 연결 부품 폴더만 읽고 있어서 보관함 코드는 보지 못했어요.
보관함은 이제 기록을 자르지 않고 비밀 값만 가립니다. 검사 ①이 읽는 범위도 보관함과 중계기 본체 코드까지 넓혔어요. 같은 판에서 답이 잘렸을 때 기록에 경고를 남기는 장치도 함께 들어갔는데, 그 이야기는 16편에 있어요.
검사 ② 실제로 돌려 본다 (7월 14일 저녁)
코드를 읽는 검사는 ‘자르는 명령’의 모양만 봅니다. 그래서 실제로 원문이 끝까지 살아남는지 확인하는 검사를 하나 더 만들었어요.
- 흉내만 내는 가짜 AI를 붙여 진짜 중계기를 켭니다
- 가짜 AI는 1,000자가 넘는 긴 거절 이유를 양식 없이 그냥 글로 보내요. 두 번째 사고의 모양 그대로입니다
- 그 글 안에는 세 번째 사고에서 잘렸던 바로 그 문구 ‘requires a newer version’과, 가짜 열쇠 값이 들어 있어요
- 그런 다음 중계기 기록을 열어 봅니다. ① 원문이 거의 줄지 않고 남았는지 ② 그 문구가 살아 있는지 ③ 가짜 열쇠가 가려지지 않은 채 노출되지는 않았는지. 하나라도 어긋나면 실패예요
세 번의 사고가 그대로 시험 문제가 된 셈이에요. 검사 ①은 처음에 시험을 돌릴 때마다 함께 돌았고, 지금은 배포 전에 반드시 거치는 점검 목록에 두 검사가 모두 들어 있습니다. 2026년 10월 1일 기준으로 검사 ①을 돌려 보면 여전히 통과해요. 검사를 언제, 어떻게 돌리는지는 31편에서 따로 다룰게요.

🧾 6. 지금 오류 한 건이 남기는 것
지금 중계기를 거치는 오류 한 건은 이렇게 남아요.
- 쓰는 쪽 프로그램이 받는 오류 — 사람이 읽을 이유 문장, 원래 상태 번호(upstream_status), 그리고 원문(upstream_body)이 함께 옵니다. 중계기 설명서의 오류 표에는 “업스트림이 실패. upstream_body에 진짜 원인이 담김”이라고 적혀 있어요. 업스트림은 중계기 뒤의 AI를 말해요. 요약이 마음에 안 들면 원문을 직접 읽으면 됩니다
- 중계기 화면의 기록 — 한 줄 요약으로 보이고, 누르면 펼쳐져 원문 전체가 나와요. 원문을 통째로 복사하거나 파일로 내보낼 수도 있습니다
- 비밀 값은 가린 채로 — 원문을 남기더라도 열쇠 같은 값은 앞 세 글자만 남기고 가려요. 원문 보존과 비밀 값 가리기는 함께 갑니다
세 번의 사고를 지나며 남은 습관을 정리하면 이래요.
① 받은 건 먼저 통째로 확보한다 — 양식으로 읽는 건 그다음입니다. 읽기에 실패해도 받은 글은 남아요
② 줄이는 건 보여 줄 때만 — 기록은 원문 그대로, 요약은 화면에서 만듭니다. 꼭 줄여야 한다면 앞이 아니라 끝을 남겨요
③ 같은 실수가 세 번이면 검사로 막는다 — 다짐 대신 자르는 코드가 들어오면 실패하는 검사를 둡니다

정리
① AI가 보내는 거절 이유(오류 원문)는 무엇이 잘못됐는지 알려 주는 단서예요. 상태 번호만으로는 알 수 없고, 세 번째 사고처럼 할 일이 문장 끝에 있기도 해요 ② 중계기는 첫 이틀 동안 그 단서를 세 번 잃었어요 — 화면 200자에서 끊긴 거절 이유, 양식이 아니라며 버려져 ‘HTTP 400’만 남은 이유, 기록 160자에서 끊긴 “더 새 버전이…” ③ 세 번째에 “오류 원문은 자르지 않는다”를 규칙으로 정하고, 코드를 읽는 검사와 실제로 돌려 보는 검사로 지켜요. 기록에는 원문 전체, 요약은 화면에서만, 비밀 값만 가려요
다음 19편은 “한국어는 토큰을 더 먹는다”입니다. 3절에서 잠깐 나온 ‘입력이 너무 길다’는 거절, 그리고 같은 글자 수라도 한국어가 AI의 분량 단위를 더 많이 쓰는 이유를 다룹니다.
2026년 10월 1일 기준입니다. 제가 만든 중계기의 개발 기록을 바탕으로 썼고, 특정 기업·서비스의 공식 입장이 아닙니다.