n4nAI

Stream structured output in LangChain with n4n.ai

Learn to stream structured output in LangChain with Pydantic models and JSON parsers, including partial parsing, validation, and production fallback patterns.

n4n Team4 min read846 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming structured output in LangChain lets you parse and validate model responses token by token instead of waiting for the full completion. This langchain streaming structured output tutorial covers the two main approaches — PydanticOutputParser for strict schemas and JsonOutputParser for flexible JSON — plus the production patterns you need when running at scale.

Step 1: Install dependencies and configure the client

Start with a clean environment. You need LangChain core, the OpenAI client (or any OpenAI-compatible endpoint), and Pydantic v2.

pip install langchain-core langchain-openai pydantic python-dotenv

Create a .env file with your API base and key. If you’re using n4n.ai, the base URL is https://api.n4n.ai/v1 and the key comes from your dashboard. The endpoint speaks the OpenAI wire format, so the standard client works without modification.

# .env
OPENAI_API_BASE=https://api.n4n.ai/v1
OPENAI_API_KEY=sk-your-key-here

Load it in your script:

# config.py
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI

load_dotenv()

llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0,
    streaming=True,
    max_tokens=2048,
)

Verify: Run python -c "from config import llm; print(llm.model_name)" — it should print gpt-4o-mini without errors.

Step 2: Define your schema with Pydantic

Pydantic v2 models are the contract between your code and the model. Use Field descriptions liberally — they become part of the system prompt and materially improve adherence.

# schemas.py
from pydantic import BaseModel, Field, field_validator
from typing import Literal, Optional
from datetime import datetime


class LineItem(BaseModel):
    sku: str = Field(description="Product SKU, e.g. 'SKU-1234'")
    quantity: int = Field(ge=1, description="Units ordered")
    unit_price_cents: int = Field(ge=0, description="Price per unit in cents")


class OrderExtraction(BaseModel):
    order_id: str = Field(description="Alphanumeric order reference")
    customer_email: str = Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
    currency: Literal["USD", "EUR", "GBP"] = "USD"
    items: list[LineItem] = Field(min_length=1)
    placed_at: datetime
    notes: Optional[str] = Field(default=None, max_length=500)

    @field_validator("order_id")
    @classmethod
    def uppercase_order_id(cls, v: str) -> str:
        return v.upper()

The validator runs after parsing, so streaming tokens that temporarily violate it (lowercase order ID mid-stream) won’t crash the parser — only the final validated object must pass.

Step 3: Stream with PydanticOutputParser

PydanticOutputParser wraps your model and handles the prompt formatting. The key is stream() on the chain, not invoke().

# stream_pydantic.py
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from config import llm
from schemas import OrderExtraction

parser = PydanticOutputParser(pydantic_object=OrderExtraction)

prompt = PromptTemplate(
    template=(
        "Extract the order details from the following text.\n"
        "{format_instructions}\n\n"
        "Text: {input_text}"
    ),
    input_variables=["input_text"],
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

chain = prompt | llm | parser

sample_text = """
Order ORD-8842 placed by sarah.chen@example.com on 2024-03-15T14:30:00Z.
Items: 2x SKU-A100 at $29.99 each, 1x SKU-B200 at $49.50.
Customer noted: 'Please use sustainable packaging.'
"""

for chunk in chain.stream({"input_text": sample_text}):
    # chunk is a *partial* OrderExtraction model
    print(chunk.model_dump_json(indent=2))
    print("---")

What you’ll see: Each iteration prints a progressively more complete OrderExtraction. Early chunks may have None for optional fields or empty lists for items. The parser yields a valid Pydantic instance at every step — it never yields raw JSON strings.

Verify: Run the script. The final chunk should match the sample text exactly, with order_id uppercased by the validator and unit_price_cents as integers (2999, 4950).

Step 4: Stream with JsonOutputParser for flexible schemas

When your schema varies per request or you want to avoid Pydantic overhead, JsonOutputParser streams raw dicts. You lose automatic validation but gain flexibility.

# stream_json.py
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
from config import llm

parser = JsonOutputParser()

prompt = PromptTemplate(
    template=(
        "Return a JSON object with keys: "
        "event_type (string), timestamp (ISO8601), payload (object).\n"
        "{format_instructions}\n\n"
        "Log line: {log_line}"
    ),
    input_variables=["log_line"],
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

chain = prompt | llm | parser

log_line = '2024-03-15T14:30:00Z INFO payment_processed {"order_id": "ORD-8842", "amount_cents": 10948, "method": "card"}'

for chunk in chain.stream({"log_line": log_line}):
    # chunk is a dict, possibly incomplete
    print(chunk)
    print("---")

Key difference: JsonOutputParser yields dict objects. Missing keys simply don’t appear in early chunks. You must handle KeyError or use .get() when consuming partial results.

Verify: The final chunk should have all three keys with correct types. payload should be a nested dict, not a string.

Step 5: Handle partial parses in your application

Streaming structured output shines when you update UI incrementally or feed downstream consumers before the full response arrives. But partial models require defensive code.

# consumer.py
from typing import Generator
from stream_pydantic import chain  # from Step 3

def render_order_stream(input_text: str) -> Generator[dict, None, None]:
    """
    Yields UI-ready dicts. Never raises on partial data.
    """
    for partial in chain.stream({"input_text": input_text}):
        data = partial.model_dump()
        
        # Coerce None/empty to sensible defaults for UI
        ui_data = {
            "order_id": data.get("order_id") or "—",
            "customer_email": data.get("customer_email") or "—",
            "currency": data.get("currency") or "USD",
            "items": data.get("items") or [],
            "subtotal_cents": sum(
                i.get("quantity", 0) * i.get("unit_price_cents", 0)
                for i in data.get("items") or []
            ),
            "placed_at": data.get("placed_at"),
            "notes": data.get("notes"),
            "is_complete": all(
                k in data and data[k] not in (None, [], "")
                for k in ("order_id", "customer_email", "items", "placed_at")
            ),
        }
        yield ui_data

The is_complete flag lets your frontend show a loading skeleton until the required fields populate. Note that items being an empty list [] is valid mid-stream — it becomes non-empty only when the model emits the first item.

Verify: Call list(render_order_stream(sample_text))[-1]["is_complete"] — should be True.

Step 6: Add retry and fallback for production

Streaming doesn’t eliminate provider failures. Wrap the chain with a retry policy and, if you’re using a gateway that supports it, automatic fallback to a healthy model.

# resilient_chain.py
from langchain_core.runnables import RunnableLambda, RunnableConfig
from langchain_core.runnables.utils import Input, Output
from config import llm
from stream_pydantic import prompt, parser
from tenacity import retry, stop_after_attempt, wait_exponential_jitter

base_chain = prompt | llm | parser

@retry(
    wait=wait_exponential_jitter(initial=1, max=10),
    stop=stop_after_attempt(3),
    reraise=True,
)
def _stream_with_retry(input_: dict, config: RunnableConfig) -> Output:
    # The gateway (n4n.ai) honors the model parameter and will
    # automatically fail over to a healthy provider if the primary
    # is rate-limited or degraded. No client-side model list needed.
    return base_chain.stream(input_, config=config)

resilient_chain = RunnableLambda(_stream_with_retry)

The @retry decorator handles transient network errors. The gateway handles provider-level failures — you send one request to one endpoint, and the gateway routes to whichever of the 240+ models is healthy. Your code stays unchanged.

Verify: Simulate a failure by temporarily setting an invalid API key. The retry logic should attempt three times with exponential backoff before raising.

Step 7: Meter usage per request

If you bill customers or enforce quotas, you need token counts per stream. LangChain exposes usage metadata via RunnableConfig callbacks.

# metered_chain.py
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.runnables import RunnableConfig
from resilient_chain import resilient_chain
from typing import Any

class TokenMeter(BaseCallbackHandler):
    def __init__(self):
        self.prompt_tokens = 0
        self.completion_tokens = 0
        self.total_tokens = 0

    def on_llm_end(self, response: Any, **kwargs: Any) -> None:
        usage = getattr(response, "llm_output", {}).get("token_usage", {})
        self.prompt_tokens = usage.get("prompt_tokens", 0)
        self.completion_tokens = usage.get("completion_tokens", 0)
        self.total_tokens = usage.get("total_tokens", 0)

def stream_metered(input_text: str) -> tuple[Generator, TokenMeter]:
    meter = TokenMeter()
    config = RunnableConfig(callbacks=[meter])
    gen = resilient_chain.stream({"input_text": input_text}, config=config)
    return gen, meter

After the stream exhausts, meter.total_tokens holds the billable count. The gateway also returns per-token usage in the response headers if you call the REST API directly — useful for audit logs.

Verify: Run a request, then assert meter.total_tokens > 0.

Step 8: Test with a CI-friendly harness

Add a pytest fixture that exercises the full pipeline without hitting the network. Use a fake LLM that yields predetermined chunks.

# test_streaming.py
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessageChunk
from langchain_core.outputs import ChatGenerationChunk
from schemas import OrderExtraction
from stream_pydantic import prompt, parser

class FakeStreamingLLM(BaseChatModel):
    chunks: list[str]
    
    def _stream(self, *args, **kwargs):
        for chunk in self.chunks:
            yield ChatGenerationChunk(message=AIMessageChunk(content=chunk))
    
    @property
    def _llm_type(self) -> str:
        return "fake"

@pytest.fixture
def fake_chain():
    # Simulate the model emitting JSON across 4 chunks
    chunks = [
        '{"order_id": "ORD-8842", "customer_email": "sarah.chen@example.com", ',
        '"currency": "USD", "items": [{"sku": "SKU-A100", "quantity": 2, ',
        '"unit_price_cents": 2999}], "placed_at": "2024-03-15T14:30:00Z"}',
    ]
    llm = FakeStreamingLLM(chunks=chunks)
    return prompt | llm | parser

def test_streaming_parses_correctly(fake_chain):
    results = list(fake_chain.stream({"input_text": "dummy"}))
    final = results[-1]
    assert isinstance(final, OrderExtraction)
    assert final.order_id == "ORD-8842"
    assert len(final.items) == 1
    assert final.items[0].unit_price_cents == 2999

This test runs in milliseconds, requires no API key, and catches parser regressions when you upgrade LangChain or Pydantic.

Verify: pytest test_streaming.py -v passes.

Verification checklist

Before shipping, confirm each of these:

  1. Schema adherence: Final parsed object passes all Pydantic validators (python -c "from stream_pydantic import chain; list(chain.stream(...))[-1]")
  2. Partial safety: Your consumer code never raises KeyError or AttributeError on mid-stream chunks (run consumer.py and watch for crashes)
  3. Retry behavior: Transient 5xx errors trigger exactly three attempts with backoff (check logs)
  4. Fallback works: Gateway routes around a degraded provider without client changes (inspect response headers for x-n4n-model or similar)
  5. Token accounting: meter.total_tokens matches the provider’s billing (spot-check against dashboard)
  6. CI passes: pytest suite green on every PR

Common pitfalls

Pitfall Symptom Fix
Forgetting streaming=True on ChatOpenAI Full response buffers, then yields once Set streaming=True in config.py
Using invoke() instead of stream() No partial results, defeats the purpose Call .stream() on the runnable
Pydantic model too strict for streaming Parser raises mid-stream on missing required fields Mark fields Optional with defaults; validate at the end
No retry wrapper Transient blips surface as 500s to users Add tenacity retry as shown in Step 6
Assuming JsonOutputParser yields valid JSON each chunk json.loads() fails on partial dict Consume the yielded dict directly; don’t re-serialize

Streaming structured output moves the parse boundary earlier in your pipeline. You validate incrementally, render progressively, and catch provider failures before they become user-facing errors. The patterns above are battle-tested — use them as the foundation for any LangChain application that needs structured data in real time.

Tagslangchainstreamingstructured-outputn4n-ai

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All langchain structured output & parsers posts →