网站Logo 开飞机的舒克

晚灯:AI 聊天产品的状态管理、SSE 流式响应与安全边界设计

dddd
0
2026-07-27

一、先讲一个场景

晚上十一点,小林刚加完班,瘫在椅子上,打开了一个叫"晚灯"的页面。

她打字:"今天又被老板骂了,觉得自己什么都做不好。"

回车。

她看到自己的消息立刻出现在屏幕上。然后,屏幕下方慢慢浮出一个空白的气泡,一个字一个字地长出来:

"先抱抱你。被批评的时候确实很难受,但这不代表你什么都做不好……"

小林看着那些字像有人在对面慢慢打字一样,一颗一颗跳出来。她没觉得对面是机器,她只觉得——有人在听。

这就是"晚灯"想做的事。它不是要证明自己有多智能,它只是想做好一件事:把一句话慢慢说完。

但"慢慢说完"这件事,在工程上远比"一次性说完"要复杂得多。


二、这盏灯是怎么亮的

晚灯是一个中文 AI 陪伴聊天应用。前端用 Vue 3,后端用 FastAPI,数据库是 PostgreSQL。大模型接的是 DeepSeek,联网搜索用的是 Tavily。

你可以把整条链路想象成一盏台灯的结构:

用户(你)→ 灯罩(Vue 3 前端)→ 灯芯(Pinia 状态管理)
                                      ↓
                              电线(REST API / SSE 流)
                                      ↓
                              电源(FastAPI 后端)→ 灯泡(DeepSeek 模型)
                                      ↓
                              记忆(PostgreSQL)+ 眼睛(Tavily 搜索)

前端负责让你看着舒服,后端负责让一切安全可靠。中间那条分界线很重要——情感陪伴产品越像人,越不能在安全边界上含糊。


三、前端:灯罩不藏电,只负责光

前端的技术栈很克制:Vue 3 + TypeScript + Vite + Tailwind CSS + Pinia。

入口在 frontend/src/main.ts。应用启动时,会先悄悄做一件事:问问后端"我是不是之前来过?"如果后端认出了你,就恢复会话,然后才点亮页面。

路由只有三条路:

路径

页面

意义

/login

登录/注册

没进门的时候

/chat

聊天页面

进门之后的主房间

/settings

设置

你可以随时走,也可以把灯关掉

路由守卫很简单:没登录的,不能进房间;已经在房间的,不用再回门口;找不到路的,根据你的状态送你到该去的地方。

一个小故事:刷新之后,灯为什么还能亮?

有一天,小林聊到一半,手滑刷新了页面。

她心跳停了一拍——刚才聊到哪了?那些话还在吗?

如果晚灯把"通行证"存在浏览器的 localStorage 里,刷新后恢复确实很容易。但那样就像把家里钥匙挂在门把手上,谁路过都能看一眼。

晚灯没这么做。

它把短期的"通行证"(access token)存在脑子里(内存),刷新就忘;把长期的"钥匙"(refresh token)交给楼下前台(HttpOnly cookie),浏览器能自动去取,但页面上的代码碰不到。

所以小林刷新后,页面会默默去后台问一声:"我之前的钥匙还在吗?"在的话,重新领一张通行证,灯重新亮起,聊天记录也还在。

不是不让你进门,只是不让钥匙挂在显眼的地方。


四、Pinia:这盏灯的"中控台"

聊天页面 ChatView.vue 本身不处理复杂逻辑,它更像一个舞台导演,负责调度几个演员:

  • ConversationSidebar(左侧的聊天记录)

  • ChatMessage(对话气泡)

  • ChatComposer(输入框)

  • PersonaSwitcher(切换人设)

  • ConfirmDialog(确认弹窗)

真正的"大脑"在 frontend/src/stores/chat.ts 里。

这个 store 管着几样东西:

conversations                    // 所有会话
selectedConversationId           // 当前在看哪一盏灯
messagesByConversation           // 每个会话里的消息
pendingMessageIdByConversation   // 正在"打字中"的那条消息
streamAbortByConversation        // 每个会话的"紧急刹车"
selectedPersonaId                // 你选了谁陪你聊天
forceWebSearch                   // 要不要去网上查资料
loading / error                  // 状态

最核心的是这三个:消息列表、正在生成的消息、紧急刹车。

它们组合在一起,解决了一个聊天产品里最麻烦的问题:用户可以在 AI 说话的时候打断它、说错了可以重试、可以切到别的聊天窗口再回来,而页面不能乱。

发送消息时,到底发生了什么?

小林按下回车。前端没有傻等,而是立刻做了三件事:

  1. 先画出来:如果这是新对话,先创建一个会话;然后把小林的话立刻贴到屏幕上。这叫"乐观更新"——我先当你成功了,不给用户留空白。

  2. 挂好刹车:创建一个 AbortController,标记这个会话正在"流式生成"中。

  3. 打开水龙头:调用 streamPost(),向后端要一个 SSE 流。

后端会发来五种"信号":

信号

含义

message_started

AI 开始回复了,给你一个消息 ID

delta

又来了一段字,接着往上贴

completed

说完了

stopped

用户按了停止,我也停了

error

出错了,但可以重试

收到 message_started 时,屏幕上会出现一个空气泡,状态是"streaming"。收到 delta,就往里面加字。收到 completedstopped,就把状态改掉,清理"正在输入"的标记。

那条"正在输入"的消息

小林发完那句"被老板骂了",如果系统憋了 8 秒钟,突然砸过来一大段安慰,她会觉得自己在跟一台复印机说话。

但她看到的是:自己的消息先出现,然后下面慢慢浮出一个空白气泡,一个字一个字长出来——"先抱抱你。没过不是你不行……"

这时候,流式响应不是技术炫技,它是情绪节奏。

状态管理的意义也在这里:不是为了让代码看起来更高级,而是为了让产品在关键时刻不掉链子。


五、为什么不用 EventSource,自己造了一个轮子?

浏览器原生的 EventSource 很适合收 SSE,但它有个毛病:只能发 GET,不方便带 POST body。

可聊天接口需要提交不少东西:

{
  "content": "用户输入",
  "persona_id": "minmin",
  "force_web_search": false
}

所以项目在 frontend/src/lib/sse.ts 里自己写了一个 streamPost()

  1. fetch() 发 POST 请求

  2. 请求头写上 Accept: text/event-stream

  3. 拿到 response.body.getReader() 读取二进制流

  4. TextDecoder 解码

  5. 按 SSE 的空行规则把数据块拆开

  6. 解析 event:data:

  7. 做成 async generator,一个一个 yield 出来

业务层用起来很舒服:

for await (const event of streamPost(...)) {
  applyStreamEvent(conversationId, event)
}

读起来就像:只要后端还在说,我就继续听。


六、后端:电源箱里的事,不能含糊

后端入口在 backend/app/main.py,注册了四条路由:

  • auth_router:注册、登录、刷新、退出、改密码

  • account_router:注销账号

  • conversations_router:会话和消息

  • settings_router:改昵称

技术栈:Python 3.12 + FastAPI + SQLAlchemy 2 async + PostgreSQL + Argon2id + PyJWT。

鉴权:两张票,一张随时丢,一张锁在柜子里

  • access token:短期 JWT,存在前端脑子里(内存),丢了就丢了。

  • refresh token:随机字符串,只以 HttpOnly cookie 存在浏览器,JS 碰不到。

  • 数据库里只存 refresh token 的 SHA-256 哈希。

  • 登录、注册、刷新都会换新的 session。

  • 退出、改密、注销会立刻废掉旧的 session。

密码用 Argon2id 哈希。手机号要符合国内 11 位规则,密码至少 12 位。

注销账号:不是把灯关掉,是把线拔掉

情感陪伴产品有个特殊之处:用户留下的不是普通数据,而是大量的私人表达。

晚灯的数据表通过 user_id 关联,外键级联删除。用户确认密码后,后端直接删除用户记录,相关的会话、消息、刷新 session、用户画像,全部跟着清理。

删除不是前端表演,是数据库事实。


七、聊天后端:先落笔,再说话

聊天接口在:

POST /conversations/{conversation_id}/messages/stream

它会先确认"这个会话确实是你的",然后返回 StreamingResponse,真正的生成逻辑交给 backend/app/services/chat.pystream_chat_response()

这个函数做了一件很重要的事:边写边说。

  1. 先把用户消息存进数据库。

  2. 创建一条空的 AI 消息,状态标记为 streaming

  3. 提交事务。

  4. 告诉前端:"我开始说了,这是消息 ID。"

  5. 构造上下文和 system prompt。

  6. 调用模型,流式生成。

  7. 每收到一个 delta,就追加到数据库并 commit。

  8. 如果发现 cancel_requested,标记为 stopped

  9. 正常结束标记 complete

  10. 异常标记 failed,发 error 事件。

为什么不等说完再存?

  • 刷新页面后,已经生成的字不会丢。

  • 按了停止,已经出来的片段能留住。

  • 失败了,前端知道该显示"重试"。

  • 服务端是消息状态的最终来源。

用户点下"停止"的那一秒

很多产品的"停止生成",只是前端把耳朵捂上了。看起来停了,但后端可能还在请求模型,数据库也不知道用户不想听了。

晚灯的处理更完整:

前端拿到 message_started 后,就知道这条 AI 消息的 ID。用户点击停止,前端会调用:

POST /messages/{message_id}/stop

后端把这条消息的 cancel_requested 设为 true。生成循环每次写入后都会检查这个标记,一旦发现,就把状态改成 stopped,并发出 stopped 事件。

这就像在对话里真的说了一句"先停一下"——不是把耳机摘掉假装没听见,而是真的告诉对方:这一段到这里就好。


八、人设、记忆与联网搜索

晚灯目前有两个人设:

  • 女性恋人:温暖、亲密、略带俏皮

  • 男性恋人:沉稳、照顾感强、带点幽默

前端负责展示和选择,后端负责把 persona 真正写进 system prompt。

上下文构造在 backend/app/services/context.py 里,包含:

  • 最近的用户画像 UserProfile

  • 最近的会话摘要 ConversationSummary

  • 当前会话最近 16 条消息

这些内容会被整理成 system context,连同用户输入一起发给模型。

联网搜索在 backend/app/services/deepseek.pytools.py 里完成。系统会判断问题是否需要实时信息(天气、新闻、股价、日期等),或者用户手动打开联网开关,就调用 search_web 工具,通过 Tavily 查询。

前端只提供开关,真正的 Tavily API Key 和工具调用都在服务端。


九、安全策略:越像人,越要有边界

backend/app/services/safety.py 里有一个轻量风险分类:

  • normal(正常)

  • elevated(需要关注)

  • imminent(紧急)

如果用户文本包含明显的自伤风险表达,后端会在 system prompt 里加入额外约束:保持关怀语气,鼓励联系可信任的人或专业支持,不诊断疾病,不承诺保密,不把服务包装成现实支持的替代品。

这部分代码不复杂,但方向是对的。

AI 陪伴产品最容易犯的错,是为了"更像陪伴"而越过现实边界。真正可靠的陪伴不是把用户封闭在产品里,而是在危险时刻把人带回现实支持网络。


十、数据库:每一句对话都有"状态"

主要数据表:

作用

users

账号、手机号、密码哈希、昵称

auth_sessions

refresh token 会话

conversations

会话

messages

用户与 AI 的消息

user_profiles

用户画像片段

conversation_summaries

会话摘要

消息表的关键字段:

  • role:user 或 assistant

  • content:内容

  • status:complete / streaming / stopped / failed

  • cancel_requested:服务端停止标记

  • error_code:失败原因

这些字段让"流式生成"从一个前端动画,变成了可持久化、可恢复、可审计的业务状态。

项目早期还规划过 relationship_eventsmemory_jobs,但后续移除了。这像是一个产品从"想做好多事"收敛到"先把一件事做好"的过程。


十一、部署:让流不被堵住

多阶段 Dockerfile:

  1. Node 镜像构建前端静态资源

  2. Python 镜像构建 FastAPI 后端

  3. Nginx 镜像托管前端,并把 /api/ 反向代理到后端

docker-compose.yml 编排三个服务:postgres、api、web。

生产环境里,Nginx 对 /api/ 关闭了 buffering。这对 SSE 很关键——如果代理层把响应攒起来,用户就看不到逐字输出,只会在缓冲区满了或请求结束时,突然收到一大段。

流式体验,从后端到前端,中间不能有任何一层"攒着不发"。


十二、这套设计到底做对了什么?

1. 把聊天状态当成"业务状态",不是"UI 临时状态"

消息有生命周期:

用户发送 → AI 开始 → 逐字追加 → 完成 / 停止 / 失败 → 重试

Pinia 和数据库都围绕这个生命周期设计,前后端才能对齐。

2. POST SSE 比 EventSource 更适合聊天

聊天请求需要 body:内容、人设、工具开关、上下文参数。用 fetch + ReadableStream 自己实现,比硬套 EventSource 灵活得多。

3. 安全边界必须服务端优先

模型 API Key、Tavily Key、refresh token 哈希、用户数据隔离、注销删除——这些都该后端掌控。前端负责体验,但不能成为可信源。

4. 停止生成要通知服务端

真正的停止,不只是 AbortController.abort()。前端中断读取只能影响浏览器,服务端状态也要同步变化。

5. 情感陪伴产品要有"边界感"

人设可以亲密,语气可以温暖,但系统提示必须明确禁止情感勒索、封闭依赖和危险承诺。越是亲密型产品,越需要工程上的冷静。


十三、还可以继续点亮的地方

当前版本已经完成了核心聊天闭环,但还有一些可以推进的方向:

  • "我们的故事"模块:恢复或重设计,但要先明确它和会话摘要的边界。

  • 自动提炼记忆:让 UserProfileConversationSummary 真正自动更新。

  • 中文风险识别:目前自伤关键词偏英文,需要优化。

  • 消息分页的前端交互:后端已支持 before 参数,前端可以加上"加载更多"。

  • 搜索来源展示:让用户知道哪些信息来自联网检索。

  • 编码问题:修复 frontend/src/stores/auth.ts 中几处中文文案的编码错误。


结语:一盏好灯,不只是"能亮"

晚灯这个项目最有价值的地方,不在于它用了 Vue 3 还是 FastAPI,也不在于它接的是哪个大模型。

而在于它把一个聊天产品该有的骨架,一根一根搭了起来:

  • 前端用状态管理,接住了流式体验的温度。

  • 后端用 SSE,把模型输出变成了可消费的事件。

  • 数据库记录了每一条消息的生命周期。

  • 鉴权体系守住了用户数据的门。

  • 工具调用和 API Key 留在了服务端。

  • 安全 prompt 给情感陪伴画了一条线。

用户看到的是"我会认真听"。

工程上对应的是:消息先落库,流式可停止,失败可重试,令牌不乱放,删除能落实,边界不越线。

也正是这些安静的细节,才让一盏晚灯——真的亮得住。