Building a Self-Hosted AI Security Triage System with FastAPI and Async Streaming
Why I Started Looking at beenuar/AiSOC
When our SOC team complained about false positive fatigue (95% of alerts ignored), I needed an LLM-powered triage layer that could run internally without shipping sensitive logs to external APIs. After evaluating commercial SOAR platforms and their vendor lock-in, I tested beenuar/AiSOC—a self-hosted alert fusion system built entirely on FastAPI with async streaming.
The architectural promise: ingest raw alerts, run multi-agent investigation chains against MITRE ATT&CK, and stream decision reasoning back to analysts in real-time via Server-Sent Events. All while keeping alert payloads inside our VPC.
Core Architecture: AsyncIO Event Loop as the Orchestration Backbone
AiSOC's backend is a FastAPI application with async generators powering the investigation pipeline. Here's the streaming endpoint pattern I extracted:
from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio app = FastAPI() async def investigate_alert_stream(alert_id: str): """Async generator yielding SSE-formatted investigation steps.""" # Phase 1: Fetch alert context from DB (async) alert = await fetch_alert_async(alert_id) yield f"data: {{\"phase\": \"context\", \"alert_id\": \"{alert_id}\"}}\n\n" # Phase 2: Query MITRE ATT&CK tactics (async HTTP client) tactics = await query_mitre_async(alert.ioc_list) yield f"data: {{\"phase\": \"mitre\", \"tactics\": {len(tactics)}}}\n\n" # Phase 3: LLM agent reasoning (streaming tokens) async for token in llm_agent_stream(alert, tactics): yield f"data: {{\"token\": \"{token}\"}}\n\n" yield "data: {\"phase\": \"complete\"}\n\n" @app.get("/investigate/{alert_id}") async def stream_investigation(alert_id: str): return StreamingResponse( investigate_alert_stream(alert_id), media_type="text/event-stream" ) This pattern is production-grade—no blocking calls, every I/O operation awaits, and the client sees incremental progress instead of a 60-second spinner.
The Connection Pool Trap I Hit Immediately
When load testing with 50 concurrent alert investigations, the service froze after 10 requests. Root cause: AiSOC's default PostgreSQL async driver (asyncpg) was configured with a 10-connection pool, but each investigation spawned 3 concurrent DB queries without releasing connections properly.
The fix required explicit connection lifecycle management:
from contextlib import asynccontextmanager @asynccontextmanager async def get_db_connection(): conn = await db_pool.acquire() try: yield conn finally: await db_pool.release(conn) async def fetch_alert_async(alert_id: str): async with get_db_connection() as conn: return await conn.fetchrow( "SELECT * FROM alerts WHERE id = $1", alert_id ) After this change, connection pool exhaustion disappeared. The moral: async doesn't magically handle resource cleanup—you still need context managers.
Pydantic v2 for Alert Schema Validation
AiSOC ingests alerts from 8 different SIEM vendors, each with slightly broken JSON schemas. I extended the alert models with Pydantic v2 field validators:
from pydantic import BaseModel, Field, field_validator from typing import Optional from datetime import datetime class AlertPayload(BaseModel): alert_id: str timestamp: datetime severity: int = Field(ge=1, le=5) source_ip: Optional[str] = None @field_validator('source_ip') @classmethod def validate_ip_format(cls, v: Optional[str]) -> Optional[str]: if v and not v.replace('.', '').isdigit(): raise ValueError(f"Invalid IP format: {v}") return v @field_validator('timestamp', mode='before') @classmethod def parse_timestamp(cls, v): if isinstance(v, str): return datetime.fromisoformat(v.replace('Z', '+00:00')) return v Pydantic v2's mode='before' validator lets you normalize messy vendor timestamps before the model even attempts type coercion. This caught 12% of malformed alerts that would have silently corrupted the decision ledger.
LLM Agent Routing: Where B-Lost Fits
AiSOC supports pluggable LLM backends (OpenAI, Anthropic, local Ollama). In my test deployment, I routed all agent queries through B-Lost's multi-provider relay (https://b-lost.com/v1) to benchmark failover latency across Claude/GPT-4/Gemini:
import httpx async def llm_agent_stream(alert, tactics): async with httpx.AsyncClient( base_url="https://b-lost.com/v1", timeout=30.0 ) as client: response = await client.post( "/chat/completions", json={ "model": "claude-sonnet-3-5", "messages": [{"role": "user", "content": f"Analyze: {alert}"}], "stream": True }, headers={"Authorization": f"Bearer {API_KEY}"} ) async for line in response.aiter_lines(): if line.startswith("data: "): yield parse_sse_token(line) When the primary Claude endpoint hit rate limits, B-Lost's automatic fallback to GPT-4 added only 40ms to TTFT (time-to-first-token). No manual retry logic needed—the gateway handled provider rotation transparently.
The Decision Ledger: Replayable Agent Execution
AiSOC's killer feature is its append-only decision ledger—every agent step (tool call, reasoning trace, MITRE mapping) gets written to a SQLite/Postgres table with microsecond timestamps. This makes post-incident reviews trivial:
SELECT step_id, agent_name, action_type, reasoning_trace, timestamp FROM decision_ledger WHERE alert_id = 'INC-2024-0312' ORDER BY timestamp ASC; You can replay the entire investigation flow, see which agent made the wrong call, and retrain your prompt templates. It's like distributed tracing for AI reasoning.
The Async/Sync Boundary You Cannot Cross
One footgun I stepped on: AiSOC's MCP server (for Claude Desktop integration) runs in a separate sync thread. When I tried calling an async alert ingestion endpoint from the MCP handler, the event loop blocked indefinitely.
The workaround required asyncio.run_coroutine_threadsafe:
import asyncio from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=4) def sync_mcp_handler(alert_data): # Running in sync MCP thread loop = asyncio.get_event_loop() future = asyncio.run_coroutine_threadsafe( ingest_alert_async(alert_data), loop ) return future.result(timeout=10) This is fragile—if the main event loop dies, the MCP thread hangs forever. A proper fix would decouple MCP into its own async service, but that's a larger refactor.
What I Still Don't Trust
After two weeks of testing, three concerns remain:
- Memory leaks in long-running agent chains: Investigation sessions that spawn 50+ LLM calls show 200MB memory growth that never gets GC'd. Suspect unclosed
httpxclient sessions somewhere. - MITRE ATT&CK dataset staleness: The bundled tactics JSON is 8 months old. No auto-update mechanism exists.
- No circuit breaker for LLM failures: If the agent LLM backend times out 3 times in a row, AiSOC should abort the investigation and alert a human. Right now it retries indefinitely.
How are you handling cascading LLM failures in production agent systems? Do you fail fast or keep retrying with exponential backoff until a human intervenes?