跪拜 Guibai
← All articles
Frontend · Backend · Artificial Intelligence

A Producer-Consumer EventStream Channel That Decouples LLM Streaming from Agent Loops

By 东方小月 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Streaming LLM responses into an agent loop usually means coupling the transport protocol directly to the consumption logic, which makes swapping providers or handling backpressure painful. A decoupled EventStream channel keeps the agent's control flow simple while adapter code handles the mess of vendor-specific SSE formats and error recovery.

Summary

When an LLM streams responses, the API adapter pushes data chunks at one speed while the agent loop consumes them at another. An EventStream channel bridges that gap with an in-memory buffer built on two queues: one for events and one for suspended consumers. The producer calls `push()` without knowing whether the consumer is ready; the consumer iterates with `for await...of` without caring whether data arrives immediately or later.

The implementation wraps an `AsyncIterable` class that validates events, detects terminal signals, and exposes a final-result promise for metadata like token counts. A single-iterator guard prevents multiple consumers from racing on the same stream. Error handling funnels API failures and network interruptions into a `fail()` method that rejects every waiting consumer and the final-result promise at once.

Adapter code from different LLM providers can normalize their varying SSE payloads into a uniform event format before pushing into the channel, so the agent sees a consistent stream regardless of which model sits behind it.

Takeaways
An EventStream is an AsyncIterable that lets an agent consume LLM streaming events with a standard `for await...of` loop.
The channel uses two internal arrays: a `queue` for buffered events and a `waiters` array for suspended consumer promises.
A `push()` method validates events, detects terminal signals, and either resolves a waiting consumer or queues the event.
The `fail()` method rejects every pending consumer and the final-result promise when an unrecoverable error occurs.
Each EventStream instance enforces a single-iterator policy to prevent multiple consumers from racing on the same stream.
A `result()` promise carries stream metadata such as token consumption and end reason, separate from the iteration loop.
Conclusions

Decoupling the transport from the consumption loop with a producer-consumer channel is a pattern that generalizes beyond LLM streaming to any async data source an agent might poll.

The single-iterator guard is a practical constraint that avoids the complexity of fan-out semantics, but it also means the channel is deliberately not a general-purpose pub-sub bus.

Storing suspended `resolve` functions in a `waiters` array is essentially a hand-rolled async coordination primitive; it replaces what a library like RxJS would do with a Subject, at a fraction of the conceptual overhead.

Concepts & terms
AsyncIterable
A JavaScript protocol that allows an object to be consumed by a `for await...of` loop. The object must implement `[Symbol.asyncIterator]()`, which returns an iterator whose `next()` method returns a Promise resolving to `{ value, done }`.
Producer-Consumer Pattern
A concurrency pattern where one or more producers generate data and push it into a buffer, while one or more consumers pull data from that buffer independently. The buffer decouples the two sides so neither blocks the other.
SSE (Server-Sent Events)
A standard HTTP-based protocol where a server pushes a stream of text events to a client over a single long-lived connection. LLM APIs commonly use SSE to deliver streaming token chunks.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗