Stage 4 · 流式事件协议(AssistantMessageEventStream)
核心目标:掌握
models.streamSimple()返回的事件流,做打字机效果、thinking 流、abort 控制。
摘要:本文讲解
models.streamSimple()的流式事件协议:对比两种调用方式,将 11 种事件分三档,梳理完整生命周期,并说明text_delta是打字机效果的唯一依据、如何取最终消息、abort()取消用法及streamSimple跨 provider 优势,最后附实战 demo 与自检清单。
4.1 两种调用方式
// 一次性
const reply = await models.complete(model, context);
// 流式, 见4.7 streamSimple vs stream,让我们慢慢来,简单理解为统一封装好的流式函数
const stream = models.streamSimple(model, context, options);
for await (const event of stream) {
// 实时处理事件 见 4.2 事件类型
}
const finalMessage = await stream.result();
complete() 内部就是先 stream 再聚合,结果一致,只是 stream 给你看中间过程。
4.2 11 种事件类型(按业务常用度分三档)
第一档 ·每天必用(4 种):
| 事件 | 触发时机 | 关键字段 |
|---|---|---|
start | 流开始 | partial: AssistantMessage(带 model 信息) |
text_delta | 每段文本 | contentIndex + delta |
done | 流结束 | reason: StopReason + message: AssistantMessage |
error | 流异常 | error: { errorMessage } |
第二档 ·reasoning 模型(3 种):
| 事件 | 触发时机 |
|---|---|
thinking_start | 思考段开始 |
thinking_delta | 每段思考 |
thinking_end | 思考段结束 |
第三档 ·工具调用(4 种,Stage 7):
| 事件 | 触发时机 |
|---|---|
toolcall_start | 工具调用段开始 |
toolcall_delta | partial JSON 增量 |
toolcall_end | 工具调用段结束 |
4.3 完整生命周期
普通回复:
start → text_start → text_delta "你" → text_delta "好" → ...
→ text_end → done(reason: "stop")
有 thinking:
start → thinking_start → thinking_delta "..." → thinking_end
→ text_start → text_delta "..." → text_end → done
有工具调用(Stage 7):
start → text_delta "我需要查" → text_end
→ toolcall_start → toolcall_delta { partial JSON }
→ toolcall_end { name, arguments } → done(reason: "toolUse")
4.4 text_delta 是"打字机效果"的唯一依据
// ❌ 错:一次性打印,无打字机效果
const reply = await models.complete(model, context);
process.stdout.write(reply.content[0].text);
// ✅ 对:监听 text_delta,实时 print
const stream = models.stream(model, context);
for await (const event of stream) {
if (event.type === "text_delta") {
process.stdout.write(event.delta);
}
}
4.5 从 stream 拿最终 AssistantMessage
// 方式 1:done 事件里取
for await (const event of stream) {
if (event.type === "done") {
const finalMessage = event.message;
break;
}
}
// 方式 2:stream.result()(更显式)
const finalMessage = await stream.result();
两种等价。
问: 为什么要知道怎么拿finalMessage?
答:为了后面的会话持久化,这是必须了解的,每个会话都需要留存记录,可以在应用重启后还能继续回到会话。
4.6 abort() — 中途取消
import { makeModels } from "./models/index.ts";
import { makeContext } from "./context.ts";
const models = makeModels();
const model = models.getModel("deepseek", "deepseek-v4-flash")!;
const context = makeContext("讲一个长故事", "写一篇 5000 字的小说");
const stream = models.streamSimple(model, context);
// 5 秒超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
for await (const event of stream) {
if (event.type === "text_delta") {
process.stdout.write(event.delta);
}
}
} catch (err) {
console.error("\n[超时取消]", err);
} finally {
clearTimeout(timeoutId); // ★ 必须清,否则 Node 进程不退出
}
const finalMessage = await stream.result();
console.log("\nstopReason:", finalMessage.stopReason); // "aborted" 或 "stop"
abort 后:
for await抛错退出await stream.result()仍能拿到 AssistantMessage,但stopReason === "aborted"
4.7 streamSimple vs stream(必看)
学习讲究循序渐进,推荐直接使用
streamSimple,统一好的options,通用性强
stream | streamSimple | |
|---|---|---|
| options | provider-specific | provider-neutral |
| 字段 | reasoningEffort / thinkingEnabled / thinkingBudget | reasoning: "medium" |
| 跨 provider | ❌ 要写多份 | ✅ 一份通用 |
6 家国内 provider 怎么传 thinking:
| provider | 协议 | streamSimple 统一字段 |
|---|---|---|
| DeepSeek / MiMo / Qwen / GLM / Moonshot | openai-completions | reasoning: "medium" |
| MiniMax CN | anthropic-messages | reasoning: "medium" |
业务层永远默认 streamSimple。
4.8 实战一:打字机 demo src/04-typewriter.ts
import { makeModels } from "./models/index.ts";
import { makeContext } from "./context.ts";
async function main() {
const models = makeModels();
const model = models.getModel("deepseek", "deepseek-v4-flash")!;
const context = makeContext(
"你是一个简洁的助手。",
"用 100 字介绍 JavaScript 的事件循环。",
);
const stream = models.streamSimple(model, context);
process.stdout.write("🤖 ");
for await (const event of stream) {
switch (event.type) {
case "text_delta":
process.stdout.write(event.delta);
break;
case "done":
process.stdout.write("\n\n");
break;
case "error":
console.error("出错:", event.error.errorMessage);
break;
}
}
const finalMessage = await stream.result();
console.log(`[用量] input=${finalMessage.usage.input} output=${finalMessage.usage.output}`);
console.log(`[停止原因] ${finalMessage.stopReason}`);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
注意:不用 import 任何 event 类型,TypeScript 自动从 for await 推断,switch 里自动收窄。
4.9 实战二:thinking 流 src/04-thinking.ts
import { makeModels } from "./models/index.ts";
import { makeContext } from "./context.ts";
async function main() {
const models = makeModels();
const model = models.getModel("deepseek", "deepseek-v4-flash")!;
const context = makeContext(
"你是一个严谨的推理助手。",
"9.11 和 9.9 哪个大?请详细推理。",
);
// ★ 关键:openai-completions 协议都要传 reasoningEffort
const stream = models.streamSimple(model, context, {
reasoning: "medium",
});
for await (const event of stream) {
switch (event.type) {
case "thinking_start":
console.log("\n💭 [思考中...]");
break;
case "thinking_delta":
process.stdout.write("\x1b[90m" + event.delta + "\x1b[0m");
break;
case "thinking_end":
console.log("\n💭 [思考结束]");
break;
case "text_start":
console.log("\n📝 [回答]");
break;
case "text_delta":
process.stdout.write(event.delta);
break;
case "done":
console.log("\n[done]");
break;
case "error":
console.error("[error]", event.error.errorMessage);
break;
}
}
const finalMessage = await stream.result();
console.log(`\n[content blocks]`);
for (const block of finalMessage.content) {
console.log(` - ${block.type}`);
}
}
main().catch(console.error);
4.10 Stage 4 自检清单
- 讲清 11 种事件的三档分类
- 讲清
text_delta是打字机效果的唯一依据 - 讲清两种取最终消息的方式(
event.messagevsstream.result()) - 讲清
abort()后 stream 还能调stream.result(),但stopReason === "aborted" - 04-typewriter.ts 手敲完,跑出打字机效果
- 04-thinking.ts 用
streamSimple+reasoning: "medium"跑出 thinking 块 - 至少在两家provider的模型上都跑通 thinking(验证 streamSimple 跨 provider 通用)
- 看到 content blocks 里同时有
thinking+text - 进阶:加超时控制(5 秒没吐完字就 abort),验证
finalMessage.stopReason === "aborted"
4.11 常见踩坑
| 现象 | 真原因 | 修法 |
|---|---|---|
| for await 拿不到任何 event | await models.stream() 错了 | for await 直接消费 |
stream.result() 卡死 | break 出去但 stream 还在跑 | 用 return 或抛错 |
| abort 后进程不退出 | 没清 timeout | try/finally 清 setTimeout |
text_delta 看着像一次性 | 用了 complete() 不是 stream() | 改 streamSimple |
| reasoning 模型不产生 thinking | model id 是 chat 版 | 用可以支持thinking的模型(见附加demo 4.21) |
| 中文乱码 | 终端不是 UTF-8 | Windows: chcp 65001 或换 Windows Terminal |
| 切换 provider 时 thinking 丢失 | thinkingSignature 没保留 | Stage 6 接力内容 |
4.12 附加demo 新的list-models
import('@earendil-works/pi-ai/providers/all').then(({ builtinModels }) => {
const models = builtinModels();
const all = models.getModels('qwen-token-plan-cn'); //自动修改provider id
console.log('Qwen Token Plan CN 模型列表(共 ' + all.length + ' 个):');
for (const m of all) {
console.log(` ${m.id.padEnd(30)} — ${m.name.padEnd(20)} [reasoning:${m.reasoning}] [ctx:${m.contextWindow}]`);
}
});
什么都不说,自己看输出,reasoning和ctx,分别什么作用,自己思考下🤭
Qwen Token Plan CN 模型列表(共 18 个):
MiniMax-M2.5 — MiniMax-M2.5 [reasoning:true] [ctx:196608]
deepseek-v3.2 — DeepSeek V3.2 [reasoning:true] [ctx:131072]
deepseek-v4-flash — DeepSeek V4 Flash [reasoning:true] [ctx:1000000]
deepseek-v4-flash-0731 — DeepSeek V4 Flash 0731 [reasoning:true] [ctx:1000000]
deepseek-v4-pro — DeepSeek V4 Pro [reasoning:true] [ctx:1000000]
deepseek-v4-pro-0813 — DeepSeek V4 Pro 0813 [reasoning:true] [ctx:1000000]
glm-5 — GLM-5 [reasoning:true] [ctx:202752]
glm-5.1 — GLM-5.1 [reasoning:true] [ctx:202752]
glm-5.2 — GLM-5.2 [reasoning:true] [ctx:1000000]
kimi-k2.5 — Kimi K2.5 [reasoning:true] [ctx:262144]
kimi-k2.6 — Kimi K2.6 [reasoning:true] [ctx:262144]
kimi-k2.7-code — Kimi K2.7 Code [reasoning:true] [ctx:262144]
qwen3.6-flash — Qwen3.6 Flash [reasoning:true] [ctx:1000000]
qwen3.6-plus — Qwen3.6 Plus [reasoning:true] [ctx:1000000]
qwen3.7-max — Qwen3.7 Max [reasoning:true] [ctx:1000000]
qwen3.7-plus — Qwen3.7 Plus [reasoning:true] [ctx:1000000]
qwen3.8-flash — Qwen3.8 Flash [reasoning:true] [ctx:1000000]
qwen3.8-max — Qwen3.8 Max [reasoning:true] [ctx:1000000]
&spm=1001.2101.3001.5002&articleId=164807820&d=1&t=3&u=2046b2d20d4a480695c95431485dbbe9)
475

被折叠的 条评论
为什么被折叠?



