你说话,其实是在吐事件流
用户看到的是一句流畅的回答,但底层不是「一次性返回一段文本」。模型吐出的是一串事件(events):先开始,再逐块(delta)追加文字或思考,可能夹着工具调用,最后收尾。packages/ai 把这些事件聚合成一条结构化的 AssistantMessage。
stream(model, context) ──▶ 事件流 start (整条消息开始) ├─ text_start / thinking_start / toolcall_start ├─ text_delta "你" "好" "," "世" "界" ├─ text_end └─ done ──▶ 聚合成一条 AssistantMessage
一条 Message 是「内容块的数组」
消息不是纯字符串,而是 content: Block[]。一条 assistant 消息里可以同时有:思考块(thinking)、文字块(text)、若干工具调用块(toolCall)。这正是 Agent Loop 能从消息里 filter 出工具调用的原因。
packages/ai/src/types.ts(精简)typescript
4 个注解
1type Block =
2 | { type: "text"; text: string }
3 | { type: "thinking"; thinking: string }
4 | { type: "toolCall"; id: string; name: string; arguments: Record<string, any> }
5
6interface AssistantMessage {
7 role: "assistant"
8 content: Block[] // 内容是块的数组
9 stopReason: // 为什么停下
10 | "stop" // 正常说完
11 | "toolUse" // 要调工具
12 | "length" // 吐到上限
13 | "aborted" | "error"
14 usage: { input; output; cacheRead; cacheWrite }
15}
16
17// 流式聚合:把 delta 累积进当前块
18for await (const ev of stream(model, ctx)) {
19 if (ev.type === "text_delta")
20 current.text += ev.delta
21 if (ev.type === "done")
22 return ev.message
23}
usage:每条消息都自带账单
usage 记录了输入、输出、缓存读写的 token 数。它既是计费依据,也是 Compaction 判断「该压缩了」的信号源。