从零开发一个 Coding Agent(三):EventStream 事件流通道设计与实现

本篇文章是《从零开发一个Coding Agent》系列第三篇,主要介绍Agent的事件流机制以及实现一个事件流(EventStream)通道。

我们都知道,当调用大模型 API 时,通常开启流式响应(Streaming)。大模型会通过 SSE(Server-Sent Events)协议 持续返回数据块。每个 SSE 消息的 data 字段中携带一个 JSON 字符串 作为载荷,不同厂商返回的 JSON 结构(如字段名 deltacontent)有所不同。以文本生成为例,当你问'今天天气怎么样?',大模型会通过 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 获取原始数据),消费者是 Agentfor 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 一个事件对象。当事件对象的 typedone 时,循环便会结束。

接下来我们开始一步一步实现 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消费者类型,来处理异步迭代器的 resolvereject 操作,以及 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 });
      }
    }
  }

这里的 waiterAgent 在调用 nextEvent() 时被挂起的等待凭证,它包含了唤醒 Agent 所需的 resolvereject 方法。如

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 方法负责把数据取出来。它们通过 queuewaiters 这两个内部数组联动:当消费者调用 nextEvent() 且有数据时,直接返回;没有数据时,把 resolve 方法 存入 waiters,等 push 来唤醒。这样两端就在时间上完全解耦了

实现[Symbol.asyncIterator]()方法

当我们for await...of实例化EventStream的时候,就会调用到[Symbol.asyncIterator]()方法,这个方法会返回一个包含next函数的异步迭代器对象,next函数会返回一个Promise对象,Promise对象会解析为一个包含valuedone属性的对象,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 事件流通道。让我们回顾一下它的核心设计:

  1. 它解决了什么问题?

在大模型流式响应中,生产者(API 适配器)推送数据的速度消费者(Agent 循环)消费数据的速度 往往不匹配。EventStream 通过一个内存中的缓冲通道,将两端在时间上彻底解耦:

  • 生产者只需调用 push(event),不需要关心消费者是否准备好。
  • 消费者只需写 for await...of,不需要关心数据是"立即到达"还是"稍后到达"。
  1. 它的内部是如何工作的?
组件 作用 对应变量
事件队列 事件缓冲区。当生产者快于消费者时,事件暂存于此,等消费者空闲时逐个取出。 queue
消费者等待队列 当消费者快于生产者时,nextEvent()resolve 被挂起在这里,等生产者 push 后唤醒。 waiters
流结束标志 当收到终结事件(done)时置为 true,确保下一次 next() 返回 done: true,循环优雅退出。 terminal
故障状态 当 API 报错或网络中断时记录错误,后续所有 next() 直接拒绝,实现"快速失败"。 failure
最终结果 Promise 流结束后,通过 getResult 提取的统计信息(如 token 消耗、结束原因)由 result() 方法返回,与循环体互不干扰。 finalResultPromise

本章节git分支EventStream

相关推荐
卫子miao1 小时前
如何评价当前大语言模型的记忆机制?
后端·架构
中微极客1 小时前
降维算法75倍加速:从PCA到稀疏字典学习的工程实践
人工智能·学习·算法
代码青铜1 小时前
三步给 Codex 接上一个真正的后端:无需写代码,让 AI 自动搭建完整应用
人工智能
神奇小汤圆2 小时前
为什么 AQS 成为 Java 并发的基石?
后端
程序员黑豆2 小时前
鸿蒙应用开发:Refresh + List 下拉刷新组件使用教程
前端·华为·harmonyos
Hilaku2 小时前
为什么大厂对前端算法要求极高?
前端·javascript·程序员
文心快码BaiduComate2 小时前
从“提示词工程”到“技能工程”:Comate 创建Agent Skills 实战
人工智能
星栈2 小时前
MCP 从 stdio 迁到 SSE,踩了 5 个传输层坑
人工智能·后端·架构
Conan在掘金2 小时前
鸿蒙报错速查:struct 里嵌 enum 声明就炸,根因 + 真解法
后端