KDnuggets

Building a Streaming Local AI Agent

8.5内容质量

TL;DR · AI 摘要

构建本地流式AI代理需采用两阶段过滤器处理维基百科实时编辑流,结合Ollama实现本地推理,无需API密钥或云服务。

核心要点

  • 两阶段过滤器可减少90%无意义编辑的模型计算负载
  • Ollama支持本地运行llama3.1:8b等结构化输出模型
  • 仅需Python 3.11和Wikipedia公共EventStreams接口

结构提纲

按章节快速跳转。

  1. 澄清AI代理中流式处理的两种不同应用场景及实现必要性。

  2. 两阶段过滤器设计是解决实时处理与计算效率矛盾的关键架构决策。

  3. 通过Python数学运算预筛编辑事件,仅将潜在 vandalism 送入本地LLM处理。

  4. 基于Ollama+FastAPI构建,依赖Wikipedia公共EventStreams接口。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 流式本地AI代理
    • 两阶段过滤器
      • Python预筛
      • LLM精处理
    • 技术实现
      • Ollama
      • FastAPI
      • Wikipedia EventStreams

金句 / Highlights

值得收藏与分享的关键句。

#AI#流式处理#Ollama#本地AI代理
打开原文

构建流式本地AI代理 - KDnuggets

publ: 2026年8月13日

  • 博客热门文章
  • 主题 AI 职业建议 计算机视觉 数据工程 数据科学 语言模型 机器学习 MLOps NLP 编程 Python SQL
  • 数据集
  • 活动
  • 资源 快速参考指南 推荐 技术简报
  • 广告

订阅电子报

#header end

/ad_wrapper

构建流式本地AI代理

当人们谈论AI代理时,"流式"这个词会被用在两种不同的场景中。在这里澄清你的理解。

作者:

Shittu Olumide,技术内容专家,2026年8月13日发布于

人工智能

<div class="addthis_native_toolbox"></div>

当人们讨论AI代理时,"流式"这个词通常有两种不同的含义,而大多数教程只构建其中一种。有时它意味着代理消费实时事件流而不是等待用户输入消息。有时它意味着代理的输出以逐个标记的方式流式传输,而不是在长时间停顿后一次性显示。本次构建特意同时实现了这两种方式,因为它们解决了不同的问题,而真正有用的常驻代理需要同时满足这两个需求。

值得借鉴的框架来自于通常称为环境代理的概念,LangChain将其描述为由事件触发而非人类消息触发的代理,Google的Agent Development Kit从基础设施角度同样如此描述:代理由流中的事件唤醒,而非通过请求-响应调用。本次构建的场景是具体且真实的:一个本地代理监控维基百科的实时公开编辑流(无需API密钥),并通过Ollama在本地机器上推理哪些编辑看起来像破坏行为。以下每一行代码都经过编写和实际测试后才被纳入本文。

你的前置条件如下:

  • Python 3.11或更新版本
  • 本地安装Ollama并已拉取模型(执行ollama pull llama3.1:8b,或任何支持结构化JSON输出的模型)
  • pip install fastapi uvicorn httpx pydantic ollama sse-starlette
  • 无需API密钥、无需云账户,唯一成本是你的电费。该服务仅与维基百科的公开EventStreams端点建立出站网络连接,且无需认证

# 决定成败的设计决策

维基百科的编辑流并非涓涓细流。在活跃的日子里,每秒会向所有语言版本合计推送数次编辑。如果将每个编辑都交给语言模型处理,会发生两件事:首先,你的机器会消耗大量计算资源处理那些本身就不值得关注的编辑;其次,代理会落后于它本应监控的实时流,这完全违背了构建"常驻"系统的初衷。

解决方案是采用两阶段漏斗过滤机制,这是本次构建中最重要的设计思想:

  • 第一阶段是低成本的纯Python计算,无需任何模型参与,对每个事件执行简单数学运算:这次编辑删除了多少字节?该用户在过去几分钟内做了多少次编辑?绝大多数编辑都是平淡无奇的,而检测平淡无奇的代价是免费的
  • 第二阶段才是实际的本地LLM,只对第一阶段触发阈值的少数事件做出响应。这与任何优秀的监控系统遵循的原则一致:前期使用低成本过滤器,仅将昂贵的推理资源留给通过筛选的候选事件

// 文件夹结构

code
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

每个文件都精确对应上述流水线描述中的一个阶段,这种设计使整个系统易于理解和测试,也正因如此,本文实际构建时就是采用这种方式进行开发的。

# 构建第一部分:事件流消费者

维基百科的EventStreams服务通过普通的HTTP协议推送编辑事件,以Server-Sent Events形式传输。无需密钥,除了一个保持开放状态的普通GET请求外,不需要任何握手过程。

code
# 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 在这个流中不会发送明确的 "用户是否匿名" 标志;
# 匿名编辑会使用编辑者的IP地址而非用户名进行归因,
# 因此在实际应用中,通过判断用户名是否为IP地址格式来识别匿名用户
_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帧数据,这些数据以 'data: ' 开头。注释行
    (以 ':' 开头)和保持连接的空白行在该数据流中很常见,
    应该被静默忽略,而不是作为错误处理。"""
    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]:
    """将原始的Wikimedia数据负载转换为我们的标准化模式。
    对于不关心的事件类型返回None而不是抛出异常,
    因为这个高流量的流会持续包含我们不关注的数据格式。"""
    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]:
    """被main.py使用的实时异步生成器。在连接断开时会自动重新连接,
    而不是因为一次网络故障就导致整个服务崩溃,这对于
    需要无人值守运行的服务非常重要。"""
    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 循环中,并在遇到任何 HTTP 错误时执行重新连接和休眠操作,从而确保服务不会因首次断开连接就停止运行,这才是真正意义上的持续在线服务。

# 构建部分 2:廉价过滤器,第一阶段

code
# src/filters.py
import time
from collections import defaultdict, deque
from typing import Optional

from .schemas import RecentChangeEvent, FilterSignal
from . import config

class EditVelocityTracker:
    """按滑动窗口跟踪每个用户的最近编辑时间戳,使过滤器能够检测到快速连续的编辑爆发,而不仅仅是单次大范围删除。内存使用有限:旧用户会被移除,而非无限保存。"""

    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:
        """记录此次编辑并返回用户在滑动窗口内(包括本次)的编辑次数。"""
        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:
    """将速度跟踪器和字节删除检查整合为每个事件的通过/失败决策。"""

    def __init__(self, tracker: Optional[EditVelocityTracker] = None):
        self.tracker = tracker or EditVelocityTracker()

    def evaluate(self, event: RecentChangeEvent) -> Optional[FilterSignal]:
        """如果此事件值得LLM处理则返回FilterSignal,否则返回None。绝大多数事件都会返回None。"""
        if event.is_bot:
            return None  # 机器人编辑有独立的审核流程

        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"单次编辑删除了{bytes_removed}字节")
        if recent_count >= config.EDIT_VELOCITY_THRESHOLD:
            reasons.append(f"{recent_count}次编辑在{self.tracker.window_seconds}秒内")

        if not reasons:
            return None

        return FilterSignal(
            event=event, bytes_removed=bytes_removed,
            recent_edit_count=recent_count, reason="; ".join(reasons),
        )

EditVelocityTracker为每个用户维护一个最近编辑时间戳的双端队列,每次调用都会修剪滑动窗口外的时间戳,这使得"5次编辑在2分钟内"成为持续准确的实时统计而非估算值。

max_tracked的移除保护机制存在是因为如果不断有新用户流入而没有限制,这个字典会无限增长。这个细节在演示中容易被忽略,但在生产环境中发现时代价高昂。Stage1Filter.evaluate是真正的判断门禁:绝大多数事件都会返回None(即"不值得关注"),只有当真正跨越阈值时才会构建FilterSignal对象。

# 构建部分3:本地推理器,第二阶段

code
# 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:向客户端广播实时推理

code
# 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:
        """将负载分发给所有订阅者。当订阅者的队列已满时,消息会被丢弃而非阻塞整个管道,
        慢速客户端永远不应该能减缓代理的实际处理循环。"""
        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]:
        """调用者可以循环使用的异步生成器,由 main.py 中的 SSE 端点直接使用。"""
        queue = self.subscribe()
        try:
            while True:
                message = await queue.get()
                yield message
        finally:
            self.unsubscribe(queue)

这实现的原理:每个连接的客户端都会获得自己的 asyncio.Queue,publish 方法通过 try/except 包裹的 put_nowait 独立地将消息发送到每个队列,因此一个缓慢或停滞的订阅者只会静默丢弃该客户端的消息,而不会阻塞实际处理实时维基百科编辑的循环。这种隔离的重要性比看起来更大:没有它的话,一个缓慢的浏览器标签页可能会悄悄让整个代理停滞。在测试这个功能时发现了一个真正有用的知识点:stream() 是一个异步生成器,而异步生成器是惰性的;其中的 subscribe() 调用实际上直到有人第一次调用 __anext__() 时才会执行。在真实的 FastAPI 端点中,这不是问题因为迭代会立即开始,但正是这种微妙之处容易让编写自己测试的人遇到问题,而我的第一次测试也正好因此失败,直到我修复了测试本身才解决这个问题。

# 整合连接

code
# 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:
    """无限消费实时流,在每个事件上运行阶段1过滤器,
    仅对通过阶段1的事件触发LLM处理阶段。"""
    async for event in wikipedia_event_stream():
        signal = stage1.evaluate(event)
        if signal is None:
            continue

        logger.info("阶段1标记: %s 由 %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("阶段2处理 %s 失败,跳过此信号", signal.event.title)

@asynccontextmanager
async def lifespan(app: FastAPI):
    task = asyncio.create_task(run_pipeline())
    logger.info("流式本地代理已启动,开始监控编辑...")
    yield
    task.cancel()
    logger.info("流式本地代理正在关闭")

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())

运行方式

安装Ollama并拉取模型后:

code
ollama pull llama3.1:8b
ollama serve   # 如果尚未作为后台服务运行

然后从项目根目录执行:

code
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn src.main:app --reload

启动后,打开第二个终端并查看实时数据流:

code
curl -N http://localhost:8000/events

或者直接在浏览器中访问 http://localhost:8000/events;大多数浏览器会将SSE流渲染为逐步到达的纯文本。在活跃的维基百科上,几分钟内你应该能看到标记消息的到达,首先是Stage 1检测到大规模删除或编辑爆发,随后是本地模型实时推理生成的令牌消息流,最终以包含结构化严重性评分的判决消息结束。无聊的编辑(占绝大多数流量)根本不会出现,这正是设计的初衷。

# 关于扩展规模的注意事项

当前实现中的进程内asyncio.Queue广播器和单个后台任务,是用于单台机器监控单个数据流的合适基础设施。在实际生产规模中,需要监控多个来源、运行多个消费者进程、在服务重启后仍能保留飞行中的事件,自然的升级方案是将直接流连接和内存广播器替换为真正的消息总线(如Kafka),在生产者和推理阶段之间部署。

# 总结

所有这些代码背后真正的教训,不在于维基百科、Ollama或FastAPI本身,而在于当代理从"被询问时回答"转变为"始终在线"的那一刻,效率就不再是后期添加的优化项。闲置的聊天代理成本为零,而流式代理本质上始终在消耗资源。这个实现中的每一个设计选择——双阶段漏斗、内存限制驱逐、对缓慢订阅者的优雅降级、断开连接时的自动重连——都存在,因为一个无法长期维持运行的始终在线系统,无论它在最初五分钟运行时表现得多好,实际上都尚未完成。

Shittu Olumide 是一位软件工程师和技术作家,热衷于利用前沿技术创作引人入胜的叙事,注重细节并擅长简化复杂概念。你也可以在Twitter上找到Shittu。

更多相关内容

  • 数据科学家的数据流指南
  • Python在金融中的应用:Jupyter Notebook中的实时数据流
  • 构建并部署你的第一个自主代理的7个步骤
  • 使用微软代理框架构建代理AI系统
  • 构建强大AI代理的五大代理技能市场
  • 使用代理开发工具包构建生产级AI代理

<hr class="grey-line"><br> <div><h3>我们推荐的5门免费课程</h3><br> </div>

Mailchimp for WordPress v4.14.0 - https://wordpress.org/plugins/mailchimp-for-wp/

/ Mailchimp for WordPress插件

你可以从这里开始编辑。

如果评论已关闭。

<= 上一篇

下一篇 =>

#content end

<script type="text/javascript">kda_sid_write(kda_sid_n);</script>

最新文章

  • 如何使用Python构建简单的AI网络爬虫 5篇有趣的代理AI论文阅读 构建流式本地AI代理 限制输出空间以优化SLM窄自动化 构建端到端的数据科学项目组合 5种轻松在Windows上安装Python的方法

热门文章

  • 规格工程:提示工程之后的新技能
  • 如何使用Python构建简单的AI网络爬虫
  • 5篇有趣的代理AI论文阅读
  • 构建端到端的数据科学作品集项目
  • 5门免费课程学习现代AI和LLMs
  • 构建流媒体本地AI代理
  • 贡献开源项目的终极指南
  • 3个直观理解中心极限定理的可视化证明
  • 5种在Windows上安装Python的简便方法
  • 2026年极简AI工程师工具包

#content_wrapper end

© 2026

Guiding Tech Media

|

关于

联系我们

广告合作

隐私政策

服务条款

2026年8月13日发布于

blank

不,谢谢!

/.main_wrapper

<script defer type="text/javascript" src="https://s7.addthis.com/js/300/addthis_widget.js#pubid=gpsaddthis"></script>

noptimize

/noptimize