Skip to content
多模态 AI 应用架构:文本、语音与视觉模型统一交互设计
1. 概述:目标与边界
多模态 AI 应用指同时处理文本、语音(音频)与视觉(图像/视频)输入的应用。常见的形态包括:
- 用户语音输入,应用先转写为文本,再交给语言模型处理;
- 用户上传图片,应用调用视觉模型提取文字或描述内容;
- 应用将回答文本合成为语音,以音频流返回给客户端。
在这些应用中,模型服务形态差异很大。文本模型接收字符串消息,ASR 服务接收音频字节流,TTS 服务返回音频二进制,视觉模型接收图像引用加文本提示。如果应用层直接对接这些 API,每接入一个新模型都需要改动业务代码,而且不同模态的请求无法共享同一份会话上下文。
统一交互层解决的是这个问题。它位于客户端与模型服务之间,承担四项职责:
- 统一消息格式:将文本、图像、音频统一表示为“内容片段数组”;
- 统一会话状态:所有模态的输入输出写入同一份会话历史;
- 统一模型接入:通过适配器屏蔽不同厂商 API 的差异;
- 统一流式事件:流式输出收敛为一致的事件序列。
需要明确统一交互层的边界。它不负责模型训练、微调和推理优化,也不负责麦克风采集、摄像头驱动、音频编解码等端侧媒体处理。它处理的是请求与响应的组织方式,而不是媒体信号的生成方式。
关于“统一到什么程度”,一个需要澄清的问题是:是否所有模态输入都应触发同一套业务逻辑?不是。语音输入经 ASR 后本质是文本;视觉输入是“图像引用 + 文本问题”;纯文本输入则直接进入语义链路。三种输入的语义不同,业务动作不同。统一交互层统一的不是业务语义,而是消息表达结构、会话状态和事件形态。各模态保持各自的输入输出特性,但共享同一份上下文和同一种请求/响应协议。
2. 基本概念
模态(Modality)
模态是信息的承载形式。多模态应用至少涉及三种:文本、音频、图像/视频。输入模态和输出模态可以不同。例如:
- 音频输入 → 文本输出(ASR + 语言模型);
- 文本输入 → 音频输出(语言模型 + TTS);
- 图像 + 文本输入 → 文本输出(视觉模型)。
ASR 与 TTS
ASR(Automatic Speech Recognition,自动语音识别)把音频转为文本。流式 ASR 在说话过程中不断产出部分识别结果,并在一个语义片段结束时给出最终结果。
TTS(Text-to-Speech,文本转语音)把文本合成为音频。流式 TTS 边合成边输出音频块,客户端可以边收边播,从而降低首包延迟。
注意,ASR 和 TTS 虽然都属于“语音”方向,但处理方向相反,流式语义也不同。第 7 节会专门区分。
视觉理解
视觉理解指从图像/视频中提取信息。它可由多模态大语言模型完成,也可由专用视觉服务完成。Google Cloud Vision AI 家族是后一种例子:Cloud Vision API 提供预构建的图像标签、人脸和地标检测、OCR、SafeSearch;Document AI 面向扫描文档提供文档理解、实体识别;Video Intelligence API 负责视频中的对象检测与跟踪、场景理解 [2]。
应用层需要根据任务选择服务类型:任务是开放问答,可以走多模态语言模型;任务只是高精度提取文档文字,选择专用文档理解服务更合适。
统一交互接口
统一交互接口是网关暴露给客户端的 API。它不暴露“这是 ASR 结果”“这是视觉模型输出”这类内部细节,而是给出统一的消息结构和事件流。客户端只需要发送消息、接收事件,不需要关心背后调用了几个模型。
多模态会话
多模态会话是一个上下文容器。同一会话内可以交替出现文本消息、图片消息、语音转写结果和模型回复。会话按到达顺序累积这些消息,保证多轮交互的连贯性。
3. 消息抽象与协议设计
统一交互层的核心是消息抽象。一个基本问题是:为什么不能把文本、图片、音频分别设计成三个独立字段?
因为一条用户消息可以同时包含多种模态。典型的例子是用户发来一张截图并附上文字“这个报错怎么解决”;语音输入也可能是“看一下这张图”加上一张图片。因此消息需要表达为片段(parts)数组,每个片段携带类型信息。
本节及后续章节中的类型名、方法名与字段名用于演示架构形态,并非任何单一厂商协议的固定名称。接入具体服务时,这些名称需要与对应 SDK 或 API 文档对齐。
typescript
type ContentPart =
| { type: 'text'; text: string }
| { type: 'image'; source: ImageSource }
| { type: 'audio'; source: AudioSource }
| { type: 'toolResult'; toolId: string; output: string };
type ImageSource =
| { kind: 'url'; url: string }
| { kind: 'base64'; data: string; mimeType: string };
type AudioSource =
| { kind: 'url'; url: string }
| { kind: 'base64'; data: string; mimeType: string };
interface Message {
role: 'user' | 'assistant' | 'system' | 'tool';
parts: ContentPart[];
createdAt: number;
}role: 'tool' 用于承载工具调用结果(见第 5 节的 Agent 循环)。该角色来自 OpenAI 工具调用(function calling)的消息约定 [4],本教程将其纳入统一消息抽象。并非所有厂商都使用这一角色名,接入时需要对应厂商自己的工具结果字段。
createdAt 用于会话按真实到达顺序排序,避免并发处理导致乱序。
图片和音频通过 URL 或 base64 传递。以图像为例,多模态模型 API 通常接受公有 URL 或 base64 data URI。DeepInfra 的视觉 API 采用 OpenAI vision 格式,通过 content 数组传递图片,其中 image_url.url 可以是 data:image/jpeg;base64,... 形式,也可以是公网可访问的 URL [1]。统一消息中的 ImageSource 正是为了映射这两种传图方式。
请求协议
统一请求携带会话标识、消息历史以及本次请求的输出模态约束:
typescript
interface GenerateRequest {
conversationId: string;
messages: Message[];
outputModalities: Modality[];
options?: {
temperature?: number;
maxOutputTokens?: number;
signal?: AbortSignal;
};
}outputModalities 用于告诉网关本次回复期望的形式:文本、音频还是两者。网关据此选择合适的模型链路。
流式事件协议
流式输出统一为事件序列:
typescript
type StreamEvent =
| { type: 'start' }
| { type: 'delta'; part: ContentPart }
| { type: 'end'; reason: 'stop' | 'length' | 'abort' }
| { type: 'error'; error: { code: string; message: string } };start表示流开始;delta携带增量内容。文本模式下通常是一段递增文本;音频模式下是一个可播放的二进制块;end表示流正常结束,reason说明结束原因;error表示异常终止。
delta 中的 part 不一定是完整片段。对于流式文本,多次 delta 拼接才是一个完整文本;对于音频,一个 delta 可能只是整段音频的一部分。客户端不应假设每个 delta 都是完整内容。
base64 传图会带来固定的字节开销。base64 将每 3 字节原始数据编码为 4 字节 ASCII 字符,体积约为原值的 4/3,即增加约 33%。若图片较大或请求频繁,网关应先将上传的媒体转存为对象存储引用,再在适配器中按模型要求决定传 URL 还是 base64。不要在会话历史中反复保存 base64 图片。
4. API 设计:模型适配器
适配器的职责是把统一消息转换为具体模型 API 的请求格式,再把响应转换为统一消息结构。上层只依赖适配器接口,不依赖任何厂商 SDK。
适配器接口
typescript
interface ModelAdapter {
readonly id: string;
readonly capabilities: {
input: Modality[];
output: Modality[];
streaming: boolean;
};
generate(req: GenerateRequest): Promise<GenerateResponse>;
stream(req: GenerateRequest): AsyncIterable<StreamEvent>;
}
interface GenerateResponse {
text: string;
meta: {
model: string;
tokenCount?: number;
imageCount?: number;
audioDurationMs?: number;
};
}generate 用于非流式调用,stream 用于流式调用。这里的 generate 和 stream 只是本教程定义的方法名,不是标准 API 名称。meta 携带成本追踪所需的量化信息。
但“生成”这一语义并不能覆盖所有模态。ASR 的输入是音频、输出是文本,TTS 输入是文本、输出是音频字节。语音相关的适配器需要单独定义:
typescript
interface SpeechAdapter {
transcribe(audio: AudioSource, options?: { signal?: AbortSignal }): Promise<{ text: string }>;
synthesize(text: string, options?: { signal?: AbortSignal }): AsyncIterable<Buffer>;
}同样,transcribe 和 synthesize 是教程自定义的方法名,接入具体语音服务时应使用厂商 SDK 中的实际方法。
在网关内部,语音输入经 transcribe 变成文本消息后,会作为普通文本消息追加到会话历史;语音输出则是把模型文本交给 synthesize,把音频块包装成 delta 事件。
示例:OpenAI 兼容 Chat 适配器
大多数多模态模型 API 采用 OpenAI 兼容的 chat 格式。以下适配器把统一消息转为该格式,适用于文本和视觉两类模型,区别只在于 parts 数组里是否包含图片片段。
typescript
class OpenAICompatibleChatAdapter implements ModelAdapter {
constructor(
private config: {
baseUrl: string;
apiKey: string;
model: string;
streaming: boolean;
}
) {}
readonly capabilities = {
input: ['text', 'image'] as Modality[],
output: ['text'] as Modality[],
streaming: this.config.streaming,
};
async generate(req: GenerateRequest): Promise<GenerateResponse> {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: req.messages.map(toOpenAIMessage),
stream: false,
}),
signal: req.options?.signal,
});
if (!response.ok) {
throw new Error(`model api error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content ?? '';
return { text, meta: { model: this.config.model } };
}
async *stream(req: GenerateRequest): AsyncIterable<StreamEvent> {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages: req.messages.map(toOpenAIMessage),
stream: true,
}),
signal: req.options?.signal,
});
if (!response.ok || !response.body) {
throw new Error(`model api error: ${response.status} ${response.statusText}`);
}
yield { type: 'start' };
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter((line) => line.startsWith('data:'));
for (const line of lines) {
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
yield { type: 'delta', part: { type: 'text', text: delta } };
}
}
}
yield { type: 'end', reason: 'stop' };
}
}Authorization: Bearer ... 是 HTTP 标准 Bearer 认证(RFC 6750)的写法,通用 OpenAI 兼容 API 采用相同格式。
stream 方法读取响应体时使用 TextDecoder 处理分块数据,按行过滤出 data: 前缀的事件,解析后取出 choices[0].delta.content。事件循环在响应体结束时返回 end。delta 消息是累计输出的增量,可能为空串。
消息转换函数:
typescript
function toOpenAIMessage(message: Message) {
return {
role: message.role,
content: message.parts.map(toOpenAIContentPart),
};
}
function toOpenAIContentPart(part: ContentPart) {
switch (part.type) {
case 'text':
return { type: 'text', text: part.text };
case 'image':
if (part.source.kind === 'base64') {
return {
type: 'image_url',
image_url: {
url: `data:${part.source.mimeType};base64,${part.source.data}`,
},
};
}
return { type: 'image_url', image_url: { url: part.source.url } };
default:
throw new Error(`unsupported part type: ${part.type}`);
}
}这里 image_url 的 base64 用法与 DeepInfra 视觉 API 的请求格式一致 [1]。
对于非 chat 风格的视觉服务,适配器做的是另一套转换。例如 Google Cloud Vision 的 DetectText 接受 base64 图像和特征类型参数 [2],统一消息中的 image part 需要被转换为该请求结构。上层代码感知不到这种差异,这正是适配器模式的价值。
5. 工作原理:多模型编排
编排层决定“哪个模型、以什么顺序、如何处理这条输入”。
路由
按输入模态划分是最基础的路由策略:
typescript
interface RouteStep {
action: 'asr' | 'llm' | 'tts' | 'ocr' | 'tool';
adapterId: string;
input: Modality[];
output: Modality;
}
interface RoutePlan {
steps: RouteStep[];
}
class ModalityRouter {
constructor(private adapters: Map<string, ModelAdapter>) {}
route(parts: ContentPart[]): RoutePlan {
const hasAudio = parts.some((p) => p.type === 'audio');
const hasImage = parts.some((p) => p.type === 'image');
const hasText = parts.some((p) => p.type === 'text');
if (hasAudio) {
return {
steps: [
{ action: 'asr', adapterId: 'asr-1', input: ['audio'], output: 'text' },
{ action: 'llm', adapterId: 'text-llm', input: ['text'], output: 'text' },
],
};
}
if (hasImage) {
return {
steps: [
{ action: 'llm', adapterId: 'vision-llm', input: ['image', 'text'], output: 'text' },
],
};
}
if (hasText) {
return {
steps: [
{ action: 'llm', adapterId: 'text-llm', input: ['text'], output: 'text' },
],
};
}
throw new Error('empty message');
}
}更复杂的路由会引入意图分类:先让一个轻量模型判断用户意图是“问答”“指令执行”还是“内容描述”,再选择模型链路。意图路由适合模型服务较多的场景,但会额外增加一跳延迟,是否引入需要权衡。
串行与并行
模型调用之间的关系由数据依赖决定。音频输入必须先经过 ASR 才能进入语言模型,这是串行依赖。两个任务互不依赖时,可以并行:
typescript
async function handleMediaQuery(audio: AudioSource, image: ImageSource) {
// 语音转写与图像 OCR 互不依赖,可并行
const [transcript, ocrText] = await Promise.all([
asrAdapter.transcribe(audio),
visionAdapter.detectText(image),
]);
// 合并结果后交给语言模型生成回答
return textAdapter.generate({
conversationId,
messages: [
{
role: 'user',
parts: [
{ type: 'text', text: `语音内容:${transcript.text}` },
{ type: 'text', text: `图像中的文字:${ocrText}` },
],
createdAt: Date.now(),
},
],
});
}并行的收益是延迟降低,代价是同时占用多个模型配额,成本上升。编排层应把“是否并行”作为显式决策,而不是默认全并行。
仲裁
当多个模型产生冲突结果时,仲裁层决定采用哪个结果。常见依据是置信度:
typescript
interface Candidate {
adapterId: string;
text: string;
confidence: number;
}
function arbitrate(candidates: Candidate[], threshold = 0.6): string {
if (candidates.length === 0) return '';
const sorted = [...candidates].sort((a, b) => b.confidence - a.confidence);
const top = sorted[0];
if (top.confidence >= threshold) {
return top.text;
}
// 所有候选置信度均低于阈值,走降级逻辑
return fallback(sorted);
}示例中的 0.6 仅用于演示仲裁过程,实际值应根据模型置信度分布与业务容忍度确定。
另一种仲裁场景是语音命令、文本消息和视觉结果同时到达。此时优先级规则通常是:显式文本指令优先于语音;语音指令优先于视觉推测;视觉结果作为内容补充而非意图来源。例如用户说“描述这张图”并同时发来图片,语音负责意图(描述),图片负责内容(描述对象),两者合并为一次视觉模型调用,而不是分别执行。
降级
降级的目的是在模型服务不可用时提供可用性降级但明确的响应:
- 视觉模型不可用,任务只是提取文字 → 降级为 OCR 专用 API;
- 流式接口超时 → 降级为非流式,网关缓冲完成后一次性返回;
- 首选模型限流 → 降级到同能力的备用模型。
降级必须被记录。网关应在响应元数据或日志中标注实际使用的适配器和降级原因,否则后续排查无从下手。
工具调用与 Agent 循环
多模态 Agent 的一个关键机制是工具调用:模型输出不是最终文本,而是一个或多个工具调用指令;网关执行工具后,把结果写回会话,再次调用模型,直到模型输出最终回答。
统一消息抽象中,role: 'tool' 和 ContentPart 的 toolResult 片段就是为这个循环设计的。一个最小实现:
typescript
async function runAgentLoop(gateway: MultimodalGateway, conversationId: string): Promise<string> {
let resultText = '';
for (let round = 0; round < 5; round++) {
const events = gateway.stream(conversationId, {
role: 'user',
parts: [{ type: 'text', text: '继续' }],
createdAt: Date.now(),
});
for await (const event of events) {
if (event.type === 'delta' && event.part.type === 'text') {
resultText += event.part.text;
}
}
const lastMessage = getLastAssistantMessage(conversationId);
if (!lastMessage || !lastMessage.toolInvocations?.length) {
return resultText;
}
// 执行工具,把结果追加为 tool 消息,下一轮循环再交给模型
for (const invocation of lastMessage.toolInvocations) {
const output = await executeTool(invocation.name, invocation.arguments);
appendToolResult(conversationId, invocation.id, output);
}
}
throw new Error('agent loop exceeded max rounds');
}上述 toolInvocations、executeTool 是自定义接口,实际接入时对应各厂商的 function calling 或工具协议。工具循环的终止条件必须设置上限,避免模型反复调用工具导致失控。
6. 会话上下文与记忆管理
会话上下文是统一交互层状态的核心。所有模态的输入与输出,无论经过多少模型,最终都写回同一个消息列表。
会话存储结构
typescript
interface Session {
id: string;
userId: string;
messages: Message[];
createdAt: number;
updatedAt: number;
}一个会话内混合多种模态:
user: "这张图里有什么文字?"
user: [image: https://cdn.example.com/photo.jpg]
assistant: "图片中的文字是:欢迎光临。"
user: [audio: 转写结果 "接下来帮我读一下这段文字"]
assistant: [audio: 合成的语音回复]媒体引用与向量化
图像/视频等大对象在会话历史中应以引用形式存在(对象存储 key 或 URL),而不是 base64 内嵌。原因有两个:
- 每个后续请求都会把全部历史消息发送给模型,内嵌 base64 会随轮数线性放大传输体积;
- 图片可能在一次会话中被多次引用,引用形式可以复用存储,避免重复编码。
向量化的适用范围更窄。它只在需要检索历史时才有意义,例如“我刚才提到的那份合同在哪张图里”。普通多轮会话直接按顺序读取消息即可,不需要把历史图像向量化。
上下文压缩
模型上下文窗口有限。当会话历史超出预算时,常用三种手段:
| 手段 | 做法 | 代价 |
|---|---|---|
| 截断 | 丢弃最早的非 system 消息,保留最近 N 条 | 可能丢失旧信息 |
| 摘要 | 让文本模型把旧消息压缩为摘要 | 多消耗一次模型调用 |
| 检索 | 保留全部历史在外部存储,按需取回 | 需要检索基础设施 |
常用的折中方案是:system 提示永远保留,普通消息超过预算后优先丢弃旧的文本消息,再丢弃旧的图片引用。丢弃图片引用时,如果后续问题依赖图片内容,需要提示模型“该图片上下文已不可用”。
typescript
class SessionStore {
private sessions = new Map<string, Session>();
getOrCreate(id: string, userId: string): Session {
let session = this.sessions.get(id);
if (!session) {
session = { id, userId, messages: [], createdAt: Date.now(), updatedAt: Date.now() };
this.sessions.set(id, session);
}
return session;
}
append(sessionId: string, message: Message): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.messages.push(message);
session.updatedAt = Date.now();
this.trimIfNeeded(session);
}
private trimIfNeeded(session: Session): void {
const maxMessages = 20;
if (session.messages.length <= maxMessages) return;
const system = session.messages.filter((m) => m.role === 'system');
const rest = session.messages.filter((m) => m.role !== 'system');
const recent = rest.slice(-maxMessages);
session.messages = [...system, ...recent];
}
}trimIfNeeded 使用固定数量 maxMessages = 20 演示截断逻辑。实际系统应根据模型上下文窗口和已用 token 数估算预算。示例中的行为:对话超过 20 条消息后,旧的非 system 消息被移除,system 消息始终保留,会话仍可继续追加新消息。
状态同步
当多条输入几乎同时到达(比如语音命令与图片上传同时发生),应以到达顺序写入会话,而不是以处理完成顺序写入。createdAt 时间戳是排序依据;网关在收到消息时立即分配时间戳,而不是等模型处理完成后再写。
7. 语音流式交互与打断处理
语音交互涉及两种方向完全不同的流式语义。ASR 和 TTS 不应混用:
| 方向 | 流的内容 | 结束判定 | 需要区分的状态 |
|---|---|---|---|
| ASR | 文本增量 | 静音检测 / 用户停顿 | 部分结果、最终结果 |
| TTS | 音频二进制块 | 文本合成完毕 | 正常结束、被中断 |
ASR 流中,识别器会先给出一段可能被修正的临时文本,再在语音边界处给出最终结果。应用层若把临时文本当作最终文本处理,会产生“语音识别结果跳动”的现象。统一事件中可以用 isFinal 标志区分:
typescript
type AsrEvent =
| { type: 'partial'; text: string }
| { type: 'final'; text: string }
| { type: 'end' };TTS 流则没有“部分文本”的概念,每个音频块要么播放要么丢弃。客户端需要边收边播,并保留对播放缓冲的控制权,以便中断时立即停止。
语音交互状态机
一次完整的语音交互(语音唤醒/按键说话 → 识别 → 语义处理 → 语音回复)由四个状态组成:
IDLE → LISTENING → PROCESSING → SPEAKING → IDLE
^ |
|--------- 打断 ---------|LISTENING:客户端采集音频,ASR 持续产生部分结果;PROCESSING:ASR 给出最终文本,语言模型生成回复;SPEAKING:TTS 合成音频,客户端播放;- 打断:用户在
SPEAKING期间开始说话,系统取消 TTS、清空播放缓冲、回到LISTENING。
示例:Node.js 打断实现
TTS 流是可取消的。用 AbortController 作为中断信号:
typescript
class VoiceTurnManager {
private controller: AbortController | null = null;
private audioBuffer: Buffer[] = [];
async speak(text: string, tts: SpeechAdapter): Promise<void> {
this.controller = new AbortController();
try {
for await (const chunk of tts.synthesize(text, { signal: this.controller.signal })) {
this.audioBuffer.push(chunk);
await this.playChunk(chunk);
}
} catch (err) {
if (this.controller?.signal.aborted) {
// 中断导致的停止,不做错误上报
return;
}
throw err;
} finally {
this.audioBuffer = [];
this.controller = null;
}
}
interrupt(): void {
this.controller?.abort();
this.audioBuffer = [];
// 通知播放器立即停止
this.stopPlayback();
}
}interrupt() 只取消了客户端拉取音频块的循环。服务端 TTS 可能仍在合成。网关层应在收到客户端中断信号后,关闭与 TTS 服务之间的连接,避免继续传输无用的音频块。
8. 视觉输入的应用层处理
输入约束与传递方式
视觉模型 API 对图片输入有明确的格式约束。以 DeepInfra 的视觉 API 为例,图片通过 image_url 字段传递,值可以是公网 URL,也可以是 base64 data URI,格式为 data:image/jpeg;base64,... [1]。适配器在转换统一消息时,需要根据图片来源决定构造哪种 URL。
typescript
function imageSourceToUrl(source: ImageSource): string {
if (source.kind === 'url') return source.url;
return `data:${source.mimeType};base64,${source.data}`;
}图片在传给模型之前应做尺寸检查。超大图片既增加传输延迟,也超出模型的输入规格。网关可以选择在入口处限制图片大小,或提示客户端先压缩。
视频输入
视频是图片序列。应用层处理视频的常见方式是抽帧,把视频转换为若干图片片段,再交给视觉模型:
typescript
async function sampleFrames(videoUrl: string, intervalSec = 2): Promise<ContentPart[]> {
// 示意:用 ffmpeg 按固定间隔抽帧并上传到对象存储
// ffmpeg -i video.mp4 -vf fps=1/2 frame_%02d.jpg
const frameUrls = await extractFrames(videoUrl, { fps: 1 / intervalSec });
return frameUrls.map((url) => ({
type: 'image',
source: { kind: 'url', url },
}));
}抽帧间隔决定信息密度。间隔过大可能漏掉关键帧,间隔过小会增加模型调用成本。对于需要完整视频理解的场景(物体跟踪、活动识别),抽帧后交给多模态模型并不合适,应使用视频分析专用服务 [2]。
OCR 与视觉问答
“视觉输入”不只有一个出口。任务不同,选型不同:
- 提取图片中的文字:使用 OCR 能力。Google Cloud Vision API 的 OCR 能力、Document AI 的文档理解能力均覆盖这类场景 [2];
- 理解图像语义并回答:使用多模态语言模型,如 Qwen2.5-VL、Llama-3.2 Vision 等 [1];
- 视频内容分析:使用视频分析 API [2]。
选型影响适配器的实现。OCR 适配器接收图像后返回结构化文本,不产生“回答”;多模态语言模型适配器返回的是自然语言回答。统一层在调用前需要明确任务类型,而不是简单把一切图像都发给同一个模型。
API 版本演进
视觉 API 的版本迭代比文本 API 更频繁。Azure 的视觉服务更新历史显示,其 Read API 从 v3.1 预览版演进到 GA 版,旧预览版随后退役,GA 版提供更新的 OCR 模型和更广的语言覆盖 [3]。应用层对接云厂商视觉服务时,应把 API 版本作为适配器配置显式管理,并关注厂商的退役时间表,避免版本下线导致的接口失效。
9. 示例:Node.js 多模态网关
本节实现一个最小可用的多模态网关骨架,由四部分组成:
SessionStore:内存会话存储;ModelAdapter:模型适配器;Router:路由;MultimodalGateway:对外入口。
网关主体
typescript
import { randomUUID } from 'node:crypto';
interface GatewayOptions {
adapters: Map<string, ModelAdapter>;
sessionStore: SessionStore;
router: ModalityRouter;
}
class MultimodalGateway {
private adapters: Map<string, ModelAdapter>;
private sessionStore: SessionStore;
private router: ModalityRouter;
constructor(options: GatewayOptions) {
this.adapters = options.adapters;
this.sessionStore = options.sessionStore;
this.router = options.router;
}
async *stream(
conversationId: string,
userId: string,
userMessage: Message
): AsyncIterable<StreamEvent> {
const session = this.sessionStore.getOrCreate(conversationId, userId);
this.sessionStore.append(conversationId, userMessage);
const plan = this.router.route(userMessage.parts);
const firstStep = plan.steps[0];
const adapter = this.adapters.get(firstStep.adapterId);
if (!adapter) {
yield {
type: 'error',
error: { code: 'adapter_not_found', message: firstStep.adapterId },
};
return;
}
const request: GenerateRequest = {
conversationId,
messages: session.messages,
outputModalities: ['text'],
};
try {
yield { type: 'start' };
for await (const event of adapter.stream(request)) {
if (event.type === 'delta') {
this.sessionStore.append(conversationId, {
role: 'assistant',
parts: [event.part],
createdAt: Date.now(),
});
}
yield event;
}
yield { type: 'end', reason: 'stop' };
} catch (err) {
yield {
type: 'error',
error: { code: 'adapter_error', message: String(err) },
};
}
}
}实际工程实现中,delta 事件里的 part 是增量,不能逐段追加为独立消息,而应合并到同一 assistant 消息中。上面示例为演示精简了合并逻辑。
组装依赖
typescript
function createGateway(): MultimodalGateway {
const adapters = new Map<string, ModelAdapter>();
adapters.set(
'text-llm',
new OpenAICompatibleChatAdapter({
baseUrl: process.env.TEXT_LLM_BASE_URL!,
apiKey: process.env.TEXT_LLM_API_KEY!,
model: process.env.TEXT_LLM_MODEL!,
streaming: true,
})
);
adapters.set(
'vision-llm',
new OpenAICompatibleChatAdapter({
baseUrl: process.env.VISION_LLM_BASE_URL!,
apiKey: process.env.VISION_LLM_API_KEY!,
model: process.env.VISION_LLM_MODEL!,
streaming: true,
})
);
return new MultimodalGateway({
adapters,
sessionStore: new SessionStore(),
router: new ModalityRouter(adapters),
});
}HTTP 接入
网关对外暴露一个流式 HTTP 接口。客户端通过 fetch 发送消息,通过流读取事件:
typescript
import { createServer } from 'node:http';
const gateway = createGateway();
createServer(async (req, res) => {
if (req.url !== '/stream' || req.method !== 'POST') {
res.writeHead(404);
res.end();
return;
}
const body = await readJson(req);
const conversationId = body.conversationId ?? randomUUID();
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
});
const userMessage: Message = {
role: 'user',
parts: body.parts,
createdAt: Date.now(),
};
for await (const event of gateway.stream(conversationId, body.userId, userMessage)) {
res.write(JSON.stringify(event) + '\n');
}
res.end();
}).listen(3000);readJson 是读取请求体的辅助函数,可从 req 流中累积数据后解析。上述接口使用 NDJSON 而非 SSE,因为流事件中包含二进制音频块时,NDJSON 无法直接表达。实际工程中,音频块通常以 base64 放在 delta.part 的 data 字段里,或用二进制帧协议传输。
超时控制
外部模型调用必须设置超时。否则某个模型服务挂起,会连带整个网关请求挂起:
typescript
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`timeout after ${ms}ms`));
}, ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer!);
}
}
// 使用
const response = await withTimeout(
adapter.generate(request),
30_000
);超时和重试需要分别处理。ASR 这类幂等调用超时后可以安全重试;涉及外部副作用的工具调用(如创建订单)超时后不应盲目重试,否则可能产生重复副作用。
10. 注意点与限制:延迟、成本、可观测性与安全
延迟
多模态应用的延迟由链路中最慢的模型决定,优化手段围绕三点展开:
- 流式替换非流式:TTS 的首包时间远小于整段合成本时间;ASR 的部分结果让用户感觉系统“正在听”;
- 并行替换串行:把无依赖的模型调用放入
Promise.all; - 减少传输体积:避免在会话历史中长期携带 base64 图片;大图先压缩再上传。
成本
不同模态的计费维度不同。文本按 token 计费,图像按张数和分辨率计费,语音按音频时长计费。编排层需要把每次调用的费用维度记录在 GenerateResponse.meta 中,网关汇总后输出成本日志。
成本控制的关键在会话历史:每轮请求都会携带全部历史消息,历史越长,重复发送的 token 越多。第 6 节的截断和摘要是直接的成本控制手段。
可观测性
每个入站请求应分配一个请求 ID,并传播到所有下游模型调用。推荐的日志结构:
typescript
function trace(conversationId: string, event: string, meta: unknown): void {
console.log(
JSON.stringify({
ts: new Date().toISOString(),
conversationId,
event,
meta,
})
);
}需要记录的节点:
- 路由决策(选用了哪个适配器、哪条链路);
- 每个适配器的耗时和状态码;
- 仲裁结果(采用了哪个候选、置信度是多少);
- 降级行为(降级原因、降级目标)。
没有这些记录,多轮多模型的调用链很难排查。
安全与隐私
多模态数据涉及更多隐私面。音频可能包含说话人身份信息,图像可能包含人脸、车牌、文档内容。统一的控制点包括:
- 数据最小化:只保留处理所需的时长。音频在 ASR 完成后可立即删除;图像在生成回答后可按会话策略清理;
- 用途限制:模型 API 传出的数据默认不应用于训练。多数云厂商提供数据不保存选项,网关配置中应显式开启;
- 隔离:会话绑定
userId,网关按用户隔离会话读取权限,防止越权访问其他用户的上下文; - 留存与删除:为会话设置保留期,提供用户删除接口,删除时同时清理关联的媒体文件;
- 加密:媒体文件在存储侧加密,传输链路一律使用 TLS。
11. 演进:开放协议与多模态 Agent
原生多模态模型
模型形态正在从“单模态模型拼接”走向“原生多模态模型”。DeepInfra 托管的 Qwen2.5-VL 系列、Llama-3.2-11B-Vision-Instruct 等模型,已经接受图像与文本混合输入并直接输出文本 [1]。随着模型逐步原生支持音频输入,ASR 与语言模型之间的拼接可能变得不再必要,统一交互层的内部链路会随之简化。
但模型形态的演进不会消除统一交互层,反而会强化它。更多模态、更多模型版本并存,意味着应用层更需要对模型差异做隔离。
开放协议
MCP(Model Context Protocol)和 A2A(Agent-to-Agent)等开放协议正在把“模型如何获取上下文”和“Agent 如何互相调用”标准化。统一交互层可以对接这些协议,把工具调用、上下文获取、能力发现变成标准操作,而不是每个厂商一套私有格式。
多模态 Agent
多模态 Agent 的典型循环是:模型输出工具调用 → 网关执行工具 → 结果写回消息列表 → 再调用模型。统一交互层的消息抽象需要支持这种循环:role: 'tool' 消息、toolResult 片段、按 toolId 关联调用与结果。第 5 节的 Agent 循环实现展示了这一结构。
架构演进中值得保留的部分
无论模型如何演进,统一交互层有四个部分值得长期保留:
- 适配器接缝:模型会频繁更换,换模型不换上层逻辑;
- 消息抽象:
parts数组可以表达未来任意新模态(如触觉、脑机接口信号),只需新增一种 part 类型; - 单一会话状态:所有模态共享同一份上下文,是 Agent 记忆的基础;
- 流式事件收敛:客户端只认一套事件协议,内部接入多少流式服务对客户端透明。
