[개발 깨알 상식_Tips] / Responses API WebSocket mode에서 매 턴 전체 히스토리 다시 보내지 않기: previous_response_id로 incremental loop 붙이기.md

Responses API WebSocket mode에서 매 턴 전체 히스토리 다시 보내지 않기: previous_response_id로 incremental loop 붙이기

조회

2026년 4월 23일 | 개발 깨알 상식_Tips


Responses API WebSocket mode를 보면서 내가 메모한 건 새 전송 방식 하나가 아니라 에이전트 turn을 새 요청으로 취급하지 않는 기준이었다. OpenAI가 4월 22일 공개한 설명을 보면, tool call이 20번 넘게 이어지는 롤아웃에서 종단 기준 약 40% 더 빠른 실행을 봤다고 적혀 있다. 포인트는 모델만 빨라진 게 아니라, follow-up turn마다 전체 대화 히스토리와 도구 정의를 다시 검증하고 다시 토크나이즈하는 낭비를 줄였다는 데 있었다.

나는 이런 글을 보면 성능 수치보다 먼저 내 루프에서 무엇을 매번 다시 보내고 있었나를 본다. 코딩 에이전트나 tool-heavy orchestration은 생각보다 모델 추론보다 바깥의 왕복이 길다. 같은 세션에서 tool result 하나 붙였을 뿐인데 매번 새 HTTP 요청처럼 이어 붙이고 있었다면, 프롬프트를 더 다듬기 전에 continuation 방식을 먼저 바꾸는 편이 체감 차이가 크다. 오늘 팁은 이 한 줄로 정리된다. 새 turn마다 전체 히스토리를 되감지 말고, 소켓 하나를 오래 붙인 채 incremental input만 이어 보내기.

HTTP turn-by-turn loop와 WebSocket incremental loop 비교 도식

Figure 1. 같은 agent loop라도 매 턴 전체 상태를 다시 싣는 방식과, 소켓 위에서 이전 response 상태를 이어 쓰는 방식의 차이가 꽤 크다.

이 그림처럼 보면 감이 빨리 온다. 왼쪽은 새 turn마다 전체 맥락을 다시 실어 보내는 방식이고, 오른쪽은 같은 연결 위에 이전 response 상태를 남겨 두고 새 정보만 덧붙이는 방식이다. OpenAI가 WebSocket을 고른 이유도 여기에 있었다. input/output shape를 완전히 갈아엎지 않고도, repeated continuation overhead를 줄이는 쪽으로 갈 수 있었기 때문이다.

1. 핵심은 WebSocket 자체보다 previous_response_id를 operational primitive로 보는 것

문서에서 제일 중요한 문장은 의외로 단순하다. WebSocket mode에서는 persistent connection을 유지하고, 다음 turn을 보낼 때 previous_response_id새 input item만 보낸다. 즉 continuation의 단위를 "새 request"가 아니라 "같은 run의 다음 조각"으로 보는 셈이다. 이 관점이 생기면 설계가 달라진다. tool output, 다음 사용자 한 줄, 혹은 아주 짧은 보정 메시지만 보내고, 나머지 state는 소켓 위에 남겨 둔다는 생각으로 바뀐다.

이게 중요한 이유는 도구 호출이 많은 루프일수록 반복 비용이 커지기 때문이다. 블로그는 이전 세대 모델이 대략 초당 65 token 근처였던 시절에는 API service overhead가 잘 가려졌다고 적는다. 그런데 빠른 코딩 모델과 하드웨어가 들어오면서, 이제는 inference보다 validation, processing, network hop, history rebuild 쪽이 더 잘 보이기 시작했다. 나는 이 설명이 꽤 실전적이었다. 모델이 빨라질수록, 우리 쪽의 무의식적인 왕복 낭비가 더 선명하게 튀어나온다.

from websocket import create_connection
import json
import os

ws = create_connection(
    "wss://api.openai.com/v1/responses",
    header=[f"Authorization: Bearer {os.environ['OPENAI_API_KEY']}"],
)

ws.send(
    json.dumps(
        {
            "type": "response.create",
            "model": "gpt-5.4",
            "store": False,
            "input": [
                {
                    "type": "message",
                    "role": "user",
                    "content": [{"type": "input_text", "text": "Find fizz_buzz()"}],
                }
            ],
            "tools": [],
        }
    )
)

첫 turn은 생각보다 평범하다. 중요한 건 여기서 끝나지 않고, 같은 소켓 위에서 다음 turn을 이어 받는다는 점이다. 문서가 강조하는 것도 그 부분이다. transport만 바꾸고 request body는 가능한 한 그대로 유지한다는 접근이라, 기존 Responses API 사용 코드를 완전히 갈아엎지 않아도 들어갈 수 있다.

2. 진짜 팁은 follow-up turn에서 전체 맥락 대신 새 조각만 보내는 습관

내가 오늘 바로 가져가고 싶은 포인트는 이 섹션이었다. follow-up turn에서는 tool 결과와 다음 사용자 메시지처럼 새로 생긴 item만 input에 넣고, 그 앞단 맥락은 previous_response_id로 잇는다. 이 습관 하나만 잡아도 "툴 한 번 돌릴 때마다 왜 이렇게 느리지"라는 질문의 절반은 구조적으로 설명된다.

ws.send(
    json.dumps(
        {
            "type": "response.create",
            "model": "gpt-5.4",
            "store": False,
            "previous_response_id": "resp_123",
            "input": [
                {
                    "type": "function_call_output",
                    "call_id": "call_123",
                    "output": "tool result",
                },
                {
                    "type": "message",
                    "role": "user",
                    "content": [{"type": "input_text", "text": "Now optimize it."}],
                },
            ],
            "tools": [],
        }
    )
)

나는 여기서 "무엇을 다시 보내지 않을 것인가"를 먼저 적는 편이다. 긴 system instruction, 변하지 않은 tool definition, 이미 처리한 이전 대화 덩어리를 매번 새 request body처럼 다 싣는 순간, inference가 아무리 빨라도 체감은 둔해진다. 반대로 tool-heavy loop에서 이 규칙을 지키면, 모델이 빨라진 만큼 사용자가 바로 느끼는 속도도 같이 올라간다.

3. warmup과 cache는 세세한 옵션이 아니라 긴 세션용 기본기였다

문서에서 의외로 좋았던 부분이 generate: false warmup 설명이다. 앞으로 쓸 tool, instruction, custom message가 이미 정해져 있으면 먼저 request state를 준비만 해 두고, 실제 생성 turn은 그다음에 붙일 수 있다. 이건 단순한 미세 튜닝이 아니라, 긴 세션에서 앞단 세팅을 미리 고정해 두는 운영 습관에 가깝다.

또 하나 기억할 점은 WebSocket continuation이 빠른 이유가 connection-local in-memory cache를 쓰기 때문이라는 점이다. 즉 최근 response 상태가 그 소켓 위에 살아 있을 때 가장 빠르다. 그래서 나는 이 기능을 보면 무조건 "long-lived agent loop에만 붙일지, 아니면 짧은 질의응답에도 섞을지"를 먼저 나눈다. 짧은 one-shot 호출은 굳이 이 복잡도를 안 가져가도 되지만, coding agent나 tool orchestration처럼 왕복이 길어질수록 이쪽이 훨씬 자연스럽다.

4. 실무에서 바로 걸릴 만한 함정 세 가지

  • 한 소켓에서 한 번에 하나만: 현재는 multiplexing이 없어서 한 연결에서 여러 run을 동시에 태우지 못한다. 병렬 작업이 필요하면 연결을 여러 개 써야 한다.
  • 60분 제한: 연결 수명은 60분이다. 길게 도는 작업이면 reconnect 경로를 미리 넣어 두는 편이 안전하다.
  • store=false에서 cache를 잃으면 끝: 문서 기준으로 uncached previous_response_idprevious_response_not_found를 돌려준다. persisted fallback이 없기 때문이다.

이 셋 때문에 나는 WebSocket mode를 볼 때 "빠르다"보다 먼저 실패 후 재개 전략을 적는다. 특히 store=false나 ZDR 쪽은 소켓이 끊긴 순간 chain을 잇지 못할 수 있으니, 새 chain으로 갈지, compaction 결과를 베이스로 다시 만들지, 아니면 아예 full context를 다시 붙일지 기준이 있어야 한다. 빠른 transport를 붙였는데 recovery path가 없으면 체감은 금방 다시 나빠진다.

상황 내 선택 이유
tool call 20회 이상, coding/agent loop WebSocket mode 우선 per-turn continuation overhead가 누적돼서 차이가 잘 드러난다.
짧은 one-shot 요청 일반 Responses 호출 유지 연결 관리 복잡도를 들고 올 이유가 크지 않다.
긴 세션 + 재개 가능성 큼 reconnect / compaction 루트 먼저 설계 속도보다 recovery path가 없을 때 체감 품질이 더 빨리 무너진다.

5. 오늘 내가 바꿔 적어 둔 체크리스트

  • 첫째, 이 워크플로가 정말 multi-turn / tool-heavy 인지 먼저 본다.
  • 둘째, continuation payload에 새 item만 남기고 나머지는 소켓 상태로 넘길 수 있는지 점검한다.
  • 셋째, generate: false warmup이 이득인 구간인지 본다.
  • 넷째, 60분 제한과 previous_response_not_found 복구 루트를 같이 적는다.

나는 이런 주제를 보면 금방 거대한 아키텍처 이야기로 흘러가기 쉬운데, 오늘은 오히려 반대로 적었다. 새 turn마다 전체 히스토리를 다시 보내지 않기. 이 한 줄이 핵심이었다. WebSocket mode는 그걸 위한 transport이고, previous_response_id는 그 습관을 코드에 박아 넣는 고리였다. agent loop가 길어질수록 prompt engineering보다 continuation engineering이 먼저 체감 차이를 만들 때가 있는데, 오늘 글이 딱 그 사례였다.

원문은 OpenAI 블로그 글, WebSocket mode 가이드, Python responses.connect() 레퍼런스를 같이 보면 바로 감이 온다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.