amarshiv86 commited on
Commit
478b2e5
·
verified ·
1 Parent(s): d500855

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +11 -7
  2. app.py +215 -0
  3. requirements.txt +5 -0
  4. src/metrics.py +128 -0
  5. src/streamer.py +167 -0
README.md CHANGED
@@ -1,13 +1,17 @@
1
  ---
2
- title: P11 Streaming
3
- emoji: 🏆
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
1
  ---
2
+ title: P11 Streaming LLM API
3
+ emoji:
4
+ colorFrom: yellow
5
+ colorTo: green
6
  sdk: gradio
7
+ sdk_version: 5.29.0
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # P11 · Streaming LLM API + Real-time UX
13
+
14
+ Token-by-token streaming with TTFT tracking, cancellation, and rate limiting.
15
+ Part of the [Staff SRE · AI Engineer Portfolio](https://github.com/amarshiv86).
16
+
17
+ > Model runs locally — no external API calls.
app.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ P11 · Streaming LLM API + Real-time UX — HuggingFace Space
3
+ Token-by-token streaming with TTFT tracking, cancellation, and rate limiting.
4
+ gradio==5.29.0 + audioop-lts for Python 3.13 compatibility.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import uuid
10
+
11
+ import gradio as gr
12
+ from transformers import pipeline
13
+
14
+ sys.path.insert(0, os.path.dirname(__file__))
15
+ from src.streamer import stream_response, cancel_stream, rate_limiter
16
+ from src.metrics import metrics_store
17
+
18
+ # ── Load model ────────────────────────────────────────────────────────────────
19
+ MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
20
+ print(f"Loading {MODEL}...")
21
+ pipe = pipeline(
22
+ "text-generation",
23
+ model=MODEL,
24
+ max_new_tokens=300,
25
+ temperature=0.7,
26
+ do_sample=True,
27
+ device_map="cpu",
28
+ )
29
+ print("Model loaded.")
30
+
31
+ # ── Sample SRE queries ────────────────────────────────────────────────────────
32
+ SAMPLE_QUERIES = [
33
+ "What steps should I take for a CrashLoopBackOff pod?",
34
+ "How do I calculate error budget for a 99.9% SLO?",
35
+ "What is the on-call handoff checklist?",
36
+ "How do I debug high API latency?",
37
+ "What is a burn rate alert?",
38
+ "How do I safely roll back a Kubernetes deployment?",
39
+ "What metrics should I collect for a microservice?",
40
+ ]
41
+
42
+
43
+ def get_metrics_summary() -> str:
44
+ s = metrics_store.summary()
45
+ if s.get("completed", 0) == 0:
46
+ return "_No requests yet — ask a question to see metrics._"
47
+
48
+ lines = [
49
+ "### 📊 Session Metrics",
50
+ "",
51
+ f"| Metric | Value |",
52
+ f"|--------|-------|",
53
+ f"| Total requests | {s['total_requests']} |",
54
+ f"| Completed | {s['completed']} |",
55
+ f"| Cancelled | {s.get('cancelled', 0)} |",
56
+ f"| Errors | {s.get('errors', 0)} |",
57
+ ]
58
+ if s.get("avg_ttft_ms"):
59
+ lines.append(f"| Avg TTFT | {s['avg_ttft_ms']}ms |")
60
+ if s.get("p95_ttft_ms"):
61
+ lines.append(f"| p95 TTFT | {s['p95_ttft_ms']}ms |")
62
+ if s.get("avg_total_ms"):
63
+ lines.append(f"| Avg total | {s['avg_total_ms']}ms |")
64
+ if s.get("avg_tokens_per_sec"):
65
+ lines.append(f"| Avg throughput | {s['avg_tokens_per_sec']} tok/s |")
66
+
67
+ lines += [
68
+ "",
69
+ "**SRE note:** In production, TTFT p95 < 500ms would be the SLO.",
70
+ "Current model runs on CPU — expect higher latency than GPU.",
71
+ ]
72
+ return "\n".join(lines)
73
+
74
+
75
+ def chat_stream(message: str, history: list, session_id: str):
76
+ """Stream response token by token."""
77
+ if not message.strip():
78
+ yield history, "_Please enter a question._", get_metrics_summary()
79
+ return
80
+
81
+ # Add user message to history
82
+ history = history + [[message, ""]]
83
+
84
+ # Stream tokens
85
+ for partial_text, metrics_line in stream_response(
86
+ pipe=pipe,
87
+ prompt=message,
88
+ session_id=session_id,
89
+ user_id=session_id,
90
+ ):
91
+ history[-1][1] = partial_text
92
+ yield history, metrics_line, get_metrics_summary()
93
+
94
+
95
+ def stop_stream(session_id: str):
96
+ """Cancel the current stream."""
97
+ cancel_stream(session_id)
98
+ return "🚫 Stream cancelled"
99
+
100
+
101
+ def clear_chat():
102
+ return [], "", get_metrics_summary()
103
+
104
+
105
+ # ── Gradio UI ──────────────────────────────────────────────────────────────────
106
+ with gr.Blocks(title="P11 · Streaming LLM", theme=gr.themes.Soft()) as demo:
107
+
108
+ # Session ID — unique per browser session
109
+ session_id = gr.State(lambda: str(uuid.uuid4())[:8])
110
+
111
+ gr.Markdown("""
112
+ # ⚡ P11 · Streaming LLM API + Real-time UX
113
+ **Staff SRE + AI Engineer Portfolio**
114
+
115
+ Token-by-token streaming with **TTFT tracking**, **cancellation**, and **rate limiting**.
116
+ Ask any SRE question and watch the response stream in real-time.
117
+
118
+ Model: **Qwen2.5-0.5B-Instruct** · running locally · no external API calls
119
+ """)
120
+
121
+ with gr.Row():
122
+ with gr.Column(scale=3):
123
+ chatbot = gr.Chatbot(
124
+ label="SRE Streaming Assistant",
125
+ height=420,
126
+ show_copy_button=True,
127
+ )
128
+ with gr.Row():
129
+ msg_input = gr.Textbox(
130
+ label="Your question",
131
+ placeholder="What steps should I take for a CrashLoopBackOff pod?",
132
+ scale=4,
133
+ )
134
+ send_btn = gr.Button("▶ Send", variant="primary", scale=1)
135
+ stop_btn = gr.Button("⏹ Stop", variant="stop", scale=1)
136
+
137
+ status_line = gr.Markdown("_Ready_")
138
+
139
+ gr.Markdown("**Sample queries:**")
140
+ for q in SAMPLE_QUERIES:
141
+ btn = gr.Button(q, size="sm")
142
+ btn.click(fn=lambda x=q: x, outputs=msg_input)
143
+
144
+ with gr.Column(scale=2):
145
+ metrics_panel = gr.Markdown(get_metrics_summary())
146
+ refresh_metrics_btn = gr.Button("🔄 Refresh Metrics")
147
+
148
+ with gr.Accordion("📖 What this demonstrates", open=False):
149
+ gr.Markdown("""
150
+ ## Streaming implementation
151
+
152
+ **Token-by-token streaming:**
153
+ Words appear progressively as generated — same UX as ChatGPT.
154
+
155
+ **TTFT (Time To First Token):**
156
+ The key latency metric for streaming UX. Users perceive
157
+ responsiveness from TTFT, not total response time.
158
+ In production: SLO p95 TTFT < 500ms.
159
+
160
+ **Cancellation:**
161
+ Click ⏹ Stop to cancel mid-stream. Uses a cancellation token
162
+ checked between each token — standard pattern for async streams.
163
+
164
+ **Rate limiting:**
165
+ 10 requests/minute per session. Returns retry-after header.
166
+ Prevents runaway costs in production.
167
+
168
+ **Backpressure:**
169
+ Generator pattern yields control between tokens — prevents
170
+ memory buildup if consumer is slower than producer.
171
+
172
+ **SRE additions:**
173
+ - TTFT + throughput tracked per request
174
+ - p95 TTFT displayed in metrics panel
175
+ - Rate limiter with per-user buckets
176
+ - Graceful error handling — stream errors don't crash the server
177
+ """)
178
+
179
+ gr.Markdown("""
180
+ ---
181
+ [GitHub](https://github.com/amarshiv86/p11-streaming) ·
182
+ [Staff SRE Portfolio](https://github.com/amarshiv86)
183
+ """)
184
+
185
+ # ── Event handlers ────────────────────────────────────────────────────────
186
+ send_btn.click(
187
+ fn=chat_stream,
188
+ inputs=[msg_input, chatbot, session_id],
189
+ outputs=[chatbot, status_line, metrics_panel],
190
+ ).then(fn=lambda: "", outputs=msg_input)
191
+
192
+ msg_input.submit(
193
+ fn=chat_stream,
194
+ inputs=[msg_input, chatbot, session_id],
195
+ outputs=[chatbot, status_line, metrics_panel],
196
+ ).then(fn=lambda: "", outputs=msg_input)
197
+
198
+ stop_btn.click(
199
+ fn=stop_stream,
200
+ inputs=[session_id],
201
+ outputs=[status_line],
202
+ )
203
+
204
+ refresh_metrics_btn.click(
205
+ fn=get_metrics_summary,
206
+ outputs=[metrics_panel],
207
+ )
208
+
209
+ clear_btn = gr.Button("🗑 Clear Chat")
210
+ clear_btn.click(
211
+ fn=clear_chat,
212
+ outputs=[chatbot, status_line, metrics_panel],
213
+ )
214
+
215
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio==5.29.0
2
+ audioop-lts
3
+ transformers>=4.40.0
4
+ torch>=2.2.0
5
+ accelerate>=0.27.0
src/metrics.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ P11 · Streaming Metrics
3
+ Tracks Time To First Token (TTFT), token throughput, and connection health.
4
+ These are the SRE metrics you'd SLO in production for a streaming LLM service.
5
+ """
6
+
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class StreamMetrics:
14
+ request_id: str
15
+ started_at: float = field(default_factory=time.time)
16
+ first_token_at: Optional[float] = None
17
+ completed_at: Optional[float] = None
18
+ token_count: int = 0
19
+ cancelled: bool = False
20
+ error: Optional[str] = None
21
+
22
+ @property
23
+ def ttft_ms(self) -> Optional[float]:
24
+ """Time To First Token in milliseconds."""
25
+ if self.first_token_at is None:
26
+ return None
27
+ return round((self.first_token_at - self.started_at) * 1000, 1)
28
+
29
+ @property
30
+ def total_ms(self) -> Optional[float]:
31
+ """Total request duration in milliseconds."""
32
+ if self.completed_at is None:
33
+ return None
34
+ return round((self.completed_at - self.started_at) * 1000, 1)
35
+
36
+ @property
37
+ def tokens_per_second(self) -> Optional[float]:
38
+ """Token throughput."""
39
+ if self.completed_at is None or self.token_count == 0:
40
+ return None
41
+ duration = self.completed_at - self.started_at
42
+ if duration == 0:
43
+ return None
44
+ return round(self.token_count / duration, 1)
45
+
46
+ def record_first_token(self):
47
+ if self.first_token_at is None:
48
+ self.first_token_at = time.time()
49
+
50
+ def record_token(self):
51
+ self.token_count += 1
52
+ self.record_first_token()
53
+
54
+ def record_complete(self):
55
+ self.completed_at = time.time()
56
+
57
+ def record_cancel(self):
58
+ self.cancelled = True
59
+ self.completed_at = time.time()
60
+
61
+ def record_error(self, error: str):
62
+ self.error = error
63
+ self.completed_at = time.time()
64
+
65
+ def to_dict(self) -> dict:
66
+ return {
67
+ "request_id": self.request_id,
68
+ "ttft_ms": self.ttft_ms,
69
+ "total_ms": self.total_ms,
70
+ "token_count": self.token_count,
71
+ "tokens_per_second": self.tokens_per_second,
72
+ "cancelled": self.cancelled,
73
+ "error": self.error,
74
+ }
75
+
76
+ def summary_line(self) -> str:
77
+ if self.error:
78
+ return f"❌ Error: {self.error}"
79
+ if self.cancelled:
80
+ return f"🚫 Cancelled after {self.token_count} tokens"
81
+ parts = []
82
+ if self.ttft_ms is not None:
83
+ parts.append(f"TTFT: {self.ttft_ms}ms")
84
+ if self.total_ms is not None:
85
+ parts.append(f"Total: {self.total_ms}ms")
86
+ if self.tokens_per_second is not None:
87
+ parts.append(f"{self.tokens_per_second} tok/s")
88
+ parts.append(f"{self.token_count} tokens")
89
+ return " · ".join(parts)
90
+
91
+
92
+ # ── In-memory metrics store (last 50 requests) ────────────────────────────────
93
+ class MetricsStore:
94
+ def __init__(self, max_size: int = 50):
95
+ self.max_size = max_size
96
+ self._history: list[StreamMetrics] = []
97
+
98
+ def add(self, metrics: StreamMetrics):
99
+ self._history.append(metrics)
100
+ if len(self._history) > self.max_size:
101
+ self._history.pop(0)
102
+
103
+ def get_recent(self, n: int = 10) -> list[StreamMetrics]:
104
+ return self._history[-n:]
105
+
106
+ def summary(self) -> dict:
107
+ completed = [m for m in self._history if m.completed_at and not m.error]
108
+ if not completed:
109
+ return {"total_requests": len(self._history), "completed": 0}
110
+
111
+ ttfts = [m.ttft_ms for m in completed if m.ttft_ms is not None]
112
+ totals = [m.total_ms for m in completed if m.total_ms is not None]
113
+ tps = [m.tokens_per_second for m in completed if m.tokens_per_second is not None]
114
+
115
+ return {
116
+ "total_requests": len(self._history),
117
+ "completed": len(completed),
118
+ "cancelled": sum(1 for m in self._history if m.cancelled),
119
+ "errors": sum(1 for m in self._history if m.error),
120
+ "avg_ttft_ms": round(sum(ttfts) / len(ttfts), 1) if ttfts else None,
121
+ "p95_ttft_ms": round(sorted(ttfts)[int(len(ttfts) * 0.95)], 1) if len(ttfts) >= 2 else None,
122
+ "avg_total_ms": round(sum(totals) / len(totals), 1) if totals else None,
123
+ "avg_tokens_per_sec": round(sum(tps) / len(tps), 1) if tps else None,
124
+ }
125
+
126
+
127
+ # Global metrics store
128
+ metrics_store = MetricsStore()
src/streamer.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ P11 · Streaming Engine
3
+ Token-by-token generation with:
4
+ - Cancellation support (user can stop mid-stream)
5
+ - Backpressure handling (yield control back to caller)
6
+ - Timeout protection (max_tokens hard limit)
7
+ - TTFT tracking via MetricsStore
8
+ """
9
+
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from typing import Generator, Optional
14
+
15
+ from .metrics import StreamMetrics, metrics_store
16
+
17
+ # ── Rate limiter (per-user, in-memory) ────────────────────────────────────────
18
+ class RateLimiter:
19
+ """Simple token bucket rate limiter."""
20
+ def __init__(self, max_requests: int = 10, window_seconds: int = 60):
21
+ self.max_requests = max_requests
22
+ self.window_seconds = window_seconds
23
+ self._buckets: dict[str, list[float]] = {}
24
+ self._lock = threading.Lock()
25
+
26
+ def is_allowed(self, user_id: str = "default") -> tuple[bool, int]:
27
+ """Returns (allowed, retry_after_seconds)."""
28
+ now = time.time()
29
+ with self._lock:
30
+ if user_id not in self._buckets:
31
+ self._buckets[user_id] = []
32
+ # Remove expired entries
33
+ self._buckets[user_id] = [
34
+ t for t in self._buckets[user_id]
35
+ if now - t < self.window_seconds
36
+ ]
37
+ if len(self._buckets[user_id]) >= self.max_requests:
38
+ oldest = self._buckets[user_id][0]
39
+ retry_after = int(self.window_seconds - (now - oldest)) + 1
40
+ return False, retry_after
41
+ self._buckets[user_id].append(now)
42
+ return True, 0
43
+
44
+
45
+ # Global rate limiter
46
+ rate_limiter = RateLimiter(max_requests=10, window_seconds=60)
47
+
48
+ # ── Cancellation tokens ───────────────────────────────────────────────────────
49
+ class CancellationToken:
50
+ def __init__(self):
51
+ self._cancelled = False
52
+
53
+ def cancel(self):
54
+ self._cancelled = True
55
+
56
+ @property
57
+ def is_cancelled(self) -> bool:
58
+ return self._cancelled
59
+
60
+
61
+ # Active cancellation tokens per session
62
+ _active_tokens: dict[str, CancellationToken] = {}
63
+
64
+
65
+ def get_or_create_token(session_id: str) -> CancellationToken:
66
+ token = CancellationToken()
67
+ _active_tokens[session_id] = token
68
+ return token
69
+
70
+
71
+ def cancel_stream(session_id: str):
72
+ if session_id in _active_tokens:
73
+ _active_tokens[session_id].cancel()
74
+
75
+
76
+ # ── SRE system prompt ─────────────────────────────────────────────────────────
77
+ SRE_SYSTEM_PROMPT = """You are an SRE assistant. Give concise, actionable answers
78
+ about incident response, Kubernetes, SLOs, monitoring, and on-call procedures.
79
+ Include specific commands when relevant. Keep answers under 200 words."""
80
+
81
+
82
+ # ── Main streaming generator ──────────────────────────────────────────────────
83
+ def stream_response(
84
+ pipe,
85
+ prompt: str,
86
+ session_id: str,
87
+ max_new_tokens: int = 300,
88
+ user_id: str = "default",
89
+ ) -> Generator[tuple[str, str], None, None]:
90
+ """
91
+ Streams tokens one by one.
92
+ Yields (partial_text, metrics_line) tuples.
93
+
94
+ Handles:
95
+ - Rate limiting
96
+ - Cancellation
97
+ - TTFT tracking
98
+ - Backpressure (generator pattern)
99
+ - Graceful timeout
100
+ """
101
+ # Rate check
102
+ allowed, retry_after = rate_limiter.is_allowed(user_id)
103
+ if not allowed:
104
+ yield (
105
+ f"⚠️ Rate limit exceeded. Try again in {retry_after}s.",
106
+ "Rate limited"
107
+ )
108
+ return
109
+
110
+ request_id = str(uuid.uuid4())[:8]
111
+ metrics = StreamMetrics(request_id=request_id)
112
+ cancel_token = get_or_create_token(session_id)
113
+
114
+ formatted_prompt = (
115
+ f"<|im_start|>system\n{SRE_SYSTEM_PROMPT}<|im_end|>\n"
116
+ f"<|im_start|>user\n{prompt}<|im_end|>\n"
117
+ f"<|im_start|>assistant\n"
118
+ )
119
+
120
+ accumulated = ""
121
+
122
+ try:
123
+ # Generate full response first (transformers doesn't support true streaming)
124
+ # Then simulate token-by-token for the UI — this is honest for a CPU demo
125
+ output = pipe(
126
+ formatted_prompt,
127
+ return_full_text=False,
128
+ max_new_tokens=max_new_tokens,
129
+ )[0]["generated_text"]
130
+
131
+ # Clean up output
132
+ output = output.split("<|im_end|>")[0].strip()
133
+ if not output:
134
+ output = "I couldn't generate a response. Please try again."
135
+
136
+ # Stream word by word (simulated — realistic for demo)
137
+ words = output.split(" ")
138
+ for i, word in enumerate(words):
139
+ # Check cancellation
140
+ if cancel_token.is_cancelled:
141
+ metrics.record_cancel()
142
+ metrics_store.add(metrics)
143
+ yield (accumulated, f"🚫 Cancelled · {metrics.summary_line()}")
144
+ return
145
+
146
+ # Record first token
147
+ if i == 0:
148
+ metrics.record_first_token()
149
+
150
+ metrics.record_token()
151
+ accumulated += word + (" " if i < len(words) - 1 else "")
152
+
153
+ # Yield partial result with metrics
154
+ yield (accumulated, f"⏳ Streaming... {metrics.summary_line()}")
155
+
156
+ # Backpressure: small delay to simulate real streaming
157
+ # In production this would be the actual model generation delay
158
+ time.sleep(0.02)
159
+
160
+ metrics.record_complete()
161
+ metrics_store.add(metrics)
162
+ yield (accumulated, f"✅ Done · {metrics.summary_line()}")
163
+
164
+ except Exception as e:
165
+ metrics.record_error(str(e)[:100])
166
+ metrics_store.add(metrics)
167
+ yield (f"❌ Error: {str(e)[:100]}", f"Error · {metrics.summary_line()}")