我做了个病历归档小程序,被 AI 识别的"最后一公里"狠狠上了一课

一、现象引入:一个真实但小众的需求

家里有娃的应该都懂:病历本、化验单、出院小结、疫苗本......散落在各个袋子里,下次复查想找"上上次血常规是哪家医院开的",翻箱倒柜十分钟。

市面上的健康管理 App 要么功能太重(会员、社交、广告),要么我不敢把全家人的医疗影像传给一个我不了解的服务器。于是决定自己写一个「电子病例夹」:

  • 前端:微信小程序原生开发,四个页面(首页 / 拍照录入 / 详情 / 个人中心)
  • 后端 :纯 Node.js 内置模块,node index.js 直接跑,零第三方依赖
  • AI 识别:拍照后自动提取「就诊日期 / 医院 / 疾病名称 / 报告分类」,支持三种引擎热切换

整个项目不到 30 个文件,却把我在 2026 年做 AI 应用落地时最常被问到的几个问题都踩了一遍:

  1. 本地跑视觉大模型,一次推理 40-60 秒,而 wx.request 的超时上限是 60 秒,怎么办?
  1. 想让用户"零配置"切换本地模型和云服务,架构怎么设计?
  1. 不用腾讯云 SDK,如何用 Node 内置 crypto 手写 TC3 签名?
  1. 模型首次加载要 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 成本"的方案从玩具变成了可交付。

相关推荐
H_Lisa1 小时前
我用 Express 写了个 MCP,Claude 直接调用我的 API 了
前端
IT_陈寒2 小时前
React的useEffect依赖数组把我坑惨了
前端·人工智能·后端
兰亭妙微UI设计公司3 小时前
兰亭妙微ui设计公司分享:B端组件命名如何支持团队协作
前端·javascript·vue.js
云和数据.ChenGuang3 小时前
光哥说Python 异常深度解析:从异常模型到工程化实践
java·前端·人工智能·python·langchain
小大宇3 小时前
Vue知识点-ElementPlus-03提示框+询问+通知
前端·javascript·vue.js
步行cgn4 小时前
Axios 请求成功但页面不显示:两个前端错误的分析与修正
前端
时间的情敌5 小时前
WebGIS + OpenLayers + GeoServer + PostGIS #量算 #点聚合
前端·geoserver·openlayers
不可能片场6 小时前
Electron 发布 cookie 库先查明文再写解密
前端·electron
郭小李6 小时前
springmvc 拦截器
java·开发语言·前端