《AI Agent 开发入门》17.3 TS 落点一:同一协议的 TS 客户端

本节是 TS 落点一:用 TypeScript 写一个消费 17.2 SSE 协议的最小客户端——fetch 拿到 ReadableStream,逐块解码、按空行切帧、解析 event:/data: 行,把 token/tool/step 事件渲染成可读输出。全部代码只用 Node 26 的可擦除语法,node client.ts 直跑、tsc --noEmit 过检,在线与离线两种方式真跑。

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) {}需要在构造函数里插入赋值,属「生成代码」
enumenum Role { User, Tool }会生成运行时对象
namespacenamespace 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.14TypeScript(Node 26)
共享的东西只有「event: + data: 帧格式」这一份约定同左
共享的代码无无
各自内部类型Event dataclassSseFrame 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 端到端 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距