一、先讲一个场景
晚上十一点,小林刚加完班,瘫在椅子上,打开了一个叫"晚灯"的页面。
她打字:"今天又被老板骂了,觉得自己什么都做不好。"
回车。
她看到自己的消息立刻出现在屏幕上。然后,屏幕下方慢慢浮出一个空白的气泡,一个字一个字地长出来:
"先抱抱你。被批评的时候确实很难受,但这不代表你什么都做不好……"
小林看着那些字像有人在对面慢慢打字一样,一颗一颗跳出来。她没觉得对面是机器,她只觉得——有人在听。
这就是"晚灯"想做的事。它不是要证明自己有多智能,它只是想做好一件事:把一句话慢慢说完。
但"慢慢说完"这件事,在工程上远比"一次性说完"要复杂得多。
二、这盏灯是怎么亮的
晚灯是一个中文 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。应用启动时,会先悄悄做一件事:问问后端"我是不是之前来过?"如果后端认出了你,就恢复会话,然后才点亮页面。
路由只有三条路:
路由守卫很简单:没登录的,不能进房间;已经在房间的,不用再回门口;找不到路的,根据你的状态送你到该去的地方。
一个小故事:刷新之后,灯为什么还能亮?
有一天,小林聊到一半,手滑刷新了页面。
她心跳停了一拍——刚才聊到哪了?那些话还在吗?
如果晚灯把"通行证"存在浏览器的 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 说话的时候打断它、说错了可以重试、可以切到别的聊天窗口再回来,而页面不能乱。
发送消息时,到底发生了什么?
小林按下回车。前端没有傻等,而是立刻做了三件事:
先画出来:如果这是新对话,先创建一个会话;然后把小林的话立刻贴到屏幕上。这叫"乐观更新"——我先当你成功了,不给用户留空白。
挂好刹车:创建一个
AbortController,标记这个会话正在"流式生成"中。打开水龙头:调用
streamPost(),向后端要一个 SSE 流。
后端会发来五种"信号":
收到 message_started 时,屏幕上会出现一个空气泡,状态是"streaming"。收到 delta,就往里面加字。收到 completed 或 stopped,就把状态改掉,清理"正在输入"的标记。
那条"正在输入"的消息
小林发完那句"被老板骂了",如果系统憋了 8 秒钟,突然砸过来一大段安慰,她会觉得自己在跟一台复印机说话。
但她看到的是:自己的消息先出现,然后下面慢慢浮出一个空白气泡,一个字一个字长出来——"先抱抱你。没过不是你不行……"
这时候,流式响应不是技术炫技,它是情绪节奏。
状态管理的意义也在这里:不是为了让代码看起来更高级,而是为了让产品在关键时刻不掉链子。
五、为什么不用 EventSource,自己造了一个轮子?
浏览器原生的 EventSource 很适合收 SSE,但它有个毛病:只能发 GET,不方便带 POST body。
可聊天接口需要提交不少东西:
{
"content": "用户输入",
"persona_id": "minmin",
"force_web_search": false
}所以项目在 frontend/src/lib/sse.ts 里自己写了一个 streamPost():
用
fetch()发 POST 请求请求头写上
Accept: text/event-stream拿到
response.body.getReader()读取二进制流用
TextDecoder解码按 SSE 的空行规则把数据块拆开
解析
event:和data:行做成 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.py 的 stream_chat_response()。
这个函数做了一件很重要的事:边写边说。
先把用户消息存进数据库。
创建一条空的 AI 消息,状态标记为
streaming。提交事务。
告诉前端:"我开始说了,这是消息 ID。"
构造上下文和 system prompt。
调用模型,流式生成。
每收到一个 delta,就追加到数据库并 commit。
如果发现
cancel_requested,标记为stopped。正常结束标记
complete。异常标记
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.py 和 tools.py 里完成。系统会判断问题是否需要实时信息(天气、新闻、股价、日期等),或者用户手动打开联网开关,就调用 search_web 工具,通过 Tavily 查询。
前端只提供开关,真正的 Tavily API Key 和工具调用都在服务端。
九、安全策略:越像人,越要有边界
backend/app/services/safety.py 里有一个轻量风险分类:
normal(正常)elevated(需要关注)imminent(紧急)
如果用户文本包含明显的自伤风险表达,后端会在 system prompt 里加入额外约束:保持关怀语气,鼓励联系可信任的人或专业支持,不诊断疾病,不承诺保密,不把服务包装成现实支持的替代品。
这部分代码不复杂,但方向是对的。
AI 陪伴产品最容易犯的错,是为了"更像陪伴"而越过现实边界。真正可靠的陪伴不是把用户封闭在产品里,而是在危险时刻把人带回现实支持网络。
十、数据库:每一句对话都有"状态"
主要数据表:
消息表的关键字段:
role:user 或 assistantcontent:内容status:complete / streaming / stopped / failedcancel_requested:服务端停止标记error_code:失败原因
这些字段让"流式生成"从一个前端动画,变成了可持久化、可恢复、可审计的业务状态。
项目早期还规划过 relationship_events 和 memory_jobs,但后续移除了。这像是一个产品从"想做好多事"收敛到"先把一件事做好"的过程。
十一、部署:让流不被堵住
多阶段 Dockerfile:
Node 镜像构建前端静态资源
Python 镜像构建 FastAPI 后端
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. 情感陪伴产品要有"边界感"
人设可以亲密,语气可以温暖,但系统提示必须明确禁止情感勒索、封闭依赖和危险承诺。越是亲密型产品,越需要工程上的冷静。
十三、还可以继续点亮的地方
当前版本已经完成了核心聊天闭环,但还有一些可以推进的方向:
"我们的故事"模块:恢复或重设计,但要先明确它和会话摘要的边界。
自动提炼记忆:让
UserProfile和ConversationSummary真正自动更新。中文风险识别:目前自伤关键词偏英文,需要优化。
消息分页的前端交互:后端已支持
before参数,前端可以加上"加载更多"。搜索来源展示:让用户知道哪些信息来自联网检索。
编码问题:修复
frontend/src/stores/auth.ts中几处中文文案的编码错误。
结语:一盏好灯,不只是"能亮"
晚灯这个项目最有价值的地方,不在于它用了 Vue 3 还是 FastAPI,也不在于它接的是哪个大模型。
而在于它把一个聊天产品该有的骨架,一根一根搭了起来:
前端用状态管理,接住了流式体验的温度。
后端用 SSE,把模型输出变成了可消费的事件。
数据库记录了每一条消息的生命周期。
鉴权体系守住了用户数据的门。
工具调用和 API Key 留在了服务端。
安全 prompt 给情感陪伴画了一条线。
用户看到的是"我会认真听"。
工程上对应的是:消息先落库,流式可停止,失败可重试,令牌不乱放,删除能落实,边界不越线。
也正是这些安静的细节,才让一盏晚灯——真的亮得住。