模块 00 · 基础功底 · 第 0.2 课

0.2 TypeScript:给 Agent 套上类型的安全带(Go 程序员版)

第 0 部分 · 第 2 课 | 预计 3–4 小时 | 前置:学完 0.1 Go 并发,Node 22+

本课默认你熟悉 Go、不熟悉 TS。每个新语法第一次出现都会对照 Go 讲一遍;每段代码后面都有“逐行解读”。

为什么学这个

Go 那一课解决的是“并发不出错”,这一课解决的是“数据不出错”。Agent 里最危险的数据有三种:

LLM 返回的 tool_call 参数(模型随时可能编错 JSON)
流式事件(text / tool_call / done / error 混在一个流里)
外部取消(用户点了“停止”、超时、上游断开)
问题 Go 里你怎么做 TS 里怎么做
一个流里有多种事件 interface + switch v := ev.(type) 可辨识联合 + switch (ev.type)
校验外部 JSON json.Unmarshal + 手写 Validate() Zod
流式读取 goroutine 往 channel 里塞 AsyncGenerator + for await
取消 context.Context AbortController / AbortSignal
并行工具 errgroup Promise.all + worker

本课结束时,你能写出 TS 版的“流式解析 + 工具参数校验 + 可取消的并行工具执行”,和 0.1 的 Go 代码一一对应。


0. Go 程序员的 TS 语法速成

先把后面会用到的语法一次讲清楚。看不懂后面的代码时,回到这一节查。

0.1 变量

const name = "search";   // 不能重新赋值,类似 Go 的 name := "search" 但之后不能改
let count = 0;           // 可以重新赋值
count = 1;
const limit: number = 4; // 显式写类型:变量名: 类型(Go 是 var limit int = 4,类型在后面,这点一样)

0.2 基本类型

TS Go 说明
string string
number int / float64 TS 只有一种数字,没有 int/float 之分
boolean bool
string[] []string 数组
Record<string, number> map[string]int 键值对象
undefined / null nil 两种“空”,undefined 更常见
unknown any(interface{}) 不知道是什么,用之前必须先检查
any 没有对应 关掉类型检查,尽量别用
never 没有对应 “不可能出现的值”,后面讲
void 函数无返回值

0.3 对象类型:type

type ToolCall = {
  id: string;
  name: string;
  args: string;
};

等价于 Go:

type ToolCall struct {
    ID   string
    Name string
    Args string
}

区别:TS 的对象不需要 ToolCall{...} 这种构造,直接写字面量就行:

const c: ToolCall = { id: "1", name: "search", args: "{}" };
console.log(c.name); // "search"

字段后面加 ? 表示可选(可以不传):

type Options = { limit?: number };   // limit 的类型是 number | undefined

你也会看到 interface ToolCall { ... },作用和 type 基本一样。本课统一用 type。

0.4 联合类型 |:“要么是 A,要么是 B”

let x: string | number;  // x 可以是 string,也可以是 number
x = "hi";
x = 42;

Go 没有这个语法,最接近的是“用一个 interface 装多种类型”。

字面量类型:类型可以是一个具体的值。

type Role = "user" | "assistant" | "tool"; // Role 只能是这三个字符串之一
const r: Role = "user";      // ✅
const r2: Role = "admin";    // ❌ 编译报错

相当于 Go 里的 type Role string + 三个 const,但 TS 会在编译期拦住非法值,Go 不会。

0.5 函数

// 普通函数:参数名: 类型,返回值类型写在括号后面
function add(a: number, b: number): number {
  return a + b;
}

// 箭头函数:另一种写法,常用于回调和短函数
const add2 = (a: number, b: number): number => a + b;
//          ^^^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^   ^^^^^
//          参数                      返回类型 函数体(只有一个表达式时可省略 return 和 {})

// 函数类型:描述“一个函数长什么样”
type Tool = (args: string, signal: AbortSignal) => Promise<string>;
// 读作:Tool 是一个函数,接收 args 和 signal 两个参数,返回 Promise<string>

对照 Go:

func add(a, b int) int { return a + b }
add2 := func(a, b int) int { return a + b }
type Tool func(args string, ctx context.Context) (string, error)

注意:TS 函数只有一个返回值,错误靠 throw 抛出,而不是 (string, error)。

0.6 错误处理:throw / try / catch / finally

function mustPositive(n: number): number {
  if (n < 0) throw new Error("负数");  // 相当于 Go 的 return 0, errors.New("负数")
  return n;
}

try {
  mustPositive(-1);
  console.log("不会执行到这里");      // throw 之后的代码直接跳过
} catch (err) {
  console.log("出错了:", err);        // 相当于 if err != nil { ... }
} finally {
  console.log("无论成功失败都执行");  // 相当于 defer
}

0.7 Promise 与 async/await:TS 的“异步”

这是 Go 程序员最需要转换思路的地方。

// fetch 返回 Promise<Response>:一个“将来会给你 Response”的凭证
const p: Promise<Response> = fetch("https://example.com");

// await:在这里暂停当前函数,等凭证兑现,拿到真正的值
const res: Response = await fetch("https://example.com");

await 只能在 async 函数里用:

async function getText(url: string): Promise<string> {
  const res = await fetch(url);   // 暂停,等网络返回;此时线程去处理别的事
  return await res.text();        // 再暂停,等 body 读完
}
// async 函数的返回值会自动包一层 Promise:写 return "abc",调用方拿到 Promise<string>

对照 Go 心智模型:

TS Go 大致对应
const p = doWork()(不 await) go doWork(),开始跑了,但你没等它
await p <-done,阻塞等结果
await Promise.all([p1, p2, p3]) wg.Wait(),等三个都完成
Promise reject(失败) 返回了 error

关键区别:await 只暂停当前函数,不暂停整个程序。所以同一时间可以有很多个函数“停在 await 上”,看起来像并发。但同一时刻真正在执行 JS 代码的只有一个,所以普通变量读写不会有数据竞争,不需要锁。

0.8 泛型 <T>

type Box<T> = { value: T };
const b: Box<string> = { value: "hi" };

Promise<string>   // 一个最终会给出 string 的 Promise
Array<number>     // 等同于 number[]

和 Go 1.18 的泛型一样,Box[T any] 写成 Box<T>,用尖括号。

0.9 几个小语法(后面代码里都会出现)

// 解构:一次从对象里取多个字段,可带默认值
const opts = { signal: s, limit: 8 };
const { signal, limit = 4, perToolMs = 20_000 } = opts;
// 等价于:
// const signal = opts.signal;
// const limit = opts.limit ?? 4;          // opts 里有 limit=8,所以是 8
// const perToolMs = opts.perToolMs ?? 20000; // 没有,所以用默认值 20000
// 20_000 就是 20000,下划线只是方便阅读(Go 也支持)

// 可选链 ?.:左边是 undefined/null 时不报错,直接得到 undefined
signal?.aborted   // signal 为空 → undefined;否则 → signal.aborted

// 非空断言 !:告诉编译器“我保证这里不是 undefined”
signal!.reason

// 模板字符串:反引号 + ${},相当于 fmt.Sprintf
const msg = `未知工具 ${name}`;   // Go: fmt.Sprintf("未知工具 %s", name)

// 类型断言 as:告诉编译器“把它当成这个类型”,运行时不做任何检查
const x = JSON.parse(raw) as ToolCall; // 危险!后面专门讲为什么

// typeof(在类型位置):取一个变量的类型
const cfg = { a: 1 };
type Cfg = typeof cfg;   // { a: number }

// import / export:模块,相当于 Go 的 package 导入导出
import { z } from "zod";           // 从 zod 包里导入名字 z
export function parseEvent() {}    // 让别的文件能 import 它(Go 里是首字母大写)

好,有了这些,下面的代码都能读懂了。


1. 严格模式:先把编译器调到最凶

TS 编译器的检查强度是可调的。tsconfig.json 是项目配置文件(类似 go.mod + go vet 规则),最少这样开:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true
  }
}

逐项解释:

选项 作用
target: ES2022 编译输出的 JS 版本,ES2022 Node 22 全支持
module / moduleResolution: NodeNext 按 Node 的规则解析 import
strict: true 打开一整套严格检查,最重要的是:不许隐式 any、undefined 必须处理
noUncheckedIndexedAccess arr[0] 的类型变成 T \| undefined
exactOptionalPropertyTypes 可选字段不许显式传 undefined
noImplicitOverride 子类覆盖方法必须写 override
verbatimModuleSyntax 只导入类型时必须写 import type,避免打包问题

重点看 noUncheckedIndexedAccess:

const choices: string[] = [];
const first = choices[0];   // 开启后,first 的类型是 string | undefined
first.toUpperCase();        // ❌ 编译报错:first 可能是 undefined
if (first) first.toUpperCase(); // ✅ 检查过了,可以用

Go 里 choices[0] 越界会运行时 panic;TS 默认会让你得到 undefined 然后在别处崩。开了这个选项,编译期就逼你处理“模型返回空 choices”的情况。

两条规范: - 外部数据(模型输出、HTTP 请求体、第三方 API)一律先当 unknown,校验后才变成具体类型。禁止用 as 强转外部数据。 - 不用 any。真要用,旁边写注释说明为什么。

2. 可辨识联合:一个流里的多种事件

LLM 流式返回时,同一个流里会混着几种事件:一段文字、一次工具调用、结束、出错。怎么用类型描述“这几种之一”?

type StreamEvent =
  | { type: "text"; delta: string }
  | { type: "tool_call"; id: string; name: string; args: string }
  | { type: "done"; usage: { input: number; output: number } }
  | { type: "error"; message: string };

逐行解读

这样的值都是合法的 StreamEvent:

const a: StreamEvent = { type: "text", delta: "你好" };
const b: StreamEvent = { type: "done", usage: { input: 10, output: 20 } };
const c: StreamEvent = { type: "text", message: "x" }; // ❌ text 类型没有 message 字段

对照 Go,你大概会这么写:

type StreamEvent interface{ isEvent() }
type TextEvent struct{ Delta string }
type ToolCallEvent struct{ ID, Name, Args string }
// ...每个 struct 实现 isEvent()

switch e := ev.(type) {
case TextEvent:     fmt.Print(e.Delta)
case ToolCallEvent: fmt.Println(e.Name)
}

TS 版本的用法:

function handle(ev: StreamEvent): void {
  switch (ev.type) {
    case "text":
      process.stdout.write(ev.delta);
      break;
    case "tool_call":
      console.log(ev.name, ev.args);
      break;
    case "done":
      console.log("tokens:", ev.usage.output);
      break;
    case "error":
      throw new Error(ev.message);
    default:
      assertNever(ev);
  }
}

function assertNever(x: never): never {
  throw new Error(`unhandled: ${JSON.stringify(x)}`);
}

逐行解读

OpenAI Responses、Anthropic Messages 的流式事件本质上都是这种结构,你自己 Agent 内部的事件也应该这么定义。

3. Zod:运行时校验 + 类型推导 + JSON Schema 一次写完

问题在哪

TS 的类型只在编译期存在,编译成 JS 后全部消失。而 LLM 的输出只在运行时出现。所以:

const args = JSON.parse(modelOutput) as SearchArgs;  // 编译器信了你
args.query.trim();  // 运行时:模型没给 query → 崩溃 "Cannot read properties of undefined"

as 只是让编译器闭嘴,运行时什么也不检查。Go 的 json.Unmarshal 至少会把缺失字段填零值,TS 连这个都没有。

Zod 是一个库:你用它的 API 描述数据形状,它同时给你三样东西: 1. 运行时校验函数(真的去检查每个字段) 2. TS 类型(自动推导,不用再手写一份 type) 3. JSON Schema(直接发给模型当工具定义)

定义一个工具参数

import { z } from "zod"; // zod v4

const SearchArgs = z.object({
  query: z.string().min(1).describe("搜索关键词"),
  topK: z.number().int().min(1).max(20).default(5),
});

type SearchArgs = z.infer<typeof SearchArgs>;

逐行解读:

一份定义,同时得到校验器和类型,两者永远不会不一致。Go 里你得写一个 struct + 一个 Validate() 方法 + 手写一份 JSON Schema,三处改一处忘一处是常事。

用它生成工具定义(发给模型)

const toolDef = {
  name: "search",
  description: "搜索内部文档",
  parameters: z.toJSONSchema(SearchArgs),
};

z.toJSONSchema(SearchArgs) 生成大致这样的对象:

{
  "type": "object",
  "properties": {
    "query": { "type": "string", "minLength": 1, "description": "搜索关键词" },
    "topK":  { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }
  },
  "required": ["query"]
}

这正是 OpenAI / Anthropic 工具调用 API 要的 parameters / input_schema 格式。

校验模型返回的参数

type ParseResult =
  | { ok: true; data: SearchArgs }
  | { ok: false; error: string };

function parseArgs(raw: string): ParseResult {
  let json: unknown;
  try {
    json = JSON.parse(raw);
  } catch {
    return { ok: false, error: "参数不是合法 JSON" };
  }

  const r = SearchArgs.safeParse(json);
  if (r.success) {
    return { ok: true, data: r.data };
  }
  return { ok: false, error: z.prettifyError(r.error) };
}

逐行解读:

调用方:

const r = parseArgs(toolCall.args);
if (!r.ok) {
  // 把错误文本作为工具结果回传给模型,让它修正参数重试
  return { id: toolCall.id, output: r.error, isError: true };
}
await search(r.data.query, r.data.topK); // 这里 r.data 类型确定,而且真的校验过

校验失败不抛异常,而是把错误文本回传给模型,让它下一轮自己修正。这和 Go 课里“工具失败作为观察回传,return nil”是同一个思想。

parse vs safeParse: - SearchArgs.parse(x):失败直接 throw。用于内部配置,启动时错了就该崩。 - SearchArgs.safeParse(x):失败返回结果对象。用于信任边界外的数据(模型、用户、第三方 API)。

4. AsyncGenerator:把 SSE 变成 for await

先理解两个概念

普通生成器(generator):一个可以“暂停-继续”的函数,每次产出一个值。

function* count() {     // 注意 function 后面的 *,表示这是生成器
  yield 1;              // 产出 1,然后暂停在这里
  yield 2;              // 下次继续,产出 2,再暂停
  yield 3;
}

for (const n of count()) {
  console.log(n);       // 1, 2, 3
}

对照 Go:这就像一个 goroutine 往 channel 里发 1、2、3,然后 close;调用方 for n := range ch。区别是:生成器是“拉”模式,调用方要下一个值时它才执行到下一个 yield,不会提前跑;没有额外的 goroutine,也就不存在泄漏。

异步生成器(async generator):生成器里面可以 await。

async function* ticks() {   // async + function*
  for (let i = 0; i < 3; i++) {
    await sleep(100);       // 等 100ms
    yield i;
  }
}

for await (const t of ticks()) {   // for await:每拿一个值都要等
  console.log(t);                  // 0, 1, 2,每隔 100ms 一个
}

这正好适合流式读取:网络数据一点点到达,读到一条完整事件就 yield 一条。

解析 SSE

先回忆 SSE 格式(0.1 讲过):

data: {"type":"text","delta":"你"}

data: {"type":"text","delta":"好"}

data: [DONE]

每条事件是一行 data: ...,事件之间空一行,最后以 [DONE] 结束。

async function* sseEvents(res: Response): AsyncGenerator<string> {
  if (!res.body) throw new Error("no body");

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buf = "";

  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) return;
      buf += value;

      let idx: number;
      while ((idx = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, idx).trim();
        buf = buf.slice(idx + 1);

        if (!line.startsWith("data:")) continue;
        const data = line.slice(5).trim();
        if (data === "[DONE]") return;
        yield data;
      }
    }
  } finally {
    reader.releaseLock();
  }
}

逐行解读

函数签名

拿到读取器

主循环

按行切

意思是:只要缓冲区里还有完整的一行,就一直处理。一块数据可能包含好几行,也可能只有半行(那就留在 buf 里等下一块)。 - const line = buf.slice(0, idx).trim();:取出这一行(slice(0, idx) = Go 的 buf[:idx]),trim() 去掉首尾空白(含 \r)。 - buf = buf.slice(idx + 1);:把这一行连同换行符从缓冲区删掉(= buf[idx+1:])。 - if (!line.startsWith("data:")) continue;:不是 data: 开头的(空行、event:、: 注释)跳过。 - const data = line.slice(5).trim();:去掉前 5 个字符 data:,再去空格。 - if (data === "[DONE]") return;:结束标记。注意 TS 比较用 ===(三个等号),永远不要用 ==,后者会做奇怪的类型转换。 - yield data;:产出这条数据,函数在这里暂停,直到调用方的 for await 要下一条。

清理

怎么用,以及怎么取消

const ctrl = new AbortController();

const res = await fetch("https://api.example.com/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ stream: true, messages: [] }),
  signal: ctrl.signal,
});

let count = 0;
for await (const data of sseEvents(res)) {
  const json: unknown = JSON.parse(data);
  // 下一步交给 Zod 校验成 StreamEvent(见练习 1)
  count++;
  if (count >= 100) break;
}

// 别的地方(比如用户点了停止按钮):
ctrl.abort();

逐行解读:

5. AbortController:TS 版的 context

Go TS 说明
ctx, cancel := context.WithCancel(parent) const ctrl = new AbortController() 手动取消
ctx ctrl.signal(类型 AbortSignal) 往下传的就是它
cancel() ctrl.abort() 触发取消
context.WithTimeout(ctx, 5*time.Second) AbortSignal.timeout(5000) 超时自动取消,单位毫秒
子 ctx 继承父 ctx AbortSignal.any([a, b]) 任一取消就取消
<-ctx.Done() signal.addEventListener("abort", fn) 取消时回调
ctx.Err() != nil signal.aborted 是否已取消(布尔值)
ctx.Err() signal.reason 取消原因

一个和 Go 的关键不同:Go 的 ctx 是一棵树,子 ctx 自动继承父 ctx。TS 没有父子关系,要用 AbortSignal.any 手动合并。

async function runTurn(userSignal: AbortSignal): Promise<void> {
  const signal = AbortSignal.any([userSignal, AbortSignal.timeout(60_000)]);
  const res = await fetch(LLM_URL, { method: "POST", body: "...", signal });
  // ... 再把 signal 一路往下传给每个工具
}

逐行解读:

区分“被取消”和“真出错”:

try {
  await runTurn(signal);
} catch (err) {
  if (err instanceof Error && err.name === "AbortError") {
    console.log("用户取消了");        // 手动 abort()
  } else if (err instanceof Error && err.name === "TimeoutError") {
    console.log("超时了");            // AbortSignal.timeout 触发
  } else {
    throw err;                        // 真正的错误,继续往上抛
  }
}

规范(和 Go 一致): - signal 作为参数显式往下传。 - 自己写的长循环里要检查:signal.throwIfAborted();,已取消就立刻抛错(= Go 里 if ctx.Err() != nil { return ctx.Err() })。

6. 并行工具调用:带并发上限 + 单工具超时

这是 0.1 里 runTools(errgroup 版)的 TS 翻译。先看完整代码,再逐行拆。

type ToolCall = { id: string; name: string; args: string };
type ToolResult = { id: string; output: string; isError: boolean };
type Tool = (args: string, signal: AbortSignal) => Promise<string>;

type RunOptions = {
  signal: AbortSignal;
  limit?: number;
  perToolMs?: number;
};

async function runTools(
  calls: ToolCall[],
  tools: Record<string, Tool>,
  opts: RunOptions,
): Promise<ToolResult[]> {
  const { signal, limit = 4, perToolMs = 20_000 } = opts;
  const results: ToolResult[] = new Array(calls.length);
  let next = 0;

  async function worker(): Promise<void> {
    while (true) {
      const i = next++;
      const c = calls[i];
      if (!c) return;

      const tool = tools[c.name];
      if (!tool) {
        results[i] = { id: c.id, output: `未知工具 ${c.name}`, isError: true };
        continue;
      }

      try {
        const s = AbortSignal.any([signal, AbortSignal.timeout(perToolMs)]);
        const output = await tool(c.args, s);
        results[i] = { id: c.id, output, isError: false };
      } catch (err) {
        if (signal.aborted) throw err;
        results[i] = { id: c.id, output: String(err), isError: true };
      }
    }
  }

  const n = Math.min(limit, calls.length);
  const workers: Promise<void>[] = [];
  for (let k = 0; k < n; k++) {
    workers.push(worker());
  }
  await Promise.all(workers);

  return results;
}

逐行解读

类型定义

函数签名

准备

worker:一个“工人”,不停领任务直到领完

执行一个工具

失败处理:分两种

启动 worker 并等待

和 Go 版对照

Go(0.1) TS
g.SetLimit(4) 开 limit 个 worker
g.Go(func() error {...}) workers.push(worker())
g.Wait() await Promise.all(workers)
每个 goroutine 写 results[i] 每个 worker 写 results[i]
工具失败 return nil catch 里记结果,不抛
整体取消 return err if (signal.aborted) throw err

你也会看到别人写 Promise.all(Array.from({ length: n }, worker)),意思和上面的 for 循环一样:Array.from({ length: n }, fn) 会调用 fn n 次,把返回值组成数组。本课用 for 循环,更好读。

Promise.all vs Promise.allSettled: - Promise.all:任一失败就立刻失败。 - Promise.allSettled:等全部完成,每个结果标记成功/失败,自己永远不会失败。

这里故意用 all:单个工具失败已经在 try/catch 里变成了结果,只有整体取消才会抛到这一层,这时就应该立刻停。

7. 三个高频坑

坑 1:忘了 await

async function handle() {
  saveToDb(result);   // ❌ 没 await:函数开始执行,但你没等它
  return "ok";        // 立刻返回;如果 saveToDb 后来失败了,没人接这个错误
}

没被接住的 Promise 失败叫 unhandled rejection,Node 默认会直接退出进程。类似 Go 里 go saveToDb() 然后 goroutine 里 panic。

解决:开 ESLint 规则 @typescript-eslint/no-floating-promises,没 await 的 Promise 直接报错。真想“发出去不管”就显式写 void saveToDb(result).catch(log)。

坑 2:用 as 骗编译器

const call = JSON.parse(raw) as ToolCall; // 编译通过
call.name.toUpperCase();                  // 运行时:模型没给 name → 崩

as 不做任何检查。外部数据只能 Zod 校验后再用。 as 只在你比编译器更清楚、而且数据来自你自己的代码时才用(练习 4 里有一个例子)。

坑 3:abort 了,但资源没释放

const badTool: Tool = async (args, signal) => {
  await new Promise((r) => setTimeout(r, 30_000)); // 完全没理会 signal
  return "done";
};

runTools 那边 AbortSignal.timeout 到点了,await 因此提前结束吗?不会。AbortSignal 只是一个“通知”,工具自己不检查,就没人能打断它。这和 Go 一样:goroutine 不读 ctx.Done(),cancel 了也照样跑。

正确写法见练习 2 的 sleep(ms, signal):监听 abort 事件,清掉定时器,立刻 reject。fetch 自带这个能力,所以把 signal 传给 fetch 就够了;你自己用 setTimeout、子进程、数据库连接时,要自己处理。


练习(今天做完)

环境准备

mkdir -p projects/01-ts-agent-core/src && cd projects/01-ts-agent-core
npm init -y                       # 生成 package.json,相当于 go mod init
npm pkg set type=module           # 用 ES 模块(import/export)
npm i zod                         # 运行时依赖,相当于 go get
npm i -D typescript vitest @types/node   # -D:只在开发时用(编译器、测试框架、Node 类型定义)
npx tsc --init                    # 生成 tsconfig.json,再按第 1 节改

题目

  1. 用 Zod 定义 StreamEvent 可辨识联合(z.discriminatedUnion),写 parseEvent(data: string),非法输入返回错误而不是抛异常
  2. 写可取消的 sleep(ms, signal),abort 时立刻 reject 并清掉定时器
  3. 用 runTools 跑三个假工具(50ms / 100ms / 5s),perToolMs = 200,断言前两个成功、第三个 isError: true、总耗时 < 400ms
  4. 用 node:http 起一个假 SSE 服务,客户端读到 3 个事件后 abort(),断言服务端收到 close 事件
  5. npx tsc --noEmit 零错误,npx vitest run 全绿

建议先自己写,卡住了再看下面的参考实现。

自测题

  1. 为什么 switch 的 default 里要调 assertNever?
  2. 工具参数 Zod 校验失败,为什么不抛异常而是回传给模型?
  3. for await 里 break,generator 里的 finally 会执行吗?为什么?
  4. AbortSignal.any 解决了什么问题?对应 Go 的什么?
  5. 为什么 runTools 里 next++ 不需要锁,而 Go 里同样写法会被 -race 报出来?

参考答案与详解

自测题答案

1. assertNever 的作用

default 分支里 ev 的类型应该是 never,因为所有情况都处理完了。以后有人往 StreamEvent 里加了 { type: "thinking" } 却忘了加 case,default 里的 ev 就变成了 { type: "thinking" },传给只接受 never 的 assertNever 会编译报错。这把“漏处理一种事件”从运行时 bug 变成了编译错误。Go 的 type switch 漏写 case 只会静默跳过。

2. 校验失败为什么回传给模型

模型编错参数是常态,不是异常。回传类似 topK: Too big, expected <=20 的错误文本,模型下一轮通常能自己修正参数重试。抛异常会让整轮对话失败,用户什么也拿不到。只有“系统坏了”(网络断、配置错、被取消)才应该抛。和 0.1 里“工具失败 return nil,把错误写进结果”是同一个原则。

3. break 后 finally 会执行吗

会。for await...of 提前退出(break、return、循环体里抛错)时,JS 会自动调用生成器的 return() 方法,生成器从暂停的 yield 处“被结束”,执行 finally。所以把 releaseLock()、关连接放在 finally 里是安全的,相当于 Go 的 defer。

例外:如果你不用 for await,而是手动调用 gen.next(),中途把生成器丢掉不管,finally 不会执行。所以总是用 for await 消费生成器。

4. AbortSignal.any

把多个取消源合成一个:用户停止 OR 整轮超时 OR 单工具超时,任一触发就取消。Go 的 ctx 天然是树形继承的(WithTimeout(parent, d) 自动带上父 ctx 的取消),TS 的 AbortSignal 没有父子关系,AbortSignal.any 就是用来补这个的。

5. 为什么 next++ 不需要锁

JS 运行在单线程的事件循环上。const i = next++ 是一段同步代码,执行过程中不可能被别的 worker 插进来;worker 之间只在 await 处切换。所以不存在两个 worker 同时读写 next。

Go 的 goroutine 可以跑在多个 CPU 核上真正同时执行,两个 goroutine 同时 next++(读、加一、写回三步)就可能都读到同一个值,这就是数据竞争,-race 会报。Go 版要么用 atomic.AddInt64(&next, 1),要么像 0.1 那样每个 goroutine 预先分到自己的下标 i。

练习参考实现

练习 1:parseEvent(src/events.ts)

import { z } from "zod";

export const StreamEventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("text"), delta: z.string() }),
  z.object({ type: z.literal("tool_call"), id: z.string(), name: z.string(), args: z.string() }),
  z.object({ type: z.literal("done"), usage: z.object({ input: z.number(), output: z.number() }) }),
  z.object({ type: z.literal("error"), message: z.string() }),
]);

export type StreamEvent = z.infer<typeof StreamEventSchema>;

export type ParseEventResult =
  | { ok: true; event: StreamEvent }
  | { ok: false; error: string };

export function parseEvent(data: string): ParseEventResult {
  let json: unknown;
  try {
    json = JSON.parse(data);
  } catch {
    return { ok: false, error: "invalid json" };
  }
  const r = StreamEventSchema.safeParse(json);
  if (r.success) return { ok: true, event: r.data };
  return { ok: false, error: z.prettifyError(r.error) };
}

逐行解读:

测试(src/events.test.ts):

import { test, expect } from "vitest";
import { parseEvent } from "./events.js";

test("合法事件", () => {
  const r = parseEvent('{"type":"text","delta":"你好"}');
  expect(r.ok).toBe(true);
  if (r.ok) expect(r.event).toEqual({ type: "text", delta: "你好" });
});

test("非法事件都返回 ok: false", () => {
  expect(parseEvent('{"type":"text"}').ok).toBe(false);    // 缺 delta
  expect(parseEvent('{"type":"unknown"}').ok).toBe(false); // 未知类型
  expect(parseEvent("not json").ok).toBe(false);           // 不是 JSON
});

练习 2:可取消的 sleep(src/sleep.ts)

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise<void>((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const onAbort = () => {
      clearTimeout(timer);
      reject(signal!.reason);
    };

    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);
      resolve();
    }, ms);

    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

逐行解读:

当你要把“回调风格”的 API(比如 setTimeout)包装成可以 await 的东西时,就用这个写法。Go 里大致相当于: go done := make(chan error, 1) // ... 在某处 done <- nil 或 done <- err return <-done - if (signal?.aborted) { reject(...); return; }:如果传进来时已经取消了,立刻失败,不开定时器。 - const onAbort = () => {...}:取消时要执行的函数:清掉定时器(不然它到点还会触发)、让 Promise 失败。 - signal!.reason:这里用 !,因为 onAbort 只会在 signal 存在时被注册,我们比编译器更清楚它不为空。 - const timer = setTimeout(fn, ms):ms 毫秒后执行 fn,返回一个定时器句柄。= Go 的 time.AfterFunc(d, fn)。 - 定时器到点:先移除 abort 监听器(不再需要了,避免泄漏),再 resolve()。 - signal?.addEventListener("abort", onAbort, { once: true }):signal 被取消时调用 onAbort。{ once: true } 表示触发一次后自动移除。 - onAbort 里引用了 timer,但 timer 定义在 onAbort 后面。这没问题:onAbort 只会在之后被调用,那时 timer 已经赋值了。

测试:

test("abort 立刻打断 sleep", async () => {
  const ctrl = new AbortController();
  const start = Date.now();
  setTimeout(() => ctrl.abort(), 20);              // 20ms 后取消
  await expect(sleep(1000, ctrl.signal)).rejects.toThrow();
  expect(Date.now() - start).toBeLessThan(100);    // 远小于 1000ms
});

练习 3:并行工具与超时(src/runTools.test.ts)

import { test, expect } from "vitest";
import { runTools, type Tool } from "./runTools.js";
import { sleep } from "./sleep.js";

const fakeTool = (ms: number): Tool => {
  return async (_args, signal) => {
    await sleep(ms, signal);
    return `done ${ms}`;
  };
};

test("慢工具超时,其他成功", async () => {
  const start = Date.now();

  const res = await runTools(
    [
      { id: "a", name: "fast", args: "{}" },
      { id: "b", name: "mid", args: "{}" },
      { id: "c", name: "slow", args: "{}" },
    ],
    { fast: fakeTool(50), mid: fakeTool(100), slow: fakeTool(5000) },
    { signal: new AbortController().signal, perToolMs: 200 },
  );

  expect(res.map((r) => r.isError)).toEqual([false, false, true]);
  expect(Date.now() - start).toBeLessThan(400);
});

逐行解读:

练习 4:服务端感知断开(src/sse.test.ts)

import { test, expect } from "vitest";
import http from "node:http";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { sseEvents } from "./sse.js";

test("客户端 abort 后服务端收到 close", async () => {
  // 1. 准备一个“等服务端关闭”的 Promise
  let markClosed!: () => void;
  const closed = new Promise<void>((resolve) => {
    markClosed = resolve;
  });

  // 2. 起一个假 SSE 服务:每 20ms 发一条
  const server = http.createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/event-stream" });
    let n = 0;
    const timer = setInterval(() => {
      res.write(`data: {"type":"text","delta":"t${n++}"}\n\n`);
    }, 20);
    res.on("close", () => {
      clearInterval(timer);
      markClosed();
    });
  });
  server.listen(0);
  await once(server, "listening");
  const { port } = server.address() as AddressInfo;

  // 3. 客户端读 3 条后取消
  const ctrl = new AbortController();
  const res = await fetch(`http://127.0.0.1:${port}`, { signal: ctrl.signal });
  let count = 0;
  try {
    for await (const _ of sseEvents(res)) {
      count++;
      if (count === 3) ctrl.abort();
    }
  } catch (err) {
    expect((err as Error).name).toBe("AbortError");
  }

  // 4. 断言服务端真的感知到了断开
  await closed;
  expect(count).toBe(3);
  server.close();
});

逐行解读:

第 1 步:把 resolve 拿到外面

第 2 步:假服务端

第 3 步:客户端

第 4 步:断言

练习 5:类型检查与测试

npx tsc --noEmit   # 只做类型检查,不输出文件,= go vet + go build 的类型检查部分
npx vitest run     # 跑一遍所有测试后退出,= go test ./...

两条都通过,这一课就算完成,可以在 ROADMAP 里勾上 TypeScript。

© 2026 duang777 · 课件源文件:notes/00-foundations/02-typescript.md