实战:基于 Vue3 与大模型的多模态“拍照记单词”应用构建与思考

4 阅读8分钟

随着大语言模型(LLM)能力的边界不断拓展,前端开发的范式正在发生微妙的变化。过去我们需要后端提供结构化的数据接口,现在前端可以直接与多模态模型对话,让应用具备“看”和“说”的能力。

今天我想分享一个小型的全栈实践案例:一个“拍照记单词”的应用。它的核心逻辑很简单:用户拍摄或上传一张生活照片,系统识别图片内容,提取一个适合初学者的英文单词,生成例句,并朗读出来。

虽然功能看似简单,但在实现过程中,涉及到了文件处理、多模态 API 调用、音频流处理以及 Prompt 工程等多个技术点。本文将剥离出核心代码逻辑,探讨其中的实现细节、设计考量以及潜在的优化空间。

一、核心交互与文件处理

在传统的文件上传场景中,我们通常将文件直接提交给后端。但在这个应用中,图片需要同时做两件事:

  1. 本地预览:让用户确认上传的内容。
  2. 发送给 LLM:作为多模态模型的输入。

1. 无障碍与样式控制的平衡

PictureCard 组件中,文件上传的实现采用了经典的 input + label 组合模式:

<input type="file" id="selecteImage" class="input" accept="image/*" @change="updateImageData">
<label for="selecteImage" class="upload">
    <img :src="imgPreview" alt="camera" class="img">
</label>

这里有两个细节值得注意:

首先是无障碍访问(Accessibility)。原生的 input[type="file"] 样式难以定制,且在不同浏览器上表现不一。通过 display: none 隐藏 input,并使用 label 关联 id,我们既获得了完全自由的样式控制权,又保留了语义化。当用户点击美观的相机图标时,实际上触发的是原生文件选择器。对于使用读屏器的视障用户,label 标签能准确传达“上传图片”的意图,这是开发中容易忽视但至关重要的细节。

其次是文件读取机制。为了将图片发送给 LLM,我们需要将其转换为 Base64 格式。这里使用了 HTML5 提供的 FileReader API:

const reader = new FileReader(); 
reader.readAsDataURL(file);
reader.onload = () => {
    const data = reader.result as string;
    imgPreview.value = data;
    emit('update-image', data);
}

readAsDataURL 会将文件内容读取为一个包含 MIME 类型的 Base64 字符串(例如 data:image/png;base64,...)。

  • 优点:格式统一,可以直接嵌入 JSON 发送给大多数多模态 API,同时也方便直接赋值给 img 标签的 src 进行预览。
  • 缺点:Base64 编码会使文件体积增加约 33%。如果图片过大,不仅影响传输速度,还可能超出 LLM 的 Token 限制。在实际生产中,通常需要在读取前对图片进行压缩或尺寸限制。

二、与大模型的对话:Prompt 工程与多模态

应用的核心智能来源于对 Kimi(Moonshot)多模态接口的调用。在 App.vue 中,我们构建了请求体。

1. 多模态输入的标准格式

目前主流的多模态模型(如 GPT-4V, Moonshot-v1-vision)在接收图片时,通常要求 messages 中的 content 字段是一个数组,分别包含文本和图片对象:

messages: [
  {
    role: 'user',
    content: [{
      type: 'image_url',
      image_url: { url: imageDate } // 这里是 Base64 或 HTTP URL
    }, {
      type: 'text',
      text: userPrompt
    }]
  }
]

这种设计允许模型同时“看”到图片并“读”到指令。需要注意的是,虽然代码中直接使用了 Base64,但如果图片较大,建议先上传至对象存储(OSS),将 HTTP URL 传给模型,以减少请求包体大小。

2. 结构化输出的重要性

userPrompt 的设计上,我们没有让模型自由发挥,而是严格限制了输出格式:

返回 JSON 数据:
{
  "representative_word": "图片代表的英文单词",
  "example_sentence": "结合英文单词和图片描述,给出一个简单的例句",
  "explaination": "...",
  ...
}

这是开发 AI 应用的一个关键原则:机器与人对话可以自然,但机器与代码对话必须严谨。

通过要求模型返回 JSON,我们可以直接 JSON.parse 结果,将单词、句子、解释分发到不同的 UI 区域。如果让模型自由返回文本,前端就需要编写复杂的正则去提取单词,这不仅脆弱,而且容易出错。此外,Prompt 中明确了词汇难度(A1~A2),这是产品价值的体现——我们不是在做一个翻译工具,而是在做一个适合初学者的教育工具。

三、音频生成与播放机制

当模型返回例句后,应用需要调用 TTS(Text-to-Speech)服务将文本转为音频。这里涉及到了二进制数据的处理。

1. Base64 到 Blob URL 的转换

TTS 接口返回的通常是音频文件的 Base64 数据。在 audio.ts 中,我们实现了一个 createBlobURL 函数:

const byteCharacters = atob(base64AudioData);
// ... 转换为 Uint8Array
const audioBlob = new Blob([new Uint8Array(byteArrays)], { type: 'audio/mp3' });
const blobURL = URL.createObjectURL(audioBlob);

这里有一个常见的疑问:为什么不直接使用 data:audio/mp3;base64,... 赋值给 audio 标签?

虽然 Data URI 可以直接播放,但在处理较长音频或高频调用时,Blob URL 方案更具优势:

  1. 性能:Blob URL 指向的是内存中的二进制对象,浏览器解码效率通常更高。
  2. 内存管理URL.createObjectURL 创建的引用是可以被显式释放的(通过 URL.revokeObjectURL)。虽然示例代码中为了简洁未展示释放逻辑,但在组件卸载时调用释放,可以有效防止内存泄漏。
  3. 类型安全:显式创建 Blob 可以确保 MIME 类型被浏览器正确识别,避免某些移动端浏览器对 Data URI 音频支持不佳的问题。

2. 音频格式的潜在风险

在代码审查中,我发现了一个值得注意的细节:

  • TTS 请求参数中设置的是 encoding: 'ogg_opus'
  • 但在创建 Blob 时,MIME 类型指定的是 audio/mp3

这可能会导致部分浏览器播放失败或无法识别时长。严谨的做法是根据 API 实际返回的音频流格式来设定 Blob 的 type,或者在 API 请求时直接要求返回 MP3 格式。这提醒我们在对接第三方服务时,必须严格核对输入输出的格式规范。

四、架构思考与安全隐患

在复盘整个项目时,除了功能实现,还有几个架构层面的问题需要深入探讨。

1. 前端密钥的安全风险

App.vue 中,我们看到了这样的代码:

'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`

这是一个严重的安全隐患。 将 LLM 的 API Key 直接暴露在前端代码中,意味着任何查看网页源码的用户都可以获取你的密钥,从而盗用你的额度。

改进方案: README 中提到了技术栈包含 NestJS。正确的架构应该是:

  1. 前端发起请求到自有的 NestJS 后端。
  2. 后端在服务器端存储 API Key,并转发请求给 Kimi 和 TTS 服务。
  3. 后端可以做一层代理,同时实现限流、鉴权和日志记录。

目前的实现仅适合本地学习或内部演示,绝不可直接部署到公网。

2. 状态管理的解耦

当前逻辑集中在 App.vue 中,包括图片状态、单词状态、音频状态等。随着功能增加(例如历史记录、生词本),组件会变得臃肿。

建议引入状态管理库(如 Pinia),将“学习会话”作为一个 Store 管理。同时,将 generateAudiofetchLLM 封装为独立的 Service 层,与 UI 组件彻底解耦。这样不仅便于测试,也方便后续将 API 调用迁移到后端时,前端只需修改 Service 层的请求地址。

3. 用户体验的细腻处理

代码中实现了基础的加载状态(如“分析中..."),但在网络波动或 API 报错时,用户体验还可以更好:

  • 重试机制:LLM 接口偶尔会超时,提供“重试”按钮比直接报错更友好。
  • 音频预加载:在生成音频 URL 后,可以实例化 new Audio(url) 进行预加载,确保用户点击播放时无延迟。
  • 图片压缩:如前所述,在 FileReader 读取前,使用 Canvas 对图片进行压缩,能显著提升上传和解析速度。

五、总结

通过这个“拍照记单词”的小应用,我们实践了 Vue3 组合式 API 的组件通信,探索了 FileReader 与 Blob 的二进制处理,并深入体验了多模态大模型的接入流程。

技术本身并不是目的,解决用户痛点才是。在这个案例中,技术的价值在于将“生活中的任意场景”瞬间转化为“可学习的语言素材”,降低了语言学习的门槛。

对于前端开发者而言,拥抱 AI 不仅仅是学会调用 API,更在于理解如何设计 Prompt 以获得稳定的输出,如何处理多媒体数据流,以及如何在享受 AI 便利的同时,守住安全与性能的底线。希望这个案例能为你构建自己的 AI 应用提供一些实在的参考。