JavaScript-疲劳-HTMX-是构建-ChatGPT-的全部所需---第二部分
JavaScript 疲劳:HTMX 是构建 ChatGPT 的全部所需 — 第二部分
原文:
towardsdatascience.com/javascript-fatigue-you-dont-need-js-to-build-chatgpt-part-2/
-
✅ 使用 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 发送的顺序和接收用户查询的顺序。在后台,顺序是:
-
接收用户消息
-
创建消息队列并填充它
-
从队列中发送消息以进行流式响应
如果不这样做,可能会导致不希望的行为,即首先读取(空的)消息队列,然后使用用户的查询填充它。
控制顺序的解决方案是使用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/了解更多信息
开心编码。

浙公网安备 33010602011771号