Responses API의 web_search·file_search·computer_use를 한 스트리밍 루프에서 조율하기
백엔드 서비스에 AI 에이전트를 붙이려 할 때 처음 부딪히는 벽이 있습니다. 도구 호출 → 결과 수집 → 재호출로 이어지는 루프를 직접 구현하는 일입니다. Chat Completions API로 이걸 해본 분들은 알 겁니다. tool_calls 파싱, 결과 메시지 재조립, 스트리밍과 도구 호출의 동시 처리… 생각보다 지저분해집니다. 저도 스트리밍 중에 JSON 인수가 청크 경계에서 잘리는 버그를 잡느라 반나절을 날린 적이 있습니다.
2025년 3월에 나온 Responses API는 이 루프를 API 레벨의 일급 개념으로 흡수한 시도입니다. 여기에 web_search, file_search, computer_use 세 가지 내장 도구가 붙어 있습니다. 이 글이 다루려는 질문은 하나입니다. 세 도구를 함께 다뤄야 하는 백엔드 에이전트를 만들 때, 실제로 어떻게 등록·조율하고 어디서 손을 대야 하는가.
2026년 9월 현재 OpenAI는 Assistants API 대신 Responses API를 신규 프로젝트의 기본 출발점으로 권장하고 있고, Assistants API의 공식 deprecation 일정은 개별 공지를 확인해야 합니다. 정확한 시점은 OpenAI Deprecations 페이지에서 최신 상태를 봐 두세요.
Chat Completions에서 Responses API로: 무엇이 달라졌나
에이전트 루프의 위치가 바뀌었다
기존 Chat Completions에서는 도구 호출이 발생하면 개발자가 직접 루프를 돌려야 했습니다. 모델이 tool_calls를 반환하면 실행하고, role: "tool" 메시지로 결과를 넣어서 다시 API를 호출하는 구조였죠. Responses API는 이 사이클 중 상당 부분을 API 내부로 옮겼습니다.
web_search와 file_search는 OpenAI 인프라 안에서 실행되는 호스티드 도구입니다. 모델이 도구를 부르면 결과 주입까지 자동으로 처리됩니다. 반면 computer_use는 실제 환경(브라우저·OS)이 개발자 쪽에 있으므로 여전히 액션을 받아서 실행하고 결과를 다시 넣는 수동 루프가 필요합니다.
상태 관리도 서버 쪽으로
previous_response_id를 넘기면 이전 응답의 컨텍스트를 서버가 이어받습니다. 클라이언트에서 전체 히스토리를 매번 실어 보낼 필요가 없어 멀티턴 에이전트를 만들 때 상태 관리 부담이 줄어듭니다.
세 도구의 역할과 동작 방식
web_search — 실시간 그라운딩
훈련 데이터 컷오프 이후 정보나 실시간 데이터가 필요할 때 씁니다. search_context_size로 검색 깊이를 조절할 수 있고 쿼리당 비용이 별도로 청구됩니다. 도구 타입 문자열은 시점에 따라 web_search_preview, web_search 등으로 갈릴 수 있으니 Web search 문서에서 현재 지원 타입명을 확인하고 쓰세요. 아래는 저비용 모드로 등록한 예시입니다.
tools = [
{
"type": "web_search_preview",
"search_context_size": "low",
}
]file_search — 내부 지식 베이스 RAG
사전에 업로드된 파일을 Vector Store에 넣어 두면 시맨틱·키워드 검색을 결합해 관련 청크를 자동으로 주입합니다. 파일 업로드 시 파싱·청킹·임베딩이 관리형으로 이루어집니다. Vector Store의 파일 수, 용량, 비용 등 정량 지표는 시점에 따라 바뀌므로 File search 문서의 최신 값을 그대로 참조하세요.
tools = [
{
"type": "file_search",
"vector_store_ids": ["vs_abc123"],
}
]computer_use — 스크린샷 기반 UI 조작
레거시 웹 앱 자동화나 GUI 기반 내부 시스템 조작에 씁니다. 이 도구는 전용 모델(예: computer-use-preview) 에서 동작하며, 일반 gpt-4o로는 그대로 호출되지 않는 경우가 있으니 Computer use 문서에서 현재 사용 가능한 모델 ID를 확인해야 합니다.
API 대화 상태와 실제 브라우저 상태가 분리돼 있어서 두 상태를 동기화할 책임은 온전히 개발자에게 있습니다. 스크린샷을 다운스케일하면 좌표 변환도 직접 처리해야 합니다.
세 도구를 하나의 스트리밍 루프에 담기
이 글의 본론입니다. 호스티드 도구(web_search·file_search)와 클라이언트 도구(computer_use)는 성격이 다르지만, 하나의 요청에 세 도구를 모두 등록하고 응답 스트림을 소비하면서 클라이언트 도구만 개별 처리하는 형태로 통합할 수 있습니다.
통합 등록
from openai import OpenAI
client = OpenAI()
TOOLS = [
{"type": "web_search_preview", "search_context_size": "low"},
{"type": "file_search", "vector_store_ids": ["vs_internal_kb"]},
{
"type": "computer_use_preview",
"display_width": 1280,
"display_height": 800,
"environment": "browser",
},
]
SYSTEM = """
답변할 때 다음 순서를 지켜.
1) 먼저 file_search로 내부 KB를 찾는다.
2) 내부 문서에 없거나 최신 정보가 필요할 때만 web_search를 쓴다.
3) UI 조작이 필요한 작업만 computer_use로 넘긴다.
""".strip()시스템 프롬프트로 우선순위를 명시하는 이유가 있습니다. 세 도구를 함께 등록하면 모델이 웹 검색을 먼저 고르는 경향이 있는데, 그러면 내부 문서로 답할 수 있는 질문에도 유료 웹 검색 쿼리가 발생해 비용이 누수됩니다. 명시적 우선순위가 이 편향을 낮춥니다.
이벤트 소비 루프
Responses API 스트리밍은 SSE로 타입이 명시된 이벤트를 뿌립니다. Chat Completions의 raw delta 파싱과 달리 이벤트 스키마가 정해져 있어 처리가 명확합니다. 다만 각 이벤트 타입에 어떤 필드가 실리는지는 시점에 따라 바뀌므로, 아래 코드에서 접근하는 속성은 Responses streaming events 레퍼런스와 대조해 두세요. 이벤트 이름과 필드는 개념적 예시로 봐도 좋습니다.
def run_turn(user_input, previous_response_id=None, screenshot_b64=None):
input_items = [
{
"role": "user",
"content": [{"type": "input_text", "text": user_input}],
}
]
if screenshot_b64:
input_items[0]["content"].append({
"type": "input_image",
"image_url": f"data:image/png;base64,{screenshot_b64}",
})
stream = client.responses.create(
model="computer-use-preview",
instructions=SYSTEM,
tools=TOOLS,
input=input_items,
previous_response_id=previous_response_id,
stream=True,
)
pending_computer_calls = []
final_response = None
for event in stream:
et = event.type
if et == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif et.startswith("response.web_search_call."):
print(f"\n[web_search 이벤트: {et}]")
elif et.startswith("response.file_search_call."):
print(f"\n[file_search 이벤트: {et}]")
elif et == "response.output_item.done":
item = event.item
if getattr(item, "type", None) == "computer_call":
pending_computer_calls.append(item)
elif et == "response.completed":
final_response = event.response
return final_response, pending_computer_calls포인트가 두 개입니다. 첫째, web_search·file_search의 진행 상태 이벤트는 UI에 검색 중 배지를 띄우는 용도로만 씁니다. 이벤트 페이로드의 실제 필드명(쿼리 문자열 노출 여부 포함)은 레퍼런스로 확인하고 접근하세요. 잘못된 속성을 찍으면 런타임 에러가 납니다. 둘째, computer_call은 스트림이 끝난 뒤 별도로 처리하기 위해 큐에 쌓아 둡니다.
computer_call을 물려서 이어 돌리기
수집한 computer_call을 실행한 뒤, Responses API의 computer_call_output 스키마로 결과를 첨부해 다음 응답을 요청합니다. Chat Completions의 role: "tool" + tool_call_id 패턴과는 다른 포맷입니다.
def capture_screenshot() -> str: # 개념적 예시
...
def execute_action(action) -> None: # 개념적 예시
...
def drive_agent(user_input):
previous_id = None
screenshot = None
prompt = user_input
while True:
response, computer_calls = run_turn(prompt, previous_id, screenshot)
previous_id = response.id
if not computer_calls:
return response
follow_up_items = []
for call in computer_calls:
execute_action(call.action)
new_shot = capture_screenshot()
follow_up_items.append({
"type": "computer_call_output",
"call_id": call.call_id,
"output": {
"type": "input_image",
"image_url": f"data:image/png;base64,{new_shot}",
},
})
stream = client.responses.create(
model="computer-use-preview",
tools=TOOLS,
input=follow_up_items,
previous_response_id=previous_id,
stream=True,
)
prompt = ""
screenshot = None주의할 지점 몇 가지입니다.
- 필드명은
computer_call_output/call_id입니다. Chat Completions 감각으로role: "tool"·tool_call_id를 쓰면 Responses API input 스키마와 어긋납니다. call_id는computer_call항목이 노출하는 참조값입니다. 항목 자체의id와 혼동하지 마세요. 정확한 필드는 응답 객체 스키마로 확인하는 게 안전합니다.- 후속 요청도
stream=True로 유지하면 텍스트 delta와 다음 액션을 동일한 소비 루프에서 다룰 수 있어 일관성이 유지됩니다.
도구 조율의 의사결정 흐름
스트리밍에서 흔히 만나는 함정
함수 인수 델타가 청크 경계에서 잘린다
커스텀 함수 도구를 함께 등록하면 인수 JSON이 여러 델타 이벤트에 걸쳐 도착합니다. 이벤트 이름은 시점에 따라 다르지만(예: response.function_call_arguments.delta / .done) 원리는 같습니다. 델타가 오는 즉시 파싱하지 말고 완료 이벤트까지 누적한 뒤 처리해야 합니다. 정확한 이벤트 이름은 스트리밍 이벤트 레퍼런스를 확인해 코드에 반영하세요.
import json
buffers = {}
for event in stream:
if event.type.endswith(".function_call_arguments.delta"):
buffers.setdefault(event.call_id, "")
buffers[event.call_id] += event.delta
elif event.type.endswith(".function_call_arguments.done"):
args = json.loads(buffers.pop(event.call_id))file_search 청킹은 직접 제어할 수 없다
PDF의 표나 페이지 경계를 걸친 정보가 잘못 검색되는 사례를 종종 봅니다. 청킹 방식을 세밀하게 조정할 수 없기 때문에, 구조화된 데이터는 스토어에 넣기 전에 텍스트로 전처리하거나 요약 인덱스를 따로 만들어 두는 편이 안정적입니다.
web_search 편향과 비용
세 도구를 함께 등록하면 모델이 웹 검색을 기본 선택지로 자주 고릅니다. 내부 문서 활용을 강제하려면 시스템 프롬프트로 우선순위를 명시하고, search_context_size를 low로 시작해 필요할 때만 올리는 접근이 안전합니다.
computer_use의 상태 동기화
브라우저 상태와 API 상태가 어긋나면 모델이 이전 스크린샷 기반으로 좌표를 되뇌는 일이 생깁니다. 액션 후 반드시 새 스크린샷을 캡처하고, 다운스케일 시 좌표 변환 매트릭스를 명시적으로 관리하세요. 장기 실행 루프는 재시도·타임아웃·중단 재개가 얽히므로 Temporal 같은 워크플로우 오케스트레이터와 조합하는 패턴도 실무에서 종종 보입니다.
Agents SDK를 쓸 것인가, 직접 짤 것인가
OpenAI 공식 오픈소스인 openai-agents-python은 Responses API 위에서 멀티에이전트 워크플로우, 핸드오프, 가드레일을 추상화합니다. 스트리밍 이벤트를 타입 객체로 넘겨 주기 때문에 SSE를 직접 파싱할 필요가 없습니다.
단일 에이전트에 단일 스트리밍 루프라면 SDK 없이 openai 파이썬 패키지만으로 충분합니다. 여러 에이전트가 서로 작업을 넘기거나 가드레일·트레이싱이 필요한 시나리오라면 SDK 채택이 낫습니다. computer_use처럼 실패·재개가 잦은 장기 루프는 SDK만으로는 부족하고, 워크플로우 오케스트레이터를 앞단에 두는 조합을 고려하세요.
정리
Responses API의 실질적 이득은 두 가지입니다. 호스티드 도구의 실행 루프가 API로 내려갔다는 것, 그리고 스트리밍 이벤트가 타입 스키마로 정리됐다는 것. 여기까지는 코드가 깔끔해집니다. 하지만 computer_use처럼 환경이 개발자 쪽에 있는 도구는 여전히 클라이언트에서 루프를 짜야 하고, 그 지점에서 상태 동기화·필드명·재요청 스키마 같은 실수 유발 요소가 몰려 있습니다.
세 도구를 함께 다루는 백엔드 에이전트를 만든다면 이렇게 접근하길 권합니다. 세 도구를 한 요청에 등록하고, 시스템 프롬프트로 사용 우선순위를 못 박고, 스트리밍 이벤트 소비 루프에서 computer_call만 큐잉해 별도 처리하는 형태입니다. 이벤트 이름과 필드는 시점마다 바뀌므로, 코드에 하드코딩하기 전에 스트리밍 이벤트 레퍼런스와 Computer use 문서에서 현재 스키마를 다시 한 번 대조해 두세요.
참고 자료
- OpenAI — New tools for building agents
- OpenAI — New tools and features in the Responses API
- OpenAI 공식 문서 — Using tools
- OpenAI 공식 문서 — Web search
- OpenAI 공식 문서 — File search
- OpenAI 공식 문서 — Computer use
- OpenAI 공식 문서 — Streaming API responses
- OpenAI 공식 문서 — Responses streaming events
- OpenAI Deprecations 페이지
- OpenAI Agents SDK — Streaming
- GitHub — openai/openai-agents-python
- Microsoft Learn — Azure OpenAI Responses API