作者归档:admin

做了 10 年SaaS 产品后,我总结的权限设计避坑指南

做 SaaS 产品这么多年,我发现权限控制是个特别有意思的话题。说它简单吧,很多团队都做得奇奇怪怪;说它复杂吧,掌握了核心原理后其实也就那么回事。

如果你是产品经理、技术负责人,或者正在做 B 端产品的创业者,这篇文章可能会对你有一些帮助。今天咱们就聊聊 SaaS 产品里的权限控制,怎么设计、怎么实施、怎么避坑。

1 为什么权限控制这么重要

说个数据:2022 年 SaaS 安全报告显示,43% 的企业因为权限配置错误导致过数据泄露。而业内人士都知道,实际比例可能高达63%——很多公司出了事都选择悄悄处理,不对外声张(也能理解的)。

再看一下 2020 年,微盟删库事件,一个运维人员因为跟公司有矛盾,趁着自己还有生产环境的管理员权限,直接把核心数据库给删了。

300 万商家的店铺全部瘫痪,整整 7 天无法营业。正值疫情期间,很多商家本来就靠线上维持生计,这一下彻底断了收入来源。最后微盟赔偿了1.5亿,股价暴跌,品牌信誉更是一落千丈。

事后复盘发现问题出在哪?

  • 一个人就能删除生产数据库,没有任何审批流程
  • 删除操作没有双人复核机制
  • 权限过度集中,运维人员的权限大到离谱

以此作为警示:对 SaaS 行业来说,权限管理不是技术问题,是生死问题。

为什么说权限问题往往比较致命?

做了这么多年 ToB 产品,我发现权限问题有几个特点:

1. 爆发性强:不像性能问题是逐渐恶化,权限问题是突然爆发。今天还好好的,明天就可能因为一个配置错误,导致全部客户数据泄露。

2. 影响面广:一个权限漏洞,可能影响所有客户。特别是多租户架构,一个 bug 就能让所有租户的数据混在一起(如果在多租户逻辑中使用的是字段隔离,而且大部分 SaaS 产品是这样做的)。

3. 修复成本高:早期设计不好,后期改造就是噩梦。

4. 信任难恢复:客户把核心数据放在你的系统里,是基于信任。一旦出现权限问题,这种信任很难恢复。哪怕你后来改得再好,客户心里也会有阴影。

权限控制是基础,这就像盖房子,地基不牢,楼盖得越高越危险。

2 权限控制的核心概念

在深入讨论之前,咱们先把几个基本概念理清楚。

2.1 权限的本质是什么

说白了,权限就是回答一个问题:谁能对什么做什么操作?

  • 谁:用户、角色、部门
  • 什么:功能模块、数据对象、页面按钮
  • 操作:查看、创建、编辑、删除、审批

这三个要素组合起来,就构成了权限控制的基础。比如「财务主管可以查看所有部门的报销单」,这就是一条权限规则。

2.2 功能权限和数据权限

很多人容易把这俩混在一起,其实它们解决的是不同维度的问题。

功能权限控制的是「能不能用这个功能」。比如普通员工看不到薪资管理模块,这就是功能权限。实现起来相对简单,一般在前端控制菜单显示,后端做接口校验就行。

数据权限控制的是「能看到哪些数据」。同样是查看订单列表,销售 A 只能看自己的订单,销售主管能看整个团队的订单,老板能看全公司的订单。这就是数据权限,实现起来要复杂得多。

有一个典型案例:某 CRM 系统,销售经理发现自己看不到下属的客户数据,一查才发现只做了功能权限,忘了做数据权限。结果所有销售经理都只能看到自己作为销售时录入的客户,管理功能形同虚设。

2.3 权限的安全边界

做权限控制,安全永远是第一位的。我总结了几个容易踩坑的地方:

前端权限不可信:永远不要只在前端做权限判断,哪怕把按钮隐藏了,懂技术的人照样能通过开发者工具发请求。所有权限判断必须在后端再做一遍。

默认拒绝原则:权限设计应该是「没有明确允许的都是禁止的」,而不是「没有明确禁止的都是允许的」。这个原则能避免很多安全漏洞。

最小权限原则:给用户的权限应该刚好够用就行,不要为了方便给过多权限。特别是生产环境的管理员权限,能不给就不给,给了也要有审计日志。

3 三种主流权限模型

聊完基础概念,咱们来看看业界常用的几种权限模型。每种模型都有自己的适用场景,没有绝对的好坏。

3.1 ACL

ACL,访问控制列表,是最直观的权限模型,直接定义「用户-资源-权限」的对应关系。比如:

  • 张三可以编辑文档 A
  • 李四可以查看文件夹 B
  • 王五可以删除报表 C

优点是简单直接,实现容易。早期的文件系统、简单的内容管理系统多用这种模型。

缺点也很明显:用户一多就没法管理了。假设你有 1000 个用户,100 个资源,每个资源有 5 种操作权限,理论上你需要维护 50 万条权限记录。更要命的是,新员工入职你得一个个配置权限,员工离职你得一个个回收权限,运维成本极高。

所以 ACL 一般只适合用户量少、权限关系简单的场景。如果你的 SaaS 产品用户量大,还是趁早换其他模型。

3.2 RBAC

RBAC,基于角色的访问控制,是目前最主流的权限模型,核心思想是引入「角色」这个中间层。用户不直接拥有权限,而是通过角色来获得权限。

比如定义几个角色:

  • 销售员:可以查看和编辑自己的客户、订单
  • 销售主管:可以查看和编辑本部门所有客户、订单,可以查看销售报表
  • 财务人员:可以查看所有订单,可以开具发票,可以查看财务报表

新员工入职,只需要给他分配对应角色就行了。角色的权限变了,所有该角色的用户权限自动更新。

RBAC 还可以细分为四种类型,实际应用中按需选择:

RBAC0(基本模型):最简单的实现,用户-角色-权限三层结构。大部分中小型 SaaS 产品用这个就够了。

RBAC1(角色分层模型):角色可以继承。比如「销售主管」自动继承「销售员」的所有权限,再加上管理权限。这样可以减少重复配置。

RBAC2(角色限制模型):增加了约束条件。比如「角色互斥」(一个用户不能既是采购员又是审批员),「角色数量限制」(一个用户最多只能有 3 个角色)等。

RBAC3(统一模型):集成了 RBAC1 和 RBAC2 的所有特性,最完整但也最复杂。

我的建议是从 RBAC0 开始,随着业务发展再考虑升级。过度设计只会增加系统复杂度。

3.3 ABAC

ABAC,基于属性的访问控制,是相对较新的模型,通过属性组合来判断权限。这些属性可以来自:

  • 用户属性:部门、职级、工龄、地域
  • 资源属性:类型、创建者、敏感度、标签
  • 环境属性:时间、地点、设备类型

举个例子:”华东区的销售经理在工作时间可以查看本区域高价值客户的信息”。这条规则涉及了用户的地域属性、角色属性,资源的地域属性、价值属性,以及时间这个环境属性。

ABAC 的优势是灵活性极高,可以实现非常精细的权限控制。缺点是实现复杂,性能开销大,权限规则难以理解和调试。

一般来说,如果你的业务场景确实需要这么复杂的权限控制(比如医疗、金融等强监管行业),可以考虑 ABAC。否则 RBAC 就足够了。

4 SaaS 产品的特殊挑战

相比传统的企业内部系统,SaaS 产品在权限控制上面临一些独特的挑战。

4.1 多租户隔离

这是 SaaS 最核心的需求。同一套系统里住着几百上千家企业,必须保证数据完全隔离。A 公司的员工绝对不能看到 B 公司的任何数据。

常见的隔离方案有三种:

独立数据库:每个租户一个数据库。隔离性最好,但成本高,难以维护。适合大客户少量部署的场景。

共享数据库、独立 Schema:每个租户一个 Schema。隔离性不错,成本适中。适合中等规模的 SaaS 产品。

共享数据库、共享表:所有租户的数据都在同一张表里,通过 tenant_id 字段区分。成本最低,但要特别小心 SQL 注入和权限泄露。这是大部分 SaaS 产品的选择。

如果采用第三种方案,一定要在所有 SQL 查询中强制加上 tenant_id 条件。我见过的好做法是在 ORM 层面做全局过滤器,或者在数据库层面用行级安全策略(Row Level Security)。

4.2 组织架构的映射

企业客户通常都有复杂的组织架构,我们的权限系统必须能够映射这种结构。常见的需求包括:

  • 树形部门结构,支持多层级
  • 一个人可能属于多个部门(兼职、虚拟团队)
  • 临时授权(代理、请假)
  • 按项目组、按地域等多维度的权限控制
  • 集团,公公司等逻辑

我的经验是,组织架构不要做得太复杂,够用就行。很多企业其实就是简单的部门层级 + 角色,硬要上矩阵式组织、事业部制这些复杂结构,反而增加了使用成本。

4.3 权限的动态性

SaaS 产品的权限需求经常变化:

  • 新功能上线,需要新的权限点
  • 客户要求定制化的权限规则
  • 不同行业、不同规模的客户,权限需求差异很大

所以权限系统必须设计得足够灵活。我推荐的做法是:

权限点动态化:不要把权限点写死在代码里,而是存在数据库里,通过配置来管理。

规则引擎:对于复杂的权限判断逻辑,可以引入规则引擎,让权限规则可以通过配置来调整。

权限模板:为不同类型的客户准备权限模板,新客户注册时可以快速初始化。

4.4 性能优化

权限判断是高频操作,一个页面可能要判断几十上百个权限点。如果每次都查数据库,性能肯定扛不住。

常用的优化手段:

缓存:用户登录时把权限信息缓存到 Redis,设置合理的过期时间。权限变更时主动刷新缓存。

权限位图:把权限用位图来表示,一个 long 型变量可以表示 64 个权限点,判断权限只需要位运算。

懒加载:不要一次性加载所有权限,而是按需加载。比如用户进入某个模块才加载该模块的权限。

预计算:对于数据权限,可以预先计算好用户能访问的数据 ID 列表,查询时直接用 IN 条件。

5 设计一个权限系统

说了这么多理论,咱们来点实际的。假设你要为一个 SaaS CRM 系统设计权限控制,应该怎么做?

5.1 需求分析

首先要搞清楚业务需求:

  • 系统有哪些功能模块?客户管理、订单管理、报表分析等
  • 有哪些角色?销售员、销售主管、客服、财务、管理员等
  • 数据权限如何划分?按部门、按区域、按客户等级等
  • 有哪些特殊需求?审批流程、临时授权、数据导出限制等

5.2 模型选择

对于 CRM 这种相对标准的业务系统,RBAC 是首选。具体用 RBAC0 还是 RBAC1,看企业规模:

  • 中小企业:RBAC0 足够,角色数量有限,权限关系简单
  • 大型企业:考虑 RBAC1,利用角色继承减少配置工作

5.3 数据库设计

核心表结构:

-- 用户表
CREATETABLEusers (
    idBIGINT PRIMARY KEY,
    tenant_id BIGINTNOTNULL,
    username VARCHAR(50NOTNULL,
    -- 其他字段...
    INDEX idx_tenant (tenant_id)
);

-- 角色表
CREATETABLEroles (
    idBIGINT PRIMARY KEY,
    tenant_id BIGINTNOTNULL,
    role_name VARCHAR(50NOTNULL,
    parent_id BIGINT-- 用于角色继承
    -- 其他字段...
    INDEX idx_tenant (tenant_id)
);

-- 权限表
CREATETABLE permissions (
    idBIGINT PRIMARY KEY,
    permission_code VARCHAR(100NOTNULL-- 如 'customer.view'
    permission_name VARCHAR(100NOTNULL,
    moduleVARCHAR(50), -- 所属模块
    -- 其他字段...
    UNIQUEKEY uk_code (permission_code)
);

-- 用户-角色关联表
CREATETABLE user_roles (
    user_id BIGINTNOTNULL,
    role_id BIGINTNOTNULL,
    PRIMARY KEY (user_id, role_id)
);

-- 角色-权限关联表
CREATETABLE role_permissions (
    role_id BIGINTNOTNULL,
    permission_id BIGINTNOTNULL,
    PRIMARY KEY (role_id, permission_id)
);

-- 数据权限规则表
CREATETABLE data_permissions (
    idBIGINT PRIMARY KEY,
    role_id BIGINTNOTNULL,
    resource_type VARCHAR(50), -- 如 'customer', 'order'
    rule_type VARCHAR(50), -- 如 'self', 'department', 'all'
    rule_value TEXT-- 具体规则,可以是 JSON
    INDEX idx_role (role_id)
);

6 避坑指南

做了这么多项目,我总结了一些常见的坑,希望你能避开:

6.1 过度设计

最常见的错误就是一上来就想做一个「完美」的权限系统。支持 ABAC、支持动态规则、支持工作流集成… 结果做了半年还没上线,业务等不及了。

记住,权限系统是为业务服务的,不是为了秀技术。先满足基本需求,再逐步迭代。

6.2 忽视性能

另一个常见问题是只关注功能,不关注性能。权限判断是高频操作,如果每次都要查十几张表,系统很快就会崩溃。

一定要做好缓存,关键接口要做压测。我的经验是,权限判断的响应时间应该控制在 10ms 以内。

6.3 权限配置过于复杂

有些系统的权限配置界面,复杂得连开发人员都搞不清楚。这样的系统,客户是不会用的。

权限配置要尽量简化,提供合理的默认值和模板。最好能提供权限检查工具,让管理员可以模拟某个用户的权限,看看到底能访问哪些功能和数据。

6.4 缺少审计日志

权限系统必须有完善的审计日志,记录谁在什么时候做了什么操作。特别是权限的授予和回收,必须有据可查。

这不仅是安全需要,很多行业还有合规要求。审计日志最好是独立存储,防止被篡改。

6.5 数据权限的 N+1 问题

实现数据权限时,很容易出现 N+1 查询问题。比如查询订单列表,每个订单都要判断一次是否有权限查看,结果一个列表页产生了上百次数据库查询。

解决方案是在列表查询时就加入权限过滤条件,而不是查出来再过滤。这需要在 SQL 层面就考虑权限问题。

7 其它一些变化

权限控制这个领域,这几年也有一些新的发展趋势:

7.1 Zero Trust 模型

Zero Trust 模型就是我们常说的零信任模型。

传统的权限模型是「城堡式」的:进了城门(登录系统)就基本畅通无阻。Zero Trust 模型要求每次访问都要验证权限,不管你是内部用户还是外部用户。

这对 SaaS 产品来说特别重要,因为用户可能从任何地方、任何设备访问系统。

7.2 AI 辅助的权限管理

利用机器学习来优化权限配置,比如:

  • 根据用户行为自动推荐合适的角色
  • 检测异常的权限使用,可能是账号被盗用
  • 自动发现权限配置中的冲突和冗余

7.3 细粒度的数据权限

不仅控制能不能看某条数据,还要控制能看到数据的哪些字段。比如普通销售能看到客户的基本信息,但看不到信用额度;财务能看到信用额度,但看不到跟进记录。

这需要在字段级别做权限控制,实现起来更复杂,但确实是一些行业的刚需。

8 写在最后

权限控制是 SaaS 产品的基础设施,做好了用户感知不到,做不好用户骂声一片。它不是一个能带来直接收益的功能,但却是产品能否长期发展的关键。

我的建议是:

  1. 不要等到出问题才重视权限,一开始就要规划好
  2. 选择适合自己业务的权限模型,不要过度设计
  3. 功能权限和数据权限要分开考虑,都很重要
  4. 做好性能优化和安全防护,这是基本要求
  5. 保持系统的灵活性,因为需求一定会变

技术是为业务服务的。不要为了炫技而把简单问题复杂化,也不要为了省事而在安全问题上偷懒。在这两者之间找到平衡,才是一个成熟的技术方案。

以上。

AI 编程的真相:一个老程序员的冷静观察

如果你是一名程序员,最近一两年肯定被各种 AI 编程工具刷屏了。从 GitHub Copilot 到 Cursor,到今年国内出的 Trae,以及最近发布的为提升 AI 编程效率而生的 Claude Code,还有国内的通义灵码等等,简直让人眼花缭乱。

身边不少同事和朋友都已经用上了,有人说效率翻倍,有人说就是个高级的代码补全。在网上也看到许多争论,如程序员会不会被 AI 取代等等话题。

作为一个在一线写了十多年代码的人,我想聊聊自己的观察和思考。这篇文章不是要唱衰 AI,也不是要贩卖焦虑,而是想分析一下当前 AI 编程的真实情况。

今天主要聊两块:LLM 的固有局限、这些局限在编程领域的具体表现,应对策略我们下一篇文章再聊。

1. LLM 的天生缺陷

要理解 AI 编程的问题,得先搞清楚底层的大语言模型(LLM)有哪些局限。这些局限不是某个产品的 bug,而是当前技术架构的固有特性。

1.1 概率预测的本质

LLM 说到底是个概率模型。它的工作原理是根据上下文,预测下一个最可能出现的词。注意,是「最可能」,不是「最正确」。

这就像一个特别会察言观色的人,能根据前面的对话猜出你想听什么,但他并不真正理解你们在聊什么。大部分时候猜得挺准,偶尔也会离谱到家。

在写作、聊天这种场景下,这种「猜测」问题不大,甚至还能带来一些创意。但在编程这种需要 100% 精确的领域,问题就来了,这就是我们所说的 LLM 的幻觉。

以编程为例,AI 可能会「发明」一个当前环境中并不存在的库函数,或者一本正经地告诉你某个框架有某种你从未听说过的特性。例如,你让它用一个小型库 mini-lib 写个功能,它可能会自信地写下 mini-lib.complex_function(),而这个函数实际上只存在于它通过模式匹配「幻想」出的世界里。这种随机性在创意写作中是火花,但在编程中就是地雷。一个分号、一个等号、一个大于号的随机错误,都可能导致程序编译失败、运行崩溃或产生灾难性的计算错误。

LLM 的本质是一个概率预测引擎,而不是一个事实检索数据库。它的核心任务是基于海量训练数据,「猜」下一个 token 是什么最合理,而不是「下一个 token 是什么最真实」。它的训练数据中包含了海量的代码和文档,当它发现很多库都有 .complex_function() 这种模式时,它就会推断 mini-lib 可能也有,从而生成一个语法通顺但功能无效的代码。它追求的是「看起来对」,而不是「真的对」。

1.2 知识的时间窗口

训练一个大模型需要几个月时间和巨额成本,所以模型的知识总是滞后的。比如 Claude 的知识截止到 2025 年 1 月,那么 2 月份发布的新框架、新 API,它就完全不知道。

对于技术更新速度极快的编程领域,这是个大问题。React 19 出了新特性,Node.js 又发布了新版本,某个常用库爆出了安全漏洞……这些信息,AI 都无法及时获取。

虽然可以通过 RAG/Agent 等技术缓解,但这更像是在给一个旧大脑外挂一个「实时信息提示器」,而非大脑本身的更新。

对于技术迭代比翻书还快的软件开发领域,依赖一个「活在过去」的工具,无异于拿着旧地图在新世界航行。更危险的是,它可能会自信地推荐一个已经停止维护、或者已知存在 CVE 的第三方依赖库,从而出现安全隐患。

1.3 上下文窗口限制

这个问题就像人的短期记忆一样。当我们和 AI 聊天聊久了,它会忘记开头说了什么。目前最好的模型,上下文窗口能达到百万级 token,能解决部分问题,但是也不够用。

对于动辄几十万、上百万行代码的现代开发项目,AI 就像一个只能通过门缝看房间的访客。它能看到门缝里的景象,但对整个房间的布局、风格和功能一无所知。开发者们常常抱怨 AI 编程工具「用着用着就变笨了」,根本原因就在于此。

1.4 缺乏真正的理解

这是最根本的问题。LLM 不理解代码的含义,它只是在模式匹配。

举个例子,当我们让 AI 写一个排序算法,它能写出完美的快排代码。但这不是因为它理解了「分治」的思想,而是因为训练数据里有大量类似的代码,它学会了这个模式。

一旦遇到需要真正理解业务逻辑、需要创新思维的场景,AI 就可能搞不定了。

2. 编程领域的具体挑战

上面这些通用局限,在编程领域会被急剧放大,产生一些特有的问题。

2.1 错误的放大效应

我们知道人是有容错能力的,如这张图,汉字顺序错了,我们也能读懂。

写文章错个字,读者能看懂。但代码里少个分号、多个逗号,程序直接跑不起来。更要命的是逻辑错误,比如边界条件判断错了,可能测试都能通过,上线后才爆雷。

我见过 AI 生成的代码,把 < 写成 <=,导致数组越界。还有在金融计算中使用浮点数,精度问题累积后造成账目对不上。这些都是看起来微小,实际后果严重的错误。

2.2 安全漏洞

这个问题相当严重。研究显示,AI 生成的代码中,包含安全漏洞的比例明显高于人工编写的代码。

原因很简单:

  • 训练数据本身就包含大量有漏洞的代码
  • AI 不理解什么是「安全」,只知道完成功能
  • 很多老旧的、不安全的编码模式被 AI 学习并复现

最常见的问题包括 SQL 注入、XSS、路径遍历等。AI 可能会直接把用户输入拼接到 SQL 语句里,或者在处理文件上传时不做任何验证。除非特别要求。

我们在实际写代码过程中,正向逻辑往往并不是花时间最多的,最复杂的就是边界,异常和特殊情况

2.3 项目上下文的缺失

真实的项目开发不是写独立的函数,而是在一个复杂的系统中工作。每个项目都有自己的:

  • 代码规范和风格
  • 架构设计和模式
  • 业务领域知识
  • 自定义的工具类和框架

AI 看不到这些全貌,经常会:

  • 重复造轮子(明明有现成的工具类不用)
  • 违背架构原则(在该用依赖注入的地方直接 new 对象)
  • 误用内部 API(不理解接口的设计意图)

2.4 代码质量和可维护性

AI 生成的代码往往追求「能跑就行」,但忽略了可读性和可维护性。常见问题包括:

  • 过度复杂的嵌套和链式调用
  • 缺乏有意义的变量名和注释
  • 不符合团队的编码规范
  • 没有考虑扩展性和重用性

当我们习惯了 AI 写代码,可能会不想去看代码(自信点,就是不想看),如果这样过度依赖 AI,可能会失去对代码的深度理解。当需要调试或优化时,面对一堆自己没真正理解的代码,问题就会比较大,甚至出了问题还需要现场看代码来定位问题。

小结

写了这么多,核心观点其实很简单 :AI 编程工具是很强大,但也有明显的局限性。我们需要清醒地认识这些局限,合理地使用工具,同时不断提升自己的核心能力。

代码是我们与机器对话的语言,但写代码的意义,永远是为了解决人的问题。无论工具如何进化,这一点不会变。

所以,继续写代码吧,带着思考去写,带着责任去写。让 AI 成为你的助手,而不是你的拐杖。

毕竟,最好的代码,永远是有灵魂的代码,在写代码中注入心流。

以上。

DeepSeek 和腾讯元宝都选择在用的SSE 到底是什么?

在我们和 AI 聊天中,AI Chat 都采用了一种「打字机」式效果的实时响应方式,AI 的回答逐字逐句地呈现在我们眼前。

在实现这个功能的技术方案选择上,不管是 DeepSeek ,还是腾讯元宝都在这个对话逻辑中选择了使用 SSE,如下面 4 张图:

这是为啥,它有什么优势,以及如何实现的。

SSE 的优势

因为它在该场景下的优势非常明显,主要是以下 4 点:

1.场景的高度匹配。

AI 对话的核心交互模式是:

  1. 用户发起一次请求(发送问题)。
  2. AI 进行一次持续的、单向的响应输出(生成回答)。

SSE 的单向通信(服务器 → 客户端)模型与这个场景高度切尔西。它就像一条专门为服务器向客户端输送数据的「单行道」,不多一分功能,也不少一毫关键。

相比之下,WebSocket 提供的是全双工通信,即客户端和服务器可以随时互相发送消息,这是一条「双向八车道高速公路」。为了实现 AI 的流式回答,我们只需要其中一个方向的车道,而另一方向的车道(客户端 → 服务器)在 AI 回答期间是闲置的。在这种场景下使用 WebSocket,无异于「杀鸡用牛刀」,引入了不必要的复杂性。用户的提问完全可以通过另一个独立的、常规的 HTTP POST 请求来完成,这让整个系统的架构更加清晰和解耦。

2.HTTP 原生支持,与生俱来的优势

SSE 是建立在标准 HTTP 协议之上的。这意味着:

  • 无需协议升级:SSE 连接的建立就是一个普通的 HTTP GET 请求,服务器以 Content-Type: text/event-stream 响应。而 WebSocket 则需要一个特殊的「协议升级」(Upgrade)握手过程,从 HTTP 切换到 ws://wss:// 协议,过程相对复杂。
  • 兼容性极佳:由于它就是 HTTP,所以它能天然地穿透现有的网络基础设施,包括防火墙、企业代理、负载均衡器等,几乎不会遇到兼容性问题。WebSocket 有时则会因为代理服务器不支持其协议升级而被阻断。并且云服务商对于 Websocket 的支持并不是很完善。
  • 实现轻量:无论是前端还是后端,实现 SSE 都非常简单。前端一个 EventSource API 即可搞定,后端也只需遵循简单的文本格式返回数据流。这大大降低了开发和维护的成本。

3.断网自动重连,原生容错

这是 SSE 的「王牌特性」,尤其在网络不稳定的移动端至关重要。

想象一下,当 AI 正在为我们生成一篇长文时,我们的手机网络突然从 Wi-Fi 切换到 5G,造成了瞬间的网络中断。

  • 如果使用 WebSocket:连接会断开,我们需要手动编写复杂的 JavaScript 代码来监听断开事件、设置定时器、尝试重连、并在重连成功后告知服务器从哪里继续,实现起来非常繁琐。
  • 如果使用 SSE浏览器会自动处理这一切EventSource API 在检测到连接中断后,会自动在几秒后(这个间隔可以通过 retry 字段由服务器建议)发起重连。更棒的是,它还会自动将最后收到的消息 id 通过 Last-Event-ID 请求头发送给服务器,让服务器可以从中断的地方继续推送数据,实现无缝的「断点续传」。当然,Last-Event-ID 的处理逻辑需要服务端来处理。

这种由浏览器原生提供的、可靠的容错机制,为我们省去了大量心力,并极大地提升了用户体验。

4. 易于调试

因为 SSE 的数据流是纯文本并通过标准 HTTP 传输,调试起来异常方便:

  • 我们可以直接在浏览器地址栏输入 SSE 端点的 URL,就能在页面上看到服务器推送的实时文本流。
  • 我偿可以使用任何 HTTP 调试工具,如 curl 命令行或者 Chrome 开发者工具的「网络」面板,清晰地看到每一次数据推送的内容。

而 WebSocket 的数据传输基于帧,格式更复杂,通常需要专门的工具来调试和分析。

使用 SSE 实现「打字机」效果

1.后端——调用大模型并开启「流式」开关

当后端服务器收到用户的问题后,它并不等待大语言模型生成完整的答案。相反,它在调用 LLM 的 API 时,会传递一个关键参数:stream=True

这个参数告诉 LLM:「请不要等全部内容生成完再给我,而是每生成一小部分(通常是一个或几个‘词元’/Token),就立刻通过数据流发给我。」

下面是一个使用 Python 和 OpenAI API 的后端伪代码示例:

python

代码解读
复制代码
from flask import Flask, Response, request
import openai
import json

app = Flask(__name__)

# 假设 OpenAI 的 API Key 已经配置好
# openai.api_key = "YOUR_API_KEY"

@app.route('/chat-stream')
def chat_stream():
    prompt = request.args.get('prompt')

    def generate_events():
        try:
            # 关键:设置 stream=True
            response_stream = openai.ChatCompletion.create(
                model="gpt-4", # 或其他模型
                messages=[{"role": "user", "content": prompt}],
                stream=True 
            )

            # 遍历从大模型返回的数据流
            for chunk in response_stream:
                # 提取内容部分
                content = chunk.choices[0].delta.get('content', '')
                if content:
                    # 关键:将每个内容块封装成 SSE 格式并 yield 出去
                    # 使用 json.dumps 保证数据格式正确
                    sse_data = f"data: {json.dumps({'token': content})}\n\n"
                    yield sse_data

            # (可选) 发送一个结束信号
            yield "event: done\ndata: [STREAM_END]\n\n"

        except Exception as e:
            # 错误处理
            error_message = f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
            yield error_message

    # 返回一个流式响应,并设置正确的 MIME 类型
    return Response(generate_events(), mimetype='text/event-stream')

if __name__ == '__main__':
    app.run(threaded=True)

在这段代码中,有几个关键点:

  1. stream=True:向 LLM 请求流式数据。
  2. 生成器函数(generate_events:使用 yield 关键字,每从 LLM 收到一小块数据,就立即将其处理成 SSE 格式(data: ...\n\n)并发送出去。
  3. Response(..., mimetype='text/event-stream'):告诉浏览器,这是一个 SSE 流,请保持连接并准备接收事件。
2.SSE 格式的约定

后端 yield 的每一条 data: 都像是一个个装着文字的信封,通过 HTTP 长连接这个管道持续不断地寄给前端。

前端收到的原始数据流看起来就像这样:

vbnet

代码解读
复制代码
data: {"token": "当"}

data: {"token": "然"}

data: {"token": ","}

data: {"token": "很"}

data: {"token": "乐"}

data: {"token": "意"}

data: {"token": "为"}

data: {"token": "您"}

data: {"token": "解"}

data: {"token": "答"}

data: {"token": "。"}

event: done
data: [STREAM_END]

看 DeepSeek 和腾讯元宝的数据格式,略有不同,不过有一点,都是直接用的 JSON 格式,且元宝的返回值相对冗余一些。 且都没有 data: 的前缀。

3.前端监听并拼接成「打字机」

前端的工作就是接收这些「信封」,拆开并把里面的文字一个个地追加到聊天框里。

html

代码解读
复制代码
<!-- HTML 结构 -->
<div id="chat-box"></div>
<input id="user-input" type="text">
<button onclick="sendMessage()">发送</button>

<script>
    let eventSource;

    function sendMessage() {
        const input = document.getElementById('user-input');
        const prompt = input.value;
        input.value = '';

        const chatBox = document.getElementById('chat-box');
        // 创建一个新的 p 标签来显示 AI 的回答
        const aiMessageElement = document.createElement('p');
        aiMessageElement.textContent = "AI: ";
        chatBox.appendChild(aiMessageElement);

        // 建立 SSE 连接
        eventSource = new EventSource(`/chat-stream?prompt=${encodeURIComponent(prompt)}`);

        // 监听 message 事件,这是接收所有 "data:" 字段的地方
        eventSource.onmessage = function(event) {
            // 解析 JSON 字符串
            const data = JSON.parse(event.data);
            const token = data.token;

            if (token) {
                // 将新收到的文字追加到 p 标签末尾
                aiMessageElement.textContent += token;
            }
        };

        // 监听自定义的 done 事件,表示数据流结束
        eventSource.addEventListener('done', function(event) {
            console.log('Stream finished:', event.data);
            // 关闭连接,释放资源
            eventSource.close();
        });

        // 监听错误
        eventSource.onerror = function(err) {
            console.error("EventSource failed:", err);
            aiMessageElement.textContent += " [出现错误,连接已断开]";
            eventSource.close();
        };
    }
</script>

这段代码主要有如下的点:

  1. new EventSource(...):发起连接。
  2. eventSource.onmessage:这是主要的处理函数。每当收到一条 data: 消息,它就会被触发。
  3. aiMessageElement.textContent += token;:这就是「打字机」效果的精髓所在——持续地在同一个 DOM 元素上追加内容,而不是创建新的元素。
  4. eventSource.close():在接收到结束信号或发生错误后,务必关闭连接,以避免不必要的资源占用。

EventSource 的来源与发展

在 SSE 标准化之前,Web 的基础是 HTTP 的请求-响应模型:客户端发起请求,服务器给予响应,然后连接关闭。这种模式无法满足服务器主动向客户端推送信息的需求。为了突破这一限制,开发者们创造了多种「模拟」实时通信的技术。

  1. 短轮询:这是最简单直接的方法。客户端通过 JavaScript 定时(如每隔几秒)向服务器发送一次 HTTP 请求,询问是否有新数据。无论有无更新,服务器都会立即返回响应。这种方式实现简单,但缺点显而易见:存在大量无效请求,实时性差,并且对服务器造成了巨大的负载压力。
  2. 长轮询:为了改进短轮询,长轮询应运而生。客户端发送一个请求后,服务器并不会立即响应,而是会保持连接打开,直到有新数据产生或者连接超时。一旦服务器发送了数据并关闭了连接,客户端会立即发起一个新的长轮询请求。这大大减少了无效请求,提高了数据的实时性,但仍然存在 HTTP 连接的开销,并且实现起来相对复杂。
  3. Comet:一个时代的统称:在 HTML5 标准化之前,像长轮询和 HTTP 流(HTTP Streaming)这样的技术被统称为 Comet。 Comet 是一种设计模式,它描述了使用原生 HTTP 协议在服务器和浏览器之间实现持续、双向交互的多种技术集合。 它是对实现实时 Web 应用的早期探索,为后来更成熟的标准化技术(如 SSE 和 WebSockets)奠定了基础。

随着 Web 应用对实时性要求的日益增长,需要一种更高效、更标准的解决方案。

  • WHATWG 的早期工作:SSE 机制最早由 Ian Hickson 作为「WHATWG Web Applications 1.0」提案的一部分,于 2004 年开始进行规范制定。
  • Opera 的先行实践:2006 年 9 月,Opera 浏览器在一项名为“Server-Sent Events”的功能中,率先实验性地实现了这项技术,展示了其可行性。
  • HTML5 标准化:最终,SSE 作为 HTML5 标准的一部分被正式确立。它通过定义一种名为 text/event-stream 的 MIME 类型,让服务器可以通过一个持久化的 HTTP 连接向客户端发送事件流。 客户端一旦与服务器建立连接,就会保持该连接打开,持续接收服务器发送的数据。

SSE 的本质是利用了 HTTP 的流信息机制。服务器向客户端声明接下来要发送的是一个数据流,而不是一次性的数据包,从而实现了一种用时很长的「下载」过程,服务器得以在此期间不断推送新数据。

其返回内容标准大概如下:

event-source 必须编码成 utf8 的格式,消息的每个字段都是用”\n”来做分割,下面 4 个规范定义好的字段:

  1. Event: 事件类型
  2. Data: 发送的数据
  3. ID:每一条事件流的ID
  4. Retry: 告知浏览器在所有的连接丢失之后重新开启新的连接等待的事件,在自动重连连接的过程中,之前收到的最后一个事件流ID会被发送到服务器

在实际中,大概率不一定按这个标准来实现。对于一些重连的逻辑需要自行实现。

现在大部分的浏览器都兼容这个特性,如图:

参考资料:

  1. en.wikipedia.org/wiki/Server…
  2. learn.microsoft.com/zh-cn/azure…
  3. www.cnblogs.com/openmind-in…
  4. javascript.ruanyifeng.com/htmlapi/eve…

以上。