본문으로 건너뛰기

Chat Completions 함수 호출을 Responses API로 이전하는 Python 검증 절차

Responses API 이전은 모든 함수 호출을 앱에서 검증·실행하고, 원래 call_id로 결과를 반환한 뒤 최종 답변까지 받아야 끝납니다. Python 비동기 예제로 전체 루프를 먼저 오프라인에서 확인하고, 업무 결과가 불명확하면 재실행을 멈춥니다.

LaoZhang AI Team게시업데이트 8 분 소요
목차
모델의 함수 호출과 앱의 검증·실행, 같은 call_id로 반환하는 결과, 최종 답변의 연결을 나타낸 순서도

Chat Completions에서 이미 돌아가는 함수 호출을 Responses API로 이전하려면 요청 형식과 도구 실행 루프를 함께 바꿔야 합니다. 모델이 반환한 function_call은 실행 요청입니다. 앱이 함수명·인자·권한을 검사하고 실제 함수를 실행한 다음, 원래 call_id를 가진 function_call_output을 돌려줘야 모델이 결과를 바탕으로 답변할 수 있습니다. HTTP 요청 성공이나 “처리했습니다”라는 문장만으로 업무 완료를 판정하면 안 됩니다.

먼저 아래 Python 예제를 오프라인에서 실행해 보세요. 가상 반품 수거 요청 세 개를 받아 등록 두 건을 만들고, 각 호출의 결과를 반환한 뒤 최종 답변을 받는 전체 흐름입니다. 실제 OpenAI 클라이언트나 택배 서비스에 연결하지 않습니다. 기존 앱에 붙일 때는 transport와 업무 저장소를 교체하고, 마지막의 전환 확인표를 실제 환경에서 통과해야 합니다.

OpenAI의 이전 가이드는 신규 프로젝트에 Responses를 권장하면서 Chat Completions도 계속 지원한다고 설명합니다. 기존 서비스를 한 번에 바꾸기보다 같은 업무의 호출·결과·복구 경로를 대조할 수 있는 단위로 이전하세요.

기존 코드에서 바꿔야 할 여섯 지점

함수 이름과 업무 규칙은 유지할 수 있지만, Chat 메시지를 Responses 입력에 그대로 섞을 수는 없습니다.

Chat Completions에서 사용한 값Responses에서 사용할 값앱에서 확인할 점
client.chat.completions.createclient.responses.create실제 연결 경로의 지원 여부
messagesinput문자열만 이어 붙이지 않고 관련 Item 보존
tools[].function 안의 정의tools[]에 평탄하게 놓은 함수 정의type, name, parameters, strict
choices[0].message.tool_callsoutput 전체의 function_call호출이 0개·1개·여러 개일 수 있음
role: "tool", tool_call_idfunction_call_output, call_id반환된 원래 call_id 유지
첫 assistant 메시지의 텍스트도구 결과 반환 후 최종 메시지업무 결과와 최종 문장을 별도로 확인

함수 호출 문서의 호출 Item에는 함수명, JSON 문자열인 arguments, call_id가 있습니다. Item 자체의 id는 다른 식별자입니다. 배열의 첫 요소가 텍스트라고 가정하거나, id를 call_id 대신 결과에 넣지 마세요.

아래 함수는 strict: True를 명시하고 객체의 모든 속성을 required에 넣으며 additionalProperties: False로 닫습니다. 현재 Responses에서 strict를 생략하면 가능한 스키마는 strict로 정규화하고, 호환되지 않으면 엄격한 스키마 보장 없이 호출하는 방식으로 돌아갑니다. 명시적으로 strict: True를 보냈는데 요구사항이 맞지 않으면 거절됩니다. 항상 strict가 된다고 가정하는 것도, 생략하면 무조건 오류라고 설명하는 것도 부정확합니다. 현재 strict 규칙을 기준으로 결정하세요.

strict는 인자의 형태를 제한할 뿐 주문 소유권이나 수거 가능 여부를 보증하지 않습니다. 함수 실행 없이 최종 JSON만 필요하다면 Structured Outputs와 Function Calling의 선택 기준을 먼저 확인하세요.

Python 비동기 예제: 호출 세 개, 가상 수거 등록 두 건

다음 전체 코드를 pickup_loop.py로 저장하고 Python 3.10 이상에서 실행합니다. 표준 라이브러리만 사용하므로 SDK 설치나 API 키가 필요 없습니다.

bash
python3 pickup_loop.py

Session의 사용자 범위·요청 키·완료 대상은 인증된 앱이 정하는 값입니다. 모델에게 사용자나 업무 권한을 선택하게 하지 않습니다. 예제 날짜와 주문 번호는 가상 데이터이며, 동시 실행 2개·업무 제한 2초·전체 제한 30초·최대 6라운드는 이 예제를 위한 값입니다.

python
import asyncio
import copy
import json
from dataclasses import dataclass, field

TOOLS = [{
    "type": "function", "name": "register_return_pickup",
    "description": "Register a pickup for an authorized return order.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string"},
            "pickup_date": {"type": "string", "enum": ["2026-10-12", "2026-10-13"]},
        },
        "required": ["order_id", "pickup_date"],
        "additionalProperties": False,
    },
}]
INSTRUCTIONS = (
    "반품 수거를 등록하려면 register_return_pickup을 호출하세요. "
    "도구 결과에 없는 등록 상태를 만들지 마세요. "
    "ok=false이면 자동 재시도하지 말고 오류를 설명하세요."
)

class StopLoop(RuntimeError):
    pass

@dataclass
class Session:
    # 인증된 앱이 결정하는 값이며 모델 인자가 아닙니다.
    tenant: str = "demo-shop"
    request_key: str = "return-request-44"
    allowed_orders: frozenset = frozenset({"ORD-7391", "ORD-7392"})
    history: list = field(default_factory=lambda: [{
        "role": "user", "content": "ORD-7391과 ORD-7392를 10월 13일에 수거해 주세요."
    }])
    expected_orders: frozenset = frozenset({"ORD-7391", "ORD-7392"})
    calls: dict = field(default_factory=dict)
    jobs: dict = field(default_factory=dict)
    registrations: dict = field(default_factory=dict)
    receipts: dict = field(default_factory=dict)
    limit: asyncio.Semaphore = field(default_factory=lambda: asyncio.Semaphore(2))


def parse_args(raw, session):
    def unique_keys(pairs):
        value = {}
        for key, item in pairs:
            if key in value:
                raise ValueError("duplicate_json_key")
            value[key] = item
        return value
    value = json.loads(raw, object_pairs_hook=unique_keys)
    if not isinstance(value, dict) or set(value) != {"order_id", "pickup_date"}:
        raise ValueError("invalid_fields")
    if not all(isinstance(v, str) for v in value.values()):
        raise ValueError("invalid_type")
    if value["order_id"] not in session.allowed_orders:
        raise ValueError("order_not_authorized")
    if value["pickup_date"] not in {"2026-10-12", "2026-10-13"}:
        raise ValueError("date_not_available")
    return value


async def register_return_pickup(args, session):
    # 가상 업무 저장소입니다. 외부 택배 서비스에는 연결하지 않습니다.
    await asyncio.sleep(0)
    key = (session.tenant, session.request_key, args["order_id"])
    receipt = {"pickup_id": "demo-" + args["order_id"], **args, "status": "registered"}
    session.registrations[key] = receipt
    return receipt

HANDLERS = {"register_return_pickup": register_return_pickup}


async def run_job(handler, args, session):
    try:
        async with session.limit:
            result = await asyncio.wait_for(handler(args, session), timeout=2)
        return {"ok": True, "data": result}
    except asyncio.CancelledError:
        raise
    except Exception:
        # 시간 초과·연결 오류만으로 외부 쓰기 실패를 확정할 수 없습니다.
        return {"ok": False, "error": "business_outcome_unknown"}


async def dispatch(call, session):
    cid = call["call_id"]
    handler = HANDLERS.get(call["name"])
    if handler is None:
        payload = {"ok": False, "error": "function_not_allowed"}
    else:
        try:
            args = parse_args(call["arguments"], session)
        except (ValueError, TypeError):
            payload = {"ok": False, "error": "arguments_rejected"}
        else:
            key = (session.tenant, session.request_key, args["order_id"])
            signature = json.dumps(args, sort_keys=True)
            if key not in session.jobs:
                # await 전에 pending 작업을 등록해 동시 중복 실행을 막습니다.
                session.jobs[key] = (signature, asyncio.create_task(run_job(handler, args, session)))
            saved_signature, task = session.jobs[key]
            if saved_signature != signature:
                payload = {"ok": False, "error": "operation_key_conflict"}
            else:
                payload = await asyncio.shield(task)
    session.receipts[cid] = payload
    return {"type": "function_call_output", "call_id": cid,
            "output": json.dumps(payload, ensure_ascii=False)}


def inspect_output(response, session):
    if response.get("status") != "completed" or response.get("error"):
        raise StopLoop("response_not_completed: " + str(response.get("incomplete_details")))
    items = response.get("output")
    if not isinstance(items, list):
        raise StopLoop("missing_output_array")
    fresh, signatures = {}, dict(session.calls)
    texts, refused = [], False
    for item in items:
        kind = item.get("type")
        if kind not in {"message", "reasoning", "function_call"}:
            raise StopLoop("unsupported_item: " + str(kind))
        if kind == "function_call":
            cid = item.get("call_id")
            if not isinstance(cid, str) or not cid:
                raise StopLoop("missing_call_id")
            if not isinstance(item.get("name"), str) or not isinstance(item.get("arguments"), str):
                raise StopLoop("malformed_call")
            if (item.get("status") not in {None, "completed"}
                or item.get("async") is True or item.get("namespace")
                or item.get("caller") not in (None, {"type": "direct"})):
                raise StopLoop("unsupported_call_mode")
            sig = (item["name"], item["arguments"])
            if cid in signatures and signatures[cid] != sig:
                raise StopLoop("call_id_conflict")
            if cid not in signatures:
                fresh[cid] = item
            signatures[cid] = sig
        elif kind == "message":
            if item.get("status") not in {None, "completed"}:
                raise StopLoop("partial_message")
            for part in item.get("content", []):
                refused |= part.get("type") == "refusal"
                if part.get("type") == "output_text" and item.get("phase") != "commentary":
                    texts.append(part["text"])
    if refused:
        raise StopLoop("model_refusal")
    if not fresh and any(i.get("type") == "function_call" for i in items):
        raise StopLoop("duplicate_call_without_new_progress")
    session.calls = signatures
    return list(fresh.values()), "".join(texts).strip()


async def migrate(create, model, session, reasoning=None):
    deadline = asyncio.get_running_loop().time() + 30
    try:
        for _ in range(6):
            remaining = deadline - asyncio.get_running_loop().time()
            if remaining <= 0:
                raise StopLoop("deadline_exceeded")
            request = {"model": model, "instructions": INSTRUCTIONS,
                       "input": copy.deepcopy(session.history), "tools": TOOLS,
                       "parallel_tool_calls": True, "store": False,
                       "max_output_tokens": 1200}
            if reasoning is not None:
                request["reasoning"] = reasoning
            response = await asyncio.wait_for(create(**request), timeout=remaining)
            calls, text = inspect_output(response, session)
            # 원본 Item 전체를 보존합니다. phase와 encrypted_content도 유지됩니다.
            session.history.extend(copy.deepcopy(response["output"]))
            if not calls:
                if not text:
                    raise StopLoop("final_text_missing")
                if not session.receipts:
                    return {"status": "no_action", "text": text, "receipts": {}}
                status = "tool_failed"
                if all(r["ok"] for r in session.receipts.values()):
                    actual = {r["data"]["order_id"] for r in session.receipts.values()}
                    status = "done" if actual == session.expected_orders else "task_incomplete"
                return {"status": status, "text": text, "receipts": session.receipts}
            outputs = await asyncio.wait_for(
                asyncio.gather(*(dispatch(c, session) for c in calls)),
                timeout=max(0.001, deadline - asyncio.get_running_loop().time()))
            session.history.extend(outputs)
            if any(r.get("error") == "business_outcome_unknown" for r in session.receipts.values()):
                raise StopLoop("business_outcome_unknown: reconcile before resuming")
        raise StopLoop("round_limit_exceeded")
    except (asyncio.TimeoutError, asyncio.CancelledError):
        # 취소된 네트워크 요청 또는 남은 업무의 결과를 먼저 대조해야 합니다.
        for _, task in session.jobs.values():
            if not task.done():
                task.cancel()
        await asyncio.gather(*(task for _, task in session.jobs.values()), return_exceptions=True)
        raise


class FakeResponses:
    def __init__(self):
        self.requests = []

    async def create(self, **request):
        self.requests.append(copy.deepcopy(request))
        if len(self.requests) == 1:
            calls = [{"type": "function_call", "id": "fc-" + str(i),
                      "call_id": "call-" + str(i), "name": "register_return_pickup",
                      "arguments": json.dumps({"order_id": order, "pickup_date": "2026-10-13"}),
                      "status": "completed"}
                     for i, order in enumerate(["ORD-7391", "ORD-7392", "ORD-7391"])]
            return {"status": "completed", "output": [
                {"type": "reasoning", "id": "rs-demo", "summary": [], "encrypted_content": "synthetic"},
                {"type": "message", "role": "assistant", "phase": "commentary",
                 "content": [{"type": "output_text", "text": "수거 가능 여부를 확인하겠습니다."}]},
                *calls]}
        outputs = [i for i in request["input"] if i.get("type") == "function_call_output"]
        assert {i["call_id"] for i in outputs} == {"call-0", "call-1", "call-2"}
        assert all(json.loads(i["output"])["ok"] for i in outputs)
        return {"status": "completed", "output": [{
            "type": "message", "role": "assistant", "phase": "final_answer",
            "content": [{"type": "output_text", "text": "두 주문의 10월 13일 수거가 등록되었습니다."}]}]}


async def offline_demo():
    fake, session = FakeResponses(), Session()
    result = await migrate(fake.create, "offline-fixture", session)
    assert result["status"] == "done"
    assert len(session.registrations) == 2  # 원본 call_id는 세 개, 가상 등록은 두 건
    assert fake.requests[1]["input"][1]["encrypted_content"] == "synthetic"
    assert fake.requests[1]["input"][2]["phase"] == "commentary"
    print(json.dumps(result, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    asyncio.run(offline_demo())

출력의 status가 done이고 세 call_id의 receipt가 모두 성공이면, 가상 응답에 대한 앱 로직이 끝난 것입니다. call-0과 call-2는 같은 주문·요청을 가리키므로 가상 등록을 공유하지만, 도구 결과는 각 원래 ID로 따로 반환합니다. 최종 완료 판단에는 요청한 두 주문의 실제 receipt도 필요합니다. 하나만 등록했는데 모델이 두 건을 처리했다고 말해도 task_incomplete가 됩니다.

가상 예제의 호출 세 개가 업무 키에 따라 수거 등록 두 건을 공유하고 원래 call_id별로 결과를 반환하는 관계도

오류를 최종 성공으로 바꾸지 않는 방법

함수명 허용 목록과 인자 검사를 통과하지 못하면 handler를 실행하지 않고 ok: false를 돌려줍니다. 모델이 이 오류를 설명할 수는 있지만 결과 상태는 tool_failed입니다. 함수 호출 없이 최종 문장만 온 경우도 no_action으로 구분합니다.

failed, incomplete, queued, in_progress, cancelled 응답은 응답 완료를 기다리는 이 루프에서 실행 대상으로 받지 않습니다. call_id 누락·충돌, 지원하지 않는 Item, 미완료 호출, 거절 응답, 최종 텍스트 누락도 중단 조건입니다. Responses 상태·스트리밍 문서에 맞춰 오류와 부분 응답을 성공 경로와 분리한 앱 정책입니다.

이 예제는 일반 사용자 정의 함수만 다룹니다. Python에서 asyncio를 사용한다는 이유로 API의 별도 비동기 도구 기능을 구현한 것은 아닙니다. async: true, namespace, program caller 같은 다른 호출 방식은 명시적으로 중단합니다. 도입하려면 그 기능의 별도 실행·재개 계약이 필요합니다.

메모리 중복 방지와 영속 멱등성은 다릅니다

session.jobs는 handler를 기다리기 전에 pending task를 등록합니다. 같은 프로세스에서 두 호출이 동시에 들어와도 같은 업무 키에 대해 handler를 두 번 시작하지 않습니다. 동일한 업무 키에 다른 날짜를 넣으면 기존 결과를 덮어쓰지 않고 operation_key_conflict를 반환합니다.

하지만 프로세스가 재시작되면 이 딕셔너리는 사라집니다. 실제 수거 등록은 (tenant, request_key, order_id) 같은 안정적인 업무 키를 DB의 unique constraint로 관리하고, 키 확인·업무 변경·결과 기록을 같은 트랜잭션에 넣어야 합니다. 외부 택배 시스템이라면 영속 outbox와 해당 시스템의 멱등 키, 결과 조회·대조 절차가 필요합니다. 이 설계는 앱에서 구현할 사항이며 OpenAI가 제공하는 정확히 한 번 실행 보장이 아닙니다.

원래 call_id는 모델 호출과 도구 결과를 연결합니다. 새 모델 요청에서 ID가 달라져도 같은 반품 요청일 수 있으므로 비즈니스 키로 대신 사용할 수 없습니다. parallel_tool_calls: False로 바꾸는 것도 중복 쓰기 방지책이 아닙니다. 그 설정은 한 턴에 호출을 0개 또는 1개로 제한합니다. tool_choice: "required"도 호출 요청을 요구할 뿐 실행 성공을 보증하지 않습니다.

handler 예외·시간 초과 때는 business_outcome_unknown으로 멈춥니다. 응답이 없다는 이유만으로 택배 시스템에 등록이 안 됐다고 단정할 수 없기 때문입니다. 외부 상태와 영속 기록을 확인하기 전에는 같은 쓰기를 자동으로 재실행하지 마세요.

기존 Responses 클라이언트에 연결하기

migrate는 비동기 create(**request)가 전체 응답 딕셔너리를 반환하도록 받습니다. 이미 앱에서 관리하는 Python Responses 클라이언트가 있다면 다음 adapter로 연결할 수 있습니다. 클라이언트 생성, 인증 설정과 실행 호출은 기존 앱에서 맡습니다.

python
from pickup_loop import migrate, Session

async def run_with_existing_client(client, model, session, reasoning=None):
    async def create(**request):
        response = await client.responses.create(**request)
        return response.model_dump(exclude_unset=True)
    return await migrate(create, model, session, reasoning=reasoning)

이 adapter는 문서 형태에 맞춘 연결 예시이며, 이 글에서는 실제 SDK의 수락이나 서버 응답을 검증하지 않았습니다. 기존 앱의 SDK 버전·모델·route로 요청과 replay를 확인해야 합니다. output_text는 Python SDK가 텍스트를 모아 주는 편의 속성이지, raw REST의 전체 상태를 대신하는 필드가 아닙니다. 위 코드는 전체 output을 보존하고 최종 메시지의 텍스트를 별도로 추출합니다.

이전 과정에서 모델까지 자동으로 바꾸지 마세요. 현재 모델별 API·매개변수 호환성에 따르면 GPT-6 Astra와 GPT-6.1 Sol의 함수 호출에는 Responses가 필요합니다. GPT-6 Sol·Luna의 Chat 함수 호출에는 reasoning_effort: none 조건이 있습니다. Responses에서는 reasoning.effort를 사용하며, Astra·6.1 Sol은 none을 지원하지 않습니다. reasoning이 none이 아닌 설정에 기존 sampling·logprob 옵션을 그대로 넘기지도 마세요. 기존 모델과 실제 effort를 보존할 수 있는 호환 조합부터 확인합니다.

다음 요청에 어떤 상태를 남길 것인가

위 코드는 수동 replay를 사용합니다. response.output 전체와 이번 배치의 도구 결과를 다음 input에 추가하며, 매번 instructions를 보냅니다. 텍스트 메시지만 추출해 이력을 다시 만들면 reasoning Item과 assistant 메시지의 phase를 잃을 수 있습니다. 현재 상태 관리 문서는 암호화된 reasoning Item을 기본 반환하며 관련 전체 Item의 보존을 안내합니다. commentary 메시지는 이력에 남지만 이 예제의 최종 답변으로는 사용하지 않습니다.

다른 상태 방식은 보관·복구 조건에 따라 선택합니다.

방식다음 요청놓치기 쉬운 조건
수동 replay관련 전체 이력과 새 도구 결과앱이 이력을 보관하고 문맥 크기를 관리
previous_response_id이전 응답 ID와 새 결과이전 top-level instructions는 승계되지 않음
conversation지속 대화 ID와 새 입력완료 후 입력·출력이 자동 추가되며 보관 정책 별도

previous_response_id와 conversation은 같은 요청에 함께 지정할 수 없습니다. 이미 ID로 참조하는 이력까지 전체로 중복 보내지 마세요. previous_response_id는 HTTP에서도 사용하며, WebSocket 연결 내부 캐시와는 복구 조건이 다릅니다. 요청 payload가 작아져도 이전 문맥의 입력 토큰 비용이 없어지는 것은 아닙니다.

store=False는 일반 Response 저장을 끄는 옵션입니다. 조직을 Zero Data Retention 대상으로 만들거나 앱·중간 제공자의 로그까지 지우지 않습니다. 일반 저장 응답의 기본 30일 보관과 달리 Conversation Item에는 같은 30일 TTL이 적용되지 않습니다. 데이터 보관 문서에서 조직의 승인된 데이터 제어와 실제 사용하는 기능을 함께 확인하세요.

스트리밍: arguments.done만 보고 함수를 실행하지 않기

스트리밍에서도 원래 call_id는 실제 호출 Item에서 가져옵니다. response.function_call_arguments.done의 현재 스키마에는 arguments, item_id, output_index, sequence_number, type이 있으며 전체 Item이나 call_id가 들어 있다고 가정하면 안 됩니다.

다음 collector는 보수적으로 전체 response.completed까지 모은 뒤 동일한 migrate 루프에 응답을 넘깁니다. delta는 실행하지 않고, 인자 완료·Item 완료·전체 응답 완료를 따로 확인합니다. response.output_item.done의 완료 Item과 최종 응답이 다르면 중단합니다.

stream_collect.py로 저장하세요. 같은 디렉터리의 pickup_loop.py에서 오류 클래스를 가져오며, 입력 events는 JSON 딕셔너리를 내보내는 비동기 iterator입니다. SDK 이벤트 객체를 사용한다면 먼저 해당 버전의 직렬화 방식으로 변환해야 합니다.

python
import json
from pickup_loop import StopLoop


async def collect_stream(events):
    seen, slots = {}, {}
    async for event in events:
        seq = event["sequence_number"]
        encoded = json.dumps(event, sort_keys=True)
        if seq in seen:
            if seen[seq] != encoded:
                raise StopLoop("event_sequence_conflict")
            continue
        seen[seq] = encoded
        kind = event["type"]
        if kind in {"error", "response.failed", "response.incomplete"}:
            raise StopLoop("stream_failed: " + kind)
        if kind == "response.output_item.added" and event["item"]["type"] == "function_call":
            item = event["item"]
            index = event["output_index"]
            if index in slots:
                raise StopLoop("duplicate_stream_slot")
            slots[index] = {"item": item, "buf": item["arguments"], "args_done": False, "done": False}
        elif kind in {"response.function_call_arguments.delta", "response.function_call_arguments.done"}:
            slot = slots.get(event["output_index"])
            if slot is None or slot["item"]["id"] != event["item_id"]:
                raise StopLoop("unmatched_arguments")
            if slot["args_done"]:
                raise StopLoop("arguments_after_done")
            if kind.endswith(".delta"):
                slot["buf"] += event["delta"]
            else:
                if slot["buf"] != event["arguments"]:
                    raise StopLoop("arguments_mismatch")
                slot["args_done"] = True
        elif kind == "response.output_item.done" and event["item"]["type"] == "function_call":
            slot = slots.get(event["output_index"])
            item = event["item"]
            if (slot is None or not slot["args_done"] or slot["done"]
                or any(item.get(k) != slot["item"].get(k) for k in ("id", "call_id", "name"))
                or item["arguments"] != slot["buf"]):
                raise StopLoop("completed_item_mismatch")
            slot["item"], slot["done"] = item, True
        elif kind == "response.completed":
            response = event["response"]
            if response.get("status") != "completed" or response.get("error"):
                raise StopLoop("terminal_response_failed")
            final_calls = {i: item for i, item in enumerate(response["output"])
                           if item["type"] == "function_call"}
            if set(final_calls) != set(slots):
                raise StopLoop("terminal_call_set_mismatch")
            for index, item in final_calls.items():
                if not slots[index]["done"] or slots[index]["item"] != item:
                    raise StopLoop("terminal_item_mismatch")
            return response  # reasoning·message·phase도 포함한 최종 전체 응답
    raise StopLoop("stream_ended_without_completed")

stream transport의 create가 await collect_stream(events)를 반환하면 비스트리밍과 같은 검증·실행·결과 반환 루프를 사용할 수 있습니다. 네트워크 이벤트 대기에도 deadline을 적용해야 합니다. 위 migrate는 transport 전체를 asyncio.wait_for로 감싸므로 스트리밍 완료를 영원히 기다리지 않습니다.

이 collector의 중복 이벤트 검사와 완료 후 실행 정책은 앱 구현 예시입니다. 서버가 끊긴 스트림을 자동 복구하거나 이벤트 재전송을 보장한다는 뜻이 아닙니다. failed, incomplete, 오류 이벤트 또는 완료 이벤트 없는 EOF에서는 handler를 실행하지 않습니다. 업무 실행 후 최종 답변을 받다가 끊겼다면 아래 복구 경로로 넘어갑니다.

전체 응답 완료 후 실행하는 스트리밍 예제와 업무 실행·결과 확인 상태에 따른 세 가지 복구 경로를 설명한 개념도

최종 답변이 끊겨도 수거를 다시 등록하지 않기

복구할 때 먼저 마지막으로 확인된 단계부터 구분합니다.

  1. 완료된 호출 Item을 받기 전: 부분 JSON을 실행하지 않았는지 확인합니다. 실행이 없었다면 저장한 입력에서 모델 요청을 다시 시작할 수 있습니다.
  2. 업무 실행 중 결과가 불명확: 영속 업무 키로 택배 시스템과 결과 기록을 조회합니다. 등록됐는지 확인 전에는 쓰기를 다시 하지 않습니다.
  3. 업무 결과는 확정됐지만 모델 답변이 없음: 저장해 둔 원본 호출·관련 전체 Item·각 function_call_output으로 이어갑니다. 복구 요청의 목적은 이미 확정된 결과의 설명입니다.

모델 요청 retry와 업무 처리 retry는 별도 정책입니다. 예제의 메모리 Session만 저장했다고 프로세스 장애 뒤 복구가 된다고 주장할 수 없습니다. 운영에서는 인증 범위, 업무 키, 원래 호출과 출력, 확정·불명확 상태를 영속 저장하되 보관 정책에 맞게 민감 정보를 줄입니다.

max_output_tokens는 reasoning과 사용자에게 보이는 출력을 함께 제한합니다. max_tool_calls는 내장 도구 호출 제한이며 위 사용자 정의 handler의 라운드 수나 업무 처리 비용을 제한하지 않습니다. 앱의 deadline·동시 실행 수·최대 라운드·업무 결과 기록을 별도로 관리해야 합니다.

운영 전환 전에 대조할 결과

가상 예제에서는 여러 호출, 같은 업무의 중복, 인자·권한 거절, ID 충돌, 응답 실패, 빈 답변, 합성 스트림의 EOF·미완료·중복 이벤트를 오프라인으로 확인했습니다. 이는 앱 분기 검증이며 실제 모델 응답, SDK 수락, 외부 수거 등록이나 재시작 후 멱등성의 증거가 아닙니다.

실제 전환에서는 동일한 인증 범위와 고정 업무 키를 사용해 기존 경로와 새 경로를 대조하세요. 실제 쓰기를 두 경로에서 중복 실행하는 대신, 테스트 저장소·읽기 전용 대조·승인된 제한 실행 중 업무에 맞는 방법을 선택합니다.

확인 항목통과 판단통과하지 못했을 때
요청·모델 호환실제 route가 flat 함수 스키마와 해당 모델 설정을 수락전환을 멈추고 endpoint·설정을 확인
실행 권한잘못된 주문·함수·인자가 handler까지 가지 않음앱 검증과 인증 범위 수정
호출 연결모든 호출의 결과가 원래 call_id에 연결임의 ID 생성 없이 원본을 복구
여러 호출배치 전체 결과가 다음 요청 전에 모임collector·동시 실행 정책 수정
업무 중복재시작·중복 요청 뒤에도 같은 업무 키의 확정 등록 한 건영속 키·트랜잭션·대조 절차부터 수정
실제 완료필요한 주문 receipt와 최종 답변 모두 확인부분 업무·모델 실패를 별도 상태로 남김
스트리밍완료·실패·EOF와 취소를 구분하고 부분 인자 미실행비스트리밍 경로를 유지
복구·롤백기존 확정 업무를 다시 쓰지 않고 답변·상태 복구전환 확대 중단

Azure나 다른 호환 제공자를 사용하면 base URL, 인증, deployment·모델 ID, 보관, 상태 연결과 이벤트 지원을 해당 제공자의 계약으로 확인해야 합니다. OpenAI 형식의 요청이나 HTTP 200만으로 모든 기능의 호환성을 판정할 수 없습니다.

롤백도 엔드포인트 플래그 하나로 끝나지 않습니다. 미리 확인한 모델·엔드포인트·매개변수·상태·업무 결과의 조합으로 돌아갈 수 있어야 합니다. Responses 전용 함수 호출 모델을 그대로 Chat으로 돌리는 것은 동작하는 롤백이 아닙니다. 이미 완료한 수거의 업무 키와 결과 기록도 전환 중 유지하세요.

자주 묻는 질문

함수 결과만 받으면 이전이 끝난 건가요?

아닙니다. 앱에서 업무 결과가 확정되고, 각 호출에 같은 call_id의 결과를 반환하며, 사용자가 받을 최종 답변까지 확인해야 합니다. 실패 설명이 자연스러워도 업무 성공으로 처리하지 않습니다.

parallel_tool_calls를 끄면 중복 등록을 막을 수 있나요?

아닙니다. 이 옵션은 한 턴의 호출을 0개 또는 1개로 제한합니다. 네트워크 retry·모델 재요청·프로세스 재시작에 따른 중복 업무는 영속 키와 업무 트랜잭션으로 처리해야 합니다.

store=False면 이전 reasoning과 phase를 빼도 되나요?

아닙니다. 수동 replay에서는 관련 전체 출력 Item을 유지해야 합니다. 현재 상태 관리 가이드는 암호화된 reasoning과 assistant phase를 보존하도록 안내합니다. 저장 옵션과 다음 요청에 필요한 문맥은 별개입니다.

Chat Completions를 반드시 중단해야 하나요?

Chat Completions는 계속 지원됩니다. 다만 사용하는 모델의 함수 호출 지원은 별도로 확인해야 합니다. 기존 앱이 호환 모델에서 정상 동작한다면, 새 루프와 복구 절차가 확인될 때까지 기존 경로를 유지할 수 있습니다.

참고 자료8

이 글이 링크한 외부 페이지를 본문에 나온 순서대로 정리했습니다. 마지막 업데이트: 2026년 10월 8일.

  1. 1.OpenAI의 이전 가이드developers.openai.com/api/docs/guides/migrate-to-responses
  2. 2.함수 호출 문서developers.openai.com/api/docs/guides/function-calling
  3. 3.Responses 상태·스트리밍 문서developers.openai.com/api/docs/guides/streaming-responses
  4. 4.현재 모델별 API·매개변수 호환성developers.openai.com/api/docs/guides/latest-model
  5. 5.현재 상태 관리 문서developers.openai.com/api/docs/guides/conversation-state
  6. 6.previous_response_id와 conversation은 같은 요청에 함께 지정할 수 없습니다developers.openai.com/api/reference/resources/responses/methods/create
  7. 7.데이터 보관 문서developers.openai.com/api/docs/guides/your-data
  8. 8.response.function_call_arguments.done의 현재 스키마developers.openai.com/api/reference/resources/responses/streaming-events
Assistant 설정과 Thread 대화, Run 실행을 Responses의 설정·상태·도구 처리로 옮기는 흐름
API 가이드

OpenAI Assistants API를 Responses API로 마이그레이션하는 방법

Assistants API는 이미 종료됐습니다. 앱에 남아 있는 설정과 대화 기록을 기준으로 Responses 요청을 구성하고, 도구 결과와 검색 근거를 확인한 뒤 트래픽을 전환해야 합니다. 장애 시 돌아갈 대상도 동작하는 Responses 버전으로 준비합니다.

10 분
Nano Banana Pro의 429 응답에서 한도 확인, 대기, 중단을 구분하고 이미지 결과를 보존하는 흐름
API 가이드

Nano Banana Pro RESOURCE_EXHAUSTED 해결: 429 재시도와 이미지 작업 복구

Nano Banana Pro에서 429가 발생하면 명시적 한도 0·일일 한도·결제 중단은 먼저 처리하고, 일시적 제한만 서버가 요구한 시간 이후 재시도합니다. Python과 Node.js 예제는 총 시도와 누적 시간을 제한하며 실제 이미지와 완료 기록을 저장합니다.

7 분
ChatGPT 구독과 별도로 API 키, 결제 크레딧, 환경 변수, 첫 호출을 확인하는 흐름도
API 가이드

ChatGPT API 키 발급 방법: OpenAI 키 생성·결제·첫 호출까지

ChatGPT 구독과 OpenAI API 결제는 별개입니다. Platform 프로젝트에서 키를 발급해 한 번만 저장하고, 권한과 Billing을 확인한 다음 서버 환경 변수로 Responses 요청을 보내 Usage 기록까지 확인해야 실제 발급이 끝납니다.

4 분
터미널에서 ModelScope에 액세스 토큰으로 연결해 Qwen Image 2.1로 이미지를 생성하고 PNG 파일로 저장하는 흐름
API 가이드

Qwen Image 2.1 무료 API: ModelScope에서 쓰는 법

Qwen-Image-2.1은 ModelScope 무료 API로 호출할 수 있습니다. 실명 인증된 Alibaba Cloud 계정 연결이 필요하고, 하루 약 250회가 이론상 상한이며 비상업용입니다.

10 분
API 프로젝트의 권한과 결제를 확인하고 Astra에 요청해 첫 응답을 받는 흐름과 별도로 확인해야 하는 ChatGPT 구독
API 가이드

GPT-6 Astra API 사용 방법: 프로젝트 권한 확인부터 첫 응답까지

GPT-6 Astra는 API에서 제공 중입니다. 실제 호출 가능 여부는 사용하는 API 조직·프로젝트와 결제 상태에 따라 달라집니다. Python 예제로 첫 요청을 보내고, 정상 응답·출력 중단·인증 및 한도 오류를 구분하는 방법을 안내합니다.

6 분