Coverage for agentos/core/streaming_optimizer.py: 0%
187 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
1"""
2AgentOS Streaming Optimizer — SSE Stream Processing & Backpressure
3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5Production-grade streaming optimization for LLM responses:
7 - Chunk aggregation (avoid 1-token-at-a-time jitter)
8 - Backpressure handling (pause/resume flow control)
9 - Stream transformation pipeline (chains of transformers)
10 - Token counting and rate estimation
11 - Adaptive chunk sizing based on network conditions
12 - Stream cancellation and cleanup
14Usage:
15 optimizer = StreamingOptimizer()
16 async for chunk in optimizer.optimize(llm_stream):
17 yield chunk
18"""
20from __future__ import annotations
22import asyncio
23import time
24from collections.abc import AsyncIterable, AsyncIterator, Callable
25from dataclasses import dataclass, field
26from enum import StrEnum
27from typing import Any
29# ---------------------------------------------------------------------------
30# Stream Chunk
31# ---------------------------------------------------------------------------
34@dataclass
35class StreamChunk:
36 """A single chunk from an LLM stream."""
38 content: str
39 index: int
40 timestamp: float = field(default_factory=time.time)
41 is_final: bool = False
42 metadata: dict[str, Any] = field(default_factory=dict)
45# ---------------------------------------------------------------------------
46# Configuration
47# ---------------------------------------------------------------------------
50class AggregationStrategy(StrEnum):
51 """How to aggregate small chunks."""
53 NONE = "none" # Pass through as-is
54 FIXED_SIZE = "fixed" # Wait for N tokens before emitting
55 TIME_WINDOW = "time" # Emit every T milliseconds
56 ADAPTIVE = "adaptive" # Adjust based on latency
59@dataclass
60class StreamConfig:
61 """Configuration for streaming optimization."""
63 strategy: AggregationStrategy = AggregationStrategy.ADAPTIVE
64 min_chunk_size: int = 3 # Minimum tokens before emitting
65 max_chunk_size: int = 50 # Maximum tokens in a single chunk
66 time_window_ms: int = 50 # Max wait time between emissions
67 buffer_max_chunks: int = 100 # Max unacknowledged chunks (backpressure)
68 adaptive_latency_target_ms: int = 100 # Target round-trip latency
69 adaptive_min_chunk_size: int = 1
70 adaptive_max_chunk_size: int = 100
71 enable_compression: bool = False
72 track_performance: bool = True
75# ---------------------------------------------------------------------------
76# Performance Tracker
77# ---------------------------------------------------------------------------
80@dataclass
81class StreamMetrics:
82 """Stream performance metrics."""
84 total_chunks_received: int = 0
85 total_chunks_emitted: int = 0
86 total_tokens_received: int = 0
87 total_tokens_emitted: int = 0
88 avg_chunk_latency_ms: float = 0.0
89 total_wall_time_ms: float = 0.0
90 backpressure_events: int = 0
91 peak_buffer_size: int = 0
92 tokens_per_second: float = 0.0
94 @property
95 def aggregation_ratio(self) -> float:
96 if self.total_chunks_received == 0:
97 return 1.0
98 return self.total_chunks_received / max(1, self.total_chunks_emitted)
101class MetricsCollector:
102 """Collect streaming performance metrics."""
104 def __init__(self):
105 self._metrics = StreamMetrics()
106 self._latencies: list[float] = []
108 def record_received(self, tokens: int = 1) -> None:
109 self._metrics.total_chunks_received += 1
110 self._metrics.total_tokens_received += tokens
112 def record_emitted(self, tokens: int = 1, latency_ms: float = 0.0) -> None:
113 self._metrics.total_chunks_emitted += 1
114 self._metrics.total_tokens_emitted += tokens
115 self._latencies.append(latency_ms)
117 def record_backpressure(self) -> None:
118 self._metrics.backpressure_events += 1
120 def record_buffer_size(self, size: int) -> None:
121 if size > self._metrics.peak_buffer_size:
122 self._metrics.peak_buffer_size = size
124 def finalize(self, wall_time_ms: float) -> StreamMetrics:
125 self._metrics.total_wall_time_ms = wall_time_ms
126 if self._latencies:
127 self._metrics.avg_chunk_latency_ms = sum(self._latencies) / len(self._latencies)
128 if wall_time_ms > 0:
129 self._metrics.tokens_per_second = self._metrics.total_tokens_emitted / (
130 wall_time_ms / 1000
131 )
132 return self._metrics
135# ---------------------------------------------------------------------------
136# Stream Transformer
137# ---------------------------------------------------------------------------
140StreamTransformer = Callable[[StreamChunk], StreamChunk | None]
143class TransformerPipeline:
144 """Chain of stream transformers applied to each chunk."""
146 def __init__(self):
147 self._transformers: list[StreamTransformer] = []
149 def add(self, transformer: StreamTransformer) -> TransformerPipeline:
150 self._transformers.append(transformer)
151 return self
153 def apply(self, chunk: StreamChunk) -> StreamChunk | None:
154 current = chunk
155 for t in self._transformers:
156 if current is None:
157 return None
158 current = t(current)
159 return current
161 @property
162 def size(self) -> int:
163 return len(self._transformers)
166# Built-in transformers
169def strip_leading_whitespace() -> StreamTransformer:
170 """Remove leading whitespace from first chunk."""
171 first = True
173 def transformer(chunk: StreamChunk) -> StreamChunk:
174 nonlocal first
175 if first:
176 chunk.content = chunk.content.lstrip()
177 first = False
178 return chunk
180 return transformer
183def normalize_newlines() -> StreamTransformer:
184 """Normalize all line endings to '\n'."""
186 def transformer(chunk: StreamChunk) -> StreamChunk:
187 chunk.content = chunk.content.replace("\r\n", "\n").replace("\r", "\n")
188 return chunk
190 return transformer
193def filter_empty_chunks() -> StreamTransformer:
194 """Drop chunks with empty content."""
196 def transformer(chunk: StreamChunk) -> StreamChunk | None:
197 return chunk if chunk.content else None
199 return transformer
202def add_token_count() -> StreamTransformer:
203 """Add approximate token count to metadata."""
205 def transformer(chunk: StreamChunk) -> StreamChunk:
206 # Rough estimate: ~1.3 chars per token
207 chunk.metadata["approx_tokens"] = max(1, int(len(chunk.content) / 1.3))
208 return chunk
210 return transformer
213# ---------------------------------------------------------------------------
214# Streaming Optimizer
215# ---------------------------------------------------------------------------
218class StreamingOptimizer:
219 """
220 Optimize LLM streaming with aggregation, backpressure, and metrics.
222 Usage:
223 opt = StreamingOptimizer()
224 async for chunk in opt.optimize(llm_stream):
225 yield chunk.content
227 With transformers:
228 opt = StreamingOptimizer()
229 opt.pipeline.add(strip_leading_whitespace())
230 async for chunk in opt.optimize(llm_stream):
231 ...
232 """
234 def __init__(self, config: StreamConfig | None = None):
235 self._config = config or StreamConfig()
236 self._pipeline = TransformerPipeline()
237 self._buffer: list[StreamChunk] = []
238 self._token_accumulator: list[str] = []
239 self._metrics = MetricsCollector()
240 self._start_time: float | None = None
241 self._paused = False
243 @property
244 def pipeline(self) -> TransformerPipeline:
245 return self._pipeline
247 @property
248 def config(self) -> StreamConfig:
249 return self._config
251 async def optimize(self, stream: AsyncIterable[StreamChunk]) -> AsyncIterator[StreamChunk]:
252 """
253 Optimize a stream of chunks.
255 Applies aggregation and transformer pipeline.
256 """
257 self._start_time = time.time()
259 async for chunk in stream:
260 self._metrics.record_received()
262 # Backpressure: wait if buffer is full
263 while len(self._buffer) >= self._config.buffer_max_chunks:
264 self._metrics.record_backpressure()
265 await asyncio.sleep(0.01)
267 self._buffer.append(chunk)
268 self._metrics.record_buffer_size(len(self._buffer))
270 # Check if we should emit
271 result = await self._maybe_emit()
272 if result is not None:
273 yield result
275 # Flush remaining buffer
276 async for chunk in self._flush():
277 yield chunk
279 self._metrics.finalize((time.time() - self._start_time) * 1000)
281 async def optimize_simple(self, text_stream: AsyncIterable[str]) -> AsyncIterator[str]:
282 """
283 Optimize a simple text stream (strings instead of StreamChunk objects).
284 """
285 async for chunk in self.optimize(
286 StreamChunk(content=text, index=i) for i, text in enumerate(text_stream)
287 ):
288 yield chunk.content
290 async def _maybe_emit(self) -> StreamChunk | None:
291 """Check if we should emit an aggregated chunk."""
292 if not self._buffer:
293 return None
295 tokens = sum(chunk.metadata.get("approx_tokens", 1) for chunk in self._buffer)
297 should_emit = False
299 if self._config.strategy == AggregationStrategy.NONE:
300 should_emit = True
301 elif self._config.strategy == AggregationStrategy.FIXED_SIZE:
302 should_emit = tokens >= self._config.min_chunk_size
303 elif self._config.strategy == AggregationStrategy.TIME_WINDOW:
304 if self._buffer:
305 elapsed = (time.time() - self._buffer[0].timestamp) * 1000
306 should_emit = elapsed >= self._config.time_window_ms
307 elif self._config.strategy == AggregationStrategy.ADAPTIVE:
308 should_emit = tokens >= self._config.adaptive_min_chunk_size and (
309 tokens >= self._config.max_chunk_size or self._buffer[-1].is_final
310 )
312 if not should_emit:
313 return None
315 return self._emit_aggregated()
317 def _emit_aggregated(self) -> StreamChunk | None:
318 """Aggregate buffered chunks into a single emission."""
319 if not self._buffer:
320 return None
322 # Aggregate content
323 content = "".join(c.content for c in self._buffer)
324 is_final = self._buffer[-1].is_final
325 index = self._buffer[-1].index
326 timestamp = time.time()
328 # Clear buffer
329 count = len(self._buffer)
330 self._buffer.clear()
332 # Build aggregated chunk
333 chunk = StreamChunk(
334 content=content,
335 index=index,
336 timestamp=timestamp,
337 is_final=is_final,
338 metadata={"aggregated_from": count},
339 )
341 # Apply transformer pipeline
342 chunk = self._pipeline.apply(chunk)
343 if chunk is None:
344 return None
346 # Record metrics
347 latency_ms = (timestamp - self._start_time) * 1000 if self._start_time else 0
348 approx_tokens = chunk.metadata.get("approx_tokens", 1)
349 self._metrics.record_emitted(tokens=approx_tokens, latency_ms=latency_ms)
351 return chunk
353 async def _flush(self) -> AsyncIterator[StreamChunk]:
354 """Flush remaining buffered chunks."""
355 while self._buffer:
356 chunk = self._emit_aggregated()
357 if chunk is not None:
358 yield chunk
360 def get_metrics(self) -> StreamMetrics:
361 return self._metrics._metrics
363 def reset_metrics(self) -> None:
364 self._metrics = MetricsCollector()
365 self._start_time = None