동적 실행 엔진스트리밍Redis PubSub진단

채널 키를 요청 ID로 잡으면 안 되는 이유

Redis 채널 이름을 요청 ID로 지어서 중첩 호출 때 구독자가 둘이 됐다. 토큰 5개가 195개로 불어났고, 몇 주 뒤에는 같은 원인으로 병렬 호출의 토큰이 섞였다.

2026년 3월 13일5분

에이전트 안에서는 사용자가 쓴 코드가 돌아가고, 그 코드가 다른 에이전트를 부를 수 있다. 부르면 답이 토큰 단위로 돌아오고, 그 토큰들이 브라우저까지 흘러간다.

print("Hello World") 한 줄을 실행시켰다. 모델이 만든 토큰은 다섯 개였다 — print, (", Hello, World, "). 브라우저가 받은 SSE 이벤트는 195개였다.

같은 실행에서 나온 두 숫자
브라우저가 받은 SSE 이벤트
195
모델이 실제로 만든 토큰
5

내용이 틀린 게 아니라 같은 것이 계속 왔다. 화면에는 같은 문장이 수십 번 쌓였다.

채널 이름이 요청 ID였다

대화 서비스는 요청을 받으면 sse.response:{요청 ID} 라는 이름의 Redis 채널 하나를 구독하고, 거기 올라오는 것을 브라우저로 밀어준다. 그게 SSE 응답이다.

샌드박스도 "코드 생성 중…" 같은 진행 상태를 브라우저에 띄우려고 같은 채널에 직접 발행한다. 여기까지는 설계대로다.

문제는 샌드박스가 다른 에이전트를 부를 때 요청 ID를 그대로 물려줬다는 것이다. 요청 ID가 같으니 채널 이름도 같아지고, 그 에이전트를 맡은 두 번째 대화 서비스도 같은 채널을 구독하게 된다.

브라우저대화 서비스 A샌드박스대화 서비스 B질문코드 실행다른 에이전트 호출 · 같은 요청 ID토큰 "print"자기가 쓴 것을 자기가 읽음같은 토큰이 반복해서 도착
1 / 6

01A가 sse.response:ORIG 를 구독한다. 여기까지는 정상이다.

토큰이 불어난 순서

로그에 남은 순서가 원인을 그대로 보여준다. 첫 토큰이 먼저 부풀고, 그다음부터는 지금까지 나온 토큰 전체가 한 덩어리로 계속 돈다.

단계채널에 올라온 것
1print × 7
2(" + 이전 토큰 1개
3Hello + 이전 토큰 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가 스트림마다 다르지 않았다.

요청 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 의 첫 값은 원본 그대로 남는다. 발행 대상 채널은 이 첫 값에서 가져오기 때문에, 채널은 갈리지만 브라우저로 나가는 길은 원래 하나 그대로다. 추적 체인도 끊기지 않는다.

병렬 쪽도 같은 방식으로 고쳤다. 공용 병렬 클라이언트가 태스크마다 헤더를 만들 때 요청 ID를 {원본}-{호출이름}-{난수 8자} 로 파생시킨다. 이름을 보면 어느 원본에서 갈라져 나온 어느 호출인지 읽힌다.

받는 쪽은 그대로 뒀다

고친 건 부르는 쪽이다. 받는 쪽 대화 서비스는 시작 에이전트 헤더가 없으면 자기 이름으로 채우는데, 중첩 호출에서 그 값이 비어 있다면 거의 확실히 실수인데도 거절하거나 경고하지 않고 그냥 넘어간다.

이 사고를 낸 파일은 에이전트마다 하나씩 있는 사용자 코드라서 규격도 없고 형상 관리 밖에 있다. 그 파일은 에이전트 이름 헤더는 넘기면서 요청 ID는 원본을 그대로 쓴다. 공용 클라이언트를 쓰면 열세 개 헤더가 전부 자동으로 처리되는데, 직접 HTTP 클라이언트를 만드는 길이 막혀 있지 않다. 몰라서 생긴 문제라기보다 우회 경로가 열려 있어서 생긴 문제인 것 같다.

발행 직전에 요청 ID, 발신 파드, 시각만 로그로 남겼어도 "구독자가 두 명"이라는 사실이 첫날에 보였을 것이다. 사후 분석 문서에도 그 로그가 필요하다고 적혀 있다. 에이전트가 에이전트를 부르고 브라우저가 받은 이벤트 수를 세는 테스트가 있었다면 병렬 호출 쪽도 미리 잡혔을 것 같은데, 중첩 호출 사고가 닫히고 나서 그 검사는 급한 일에서 밀렸고, 몇 주 뒤 병렬 호출에서 같은 문제를 또 겪었다.

끝