A 30-File Medical Record Mini-Program Hits Every AI Bottleneck: Timeouts, Cold Starts, and a 1x1 PNG Fix
1. Phenomenon Introduction: A Real but Niche Need
If you have kids at home, you know the drill: medical records, test results, discharge summaries, vaccine booklets... scattered in various bags. When the next follow-up demands "which hospital did the last complete blood count from the previous time," you spend ten minutes rummaging through drawers.
Health management apps on the market are either too heavyweight (memberships, social, ads) or I don't want to upload my family's medical images to a server I don't understand. So I decided to build my own "Electronic Medical Record Folder" myself:
- Frontend: WeChat Mini Program native development, four pages (Home / Photo Entry / Detail / Profile)
- Backend: Pure Node.js built-in modules,
node index.jsruns directly, zero third-party dependencies - AI Recognition: Automatically extracts "visit date / hospital / disease name / report category" after photo capture, supports hot-switching among three engines
The entire project is fewer than 30 files, and it hit almost every question I get most often when doing AI application deployment in 2026:
- Local vision LLM takes 40-60 seconds per inference, while
wx.requesthas a 60-second timeout cap. What to do? - How to design an architecture that lets users switch between local model and cloud service with "zero configuration"?
- Without Tencent Cloud SDK, how to handwrite TC3 signing using Node's built-in
crypto? - Model first load has a 50-second cold start that wrecks UX. How to preheat?
Let's break these down one by one.
2. In-Depth Analysis
2.1 Core Contradiction: AI Inference Time vs. Mini Program Request Timeout
This is the deepest pit I fell into, and it deserves its own section.
In the project I chose local Ollama running Qwen2.5-VL 3B (about 3.2GB after quantization) for a simple reason: medical imaging data never leaves the house, and zero API cost. Measured pure-CPU inference on one medical photo:
| Stage | Time |
|---|---|
| Vision encoding (Qwen2.5-VL consistently produces ~1155 visual tokens) | ~13s |
| Token-by-token prefill (~35ms/token) | ~27s |
| Total | 40-60s |
But WeChat Mini Program's wx.request has a 60-second hard cap, and under network jitter the actually usable time is shorter. Synchronous waiting = inevitable "network anomaly" error, and the user has no idea what happened.
Solution: Async task + polling. The backend immediately returns jobId (HTTP 202) upon receiving the recognition request, and the actual inference runs in the background. The client polls the task status every 1.2 seconds:
// server/index.js —— return immediately after submit, do not wait for inference
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
}
// utils/api.js —— frontend polling, every call is a short request, never hits platform timeout
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 正在识别病历…') // also pass progress to UI
return new Promise((r) => setTimeout(r, interval)).then(poll)
})
}
Several details worth noting:
- Task queue implemented with in-memory Map, cap 50 entries, auto-mark fail and evict after 5-minute timeout, to prevent unbounded accumulation — sufficient for personal projects, don't jump straight to Redis;
- Polling interval sent by the server (
pollAfterMs), the server can adjust the rhythm based on load; this decoupling point is rarely mentioned in tutorials; - Progress callbacks passed through layers:
api.recognize(b64, mime, onProgress)→recognizer.recognize()→ page'ssetData({ aiStage, aiElapsed }), letting users see "waiting N seconds" in real time. A 40-second wait changes from "looks frozen" to "a perceivable process".
One-sentence summary: For long-running AI tasks + constrained clients, the answer is always async, never increasing the timeout.
2.2 Three-Engine Swappable: Provider Pattern Is the Correct Posture for AI Apps
In 2026, "binding to one model vendor" is the most dangerous design for AI apps. This project uses a unified backend entry point, with environment variables determining which path to take:
VISION_PROVIDER=ollama → local Qwen2.5-VL (zero cost, data stays home)
VISION_PROVIDER=tencent → Tencent Cloud OCR (millisecond-level, pay per use)
VISION_PROVIDER=openai → OpenAI-compatible interface (official or domestic relay)
The key design is that the return value is always unified as { result, error }, so the four scenarios (three engines + unconfigured) have identical structures, and the frontend needs zero adaptation:
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)')
}
Combine with a health check endpoint to expose "configuration status" transparently to the frontend:
// GET /api/health —— query once when mini program starts
{
"ok": true,
"vision": true,
"visionProvider": "ollama",
"ollama": {
"online": true,
"model": "qwen2.5vl:3b",
"ready": true, // model installed and supports vision
"installed": ["qwen2.5vl:3b"],
"expectedSeconds": "40-60" // tell frontend expected duration
}
}
This lets the frontend clearly distinguish three UI states: "unconfigured → show manual entry", "configured but model not installed → prompt to run ollama pull", "normal → show recognition progress". A lot of bad reviews come from users seeing a vague "service error" when they could have solved the problem themselves.
2.3 Handwritten Tencent Cloud TC3 Signature: The Value of Zero Dependencies
To keep the obsession that the backend doesn't even need npm install, I avoided the SDK for Tencent Cloud OCR and handwritten the TC3-HMAC-SHA256 signature using Node's built-in crypto. The core is about 20 lines:
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}`
}
The signature is just the appetizer; the real meat is turning OCR text into structured fields. Tencent OCR returns lines of text, and I added a lightweight parsing layer using "regex + keyword priority":
// Classification rules: array order is priority, "出院小结" outranks generic "化验"
const catRules = [
['出院小结', /出院小结|出院记录/],
['化验单', /化验|检验|血常规|尿常规|生化|肝功|肾功|甲功/],
['检查报告', /CT|超声|彩超|B超|核磁共振|MRI|X光|放射|心电|内镜/],
['处方笺', /处方|药方|用药医嘱/],
['疫苗记录', /疫苗|接种/],
['门诊病历', /门诊病历|初诊|复诊|病历/]
]
Date extraction has an easy-to-hit pitfall: only patterns with a 4-digit year are considered as visit dates (20\d{2} starting numeric dates, or Chinese format like "2024年3月5日"), otherwise ubiquitous strings like "1日3次" or "5ml" would be misjudged.
2.4 Cold Start 50s → 6s: Preheat with a 1x1 Image to "Trick" It into Loading
This is my proudest discovery of the whole project, and also a hard lesson.
Ollama's first call to a model loads all weights into memory. On a local pure-CPU environment, measured about 50 seconds. Even sneakier: if you send a pure-text message to preheat, Ollama only loads the language model part, and the vision tower never loads — when the user actually uploads an image, you still wait 52 seconds.
Solution: at service startup, send a background request with a 1x1 pixel PNG (base64 only ~100 bytes) to bring up the entire vision pipeline:
// 1x1 transparent PNG, used to trigger vision encoder loading
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] }] // key: must contain image
})
})
Then stack a progressive retry strategy: the model may not have finished downloading (3.2GB takes 1-3 minutes), Ollama service may not be up yet, so use a backoff sequence of [0, 3s, 5s, 8s, 10s, 15s, 20s, 25s, 30s×4] retrying for about 4 minutes, covering most cold-start scenarios.
Final effect: first recognition 50s → 6s (hot load), below the mini program timeout threshold, completing the experience loop.
Extended suggestion: if your inference service is long-running, absolutely make "preheat" part of the service startup process, not just a line in the docs like "it is recommended to call once ahead of time".
2.5 Error Message Design: Translate errMsg into Human Language
In the mini program's wx.request fail callback, errMsg is a goldmine, but showing it directly to the user is nonsense. I added a translation layer in utils/api.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 地址与端口正确'
}
// ...
}
The principle is a single one: every error message must include a "what to do next". "Ollama has no model xxx. Currently installed: a, b —— run: ollama pull xxx" is far better than a bare "HTTP 500".
3. Practical Guide: Port This Architecture to Your Project
If you take only three things, take these three:
1. Long tasks should always be async. The judging criterion is simple: tasks that might exceed the client timeout cap (mini program 60s / regular frontend fetch default 300s) — image recognition, video transcoding, report exports — should all go through "submit jobId → poll" or SSE/WebSocket. In-memory Map is enough for personal projects, just be sure to set timeout and capacity limits.
2. Abstract AI providers into a unified interface. Model vendors are iterating too fast. Converge "which model from which vendor" into an environment variable + a single recognize() function; switching models costs one line of config change. And use health checks to expose config state, model readiness, and expected duration to the frontend.
3. Vision models must be preheated with an image. Text-only preheating loads only the language model; the vision tower stays cold. A 1x1 PNG is enough, zero cost, and the benefit is cutting first-frame latency from 50 seconds to 6.
4. (Bonus) For sensitive data, prioritize local inference. For medical, financial, identity scenarios, models around Qwen2.5-VL 3B are already capable of structured extraction on pure CPU; after quantization it's 3.2GB and runs without GPU — "data never leaves" is itself a product selling point.
4. Opinion Summary
After finishing this project, I have three more concrete judgments about "AI application engineering":
First, 80% of the engineering work in AI apps is outside AI. The real time sink is not prompt tuning, but async tasks, timeout governance, error translation, preheat strategies, and config state transparency — these "traditional backend problems" set the lower bound of AI feature UX.
Second, "zero dependencies" is productivity for small projects. This backend has no npm packages. Deployment = copy directory + node index.js. Signing, routing, static files, JSON parsing all use built-in modules. Dependency management itself carries a cost; small tooling projects are worth re-examining with this mindset.
Third, local small models have reached the tipping point of "usable". A 3B-level vision model running CPU-based structured information extraction is entirely viable. Combined with async task architecture to avoid the speed weakness, the "privacy-first + zero API cost" solution goes from toy to deliverable.