채널 이름을 잘못 지었다
토큰 5개를 만들었는데 브라우저에는 195개가 도착했다. 스트리밍 코드는 멀쩡했고, 문제는 채널에 붙인 이름이었다.
에이전트 안에서 사용자가 쓴 코드가 돌아간다. 그 코드가 다른 에이전트를 부를 수 있다. 부르면 답이 토큰 단위로 돌아오고, 그 토큰들이 브라우저까지 흘러간다.
print("Hello World") 한 줄을 실행시켰다. 모델이 만든 토큰은 다섯 개였다 — print, (", Hello, World, "). 브라우저가 받은 SSE 이벤트는 195개였다.
내용이 틀린 게 아니라 같은 것이 계속 왔다. 화면에는 같은 문장이 수십 번 쌓였다.
채널 이름이 요청 ID였다
대화 서비스는 요청을 받으면 Redis 채널 하나를 구독한다. 채널 이름은 sse.response:{요청 ID}. 거기 올라오는 것을 브라우저로 밀어준다. 그게 SSE 응답이다.
샌드박스도 같은 채널에 직접 발행한다. "코드 생성 중…" 같은 진행 상태를 브라우저에 띄우려면 그래야 한다. 여기까지는 설계대로다.
문제는 샌드박스가 다른 에이전트를 부를 때 요청 ID를 그대로 물려줬다는 것이다. 요청 ID가 같으면 채널 이름이 같다. 채널 이름이 같으면 그 에이전트를 맡은 두 번째 대화 서비스도 같은 채널을 구독한다.
01A가 sse.response:ORIG 를 구독한다. 여기까지는 정상이다.
한 명이 쓰고 두 명이 읽는데, 그 두 명 중 하나가 쓰는 사람이었다.
에코가 자라는 모양
로그에 남은 순서가 원인을 그대로 보여준다. 첫 토큰이 먼저 부풀고, 그다음부터는 지금까지 나온 토큰 전체가 한 덩어리로 계속 돈다.
| 단계 | 채널에 올라온 것 |
|---|---|
| 1 | print × 7 |
| 2 | (" + 이전 토큰 1개 |
| 3 | Hello + 이전 토큰 2개 |
| 4 | World + 이전 토큰 3개 |
| 5 | ") + 이전 토큰 4개, 이 5토큰 묶음이 38회 반복 |
증상만 보면 스트리밍 버그다. 그래서 처음에는 스트리밍 코드를 봤다. 버퍼링, 청크 경계, 재조립. 전부 아니었다. 중복은 파이프의 문제가 아니라 구독자 수의 문제다. 한 명이 쓰고 두 명이 읽으면 파이프를 아무리 잘 짜도 두 번 나온다. 파이프를 보는 동안에는 그게 안 보인다.
같은 버그의 두 번째 얼굴
몇 주 뒤 다른 증상이 올라왔다. 이번엔 중첩 호출이 아니라 병렬 호출이었다. 샌드박스가 에이전트 두 개를 동시에 불러 서울 날씨와 LA 날씨를 각각 받아오는 코드였다.
LA 쪽은 멀쩡했다. 서울 쪽은 이렇게 나왔다.
## 🌤# 🌤️# 🌤️# 🌤️ Tomorrow# 🌤️ Tomorrow#'s Weather 🌤️ Tomorrow#'s
Weather 🌤️ in Tomorrow Seoul#'s Weather 🌤 (️Web Search in Tomorrow Seoul#'s …토큰이 반복되는 게 아니라 서로 다른 지점에 끼워 넣어지고 있었다. 스트리밍 중에는 인용 표시가 자리표시자로 먼저 나가고, 출처가 확정되면 "이 문자열을 저것으로 바꿔라"는 이벤트가 뒤따라간다. 병렬 요청 둘이 같은 채널을 쓰니 그 치환 이벤트가 서로의 텍스트에 적용됐다. 이미 바뀐 자리에 또 적용되고, 부분 일치가 누적됐다.
증상은 완전히 달라 보이는데 원인은 한 줄이다. 채널 이름이 요청 ID인데, 그 요청 ID가 스트림마다 다르지 않았다. 중첩이면 에코가 되고, 병렬이면 뒤섞인다.
두 번의 수정, 같은 모양
먼저 나간 것은 임시방편이었다. pubsub 발행을 에이전트 레벨에서 떼어내 오케스트레이터 레벨로 올렸다. 안쪽에서 발행하는 주체를 없애면 루프가 성립하지 않는다. 증상은 멈췄지만 채널 이름은 그대로였다.
진짜 수정은 안쪽 호출에 새 ID를 파생시키는 것이었다.
# 안쪽 호출용 ID를 새로 만든다 — 채널이 갈린다
inner_request_id = uuid7str()
inner_headers = {
**headers,
"x-request-id": inner_request_id,
"x-request-traces": f"{x_request_id},{inner_request_id}",
}x-request-traces 의 첫 값이 원본으로 남는 게 핵심이다. 발행 대상 채널은 traces의 첫 값에서 가져오기 때문에, 채널은 갈리지만 브라우저로 나가는 길은 원래 하나 그대로다. 추적 체인도 끊기지 않는다.
병렬 쪽도 같은 모양으로 끝났다. 공용 병렬 클라이언트가 태스크마다 헤더를 만들 때 요청 ID를 {원본}-{호출이름}-{난수 8자} 로 파생시킨다. 이름을 보면 어느 원본에서 갈라져 나온 어느 호출인지 읽힌다.
남은 것
받는 쪽은 지금도 조용하다. 고친 것은 부르는 쪽이다. 대화 서비스는 여전히 시작 에이전트 헤더가 없으면 자기 이름으로 채운다. 중첩 호출인데 그 값이 비어 있다는 건 거의 확실히 실수인데, 거절하지도 경고하지도 않는다.
샌드박스 코드는 규격이 없다. 이 사고를 낸 파일은 에이전트마다 하나씩 있는 사용자 코드고, 형상 관리 밖에 있다. 지금 확인해 보니 그 파일은 에이전트 이름 헤더는 넘기지만 요청 ID는 여전히 원본을 그대로 쓴다. 공용 클라이언트를 쓰면 열세 개 헤더가 전부 자동으로 처리되는데, 직접 HTTP 클라이언트를 만드는 길이 막혀 있지 않다. 문제는 지식이 아니라 우회 경로다.
누가 몇 번 발행했는지 세는 곳이 없다. 발행 직전에 요청 ID, 발신 파드, 시각만 남겼어도 "구독자가 두 명"이라는 사실이 첫날에 보였을 것이다. 이 로그는 아직 없다. 사후 분석 문서에 "필요하다"고 적혀만 있다.
재현 테스트는 없다. 에이전트가 에이전트를 부르고 브라우저가 받은 이벤트 수를 세는 검사 하나면 이 계열의 버그는 끝난다. 사고가 닫히자 급하지 않아졌고, 그게 병렬 호출에서 같은 원인이 다시 나온 이유다.