Skip to content

8.2 消息历史与流式事件

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题8.1 做出来的 FakeModel 是「憋完整句、再一次性返回」,用户要盯着空行等它写完。本章把模型抽象从「返回一个 Promise」换成「返回一串事件」,用 async function* 让 FakeModel 一个 token 一个 token 地吐字,用 for await..of 在终端上做出打字机效果;并把一个容易被忽略的问题讲透——一边流一边显示的碎片,是在哪一刻、由谁、收拢成消息历史里的一条完整 assistant 消息的

前置知识8.1 最小 CLI 与 Fake ModelMessage / Context 类型与对话循环骨架);2.6 异步迭代器与 for awaitasync function*、惰性求值);2.3 union、字面量与可辨识联合3.3 流式输出(事件协议的形状);5.4 流式事件如何传播到界面(Pi 的三层事件)。

学习目标:读完本章后你能

  • 说清 step03 的 StreamEvent 是哪 4 种事件、一次正常流与一次出错流各是什么序列;
  • async function* 写出一个逐块吐事件的模型,并解释它为什么是惰性的;
  • 说出 StreamFn 的契约「不允许 throw,错误必须编码成 error 事件」以及这条契约买到了什么;
  • 指出 step03 里「消息历史收拢」发生在哪一行,并说明为什么 renderStream 的返回类型是 AssistantMessage | undefined
  • 对照 Pi 的 message_update 机制,说出 Pi 与 step03 在「半成品消息要不要先进历史」上的两种取舍。

建立直觉:一处改动,三个新问题

step02 与 step03 的主循环差别只有两行:

ts
// step02/src/main.ts:46-48
const reply = await model(context);
context.messages.push(reply);
console.log(`助手> ${messageText(reply)}\n`);

// step03/src/main.ts:47-51
const message = await renderStream(model(context));
// 只有正常结束的回复才进上下文;出错的半截回复丢弃。
if (message) {
  context.messages.push(message);
}

await model(context) 变成了 renderStream(model(context))。表面上只是「等一个值」换成「消费一串事件」,但这一换会立刻逼出三个问题,本章的全部内容就是回答它们:

  1. 谁负责拼装? 界面收到的是一块块碎片,历史里要存的是一条完整消息。碎片拼回整体,是模型做还是调用方做?
  2. 中途出错怎么办? 前 19 个 token 已经打到屏幕上了,第 20 个的位置上发生了错误。这时候还能 throw 吗?
  3. 什么时候入历史? 一条正在生成的回复算不算「历史里的一条消息」?出错的半截回复要不要留下来?
📘 概念流式事件(Stream Event)
把一次模型回复表达成「一串带类型的对象」而不是「一个最终字符串」。每个事件说明这一刻发生了什么:开始了、又多了一小段文本、结束了、出错了。消费方按类型分支处理,界面实时性与状态正确性因此可以由不同的事件分别负责。

最小示例:step03 的四个关键文件

实验目录:labs/mini-agent-harness/step03-streaming。安装并运行:

sh
cd labs/mini-agent-harness/step03-streaming
npm install
npm start       # 交互模式,每 token 间隔 30ms,看得见打字机节奏
npm run demo    # 非交互模式,delayMs=0,输出与 expected-output.txt 可比对

事件类型:一个 4 成员的可辨识联合

src/types.ts 新增的核心类型是 StreamEvent

ts
export type StreamEvent =
  | { type: "start" }
  /** 新吐出的一小段文本(一个或几个 token)。 */
  | { type: "text_delta"; delta: string }
  /** 流正常结束,附带拼装好的完整助手消息。 */
  | { type: "done"; message: AssistantMessage }
  /** 流因错误结束。注意:错误是**事件**,不是抛异常。 */
  | { type: "error"; error: string };

判别字段是 type,这是 2.3 的可辨识联合(Discriminated Union):switch (event.type) 之后,每个分支里 TypeScript 都收窄到了具体形状——text_delta 分支能访问 event.deltadone 分支能访问 event.message,写错了编译期就报。

四种事件拼出的序列只有两种形状:

  • 正常:start → text_delta × N → done
  • 出错:start → text_delta × N → error

done 事件里带着已经拼装好的完整消息,这是本章第一个问题的答案:拼装由模型侧负责。理由是每个调用方都自己拼一遍,既重复又容易各拼各的;让产生事件的一方顺手给出权威结果,消费方就只剩「显示」这一件事。

同一个文件里还新增了 StopReasonStreamFn

ts
export type StopReason = "stop" | "error" | "aborted";

export type StreamFn = (context: Context, options?: StreamOptions) => AsyncIterable<StreamEvent>;

StreamFn 是整个 Mini Harness 最重要的一个抽象:给我一个 Context,还我一串事件。本步骤的 FakeModel 实现它,8.6 接入的真实 Provider(模型服务提供方)也实现它;从 8.3 起,Agent Loop(Agent 循环)只认这个类型,不关心背后是谁。StopReason 里的 "aborted"8.5 才会真正用上,位置先留好。

生产端:async function*

src/fake-model.ts 与 step02 的结构性差别只有一处——函数体从 async (context) => {...} 变成 async function* (context) {...}

ts
return async function* (context: Context, callOptions): AsyncGenerator<StreamEvent> {
  const delayMs = callOptions?.delayMs ?? defaultDelay;

  yield { type: "start" };

  const last = context.messages[context.messages.length - 1];
  const input = last ? messageText(last) : "";
  const rule = rules.find((r) => r.match === undefined || input.includes(r.match));

  // 契约:不 throw,把错误编码成事件。
  if (rule?.error !== undefined) {
    yield { type: "error", error: rule.error };
    return;
  }

  const reply = rule?.reply ?? "(FakeModel 没有命中任何规则,也没有兜底规则。)";
  let text = "";
  for (const token of tokenize(reply)) {
    if (delayMs > 0) await sleep(delayMs);
    text += token;
    yield { type: "text_delta", delta: token };
  }

  const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text }], stopReason: "stop" };
  yield { type: "done", message };
};

三个细节值得指出:

  • tokenize() 按 2 个字符一刀切「token」。真实模型的分词复杂得多,但对理解流式机制不重要——重要的只是「回复被切成了多块,一块一块地出来」。
  • 循环里同时维护了两样东西:text 累加出完整文本(给 done 用),yield 吐出增量(给界面用)。同一份内容,两种粒度,各服务一个需求
  • 命中带 error 的规则时,是 yield 一个 error 事件然后 return不是 throw
⚠️ 常见误解以为 for await 会先把事件收集完再执行循环体
不会。异步生成器是**惰性**的:调用方每执行一次循环体,生成器才继续跑到下一个 `yield`。所以 `await sleep(delayMs)` 是真的一边等一边吐,而不是先在内存里算完再回放。把 `delayMs` 调到 300 跑一次 `npm start`,你会看到字确实是一顿一顿出来的——如果生成器不是惰性的,你会先干等几秒、然后整段文字瞬间出现。

消费端:打字机在哪一行

src/render.ts 是事件的消费方,整个文件只有一个函数:

ts
export async function renderStream(
  stream: AsyncIterable<StreamEvent>,
): Promise<AssistantMessage | undefined> {
  let message: AssistantMessage | undefined;
  let wroteText = false;

  for await (const event of stream) {
    switch (event.type) {
      case "start":
        stdout.write("助手> ");
        break;
      case "text_delta":
        // 关键:不是 console.log,而是 write——不换行,才有打字机效果。
        stdout.write(event.delta);
        wroteText = true;
        break;
      case "done":
        message = event.message;
        stdout.write("\n\n");
        break;
      case "error":
        // 已经吐了半截文本的话先换行,避免错误信息接在半句话后面。
        stdout.write(`${wroteText ? "\n" : ""}[错误] ${event.error}\n\n`);
        break;
    }
  }

  return message;
}

打字机效果就在 stdout.write(event.delta) 这一行:console.log 每次都会补一个换行,write 不会,于是一块接一块地贴在同一行上。

更值得注意的是返回类型 Promise<AssistantMessage | undefined>。渲染层不碰消息历史——它只把「这一轮有没有产出一条可以入历史的消息」作为返回值交出去,要不要 push 由调用方决定。模型只管产生事件、渲染只管显示、主循环只管维护历史,三者通过 StreamEvent 这个联合解耦:以后换模型不用改渲染,换界面不用改模型。

收拢:消息历史只在流结束时变长

src/main.ts 的主循环里,消息历史 context.messages 一共只被写两次:

ts
context.messages.push(userText(line));

const message = await renderStream(model(context));
// 只有正常结束的回复才进上下文;出错的半截回复丢弃。
if (message) {
  context.messages.push(message);
}

流式期间,几十个 text_delta 全部只流向终端,一个都没有进历史。历史只在 done 事件到达、renderStream 返回之后长出一条完整的 assistant 消息。这就是本章开头第三个问题的答案:step03 的取舍是「要么是一条完整消息,要么什么都没有」,出错的半截回复直接丢弃。

这条取舍在 demo 的真实输出里可以数出来:

Mini Agent Harness · step03 streaming(demo)

你> 你好
助手> 你好!我是 FakeModel,正在一个 token 一个 token 地把这句话吐给你。

你> 流式输出是什么
助手> 流式输出的本质是:模型每生成一小段就发一个事件,调用方一边收一边打印。

你> 报错试试
助手> [错误] 模拟的模型错误:额度不足

你> /exit
再见。(本次对话共 5 条消息)

最后一行是 5 条而不是 6 条:3 条用户消息 + 2 条助手消息。第三轮出错了,那条半截回复没有进历史。这个数字是验证你理解正确与否最省事的一个探针。

图解

图加载中…

图 8.2-1 step03 一轮对话的事件序列
阅读顺序:从上到下,时间向下流动。请重点关注三处。第一,箭头方向:`renderStream` 是**主动去拉**事件的(`R->>M`),生成器被拉一次才跑一次,这就是惰性;实线回箭头 `M-->>R` 是一次 `yield` 的到达。第二,`loop` 框里只有两条线——收到增量、写进终端,**没有任何一条通向消息历史 H**。第三,`alt` 的两条分支是全章的分水岭:走 `done` 分支时历史长出一条完整消息,走 `error` 分支时历史一动不动。图中的五个参与者分别对应实验里的 `src/main.ts`、`context.messages`(`src/types.ts` 的 `Context`)、`src/fake-model.ts` 的 `async function*`、`src/render.ts` 的 `renderStream`、`node:process` 的 `stdout`。

对照 Pi 源码:12 种事件与 message_update

step03 的 4 种事件是把 Pi 的协议缩小到「刚好够用」。Pi 的第一层事件协议长这样:

packages/ai/src/types.ts · AssistantMessageEvent
earendil-works/pi@c13ffe1第 493–513 行在 GitHub 查看 ↗
统一事件协议:注释规定流必须先发 start,最后以 done 或 error 之一收尾;中间是文本、思考、工具调用三组「start / delta / end」。

清点结果是 12 个成员(源码事实):start、文本三件套、思考三件套、工具调用三件套、doneerror。step03 的 4 种正好是它的骨架:startdone / error 一一对应,text_delta 对应文本三件套里的中间那个。剩下 8 种的去向是:工具调用三件套到 8.3 才登场(step04 用一个合并的 toolcall 事件代替这三个);text_start / text_end 这类块边界,以及整组思考事件,Mini Harness 全程都没有对应物——因为我们的内容块只有一种,用不着「这一块属于第几个」这一维。

「不允许 throw」这条契约不是本书发明的,Pi 在两处都把它写成了明文:

packages/ai/src/types.ts · StreamFunction
earendil-works/pi@c13ffe1第 312–324 行在 GitHub 查看 ↗
pi-ai 的 StreamFunction 契约注释:一旦被调用,请求、模型、运行时的失败都应编码进返回的流,而不是抛出。
earendil-works/pi@c13ffe1第 18–32 行在 GitHub 查看 ↗
pi-agent-core 的 StreamFn 契约注释:不得 throw、不得返回 rejected promise;失败必须通过协议事件表达,并以 stopReason 为 error 或 aborted 的最终消息收尾。

这正是 step03 里 yield { type: "error", ... } 的出处(官方说明层级的事实:这两段是源码注释里的契约声明)。它买到的东西是:调用方只有一条代码路径。流是边收边用的,如果第 20 个 token 之后抛异常,消费者会同时处在「已经打印了半截」和「异常处理」两种状态里,要用 try/catch 包住 for await,还要和已打印的内容对齐;编码成事件后,switch 里多一个 case 就完了——render.tserror 分支只有两行,这就是账。

Pi 把碎片放进历史的时机与 step03 相反

现在回到本章的主问题:半成品要不要先进消息历史。step03 的答案是「不进」。Pi 的答案是「进,而且原地替换」。

本书调研笔记 research/R02-agent-core.md 的结论(行号已复核)是:Agent 层的 AgentEvent 共 10 种,其中没有顶层的 text_delta——pi-ai 那 9 种块内事件被统一折叠进一个 message_update,原事件作为 assistantMessageEvent 字段随行。

earendil-works/pi@c13ffe1第 422–437 行在 GitHub 查看 ↗
Agent 循环对外发出的 10 种事件:agent / turn / message / tool_execution 四组生命周期;message_update 仅在 assistant 消息流式期间发出。

折叠动作发生在 streamAssistantResponse()for await 循环里,也正是在这里,Pi 对消息历史做了 step03 没做的事:

earendil-works/pi@c13ffe1第 317–359 行在 GitHub 查看 ↗
start 时把 partial 半成品消息推进 context.messages;每个块内事件把最后一条原地替换成新的 partial;done 或 error 时取 response.result 的最终消息再原地替换一次,然后发 message_end。
ts
// packages/agent/src/agent-loop.ts:317-359(有省略)
for await (const event of response) {
	switch (event.type) {
		case "start":
			partialMessage = event.partial;
			context.messages.push(partialMessage);   // 半成品先占位
			addedPartial = true;
			await emit({ type: "message_start", message: { ...partialMessage } });
			break;

		case "text_start": case "text_delta": case "text_end":
		// …(省略:thinking_* 与 toolcall_* 共 6 个 case 标签,与上面三个共用同一分支体)
			if (partialMessage) {
				partialMessage = event.partial;
				context.messages[context.messages.length - 1] = partialMessage;   // 原地替换
				await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
			}
			break;

		case "done":
		case "error": {
			const finalMessage = await response.result();       // 权威结果
			if (addedPartial) context.messages[context.messages.length - 1] = finalMessage;
			// …(省略:addedPartial 为 false 时补发 message_start)
			await emit({ type: "message_end", message: finalMessage });
			return finalMessage;
		}
	}
}

三处差别值得逐条对照 step03:

  • 半成品占位:Pi 在 start 时就把一条空的 assistant 消息推进 context.messages,之后每个事件都把数组末尾那条替换掉。这样上层任何时刻去看 messages,看到的都是「包含正在生成的这条」的完整上下文。step03 没有这个需求,因为它的渲染层直接写终端,用不着从历史里读半成品。

  • 权威结果不来自事件本身,而来自 await response.result()AssistantMessageEventStreamdone / error 事件被 push 进来时就解析出最终消息,result() 拿的是同一个值:

    packages/ai/src/utils/event-stream.ts · AssistantMessageEventStream
    earendil-works/pi@c13ffe1第 69–83 行在 GitHub 查看 ↗
    以 done 或 error 判定流结束,并从 done 的 message 或 error 的 error 字段取出最终 AssistantMessage 作为 result。

    这和 step03 的 done.message 是同一个设计意图的两种写法:权威的完整消息由产生方给出

  • 出错的半截回复留在历史里。Pi 的最终消息带 stopReason,出错时是 "error",被取消时是 "aborted",它照样留在 context.messages 里;随后循环靠这个字段决定收摊:

    earendil-works/pi@c13ffe1第 193–200 行在 GitHub 查看 ↗
    拿到 assistant 消息后先看 stopReason:error 或 aborted 就发 turn_end 与 agent_end 直接返回,不再进入工具执行。

    step03 则直接丢弃。一种看法是:Pi 的做法保住了「用户看到过什么,历史里就有什么」,重试和 8.4 那样的持久化都需要这条记录;代价是历史里会出现「不完整的 assistant 消息」,后续每个消费者都得会看 stopReason。step03 的丢弃法省掉了这条心智负担,代价是「屏幕上出现过、历史里查不到」——等到 8.5 要处理「取消到一半」时,这笔账会重新摊开。

把两种收拢方式并排画出来:

图加载中…

图 8.2-2 两种把流收拢成消息的方式
阅读顺序:两个分组各自从上往下读,读完再横向对比。请关注两处。第一,左边 `A2` 与右边 `B2`、`B3` 的对照:step03 在流式期间**完全不动**消息历史,Pi 则让历史里一直有一条「正在长大的」assistant 消息。第二,两个菱形之后的分支:step03 的 `error` 分支让历史长度保持不变,Pi 的 `done` 与 `error` 走的是**同一条路**——都用 `result()` 的最终消息替换占位那条,区别只写在 `stopReason` 字段里。右边四个方框对应 `packages/agent/src/agent-loop.ts` 第 317–359 行的三个 `case` 分支;左边四个方框对应实验里 `src/render.ts` 的 `switch` 与 `src/main.ts` 的 `if (message)`。

最后一处对照留给 FakeModel 本身:真实项目同样需要一个可脚本化的假模型来做零成本、可复现的测试。Pi 的对应物是 faux provider,它的文本分块循环与 step03 的 tokenize + for 循环是同一件事的两种写法:

packages/ai/src/providers/faux.ts · splitStringByTokenSize
earendil-works/pi@c13ffe1第 359–374 行在 GitHub 查看 ↗
faux provider 的文本块流式化:按 token 大小切块,每块之间按 tokensPerSecond 等待,累加进 partial 后 push 一个 text_delta,块结束时 push text_end。

区别只在细节:Pi 按 token 数随机切块并按「每秒多少 token」计算等待时间,step03 按固定 2 个字符切、固定 delayMs 等待。机制完全一样。

实践任务

🛠 实践任务流式事件与打字机labs/mini-agent-harness/step03-streaming

目标:跑通 step03,亲手验证事件序列、惰性求值与「消息历史何时收拢」三件事。实验目录:labs/mini-agent-harness/step03-streaming(全书实验索引见实践任务索引)。全程不联网、不需要任何 API Key。

步骤

  1. 安装并跑非交互 demo,与 expected-output.txt 比对:

    sh
    cd labs/mini-agent-harness/step03-streaming
    npm install
    npm run demo
  2. 跑交互模式 npm start,依次输入「你好」「报错试试」,肉眼确认文字是逐字长出来的,以及错误提示前有换行。

  3. src/main.ts 第 17 行的 delayMs 从 30 改成 300,重跑 npm start。这一步是为了验证惰性:如果生成器不惰性,你会先干等再看到整段文字。

  4. src/render.tstext_delta 分支里,把 stdout.write(event.delta) 换成 console.log(event.delta),重跑并解释输出为什么基本上变成了每两个字符一行(tokenize 按 2 个字符切,末尾那块可能只有 1 个字符)。改回去。

  5. src/render.tserror 分支末尾加一行 message = { role: "assistant", content: [{ type: "text", text: "半截" }], stopReason: "error" };,重跑 npm run demo,观察最后一行的消息条数从 5 变成 6,并说出这相当于把 step03 的取舍换成了 Pi 的哪一种。改回去。

  6. src/rules.ts 里加一条自己的规则(match + reply),重跑 npm start 验证命中。

预期现象:第 1 步的输出与 expected-output.txt 完全一致,关键三行:

助手> 流式输出的本质是:模型每生成一小段就发一个事件,调用方一边收一边打印。
助手> [错误] 模拟的模型错误:额度不足
再见。(本次对话共 5 条消息)

第 5 步改动后,最后一行变成「共 6 条消息」。

如何判断成功:你能不看代码画出正常流与出错流的两条事件序列;能指出打字机效果、消息收拢分别落在哪一行代码;能解释第 5 步为什么把 5 改成了 6。

常见错误

  • 看不到打字机效果:多半把输出重定向到了文件或管道(npm start > out.txtnpm start | cat),请在终端里直接运行;npm run demodelayMs 是 0,本来就看不出节奏。
  • for await 写成 forfor..of 走同步迭代协议,编译器会提示对象上没有 Symbol.iterator
  • 在 FakeModel 里 throw 而不是 yield 一个 error 事件:renderStreamfor await 会把异常原样抛给主循环,进程直接退出——这就是契约存在的理由。
  • switch 分支忘了 breakdone 分支穿透到 error 分支,屏幕上每轮都会多出一条错误提示。

对应源码位置packages/ai/src/types.ts 第 493–513 行 AssistantMessageEvent(实验的 StreamEvent 是它的缩小版)与第 312–324 行 StreamFunction 契约;packages/agent/src/types.ts 第 18–32 行 StreamFn 契约、第 422–437 行 AgentEventpackages/agent/src/agent-loop.ts 第 317–359 行(实验里 renderStream 的真实对应物);packages/ai/src/providers/faux.ts 第 359–374 行(FakeModel 的真实对应物)。

本章小结

  • 模型抽象从 (context) => Promise<AssistantMessage> 换成 StreamFn = (context, options?) => AsyncIterable<StreamEvent>,生产端是 async function*,消费端是 for await..of。主循环的骨架一行没改。
  • step03 的 StreamEvent 有 4 个成员:starttext_deltadoneerror;序列只有两种形状——start → text_delta × N → donestart → text_delta × N → error
  • 拼装由模型侧负责:done 事件携带拼装好的完整消息,消费方不必自己累加。text_delta 服务于界面实时性,done 服务于状态正确性。
  • 错误是事件不是异常。Pi 在 StreamFunctionStreamFn 两处注释里把这条写成了明文契约,收益是消费方只有一条代码路径。
  • 消息历史的收拢点在主循环那一句 if (message) context.messages.push(message):流式期间历史一动不动,只有 done 到达才长出一条完整消息。demo 最后一行的「5 条消息」就是这条规则的探针。
  • Pi 的取舍相反:start 时先把半成品推进 context.messages,每个块内事件原地替换,done / error 时用 response.result() 的最终消息再替换一次;出错与被取消的消息带着 stopReason 留在历史里。9 种块内事件在 Agent 层被折叠成一个 message_update 信封。

关键术语:流式输出(Streaming)、流式事件(Stream Event)、异步迭代器(Async Iterator)、异步生成器(async function*)、可辨识联合(Discriminated Union)、增量(delta)、终局事件(done / error)、StreamFnStopReason、消息历史收拢、message_update

关键源码索引packages/ai/src/types.ts 第 493–513 行 AssistantMessageEvent(12 种)、第 312–324 行 StreamFunction 契约;packages/agent/src/types.ts 第 18–32 行 StreamFn 契约、第 422–437 行 AgentEvent(10 种);packages/agent/src/agent-loop.ts 第 317–359 行事件折叠与历史替换、第 193–200 行 stopReason 分流;packages/ai/src/utils/event-stream.ts 第 69–83 行 AssistantMessageEventStreampackages/ai/src/providers/faux.ts 第 359–374 行文本分块

自测问题

  1. renderStream 的返回类型为什么是 AssistantMessage | undefined 而不是 AssistantMessage?如果改成必返回一条消息,demo 最后一行会变成几条?
  2. FakeModel 里如果把 yield { type: "error", ... } 换成 throw new Error(...),程序在哪一行崩、屏幕上会留下什么?请照着 render.tsmain.ts 的代码走一遍。
  3. done 事件里已经有完整消息了,text_delta 是不是多余的?如果只保留 done,用户体验和 step02 有什么区别?
  4. Pi 在 start 事件时就把半成品消息推进了 context.messages,step03 不这么做。各举一个「这样做更方便」的具体场景。

下一章预告8.3 Agent Loop 与工具执行——到目前为止,模型只会说话。下一章给 ContentBlock 加入 toolCall、给 StreamEvent 加入 toolcall 事件、给 StopReason 加入 "toolUse",还会出现第三种 role toolResult;「一问一答」于是变成「请求 → 执行工具 → 回填结果 → 再请求」的内层循环。本章那句「事件种类到 8.3 才补齐」的账,下一章开始还。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)