前言

去年那篇 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
/**
* 压缩图片:等比缩放到 maxSide 以内,导出 JPEG blob
*/
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");
// 白底兜底,避免透明 PNG 转 JPEG 后背景变黑
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;
});
}

// 转成 base64 data URL,直接塞进 messages
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; // 消费日期 YYYY-MM-DD
currency: string; // 币种,如 CNY
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) {
// 先兜一层:剥掉模型偶尔包裹的 ```json fences(注意先 trim,模型有时会在围栏前后加空行)
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 });

// 链路:mediaStream -> AudioContext -> AnalyserNode(只读,用于波形)
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(mediaStream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);

// MediaRecorder 负责真正录制
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(); // 必须在 click 事件处理器内调用
}

同理,TTS 的 speechSynthesis.speak() 在 iOS 上也要求先有一次用户交互,别在页面加载时偷跑。

写在最后

做完这个小玩具,我对”多模态”的体感是:模型侧的能力已经跑在前面,反而是浏览器端的人机交互(录音、波形、权限、降级)才是前端真正的发挥空间。留了个尾巴没做:TTS 目前还是整段合成,按句子切分、分段排队播放排在下个迭代。下一篇想聊聊流式渲染,把”边生成边显示”这件事接着往下说。