fetch + ReadableStream 实战:从 a2ui-full 拆一条 AG-UI SSE 全链路
浏览器里那种「AI 一边想、界面一边长出来」的效果,落到代码其实只有两件事:
- 服务端把一串事件写进一条不肯结束的 HTTP 响应;
- 客户端从
response.body里一块一块读出来,边读边解析、边渲染。
听起来像是一个下午就能写完的活。但真正把它接到 Agent 上,坑会集中在几个边界上:一次网络分片不等于一个事件、响应头发出之后状态码就改不了、连接关闭不等于任务完成、在错误的位置 throw 会让客户端永远等下去。这些都不是 API 会不会用的问题,而是「边界由谁负责」的问题。
这篇拿一个真实项目开刀:bigfe/encode_lesson/a2ui-full。它是一个 A2UI Playground——用户在网页里描述界面,Koa 服务端调用 LLM 生成 A2UI 协议,浏览器通过 fetch + ReadableStream 接收,边收边把界面渲染到右侧预览区。服务端用 @ag-ui/core 与 @ag-ui/encoder 组织事件,客户端手写 SSE 分帧,协议载荷则是 A2UI 的 JSONL。
我会按四条线拆:
- @ag-ui 是什么,它和 A2UI、MCP、A2A 各管哪一层;
- Node 端推 SSE 的完整步骤,从设置响应头到错误、取消、心跳;
- 客户端
fetch+ReadableStream的完整步骤,从getReader()到分帧、解析、收尾; - 这条链路里最值钱的十几个坑,其中几个是我在本机复现出来的,不是「听说」。
文中的实测脚本与官方文档原文都留档在 source/a2ui-sse-lab/。SSE 的基础格式、EventSource 与 fetch 的取舍,站内已经有两篇拆过——《SSE 是什么》与《EventSource 是什么,为什么 Agent 流式接收最后都换成了 fetch + ReadableStream》——这里不重复背景,只在需要的地方给出链接。
0. 先把结论摆出来
如果你只想带走五句话:
- 服务端的最小骨架是:
async generator产出事件 → 编码成data: JSON\n\n→Readable.from()→ 交给 Koa 的ctx.body。真正要写的不是这四行,而是围绕它的错误、取消、心跳与降级。 - 客户端的最小骨架是:
fetch拿到Response→response.body.getReader()→TextDecoder({ stream: true })→ 字符串缓冲区 → 按\n\n切帧 → 解析data:行。缺任何一环都会在某个边界上烂掉。 - AG-UI 是事件信封,A2UI 是信封里的 payload。这个项目把 A2UI 的 JSONL 文本碎片塞进 AG-UI 的
CUSTOM事件,既满足了 AG-UI「一帧一个完整事件」的约定,又让 A2UI 可以按任意字节边界切片。 - 最危险的不是「流没开始」,而是「流已经开始之后出错」。响应头一旦发出,HTTP 状态码就冻结了;此时唯一正确的报错方式是在流里发一个
RUN_ERROR,而不是throw。 - 这个项目的 LLM 调用本身不是流式的。它先一次性拿到完整回答,再把 JSONL 按 4–48 个字符随机切片、间隔 20–120ms 推给客户端,制造「打字机」效果。真正的 token 级流式在另一个路由
/api/chat里。
0.1 一张图看清链路
0.2 先认识这几个文件
后面会反复回到它们:
| 路径 | 职责 |
|---|---|
server/a2ui-playground-server/src/routes/agent.ts | /api/agent 路由:校验、选模式、设响应头、把事件流交给 Koa |
server/a2ui-playground-server/src/agent/llmA2uiAgentStream.ts | LLM 路径:组 messages、调模型、解析、合并、切片、发事件 |
server/a2ui-playground-server/src/agent/mockStream.ts | Mock 路径:随机挑一个场景,走同样的切片逻辑 |
server/a2ui-playground-server/src/routes/chat.ts | /api/chat:手写 SSE 的最简版本,真·token 流式 |
web/a2ui-playground/src/utils/agent-sse.ts | 客户端核心:fetch 响应 → SSE 事件 → 回调 |
web/a2ui-playground/src/hooks/use-agent-chat.ts | 把 SSE 事件接到聊天状态、parser、UI 上 |
packages/a2ui-core/src/parser/index.ts | JSONL 缓冲区与增量解析:任意碎片进来,能拼成消息就先渲染 |
source/a2ui-sse-lab/ | 本文的复现脚本、输出日志、官方原文留档 |
1. 先认识 @ag-ui:Agent 和前端之间的「事件语法」
在讲具体代码之前,得先把这个项目里反复出现的 RUN_STARTED、CUSTOM、RUN_FINISHED 是从哪来的讲清楚。它们不是 A2UI 的东西,而是 AG-UI 的东西。
1.1 它解决的问题:请求 / 响应装不下 Agent
传统前端后端的模型是:客户端发一个请求,服务端返回一份数据,客户端渲染,交互结束。Agent 不是这样:
- 它可能跑很久,中间不断产出中间结果;
- 它可能同时产出文本、工具调用、状态更新、UI 指令;
- 它可能中途需要人确认,也可能被用户取消;
- 它的输出是不确定的,前端不能假设「一次请求一定对应一次完整回答」。
AG-UI 官方的定位是一句话:
【官方原文】"AG-UI is an open, lightweight, event-based protocol that standardizes how AI agents connect to user-facing applications." —— AG-UI Overview(抓取日期 2026-10-22,
curl,HTTP 200)
关键词是 event-based:它不规定你用哪种传输,而是先规定「Agent 运行过程中会发出哪些类型的事件」。传输可以是 SSE、WebSocket、webhook,甚至是你自己的队列。
1.2 它和 A2UI、MCP、A2A 不是竞争关系
这是最容易混的地方。官方在 Overview 里直接放了一张分层表:
| 层 | 协议 | 管什么 |
|---|---|---|
| Agent ↔ User Interaction | AG-UI | Agent 怎么把运行过程、消息、状态、UI 意图实时送给前端 |
| Agent ↔ Tools & Data | MCP | Agent 怎么安全地接工具、接数据源 |
| Agent ↔ Agent | A2A | 多个 Agent 之间怎么协作、分工 |
而 A2UI 又是另一件事。官方在同一页特意解释了这两个名字很像的协议:
【官方原文】"A2UI is a generative UI specification - allowing agents to deliver UI widgets, where AG-UI is the Agent↔User Interaction protocol - which connects an agentic frontend to any agentic backend." —— 同上
翻成大白话:
- A2UI 管「UI 长什么样」:Agent 用 JSON 描述「这里放一个 Column,里面一个 Text、一个 Button」,客户端用自己的组件库渲染。它不关心这份 JSON 是怎么送到浏览器的。
- AG-UI 管「Agent 和前端怎么说话」:它定义 run 的开始、结束、文本增量、工具调用、状态同步、自定义事件。它不关心 payload 里是不是 UI。
- 两者可以叠加:AG-UI 负责把事件送到前端,A2UI 的 JSON 作为事件 payload 的一种。
A2UI 官方文档也把 AG-UI 列为可用传输之一:
【官方原文】"A2UI is transport-agnostic: use any method that can send JSON." —— A2UI Transports(抓取日期 2026-10-22,
curl,HTTP 200)
同一页的「Available Transports」表里,AG-UI 的状态是 Stable,用途写的是 “Full stack React, Vue, Angular applications (CopilotKit)”;SSE 单独列出的状态则是 Proposed。
这里要避免一个误读:「SSE 是 Proposed」不等于「A2UI 不能走 SSE」。那张表说的是「A2UI 官方有没有为这种传输写专门章节」。A2UI 的正文明确说 transport-agnostic,只要能把 JSON 送到就行。这个项目走的是 AG-UI 的 HTTP+SSE 绑定,A2UI 消息作为 CUSTOM 事件的 value 传输——这仍然落在「任何能送 JSON 的传输」的范围内。
1.3 两个包:@ag-ui/core 与 @ag-ui/encoder
项目里装的是 @ag-ui/core@0.0.48 与 @ag-ui/encoder@0.0.48。它们的分工很清楚:
| 包 | 提供什么 | 在本项目里怎么用 |
|---|---|---|
@ag-ui/core | 消息/状态/工具/RunAgentInput 的类型与 zod schema,以及 EventType 枚举 | RunAgentInputSchema.safeParse() 校验请求;EventType.RUN_STARTED 等构造事件 |
@ag-ui/encoder | EventEncoder:按 Accept 协商 SSE / protobuf,负责把事件编码成帧 | encoder.encodeSSE(event) 把事件变成 data: {...}\n\n |
@ag-ui/proto | protobuf 定义,供 binary 传输使用 | 本项目不用,但 encoder 会依赖它 |
EventEncoder 的实现非常短,核心就一行:
encodeSSE(event) {
return `data: ${JSON.stringify(event)}\n\n`;
}
也就是说,AG-UI 的 SSE 帧格式没有任何魔法:一个 data: 行,一段 JSON,两个换行。你完全可以手写。这个项目用 encoder,主要是为了拿类型和未来切换 protobuf 的可能性。
1.4 一次 run 的输入:RunAgentInput
客户端 POST 的 body 不是一个随便的 JSON,而是一个 RunAgentInput。它的顶层字段(以 0.0.48 的 schema 为准)是:
| 字段 | 类型 | 作用 |
|---|---|---|
threadId | string | 会话线程 id,多轮对话靠它关联 |
runId | string | 本次运行 id,同一次 run 的所有事件都带它 |
messages | Message[] | 历史消息;role 可以是 user / assistant / system / developer / tool / activity / reasoning |
tools | Tool[] | 客户端声明的工具(name / description / parameters) |
context | Context[] | 额外的上下文片段(value / description) |
state | any | 共享状态;本项目传空对象 |
forwardedProps | any | 协议不解释的透传字段,本项目把 A2UI 画布快照塞在这里 |
parentRunId | string? | 分支 / 时间旅行的父 run |
messages 里的内容既可以是纯字符串,也可以是 AG-UI 的多模态数组:
[
{ "type": "text", "text": "把这个卡片改成深色" },
{ "type": "binary", "mimeType": "image/png", "data": "<base64>" }
]
服务端再把 binary 转成 OpenAI 的 image_url。这段转换在 agUiContentToOpenAi.ts 里,和传输层无关,但它是「AG-UI 消息模型 → 具体模型 API」的一个典型适配层。
forwardedProps 是这个项目多轮修改的关键:
const forwardedProps = hasSurfaceInStore
? { a2uiCurrentProtocol: buildA2uiProtocolSnapshot(storeState) }
: {};
浏览器把当前画布反推成一份 A2UI 协议快照,塞进 forwardedProps.a2uiCurrentProtocol。服务端拿到模型输出后,先把增量合并进这份快照,再整体下发。从传输层看,它只是 POST body 里的一个普通字段;从产品层看,它让「多轮改 UI」不必依赖聊天历史里的长 JSON。
1.5 事件模型:33 个 EventType,先记住六类
@ag-ui/core@0.0.48 的 EventType 枚举有 33 个成员(含 5 个已废弃的 THINKING_*)。不用背,按类别记:
| 类别 | 代表事件 | 用途 |
|---|---|---|
| 生命周期 | RUN_STARTED、RUN_FINISHED、RUN_ERROR、STEP_STARTED、STEP_FINISHED | 一次 run 的边界与阶段 |
| 文本消息 | TEXT_MESSAGE_START、TEXT_MESSAGE_CONTENT、TEXT_MESSAGE_END、TEXT_MESSAGE_CHUNK | 逐 token / 逐段文本 |
| 工具调用 | TOOL_CALL_START、TOOL_CALL_ARGS、TOOL_CALL_END、TOOL_CALL_RESULT、TOOL_CALL_CHUNK | 工具调用的流式参数与结果 |
| 状态同步 | STATE_SNAPSHOT、STATE_DELTA、MESSAGES_SNAPSHOT | 快照 + 增量 |
| 活动与推理 | ACTIVITY_SNAPSHOT、ACTIVITY_DELTA、REASONING_* | 长任务进度、推理过程 |
| 特殊 | RAW、CUSTOM | 透传外部事件、应用自定义事件 |
其中只有 RUN_STARTED 和 RUN_FINISHED / RUN_ERROR 是强制的:一次 run 必须有开始,并且必须以「完成」或「错误」之一结束。官方 Events 文档写得很明确:
"The RunStarted and either RunFinished or RunError events are mandatory, forming the boundaries of an agent run." —— AG-UI Events(抓取日期 2026-10-22,
curl,HTTP 200)
这句话直接决定了客户端怎么写:没收到终态事件,就不能把这次流当成成功。
1.6 为什么这个项目用 CUSTOM 装 A2UI
AG-UI 的 CUSTOM 事件只有两个字段:
| 字段 | 含义 |
|---|---|
name | 应用自定义的事件名 |
value | 应用自定义的载荷 |
官方对它的定位是「应用自定义语义的扩展机制」:
【官方原文】"Custom events are explicitly part of the protocol but with application-defined semantics. The
nameproperty identifies the specific custom event type, while thevalueproperty contains the associated data." —— AG-UI Events · Custom(抓取日期 2026-10-22,curl,HTTP 200)
本项目定义了三个:
name | value | 用途 |
|---|---|---|
a2ui.jsonl.chunk | string | 一段 A2UI JSONL 文本碎片,可能切在 JSON 中间 |
a2ui.llm.raw | string | 模型完整原文,仅调试面板使用 |
a2ui.message | object | 兼容旧 mock:一条 CUSTOM 携带一个完整的单键 A2UI 消息 |
这个设计很值得抄。它把两个层次分开了:
- AG-UI 层:每一帧仍然是一个完整的 AG-UI 事件,满足「一帧一个事件」的约定;
- A2UI 层:
value内部是一段可以任意切片的 JSONL 文本流,客户端自己缓冲、自己拼行。
如果反过来,把 A2UI 的半截 JSON 直接当成一个 AG-UI 事件发出去,就违反了 AG-UI 的帧契约;而如果把整条 A2UI 消息塞进一个 CUSTOM,又失去了渐进渲染。用 CUSTOM 的 value 装碎片,是这两者之间的折中。
1.7 传输绑定:HTTP + SSE 的几条硬约定
AG-UI 的规范里,HTTP + SSE 是「default binding」。它规定得很细,几条和本文直接相关:
【官方原文】"The client sends POST to the agent endpoint. The body is the RunAgentInput as a single JSON object, UTF-8 encoded, with Content-Type: application/json."
【官方原文】"The client sends Accept: text/event-stream (adding the protobuf media type when it can consume that binding too)."
【官方原文】"Each SSE event’s data payload is exactly one protocol event as a JSON object — never more than one, never a fragment."
【官方原文】"A producer MUST frame the stream with LF (\n) line endings."
【官方原文】"A failure after the stream opens travels in-stream, as RUN_ERROR. The HTTP status is already sent and cannot change; a consumer MUST NOT infer success from 200 alone."
【官方原文】"A connection that drops without a terminal event is a truncated run."
【官方原文】"The binding has no stream resumption: SSE’s Last-Event-ID mechanism is not used, and a broken stream cannot be re-entered." —— AG-UI · HTTP + Server-Sent Events(抓取日期 2026-10-22,
curl,HTTP 200)
这几条几乎就是本文后半部分的目录:
- POST 而不是 GET →
EventSource用不了; - 一帧一个完整事件 → 碎片要放在
value里,不能直接当帧; - LF 分帧 → 客户端别只认
\r\n,也别假设\n\n一定来自网络分片; - 200 不等于成功 → 必须等
RUN_FINISHED; - 没有断点续传 → 断了就是新 run,别指望
Last-Event-ID。
2. 把镜头拉近:a2ui-full 的端到端链路
2.1 一次请求到底发生了什么
先补一句 A2UI 的最小背景,免得后面的 JSONL 看起来像天书:A2UI v0.8 的「服务端 → 客户端」消息只有四类——beginRendering、surfaceUpdate、dataModelUpdate、deleteSurface;每条消息是一个独立 JSON 对象,流式传输时通常写成 JSONL(一行一条 JSON)。本项目只实现单 surface 和一部分组件,协议细节可以看站内的 A2UI 笔记系列。
把时序展开:
实际抓一条 ?mock=1 的流(完整 4932 字节留档在 source/a2ui-sse-lab/project-mock-stream.log),首尾是这样:
data: {"type":"RUN_STARTED","threadId":"t1","runId":"r1","input":{...}}
data: {"type":"CUSTOM","name":"a2ui.jsonl.chunk","value":"{\"beginRendering\":{\"surfaceId\":\""}
data: {"type":"CUSTOM","name":"a2ui.jsonl.chunk","value":"surface-001\",\"root\":\"card-"}
data: {"type":"CUSTOM","name":"a2ui.jsonl.chunk","value":"container\"}}\n{\"surfaceUpdate\":{\"surf"}
...
data: {"type":"RUN_FINISHED","threadId":"t1","runId":"r1","result":{"mock":"card-demo","a2uiMessageCount":2,"protocolChunkCount":44,"jsonlBytes":1126,"random":true,"streamMode":"jsonl-chunks"}}
两个数字值得注意:1126 字节的 JSONL 被切成了 44 个 chunk,平均每片约 25 个字符;value 里能看到 \n 和半截的 "card- / "surf——这正是「碎片可以切在任何位置」的直观证据。
2.2 一个容易被忽略的事实:LLM 本身不是流式的
llmA2uiAgentStream.ts 里调用的是:
const completion = await client.chat.completions.create(requestParams);
没有 stream: true。模型一次性返回完整回答,服务端再把它切成小片。所以你在页面上看到的「打字机」,本质上是服务端人为制造的节奏,不是模型逐 token 到达。
这样做有两个现实原因:
- A2UI 的 JSON 必须先完整解析、合并、拆分,才能保证顺序和合法性。半截 JSON 直接往下发,客户端解析器要处理的边界会多得多。
- 渐进渲染的收益主要来自「组件逐个出现」,而不是「字符逐个出现」。把已经合法的 JSONL 切片,客户端每收到一个完整组件就能先画出来,视觉效果已经够了。
代价也很明确:
- 首字节延迟包含完整 LLM 调用时间。工具循环最多 6 轮,可能几十秒。
- 这不是真正的 token 级流式;如果模型输出很长,用户等待首屏的时间不会因为切片而缩短。
- 切片是随机的,调试时同一段协议每次的到达节奏都不同。
项目里另一个路由 /api/chat 展示了真正的流式:chat.completions.create({ stream: true }),for await 拿到每个 delta,立刻 yield 成 SSE 帧。两条路由可以对照着看。
2.3 为什么切片大小是 4–48 个字符
代码里:
const PROTO_CHUNK_CHARS_MIN = 4;
const PROTO_CHUNK_CHARS_MAX = 48;
const CHUNK_GAP_MS_MIN = 20;
const CHUNK_GAP_MS_MAX = 120;
这个范围不是协议要求,是产品调参:
- 太小(比如 1 个字符):事件数量爆炸,每个
CUSTOM都要JSON.stringify一次,网络和客户端回调的开销都上去了; - 太大(比如整条 JSONL):一次到达,失去了渐进渲染;
- 4–48 个字符、20–120ms:肉眼看起来像「在写」,但又不至于把事件循环打满。
更重要的是,切片边界可以落在任何地方:JSON 字符串中间、转义字符中间、甚至 UTF-16 代理对中间。客户端的 JSONLBuffer 必须能接受这种输入。这一点后面单独讲。
2.4 同一个项目里的两条 SSE 路由
项目里其实有两条 SSE 路径,正好代表两个抽象层级:
/api/agent | /api/chat | |
|---|---|---|
| 协议 | AG-UI 事件 | 自定义 { type, text } |
| 事件类型 | RUN_STARTED / CUSTOM / RUN_FINISHED | start / delta / done / error |
| 编码 | EventEncoder.encodeSSE() | 手写模板字符串 |
| LLM | 非流式,事后切片 | stream: true,逐 delta 转发 |
| 客户端 | consumeAgentSse | consumeChatSse |
| 用途 | A2UI 协议生成 | 纯模型对话 / 对照实验 |
/api/chat 的服务端核心只有十几行:
async function* sseLines(): AsyncGenerator<string> {
yield `data: ${JSON.stringify({ type: 'start' })}\n\n`;
try {
const streamResp = await openai.chat.completions.create({
model,
messages: openAiMessages,
stream: true
});
for await (const part of streamResp) {
const delta = part.choices[0]?.delta?.content ?? '';
if (delta) {
yield `data: ${JSON.stringify({ type: 'delta', text: delta })}\n\n`;
}
}
yield `data: ${JSON.stringify({ type: 'done' })}\n\n`;
} catch (e) {
yield `data: ${JSON.stringify({ type: 'error', message: String(e) })}\n\n`;
}
}
ctx.body = Readable.from(sseLines(), { objectMode: false });
它没有 AG-UI 的类型系统,也没有 CUSTOM 的扩展点,但把「开流 → 逐段写 → 终态 → 错误」这条骨架演示得最干净。如果你想先跑通一个 SSE 端点,建议从这个形状开始,再逐步加协议。
一个小的工程问题:客户端的 consumeAgentSse 和 consumeChatSse 是两份几乎相同的分帧代码。传输层的「按 \n\n 切帧 + 解析 data:」应该抽成一个通用函数,业务层只做事件分派。本文 §4.9 给了一个可以复用的版本。
3. Node 端推 SSE:完整步骤
下面按「从零写一个 SSE 端点」的顺序走。每一步都对应项目里的真实代码,最后给一个可以直接跑的 Koa 版本。
步骤 0:先定事件契约,再写代码
在碰 res.write 之前,先回答四个问题:
- 一次 run 的开始事件是什么? 这里用
RUN_STARTED。 - 中间载荷用什么事件? 这里用
CUSTOM,name是a2ui.jsonl.chunk。 - 正常结束发什么?
RUN_FINISHED。 - 出错发什么?
RUN_ERROR,而且只能有一个终态。
把它写成一张契约表:
| 时机 | 事件 | 是否必须 |
|---|---|---|
| 流打开后立刻 | RUN_STARTED | 是 |
| 有 A2UI 碎片时 | CUSTOM / a2ui.jsonl.chunk | 0..N |
| 调试信息 | CUSTOM / a2ui.llm.raw | 0..1 |
| 正常结束 | RUN_FINISHED | 与 RUN_ERROR 二选一 |
| 失败 | RUN_ERROR | 与 RUN_FINISHED 二选一 |
约定三条铁律:
RUN_STARTED之前不要发业务事件;- 终态之后不要再发任何事件;
- 不要让生成器「无声地结束」。没有终态的流,在客户端看来就是截断。
步骤 1:校验输入,决定模式
项目里的做法:
const normalized = normalizeRunAgentBody(ctx.request.body);
const parsed = RunAgentInputSchema.safeParse(normalized);
if (!parsed.success) {
ctx.status = 400;
ctx.body = {
error: 'Invalid RunAgentInput',
details: parsed.error.flatten()
};
return;
}
这一步发生在流打开之前,所以还可以用正常的 HTTP 状态码。校验失败就 400,不要先 200 text/event-stream 再在流里报「参数错了」——那会让客户端误以为 run 已经开始。
模式选择也在这一步:
const client = getOpenAiCompatibleClient();
const useMock = useMockA2uiAgent(ctx);
const mode = client && !useMock ? 'llm' : 'mock';
没有 API Key、或者 AGENT_USE_MOCK=1、或者 URL 带 ?mock=1,就走 mock。这个降级让项目在没有密钥时也能演示完整链路,是很实用的工程决策。
步骤 2:设置响应头
这是 SSE 端点最容易漏的一步。项目里是:
ctx.set('Content-Type', 'text/event-stream');
ctx.set('Cache-Control', 'no-cache');
ctx.set('Connection', 'keep-alive');
ctx.set('X-Accel-Buffering', 'no');
逐个解释:
| 响应头 | 值 | 为什么 |
|---|---|---|
Content-Type | text/event-stream | 客户端 / 代理识别 SSE 的依据;最好带 ; charset=utf-8 |
Cache-Control | no-cache | 不让浏览器 / 代理缓存这条长响应;更稳妥的写法是 no-cache, no-transform |
Connection | keep-alive | HTTP/1.1 下明确保持连接;HTTP/2 里这是被禁止的 hop-by-hop 头,写了也会被忽略 |
X-Accel-Buffering | no | 告诉 nginx 不要缓冲这条响应;只对 nginx 生效,但成本极低 |
Content-Length | 不要设 | 长度未知;设了会把流变成定长响应,必须等够字节才发 |
Content-Encoding | 可设 identity | 避免某些中间层强行压缩;更根本的做法是让压缩中间件跳过 text/event-stream |
关于压缩:SSE 是长连接、小帧、低延迟;gzip 之类的压缩中间件往往会攒一批再压,把流式退化成一次性返回——站内《EventSource 是什么,为什么 Agent 流式接收最后都换成了 fetch + ReadableStream》里实测过,开 gzip 后 50 次分片被压成了 1 次。如果你的栈里有 compression / koa-compress,要么把 text/event-stream 排除,要么确认它会逐帧 flush。这个项目没挂压缩中间件,所以没这个问题。
关于 CORS:如果前端和 API 不同源,fetch 读 SSE 仍然要过 CORS。项目用 koa-cors,默认 origin: '*',本机玩没问题;生产环境要收窄,并且注意带 cookie 时不能用 *。
还有一个顺序问题:先设 ctx.status = 200,再 flush 头。如果你在 ctx.status 还是默认 404 的时候调用 ctx.res.flushHeaders(),客户端会先收到一个 404 响应头,然后正文照样开始流——这是一个很难查的错位。本项目没有手动 flush,靠第一个事件触发发头,所以没踩到;但如果你要「先发头、稍后发首帧」,顺序必须写对。
步骤 3:用 async generator 产出事件
项目里所有事件源都是 AsyncGenerator<BaseEvent>:
async function* agentEvents(): AsyncGenerator<BaseEvent> {
const core = mode === 'llm'
? llmA2uiAgentEventStream(input, client!)
: mockAgentEventStream(input, loadA2uiMockJson);
yield* traceEvents(core);
}
为什么用 generator,而不是「在路由里直接 res.write」?
- 事件生产和传输解耦:生成器只负责「按顺序产出事件」,不关心 SSE、不关心 Koa、不关心 socket;
- 天然背压:消费端不
next(),生成器就停在yield,不会无限生产; - 错误可以统一包在生成器内部:这一点在步骤 6 会展开;
- 可测试:你可以
for await一个生成器,把事件收集成数组,不需要起 HTTP 服务。
项目里还有一个 traceEvents 包装器,只做计数和日志:
async function* traceEvents(source: AsyncGenerator<BaseEvent>) {
const counts: Record<string, number> = {};
let n = 0;
for await (const ev of source) {
n += 1;
const t = ev.type ?? 'unknown';
counts[t] = (counts[t] ?? 0) + 1;
yield ev;
}
a2uiAgentInfo('stream generator finished', { eventCount: n, counts });
}
这个模式很值得保留:在事件源和编码器之间插一层「只观察、不修改」的包装,你就能在不污染业务代码的情况下拿到每类事件的数量、耗时和终态。
步骤 4:把事件编码成 SSE 帧
AG-UI 的做法:
const encoder = new EventEncoder({ accept: 'text/event-stream' });
async function* eventStrings() {
for await (const event of agentEvents()) {
yield encoder.encodeSSE(event as BaseEvent);
}
}
encodeSSE 的产出就是:
data: {"type":"RUN_STARTED","threadId":"...","runId":"..."}
data: {"type":"CUSTOM","name":"a2ui.jsonl.chunk","value":"{\"beginRen"}
每一帧以两个换行结束。JSON.stringify 会把字符串里的换行转义成 \n,所以事件 JSON 本身不会破坏帧边界;你唯一要保证的是帧与帧之间真的有 \n\n。
这里有一个项目里特意绕开的坑,值得单独说。
为什么 EventEncoder 被硬编码成 text/event-stream
EventEncoder 会根据构造参数里的 accept 判断客户端能不能收 protobuf:
constructor(params) {
this.acceptsProtobuf = params?.accept
? this.isProtobufAccepted(params.accept)
: false;
}
getContentType() {
if (this.acceptsProtobuf) return AGUI_MEDIA_TYPE; // application/vnd.ag-ui.event+proto
return 'text/event-stream';
}
AG-UI 客户端经常会带:
Accept: application/vnd.ag-ui.event+proto, text/event-stream
如果你把 ctx.headers.accept 原样交给 EventEncoder,它会认为客户端接受 protobuf,getContentType() 返回 application/vnd.ag-ui.event+proto。但项目里实际调用的是 encodeSSE()——正文是文本,响应头却说自己是 protobuf。客户端按二进制解析,直接烂掉。
项目的修法是:
// 忽略客户端对 protobuf 的协商,本接口固定为 SSE(data 行内 JSON)
const encoder = new EventEncoder({ accept: 'text/event-stream' });
这不是「不支持 protobuf」的缺陷,而是一个明确的取舍:要么完整支持两种编码(getContentType() 配 encodeBinary()),要么固定一种并显式声明。最怕的是「头说 A、正文是 B」。
顺带一提:在 @ag-ui/encoder@0.0.48 里,encode() 永远返回字符串,encodeBinary() 才会根据协商结果返回 Uint8Array。如果你用 getContentType() 做协商,就必须配套用 encodeBinary(),不能继续用 encode() / encodeSSE()。
步骤 5:把字符串流交给 HTTP 响应
Koa 的写法只有三行:
ctx.status = 200;
ctx.body = Readable.from(eventStrings(), { objectMode: false });
Readable.from() 把 async iterable 适配成 Node 的可读流,Koa 的 respond 看到 body instanceof Stream 就 body.pipe(res)。
几个细节:
objectMode: false:我们 yield 的是字符串 / Buffer,不是对象。对象模式也能跑,但语义上这是字节流,关掉更准确。- 不要设
Content-Length:长度未知,Node 会自动用 chunked。 - 背压由
pipe负责:客户端读得慢,res.write()返回false,pipe暂停源流,Readable.from就不会继续拉生成器。这就是为什么不要把生成器换成「一次性JSON.stringify整个数组再res.end」。 - 不要混用
ctx.body和ctx.res.write():Koa 会在中间件结束后统一respond,你手动写过的字节会和它再写一遍,轻则重复,重则报write after end。
如果你不用 Koa,裸 Node 的等价写法是:
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
});
for await (const frame of eventStrings()) {
if (!res.write(frame)) {
await new Promise((resolve) => res.once('drain', resolve));
}
}
res.end();
注意 res.write() 的返回值:忽略它,内存会在慢客户端上越积越多。pipe 帮你做了这件事,手写循环就得自己做。
步骤 6:错误处理,分三个阶段
SSE 的错误处理不能笼统写一个 try/catch,要按「响应头有没有发出去」分三段。
阶段 A:响应头之前
参数错、鉴权失败、没有 API Key——这些都还能用 HTTP 状态码:
ctx.status = 400;
ctx.body = { error: 'Invalid RunAgentInput', details: ... };
或者:
ctx.status = 503;
ctx.body = { error: 'LLM 未配置:请设置 OPENAI_API_KEY' };
客户端 fetch 拿到非 2xx,读 JSON 错误体,不要把它当 SSE 解析。
阶段 B:响应头之后,但生成器还能控制
一旦 200 text/event-stream 发出去,状态码就冻结了。此时唯一的报错方式是在流里发 RUN_ERROR,然后正常结束流:
try {
// ... 生产事件
} catch (e) {
yield { type: EventType.RUN_ERROR, message: `LLM 调用失败: ${msg}` };
return;
}
项目在 LLM 调用失败、模型输出无法解析、输出里没有 A2UI 键这三种情况下都是这么做的。客户端收到 RUN_ERROR 后,把错误文案写进气泡,结束 loading。
阶段 C:生成器直接 throw
这是最危险的情况。很多人会想:「生成器抛错,流自然就结束了吧?」——在 Koa 2.16.4 + Node 24 下,不是。
我写了一个最小复现(source/a2ui-sse-lab/koa-hang.mjs):服务端先发两条事件,然后 throw new Error('boom-after-headers')。客户端的 reader.read() 结果是这样:
status 200 at 48 ms
read# 1 at 49 "data: {\"n\":0}\n\n"
read# 2 at 243 "data: {\"n\":1}\n\n"
Error: boom-after-headers
at gen (...)
read threw TimeoutError The operation was aborted due to timeout at 3002 reads 2
注意最后一行:客户端不是收到 done,也不是收到错误,而是一直挂着,直到它自己的 3 秒超时把连接掐掉。
原因在 Koa 的实现里:
ctx.body = stream时,Koa 给流挂了stream.once('error', err => ctx.onerror(err));ctx.onerror()一进来就检查this.headerSent || !this.writable;- 头已经发了,
headerSent为真,onerror直接 return,既不销毁res,也不res.end(); - Node 的
readable.pipe(writable)在源流出错时不会自动结束目标流。
于是源流进入了错误态,目标 res 却还开着,客户端永远等不到结束帧。
这条实测结论的价值不在于「Koa 有 bug」,而在于它给出了一个硬规则:
在 SSE 生成器里,
yield过第一个事件之后,不要再让异常冒泡出去。 所有可预期的失败都要在生成器内部捕获,转成RUN_ERROR;所有清理放在finally。
一个更稳的写法:
async function* eventStream() {
let terminal = false;
try {
yield { type: EventType.RUN_STARTED, threadId, runId };
// ... 业务事件
terminal = true;
yield { type: EventType.RUN_FINISHED, threadId, runId, result };
} catch (e) {
if (!terminal) {
terminal = true;
yield { type: EventType.RUN_ERROR, message: toMessage(e) };
}
} finally {
// 关连接、清定时器、取消上游请求
}
}
如果连 yield 本身都失败(比如客户端已经断开),那说明流已经没人读了;此时 finally 里做清理即可,不要再试图写。
阶段 D:客户端已经断开
客户端 abort() 后,fetch 的 reader.read() 会抛 AbortError。服务端这边,Koa 在响应结束时用 onFinish 销毁 ctx.body 对应的流;Readable.from 的销毁会调用 async iterator 的 return(),于是生成器的 finally 会执行。
我用裸 Node 验证了「服务端能感知客户端断开」这件事(fetch-lab.mjs 的 /abort):
{ path: '/abort', headerAt: 2, abortError: 'AbortError', buf: 'data: {"i":0}\n\ndata: {"i":1}\n\ndata: {"i":2}\n\n' }
[server] res close early -> abortFinally = true
但要注意:生成器的 finally 能执行,不代表上游的 LLM 请求会被取消。项目里的 client.chat.completions.create() 没有传 signal,所以用户点「停止」之后,服务端可能还在等模型返回。正确做法是把 AbortController 一路传到 OpenAI SDK:
const completion = await client.chat.completions.create(params, {
signal: inputSignal
});
步骤 7:取消与清理
服务端要清理的东西通常有四类:
| 资源 | 例子 | 清理位置 |
|---|---|---|
| 定时器 | 心跳、切片间隔的 setTimeout | finally |
| 上游请求 | LLM、数据库、工具调用 | finally + 传 signal |
| 队列 / 缓冲区 | 你自己实现的异步队列 | finally |
| 日志 / 指标 | 记录本次 run 的终态 | finally |
判断客户端是否断开,Koa 里可以监听响应的 close:
ctx.res.on('close', () => {
// 正常结束时也会触发;用 writableEnded 区分「写完了」和「被掐断」
if (!ctx.res.writableEnded) {
// 请求方提前关闭了连接
}
});
不建议用 req.on('close') 来判断:req 的 close 语义在不同 Node 版本 / 请求方法下不够稳定(它可能表示「请求体读完了」,而不是「连接断了」);res 的 close 才直接对应响应生命周期的结束。更稳的是依赖流的 finally,因为它同时覆盖「正常结束」和「异常销毁」。两者可以都做:监听 close 设置一个 aborted 标志,生成器在每次 yield 前检查。
步骤 8:心跳与空闲超时
SSE 是长连接,但中间层不一定有耐心。常见的空闲超时:
- nginx
proxy_read_timeout默认 60s; - 云负载均衡 / API 网关常见 30s–120s;
- 浏览器 / 移动网络也会掐掉长时间无数据的连接。
心跳就是一个注释帧:
: ping
它以一个冒号开头,没有 data: 行。SSE 规范里注释行会被忽略,项目的客户端也会因为它没有 data: 行而跳过——所以心跳不会污染业务解析。
什么时候需要心跳?
- 两次业务事件之间的间隔可能超过 30s;
- 尤其注意:这个项目在
RUN_STARTED之后要等完整的 LLM 调用(可能几十秒)才发第一个 chunk。如果中间层 30s 就断,用户会看到「流莫名结束」。
项目目前没有实现心跳。它靠 RUN_STARTED 立即发出,保证首字节很快;但 LLM 调用期间的长空档仍然没有保活。生产环境建议加一个 15–30s 的 : ping\n\n。
实现上有两种做法:
- 手动写
res:setInterval(() => res.write(': ping\n\n'), 15000),在finally里clearInterval。简单,但要自己处理背压和「写已结束」。 - 把心跳并进事件流:用一个异步队列 /
Promise.race,让生成器在等待业务事件时也能yield心跳。复杂一点,但和Readable.from的背压模型一致。
无论哪种,心跳都必须是独立的一帧,不能插在某个 data: 帧中间。
步骤 9:留一个非 SSE 的降级出口
项目支持 ?sse=0(或 false / no / off / json):
if (!useSse) {
const events: BaseEvent[] = [];
for await (const event of agentEvents()) {
events.push(event);
}
ctx.set('Content-Type', 'application/json; charset=utf-8');
ctx.body = { events };
return;
}
用途:
- 用
curl/ Postman 调试时,不用处理流; - 写集成测试时,直接断言事件数组;
- 某些不支持流式的客户端可以退化成一次性响应。
注意:它复用同一个事件源。这是 generator 设计的额外收益——同一套业务逻辑,既能流式发,也能收集成数组。
步骤 10:可观测性
至少记录这些:
| 指标 | 为什么 |
|---|---|
threadId / runId | 把一次请求的所有日志串起来 |
mode(llm / mock) | 排查「为什么没调模型」 |
| 事件总数、每类事件数 | 判断是「没产出」还是「产出了但客户端没收到」 |
| 首个事件耗时、总耗时 | 区分 LLM 慢和网络慢 |
终态(RUN_FINISHED / RUN_ERROR / 无终态) | 无终态是最容易被忽略的故障 |
| 发送字节数 | 判断切片是否过碎 |
项目的 traceEvents 已经统计了事件数;a2uiAgentLog.ts 还会把 system prompt、模型原文、请求体落盘。后者对调试很有用,但要注意:默认把完整 prompt 和用户输入写进日志,在生产环境是隐私风险。项目用环境变量提供了关闭开关,这是对的。
一个可直接跑的 Koa SSE 端点
把上面的步骤合起来,去掉 A2UI 业务,骨架大概是这样:
import Koa from 'koa';
import Router from 'koa-router';
import { Readable } from 'node:stream';
const app = new Koa();
const router = new Router();
// 省略:app.use(bodyParser()),否则 ctx.request.body 是 undefined
router.post('/api/agent', async (ctx) => {
// 1. 流开始前:还能用 HTTP 状态码
const input = ctx.request.body;
if (!input?.threadId || !input?.runId) {
ctx.status = 400;
ctx.body = { error: 'threadId and runId are required' };
return;
}
// 2. 响应头
ctx.set('Content-Type', 'text/event-stream; charset=utf-8');
ctx.set('Cache-Control', 'no-cache, no-transform');
ctx.set('Connection', 'keep-alive');
ctx.set('X-Accel-Buffering', 'no');
ctx.status = 200;
// 3. 事件源:只产出事件,不碰 res
async function* events() {
let terminal = false;
try {
yield { type: 'RUN_STARTED', threadId: input.threadId, runId: input.runId };
for (let i = 0; i < 5; i++) {
yield {
type: 'CUSTOM',
name: 'demo.chunk',
value: `第 ${i} 段`
};
await new Promise((r) => setTimeout(r, 200));
}
terminal = true;
yield { type: 'RUN_FINISHED', threadId: input.threadId, runId: input.runId };
} catch (e) {
if (!terminal) {
terminal = true;
yield {
type: 'RUN_ERROR',
message: e instanceof Error ? e.message : String(e)
};
}
}
}
// 4. 编码 + 交给 Koa
async function* frames() {
for await (const ev of events()) {
yield `data: ${JSON.stringify(ev)}\n\n`;
}
}
ctx.body = Readable.from(frames(), { objectMode: false });
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3847, () => console.log('http://localhost:3847'));
这段代码没有心跳、没有上游取消、没有 protobuf;但它把「事件生产 / 编码 / 传输 / 错误 / 终态」五件事分开了。剩下的都是在这五个位置上加东西。
4. 客户端 fetch + ReadableStream:完整步骤
步骤 1:为什么不是 EventSource
站内《EventSource 是什么,为什么 Agent 流式接收最后都换成了 fetch + ReadableStream》已经把规范依据拆透了,这里只留结论:
| 需求 | EventSource | fetch + ReadableStream |
|---|---|---|
| POST + JSON body | 不支持(只能 GET) | 支持 |
自定义请求头(如 Authorization) | 不支持 | 支持 |
| 看 HTTP 状态码 | 不支持 | 支持 |
| 主动取消 | close(),但语义有限 | AbortController |
| 断线自动重连 | 内置 | 自己写 |
| 断点续传 | 内置 Last-Event-ID | 自己写 |
AG-UI 的 HTTP+SSE 绑定要求 POST RunAgentInput,所以 EventSource 从第一步就不成立。保留 SSE 的帧格式,换掉读它的那只手,这就是 fetch + ReadableStream 的位置。
步骤 2:fetch 的五个关键参数
项目里:
const res = await fetch(`/api/agent?t=${Date.now()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream'
},
signal: abortRef.current.signal,
body: JSON.stringify({
threadId: threadIdRef.current,
runId: `run-${Date.now()}`,
state: {},
messages: chatMessagesToAgentApiPayload(history, { ... }),
tools: [],
context: [],
forwardedProps
})
});
逐个说:
| 参数 | 作用 | 注意 |
|---|---|---|
method: 'POST' | 携带 RunAgentInput | SSE 本身不限制方法,但 AG-UI 绑定要求 POST |
Content-Type: application/json | 告诉服务端 body 是 JSON | 少了它,Koa 的 bodyparser 不会解析 |
Accept: text/event-stream | 声明期望流式响应 | 服务端应据此选 SSE;本项目固定 SSE |
signal | 取消请求 | abort() 会让 reader.read() 抛 AbortError |
body: JSON.stringify(...) | 请求体 | 不要在这里放二进制;图片走 base64 字段 |
?t=${Date.now()} 是缓存破坏;POST 通常不会被缓存,但加上无害。真正要防缓存的是响应头,不是 URL。
步骤 3:先看 status 和 content-type,再决定怎么读
fetch 在响应头到达时就 resolve,不是在 body 结束时。所以:
if (!res.ok) {
const raw = await res.text();
// 尝试解析 JSON 错误体
return;
}
const ct = res.headers.get('content-type') || '';
if (!ct.includes('text/event-stream') && !ct.includes('event-stream')) {
// 服务端退化成 JSON 了
const raw = await res.text();
// 按 JSON 处理
return;
}
这段检查很值钱:
- 服务端可能返回 400 / 503,body 是 JSON,不是 SSE;
- 服务端可能忽略
Accept,直接返回一次性 JSON; - 代理可能把
Content-Type改掉(少见,但要能诊断)。
项目里两条路径都做了这个判断:/api/agent 和 /api/chat。如果 content-type 不是流,就把整个 body 读出来,按 data: 行或 JSON 解析,并给用户一个「已按非标准响应处理」的提示。
步骤 4:getReader() + TextDecoder({ stream: true })
const body = res.body;
if (!body) throw new Error('响应无 body');
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = '';
三个点:
res.body可能为null(比如 HEAD 请求、或者响应已经被消费)。先判空。- 一个
ReadableStream同一时间只能有一个 reader。不要在别处再getReader();读完之前也不要releaseLock()。 TextDecoder必须配合{ stream: true }。
第三点单独说,因为它是「看起来能跑、偶尔烂一个字」的典型。
网络分片是按字节切的。一个 UTF-8 汉字通常 3 个字节,完全可能被切在两个分片之间。TextDecoder 默认的 decode(value) 会把不完整的尾字节当成非法序列,输出 U+FFFD(替换字符,显示为 �);带了 { stream: true },它会把不完整的尾字节缓存起来,等下一片到了再一起解码。
我用 fetch-lab.mjs 复现了:
-- stream:true --
{ path: '/utf8', buf: 'data: {"text":"你好"}\n\n' }
-- stream:false --
{ path: '/utf8', buf: 'data: {"text":"��好"}\n\n' }
服务端故意把「你」的 3 个字节切成 2 + 1。正确解码还原出「你好」;默认解码得到两个 �。
流结束时,严格的写法还应该补一次 decoder.decode(),把可能残留的尾字节 flush 出来。项目的 consumeAgentSse 没有做,因为服务端保证每条事件都以完整 UTF-8 字符结束;但通用客户端应该补。
步骤 5:按空行分帧
SSE 的帧边界是空行,不是网络分片。一个分片可能包含:
- 半个事件;
- 一个完整事件;
- 十几个完整事件。
所以必须有一个跨分片的字符串缓冲区:
buf += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const block = buf.slice(0, sep);
buf = buf.slice(sep + 2);
// 处理 block
}
这段逻辑看着简单,边界不少:
| 情况 | 处理 |
|---|---|
buf 里没有 \n\n | 继续 read() |
一个分片里有多个 \n\n | 内层 while 全部切出来 |
流结束时 buf 还有残余 | 按规范丢弃(残帧不是完整事件) |
服务端用 \r\n 分帧 | indexOf('\n\n') 找不到,见下面的坑 |
心跳注释帧 : ping\n\n | 切出来,但没有 data: 行,跳过 |
| 空行本身 | block 为空,跳过 |
第 4 条是这个项目客户端的一个真实缺陷。代码注释里写了「若代理改写成 \r\n,\r 会留在行尾,由 slice(5).trim() 去掉」——但那只处理了 data: 行尾的 \r,帧分隔符本身 \r\n\r\n 里并没有连续的 \n\n,indexOf('\n\n') 会一直返回 -1,整个流一个事件都切不出来。
验证很简单:
'\r\n\r\n'.indexOf('\n\n') // -1
AG-UI 规范要求 producer 用 LF,所以这个项目自己的服务端不会触发;但只要有中间层改写行尾,客户端就会静默卡住。通用写法是先做一次归一化:
buf = buf.replace(/\r\n/g, '\n');
或者在切帧时同时找两种分隔符。代价很小,收益是少一个「线上偶发卡死」。
步骤 6:解析 data: 行与 JSON
一个 SSE 事件块由若干 field: value 行组成:
event: message
id: 42
data: {"type":"CUSTOM","name":"a2ui.jsonl.chunk","value":"..."}
: comment
项目只关心 data:,而且是第一条:
const line = block.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const json = line.slice(5).trim();
if (!json) continue;
let ev;
try {
ev = JSON.parse(json);
} catch {
continue;
}
这里有三处简化,通用客户端要知道:
- 多行
data::SSE 规范允许多个data:行,客户端应该用\n把它们拼起来。项目只取第一行。它的服务端每个事件只写一行data:,所以没问题;换一个服务端就可能丢内容。 event:/id:/retry::项目全部忽略。AG-UI 的绑定规定 consumer 可以忽略除data外的字段,所以这是合规的。但如果你要支持EventSource式的命名事件或续传,就得解析它们。- JSON 解析失败静默跳过:这是有意的取舍——宁可少渲染一条,也不让整个页面崩。但必须记录指标,否则你会不知道「为什么少了一块 UI」。
步骤 7:把事件分派给业务
项目在 consumeAgentSse 里做的是「通用 SSE 解析 + AG-UI 事件分派」,不碰 A2UI:
if (t === 'CUSTOM') {
const cname = ev.name;
if (cname === A2UI_LLM_RAW_NAME && typeof ev.value === 'string') {
options?.onLlmRaw?.(ev.value);
}
const w = a2uiCustomEventToWriteString(ev);
if (w !== null) onWrite(w);
}
if (t === 'RUN_FINISHED') {
finishedSummary = `Agent 完成:${JSON.stringify(ev.result)}`;
}
if (t === 'RUN_ERROR') {
runError = typeof ev.message === 'string' ? ev.message : 'RUN_ERROR';
}
a2uiCustomEventToWriteString 是这一层的核心适配器:
export function a2uiCustomEventToWriteString(ev) {
if (ev.type !== 'CUSTOM') return null;
if (ev.name === 'a2ui.jsonl.chunk' && typeof ev.value === 'string') {
return ev.value; // 原样透传,不能补 \n
}
if (ev.name === 'a2ui.message' && ev.value != null) {
return `${JSON.stringify(ev.value)}\n`; // 对象要序列化 + 补行终止符
}
return null;
}
两条分支不对称,原因值得记住:
a2ui.jsonl.chunk的 value 是已经序列化好的 JSONL 文本碎片,换行符在字符串内部,服务端已经按协议切好片。再加\n会凭空插入空行,污染调试副本,也可能把恰好切在行中间的碎片改坏。a2ui.message的 value 是对象,必须JSON.stringify才能变成文本;而JSON.stringify只产出{...},不带任何行终止符,所以要补\n。
一句话:序列化负责变成文本,换行负责分帧,两者不能混。JSONL 的行终止符不是排版,是记录边界。
步骤 8:流结束后的收尾
consumeAgentSse 返回,只代表网络流结束了。此时业务层还有两件事没做:
- 解析器缓冲里可能还有未闭合的行 / 未闭合的组件;
- 渲染节流窗口里可能还有一次 treeBuild 没执行。
所以项目的调用方在 await consumeAgentSse(...) 之后必须:
a2uiParser.endStream();
a2uiParser.flushPendingRender();
setStoreState(createdStore.getState());
这三行的顺序不能乱:
endStream()把缓冲区里剩下的内容做最后一次解析(如果能解析);flushPendingRender()立即执行被 400ms 节流压住的那次渲染;- 最后把 store 快照同步给 React state,让调试面板和预览区拿到最终状态。
漏掉 flushPendingRender() 的典型症状是:最后一个组件要等到下一次用户操作才出现。漏掉 endStream() 的典型症状是:最后一条没有换行结尾的消息永远不渲染。
步骤 9:取消、超时与截断
客户端有三种「没正常结束」:
| 情况 | 表现 | 应该怎么处理 |
|---|---|---|
| 用户主动取消 | reader.read() 抛 AbortError | 显示「已取消」,保留已收到的部分 |
| 网络断开 / 服务端崩溃 | read() 抛 TypeError / terminated | 显示「连接中断」,标记为不完整 |
| 服务端正常关闭但没发终态 | read() 返回 done: true | 按截断处理,不要当成功 |
项目对第一种有明确处理:
catch (e) {
if ((e as Error).name === 'AbortError') {
// 保留 agentJsonlAccumRef / agentLlmRawRef,气泡显示「已取消」
return;
}
// 其他错误显示「客户端错误:...」
}
对第三种,它有一个兜底文案:
const assistantText = runError ?? finishedSummary ?? '流结束(未收到 RUN_FINISHED)。';
但从产品语义上,这还应该被标成 不完整,而不是普通完成。AG-UI 规范说得很直接:
"A connection that drops without a terminal event is a truncated run."
一个更严谨的客户端会记录:
const complete = Boolean(finishedSummary) && !runError;
然后把 complete 传给 UI:不完整的流可以保留已渲染的界面,但要提示用户「本次生成未完成,可重试」。
超时也建议分两层:
- 首字节超时:
fetch发出后 N 秒还没收到响应头,直接放弃; - 空闲超时:两个分片之间超过 N 秒没有数据,放弃。
AbortSignal.timeout() 只能设总时长,不适合长流;空闲超时需要每次 read() 用 Promise.race 包一个定时器。很多线上「流卡住」不是断了,而是某一端在等一个永远不会来的字节。
一个可复用的通用 SSE 读取器
把项目里的实现抽掉 A2UI 业务,骨架如下:
export async function consumeSse(
response: Response,
onEvent: (ev: unknown) => void,
options: { signal?: AbortSignal } = {}
): Promise<{ sawTerminal: boolean; error?: string }> {
const body = response.body;
if (!body) throw new Error('响应无 body');
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = '';
let sawTerminal = false;
let error: string | undefined;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
// 兼容被中间层改写成 CRLF 的情况。
// 注意先拼接、再归一化:否则切在 \r 和 \n 之间的分片会漏掉。
buf += decoder.decode(value, { stream: true });
buf = buf.replace(/\r\n/g, '\n');
let sep: number;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const block = buf.slice(0, sep);
buf = buf.slice(sep + 2);
// 多行 data: 按规范用 \n 拼接
const dataLines = block
.split('\n')
.filter((l) => l.startsWith('data:'))
.map((l) => l.slice(5).replace(/^ /, ''));
if (dataLines.length === 0) continue;
const json = dataLines.join('\n').trim();
if (!json) continue;
let ev: any;
try {
ev = JSON.parse(json);
} catch {
continue; // 单条坏事件不影响整条流
}
if (ev?.type === 'RUN_FINISHED') sawTerminal = true;
if (ev?.type === 'RUN_ERROR') {
sawTerminal = true;
error = typeof ev.message === 'string' ? ev.message : 'RUN_ERROR';
}
onEvent(ev);
}
}
// 流结束:残余 buf 按规范丢弃
return { sawTerminal, error };
}
调用方:
const res = await fetch('/api/agent', { method: 'POST', ... });
if (!res.ok) throw new Error(await res.text());
const { sawTerminal, error } = await consumeSse(res, (ev) => {
// 分派
});
if (!sawTerminal) {
// 截断:不要当成功
}
这段代码不依赖 A2UI,也不依赖 AG-UI,可以放进任何 SSE 客户端。
5. 这条链路里最值钱的坑
下面这些坑按「分帧 / 传输 / 生命周期」分组。每条尽量给:现象 → 原因 → 做法。
5.1 分帧与编码
坑 1:把网络分片当成事件
现象:本地跑得好好的,线上偶尔 JSON 解析失败、UI 少一块。
原因:reader.read() 返回的是网络分片,和 SSE 事件边界没有任何对应关系。一个分片可能是半个事件,也可能是十个事件。
做法:永远维护字符串缓冲区,按 \n\n 切;切不出来的留在 buf 里等下一片。不要对每个 value 直接 JSON.parse。
坑 2:TextDecoder 不带 stream: true
现象:中文偶尔变成 �,英文和数字永远正常。
原因:一个 UTF-8 汉字 3 个字节,被网络分片切在中间;默认 decode 会把不完整的尾字节判为非法。
做法:decoder.decode(value, { stream: true });流结束时再 decoder.decode() flush 一次。
这条我在本机复现过:同一个被切成 2 + 1 的「你」,stream: true 得到「你好」,默认得到 ��好。
坑 3:按 UTF-16 下标切片,切开了代理对
现象:服务端按字符数切片,某些 emoji 在日志里变成半个字符;客户端最终 JSON 却又能解析。
原因:jsonl.slice(pos, pos + size) 按 UTF-16 code unit 切,一个 emoji 可能是两个 code unit(代理对)。切在中间会得到两个「半个代理」。
做法:不要假设每个 chunk 都是合法的独立字符串 / JSON。客户端要把 chunk 当作不透明字节流,先拼进缓冲区再解析。本项目正是这么做的:a2ui.jsonl.chunk 的 value 原样写入 JSONLBuffer,不单独 JSON.parse。如果服务端要在日志里打印每个 chunk,先做一次 Buffer.from(chunk).toString('utf8') 也救不了半个代理——最好只打印长度或转义后的形式。
坑 4:\n\n 与 \r\n\r\n
现象:某个代理 / 网关后面,客户端一个事件都收不到,连接却一直开着。
原因:客户端只找 \n\n,而中间层把行尾改成了 CRLF,\r\n\r\n 里没有连续的 \n\n。
做法:入缓冲区后先归一化 \r\n 为 \n,或者切帧时同时匹配两种分隔符。AG-UI 规范要求 producer 用 LF,但 consumer 最好宽容一点。
坑 5:给 JSONL 碎片补 \n
现象:调试面板里出现空行;偶尔某个组件不渲染。
原因:a2ui.jsonl.chunk 的 value 已经是带换行的 JSONL 文本,再补 \n 会插入空行;空行不是合法 JSON 行,解析器要么跳过,要么污染缓冲区。
做法:区分两种 CUSTOM:
a2ui.jsonl.chunk(字符串碎片)→ 原样透传;a2ui.message(对象)→JSON.stringify后补\n。
坑 6:EventEncoder 协商成 protobuf,正文却写 SSE
现象:某些 AG-UI 客户端(会带 Accept: application/vnd.ag-ui.event+proto)收到乱码 / 解析失败;curl 却正常。
原因:getContentType() 根据 Accept 返回了 protobuf 媒体类型,但代码调用的是 encodeSSE(),正文是文本。
做法:二选一:
- 固定 SSE:
new EventEncoder({ accept: 'text/event-stream' }),并且响应头写text/event-stream; - 完整协商:
getContentType()配encodeBinary(),用Buffer写响应。
最忌讳「头说 A、正文是 B」。
5.2 传输与部署
坑 7:响应头要等第一个 chunk 才发
现象:服务端明明已经进入路由,客户端 fetch 却迟迟不 resolve;监控上「首字节时间」等于「第一个业务事件时间」。
原因:Node 的 HTTP 响应头通常在第一次 write() 时才真正发出。如果生成器先等 1.5 秒才 yield 第一个事件,fetch 就要等 1.5 秒才 resolve。
我实测(fetch-lab.mjs):
不 flush:headerAt 1546ms,firstByteAt 1547ms
先 flush:headerAt 4ms,firstByteAt 1551ms
做法:
- 流打开后立刻发一个
RUN_STARTED(本项目就是这么做的); - 或者先
ctx.status = 200,再ctx.res.flushHeaders(),让客户端先拿到响应头。
注意顺序:先设 status,再 flush。反了会把默认 404 发出去。
坑 8:代理缓冲 / 压缩把流攒成一次性
现象:本地逐字出现,部署到 nginx / 网关后「转圈很久,然后整段出现」。
原因:中间层开了响应缓冲或压缩,攒够一批才往下发。
做法:
X-Accel-Buffering: no(nginx);Cache-Control: no-cache, no-transform;- 压缩中间件排除
text/event-stream,或确认它逐帧 flush; - 检查网关的缓冲配置和空闲超时。
坑 9:HTTP/1.1 的 6 连接上限
现象:开多个标签页 / 多个组件同时连 SSE,后面的请求一直 pending。
原因:浏览器对同一 origin 的 HTTP/1.1 并发连接通常限制在 6 条;每条 SSE 长期占用一条。
做法:生产用 HTTP/2(多路复用);或者合并流、限制同时打开的流数量。这个项目是本地 Playground,不受影响,但做产品要想。
坑 10:客户端断开后,上游还在跑
现象:用户点了「停止」,界面不更新了,但服务端日志还在打 LLM 请求 / 工具调用。
原因:客户端的 AbortController 只取消了浏览器到服务端的连接;服务端到 LLM 的请求是另一条链路,没有传 signal。
做法:把取消信号一路传下去:
await client.chat.completions.create(params, { signal });
并在生成器的 finally 里清理定时器、队列、数据库连接。
5.3 生命周期与错误
坑 11:头发出后 throw,客户端永久挂起
现象:流前面几条正常,然后客户端一直转圈,既不结束也不报错;服务端日志里有异常。
原因:Koa 的 onerror 在 headerSent 时直接返回,不结束 res;pipe 也不会因为源流出错而结束目标流。
做法:生成器内部捕获所有异常,转成 RUN_ERROR;yield 过第一个事件之后,不要让异常冒泡。清理放 finally。
这是本文最值得记住的一条,因为它不是「概率问题」,而是一旦发生就必然挂到超时。
坑 12:把 200 当成功
现象:HTTP 200,客户端显示「完成」,但其实只收到半截 UI。
原因:SSE 的响应头在第一个字节就发出去了;后续失败只能通过流内事件表达。fetch 的 res.ok 只代表「流打开了」,不代表「run 成功了」。
做法:以 RUN_FINISHED / RUN_ERROR 为准;没有终态就是截断。UI 上要区分「完成」「失败」「不完整」三种状态。
坑 13:断线后自动重连,重复执行 run
现象:网络抖动后,同一个问题被回答了两次;或者 UI 出现两套组件。
原因:EventSource 会自动重连;即使你用 fetch,也可能在业务层写了「失败重试」。AG-UI 的 HTTP+SSE 绑定没有断点续传:
"The binding has no stream resumption... Re-running is a new run with a new runId."
做法:重连 = 新的 run;不要复用 runId 假装续传。如果业务需要幂等,用 threadId + 客户端生成的请求 id 去重。
坑 14:心跳帧被业务解析器误判
现象:加了心跳后,客户端偶尔报「JSON 解析失败」。
原因:心跳写成了 data: ping\n\n,而不是注释帧 : ping\n\n。
做法:心跳用冒号开头的注释行;客户端切到没有 data: 的块就跳过。
坑 15:慢客户端把内存打爆
现象:服务端内存随连接数上涨;GC 频繁。
原因:生成器产出速度远大于网络发送速度,而代码忽略了 res.write() 的返回值 / 没用 pipe 的背压。
做法:用 Readable.from() + pipe,或者手写 if (!res.write(frame)) await once(res, 'drain')。同时限制单条流的最大缓冲、最大事件数、最大时长。
坑 16:调试副本无限增长
现象:长时间流式会话后,页面变卡,内存上涨。
原因:客户端把每个 chunk 都累加到 agentJsonlAccumRef,为了「View A2UI JSON」调试面板保留全文。协议越大,副本越大。
做法:调试副本设上限(比如保留前 1MB + 后 1MB),或者只在调试开关打开时累积。生产环境不要默认保留完整模型原文。
5.4 调试 SSE 的四个手法
SSE 的调试体验比普通接口差,因为它「不结束」。下面几个手法可以少走弯路。
① curl -N 看原始帧
curl -N -X POST http://localhost:3847/api/agent \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
-d '{"threadId":"t1","runId":"r1","messages":[],"tools":[],"context":[]}'
-N 关掉 curl 的输出缓冲,你能看到帧到达的真实节奏。如果所有帧「唰」地一起出现,优先怀疑代理 / 压缩,而不是客户端。
② 浏览器的 Network → EventStream
Chrome DevTools 的 Network 面板对 text/event-stream 有专门的 EventStream 视图,能看到每个事件的到达时间和 data。它比 console.log 更接近线上,因为经过了真实的浏览器网络栈。
③ 服务端计数
在 traceEvents 里按类型计数,结束时打一条摘要:
eventCount: 87, counts: { RUN_STARTED: 1, CUSTOM: 85, RUN_FINISHED: 1 }
如果客户端说「没收到」,而服务端计数正常,问题在传输;如果服务端计数里没有 RUN_FINISHED,问题在生成器。
④ 非流式降级对照
同一个事件源,用 ?sse=0 收成数组:
curl -s -X POST 'http://localhost:3847/api/agent?sse=0' ...
如果 JSON 里事件齐全、SSE 却缺,说明分帧 / 传输层有问题;如果 JSON 里也缺,说明业务事件源有问题。把「业务」和「传输」分开验证,比盯着浏览器一行行看快得多。
6. 最小可复现:不用 Koa 也能跑
如果你想亲手验证「分片不等于事件」「UTF-8 被切半」「错误发生在头之后」,可以不装任何依赖,用 Node 内置的 http 写一个 60 行的服务端。
6.1 服务端
import http from 'node:http';
const server = http.createServer(async (req, res) => {
if (req.url !== '/sse') {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
});
let closed = false;
res.on('close', () => {
if (!res.writableEnded) {
closed = true;
console.log('[server] client closed early');
}
});
try {
for (let i = 0; i < 5; i++) {
if (closed) break;
const ev = {
type: i === 0 ? 'RUN_STARTED' : 'CUSTOM',
name: i === 0 ? undefined : 'demo.chunk',
value: i === 0 ? undefined : `第 ${i} 段:你好`
};
const frame = `data: ${JSON.stringify(ev)}\n\n`;
if (!res.write(frame)) {
await new Promise((resolve) => res.once('drain', resolve));
}
await new Promise((r) => setTimeout(r, 300));
}
if (!closed) {
res.write(`data: ${JSON.stringify({ type: 'RUN_FINISHED' })}\n\n`);
}
} catch (e) {
// 注意:头已经发了,不能再改状态码
if (!closed) {
res.write(`data: ${JSON.stringify({
type: 'RUN_ERROR',
message: e instanceof Error ? e.message : String(e)
})}\n\n`);
}
} finally {
res.end();
}
});
server.listen(3906, () => console.log('http://localhost:3906/sse'));
6.2 客户端
const res = await fetch('http://localhost:3906/sse', {
headers: { Accept: 'text/event-stream' }
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const block = buf.slice(0, sep);
buf = buf.slice(sep + 2);
const line = block.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const json = line.slice(5).trim();
if (!json) continue;
try {
console.log(JSON.parse(json));
} catch {
console.warn('bad event', json);
}
}
}
6.3 你可以自己改的几个实验
| 改动 | 观察什么 |
|---|---|
把服务端 res.write(frame) 改成一次写半帧 | 客户端不会立刻解析,等下一片补全 |
把 res.writeHead 里的 Content-Type 改成 application/json | 客户端仍能收,但你的检查逻辑应该走降级 |
在第一个 yield 后 throw | 对比 Koa 版本:裸 Node 里 res.end() 在 finally 会结束;Koa 里不会 |
去掉 { stream: true } | 中文偶发 � |
客户端 450ms abort() | 服务端 res 的 close 事件触发(writableEnded 为 false) |
7. 为什么 A2UI 能「边收边渲染」
传输层只负责把字节送到。真正让界面「长出来」的,是 packages/a2ui-core/src/parser/index.ts 里的 JSONLBuffer。
7.1 正常路径:按行切
JSONLBuffer.write() 把新数据追加到内部字符串,然后:
const lines = trimmedBuffer.split('\n');
if (lines.length > 1) {
// 除最后一行外,都当作完整行立即解析
for (let i = 0; i < lines.length - 1; i++) {
this.processLine(lines[i]);
}
// 最后一行留作「可能不完整」的缓冲
this.buffer = lines[lines.length - 1];
}
这就是 JSONL 的标准处理:换行是记录边界。服务端保证每条 A2UI 消息以 \n 结尾,客户端就能逐行解析。
7.2 加速路径:半条 surfaceUpdate 也能先渲染组件
如果缓冲区里只有一行,而且还没闭合,它会先试 isCompleteJSON()(花括号计数 + 字符串状态扫描);如果还不完整,但包含 "surfaceUpdate":,就进入 extractSurfaceIdAndComponents():
- 正则取出
surfaceId; - 从
"components":[之后做大括号匹配,跳过字符串和转义; - 每提取到一个完整的 component,就立刻回调一次
surfaceUpdate,只带这一个组件; - 把这个组件从缓冲区里移除,继续找下一个。
效果是:整条 surfaceUpdate 还没收完,已经完整的组件可以先画出来。这是这个项目「渐进渲染」观感的关键。
代价也很明显:
- 只覆盖
surfaceUpdate,其他消息必须等完整 JSON; - 大括号匹配是手写的,边界情况(字符串里的
{、转义、嵌套)靠状态机兜; JSON.parse失败被静默忽略(parser/index.ts:278),语法错误会导致该组件被跳过,但不会中断流;- 没有 schema 校验,结构错了也能进渲染层。
这些是「教学原型」的合理取舍,不是通用 SDK 的做法。真要做生产客户端,应该用 schema 校验 + 增量 JSON 解析器,而不是手写括号匹配。
7.3 渲染节流:400ms
即使传输层每 20–120ms 到一个 chunk,也不应该每来一个 chunk 就重建一次 React 树。项目的 parser 用 400ms 节流:
- 距上次渲染不足 400ms:只安排一次延迟渲染;
- 流结束或本地数据更新:调用
flushPendingRender()立即刷新。
这个数字是体感调参:太小会抖,太大会显得卡。它和传输层的切片节奏是两套独立的节流,不要混为一谈。传输层决定「数据多快到达」,渲染层决定「界面多快更新」。
7.4 一个需要知道的协议顺序问题
服务端 splitCombinedA2uiMessage() 拆分的顺序是:
['beginRendering', 'surfaceUpdate', 'dataModelUpdate', 'deleteSurface']
这个顺序是为这个客户端量身定做的:它的 parser 收到 beginRendering 时会清空 hydrateNodes,然后等后续 surfaceUpdate 建树。所以必须先发 beginRendering。
但 A2UI v0.8 规范的要求是反过来的:客户端应该先缓冲 surfaceUpdate / dataModelUpdate,等 beginRendering 到达后再首次渲染。项目自己的 CHECK.md 把这一点列为 P0:如果接一个按规范顺序(组件先、beginRendering 后)的服务端,这个客户端会崩。
也就是说:传输层没问题,协议时序是项目自己的耦合。如果你要复用这套 SSE 代码,别把「先 beginRendering」当成 A2UI 的通用规则。
8. 生产清单
把上面的内容压成一张表,部署前逐项过:
服务端
| 检查项 | 说明 |
|---|---|
| 路由先校验再开流 | 参数错用 4xx JSON,不要先 200 再报错 |
Content-Type: text/event-stream | 带 charset |
Cache-Control: no-cache, no-transform | 防缓存 / 防转换 |
X-Accel-Buffering: no | nginx 不缓冲 |
不设 Content-Length | 让 Node 用 chunked |
| 压缩中间件排除 SSE | 或确认逐帧 flush |
| 首个事件立即发 | RUN_STARTED 或 flushHeaders() |
| 事件契约有终态 | RUN_FINISHED / RUN_ERROR 二选一 |
| 生成器内部捕获异常 | 头发出后不要 throw |
| 客户端断开能清理 | finally + 上游 signal |
| 心跳 15–30s | 注释帧 : ping |
| 背压 | Readable.from / drain |
| 非 SSE 降级 | ?sse=0 返回事件数组 |
| 指标 | 事件数、首字节、总耗时、终态 |
| 日志脱敏 | 不要默认记录完整 prompt / 用户输入 |
客户端
| 检查项 | 说明 |
|---|---|
检查 res.ok | 非 2xx 读 JSON 错误体 |
检查 content-type | 不是流就走降级 |
res.body 判空 | 可能为 null |
TextDecoder({ stream: true }) | 防止多字节字符被切 |
| 跨分片缓冲区 | 按 \n\n 切帧 |
| 兼容 CRLF | 归一化或双分隔符 |
多行 data: | 按规范用 \n 拼接 |
| JSON 解析失败不中断 | 但要计数 / 打日志 |
| 区分三种结束 | 完成 / 失败 / 截断 |
AbortController | 用户取消要能立刻生效 |
| 首字节 / 空闲超时 | 防止永久挂起 |
| 流结束收尾 | endStream() + flushPendingRender() |
| 调试副本限长 | 防止内存无限增长 |
| 渲染节流 | 不要每个 chunk 重建一次树 |
9. 参考资料与实测环境
9.1 一手来源(抓取日期 2026-10-22,curl,HTTP 200)
| 资料 | URL | 用途 |
|---|---|---|
| AG-UI Overview | docs.ag-ui.com/introduction | AG-UI 定位、与 A2UI/MCP/A2A 的分层 |
| AG-UI Events | docs.ag-ui.com/concepts/events | 事件类别、CUSTOM、生命周期强制事件 |
| AG-UI HTTP + SSE | docs.ag-ui.com/spec/1.0/basic/transports/http-sse | POST、一帧一事件、LF、错误语义、无续传 |
| AG-UI Architecture | docs.ag-ui.com/concepts/architecture | 传输无关、HttpAgent |
| A2UI Transports | a2ui.org/concepts/transports | transport-agnostic、AG-UI 状态 |
| A2UI Data Flow | a2ui.org/concepts/data-flow | JSONL 消息格式 |
原文留档在 source/a2ui-sse-lab/official/,逐字引用与抓取命令见 source/a2ui-sse-lab/official-sources.md。
9.2 项目与实测
- 项目:
/Users/aster/projj/gitlab.sumshare.cn/10022/bigfe/encode_lesson/a2ui-full - 代码基线:
8afaa38(2026-10-22) - 实测环境:Node
v24.14.1、macOS;Koa2.16.4(取自项目 pnpm store) - 脚本与日志:
source/a2ui-sse-lab/fetch-lab.mjs/fetch-lab.log:响应头时机、UTF-8 切半、abortkoa-hang.mjs/koa-hang.log:头发出后throw导致客户端挂起README.md:跑法与结果表
9.3 局限
- 全部实测在本机回环、单进程完成,没有经过 nginx / CDN / 真实浏览器;代理缓冲、连接上限、空闲超时这些结论来自实践共识,不是本文实测。
- Koa 挂起结论只覆盖
koa@2.16.4+Node v24.14.1+ctx.body = Readable.from(asyncGenerator)这一组合;不同 Koa 版本、不同中间件可能有不同行为。 - 「不 flush 时
fetch要等首字节」是 Node HTTP/1.1 服务端 + undicifetch的行为;不同运行时可能不同。 - 项目使用 A2UI v0.8(官方当前生产版本是 v0.9.1),协议版本的细节不适用于 v0.9;但本文讨论的传输层、SSE 分帧、错误语义与版本无关。
- 引用的 AG-UI 文档是官网当前页面(含 1.0 规范的 HTTP+SSE 绑定页),项目安装的是
@ag-ui/core@0.0.48。两者在本文涉及的事件类型、CUSTOM语义、SSE 帧格式上一致,但版本号并非严格对应;引用时已注明页面与抓取日期。 - 项目自身的
CHECK.md已指出多 surface、beginRendering时序、模板子树、标准userAction等协议层问题;本文只讨论传输,不重复评估。
10. 最后一句
把这条链路拆完,会发现真正难的从来不是「怎么 fetch」「怎么 getReader」——这些 API 半小时就能学会。难的是边界的所有权:
- 分片边界归客户端缓冲区;
- 帧边界归 SSE 的
\n\n; - 事件边界归 AG-UI 的事件;
- 消息边界归 A2UI 的 JSONL 行;
- 生命周期边界归
RUN_STARTED/RUN_FINISHED/RUN_ERROR。
每一层都只做自己那一小段,并且不假设下一层会替自己兜底。这条链路就稳了。
