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

1 | Browser -> [HTTP / FastAPI] -> Runner -> Agent -> Prompt -> ReAct -> LLM -> Tool -> Response |
这一站我们站在浏览器和服务器之间,看看你按下回车那一刻到底发生了什么——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
3curl -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
2Request --> 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"])
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
13async 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
7data: {"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
3curl -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
5data: {"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 请求的完整生命周期。
步骤:
- 打开浏览器,访问
http://127.0.0.1:8088/ - 按 F12 打开开发者工具,切换到 Network(网络)面板
- 在聊天框输入
Hi,按回车 - 在 Network 面板中找到发往
/console/chat的请求 - 点击它,查看 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。那个函数做了什么?请求接下来去了哪里?下一章我们跟着它继续走。