前言
去年那篇 DeepSeek 接入的文章里,我玩的还是纯文本对话。这半年多模态能力肉眼可见地成熟了:GPT-4o、Claude、Gemini 的视觉能力已经能用于生产,语音对话也成了各家产品的标配。作为一个前端工程师,我更关心的问题是:浏览器端的 Web API 能把大模型的多模态能力用到什么程度?
于是我做了一个小玩具:”看图说话”应用:用户上传(或拍)一张图,模型看图输出结构化结果,用户可以直接语音提问,应用把回复朗读出来。这篇文章把整个过程拆开讲:图片压缩、结构化识图、Web Audio 录音与波形可视化、语音转文字、TTS 朗读,外加一筐 iOS Safari 的坑。
1. 多模态大模型能力概览
现在的多模态模型大致有四类能力,对前端来说都能通过 API 用起来:
- 图片理解(Vision):识图、OCR、图表解读、UI 走查,这是本文的重点;
- 音频输入(ASR):语音转文字,或者直接音频进音频出(Realtime 这类);
- 语音输出(TTS):把文本转成自然语音,比 Web Speech API 的合成音自然得多;
- 视频与文档理解:抽帧送 Vision,或者直接传 PDF。
本文的架构很简单:前端用 Canvas 压缩图片 → base64 传给 Vision API;用 Web Audio + MediaRecorder 录音 → 传给语音转文字接口;回复用 TTS 朗读。全程没有复杂的后端逻辑,服务端只需要做一层隐藏 API Key 的代理。
先回答一个常见疑问:什么时候值得上多模态?我的判断标准是”信息是否只在非文本模态里”。能用文字说清的就别传图,token 成本差一个数量级;而小票、截图、图表这类”看一眼比说半天快”的输入,Vision 才是正解。语音则更多是交互层面的选择:双手被占用、输入串长,或者想让产品”像个助手”的时候。
2. 图片理解:先压缩,再上传
2.1 为什么要前端压缩:token 与带宽
多模态 API 的图片计费本质上是按像素折算成 token 的。一张手机直出的 4032×3024 照片,直传不仅浪费带宽(动辄 5MB+),折算下来也是实打实的 token 成本。而”看图说话”这类应用,绝大多数场景在长边 1024px 内就足够模型看清内容了。
所以在上传前做一次前端压缩,收益是双重的:省带宽、省 token。思路就是经典的 Canvas 缩放:把图片画到限宽的 canvas 上,再用 canvas.toBlob 导出 JPEG。
压到什么程度合适?我的经验值:长边 1024、质量 0.85,大多数识图场景在精度和成本之间刚好平衡,压完通常只有一两百 KB;如果只是”看看图里有什么”这类粗粒度任务,长边 768 也完全够用。
2.2 Canvas 缩放 + toBlob 完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
function compressImage(file, maxSide = 1024, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file);
img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob( (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), "image/jpeg", quality ); }; img.onerror = reject; img.src = url; }); }
function blobToDataURL(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); }
|
2.3 调用 Vision API
以 OpenAI 兼容的接口为例,把 data URL 直接放进 image_url 即可(请求先发到自己的代理 /api/vision,由代理转发给模型厂商,一是藏 Key,二是方便后续换供应商):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| async function askVision(dataUrl, question) { const res = await fetch("/api/vision", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-4o-mini", messages: [ { role: "user", content: [ { type: "text", text: question || "请描述这张图片" }, { type: "image_url", image_url: { url: dataUrl, detail: "low" } }, ], }, ], }), }); const data = await res.json(); return data.choices[0].message.content; }
|
一个实用细节:detail: "low" 是低清模式,token 消耗小一个量级,识小票这类文字密集场景再切 high。
3. 结构化识图:购物小票识别
自由发挥的”描述图片”没什么生产力,我更想演示的是结构化输出:让模型把小票识别成固定结构的 JSON,前端直接入库。
3.1 Prompt 设计与类型定义
1 2 3 4 5 6 7 8 9 10 11
| interface Receipt { merchant: string; date: string; currency: string; total: number; items: { name: string; price: number; quantity: number; }[]; }
|
对应的 Prompt,关键是给足约束和兜底规则:
1 2 3 4 5 6 7 8
| 你是小票识别助手。请把图片中的小票内容解析为 JSON,字段定义如下: merchant: 商户名(string);date: 消费日期,格式 YYYY-MM-DD; currency: 三位币种代码;total: 总金额(number); items: 商品数组,每项包含 name / price / quantity。 规则: 1. 只输出 JSON,不要输出任何解释或 markdown 代码块; 2. 识别不清的字段填 null,不要猜测; 3. 金额一律保留两位小数。
|
3.2 用 zod 校验模型输出
模型偶尔会”手滑”输出不合规 JSON,运行时校验必不可少。zod 的体验最顺手:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { z } from "zod";
const ReceiptSchema = z.object({ merchant: z.string().nullable(), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(), currency: z.string().length(3).nullable(), total: z.number().nonnegative().nullable(), items: z .array( z.object({ name: z.string(), price: z.number().nonnegative(), quantity: z.number().int().positive(), }) ) .default([]), });
function parseReceipt(raw) { const jsonStr = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); const parsed = ReceiptSchema.safeParse(JSON.parse(jsonStr)); if (!parsed.success) { throw new Error("小票解析失败:" + parsed.error.issues[0].message); } return parsed.data; }
|
safeParse 不通过就提示用户重拍,而不是让脏数据流进后面的统计逻辑。如果要更省事,现在不少模型 API 已原生支持 JSON Schema 受限解码(structured output),把 zod 转 JSON Schema 传给 response_format,把模型”锁死”在 schema 里,命中率会高很多。
4. 语音输入:Web Audio API 录音

4.1 AudioContext 与 MediaRecorder
录音分两层:MediaRecorder 负责把麦克风流编码成音频文件;AudioContext + AnalyserNode 负责拿到实时音频数据做波形可视化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| let mediaStream, mediaRecorder, audioContext, analyser, chunks = [];
async function startRecording() { mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(mediaStream); analyser = audioContext.createAnalyser(); analyser.fftSize = 256; source.connect(analyser);
mediaRecorder = new MediaRecorder(mediaStream); chunks = []; mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data); mediaRecorder.onstop = async () => { const blob = new Blob(chunks, { type: mediaRecorder.mimeType }); await transcribe(blob); }; mediaRecorder.start();
drawWave(); }
function drawWave() { const data = new Uint8Array(analyser.frequencyBinCount); const canvas = document.getElementById("wave"); const ctx = canvas.getContext("2d");
(function loop() { if (mediaRecorder.state !== "recording") return; analyser.getByteTimeDomainData(data); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); const step = canvas.width / data.length; for (let i = 0; i < data.length; i++) { const y = (data[i] / 128) * (canvas.height / 2); i === 0 ? ctx.moveTo(0, y) : ctx.lineTo(i * step, y); } ctx.strokeStyle = "#4f8cff"; ctx.lineWidth = 2; ctx.stroke(); requestAnimationFrame(loop); })(); }
|
波形这个看似”纯装饰”的功能其实很有产品价值:用户看到波形起伏,才确信麦克风真的在工作,这在”录不到声音”的排查里能省掉大量客诉。
交互形态上我选了”点击开始/点击结束”而不是”按住说话”:后者在移动端容易和长按菜单、页面滚动打架,而且按住时手指遮挡按钮,看不到波形。另外 MediaRecorder 支持传 timeslice 周期性回调分片,如果以后想做”边说边转文字”的实时上屏,可以基于 ondataavailable 做增量上传,这也是语音输入体验的下一个台阶。
4.2 语音转文字
录完把 blob 塞给 ASR 接口(这里以 OpenAI 兼容的 transcriptions 接口为例),拿到文本回填输入框:
1 2 3 4 5 6 7 8
| async function transcribe(blob) { const form = new FormData(); form.append("file", blob, "audio.webm"); form.append("model", "whisper-1"); const res = await fetch("/api/asr", { method: "POST", body: form }); const data = await res.json(); document.getElementById("question-input").value = data.text; }
|
5. TTS:把回复读出来
回复的朗读有两档选择。轻量方案是浏览器原生的 speechSynthesis,零成本但音色机械:
1 2 3 4 5 6
| function speakFallback(text) { const u = new SpeechSynthesisUtterance(text); u.lang = "zh-CN"; u.rate = 1; speechSynthesis.speak(u); }
|
效果优先就用模型厂商的 TTS API,返回 mp3 后用 <audio> 播放,音质差距一听就知道。我的做法是默认走 API,网络失败时降级到 speechSynthesis,保证功能永远可用。
还有一个体验细节:长回复整段合成再播放,等待会非常明显。更好的做法是先把回复按句子切分,分段请求 TTS、按顺序排队播放,用户听到第一句时后面的还在合成——这其实是把”流式”的思想搬到了音频上,和下篇文章要聊的话题不谋而合。
6. 踩坑记录
6.1 iOS Safari 的录音权限与格式兼容
- Safari 要到 iOS 14.5 才有
MediaRecorder,且早期版本只支持 audio/mp4。要用 MediaRecorder.isTypeSupported() 从 audio/webm;codecs=opus、audio/mp4 里挑第一个可用的,别写死 mimeType;
getUserMedia 要求页面必须跑在 HTTPS(localhost 除外);
- iOS 上 getUserMedia 的权限弹窗必须由真实用户手势触发,把调用挂在
click 回调最外层,不要套在 Promise 或 async 函数里”拐弯”调用,否则弹窗可能不出现。
6.2 AudioContext 必须用户手势激活
iOS Safari 对 AudioContext 有 autoplay policy:创建之后默认是 suspended 状态,必须在用户手势里调用 resume()。典型的翻车现场:页面加载就 new AudioContext() 建好波形链路,用户点开始录音时波形纹丝不动。修复方式:
1 2 3
| if (audioContext.state === "suspended") { await audioContext.resume(); }
|
同理,TTS 的 speechSynthesis.speak() 在 iOS 上也要求先有一次用户交互,别在页面加载时偷跑。
写在最后
做完这个小玩具,我对”多模态”的体感是:模型侧的能力已经跑在前面,反而是浏览器端的人机交互(录音、波形、权限、降级)才是前端真正的发挥空间。留了个尾巴没做:TTS 目前还是整段合成,按句子切分、分段排队播放排在下个迭代。下一篇想聊聊流式渲染,把”边生成边显示”这件事接着往下说。