Python That Agent Frameworks Assume

Generators & Streaming

Generators, yield And Streaming

When you use ChatGPT the answer appears a few words at a time rather than all at once. That is streaming, and underneath it is a generator.

Generators are also how you handle files too large to fit in memory, and how frameworks hand you events from a running agent. One idea, three uses.

The idea in one picture

Think of a waiter.

A normal function is a waiter who disappears into the kitchen and comes back with the entire meal on one enormous tray.

A generator is a waiter who brings one dish, waits, brings the next when you are ready, and remembers exactly where they were in between.

Same food. Very different experience — and the second one you can start eating immediately.

One word of difference

def get_all():
    return [1, 2, 3]

def get_one_at_a_time():
    yield 1
    yield 2
    yield 3

return ends the function and hands back a value.

yield hands back a value and pauses, keeping everything exactly where it was. Next time you ask, it carries on from that line.

print(get_all())
print(get_one_at_a_time())
[1, 2, 3]
<generator object get_one_at_a_time at 0x000001A2...>

The second one is not a list. It is a thing that will produce values when asked.

Ask it with a for loop:

for n in get_one_at_a_time():
    print(n)
1
2
3

Nothing runs until you ask

This surprises everyone once, so let us watch it happen.

def counter():
    print("  ...body started")
    yield 1
    print("  ...between yields")
    yield 2

gen = counter()
print("generator built - notice nothing printed yet")

print("first  ->", next(gen))
print("second ->", next(gen))
generator built - notice nothing printed yet
  ...body started
first  -> 1
  ...between yields
second -> 2

Calling counter() did not run the body. It built a generator that is ready to run. The code only moves forward when something asks for the next value.

This is called lazy evaluation, and it is exactly what makes streaming possible: the source can produce values slowly, and your loop simply waits.

Why it matters for memory

def read_lines(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.strip()

for line in read_lines("huge.log"):
    if "ERROR" in line:
        print(line)

This works on a file of any size, because only one line is in memory at a time.

The eager version, lines = f.readlines(), loads the whole file and falls over on a large one.

In agent work this matters when chunking documents for retrieval, or walking a long log to build a test set.

Streaming a model response

Here is the shape you will actually meet:

for chunk in llm.stream("Explain retries in one paragraph"):
    print(chunk.content, end="", flush=True)

llm.stream(...) returns a generator. Each turn of the loop is a piece of the answer as it arrives.

Two details that are easy to miss:

  • end="" stops print adding a newline after every chunk
  • flush=True pushes it to the screen immediately instead of buffering

Forget flush=True and streaming looks broken — nothing appears until the very end.

When to stream, and when not to

invokestream
Returnsthe whole answera generator of pieces
First outputafter the model finishesalmost immediately
Feelsslowresponsive
Easier to work withyesno

Use stream when a person is watching. Use invoke when the result feeds the next step of your program.

Sitting in the middle of a stream

Because a generator both consumes and produces, you can wrap one:

def with_logging(stream):
    total = 0
    for chunk in stream:
        total += len(chunk)
        yield chunk
    print(f"\n[streamed {total} characters]")

The caller still sees a stream, arriving piece by piece. You have added behaviour without buffering the whole response.

This is how token counting and cost tracking get bolted onto streaming responses.

A generator is used up

gen = get_one_at_a_time()
print("first pass :", list(gen))
print("second pass:", list(gen))
first pass : [1, 2, 3]
second pass: []

Once exhausted it is finished. There is no rewind.

This causes a genuinely puzzling bug: you consume a stream to log it, then try to use it again and get nothing at all, with no error. If you need the values twice, capture them once:

items = list(gen)

Generator expressions

One bracket different from a list comprehension:

squares_list = [n * n for n in range(1_000_000)]    # a million numbers, in memory
squares_gen  = (n * n for n in range(1_000_000))    # a recipe, nothing computed yet

Square brackets build the list. Round brackets build a generator.

When you only intend to loop once, prefer round:

print(sum(n * n for n in range(10)))
285

Async generators

When the source is a network call, the loop gains a keyword:

async for chunk in llm.astream("Explain retries"):
    print(chunk.content, end="", flush=True)

Same idea, with waiting allowed between pieces. Note the a in astream — frameworks name the async version of a method with an a in front.

Coming from Java or C#

C# has this almost exactly: yield return, and IEnumerable<T> is lazy in the same way. If that is your background, you already know this page.

Java's Stream and Iterator are lazy too, but there is no yield keyword — you write an Iterator or use Stream.generate, both far more ceremony for the same result.

ConceptJavaC#Python
Lazy sequenceStream<T>, Iterator<T>IEnumerable<T>generator
Produce and pauseno keywordyield returnyield
ConsumeforEach, hasNextforeachfor
Async versionFlux (Reactor)IAsyncEnumerable<T>async for

Common mistakes

  • Reusing an exhausted generator and getting an empty result with no error.
  • Calling len() on one. There is no length — it does not know how many values remain.
  • Building a list when you only loop once. Memory cost, no benefit.
  • Forgetting flush=True while streaming, so streaming looks broken.
  • Expecting the body to run on call. It runs on the first request for a value.

Practise this

  • Write a generator yielding 1 to 5 and print them with a for loop.
  • Add a print before the first yield and prove it does not run until you ask.
  • Consume a generator twice and observe the empty second pass.
  • Turn a list comprehension into a generator expression and use it with sum().
  • Write with_logging and wrap a generator with it.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# A generator uses `yield` instead of `return`. It pauses, and carries on.
2
3import time
4
5
6def get_all():
7 return [1, 2, 3]
8
9
10def get_one_at_a_time():
11 yield 1
12 yield 2
13 yield 3
14
15
16print("a list :", get_all())
17print("a generator:", get_one_at_a_time())
18print("looped :", [n for n in get_one_at_a_time()])
19print()
20
21# --- Nothing runs until you ask ---
22def counter():
23 print(" ...starting")
24 yield 1
25 print(" ...middle")
26 yield 2
27
28gen = counter()
29print("built the generator - nothing has run yet")
30print("next():", next(gen))
31print("next():", next(gen))
32print()
33
34# --- A generator is used up ---
35g = get_one_at_a_time()
36print("first pass :", list(g))
37print("second pass:", list(g), " <- empty, and no error")
38print()
39
40# --- Simulating a streamed model response ---
41def fake_stream(text):
42 """Pretend to be llm.stream() - yields the answer piece by piece."""
43 for word in text.split():
44 time.sleep(0.05)
45 yield word + " "
46
47print("streaming: ", end="")
48for chunk in fake_stream("Retries protect you from transient failures."):
49 print(chunk, end="", flush=True) # flush=True or nothing appears until the end
50print("\n")
51
52# --- Wrapping a stream: consume and produce at the same time ---
53def with_logging(stream):
54 total = 0
55 for chunk in stream:
56 total += len(chunk)
57 yield chunk
58 print(f"\n[streamed {total} characters]")
59
60for piece in with_logging(fake_stream("The caller still sees a stream.")):
61 print(piece, end="", flush=True)
62print()
63
64# --- Memory: a generator holds one item at a time ---
65squares_list = [n * n for n in range(1_000_000)] # a million numbers in memory
66squares_gen = (n * n for n in range(1_000_000)) # a recipe
67print("list :", type(squares_list), f"{len(squares_list):,} items held")
68print("gen :", type(squares_gen), "nothing held yet")
69print("sum of first 10:", sum(n * n for n in range(10)))
70print()
71
72# --- Reading a big file, one line at a time ---
73def read_lines(path):
74 with open(path, encoding="utf-8") as f:
75 for line in f:
76 yield line.strip()
77
78with open("demo.log", "w", encoding="utf-8") as f:
79 f.write("INFO started\nERROR code=503\nINFO done\nERROR code=500\n")
80
81print("errors found:", [line for line in read_lines("demo.log") if "ERROR" in line])