JavaScript-疲劳-HTMX-是构建-ChatGPT-的全部所需---第二部分

JavaScript 疲劳:HTMX 是构建 ChatGPT 的全部所需 — 第二部分

原文:towardsdatascience.com/javascript-fatigue-you-dont-need-js-to-build-chatgpt-part-2/

第一部分, 我们展示了如何利用 HTMX 为我们的 HTML 元素添加交互性。换句话说,无需 JavaScript 也能实现 JavaScript。为了说明这一点,我们开始构建一个简单的聊天程序,该程序将返回模拟的 LLM 响应。在这篇文章中,我们将扩展聊天机器人的功能并添加几个特性,其中之一是流式传输,与之前构建的同步聊天相比,这在用户体验方面是一个重大的改进。

  • ✅ 使用 SSE 实现实时流式传输

  • ✅ 为多个用户构建基于会话的架构

  • ✅ 使用 asyncio.Queue 的异步协调

  • ✅ 使用专用 SSE 处理的干净 HTMX 模式

  • ✅ 一个谷歌搜索代理,用新鲜数据回答查询

  • ✅ 几乎不需要 JavaScript

今天我们将构建以下内容:

从同步通信到异步

我们之前构建的内容利用了非常基本的网络功能,利用表单。我们的通信是同步的,这意味着我们不会得到任何东西,直到服务器完成。我们发出请求,等待完整响应,然后显示它。在这两者之间,我们只是…等待。

但现代聊天机器人工作方式不同,它们通过提供异步通信能力。这是通过流式传输实现的:我们获取更新和部分响应,而不是等待完整响应。当响应过程需要时间时,这尤其有用,对于 LLM 来说,答案通常很大。

SSE 与 Websockets 对比

SSE (服务器端事件)Websockets 是客户端和服务器之间两种实时数据交换协议。

Websockets 允许全双工连接:这意味着浏览器和服务器可以同时发送和接收数据。这通常用于在线游戏、聊天应用程序和协作工具(想想 Google Sheets)。

SSE 是单向的,只允许从服务器到客户端的单向对话。这意味着客户端不能通过此协议向服务器发送任何内容。如果 Websockets 是一个双向电话对话,人们可以同时说话和听,那么 SSE 就像听收音机一样。SSE 通常用于发送通知、更新金融应用程序中的图表或新闻源。

那么,我们为什么选择 SSE 呢?因为在我们的用例中,我们不需要全双工,简单的 HTTP(这不是 Websockets 的工作方式)就足够我们的用例了:我们发送数据,接收数据。SSE 意味着我们将以流的形式接收数据,不需要更多。

概述

下面是我们将要构建的流程:

  • 浏览器将此信息添加到 DOM 中

  • 用户输入查询

  • 服务器接收查询并将其发送到 LLM

  • LLM 开始生成内容

  • 对于每块内容,服务器立即返回它

后端

后端将分两步进行:

  • 一个 POST 端点,用于接收消息,但不返回任何内容

  • 一个 GET 端点,用于读取队列并生成输出流。

在我们的演示中,首先我们将通过重复用户输入来创建一个假的 LLM 响应,这意味着流中的单词将完全与用户输入相同。

为了保持整洁,我们需要按用户会话区分消息流(队列),否则我们最终会混淆对话。因此,我们将创建一个会话字典来托管我们的队列。

接下来,我们需要告诉后端在流式传输我们的响应之前等待队列填满。如果我们不这样做,我们将遇到并发运行或时序问题:SSE 在客户端启动,队列是空的,SSE 关闭,用户输入了一条消息,但…太晚了!

解决方案:异步队列!使用异步队列有几个优点:

  • 如果队列有数据:立即返回

  • 如果队列是空的:挂起执行,直到调用queue.put()为止

  • 多个消费者:每个消费者都得到自己的数据

  • 线程安全:没有竞态条件

我知道你迫不及待地想知道更多,所以下面是下面的代码:

from fastapi import FastAPI, Request, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, StreamingResponse
import asyncio
import time
import uuid

app = FastAPI()
templates = Jinja2Templates("templates")

# This object will store session id and their corresponding value, an async queue.
sessions = dict()

@app.get("/")
async def root(request: Request):
    session_id = str(uuid.uuid4())
    sessions[session_id] = asyncio.Queue()
    return templates.TemplateResponse(request, "index.html", context={"session_id": session_id})

@app.post("/chat")
async def chat(request: Request, query: str=Form(...), session_id: str=Form(...)):
    """ Send message to session-based queue """

    # Create the session if it does not exist
    if session_id not in sessions:
        sessions[session_id] = asyncio.Queue()

    # Put the message in the queue
    await sessions[session_id].put(query)

    return {"status": "queued", "session_id": session_id}

@app.get("/stream/{session_id}")
async def stream(session_id: str):

    async def response_stream():

        if session_id not in sessions:
            print(f"Session {session_id} not found!")
            return

        queue = sessions[session_id]

        # This BLOCKS until data arrives
        print(f"Waiting for message in session {session_id}")
        data = await queue.get()
        print(f"Got message: {data}")

        message = ""
        await asyncio.sleep(1)
        for token in data.replace("\n", " ").split(" "):
            message += token + " "
            data = f"""data: <li class='mb-6 ml-[20%]'> <div class='font-bold text-right'>AI</div><div>{message}</div></li>\n\n"""
            yield data
            await asyncio.sleep(0.03)

        queue.task_done()

    return StreamingResponse(response_stream(), media_type="text/event-stream") 

让我们在这里解释几个关键概念。

会话隔离

每个用户都得到自己的消息队列很重要,这样就不会混淆对话。我们将通过使用会话字典来处理这个问题。

注意:对于这个演示,我们将会话存储在全局字典中。在生产中,我们会使用 Redis 或数据库,否则,我们的服务器内存将无限期地填满。

在下面的代码中,我们看到在页面加载时创建了一个新的会话 ID,并存储在会话字典中。重新加载页面将启动一个新的会话,我们并没有持久化消息队列,但我们可以通过数据库等来实现。

# This object will store session id and their corresponding value, an async queue.
sessions = dict()

@app.get("/")
async def root(request: Request):
    session_id = str(uuid.uuid4())
    sessions[session_id] = asyncio.Queue()
    return templates.TemplateResponse(request, "index.html", context={"session_id": session_id}) 

阻塞协调

我们需要控制 SSE 发送的顺序和接收用户查询的顺序。在后台,顺序是:

  1. 接收用户消息

  2. 创建消息队列并填充它

  3. 从队列中发送消息以进行流式响应

如果不这样做,可能会导致不希望的行为,即首先读取(空的)消息队列,然后使用用户的查询填充它。

控制顺序的解决方案是使用asyncio.Queue。此对象将被使用两次:

  • 当我们将新消息插入队列时。插入消息将“唤醒”SSE 端点的轮询
await sessions[session_id].put(query)
  • 当我们从队列中拉取消息时。在这行代码中,代码被阻塞,直到队列发出“嘿,我有新数据!”的信号:
data = await queue.get()

这种模式提供了几个优点:

  • 每个用户都有自己的队列

  • 没有竞态条件风险

流式模拟

在这篇文章中,我们将通过将用户的查询拆分成单词并逐个返回这些单词来模拟 LLM 响应。在第三部分,我们将实际上将一个真实的 LLM 连接到它。

流式处理是通过 FastAPI 中的StreamingResponse对象来处理的。该对象期望一个异步生成器,它将产生数据,直到生成器结束。我们必须使用yield关键字而不是return关键字,否则我们的生成器将在第一次迭代后停止。

让我们分解我们的流式函数:

首先,我们需要确保我们有当前会话的队列,我们将从中拉取消息:

if session_id not in sessions:
    print(f"Session {session_id} not found!")
    return

queue = sessions[session_id]

接下来,一旦我们有了队列,如果队列中有消息,我们将从队列中拉取消息;如果没有,代码将暂停并等待消息到达。这是我们的函数中最重要的部分:

# This BLOCKS until data arrives
print(f"Waiting for message in session {session_id}")
data = await queue.get()
print(f"Got message: {data}")

为了模拟流,我们现在将消息分成单词(在这里称为tokens),并添加一些时间休眠来模拟从 LLM(asyncio.sleep部分)生成文本的过程。注意我们实际产生的数据实际上是 HTML 字符串,封装在以“data:”开头的字符串中。这就是 SSE 消息的发送方式。你也可以选择使用“event:”元数据来标记你的消息。一个例子是:

event: my_custom_event
data: <div>Content to swap into your HTML page.</div> 

让我们看看我们如何在 Python 中实现它(对于纯粹主义者,使用 Jinja 模板来渲染 HTML 而不是字符串:)

message = ""

# First pause to let the browser display "Thinking when the message is sent"
await asyncio.sleep(1)

# Simulate streaming by splitting message in words
for token in data.replace("\n", " ").split(" "):

    # We append tokens to the message
    message += token + " "

    # We wrap the message in HTML tags with the "data" metadata
    data = f"""data: <li class='mb-6 ml-[20%]'><div class='font-bold text-right'>AI</div><div>{message}</div></li>\n\n"""
    yield data

    # Pause to simulate the LLM generation process
    await asyncio.sleep(0.03)

queue.task_done()

前端

我们的前端有两个任务:将用户查询发送到后端,并监听特定通道(session_id)上的 SSE 消息。为此,我们应用了一个名为“概念分离”的概念,意味着每个 HTMX 元素只负责一项工作。

  • 表单发送用户输入

  • sse 监听器处理流式传输

  • ul 聊天显示消息

为了发送消息,我们将在表单中使用标准的textarea输入。HTMX 魔法就在下面:

<form 
    id="userInput" 
    class="flex max-h-16 gap-4"
    hx-post="/chat" 
    hx-swap="none"
    hx-trigger="click from:#submitButton" 
    hx-on::before-request="
        htmx.find('#chat').innerHTML += `<li class='mb-6 justify-start max-w-[80%]'><div class='font-bold'>Me</div><div>${htmx.find('#query').value}</div></li>`;
        htmx.find('#chat').innerHTML += `<li class='mb-6 ml-[20%]'><div class='font-bold text-right'>AI</div><div class='text-right'>Thinking...</div></li>`;
        htmx.find('#query').value = '';
    "
>
    <textarea 
        id="query" 
        name="query"
        class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[44px] max-h-[200px]"
        placeholder="Write a message..." 
        rows="4"></textarea>
    <button 
        type="submit" 
        id="submitButton"
        class="inline-flex max-h-16 items-center justify-center rounded-md bg-neutral-950 px-6 font-medium text-neutral-50 transition active:scale-110"
    >Sends</button>
</form> 

如果你记得第一部分的文章第一部分,我们有一些 HTMX 属性值得解释:

  • hx-post: 表单数据将被提交的端点。

  • hx-swap: 设置为无,因为在我们这个案例中,端点不返回任何数据。

  • hx-trigger: 指定哪个事件将触发请求

  • hx-on::before-request: 一个非常轻量级的部分,使用 javascript 为应用添加一些活力。我们将把用户的请求添加到聊天列表中,并在我们等待 SSE 消息流式传输时向用户显示“思考中”的消息。这比盯着空白页面要好。

值得注意的是,我们实际上向后端发送了两个参数:用户的输入和会话 ID。这样,消息就会在后台的正确队列中插入。

然后,我们定义了另一个专门用于监听 SSE 消息的组件。

<!-- Messages will be added to this list-->
<div class="mb-auto max-h-[80%] overflow-auto">
    <ul id="chat" class="rounded-2xl p-4 mb-16 justify-start">
    </ul>
</div>

<!-- SSE listened (message buffer)-->
<div 
    hx-ext="sse" 
    sse-connect="/stream/{{ session_id }}" 
    sse-swap="message" 
    hx-swap="outerHTML scroll:bottom"
    hx-target="#chat>li:last-child" 
    style="display: none;"
></div>

此组件将监听 /stream 端点,并将其会话 id 传递以仅监听此会话的消息。hx-target 告诉浏览器将数据添加到聊天中的最后一个 li 元素。hx-swap 指定数据实际上是要替换整个当前的 li 元素。这就是我们的流式效果将如何工作:用最新的消息替换当前消息。

注意:可以使用其他方法来替换 DOM 的特定元素,例如带外(OOB)交换。它们的工作方式略有不同,因为它们需要在 DOM 中查找特定的 id。在我们的情况下,我们故意没有为每个已编写的列表元素分配 id

使用 Google 代理开发工具包的代理式聊天机器人

现在是时候用真实的 LLM 替换我们的虚拟流式端点了。为了实现这一点,我们将构建一个使用 Google ADK 的代理,配备工具和内存来获取信息和记住对话细节。

代理的简要介绍

你可能已经知道什么是 LLM,至少我假设你知道。截至今天,LLM 的主要缺点是 LLM 本身无法访问实时信息:它们的知识在训练的那一刻就冻结了。另一个缺点是它们无法访问其训练范围之外的信息(例如,你公司的内部数据)

代理是一种可以推理、行动和观察的 AI 应用程序。推理部分由 LLM(“大脑”)处理。代理的“手”就是我们所说的“工具”,可以采取多种形式:

  • 一个 Python 函数,例如用于获取 API

  • 一个 MCP 服务器,这是一个允许代理通过标准化接口连接到 API 的标准(例如,无需自己编写 API 连接器即可访问所有 Gsuite 工具)

  • 其他代理(在这种情况下,这种模式被称为代理委派,其中路由器或主代理控制不同的子代理)

在我们的演示中,为了使事情非常简单,我们将使用一个非常简单的代理,它可以使用一个工具:Google 搜索。这将使我们能够获取新鲜信息并确保其可靠性(至少我们希望 Google 搜索的结果是……)

在 Google ADK 世界中,代理需要基本信息:

  • 名称和描述,主要用于文档目的

  • 指令:定义代理行为的提示(工具使用、输出格式、要遵循的步骤等)

  • 工具:代理可以使用以实现其目标的函数 / MCP 服务器 / 代理

还有其他关于内存和会话管理的概念,但这些都超出了范围。

不再拖延,让我们定义我们的代理!

一个流式 Google 搜索代理

from google.adk.agents import Agent
from google.adk.agents.run_config import RunConfig, StreamingMode
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from google.adk.tools import google_search

# Define constants for the agent
APP_NAME = "default"  # Application
USER_ID = "default"  # User
SESSION = "default"  # Session
MODEL_NAME = "gemini-2.5-flash-lite"

# Step 1: Create the LLM Agent
root_agent = Agent(
    model=MODEL_NAME,
    name="text_chat_bot",
    description="A text chatbot",
    instruction="You are a helpful assistant. Your goal is to answer questions based on your knowledge. Use your Google Search tool to provide the latest and most accurate information",
    tools=[google_search]
)

# Step 2: Set up Session Management
# InMemorySessionService stores conversations in RAM (temporary)
session_service = InMemorySessionService()

# Step 3: Create the Runner
runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)

Runner 对象充当你与代理之间的协调者。

接下来,我们(重新)定义我们的 /stream 端点。我们首先检查代理是否存在会话,否则创建它:

 # Attempt to create a new session or retrieve an existing one
        try:
            session = await session_service.create_session(
                app_name=APP_NAME, user_id=USER_ID, session_id=session_id
            )
        except:
            session = await session_service.get_session(
                app_name=APP_NAME, user_id=USER_ID, session_id=session_id
            )

然后,我们以异步方式将用户查询传递给代理,以获取流回:

 # Convert the query string to the ADK Content format
        query = types.Content(role="user", parts=[types.Part(text=query)])

        # Stream the agent's response asynchronously
        async for event in runner.run_async(
            user_id=USER_ID, session_id=session.id, new_message=query, run_config=RunConfig(streaming_mode=StreamingMode.SSE)
        ): 

接下来有一个细微之处。当生成响应时,代理可能会输出一个双行断行符“\n\n”。这是问题所在,因为 SSE 事件以这个符号结束。因此,在字符串中包含双行断行符意味着:

  • 您当前的消息将被截断

  • 您的下一条消息将格式错误,并且 SSE 流将停止

您可以亲自尝试。为了解决这个问题,我们将使用一个小技巧,以及另一个小技巧来格式化列表元素(我使用 Tailwind CSS,它覆盖了某些 CSS 规则)。这个技巧是:

 if event.partial:
                message += event.content.parts[0].text

                # Hack here
                html_content = markdown.markdown(message, extensions=['fenced_code']).replace("\n", "<br/>").replace("<li>", "<li class='ml-4'>").replace("<ul>", "<ul class='list-disc'>")

                full_html = f"""data: <li class='mb-6 ml-[20%]'> <div class='font-bold text-right'>AI</div><div>{html_content}</div></li>\n\n"""

                yield full_html

这样,我们确保没有双行断行符会打断我们的 SSE 流。

路由的完整代码如下:

@app.get("/stream/{session_id}")
async def stream(session_id: str):

    async def response_stream():

        if session_id not in sessions:
            print(f"Session {session_id} not found!")
            return

        # Attempt to create a new session or retrieve an existing one
        try:
            session = await session_service.create_session(
                app_name=APP_NAME, user_id=USER_ID, session_id=session_id
            )
        except:
            session = await session_service.get_session(
                app_name=APP_NAME, user_id=USER_ID, session_id=session_id
            )

        queue = sessions[session_id]

        # This BLOCKS until data arrives
        print(f"Waiting for message in session {session_id}")
        query = await queue.get()
        print(f"Got message: {query}")

        message = ""

        # Convert the query string to the ADK Content format
        query = types.Content(role="user", parts=[types.Part(text=query)])

        # Stream the agent's response asynchronously
        async for event in runner.run_async(
            user_id=USER_ID, session_id=session.id, new_message=query, run_config=RunConfig(streaming_mode=StreamingMode.SSE)
        ):
            if event.partial:
                message += event.content.parts[0].text

                html_content = markdown.markdown(message, extensions=['fenced_code']).replace("\n", "<br/>").replace("<li>", "<li class='ml-4'>").replace("<ul>", "<ul class='list-disc'>")

                full_html = f"""data: <li class='mb-6 ml-[20%]'> <div class='font-bold text-right'>AI</div><div>{html_content}</div></li>\n\n"""

                yield full_html

        queue.task_done()

    return StreamingResponse(response_stream(), media_type="text/event-stream")

就这样!您将能够与您的聊天进行对话!

附加说明:以下是一个 CSS 片段,用于格式化由 LLM 提供的代码块,这是 HTML 代码:

pre, code {
      background-color: black;
      color: lightgrey;
      padding: 1%;
      border-radius: 10px;
      white-space: pre-wrap;
      font-size: 0.8rem;
      letter-spacing: -1px;
    }

格式化后的代码片段将看起来像这样:

图片

结论

不到 200 行代码 (LoC),我们能够编写一个聊天应用,具有以下工作流程:从服务器流式传输响应并利用 SSE 和 HTMX 将其非常优雅地显示出来。

首先,我们展示了如何使用非常少的纯 JavaScript 和主要不使用重量级的 JS 框架,仅通过使用 Python 和 HTML 来开发聊天机器人应用。我们涵盖了诸如

  • 服务器端渲染

  • 服务器发送事件 (SSE)

  • 异步流

  • 代理

在一个神奇的库,HTMX 的帮助下。

本文的主要目的也是展示,对于非 JavaScript 开发者来说,Web 应用的世界并非遥不可及!实际上,有一个非常强大且合理的理由不总是在 Web 开发中使用 JavaScript 框架,尽管 JavaScript 是一种强大的语言,但今天的我感觉它有时被过度使用,取代了更简单但同样稳健的方法。服务器端与客户端应用的争论已经持续很长时间,而且还没有结束,但我希望这次阅读能对你们中的某些人起到启发作用,并开启新的视角。

htmx.org/了解更多信息

开心编码。

posted @ 2026-03-27 09:53  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报