零到全栈 · 课程
模块 6.6:状态与会话
认识 Web 项目的会话与状态保持
先把历史显示出来
这一节我们要讲的是会话。在讲会话之前,我们先动手改点东西。
用户在我们的文字实验室里查询的历史已经被存进数据库了,通过/api/history 也能查出来,只是页面上一直没显示它。
所以我们先对前端进行一次小迭代, 在页面上把这些历史记录显示出来。
前端直接替换
前端不是这一节的重点,所以这部分我们不手敲,直接在GitHub的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 的官网 https://fastapi.tiangolo.com/、Vite 的官网 https://vite.dev/,还有李勃老师的课件网站 https://李勃老师.com/。它们不是"认不出",是压根不需要认。来的人只是读文档,认得出是谁毫无意义,就没必要费这个劲。
只有当一个应用要为每个人分别留住点什么的时候——历史记录、购物车、草稿、偏好设置——“认人"才变成一道绕不过去的坎。我们的文字实验室,刚好走到了这一步。
让大模型 API 忘给我们看
无状态不是 HTTP 一家独有。我们可以看一个更极端、也更好玩的例子。
我们在终端里用 curl 调用一下 DeepSeek API。
如果没有API Key也可以对照下面的输出往下看。
第一次调用,我们告诉它我们的名字:
thinking、reasoning_effort两个参数是让它"深度思考"用的,这里只要最简单直接的问答,所以关掉了;另外写这篇课件的时候是2026年9月,DeepSeek的最新模型是deepseek-v4-pro, 你阅读本文的时候可以再去DeepSeek API 文档查看最新模型还是不是这个。
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 向用户传递这个会话编号(session_id), 那么浏览器在用户下一次通过 HTTP 请求服务端的时候,会自动在请求头里带上 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,这是跨源的,5.5 那一节学过 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)过来。”
5.5 那一节我们没图省事写
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,同源请求浏览器默认就带。等到模块 7,我们用 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, Response
def 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 又往外挪了一步。6.5 我们把"一次给几条"这个决定从存储层交还给了调用方,但那个数字当时还写死在 main.py 里;现在写成 limit: int = 10,这个数字就可以由调用方自己说了:
http://localhost:8000/api/history?limit=2
URL 里写 ?limit=2,就只回 2 条;不带 ?limit=,还是按默认的 10 条。
上面几步做完,后端的开发工作就搞定了。
先看一眼那张纸条
到这儿,所有代码就都改完了。不过先别急着打开浏览器——我们先用最朴素的方式,亲眼看看这个纸条(cookie)。
5.3 我们学过 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工具,比如DeepSeek Harness、Claude Code、Codex、OpenClaw、WorkBuddy、Pi Agent……我们在对话框里一句接一句地聊,感觉它"记得";真相是它在背后替我们攒着那个 messages 数组,每问一次,就把整段重新交上去一遍。
想通这一层,AI 工具里那些名词也就不神秘了:所谓知识库、所谓 Skill,说到底都是在决定往那个数组里放什么、放多少——都是在管上下文(也就是那个 messages 数组)。
最后回头看一眼这三种做法:
- Web 应用——状态留在服务器,浏览器只揣着一个编号;
- 大模型 API——状态全在调用方手里,每次把整段历史原样交上去;
- 手机 App——没有 cookie,就把编号塞进请求头(前面提过的 token)。
手法各不相同,要办的却是同一件事——
把一串散落的、彼此不认识的请求,重新认成"同一个人"。
这就是会话。
模块 6 收官
这一节到此结束,我们的整个模块 6,也一起走完了。回头看这六节走过的路:
- 6.1——生态:不要重复造轮子,优先考虑用别人已经做出来的库;
- 6.2——第一次换芯:找到
pypinyin和snownlp两个库,把文字实验室做成真的; - 6.3——文件版历史:理解存储,理解数据库存在的必要性;
- 6.4——数据库:认识了一些数据库,并上手操作了一下SQLite;
- 6.5——第二次换芯:把数据存进了 SQLite,理解了重构;
- 6.6——状态与会话:让每个访客有了自己的历史,也给未来的认证打好了地基。
清点行囊——现在这个项目由三样东西组成:
静态前端 + FastAPI 后端 + SQLite
下一个模块,就是把这一整套搬上我们的云服务器——前端用生产地址重新 build、后端用 systemd 变成常驻服务、Nginx 把 /api/ 反代过去让前后端同源。之前埋的很多线,在部署的时候将会一次性全部收回。
功能,到此完整。剩下的,只差上线。