第 1 章:浏览器按下回车之后

个人公众号
1
2
3
Browser -> [HTTP / FastAPI] -> Runner -> Agent -> Prompt -> ReAct -> LLM -> Tool -> Response
^
you are here

这一站我们站在浏览器和服务器之间,看看你按下回车那一刻到底发生了什么——HTTP 请求长什么样、FastAPI 怎么接住它、中间件链怎么层层过滤、最终怎么把请求交给处理函数。


问题

你在浏览器聊天框里输入了一句话,按下回车。屏幕上什么都没发生,过了半秒钟,AI 的回复一个字一个字地冒出来。

这半秒钟里,发生了什么?

第一条线索:浏览器不是直接跟”AI”对话的。它发送了一个 HTTP 请求,目标是 http://127.0.0.1:8088/api/console/chat。接下来我们就跟这个请求走。

术语其实很简单

术语:HTTP(HyperText Transfer Protocol,超文本传输协议)
想象你给朋友寄快递——包裹上要写收件地址、收件人姓名、里面装了什么。HTTP 就是”快递单”的格式规范。浏览器是寄件人,服务器是收件人,请求内容是包裹。POST 是”寄一个有内容的包裹”,GET 是”请帮我查一下这个地址的信息”。

术语:FastAPI(Python Web 框架)
想象一家餐厅的前台——客人(HTTP 请求)来了,前台根据菜单(路由表)把客人领到对应的服务员(处理函数)面前。FastAPI 就是这个前台,它写得又快又准——请求来了,一眼就知道该找谁。

术语:中间件(Middleware)
想象进办公楼要过三道关卡:第一道检查工牌,第二道测体温,第三道登记访客。中间件就是这些关卡——请求在到达处理函数之前,必须依次穿过每一层中间件。每层可以决定放行、拦截或修改请求。反过来,响应返回时也要逆序经过每一层。这种”一层包一层”的结构叫”洋葱模型”。

术语:SSE(Server-Sent Events,服务器推送事件)
想象你打开一个水龙头,水一直流。SSE 就是服务器端的水龙头——服务器不断往里”倒”数据,浏览器不断”接”数据。和 WebSocket 不同,SSE 是单向的:只有服务器能往客户端发数据。但对 AI 聊天来说,我们只需要服务器往浏览器推文字,SSE 正好够用。

探索

浏览器发出了什么?

按下回车后,浏览器会向 QwenPaw 的服务器发送一个 HTTP POST 请求。我们可以用 curl 模拟它:

1
2
3
curl -X POST http://127.0.0.1:8088/api/console/chat \
-H "Content-Type: application/json" \
-d '{"input": [{"content": [{"text": "Hello"}]}]}'

这个请求有三个关键部分:

  • URL/api/console/chat——告诉服务器”我要发消息”
  • 方法POST——意思是”我要提交数据”
  • 请求体:一个 JSON,包含你的消息内容

浏览器的开发者工具(F12 → Network 面板)也能看到这个请求。

请求到达 FastAPI

HTTP 请求先到达 Uvicorn(Python Web 服务器),Uvicorn 再把它交给 FastAPI 应用。

FastAPI 应用在 src/qwenpaw/app/_app.py 中创建:

1
2
3
4
5
# _app.py 中的核心行
app = FastAPI(
lifespan=lifespan, # 启动时执行的初始化函数
docs_url="/docs", # API 文档地址
)

app 就是那个”前台”——所有请求都先到它这里。lifespan 是服务器启动时执行的函数,负责创建各种管理器(Provider、Agent 等)。

中间件链——请求的第一道关卡

请求到了 app 之后,不是直接去找处理函数。它要先穿过中间件链。看看 _app.py 里中间件的注册顺序:

1
2
3
4
# 注册中间件(注意:先注册的在最外层)
app.add_middleware(AgentContextMiddleware) # 识别当前请求对应哪个 Agent
app.add_middleware(AuthMiddleware) # 检查是否需要认证
# 如果配置了 CORS_ORIGINS,还会注册 CORSMiddleware

中间件的工作方式像洋葱——请求从外层进入,逐层穿过,到达处理函数后再逐层返回:

1
2
Request --> AgentContextMiddleware --> AuthMiddleware --> CORSMiddleware --> Handler
<-- AgentContextMiddleware <-- AuthMiddleware <------------------ Response

AgentContextMiddleware 做了什么?它检查 URL 路径中是否包含 Agent ID(比如 /api/agents/default/chats),或者检查请求头 X-Agent-Id。找到后存到上下文变量里——后续代码就知道该用哪个 Agent 了:

1
2
3
4
# agent_scoped.py 中的核心逻辑(伪代码)
agent_id = path中的agentId部分 or X-Agent-Id请求头
if agent_id:
set_current_agent_id(agent_id) # 存到上下文,后续代码可用

AuthMiddleware 做了什么?检查请求是否需要认证。默认情况下认证是关闭的——QWENPAW_AUTH_ENABLED 环境变量没设置的话,所有请求直接放行:

1
2
3
4
5
6
7
8
9
# auth.py 中的放行条件(伪代码)
if not is_auth_enabled() or not has_registered_users():
return 放行 # 默认情况:认证没开,直接过
if path是公开路径 or 请求来自localhost:
return 放行
if token有效:
return 放行
else:
return 401 Unauthorized

注意:默认配置下 AuthMiddleware 直接放行所有请求。安全认证是可选功能。

路由匹配——找到正确的处理函数

穿过中间件后,请求到达路由层。FastAPI 根据 URL 找到对应的处理函数。

看看 _app.py 里的路由注册:

1
2
3
4
# _app.py 中的路由注册
app.include_router(api_router, prefix="/api") # /api/*
app.include_router(agent_scoped_router, prefix="/api") # /api/agents/{agentId}/*
app.include_router(agent_app.router, prefix="/api/agent") # /api/agent/*

api_router 是一个聚合路由——它把十几个子路由器打包在一起。我们关心的 console_router 就是其中之一,定义在 routers/console.py

1
2
3
4
5
6
7
# console.py
router = APIRouter(prefix="/console", tags=["console"])

@router.post("/chat")
async def post_console_chat(request_data, request):
"""处理聊天请求,返回流式响应"""
...

路径拼装:/api + /console + /chat = /api/console/chat。请求被路由到了 post_console_chat() 函数。

就像快递分拣:先按省分(/api),再按市分(/console),最后按街道分(/chat)。层层缩小范围,找到唯一的处理函数。

处理函数做了什么?

post_console_chat() 是本章最重要的函数。看看它的核心流程(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
async def post_console_chat(request_data, request):
# 1. 找到对应的 Agent(工作空间)
workspace = await get_agent_for_request(request)
# 2. 获取 Console Channel(网页聊天通道)
console_channel = await workspace.channel_manager.get_channel("console")
# 3. 解析请求体,提取消息内容
native_payload = _extract_session_and_payload(request_data)
# 4. 获取或创建聊天会话
chat = await workspace.chat_manager.get_or_create_chat(...)
# 5. 启动流式任务
queue, _ = await tracker.attach_or_start(chat.id, native_payload, ...)
# 6. 返回 SSE 流式响应
return StreamingResponse(event_generator(), media_type="text/event-stream")

六个步骤:找 Agent → 找通道 → 解析消息 → 创建会话 → 启动任务 → 流式返回。

前四步是”准备”,第五步是”开始工作”,第六步是”把结果送出去”。第五步里的 stream_one 才是真正让 Agent 干活的地方——我们在第 2 章会跟进去看。

SSE 流式响应——为什么能看到逐字输出?

AI 的回答不是一次性全部返回的。它像说话一样,一个词一个词地”蹦”出来。这个效果靠 SSE:

1
2
3
4
5
6
7
data: {"type": "text", "content": "你"}

data: {"type": "text", "content": "好"}

data: {"type": "text", "content": "!"}

data: {"type": "done"}

每一行以 data: 开头,后面跟着 JSON,两个换行符分隔。浏览器收到一条就显示一条。

event_generator() 是这个过程的推手:

1
2
3
4
5
# console.py 中的 event_generator(简化版)
async def event_generator():
stream_it = tracker.stream_from_queue(queue, chat.id)
async for event_data in stream_it:
yield event_data # 每收到一条就立刻发给浏览器

它从 TaskTracker 的队列里持续读数据,读到一条就推给浏览器。Agent 在后台不断往队列里写数据,event_generator 在前台不断读出来——一个生产者-消费者模型。

TaskTracker 还支持断线重连:所有已发送的事件都会被存一份。如果中途断开连接,重新连接时先把缓冲里的事件回放给你,再继续接收新的。

实验

curl 发送一条请求,观察 SSE 的原始格式。确保 QwenPaw 正在运行(qwenpaw app),然后执行:

1
2
3
curl -N -X POST http://127.0.0.1:8088/api/console/chat \
-H "Content-Type: application/json" \
-d '{"input": [{"content": [{"text": "Hi"}]}]}'

-N 告诉 curl 不要缓冲,实时显示收到的数据。

预期输出:

1
2
3
4
5
data: {"type":"text","content":"你好"}

data: {"type":"text","content":"!"}

data: {"type":"done","chat_id":"..."}

看到这样的 SSE 事件流就说明请求成功穿越了 HTTP → FastAPI → 中间件 → 路由 → 处理函数,并且响应通过 SSE 流回来了。

工程权衡

为什么用 SSE 而不是 WebSocket?

SSE 是单向通道(服务器→浏览器),WebSocket 是双向通道。QwenPaw 的聊天场景中,浏览器只需要接收服务器的推送——发消息用一个普通的 POST 请求就够了。SSE 更简单:不需要握手协议、不需要维护连接状态、HTTP 基础设施天然支持(代理、负载均衡都能处理 SSE)。用 curl 就能调试 SSE;调试 WebSocket 需要专门的工具。

WebSocket 适合需要双向实时通信的场景(比如在线游戏、协作编辑器)。QwenPaw 唯一使用 WebSocket 的地方是实时语音对话(voice_router),因为语音需要双向实时传输。普通文字聊天,SSE 完全够用。

为什么中间件是洋葱模型?

洋葱模型让每一层中间件都可以在请求进来时做处理、在响应出去时再做处理。比如 AuthMiddleware 可以在请求进来时检查认证、在响应出去时添加安全头。如果认证逻辑写在每个路由函数里,就要在几十个函数里重复写同样的检查代码,忘了加的那个就是安全漏洞。中间件让”横切关注点”(认证、日志、CORS)集中在一处。

常见误区

误区:为什么不把认证逻辑直接写在路由函数里?

有人觉得 post_console_chat() 开头加个 if not authenticated: return 401 不就行了?

问题是:QwenPaw 有几十个路由函数。如果每个都手动加认证检查,忘了加的那个就是安全漏洞。而且将来改认证逻辑(比如从 session 切换成 JWT),要改几十个地方。中间件让认证逻辑集中在一处,自动覆盖所有路由。改一次,处处生效。

动手环节

任务:用浏览器开发者工具观察 HTTP 请求的完整生命周期。

步骤

  1. 打开浏览器,访问 http://127.0.0.1:8088/
  2. 按 F12 打开开发者工具,切换到 Network(网络)面板
  3. 在聊天框输入 Hi,按回车
  4. 在 Network 面板中找到发往 /console/chat 的请求
  5. 点击它,查看 Headers(请求头)、Payload(请求体)、Response(响应)

预期输出

  • Headers 中看到 Content-Type: application/json
  • Response 中看到 data: {"type":"text",...} 格式的 SSE 事件流
  • 请求方法是 POST

自检

  • 找到了 /console/chat 请求(不是别的 URL)
  • 看到了 SSE 格式的响应(以 data: 开头的行)
  • 知道这个请求被路由到了 post_console_chat() 函数

请求从浏览器到 FastAPI,再到处理函数,这条路走通了。但 post_console_chat() 里只做了”准备工作”——它把消息转交给了 stream_one。那个函数做了什么?请求接下来去了哪里?下一章我们跟着它继续走。