1. 项目概述:为什么文件搜索场景需要fetchEventSource
先说结论:做AI智能助手,尤其是带文件搜索能力的助手,最容易被低估的就是“流式响应”这块。很多人把精力全扑在模型选型、Prompt编排、向量化召回上,结果一联调才发现,助手回答问题时前端要么转圈等到超时,要么一次性吐出一大段文字毫无交互感,体验非常糟糕。
这个项目要解决的核心问题很简单:当用户问“帮我找一下去年Q3的季度汇报PPT”,助手需要边搜索文件、边告诉用户“正在扫描本地目录…”“命中3个候选文件…”“正在生成摘要…”,最后把答案和文件路径一起流式返回给前端。 整个过程不能是“憋大招”式的等待,而应该是像打字机一样持续输出中间状态,用户随时能感知到系统在工作。
我选用了 fetchEventSource 作为这个场景的通信底座。它是微软开源的SSE(Server-Sent Events)客户端库,相比原生 EventSource,它额外支持了POST请求、自定义Headers、请求中断等能力,特别适合AI对话这种“客户端发指令、服务端持续回传”的交互模型。在文件搜索场景里,服务端要分阶段推送“扫描进度”“命中结果”“摘要生成”等事件,fetchEventSource天然就是干这个的。
这篇文章不是泛泛讲概念,我会把整个项目的实现链路拆开:从技术选型的原因、服务端事件流的协议设计、前端如何优雅消费事件流,到文件搜索这个场景里特有的坑(路径编码、权限问题、中文文件名乱码等),全部用实际代码和踩坑记录来说话。适合正在做AI助手类产品、或者想把SSE落地到生产环境的后端和前端同学参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案选型:不止是fetchEventSource,整个链路都要选对
2.1 为什么是SSE而不是WebSocket
在动手写代码之前,有个技术决策必须想清楚:AI助手和文件搜索场景的消息通道,到底是选SSE还是WebSocket?我见过不少团队一上来就上WebSocket,理由是“WebSocket更现代、支持双向通信”,但在这个场景里属于杀鸡用牛刀,还平白引入了一堆复杂度。
SSE和WebSocket的核心差异在于连接方向。WebSocket是双向全双工通道,客户端和服务端可以随时互发消息,适合在线协作编辑、实时游戏这类需要频繁双向交互的场景。但AI助手对话本质上是一个典型的“半双工”模型:客户端发一次请求,服务端持续回传多轮消息,中间不需要客户端再主动插入其他指令。文件搜索也是同理——用户提交搜索条件后,系统持续反馈进度和结果,用户不需要在流式传输过程中再发第二条消息。
从实现成本看,SSE建立在普通HTTP之上,服务端只需设置 Content-Type: text/event-stream 响应头,就可以一段一段往外写数据。而WebSocket需要先握手升级协议,服务端要维护长连接状态、处理心跳保活,前端还要考虑断线重连逻辑。在文件搜索这种“请求-响应流”模型里,SSE的简单直接是压倒性的优势。
还有一个很实际的点:SSE可以享受HTTP层现成的能力,比如自动重连(EventSource 内置)、自定义Header(配合鉴权)、通过标准HTTP状态码表达错误。WebSocket虽然也有子协议机制,但调试起来明显更麻烦,浏览器DevTools对SSE的EventStream支持也更直观,一行一条消息清清楚楚。
2.2 为什么选中fetchEventSource而不是原生EventSource
既然定了SSE,下一个问题就是用原生 EventSource 还是 fetchEventSource。原生 EventSource 用起来确实简单,三行代码就能接上:
javascript复制const es = new EventSource('/api/search');
es.onmessage = (event) => {
console.log(event.data);
};
但真把它放到AI助手的文件搜索场景里,马上就会撞上三堵墙。
第一堵墙:原生 EventSource 只支持GET请求。文件搜索往往需要传递复杂的查询条件(关键词、文件类型、时间范围、目录路径),用GET就得把这些参数拼到URL上,既受URL长度限制,又会把搜索条件暴露在访问日志里。更麻烦的是,有些接口依赖POST body传JSON结构,原生方案直接没戏。
第二堵墙:自定义Headers受限。AI助手基本都要鉴权,常见的做法是通过 Authorization: Bearer <token> 头传身份凭证。原生 EventSource 压根没法设置这个Header,只能用Cookie或者把token拼在URL上,既不安全又容易出问题。fetchEventSource基于 fetch API实现,Header、Body、Credentials等全是标准fetch配置,无缝对接现有的鉴权体系。
第三堵墙:中断控制。用户在等AI助手搜索文件时,如果发现搜索结果不对,或者等得不耐烦了,会直接点击“停止生成”。原生 EventSource 虽然有 close() 方法,但无法精细控制请求的中止信号(AbortSignal)。fetchEventSource支持传入 signal,通过 AbortController 可以随时中断连接,服务端也能立刻感知到客户端断开。
所以在这个项目里,我不考虑原生方案,直接锁定 @microsoft/fetch-event-source。这个库的API设计得很克制,核心就是 fetchEventSource(url, options),但它把SSE生命周期拆得很细,下面会重点讲。
2.3 架构总览:一条从“用户提问”到“文件搜索+AI生成”的事件链路
整个文件搜索助手的架构可以分成三层:前端交互层、服务端编排层、文件检索层。
前端交互层负责采集用户输入(自然语言),渲染流式返回的中间状态。用户可能说“帮我找合同里关于违约金的条款”,前端要把这句话转成一个结构化的搜索任务,再通过fetchEventSource发送到服务端。
服务端编排层是这个项目的核心,它接收前端的POST请求后,负责拆解任务:先调用文件检索服务扫描目标目录,再把命中的文件路径、片段交给AI模型生成摘要,整个过程通过SSE持续推送事件给前端。这一层我要重点设计“事件协议”,因为AI对话流和文件搜索流之间不是串行关系,搜索过程中随时可能穿插AI的“思考”“判断”事件。
文件检索层可以做得简单也可以做得复杂。这个项目里我采用的是“目录扫描 + 文件名/内容关键词匹配”的方式,没有上向量检索,毕竟本质是本地文件搜索,先把链路跑通、把体验调顺才是重点。如果你要搜索的内容是PDF、Office文档里的正文,可以做一层文本抽取器(比如 pdf-parse + mammoth),但这不是这个项目的重点。
事件链路大体是这样的:
- 前端POST请求到
/api/assistant/search,携带用户问题和搜索范围。 - 服务端立即返回SSE响应,推送
search.started事件。 - 服务端扫描目录,每扫完一个子目录推送一条
search.progress事件,携带进度百分比。 - 命中文件后推送
search.match事件,携带文件路径和基础元信息。 - 全部扫描完成,推送
search.completed事件,携带匹配总数。 - AI模型根据文件名、摘要、路径生成最终回答,推送
ai.answer事件,内容可能是多个chunk分片推送。 - 最后推送
done事件,通知前端关闭连接。
这条链路我实际跑下来的体验是:搜索结果通常在几百毫秒内开始回传,用户看到的第一条“正在扫描”消息能大幅降低等待焦虑。
3. 深入拆解fetchEventSource的机制与核心配置
3.1 fetchEventSource的API结构和事件生命周期
@microsoft/fetch-event-source 的源码并不复杂,核心就是一个封装了SSE解析的fetch调用。它接受两个参数:URL和配置对象。配置对象里最常用的字段有这些:
method:请求方法,默认GET,文件搜索场景用POST。headers:自定义请求头,和fetch的Headers结构一致。body:请求体,POST时使用。signal:AbortSignal,用于中断请求。openWhenHidden:默认false,即页面隐藏时自动断开连接;文件搜索场景建议设为true,否则用户切一下标签页连接就断了。onopen:连接建立后的回调,可以在这里检查响应状态码,非2xx可以主动抛出异常。onmessage:收到SSE消息的回调,事件会被解析为{ event, data, id, retry }结构。onclose:连接正常或非正常关闭后的回调。onerror:出错回调,注意如果在这个回调里不抛出异常,库会自动重连。
这里最关键的是 onmessage 的 event 字段。SSE协议允许服务端通过 event: 字段自定义事件类型,客户端只有在 onmessage 里判断 event 名称才能分流处理不同事件。你可以把它理解成“带类型的消息通道”。
看一段实际代码:
javascript复制import { fetchEventSource } from '@microsoft/fetch-event-source';
const controller = new AbortController();
fetchEventSource('/api/assistant/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({
query: '找一下去年Q3的季度汇报PPT',
scope: '/data/documents',
}),
signal: controller.signal,
openWhenHidden: true,
async onopen(response) {
if (response.status !== 200) {
throw new Error(`连接失败: ${response.status}`);
}
console.log('SSE连接已建立');
},
onmessage(msg) {
const { event, data } = msg;
switch (event) {
case 'search.started':
handleSearchStarted(JSON.parse(d
