Skip to content

Streamed responses contain replacement characters when a multibyte character is split across reads #373

Open

Description

@irisfield

Description

When a job streams a response ("stream": true) and a multibyte UTF-8 character (accented letters, Chinese, Japanese, emoji) is split across two reads of the vLLM response, the handler yields U+FFFD in place of that character. Each half of the split character becomes its own replacement character, so the original character is lost. The streamed text is permanently wrong, and clients cannot repair it.

Reproduction

  1. Run a local server that answers POST /v1/chat/completions with an SSE body containing héllo 😀 and writes it in two parts, with the split inside the two bytes of é.
  2. Point VLLM_BASE_URL in src/handler.py at that server.
  3. Run a streaming job through handler:
import asyncio
from aiohttp import web
import handler as h

async def sse(request):
    resp = web.StreamResponse(headers={"Content-Type": "text/event-stream"})
    await resp.prepare(request)
    data = "data: héllo 😀\n\n".encode()
    cut = data.index("é".encode()) + 1
    await resp.write(data[:cut])
    await asyncio.sleep(0.05)
    await resp.write(data[cut:])
    await resp.write_eof()
    return resp

async def main():
    app = web.Application()
    app.router.add_post("/v1/chat/completions", sse)
    runner = web.AppRunner(app)
    await runner.setup()
    await web.TCPSite(runner, "127.0.0.1", 18999).start()
    h.VLLM_BASE_URL = "http://127.0.0.1:18999"
    job = {"input": {"openai_route": "/v1/chat/completions",
                     "openai_input": {"model": "m", "stream": True, "messages": []}}}
    out = [x async for x in h.handler(job)]
    print(out)
    print("".join(out) == "data: héllo 😀\n\n")
    await runner.cleanup()

asyncio.run(main())

Actual output:

['data: h�', '�llo 😀\n\n']
False

I also ran this against vllm serve (v0.32.0) on an RTX A5000 with Qwen/Qwen2.5-0.5B-Instruct, streaming Chinese, Japanese and emoji output. With the response re-chunked to one byte per read, 15 of 15 streams contained replacement characters (4170 in total). A direct request to vLLM contained none. With vLLM answering the handler directly on localhost, I did not see a split.

Expected behavior

The text yielded for a stream is the same text vLLM sent, regardless of where the response is split into reads. A character that spans two reads is yielded once, intact.

Version

worker-vllm v2.30.3

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions