Skip to content

    Forward Deployed Engineer

    Streaming Responses and Invalid Model Output

    Stream model text to a client with server-sent events, end every stream with one outcome, and classify truncated or malformed output before anything uses it.

    By Published 6 min read

    • fde
    • streaming
    • server-sent-events
    • fastapi
    • validation
    • error-handling
    Share

    This lesson is part of Forward Deployed Engineer: Build LLM Features. Enroll for free, then sign in, to read it in full.

    What you'll learn: how server-sent events carry model text to a client, why a failure after the first byte must travel inside the stream, and how to classify a finished reply as valid, truncated or malformed.

    At a glance

    Figure 1. A stream: the status is sent first, the outcome last

    sequenceDiagram accTitle: A streamed answer with two possible endings accDescr: The Client app opens a stream to the Orchestrator, which calls the LLM provider. The Orchestrator sends the 200 status and headers at once. Text deltas from the provider are forwarded to the client as delta events. If the provider fails midway, the Orchestrator sends an error event and the status stays 200. If the provider finishes, the Orchestrator validates the whole text and sends a done event. participant C as Client app participant O as Orchestrator participant P as LLM provider C->>O: open the stream O->>P: request O-->>C: 200 and text/event-stream P-->>O: delta O-->>C: event delta alt the provider fails midway P-->>O: connection drops O-->>C: event error, status still 200 else the provider finishes P-->>O: last delta Note over O: validate the whole text O-->>C: event done end

    How to read this: the client learns about a failure only from an event, because the status line was already sent.

    • Streaming shows text sooner; the model is no faster.
    • After the first byte the HTTP status is fixed at 200, so failures must be events.
    • Every stream ends with exactly one terminal event: done or error.
    • Use structured output only after the whole reply is validated.
    • Truncated, malformed and wrong-shaped replies need different cures.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply's sales engineers say the copilot "freezes for ten seconds and then dumps a page". The team streams the answer. Then a new complaint arrives: an answer stops mid-sentence and looks finished. The stream had broken, and the interface took the end of the connection for the end of the answer.

    What you will be able to do

    • Serve a stream of events from FastAPI and read it back as a client would.
    • End every stream with exactly one terminal event, even when the source fails.
    • Classify a reply as valid, fenced, empty, truncated, malformed or wrong-shaped.
    • Explain why a partial parse is unsafe for tool arguments.

    Prerequisites

    Calling a provider gives the complete contract. Structured outputs and schema validation covers the repair step. Tool calling with one read-only tool shows what happens to valid arguments.

    Core ideas

    What streaming changes

    One response A stream
    First visible text After the whole answer After the first delta
    Failure midway An HTTP error status An event; the status was already 200
    When you can validate On the reply Only after the last delta
    Idle connections One long wait Proxies may drop a silent connection

    The provider's API can also send an error event after returning 200 (checked 2026-11-02). Your endpoint has the same shape.

    Server-sent events on the wire

    SSE is a text format over one HTTP response. Per the WHATWG specification:

    Line Meaning
    event: name The event type; message if absent
    data: text One line of payload; several data lines are joined with newlines
    id: value The last event id, sent back as Last-Event-ID on reconnect
    : text A comment, ignored; used as a keep-alive
    a blank line Dispatches the event; an event cut off before it is discarded

    FastAPI 0.135.0 added fastapi.sse with EventSourceResponse and ServerSentEvent (this build runs 0.162.0). It JSON-encodes data and sends a keep-alive comment every 15 seconds.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py

    def create_demo_app(make_deltas: Any, model: type[BaseModel] | None = None) -> FastAPI:
        app = FastAPI()
    
        @app.get("/stream", response_class=EventSourceResponse)
        def stream() -> Iterable[ServerSentEvent]:
            yield from stream_events(make_deltas(), model)
    
        return app

    A test client requesting /stream gets this body (real output):

    event: delta
    data: {"text":"alpha beta "}
    
    event: delta
    data: {"text":"gamma"}
    
    event: done
    data: {"chars":16,"result":null}

    One terminal event, even when the source breaks

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py

    def stream_events(deltas: Iterable[str], model: type[BaseModel] | None = None) -> Iterator[ServerSentEvent]:
        """Forward deltas as they arrive, then finish with exactly one `done` or one `error` event."""
        parts: list[str] = []
        try:
            for piece in deltas:
                parts.append(piece)
                yield ServerSentEvent(data=Delta(text=piece), event="delta")
        except Exception:  # the cause may hold internal detail: send a fixed message, log the cause elsewhere
            failed = Failed(code="stream_interrupted", message="The answer stopped before it was complete.", received_chars=len("".join(parts)))
            yield ServerSentEvent(data=failed, event="error")
            return
        text = "".join(parts)
        result: dict[str, Any] | None = None
        if model is not None:
            outcome, value = check_output(text, model)
            if value is None:
                failed = Failed(code=f"llm_{outcome}", message="The answer was not valid structured output.", received_chars=len(text))
                yield ServerSentEvent(data=failed, event="error")
                return
            result = value.model_dump()
        yield ServerSentEvent(data=Done(chars=len(text), result=result), event="done")
    • A delta is forwarded the moment it arrives.
    • If the source raises, the client gets one error event with a fixed message.
    • done is sent only after the whole text is validated.
    NOTE

    The capstone's MockProvider does not stream. word_deltas cuts a finished text into pieces, testing the handling, not timing.

    Spot the flaw. A teammate writes this to cut latency. Find every defect.

    Pseudocode.

    for event in provider_stream:
        buffer += event.partial_json
        args = parse_partial(buffer)
        if "sku" in args:
            send_to_client(run_tool("get_product_spec", args))
    send_event("done")
    Answer

    Three. It acts on partial arguments: a tolerant parser can return a sku that is only the first characters of the final value. It sends done even when the stream broke. And nothing validates the full reply. The provider says to accumulate the partial JSON and parse it when the content block stops.

    Partial output is not valid output

    Pydantic can parse an unfinished JSON text, and the result looks plausible:

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/test_m2_l6_streaming.py

    def test_a_partial_parse_would_have_hidden_the_problem() -> None:
        from pydantic_core import from_json
        cut = '{"category": "sensors", "quantity": '
        assert from_json(cut, allow_partial=True) == {"category": "sensors"}  # looks fine, quantity silently missing
        assert check_output(cut, Requirements)[0] == "truncated"

    With allow_partial=True the unfinished piece is dropped silently. Use partial parsing for a progress display only.

    Classify the finished reply, then choose the cure

    Figure 2. States of a streamed structured answer

    stateDiagram-v2 accTitle: Failure states of a streamed structured answer accDescr: A stream starts in Streaming. If the connection drops or an error event arrives it moves to Interrupted, and the partial text is discarded. If it ends normally it moves to Validating. Validating leads to Accepted for valid or fenced JSON, to Truncated if the text is cut off, and to Malformed for bad JSON or a wrong shape. Interrupted and Truncated can lead to a bounded Retry that restarts Streaming. Malformed gets one repair. An empty reply also goes to Retry. Anything still failing ends in Failed. [*] --> Streaming Streaming --> Interrupted: error event or dropped connection Streaming --> Validating: end of stream Validating --> Accepted: valid or fenced Validating --> Truncated: cut off Validating --> Malformed: bad JSON or wrong shape Validating --> Retry: empty reply Interrupted --> Retry: discard the partial text Truncated --> Retry: with a larger limit Malformed --> Repair: one repair prompt Repair --> Accepted: valid Repair --> Failed: still invalid Retry --> Streaming: bounded attempts Retry --> Failed: attempts used up Accepted --> [*] Failed --> [*]

    How to read this: only Accepted ever reaches a user or a tool, and every other path is bounded and ends in Failed.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py

    Outcome = Literal["valid", "fenced", "empty", "truncated", "invalid_json", "schema_violation"]
    # What to do with each outcome. Only the first two ever reach a user or a tool.
    ACTION: dict[Outcome, str] = {
        "valid": "accept",
        "fenced": "accept",  # a Markdown fence around valid JSON: strip it, it is not an error
        "empty": "retry",
        "truncated": "retry_with_more_tokens",  # the cure is a larger limit or a continuation, not a repair prompt
        "invalid_json": "repair_once",
        "schema_violation": "repair_once",
    }

    Per the provider's documentation, a reply cut at the token limit has the stop reason max_tokens. Without a stop reason, the function below guesses from where the parse failed.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/m2_l6_streaming.py

    def check_output(text: str, model: type[T], stop_reason: str | None = None) -> tuple[Outcome, T | None]:
        """Classify a finished model reply. `stop_reason` ('max_tokens') is the reliable truncation signal when you have it."""
        body = text.strip()
        if not body:
            return "empty", None
        if stop_reason == "max_tokens":
            return "truncated", None
        fenced = body.startswith("```")
        try:
            return ("fenced" if fenced else "valid"), parse_json_model(model, body)
        except json.JSONDecodeError as err:
            inner = body.removeprefix("```json").removeprefix("```").strip()
            ended_early = err.msg.startswith("Unterminated string") or err.pos >= len(inner)
            return ("truncated" if ended_early else "invalid_json"), None
        except ValidationError:
            return "schema_violation", None
    NOTE

    The capstone's Completion carries no stop reason, so complete_structured cannot tell truncation from malformed JSON and spends its one repair on both.

    In the interview: "The answer stopped halfway and the user did not notice. What happened and what do you change?" A good answer finds the missing terminal event, adds done and error, and gives the interface a visible incomplete state with a retry.

    Worked example

    Assumptions (illustrative): a 40-word reply streams in chunks of two words, so 20 delta events; the connection drops after the 12th.

    Item Value
    Delta events sent before the drop 12
    Words the client has 24 of 40 (60 percent)
    Terminal event one error, code stream_interrupted, received_chars set; nothing after it
    What the interface shows the 24 words, marked incomplete, with a retry button

    Explain it back. Tell a sales engineer, in three sentences, why an answer can stop in the middle and what the interface now does. Say the connection broke, that an unfinished answer is now marked incomplete, and that retrying is safe because nothing was acted on.

    Build it

    The code is in capstone/fde-product-discovery-copilot/lessons/M2-L6/. Step 1: stream from a source that fails after one delta; the status stays 200.

    Verified: capstone/fde-product-discovery-copilot/lessons/M2-L6/test_m2_l6_streaming.py

    def test_a_failure_after_the_200_is_only_visible_inside_the_stream() -> None:
        def broken() -> Iterator[str]:
            yield "partial "
            raise RuntimeError("upstream reset")
    
        response = TestClient(create_demo_app(broken)).get("/stream")
        assert response.status_code == 200  # the status was sent before the failure
        assert [name for name, _ in parse_sse(response.text)] == ["delta", "error"]

    Step 2: run the whole folder.

    ./.venv/Scripts/python -m pytest lessons/M2-L6 -q

    Real output (timing varies):

    ........................                                                 [100%]
    24 passed in 5.55s

    The count includes the exercise's reference solution; you will see 21.

    Exercise

    Write stream_events_capped(deltas, max_chars): forward deltas until the next one would pass max_chars, then send one error event with the code too_long and stop.

    Acceptance criteria: a short answer ends in done; a long one ends in one error, no done, reporting the characters sent; the source iterator is closed. Done when the three tests pass.

    Hint 1: when do you check the limit?

    Before sending a delta, not after: characters already sent plus the new piece.

    Hint 2: how do you stop the provider?

    A generator has a close() method. Call it in a finally block.

    Common mistakes

    • Treating the end of the connection as the end of the answer. Only done means finished.
    • Sending an error as a new HTTP status. The status line is gone after the first byte. Use an event.
    • Acting on a partial parse. It looks valid. Parse once, after the last delta.
    • Forwarding exception text. It can hold addresses and keys. Send a fixed message; log the cause.

    Knowledge check

    1. The provider fails after the first 12 deltas. Why can the server not return a 500?

    The 200 status went out before the first delta, and HTTP cannot change it once the body starts. The failure must be an event.

    2. Why validate a structured reply only after the last delta, even if the first deltas look fine?

    Structured output is complete only when its closing brackets arrive. Acting on a prefix risks running a tool on half a value.

    3. A reply ends mid-string and the stop reason is `max_tokens`. Why is a repair prompt the wrong response?

    The model was cut off, not confused, so a repair wastes a call and may truncate again. A higher limit or a continuation gives it room.

    4. Why does the error event use a fixed message instead of the exception text?

    The exception may hold host names or private input, and the stream goes to a browser. A fixed message is safe; the cause goes to your logs.

    5. Why can a proxy break a stream that works on your laptop, and what does FastAPI do about it?

    Some proxies buffer responses or drop silent connections. FastAPI's SSE response discourages buffering and sends a keep-alive every 15 seconds.

    You can now

    • Serve and read an event stream.
    • Guarantee one terminal event.
    • Classify a reply and choose the cure.
    • Explain why a partial parse must never drive an action.

    Further reading

    Last verified: 2026-11-02

    Next

    This is the last core lesson of the module on LLM application engineering. The next module starts with Ingestion and normalization.

    Enroll to keep reading

    Lesson 6 of 7 · Forward Deployed Engineer: Build LLM Features

    Stream model text to a client with server-sent events, end every stream with one outcome, and classify truncated or malformed output before anything uses it.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Build LLM Features, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments