디렌더러 API

디렌더러 API (Derenderer APIs)

디렌더러 API는 렌더러 API의 후처리 대응편입니다. /render가 요청을 토큰 ID로 바꾸는 전처리라면, /derender는 생성된 토큰 ID를 다시 완전한 OpenAI 호환 응답(detokenization, reasoning 파싱, tool call 파싱)으로 되돌립니다. 이 모든 작업은 GPU 없이 수행됩니다.

출처: 문서

본문

디렌더러 API는 Renderer APIs의 후처리 대응편입니다. /render가 요청을 토큰 ID로 바꾸는(전처리) 반면, /derender는 생성된 토큰 ID를 다시 완전한 OpenAI 호환 응답(detokenization, reasoning 파싱, tool call 파싱)으로 되돌립니다. 모두 GPU 없이 수행됩니다.

이렇게 하면 disaggregated 서빙의 token-in / token-out 엔진에 대한 루프가 완성됩니다:

  • GPU 없는 후처리: Detokenization, reasoning 파싱, tool call 파싱이 /render를 호스팅하는 동일한 GPU 없는 프론트엔드에서 실행됩니다
  • 파서 일관성(parity): 디렌더러는 vLLM의 tool·reasoning 파서를 재사용하므로, disaggregated 배포가 표준 vllm serve 서버와 동일한 content/reasoning/tool_calls 분할을 만들어냅니다
  • 스트리밍 및 원샷 파싱: 비스트리밍 호출은 모든 토큰 ID가 있는 완전한 GenerateResponse를 보내고 원샷 파싱을 수행합니다. 두 엔드포인트 모두 stream: true도 받아, 하나의 GenerateStreamResponse 델타 + 클라이언트가 유지하는 stream_state를 받아 {chunk, stream_state}를 반환합니다. chat 엔드포인트의 스트리밍 경로는 같은 토큰 ID에 대해 generate 스트리밍 경로가 내보내는 것과 동일한 reasoning/content/tool_calls 델타를 내보내면서 reasoning과 tool call 파싱을 지원합니다(Streaming 참고)

두 엔드포인트 모두 vllm launch render로 시작되는 GPU 없는 렌더링 서버가 /render 엔드포인트와 함께 호스팅합니다.

파이프라인 (Pipeline)

                render                 generate                derender
  request  ───────────────▶  token_ids  ─────────▶  token_ids  ──────────▶  response
 (chat /            (GPU less)          (token-in /            (GPU less)   (OpenAI
 completion)            │               token-out engine)          ▲        compatible)
                        └─────────────── request + prompt_tokens ──┘

디렌더 단계는 엔진의 token_ids보다 더 많은 것을 필요로 합니다. 또한 render 단계에서 이어받은 원래 chat_request/completion_requestprompt_tokens(Request format 참고)도 소비하므로, tool·reasoning 파서가 필요한 문맥을 가질 수 있습니다. chat 스트리밍 경로는 파서가 구성되어 있을 때 추가로 prompt_token_ids(Streaming cost 참고)가 필요합니다.

API 레퍼런스

  • Chat Completions Derender API (/v1/chat/completions/derender) 단일 GenerateResponseChatCompletionResponse로 후처리합니다. stream: true이면 하나의 GenerateStreamResponse 청크를 ChatCompletionStreamResponse 청크로 후처리합니다
  • Completions Derender API (/v1/completions/derender) GenerateResponse 객체 목록(프롬프트당 하나)을 CompletionResponse로 후처리합니다. stream: true이면 하나의 GenerateStreamResponse 청크를 CompletionStreamResponse 청크로 후처리합니다

요청 형식 (Request format)

각 요청은 엔진의 GenerateResponse를 GPU 없이 최종 응답을 재구성하는 데 필요한 호출자 메타데이터와 함께 감쌉니다.

/v1/chat/completions/derender:

    stream: Literal[False] = False

    model: str | None = None
    """Served model name. Defaults to the server's served model name."""

    generate_response: GenerateResponse
    """The complete token-in / token-out engine response to derender."""

    prompt_tokens: int | None = None
    """Prompt token count for usage; defaults to 0 if omitted.

    GenerateResponse carries only output tokens; the caller already has
    len(GenerateRequest.token_ids) from the render step.
    """

    chat_request: ChatCompletionRequest | None = None
    """The original (post-adjust_request) ChatCompletionRequest from /render.

    Required by the parsing so that tool/reasoning parsers can receive the full
    request context they expect (request.tools, request.tool_choice,
    request._grammar_from_parser, etc.).
    """

/v1/completions/derender:

    stream: Literal[False] = False

    model: str | None = None
    """Served model name. Defaults to the server's served model name."""

    generate_responses: list[GenerateResponse]
    """One response per prompt, parallel to the list[GenerateRequest]
    returned by /v1/completions/render."""

    prompt_tokens: list[int] | None = None
    """One prompt token count per response; each defaults to 0 if omitted.

    If provided, len(prompt_tokens) must equal len(generate_responses).
    """

    completion_request: CompletionRequest | None = None
    """The original (post-adjust_request) CompletionRequest from /render.

    Mirrors chat_request on DerenderChatRequest. Required by the parsing
    so parsers receive the full request context.
    """

스트리밍 요청은 stream: true를 설정하고 완전한 응답 대신 하나의 generate 청크(generate_chunk)와 stream_state를 가져갑니다.

/v1/chat/completions/derender with stream: true:

    stream: Literal[True]

    model: str | None = None
    generate_chunk: GenerateStreamResponse
    """One SSE chunk from ``/inference/v1/generate`` (``stream=True``)."""

    stream_state: DerenderStreamState | None = None
    """Client carried detok state from the previous call. ``None`` on first."""

    prompt_tokens: int | None = None
    """Prompt token count for usage. Forwarded from the render step."""

    prompt_token_ids: list[int] | None = None
    """Prompt token IDs. Required by the parser path's `parse_delta` to
    settle its initial reasoning state (e.g. chat templates that pre-open
    `` thinking``). `prompt_tokens` is a usage count and cannot serve this
    purpose. Sourced from `GenerateRequest.token_ids` at the render step.

    Rejected with a 400 (by `ServingDerender`) when a tool or reasoning
    parser is configured and this is omitted. Without it, `parse_delta`
    cannot tell whether the prompt left reasoning open and would silently
    misclassify reasoning content as plain content. Unused on the plain
    detokenization path.
    """

    chat_request: ChatCompletionRequest | None = None
    """The original (post adjust_request) ChatCompletionRequest from /render."""

/v1/completions/derender with stream: true:

    stream: Literal[True]

    model: str | None = None
    generate_chunk: GenerateStreamResponse
    """One SSE chunk from ``/inference/v1/generate``."""

    stream_state: DerenderStreamState | None = None
    """Client-carried detok state. ``None`` on the first call."""

    prompt_tokens: int | None = None
    """Prompt token count for usage."""

    completion_request: CompletionRequest | None = None
    """The original (post adjust_request) CompletionRequest from /render."""

둘 다 {"chunk": ..., "stream_state": ...}를 반환합니다. chunkChatCompletionStreamResponse 또는 CompletionStreamResponse이고, stream_state는 다음 호출에 사용됩니다.

과도하게 큰 페이로드는 tokenizer.decode()나 파서가 실행되기 전에 400으로 거부됩니다.

파서 구성 (Parser configuration)

디렌더러는 자신의 서버 플래그와 각 호출과 함께 보내지는 chat_request로부터 tool·reasoning 파서를 구성합니다. 요청이 다른 곳에서 어떻게 렌더·서빙됐는지는 볼 수 없으므로, 출력이 vllm serve 서버와 일치하려면:

  • render 서버를 이 모델에 vllm serve에 줄 것과 같은 --tool-call-parser, --reasoning-parser, --enable-auto-tool-choice, --chat-template, --default-chat-template-kwargs로 시작하세요. /render/derender가 다른 서버에서 실행된다면 둘 다 같은 값을 주세요.
  • /render에 간 전체 chat_request를 보내세요. messagestools만이 아니라. chat_template_kwargs, reasoning_effort, tool_choice, include_reasoning` 같은 필드가 출력 파싱 방식을 바꿉니다.

불일치가 실패를 만들지는 않습니다. 파서가 vllm serve가 반환하는 것과 다르게 reasoning, content, tool_calls를 나눌 뿐입니다.

/v1/completions/derender는 detokenize만 합니다. vllm serve/v1/completions처럼 tool·reasoning 파서를 절대 실행하지 않습니다. completion_request에서 skip_special_tokens만 읽습니다.

스트리밍 (Streaming)

스트리밍 디렌더는 /inference/v1/generate를 미러링합니다. 같은 경로의 body에 stream: true를 설정하면 엔드포인트가 완전한 응답 대신 generate 스트림 청크 하나를 받습니다. SSE가 아닌 JSON으로 응답합니다. 각 호출은 하나의 generate 청크를 하나의 derendered 청크로 바꾸고, 클라이언트는 이를 자신의 호출자에게 전달합니다.

서버는 호출 간 상태를 유지하지 않습니다. 다음 호출에 필요한 모든 것이 클라이언트가 가져가는 stream_state에 있습니다:

  • stream_state를 그대로 되돌려 보내세요. 첫 호출에서는 생략(또는 null 전송)하고, 그 다음부터는 각 응답의 stream_state를 다음 요청에 그대로 보내세요. 합산되지 않는 상태(예: output_chunk_lens가 출력 토큰 수와 일치하지 않음)는 400으로 거부됩니다.
  • 청크당 하나의 choice. /inference/v1/generate는 각 SSE 이벤트에 하나의 choice를 보내고, 각 디렌더 호출은 최대 하나를 받습니다. 그 이상은 400으로 거부됩니다.
  • n > 1은 N개의 스트림. Generate는 서로 다른 choice의 청크를 인터리브하고 각각에 index를 태깅합니다. 인덱스별로 하나의 stream_state를 유지하고 각 청크를 그 인덱스의 상태와 함께 보내세요. 각 인덱스는 첫 청크에서 자신의 role 델타를 받습니다.
  • 마지막 청크를 포함한 모든 청크를 보내세요. finish reason은 보통 마지막 토큰과 함께 도착합니다. finish_reason가 있고 토큰이 없는 청크도 허용되며, 버퍼링된 tool call 인자를 flush합니다. stream_options: {"include_usage": true}로 generate는 사용량만 있는 청크(choices: [])로 끝납니다. 다른 청크처럼 derender에 보내 응답의 usage 청크를 얻으세요. usage.prompt_tokens는 설정된 경우 요청의 prompt_tokens이고, 그렇지 않으면 generate 청크의 것입니다.
  • 매 호출에 같은 문맥을 보내세요. chat_requestprompt_token_ids는 호출 간 유지되지 않으므로 usage 청크를 포함한 모든 청크에 함께 갑니다. tool·reasoning 파서가 구성되면 둘 다 필수입니다. prompt_token_ids/render가 반환한 GenerateRequesttoken_ids입니다.
  • [DONE]은 전달하지 마세요. generate 스트림의 끝을 표시하며 청크가 아닙니다.

스트리밍 청크는 아직 logprobs를 담지 않으므로 generate 청크의 logprobs는 버려집니다. 비스트리밍 엔드포인트는 token_id:N 플레이스홀더를 포함해 이를 해석합니다.

스트리밍 비용 (Streaming cost)

스트리밍 디렌더는 서버에 세션 상태를 유지하는 대신 클라이언트가 가져가는 stream_state를 per-chunk 호출에 걸쳐 이어갑니다.

일반 detokenization(파서 미구성)은 생성 길이와 무관한 작고 제한된 증분 디코드 창만 stream_state에 담습니다.

tool·reasoning 파서가 구성되면 파서 내부 상태(버퍼링된 마크업, reasoning/tool 단계)를 직렬화할 수 없습니다. 따라서 stream_state는 대신 지금까지 본 전체 output_token_idsoutput_chunk_lens(각 청크가 도착한 토큰 수)를 담습니다. 각 청크는 새 파서를 만들고 그 기록을 parse_delta로 재생하고, 원래 청크당 한 호출씩, 새 토큰을 실제로 처리하기 전에 돌립니다. 파서 경로에만 해당하는 의미는:

  • 전송: output_token_idsoutput_chunk_lens가 매 호출 양방향으로 전체 왕복합니다. output_chunk_lens는 청크당 하나의 항목을 가지며, speculative decoding이 없으면 토큰당 하나입니다. 즉 청크당 O(n) 바이트, 전체 생성에 O(n²) 바이트입니다. max_model_len으로 제한됩니다. prompt_token_ids도 매 호출 전체가 전송되며 output_token_ids가 커져도 줄지 않습니다. 이는 스트림의 대부분 동안 per-chunk 페이로드를 지배하게 됩니다. 100k 토큰 프롬프트에 1k 토큰 출력이면 prompt_token_ids가 매 청크 요청 본문의 ~99%를 차지합니다.
  • 컴퓨트: 재생은 청크당 O(n) parse_delta 호출입니다(생성당 O(n²)). parse_delta 자체는 누적 텍스트를 재스캔하는 파서(예: Hermes tool-call JSON, DeepSeek-R1 reasoning)에 대해 O(n)입니다. 생성당 비용은 O(n³) 문자 작업이지 O(n²)가 아닙니다. 캐싱 레이어가 없는 의도적으로 최소한의 첫 구현입니다.
  • 파서 경로는 prompt_token_ids도 요구합니다. parse_delta가 프롬프트가 reasoning을 열어두고 갔는지 정할 수 있어야 하기 때문입니다. 파서 상태는 호출 간 옮길 수 없으므로, 전체 프롬프트를 청크당 한 번 재스캔합니다.
  • 재생은 렌더러의 executor(renderer_num_workers, 기본 1)의 이벤트 루프 밖에서 실행됩니다. 예상되는 동시 파서 구성 스트림 수에 맞게 크기를 조정하세요.

output_token_idsprompt_token_ids는 둘 다 max_model_len으로 제한되지만, 파서 구성 모델을 통해 긴 reasoning 트레이스를 스트리밍하는 호출자는 일반 detokenization 경로보다 훨씬 더 많은 상태 전송과 CPU 비용을 기대해야 합니다.

예시 (Example)

아래 예시는 GPU 없는 render 서버(/render, /derender)와 token-in / token-out 엔진(/inference/v1/generate)에 대한 chat 요청의 전체 render → generate → derender 왕복을 보여줍니다.

먼저 두 서버를 실행하세요:

vllm launch render meta-llama/Llama-3.2-1B-Instruct --port 8100
vllm serve meta-llama/Llama-3.2-1B-Instruct --tokens-only --port 8200
import httpx

MODEL = "meta-llama/Llama-3.2-1B-Instruct"
RENDER = "http://localhost:8100"  # vllm launch render ...
ENGINE = "http://localhost:8200"  # token-in / token-out engine

chat_request = {
    "model": MODEL,
    "messages": [{"role": "user", "content": "What is 2+2?"}],
    "max_tokens": 32,
}

with httpx.Client(timeout=60.0) as client:
    # 1. Render: request -> token IDs (GPU less)
    generate_request = client.post(
        f"{RENDER}/v1/chat/completions/render", json=chat_request
    ).json()
    prompt_tokens = len(generate_request["token_ids"])

    # 2. Generate: token IDs -> token IDs (token-in / token-out engine)
    generate_response = client.post(
        f"{ENGINE}/inference/v1/generate", json=generate_request
    ).json()

    # 3. Derender: token IDs -> ChatCompletionResponse (GPU less)
    response = client.post(
        f"{RENDER}/v1/chat/completions/derender",
        json={
            "model": MODEL,
            "generate_response": generate_response,
            "prompt_tokens": prompt_tokens,
            "chat_request": chat_request,
        },
    ).json()

print(response["choices"][0]["message"]["content"])

chat_request를 넘기면 디렌더러가 구성된 tool·reasoning 파서를 실행할 수 있습니다. 즉 response["choices"][0]["message"]vllm serve 서버가 만들어내는 것과 같은 content/reasoning/tool_calls 분할을 담습니다. chat_request는 tool·reasoning 파서가 전혀 구성되지 않은 모델에서만 생략할 수 있습니다. 파서가 구성된 모델은 조용히 일반 detokenization으로 폴백하는 대신 chat_request 누락 시 400으로 거부합니다.

스트리밍 예시

같은 왕복을 스트리밍으로. /render는 반환하는 GenerateRequeststreamstream_options를 유지하므로 generate 호출도 스트리밍합니다. 각 generate 청크는 이전 호출의 stream_state와 함께 derender를 거칩니다(Streaming 참고).

import json

import httpx

MODEL = "meta-llama/Llama-3.2-1B-Instruct"
RENDER = "http://localhost:8100"  # vllm launch render ...
ENGINE = "http://localhost:8200"  # token-in / token-out engine

chat_request = {
    "model": MODEL,
    "messages": [{"role": "user", "content": "What is 2+2?"}],
    "max_tokens": 32,
    "stream": True,
    "stream_options": {"include_usage": True},
}

with httpx.Client(timeout=60.0) as client:
    # 1. Render: request -> token IDs (GPU less)
    generate_request = client.post(
        f"{RENDER}/v1/chat/completions/render", json=chat_request
    ).json()
    prompt_token_ids = generate_request["token_ids"]

    # 2. Generate: stream token IDs (token-in / token-out engine)
    stream_state = None  # one per choice index when n > 1
    with client.stream(
        "POST", f"{ENGINE}/inference/v1/generate", json=generate_request
    ) as generate_stream:
        for line in generate_stream.iter_lines():
            if not line.startswith("data: ") or line == "data: [DONE]":
                continue

            # 3. Derender: one generate chunk -> one chat completion chunk
            derendered = client.post(
                f"{RENDER}/v1/chat/completions/derender",
                json={
                    "stream": True,
                    "model": MODEL,
                    "generate_chunk": json.loads(line[len("data: ") :]),
                    "stream_state": stream_state,
                    "prompt_tokens": len(prompt_token_ids),
                    "prompt_token_ids": prompt_token_ids,
                    "chat_request": chat_request,
                },
            ).json()
            stream_state = derendered["stream_state"]

            chunk = derendered["chunk"]
            for choice in chunk["choices"]:
                print(choice["delta"].get("content") or "", end="", flush=True)
            if chunk.get("usage"):
                print(f"\n{chunk['usage']}")

tool·reasoning 파서가 구성되면 deltareasoningtool_calls를 담습니다. /v1/chat/completions가 그 토큰들에 대해 스트리밍하는 것과 같은 델타입니다.

더 알아보기 (Learn more)