本篇文章是《从零开发一个Coding Agent》系列第三篇,主要介绍Agent的事件流机制以及实现一个事件流(EventStream)通道。
我们都知道,当调用大模型 API 时,通常开启流式响应(Streaming)。大模型会通过 SSE(Server-Sent Events)协议 持续返回数据块。每个 SSE 消息的 data 字段中携带一个 JSON 字符串 作为载荷,不同厂商返回的 JSON 结构(如字段名 delta 或 content)有所不同。以文本生成为例,当你问'今天天气怎么样?',大模型会通过 SSE(Server-Sent Events)协议 不断返回数据块,格式类似:
json
// 第一条 SSE 消息
data: {
"id": "chatcmpl-123",
"object": "chat.completion.chunk",
"created": 1694268190,
"model": "gpt-4",
"choices": [
{
"index": 0,
"delta": {
"content": "今天"
},
"finish_reason": null
}
]
}
// 第二条 SSE 消息
data: {
"id": "chatcmpl-123",
"object": "chat.completion.chunk",
"created": 1694268190,
"model": "gpt-4",
"choices": [
{
"index": 0,
"delta": {
"content": "天气"
},
"finish_reason": null
}
]
}
// 第三条 SSE 消息
data: {
"id": "chatcmpl-123",
"object": "chat.completion.chunk",
"created": 1694268190,
"model": "gpt-4",
"choices": [
{
"index": 0,
"delta": {
"content": "晴朗"
},
"finish_reason": null
}
]
}
//等等等等
"注:为了便于大家理解,本节仅以文本增量(text_delta)事件举例。在实际的 Coding Agent 项目中,流中还会包含 tool_call(工具调用)和 done(结束标志)等事件,我们将在后续的适配器实操章节中统一处理。"
后续我们会加个统一的适配器,将不同厂商的事件流适配为,返回一个统一的事件流格式,例如
json
{
"type": "text_delta",
"contentIndex": 0,
"delta": "今天"
}
{
"type": "text_delta",
"contentIndex": 0,
"delta": "天气"
}
{
"type": "text_delta",
"contentIndex": 0,
"delta": "晴朗"
}
//等等等等
拿到结果之后,我们的Agent就可以根据事件流格式,来处理结果。比如渲染到终端,调用工具等等。
生产者和消费者
了解了上面的内容,聪明的你一定已经知道了,事件流(EventStream)通道其实就是一个生产者-消费者模型。在这个模型中,生产者是负责调用 push() 将事件注入通道的适配器代码(它从大模型 API 获取原始数据),消费者是 Agent 的 for await...of 循环。。生产者会不断生产事件比如我们去请求大模型api,它会不断返回今天,天气,晴朗等。消费者会不断消费事件,拿到一个个的事件不断的渲染在终端界面。接下来我们就开始实现一个事件流(EventStream)通道。
实现事件流(EventStream)通道
先简单了解一下我们最终实现的是什么:
EventStream 是一个
实时数据通道。它把大模型推送数据和Agent 消费数据这两件异步发生的事,通过同一个内存对象无缝衔接起来,让 Agent 可以用简单的for await...of循环,像读数组一样读取实时流。
在开始实现之前,我们先明确一个关键概念:为了让 EventStream 类能够被 for await...of 循环遍历,它必须是一个异步可迭代对象(AsyncIterable)。我们需要在类上实现 [Symbol.asyncIterator] 方法,该方法返回一个异步迭代器对象(包含 next(): Promise<...> 方法)。当 for await...of 循环遍历 EventStream 实例时,会调用 [Symbol.asyncIterator] 方法,返回一个异步迭代器对象。异步迭代器对象的 next() 方法会返回一个 Promise,该 Promise 会最终 resolve 一个事件对象。当事件对象的 type 为 done 时,循环便会结束。
接下来我们开始一步一步实现 EventStream 类。首先在packages/ai/src/utils目录下新建一个event-stream.ts文件。
定义选项类型
首先我们定义实例化这个类的时候传入的选项EventStreamOptions类型
js
interface EventStreamOptions<TEvent,TResult> {
validate(event: TEvent): void;
isTerminal(event: TEvent): boolean;
getResult(event: TEvent): TResult;
}
这样我们就可以在实例化 EventStream 类时,传入一个选项对象,来定义事件流的行为。比如我们可以验证事件是否符合预期的格式,或者判断事件是否结束,或者流结束获取最终结果。
然后再继续写下 Waiter消费者类型,来处理异步迭代器的 resolve 和 reject 操作,以及 normalizeError 函数,来将未知的错误对象统一转换为 Error 类型。
ts
interface Waiter<TEvent> {
resolve(result: IteratorResult<TEvent>): void;
reject(cause: unknown): void;
}
function normalizeError(cause: unknown): Error {
return cause instanceof Error ? cause : new Error(String(cause));
}
加入类字段和构造器
接下来我们开始定义 EventStream 类。因为后续我们会支持多家api的事件类型TEvent)以及最终返回结果TResult(包含总 token 数、结束原因、耗时等等参数),所以这里以泛型的形式定义。上面提到我们这个类是一个异步可迭代对象(AsyncIterable),所以这里基于 AsyncIterable 接口来实现。
ts
export class EventStream<TEvent, TResult> implements AsyncIterable<TEvent> {
// 事件流选项
private readonly options: EventStreamOptions<TEvent, TResult>;
// 事件队列
private readonly queue: TEvent[] = [];
// 消费者等待队列
private readonly waiters: Waiter<TEvent>[] = [];
// 最终结果Promise
private readonly finalResultPromise: Promise<TResult>;
// 解析最终结果
private resolveFinalResult!: (result: TResult) => void;
// 拒绝最终结果
private rejectFinalResult!: (cause: unknown) => void;
// 流是否结束
private terminal = false;
// 错误对象
private failure?: Error;
// 迭代器是否已启用(每个实例只能一个迭代器在运行)
private iteratorClaimed = false;
constructor(options: EventStreamOptions<TEvent, TResult>) {
this.options = options;
//当调用result方法时,会返回最终结果Promise,当流结束时,什么时候执行this.resolveFinalResult方法什么时候就会返回结果
this.finalResultPromise = new Promise<TResult>((resolve, reject) => {
this.resolveFinalResult = resolve;
this.rejectFinalResult = reject;
});
void this.finalResultPromise.catch(() => undefined);
}
[Symbol.asyncIterator](): AsyncIterator<any> {
return {
async next() {
return { value: '', done: false };
}
};
}
// 获取最终结果
result(): Promise<TResult> {
return this.finalResultPromise;
}
}
注意: 这里我们暂时先加 [Symbol.asyncIterator]()防止报错,后面会加具体实现。
void this.finalResultPromise.catch(() => undefined);这行代码只标记内部 Promise 已有 rejection handler,防止调用方只迭代、不调用 result() 时出现未处理 rejection 警告。result() 仍返回原 Promise,所以故障仍会 reject,不会被吞掉。
在类中加入 push 方法
在 EventStream 类中,我们需要一个方法来将事件添加到事件队列中。这个方法就是 push 方法。理解起来很简单,就是在请求大模型api时,我们会调用这个方法,将返回的事件一个个添加到事件队列中。但是在此之前我们需要加一系列的判断,先加一个函数assertCanPush,来判断是否可以添加事件。在有失败原因和流已经结束的时候直接抛出错误
js
private assertCanPush(): void {
if (this.failure) {
throw this.failure;
}
if (this.terminal) {
throw new Error("EventStream is already settled");
}
}
再加一个fail方法用于处理错误情况,当大模型 API 返回错误码、网络中断或发生其他不可恢复的异常时,我们调用 fail 方法来处理。该方法会把错误对象存储在专用的failure 字段中,并立即拒绝所有正在等待数据的消费者(waiters)。同时,它还会调用 rejectFinalResult,让最终结果 Promise 也变为 rejected 状态。
ts
fail(cause: unknown): void {
if (this.failure) {
return;
}
if (this.terminal) {
throw new Error("EventStream is already settled");
}
const error = normalizeError(cause);
this.failure = error;
this.rejectFinalResult(error);
while (this.waiters.length > 0) {
const waiter = this.waiters.shift();
waiter?.reject(error);
}
}
接下来就可以实现push方法了
ts
push(event: TEvent): void {
//如果不通过会报错
this.assertCanPush();
//流结束的最终结果
let terminalResult: { value: TResult } | undefined;
try {
//校验事件是否合法
this.options.validate(event);
//如果流事件结束,则获取最终结果
if (this.options.isTerminal(event)) {
terminalResult = { value: this.options.getResult(event) };
}
} catch (cause) {
const error = normalizeError(cause);
//如果出错,调用fail方法reject正在等待的waiter以及获取最终结果的Promise
this.fail(error);
//抛出错误
throw error;
}
if (terminalResult) {
this.terminal = true;
//resolve最终结果Promise
this.resolveFinalResult(terminalResult.value);
}
const waiter = this.waiters.shift();
if (waiter) {
//如果有等待的消费者,resolve事件直接给它
waiter.resolve({ value: event, done: false });
} else {
//如果没有等待的消费者,将事件添加到队列中
this.queue.push(event);
}
//这个 while 循环在标准的单消费者场景下不会执行,它用于防御极端并发场景,确保没有等待者被遗漏。这是一个防御性编程的典型实践。
if (terminalResult) {
while (this.waiters.length > 0) {
const pendingWaiter = this.waiters.shift();
pendingWaiter?.resolve({ value: undefined, done: true });
}
}
}
这里的 waiter 是 Agent 在调用 nextEvent() 时被挂起的等待凭证,它包含了唤醒 Agent 所需的 resolve 和 reject 方法。如
ts
// 假设已定义好 options(包含 validate、isTerminal、getResult)
const stream = new EventStream<MyEvent, MyResult>(options);
for await (const event of stream) {
console.log(event);
}
就会调用到[Symbol.asyncIterator]()方法,接下来我们开始实现[Symbol.asyncIterator]()方法了
到这里,push 方法负责把数据塞进去,而 nextEvent 方法负责把数据取出来。它们通过 queue 和 waiters 这两个内部数组联动:当消费者调用 nextEvent() 且有数据时,直接返回;没有数据时,把 resolve 方法 存入 waiters,等 push 来唤醒。这样两端就在时间上完全解耦了
实现[Symbol.asyncIterator]()方法
当我们for await...of实例化EventStream的时候,就会调用到[Symbol.asyncIterator]()方法,这个方法会返回一个包含next函数的异步迭代器对象,next函数会返回一个Promise对象,Promise对象会解析为一个包含value和done属性的对象,value是事件,done是布尔值,如果事件是最后一个事件,done为true,就会停止循环。
ts
[Symbol.asyncIterator](): AsyncIterator<TEvent> {
if (this.iteratorClaimed) {
throw new Error("EventStream supports exactly one async iterator");
}
//设置迭代器已被调用,防止重复调用
this.iteratorClaimed = true;
return {
next: () => this.nextEvent(),
};
}
实现nextEvent方法:
ts
private nextEvent(): Promise<IteratorResult<TEvent>> {
//如果有事件在队列中,直接取出第一个事件resolve
if (this.queue.length > 0) {
const queuedEvent = this.queue.shift() as TEvent;
return Promise.resolve({ value: queuedEvent, done: false });
}
//如果有错误,拒绝Promise
if (this.failure) {
return Promise.reject(this.failure);
}
//如果流事件结束,resolve为undefined,done为true,结束循环
if (this.terminal) {
return Promise.resolve({ value: undefined, done: true });
}
//如果没有事件队列为空,保存waiter的resolve和reject方法,当事件push的时候调用resolve即可继续循环
return new Promise<IteratorResult<TEvent>>((resolve, reject) => {
this.waiters.push({ resolve, reject });
});
}
到这里就实现了EventStream,最后在ai/src/index.ts中引入导出EventStream:
ts
export { EventStream } from "./utils/event-stream.ts";
总结
至此,我们完整实现了一个 EventStream 事件流通道。让我们回顾一下它的核心设计:
- 它解决了什么问题?
在大模型流式响应中,生产者(API 适配器)推送数据的速度 和 消费者(Agent 循环)消费数据的速度 往往不匹配。EventStream 通过一个内存中的缓冲通道,将两端在时间上彻底解耦:
- 生产者只需调用
push(event),不需要关心消费者是否准备好。 - 消费者只需写
for await...of,不需要关心数据是"立即到达"还是"稍后到达"。
- 它的内部是如何工作的?
| 组件 | 作用 | 对应变量 |
|---|---|---|
| 事件队列 | 事件缓冲区。当生产者快于消费者时,事件暂存于此,等消费者空闲时逐个取出。 | queue |
| 消费者等待队列 | 当消费者快于生产者时,nextEvent() 的 resolve 被挂起在这里,等生产者 push 后唤醒。 |
waiters |
| 流结束标志 | 当收到终结事件(done)时置为 true,确保下一次 next() 返回 done: true,循环优雅退出。 |
terminal |
| 故障状态 | 当 API 报错或网络中断时记录错误,后续所有 next() 直接拒绝,实现"快速失败"。 |
failure |
| 最终结果 Promise | 流结束后,通过 getResult 提取的统计信息(如 token 消耗、结束原因)由 result() 方法返回,与循环体互不干扰。 |
finalResultPromise |
本章节git分支EventStream