4. 流式事件协议 — 基于@earendil-works/pi-ai,学习搭建多模型业务层框架(国内模型)

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_deltapartial 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,通用性强

streamstreamSimple
optionsprovider-specificprovider-neutral
字段reasoningEffort / thinkingEnabled / thinkingBudgetreasoning: "medium"
跨 provider❌ 要写多份✅ 一份通用

6 家国内 provider 怎么传 thinking

provider协议streamSimple 统一字段
DeepSeek / MiMo / Qwen / GLM / Moonshotopenai-completionsreasoning: "medium"
MiniMax CNanthropic-messagesreasoning: "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.message vs stream.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 拿不到任何 eventawait models.stream() 错了for await 直接消费
stream.result() 卡死break 出去但 stream 还在跑用 return 或抛错
abort 后进程不退出没清 timeouttry/finally 清 setTimeout
text_delta 看着像一次性用了 complete() 不是 stream()改 streamSimple
reasoning 模型不产生 thinkingmodel id 是 chat 版用可以支持thinking的模型(见附加demo 4.21)
中文乱码终端不是 UTF-8Windows: 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]
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值