TL;DR
이 글은 Wikipedia의 실시간 편집 스트림을 감시하면서 로컬 Ollama 모델로 vandalism 후보를 판단하고 그 결과를 토큰 단위로 전송하는 always-on agent 구축법을 다룹니다. 모든 이벤트를 LLM에 보내는 대신 Python 기반 Stage 1에서 삭제된 바이트 수와 사용자별 최근 편집 빈도를 계산해 후보를 줄이고, 통과한 이벤트만 구조화된 AgentVerdict 스키마와 함께 Stage 2 추론에 전달합니다. httpx의 자동 재접속, bounded-memory 기록, 느린 SSE 구독자에 대한 메시지 삭제, FastAPI lifespan 기반 백그라운드 task로 장기 실행 안정성도 확보합니다. 단일 머신을 넘어 여러 소스와 프로세스를 처리하거나 재시작 후 이벤트를 보존하려면 asyncio.Queue를 Kafka 같은 message bus로 바꾸는 확장이 필요합니다.
섹션별 상세
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 NoneSSE 입력에서 주석, 빈 keep-alive 줄과 잘못된 JSON을 걸러내고 데이터 줄만 파싱합니다.

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),
)모든 편집 이벤트에서 봇 여부, 삭제 바이트 수, 최근 편집 빈도를 검사해 LLM 호출 대상을 선별합니다.
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 verdictOllama에 JSON 스키마를 전달해 토큰을 실시간으로 내보내고, 완료 후 검증된 AgentVerdict 객체를 반환합니다.
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 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)실시간 Wikipedia 편집을 1단계 필터에 통과시킨 뒤 후보 이벤트에만 로컬 LLM을 호출하고 결과를 클라이언트에 전송합니다.
이미지 분석

이미지는 이벤트 입력, 필터·처리 계층, 로컬 컴퓨팅 장치, 클라이언트 출력이 연결된 구조를 시각화합니다. 본문에서 Wikipedia 실시간 편집을 수집하고 Stage 1의 값싼 필터와 Stage 2의 Ollama 기반 로컬 LLM 추론을 거쳐 live clients에 결과를 전달하는 전체 아키텍처와 직접 연결됩니다.
로컬 장치에서 실행되는 streaming local AI agent의 구성과 데이터 흐름을 나타낸 도식입니다.
용어 해설
- 앰비언트 에이전트(Ambient Agent)
- — 사람이 직접 메시지를 보내야 작동하는 대신 외부 이벤트가 도착하면 자동으로 깨어나는 에이전트입니다. 이벤트 스트림을 감시하다가 조건을 만족한 입력만 처리하므로 요청-응답 방식보다 지속적인 모니터링에 적합합니다. 이 글에서는 Wikipedia 편집 이벤트를 트리거로 사용합니다.
- 서버 전송 이벤트(Server-Sent Events)
- — 서버가 하나의 HTTP 연결을 유지하면서 클라이언트로 이벤트를 계속 보내는 방식입니다. Wikipedia EventStreams의 편집 데이터를 수신하고, FastAPI 서버가 처리 결과를 브라우저나 curl 클라이언트로 실시간 전달하는 데 사용됩니다. 클라이언트에서 서버로 연결을 유지할 필요가 없는 단방향 스트리밍에 맞습니다.
- 구조화된 출력(Structured Output)
- — 언어 모델의 응답 형식을 미리 정한 스키마에 맞추는 방식입니다. 이 구현은 AgentVerdict의 JSON 스키마를 Ollama에 전달해 불리언 판정, 1~5 severity, reasoning, suggested_action 필드를 생성하게 합니다. 결과를 사람이 읽는 텍스트가 아니라 프로그램이 바로 처리할 객체로 사용할 수 있습니다.
- 슬라이딩 윈도(Sliding Window)
- — 최근 일정 시간 동안 발생한 이벤트만 유지해 빈도나 누적량을 계산하는 방식입니다. EditVelocityTracker는 사용자별 편집 시각을 deque에 저장하고 설정된 시간 범위를 벗어난 기록을 제거합니다. 따라서 특정 사용자의 최근 편집 횟수를 계속 갱신하면서 메모리 증가도 제한합니다.
- 메시지 버스(Message Bus)
- — 이벤트를 생산하는 단계와 소비·추론하는 단계를 분리하는 중간 전달 시스템입니다. 글의 단일 머신 구현은 메모리 기반 asyncio.Queue를 사용하지만, 여러 소스와 프로세스, 재시작 후 미처리 이벤트 보존이 필요할 때 Kafka 같은 메시지 버스로 확장할 수 있습니다. 처리 단계 간 결합을 낮추는 인프라 역할을 합니다.
기술
- Python 3.11
- Ollama
- llama3.1:8b
- fastapi
- uvicorn
- httpx
- pydantic
- ollama
- sse-starlette
- Wikipedia EventStreams
- Server-Sent Events
- FastAPI
- asyncio
- Kafka
활용 사례
- Wikipedia 실시간 편집에서 vandalism 후보 감시
- 항상 실행되는 이벤트 기반 로컬 AI 에이전트
- 실시간 모니터링 시스템의 저비용 사전 필터링
- 브라우저나 curl 클라이언트로 LLM 판단 과정과 verdict 전달
AI 요약 · 북마크 · 개인 피드 설정 — 무료
출처 · 인용 안내
인용 시 "요약 출처: AI Trends (aitrends.kr)"를 표기하고, 사실 확인은 원문 보기 기준으로 진행해 주세요. 자세한 기준은 운영 정책을 참고해 주세요.