ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Easysearch智能助手Web端升级实践:从架构设计到安全加固

Easysearch智能助手Web端升级实践:从架构设计到安全加固 拿到“Easysearch 智能助手 Web 端升级”这个项目时我的第一反应不是兴奋而是心里一沉。因为智能助手的核心能力——多轮对话、实时搜索、结果聚合——本身就够复杂了再叠加上“Web 端”三个字意味着要处理的不只是功能迁移还有流式传输、并发压力、浏览器兼容性、安全防护这一整套之前没怎么操过心的问题。Easysearch 这个名字听起来很轻巧但真正把它搬上 Web让用户在浏览器里获得接近原生客户端的体验牵扯出来的细节远比想象中多。这篇文章我从头梳理一遍这次升级的完整过程包括整体设计思路、技术选型背后的取舍、核心功能的实现细节、性能优化和 Web 安全加固的实操记录以及那些翻车之后总结出来的排查经验。无论你是准备给现有的智能助手加 Web 端还是打算从零做一个带 AI 搜索能力的 Web 项目这篇文章里的方案和坑应该都能直接参考。1. 项目背景与整体设计思路1.1 从客户端形态迁移到 Web 端的真实动因Easysearch 智能助手最早跑在桌面客户端里本地起一个服务进程前端界面直接调本地接口。这个模式下不存在跨域、并发、部署这些问题因为所有请求都走 localhost天然隔离。但随着产品要覆盖更多场景——比如在别人的电脑上临时使用、嵌入企业门户、给移动设备访问——客户端形态就变成了一个硬伤用户不愿意为了一个偶尔用一次的工具专门下载安装包更别说配环境、升级版本这些事了。所以这次升级的第一诉求其实不是“体验更好”而是“触达更广”。Web 端意味着用户打开浏览器输入地址就能用不用安装、不用更新、跨平台。这个决策听起来理所应当但它给架构带来的冲击是巨大的原来本地进程间通信变成了真实的 HTTP 请求原来只管一个用户的逻辑突然要面对几十个用户同时在线搜索原来数据直接落本地磁盘现在要考虑会话存储、鉴权、安全边界。1.2 整体架构前后端分离加网关层我最后定的方案是经典的三层结构前端 Vue 3 单页应用负责交互展示后端 Spring Boot 3 负责业务逻辑与搜索调度两者之间加一层 Nginx 做反向代理和静态资源服务。中间插一个网关层不是多余的步骤它解决了三个实际问题一是统一处理 HTTPS 证书和 WebSocket 升级协议二是在不改动后端代码的前提下做接口路由分发三是给后续做限流、灰度发布留了一个天然的切入点。这个架构从投入产出比来看是最稳的。企业级 Web 开发最怕的就是过度设计一上来就拆微服务、上消息队列结果核心搜索链路还没通先被基础设施拖死了。我的原则是先让核心功能跑通再谈分布式演进。三层结构里任何一层都有独立的替换空间前端撑不住了可以换 React后端要拆服务了可以把搜索模块独立出去网关层随时可以接注册中心。这种演进路径比一开始就上阿里云全家桶要务实得多。1.3 为什么不能直接复用客户端的本地服务方案踩过的最大一个认知坑是以为把客户端的本地服务代码改改端口就能直接部署成 Web 后端。客户端场景下服务和 UI 跑在同一台机器接口调用不需要考虑网络传输耗时但 Web 端不一样浏览器到服务器之间隔着一个公网每次搜索请求都要经过 DNS 解析、TCP 握手、TLS 协商、业务处理、响应传输这一整条链路。原来客户端里 50 毫秒的接口响应到了 Web 端可能变成 500 毫秒用户体验直线下降。更关键的是安全模型完全不同。本地服务只对本机暴露默认可信Web 服务暴露在公网就要面对扫描器、恶意爬虫、SQL 注入、越权访问这些威胁。客户端的搜索接口没有任何鉴权逻辑原封不动搬上 Web 等于把核心数据裸奔在公网。所以这次升级不是“改改前端”而是按企业级 Web 应用的标准重新审视了整条链路的安全设计。我在这件事上的教训是迁移之前先画一张数据流向图把所有请求路径和依赖关系列清楚再开始动手。跳过这一步直接改代码后面每修一个 bug 都要回头查架构成本翻倍。2. 核心技术选型与方案取舍2.1 前端框架选型Vue 3 加 TypeScript兼顾效率与健壮性选 Vue 3 没有太多纠结。Easysearch 的界面复杂度在于多轮对话、搜索结果卡片、分组聚合、流式输出高亮这些交互Vue 3 的组合式 API 在这种中等复杂度场景下写起来非常顺手响应式系统也不需要手动优化就能达到不错的性能。配 TypeScript 则是被迫的——项目里接口返回的数据结构越来越复杂搜索结果的类型、来源、标签、置信度分数纯 JavaScript 写根本扛不住迭代类型标注之后前端接口联调和后端字段变更的沟通成本明显降下来了。组件层面我做了个很实用的拆分SearchBox、ChatPanel、ResultCard、SourceBadge、StreamingText 这几个核心组件互不依赖数据和事件全部通过 props 和 emits 传递。这个设计让后端的流式输出逻辑可以完全不碰 UI 组件代码——后端只管推数据前端 StreamingText 组件负责把字符串增量渲染成打字机效果。开发过程中实测下来这个拆分对调试效率的提升是肉眼可见的搜索链路出问题只需要看网络面板和状态管理界面问题只需要定位到具体组件谁也干扰不到谁。2.2 后端框架与搜索调度Spring Boot 3 还是够稳后端本来想换 Go考虑到 Easysearch 的搜索调度逻辑已经用 Java 写了一版迁移到 Go 等于推倒重来最终定了 Spring Boot 3。这里说句公道话Java 的冷启动和内存占用确实不占优势但对于这种 IO 密集型、有重试补偿、要接多种数据源的搜索聚合服务Spring 生态的成熟度能省下大量开发时间。启动时间 3 秒和 300 毫秒的差异对用户来说根本感知不到但 Spring Boot 自动配置、Actuator 监控、参数校验这些能力在追赶进度的时候都是实打实的加速器。搜索调度的核心逻辑是并行聚合加超时熔断。用户输入一个 query后端同时向多个搜索源发起请求——全文索引、知识库、外部 API——然后对结果做去重、打分、排序最后统一输出。这里有个关键参数超时时间。我设置的是主搜索源 3 秒辅助源 1.5 秒超过直接放弃该源结果不需要等待全部返回。这样用户最长等待时间被锁死在 3 秒以内不会因为某个外部搜索源响应慢导致整条链路卡死。这个策略对 AI 搜索类应用几乎是标配但很多刚上手的人容易忽略想当然地写成“等所有结果到齐再返回”结果一个慢接口拖垮整个搜索体验。2.3 流式通信选型SSE 最终打赢了 WebSocket智能助手 Web 端最核心的体验是“流式输出”——用户提问后答案是一个字一个字蹦出来的。实现这个效果有两条路WebSocket 和 SSE。我两个都写了 demo 实测过最终选了 SSE也就是 Server-Sent Events。原因是这个场景下数据是单向的——服务器往浏览器推浏览器侧是纯接收。WebSocket 是双向全双工能力更强但复杂度也更高要处理心跳、断线重连、二进制帧、消息压缩这些没完没了的细节SSE 是基于原生 HTTP 的长连接浏览器内置 EventSource API断线自动重连服务端实现就是设置响应头为 text/event-stream然后持续写入数据简洁得让人放心。还有一个容易被忽略的点走 SSE 之后Nginx 的配置可以完全复用普通的 HTTP 代理规则。之前用 WebSocket 的测试环境里Nginx 需要在 location 块里单独设置 Upgrade 和 Connection 请求头SSE 完全不用管这些proxy_buffering 关掉就行。部署成本低了一截。当然如果后续 Easysearch 需要做协同编辑、实时通知推送这类真正双向通信的功能WebSocket 还是绕不开的但就当前版本而言SSE 是性价比最高的选择。3. 核心功能实现与实操细节3.1 搜索链路重构从串行请求改成并行聚合Easysearch 助手 Web 端最核心的搜索链路分四步意图识别、查询改写、并行检索、结果重排。意图识别模块判断用户是想做通用搜索、查知识库还是直接要一个概括性答案查询改写会在原始 query 的基础上做同义词扩展和纠错并行检索把改写后的查询分发给多个数据源结果重排通过打分模型把最相关内容排到最前面。Web 端上线后发现最大的性能瓶颈在第三步——并行检索。原版设计里数据源是逐个请求的一个源超时 3 秒五个源最多要 15 秒这谁能忍。改进后的方案是用 Java 的虚拟线程配合 CompletableFuture 做并发调用五个源同时发出请求谁先返回谁先进入结果集整体耗时从最坏 15 秒压到了平均 2 秒左右。这个优化带来的体验提升是决定性的搜索结果几十秒没出来和两三秒出来用户的耐心完全不是一个量级。ListCompletableFutureListSearchResult futures sources.stream() .map(source - CompletableFuture.supplyAsync(() - source.search(query), executor)) .toList(); ListSearchResult merged CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .thenApply(v - futures.stream() .map(CompletableFuture::join) .flatMap(List::stream) .collect(Collectors.toList())) .get(3, TimeUnit.SECONDS);这个写法里有几个细节容易犯错我踩过坑列一下一是 allOf 要配合 get(3, TimeUnit.SECONDS) 做整体超时否则任何一个内部异常都会让整个链路挂住二是内部每个 source.search() 必须自己捕获异常返回空列表不能把异常抛到外层三是 executor 线程池要有明确的拒绝策略默认的 AbortPolicy 在这个场景下会让并发高峰期直接抛异常我改成了 CallerRunsPolicy线程池满了就让调用线程自己跑这个任务宁可慢一点也不能断。3.2 前端打字机效果的正确实现方式SSE 后端推数据没问题但前端怎么优雅地渲染流式文本这里面大有讲究。最开始我图省事用 setInterval 定时从缓冲区取一段字符往界面上怼效果勉强能看但一旦后端推数据的速度波动一会儿快一会儿慢界面上的文字就会一会儿卡顿一会儿猛飙体验非常奇怪。后来换成了基于 requestAnimationFrame 的渲染循环把接收数据和解耦渲染彻底分开EventSource 的 onmessage 只负责把数据块 push 进缓冲区渲染循环每帧从缓冲区取固定量的字符更新到界面上。这样即使网络状况波动界面上的文字速度始终是稳定的、平滑的。用这个方案实测即使用 throttling 模拟弱网环境打字机效果也不会出现明显的抽搐感。const pendingBuffer []; let displayedLength 0; eventSource.onmessage (event) { pendingBuffer.push(event.data); }; function renderFrame() { const fullText pendingBuffer.join(); if (fullText.length displayedLength) { const diff Math.ceil((fullText.length - displayedLength) / 20); displayedLength Math.min(fullText.length, displayedLength diff); contentEl.textContent fullText.slice(0, displayedLength); } requestAnimationFrame(renderFrame); } renderFrame();这个方案里 diff 的计算决定了“打字”的速度20 帧完成全部追赶可以做到视觉上比较舒服的呈现节奏。另一个细节是必须在 receive 和 render 之间做流控不然网络好的时候一次推 50KB 进来界面瞬间渲染全量文字流式输出的意义就没了。3.3 多轮对话上下文管理Web 端和客户端一个很大的区别是刷新页面就丢状态。客户端本地进程一直在内存里的上下文不会丢浏览器刷新一下整个 JavaScript 上下文全没了用户刚才聊的内容瞬间消失。这个问题不做处理多轮对话的体验就是断裂的用户问一句“上一步的结果帮我总结一下”助手根本不知道“上一步”是什么。我的方案是三分层存储第一层是内存 Map 保存活跃会话保证同一用户短时间内的连续对话不重复读库第二层是 Redis 做会话状态持久化用户断开重连后可以恢复最近的 20 轮消息同时设置 30 分钟过期时间避免内存无限膨胀第三层是 MySQL 里的长期归档表每次会话结束后把完整记录落库供后续做检索增强和数据复盘。三个存储层各有明确的读写场景接口请求先查内存命中直接返回内存未命中查 Redis并回填内存需要跨会话查询或者做统计时查 MySQL。这个分级设计跑下来效果很稳定Redis 的内存占用一直维持在几十 MB 级别MySQL 的写入频率也不高。这里有个经验千万不要为了省事把所有轮次的上下文都塞进请求参数里往上抛调试的时候会疯掉的而且网络传输成本一直在那儿。维护一份会话索引按需拉取上下文片段才是正经做法。3.4 Web 页面 PDF 打印需求一个容易被低估的功能Easysearch 搜索结果里有不少报告类卡片用户点了卡片要求打印成 PDF 存档。这个需求看着小做起来一堆坑。浏览器直接调 window.print() 打印的话打印预览里会带着侧边栏、推荐卡片、页脚这些乱七八糟的东西纸质版完全不能用。最后是给打印页面单独写了一套 CSS 媒体查询规则通过 media print 隐藏非核心区域只保留主内容区同时用分页符控制好页面断点。media print { .sidebar, .header, .recommend-card, .footer { display: none !important; } .result-content { width: 100%; margin: 0; } .source-item { break-inside: avoid; page-break-inside: avoid; } }这套 CSS 加完之后打印效果基本达标但有个问题还是让我折腾了一晚上搜索结果里动态渲染的内容在打印预览里偶尔会缺少部分样式。排查后确认是浏览器打印时尚未等异步渲染完成就触发了快照渲染。解决方式是在打印前强制等待字体加载和图片解码完成async function printWithAssetsReady() { await document.fonts.ready; const images Array.from(document.images); await Promise.all(images.map(img img.decode())); window.print(); }如果你们的产品也有打印需求建议把这个函数和打印按钮的点击事件绑死不然用户大概率会打出白屏或者缺字体的版本。这个坑一般不会出现在需求文档里但线上反馈里绝对会出现。4. 性能优化与安全加固4.1 首屏加载优化白屏时间是怎么从 3 秒压到 1 秒的智能助手类应用的首页就是一个搜索框加几个推荐问题逻辑简单但首屏渲染走了弯路。最开始所有代码打包成一个 bundle 文件体积 1.8 MB用户打开页面要等所有脚本下载并解析完毕才显示界面白屏 3 秒这体验放在 2025 年完全不合格。优化措施分了三步第一步是把路由级页面拆成异步 chunk配合 Webpack 的魔法注释按需加载第二步是把 Vue、Element Plus、Markdown 解析器这些基础库单独打成 vendor chunk利用浏览器强缓存减少重复下载第三步是给搜索框区域做了骨架屏在资源加载期间先用 CSS 动画占住视觉位置避免用户盯着白屏干等。实际数据首屏关键路径压缩后从 800 KB 降到 180 KBDOMContentLoaded 时间从 3.2 秒降到 1.1 秒。这里有个容易被忽视的点——Web 端智能助手的首屏体验不单是加载快还要让用户第一眼就知道“这个页面是干嘛的”。骨架屏显示的搜索框和推荐问题就能传达出“这里是输入问题的地方”比一个转圈菊花要好得多。4.2 搜索结果的缓存策略分层缓存减少后端压力Easysearch 的搜索请求有很强的重复性大量用户搜的是同样的问题。如果每次搜索都实时聚合数据源后端的负载和外部 API 的配额消耗都扛不住。我在三个层级做了缓存浏览器层、网关层、服务层。浏览器层的 Service Worker 缓存同 URL 的静态资源接口数据不缓存因为流式响应没法直接被 Service Worker 利用网关层 Nginx 对搜索结果做 10 秒的 proxy_cachemap 到 memcached 类型存储服务层加了 Caffeine 本地缓存过期时间 5 分钟命中率在实测中稳定在 68% 左右。三层缓存叠加同 query 的重复请求基本不会打到真实搜索源外部 API 的日调用量在流量翻倍的情况下反而没有明显上涨。缓存策略里有个细节必须注意搜索结果类数据的缓存时间不能太长否则用户搜到的新内容会被旧缓存挡住。10 秒到 5 分钟是常见的区间按需求调整即可。另外要在搜索 API 的响应头里加上明确的 Cache-Control 头防止中间链路擅自做不合理的缓存Cache-Control: public, max-age10, s-maxage10加了这行响应头之后浏览器和 Nginx 的缓存行为都变得可预期了后端日志里重复查询的比例明显下降。4.3 Web 安全防护接口鉴权、防爬与防注入Web 端一上线就注定会被扫描器盯上所以安全设计不是在功能做完之后补而是从架构阶段就开始设计。第一道防线是接口鉴权。所有 /api/ 路径的请求都要求带 JWT token登录接口单独走验证码和限流。JWT 的有效期设为 1 小时刷新 token 有效期 7 天前端在 axios 的响应拦截器里做自动刷新重试。这套逻辑实现起来不复杂但能挡住绝大多数未授权访问。第二道防线是防爬。Easysearch 搜索接口有真实的计算成本如果被爬虫薅羊毛服务器资源会被迅速打满。我在 Nginx 层做了三层防护一是按 IP 维度做单位时间请求数限制超过阈值直接返回 429二是要求客户端必须带合法的 token 才能访问搜索接口爬虫拿不到合法 token三是针对可疑 UA 和频繁访问的模式做封禁。这里要特别提醒限流规则的参数需要根据真实流量曲线来调拍脑袋定的阈值很容易误伤正常用户。我一开始设置的每分钟 60 次瞬时觉得够用结果有客户公司全员用同一出口 IP 访问直接被误判为攻击全被 429。后来改成按 IP 加用户标识组合维度限流才真正解决了这个问题。第三道防线是参数校验和注入防护。后端对每个 query 参数做长度限制最长 200 字符、特殊字符过滤、白名单校验。搜索词里带单引号这些直接做转义处理不给拼接进查询语句的机会。这些逻辑在 Spring Boot 里用注解很容易实现PostMapping(/search) public Result search(RequestBody Valid SearchRequest request) { // request.getQuery() 已经经过 NotBlank Size(max 200) 校验 }Web 安全没有银弹但以上三道防线对一个小型 AI 搜索产品来说足够了——拦住 99% 的脚本扫描和未授权访问剩下的 1% 交给持续监控和应急响应。5. 常见问题与排查技巧实录5.1 流式响应断流隐藏的代理缓冲问题上线第二天客户反馈搜索答案经常只显示一半就停了浏览器控制台看不到明显的报错。一开始怀疑是后端线程池满了查日志发现线程池正常最后用 curl 模拟请求才发现端倪——Nginx 默认开启了 proxy_bufferingSSE 的数据被 Nginx 缓冲了事件不会即时转发给浏览器当连接被某个环节提前关闭时缓冲未刷出的数据就丢了。这个问题非常隐蔽因为代理层在正常网络下不会立刻显现只有在持续输出时间较长、内容较大的情况下才会暴露。解决方案是在 Nginx 配置里关闭缓冲并调低超时时间location /api/search-stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; chunked_transfer_encoding off; }改完之后重新压测SSE 的每一块数据都能即时到达浏览器断流问题彻底消失。这个教训告诉我凡是涉及长连接、流式传输的场景排查清单里第一项就是代理层的缓冲配置。5.2 浏览器内存泄漏聊天记录无限累积的坑跑了一个多月测试反馈长时间使用的页面越来越卡最后直接白屏。用 Chrome DevTools 的内存快照一对比发现聊天内容的数据对象一直被引用着无法回收。原因很简单我把所有对话消息全部保存在 Vuex store 里而且为了让用户滚动回看历史消息时不重新请求选择把全部数据常驻内存。表面上看这是“缓存数据方便用户”的好设计但对话是一个无限增长的数据结构会话时间越长内存里的消息数组越大最终把浏览器内存耗尽。解决方案是给 store 里的消息列表加上一个阈值超过 200 条之后只保留最近 200 条和所有非文本类附件图片、文件路径更早的内容按需从后端重新拉取。if (messages.length MAX_MESSAGES) { const overflow messages.length - MAX_MESSAGES; messages messages.concat(messages.splice(0, overflow).map(m ({ ...m, content: ... }))); }做了这个限制之后即使用户连续聊两三个小时页面内存也稳定在 250 MB 以内。千万记住一条原则前端可以缓存但缓存必须有上限并且要设计淘汰策略否则缓存本身就会变成新问题的根源。5.3 跨域问题的正确解决方式而不是一禁了之前后端分离后跨域问题如期而至。开发环境前端跑在 5173 端口后端在 8080 端口浏览器的同源策略直接把所有请求拦了。新手常见的两种做法是“在浏览器插件里关掉 CORS”和“后端统一加 Access-Control-Allow-Origin: *”。这两种都不可取前者让真实用户没法复现后者等于把所有域名都放行了后患无穷。正确做法是精确配置 CORS。生产环境的前端域名是固定的只需要允许这个源访问接口Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(https://search.company.com) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .maxAge(3600); } }实际开发中还有一个更彻底的办法生产环境直接用 Nginx 做同源反向代理把前端静态资源和后端 API 挂在同一个域名下这样根本不存在跨域问题。我的生产配置就是这种方式前端访问 https://search.company.com接口也走同一个域名由 Nginx 把 /api/ 路径代理到后端服务CORS 配置在 Nginx 里简单加一下即可。5.4 白屏与资源加载失败SRI 哈希校验的固守部署过程中遇到过一次很诡异的问题部分用户反馈打开页面白屏但 F12 控制台没有任何 JS 报错。排查发现是静态资源文件在加载过程中被链路里的某些安全网关做了内容改写导致 HTML 引入的 JS 文件实际内容与构建产物不一致浏览器解析直接失败。这个问题的防护措施是在 index.html 里的 script 标签加 integrity 属性和 crossorigin 属性也就是 SRISubresource Integrity。构建工具会自动生成资源文件内容的哈希值浏览器加载时会校验文件完整性script src/assets/index-7a3cc.js integritysha384-NDg5BOrO64fWmdWxO6VwRq6fJazLJvYw8Jjk2FtlyuWqatjCzl8lW5aI7JAIynM0 crossoriginanonymous/scriptNginx 配置里同时开启 gzip在 content-security-policy 里加上 SRI 相关的策略。虽然 SRI 增加了部署时的一些工作量每次构建都要重新生成新的哈希但对比排查白屏的精力消耗这点成本不值一提。6. 扩展方向与个人心得注意Easysearch Web 端的升级到这里已经跑通并稳定运行了。下面只聊一些我个人的实际感受和后续想法不作泛泛的展望。这次从 0 做 Web 端升级最大体会是很多事情等到线上出问题再去想代价太高了。比如代理缓冲导致的 SSE 断流如果在上线前用工具完整模拟过 30 分钟的长对话这个问题本来可以在发布前发现。现在的习惯是每做一个有网络传输的功能第一件事就画一遍数据流路径标记出所有可能缓冲、超时、改包、断连的节点再对着这条链路逐项验证。Easysearch 这个 Web 端后续我会考虑做两个扩展一是把搜索结果里的引用来源做成可点击跳转的原文链接卡片让用户能直接确认信息来源的可靠性二是把 PDF 打印功能从单结果打印扩展成选区打印和自定义报告模板这样真正能替代一部分人工整理周报的工作。如果你也在做 AI 搜索相关的 Web 项目建议大家把用户反馈里出现频率最高的几个关键词拉出来看往往比任何技术指标都更能指明下一步该往哪里走。
返回列表