如果你和我一样,平时会在微信里随手记点想法、读书摘抄和工作要点,大概率也遇到过同一个烦恼:记的时候很爽,找的时候就傻眼了。我最近基于 Python 和微信小程序做了个智能笔记,核心思路一句话:小程序负责快速记录和展示,Python 后端负责把碎片化内容自动整理成带关键词、能摘要、可全文搜索的结构化数据。项目从想法到跑通花了两三周,踩了不少坑,也沉淀了一些经验。这篇文章不写空泛的概念,直接把需求和选型、关键词和摘要的落地代码、小程序端的翻页与搜索交互、以及真机联调和体验版的完整链路从头捋一遍。适合谁看呢?想用 Python 给小程序做后端能力的开发者、想做个人知识库工具的业余玩家,以及正卡在“开发环境调通、一上真机就出问题”阶段的朋友。
1. 为什么把“智能”放在 Python 后端,而不是塞进小程序
1.1 笔记应用里的“智能”到底指哪几件事
很多人一听到“智能笔记”,第一反应就是上大模型。但我实际做下来觉得,个人笔记这个场景真正的痛点不是“生成”,而是“召回”——你记了一百条散装笔记,能不能在需要的时候快速找到对的那一条。
所以我把“智能”拆成了这么几个具体能力:
- 自动打标签:碎片笔记不需要手动归类,系统根据内容自动归入“工作”“学习”“Python”“阅读”等分类。
- 关键词抽取:列表页不用翻正文,扫一眼关键词就知道这篇笔记讲了什么。
- 自动摘要:长文笔记在列表里不能只从头截取,需要用算法把真正有信息量的句子捞出来。
- 全文搜索:搜“FastAPI”能把所有相关笔记都找出来,而不是只靠标题匹配。
- 相似笔记关联:这个我放在二期,不阻塞主体功能。
把这些需求翻译成技术,就是一个典型的 NLP 小项目:分词、停用词过滤、TF-IDF / TextRank 抽取、标签规则引擎、全文检索。这些东西如果全塞进小程序端,基本是折磨自己——小程序代码包有体积限制,开发者工具里的 JavaScript 环境跑分词词典都有点吃力,更别提换模型了。所以第一版架构就直接定了:Python 做后端,小程序只做前端的壳。
1.2 技术选型对比:为什么是 Python 后端 + 原生小程序
我先对比了几条路线,下面的表格是我当时做的选型记录:
| 实现方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| 纯小程序端实现全部功能 | 部署简单,不用租服务器 | 分词性能差、代码包体积受限、算法升级要发版 | 不推荐 |
| Python 后端 + 原生小程序 | NLP 生态成熟、算法可随时升级、小程序保持轻量 | 需要服务器、域名备案和 HTTPS | 最终采用 |
| App | 体验完整、能力不受限 | 安装成本高、分发难 | 不适合个人工具 |
| H5 | 开发快、更新方便 | 入口不够轻、微信原生能力弱 | 只作为兜底 |
Python 的优势不用多说,生态里有 jieba、scikit-learn、FastAPI 这些东西,一个人开发的时候效率很重要。Java 或者 Go 写接口也不难,但 NLP 这一块明显还是 Python 顺手。
Web 框架我在 FastAPI 和 Flask 之间犹豫过,最后选了 FastAPI。原因是 FastAPI 自带 OpenAPI 文档,联调的时候直接打开/docs页面就能测接口;Pydantic 做参数校验也省了我不少事;异步支持让后面接入后台任务变得很干净。如果你的项目只有三五个接口,用 Flask 也行,但一旦涉及请求体校验和异步任务,FastAPI 的体验会好很多。
1.3 链路设计与分层:一次保存请求的完整旅途
整个系统的调用链路大致是这样的:
小程序页面发起请求 → Nginx 反向代理(HTTPS)→ FastAPI 接口层 → 文本清洗与分词 → 关键词和摘要抽取 → 标签规则引擎 → 数据库写入 → 返回结构化 JSON。
我在设计的时候特意把“NLP 处理”和“接口返回”解耦。用户点保存笔记,后端先把笔记基础数据写入数据库,返回一个“已接收”的状态,然后后台异步跑分词和摘要,处理完再回填到记录里。如果同步去跑,第一次加载 jieba 词典就要一秒钟左右,用户会明显感觉到卡顿。
还有一个好处是,只要接口协议稳定,同一个后端可以服务多条业务线——小程序只是入口,未来如果想加一个每周笔记邮件推送,或者做一个 Web 端访问入口,后端基本不用重写。
2. 智能笔记的“智能”部分:从原始文本到结构化信息
需求捋清楚之后,下一步就是把“智能”拆成能落地的代码。这一块是整个项目里最有意思的部分。
先说一下环境准备。如果你还在装 Python 的阶段,就去官网下载 3.10 以上的安装包,装的时候记得勾选“Add Python to PATH”。装好之后在终端敲python --version能看到版本号,就说明环境通了。项目依赖只需要几个库:
pip install jieba fastapi uvicorn2.1 先清理再分词:脏文本会毁掉所有统计
我见过不少新手直接拿原始文本做分词,结果词频统计里全是被截断的 URL、乱七八糟的符号和无意义的虚词。笔记文本还有一个特点,就是从微信复制过来的内容经常带一堆换行、空格和特殊符号,不清洗的话,后面所有统计都会偏。
所以清洗这一步比算法本身更重要。我的清洗逻辑大概是这样的:
import re import jieba STOPWORDS = set() def load_stopwords(path="stopwords.txt"): """加载中文停用词表""" with open(path, encoding="utf-8") as f: for line in f: word = line.strip() if word: STOPWORDS.add(word) def clean_text(raw: str) -> str: # 去掉 URL、话题标签 text = re.sub(r"https?://\S+", "", raw) text = re.sub(r"#\S+", "", text) # 压缩连续空白 text = re.sub(r"\s+", " ", text) text = text.strip(",。;、,.!!?? ") return text def tokenize(text: str) -> list[str]: return [ w for w in jieba.lcut(text) if w.strip() and len(w) > 1 and w not in STOPWORDS ]停用词表建议自己准备一份中文的。网上搜“中文停用词表”能找到,但我实际用下来还要自己过滤掉“我们”“就是”“一个”“这种”这类口语词,因为个人笔记里这类词出现的频率特别高,不滤掉关键词就会被它们占满。
这里有一个特别容易被忽略的坑:jieba 对计算机领域的新词识别不准。比如“大模型”默认可能被切成“大/模型”,对 AI 主题笔记来说这种错切会直接影响标签和摘要质量。解决办法是准备自定义词典:
大模型 50 n 私有化部署 30 nz RAG 20 nz Fine-tuning 20 nz然后在代码里加载:
jieba.load_userdict("userdict.txt")这个自定义词典一定要在分词之前配置好,否则后面所有关键词结果都会偏离,再回头排查的时候很难意识到是分词器的问题。
2.2 关键词和摘要:TF-IDF 与 TextRank 双管齐下
关键词抽取我直接用 jieba 自带的analyse.extract_tags,它本质上是 TF-IDF 的实现。核心思想用大白话说就是:如果一个词在你这一篇笔记里出现得多,但在你所有笔记里很少出现,那它就很有可能是这篇笔记的主题词。就像一群人聊天,只有你这儿反复提“FastAPI”,那这次聊天十有八九跟 FastAPI 有关。
摘要我用了jieba.analyse.textrank,TextRank 算法类似于网页排名。把句子看成节点,句子之间如果共用了某些关键词,就建立一条边,然后迭代计算每个句子的得分,最后取分数最高的几个句子作为摘要。
实际使用代码很短:
from jieba.analyse import extract_tags, textrank def extract_info(text: str, top_k: int = 5): keywords = extract_tags(text, topK=top_k, withWeight=True) summary_sentences = textrank(text, topK=3) return keywords, summary_sentences拿一段真实示例文本跑一下,大概是这个效果:
demo_text = """ FastAPI是一个现代的Python Web框架,基于Starlette和Pydantic构建, 支持异步接口和自动生成API文档。我在智能笔记项目中使用FastAPI提供后端服务, 同时结合jieba分词库实现了关键词抽取和自动摘要功能。 """ keywords, summaries = extract_info(demo_text) print(keywords) # [('FastAPI', 0.356), ('摘要', 0.233), ('关键词', 0.201), ('后端', 0.175), ('分词', 0.152)] print(summaries) # ['我在智能笔记项目中使用FastAPI提供后端服务,同时结合jieba分词库实现了关键词抽取和自动摘要功能。']这里要提醒一下:TF-IDF 里的 IDF 是整个语料库的统计值。如果笔记很少,IDF 计算就不稳定。我的方案是把所有笔记合并成一个语料池,定期重算一遍 IDF;新笔记写入后,会触发一次后台任务更新语料统计,这样关键词抽取会随着笔记增多越来越准。
2.3 自动标签:规则保底、关键词补充
标签是整个笔记列表里最直观的分类入口,也是用户感知“智能”最强烈的一个点。我做自动标签用了两层策略:规则层加关键词层。
规则层本质是一组关键词映射表:
LABEL_RULES = { "工作": ["会议", "需求", "验收", "日报", "周报"], "学习": ["课程", "教程", "学习", "笔记"], "Python": ["python", "flask", "fastapi", "django", "爬虫", "venv"], "阅读": ["阅读", "读完", "书摘", "章节"], } def auto_labels(text: str, keywords: list[tuple[str, float]]) -> list[str]: labels = set() lower_text = text.lower() for label, words in LABEL_RULES.items(): if any(w in lower_text for w in words): labels.add(label) for word, _ in keywords: if len(word) <= 4: labels.add(word) return list(labels)[:5]规则层保证“工作笔记”不会莫名其妙被归到“阅读”类,关键词层则负责补充个性化标签。我当时也试过完全用聚类算法,用 KMeans 把笔记向量化再分组,效果很一般——个人笔记主题太分散,聚类中心不稳定,远不如“规则 + 关键词”直观可控。
2.4 FastAPI 接口:把能力包成可调用的 API
现在把这些能力包成接口,小程序端只需要 POST 一条笔记,就能拿回关键词、摘要和标签。
from fastapi import FastAPI, BackgroundTasks from pydantic import BaseModel from typing import Optional app = FastAPI() class NoteIn(BaseModel): title: str content: str tags: Optional[list[str]] = [] class NoteOut(BaseModel): id: int title: str keywords: list[str] summary: str labels: list[str] def process_note(note_id: int): """后台处理:清洗文本、抽关键词、生成摘要、打标签""" # 这里省略数据库读取逻辑 raw_text = "从数据库读取的笔记正文" cleaned = clean_text(raw_text) keywords, summaries = extract_info(cleaned) labels = auto_labels(cleaned, keywords) # 回填数据库字段:keywords、summary、labels、status=done # update_note(note_id, keywords=keywords, summary=..., labels=labels) @app.post("/api/note", response_model=NoteOut) async def create_note(note: NoteIn, background_tasks: BackgroundTasks): # 先快速写入基础数据,status=pending # note_id = insert_note(title=note.title, content=note.content) background_tasks.add_task(process_note, note_id) return NoteOut( id=note_id, title=note.title, keywords=[], summary=note.content[:50], labels=note.tags )开发阶段数据库用 SQLite 就够了,一行sqlite3.connect就能跑起来。生产环境建议换 MySQL 或者 PostgreSQL,至少用 SQLAlchemy 做 ORM,否则后面表结构变更会非常痛苦。我当时的做法是先用 SQLite 把整个流程跑通,确认所有功能没问题后再切 MySQL,切换成本主要就是改数据库连接配置。
3. 小程序端的核心交互:列表翻页、搜索防抖和接口封装
后端接口搞定,前端这些页面就好做多了。但小程序端的交互不是简单渲染数据,有几个细节直接影响体验。
3.1 页面规划:减少页面的心智负担
整个小程序我控制在三个页面:首页笔记列表、编辑页、搜索页。
- 首页:卡片列表,每张卡片显示标题、摘要、标签和时间。
- 编辑页:标题输入框加正文 textarea,底部放一个“保存”按钮。
- 搜索页:输入框加搜索结果列表,顶部几个标签快捷筛选。
这里有一个前端适配细节:不同机型的顶部导航栏高度不一样,写自定义导航栏时不要写死数值,用微信提供的胶囊按钮位置做动态计算,不然刘海屏和全面屏会出现明显的错位。
我选择用原生小程序而不是 uni-app 或 Taro,是因为项目就三个页面,前端很轻,没必要引入跨端框架的构建链路。如果你打算将来同时发布到支付宝小程序或百度小程序,再考虑跨端框架也不迟。
3.2 列表加载更多:onReachBottom 与翻页参数
列表页最核心的问题就是分页。微信小程序的onReachBottom会在页面滚动到底部时触发,但触发时机可能比预期频繁,如果不加锁,会连续发出多个重复请求。
我的实现如下:
// pages/index/index.js Page({ data: { list: [], page: 1, pageSize: 10, hasMore: true, loading: false }, onLoad() { this.loadList(true); }, onReachBottom() { if (this.data.loading || !this.data.hasMore) return; this.loadList(false); }, loadList(reset) { if (this.data.loading) return; this.setData({ loading: true }); const page = reset ? 1 : this.data.page; wx.request({ url: `${BASE_URL}/api/notes`, data: { page, pageSize: this.data.pageSize }, success: (res) => { const rows = res.data.rows || []; this.setData({ list: reset ? rows : this.data.list.concat(rows), page: page + 1, hasMore: rows.length === this.data.pageSize, loading: false }); }, fail: () => { this.setData({ loading: false }); } }); } });两个细节说明一下。第一,loading字段的主要作用不是给用户看加载动画,而是锁住并发请求——触底事件可能连续触发,如果没有锁,同一页数据会被请求十几次。第二,hasMore的判定用rows.length === pageSize,如果返回不足一页就说明没有更多数据了。后端接口也要记得给列表字段加索引,不然数据量上来了全表扫描会很慢。
后端对应的分页接口可以按这个思路写:
@app.get("/api/notes") def list_notes(page: int = 1, pageSize: int = 10): # 按创建时间倒序,返回 rows 和 total rows = get_notes_page(page, pageSize) return {"rows": rows, "page": page, "pageSize": pageSize}3.3 搜索防抖:不只是性能,还有结果乱序
搜索框要防抖,这个很多朋友都知道。但防抖还有一个容易被忽略的附带作用:避免结果乱序。
具体来说,用户输入“Python”后停顿了一下,然后改成“人工智能”。如果不做防抖,刚才那个“Python”请求可能还没返回,用户就已经发起了“人工智能”请求。网络情况一波动,先发出的请求可能后返回,最终页面上显示的是上一次搜索的结果,这时候用户会以为系统坏了。
我的代码里用一个requestSeq变量来解决这个问题:
// pages/search/search.js let searchTimer = null; let requestSeq = 0; Page({ data: { keyword: '', results: [], searching: false }, onInput(e) { const keyword = e.detail.value.trim(); this.setData({ keyword }); clearTimeout(searchTimer); if (!keyword) { this.setData({ results: [] }); return; } searchTimer = setTimeout(() => { this.doSearch(keyword); }, 400); }, doSearch(keyword) { const seq = ++requestSeq; this.setData({ searching: true }); wx.request({ url: `${BASE_URL}/api/search?q=${encodeURIComponent(keyword)}`, success: (res) => { if (seq !== requestSeq) return; // 丢弃过期的响应 this.setData({ results: res.data.items || [], searching: false }); }, fail: () => { if (seq === requestSeq) this.setData({ searching: false }); } }); } });这个seq !== requestSeq判断非常关键。我第一次做的时候没加这个设施,结果就是“Python”的结果晚于“人工智能”返回,页面显示的是旧关键词的搜索结果。排查了半天,最后发现是响应乱序导致的,加上序号判断后问题立刻消失。
搜索接口本身也要做一点处理。个人笔记场景数据量不大,用数据库的 LIKE 查询就够,但关键词最好做分词拆解,把分词结果用 OR 组合,这样匹配范围更合理。
3.4 封装 request 与用户身份绑定
所有请求都直接写wx.request会导致大量重复代码。我封装了一个简单的 Promise 版本:
const BASE_URL = 'https://api.example.com/api'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'content-type': 'application/json', 'Authorization': 'Bearer ' + (wx.getStorageSync('token') || '') }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data); } else if (res.statusCode === 401) { // token 失效,引导重新登录 wx.navigateTo({ url: '/pages/login/login' }); reject(res); } else { reject(res); } }, fail: reject }); }); } module.exports = { request };用户身份绑定走的是标准微信登录流程:小程序端wx.login()拿到临时 code,发给后端,后端用 code 调用微信的jscode2session接口换 openid,再生成自己的 token 返回给小程序。小程序把 token 存到wx.setStorageSync,后续所有请求带上 token。
这里要注意:不要在客户端拿 code 当身份凭证。code 有时效性,而且按微信的规范必须由后端换 openid,小程序端不应该感知 openid 的存在。
3.5 草稿保存:监听 onHide 和 onUnload
编辑页有一个很实际的场景:用户写了一大段笔记,切到微信回了个消息,回来发现草稿丢了。这时候就需要监听页面隐藏和卸载事件。
小程序页面生命周期里有onHide和onUnload,分别在页面切到后台和页面销毁时触发。我在这两个事件里把编辑框内容写入本地 storage:
// pages/edit/edit.js Page({ data: { title: '', content: '' }, onTitleInput(e) { this.setData({ title: e.detail.value }); }, onContentInput(e) { this.setData({ content: e.detail.value }); }, onHide() { this.saveDraft(); }, onUnload() { this.saveDraft(); }, saveDraft() { if (!this.data.title && !this.data.content) return; wx.setStorageSync('draft_note', { title: this.data.title, content: this.data.content, time: Date.now() }); } });下次进入编辑页时,读取本地草稿并提示用户是否恢复。这个功能虽然不起眼,但实际用起来非常提升好感度——笔记类工具最怕丢内容,一次丢失就可能让用户直接放弃整个产品。
4. 真机联调、抓包和体验版:连接开发与真实用户
到这里,开发层面的功能基本齐了。真正折磨人的是联调阶段——本机跑得好好的,一到真机就各种翻车。这块我踩坑最多,单独拿出来写。
4.1 本机能跑、手机就崩:先解决本地联调
开发者工具里可以勾选“不校验合法域名”,所以后端跑在http://127.0.0.1:8000时,开发者工具里能正常请求。一旦用真机预览,请求大概率直接失败。原因很简单:手机上的微信根本不认识127.0.0.1,这个地址在手机上看指代的是手机自己。
解决办法分几步:
- 后端启动时监听所有网卡:
uvicorn main:app --host 0.0.0.0 --port 8000。 - 手机和电脑连同一个 Wi-Fi。
- 电脑 IP 用
ipconfig(Windows)或者ifconfig(Mac/Linux)查看,假设是192.168.1.10。 - 小程序里
BASE_URL临时改成http://192.168.1.10:8000/api。 - 微信开发者工具的“预览”弹层里勾选“启用开发调试模式”,手机端才允许访问 http 域名。
这一步我当时卡了差不多一个晚上,最后发现就是手机微信的域名校验在捣鬼。注意这只能是开发阶段的临时方案,正式发布必须换成有备案的 HTTPS 域名。
4.2 用 Charles 抓包看真实请求:一次难忘的 401
真机上接口报错,但开发者工具里一切正常,这种情况下最有效的排查方式就是用抓包工具看真实请求。Charles 是我常用的选择。
操作流程:
- 电脑打开 Charles,默认代理端口 8888。
- 手机 Wi-Fi 设置里把 HTTP 代理指向电脑 IP:8888。
- 手机浏览器访问
chls.pro/ssl下载并安装 Charles 的 SSL 证书。 - 重新打开小程序,Charles 里就能看到所有
wx.request请求的完整链路:请求地址、请求头、请求体、响应体。
抓包能定位到很多真机上才暴露的问题。我印象最深的一次是搜索接口在真机上始终返回 401,开发者工具里却完全正常。抓包一开,发现手机端请求头里的 token 是空的——原因是页面跳转太快,wx.login的回调还没执行完,页面就已经发出了请求。这类时序问题不看真实请求很难定位。
需要强调一下:Charles 抓包是用来做正常开发调试的,不要绕过任何证书校验机制,也不要拿它做非法用途。常规的抓包分析完全可以满足联调需求。
4.3 体验版分发:收集试用反馈的正确姿势
功能写完,想喊几个朋友试用,这时候不需要提审上线,走体验版通道就行。
具体流程:
- 微信开发者工具右上角点“上传”,填版本号和备注。
- 登录微信公众平台,进入“版本管理”。
- 在“开发版本”里找到刚上传的版本,点“设为体验版”。
- 在“成员管理”里添加体验成员,填朋友的微信号。
- 朋友在微信里直接打开你的小程序体验版,就能正常使用。
这里有个关键决策:体验版一定要把后端部署到有正式 HTTPS 域名的服务器上,不要让朋友连你的笔记本局域网。最简单的低成本方案是买一台云服务器,装好 Nginx 和 TLS 证书,FastAPI 跑在 8000 端口,Nginx 反向代理到 443。然后在微信公众平台配置 request 合法域名。
这里还要提醒两件事。第一,微信小程序的正式版和体验版都会强制校验 TLS,证书不能是自签的,必须机构签发。第二,域名备案要提前处理,不然配置合法域名时会被卡住。个人主体的小程序每隔一年要做一次年审,这个时间节点也值得记在日历里,过期了会影响服务。
我当时的做法是在小程序里加一个简单的“反馈”入口,试用者可以直接提交使用感受。这样收集到的反馈会比口头沟通完整得多,也方便后续迭代。
4.4 上线前检查清单:别让细节毁掉体验
最后整理一份上线前检查清单,都是我吃过亏之后总结出来的:
| 检查项 | 说明 |
|---|---|
| HTTPS 域名 | request 合法域名必须配置,证书有效且非自签 |
| 接口鉴权 | token 过期处理、越权访问、openid 绑定 |
| 数据备份 | 每天定时 dump 数据库,备份文件放对象存储或异地 |
| 隐私协议 | 小程序审核需要说明收集了哪些用户数据 |
| 分页索引 | 列表和搜索接口的字段加数据库索引 |
| 日志监控 | 后端接口打印 request_id,出错能快速定位到具体请求 |
| 连接池 | 数据库连接用连接池,避免短连接在高并发下崩溃 |
日志这里多说一句:不要打印用户笔记的完整正文,一方面涉及隐私,另一方面日志文件很快就会膨胀。打印长度截断后的摘要字段就够了。
这套项目做到最后,我最大的感受是:所谓“智能”,并不是一开始就要上多复杂的东西。先把分词、关键词、摘要这些基础能力跑通,用户就已经能感受到明显差异了。后续如果想做相似笔记推荐,可以考虑向量数据库加文本嵌入模型,把关键词阶段的输出作为一个特征喂进去,这是另一个话题了。
给同样想试试的朋友一个建议:别一上来就追求完整产品,先把“保存一条笔记,自动返回关键词和标签”这条链路跑通,再逐步加列表翻页、搜索防抖和体验版分发。功能是慢慢长出来的,链路通了,后面每一步都很快。如果你也在折腾类似的小程序加 Python 项目,欢迎分享一下你踩过的那些坑。