到上一篇为止,后端已经有了两个接口:GET /api/profile 返回主页内容,POST /api/analyze 接收文字并返回分析结果------只是分数、标签、拼音还全是写死的占位值,接口文档倒是自动生成了;这一篇先让前后端真正握上手:网页从接口读数据,途中撞上 CORS 这道浏览器安全规则、看清 OPTIONS 预检的 "先问后发",再把写死的后端地址收进环境变量;随后直面那笔假数据的欠账------去 PyPI 找库、验库,用 pypinyin 和 snownlp 换掉接口里的占位实现,守住约定只换内部,让文字实验室真的会分析
前后端联调与 CORS:
这一部分让前端和后端真正握上手:走通 "输入 → 请求 → 后端计算 → 响应 → 界面更新" 的完整链路,并在路上认识浏览器的一道安全规则------CORS
要完成的四件事:
做完这一部分:
-
主页的标题、副标题、作品和座右铭,会来自 GET /api/profile
-
文字实验室里输入一段文字、点击 "开始分析",结果区会显示 POST /api/analyze 返回的结果
-
认识并解决跨源限制 CORS,顺带看清 OPTIONS 预检
-
把散落在代码里的后端地址,收进配置文件
还记得讲数据与界面分离时埋的那颗种子吗?当时 site.js 里的内容都是写死的,我们说过它们以后可以从网络接口获取------今天就是兑现这句话的日子
先把两边都跑起来,需要两个终端:
bash
# 终端 1:后端
cd ~/zero-to-tech/backend
source .venv/bin/activate
fastapi dev # → http://localhost:8000
bash
# 终端 2:前端
cd ~/zero-to-tech
npm run dev # → http://localhost:3000
一台电脑,两个一直运行的程序:3000 是前端,8000 是后端
先让后端数据与前端约定一致:
正式连接之前,先确认两边说的是同一种 "数据语言"
现在前端 data/site.js 里的 home 不只有标题和副标题,还有作品与身份信息;但 /api/profile 只返回了标题和副标题两个字段------当时说过,重点先放在 HTTP 和框架上,数据结构等前端真的来调时再对齐;这一刻到了:如果前端组件按照原来的结构取 featuredWork.title,后端却没有返回 featuredWork,两边就对不上了
这就是那句话:API 是调用方和被调用方之间的一份约定;当前 /api/profile 返回的字段还不满足前端的需要,所以先改后端,把 profile 补成和 site.js 的 home 同构;为了等会儿一眼看出数据是否真的来自后端,先在标题后面临时加一个明显的标记 "(来自后端)":
python
profile = {
"heroTitle": "关于我(来自后端)", # → 临时加的标记,验证完删掉
"heroSubtitle": "项目,创意,灵感,心得,我的作品",
"featuredWork": {
"kicker": "作品",
"title": "文字实验室",
"copy": "拼音和情绪,挖掘中文里的细节",
"linkLabel": "打开作品",
},
"identity": {
"motto": "已识乾坤大,尤怜草木青",
"learning": "零到全栈",
},
}
保存后,先用 curl 确认后端这边正常:
bash
curl http://localhost:8000/api/profile
能看到完整 JSON 和 "来自后端" 的标记,说明接口本身没有问题
用现成的前端代码替换:
后端准备好了,轮到前端;前端要改的地方不少:主页要改成向后端请求数据,文字实验室的输入卡和结果卡也要接上接口------这些都是很常规的 "取数据、发请求" 写法,而这一部分真正的重点是联调:请求为什么不通、怎么排查;所以不逐行讲前端代码,直接用改好的版本替换
改好的代码放在仓库 joylibo/zero-to-tech-demos 里;克隆下来,用 zero-to-tech-5-5/ 里的文件覆盖项目中的同名文件:
bash
git clone https://github.com/joylibo/zero-to-tech-demos.git
cp zero-to-tech-demos/zero-to-tech-5-5/components/*.jsx ~/zero-to-tech/components/
cp zero-to-tech-demos/zero-to-tech-5-5/css/lab.css ~/zero-to-tech/css/
替换了哪些文件、各自做了什么,对照一下即可,不必抠语法:
| 文件 | 改动 |
|---|---|
| HomeView.jsx | 变成客户端组件;打开页面先用 site.js 的数据打底,再去 GET /api/profile,拿到后端数据后更新界面 |
| TextLabView.jsx | 变成客户端组件;把 "分析结果" 这份状态提升到这里,分别下发给输入卡和结果卡(学过的 "状态提升") |
| InputCard.jsx | 点 "开始分析" 时,把文字 POST 给 /api/analyze |
| ResultCard.jsx | 改成显示父组件传来的结果,还没有结果时用一份默认占位 |
| css/lab.css | 新增一条 .lab-error 样式,用于请求失败时的提示 |
有几点先记下来:
-
因为要在浏览器里发请求,HomeView、InputCard 这些组件顶部都加了 "use client",成了客户端组件------这个概念讲 Next.js 时提过,这里不展开
-
这些组件里,后端地址 http://localhost:8000 都是直接写死的;现在能跑,但并不理想,本部分最后会把它收进配置文件
-
请求失败时(后端没跑、跨源被拦等),代码用 try/catch 做了基本兜底:主页失败就保持 site.js 的打底数据,输入卡失败就在按钮上方给一行提示,界面不会无声崩掉------属于常规的错误处理,不是重点,不展开
替换完,保存,打开 http://localhost:3000
按理说,主页大标题应该变成后端返回的 "关于我(来自后端)",但页面上仍然是原来的 "关于我";前端代码是现成的、后端也用 curl 验证过------为什么网页没有拿到数据?
先不要急着改代码;真正的联调,往往就是从这种 "结果与预期不一致" 开始的
第一次撞墙:顺着线索排查
遇到这种情况,不要盯着代码猜,先弄清楚这次请求走到哪一步断的
一次请求会在三个地方留下线索,按数据流动的顺序、从发出请求的这一头开始一站一站往下看,每一站回答一个问题:
| 看哪里 | 回答什么问题 |
|---|---|
| 浏览器 Network | 请求发出去了吗? |
| 后端终端 | 后端收到了吗?又是怎么处理的? |
| 浏览器 Console | 结果为什么没有交到我们的代码手里? |
第一站:浏览器 Network
打开浏览器开发者工具,切到 Network,刷新页面,找到 /api/profile;请求确实存在------说明它发出去了,不是那段 fetch 代码根本没执行
开发环境里如果看到两条相同的 GET,也不用慌:Next.js 的 App Router 默认开启 React 严格模式,开发时会多执行一次 Effect 来帮忙检查副作用;正式构建不会因为这个检查多发一次
第一个问题有了答案:请求发出去了;那它到没到后端?
第二站:后端终端
回到运行后端的终端,可以看到类似:
bash
GET /api/profile HTTP/1.1 200 OK
这一行说明两件事:请求已经到达后端,而且后端处理完并返回了 200------在后端看来,这次请求是成功的
到这里就有点奇怪了:请求发出去了,后端也收到并成功返回了,页面上却没有数据;东西已经送到门口,是谁把它扣下的?
第三站:浏览器 Console
切到 Console,会看到一段红字:
Access to fetch at 'http://localhost:8000/api/profile'
from origin 'http://localhost:3000'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present...
把三站的线索连起来看,会发现一个很有意思的现象:
-
Network 里能看到请求,说明发出去了
-
后端日志显示 GET 返回 200,说明收到了也处理成功了
-
之前用 curl 也能拿到完整 JSON
-
但网页 JavaScript 仍然拿不到结果
问题不在接口有没有运行,也不在路径写错,而在浏览器提到的 CORS
CORS 到底拦了什么:
CORS,中文叫 "跨源资源共享",是浏览器对网页 JavaScript 的一条安全规则
什么叫 "跨源":
一个 "源" 由三部分组成:
协议 + 域名 + 端口
任何一项不同,就是不同的源;我们的前端是 http://localhost:3000,后端是 http://localhost:8000:协议相同、域名相同,但端口不同------所以它们是两个源;网页脚本从 3000 去访问 8000,就是跨源
请求其实已经到达了后端:
对刚才这个简单 GET 来说,浏览器已经把请求发给了后端,后端也返回了 200;CORS 拦下的不是 "请求到达服务器",而是:
浏览器不允许当前网页的 JavaScript 读取这份未经授权的跨源响应
这也解释了为什么 curl 一直畅通无阻:CORS 是浏览器对网页脚本的规则,curl 不是网页,不受这条规则约束
浏览器怎样询问,后端怎样回答:
网页发起跨源请求时,浏览器会自动带上:
Origin: http://localhost:3000
意思是 "这段网页脚本来自哪里";后端如果愿意让这个来源读取响应,就在响应头里给出:
Access-Control-Allow-Origin: http://localhost:3000
这就是后端的许可:请求里说明来源,响应里给出许可;浏览器看到两边对得上,才把响应交给网页 JavaScript
给 FastAPI 加上 CORS:
要让网页读到响应,就得让后端在响应里带上那行 Access-Control-Allow-Origin;我们有两个接口,与其给每个接口都手写响应头,不如用一个现成的中间件统一处理------中间件是加在 "请求进入、响应离开" 必经之路上的一层处理,所有请求和响应都会过它一道
打开 backend/main.py,顶部增加 import:
python
from fastapi.middleware.cors import CORSMiddleware
紧跟在 app = FastAPI() 后面增加:
python
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
)
allow_origins 就是控制 Access-Control-Allow-Origin 这行响应头的开关:把前端地址 http://localhost:3000 填进去,后端就认这个来源
中间件还有别的参数,但眼下这个 GET 只差 "来源许可" 这一项,先加这一行就够,其余等真正遇到问题再补
保存后,后端自动重启;刷新主页------标题变成了 "关于我(来自后端)",数据终于从 8000 端口流到了 3000 端口的网页
再看 Network 里的 /api/profile,Response Headers 多了一行:
access-control-allow-origin: http://localhost:3000
这就是后端发的许可;确认成功后,可以把标题里的 "(来自后端)" 删掉,恢复正常文案;以后想验证数据是否来自后端,也可以临时改一个字段再刷新页面观察
第二次撞墙:POST 被预检拦下
主页的 GET 通了;接下来试文字实验室的 POST /api/analyze:这个接口已经写好,前端也替换好了,看起来一切就绪
打开文字实验室,输入一段文字,点击 "开始分析"------界面上冒出一行红字:Failed to fetch;又撞墙了
这个提示很笼统,只说 "请求失败了",没说为什么;于是还是老规矩,去现场找线索
打开 Network,会发现这次和上次不同:只点了一次 "分析",列表里却出现了一条从没写过的 OPTIONS 请求,而且它失败了;真正想发的那条 POST 反而没有出现:
OPTIONS /api/analyze ← 失败
(没有 POST)
再看 Console:
...has been blocked by CORS policy:
Method POST is not allowed by Access-Control-Allow-Methods in preflight response.
又是 CORS,但和第一堵墙不一样:第一次是请求发出去了、响应被拦回来;这次那条 POST 根本没发出去,被这个 OPTIONS 挡在了前面
这个 OPTIONS 是浏览器自动发的:
它有个专门的名字,叫 CORS 预检(preflight);认识 HTTP 方法时 OPTIONS 混过一次脸熟------ "我能对这个资源做什么",现在派上了用场
要点先说清楚:这个 OPTIONS 不是我们写的,也不是 Next.js 或 React 的功能,而是浏览器自动发的,属于 CORS 规则的一部分------这也解释了为什么之前用 curl 从没见过它:curl 不是浏览器,不受 CORS 约束
为什么第一个 GET 没有预检、这个 POST 却有?因为浏览器把跨源请求分成两类:像主页那个 GET,方法普通、也没带特别的头,属于简单请求,浏览器直接发,顶多事后拦下响应不给脚本读;而这次的 POST 带了 Content-Type: application/json,就成了不简单的请求------对这种请求,浏览器会先发一个 OPTIONS 去问后端:允许这个来源吗?允许 POST 吗?允许带这个请求头吗?预检通过,才发真正的 POST;不通过,POST 根本不会离开浏览器
为什么要多这一道?POST 往往会改动数据(比如发一条评论、下一个订单之类的):要是不问就发,即便响应被拦、脚本读不到,这件事也已经在后端做了;预检 "先问后发",把没授权、又可能产生副作用的请求,挡在发送之前
预检问的,正是我们没回答的:
预检失败的原因,Console 已经写明:Method POST is not allowed;回头看刚配的中间件,我们只写了 allow_origins------只说了 "谁能来",没说 "能用什么方法";预检由 CORSMiddleware 自动应答(不需要我们为 OPTIONS 写任何代码),它一查:来源允许,但方法 POST 不在名单里,于是打回
补上 allow_methods 即可:
python
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["GET", "POST"],
)
allow_methods 声明这个接口允许被哪些方法跨源调用;项目用到 GET 和 POST,就如实写这两个
预检其实还会问 "能不能带某些请求头",对应参数是 allow_headers:我们带的 Content-Type 属于浏览器默认放行的常见头,不必专门声明;将来若请求要带不常见的头(比如登录用的 token),才需要在 allow_headers 里列出
保存后端、自动重启,再点一次 "开始分析";这次结果区出来了:原文正是刚提交的文字,还有分数、判断和占位的拼音;换一段文字再点,原文随之改变------每一次结果都真的来自后端(分数和拼音目前仍是写死、占位的,后面才会真正计算)
回到 Network 再看,这次是两条请求:先一条 OPTIONS 预检(这次通过),紧接一条真正的 POST------这就是"先问后发"走通后的样子
同一个 CORS,两副面孔:
至此见到了 CORS 的两种场景:
-
简单 GET 已经到达后端,但没有许可时,网页不能读取响应
-
带 JSON 的 POST 会先发 OPTIONS 预检,通过后才发送真正的 POST
最后一步:把写死的地址收进配置
GET 和 POST 现在都正常了,但还留着一个小尾巴:前端代码里,后端地址是写死的;用 VS Code 全局搜索:
会在 HomeView.jsx 和 InputCard.jsx 里各找到一次
一个很现实的问题是:这个地址是会变的------后端换个端口、项目挪到另一台机器、以后要部署到线上,每一种情况这个地址都得跟着改;现在只有两处,改起来还不费事;可组件一旦多起来,同一个地址散落在十几个文件里,每变一次都要满项目搜索替换,漏掉一处就是一个特别难找的联调 bug
再往深一层看:一个地址该填什么,取决于代码跑在哪个环境------开发机、测试机、线上服务器各不相同;这种跟运行环境绑定的值,本就不该写死进组件,让业务代码为了换个环境反复改动
这说明:后端地址不属于组件的业务逻辑,而是一项配置,配置应该集中管理
你可能会问:后端 allow_origins 里那个 http://localhost:3000,不也是把地址写死在代码里了吗?没错,它同样是一个跟环境绑定的配置值,正式项目里也该从配置读取------道理是对称的:这里先拿前端这一侧当例子,把 "地址属于配置" 讲透;后端这类配置,留到部署时一起处理
创建 .env.local:
在前端项目根目录(也就是 package.json 所在的目录),新建 .env.local:
bash
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_ 前缀表示这个值可以进入浏览器代码;正因如此,它只能放后端地址这类可以公开的配置,不能放 API 密钥、密码等秘密信息------那些会被打包进浏览器,等于公开
在两个组件的 import 下方都增加:
javascript
const API = process.env.NEXT_PUBLIC_API_BASE_URL;
然后把硬编码地址分别改成:
javascript
fetch(`${API}/api/profile`)
和:
javascript
fetch(`${API}/api/analyze`, {
创建或修改环境变量后,要重启前端:
.env.local 是在开发服务器启动时读取的,光保存文件还不够,必须重启:
bash
# 按 Ctrl + C 停止原来的开发服务器
npm run dev
重启后,再分别验证主页 GET 和文字实验室 POST,应该一切正常
项目的 .gitignore 已经包含 .env*,所以 .env.local 默认不会进入 Git;为什么环境配置通常不直接提交、部署时又怎样提供真实地址,等真正部署后端时再具体处理
见证:网站真正活了
现在把这次 POST 请求的完整链路再看一遍:
输入文字
→ 浏览器发送 POST
→ OPTIONS 预检通过
→ FastAPI 接收并校验请求体
→ Python 计算结果
→ FastAPI 返回 JSON
→ 前端拿到 JSON、更新界面
→ 结果区自动刷新
从那个双击打开的静态页面,到今天------浏览器里运行着 React,电脑上运行着 Python,中间通过真实的 HTTP 请求交换数据,整条链路是我们自己搭起来的
主页这类编辑型内容,其实继续留在 site.js 完全没问题;把它接到 GET,主要是为了用最简单的数据学习第一次前后端联调;真正非后端不可的,是 /api/analyze 这种需要根据用户输入实时计算的功能
这一段的收束:
回头看后端这一段走过的路:亲手调用真实 API,理解了调用方、被调用方和接口约定;装好 Python、建好 venv,用 pip 和 requirements 管理依赖;零依赖手搓 API,看清 HTTP 的请求与响应;用 FastAPI 重写接口,体验路由、校验和自动文档;最后让 React 与 Python 真正握手,学会联调排查,认识 CORS 与 OPTIONS 预检,并把配置收进了环境变量
现在还有两个明显的欠账:
-
/api/analyze 的拼音是占位符,情感分数也只是写死的固定值,还不是真正的分析
-
每次分析结果用完就丢,没有历史记录
接下来,我们用 Python 生态里的第三方库把分析变成真的,再把结果保存下来
第三方库和 PyPI:
并非所有的功能都要从 0 开发------学习使用第三方库,拒绝重新造轮子
先还上欠着的账:
后端部分收尾时留了两笔账:/api/analyze 的分析是假的,每次分析完就丢;这一段把它们还清,先对付第一笔:让分析变成真的
那问题来了:"真的",怎么算?
先说拼音;要给任意一段中文标注拼音,得有一张覆盖几万个汉字的读音表;这还不够------中文有多音字:"重庆" 的 "重" 和 "重要" 的 "重" 不是一个音,"行长" "银行" "行走" 里的 "行" 能读出花来;还得整理出海量的词组规则,才能判断一个字在哪个词里读哪个音
再说情感分数;凭什么说这句话 "0.9 分,偏积极"、那句话 "0.5 分,偏中性"?这个分不是查表查出来的,得有一个从大量真实文本里学出来的模型来判断;训练模型,得先有语料、有方法、有时间
掂量一下:这两样我们能自己做吗?不是不能做,只是会很花时间;所以面对这种需求,正确的第一反应不该是 "我怎么把它写出来";程序员圈有个特别形象的说法------"重新造轮子" (reinvent the wheel):轮子早被造出来了,非要从头再搓一个,费时费力,还多半造得更糙;如果有成熟的现成品,就别自己重复造
所以第一反应应该是先问一句:这件事,社区里是不是早有人写好了?这一部分就把 "找现成的库" 这套动作完整走一遍
去哪找:PyPI,和几种查法
"别人写好的库" 放在哪?前端见过答案:npm 的包都放在 npm registry 上,npm install animejs 就是从那儿下载的;Python 世界的同款叫 PyPI (Python Package Index),网址 pypi.org------这里有几十万个包,人人可取
怎么从里面找到想要的那一个?方式不止一种:
-
直接上 pypi.org 搜关键词,看简介、安装命令、文档链接、版本历史
-
用搜索引擎搜 "python 中文 拼音 库" 这类
-
或者干脆问 AI:把需求描述给它,让它推荐几个候选
三条路都能给出 "候选",但谁都不保证候选靠谱------尤其是 AI;一是它的知识有截止时间,可能推荐过时的方案;二是它偶尔会一本正经地编出一个不存在的包名(这叫 "幻觉"),甚至有坏人专门抢注这类名字、往里塞恶意代码;再加上 PyPI 上谁都能上传------这是生态繁荣的原因,也意味着上面质量参差
所以立一条规矩:不管候选是搜出来的还是 AI 给的,拿到手都得自己验一遍;回想那句话------ "照着别人的文档,用上别人的能力",前提是先找到靠谱的 "别人"
用这套方法,锁定 pypinyin 和 snownlp:
回到两个需求,就用上面的方式查一查;搜 "中文 拼音",或者问 AI "Python 里给中文标拼音、还能处理多音字的库有哪些"------线索都指向同一个名字:pypinyin;再搜 "中文 情感分析",答案落在 snownlp 上:
候选有了;但先别急着 pip install------按刚立的规矩,先验
验库:靠谱吗?能不能满足需求?
验两层
第一层,靠不靠谱;打开候选在 PyPI 的页面(一般带 GitHub 仓库链接),看两个快信号:
-
GitHub 星数------多少人给它点过赞,是人气和信任最直观的参考
-
最近更新时间------还活着吗?持续发版说明有人在认真维护;几年没动的库要谨慎
第二层,能不能满足需求;人气高不等于合用,还得翻开文档,拿它和需求逐条对照:
-
拼音:要能带声调,还要认多音字("重庆" 和 "重要" 的 "重" 读不同音)
-
情感:要能给出一个 0 到 1 的分数,好换算成 "偏积极 / 偏消极"
读 pypinyin、snownlp 的文档,看它们提供的函数是不是正好覆盖这些------覆盖得上,才算选对了;看这两个库的文档,可以去它们各自的 GitHub 项目主页:
-
pypinyin:github.com/mozillazg/python-pinyin
-
snownlp:github.com/isnowfy/snownlp
打开仓库页,首屏那篇长长的说明就是 README------它相当于项目的门面、主页:库怎么装、提供哪些函数、每个函数怎么用,通常都写在这儿(PyPI 的包页面上一般也有指向 GitHub 的链接,顺着点过去就到)
读文档时还会注意到一个细节:它们的用法示例,经常是以 >>> 开头的 Python 代码;比如 pypinyin 的 GitHub README 中的写法:
bash
>>> from pypinyin import pinyin, lazy_pinyin, Style
>>> pinyin('中心') # or pinyin(['中心']),参数值为列表时表示输入的是已分词后的数据
[['zhōng'], ['xīn']]
>>> pinyin('中心', heteronym=True) # 启用多音字模式
[['zhōng', 'zhòng'], ['xīn']]
这个 >>> 是什么?等下回答,先在项目里安装这两个库
装上、试运行------顺便认识 REPL
安装之前,先看提示符,确认 venv 环境正确:
bash
cd ~/zero-to-tech/backend
source .venv/bin/activate # 确认提示符前有 (zero-to-tech)
pip install pypinyin snownlp
snownlp 的背后,是别人已经训练好的模型------模型不是代码,而是别人做的训练结果,我们 import 一下就能直接用;如果对 "模型" "训练" 这些词感觉陌生,先不要担心:今天毕竟是用轮子,不知道轮胎的橡胶是怎么加工的,其实也没关系
装好之后要跑一下试试;可以写个 Python 脚本(比如 pinyin_test.py)再运行,但有一种更便捷的方式值得学一下------刚才文档里满屏的 >>>,是 Python 的 REPL(交互式解释器):敲一行代码,立刻执行、立刻出结果;终端里直接敲 python 命令即可进入(注意在 (zero-to-tech) 环境里敲------刚装的库在这个环境里):
bash
python3
回车,提示符变成 >>>,就进来了;名字不用记,体感记住就行:敲一行看一行
先别急着 import 库,用几行最简单的代码感受一下 REPL 和 "写脚本" 有什么不一样:
bash
>>> 1 + 1
2
>>> name = "全栈"
>>> name
'全栈'
>>> print("你好," + name)
你好,全栈
注意第一行:敲 1 + 1 回车,它直接把 2 显示了出来------我们并没有写 print;这就是 REPL 和脚本最直观的区别;回想手搓 API 时的 handmade.py:那是把一整套逻辑写进一个文件,python3 handmade.py 从头到尾一次跑完,屏幕上只会出现显式 print 的内容;上面这几行要是写进 .py 文件去跑,1 + 1、name 这两行什么都不会显示,想看到 2 得写成 print(1 + 1);而在 REPL 里,敲进去的只要是个 "值",它就顺手把结果回显出来
一句话理清两者的关系:都是同一个 Python------.py 脚本是 "把动作写全、一次跑完",适合正式的程序;REPL 是 "敲一句、答一句",适合把玩、试错、快速验证一个想法或一个新库;今天验库,正是 REPL 的主场
好,手感有了;先验 pypinyin,正好对着那两条需求:
bash
>>> from pypinyin import pinyin
>>> pinyin("你好")
[['nǐ'], ['hǎo']]
>>> pinyin("重庆")
[['chóng'], ['qìng']]
>>> pinyin("重要")
[['zhòng'], ['yào']]
注意 "重庆" 和 "重要"------同一个 "重" 字,在 "重庆" 里读 chóng、在 "重要" 里读 zhòng,pypinyin 都判对了:它不光认多音字,还能看词定音;文档承诺的、需求要的,对上了------这背后就是那张几万字的读音表加词组规则,别人替我们整理好了,import 一下就能用
顺带看清 pinyin 返回的形状:[['chóng'], ['qìng']]------一个 "嵌套列表",读起来不太直观;我们的项目只想要一串干净的拼音,用不上这层嵌套;pypinyin 另给了一个更省事的函数 lazy_pinyin:
bash
>>> from pypinyin import lazy_pinyin
>>> lazy_pinyin("重庆")
['chong', 'qing']
lazy_pinyin 的区别在于:① 每个字只给一个音、不再套那层列表,直接是扁平的字符串列表;② 默认不带声调(chong 而非 chóng)
如果还需要声调,可以给 lazy_pinyin 加上 style=Style.TONE,声调就回来了:
bash
>>> from pypinyin import lazy_pinyin, Style
>>> lazy_pinyin("重庆", style=Style.TONE)
['chóng', 'qìng']
Style 是 pypinyin 提供的一组 "拼音样式" 开关,Style.TONE 就是 "带声调符号" 这一档;记住 lazy_pinyin(text, style=Style.TONE) 这个写法,下一段直接用它
再验 snownlp:
bash
>>> from snownlp import SnowNLP
>>> SnowNLP("今天的风很轻,适合把想法写下来").sentiments
0.9465...
>>> SnowNLP("太失望了,再也不来了").sentiments
0.0027...
sentiments 给出一个 0 到 1 之间的分数:越接近 1 越积极;第一句 0.94,第二句 0.003------这不是查表,是包里那个别人训练好的模型在 "判断";跑出来的小数位可能略有出入,这也是正常的
两条需求都验证通过,就可以退出 REPL:
bash
>>> exit()
以后拿到任何新库,都可以尝试进 REPL 玩两下------敲一行看一行,比闷头读半天文档更快建立手感(REPL 平时还能当计算器、当小试验田,随开随用)
顺带认识这片生态:中文 NLP
pypinyin 和 snownlp 都属于同一片生态------中文自然语言处理 (NLP,让程序 "处理人话" 的那一类技术);这片地界上还有一位常客值得认一下:
- jieba(结巴分词):把一句话切成一个个词------"我来到北京清华大学" 切成 "我 / 来到 / 北京 / 清华大学";分词是很多中文处理的第一步(搜索、统计词频、做词云......都先得切词);我们的项目用不上,认得名字就行,不安装
更值得了解的是:每个领域都有自己的一片生态------图像处理、爬虫、数据分析、AI......套路全是今天这一套:找库 → 验库 → REPL 玩两下 → 接进项目;这一部分学的不只是这两个库,而是这个套路
记上账:requirements.txt
两个重要的库装好了,最后别忘了那两条老规矩:.venv 不进 Git,但 requirements 清单要进;库变多了,重新记一次账:
bash
pip freeze > requirements.txt
这样 pypinyin、snownlp 这两个依赖就连同它们各自的依赖一起记进来了
这份清单的意义是:任何人拿到这个项目,只需要------
bash
pip install -r requirements.txt
一条命令,整个环境原样复现;"任何人" 三个字里,也包括将来站在服务器上的我们------到时候会亲手体会这份清单值多少钱(对照老直觉:它就是后端的 package.json,pip install -r 就是后端的 npm install)
顺手把改动提交了------这一部分代码一行没动,只有 requirements.txt 变了,正好是一次干净的提交
让网页真的会分析文字:
两个库装好了、也验证过了,接下来把它们应用起来,通过 /api/analyze 对外提供服务------只改接口的 "内部",不动接口的 "约定"
只动一处:
这一部分只需要动 analyze 函数的内部:API 的访问地址不动、方法不动、请求体不动、返回的字段一个不加一个不减
看一下现状;此前做好的接口形状长这样------分析值全是写死的占位:
python
@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
return {
"text": req.text,
"score": 0.5,
"label": "偏平静",
"pinyin": "(先占位)",
}
分数永远 0.5,标签永远 "偏平静",拼音干脆写着先占位------现在就把这几个值都搞活
动手换芯:
打开 backend/main.py;先在文件顶部把两位新成员请进来(import 照例放顶部):
python
from pypinyin import lazy_pinyin, Style
from snownlp import SnowNLP
lazy_pinyin 之前体验过;Style 是它的搭档------用它让拼音带上声调
再看占位版返回的四个字段:text、score、label、pinyin;text 本来就是真的(原样回传用户输入),剩下三个是假的;先挑两个能直接从库里拿到的下手------score 和 pinyin;把 /api/analyze 改成:
python
@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
text = req.text
score = round(SnowNLP(text).sentiments, 2) # 真模型打的分
return {
"text": text,
"score": score,
"label": "偏平静", # ← 先留着,下面处理
"pinyin": " ".join(lazy_pinyin(text, style=Style.TONE)), # 真拼音,带声调
}
主要变化有两处:一处是用 SnowNLP 算出一个 score,交给 return 里的 score;另一处是用 lazy_pinyin 算出拼音,交给 return 里的 pinyin;具体语法不理解也没事,知道发生了什么就行
现在只剩 label 还占着位;它和前两个不一样------这两个库里并没有一个函数能直接吐出 "偏积极
" 或 "偏平静" 这几个字;label 是给人看的结论,得由我们从 score 这个数字换算出来:接近 1 说 "偏积极",接近 0 说 "偏消极",中间地带算 "中性"
这段 "数字 → 结论" 的翻译逻辑,值得单独拎成一个小函数,放在 analyze 上面(elif 就是 "else if",手搓路由时见过这种连排判断):
python
def score_label(score):
if score >= 0.6:
return "偏积极"
elif score <= 0.4:
return "偏消极"
else:
return "中性"
这两个阈值(0.6 / 0.4)不是什么标准答案,是拿一批句子实测分数之后拍板的产品决定------分数是模型给的,但 "多少分算积极" 由我们说了算,也可以调成别的
有了它,把 analyze 里那行占位的 "label": "偏平静" 变成 "label": score_label(score),这个 API 就完全写好了:
python
@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
text = req.text
score = round(SnowNLP(text).sentiments, 2)
return {
"text": text,
"score": score,
"label": score_label(score),
"pinyin": " ".join(lazy_pinyin(text, style=Style.TONE)),
}
代码其实没多几行,但能力已经强了许多------它真的可以做计算了
见证效果:
改完代码,分别启动前后端服务:
bash
cd ~/zero-to-tech
npm run dev
bash
cd ~/zero-to-tech/backend
source .venv/bin/activate
fastapi dev
直接访问 http://localhost:3000,进入文字实验室;在输入框里打一句话,点 "开始分析":
-
拼音真的出来了------带着声调,多音字也对(顺带一提:句子里的逗号会原样留在拼音里,因为 lazy_pinyin 只翻译汉字,非汉字原样透传,这是正常的)
-
分数是模型打的------试试这几句(实测过的分数,跑出来应该一致):
| 输入 | score | label |
|---|---|---|
| 我特别喜欢这部电影 | 0.95 | 偏积极 |
| 今天的风很轻,适合把想法写下来 | 0.95 | 偏积极 |
| 太失望了,再也不来了 | 0.00 | 偏消极 |
此前欠下的账,全部都清了
为什么前端不用改?
有没有注意到:这一节前端一点都没改;为什么?
因为虽然接口的实现变了,但接口的约定没变:路径还是 /api/analyze,方法还是 POST,请求体还是 {"text": ...},返回还是那四个字段------变的只是约定背后的具体实现方案
讲 API 时说过 "API 的调用方不需要知道服务端内部怎么实现";今天站在服务方这一侧,可以体会到这句话的另一面:
只要守住约定,内部随便换。调用方不知道、也不需要知道
顺手看一眼 http://localhost:8000/docs,文档也纹丝没动,因为约定没变
我们哪怕用 Java 把后端重写一遍,或者换一个新的模型来计算情感------只要这个 API 的约定不变,前端都不用改
模型的边界:
接下来多试试这个情感模型,试试这两句:
| 输入 | score | label | 问题 |
|---|---|---|---|
| 今天下午三点开会 | 0.26 | 偏消极 | 一句毫无感情的话,被判了 "偏消极" |
| 呵呵,真是太棒了呢 | 0.94 | 偏积极 | 阴阳怪气,它当了真 |
翻车了,为什么?
因为 snownlp 的情感模型,主要是在商品评论语料上训练出来的------它擅长判断 "像评论的句子"(好评差评那种),但 "下午三点开会" 这种中性陈述、以及反讽阴阳怪气,都在它的训练经验之外
这不代表 snownlp 太差,而是所有模型的共性:
模型没有 "常识",只有 "训练时见过的世界"
用任何模型之前,先弄清它的边界------知道它哪里不准,比迷信它的分数重要得多;这句话在大模型时代照样成立:ChatGPT、DeepSeek 也有各自的边界(幻觉就是一种),只是边界更远、更隐蔽
放眼看看:从小模型到大模型
说到大模型------也许已经想到了:情感分析这件事,今天完全可以调用大模型的 API 来做(就像调 DeepSeek 那样,把句子发过去,让它打分);那为什么我们不用?
把本地小模型和云端大模型 API 这两条路摆在一起:
| 本地小模型(snownlp) | 大模型 API(如 DeepSeek) | |
|---|---|---|
| 花钱 | 免费 | 按量计费 |
| 联网 | 不需要 | 必须 |
| 速度 | 本地毫秒级 | 网络往返+推理,秒级 |
| 准确度 | 够用,边界明显 | 强得多,连反讽都懂 |
| 隐私 | 数据不出自己的机器 | 用户的文本要发给第三方 |
再往远看一步:大模型也不只有 "调云端 API" 这一条路;像 DeepSeek 这类开源大模型,权重是公开的,可以下载到自己的机器上跑------业内叫 "本地部署" 或 "私有化部署":数据不出门、也不按次付费,代价是得自己备一台够劲的机器,还得自己维护
而且这类开源模型通常有好几种尺寸:参数量常写成 1.5B、7B、70B、671B 这样(B = 十亿)------尺寸越大越聪明,但也越吃显卡、越慢;具体部署哪一种尺寸,得根据自己手上的机器配置来挑
没有绝对的好,只有合不合适;我们的文字实验室是教学项目,免费、快、离线的本地库完全够用;从 snownlp 这样的小模型,到自己部署的开源大模型,再到云端顶配 API,是一条连续的谱系------真做产品时按预算、隐私、精度选其中一段;而不论选哪段,对我们这个项目来说都只是 "再换一次芯" 的事:壳,还是不用动;如果哪天不满足 snownlp 的能力、想把后端改为大语言模型,完全可以基于学到的知识继续改
结语:
到这里,项目的功能开发基本完成,接下来本该转入部署、安全与运营;不过在此之前,还有一笔账要还------每次分析的结果用完即弃,项目还没有 "记忆";给文字实验室配上记忆,让它记下用户查过的句子,这就是后面要做的事