- Published on
Assistants API v2 run이 queued나 in_progress에 멈출 때 실전 디버깅 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그를 보면 요청은 성공(200)으로 끝났는데, 사용자 화면에서는 스트리밍이 멈춘 채로 돌아오지 않습니다. 다시 조회해보면 run.status가 queued 또는 in_progress에 고정되어 있고, 파일 업로드 후 검색/코드 실행 같은 툴콜이 들어간 순간부터 특히 잘 발생합니다.
Assistants API v2는 스레드(thread) → 런(run) → 스텝(step) 구조로 비동기 작업이 엮이고, 여기에 파일 처리, 툴 실행, 스트리밍 전송, 네트워크 프록시까지 겹치면 “어딘가에서” 멈춘 것처럼 보이기 쉽습니다. 이 글은 현업에서 재현되는 패턴을 기준으로, 원인을 빠르게 좁히고 취소/재시도 설계와 워커 분리로 영구적으로 줄이는 체크리스트를 제공합니다.
증상 패턴을 먼저 분류하자
run stuck는 원인별로 관측되는 현상이 조금씩 다릅니다.
1) queued에 오래 머문다
- 트래픽이 몰리거나 레이트리밋/큐잉이 걸릴 때 자주 보입니다.
- 동일 사용자/동일 스레드에서 연속 호출 시 더 잘 재현됩니다.
2) in_progress에서 스트리밍이 끊긴다
- 모델은 계속 돌고 있는데 클라이언트로 이벤트가 전달되지 않거나, 중간 프록시가 연결을 끊는 경우가 많습니다.
- 혹은 툴콜이 끝나지 않아 모델이 다음 토큰을 못 내는 상황(툴 타임아웃/무한 대기)일 수 있습니다.
3) 파일 업로드 후 다음 단계가 멈춘다
- 파일은 올라갔지만, 후속 처리(인덱싱/파싱/검색 준비)가 지연되거나 실패했는데 앱이 이를 재시도 없이 기다리는 경우가 있습니다.
run이 멈추는 3대 원인: 레이트리밋, 툴 타임아웃, 컨텍스트 폭주
원인 A. 레이트리밋/큐잉: “queued가 길다”의 1순위
Assistants API v2를 쓰는 서비스는 종종 다음을 동시에 합니다.
- 사용자 입력을 받아 thread에 메시지 추가
- run 생성
- run 진행 중 툴콜/파일 검색
- 결과를 스트리밍으로 전달
이 과정에서 레이트리밋이 걸리면, 서버는 재시도/백오프를 해야 하는데 그 처리가 런타임/요청 스레드에 묶여 있으면 사용자 입장에서는 “멈춤”으로 보입니다.
특히 위험한 패턴은 다음입니다.
- 단일 요청에서 여러 OpenAI 호출(파일 업로드 + run + tool 결과 제출)을 연쇄로 실행
- 429를 즉시 재시도(고정 sleep)로 처리
- 사용자별 토큰/요청 예산이 없어서 순간 폭주
레이트리밋 대응은 별도 글로도 다뤘지만, 여기서는 run stuck 관점에서 핵심만 요약합니다: 지수 백오프 + 큐잉 + 토큰 버짓이 없으면 queued가 길어지고, 재시도가 겹쳐 더 악화됩니다. 자세한 설계는 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기를 참고하세요.
체크리스트
- 429/5xx에 대해 지수 백오프 + jitter 적용했는가
- 사용자/조직 단위로 요청 큐를 두고 동시 실행 수를 제한하는가
-
run생성 전/후로 연쇄 호출이 몇 번 발생하는지 계측했는가
원인 B. 툴 타임아웃/무한 대기: “in_progress에서 멈춤”의 단골
Assistants API에서 가장 흔한 고착은 이 패턴입니다.
- 모델이
requires_action(툴 호출 필요) 상태로 진행 - 애플리케이션이 툴을 실행
- 툴이 느리거나(외부 API), DB 락, 네트워크 hang 등으로 끝나지 않음
- 결과를
submit_tool_outputs로 올리지 못해 run이 계속in_progress처럼 보임
여기서 중요한 점은 모델이 멈춘 게 아니라, 당신의 툴이 끝나지 않은 것일 수 있다는 겁니다.
Best Practice: 툴 타임박스(timebox) + 부분 실패 전략
- 툴 실행은 반드시 하드 타임아웃을 둡니다(예: 8초/15초).
- 타임아웃이면 “실패”를 모델에게 전달하고 다음 행동(대체 경로/재시도/사용자 안내)을 유도합니다.
- 툴이 오래 걸리는 작업(크롤링, 대용량 리트리벌, 보고서 생성)은 즉시 응답형 툴로 만들지 말고 백그라운드 작업으로 분리합니다.
아래는 Python(Async)에서 툴 타임박스를 강제하는 예시입니다.
import asyncio
class ToolTimeout(Exception):
pass
async def call_external_tool(payload: dict) -> dict:
# 예: 외부 API 호출, DB 쿼리, 사내 서비스 요청
...
async def run_tool_with_timebox(payload: dict, timeout_s: float = 10.0) -> dict:
try:
return await asyncio.wait_for(call_external_tool(payload), timeout=timeout_s)
except asyncio.TimeoutError as e:
raise ToolTimeout(f"tool timed out after {timeout_s}s") from e
그리고 타임아웃/예외를 tool output으로 모델에 반환해 런이 영원히 대기하지 않게 합니다.
# pseudo-code: tool call 처리 루프
try:
result = await run_tool_with_timebox(args, timeout_s=10)
tool_output = {"ok": True, "result": result}
except ToolTimeout as e:
tool_output = {"ok": False, "error": str(e), "retryable": True}
except Exception as e:
tool_output = {"ok": False, "error": f"unexpected: {e}", "retryable": False}
# tool_output을 submit_tool_outputs로 제출
체크리스트
- 툴 실행에 하드 타임아웃이 있는가(클라이언트 타임아웃이 아니라 서버 실행 타임아웃)
- 툴 실패/타임아웃을 모델에게 반환해 run이 다음 단계로 진행하도록 했는가
- 툴이 외부 API에 의존한다면, 해당 API의 타임아웃/재시도 정책이 중복되어 폭주하지 않는가
원인 C. 컨텍스트 폭주: 토큰/메시지/파일이 커져 “끝나지 않는 작업”으로 보임
Assistants API v2는 thread에 대화가 누적됩니다. 여기에 파일 내용을 그대로 붙이거나, RAG 결과를 과도하게 넣으면 다음이 발생합니다.
- 모델 입력 토큰이 커져 응답 지연이 급증
- 스트리밍이 시작되기까지 시간이 길어져 사용자는 멈췄다고 오해
- 툴콜이 잦아지고 스텝 수가 늘어 실패 지점이 증가
실무에서 자주 보는 실수
- 파일 전문을 메시지로 붙여넣기(“이 PDF 전체 요약해줘”를 그대로 본문 포함)
- 검색 결과 Top-K를 너무 크게(예: 20~50 chunk) 넣기
- 시스템/지침 프롬프트가 과도하게 길고 중복됨
체크리스트
- thread에 누적되는 메시지를 주기적으로 요약/정리하는가
- RAG chunk 수/길이를 토큰 예산으로 제한하는가
- 파일은 메시지에 직접 주입하지 말고, 파일 검색/리트리벌 파이프라인으로 다루는가
RAG/컨텍스트 최적화는 run stuck의 간접 원인을 크게 줄입니다. 반복/환각과 함께 토큰 폭주를 다루는 방법은 LangChain LlamaIndex RAG에서 답변이 반복되고 환각될 때 리랭커와 청킹 전략 토큰 예산으로 정확도 2배 올리는 디버깅 체크리스트도 같이 보세요.
스트리밍이 “멈춘 것처럼” 보이는 네트워크/프록시 이슈도 분리해서 보자
run은 정상 진행 중인데, SSE/WebSocket 스트리밍만 끊기는 경우가 있습니다. 이때 서버는 여전히 작업을 하고 있고, 클라이언트만 이벤트를 못 받습니다.
대표 원인:
- Nginx/ALB/Cloudflare가 idle timeout으로 연결 종료
- 프록시 버퍼링으로 SSE가 즉시 flush되지 않음
- gzip/압축 설정으로 이벤트가 뭉쳐서 전달됨
이 경우 해결은 애플리케이션 로직이 아니라 인프라 튜닝입니다. 재현/해결 체크는 FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트와 함께 보시면 빠릅니다.
해결 전략 1: idempotency key로 “중복 run 생성”을 차단
간헐적 멈춤이 생기면 사용자는 새로고침/재전송을 누릅니다. 서버도 타임아웃으로 재시도합니다. 이때 같은 입력으로 run이 여러 개 생성되면:
- 비용이 폭증
- thread가 오염(동일 질문이 여러 번 쌓임)
- 서로 다른 run이 같은 리소스(파일/DB)를 잡아 충돌
권장 패턴
- 클라이언트 요청 단위로
request_id(UUID)를 생성 - 서버는
request_id를 키로 단 하나의 run만 생성 - 재시도 요청이 오면 기존 run 상태를 조회해 이어서 스트리밍/폴링
간단한 DB 스키마 예:
CREATE TABLE assistant_runs (
request_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
run_id TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
서버 처리 예(개념 코드):
def get_or_create_run(request_id, thread_id, create_run_fn):
row = db.get("SELECT run_id, status FROM assistant_runs WHERE request_id=%s", request_id)
if row:
return row["run_id"]
run = create_run_fn(thread_id=thread_id) # OpenAI run 생성
db.execute(
"INSERT INTO assistant_runs(request_id, thread_id, run_id, status) VALUES(%s,%s,%s,%s)",
request_id, thread_id, run.id, run.status,
)
return run.id
핵심은 네트워크 타임아웃/클라이언트 재시도는 정상으로 보고, 서버가 멱등성을 보장하는 것입니다.
해결 전략 2: run cancel + retry를 “정책”으로 만든다
queued/in_progress가 일정 시간 이상 지속되면 “언젠가 끝나겠지”로 두면 장애가 누적됩니다. 운영 관점에서는 다음이 필요합니다.
- 런이 오래 걸리면 취소(cancellation)
- 동일 입력은 멱등 재시도
- 취소/재시도 횟수는 제한(예: 2회)
실전 타임아웃 정책 예시
queued30초 초과: 취소 후 재시도(레이트리밋/큐잉 가능성)in_progress90초 초과: 취소 후 재시도(툴 hang/컨텍스트 폭주 가능성)- 2회 실패: 사용자에게 “지연/오류” 안내 + 백그라운드 완료 시 알림
개념 코드(폴링 기반):
import time
QUEUED_LIMIT = 30
INPROGRESS_LIMIT = 90
def monitor_and_maybe_cancel(get_run, cancel_run, run_id):
start = time.time()
while True:
run = get_run(run_id)
elapsed = time.time() - start
if run.status in ("completed", "failed", "cancelled", "expired"):
return run
if run.status == "queued" and elapsed > QUEUED_LIMIT:
cancel_run(run_id)
raise TimeoutError("run stuck in queued")
if run.status == "in_progress" and elapsed > INPROGRESS_LIMIT:
cancel_run(run_id)
raise TimeoutError("run stuck in in_progress")
time.sleep(1.0)
주의: cancel은 만능이 아닙니다. 취소 후에도 백엔드에서 일부 작업이 진행 중일 수 있으니, 후속 재시도는 idempotency로 보호하고, 동일 thread에 중복 메시지를 넣지 않도록 해야 합니다.
해결 전략 3: “툴 실행 워커”를 웹 요청/스트리밍 서버에서 분리
run stuck의 체감 장애는 종종 애플리케이션 구조에서 나옵니다.
- 웹 서버(Uvicorn/Gunicorn)가 스트리밍 연결을 오래 잡고 있음
- 같은 프로세스에서 툴 실행(외부 API/DB/파일 파싱)을 돌림
- 툴이 느려지면 이벤트 루프/워커가 점유되어 스트리밍 flush가 지연
권장 아키텍처
- API 서버: 인증, thread/run 생성, 스트리밍 전달(가볍게)
- 워커: 툴 실행, 파일 처리, 긴 작업
- 큐/브로커: Redis/SQS 등
이때 중요한 운영 포인트는 중복 작업(유령 작업) 과 무한 재시도를 막는 것입니다. Celery/Redis를 쓴다면 acks_late, prefetch_multiplier, visibility_timeout 충돌로 같은 툴 작업이 중복 실행되어 run이 더 꼬일 수 있습니다. 워커 분리 후 이상 증상이 생기면 Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트를 같이 점검하세요.
실전 디버깅 체크리스트: “어디서 멈췄는지”를 10분 안에 좁히는 법
1) 런/스텝을 반드시 구조적으로 로깅
thread_id,run_id,request_id(idempotency), 사용자 id- run status 전이:
queued → in_progress → requires_action → ... - step 단위로:
- 어떤 툴이 호출됐는지
- 툴 시작/종료 시각
- 툴 입력 크기/출력 크기
2) 툴 실행 시간 분포를 먼저 본다
- P50/P95/P99
- 특정 툴만 느려지는지
- 외부 API/DB 의존이 있는지
3) 스트리밍 끊김과 run 고착을 분리
- 스트리밍이 끊겨도 백엔드에서 run이
completed로 끝나는지 확인 - 끝난다면 프록시/네트워크 문제
- 끝나지 않는다면 툴/레이트리밋/컨텍스트 문제
4) 컨텍스트/토큰 폭주를 수치로 확인
- thread 메시지 개수, 누적 문자 수, 첨부 파일 수
- RAG chunk 개수/총 토큰 추정치
- “스트리밍 시작까지 걸린 시간(TTFB)”을 지표로 잡기
5) 재시도 폭주를 막는 안전장치
- 동일
request_id로 재요청 시 기존 run 재사용 - 429/5xx 재시도 횟수 제한
- 서킷 브레이커(외부 툴 API 장애 시 빠른 실패)
결론: run stuck은 “모델 문제”보다 “운영 설계 문제”인 경우가 많다
queued/in_progress 고착은 대개 다음 3개 중 하나로 수렴합니다.
- 레이트리밋/큐잉으로 queued가 길어짐
- 툴이 끝나지 않아 in_progress에서 대기
- 컨텍스트 폭주로 지연이 누적되어 멈춘 것처럼 보임
오늘 바로 적용할 우선순위는 이렇습니다.
- 툴 실행에 타임박스를 걸고, 실패를 모델에게 반환
- 모든 run 생성에 idempotency key(request_id) 를 붙여 중복 실행 차단
queued/in_progress장기화 시 run cancel + 제한된 retry를 정책화- 긴 작업/툴콜은 백그라운드 워커로 분리하고 웹 서버는 스트리밍에 집중
위 4가지만 갖춰도 “간헐적으로 멈춘다”는 류의 장애는 재현 가능해지고, 재현 가능해지면 대부분은 제거할 수 있습니다.