上一篇完成了一次教科书式的两步走:先把存储代码从 main.py 原样搬进 storage.py,把 "取几条” 的决定权交还给调用方;再把存储实现整个换成 SQLite——建表、INSERT、一句 SELECT 加索引,接口约定纹丝不动,前端毫无察觉;善后也一并做完:数据不进 Git、history.json 退役、给 created_at 建了索引;只剩最后一件事——历史还是全站一份,所有访客的记录混在一张表里,谁查都是全部;这一篇讲会话:弄清服务器为什么记不住人,再用 UUID 和 cookie 把散落的请求认成同一个访客,让每个人只看到自己的历史
状态与会话:
先把历史显示出来:
这一篇要讲的是会话;讲会话之前,先动手改点东西
用户的查询历史已经被存进数据库了,通过 /api/history 也能查出来——只是页面上一直没显示它;所以先对前端做一次小迭代,把历史记录显示出来
前端直接替换:
前端不是这一部分的重点,所以不手敲,直接从 demo 仓库拿代码:
git clone https://github.com/joylibo/zero-to-tech-demos.git cp zero-to-tech-demos/zero-to-tech-6-6/components/*.jsx ~/zero-to-tech/components/ cp zero-to-tech-demos/zero-to-tech-6-6/css/lab.css ~/zero-to-tech/css/新增了一个文件、更新了三个文件:
新增 HistoryModal.jsx——历史记录的弹窗
更新 ResultCard.jsx——右上角加了一个「历史记录」按钮
更新 TextLabView.jsx——管弹窗的开关,点开的那一刻才去请求 /api/history
更新 lab.css——按钮和弹窗的样式
搬完就行,这些代码不展开讲——前端不是这里要说的事
跑起来:
启动前端:
cd ~/zero-to-tech npm run dev启动后端:
cd ~/zero-to-tech/backend source .venv/bin/activate fastapi dev前后端都启动后,打开文字实验室,结果卡的右上角就会出现「历史记录」按钮,点击可以展开历史记录——它背后请求的就是 /api/history 接口
随便分析两句,再点开「历史记录」,就能看到刚刚分析的内容已经可以在历史记录中查看了;功能做完了,看上去一切正常
换一个浏览器,再看一眼:
先别急着往下走——这一步请一定亲手做一次
换一个浏览器打开同一个地址:刚才用的如果是 Chrome,现在就换 Safari、Edge、Firefox 都行;实在不想装,用 Chrome 开一个无痕窗口也可以;打开 http://localhost:3000,然后什么都别分析,直接点开「历史记录」
看到了什么?
刚才在 Chrome 里打的那几句话,一字不差地出现在这个新浏览器的历史记录里
请再往前想一步:这还只是同一台电脑上的两个浏览器;换成两台电脑、两个人,结果一模一样——只要访问的是同一个后端,看到的就是同一份历史
问题出在哪:
这就麻烦了;如果这个网站真的发布上线:
我打的字,所有陌生人都看得见
别人打的字,也全都堆在我的历史记录里
没有人会想要这样的 "历史记录";我们想要的显然是每个人看自己的那一份
这本质上是因为我们目前做的这个网站应用是「不认人」的:所有访客的记录混在一张表里,谁来查都是查这张表的全部
那给它加上 "认人" 不就行了?这正是这一部分要干的活;不过在动手之前,得先把一件事弄明白——它为什么会认不出人
服务器为什么不认人:
文字实验室前端有了、后端有了、数据库也有了,为什么它还是认不出人?
先看直接原因:对服务端 API 来说,它的任务就是处理前端发来的每一次 HTTP 请求、返回响应——而处理每一次请求时留下的东西,不会延续到下一次
我们把 HTTP 拆开看过:一个请求从前端到后端,一个响应从后端到前端,这一轮就结束了;关键在 "结束" 这两个字:请求处理完,服务器就把这一轮的一切都扔掉了;下一个请求再来,在它眼里就是一个全新的陌生人来敲门——它不记得上一个是谁,也不认为这两个之间有什么关系
所以不是服务器不想认,是它压根没留下任何能用来认人的东西;这个 "处理完就全忘" 的脾气,就叫做无状态 (stateless)——HTTP 就是典型的无状态,任意两次请求完全独立
顺带说一句:不认人并不总是缺陷;互联网上有大把网站从头到尾都不认人,比如 FastAPI 的官网 fastapi.tiangolo.com、Vite 的官网 vite.dev——它们不是 "认不出",是压根不需要认:来的人只是读文档,认得出是谁毫无意义,就没必要费这个劲
只有当一个应用要为每个人分别留住点什么的时候——历史记录、购物车、草稿、偏好设置—— "认人" 才变成一道绕不过去的坎;我们的文字实验室,刚好走到了这一步
让大模型 API 忘给我们看:
无状态不是 HTTP 一家独有,可以看一个更极端、也更好玩的例子:在终端里用 curl 调用 DeepSeek API(没有 API Key 也可以对照下面的输出往下看)
第一次调用,我们告诉它我们的名字(thinking、reasoning_effort 两个参数是让它 "深度思考" 用的,这里只要最简单直接的问答,所以关掉了;写作当时 DeepSeek 的最新模型是 deepseek-v4-pro,你阅读时可以去官方文档确认最新型号):
curl https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${DEEPSEEK_API_KEY}" \ -d '{ "model": "deepseek-v4-pro", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "我的名字叫株,你记着"} ], "thinking": {"type": "disabled"}, "reasoning_effort": "none", "stream": false }'它回得很热情:
{"role":"assistant","content":"好的,株!我记住了,很高兴认识你。"}紧接着再调一次,整条命令一个字都不改,只把 messages 里那句话换掉:
{"role": "user", "content": "我叫什么名字?"}结果:
{"role":"assistant","content":"我暂时还不知道你的名字呢!你愿意告诉我吗?"}刚才它才说 "我记住了",转头就不认识我们了
这不是它撒谎,也不是模型太笨,是它根本没有 "刚才" 这个概念:对它来说,我们发过去的每一次请求,都是世界的第一天;至于上一次调用发生过什么,它一无所知,甚至不知道曾经有过上一次——这就是 "无状态" 活生生的样子
而且请注意,这一幕里无状态体现在两个层面:
底层走的是 HTTP;两条 curl 是两个完全独立的 HTTP 请求,第二个发出去时第一个已经结束了,它俩之间没有任何东西相连
另一层是大模型本身;大模型本身就是不留记忆的,每一次调用都独立、都从零开始
这里一定有朋友要问:那我平时用 ChatGPT、DeepSeek 的网页版,或者用 Codex、WorkBuddy 之类的工具,它们明明记得我上一句说了什么?——这是因为有人替我们做了事,具体怎么做的,放在这一部分最后来说
凡是要服务海量、彼此无关的请求的地方,几乎都会走到 "无状态" 这条路上来——这显然不是巧合,而是一种刻意的选择,等会儿也会说
什么是「状态」:
对没有计算机背景的中文母语者来说,「状态」这个词可能自带误导性——它最常见的造句是 "你今天状态怎么样?" "保持积极乐观的精神状态",让人以为它是一种健康指标
但作为计算机术语,它一般指代的是一组参数;举个例子:打一局游戏打到一半,突然暂停!此刻这局游戏 "是什么样子"?我们在第几关、剩多少血、身上带了哪些装备、刚才那个机关有没有打开?这一整套 "此刻的情况",就是这局游戏的状态(state)
它有两个特点,都很要紧:
它决定了下一步会怎样;血剩多少,决定了下一刀挨不挨得住——状态不是记着好玩的,它影响接下来发生什么
它默认是会没的;一关机,这局就白打了;所以游戏才要有存档——存档干的事,说白了就是把状态挪到一个更不容易丢的地方去,比如硬盘
有了「状态」这个词,前面那句 "处理完就全忘" 就能说得更准了:服务器扔掉的不是别的,正是这一轮的状态——这就是 "无状态" 里那个 "状态" 的所指
而且回头看会发现,这一路走来其实好多地方都在跟状态打交道:
文字实验室的结果卡显示的内容,刷新一下就全没了——那是活在浏览器里的状态
在 REPL 练习时创建的刘关张三个名字,exit() 一下就找不着了——那是活在内存里的状态
数据库里搬进 history.db 的历史记录,关机重启它还在——那是落到硬盘上的状态
我们此前一直关注这些数据能活多久:一次刷新、一次运行、还是关机也不丢——一路都在给状态找一个活得更久的地方;现在存到了数据库,已经是活得最久的方式了;但现在遇到的问题已经不是它活得久不久,而是它能否活过两次不同的请求——刚进来的这个请求,和五秒前那个,是否可以共享状态
什么是「有状态」:
反过来问一句:既然 HTTP 是无状态的,那有状态的长什么样?
ssh 就是有状态的
当我们用 ssh 登录上服务器、cd 进某个目录,服务器就记着我们此刻在哪儿;再敲下一条命令时不用再自报家门,它知道是谁在敲;这就是有状态:连接的两头维持着一个 "现场",后一条命令是接着前一条往下说的;此时如果网断了,现场就没了——重新 ssh 上去就又回到家目录,刚才 cd 到哪儿全得重来
有状态和无状态没有绝对的好,只有合不合适;ssh 就应该是有状态:要是每敲一条命令都得重报一遍 "我是谁、我在哪个目录",那根本没法用——所以 ssh 必须一直维持着这个现场;ssh 和 HTTP 的差异:
| ssh | HTTP | |
|---|---|---|
| 面对的连接 | 少量、长时间、要连续性 | 海量、极短、彼此无关 |
| 选择 | 有状态,维持现场 | 无状态,用完就忘 |
那 HTTP 能不能也维持现场?如果让 HTTP 也自动维持现场,服务器就得为每个来访者挂着一份现场数据,局面会变成这样:
同时来一百万人,就得同时挂着一百万份现场数据,内存先撑不住
某一个用户建立了连接,后续请求必须回到同一台机器上(现场数据只在那一台里)——那还怎么做负载均衡?怎么临时加机器扛流量?
那台特定的机器一挂,挂在它上面的所有人的现场数据就全没了
而无状态意味着:任何一台服务器,都能处理任何一个请求——机器可以随便加、随便换,挂一台自动顶上,用户完全无感
所以无状态不是 HTTP 的缺陷,是它面对 "海量陌生请求" 这个场景做出的刻意选择;互联网能扩张到今天这个规模,很大程度上就靠这个决定;代价就是 "记住来访者" 这件事协议不管了,得由应用自己想办法——也就是这一部分要干的活;协议放弃了一点便利,换来了整个体系的可扩展性
在 HTTP 请求之间保持状态:
到这里,HTTP "底层是彻底遗忘的" 这件事看得很清楚了;但计算机是为人服务的,而人类的活动几乎没有一件是 "孤立的瞬间" ——它们全都是 "有前因后果的过程";只要是过程,就天然需要状态,需要记着刚才发生了什么
于是,一个矛盾就出现了:
| 要的是什么 | 于是倾向 | |
|---|---|---|
| 技术底层 | 效率与规模——每个请求彼此独立,才能海量扩展 | 遗忘 |
| 人的活动 | 意义与连贯——事情有前因后果,才叫做事 | 记忆 |
而且这两边谁都不能让步:让底层变成有状态,内存扛不住、没法负载均衡;而没有记忆的互联网只能做文档网站——就不会诞生电商、游戏、社交媒体这些互联网形态了
所以只剩一条路:
承认底层就是无状态的,然后在它上面,用尽可能小的代价,把状态重新 “长” 出来
请特别留意 "尽可能小的代价" 这几个字——它是后面所有设计的出发点:我们并不需要把整个 "现场数据" 都搬到每个请求里去,只需要想办法让服务器认出这是同一个人就够了;至于这个人的历史记录本身,可以存在服务器的数据库里
具体到我们的项目,"把状态长回来" 到底要长出什么?要让每个人在文字实验室只看到自己的历史记录,服务器只需要能回答两个问题——
存的时候:现在这条记录,是谁存的?
查的时候:现在来问的,又是谁?
只要这两个问题有答案,剩下的就好办了:存的时候在记录上盖个记号,查的时候只挑记号对得上的——不过是一条 WHERE 语句就能搞定的事情;为了解决这个记号的问题,就有了 "会话" (session)的概念
会话:把散落的请求认成同一个人
会话这个被造出来的概念,定义是这样的:
把一串本来彼此独立、互不相干的请求,认定为 “同一个来访者的一次连续交互”
请注意,会话是被 "构造" 出来的:HTTP 里没有会话,网络里也没有会话——它是应用这一层的概念;所谓 "保持会话",就是我们自己想办法,把散落的请求重新串成一条线
标识怎么才能每次都带上:
一个用户的多次请求怎么串在一起?我们来推一推
服务器要认出 "这些请求来自同一个人",最少需要什么?需要每个请求都带上同一个标识;服务器不需要知道我们是谁、叫什么——它只需要认出 "这个标识和刚才那个是同一个",就够了
这个标识得满足三个条件:
唯一;不能和别人撞上,否则会串号,看到别人的历史
每次请求都带着;漏一次,那次请求就成了陌生人
不能被人猜出来;要是能被猜到,别人就能冒充我们的会话
服务器生成一个唯一的、不容易被猜到的标识其实并不难;真正的问题是在 Web 应用中——前后端用 HTTP 通信时,这个标识可以怎么带?把能想到的办法都摆出来看看:
| 办法 | 怎么做 | 为什么会想到它 | 问题在哪 |
|---|---|---|---|
| 放在 URL 里 | /api/history?sid=abc123 | HTTP 请求可以通过 URL 带参数 | 暴露在 URL 中会被人看到;用户随手分享网址,会话就给别人了——不安全 |
| 认 IP 地址 | 服务器直接看请求从哪个 IP 来 | HTTP 请求会带上请求方 IP | 同一间办公室、同一个 WiFi 下所有人是同一个 IP;手机从流量切到 WiFi,IP 还会变——不可靠 |
| 前端自己存,每次手动加请求头 | 在浏览器里存一份,发请求时读出来塞进 header | 前端自己管,灵活又可靠 | 能用。但每一个请求都得记得加、不能漏,前端得一直操心 |
| 交给浏览器,让它自动带 | ? | ? | ? |
最后这一行正是我们想要的:如果浏览器能替我们记着这个标识、并且每次请求自动带上,上面所有的麻烦就都没了
浏览器有没有这个东西?有,就是 cookie;学 HTTP 的时候我们见过它——HTTP 协议里有这样一组请求头和响应头:
| 在哪 | 头 | 在说什么 |
|---|---|---|
| 响应头 | Set-Cookie | 给调用方发一张 "小纸条",下次来记得带上 |
| 请求头 | Cookie | 我随身带的 "小纸条" |
服务端通过 Set-Cookie 这个响应头,可以交给浏览器一段文字
浏览器收到之后会存着,每次通过 HTTP 发送请求时,自动塞进 Cookie 请求头
cookie 的本质,不是 "能在浏览器里存点东西"(浏览器还有别的方式也能存)——它真正值钱的地方是自动
如果这个标识是服务器生成的唯一值,那就满足了 "唯一" "猜不出" 这两个条件;浏览器又在每次请求时通过 cookie 自动带上这个唯一标识——三个条件全部满足
所以 cookie 可以用来在浏览器和服务端之间传递标识,实现会话保持
cookie 和 session 的区别:
不知道为什么,总有人问 cookie 和 session 的区别,甚至这个问题一度成了心照不宣的面试题——所以值得停下来好好掰扯一下这两个词
之所以老被混在一起,是因为很多人默认它们是同一类东西——好像是两种存数据的办法,可以挑一个用;可它们压根不是一类:
会话(session)是一种「认定」:把一串请求认定为同一个来访者的一次连续交互;它是一段关系,不是一个物件——我们没法指着服务器说 "喏,会话就在那儿"
cookie 是一套「机制」:浏览器替服务器保管一小块数据,并在每次请求时自动带上;它是实打实的东西,有位置、有大小、有有效期
一个是概念,一个是工具;所以 "cookie 和 session 哪个好" 这个问题本身就很奇怪——它们不在一个层面上,更谈不上二选一
它们真正的关联机制是:一个用户在浏览器上通过 HTTP 请求服务端,服务端如果需要维护这次会话,就把用户的数据存储起来(比如存到数据库中,并给一个会话编号 session_id),然后在响应时通过 Set-Cookie 向用户传递这个会话编号;浏览器在用户下一次请求时,会自动在请求头里带上 cookie——cookie 里面有什么?有 session_id
服务器凭这个 session_id,可以找到属于它的那份状态数据(业界也常叫它「会话数据」session data);而 cookie,就是运送 session_id 的载具
这有点像存包处:我们把包裹存在柜台——从存进去到取走(或过期)之间的这段关系,就是会话;存的那个包裹是状态数据,柜台给我们的纸条是 cookie;纸条上写的编号,就是 session_id;会话不是那个包裹,也不是那张纸条,而是 "我们和柜台之间这档子事" ——包裹和纸条,只是维持它所需要的东西
最后还有两点要说清楚:
cookie 里放的不一定是 session_id;网站的主题偏好、语言设置也常常放在 cookie 里——cookie 只是张白纸条,上面写什么由我们定
会话也不一定非得靠 cookie;手机 App 里就没有 cookie,它通常把 session_id 放在请求头里带(一般叫 token)——没有 cookie,会话还是会话
还有一件事得先记着:会话认得出 "同一个浏览器",可认不出 "访客到底是谁" ——它和登录是两回事(这条边界很关键,这一部分后面会专门说)
上面这些都理解了,就可以着手改造项目了
动手之前,先想清楚三件事:
要做的事情说穿了很简单,就三句话:
用户来的时候,生成一个会话 id
把状态数据连同这个 id 一起存进数据库(就是给数据表加一个字段的事)
通过 cookie,让这个 id 在浏览器和服务端之间传递
就这么点事;不过里面有两个地方值得动手之前先想清楚:这个 id 怎么生成?把 id 写进 cookie 的时候又该怎么写?另外还有第三道坎——跨源
一、这个 id 怎么生成?
会话 id 需要符合 "唯一" 和 "猜不出" 两个条件;遇到这种情况,就可以用 UUID(Universally Unique Identifier,"通用唯一识别码"):它会随机生成一串几乎不会出现重复的 "乱码",长这样:
3f8a1c9e42d7460b8e5f1a2c7d9b0e64
说 UUID "几乎" 不会重复,是一种很严谨的说法——我们可以认为它就是不会重复的;有人给过一个比喻:两个 UUID 重复的概率,约等于随手往宇宙里扔一粒沙子,然后从宇宙的另一端再扔一粒,这两粒沙子在太空中相撞的概率——这样说是不是放心多了
Python 标准库里就有一个 uuid 模块,直接拿来用就行(很简单)
二、写进 cookie 的时候怎么写?
写 cookie 不是往里写一个 session_id 就完事了;真正写出来的是这么一串东西:
session_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax
可以看到,除了 session_id,后面还有用分号 ; 隔开的好几样;看上去都是字符串,但它们其实分两类:
最前面的 session_id=3f8a...——这是 cookie 本身,一个名字配一个值,它才是要送回服务器的内容
后面那四个——它们叫属性,不是内容,是写给浏览器看的设置:这张纸条存多久、给不给页面上的 JS 看、什么时候该带上
那四个属性,一个一个说:
Max-Age=2592000,这个最重要;它决定了 cookie 的有效期是多久,单位是秒——2592000 就是 30 天(60×60×24×30);这个数字可以改,但不能不写:不写的话它就成了一张 "临时纸条",浏览器一关就扔
HttpOnly 和 SameSite=lax——这两个都和安全有关,先别管那么细,写上就行
Path=/——意思是这个站点下的所有路径,请求时都带上它;它本来就是默认值,等下写代码时不用操心
动手的时候,FastAPI 有现成的方法可以写这些东西,不用担心自己不会写——此刻最重要的是看懂它们是啥
三、跨源这道坎怎么过?
浏览器有条安全规则:跨源请求默认不带 cookie
我们的前端在 :3000、后端在 :8000,这是跨源的(CORS 那一部分学过);为什么一跨源就不带了?因为 cookie 经常用作身份凭证:要是浏览器不管对方是谁、见谁都自动把凭证递过去,那随便一个网站都能拿着我们的身份去调别人家的接口了
所以浏览器的要求是:送凭证这件事,必须两头都点头——
后端点头——给 CORSMiddleware 多配一个参数
前端点头——每次发请求,明说一句 "这一次带凭证"
两处都很简单,等下第一步一起做掉;开始动手吧!
开始动手改造:
第一步:让跨源请求能带 cookie
这一步前后端各改一处
先看后端,给 CORSMiddleware 补一个参数:
app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000"], allow_methods=["GET", "POST"], allow_credentials=True, # ← 新增:允许跨源请求带上 cookie )allow_credentials=True 就是后端点头:"可以带凭证(cookie)过来"
之前配 CORS 时没图省事写 allow_origins=["*"],而是老老实实写死了具体地址 "http://localhost:3000" ——就是在等今天:因为如果开了 allow_credentials=True,就绝对不能再用通配 *,这是浏览器的硬规定——带凭证时必须点名到具体的源
再看前端,两处 fetch 都加上 credentials: "include":
// InputCard 里发分析请求 const res = await fetch(`${API}/api/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", // ← 新增:带上 cookie body: JSON.stringify({ text }), });// TextLabView 里,点开弹窗时拉历史 async function openHistory() { setHistoryOpen(true); const res = await fetch(`${API}/api/history`, { credentials: "include" }); setHistory(await res.json()); }前端要改的就这两处;这句 credentials: "include" 就是前端点头:每次发请求,都明说 "这一次带凭证"
那 /api/profile 要不要也加?不用——它只是把一段固定的介绍数据返回来,压根不认人,带不带 cookie 都一样;哪个接口需要认人,就给哪个加
另一个疑问:每个接口请求都得单独写一句,那还算什么 "浏览器自动带 cookie"?credentials: "include" 的意思不是 "手动带 cookie",而是 "允许浏览器带" ——前端不需要知道 cookie 里装的是什么,它只是打开一个开关;如果是前端自己存、自己读出来、自己塞进请求头,那才叫手动,因为前端得管内容
而且这个开关只有跨源的时候才需要:fetch 的 credentials 默认值是 same-origin,同源请求浏览器默认就带;等部署时用 Nginx 把前后端做成同源,这两行也就不需要了
两头都点了头,cookie 才过得去;剩下的活,就全在后端了
第二步:给表加一列 session_id
打开 storage.py,把 init_db() 里的两句 SQL 都重写一下——建表语句和建索引语句,今天都要改:
def init_db(): conn = get_conn() cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, text TEXT, score REAL, label TEXT, pinyin TEXT, created_at TEXT ) """) cur.execute( "CREATE INDEX IF NOT EXISTS idx_history_session_created " "ON history(session_id, created_at)" ) conn.commit() conn.close()旧表里没有 session_id 这一列,IF NOT EXISTS 又不会去改已有的表——最省事的办法就是把 backend/history.db 删掉(我们现在的数据不值钱,从零来最干净),重启后端会按照新结构重建:新的表和新的索引一起建出来(旧索引跟着旧库一起没了,不用管它)
为什么索引也得跟着换?因为等下查询条件会换成 WHERE session_id = ? ORDER BY created_at DESC——先按会话筛,再按时间排;这里用到了两个不同的字段,此时的索引就应该用 ON history(session_id, created_at) 的写法来同时管住两个字段
这里的顺序也有讲究:先写用来筛的列,再写用来排的列——这样数据库先用 session_id 定位到属于这个会话的那一段,而那一段里面本来就是按时间排好的,连排序都省了;记住这句:
索引是为查询而建的,查询变了,索引就得跟着变
第三步:写一个 "发纸条 / 认纸条" 的小工具
这属于接口层,写在 main.py 里;顶部先 import uuid,并从 fastapi 引入 Request、Response:
import uuid from fastapi import Request, Responsedef get_session_id(request: Request, response: Response) -> str: sid = request.cookies.get("session_id") # 先看有没有纸条 if not sid: # 第一次来,没有——发一张 sid = uuid.uuid4().hex # 一串随机、不重复的 id response.set_cookie( "session_id", sid, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30, # 记 30 天 ) return sid这段代码里,uuid.uuid4().hex 就是生成 UUID 的;response.set_cookie(...) 就是向响应头里写 cookie 的——除了 session_id,还写了刚才提到的 httponly / samesite / max_age 三样;只有 Path=/ 没写——因为 set_cookie 的 path 参数默认就是 "/",不写它也在,等下我们会在响应头里亲眼看到它
get_session_id 的逻辑很直白:先看请求带来的 cookie 里有没有 session_id,没有就发一张新的
第三步:存和查都认 session_id
回到 storage.py:save_record 函数多一个 session_id 参数——每次存的时候调用方都得交代一下 session_id;get_history 也多一个 session_id 参数——每次取的时候调用方都得说明取的是哪一个 session_id 的历史数据:
def save_record(session_id, record): conn = get_conn() cur = conn.cursor() cur.execute( "INSERT INTO history (session_id, text, score, label, pinyin, created_at)" " VALUES (?, ?, ?, ?, ?, ?)", [session_id, record["text"], record["score"], record["label"], record["pinyin"], record["created_at"]], ) conn.commit() conn.close() def get_history(session_id, limit): conn = get_conn() cur = conn.cursor() rows = cur.execute( "SELECT * FROM history WHERE session_id = ? ORDER BY created_at DESC LIMIT ?", [session_id, limit], ).fetchall() conn.close() records = [] for row in rows: records.append(dict(row)) return records第四步:两个接口都先认人,再干活
回到 main.py,改这两个接口:
@app.post("/api/analyze") def analyze(req: AnalyzeRequest, request: Request, response: Response): sid = get_session_id(request, response) text = req.text score = round(SnowNLP(text).sentiments, 2) result = { "text": text, "score": score, "label": score_label(score), "pinyin": " ".join(lazy_pinyin(text, style=Style.TONE)), "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), } save_record(sid, result) # 存的时候盖上这个会话的记号 return result # ← 返回体一个字没变,session_id 只走 cookie @app.get("/api/history") def history(request: Request, response: Response, limit: int = 10): sid = get_session_id(request, response) return get_history(sid, limit) # 只回这个会话自己的注意,这里的 limit 又往外挪了一步:之前把 "一次给几条" 这个决定从存储层交还给了调用方,但那个数字当时还写死在 main.py 里;现在写成 limit: int = 10,这个数字就可以由调用方自己说了:
http://localhost:8000/api/history?limit=2
URL 里写 ?limit=2,就只回 2 条;不带 ?limit=,还是按默认的 10 条
上面几步做完,后端的开发工作就搞定了
先看一眼那张纸条:
到这儿,所有代码都改完了;不过先别急着打开浏览器——先用最朴素的方式,亲眼看看这个纸条(cookie)
之前学过 curl -v 能把 HTTP 的头都打出来;这次换 -i——它会在响应体前面把响应头也一并打出来,比起 -v 少了那些 * 开头的旁白和请求原文,输出干净得多:
curl -i http://localhost:8000/api/history响应头里会多出这么一行:
set-cookie: session_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax
这就是后端在向前端 "发纸条" ——我们交代过的几样东西(session_id、HttpOnly、Max-Age=2592000、Path=/、SameSite=lax)一个不落
所谓 cookie,本质上就是 HTTP 头里的一行字符串,没有任何魔法
curl 默认是不存 cookie 的——连续跑两次上面那条命令,会发现两次的 session_id 不一样:因为 curl 没把第一次的纸条存下来,第二次请求过去时手里空空,服务器只好当它是新访客,又发了一张新的;这也体现了浏览器替我们做了多少事:拿到 cookie 存下来、每次请求时自动带上,而且是浏览器的默认行为
在浏览器里验证:
现在打开浏览器,来看这一部分最值得亲眼看一次的东西(前后端两个程序都要跑着)
打开文字实验室,按 F12,切到 Application(有的浏览器叫 "应用")标签,左边找到 Cookies → http://localhost:3000
可以看到浏览器帮我们列出了好几项:session_id,后面跟着 Expires、HttpOnly、SameSite——名字基本上和刚才 curl 里看到的那些对得上;只有有效期那一栏不太一样:我们写进去的是 Max-Age=2592000(30 天的秒数),浏览器把它换算成了一个具体日期存下来
左边那一栏还列着别的东西——Local Storage、Session Storage 等等;它们都是浏览器给页面用的存储,共同点是:不会自动发给服务器,要用得自己写代码读出来、自己塞进请求;这恰好反衬出 cookie 特别在哪——它是唯一一个浏览器会替我们自动带上的
然后切到 Network 标签,刷新页面,点开那个 /api/history 请求,看 Request Headers:
Cookie: session_id=3f8a1c9e42d7460b8e5f1a2c7d9b0e64
这就是浏览器在请求头里自动为我们加上的 cookie
这里值得停一下:后端本来通过 set-cookie 发过来五样东西,浏览器都存下来了,可送回去的只剩一个 session_id;为什么?还记得前面分的那两类吗——session_id=... 是内容,后面那几个是属性;规范里,Set-Cookie 允许跟属性,而 Cookie 只写 名=值、不允许带属性;属性是写给浏览器的设置,它收下自己照办就完了,没必要再报回去——服务器要的,只有那个 session_id
见证:同一个实验再做一次
还记得这一部分开头那个实验吗?原封不动地再做一遍
两个不同的浏览器(或者一个正常窗口、一个无痕窗口),各自打开文字实验室,各分析一句不一样的话;然后分别点开「历史记录」
这一次,各看各的;开头那份 "人人都能翻到别人的字" 的公共账本,不见了
现在每个访客(浏览器)只看到自己的历史记录了——这也意味着,我们终于把它开发完了!
边界:会话不是认证
请注意,做到这一步我们只是用 cookie 实现了会话,本质上仍然无法区分访客:换台电脑、清掉 cookie,纸条就没了,服务器会把我们当成新访客,历史就 "丢" 了(其实没丢,还在数据库里,只是没人能凭纸条把它取出来了)
它维护的是会话,并不是安全的用户身份;真正的登录 / 认证,是在这套会话机制之上再加一层 "凭什么证明 ‘我就是我’ "
注册、登录、权限、密码安全、验证码、OAuth……那是自成体系、又安全敏感的一门课,后面可能会讲;但无论认证体系多么复杂,它都需要今天讲的这套会话机制作为地基
One more thing:大模型是怎么 "记住" 你的
大模型的 API 是没有状态的;它保持会话的方式和我们今天讲的这一套不同,但更容易理解——就是在请求体的 messages 里塞入历史会话
还记得刚才那两条 curl 吗?第一句我们说 "我的名字叫株,你记着",大模型回复 "好的,株!我记住了,很高兴认识你。" 这时候再问一次 "我叫什么名字",但把前面那两句一起带上——发过去的就是这样:
"messages": [ {"role": "user", "content": "我的名字叫株,你记着"}, {"role": "assistant", "content": "好的,株!我记住了,很高兴认识你。"}, {"role": "user", "content": "我叫什么名字?"} ]这次它答对了:"你叫株呀,我记着呢。"
但请注意,模型还是什么都没记住——是我们把整段对话重新发了一遍;那个 messages 数组就是这次会话的全部记忆:它不在模型那边保存,而是在我们这边(应用这边),每一次都得原样再交一遍
看懂这一点,好几件事一下就通了:
所谓 "上下文窗口",就是这个 messages 数组的长度上限——聊太长了前面就得被裁掉;这就是 AI 聊天助手 "聊着聊着把我忘了" 这件事的真相
对话越长,每次要发的越多——所以长对话越来越慢、也越来越贵:因为是按 token 计费,我们每一轮都在为整段历史重新付一次钱
也就明白了 compact 是在干嘛——既然整段历史每轮都要重发,那自然会想到把它总结压缩一下,让数组短一点、便宜一点
而最有意思的是:它和我们今天做的事,是同一个问题的两种解法;把两种做法并排放在一起看:
| 状态存在哪儿 | 每次请求带什么 | |
|---|---|---|
| 我们的 Web 会话 | 服务器(history 表,可以很大) | 只带一个 id(32 个字符) |
| 裸调大模型 API | 客户端自己 | 把全部历史都带上 |
表里那个 "客户端",就是现在常见的 AI Agent 工具,比如 Claude Code、Codex、WorkBuddy……我们在对话框里一句接一句地聊,感觉它 "记得";真相是它在背后替我们攒着那个 messages 数组,每问一次,就把整段重新交上去一遍
想通这一层,AI 工具里那些名词也就不神秘了:所谓知识库、所谓 Skill,说到底都是在决定往那个数组里放什么、放多少——都是在管上下文(也就是那个 messages 数组)
最后回头看一眼这三种做法:
Web 应用——状态留在服务器,浏览器只揣着一个编号
大模型 API——状态全在调用方手里,每次把整段历史原样交上去
手机 App——没有 cookie,就把编号塞进请求头(前面提过的 token)
手法各不相同,要办的却是同一件事——
把一串散落的、彼此不认识的请求,重新认成“同一个人”
这就是会话
这一段的收束:
回头看存储与状态这一段走过的路:优先用别人做好的库,不重复造轮子;找到 pypinyin 和 snownlp,把文字实验室做成真的;理解存储,用文件给项目装上第一份记忆,也亲手撞上了文件的天花板;认识数据库并上手 SQLite;第二次换芯,把数据搬进 SQLite,顺便理解了重构;最后用状态与会话,让每个访客有了自己的历史,也给未来的认证打好了地基
清点行囊——现在这个项目由三样东西组成:
静态前端 + FastAPI 后端 + SQLite
接下来,就是把这一整套搬上云服务器:前端用生产地址重新 build、后端变成常驻服务、Nginx 把 /api/ 反代过去让前后端同源——之前埋的很多线,会在部署时一次性全部收回
功能到此完整,剩下的只差上线