零到全栈 · 课程

模块 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也可以对照下面的输出往下看。

第一次调用,我们告诉它我们的名字:

thinkingreasoning_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 的差异:

sshHTTP
面对的连接少量长时间、要连续性海量极短、彼此无关
选择有状态,维持现场无状态,用完就忘

那 HTTP 能不能也维持现场?如果让HTTP也自动维持现场,那服务器得为每个来访者挂着一份现场数据,就会出现这样的局面:

  • 同时来一百万人,就得同时挂着一百万份现场数据,内存先撑不住;
  • 某一个用户建立了连接,后续请求必须回到同一台机器上,因为现场数据只在那一台里。那还怎么做负载均衡?怎么临时加机器扛流量?
  • 上一条说的那台特定的机器一挂,挂在它上面的所有人的现场数据就全没了。

而无状态意味着 任何一台服务器,都能处理任何一个请求。 机器可以随便加、随便换、挂一台自动顶上,用户完全无感。

所以无状态不是 HTTP 的缺陷,是它面对"海量陌生请求"这个场景做出的刻意选择。 互联网能扩张到今天这个规模,很大程度上就靠这个决定。

代价就是"记住来访者"这件事,协议不管了,得由我们应用自己想办法,也就是我们这一节要干的活

协议放弃了一点便利,换来了整个体系的可扩展性。


在 HTTP 请求之间保持状态

到这儿,我们已经把HTTP"底层是彻底遗忘的"这件事看得很清楚了。

但是计算机是为人服务的,而人类的活动,几乎没有一件是"孤立的瞬间",它们全都是"有前因后果的过程"。 而只要是过程,就天然需要状态,需要记着刚才发生了什么。

于是,一个矛盾就出现了:

要的是什么于是倾向
技术底层效率与规模——每个请求彼此独立,才能海量扩展遗忘
人的活动意义与连贯——事情有前因后果,才叫做事记忆

而且这两边,谁都不能让步。让底层变成有状态的话,内存扛不住、没法负载均衡。而没有记忆的互联网只能做文档网站,就不会诞生电商、游戏、社交媒体这些互联网形态了。

所以,只剩下一条路

承认底层就是无状态的,然后在它上面,用尽可能小的代价,把状态重新"长"出来。

请特别留意"尽可能小的代价“这几个字,它是后面所有设计的出发点。我们并不需要把整个"现场数据"都搬到每个请求里去,我们只需要想办法,让服务器认出这是同一个人就够了;至于这个人的历史记录本身,可以存在服务器的数据库里。

那么具体到我们的项目,“把状态长回来"到底要长出什么?

要让每个人在文字实验室只看到自己的历史记录,服务器只需要能回答两个问题——

  1. 存的时候:现在这条记录,是存的?
  2. 查的时候:现在来问的,又是

只要这两个问题有答案,剩下的就好办了:存的时候在记录上盖个记号,查的时候只挑记号对得上的——不过是一个 WHERE 语句就能搞定的事情罢了(战术后仰)。

为了解决这个记号的问题,就有了 “会话” (session) 的概念。


会话:把散落的请求认成同一个人

会话,这个被造出来的概念,定义是这样的:

把一串本来彼此独立、互不相干的请求,认定为"同一个来访者的一次连续交互”

请注意,会话是被"构造"出来的。 HTTP 里没有会话,网络里也没有会话。它是应用这一层的概念。

所谓"保持会话”,就是我们自己想办法,把散落的请求重新串成一条线。

标识怎么才能每次都带上

一个用户的多次请求怎么串在一起?我们来推一推。

服务器要认出"这些请求来自同一个人",最少需要什么?

需要每个请求,都得带上同一个标识。

服务器不需要知道我们是谁、叫什么,它只需要认出"这个标识和刚才那个是同一个",就够了。

这个标识得满足三个条件:

  1. 唯一。不能和别人撞上,否则会串号,看到别人的历史;
  2. 每次请求都带着。漏一次,那次请求就成了陌生人;
  3. 不能被人猜出来。要是能被猜到,别人就能冒充我们的会话;

服务器生成一个唯一的且不容易被猜到的标识其实并不难,真正的问题是在 Web 应用中,前后端用 HTTP 通信时,这个标识可以怎么带?

我们把能想到的办法都摆出来看看:

办法怎么做为什么会想到它问题在哪
放在 URL 里/api/history?sid=abc123HTTP 请求的时候可以通过 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 的区别,甚至这个问题一度成为一个很心照不宣的面试题。所以这里值得停下来好好掰扯一下这俩词。

之所以老被混在一起,是因为很多人默认它们是同一类东西——好像是两种存数据的办法,可以挑一个用。可它们压根不是一类:

  • 会话(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的时候不是往里面写一个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)。这个数字可以改,但不能不写,不写的话它就成了一张"临时纸条",浏览器一关就扔了。
  • HttpOnlySameSite=lax——这两个都和安全有关,先别管那么细,写上就行
  • Path=/——意思是这个站点下的所有路径,请求时都带上它。它本来就是默认值,等下写代码时不用我们操心。

等会儿动手的时候,FastAPI 有现成的方法可以写这些东西,所以不用担心自己不会写

对我们来说,此刻最重要的事情是看懂它们是啥

三、跨源这道坎怎么过?

浏览器有条安全规则:跨源请求默认不带 cookie

我们的前端在 :3000、后端在 :8000,这是跨源的,5.5 那一节学过 CORS,这一点我们已经知道了。

为什么一跨源就不带了?因为cookie 经常用作身份凭证。要是浏览器不管对方是谁、见谁都自动把凭证递过去,那随便一个网站都能拿着我们的身份,去调别人家的接口了。

所以浏览器的要求是:送凭证这件事,必须两头都点头。

  • 后端点头——给 CORSMiddleware 多配一个参数;
  • 前端点头——每次发请求,明说一句"这一次带凭证"。

两处都很简单,等下第〇步一起做掉。

开始动手吧!!


开始动手改造

这一步前后端各改一处

先看后端,给 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 里装的是什么,它只是打开一个开关;如果是前端自己存、自己读出来、自己塞进请求头,那才叫手动,因为前端得管内容。

而且这个开关只有跨源的时候才需要fetchcredentials 默认值是 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 引入 RequestResponse

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_cookiepath 参数默认就是 "/",不写它也在。等下我们会在响应头里亲眼看到它。

get_session_id的逻辑很直白:先看请求带来的 cookie 里有没有 session_id,没有就发一张新的。

第三步:存和查都认 session_id

回到 storage.pysave_record 函数多一个 session_id参数,每次存的时候调用方都得交代一下 session_idget_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_idHttpOnlyMax-Age=2592000Path=/SameSite=lax——一个不落。

所谓 cookie,本质上就是 HTTP 头里的一行字符串,没有任何魔法。

curl 默认是不存 cookie 的,所以我们连续跑两次上面那条命令,会发现两次的 session_id 不一样。 这是因为 curl 没把第一次的纸条存下来,第二次请求过去时手里空空,服务器只好当它是新访客,又发了一张新的。

这也体现了浏览器替我们做了多少事:拿到 cookie 存下来,每次请求时自动带上,而且这是浏览器的默认行为。


在浏览器里验证

现在打开浏览器,来看这一节最值得亲眼看一次的东西。前后端两个程序都要跑着。

打开文字实验室,按 F12,切到 Application(有的浏览器叫"应用")标签,左边找到 Cookieshttp://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——第一次换芯:找到pypinyinsnownlp两个库,把文字实验室做成真的;
  • 6.3——文件版历史:理解存储,理解数据库存在的必要性;
  • 6.4——数据库:认识了一些数据库,并上手操作了一下SQLite;
  • 6.5——第二次换芯:把数据存进了 SQLite,理解了重构;
  • 6.6——状态与会话:让每个访客有了自己的历史,也给未来的认证打好了地基。

清点行囊——现在这个项目由三样东西组成:

静态前端FastAPI 后端SQLite

下一个模块,就是把这一整套搬上我们的云服务器——前端用生产地址重新 build、后端用 systemd 变成常驻服务、Nginx 把 /api/ 反代过去让前后端同源。之前埋的很多线,在部署的时候将会一次性全部收回。

功能,到此完整。剩下的,只差上线。


← 上一节:模块 6.5 重构,在项目中使用 SQLite | 下一节:模块 7.1 把后端搬上服务器 →