零到全栈(无状态的 Web,怎么记住一个人)
发布时间:2026/9/30 14:47:02来源:尧图网络
上一篇完成了一次教科书式的两步走先把存储代码从 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 的差异sshHTTP面对的连接少量、长时间、要连续性海量、极短、彼此无关选择有状态维持现场无状态用完就忘那 HTTP 能不能也维持现场如果让 HTTP 也自动维持现场服务器就得为每个来访者挂着一份现场数据局面会变成这样同时来一百万人就得同时挂着一百万份现场数据内存先撑不住某一个用户建立了连接后续请求必须回到同一台机器上现场数据只在那一台里——那还怎么做负载均衡怎么临时加机器扛流量那台特定的机器一挂挂在它上面的所有人的现场数据就全没了而无状态意味着任何一台服务器都能处理任何一个请求——机器可以随便加、随便换挂一台自动顶上用户完全无感所以无状态不是 HTTP 的缺陷是它面对 海量陌生请求 这个场景做出的刻意选择互联网能扩张到今天这个规模很大程度上就靠这个决定代价就是 记住来访者 这件事协议不管了得由应用自己想办法——也就是这一部分要干的活协议放弃了一点便利换来了整个体系的可扩展性在 HTTP 请求之间保持状态到这里HTTP 底层是彻底遗忘的 这件事看得很清楚了但计算机是为人服务的而人类的活动几乎没有一件是 孤立的瞬间 ——它们全都是 有前因后果的过程只要是过程就天然需要状态需要记着刚才发生了什么于是一个矛盾就出现了要的是什么于是倾向技术底层效率与规模——每个请求彼此独立才能海量扩展遗忘人的活动意义与连贯——事情有前因后果才叫做事记忆而且这两边谁都不能让步让底层变成有状态内存扛不住、没法负载均衡而没有记忆的互联网只能做文档网站——就不会诞生电商、游戏、社交媒体这些互联网形态了所以只剩一条路承认底层就是无状态的然后在它上面用尽可能小的代价把状态重新 “长” 出来请特别留意 尽可能小的代价 这几个字——它是后面所有设计的出发点我们并不需要把整个 现场数据 都搬到每个请求里去只需要想办法让服务器认出这是同一个人就够了至于这个人的历史记录本身可以存在服务器的数据库里具体到我们的项目把状态长回来 到底要长出什么要让每个人在文字实验室只看到自己的历史记录服务器只需要能回答两个问题——存的时候现在这条记录是谁存的查的时候现在来问的又是谁只要这两个问题有答案剩下的就好办了存的时候在记录上盖个记号查的时候只挑记号对得上的——不过是一条 WHERE 语句就能搞定的事情为了解决这个记号的问题就有了 会话 session的概念会话把散落的请求认成同一个人会话这个被造出来的概念定义是这样的把一串本来彼此独立、互不相干的请求认定为 “同一个来访者的一次连续交互”请注意会话是被 构造 出来的HTTP 里没有会话网络里也没有会话——它是应用这一层的概念所谓 保持会话就是我们自己想办法把散落的请求重新串成一条线标识怎么才能每次都带上一个用户的多次请求怎么串在一起我们来推一推服务器要认出 这些请求来自同一个人最少需要什么需要每个请求都带上同一个标识服务器不需要知道我们是谁、叫什么——它只需要认出 这个标识和刚才那个是同一个就够了这个标识得满足三个条件唯一不能和别人撞上否则会串号看到别人的历史每次请求都带着漏一次那次请求就成了陌生人不能被人猜出来要是能被猜到别人就能冒充我们的会话服务器生成一个唯一的、不容易被猜到的标识其实并不难真正的问题是在 Web 应用中——前后端用 HTTP 通信时这个标识可以怎么带把能想到的办法都摆出来看看办法怎么做为什么会想到它问题在哪放在 URL 里/api/history?sidabc123HTTP 请求可以通过 URL 带参数暴露在 URL 中会被人看到用户随手分享网址会话就给别人了——不安全认 IP 地址服务器直接看请求从哪个 IP 来HTTP 请求会带上请求方 IP同一间办公室、同一个 WiFi 下所有人是同一个 IP手机从流量切到 WiFiIP 还会变——不可靠前端自己存每次手动加请求头在浏览器里存一份发请求时读出来塞进 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 需要符合 唯一 和 猜不出 两个条件遇到这种情况就可以用 UUIDUniversally Unique Identifier通用唯一识别码它会随机生成一串几乎不会出现重复的 乱码长这样3f8a1c9e42d7460b8e5f1a2c7d9b0e64说 UUID 几乎 不会重复是一种很严谨的说法——我们可以认为它就是不会重复的有人给过一个比喻两个 UUID 重复的概率约等于随手往宇宙里扔一粒沙子然后从宇宙的另一端再扔一粒这两粒沙子在太空中相撞的概率——这样说是不是放心多了Python 标准库里就有一个 uuid 模块直接拿来用就行很简单二、写进 cookie 的时候怎么写写 cookie 不是往里写一个 session_id 就完事了真正写出来的是这么一串东西session_id3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age2592000; Path/; SameSitelax可以看到除了 session_id后面还有用分号 ; 隔开的好几样看上去都是字符串但它们其实分两类最前面的 session_id3f8a...——这是 cookie 本身一个名字配一个值它才是要送回服务器的内容后面那四个——它们叫属性不是内容是写给浏览器看的设置这张纸条存多久、给不给页面上的 JS 看、什么时候该带上那四个属性一个一个说Max-Age2592000这个最重要它决定了 cookie 的有效期是多久单位是秒——2592000 就是 30 天60×60×24×30这个数字可以改但不能不写不写的话它就成了一张 临时纸条浏览器一关就扔HttpOnly 和 SameSitelax——这两个都和安全有关先别管那么细写上就行Path/——意思是这个站点下的所有路径请求时都带上它它本来就是默认值等下写代码时不用操心动手的时候FastAPI 有现成的方法可以写这些东西不用担心自己不会写——此刻最重要的是看懂它们是啥三、跨源这道坎怎么过浏览器有条安全规则跨源请求默认不带 cookie我们的前端在 :3000、后端在 :8000这是跨源的CORS 那一部分学过为什么一跨源就不带了因为 cookie 经常用作身份凭证要是浏览器不管对方是谁、见谁都自动把凭证递过去那随便一个网站都能拿着我们的身份去调别人家的接口了所以浏览器的要求是送凭证这件事必须两头都点头——后端点头——给 CORSMiddleware 多配一个参数前端点头——每次发请求明说一句 这一次带凭证两处都很简单等下第一步一起做掉开始动手吧开始动手改造第一步让跨源请求能带 cookie这一步前后端各改一处先看后端给 CORSMiddleware 补一个参数app.add_middleware( CORSMiddleware, allow_origins[http://localhost:3000], allow_methods[GET, POST], allow_credentialsTrue, # ← 新增允许跨源请求带上 cookie )allow_credentialsTrue 就是后端点头可以带凭证cookie过来之前配 CORS 时没图省事写 allow_origins[*]而是老老实实写死了具体地址 http://localhost:3000 ——就是在等今天因为如果开了 allow_credentialsTrue就绝对不能再用通配 *这是浏览器的硬规定——带凭证时必须点名到具体的源再看前端两处 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 都一样哪个接口需要认人就给哪个加另一个疑问每个接口请求都得单独写一句那还算什么 浏览器自动带 cookiecredentials: 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、Responseimport 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, httponlyTrue, samesitelax, max_age60 * 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.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, styleStyle.TONE)), created_at: datetime.now(timezone.utc).isoformat(timespecseconds), } 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?limit2URL 里写 ?limit2就只回 2 条不带 ?limit还是按默认的 10 条上面几步做完后端的开发工作就搞定了先看一眼那张纸条到这儿所有代码都改完了不过先别急着打开浏览器——先用最朴素的方式亲眼看看这个纸条cookie之前学过 curl -v 能把 HTTP 的头都打出来这次换 -i——它会在响应体前面把响应头也一并打出来比起 -v 少了那些 * 开头的旁白和请求原文输出干净得多curl -i http://localhost:8000/api/history响应头里会多出这么一行set-cookie: session_id3f8a1c9e42d7460b8e5f1a2c7d9b0e64; HttpOnly; Max-Age2592000; Path/; SameSitelax这就是后端在向前端 发纸条 ——我们交代过的几样东西session_id、HttpOnly、Max-Age2592000、Path/、SameSitelax一个不落所谓 cookie本质上就是 HTTP 头里的一行字符串没有任何魔法curl 默认是不存 cookie 的——连续跑两次上面那条命令会发现两次的 session_id 不一样因为 curl 没把第一次的纸条存下来第二次请求过去时手里空空服务器只好当它是新访客又发了一张新的这也体现了浏览器替我们做了多少事拿到 cookie 存下来、每次请求时自动带上而且是浏览器的默认行为在浏览器里验证现在打开浏览器来看这一部分最值得亲眼看一次的东西前后端两个程序都要跑着打开文字实验室按 F12切到 Application有的浏览器叫 应用标签左边找到 Cookies → http://localhost:3000可以看到浏览器帮我们列出了好几项session_id后面跟着 Expires、HttpOnly、SameSite——名字基本上和刚才 curl 里看到的那些对得上只有有效期那一栏不太一样我们写进去的是 Max-Age259200030 天的秒数浏览器把它换算成了一个具体日期存下来左边那一栏还列着别的东西——Local Storage、Session Storage 等等它们都是浏览器给页面用的存储共同点是不会自动发给服务器要用得自己写代码读出来、自己塞进请求这恰好反衬出 cookie 特别在哪——它是唯一一个浏览器会替我们自动带上的然后切到 Network 标签刷新页面点开那个 /api/history 请求看 Request HeadersCookie: session_id3f8a1c9e42d7460b8e5f1a2c7d9b0e64这就是浏览器在请求头里自动为我们加上的 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 表可以很大只带一个 id32 个字符裸调大模型 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/ 反代过去让前后端同源——之前埋的很多线会在部署时一次性全部收回功能到此完整剩下的只差上线
网站建设高端定制企业官网