イベントストリーム消費とトークン逐次出力を実現するローカル AI エージェントの構築方法
本文の状態
日本語全文を表示中
詳細モードで約19分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
KDnuggets
この記事は、Wikipedia の編集ストリームを監視するローカル AI エージェントの構築方法を解説し、イベントトリガー型とトークンストリーミング出力の両方を実装する設計思想を示している。
Continue in AI NEW LAB
このニュースを、実務の判断につなげる
AI NEW LABで、試したことや先に確認したい条件を共有できます。まずはログインなしで読めます。
AI NEW LABで論点を見るAI深層分析を開く2026年8月13日 23:34
AI深層分析
キーポイント
Streaming の二重定義と統合
AI エージェントにおける「ストリーミング」には、入力イベントの継続的消費と出力トークンの逐次生成という2つの異なる意味があり、有用な常時稼働型エージェントには両方の実装が必要であると指摘する。
環境認識型エージェントの実装
人間のメッセージを待つのではなく、ストリーム上のイベントで起動される「環境認識型(ambient)エージェント」の概念に基づき、Wikipedia の公開編集フィードを監視する具体的な構築例を示す。
計算コスト削減のための2段階フィルタ
ストリームが大量のデータを押し付けるため、すべての編集を直接LLMに送ると処理能力が枯渇するため、まず軽量なPython計算でフィルタリングする2段階の漏斗設計を採用する。
ローカル環境での完全動作
API キーやクラウドアカウントを一切不要とし、Ollama とローカルの LLM(例:Llama 3.1)のみを使用して、ユーザーの端末上で完全に動作する構成を提示する。
コスト効率の高い2段階フィルタリング
すべてのイベントにまず安価なPythonの計算でフィルタをかけ、興味深いイベントのみを後段のローカルLLMに送ることで、計算リソースと遅延を防ぐ。
重要な引用
"Streaming" gets used in two different ways when people talk about AI agents, and most tutorials only build one of them.
The fix is a two-stage funnel, and it's the single most important idea in this build
Every line of code below was written, then actually tested, before it went into this article.
cheap filters up front, expensive reasoning reserved for the candidates that survive
編集コメントを表示
編集コメント
本記事は、クラウド依存や高額な API コストを避けつつ、ローカル環境で動作する高度なAIエージェントの設計思想を明確に示している。特に、大量のリアルタイムデータを処理するための効率的なフィルタリング手法は、実運用におけるスケーラビリティ課題に対する有効な解決策となる。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。

**
AI エージェントの文脈で「ストリーミング」という言葉は、実は 2 つの異なる意味で使われることが多く、多くのチュートリアルではその片方しか実装していません。一方の意味は、エージェントが人間のメッセージを待つのではなく、イベントのライブストリームを継続的に消費する状態を指します。もう一方は、エージェント自身の出力が長い待機時間の後に一度に現れるのではなく、トークン単位で順次流れ出ることを意味します。
今回の構築では、これら 2 つの機能を意図的に両方実装しています。それぞれが異なる課題を解決するためであり、真に有用な常時稼働型のエージェントには、この 2 つの要素が不可欠だからです。
ここで参考になるのは、「アンビエント・エージェント」として知られる概念です。LangChain はこれを「人間のメッセージではなくイベントによってトリガーされるもの」と定義しており、Google の Agent Development Kit もインフラの観点から同様に説明しています。つまり、リクエストとレスポンスを待つのではなく、ストリームに何かが到着したことで起動するエージェントという考え方です。
今回の構築で想定しているシナリオは具体的かつ現実的なものです。API キーが不要な Wikipedia の公開編集フィードを監視し、その中からいたずら書き(ヴァンダリズム)と思われる編集を見分けるローカルエージェントです。すべて Ollama を使って、あなたのマシン上で完結して動作します。
以下のコードはすべて実際に記述され、記事に掲載される前にテスト済みです。
必要な環境は以下の通りです:
- Python 3.11 以降
ローカル環境に Ollama をインストールし、必要なモデルをプルしてください(例:ollama pull llama3.1:8b。構造化された JSON 出力に対応するモデルであれば何でも可)。
pip install fastapi uvicorn httpx pydantic ollama sse-starlette
API キーもクラウドアカウントも不要です。発生するのは電気代のみです。このサービスが外部と通信するのは、認証を必要としない Wikipedia の公開イベントストリームエンドポイントへの接続だけです。
設計において最も重要な決断一つ
Wikipedia の編集ストリームは細い流れなどではありません。活発な日であれば、全言語版を合わせても毎秒複数の編集が押し寄せます。これらすべてを言語モデルに渡すと、二つの問題が発生します。まず、そもそも興味深いはずのない編集に対して機械の計算リソースが無駄になります。次に、監視すべきライブストリームからエージェントが取り残され、「常時稼働」を目指す本末転倒な状態に陥ってしまいます。
解決策は二段階の漏斗(ファネル)です。これが今回の構築において最も重要なアイデアとなります。
- 第一段階は、モデルを一切使わない単純な Python の数値計算です。すべてのイベントに対して実行されます。「この編集で何バイト削除されたか」「直近の数分間にそのユーザーは何件の編集を行ったか」などをチェックします。編集の圧倒的多数は退屈なものですが、退屈であるかどうかを検出するのは無料(コストゼロ)なのです
2 段階目のローカル LLM は、1 段階目で閾値に引っかかったごく一部のイベントのみで起動します。これは優れた監視システムすべてに通じる原則です。手前には安価なフィルタを置き、残った候補に対してのみ高コストな推論を行います。

// フォルダ構成
streaming-local-agent/
├── src/
│ ├── __init__.py
│ ├── config.py
│ ├── schemas.py
│ ├── stream_source.py
│ ├── filters.py
│ ├── agent.py
│ ├── broadcaster.py
│ └── main.py
├── tests/
│ └── test_filters.py
├── requirements.txt
└── .env.example
各ファイルは上記のパイプラインの 1 つのステージに正確に対応しており、全体を推論しやすく、個別にテストしやすくしています。この記事で実際に構築した際も、まさにこの方針を採用しました。
# セクション 1 の構築:イベントストリームコンシューマー
**
Wikipedia の EventStreams サービスは、通常の HTTP を通じて Server-Sent Events 形式で編集情報をプッシュします。キーは不要です。通常の GET リクエストをオープン状態に保つだけで、それ以上のハンドシェイクも必要ありません。**
# src/stream_source.py
import asyncio
import json
import re
import time
from typing import AsyncIterator, Optional
import httpx
from .schemas import RecentChangeEvent
from . import config
# Wikipedia doesn't send an explicit "is this user anonymous" flag on this
# stream; anonymous edits are attributed to the editor's IP address instead
# of a username, so an IP-shaped username is how you detect one in practice.
_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) -> Optional[dict]:
"""SSE frames data as lines prefixed with 'data: '. Comment lines
(starting with ':') and blank keep-alive lines are common on this
feed and should be silently ignored, not treated as errors."""
if not line or line.startswith(":"):
return None
if line.startswith("data:"):
raw = line[len("data:"):].strip()
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
return None
def to_event(raw: dict) -> Optional[RecentChangeEvent]:
"""Converts a raw Wikimedia payload into our normalized schema.
Returns None for event types we don't care about rather than
raising, since a stream this high-volume constantly includes shapes
we're not watching for."""
if raw.get("type") != "edit":
return None
length = raw.get("length") or {}
if "old" not in length or "new" not in length:
return None
return RecentChangeEvent(
wiki=raw.get("wiki", "unknown"),
user=raw.get("user", "unknown"),
title=raw.get("title", "unknown"),
is_anonymous=is_anonymous_user(raw.get("user", "")),
is_bot=raw.get("bot", False),
old_length=length["old"],
new_length=length["new"],
timestamp=raw.get("timestamp", time.time()),
comment=raw.get("comment", "") or "",
)
async def wikipedia_event_stream() -> AsyncIterator[RecentChangeEvent]:
"""The live async generator used by main.py. Reconnects automatically
on a dropped connection rather than letting the whole service die
because of one network hiccup, which matters a lot for something
meant to run unattended."""
while True:
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", config.WIKIPEDIA_STREAM_URL) as response:
async for line in response.aiter_lines():
raw = parse_sse_line(line)
if raw is None:
continue
if raw.get("wiki") not in config.WATCHED_WIKIS:
continue
event = to_event(raw)
if event is not None:
yield event
except httpx.HTTPError:
await asyncio.sleep(5)ここで重要なのは匿名性の検出について言及しておくことです。 素朴なアプローチとして「is anonymous」という明示的なフィールドがあるか確認する方法がありますが、このフィードにはそのようなフィールドは存在しません。
Wikipedia では、匿名編集者の編集履歴はユーザー名として IP アドレスに紐付けられるため、is_anonymous_user はユーザー名が IPv4 または IPv6 の形式になっているかを確認します。これが本番環境での実際の検出ロジックです。
parse_sse_line と to_event は、ネットワーク依存を持たない純粋な関数として意図的に設計されています。これにより、実稼働接続に触れる前に、現実的なサンプルペイロードに対して解析ロジックを直接テストすることが可能になります。その過程で、匿名チェックの初期版で見つかった実際のバグも早期に発見できました。
wikipedia_event_stream は、接続が切れた際に再試行と待機を行う while True ループで実際の接続をラップしています。これは、一度の切断ですぐに停止する常時稼働サービスは、実際には常時稼働ではないからです。
# Build Section 2: The Cheap Filter, Stage One
# src/filters.py
import time
from collections import defaultdict, deque
from typing import Optional
from .schemas import RecentChangeEvent, FilterSignal
from . import config
class EditVelocityTracker:
"""Tracks recent edit timestamps per user in a sliding window, so the
filter can catch rapid-fire editing bursts, not just single large
deletions. Bounded memory: old users get evicted, not kept forever."""
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, user: str, timestamp: float) -> int:
"""Records this edit and returns how many edits this user has
made within the trailing window, including this one."""
history = self._history[user]
history.append(timestamp)
cutoff = timestamp - self.window_seconds
while history and history[0] < cutoff:
history.popleft()
if len(self._history) > self.max_tracked:
self._evict_oldest()
return len(history)
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 velocity tracker and the byte-removal check into one
pass/fail decision per event."""
def __init__(self, tracker: Optional[EditVelocityTracker] = None):
self.tracker = tracker or EditVelocityTracker()
def evaluate(self, event: RecentChangeEvent) -> Optional[FilterSignal]:
"""Returns a FilterSignal if this event is worth the LLM's time,
otherwise None, and None is the common case by a wide margin."""
if event.is_bot:
return None # bot edits have their own, separate review path
recent_count = self.tracker.record_and_count(event.user, event.timestamp)
bytes_removed = event.bytes_removed
reasons = []
if bytes_removed >= config.BYTES_REMOVED_THRESHOLD:
reasons.append(f"removed {bytes_removed} bytes in one edit")
if recent_count >= config.EDIT_VELOCITY_THRESHOLD:
reasons.append(f"{recent_count} edits in {self.tracker.window_seconds}s")
if not reasons:
return None
return FilterSignal(
event=event, bytes_removed=bytes_removed,
recent_edit_count=recent_count, reason="; ".join(reasons),
)この仕組みの役割: EditVelocityTracker は、ユーザーごとに直近の編集時刻を保持する双端キュー(deque)を持ち、呼び出しのたびに末尾のウィンドウ範囲外をトリミングします。これにより、「5 分以内に 5 回の編集」という指標が近似値ではなく、常に正確なリアルタイムの数値として機能しています。
max_tracked という退避ガードが存在するのは、ストリームが止まらない限りこの辞書が無限に成長してしまうのを防ぐためです。デモでは見落としがちですが、本番環境でこれに気づくとコストがかさみます。実際のゲートは Stage1Filter.evaluate です。これはイベントの大半に対して `None(つまり「興味なし」)を返し、実際に閾値を超えた場合のみ FilterSignal` オブジェクトを構築します。
# Build Section 3: The Local Reasoner, Stage Two
**
Stage 1 を通過した信号だけがここへ到達します。ここでは厳格なスキーマとトークンのストリーミングの両方が重要です。
# src/schemas.py
from __future__ import annotations
from pydantic import BaseModel, Field
class RecentChangeEvent(BaseModel):
wiki: str
user: str
is_anonymous: bool
is_bot: bool
old_length: int
new_length: int
timestamp: float
comment: str = ""
@property
def bytes_removed(self) -> int:
return max(0, self.old_length - self.new_length)
class FilterSignal(BaseModel):
event: RecentChangeEvent
bytes_removed: int
recent_edit_count: int
reason: str
class AgentVerdict(BaseModel):
"""The structured judgment we force the local model to return.
Constraining this with a schema is what makes the output usable in
code rather than just readable by a human."""
is_likely_vandalism: bool
severity: int = Field(ge=1, le=5, description="1 = probably fine, 5 = high 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 are a Wikipedia edit-monitoring assistant. You will be \
shown metadata about an edit that tripped an automated filter for a large \
deletion or unusually rapid editing. Decide whether this looks like likely \
vandalism or a legitimate edit (a rewrite, a cleanup, a merge). Respond with \
a JSON object matching the required schema. Be specific in your reasoning, \
reference the actual numbers you were given."""
def _build_user_prompt(signal: FilterSignal) -> str:
e = signal.event
return (
f"Page: {e.title}\n"
f"User: {e.user} ({'anonymous' if e.is_anonymous else 'registered'})\n"
f"Bytes removed: {signal.bytes_removed}\n"
f"Recent edit count by this user: {signal.recent_edit_count}\n"
f"Edit summary left by user: \"{e.comment or '(none)'}\"\n"
f"Trigger reason: {signal.reason}\n"
)
async def evaluate_signal(signal: FilterSignal) -> AsyncIterator[str | AgentVerdict]:
"""Streams the model's raw output as it's generated (str chunks), then
yields a final validated AgentVerdict once the stream completes. The
caller tells the two apart with isinstance()."""
client = ollama.AsyncClient(host=config.OLLAMA_HOST)
stream = await client.chat(
model=config.OLLAMA_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _build_user_prompt(signal)},
],
format=AgentVerdict.model_json_schema(),
stream=True,
options={"temperature": 0.1},
)
full_text = ""
async for chunk in stream:
piece = chunk["message"]["content"]
full_text += piece
if piece:
yield piece # live token, for the broadcaster to forward immediately
verdict = AgentVerdict.model_validate_json(full_text)
yield verdictこの仕組みの肝となるのは、format=AgentVerdict.model_json_schema() という設定です。これにより、単なる追加ステップ付きのチャットボットではなく、本格的なエージェントとして機能します。Ollama は生成プロセスにおいて直接このスキーマを強制するため、完成した応答は常に AgentVerdict に合致する有効な JSON として保証されます。「通常は有効だが、防御的なパース処理が必要になる JSON」といった不安定な出力にはなりません。
一方、evaluate_signal は到着した生データチャンクをそのままストリーミングし、ライブ表示用に平文の文字列として逐次返却します。そして、ストリーミングが完了した時点で初めて、検証済みの AgentVerdict オブジェクトを返却します。これにより、接続されたクライアント側では推論プロセスがリアルタイムで可視化される一方で、呼び出し元のコードは動作可能な完全な型チェック済みオブジェクトを受け取ることができます。
セクション 4:生きている推論をクライアントへブロードキャストする
# 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:
"""Fans a payload out to every subscriber. A subscriber whose
queue is full gets the message dropped rather than blocking the
whole pipeline, a slow client should never be able to slow down
the agent's actual processing loop."""
message = json.dumps(payload)
for queue in list(self._subscribers):
try:
queue.put_nowait(message)
except asyncio.QueueFull:
continue
async def stream(self) -> AsyncIterator[str]:
"""An async generator a caller can loop over to receive messages,
used directly by the SSE endpoint in main.py."""
queue = self.subscribe()
try:
while True:
message = await queue.get()
yield message
finally:
self.unsubscribe(queue)何をするものか: 接続された各クライアントには個別の asyncio.Queue が割り当てられ、publish メソッドは put_nowait でラップしたメッセージを、すべてのキューに独立して配信します。
try/except ブロック内で、1 つでも遅延または停止した購読者がいれば、全体の処理速度が低下します。
代わりに、処理中のループをブロックすることなく、そのクライアントに対して静かにメッセージをドロップすることで、優雅に処理を終了します。
この分離が重要なのは、見た目以上に深刻な意味を持つからです。これを怠ると、単に動作が遅いブラウザのタブ一つで、エージェント全体が静かに停止してしまう可能性があります。
この仕組みを検証する過程で見つかった、非常に有用な事実があります。それは stream() が非同期ジェネレーター(async generator)であり、非同期ジェネレーターは遅延評価(lazy)であるという点です。つまり、subscribe() 内の処理が実際に実行されるのは、誰かが最初にそのオブジェクトに対して __anext__() を呼び出した時だけです。
実際の FastAPI エンドポイントでは、イテレーションが即座に開始されるためこの問題は発生しませんが、このパターン用に独自テストを書く人々を陥れるような微妙な点であり、私もテスト自体を修正する前に最初の試行で引っかかりました。
# 全体をつなぎ合わせる
# src/main.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(level=logging.INFO)
logger = logging.getLogger("streaming-local-agent")
broadcaster = Broadcaster()
stage1 = Stage1Filter()
async def run_pipeline() -> None:
"""Consumes the live stream forever, runs stage 1 on every event,
and only calls the LLM stage on events that survive it."""
async for event in wikipedia_event_stream():
signal = stage1.evaluate(event)
if signal is None:
continue
logger.info("Stage 1 flagged: %s by %s (%s)", signal.event.title, signal.event.user, signal.reason)
await broadcaster.publish({"type": "flagged", "title": signal.event.title, "reason": signal.reason})
try:
async for item in evaluate_signal(signal):
if isinstance(item, str):
await broadcaster.publish({"type": "token", "title": signal.event.title, "text": item})
elif isinstance(item, AgentVerdict):
await broadcaster.publish({
"type": "verdict", "title": signal.event.title, "user": signal.event.user,
**item.model_dump(),
})
except Exception:
logger.exception("Stage 2 failed for %s, skipping this signal", signal.event.title)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(run_pipeline())
logger.info("Streaming local agent started, watching for edits...")
yield
task.cancel()
logger.info("Streaming local agent shutting down")
app = FastAPI(title="Streaming Local Agent", lifespan=lifespan)
@app.get("/events")
async def events(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("/health")
def health():
return {"status": "ok"}これが何をするか: run_pipeline は本サービス全体の骨格となる実体です。それより上の部分はすべてサポート役です。Stage 2 の呼び出しの周りに try/except を設けることで、モデルからの応答が一つでも不正であったり Ollama で一時的な不具合が発生したりした場合にエラーをログ出力し、次のイベントへ進むようにしています。これにより、バックグラウンドタスクが静かに終了してしまい、エージェントは稼働したまま永久的に見えなくなるという事態を防いでいます。
この lifespan コンテキストマネージャーは、アプリケーション起動時にパイプラインをバックグラウンドタスクとして開始し、シャットダウン時には適切に停止させる仕組みです。これは、従来の @app.on_event デコレータに代わる、現代的な FastAPI の標準的なパターンとなります。
/events エンドポイントは、すべての情報が集約される場所です。このエンドポイントを開くと、flagged、token、verdict の各メッセージが、改行区切りの SSE データとしてリアルタイムにストリーミングされます。
ループのたびに request.is_disconnected() をチェックすると、接続が切れた状態になります。
ブラウザのタブは、キューが永遠にリークする代わりにクリーンアップされます。
// 実行方法
Ollama がインストールされ、モデルがプルされている場合:
次に、プロジェクトのルートディレクトリから以下を実行します。
ollama pull llama3.1:8b
ollama serve # if it isn't already running as a background serviceその後、プロジェクトのルートから:
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn src.main:app --reloadこれで実行中になったら、別のターミナルを開いてライブフィードを確認します。
curl -N http://localhost:8000/events
または、ブラウザのタブを直接 http://localhost:8000/events に向けるだけで、多くのブラウザは SSE ストリームを逐次到着するプレーンテキストとしてレンダリングします。アクティブなウィキ上で数分待てば、まず Stage 1 が大規模な削除や編集バーストを検知して flagged メッセージが到着し、その後、ローカルモデルがリアルタイムで推論を行う過程を示す token メッセージのストリームが続きます。
構造化された重大度スコアを含む verdict メッセージで。退屈な編集、つまりトラフィックの大半は、全く表示されません。それがまさに狙いなのです。
# 規模拡大に関する注記
このビルドにおけるインプロセスの asyncio.Queue ブロードキャスターと単一のバックグラウンドタスクは、1 台のマシンで 1 つのストリームを監視する規模には最適なインフラストラクチャです。しかし、実際の生産環境では複数のソースを監視し、複数のコンシューマープロセスを実行し、サービス再起動時に進行中のイベントを失わずに耐える必要があります。その場合の自然なアップグレードは、プロデューサーと推論ステージの間に Kafka などの本格的なメッセージバスを配置し、直接ストリーム接続やメモリ上のブロードキャスターに代えることです。
まとめ
このコード全体が示す本当の教訓は、Wikipedia や Ollama、FastAPI といった特定の技術にあるわけではありません。重要なのは、エージェントが「質問された時に回答する」ものから「常時稼働する」ものへと移行した瞬間に、効率化は後付けの最適化では済まされなくなるという点です。チャットエージェントはアイドル状態であればコストはかかりませんが、ストリーミングエージェントは定義上常に何かを消費し続けています。このビルドにおけるすべての設計選択——2 段階のファネル、有界メモリによる_eviction_(退避)、低速なサブスクライバーへのグレースフルデグラデーション、切断された接続からの自動再接続——は、常時稼働するシステムが自立的に持続できなければ、最初の数分間うまく動作していても「完成した」とは言えないからです。
ソフトウェアエンジニアであり技術ライターであるShittu Olumide氏は、最先端の技術を駆使して魅力的な物語を紡ぐことに情熱を注いでいます。細部へのこだわりが強く、複雑な概念をわかりやすく解説する能力に長けています。また、Twitter でも活動しています。
原文を表示

**
"Streaming**" gets used in two different ways when people talk about AI agents, and most tutorials only build one of them. Sometimes it means the agent consumes a live stream of events instead of waiting for someone to type a message. Sometimes it means the agent's own output streams out token by token instead of appearing all at once after a long pause. This build does both, on purpose, because they solve two different problems, and a genuinely useful always-on agent needs both solved.
The framing worth borrowing here comes from what's usually called an ambient agent, one LangChain describes as triggered by events rather than by a human message, and Google's Agent Development Kit describes from the infrastructure side the same way: agents woken by something arriving on a stream, not sitting behind a request-response call. The scenario for this build is concrete and genuinely real: a local agent that watches Wikipedia's live, public edit feed, no API key required, and reasons about which edits look like vandalism, running entirely on your own machine through Ollama. Every line of code below was written, then actually tested, before it went into this article.
These are your prerequisites:
- Python 3.11 or newer
- Ollama installed locally, with a model pulled (ollama pull llama3.1:8b, or any model that supports structured JSON output)
- pip install fastapi uvicorn httpx pydantic ollama sse-starlette
- No API keys, no cloud account, and no cost beyond your own electricity. The only outbound network connection this service makes is to Wikipedia's public EventStreams endpoint, which requires no authentication
# The One Design Decision That Matters
**
Wikipedia's edit stream isn't a trickle. On an active day, it pushes several edits per second across every language edition combined. Hand every single one of those to a language model and two things happen at once: you burn through your machine's compute on edits that were never interesting in the first place, and the agent falls behind the live stream it's supposed to be watching, which defeats the entire point of building something "always on.**"
The fix is a two-stage funnel, and it's the single most important idea in this build:
- Stage one is cheap, plain Python math that runs on every event with no model involved at all: how many bytes did this edit remove, how many edits has this user made in the last couple of minutes? The overwhelming majority of edits are boring, and boring is free to detect
- Stage two, the actual local LLM, only wakes up for the small fraction of events that trip a threshold in stage one. This is the same principle behind any good monitoring system: cheap filters up front, expensive reasoning reserved for the candidates that survive

// Folder Structure
streaming-local-agent/
├── src/
│ ├── __init__.py
│ ├── config.py
│ ├── schemas.py
│ ├── stream_source.py
│ ├── filters.py
│ ├── agent.py
│ ├── broadcaster.py
│ └── main.py
├── tests/
│ └── test_filters.py
├── requirements.txt
└── .env.example
Each file maps to exactly one stage of the pipeline described above, which makes the whole thing easy to reason about and easy to test in isolation, which is exactly how it was actually built for this article.
# Build Section 1: The Event Stream Consumer
**
Wikipedia's EventStreams service pushes edits as Server-Sent Events over plain HTTP. No key, no handshake beyond an ordinary GET** request that stays open.
# src/stream_source.py
import asyncio
import json
import re
import time
from typing import AsyncIterator, Optional
import httpx
from .schemas import RecentChangeEvent
from . import config
# Wikipedia doesn't send an explicit "is this user anonymous" flag on this
# stream; anonymous edits are attributed to the editor's IP address instead
# of a username, so an IP-shaped username is how you detect one in practice.
_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) -> Optional[dict]:
"""SSE frames data as lines prefixed with 'data: '. Comment lines
(starting with ':') and blank keep-alive lines are common on this
feed and should be silently ignored, not treated as errors."""
if not line or line.startswith(":"):
return None
if line.startswith("data:"):
raw = line[len("data:"):].strip()
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
return None
def to_event(raw: dict) -> Optional[RecentChangeEvent]:
"""Converts a raw Wikimedia payload into our normalized schema.
Returns None for event types we don't care about rather than
raising, since a stream this high-volume constantly includes shapes
we're not watching for."""
if raw.get("type") != "edit":
return None
length = raw.get("length") or {}
if "old" not in length or "new" not in length:
return None
return RecentChangeEvent(
wiki=raw.get("wiki", "unknown"),
user=raw.get("user", "unknown"),
title=raw.get("title", "unknown"),
is_anonymous=is_anonymous_user(raw.get("user", "")),
is_bot=raw.get("bot", False),
old_length=length["old"],
new_length=length["new"],
timestamp=raw.get("timestamp", time.time()),
comment=raw.get("comment", "") or "",
)
async def wikipedia_event_stream() -> AsyncIterator[RecentChangeEvent]:
"""The live async generator used by main.py. Reconnects automatically
on a dropped connection rather than letting the whole service die
because of one network hiccup, which matters a lot for something
meant to run unattended."""
while True:
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", config.WIKIPEDIA_STREAM_URL) as response:
async for line in response.aiter_lines():
raw = parse_sse_line(line)
if raw is None:
continue
if raw.get("wiki") not in config.WATCHED_WIKIS:
continue
event = to_event(raw)
if event is not None:
yield event
except httpx.HTTPError:
await asyncio.sleep(5)What this does: anonymity detection here is worth calling out specifically, because the naive approach (checking for an explicit "is anonymous" field) doesn't actually exist on this feed.
Wikipedia attributes anonymous edits to the editor's IP address as their username, so is_anonymous_user checks whether the username is shaped like an IPv4 or IPv6 address instead, which is how this detection genuinely works in production. parse_sse_line and to_event are both deliberately pure functions with no network dependency, which is what lets me test the parsing logic directly against realistic sample payloads before ever touching a live connection, catching a real bug in an earlier draft of the anonymity check in the process.
wikipedia_event_stream wraps the actual connection in a while True with a reconnect-and-sleep on any HTTP error, since an always-on service that dies on the first dropped connection isn't actually always-on.
# Build Section 2: The Cheap Filter, Stage One
# src/filters.py
import time
from collections import defaultdict, deque
from typing import Optional
from .schemas import RecentChangeEvent, FilterSignal
from . import config
class EditVelocityTracker:
"""Tracks recent edit timestamps per user in a sliding window, so the
filter can catch rapid-fire editing bursts, not just single large
deletions. Bounded memory: old users get evicted, not kept forever."""
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, user: str, timestamp: float) -> int:
"""Records this edit and returns how many edits this user has
made within the trailing window, including this one."""
history = self._history[user]
history.append(timestamp)
cutoff = timestamp - self.window_seconds
while history and history[0] < cutoff:
history.popleft()
if len(self._history) > self.max_tracked:
self._evict_oldest()
return len(history)
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 velocity tracker and the byte-removal check into one
pass/fail decision per event."""
def __init__(self, tracker: Optional[EditVelocityTracker] = None):
self.tracker = tracker or EditVelocityTracker()
def evaluate(self, event: RecentChangeEvent) -> Optional[FilterSignal]:
"""Returns a FilterSignal if this event is worth the LLM's time,
otherwise None, and None is the common case by a wide margin."""
if event.is_bot:
return None # bot edits have their own, separate review path
recent_count = self.tracker.record_and_count(event.user, event.timestamp)
bytes_removed = event.bytes_removed
reasons = []
if bytes_removed >= config.BYTES_REMOVED_THRESHOLD:
reasons.append(f"removed {bytes_removed} bytes in one edit")
if recent_count >= config.EDIT_VELOCITY_THRESHOLD:
reasons.append(f"{recent_count} edits in {self.tracker.window_seconds}s")
if not reasons:
return None
return FilterSignal(
event=event, bytes_removed=bytes_removed,
recent_edit_count=recent_count, reason="; ".join(reasons),
)What this does: EditVelocityTracker keeps a per-user deque of recent edit timestamps and trims anything outside the trailing window on every single call, which is what makes "5 edits in 2 minutes" a real, continuously accurate number rather than an approximation.
The max_tracked eviction guard exists because this dictionary would otherwise grow forever on a stream that never stops, a detail that's easy to skip in a demo and expensive to discover in production. Stage1Filter.evaluate is the actual gate: it returns `None, meaning "**not interesting,**" for the overwhelming majority of events, and only builds a FilterSignal` object when a real threshold is crossed.
# Build Section 3: The Local Reasoner, Stage Two
**
Only signals that survive Stage 1 reach here. This is where a strict schema and token streaming both matter.
# src/schemas.py
from __future__ import annotations
from pydantic import BaseModel, Field
class RecentChangeEvent(BaseModel):
wiki: str
user: str
is_anonymous: bool
is_bot: bool
old_length: int
new_length: int
timestamp: float
comment: str = ""
@property
def bytes_removed(self) -> int:
return max(0, self.old_length - self.new_length)
class FilterSignal(BaseModel):
event: RecentChangeEvent
bytes_removed: int
recent_edit_count: int
reason: str
class AgentVerdict(BaseModel):
"""The structured judgment we force the local model to return.
Constraining this with a schema is what makes the output usable in
code rather than just readable by a human."""
is_likely_vandalism: bool
severity: int = Field(ge=1, le=5, description="1 = probably fine, 5 = high 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 are a Wikipedia edit-monitoring assistant. You will be \
shown metadata about an edit that tripped an automated filter for a large \
deletion or unusually rapid editing. Decide whether this looks like likely \
vandalism or a legitimate edit (a rewrite, a cleanup, a merge). Respond with \
a JSON object matching the required schema. Be specific in your reasoning, \
reference the actual numbers you were given."""
def _build_user_prompt(signal: FilterSignal) -> str:
e = signal.event
return (
f"Page: {e.title}\n"
f"User: {e.user} ({'anonymous' if e.is_anonymous else 'registered'})\n"
f"Bytes removed: {signal.bytes_removed}\n"
f"Recent edit count by this user: {signal.recent_edit_count}\n"
f"Edit summary left by user: \"{e.comment or '(none)'}\"\n"
f"Trigger reason: {signal.reason}\n"
)
async def evaluate_signal(signal: FilterSignal) -> AsyncIterator[str | AgentVerdict]:
"""Streams the model's raw output as it's generated (str chunks), then
yields a final validated AgentVerdict once the stream completes. The
caller tells the two apart with isinstance()."""
client = ollama.AsyncClient(host=config.OLLAMA_HOST)
stream = await client.chat(
model=config.OLLAMA_MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": _build_user_prompt(signal)},
],
format=AgentVerdict.model_json_schema(),
stream=True,
options={"temperature": 0.1},
)
full_text = ""
async for chunk in stream:
piece = chunk["message"]["content"]
full_text += piece
if piece:
yield piece # live token, for the broadcaster to forward immediately
verdict = AgentVerdict.model_validate_json(full_text)
yield verdictWhat this does: format=AgentVerdict.model_json_schema() is the detail that makes this a senior-grade agent rather than a chatbot with extra steps. Ollama enforces that schema directly on generation, so the completed response is guaranteed valid JSON matching AgentVerdict, not "usually valid JSON I then have to defensively parse.**" evaluate_signal still streams every raw chunk out as it arrives, yielding plain strings for live display, and only yields the final, validated AgentVerdict object once the full stream completes, which is what lets a connected client watch the reasoning appear in real time while the calling code downstream still gets a fully type-checked object to act on.
# Build Section 4: Broadcasting Live Reasoning to Clients
# 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:
"""Fans a payload out to every subscriber. A subscriber whose
queue is full gets the message dropped rather than blocking the
whole pipeline, a slow client should never be able to slow down
the agent's actual processing loop."""
message = json.dumps(payload)
for queue in list(self._subscribers):
try:
queue.put_nowait(message)
except asyncio.QueueFull:
continue
async def stream(self) -> AsyncIterator[str]:
"""An async generator a caller can loop over to receive messages,
used directly by the SSE endpoint in main.py."""
queue = self.subscribe()
try:
while True:
message = await queue.get()
yield message
finally:
self.unsubscribe(queue)What this does: each connected client gets its own asyncio.Queue, and publish fans a message out to every queue independently using put_nowait wrapped in a try/except, so one slow or stalled subscriber degrades gracefully by silently dropping a message for that client instead of ever blocking the loop that's actually processing live Wikipedia edits. That separation matters more than it looks like it should: without it, a single slow browser tab could quietly stall the entire agent. One genuinely useful thing testing this surfaced: stream() is an async generator, and async generators are lazy; the subscribe() call inside it doesn't actually run until something first calls __anext__() on it. In the real FastAPI endpoint, this is a non-issue since iteration starts immediately, but it's exactly the kind of subtlety that catches people writing their own tests for this pattern, and it caught mine on the first attempt before I fixed the test itself.
# Wiring It Together
# src/main.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(level=logging.INFO)
logger = logging.getLogger("streaming-local-agent")
broadcaster = Broadcaster()
stage1 = Stage1Filter()
async def run_pipeline() -> None:
"""Consumes the live stream forever, runs stage 1 on every event,
and only calls the LLM stage on events that survive it."""
async for event in wikipedia_event_stream():
signal = stage1.evaluate(event)
if signal is None:
continue
logger.info("Stage 1 flagged: %s by %s (%s)", signal.event.title, signal.event.user, signal.reason)
await broadcaster.publish({"type": "flagged", "title": signal.event.title, "reason": signal.reason})
try:
async for item in evaluate_signal(signal):
if isinstance(item, str):
await broadcaster.publish({"type": "token", "title": signal.event.title, "text": item})
elif isinstance(item, AgentVerdict):
await broadcaster.publish({
"type": "verdict", "title": signal.event.title, "user": signal.event.user,
**item.model_dump(),
})
except Exception:
logger.exception("Stage 2 failed for %s, skipping this signal", signal.event.title)
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(run_pipeline())
logger.info("Streaming local agent started, watching for edits...")
yield
task.cancel()
logger.info("Streaming local agent shutting down")
app = FastAPI(title="Streaming Local Agent", lifespan=lifespan)
@app.get("/events")
async def events(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("/health")
def health():
return {"status": "ok"}What this does: run_pipeline is the actual spine of the whole service; everything above is a supporting cast. It's wrapped in a try/except around the Stage 2 call specifically, so one malformed model response or one Ollama hiccup logs an error and moves on to the next event instead of silently killing the background task and leaving the agent running but permanently blind.
The lifespan context manager starts that pipeline as a background task the moment the app boots and cancels it cleanly on shutdown, the correct modern FastAPI pattern rather than the older @app.on_event decorators. The /events route is where everything converges: opening it streams every flagged, token, and verdict message live as newline-delimited SSE data, and checking request.is_disconnected() on every loop means a closed browser tab gets cleaned up instead of leaking a queue forever.
// How to Run It
With Ollama installed and a model pulled:
ollama pull llama3.1:8b
ollama serve # if it isn't already running as a background serviceThen, from the project root:
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn src.main:app --reloadWith that running, open a second terminal and watch the live feed:
curl -N http://localhost:8000/eventsOr point a browser tab at http://localhost:8000/events directly; most browsers render an SSE stream as plain text arriving incrementally. Within a few minutes on an active wiki, you should see flagged messages arrive as Stage 1 catches large deletions or edit bursts, followed by a stream of token messages as the local model reasons about it live, ending in a verdict message with a structured severity score. Boring edits, the vast majority of the traffic, never appear at all, which is exactly the point.
# A Note on Scaling This Up
**
The in-process asyncio.Queue broadcaster and the single background task in this build are the right amount of infrastructure for one machine watching one stream. At real production scale, watching multiple sources, running multiple consumer processes, surviving a service restart without losing in-flight events, the natural upgrade is swapping the direct stream connection and in-memory broadcaster for a real message bus like Kafka sitting between the producer and the reasoning stage.
# Wrapping Up
The actual lesson underneath all of this code isn't about Wikipedia, or Ollama, or FastAPI specifically, it's that efficiency stops being an optimization you bolt on later, the moment an agent goes from "answers when asked" to "always on**." A chat agent that sits idle costs nothing. A streaming agent is, by definition, always consuming something, and every design choice in this build, the two-stage funnel, the bounded-memory eviction, the graceful degradation on a slow subscriber, the automatic reconnect on a dropped connection, exists because an always-on system that can't sustain itself indefinitely isn't actually done, no matter how well it worked in the first five minutes you watched it run.
Shittu Olumide is a software engineer and technical writer passionate about leveraging cutting-edge technologies to craft compelling narratives, with a keen eye for detail and a knack for simplifying complex concepts. You can also find Shittu on Twitter.
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み