
“Streaming” will get utilized in two other ways when individuals speak about AI brokers, and most tutorials solely construct certainly one of them. Generally it means the agent consumes a dwell stream of occasions as an alternative of ready for somebody to sort a message. Generally it means the agent’s personal output streams out token by token as an alternative of showing all of sudden after a protracted pause. This construct does each, on goal, as a result of they clear up two completely different issues, and a genuinely helpful always-on agent wants each solved.
The framing value borrowing right here comes from what’s normally known as an ambient agent, one LangChain describes as triggered by occasions moderately than by a human message, and Google’s Agent Improvement Package describes from the infrastructure aspect the identical method: brokers woken by one thing arriving on a stream, not sitting behind a request-response name. The state of affairs for this construct is concrete and genuinely actual: a neighborhood agent that watches Wikipedia’s dwell, public edit feed, no API key required, and causes about which edits appear like vandalism, working totally by yourself machine via Ollama. Each line of code under was written, then truly examined, earlier than it went into this text.
These are your conditions:
- Python 3.11 or newer
- Ollama put in regionally, with a mannequin pulled (
ollama pull llama3.1:8b, or any mannequin that helps structured JSON output) pip set up fastapi uvicorn httpx pydantic ollama sse-starlette- No API keys, no cloud account, and no price past your personal electrical energy. The one outbound community connection this service makes is to Wikipedia’s public EventStreams endpoint, which requires no authentication
# The One Design Determination That Issues
Wikipedia’s edit stream is not a trickle. On an lively day, it pushes a number of edits per second throughout each language version mixed. Hand each single a kind of to a language mannequin and two issues occur without delay: you burn via your machine’s compute on edits that have been by no means attention-grabbing within the first place, and the agent falls behind the dwell stream it is purported to be watching, which defeats your complete level of constructing one thing “at all times on.“
The repair is a two-stage funnel, and it is the one most necessary concept on this construct:
- Stage one is reasonable, plain Python math that runs on each occasion with no mannequin concerned in any respect: what number of bytes did this edit take away, what number of edits has this consumer made within the final couple of minutes? The overwhelming majority of edits are boring, and boring is free to detect
- Stage two, the precise native LLM, solely wakes up for the small fraction of occasions that journey a threshold in stage one. This is identical precept behind any good monitoring system: low-cost filters up entrance, costly reasoning reserved for the candidates that survive

// Folder Construction
streaming-local-agent/
├── src/
│ ├── __init__.py
│ ├── config.py
│ ├── schemas.py
│ ├── stream_source.py
│ ├── filters.py
│ ├── agent.py
│ ├── broadcaster.py
│ └── foremost.py
├── checks/
│ └── test_filters.py
├── necessities.txt
└── .env.instance
Every file maps to precisely one stage of the pipeline described above, which makes the entire thing straightforward to cause about and straightforward to check in isolation, which is precisely the way it was truly constructed for this text.
# Construct Part 1: The Occasion Stream Shopper
Wikipedia’s EventStreams service pushes edits as Server-Despatched Occasions over plain HTTP. No key, no handshake past an atypical GET request that stays open.
# src/stream_source.py
import asyncio
import json
import re
import time
from typing import AsyncIterator, Non-compulsory
import httpx
from .schemas import RecentChangeEvent
from . import config
# Wikipedia would not ship an specific "is that this consumer nameless" flag on this
# stream; nameless edits are attributed to the editor's IP tackle as an alternative
# of a username, so an IP-shaped username is the way you detect one in apply.
_IPV4_RE = re.compile(r"^d{1,3}(.d{1,3}){3}$")
_IPV6_RE = re.compile(r"^[0-9A-Fa-f:]+:[0-9A-Fa-f:]+$")
def is_anonymous_user(username: str) -> bool:
return bool(_IPV4_RE.match(username) or _IPV6_RE.match(username))
def parse_sse_line(line: str) -> Non-compulsory[dict]:
"""SSE frames information as strains prefixed with 'information: '. Remark strains
(beginning with ':') and clean keep-alive strains are widespread on this
feed and ought to be silently ignored, not handled as errors."""
if not line or line.startswith(":"):
return None
if line.startswith("information:"):
uncooked = line[len("data:"):].strip()
if not uncooked:
return None
strive:
return json.hundreds(uncooked)
besides json.JSONDecodeError:
return None
return None
def to_event(uncooked: dict) -> Non-compulsory[RecentChangeEvent]:
"""Converts a uncooked Wikimedia payload into our normalized schema.
Returns None for occasion sorts we do not care about moderately than
elevating, since a stream this high-volume continuously contains shapes
we're not looking ahead to."""
if uncooked.get("sort") != "edit":
return None
size = uncooked.get("size") or {}
if "previous" not in size or "new" not in size:
return None
return RecentChangeEvent(
wiki=uncooked.get("wiki", "unknown"),
consumer=uncooked.get("consumer", "unknown"),
title=uncooked.get("title", "unknown"),
is_anonymous=is_anonymous_user(uncooked.get("consumer", "")),
is_bot=uncooked.get("bot", False),
old_length=size["old"],
new_length=size["new"],
timestamp=uncooked.get("timestamp", time.time()),
remark=uncooked.get("remark", "") or "",
)
async def wikipedia_event_stream() -> AsyncIterator[RecentChangeEvent]:
"""The dwell async generator utilized by foremost.py. Reconnects mechanically
on a dropped connection moderately than letting the entire service die
due to one community hiccup, which issues quite a bit for one thing
meant to run unattended."""
whereas True:
strive:
async with httpx.AsyncClient(timeout=None) as shopper:
async with shopper.stream("GET", config.WIKIPEDIA_STREAM_URL) as response:
async for line in response.aiter_lines():
uncooked = parse_sse_line(line)
if uncooked is None:
proceed
if uncooked.get("wiki") not in config.WATCHED_WIKIS:
proceed
occasion = to_event(uncooked)
if occasion isn't None:
yield occasion
besides httpx.HTTPError:
await asyncio.sleep(5)
What this does: anonymity detection right here is value calling out particularly, as a result of the naive strategy (checking for an specific “is nameless” area) would not truly exist on this feed.
Wikipedia attributes nameless edits to the editor’s IP tackle as their username, so is_anonymous_user checks whether or not the username is formed like an IPv4 or IPv6 tackle as an alternative, which is how this detection genuinely works in manufacturing. parse_sse_line and to_event are each intentionally pure features with no community dependency, which is what lets me take a look at the parsing logic straight in opposition to real looking pattern payloads earlier than ever touching a dwell connection, catching an actual bug in an earlier draft of the anonymity test within the course of.
wikipedia_event_stream wraps the precise connection in a whereas True with a reconnect-and-sleep on any HTTP error, since an always-on service that dies on the primary dropped connection is not truly always-on.
# Construct Part 2: The Low cost Filter, Stage One
# src/filters.py
import time
from collections import defaultdict, deque
from typing import Non-compulsory
from .schemas import RecentChangeEvent, FilterSignal
from . import config
class EditVelocityTracker:
"""Tracks latest edit timestamps per consumer in a sliding window, so the
filter can catch rapid-fire modifying bursts, not simply single massive
deletions. Bounded reminiscence: previous customers get evicted, not stored perpetually."""
def __init__(self, window_seconds: int = config.EDIT_VELOCITY_WINDOW_SECONDS,
max_tracked: int = config.MAX_TRACKED_WINDOWS):
self.window_seconds = window_seconds
self.max_tracked = max_tracked
self._history: dict[str, deque[float]] = defaultdict(deque)
def record_and_count(self, consumer: str, timestamp: float) -> int:
"""Data this edit and returns what number of edits this consumer has
made throughout the trailing window, together with this one."""
historical past = self._history[user]
historical past.append(timestamp)
cutoff = timestamp - self.window_seconds
whereas historical past and historical past[0] < cutoff:
historical past.popleft()
if len(self._history) > self.max_tracked:
self._evict_oldest()
return len(historical past)
def _evict_oldest(self) -> None:
oldest_user = min(self._history, key=lambda u: self._history[u][-1] if self._history[u] else 0)
del self._history[oldest_user]
class Stage1Filter:
"""Wraps the rate tracker and the byte-removal test into one
go/fail determination per occasion."""
def __init__(self, tracker: Non-compulsory[EditVelocityTracker] = None):
self.tracker = tracker or EditVelocityTracker()
def consider(self, occasion: RecentChangeEvent) -> Non-compulsory[FilterSignal]:
"""Returns a FilterSignal if this occasion is definitely worth the LLM's time,
in any other case None, and None is the widespread case by a large margin."""
if occasion.is_bot:
return None # bot edits have their very own, separate overview path
recent_count = self.tracker.record_and_count(occasion.consumer, occasion.timestamp)
bytes_removed = occasion.bytes_removed
causes = []
if bytes_removed >= config.BYTES_REMOVED_THRESHOLD:
causes.append(f"eliminated {bytes_removed} bytes in a single edit")
if recent_count >= config.EDIT_VELOCITY_THRESHOLD:
causes.append(f"{recent_count} edits in {self.tracker.window_seconds}s")
if not causes:
return None
return FilterSignal(
occasion=occasion, bytes_removed=bytes_removed,
recent_edit_count=recent_count, cause="; ".be a part of(causes),
)
What this does: EditVelocityTracker retains a per-user deque of latest edit timestamps and trims something exterior the trailing window on each single name, which is what makes “5 edits in 2 minutes” an actual, repeatedly correct quantity moderately than an approximation.
The max_tracked eviction guard exists as a result of this dictionary would in any other case develop perpetually on a stream that by no means stops, a element that is straightforward to skip in a demo and costly to find in manufacturing. Stage1Filter.consider is the precise gate: it returns None, that means "not attention-grabbing," for the overwhelming majority of occasions, and solely builds a FilterSignal object when an actual threshold is crossed.
# Construct Part 3: The Native Reasoner, Stage Two
Solely alerts that survive Stage 1 attain right here. That is the place a strict schema and token streaming each matter.
# src/schemas.py
from __future__ import annotations
from pydantic import BaseModel, Area
class RecentChangeEvent(BaseModel):
wiki: str
consumer: str
title: str
is_anonymous: bool
is_bot: bool
old_length: int
new_length: int
timestamp: float
remark: str = ""
@property
def bytes_removed(self) -> int:
return max(0, self.old_length - self.new_length)
class FilterSignal(BaseModel):
occasion: RecentChangeEvent
bytes_removed: int
recent_edit_count: int
cause: str
class AgentVerdict(BaseModel):
"""The structured judgment we power the native mannequin to return.
Constraining this with a schema is what makes the output usable in
code moderately than simply readable by a human."""
is_likely_vandalism: bool
severity: int = Area(ge=1, le=5, description="1 = most likely high quality, 5 = excessive confidence vandalism")
reasoning: str
suggested_action: str
# src/agent.py
from typing import AsyncIterator
import ollama
from .schemas import FilterSignal, AgentVerdict
from . import config
SYSTEM_PROMPT = """You're a Wikipedia edit-monitoring assistant. You'll be
proven metadata about an edit that tripped an automatic filter for a big
deletion or unusually speedy modifying. Determine whether or not this seems like possible
vandalism or a official edit (a rewrite, a cleanup, a merge). Reply with
a JSON object matching the required schema. Be particular in your reasoning,
reference the precise numbers you got."""
def _build_user_prompt(sign: FilterSignal) -> str:
e = sign.occasion
return (
f"Web page: {e.title}n"
f"Consumer: {e.consumer} ({'nameless' if e.is_anonymous else 'registered'})n"
f"Bytes eliminated: {sign.bytes_removed}n"
f"Current edit rely by this consumer: {sign.recent_edit_count}n"
f"Edit abstract left by consumer: "{e.remark or '(none)'}"n"
f"Set off cause: {sign.cause}n"
)
async def evaluate_signal(sign: FilterSignal) -> AsyncIterator[str | AgentVerdict]:
"""Streams the mannequin's uncooked output because it's generated (str chunks), then
yields a last validated AgentVerdict as soon as the stream completes. The
caller tells the 2 aside with isinstance()."""
shopper = ollama.AsyncClient(host=config.OLLAMA_HOST)
stream = await shopper.chat(
mannequin=config.OLLAMA_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _build_user_prompt(signal)},
],
format=AgentVerdict.model_json_schema(),
stream=True,
choices={"temperature": 0.1},
)
full_text = ""
async for chunk in stream:
piece = chunk["message"]["content"]
full_text += piece
if piece:
yield piece # dwell token, for the broadcaster to ahead instantly
verdict = AgentVerdict.model_validate_json(full_text)
yield verdict
What this does: format=AgentVerdict.model_json_schema() is the element that makes this a senior-grade agent moderately than a chatbot with additional steps. Ollama enforces that schema straight on era, so the finished response is assured legitimate JSON matching AgentVerdict, not "normally legitimate JSON I then must defensively parse." evaluate_signal nonetheless streams each uncooked chunk out because it arrives, yielding plain strings for dwell show, and solely yields the ultimate, validated AgentVerdict object as soon as the total stream completes, which is what lets a related shopper watch the reasoning seem in actual time whereas the calling code downstream nonetheless will get a totally type-checked object to behave on.
# Construct Part 4: Broadcasting Reside Reasoning to Shoppers
# src/broadcaster.py
import asyncio
import json
from typing import AsyncIterator
class Broadcaster:
def __init__(self, max_queue_size: int = 100):
self._subscribers: set[asyncio.Queue] = set()
self.max_queue_size = max_queue_size
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=self.max_queue_size)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
async def publish(self, payload: dict) -> None:
"""Followers a payload out to each subscriber. A subscriber whose
queue is full will get the message dropped moderately than blocking the
complete pipeline, a gradual shopper ought to by no means be capable of decelerate
the agent's precise processing loop."""
message = json.dumps(payload)
for queue in checklist(self._subscribers):
strive:
queue.put_nowait(message)
besides asyncio.QueueFull:
proceed
async def stream(self) -> AsyncIterator[str]:
"""An async generator a caller can loop over to obtain messages,
used straight by the SSE endpoint in foremost.py."""
queue = self.subscribe()
strive:
whereas True:
message = await queue.get()
yield message
lastly:
self.unsubscribe(queue)
What this does: every related shopper will get its personal asyncio.Queue, and publish followers a message out to each queue independently utilizing put_nowait wrapped in a strive/besides, so one gradual or stalled subscriber degrades gracefully by silently dropping a message for that shopper as an alternative of ever blocking the loop that is truly processing dwell Wikipedia edits. That separation issues greater than it seems prefer it ought to: with out it, a single gradual browser tab may quietly stall your complete agent. One genuinely helpful factor testing this surfaced: stream() is an async generator, and async mills are lazy; the subscribe() name inside it would not truly run till one thing first calls __anext__() on it. In the actual FastAPI endpoint, it is a non-issue since iteration begins instantly, nevertheless it's precisely the form of subtlety that catches individuals writing their very own checks for this sample, and it caught mine on the primary try earlier than I fastened the take a look at itself.
# Wiring It Collectively
# src/foremost.py
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from sse_starlette.sse import EventSourceResponse
from .broadcaster import Broadcaster
from .filters import Stage1Filter
from .stream_source import wikipedia_event_stream
from .agent import evaluate_signal
from .schemas import AgentVerdict
logging.basicConfig(degree=logging.INFO)
logger = logging.getLogger("streaming-local-agent")
broadcaster = Broadcaster()
stage1 = Stage1Filter()
async def run_pipeline() -> None:
"""Consumes the dwell stream perpetually, runs stage 1 on each occasion,
and solely calls the LLM stage on occasions that survive it."""
async for occasion in wikipedia_event_stream():
sign = stage1.consider(occasion)
if sign is None:
proceed
logger.information("Stage 1 flagged: %s by %s (%s)", sign.occasion.title, sign.occasion.consumer, sign.cause)
await broadcaster.publish({"sort": "flagged", "title": sign.occasion.title, "cause": sign.cause})
strive:
async for merchandise in evaluate_signal(sign):
if isinstance(merchandise, str):
await broadcaster.publish({"sort": "token", "title": sign.occasion.title, "textual content": merchandise})
elif isinstance(merchandise, AgentVerdict):
await broadcaster.publish({
"sort": "verdict", "title": sign.occasion.title, "consumer": sign.occasion.consumer,
**merchandise.model_dump(),
})
besides Exception:
logger.exception("Stage 2 failed for %s, skipping this sign", sign.occasion.title)
@asynccontextmanager
async def lifespan(app: FastAPI):
activity = asyncio.create_task(run_pipeline())
logger.information("Streaming native agent began, looking ahead to edits...")
yield
activity.cancel()
logger.information("Streaming native agent shutting down")
app = FastAPI(title="Streaming Native Agent", lifespan=lifespan)
@app.get("/occasions")
async def occasions(request: Request):
async def event_generator():
async for message in broadcaster.stream():
if await request.is_disconnected():
break
yield message
return EventSourceResponse(event_generator())
@app.get("/well being")
def well being():
return {"standing": "okay"}
What this does: run_pipeline is the precise backbone of the entire service; the whole lot above is a supporting solid. It is wrapped in a strive/besides across the Stage 2 name particularly, so one malformed mannequin response or one Ollama hiccup logs an error and strikes on to the subsequent occasion as an alternative of silently killing the background activity and leaving the agent working however completely blind.
The lifespan context supervisor begins that pipeline as a background activity the second the app boots and cancels it cleanly on shutdown, the proper fashionable FastAPI sample moderately than the older @app.on_event decorators. The /occasions route is the place the whole lot converges: opening it streams each flagged, token, and verdict message dwell as newline-delimited SSE information, and checking request.is_disconnected() on each loop means a closed browser tab will get cleaned up as an alternative of leaking a queue perpetually.
// The best way to Run It
With Ollama put in and a mannequin pulled:
ollama pull llama3.1:8b
ollama serve # if it is not already working as a background service
Then, from the challenge root:
python -m venv venv
supply venv/bin/activate
pip set up -r necessities.txt
uvicorn src.foremost:app --reload
With that working, open a second terminal and watch the dwell feed:
curl -N http://localhost:8000/occasions
Or level a browser tab at http://localhost:8000/occasions straight; most browsers render an SSE stream as plain textual content arriving incrementally. Inside a couple of minutes on an lively wiki, you need to see flagged messages arrive as Stage 1 catches massive deletions or edit bursts, adopted by a stream of token messages because the native mannequin causes about it dwell, ending in a verdict message with a structured severity rating. Boring edits, the overwhelming majority of the site visitors, by no means seem in any respect, which is precisely the purpose.
# A Notice on Scaling This Up
The in-process asyncio.Queue broadcaster and the one background activity on this construct are the correct amount of infrastructure for one machine watching one stream. At actual manufacturing scale, watching a number of sources, working a number of client processes, surviving a service restart with out shedding in-flight occasions, the pure improve is swapping the direct stream connection and in-memory broadcaster for an actual message bus like Kafka sitting between the producer and the reasoning stage.
# Wrapping Up
The precise lesson beneath all of this code is not about Wikipedia, or Ollama, or FastAPI particularly, it is that effectivity stops being an optimization you bolt on later, the second an agent goes from "solutions when requested" to "at all times on." A chat agent that sits idle prices nothing. A streaming agent is, by definition, at all times consuming one thing, and each design alternative on this construct, the two-stage funnel, the bounded-memory eviction, the swish degradation on a gradual subscriber, the automated reconnect on a dropped connection, exists as a result of an always-on system that may't maintain itself indefinitely is not truly executed, regardless of how effectively it labored within the first 5 minutes you watched it run.
Shittu Olumide is a software program engineer and technical author captivated with leveraging cutting-edge applied sciences to craft compelling narratives, with a eager eye for element and a knack for simplifying complicated ideas. You too can discover Shittu on Twitter.
















