如何使用 Burr、FastAPI 和 React 构建流媒体代理

概述如何使用开源工具利用流式传输来构建简单的代理聊天机器人

       欢迎来到雲闪世界。我们的代理应用程序模型。我们将向您展示如何使用流式传输来构建该模型,以便您可以创建出色的用户体验。图片由作者提供。 在这篇文章中,我们将介绍如何构建一个代理聊天机器人,利用Burr的(我是作者)流式传输功能、FastAPI 的 StreamingResponse和React查询的服务器发送事件 (SSE)向用户发送响应。所有这些都是开源工具。这针对的是那些想要更多地了解 Python 中的流式传输以及如何为其代理/应用程序添加交互性的人。虽然我们使用的工具相当具体,但这些课程应该适用于广泛的流式响应实现。

首先,我们将讨论为什么流式传输很重要。然后,我们将介绍我们使用的开源工具。我们将通过一个示例向您介绍可用于入门的代码,然后分享更多资源和替代实现。

您可以在此处跟踪 Burr + FastAPI 代码,并在此处跟踪前端代码。 您也可以通过运行来运行此示例(您需要一个 OPENAI_API_KEY 环境变量)pip install “burr[start]” && burr,然后导航到 localhost:7241/demos/streaming-chatbot(浏览器将自动打开,只需单击左侧的 demos/streaming-chatbot 即可。 注意,此示例需要burr>=0.23.0

为何要进行流式传输?

虽然通过网络进行流媒体传输是一项90 年代的技术,如今已无处不在(视频游戏、流媒体电视、音乐等),但最近生成式 AI 应用的激增引起了人们对逐字提供和渲染流式文本的兴趣。

LLM 是一项有趣的技术(甚至可能很有用),但运行速度相对较慢,用户不喜欢等待。幸运的是,可以将结果流式传输,以便用户在生成 LLM 响应时看到它。此外,鉴于 LLM 通常机械而沉闷的性质,流式传输可以使它们看起来更具交互性,几乎就像它们在思考一样。

适当的实施将允许跨多个服务边界的流通信,从而使中间代理能够在向用户呈现流数据时增强/存储它。

​ 聊天机器人架构的简单展示

编辑

虽然这些都不是火箭科学,但使 Web 开发变得简单且基本标准化的相同工具(OpenAPI / FastAPI / React + 朋友等)都具有不同程度的支持,这意味着您经常有多种与您习惯不同的选择。流式传输通常是框架设计中的事后考虑,这会导致各种限制,您可能直到构建到一半才知道。

让我们介绍一下用来实现上述堆栈的一些工具,然后看一个示例。

开源工具

我们将利用这些工具来构建这个,它们彼此之间是很好地解耦的 — — 如果你愿意,你可以交换类似的工具,但仍然应用相同的课程/代码。

伯尔

Burr是一个轻量级 Python 库,可用于将应用程序构建为状态机。您可以使用一系列操作(这些操作可以是修饰函数或对象)构建应用程序,这些操作声明来自状态的输入以及来自用户的输入。这些操作指定自定义逻辑(委托给任何框架)以及如何更新状态的说明。状态是不可变的,这允许您在任何给定点检查它。Burr 处理编排、监控、持久性等)。

@action(reads=["count"], writes=["count"])
def counter(state: State) -> State:
    return state.update(counter=state.get("count", 0) +1)
 

您将 Burr 操作作为应用程序的一部分运行 — 这使您可以将它们与一系列(可选)从一个操作到另一个操作的条件转换串联在一起。

from burr.core import ApplicationBuilder, default, expr
app = (
    ApplicationBuilder()
    .with_actions(
        count=count, 
        done=done # implementation left out above
    ).with_transitions(
        ("counter", "counter", expr("count < 10")), # Keep counting if the counter is < 10
        ("counter", "done", default) # Otherwise, we're done
    ).with_state(count=0)
    .with_entrypoint("counter") # we have to start somewhere
    .build()
)
 

Burr 带有一个用户界面,可以实现监控/遥测,以及在执行期间保持状态/执行任意代码的挂钩。

您可以将其可视化为流程图,即图形/状态机:

Burr 免费为您提供这张图片。图片由作者提供。

并使用本地遥测调试器对其进行监控:

操作系统遥测 UI 可告诉您应用程序在任何给定时间点的状态

虽然上述示例只是一个简单的说明,但 Burr 通常用于代理(如本例)、RAG 应用程序和人机交互 AI 界面。请参阅存储库示例,了解组(更详尽的)用例。稍后我们将介绍流媒体和一些更强大的功能。

快速API

FastAPI是一个框架,可让您在 REST API 中公开 Python 函数。它有一个简单的界面 — — 您编写函数,然后修饰它们,然后运行脚本 — — 通过OpenAPI将其转变为具有自文档化端点的服务器。

@app.get("/")
def read_root():
    return {"Hello": "World"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
    return {"item_id": item_id, "q": q}

FastAPI 提供了无数好处。它是异步原生的,通过 OpenAPI 提供文档,并且易于在任何云提供商上部署。它与基础设施无关,通常可以水平扩展(只要考虑到状态管理)。有关更多信息,请参阅此页面

反应

React 无需介绍 — — 它是一款非常流行的工具,为互联网提供了强大的支持。即使是最近流行的工具(如 next.js/remix)也是基于它构建的。有关更多阅读内容,请参阅react.dev。我们将使用 React 以及typescripttailwind,但您通常可以用自己喜欢的前端工具替换它,并能够重复使用本文的大部分内容。

构建一个简单的 Agentic 聊天机器人

让我们构建一个简单的代理聊天机器人 — 它具有代理性,因为它实际上进行了两个 LLM 调用:

  1. 确定要查询的模型的调用。我们的模型将有几个“模式” — — 生成一首诗、回答一个问题等等……
  2. 对实际模型的调用(在本例中为提示 + 模型组合)

对于 OpenAI API 来说,这更像是一个玩具示例 — 他们的模型是令人印象深刻的万事通。话虽如此,这种工具委托模式在各种各样的 AI 系统中都有体现,这个例子可以清晰地推断出来。

在 Burr 中对代理进行建模

建模为状态机

为了利用 Burr,我们将代理应用程序建模为状态机。基本逻辑流程如下:

 我们从用户提示输入(顶部)开始。然后我们检查安全性,如果不安全,我们将使用“不安全”的具体响应。否则,我们决定模式,并根据状态字段模式的值进行切换每个都返回流式响应。一旦它们完成流式传输,它就会回到提示并等待另一个用户输入……图片来自作者。

为了使用 Burr 对此进行建模,我们将首先使用流式 API创建相应的操作。然后我们将它们绑定在一起作为应用程序

流媒体动作

在 Burr 中,操作可以利用同步和异步 API。在本例中,我们将使用async。Burr 中的流式函数也可以与非流式操作混合搭配,但为了简化,我们将所有内容实现为流式。因此,无论是从 OpenAPI 流式传输(它有自己的异步流式传输接口),还是返回固定的抱歉,我无法回答这个问题的响应,它仍将实现为生成器。

对于那些不熟悉的人来说,生成器是一种 Python 构造,可以对一系列值进行高效、惰性求值。它们由yield 关键字创建,该关键字将控制权从函数交还给调用者,直到需要下一个项。异步生成器的功能类似,但它们还会在yield上交出对事件循环的控制。阅读有关同步生成器异步生成器的更多信息。

Burr 中的流式操作被实现为一个生成器,它产生元组,包括:

  1. 中间结果(在本例中为消息中的增量令牌)
  2. 如果已完成,则最终状态更新;如果仍在生成,则为 None

因此,最终的产量将表明流已完成,并输出最终结果以供稍后存储/调试。使用一些自定义提示操作代理 OpenAI 的基本响应如下所示:

@streaming_action(reads=["prompt", "chat_history", "mode"], writes=["response"])
async def chat_response(
    state: State, prepend_prompt: str, model: str = "gpt-3.5-turbo"
) -> AsyncGenerator[Tuple[dict, Optional[State]], None]:
    """A simple proxy.
    
    This massages the chat history to pass the context to OpenAI, 
    streams the result back, and finally yields the completed result 
    with the state update.
    """
    client = _get_openai_client()
    # code skipped that prepends a custom prompt and formats chat history
    chat_history_for_openai = _format_chat_history(
        state["chat_history"], 
        prepend_final_promprt=prepend_prompt)
    result = await client.chat.completions.create(
        model=model, messages=chat_history_api_format, stream=True
    )
    buffer = []
    
    async for chunk in result:
        chunk_str = chunk.choices[0].delta.content
        if chunk_str is None:
            continue
        buffer.append(chunk_str)
        yield {"delta": chunk_str}, None
        
    result = {
        "response": {"content": "".join(buffer), "type": "text", "role": "assistant"},
    }
    yield result, state.update(**result).append(chat_history=result["response"])
 

在示例中,我们还有一些其他流式操作 — 这些操作将代表“终端”操作 — 当状态机完成它们时,这些操作将触发工作流暂停。

构建应用程序

要构建应用程序,我们首先要构建一个图形。我们将使用Burr 的Graph API,这样我们就可以把图形的形状与其他应用程序问题分离开来。在 Web 服务中,Graph API 是一种表达状态机逻辑的非常干净的方式。您可以全局构建一次,然后在每个单独的应用程序实例中重复使用它。图形构建器如下所示 — 请注意,它引用了上面的chat_response函数:

# Constructing a graph from actions (labeled by kwargs) and 
# transitions (conditional or default).
graph = (
    GraphBuilder()
    .with_actions(
        prompt=process_prompt,
        check_safety=check_safety,
        decide_mode=choose_mode,
        generate_code=chat_response.bind(
            prepend_prompt="Please respond with *only* code and no other text" 
                "(at all) to the following",
        ),
        # more left out for brevity
    )
    .with_transitions(
        ("prompt", "check_safety", default),
        ("check_safety", "decide_mode", when(safe=True)),
        ("check_safety", "unsafe_response", default),
        ("decide_mode", "generate_code", when(mode="generate_code")),
        # more left out for brevity
    )
    .build()
)
 

最后,我们可以将它们添加到一个应用程序中 — 它公开了与服务器交互的正确的执行方法:

# Here we couple more application concerns (telemetry, tracking, etc…).
app = ApplicationBuilder()
  .with_entrypoint("prompt")
  .with_state(chat_history=[])
  .with_graph(graph)
  .with_tracker(project="demo_chatbot_streaming")
  .with_identifiers(app_id=app_id)
  .build()
)
 

当我们想要运行它时,我们可以调用astream_results。它接收一组停止条件,并返回一个AsyncStreamingResultContainer (一个缓存结果并确保调用 Burr 跟踪的生成器),以及触发停止的操作。

# Running the application as you would to test, 
# (in a jupyter notebook, for instance).
action, streaming_container = await app.astream_result(
    halt_after=["generate_code", "unsafe_response", ...], # terminal actions
    inputs={
      "prompt": "Please generate a limerick about Alexander Hamilton and Aaron Burr"
    }
)
async for item in streaming_container:
    print(item['delta'], end="")
 

在 Web 服务器中公开

现在我们有了 Burr 应用程序,我们将使用服务器发送事件 (SSE) 与 FastAPI 的流响应 API集成。虽然我们不会深入研究 SSE,但 TL;DR 是它们充当 Web 套接字的单向(服务器 → 客户端)版本。您可以在最后的链接中阅读更多内容。

要在 FastAPI 中使用这些,我们将端点声明为返回 StreamingResponse 的函数 — — 一个包装生成器的类。标准是提供特殊形状的流式响应,“data: <contents> \n\n”。在此处详细了解原因。虽然这主要是为了EventSource API(我们将绕过它而使用 fetch 和getReader()),但我们将保留这种标准格式(以便任何使用 EventSource API 的人都可以重用此代码)。

我们已经单独实现了_get_application一个实用函数来通过 ID 获取/加载应用程序。

该函数将是一个 POST 端点,因为我们正在向服务器添加数据,尽管也可以很容易地成为一个 PUT。

@app.post("/response/{project_id}/{app_id}", response_class=StreamingResponse)
async def chat_response(project_id: str, app_id: str, prompt: PromptInput) -> StreamingResponse:
    """A simple API that wraps our Burr application."""
    burr_app = _get_application(project_id, app_id)
    chat_history = burr_app.state.get("chat_history", [])
    action, streaming_container = await burr_app.astream_result(
        halt_after=chat_application.TERMINAL_ACTIONS, inputs=dict(prompt=prompt.prompt)
    )
    async def sse_generator():
        yield f"data: {json.dumps({'type': 'chat_history', 'value': chat_history})}\n\n"
        async for item in streaming_container:
            yield f"data: {json.dumps({'type': 'delta', 'value': item['delta']})} \n\n"
    return StreamingResponse(sse_generator())
 

请注意,我们在函数内部定义了一个生成器,它包装了 Burr 结果并将其转换为 SSE 友好的输出。这使我们能够对结果施加一些结构,我们将在前端使用这些结构。不幸的是,我们必须自己解析它,因为 fastAPI 不支持对 StreamingResponse 进行严格类型化。

此外,我们在执行之前实际上会在开始时就生成整个状态。虽然这并非绝对必要(我们也可以有一个单独的聊天历史记录 API),但它将使渲染更加容易。

为了测试这一点,您可以使用请求库Response.iter_lines API。

构建 UI

现在我们已经准备好了服务器、状态机和 LLM,让我们让它看起来更漂亮!这就是所有东西联系在一起的地方。虽然您可以下载并使用示例中的全部代码,但我们将重点关注单击“发送”时查询 API 的函数。

编辑

UI 如下所示。您可以通过 Burr 附带的打包 Telemetry UI 运行它。图片由作者提供。

首先,让我们使用 fetch 查询我们的 API(显然要根据您的端点进行调整,在这种情况下,我们将所有 /api 调用代理到另一台服务器…):

// A simple fetch call with getReader()
const response = await fetch(
      `/api/v0/streaming_chatbot/response/${props.projectId}/${props.appId}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt: currentPrompt })
      }
    );
const reader = response.body?.getReader();

这看起来像一个普通的旧 API 调用,利用了 typescript异步 API。这将提取一个读取器对象,这将帮助我们在结果传入时进行流式传输。

让我们定义一些数据类型来利用我们上面创建的结构。除了数据类型(使用openapi-typescript-codegenChatItem生成)之外,我们还将定义两个类,它们对应于服务器返回的数据类型。

// Datatypes on the frontend. 
// The contract is loose, as nothing in the framework encodes it
type Event = {
  type: 'delta' | 'chat_history';
};
type ChatMessageEvent = Event & {
  value: string;
};
type ChatHistoryEvent = Event & {
  value: ChatItem[];
};

接下来,我们将遍历读取器并进行解析。这假设 React 中存在以下状态变量:

  • setCurrentResponse/currentResponse
  • setDisplayedChatHistory

我们通读并根据“数据:”进行拆分,然后循环拆分并根据事件类型进行解析/反应。

// Loop through, continually getting the stream. 
// For each item, parse it as our desired datatype and react appropriately.
while (true) {
    const result = await reader.read();
    if (result.done) {
      break;
    }
    const message = decoder.decode(result.value, { stream: true });
    message
      .split('data: ')
      .slice(1)
      .forEach((item) => {
        const event: Event = JSON.parse(item);
        if (event.type === 'chat_history') {
          const chatMessageEvent = event as ChatHistoryEvent;
          setDisplayedChatHistory(chatMessageEvent.value);
        }
        if (event.type === 'delta') {
          const chatMessageEvent = event as ChatMessageEvent;
          chatResponse += chatMessageEvent.value;
          setCurrentResponse(chatResponse);
        }
      });
}
 

我们省略了一些清理/错误处理代码(清除、在请求之前/之后初始化状态变量、处理失败等) — — 您可以在示例中看到更多。

最后,我们可以渲染它(注意,这是指在上面的代码之外设置/取消设置的附加状态变量,以及仅显示带有适当图标的聊天消息的 ChatMessage 反应组件)。

<!-- More to illustrates the example -->
<div className="flex-1 overflow-y-auto p-4 hide-scrollbar" id={VIEW_END_ID}>
  {displayedChatHistory.map((message, i) => (
    <ChatMessage
      message={message}
      key={i}
    />
  ))}
  {isChatWaiting && (
    <ChatMessage
      message={{
        role: ChatItem.role.USER,
        content: currentPrompt,
        type: ChatItem.type.TEXT
      }}
    />
  )}
  {isChatWaiting && (
    <ChatMessage
      message={{
        content: currentResponse,
        type: ChatItem.type.TEXT,
        role: ChatItem.role.ASSISTANT
      }}
    />
  )}
</div>
<!-- Note: We've left out the isChatWaiting and currentPrompt state fields above, 
 see StreamingChatbot.tsx for the full implementation. -->
 

我们终于有了完整的应用程序!点击此处查看全部代码

替代的 SSE 工具

请注意,我们上面介绍的只是使用 FastAPI/react/Burr 进行流式传输的一种方法。您还可以使用许多其他工具,包括:

  • EventSource API — 标准但仅限于获取/请求
  • FetchEventSource API (似乎无人维护,但构建良好)

以及许多其他博客文章(非常棒!我读了这些文章作为入门)。这些文章也会让您更好地了解建筑。

包起来

在这篇文章中,我们涵盖了很多内容 — — 我们讨论了 Burr、FastAPI 和 React,讨论了如何使用 OpenAI API 构建流式代理聊天机器人,构建了整个堆栈,并一路传输数据!虽然您可能不会使用每一种技术,但各个部分应该能够独立工作。

要下载并使用此示例,您可以运行:

pip install "burr[start]"
burr # will open up in a new window
请注意,您需要一个来自 OpenAI 的 API 密钥才能完成此特定演示。您可以在此处找到 Burr + FastAPI 代码,并在此处找到前端代码。

感谢关注雲闪世界。(亚马逊aws谷歌GCP服务协助解决云计算及产业相关解决方案)

订阅频道(https://t.me/awsgoogvps_Host)
 TG交流群(t.me/awsgoogvpsHost)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值