Agent 一跑就是几十秒前端却只有一个转圈的 loading用户不知道模型是在思考、在调用工具还是已经卡死了——黑盒等待几乎是所有 Agent 产品的第一道体验硬伤。常规做法是轮询接口或等最终结果一次性返回前者要自己写重试与状态合并还多浪费请求后者把思考过程、工具调用这些真正体现价值的中间环节全部丢掉出了问题也分不清是模型慢还是链路断。本文围绕一套可直接落地的方案展开事件协议 SSE 流式推送 WebSocket 双向控制 前端状态机从后端如何吐事件讲到前端如何展示中间状态。一、痛点黑盒等待让用户以为产品卡死先把 Agent 一次长任务的时间线摊开看看用户眼里它到底是什么样子整体返回的假象一次性接口要等整段生成才响应用户盯着转圈十几秒最常见的动作就是直接关掉页面。中间过程全部丢失思考、检索、工具调用这些体现产品价值的环节最终聚合结果里一句都看不到。排障只能靠猜前端只有成功或失败两个信号出问题时分不清是模型慢、网关断还是渲染卡住。核心结论把「等待」变成「过程」是 Agent 产品体验的第一道分水岭。二、选型SSE 与 WebSocket 的适用边界两条通道都能把服务端数据推给浏览器但脾气完全不同先按场景对号入座维度SSEWebSocket协议形态基于 HTTP 的单向流独立的双向长连接数据格式纯文本事件流文本帧或二进制帧断线重连浏览器原生自动重连需要自己写重连逻辑代理穿透走标准 HTTP兼容性好部分网关需显式升级协议典型场景模型逐字吐出回答用户中途取消、追问单向吐字优先 SSE模型输出本质是一条单向流SSE 复用现有 HTTP 基础设施还自带重连。需要上行才用 WebSocket只有当客户端要中途发指令时引入 WS 的复杂度才划算。混合方案最省心出流走 SSE控制命令另开一个轻量接口比全量改造 WS 便宜得多。核心结论默认用 SSE只有真正需要客户端上行时才升级 WebSocket。三、契约先钉死事件类型再动手写代码流式接口最容易翻车的不是传输而是前后端对事件的约定先把协议写在纸上事件名data 载荷前端动作token{text:你}追加到正文末尾status{stage:tool_call}切换阶段徽标tool_result{name:search,rows:5}插入折叠工具卡片done{tokens:312}关闭连接并统计一条流多种事件token只管文字status只管阶段tool_result只管产物职责互不串台。每个事件带 id断线重连时用Last-Event-ID续传前端按 id 去重避免重复渲染。核心结论协议先于代码事件类型定不清楚后面全是返工。四、后端FastAPI 逐条吐出 token 与阶段事件协议定完就写服务端下面这段代码可直接跑在 FastAPI 环境里模拟 Agent 的逐字输出与工具调用import asyncio, json from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() def sse(event: str, data: dict) - str: # 按 SSE 规范拼一条事件结尾必须是两个换行 return fevent: {event}\ndata: {json.dumps(data, ensure_asciiFalse)}\n\n async def agent_stream(prompt: str): yield sse(status, {stage: thinking}) for ch in 我先查资料再分点回答: await asyncio.sleep(0.05) yield sse(token, {text: ch}) yield sse(status, {stage: tool_call, name: search}) yield sse(tool_result, {name: search, rows: 5}) yield sse(done, {tokens: 312}) app.get(/chat) async def chat(prompt: str): return StreamingResponse( agent_stream(prompt), media_typetext/event-stream, headers{Cache-Control: no-cache, X-Accel-Buffering: no}, )事件分行发送每条事件以空行结尾浏览器才能识别为一条完整消息立即触发回调。顺手关掉网关缓冲X-Accel-Buffering: no让 Nginx 直接放行省一次踩坑。整体链路是异步生成器逐条 yield → SSE 规范格式 → 浏览器按事件名分发。五、前端EventSource 驱动渲染状态机服务端会吐了前端要把它还原成打字机效果和阶段徽标const state { answer: , stage: thinking, tools: [] }; const es new EventSource(/chat?prompt encodeURIComponent(q)); [token, status, tool_result, done].forEach((name) { es.addEventListener(name, (e) { const d JSON.parse(e.data); if (name token) state.answer d.text; if (name status) state.stage d.stage; if (name tool_result) state.tools.push(d); if (name done) es.close(); render(state); // 只做最小更新避免整段重排 }); }); es.onerror () (state.stage reconnecting);按事件名分发一个监听器管一类事件状态机只维护answer / stage / tools三个字段。注意方法限制EventSource只支持 GET需要 POST 时改用fetch配合ReadableStream手动解析。整体链路是事件驱动渲染 → 单一状态源 → DOM 最小更新。六、升级WebSocket 承载可中途干预的会话当用户会随时喊停、追问或注入新指令时SSE 的单向车道就不够用了app.websocket(/ws) async def ws_channel(ws: WebSocket): await ws.accept() running None while True: cmd await ws.receive_json() if cmd[type] ask: # run_agent 内部按 token 调用 ws.send_json 推送事件 running asyncio.create_task(run_agent(cmd[prompt], ws)) elif cmd[type] cancel and running: running.cancel() await ws.send_json({event: status, stage: cancelled}) elif cmd[type] ping: await ws.send_json({event: pong})下行复用同一套事件WS 帧里仍然发token / status / done前端消费逻辑一行都不用改。上行只承载控制指令ask / cancel / ping三类消息就覆盖了绝大多数交互需求。下行流式 上行控制才是真正闭环的 Agent 会话。七、中间状态把思考与工具调用做成可视进度拉开产品差距的不是文字本身而是让用户看见 Agent 此刻在干什么阶段徽标thinking / searching / writing三态用不同色点提示用户一眼知道卡在哪一步。工具卡片tool_result折叠展示入参与返回行数比一句「已为你查询」更有说服力。思考可展开推理片段默认收起展开后能看到完整链路也方便事后复盘。中间状态不是装饰它是用户建立信任的唯一证据。八、可靠性断线重连与事件续传排错清单流式链路一拉长断连、重复、丢事件都会找上门按这张表逐一排查现象常见原因处理方式连上但一直没数据响应被代理缓冲关闭proxy_buffering重连后文字重复未按事件 id 去重记录Last-Event-ID约 30 秒自动断开网关 idle timeout注释帧心跳保活中文变成乱码缺少字符集声明media_type加charsetutf-8心跳保活每 15 秒发一条: ping注释帧既不触发渲染也不算业务事件。断点续传重连请求带上Last-Event-ID服务端从该 id 之后补发前端按 id 去重。核心结论排流式问题先确认数据有没有出服务器再看前端有没有正确解析。九、性能缓冲、背压与反代的三处坑流量一上来最先暴露的就是缓冲和背压三处配置决定它是不是真的实时网关缓冲默认proxy_buffering on会把 token 攒成整块再下发用户看到的就是「卡一下然后刷屏」。背压控制生成快于消费时用有界队列超限就合并旧 token而不是无限堆积吃内存。压缩干扰对/chat关闭gzip避免压缩把逐条事件粘在一起破坏逐字节奏。下面这段 Nginx 配置适配最常见的反向代理部署环境location /chat { proxy_pass http://agent_backend; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 300s; gzip off; }关缓冲、限背压、关压缩三件事做完流式才真的「流」起来。十、可观测给流式链路装上监控探针上线后总要回答用户为什么中途离开先把这几类指标埋进链路里首 token 延迟从请求发出到第一个字出现的时间是留存的生死线务必按路由分位数统计。流中断率done之前连接被关闭的比例异常升高通常指向网关超时或缓冲配置回退。阶段耗时分布thinking与tool_call各占多久用来定位到底是模型慢还是工具链慢。核心结论没有指标的流式链路出问题时只能靠用户反馈告诉你。结语整套方案的价值在于把 Agent 的「过程」变成了产品的一部分后端用事件协议把 token、阶段、工具产物拆开推送前端用状态机统一消费中间状态从黑盒变成了可信的进度展示再叠加断线续传、网关关缓冲和三项核心指标链路从能跑到跑得稳。落地时建议按 SSE 优先的顺序推进先跑通单向流拿到体验收益再按需引入 WebSocket 的上行控制改动面最小、验证成本也最低。先让数据流起来再让过程被看见最后让链路可度量。
阅读完成 · 觉得有帮助?