17.3 TS 落点一:同一协议的 TS 客户端
17.2 的服务跑起来了,curl 也能收到 SSE 事件流。但 curl 不是产品——真正会消费这条流的是浏览器里的前端。而前端的主力语言是 TypeScript。这一节我们就用 TS 写一个最小的 SSE 客户端,把 17.2 的协议在第二种语言里实现一遍。它的意义不止「多一个客户端」,而在于证明一件事:同一个事件协议,两种语言能无缝协作。
本节是 TS 落点一:用 TypeScript 写一个消费 17.2
/stream的客户端,fetch+ReadableStream解析event:/data:行,把 token / tool / step 事件渲染成可读输出;遵守 Node 26 的可擦除语法,node client.ts直跑、tsc --noEmit过检。
17.3.1 为什么值得再写一个语言的客户端
有人会问:服务已经能用了,为什么还要专门写个 TS 客户端?两个理由。
第一,这是协议的第一道验收。 如果一个「协议」只有 Python 一端实现,你根本无法确认它是不是真的语言中立——也许你不知不觉用了某个 Python 特有的约定(比如把 None 当成缺失、把 tuple 当数组),换一种语言就露馅。只有第二个独立实现出现,协议才算被证明是语言无关的。 TS 客户端就是那个第二实现。
第二,它逼你把「协议」从「实现」里剥出来。 写 TS 客户端时,你手里没有 minagent 的任何类型、任何类,只有 17.2 那张「event: 名 + data: JSON」的映射表。你必须只凭那张表就能把流解析出来——这正是「协议」的定义:不依赖任何一端的内部结构,双方都能照着实现的东西。 如果写客户端时你发现「咦,这里我得知道 Python 侧的某个内部约定」,那就说明协议没抽干净。
顺带说明分工:本节只做客户端这一半。服务端早在 17.2 就绪,本节的 TS 代码不碰服务端一行——两端各自实现同一份协议,互不依赖。
17.3.2 Node 26 的可擦除语法约束
写 TS 之前先立一条规矩。本机 Node 是 26.5.0,它能直接运行 .ts 文件,但用的是「类型擦除」(type stripping),不支持 --experimental-transform-types。这意味着有一类 TS 语法它拒绝:凡是需要运行时转换的语法都不行。具体三条禁令:
| 禁用语法 | 例子 | 为什么不行 |
|---|---|---|
| 参数属性 | constructor(private x: number) {} | 需要在构造函数里插入赋值,属「生成代码」 |
enum | enum Role { User, Tool } | 会生成运行时对象 |
namespace | namespace A { export const x = 1 } | 会生成运行时作用域 |
替代做法都很自然:参数属性改成在构造函数体内手工赋值;enum 改成字符串字面量联合(type Role = "user" | "tool");namespace 改成 ES 模块。这样写出来的 TS 里,所有类型标注都能被原样抹掉,剩下的就是纯 JavaScript——node client.ts 才能零构建直接跑。
类型检查仍然交给 tsc --noEmit,并在 tsconfig.json 里打开 "erasableSyntaxOnly": true,让编译器主动拦下那些不可擦除的写法:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["**/*.ts"]
}
erasableSyntaxOnly 是关键的一条:一旦你手滑写了 enum 或参数属性,tsc 立刻报错,而不是等到 node 运行时才炸。把约束交给工具,比靠记性可靠。 另外 "types": ["node"] 让 tsc 认识 process、fetch 这些 Node 全局(运行时不依赖任何包,@types/node 只是开发期的类型声明)。
17.3.3 回顾协议:SSE 帧长什么样
客户端要解析的东西,就是 17.2 里 sse() 函数产出的帧。回顾一下它的形状:
event: tool_call
data: {"id": "c1", "name": "add", "arguments": {"a": 2, "b": 3}}
一帧由若干 字段: 值 行组成,帧与帧之间用空行分隔。客户端只需要认识两个字段:event: 给事件名,data: 给载荷。这十来行文本,就是两端之间的全部契约。
17.3.4 解析器:一个纯函数 parseSSE
先把「解析」这一步单独抽成一个纯函数——它不碰网络,只把一段 SSE 文本变成帧数组。纯函数的好处是它能离线测试,这一点稍后会兑现:
// client.ts
interface SseFrame {
event: string;
data: string;
}
/** 把一段 SSE 文本切成帧:块以空行分隔,块内是 event:/data: 行。 */
function parseSSE(text: string): SseFrame[] {
const frames: SseFrame[] = [];
for (const block of text.split("\n\n")) {
if (block.trim() === "") continue;
let event = "message";
const data: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trim());
}
if (data.length > 0) frames.push({ event, data: data.join("\n") });
}
return frames;
}
四步逻辑,逐条对上协议:按 \n\n 切块(空行分帧)、每块内按 \n 拆行、认 event: 前缀取事件名、认 data: 前缀收载荷。有两个细节是照着规范来的:其一,event 默认值设成 "message"——SSE 规范里没有 event: 行时,事件名就是 "message";其二,data 用数组收集再 join("\n"),因为规范允许一帧有多行 data:,它们要拼成一段(中间补回换行)。本节的服务端每帧只发一行 data:,但这个写法对多行也成立。
注意 parseSSE 完全不知道 token、tool_call 这些业务概念——它只认「帧」。把「切帧」和「理解事件」分成两层,是这类解析器的通用做法:前者稳定、可复用,后者才随业务变。
17.3.5 渲染:把事件变成可读输出
第二层是「理解事件」——拿到一帧,按事件名决定怎么显示:
/** 把一条帧渲染成人可读输出;token 直接写、其余带前缀。 */
function render(frame: SseFrame): void {
const p = JSON.parse(frame.data) as Record<string, unknown>;
switch (frame.event) {
case "token":
process.stdout.write(String(p.text));
break;
case "tool_call":
console.log(`\n[tool_call] ${p.name}(${JSON.stringify(p.arguments)})`);
break;
case "tool_result":
console.log(`\n[tool_result] ${p.name} -> ${p.result}`);
break;
case "step":
console.log(`\n[step] index=${p.index} kind=${p.kind}`);
break;
case "final":
console.log(`\n[final] ${p.content}`);
break;
case "error":
console.log(`\n[error] ${p.detail}`);
break;
}
}
switch 的六个分支,正好对应 17.2 那张映射表的六种事件。两类渲染方式是有意的:token 用 process.stdout.write 直接写、不带换行,所以多个 token 会连着拼成一句话——这就是「打字机效果」的机制;其余事件用 console.log、各占一行并带 [标签],便于肉眼分辨「这是工具调用还是最终答复」。p.text 之类通过 JSON.parse 拿到的是 unknown,所以 String(...) 显式转换——在 strict 模式下,这种显式转换不是啰嗦,是必须。
浏览器里这段会换成把 token append 到 DOM 节点;Node 里我们写标准输出。渲染方式因端而异,但「按事件名分流」的逻辑两端一模一样。
17.3.6 流式消费:fetch + ReadableStream
真正的流式部分:发请求、拿响应体、边收边解析。关键在 res.body 是个 ReadableStream,可以一块一块读:
/** 连到服务端 /stream,边收边解析:ReadableStream -> 文本 -> 帧 -> 渲染。 */
async function streamFrom(url: string, input: string): Promise<void> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
if (!res.body) throw new Error("响应没有 body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let cut = buffer.indexOf("\n\n");
while (cut >= 0) {
const block = buffer.slice(0, cut);
buffer = buffer.slice(cut + 2);
for (const frame of parseSSE(block)) render(frame);
cut = buffer.indexOf("\n\n");
}
}
if (buffer.trim() !== "") for (const frame of parseSSE(buffer)) render(frame);
}
这段代码有两个新手必踩的坑,都体现在细节里。
其一,网络块不等于帧。 reader.read() 每次返回的是一段任意长度的字节,它可能只有半帧,也可能一次包含好几帧——TCP 不管你的帧边界。所以必须有个 buffer 做「蓄水池」:新数据先追加进去,然后只在遇到完整的 \n\n 时才切出一帧,切剩下的留在 buffer 里等下一块。如果直接对每个 chunk 调 parseSSE,半帧会被解析成残缺数据。这是所有流式解析器的心脏:边界要自己找,不能指望网络按你的格式分包。
其二,多字节字符会跨块断裂。 一个中文汉字在 UTF-8 里占 3 字节,完全可能「前 2 字节在这个 chunk、第 3 个在下一个」。所以解码用 TextDecoder 且传 { stream: true }——它会把不完整的字节暂存,等下一块补齐再吐字符。少了这个选项,中文会时不时变成乱码方块。
循环退出时(done 为真),buffer 里可能还剩一段没有以 \n\n 结尾的尾巴,所以最后补一次解析。这是收尾的边界处理,漏了会丢掉最后一帧。
17.3.7 入口:在线与离线两种模式
最后是入口。它支持两种模式:默认连本地服务,加 --offline 则把一段内嵌的 SSE 文本直接喂给同一个解析器:
const SAMPLE = `event: tool_call
data: {"id": "c1", "name": "add", "arguments": {"a": 2, "b": 3}}
event: tool_result
data: {"id": "c1", "name": "add", "result": "5"}
event: step
data: {"index": 0, "kind": "tool_call"}
event: token
data: {"text": "2"}
event: token
data: {"text": " + 3 = 5。"}
event: step
data: {"index": 1, "kind": "final"}
event: final
data: {"content": "2 + 3 = 5。"}
`;
async function main(): Promise<void> {
const url = "http://127.0.0.1:8123/stream";
if (process.argv.includes("--offline")) {
console.log("=== 离线:把 SSE 文本喂给同一个解析器 ===");
for (const frame of parseSSE(SAMPLE)) render(frame);
process.stdout.write("\n");
return;
}
console.log("=== 在线:连 127.0.0.1:8123/stream ===");
await streamFrom(url, "2+3 等于多少?");
}
await main();
离线模式的用意是让客户端在服务没起时也能跑:把一段录下来的 SSE 文本(SAMPLE)直接喂给 parseSSE,走的是和在线完全相同的解析与渲染路径——只是数据来源从「网络块」换成了「一个字符串」。这既方便演示,也是单元测试的形态:parseSSE 是纯函数,喂固定输入就能断言固定输出,不需要起服务。
17.3.8 真跑:在线、离线与类型检查
先起 17.2 的服务(uvicorn 绑 127.0.0.1:8123),再跑客户端。在线模式的真实输出:
=== 在线:连 127.0.0.1:8123/stream ===
[tool_call] add({"a":2,"b":3})
[tool_result] add -> 5
[step] index=0 kind=tool_call
2 + 3 = 5。
[step] index=1 kind=final
[final] 2 + 3 = 5。
逐行读:先收到 tool_call(要调 add)、tool_result(返回 5)、step(第一步结束);然后 11 个 token 拼成 2 + 3 = 5。(process.stdout.write 不带换行,所以它们连成一行);最后 step 与 final 收尾。这就是 17.2 那条 SSE 流,在 TypeScript 里被完整还原了出来。
离线模式,把服务关掉也能跑:
=== 离线:把 SSE 文本喂给同一个解析器 ===
[tool_call] add({"a":2,"b":3})
[tool_result] add -> 5
[step] index=0 kind=tool_call
2 + 3 = 5。
[step] index=1 kind=final
[final] 2 + 3 = 5。
两段输出逐字节相同——这正说明协议一致:parseSSE 只认帧格式,不关心帧是从 socket 来的还是从字符串来的。同一个解析器,两种数据源,一样的结果。
最后过类型检查:
npx tsc --noEmit
tsc 无输出、退出码 0,表示类型全部通过。值得强调的是:这次类型检查里,erasableSyntaxOnly 是打开的——它同时保证了「类型检查过」和「语法可擦除」两件事,所以 node client.ts 才能零构建直跑。两个命令,一个管运行、一个管类型,缺一不可。
17.3.9 同一协议、两种语言的价值
把这一节和 17.2 并排看,最有价值的结论浮出来了:服务端(Python)和客户端(TypeScript)各自独立实现了同一份协议,彼此不共享一行代码。
| 维度 | Python 服务端(17.2) | TS 客户端(17.3) |
|---|---|---|
| 产出 / 消费 | 生产 Event → 编成 SSE 帧 | 解析 SSE 帧 → 还原事件 |
| 语言 | Python 3.14 | TypeScript(Node 26) |
| 共享的东西 | 只有「event: + data: 帧格式」这一份约定 | 同左 |
| 共享的代码 | 无 | 无 |
| 各自内部类型 | Event dataclass | SseFrame interface |
「共享协议、不共享代码」是分布式系统的通用分法。 前后端之所以能并行开发,正是因为双方只需对齐那十来行帧格式,而不必对齐各自的数据结构。前端可以用自己顺手的 interface,后端可以继续用 dataclass;协议是中立的、语言无关的——只要双方都照着它产出/消费,就能接上。
这也解释了 17.2.8 说的「OpenAPI 描述不了 SSE 流」为什么重要:正因为自动文档覆盖不到流里的事件形状,这份协议得靠人来维护(那张映射表),并且每个语言的客户端都得手写一份解析器。代价是「重复劳动」,回报是「语言中立」。对这个 Agent 场景,这笔账是划算的——因为流式对话的体验价值,远大于手写一个几十行解析器的成本。
再往远看一点:浏览器里那份客户端,把 process.stdout.write 换成 element.append(text)、把 console.log 换成往消息列表里推气泡,就是完整的聊天界面。协议不变,渲染随便换。 这就是「落点一」的含义——TS 在 Agent 体系里的第一个落点,就是做这条事件流的消费者。
17.3.10 真实模型下会怎样
真实模型下,这个 TS 客户端一行都不用改——它消费的是 17.2 的 SSE 协议,而协议不会因为后端换了模型而变。变的还是服务端 build_agent() 里造哪个 Model。客户端对「模型是真是假」完全无感,这正是分层的价值。
但真实场景会给客户端加两处适配。其一,真实 token 更细碎、节奏更不匀,token 事件可能密集到来也可能停顿数秒;前端要能扛住这种节奏(渲染节流、加个「正在输入」指示),并在长时间无 token 时提示等待而不是以为卡死。其二,真实流会断。 网络抖动可能让 reader.read() 抛错或流提前结束,客户端必须把「流意外结束」和「正常收到 final」区分开——只有收到 final 才算跑完,中途断流要提示用户重试。这两点都是生产化加固,属于卷二的范围;卷一先把这个「能跑通同一协议」的最小客户端立住。
一句话:协议中立性不因模型而变,客户端要补的只是「应对不匀、会断的真实流」的健壮性。
17.3.11 小结
- TS 落点一的定位:做 17.2 那条 SSE 事件流的消费者,证明协议语言中立。
- Node 26 只支持类型擦除:禁参数属性 /
enum/namespace,erasableSyntaxOnly帮你拦错。 - 解析分两层:
parseSSE切帧(纯函数、可离线测)、render按事件名分流。 - 流式消费两个坑:网络块 ≠ 帧(要靠 buffer 找
\n\n边界)、多字节字符跨块(要TextDecoder的stream: true)。 - 在线连服务、离线喂字符串,两种模式输出逐字节相同,说明协议一致。
- 价值在「共享协议、不共享代码」:前后端各写各的,只对齐帧格式即可并行开发。
到这里,minagent 有了库接口、HTTP 服务和跨语言客户端。下一节进入最后一章 18.1 端到端 ,把这一路的所有能力拼成一个完整交付。想回看服务端是怎么把事件推出去的,翻 17.2 HTTP 服务化 。
阅读导航:上一节:17.2 HTTP 服务化 · 下一节:18.1 端到端 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。