Agent 前端难在哪:事件协议与浏览器里的第一条流
先说清楚 Agent 界面和普通聊天界面到底差在哪三件事,再认识 AG-UI 这份公开的前后端事件协议,最后用原生 fetch 加 ReadableStream 在浏览器里读通第一条流,并搞懂为什么 EventSource 在这个场景里一开始就不够用。
今日目标
- 能说出 Agent 界面区别于普通聊天界面的三个难点,并各举一个会让用户失去信任的具体表现
- 能读懂 AG-UI 的事件分组,说清运行、消息、工具三层各自解决什么问题
- 能用原生 fetch 加 ReadableStream 读一条事件流,并解释为什么这里不能用 EventSource
今天不写界面,先把地基打好:一份协议,加一个能把字节变成事件的解码器。读完回到页面顶部,把三条目标勾掉。
小白版讲解
同声传译的现场:边听边说为什么比听完再说难得多
你参加过带同声传译的会议吗?讲者在台上说,译员在隔音间里几乎同时把话译出来,你戴着耳机听到的是一句句正在成形的话。
这件事的难,不在于译员词汇量不够,而在于他必须在信息不完整的时候就开口。讲者说到"这个方案我们决定",译员已经要开始译了,可后面那半句是"采纳"还是"放弃",他还不知道。于是就有了同传现场特有的几种狼狈:译到一半发现方向错了要往回找补、听众听到半句悬在那里、讲者突然插入一个术语让译员卡住。
Agent 界面就是这个隔音间。模型一个词一个词地往外吐,你的界面必须在整句话还没成形的时候就把它显示出来。这跟传统的前端完全不是一回事:传统前端拿到的是一份完整的接口返回,渲染是一次性的、确定的;Agent 前端拿到的是一条没有终点承诺的流,它可能正常结束,可能中途报错,可能停下来问你一个问题,也可能跑了三分钟还在跑。
所以这门课的立场是:接一条流不难,难的是流进来之后界面怎么不崩。后面七天,我们都在解决后半句。
Agent 界面的三个难点:不确定性、长任务、可信度
把"难"拆开,是三件具体的事。每一件都有一个典型的失败表现,而且这些表现都会直接让用户不再信任你的产品。
第一是不确定性。你不知道这次回答有多长、会不会调用工具、会不会失败。传统前端可以画一个精确的进度条,Agent 前端不行——你连总量都不知道。失败表现是:界面显示一个转圈的加载图标,转了四十秒,用户不知道是在思考、在调用工具、还是已经卡死了,于是刷新页面,前功尽弃。
第二是长任务。一个 Agent 跑三五分钟是常态。传统前端的请求超时通常设在三十秒,而这里超时根本不适用。失败表现是:用户切到别的标签页去干活,回来发现流断了,而界面上只剩半句话,既没有提示也没有恢复入口。
第三是可信度。模型会出错、会编造。界面如果只呈现一段自信的文字,用户没有任何办法判断它可不可信。失败表现是:Agent 声称"已经帮你把文件删除了",用户完全看不到它到底调用了什么、传了什么参数、返回了什么,只能选择信或不信。
为什么需要一份协议:AG-UI 与 MCP 各管哪一段
既然前端要显示"它在想什么、在调什么工具、跑到第几步",那后端就得把这些信息按一种双方约好的格式发过来。这就是协议。
这两年 Agent 生态冒出来三个协议,管的是三段不同的连线:
| 协议 | 连接的两端 | 解决的问题 |
|---|---|---|
| MCP | Agent 与工具 | 让任何 Agent 都能接上任何工具,不用重写胶水代码 |
| A2A | Agent 与 Agent | 让不同团队做的 Agent 能互相委托任务 |
| AG-UI | Agent 与用户界面 | 让任何前端都能显示任何 Agent 的运行过程 |
本课只关心第三条。AG-UI(Agent-User Interaction Protocol)是一份开源协议,采用 MIT 许可。它做的事很朴素:规定后端往前端吐的每一个事件长什么样。
这里要解释一个决定。这门课不使用任何 Agent 前端 SDK,所有客户端代码我们自己写。但协议不自己发明——我们对齐 AG-UI。理由是:自己编一套教学协议,你学完只会用"老师编的那套";而对齐一份公开标准,你写的解码器、你对事件分层的理解,换到任何一个用 AG-UI 的后端都能直接复用。手写的是一个真实标准的客户端,不是重造轮子。
事件分组速览:六组事件,各解决一层问题
AG-UI 的事件按职责分成几组。本课固定使用其中 18 个,今天先认识三组。
运行组管的是一次执行的边界:RUN_STARTED 带着 threadId 和 runId 开场,RUN_FINISHED 收尾,出错则是 RUN_ERROR。有了这一层,界面才知道"现在是不是有任务在跑",才能正确地显示停止按钮、禁用输入框。
文本消息组管的是一条回答的生成过程,三个事件一组:
TEXT_MESSAGE_START带messageId和role,宣告"我要开始说一条消息了"TEXT_MESSAGE_CONTENT带messageId和delta,每来一个就是一小段文字TEXT_MESSAGE_END带messageId,宣告这条说完了
为什么要拆成三个,而不是直接发完整消息?因为流式的本质就是"还没说完就得显示"。START 让界面先把消息气泡建出来,CONTENT 往里填,END 让界面知道可以收尾了(比如补上语法高亮、开始朗读、显示复制按钮)。有了明确的开始和结束,前端才能区分"还在说"和"说完了"这两种状态——这个区分在第二天做渲染优化、第七天做无障碍播报时都是关键。
工具组管的是工具调用的全过程(TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END / TOOL_CALL_RESULT),这是第三天的主题。剩下的推理组、状态组、子 Agent 组分别是第四天和第六天的内容。
服务器想发的(完整报文)
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
实际到达的网络包
(还没开始收)
buffer 里留着的半行
(空)
已解析出的完整事件
读流的三种办法:EventSource、WebSocket、还是 fetch
协议定好了,剩下的问题是:浏览器里怎么把这条流读进来。有三个候选。
EventSource 是浏览器内置的 SSE 客户端,自带断线重连,看起来天生就是干这个的。WebSocket 是全双工长连接,听起来更强大。fetch 加 ReadableStream 是手动挡,什么都得自己写。
标准答案是第三个,而且前两个不是"不够好",是根本用不了或者杀鸡用牛刀。下一节解释第一个为什么用不了。
先说 WebSocket 为什么不选:Agent 的交互模式是"用户发一个请求,服务端流式回一串事件",这是请求响应,不是双向实时。用 WebSocket 意味着你要自己处理连接生命周期、心跳、重连、以及把请求响应语义架在长连接上——为一个本来就是单向的场景引入了一大堆状态。除非你真的需要服务端主动推送(比如多人协作),否则不值得。
为什么是 fetch:EventSource 的三个硬伤
EventSource 用起来确实优雅:
// 看起来很美好,但在 Agent 场景里第一步就卡住
const es = new EventSource('/api/agent')
es.onmessage = (e) => console.log(JSON.parse(e.data))问题是它有三个改不了的限制:
- 只支持 GET。而你要发的是一整个对话历史——几十上百 KB 的 JSON。塞进查询字符串会超长度限制,而且把用户的对话内容写进了 URL,会进服务器访问日志。
- 不能带请求体。同上,这是 GET 的直接后果。
- 不能自定义请求头。带不了
Authorization,只能退回到 Cookie 鉴权,跨域场景会很麻烦。
所以选择 fetch。它能 POST、能带请求体、能自定义头,代价是 SSE 的解析、重连、错误处理全部要自己写。第六天我们会把重连补上;今天先把解析跑通。
// 一次 POST,把响应体当成字节流逐块读出来
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threadId, runId, messages }),
signal, // 第六天做打断时会用到它
})
if (!res.body) throw new Error('响应没有可读流')
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()res.body 是一个 ReadableStream,装的是原始字节。TextDecoderStream 把字节解成字符串——这一步不能自己用 TextDecoder 一块块解,因为一个多字节的中文字符完全可能被切在两个数据块之间,逐块解码会解出乱码。TextDecoderStream 内部会处理这种跨块的半个字符。
passthrough 与前向兼容:遇到没见过的事件怎么办
最后一个设计问题,也是个高频面试题:你的解码器读到一个协议里没定义过的事件类型,该怎么办?
有三种选择:报错中断、静默忽略、或者透传给上层。
AG-UI 自己的 schema 定义给了答案——它的事件基类是 passthrough 的,也就是说遇到未知字段不报错,原样保留。这背后是一个前向兼容的考虑:协议还在演进,上游随时可能加新事件。如果你的前端遇到新事件就崩,那么后端每升级一次,所有还没跟上的前端就全挂了。
所以正确做法是:未知事件类型要忽略而不是报错,但要在开发模式下打一条日志。
function handleEvent(event: AgentEvent, ui: UiState) {
switch (event.type) {
case 'TEXT_MESSAGE_START':
return ui.beginMessage(event.messageId, event.role)
case 'TEXT_MESSAGE_CONTENT':
return ui.appendDelta(event.messageId, event.delta)
case 'TEXT_MESSAGE_END':
return ui.endMessage(event.messageId)
case 'RUN_ERROR':
return ui.fail(event.message, event.code)
default:
// 关键:不认识就跳过,不要 throw
if (process.env.NODE_ENV !== 'production') {
console.debug('[agent] 未处理的事件类型', event.type)
}
}
}源码导读
动手实验
这个实验的三个文件会被后面六天原样复用,所以今天值得写仔细一点:事件类型定义、解码器、离线剧本。写之前先确认 Node 版本不低于 22,然后 pnpm install --ignore-workspace。卡住的话,solution/ 里每个关键函数都有注释说明为什么这么写。
- 起一个 Next.js 应用,写一个用 SSE 吐事件的路由,先让 curl 能看到事件
- 定义本课要用的事件类型,写一个类型守卫把未知类型挡在外面
- 用 fetch 加 ReadableStream 加 TextDecoderStream 写解码迭代器,按空行切事件
- 把 TEXT_MESSAGE_CONTENT 的增量拼成一条消息渲染出来
- 注入一次 RUN_ERROR,让界面正确显示错误态而不是卡住
面试题
今天 3 道题在下方题库区,侧重流式传输在浏览器侧的选型、事件协议的分层设计、前向兼容的字段策略。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能说出 Agent 界面区别于普通聊天界面的三个难点,并各举一个会让用户失去信任的具体表现
- 能读懂 AG-UI 的事件分组,说清运行、消息、工具三层各自解决什么问题
- 能用原生 fetch 加 ReadableStream 读一条事件流,并解释为什么这里不能用 EventSource
- 能说清为什么解码时要用 TextDecoderStream 而不是自己逐块解码
- 实验的 5 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D2)我们把这个最小页面推到真实负载下:模型每秒吐几十上百个增量,今天这种"来一个增量就更新一次状态"的写法会让 React 疯狂重渲染,页面开始掉帧,连用户正在打字的输入框都会被拖卡。先把协议和解码跑通再谈性能,是因为优化必须建立在能测量的基线上——明天第一步就是复现卡顿并量出数字,没有今天这个能跑的版本,就没有可测的对象。
面试题库
浏览器里要读一条带请求体的流式响应,EventSource、WebSocket、fetch 加 ReadableStream 你选哪个?为什么?You need to consume a streaming response in the browser and the request carries a body. Would you use EventSource, WebSocket, or fetch with ReadableStream? Why?
国内高频海外高频基础#streaming#sse#browser-api分析过程 · 先想清楚再作答
- 题眼在「带请求体」四个字。没读到这半句的人会答 EventSource,因为它看起来天生就是读 SSE 的,这一答基本就结束了。
- 先把三个候选各自的硬约束摆出来:EventSource 只支持 GET、不能带请求体、不能自定义请求头;WebSocket 是全双工长连接;fetch 什么都能做但什么都要自己写。
- 再把约束对到场景上:Agent 请求要 POST 一整份对话历史(几十上百 KB)并带 Authorization 头,EventSource 的三条限制条条踩中,直接出局。
- WebSocket 能做但不该做:Agent 的交互是「发一个请求、流式收一串事件」,这是请求响应语义,不是双向实时。用长连接意味着要自己管连接生命周期、心跳、重连,还要把请求响应架在长连接上,为一个单向场景引入一堆状态。
- 结论是 fetch 加 ReadableStream,并主动说出它的代价:SSE 解析、断线重连、错误处理全都要自己实现,这正是要付的学费。
- 可预期的追问是「那断线重连怎么办」——答案是自己实现,记录最后一条事件的标识,重连时带上它让服务端续播,并且要按消息标识去重而不是按内容去重。
How to reason about it · think before answering
- The tell is 'the request carries a body'. Candidates who miss that half of the sentence answer EventSource, since it looks purpose-built for SSE, and the interview largely ends there.
- Lay out the hard constraints first: EventSource is GET-only, cannot carry a body, and cannot set custom headers. WebSocket is a full-duplex long-lived connection. Fetch can do anything but hands you nothing.
- Map the constraints onto the scenario: an agent request POSTs a full conversation history, often tens to hundreds of KB, plus an Authorization header. EventSource fails on all three counts and is out.
- WebSocket would work but is the wrong tool: the interaction is request-response with a streamed reply, not bidirectional realtime. You would own connection lifecycle, heartbeats, and reconnection, and still have to layer request-response semantics on top.
- Land on fetch with ReadableStream, and volunteer the cost: SSE parsing, reconnection, and error handling are all yours to write.
- Expect the follow-up on reconnection: implement it yourself, track the last event id, send it on reconnect so the server can resume, and dedupe by message id rather than by content.
答题要点
- 选 fetch 加 ReadableStream,因为 EventSource 只支持 GET、不能带请求体、不能自定义头,三条都挡住 Agent 场景。
- WebSocket 技术上可行但语义不匹配:这是请求响应加流式回复,不是双向实时,用长连接要多管一堆状态。
- 代价是 SSE 解析、重连、错误处理全部自己实现,这是换来灵活性必须付的成本。
- 解码时要用 TextDecoderStream 而不是逐块 TextDecoder,否则多字节字符被切在块边界上会解出乱码。
Key points
- Pick fetch with ReadableStream: EventSource is GET-only, takes no request body, and allows no custom headers, which rules it out for agent requests.
- WebSocket is technically possible but semantically wrong here: this is request-response with a streamed reply, not bidirectional realtime.
- The cost is owning SSE parsing, reconnection, and error handling yourself.
- Decode with TextDecoderStream rather than per-chunk TextDecoder, or multi-byte characters split across chunk boundaries will come out garbled.
AG-UI 这类协议为什么要把一条消息拆成 START、CONTENT、END 三个事件,而不是直接发一条完整消息?Why do protocols like AG-UI split one message into START, CONTENT, and END events instead of sending a complete message?
国内高频海外高频进阶#protocol-design#streaming#ui-state分析过程 · 先想清楚再作答
- 这题考的是「你有没有真做过流式界面」。只答「因为要流式」是复述题干,区分度全在你能不能说出这个拆分让前端多做成了哪几件事。
- 推导的起点是一个约束:流式的本质是「话还没说完就得显示」,所以前端必须能表达「这条消息正在生成中」这个状态。一条完整消息做不到这件事。
- 拆成三段之后,每一段都换来一个具体能力:START 让界面先把消息容器建出来并占好位置,避免内容到达时布局跳动;CONTENT 只带增量,省带宽也省客户端拼接成本;END 是一个明确的完成信号。
- END 的价值最容易被低估,它是很多收尾动作的唯一触发点:补语法高亮、启动屏幕阅读器播报、显示复制与重新生成按钮、把消息落库。没有 END,前端只能靠超时猜,猜早了内容还没完,猜晚了界面一直显示在打字。
- 同样重要的是每个事件都带消息标识:Agent 可能并发产出多条消息(比如同时跑几个子任务),没有标识就无法把增量归到正确的那条上。
- 可预期的追问是「那为什么还要有 CHUNK 这种把三段合一的便利事件」——因为简单场景下服务端不想维护三段状态机,协议给了个捷径,代价是失去了对开始和结束时机的精确控制。
How to reason about it · think before answering
- This probes whether you have actually built a streaming UI. Answering 'because it streams' just restates the question; the signal is whether you can name what the split buys the frontend.
- Start from the constraint: streaming means rendering before the sentence is finished, so the frontend must be able to represent 'this message is still being generated'. A single complete message cannot express that.
- Each of the three parts buys a concrete capability: START lets the UI create and reserve the message container so layout does not jump when content arrives; CONTENT carries only the delta, saving bandwidth and client-side work; END is an unambiguous completion signal.
- END is the most underrated: it is the only trigger for a pile of finishing work such as applying syntax highlighting, starting screen-reader announcement, revealing copy and regenerate actions, and persisting the message. Without it you are guessing with timeouts.
- Equally important, every event carries a message id. An agent may produce several messages concurrently, and without the id you cannot route deltas to the right one.
- Expect the follow-up about convenience CHUNK events that collapse all three: they spare a simple server from running a three-state machine, at the cost of precise control over start and end timing.
答题要点
- 流式的本质是内容没生成完就要显示,所以界面必须能表达「正在生成中」这个中间状态。
- START 让界面先建好容器避免布局跳动,CONTENT 只传增量省带宽,END 给出明确的完成信号。
- END 是补高亮、启动朗读、显示复制按钮、落库这些收尾动作的唯一可靠触发点,没有它只能靠超时猜。
- 每个事件带消息标识,才能在并发产出多条消息时把增量归到正确的那一条上。
Key points
- Streaming means rendering before generation finishes, so the UI needs an explicit in-progress state.
- START reserves the container so layout does not jump, CONTENT carries only deltas, END gives an unambiguous completion signal.
- END is the only reliable trigger for finishing work: syntax highlighting, screen-reader announcement, copy actions, persistence.
- The message id on every event is what lets you route deltas correctly when several messages stream concurrently.
你的前端收到一个协议里没定义过的事件类型,应该报错、忽略,还是透传给上层?说出你的判断依据。Your frontend receives an event type the protocol does not define. Should it throw, ignore it, or pass it through? What drives your decision?
国内高频海外高频深入#forward-compatibility#protocol-design#error-handling分析过程 · 先想清楚再作答
- 这题看起来是个 API 设计小问题,实际考的是你有没有版本演进的意识。答「报错,因为要严格校验」的人,通常没经历过后端升级把前端打挂的线上事故。
- 判断依据只有一条,而且可以直接问出口:这两端是同步发布的吗。同一个仓库里一起打包上线的,严格报错是对的,它能在开发期暴露问题;而协议两端由不同团队、不同节奏发布时,严格报错等于把「后端加了个新事件」变成「所有老前端崩溃」。
- Agent 协议属于后者,而且演进极快,上游随时在加新事件。所以正确策略是宽进:未知事件类型忽略掉,让流继续读完,绝不 throw。
- 但「忽略」不等于「装作没发生」。开发模式下要打一条日志,让开发者知道上游出了新东西该跟进了;生产环境静默即可,不要污染用户控制台。
- 这条原则在字段层面同样成立:一个事件里多出来没见过的字段也要原样保留而不是剥掉。这正是协议规范里 passthrough 的含义,也是所谓「宽进严出」在前端的落地。
- 可预期的追问是「那校验还有什么用」——校验用在你自己发出去的数据上,以及用在已知事件的必填字段上(比如文本增量事件缺了 delta 就该报错)。宽容的是未知类型,不是已知类型的坏数据。
How to reason about it · think before answering
- It looks like a small API design question but really tests whether you think about version skew. Answering 'throw, be strict' usually means you have never watched a backend release take down old frontends.
- There is one deciding question, and you can say it out loud: are the two sides released together? Inside one repo, strict failure is right and surfaces bugs early. Across teams with independent release cadences, strict failure turns 'backend added an event' into 'every older client crashes'.
- Agent protocols are the second case and are evolving fast, so be liberal in what you accept: ignore unknown event types and keep reading the stream, never throw.
- Ignoring is not pretending nothing happened. Log it in development so someone notices the upstream has moved; stay silent in production rather than polluting the user's console.
- The same rule applies at field level: keep unrecognized fields on a known event instead of stripping them. That is exactly what passthrough means in the schema.
- Expect the follow-up on what validation is still for: validate what you send, and validate required fields on events you do know. Tolerate unknown types, not malformed known ones.
答题要点
- 忽略并继续读流,绝不 throw:协议两端独立发布时,严格报错会让后端每次加事件都打挂老前端。
- 判断依据是两端是否同步发布——同仓库一起上线可以严格,跨团队独立演进必须宽容。
- 开发模式打一条日志提示上游有新东西,生产环境静默,不污染用户控制台。
- 字段层面同理:未知字段原样保留而不是剥掉,这就是协议里 passthrough 的含义。
- 宽容的对象是未知类型,不是已知类型的坏数据;已知事件缺必填字段仍然该报错。
Key points
- Ignore it and keep reading; never throw. With independent release cadences, strict failure breaks every older client each time the backend adds an event.
- The deciding question is whether both sides ship together: same repo can be strict, independently evolving sides must be tolerant.
- Log it in development so the drift gets noticed, stay silent in production.
- Same at field level: preserve unrecognized fields instead of stripping them, which is what passthrough means.
- Be liberal about unknown types, not about malformed known ones: a text delta event missing its delta should still fail.