一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

从头开发 Agent(四):实现模型回复实时逐段显示

时间:2026-09-12 13:34:01 编辑:袖梨 来源:一聚教程网

接通模型接口后,最直接的实现通常是等待完整响应再打印,但这种方式会让用户在生成期间面对空白终端。要让 Agent 的回答边生成边显示,调用链需要直接消费事件流,正确处理每次新增的文本分片,同时在输出结束后区分正常完成与中途失败。下面从调用方、协议适配器和事件交付三个环节展开。

从零构建 Agent(4):让模型回复逐字显示

上一章已经接通百炼,但程序要等 models.complete() 返回后,才一次性打印整条回复。本章保留相同的模型配置和 Provider,把调用方改为接收文本分片:第一段文字到达时就显示,后面的文字继续追加。

graph LR
    Input[Model 与 Context] --> Call[models.stream 调用</br>经 Bailian Provider 发起请求]
    Call --> API[百炼</br>返回 SSE 数据块]
    API --> Adapter[协议适配器</br>产生 text_delta]
    Adapter --> View[调用方</br>立即追加到终端]

图中的请求路径沿用第三章。本章只改变回复如何到达显示端;终端承担最小界面的作用,每次写入就能看到新增文字。

1. 每次收到的是新增文本,不是整条回复

“逐字显示”指的是回复持续出现。服务端每次可能返回一个字、几个词或一整段,调用方不能假定一个分片恰好对应一个字。

理解显示过程只需要以下几个概念:

概念在本章中的含义
文本分片 delta这次新到达的字符串,只追加一次
文本块与 contentIndex回复的 content 数组中保存文本的位置;分片说明自己属于哪个块
当前消息 partial适配器正在累积的消息,包含已经写入的内容
异步迭代 for await按顺序取出事件;暂时没有事件时等待,有事件时执行循环体

pi 在 types.tsAssistantMessageEvent 中定义文本分片事件。下面摘出其中的 text_delta 成员;这是类型声明,描述事件的字段,不是一条实际响应:

{ type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }

假设服务端依次返回 "你可以先""看到回复。",只含一个文本块时,数据变化如下:

到达的分片contentIndex适配器累积的文本终端本次写入
你可以先0你可以先你可以先
看到回复。0你可以先看到回复。看到回复。

显示端追加的是 delta。如果把累积文本也当成新增文本追加,第二次就会重复打印第一段。

2. 让调用方直接取得事件流

第三章使用的 complete() 本来就是基于流实现的。下面是 models.ts 中省略类型标注后的真实方法主体:

async complete(model, context, options) {
	return this.stream(model, context, options).result();
}

它发起流式请求,然后等待完整消息。要提前显示文字,调用方直接使用同一个 stream() 入口,并开始迭代:

const events = models.stream(model, context, { maxTokens: 256 });

for await (const event of events) {
	if (event.type === "text_delta") {
		process.stdout.write(event.delta);
	} else if (event.type === "error") {
		throw new Error(event.error.errorMessage ?? event.reason);
	}
}

这是调用方连续显示文本的最小写法,其中 events 是 pi 的统一事件流。models.stream() 立即返回事件流,认证和请求准备可以继续异步执行;for await 等待的是下一条事件,不是完整回复。process.stdout.write() 不会自动换行,因此多个分片会接成一段连续文字。

模型与 Provider 配置沿用第三章。

3. 适配器把服务端分片转换成文本事件

先创建消息和用于发送事件的对象

适配器需要两个对象:output 保存逐步累积的回复,stream 把处理过程中产生的事件交给调用方。下面摘自 openai-completions.ts 的入口,省略类型标注和其他消息字段,保留异步处理、响应循环及成功与失败分支的结构。方括号编号标出拆分位置,代码后的表格说明各片段在哪里展开:

export const stream = (model, context, options) => {
	const stream = new AssistantMessageEventStream();

	(async () => {
		const output = {
			role: "assistant",
			content: [],
			// 其他消息字段省略
		};
		try {
			// [1] 发起请求,取得 openaiStream
			// [2] 定义文本块变量和 ensureTextBlock()

			for await (const chunk of openaiStream) {
				// [3] 处理数据块、累积文本并发送 text_delta
			}

			// [4] 完成检查与成功事件
		} catch (error) {
			// [4] 整理错误信息并发送错误事件
		}
	})();

	return stream;
};

这些片段属于上面同一个异步函数,不是另外定义的函数入口:

入口中的片段展开位置
[1] 请求与 openaiStream 的来源本节“从服务端数据块中取出新增文本”的开头
[2] 文本块变量与 ensureTextBlock()下文“创建并复用本次回复的文本块”
[3] 响应循环及其循环体本节“从服务端数据块中取出新增文本”中的响应循环
[4] 循环后的完成处理,以及 catch 中的失败处理第 4 节“已显示文字,还需要确认请求是否成功结束”

外面的 stream 是适配器函数,内部的同名变量是事件流对象。函数启动异步处理后立即返回这个对象,内部循环继续通过 stream.push(...) 写入事件;调用方经 models.stream() 读取这些事件。

创建并复用本次回复的文本块

下面展开入口代码中的片段 [2]:它位于取得 openaiStream 之后、响应循环之前。

output.content 初始为空。第一次收到文本时,适配器向这个数组加入一个空文本块;之后每个分片都追加到同一个块里。下面的代码位于同一个异步函数内,省略了非文本内容处理,保留真实变量名和操作顺序:

let textBlock: TextContent | null = null;
const blocks = output.content as StreamingBlock[];
const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block);

const ensureTextBlock = () => {
	if (!textBlock) {
		textBlock = { type: "text", text: "" };
		blocks.push(textBlock);
		stream.push({ type: "text_start", contentIndex: getContentIndex(textBlock), partial: output });
	}
	return textBlock;
};

这里的 TextContent 是前文消息中的 { type: "text", text: string }StreamingBlock 是适配器内部使用的内容块类型。as StreamingBlock[] 只作 TypeScript 类型断言,不会复制数组,所以 blocksoutput.content 指向同一个数组。

第一次调用 ensureTextBlock() 时,blocks.push(textBlock) 把文本块对象放入消息;函数返回的也是这个对象。因此,后面修改返回值的 text 字段,就会改变 output.content 中的文本。getContentIndex() 查找该块在数组中的下标;本例的数组中只有这个文本块,下标就是 0text_start 只通知文本块开始,不包含需要追加的文字。

从服务端数据块中取出新增文本

下面展开入口代码中的片段 [1][3]:先说明服务端数据流从哪里来,再展开读取它的循环。

第三章已经在请求中设置 stream: true。适配器通过 OpenAI SDK 发起 Chat Completions 请求,SDK 解码 SSE 响应后,提供一个可异步迭代的数据流。源码把 SDK 返回结果的 data 命名为 openaiStream。它提供的是服务端数据块,还不是 pi 的统一事件。

例如,包含第一段文本的数据块可以是下面的形状,仅展示本章使用的字段:

{
  "choices": [
    { "delta": { "content": "你可以先" } }
  ]
}

choices 保存这次返回的候选回复,当前适配器读取第一个。choice.delta 是一个对象,新增文本保存在其 content 字段中。第二段文本到达时,这个字段变成 "看到回复。",其中不会带上第一段已经返回的文字。

下面展开入口标记 [3] 的响应循环及本章使用的循环体。它从 openaiStream 取出数据块,读取新增文本,再调用 [2] 中定义的 ensureTextBlock()。代码按真实控制顺序裁剪,省略非文本内容和统计字段处理:

for await (const chunk of openaiStream) {
	if (!chunk || typeof chunk !== "object") continue;

	const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
	if (!choice) continue;

	if (choice.delta) {
		if (
			choice.delta.content !== null &&
			choice.delta.content !== undefined &&
			choice.delta.content.length > 0
		) {
			const block = ensureTextBlock();
			block.text += choice.delta.content;
			stream.push({
				type: "text_delta",
				contentIndex: getContentIndex(block),
				delta: choice.delta.content,
				partial: output,
			});
		}
	}
}

每次非空文本到达,适配器先用 block.text += choice.delta.content 修改消息里的文本块,再通过 stream.push() 发送统一事件。处理第一段 "你可以先" 后,事件发送时的内容如下,仅展示消息中与文本相关的字段:

{
  "type": "text_delta",
  "contentIndex": 0,
  "delta": "你可以先",
  "partial": {
    "content": [{ "type": "text", "text": "你可以先" }]
  }
}

这里的 event.delta 已经是字符串,来自服务端的 choice.delta.contentpartial 引用正在更新的 output,不是独立保存的历史快照,不能作为新增文本重复追加。处理第二段后,event.delta"看到回复。",而消息里的文本变成 "你可以先看到回复。"。这正是第一节表格中“本次新增”和“已经累积”的区别。

把发送的事件交给读取方

stream.push() 把一条事件交给事件流,for await 从事件流中读取它。下面用同一个事件流对象 stream 示意这两个操作;0"你好" 是示意值,假设 output 中的文本已经由适配器累积完成。两段代码分别表示发送端和读取端,不是完整请求程序:

// 适配器:交出这次新增的文本
stream.push({
	type: "text_delta",
	contentIndex: 0,
	delta: "你好",
	partial: output,
});

// 读取方:读到事件后显示文本
for await (const event of stream) {
	if (event.type === "text_delta") {
		process.stdout.write(event.delta);
	}
}

这条事件被读取后,终端显示 你好EventStream.push() 在读取方已经等待时直接交付事件,否则先存入队列,等读取方按顺序取出。push() 负责交付事件,文本的累积由前面的适配器代码完成,显示由读取方完成。

实际调用中,适配器与调用方持有的是内外两层事件流。适配器返回的事件流经由第二章介绍的 lazyStream() 转发机制逐条向调用方的 events 转发事件。第二节的循环每收到一个 text_delta,就立即把其中的新增字符串写到终端。

4. 已显示文字,还需要确认请求是否成功结束

text_delta 只说明有新增文本。请求可能在显示半句话后中断,因此不能把“有文字”当作成功依据。

下面展开第 3 节入口代码中的片段 [4]。成功分支位于 for await 循环之后,错误分支位于对应的 catch 中;两个分支不会依次执行。适配器在成功完成时产生 done 事件,失败时产生 error 事件。以下是 openai-completions.ts 中两个分支各自的收尾代码,省略了前面的结束检查和错误信息整理:

// 成功分支
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();

// catch 分支
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();

done / error 告知调用方请求结果,适配器随后调用 stream.end() 完成事件流收尾。

5. 运行 Lab 并查看输出

完整程序在 labs/04-streaming-text.ts,沿用第三章的百炼配置,改为逐行打印收到的文本分片。

沿用第三章配置好的依赖和 .env,在项目根目录运行:

node labs/04-streaming-text.ts

输入固定为:

用中文分三句话解释为什么流式回复能减少等待感。

下面是成功运行的输出示例,中间分片已省略,实际文本和分片数量会随响应变化:

input: 用中文分三句话解释为什么流式回复能减少等待感。
model: qwen3.8-flash
delta 1: "流"
delta 2: "式回复通过逐"
delta 3: "字或"
...
delta 18: "感。"
text_delta events: 18
chapter 4 streaming passed

每行 delta 展示本次到达的文本;按序连接全部分片就是回复全文。text_delta events 是分片总数,最后的 passed 表示文本非空且完成检查通过。观察相邻几行,可以看到分片长度并不固定;一次回复也可能只包含一个分片。

6. 本章小结

现在,同一套百炼配置可以让文字随响应分片持续出现:适配器把服务端新增文本转换成统一事件,调用方每收到一段就追加到终端。显示过程不再等待整条回复,请求中途失败也能明确报错。

对应源码:types.tsmodels.tsopenai-completions.tslazy.ts


系列总览:从一次模型调用到 Agent 内核:pi 设计学习指南

热门栏目