简介:这是一份面向微信小程序开发者与人工智能初学者的实战示例代码包,定位为可直接运行和二次改写的演示工程。项目用微信小程序承载人工智能应用场景,包含关于、首页、我的、待办、消息等页面模块,覆盖页面逻辑、样式、配置和组件封装,适合希望把人工智能能力落地到小程序端、学习项目结构拆分的读者参考。资源包共 59 个文件,以图片素材、JS 脚本、WXML/WXSS 页面代码、JSON 配置和说明文档为主,整体仅 1.36MB,轻量易下载,便于快速查看目录并动手调试。目前已有 242 人浏览学习。通过这份实践包,可以获得一套完整的小程序前端工程骨架与交互演示,包括录音、播放、搜索等场景的视觉素材和工具函数,还能借助说明文档快速理解各目录用途,减少从零搭建的重复工作。
1. 人工智能实战微信小程序:这份 demo 到底能跑通什么事
下载这份《人工智能实战微信小程序demo.zip》之前,我先确认了一件事:它到底是能直接跑的完整项目,还是只有前端页面的空壳。解压之后可以明确,这是一个典型的前后端分离小项目——微信小程序负责拍照和上传,Python 后端负责跑 AI 模型推理,再把结果返回页面。换句话说,它补齐了人工智能大作业最常见的缺口:模型训练好了,却不知道怎么接进小程序。
处理手写数字识别这类任务时,常见做法是让 MNIST 模型跑在 Flask 服务里,小程序端只做三件事:取图、压缩、展示结果。这份 demo 的价值不在算法有多新,而在于把「模型服务化」和「小程序对接」这两段最容易翻车的环节完整串了起来。如果你正在做课程设计,或者想把一个训练好的模型接到微信小程序上,又不想从零踩一遍接口联调的坑,这份资源能帮你省下不少联调时间。
后面几章我会按「架构 → 后端接口 → 小程序端 → 踩坑记录 → 改造方向」的顺序,把这份 demo 完整拆开。代码里有注释的地方我会说明为什么这样写,参数我会指出哪些能改、怎么改。适合人群:有 Python 基础、正在写人工智能大作业或微信小程序项目实例的学生,以及想快速把模型做成演示产品的开发者。
2. 拆开 demo 看数据流:小程序、Flask 与模型三层的调用链
拿到一个 demo 压缩包,第一件事不是看代码,而是先搞清楚数据是怎么在页面、后端、模型之间流动的。这一层想清楚了,后面改代码才不会像盲人摸象。
2.1 一次识别请求的完整路径
用户在页面点击「拍照识别」后,完整路径是这样的:小程序端调用wx.chooseImage拿到临时文件路径,再用 canvas 把图片压缩成固定尺寸,转成 base64 字符串;接着wx.request发起 POST 请求到后端/predict接口;Flask 收到请求后做 base64 解码,用 PIL 打开成灰度图,经过 resize、归一化等预处理变成张量;模型执行一次前向推理,得到 10 个类别的概率分布;后端取出概率最高的类别和置信度,用 JSON 返回;小程序端拿到数据后setData渲染到页面上。
这条链路里最容易被忽略的是「接口协议」——前后端对字段名、数据类型、错误码的约定。这份 demo 里,前端传给后端的字段是image(base64 字符串,不带 dataURL 前缀),后端返回的字段是class_id、label、confidence、cost_ms。如果换一个项目,字段名可能是img或者result,不先读协议直接套用,后面必挂。
另外注意一个细节:小程序端在压缩图片时会先取图片的宽高,按等比缩放后再drawImage,而不是直接把原图塞进张量。因为原图可能是 3000×4000,直接 resize 成 28×28 会丢失大量信息,而且 base64 编码会让请求体膨胀约 33%,传输和解析都慢。这个点后面第 4 章会展开讲。
2.2 demo 目录结构:哪些文件可以删,哪些不能动
解压后建议先按目录结构过一遍,分清核心文件和可替换文件。下面这张表是我拆这份 demo 时整理出来的,列了每个路径的职责,以及改的时候能不能动。
| 路径 | 职责 | 核心程度 |
|---|---|---|
app.js/app.json | 小程序全局配置,注册页面、设置窗口样式 | 核心,别删 |
pages/index/index.wxml | 拍照按钮、结果展示区域 | 核心 |
pages/index/index.js | 页面逻辑:取图、压缩、请求、渲染 | 核心 |
utils/request.js | wx.request的 Promise 封装 | 核心 |
server/app.py | Flask 入口,加载模型并处理推理 | 核心 |
server/model/ | 预训练权重文件,通常是一个.pt或.pth | 核心 |
server/requirements.txt | 后端 Python 依赖清单 | 建议保留 |
项目根目录下还会有一个project.config.json,这是微信开发者工具的项目配置文件,里面记录了 appid、编译设置等信息。拿到压缩包后不要直接用文本编辑器改它,正确做法是用微信开发者工具的「导入项目」功能,选中解压后的目录,工具会自动读取这个文件。如果导入后提示 appid 无效,可以在详情里把 appid 改成测试号,不影响本地开发调试。
server/model/目录下通常只有一个权重文件,几十到几百 KB。MNIST 这种任务模型很小,整个 demo 解压后体积控制得很好,不需要像目标检测项目那样准备几十 MB 的权重。这份资源的使用门槛低,也体现在这里——一台普通笔记本的 CPU 就能把推理跑进几十毫秒。
2.3 模型选型:为什么手写数字识别更适合做课程 demo
有人可能会问,同样是「人工智能实战」,为什么不用 YOLO 做目标检测,或者用 BERT 做文本分类?我的看法是,demo 的核心目标不是证明模型多强,而是把「AI 接口能跑通」这件事闭环。MNIST 在这条链路里有三个天然优势。
第一,数据公开且规模小。60000 张训练图片、10 个类别,随便一台电脑几分钟就能训完,不用为数据集发愁。第二,类别少、任务直观。0 到 9 共 10 类,识别结果看一眼就知道对不对,演示效果好,老师或用户不需要理解「这个框框代表什么」这种抽象概念。第三,推理速度快。一个简单的 CNN 在 CPU 上推理一次只需要几十毫秒,不会出现用户按了按钮要等半分钟才出结果的尴尬场面。
对比一下,如果换成 YOLOv8 做目标检测,权重文件动辄几十 MB,后端第一次加载要好几秒,小程序端上传的图片还得先等比缩放到模型输入尺寸,整个体验会差很多。BERT 就更不用说了,模型体积按 GB 算,普通课程设计跑不起来。所以这份 demo 选 MNIST 不是偷懒,是成本、效果和可解释性权衡之后最稳的答案。
3. 把模型变成接口:Flask 推理服务的封装与自测
后端是整条链路里最接近「黑匣子」的部分。模型文件放在那里不会自己工作,必须有一个服务把它包装成 HTTP 接口,小程序才能调用。这一章讲清楚这份 demo 的 Flask 服务是怎么写的,以及为什么这样封装。
3.1 为什么选 Flask:依赖少、好解释、能落地
这份 demo 的后端用的是 Flask,不是 FastAPI,也不是 Django。选择理由很实际:课程设计和中小型 demo 的典型场景,根本用不上 FastAPI 的异步特性和自动生成 OpenAPI 文档,反而多引入一层学习成本。
Flask 的优势在于足够简单——一个app.py文件就能把路由、请求解析、JSON 返回全部搞定,依赖也就flask、torch、pillow、torchvision这几个。调试的时候直接python app.py起服务,浏览器访问http://127.0.0.1:5000/predict就能看到响应,心智负担很小。
这里有一个实操建议:给requirements.txt里的 Python 包标注版本,比如torch==2.1.0、flask==3.0.0。我拆过不少 demo,最常见的问题不是代码写错,而是环境版本不匹配——模型用旧版本torch.load存出来的权重,在新版本里加载报错。锁版本号能省掉很多这种莫名其妙的报错。
3.2 推理接口代码:加载、预处理、预测、返回
下面是这份 demo 后端核心代码的简化版本,去掉了日志和异常处理的装饰性代码,保留主干逻辑。
# server/app.py import base64 import io import time import torch import torchvision.transforms as transforms from flask import Flask, request, jsonify from PIL import Image from model import MnistCNN # MNIST 的 CNN 结构定义在 model.py app = Flask(__name__) # 设备选择:有 GPU 用 GPU,没有就用 CPU device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 模型只加载一次,放在全局变量里,避免每次请求都重新读取权重 model = MnistCNN() model.load_state_dict(torch.load("model/mnist_cnn.pt", map_location=device)) model.to(device) model.eval() # 预处理必须和训练时保持一致:灰度图、28x28、归一化 transform = transforms.Compose([ transforms.Resize((28, 28)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) LABELS = [str(i) for i in range(10)] # MNIST 类别就是 0-9 @app.route("/predict", methods=["POST"]) def predict(): t0 = time.time() data = request.get_json(force=True) # 前端传来的是纯 base64,不带 data:image/jpeg;base64, 前缀 img_bytes = base64.b64decode(data["image"]) img = Image.open(io.BytesIO(img_bytes)).convert("L") # 转灰度 tensor = transform(img).unsqueeze(0).to(device) # 增加 batch 维 with torch.no_grad(): logits = model(tensor) prob = torch.softmax(logits, dim=1)[0] # 转成概率 cls_id = int(torch.argmax(prob)) conf = float(prob[cls_id]) return jsonify({ "class_id": cls_id, "label": LABELS[cls_id], "confidence": round(conf, 4), "cost_ms": int((time.time() - t0) * 1000) }) if __name__ == "__main__": # 0.0.0.0 让局域网内的手机也能访问,方便真机调试 app.run(host="0.0.0.0", port=5000)代码逻辑分四段。第一段是模型初始化,放在模块加载时执行,这样整个服务启动后只读取一次权重,后续请求直接复用内存里的模型。第二段是预处理,Resize((28, 28))把任意尺寸的图片统一成 28×28,ToTensor()把像素值从 0~255 归一化到 0~1,Normalize用的是 MNIST 数据集的均值和标准差,这三个参数必须和训练时的配置完全一致,差一个数值识别率都会明显下降。第三段是推理,torch.no_grad()关闭梯度计算,减少内存占用,softmax把原始 logits 转成概率分布。第四段是返回结构,cost_ms这个字段对排查性能问题很有用,前端可以把它打印到控制台。
3.3 用 curl 先验证后端,再谈联调
前端联调前,我一定会先用 curl 把后端接口打一遍。这一步能快速确认服务是否正常、返回格式是否符合预期,避免前后端同时出问题时不知道先查哪边。
curl -X POST http://127.0.0.1:5000/predict \ -H "Content-Type: application/json" \ -d '{"image": "这里粘贴一小段base64编码的图片数据"}'如果一切正常,会看到类似下面的返回:
{"class_id": 7, "label": "7", "confidence": 0.9954, "cost_ms": 42}注意接口返回的label是字符串而不是数字,前端渲染时不要直接拿它做算术。另外,如果返回的不是 JSON 而是 HTML 错误页,多半是后端抛了异常,去终端看 traceback 定位;如果是连接被拒绝,先确认 Flask 服务有没有真的跑起来。
提示:后端先跑通,再接前端联调。这样可以把问题范围缩小到「请求没到达」还是「返回没解析」,效率高一倍。
4. 小程序端请求封装与图片压缩:从 wx.request 到结果渲染
后端接口就绪后,开始看小程序端。这部分是大多数初学者最头疼的——不是不会写页面,而是不知道wx.request怎么封装、图片怎么处理、数据怎么渲染。
4.1 请求封装:统一 BASE_URL、超时与错误处理
小程序端没有 axios 可用,官方原生的wx.request用起来比较啰嗦,所以 demo 里通常会在utils/request.js里做一层 Promise 封装。这样做的好处是每个页面不需要重复写header和错误处理,改接口地址也只需改一个文件。
// utils/request.js const BASE_URL = "http://127.0.0.1:5000"; // 本地开发;上线前改成正式域名 function request(path, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: "POST", data, // 直接传对象,不要手动 JSON.stringify header: { "content-type": "application/json" }, timeout: 15000, // 后端 CPU 推理可能要 1~3 秒,预留 15 秒 success(res) { if (res.statusCode === 200 && res.data) { resolve(res.data); } else { reject({ code: res.statusCode, msg: "接口非 200" }); } }, fail(err) { // err.errMsg 通常是 "request:fail xxx" 的形式 reject(err); } }); }); } module.exports = { request, BASE_URL };这个封装有三个点值得注意。第一,data直接传对象,wx.request内部会自己做序列化;如果先JSON.stringify再传,会导致content-type为application/json时双引号被二次转义,后端request.get_json解析失败。第二,timeout默认是 60 秒,但对用户来说超过 5 秒就有点焦虑了,demo 里设 15 秒比较合理——既留足推理时间,又不会让请求无限挂起。第三,BASE_URL单独导出,后面切换环境(本地、测试、正式)只需要改这一处。
4.2 图片压缩与 base64:别让原图撑爆请求体
图片处理是整个前端最容易被低估的环节。手机相册里的原图动辄 3~10 MB,直接转 base64 后请求体膨胀到 4~13 MB,上传慢、解析慢,还有可能触发微信的请求体大小限制。所以这份 demo 的处理思路是:先用 canvas 等比压缩到适合模型输入的尺寸,再转 base64 传给后端。
// pages/index/index.js function compressImage(src, size = 224, quality = 0.8) { return new Promise((resolve) => { wx.getImageInfo({ src, success(info) { // 等比缩放,避免拉伸变形 const scale = size / Math.max(info.width, info.height); const w = Math.floor(info.width * scale); const h = Math.floor(info.height * scale); const ctx = wx.createCanvasContext("compressCanvas"); ctx.drawImage(src, 0, 0, w, h); ctx.draw(false, () => { // 等一帧再取数据,canvas 绘制有时是异步的 setTimeout(() => { wx.canvasToDataURL({ canvasId: "compressCanvas", width: w, height: h, destWidth: w, destHeight: h, fileType: "jpg", quality: quality, success(res) { // 去掉 dataURL 前缀,只保留纯 base64 const b64 = res.dataURL.replace(/^data:image\/\w+;base64,/, ""); resolve(b64); } }); }, 50); }); } }); }); }这里有两个细节我拆 demo 时特意验证过。一是scale按最长边计算,保证图片不变形;如果直接drawImage(src, 0, 0, 224, 224),竖屏照片会被压扁,识别率直接崩。二是setTimeout 50的做法看起来有点「玄学」,但在部分安卓机型上ctx.draw的回调不触发,等一帧再取canvasToDataURL是最稳的兼容写法。画布本身要放在 WXML 里,且不能加display: none,否则某些机型取出来是空白。
压缩后 224×224 的 JPEG 图片通常只有几十 KB,base64 编码后也就一百多 KB,后端解析和处理都会很快。
4.3 结果渲染与本地缓存:识别记录怎么存
拿到接口返回值后,页面要做两件事:展示当前识别结果,以及把历史记录缓存到本地。展示部分在index.wxml里用{{ }}绑定result.label和result.confidence,注意confidence是 0~1 的小数,页面上显示百分比时要乘以 100 再toFixed(2)。
缓存部分很多 demo 没有做,但我建议加上,因为微信小程序切后台会被销毁,用户一离开页面识别结果就丢了。常见做法是用wx.setStorageSync存数组,配合时间戳做 TTL 过期清理。这也就是微信小程序设置缓存时间的标准实现方式。
// pages/index/index.js const CACHE_KEY = "history_records"; const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 小时有效期 function saveRecord(record) { const list = wx.getStorageSync(CACHE_KEY) || []; const now = Date.now(); // 清理过期记录 const fresh = list.filter((item) => now - item.ts < CACHE_TTL); fresh.unshift({ ...record, ts: now }); wx.setStorageSync(CACHE_KEY, fresh.slice(0, 20)); // 最多留 20 条 }TTL 的判断逻辑很简单:每次读取时比较当前时间戳和记录里的ts字段,超过有效期就丢弃,避免缓存无限膨胀。20 条的限制也是必要的,虽然单条数据很小,但wx.setStorageSync的容量上限是 10 MB,识别结果里如果带图片 base64,存太多会顶到上限。如果不需要 history 功能,这段可以直接不写。
5. 微信小程序调 AI 接口的常见坑:域名、超时与图片编码
这一章集中写我在拆这种前后端联调项目时遇到的真实问题。每一条都按「现象 → 原因 → 解决」来写,照着排查就行。
5.1 开发者工具能调通,真机一测就失败
现象:在微信开发者工具里点识别按钮,结果正常返回;换成手机预览,请求直接失败,Network 面板显示request:fail。
原因:这是最典型的开发环境与真机环境差异。开发者工具默认勾选了「不校验合法域名」,所以访问http://127.0.0.1:5000没问题;真机上没有这个豁免,而且手机访问127.0.0.1指向的是手机自己,根本不是你的电脑。另外,Flask 如果只监听127.0.0.1,局域网内其他设备也访问不到。
解决:开发调试阶段,在开发者工具右上角「详情 → 本地设置」里勾选「不校验合法域名、web-view 域名、TLS 版本以及 HTTPS 证书」;同时改后端启动参数,把app.run(host="0.0.0.0", port=5000)中的 host 从127.0.0.1换成0.0.0.0,让服务监听所有网卡;手机和电脑连同一个 Wi-Fi,把BASE_URL里的地址改成电脑的局域网 IP,比如http://192.168.1.5:5000。
5.2 图片传上去,接口要 5 秒才返回
现象:接口能通,但用户点完按钮后要等 5 秒以上才出结果,体验很差。
原因:两类原因叠加。一是图片体积太大,原图 4 MB 转成 base64 后接近 5.3 MB,后端解码和 PIL 打开要花时间;二是模型推理本身慢,如果后端代码里每次请求都重新加载权重,那第一次请求会额外多出好几秒。可以先在后端打印cost_ms,看时间花在解码、预处理还是推理哪个环节。
解决:前端压缩到 224×224、quality 0.8,图片体积能缩小到原来的几十分之一;后端把模型加载移到全局变量,只加载一次。如果cost_ms显示推理耗时超过 200 ms,检查是否误开了 GPU 之外的 CPU 线程竞争,或者模型结构里有没有多余的层。对于课程设计,CPU 推理 40~80 ms 是正常范围。
5.3 接口明明返回了数据,页面却渲染不出来
现象:后端日志显示请求已处理并返回,小程序控制台也能看到res.data有内容,但页面上的识别结果一直是空的。
原因:前后端字段名不一致。比如后端返回的字段是label,前端代码里却用了result.text;或者后端返回的confidence是浮点数,前端直接当字符串拼接导致渲染异常。还有一种情况:后端报错时返回的是 HTML 错误页,res.data里根本不是对象,res.data.class_id取到undefined。
解决:统一的返回结构是{ class_id, label, confidence, cost_ms },不要混用别名。前端在success回调里先console.log(res.data)看原始结构,再决定取哪个字段。后端接口用jsonify返回,不用return str(张量)这种隐式转换——我见过有人直接return model(tensor),Flask 会把它当成响应体,格式完全不是 JSON。
5.4 安卓正常,iOS 上传就报错
现象:同一个后端接口,安卓真机和开发工具都正常,iOS 一上传图片就报request:fail或者后端返回 400。
原因:iOS 的canvasToDataURL在部分系统版本上不遵循fileType: "jpg"的设置,输出的是 PNG 格式;PNG 对纯色背景图片压缩率低,体积可能比 JPEG 大好几倍,导致请求体超限。另外,iOS 的wx.chooseImage返回的临时文件路径后缀可能是heic(iPhone 默认格式),PIL 在部分环境里打不开 HEIC 文件。
解决:前端指定fileType: "jpg"的同时,后端解码逻辑里加一层容错:用 PIL 打开失败时,尝试把 base64 数据直接写入临时文件再读取。更稳的做法是后端不强依赖图片编码格式,一律先转灰度再 resize——MNIST 本身只需要灰度信息。如果对图片质量要求不高,可以在前端二次压缩,把 quality 降到 0.6,进一步缩小体积。
5.5 第一次请求特别慢,后面的请求快很多
现象:服务刚启动时,第一次识别花了 3 秒,后面再调用只需要 50 毫秒。
原因:模型权重懒加载。如果代码里把torch.load写在predict函数内部,那每次第一次请求都要读磁盘、反序列化、重建模型结构;就算写在全局,PyTorch 的 CPU 数值库(如 MKL-DNN)在第一次推理时也会做初始化,触发指令集选择和 kernel 编译。
解决:模型加载放全局变量只是第一步,更彻底的做法是在服务启动后主动跑一次空推理预热。常见方式是加一个@app.before_first_request装饰器(Flask 2.3 后改为@app.before_request加判断),或者在__main__里起服务前先构造一个全零张量执行一次model(tensor)。预热之后,第一次用户请求的耗时就会稳定在正常水平。
6. 把 demo 改成自己的大作业:换模型与上云改造
这份 demo 直接跑通只是第一步,大部分人会拿它改成自己的大作业或者作品集。最后一章讲两个最实用的改造方向。
6.1 换模型时三处必须同步改
如果不想用手写数字识别,想换成自己的分类模型,比如表情识别、猫狗分类,这三个位置必须同步修改,缺一个就会翻车。
| 修改位置 | 对应文件 | 关键点 |
|---|---|---|
| 图像预处理 | server/app.py的transform | 尺寸、归一化均值标准差必须与训练时完全一致 |
| 类别标签 | server/app.py的LABELS | 顺序必须与训练数据的类别顺序一致 |
| 输出解析 | server/app.py的softmax/argmax | 多标签任务要改成阈值判断,不能再直接取顶 |
最常见的坑是:训练时用的输入尺寸是 32×32 或 64×64,接口代码里还留着Resize((28, 28)),识别率直接掉到随机水平,还以为是模型训得不好。换模型后的第一件事,不要接小程序,先拿一张验证集图片用 curl 打接口,看返回的class_id和label对不对,再谈前端。
6.2 从本地到线上:HTTPS 域名与云函数
如果要把 demo 部署到线上给别人演示,只改BASE_URL是不够的。微信小程序正式环境要求所有请求域名必须是 HTTPS,并且在小程序后台「开发管理 → 服务器域名」里配置 request 合法域名,域名还必须有备案。本地开发时勾选「不校验合法域名」只是调试便利,上线前不配域名,真机上所有请求都会被拦截。
如果你的后端跑在云服务器上,需要先给域名配好 SSL 证书,再把 Flask 服务用 gunicorn 或 uwsgi 跑起来,前端BASE_URL改成https://你的域名/predict。如果只是想快速演示、不想折腾服务器,可以改用微信云开发里的云函数:把推理代码封装成云函数,小程序端用wx.cloud.callFunction直接调用,省掉域名和 SSL 配置这一整块工作。不过要注意,云函数的内存和时间限制比较紧,CPU 推理超过 5 秒的任务不建议走这条路。
这次把 demo 完整跑通,我最深的感觉是——这类项目的难点根本不在模型,而在「接口约定」这个黑匣子。前端传什么字段、后端回什么结构、预处理尺寸和训练时是否一致,任何一环漏了都要查半天。从那以后,我每次接小程序后端,都会先强制自己用 curl 打一遍接口再连前端,两侧都打印完整请求响应日志,确认cost_ms和返回体结构都在预期内才往下走。希望帮到你。
本文还有配套的精品资源,点击获取