跳到主内容
创建时间:最后更新:作者:白川

流式对话:把运行事件接入界面

将智能体运行过程接入流式会话界面,区分文本增量、工具状态与最终结果,并定义取消与等待的行为。

流式对话:把运行事件接入界面

把一次智能体运行变成用户看得见的界面,难点不在于渲染文字,而在于把三类性质完全不同的东西分开:

  • 文本增量:模型正在逐字吐字,只有一个方向的追加。
  • 工具状态:工具还没跑完,但参数已经知道了,结果还不知道。
  • 最终结果:消息定稿、运行结束,这时候才有权威结论。

搞混这三类,最典型的表现是:工具卡片先显示了"成功",几秒后才蹦出真实输出;或者流式文本一直追加,最后却发现模型中途改了主意。

这篇给出三类东西各自的数据源、事件到界面状态的映射表,以及一个能跑的 HTTP + SSE 实现。读完你能写出一个不会提前宣布成功的流式界面。

需要的前置只有一条:知道 Pi 的会话对象可以订阅事件,并区分 message_update 与 message_end。

三类东西,三个不同的数据源

先把它们各自的来源讲清楚,后面映射表才有依据。

文本增量:message_update + text_delta

message_update 事件的类型是:

typescript
// message_update 的形状:外层是会话事件,内层是 assistant 消息内部发生了什么
// 只有 assistant 消息会发这个事件,别的角色不会
type MessageUpdateEvent = {
	type: "message_update";
	message: AgentMessage;
	assistantMessageEvent: AssistantMessageEvent;
};

只有 assistant 消息会发这个事件。嵌套的 assistantMessageEvent 是一组判别联合:

typescript
// 一条 assistant 消息内部会依次发出这些事件
// 三条内容线各自成对:text_* 是正文,thinking_* 是推理,toolcall_* 是工具请求
// 每个事件都带 contentIndex,指向它在 message.content 数组里的位置
export type AssistantMessageEvent =
	| { type: "start"; partial: AssistantMessage }
	| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
	| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
	| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
	| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
	| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
	| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
	| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
	| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
	| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
	| {
			type: "done";
			// stopReason 有四种:正常停、撞长度上限、要调工具、被推迟
			reason: Extract<StopReason, "stop" | "length" | "toolUse" | "deferred">;
			message: AssistantMessage;
	  }
	| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };

纯文本增量只需要认 text_delta:

typescript
// 纯文本增量只需要认 text_delta 一种事件,其它类型可以一概不处理
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
	process.stdout.write(event.assistantMessageEvent.delta);
}

这就是官方 SDK 示例 01-minimal.ts 的全部内容。

注意 contentIndex。 一个 assistant 消息可能有多个内容块(文本、思考、工具调用)。contentIndex 指向块在 content 数组里的位置。做多块渲染时,必须按 contentIndex 分桶,不能把所有 delta 拼成一条。

text_end 带的 content 是权威值。 官方 JSON 文档的建议是:缓冲 delta 用来做实时显示,但要用 text_end 的 content 替换掉重建出来的数据。同理 message_end 到来时,要用整条 message_end.message 替换掉整个流式消息。

为什么?因为 provider 的流可能和最终消息不一致。协议层的"最终一致"要靠 message_end 兜底。

工具状态:三个事件,三个时刻

工具执行有三个事件:

typescript
// 工具执行的三个事件,顺序固定:start →(若干 update)→ end
// start 就带 args:参数此时已校验完,界面可以先画出调用行,但结果还未知
type ToolExecutionEvents =
	| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
	// update 只在工具主动调 onUpdate 时才有;partialResult 是快照还是增量由工具自己约定
	| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
	| {
			type: "tool_execution_end";
			toolCallId: string;
			toolName: string;
			// 结果只在这里出现:界面的成功/失败必须等这个事件才能定
			result: any;
			isError: boolean;
			// 工具没跑起来时没有这个字段
			durationMs?: number;
	  };

三个关键事实:

参数在 tool_execution_start 就已经全都有了。 args 是模型生成的、已通过 schema 校验的参数。官方 TUI 就是靠它在工具真正跑起来之前就把调用行画出来。

结果只在 tool_execution_end 出现。 result 是完整的工具结果,isError 标记成功失败,durationMs 是执行耗时(用单调时钟测量,工具没跑起来时则没有这个字段)。

tool_execution_update 是可选的。 只有工具主动调用 onUpdate 才会发。官方 json.md 的原话是:

partialResult is the latest partial result supplied by the tool. Whether it replaces or extends an earlier update depends on that tool's result contract.

也就是说,增量是替换还是追加,取决于工具自己的约定。你的界面必须能处理两种:内置 bash 每次上报的是完整输出快照(替换),而自己写的流式工具可能上报的是新追加的片段。

一个稳妥的做法是:把 partialResult 当成最新快照来渲染,并在工具定义里明确约定快照语义。

用 toolCallId 关联三个事件。 这是唯一可靠的关联键。不要用工具名关联——一轮里可能有多个同名工具调用。

最终结果:message_end 与 agent_end

message_end 带的是定稿的完整消息,官方 SDK 文档的原话是:

message_end contains the authoritative completed message.

会话层的事件比核心层多几个,其中最关键的是 agent_end 和 agent_settled 的区别:

typescript
// 两个"运行结束"信号,含义完全不同
// agent_end 只标记一次底层运行结束,Pi 还可能自动重试或继续跑排队的工作
// agent_settled 才代表"Pi 不会自动继续了",界面放开输入框要等它
type RunLifecycleEvents =
	| {
			type: "agent_end";
			messages: AgentMessage[];
			// willRetry 为 true 表示后面还有自动重试要接上
			willRetry: boolean;
	  }
	| { type: "agent_settled"; aborted: boolean };

agent_end 只是一次底层运行结束了。官方 SDK 文档写得很清楚:

agent_end marks the end of one low-level agent run, but automatic recovery or queued work can still follow. Use agent_settled when the host needs to know that Pi will not continue automatically.

也就是说:agent_end 之后可能还有自动重试、溢出恢复、压缩、引导消息、后续消息。只有 agent_settled 才代表"Pi 不会再自动继续了"。

界面上这个区别很实际:agent_end 时按钮应该还能点(可能马上又开始跑),agent_settled 时才真正可以发下一条消息。

agent_settled 的 aborted 字段则告诉你这次是不是被人为取消的。

事件到界面状态的映射表

这是本篇的核心。左边是事件,右边是界面该怎么变:

事件界面状态变化数据来源注意
agent_start
进入"运行中",清空上一轮的进度显示
—
会话级运行开始
turn_start
本轮开始,可显示"正在思考"
—
一轮 = 一次 assistant 响应 + 其工具调用
message_start(assistant)
新建一条空的 assistant 消息
event.message
此时内容可能还是空的
message_update + text_delta
往对应 contentIndex 的文本块追加
assistantMessageEvent.delta
按 contentIndex 分桶
message_update + text_end
用权威内容替换该文本块
assistantMessageEvent.content
替换,不是追加
message_update + toolcall_start
可以在消息里显示"准备调用某工具"
event.message.content 里的 toolCall
参数此时可能还不完整
message_end(assistant)
消息定稿,用它整体替换流式内容
event.message
权威来源
message_start/message_end(toolResult)
新增一条工具结果消息
event.message
角色是 toolResult
tool_execution_start
建工具卡片,标题用工具名,参数用 args
toolName、args
参数此时已知,结果未知
tool_execution_update
更新卡片的输出区
partialResult
语义由工具约定,可能替换也可能追加
tool_execution_end
定稿工具卡片,显示结果与耗时
result、isError、durationMs
isError 为 true 时要区别样式
turn_end
本轮结束,可统计工具调用数
toolResults 数组
toolResults.length 是本轮工具数
agent_end
底层运行结束,仍可能自动继续
willRetry 为 true 表示还要重试
不要在这里放开输入框
agent_settled
真正结束,可放开输入框
aborted 标记是否被取消
这是"运行结束"的准信号
queue_update
更新待发的引导/后续消息提示
steering、followUp
两个数组都是完整队列
compaction_start
显示"正在压缩上下文"
reason
manual / threshold / overflow
compaction_end
收起压缩提示
result、aborted、willRetry
失败时 errorMessage 有值
auto_retry_start
显示"正在重试"
attempt、maxAttempts、delayMs
provider 错误触发
auto_retry_end
收起重试提示
success、finalError
最终失败时 finalError 有值

最小实现:一个 Node HTTP + SSE 服务

有了映射表,实现就是照着填。下面这份代码用 SSE 把事件推给浏览器。

typescript
import { createServer } from "node:http";
import {
	createAgentSession,
	SessionManager,
	type AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";

/** 每个连接的界面状态。 */
interface ClientState {
	/** 当前 assistant 消息里各内容块的文本,按 contentIndex 分桶。 */
	blocks: Map<number, string>;
	/** 正在运行的工具调用。 */
	tools: Map<string, { toolName: string; args: unknown; output: string; isError: boolean }>;
}

const clients = new Set<{ res: import("node:http").ServerResponse; state: ClientState }>();

/** 把会话事件转换成界面状态变更。 */
function applyEvent(state: ClientState, event: AgentSessionEvent): void {
	switch (event.type) {
		case "message_update": {
			const inner = event.assistantMessageEvent;
			if (inner.type === "text_delta") {
				const prev = state.blocks.get(inner.contentIndex) ?? "";
				state.blocks.set(inner.contentIndex, prev + inner.delta);
			} else if (inner.type === "text_end") {
				// text_end 带的 content 是权威值,替换掉累积的 delta
				state.blocks.set(inner.contentIndex, inner.content);
			}
			break;
		}
		case "tool_execution_start": {
			// 参数此刻已经确定,结果还没有
			state.tools.set(event.toolCallId, {
				toolName: event.toolName,
				args: event.args,
				output: "",
				isError: false,
			});
			break;
		}
		case "tool_execution_update": {
			const tool = state.tools.get(event.toolCallId);
			if (tool) {
				const partial = event.partialResult as { content?: Array<{ type: string; text?: string }> };
				const text = partial?.content?.find((c) => c.type === "text")?.text ?? "";
				// 按快照语义替换
				tool.output = text;
			}
			break;
		}
		case "tool_execution_end": {
			const tool = state.tools.get(event.toolCallId);
			if (tool) {
				const result = event.result as { content?: Array<{ type: string; text?: string }> };
				tool.output = result?.content?.find((c) => c.type === "text")?.text ?? tool.output;
				tool.isError = event.isError;
			}
			break;
		}
	}
}

const { session } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});

const unsubscribe = session.subscribe((event) => {
	for (const client of clients) {
		applyEvent(client.state, event);
		// SSE 帧格式:data: 后跟单行 JSON,空行结束一帧
		client.res.write(`data: ${JSON.stringify(event)}\n\n`);
	}
});

const server = createServer(async (req, res) => {
	if (req.method === "GET" && req.url === "/events") {
		res.writeHead(200, {
			"Content-Type": "text/event-stream",
			"Cache-Control": "no-cache",
			Connection: "keep-alive",
		});
		res.write(": connected\n\n");
		clients.add({ res, state: { blocks: new Map(), tools: new Map() } });
		req.on("close", () => {
			for (const client of clients) {
				if (client.res === res) clients.delete(client);
			}
		});
		return;
	}

	if (req.method === "POST" && req.url === "/prompt") {
		const body = await readBody(req);
		try {
			await session.prompt(body);
		} catch (error) {
			res.writeHead(500, { "Content-Type": "application/json" });
			res.end(JSON.stringify({ error: String(error) }));
			return;
		}
		res.writeHead(200, { "Content-Type": "application/json" });
		res.end(JSON.stringify({ ok: true }));
		return;
	}

	if (req.method === "POST" && req.url === "/abort") {
		// abort 会停掉当前操作并等待会话空闲
		await session.abort();
		res.writeHead(200, { "Content-Type": "application/json" });
		res.end(JSON.stringify({ ok: true }));
		return;
	}

	res.writeHead(404).end();
});

/** 读出请求体。 */
async function readBody(req: import("node:http").IncomingMessage): Promise<string> {
	const chunks: Buffer[] = [];
	for await (const chunk of req) chunks.push(chunk as Buffer);
	return Buffer.concat(chunks).toString("utf-8");
}

// 进程退出时释放资源
const shutdown = async () => {
	server.close();
	unsubscribe();
	session.dispose();
};
process.on("SIGINT", () => {
	void shutdown().then(() => process.exit(0));
});
process.on("SIGTERM", () => {
	void shutdown().then(() => process.exit(0));
});

server.listen(3000, () => {
	console.log("listening on http://localhost:3000");
});

三处释放逻辑值得单独说:

unsubscribe() 取消事件订阅。 session.subscribe 返回一个取消函数。不调用它,闭包会一直持有 clients 集合。

session.dispose() 释放会话。 它会中止在跑的工作、断开与 agent 的连接、清空监听器。

server.close() 停掉 HTTP 服务。 否则进程不会退出。

process.on("SIGINT") 是为了保证 Ctrl+C 时这三件事都做掉。少任何一件,要么进程挂住,要么连接泄漏。

取消按钮和等待按钮

界面上的两个按钮对应两个不同的方法,别用混。

typescript
import type { AgentSession } from "@earendil-works/pi-coding-agent";

/** 取消按钮:停掉当前操作,并等待会话进入空闲。 */
async function onCancelClick(session: AgentSession): Promise<void> {
	// abort 会中断运行中的模型请求和工具执行
	await session.abort();
}

/** 等待按钮:只等待,不取消。 */
async function onWaitClick(session: AgentSession): Promise<void> {
	// waitForIdle 不打断任何东西:模型会继续跑完,你只是在等
	await session.waitForIdle();
}

abort() 的实现是三步:设置中止标志、调用 agent.abort() 触发运行中的 AbortSignal、然后 await this.waitForIdle()。

waitForIdle() 的语义更微妙,源码注释写着:

typescript
class AgentSession {
	/**
	 * Whether the session has no active agent run, compaction, branch summary, retry, or queued continuation.
	 */
	get isIdle(): boolean {
		return !this._isAgentRunActive && !this.isCompacting;
	}
}

它不只看"当前运行",还包括压缩、分支摘要、重试和排队的后续工作。所以"等它跑完"用 waitForIdle() 是对的。

一个常见错误是用 waitForIdle() 来代替取消。 waitForIdle() 不会中断任何东西——模型会继续跑完,工具会继续执行。你只是在等它跑完。

浏览器端的一个最小消费者

SSE 的浏览器端比想象中简单,EventSource 内置支持:

html
<!doctype html>
<html lang="zh">
	<head>
		<meta charset="utf-8" />
		<title>Pi 流式会话</title>
	</head>
	<body>
		<pre id="log"></pre>
		<form id="form">
			<input id="input" autocomplete="off" placeholder="输入任务…" />
			<button type="submit">发送</button>
			<button type="button" id="abort">取消</button>
		</form>
		<script type="module">
			const log = document.getElementById("log");
			const input = document.getElementById("input");

			const source = new EventSource("/events");
			source.onmessage = (event) => {
				const payload = JSON.parse(event.data);
				// 文本增量
				if (payload.type === "message_update" && payload.assistantMessageEvent?.type === "text_delta") {
					log.textContent += payload.assistantMessageEvent.delta;
				}
				// 工具开始:参数已知,结果未知
				if (payload.type === "tool_execution_start") {
					log.textContent += `\n[工具开始] ${payload.toolName} ${JSON.stringify(payload.args)}\n`;
				}
				// 工具结束:结果已知
				if (payload.type === "tool_execution_end") {
					log.textContent += `[工具结束] ${payload.toolName} isError=${payload.isError} 耗时=${payload.durationMs ?? "-"}ms\n`;
				}
				// 真正结束
				if (payload.type === "agent_settled") {
					log.textContent += payload.aborted ? "\n[已取消]\n" : "\n[已完成]\n";
				}
			};

			document.getElementById("form").addEventListener("submit", async (e) => {
				e.preventDefault();
				await fetch("/prompt", { method: "POST", body: input.value });
				input.value = "";
			});
			document.getElementById("abort").addEventListener("click", async () => {
				await fetch("/abort", { method: "POST" });
			});
		</script>
	</body>
</html>

宿主负责什么

职责由谁承担具体表现
事件订阅时机
宿主
必须在 prompt() 之前订阅,否则会漏掉开头的事件
界面状态归并
宿主
按 contentIndex 分桶、按 toolCallId 关联
权威内容替换
宿主
text_end 替换 delta,message_end 替换流式消息
增量的替换/追加语义
宿主 + 工具
界面按快照处理,工具负责约定语义
取消与等待
宿主
取消按钮接 abort(),等待接 waitForIdle()
结束信号的选择
宿主
用 agent_settled 判断真正结束
连接与资源释放
宿主
unsubscribe() + session.dispose() + server.close()
背压与限流
宿主
高频 message_update 要合并渲染,别每个 delta 一次 DOM 更新

第一行和倒数第二行最容易出问题。订阅必须在 prompt() 之前。 官方 RPC 文档有一段专门的提醒:

Subscribe before sending a prompt to avoid missing a fast completion.

模型响应快的话,prompt() 返回前事件就已经发完了。晚订阅会丢掉整段流式输出。

关于背压:message_update 在长回复里可能每秒几十次。浏览器端应该把 DOM 更新合并到动画帧里,而不是每个 delta 改一次 DOM。

三种场景的可观察结果

场景触发条件界面表现宿主观察到什么
成功
模型正常回复
文本逐字出现,最后一条消息定稿
message_end 后收到 agent_end,再收到 agent_settled(aborted 为 false)
失败
provider 报错或工具报错
工具卡片标红;provider 错误走 auto_retry_* 提示
auto_retry_start → auto_retry_end(success 为 false 时有 finalError)
取消
用户点了取消
保留已收到的文本,末尾标注"已取消"
agent_settled 的 aborted 为 true

取消场景有个容易忽略的细节:已经流出来的文本要留着。 用户取消不代表之前的输出无效。官方 TUI 就是这么做的——它会显示错误信息但保留已渲染的内容。

你现在能做什么

  • 写出一个订阅器,按 contentIndex 分桶累积 text_delta,并在 text_end 到达时用权威内容替换累积值。
  • 在工具卡片上验证三件事:tool_execution_start 时参数已可显示、tool_execution_update 只更新输出区、tool_execution_end 才定稿成功失败。
  • 用 toolCallId 关联同一次工具调用的三个事件,跑一轮并行工具调用确认没有串。
  • 用 agent_settled(而不是 agent_end)决定何时放开输入框,并用它的 aborted 字段区分正常结束与被取消。
  • 把上面的 SSE 服务跑起来,用浏览器端消费者验证流式文本、工具卡片和取消按钮的行为,并确认 Ctrl+C 时进程能干净退出。

排障速查

现象检查点处理
界面什么都不显示
订阅时机
subscribe() 必须在 prompt() 之前
流式文本和最终消息不一致
是否用了权威值替换
text_end 的 content 替换 delta,message_end.message 替换整条流式消息
工具卡片参数显示为空
用了哪个事件
tool_execution_start 的 args 才有参数
工具卡片先显示成功后来又变了
提前用了 result
结果只在 tool_execution_end 出现
多个同工具调用串了
关联键
必须用 toolCallId,不能用 toolName
增量内容重复出现
替换还是追加搞混
partialResult 按快照处理;若工具上报的是片段,需要工具自己约定
输入框提前放开
用了哪个结束事件
要等 agent_settled,不是 agent_end
点取消没反应
调用的方法
abort() 才是取消,waitForIdle() 只是等待
进程退不掉
释放逻辑
unsubscribe() + session.dispose() + server.close() 三件都要做
SSE 连上就断
响应头
必须 Content-Type: text/event-stream 和 Cache-Control: no-cache
长回复时界面卡顿
渲染频率
合并 message_update,别每个 delta 一次 DOM 更新

参考资料