ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

人工智能实战微信小程序demo:拍照识别全链路拆解

人工智能实战微信小程序demo:拍照识别全链路拆解

简介:面向微信小程序与人工智能入门开发者的实战演示包,围绕小程序页面组织、语音录制播放、消息交互等常见能力展开,适合希望从可运行工程中理解 AI 功能如何嵌入微信生态的初中级开发者参考。压缩包共 59 个文件,约 1.36MB,以 JavaScript、WXML、WXSS、JSON 等小程序源码为主,配套 PNG、GIF、JPG 图片与动图素材,并附 README 说明,整体轻便易读。已有 243 人学习下载。包内按关于、首页、个人、待办等页面模块组织,包含录音与音频播放工具脚本、消息组件、二维码及微软 AI 演示动图等素材,可用于对照页面结构、样式组织与交互逻辑;借助页面配置、组件通信和工具函数的组织方式,读者能快速搭建自己的小程序原型,或在此基础上替换接口与业务数据做二次开发。

1. 从拍照识别场景切入:这份人工智能实战微信小程序 demo 能跑通什么

把一份人工智能实战微信小程序 demo 源码包解压之后,我先看的不是页面样式,而是拍照、上传、推理、结果展示这条链路有没有真正打通。它对应的场景很具体:用户在微信小程序里拍一张照片,或者从相册选一张图,前端压缩后传到服务端,服务端调用视觉模型,把识别结果整理成统一结构返回,页面再用卡片形式展示标签、置信度和建议。它不是一个只摆按钮的 UI 模板,也不是一个孤立的软件/插件,而是把小程序端、Node 服务端和模型适配层拼成了一个最小闭环。

这份 demo 适合三类人:会一点 JavaScript、想学微信小程序接 AI 接口的开发者;手里有模型服务、需要快速做一个演示原型的后端;以及要给客户演示“拍照识别 + 结果页”的接单开发者。它的价值不在算法本身,而在链路完整:选图、压缩、上传、超时、错误态、历史记录都有落点。跑通的关键也很明确:先把服务端起来,再改小程序里的 baseUrl,最后处理真机域名和图片大小,否则很容易卡在“开发者工具正常、真机翻车”的玄学阶段。

2. 技术栈与目录结构:小程序端、服务端和模型适配层怎么摆

2.1 技术选型:原生小程序 + 轻量 Node 服务 + 统一模型适配层

这份 demo 的前端采用原生微信小程序写法,页面文件是 WXML、WXSS、JS、JSON 四件套,没有上跨端框架。原因很实际:demo 要的是包体小、调试路径短、开发者工具打开就能改,遇到wx.chooseMedia、wx.uploadFile这类平台 API 时不用再查框架封装。服务端用 Node.js + Express,中间件只保留必要的几个:multer接文件、cors处理本地联调、dotenv读配置、axios调模型、sharp做图片压缩和旋转校正。模型调用不直接写进路由,而是收进services/ai-adapter.js,页面和路由都不关心底层换的是哪家模型。

选型上有一个常见分歧:用云函数还是自建轻服务。云函数省运维,但本地调试、文件流处理和超时控制会受平台约束;自建 Express 多一个进程,却能把上传大小、超时、日志、错误码都握在手里。这个 demo 走的是自建轻服务路线,适合拿来理解完整请求链路。下面这张对比表是我在类似项目里常用的判断依据:

方案适合场景优点限制
云函数快速验证、无固定服务器部署快,和平台账号体系近文件流、超时、本地联调受约束
自建 Node 服务要控制上传、超时、日志链路透明,便于换模型需要自己起进程和配 HTTPS
纯前端直调模型极简演示少一层服务密钥暴露,图片大小和跨域难控

这张表不是让你二选一,而是先明确 demo 的边界:它把 AI 调用放在服务端,小程序端只拿临时票据和结果,密钥不进前端。这个边界一旦定错,后面换模型、加历史记录都会别扭。

2.2 目录结构:页面、组件、工具库和 server 各管什么

源码包的目录不复杂,但职责分得比较清楚。小程序端按页面和组件拆,服务端按路由、中间件、服务拆。下面是我按这类 demo 常见组织方式整理的目录清单,拿到包后可以对照着看:

路径职责改动频率
miniprogram/pages/index首页,选图、预览、触发上传高
miniprogram/pages/result结果页,渲染标签、置信度、建议高
miniprogram/pages/history历史记录,本地缓存或接口拉取中
miniprogram/components/upload-card上传卡片,含进度和重试中
miniprogram/utils/request.js请求封装,统一 baseUrl、超时、错误码中
miniprogram/config.js环境配置,开发/预览地址高
server/app.jsExpress 入口,挂中间件和路由低
server/routes/recognize.js/api/recognize路由,校验入参中
server/services/ai-adapter.js模型适配层,统一返回结构高
server/middlewares/upload.js文件大小、类型、临时目录低
server/.env.example密钥和模型地址占位低

这个结构里最值得先看的是ai-adapter.js和utils/request.js。前者决定换模型成本,后者决定前端错误处理是否统一。很多 demo 把wx.request散落在页面里,改一个地址要翻十几个文件,二次开发时很痛苦。这里把 baseUrl、超时、错误提示收在工具层,页面只关心业务状态。

2.3 数据流:一次识别请求从点击到返回的完整链路

一次识别请求的路径可以拆成六步:用户点“拍照”或“相册”,页面调用wx.chooseMedia拿到临时文件路径;前端用wx.getImageInfo读宽高,超过阈值先压缩;wx.uploadFile把文件传到/api/recognize;服务端multer接收后做类型和大小校验,sharp旋转纠正并压缩;适配层调用模型接口,把不同返回结构归一成{ label, score, boxes, suggestion };前端拿到结果后跳结果页,同时写一条本地历史。下面这段是前端请求封装的简化写法:

// miniprogram/utils/request.js const config = require('../config') function uploadImage(filePath, onProgress) { return new Promise((resolve, reject) => { const task = wx.uploadFile({ url: `${config.baseUrl}/api/recognize`, filePath, name: 'image', timeout: 20000, header: { 'X-Client': 'miniprogram' }, formData: { scene: 'general', needBox: 'true' }, success(res) { // uploadFile 返回的是字符串,需要手动解析 let data try { data = JSON.parse(res.data) } catch (e) { reject(new Error('返回格式不是 JSON')) return } if (res.statusCode !== 200 || data.code !== 0) { reject(new Error(data.message || `服务端错误 ${res.statusCode}`)) return } resolve(data.data) }, fail(err) { reject(new Error(err.errMsg || '上传失败')) } }) if (task.onProgressUpdate && onProgress) { task.onProgressUpdate((e) => onProgress(e.progress)) } }) } module.exports = { uploadImage }

这段代码的关键参数有四个:url来自config.baseUrl,开发时指向本地服务,真机预览时必须换成已备案的 HTTPS 域名;name必须和服务端multer的字段名一致,否则服务端收不到文件;timeout设为 20 秒,比模型侧超时略长,避免前端先断;formData里的scene和needBox是业务参数,服务端据此决定是否返回检测框。success里不能直接相信res.data是对象,wx.uploadFile返回字符串,手动JSON.parse是常见做法。

2.4 本地跑通顺序:三个终端和两个配置文件

拿到源码包后不要急着点编译,先把服务端跑起来。常见顺序是:安装根目录和server的依赖,复制环境变量文件,启动服务,再用开发者工具导入miniprogram目录,修改config.js里的 baseUrl。下面这组命令可以直接抄:

# 1. 安装依赖 cd server npm install # 2. 准备环境变量 cp .env.example .env # 编辑 .env,填入模型地址和密钥 # 3. 启动服务 npm run dev # 4. 另开终端确认端口 curl http://127.0.0.1:3000/health

服务端默认监听 3000 端口,/health返回{ "ok": true }就说明进程正常。小程序端在miniprogram/config.js里改地址:

// miniprogram/config.js module.exports = { // 开发者工具里可以先用本地地址 baseUrl: 'http://127.0.0.1:3000', // 真机预览必须换成 HTTPS 域名 // baseUrl: 'https://api.example.com', uploadMaxSize: 5 * 1024 * 1024 }

baseUrl是唯一需要频繁改的配置,uploadMaxSize要和后端multer的限制保持一致,否则前端以为能传、后端直接 413。开发者工具里记得在“详情 - 本地设置”勾选不校验合法域名,真机预览则必须走 HTTPS 域名,并且域名要在小程序后台配置。这个切换点很容易忘,翻车大多发生在这里。

提示:开发阶段可以把baseUrl写成局域网 IP,例如http://192.168.1.10:3000,但手机和电脑必须在同一网络,且防火墙放行端口。

3. 核心页面拆解:拍照上传、结果渲染和四态状态管理

3.1 相机与相册:wx.chooseMedia 的参数和兼容处理

首页的入口是wx.chooseMedia,它同时覆盖拍照和相册。参数里count控制一次选几张,demo 里设为 1;mediaType只留image;sourceType放album和camera,用户点按钮后由系统弹选择;sizeType用compressed,让平台先压一轮,减少上传体积。下面这段是页面里的选图逻辑:

// miniprogram/pages/index/index.js Page({ data: { tempPath: '', fileSize: 0, status: 'idle' // idle | loading | success | error }, chooseImage() { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['album', 'camera'], sizeType: ['compressed'], camera: 'back', success: (res) => { const file = res.tempFiles[0] if (!file) return // 前端先做一次大小兜底,避免把超大图直接传给服务端 if (file.size > 5 * 1024 * 1024) { wx.showToast({ title: '图片超过 5MB', icon: 'none' }) return } this.setData({ tempPath: file.tempFilePath, fileSize: file.size, status: 'idle' }) }, fail: (err) => { // 用户取消也会走 fail,不要一律弹错误 if (err.errMsg && err.errMsg.includes('cancel')) return wx.showToast({ title: '选择图片失败', icon: 'none' }) } }) } })

count设为 1 是因为结果页按单图设计,多图要改结果结构和后端字段。camera: 'back'让拍照默认后置,拍实物更常用。sizeType选compressed能减少传输量,但压缩后的清晰度不一定满足文字识别,所以后面服务端还会用sharp再做一次可控压缩。fail里要判断cancel,用户点取消不是错误,直接弹 toast 反而打扰。

3.2 上传:wx.uploadFile 的 formData、进度和失败重试

上传不能只写一个wx.uploadFile就完事。demo 里把上传封装成 Promise,页面只拿进度和结果。重试策略是失败后最多再试一次,并且只对网络错误重试,不对 4xx 重试。下面这段是带重试的上传调用:

// miniprogram/pages/index/index.js const { uploadImage } = require('../../utils/request') Page({ // 省略 data 和 chooseImage async submit() { const { tempPath, status } = this.data if (!tempPath || status === 'loading') return this.setData({ status: 'loading', progress: 0 }) try { const result = await this.uploadWithRetry(tempPath) this.setData({ status: 'success' }) wx.navigateTo({ url: `/pages/result/result?data=${encodeURIComponent(JSON.stringify(result))}` }) } catch (err) { this.setData({ status: 'error', errorMsg: err.message }) } }, async uploadWithRetry(filePath) { let lastError for (let i = 0; i < 2; i++) { try { return await uploadImage(filePath, (progress) => { this.setData({ progress }) }) } catch (err) { lastError = err // 4xx 通常是参数或文件问题,重试没有意义 if (/服务端错误 4\d\d/.test(err.message)) break } } throw lastError } })

uploadWithRetry里循环两次,第一次失败后不立刻弹错,给网络抖动一次机会。onProgress回调用setData更新进度条,但不要每 1% 都 set 一次,真实项目里可以按 5% 节流,减少渲染压力。navigateTo把结果用 URL 传过去只适合小数据,正式项目更稳的做法是写进全局状态或本地缓存,结果页按 id 读取,避免 URL 过长。

3.3 结果渲染:setData 的性能边界与骨架屏

结果页要处理四种状态:idle空态、loading骨架屏、success结果卡片、error错误提示。很多 demo 只写成功态,一旦接口失败页面就白屏。下面这段 WXML 用wx:if分支把四态拆开:

<!-- miniprogram/pages/result/result.wxml --> <view class="page"> <view wx:if="{{status === 'loading'}}" class="skeleton"> <view class="skeleton-line long"></view> <view class="skeleton-line"></view> <view class="skeleton-line short"></view> </view> <view wx:elif="{{status === 'error'}}" class="error-box"> <text>{{errorMsg}}</text> <button bindtap="retry">重新识别</button> </view> <view wx:elif="{{status === 'success'}}" class="result-list"> <view wx:for="{{result.labels}}" wx:key="label" class="result-item"> <text class="label">{{item.label}}</text> <text class="score">{{item.scoreText}}</text> </view> </view> <view wx:else class="empty">还没有识别结果</view> </view>

wx:elif的顺序有讲究:先判loading,再判error,最后判success,避免请求失败时还显示旧结果。wx:key用label而不是索引,列表更新时复用更稳。setData不要一次塞整个大对象,尤其是图片 base64,能传 URL 就传 URL。结果页的score在服务端已经转成scoreText,前端不做计算,减少页面逻辑。

3.4 历史记录:本地缓存先兜底,云数据库后补

demo 默认把最近 20 条识别记录写进wx.setStorageSync,刷新页面还能看到。缓存 key 用ai_history_v1,带版本号方便以后改结构。下面这段是写入和读取:

// miniprogram/utils/history.js const KEY = 'ai_history_v1' const MAX = 20 function addHistory(item) { const list = getHistory() // 用时间戳去重,避免同一张图重复点击写入多条 const next = [{ ...item, id: Date.now() }, ...list] wx.setStorageSync(KEY, next.slice(0, MAX)) } function getHistory() { try { return wx.getStorageSync(KEY) || [] } catch (e) { return [] } } module.exports = { addHistory, getHistory }

MAX限制 20 条是防止缓存无限增长,小程序单个 key 有大小限制,存图片路径和标签足够,不要存原图。id用时间戳在 demo 里够用,正式项目建议用服务端返回的请求 id。缓存方案适合单机演示,多端同步就要换云数据库,这个取舍在最后一章展开。

4. 服务端与 AI 接口封装:把模型调用关进一个适配层

4.1 路由设计:/api/recognize 的入参校验、超时和错误码

服务端入口app.js挂三个中间件:cors允许本地调试,express.json解析小 JSON,multer处理multipart/form-data。路由只做三件事:校验文件、调用适配层、统一返回。下面这段是路由的简化实现:

// server/routes/recognize.js const express = require('express') const router = express.Router() const { recognizeImage } = require('../services/ai-adapter') router.post('/api/recognize', async (req, res) => { const file = req.file if (!file) { return res.status(400).json({ code: 4001, message: '缺少图片文件' }) } if (!/^image\/(jpeg|png|webp)$/.test(file.mimetype)) { return res.status(415).json({ code: 4150, message: '只支持 jpg/png/webp' }) } try { const result = await recognizeImage({ buffer: file.buffer, mimetype: file.mimetype, scene: req.body.scene || 'general' }) return res.json({ code: 0, message: 'ok', data: result }) } catch (err) { // 模型超时和参数错误要分开,前端才能决定是否重试 if (err.code === 'AI_TIMEOUT') { return res.status(504).json({ code: 5040, message: '识别超时,请重试' }) } return res.status(500).json({ code: 5000, message: '识别服务暂不可用' }) } }) module.exports = router

req.file来自multer的memoryStorage,不落临时盘,减少清理逻辑。mimetype校验不能只看扩展名,前端可以伪造文件名。错误码用四位数,4001表示缺文件,4150表示类型不支持,5040表示模型超时,5000表示未知错误。前端根据code决定提示文案,不要直接把服务端message弹给用户。

错误码HTTP 状态含义前端动作
4001400缺少图片提示重新选图
4150415格式不支持提示换 jpg/png
5040504模型超时可重试一次
5000500服务异常提示稍后再试

4.2 适配层:统一不同模型返回结构

适配层是这份 demo 最值得抄的部分。不同模型返回字段五花八门,有的叫class_name,有的叫categories,有的置信度是 0-1,有的是百分数。适配层只暴露一个recognizeImage,内部做归一化。下面这段是带超时控制的写法:

// server/services/ai-adapter.js const axios = require('axios') const sharp = require('sharp') async function recognizeImage({ buffer, mimetype, scene }) { // 先做旋转校正和压缩,减少模型侧处理时间 const normalized = await sharp(buffer) .rotate() .resize({ width: 1024, height: 1024, fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer() const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 8000) try { const resp = await axios.post( `${process.env.AI_BASE_URL}/v1/vision/recognize`, { image: normalized.toString('base64'), scene }, { headers: { Authorization: `Bearer ${process.env.AI_API_KEY}`, 'Content-Type': 'application/json' }, timeout: 8000, signal: controller.signal } ) return normalize(resp.data) } catch (err) { if (err.name === 'CanceledError' || err.code === 'ECONNABORTED') { const e = new Error('AI_TIMEOUT') e.code = 'AI_TIMEOUT' throw e } throw err } finally { clearTimeout(timer) } } function normalize(raw) { const labels = (raw.categories || raw.results || []).map((item) => ({ label: item.class_name || item.name || '未知', score: Number(item.confidence ?? item.score ?? 0), scoreText: `${Math.round(Number(item.confidence ?? item.score ?? 0) * 100)}%` })).slice(0, 5) return { labels, suggestion: raw.suggestion || '根据识别结果生成建议', requestId: raw.request_id || '' } } module.exports = { recognizeImage }

sharp的rotate()不带参数时会按 EXIF 自动纠正方向,这是手机照片方向不对的常见解。resize限制最长边 1024,withoutEnlargement防止小图被放大。AbortController和axios timeout双保险,模型侧不回包时服务端不会一直挂着。normalize只取前 5 个标签,避免结果页渲染过长。scoreText在服务端算好,前端直接展示,减少重复逻辑。

4.3 密钥与配置:.env、CORS 白名单和日志脱敏

密钥只放服务端环境变量,.env不进版本库,仓库里只留.env.example。下面是一个够用的配置模板:

# server/.env.example PORT=3000 AI_BASE_URL=https://api.example.com AI_API_KEY=replace_with_your_key AI_TIMEOUT_MS=8000 UPLOAD_MAX_MB=5 ALLOW_ORIGIN=http://127.0.0.1:3000

AI_BASE_URL和AI_API_KEY是模型服务地址和票据,AI_TIMEOUT_MS要和适配层保持一致,UPLOAD_MAX_MB要和multer的limits.fileSize对齐。ALLOW_ORIGIN在开发时放开本地地址,上线时改成小程序合法域名对应的来源。日志里不要打完整Authorization,常见做法是只打前 6 位加星号。cors不要图省事写origin: '*',尤其是带票据的接口。

4.4 图片处理:sharp 压缩、旋转和格式统一

服务端收到图片后不要直接转发给模型。手机照片动辄 4-8MB,模型接口按流量和耗时计费,压缩一轮能省不少。除了适配层里的处理,上传中间件也要限制大小和类型:

// server/middlewares/upload.js const multer = require('multer') const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: Number(process.env.UPLOAD_MAX_MB || 5) * 1024 * 1024, files: 1 }, fileFilter(req, file, cb) { if (!/^image\/(jpeg|png|webp)$/.test(file.mimetype)) { return cb(new Error('UNSUPPORTED_IMAGE_TYPE')) } cb(null, true) } }) module.exports = upload

memoryStorage适合小文件,超过 5MB 的图在内存里处理会占资源,所以limits.fileSize必须设。files: 1防止一次传多张打爆适配层。fileFilter和路由里的mimetype校验重复了一层,这是有意为之:中间件先挡明显非法请求,路由再兜底。图片格式统一成 jpeg 后再 base64,模型侧兼容性最好。

5. 常见问题排查:真机上传、跨域、图片方向和并发超时

5.1 现象:开发者工具能上传,真机一直失败

现象是开发者工具里选图、上传、出结果都正常,手机预览时进度条走到一半就报“上传失败”。原因通常不是代码,而是真机不允许请求http://地址,或者域名没有在小程序后台配置。开发者工具的“不校验合法域名”只对工具生效,真机走的是另一套校验。解决办法是把服务端部署到 HTTPS 域名,在小程序后台把request和uploadFile合法域名都配上,然后重新编译预览。本地联调可以先用局域网 IP,但手机和电脑要同一网段,电脑防火墙放行 3000 端口。这个坑我踩过不止一次,真机调试确实有点玄学,先看域名再看网络。

5.2 现象:AI 返回慢,用户连点后重复扣费

现象是用户点一次“识别”没反应,又连点三四次,最后服务端收到多条请求,模型侧按次计费,账单很难看。原因是前端没有锁住提交态,按钮在loading期间仍可点击;后端也没有做幂等。解决办法分两层:前端在submit开头判断status === 'loading'直接返回,按钮加disabled="{{status === 'loading'}}";后端用requestId或图片哈希做短时间幂等,重复请求直接返回上一次结果。下面这段是前端锁的写法:

// 页面提交前先锁状态 if (this.data.status === 'loading') return this.setData({ status: 'loading' })

status不只是展示用,还是并发闸门。后端幂等可以用Map存hash -> result,设置 60 秒过期,适合 demo;正式项目用 Redis 更稳。别等账单出来才加锁。

5.3 现象:图片方向不对,压缩后文字糊

现象是手机竖拍的照片在小程序里预览正常,传到服务端后模型识别结果很差,或者结果页图片横过来。原因是 iPhone 等设备把方向写在 EXIF 里,前端预览会自动纠正,服务端读原始 buffer 不一定纠正。解决办法是在sharp链里先rotate(),再resize,顺序不能反。压缩参数也要按场景调:通用物体识别 1024 最长边、quality 80 够用;文字识别可以保留 1600 最长边、quality 85,别一刀切压到 640。下面这段是可直接替换的处理链:

const normalized = await sharp(buffer) .rotate() // 按 EXIF 自动纠正方向 .resize({ width: 1024, height: 1024, fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer()

fit: 'inside'保证不裁切,withoutEnlargement防止小图放大后更糊。压缩后可以顺手打一条日志,记录原始大小和处理后大小,排查时一眼能看出是不是压过头。

5.4 现象:本地联调报域名不合法或 CORS

现象是开发者工具请求本地服务报“不在以下 request 合法域名列表中”,或者浏览器里调接口报 CORS。原因是小程序有域名校验,服务端cors没放行来源。解决办法分环境处理:开发者工具勾选“不校验合法域名”,服务端cors的origin放行http://127.0.0.1:3000和局域网地址;真机则必须 HTTPS 域名,并在小程序后台配置。下面是一个最小 CORS 配置:

// server/app.js const cors = require('cors') app.use(cors({ origin: (process.env.ALLOW_ORIGIN || '').split(','), methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'X-Client'] }))

ALLOW_ORIGIN支持逗号分隔多个来源,开发时填本地地址,上线时收紧。不要用*配Authorization,浏览器和小程序都可能拒绝。CORS 报错先看响应头有没有Access-Control-Allow-Origin,再看请求是不是预检OPTIONS被路由拦了。

5.5 现象:并发一高接口就 502 或超时

现象是演示时几个人同时上传,接口开始 502,日志里模型调用排队。原因是 Node 单进程同时发起太多外部请求,模型侧限流或连接池耗尽。解决办法是加一层轻量队列和限流:服务端限制同时处理的识别请求数,超出排队或直接返回 429;axios连接数设上限;前端失败后指数退避重试,不要立刻连发。下面是一个极简并发闸门:

// server/services/queue.js let running = 0 const MAX = 3 const queue = [] function run(task) { return new Promise((resolve, reject) => { const execute = async () => { running++ try { resolve(await task()) } catch (e) { reject(e) } finally { running-- const next = queue.shift() if (next) next() } } if (running < MAX) execute() else queue.push(execute) }) } module.exports = { run }

MAX按模型侧配额调,demo 设 3 就够。排队不是万能药,队列太长要直接返回 429,让前端提示“稍后再试”。日志里记录排队时长和模型耗时,才能判断是服务端慢还是模型慢。

6. 二次开发:换模型、加历史记录和做结果分享的改造路径

6.1 换模型只改适配层,不动页面

换模型时不要改页面,也不要在路由里加if provider === 'x'。适配层暴露统一接口,内部用注册表选 provider。下面这个结构够小也好扩:

// server/services/ai-adapter.js const providers = { default: require('./providers/default'), fast: require('./providers/fast') } async function recognizeImage({ buffer, mimetype, scene }) { const name = process.env.AI_PROVIDER || 'default' const provider = providers[name] if (!provider) throw new Error('AI_PROVIDER_NOT_FOUND') const raw = await provider.recognize({ buffer, mimetype, scene }) return normalize(raw) }

新增模型时只加一个providers/xxx.js,实现recognize并返回原始结构,normalize负责统一字段。页面拿到的始终是labels、suggestion、requestId,不需要知道底层换了谁。验证方法是把AI_PROVIDER切到新值,用同一张测试图跑一遍,结果字段不缺就算通过。

6.2 历史记录:本地缓存和云数据库怎么选

本地缓存适合单机 demo,云数据库适合多端同步和留存分析。下面这张表是我做取舍时看的点:

方案成本适合场景注意
本地缓存低单人演示、离线查看换设备丢失,容量有限
云数据库中多端同步、后台统计要设计用户标识和权限
服务端数据库中高已有账号体系要加鉴权和分页

demo 默认本地缓存,二次开发要加“我的记录”跨设备同步,就在结果成功后调一个/api/history写库,列表页分页拉取。别把原图存数据库,存 URL 和标签就够。

6.3 验证:固定测试集跑回归,别只看一次成功

改完适配层或压缩参数后,不要只试一张图。准备 10 张固定测试图,覆盖竖拍、横拍、大图、小图、纯色背景和复杂背景,用一个脚本批量调接口,记录耗时和标签数量。下面这条命令够用:

for f in test-images/*.jpg; do curl -s -X POST http://127.0.0.1:3000/api/recognize \ -F "image=@$f" \ -F "scene=general" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);console.log('$f',j.code,j.data?.labels?.length)})" done

看三个指标:code是否稳定为 0,labels数量是否在 1-5 之间,耗时是否超过 8 秒。任何一项波动大,先查图片方向和压缩参数,再查模型侧限流。从那以后我每次拿到这类 AI 小程序 demo,都先跑固定测试集再改页面,免得被一次成功骗了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进