一、现象引入:一个真实但小众的需求
家里有娃的应该都懂:病历本、化验单、出院小结、疫苗本......散落在各个袋子里,下次复查想找"上上次血常规是哪家医院开的",翻箱倒柜十分钟。
市面上的健康管理 App 要么功能太重(会员、社交、广告),要么我不敢把全家人的医疗影像传给一个我不了解的服务器。于是决定自己写一个「电子病例夹」:
- 前端:微信小程序原生开发,四个页面(首页 / 拍照录入 / 详情 / 个人中心)
- 后端 :纯 Node.js 内置模块,
node index.js直接跑,零第三方依赖
- AI 识别:拍照后自动提取「就诊日期 / 医院 / 疾病名称 / 报告分类」,支持三种引擎热切换
整个项目不到 30 个文件,却把我在 2026 年做 AI 应用落地时最常被问到的几个问题都踩了一遍:
- 本地跑视觉大模型,一次推理 40-60 秒,而
wx.request的超时上限是 60 秒,怎么办?
- 想让用户"零配置"切换本地模型和云服务,架构怎么设计?
- 不用腾讯云 SDK,如何用 Node 内置
crypto手写 TC3 签名?
- 模型首次加载要 50 秒冷启动,用户体验直接崩,如何预热?
下面逐个拆解。
二、深度分析
2.1 核心矛盾:AI 推理耗时 vs 小程序请求超时
这是我踩的最深的一个坑,值得单独开一节。
项目里我首选了本地 Ollama 跑 Qwen2.5-VL 3B(量化后约 3.2GB),理由很朴素:医疗影像数据不出家门,且零 API 成本。实测纯 CPU 推理一张病历照片:
| 阶段 | 耗时 |
|---|---|
| 视觉编码(Qwen2.5-VL 恒定产出约 1155 个视觉 token) | ~13s |
| 逐 token prefill(约 35ms/token) | ~27s |
| 合计 | 40-60s |
而微信小程序 wx.request 有 60 秒硬上限,且网络抖动下实际可用时间更短。同步等待 = 必然报「网络异常」,用户根本不知道发生了什么。
解法:异步任务 + 轮询。 后端收到识别请求后立即返回 jobId(HTTP 202),真正的推理放到后台执行,客户端每 1.2 秒轮询一次任务状态:
js
// server/index.js ------ 提交后立即返回,不等推理完成
if (p === '/api/recognize' && req.method === 'POST') {
const body = JSON.parse(await readBody(req))
const job = jobs.createJob(body.image.b64, body.image.mime)
sendJSON(res, 202, { jobId: job.id, status: 'pending', pollAfterMs: 1200 })
return
}
js
// utils/api.js ------ 前端轮询,每次都是短请求,永不触碰平台超时
const poll = () => {
return request('GET', '/api/recognize/' + jobId, null, POLL_TIMEOUT).then((s) => {
if (s.status === 'done') return { result: s.result, error: '' }
if (s.status === 'failed') return { result: null, error: s.error }
if (Date.now() - started > MAX_WAIT) return { result: null, error: '识别超时' }
report(Date.now() - started, 'AI 正在识别病历...') // 顺带把进度透传给 UI
return new Promise((r) => setTimeout(r, interval)).then(poll)
})
}
几个细节值得注意:
- 任务队列用内存 Map 实现,上限 50 条,超时 5 分钟自动标记失败并淘汰,防止无限堆积 ------ 个人项目够用,别一上来就上 Redis;
- 轮询间隔由服务端下发 (
pollAfterMs),服务端有权根据负载调整节奏,这是很多教程不会提的解耦点;
- 进度回调层层透传 :
api.recognize(b64, mime, onProgress)→recognizer.recognize()→ 页面的setData({ aiStage, aiElapsed }),用户能实时看到"已等待 N 秒",40 秒的等待从"疑似卡死"变成"可感知的过程"。
一句话总结:长耗时 AI 任务 + 受限客户端,答案永远是异步化,不是把 timeout 调大。
2.2 三引擎可切换:Provider 模式才是 AI 应用的正确姿势
2026 年做 AI 应用,"绑定某一家模型"是最危险的设计。这个项目的做法是后端统一一个入口,环境变量决定走哪条路:
ini
VISION_PROVIDER=ollama → 本地 Qwen2.5-VL(零成本,数据不出门)
VISION_PROVIDER=tencent → 腾讯云 OCR(毫秒级,按量计费)
VISION_PROVIDER=openai → OpenAI 兼容接口(官方或国内中转皆可)
关键设计是返回值统一为 { result, error } ,四种场景(三家引擎 + 未配置)返回结构完全一致,前端零适配:
js
async function recognize(base64, mime) {
const provider = process.env.VISION_PROVIDER
if (provider === 'openai') return recognizeByOpenAI(base64, mime)
if (provider === 'ollama') return recognizeByOllama(base64, mime)
if (provider === 'tencent') return tencent.recognize(base64, mime)
return FAIL('AI 识别未启用(未配置 VISION_PROVIDER)')
}
再配合健康检查接口把"配置状态"透明给前端:
js
// GET /api/health ------ 小程序启动时先问一嘴
{
"ok": true,
"vision": true,
"visionProvider": "ollama",
"ollama": {
"online": true,
"model": "qwen2.5vl:3b",
"ready": true, // 模型已安装且支持视觉
"installed": ["qwen2.5vl:3b"],
"expectedSeconds": "40-60" // 把预期耗时也告诉前端
}
}
这样前端能明确区分三种 UI 状态:「未配置 → 显示手动填写」 、「配置了但模型没装好 → 提示执行 ollama pull」 、「正常 → 展示识别进度」。大量差评来源于用户看到一句含糊的"服务异常",而他们本可以自己解决问题。
2.3 手写腾讯云 TC3 签名:零依赖的价值在哪
为了保持后端 npm install 都不需要的洁癖,腾讯云 OCR 的调用我没有引 SDK,而是用 Node 内置 crypto 手写了 TC3-HMAC-SHA256 签名。核心就 20 行:
js
function buildAuthorization({ secretId, secretKey, service, host, action, version, region, timestamp, date, payload }) {
const hashedPayload = sha256hex(Buffer.from(payload, 'utf8'))
const canonicalRequest = [
'POST', '/', '',
`content-type:application/json; charset=utf-8\nhost:${host}\n`,
'content-type;host',
hashedPayload
].join('\n')
const stringToSign = [
'TC3-HMAC-SHA256', String(timestamp),
`${date}/${service}/tc3_request`,
sha256hex(Buffer.from(canonicalRequest, 'utf8'))
].join('\n')
const kDate = hmac('TC3' + secretKey, date)
const kService = hmac(kDate, service)
const kSigning = hmac(kService, 'tc3_request')
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
return `TC3-HMAC-SHA256 Credential=${secretId}/${date}/${service}/tc3_request, SignedHeaders=content-type;host, Signature=${signature}`
}
签名只是开胃菜,真正有意思的是拿到 OCR 文字后怎么变成结构化字段。腾讯 OCR 返回的是一行行文字,我用"正则 + 关键词优先级"做了一层轻量解析:
js
// 分类规则:数组顺序即优先级,"出院小结"优先于泛化的"化验"
const catRules = [
['出院小结', /出院小结|出院记录/],
['化验单', /化验|检验|血常规|尿常规|生化|肝功|肾功|甲功/],
['检查报告', /CT|超声|彩超|B超|核磁共振|MRI|X光|放射|心电|内镜/],
['处方笺', /处方|药方|用药医嘱/],
['疫苗记录', /疫苗|接种/],
['门诊病历', /门诊病历|初诊|复诊|病历/]
]
日期提取上有个容易踩的坑:只有匹配到 4 位年份才认为它是就诊日期 (20\d{2} 开头的数字日期,或"2024年3月5日"的中文格式),否则病历上随处可见的"1日3次""5ml"都会误判。
2.4 冷启动 50 秒 → 6 秒:预热要用 1x1 图片"骗"它加载
这是我全项目最得意的发现,也是血泪教训。
Ollama 首次调用模型需要把权重全部加载进内存,本地纯 CPU 环境实测约 50 秒 。更阴险的是:如果你只发一条纯文本 消息做预热,Ollama 只会加载语言模型部分,视觉编码器(vision tower)根本不会加载 ------ 等用户真正传图片时,照样要等 52 秒。
解法:服务启动时后台发一个带 1x1 像素 PNG(base64 只有 100 字节左右)的请求,把整条视觉链路全部拉起来:
js
// 1x1 像素的透明 PNG,用于触发视觉编码器加载
const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ...'
await fetch(base + '/api/chat', {
method: 'POST',
body: JSON.stringify({
model,
stream: false,
messages: [{ role: 'user', content: 'ok', images: [TINY_PNG] }] // 关键:必须带图
})
})
再叠加一个渐进式重试 策略:模型可能还没下载完(3.2GB 要 1-3 分钟)、Ollama 服务可能还没启动,所以用 [0, 3s, 5s, 8s, 10s, 15s, 20s, 25s, 30s×4] 的退避序列重试约 4 分钟,覆盖绝大多数冷启动场景。
最终效果:首次识别 50s → 6s(热加载),低于小程序超时阈值,体验闭环。
延伸建议:如果你的推理服务是常驻型的,务必把"预热"当作服务启动流程的一部分,而不是文档里的一句"建议提前调用一次"。
2.5 错误信息设计:把 errMsg 翻译成人话
小程序端的 wx.request fail 回调里,errMsg 是金矿,但直接展示给用户就是天书。我在 utils/api.js 里做了一层翻译:
js
function describeFail(errMsg) {
const m = String(errMsg || '').toLowerCase()
if (m.indexOf('not in domain list') !== -1) {
return '请求域名不合法。请在微信开发者工具「详情 → 本地设置」勾选「不校验合法域名...」'
}
if (m.indexOf('timeout') !== -1) {
return '请求超时。后端可能正在首次加载 AI 模型(较慢),请稍后重试;或先执行 ollama run qwen2.5vl:3b 预热'
}
if (m.indexOf('refused') !== -1) {
return '无法连接后端服务。请确认后端已启动,且 SERVER_BASE_URL 地址与端口正确'
}
// ...
}
原则只有一条:每一条错误提示都必须附带"下一步怎么做" 。"Ollama 中没有模型 xxx。当前已安装: a, b ------ 请执行: ollama pull xxx" 远好于一句 "HTTP 500"。
三、实践指南:把这个架构搬到你的项目
如果只带走三件事,带走这三件:
1. 长任务一律异步化。 判断标准很简单:处理时间可能超过客户端超时上限(小程序 60s / 普通前端 fetch 默认 300s)的任务 ------ 图片识别、视频转码、报表导出 ------ 都应该走"提交 jobId → 轮询"或 SSE/WebSocket。个人项目用内存 Map 就够了,注意设置超时和容量上限。
2. AI Provider 抽象成统一接口。 现在模型供应商迭代太快,把"调用哪家的什么模型"收敛到一个环境变量 + 一个 recognize() 函数里,换模型的成本是改一行配置。同时用健康检查把配置状态、模型就绪情况、预期耗时透传给前端。
3. 视觉模型必须带图预热。 只发文本预热只加载语言模型,vision tower 仍是冷的。一张 1x1 PNG 即可,成本为零,收益是把首帧延迟从 50 秒砍到 6 秒。
4.(附赠)敏感数据优先考虑本地推理。 医疗、财务、身份类场景,Qwen2.5-VL 3B 这个量级的模型在纯 CPU 上已经能胜任结构化抽取,量化后 3.2GB、无 GPU 也能跑 ------ "数据不出门"本身就是产品卖点。
四、观点总结
做完这个项目,我对"AI 应用工程化"有了三个更具体的判断:
第一,AI 应用 80% 的工程量在 AI 之外。 真正花时间的不是调 prompt,而是异步任务、超时治理、错误翻译、预热策略、配置状态透传 ------ 这些"传统后端问题"决定了 AI 功能的体验下限。
第二,"零依赖"在小项目里是生产力。 这个后端没有任何 npm 包,部署 = 拷贝目录 + node index.js,签名、路由、静态文件、JSON 解析全部内置模块搞定。依赖管理本身是有成本的,小工具类项目值得用这个思路重新审视一遍。
第三,本地小模型已经到了"能用"的临界点。 3B 级别的视觉模型在 CPU 上跑结构化信息抽取完全可行,配合异步任务架构规避速度短板,"隐私优先 + 零 API 成本"的方案从玩具变成了可交付。