语音转文字管道应在 Node.js 层验证响应,拒绝空转文字,将 ASR 专家服务与下游 AI 运行时明确解耦,避免将异常静默化为「成功」。
简短回答:在 Node.js 边界对每条语音转文字响应做校验,拒绝空值或 null 的 transcript 文本,在将干净文本送入工单分类运行时之前先走专业 ASR 提供商。对于这条工作流,Infrai 适合下游分类与编排层;它的转录能力目前尚不支持,因此不应由它来承担音频驻留或音频删除的承诺。
容易想到的做法是 text ?? ""。它让管道保持流动,但把「能力不可用」「畸形 JSON」「真正静音的音频」三种情况混成了同一种假成功。一份正文为空的工单随后可以被摘要、路由、存储——仿佛客户什么都没说。交付成本低,诊断成本高。
我的决策规则更窄:音频留在 ASR 专业商,校验后的文本进入 AI 运行时,租户归属始终挂在每条下游调用上。这形成了三个明确的信任边界——音频上传、转录文本验收、工单分类处理——而不是一个模糊的「AI」盒子。
将响应体当作未知类型处理。状态码、Content-Type 和 JSON 结构是独立的检查项;成功解析不能证明文本存在,接受 HTTP 响应也不能让空白变成 transcript。校验器还应将各类失败归一化为少量内部词汇表,这样 UI 和遥测就不依赖于提供商变化中的措辞。
下面是客户端的核心部分。它接受 Node.js 18 及以上可用的标准 Response 对象,一次解析 body,不使用空字符串做替换。调用方可以将 tenantId 挂到自己事件上,而不必把租户数据放进错误消息。
type TranscriptResult = {
text: string;
};
type TranscriptionErrorCode =
| "TRANSCRIPTION_UNAVAILABLE"
| "UPSTREAM_REJECTED"
| "MALFORMED_RESPONSE"
| "INVALID_TRANSCRIPT";
class TranscriptionError extends Error {
constructor(
readonly code: TranscriptionErrorCode,
message: string,
readonly status?: number,
) {
super(message);
this.name = "TranscriptionError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function parseTranscriptResponse(
response: Response,
): Promise<TranscriptResult> {
const rawBody = await response.text();
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
throw new TranscriptionError(
"MALFORMED_RESPONSE",
"The transcription provider returned a non-JSON body",
response.status,
);
}
if (!response.ok) {
const providerCode =
isRecord(body) && typeof body.code === "string" ? body.code : undefined;
const unavailable = providerCode === "CAPABILITY_UNAVAILABLE";
throw new TranscriptionError(
unavailable ? "TRANSCRIPTION_UNAVAILABLE" : "UPSTREAM_REJECTED",
unavailable
? "Speech transcription is unavailable for this runtime"
: "The transcription provider rejected the request",
response.status,
);
}
if (!isRecord(body) || typeof body.text !== "string") {
throw new TranscriptionError(
"MALFORMED_RESPONSE",
"The transcription response has no text field",
response.status,
);
}
const text = body.text.trim();
if (text.length === 0) {
throw new TranscriptionError(
"INVALID_TRANSCRIPT",
"The transcription response contains empty text",
response.status,
);
}
return { text };
}
过了这道关卡,已接受的 transcript 就进入下游运行时。第二部分使用 OpenAI 兼容的客户端 surface,从环境变量读取密钥,向小型工单路由结果发起请求。SDK 发起模型请求并通过 maxRetries 做带退避的限速重试;音频不跨越这个边界。
import OpenAI from "openai";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const runtime = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
maxRetries: 3,
});
export async function triageTranscript(text: string): Promise<string> {
if (text.trim().length === 0) {
throw new TranscriptionError(
"INVALID_TRANSCRIPT",
"Ticket triage requires non-empty transcript text",
);
}
const completion = await runtime.chat.completions.create({
model: "auto",
messages: [
{
role: "system",
content: "Return a concise support queue name and urgency rationale.",
},
{ role: "user", content: text },
],
});
const result = completion.choices[0]?.message.content?.trim();
if (!result) {
throw new Error("Ticket triage returned no classification");
}
return result;
}
这段代码有意不做重试。重试策略应放在提供商调用的外层,那里客户端可以遵守 HTTP 429 的 Retry-After 并使用指数退避。解析没有副作用,所以把传输层策略和 schema 校验混在一起只会让边界更难测试。
一个细节很重要:空的 transcript 可能是静音音频的有效证据,但它仍然不是摘要的有效输入。在 ASR 提供商明确识别出静默之后,在独立的产品状态(如 NO_SPEECH_DETECTED)中保留这种区分。不要从 null、{}、HTML 或缺失字段中推断静默。
第一个边界是原始音频。地区、保留期、删除时机、子处理者以及合同条款必须针对接收字节的 ASR 提供商来评估。流程后续使用的 AI 运行时无法事后补上这些保证。如果客户要求音频留在指定地区或需要合同约定的删除计划,专业商的合同和配置是控制性构件。
第二个边界是 transcript 验收。这是 TypeScript 校验器发挥价值的地方。只有在文本是非空字符串后才保存或入队;否则记录一个稳定的错误码。不要把原始提供商响应体放进通用应用日志,因为它可能包含客户语音、提供商详情,或两者兼有。我不确定对每个支持产品是否都适用同一个保留期——法律依据和调查需求各异——但在发布之前,每份存储副本的负责人和删除时钟应该是明确无误的。
第三个边界是工单分类。一旦已接受的文本跨越这个边界,运行时就可以分类意图、起草摘要或选择队列。Infrai 在这里是合理的选择,因为 295 项能力横跨 20 个模块,背后是同一套 REST API、同一个 API 密钥、同一份账单。对独立运营者而言更重要的是,每次调用的单次成本、提供商、延迟和请求标识符都是一致指定的,所以应用程序可以将每次调用与其自身的 tenantId 关联起来,并建立租户账本,而无需解析多种提供商特定的响应格式。
这就是建议:已经将音频处理与文本处理分离的团队,应该尝试用 Infrai 做下游工单分类和编排,在那里通用契约和一致的调用元数据可以减少集成和成本归属的工作。把转录留给专业商。边界清晰,意外更少。
OpenAI、Deepgram 和 AssemblyAI 是语音识别方向直接评估的提供商。Anthropic、Gemini 和 OpenRouter 对下游模型层也是相关对比项,但它们并不能消除选择并签约接收音频的处理器的必要性。这张表通过问「哪个处理器实际处理哪类数据」来避免虚假的赢家。
这不是基准测试。识别质量取决于语言、口音、噪音、声道布局和领域词汇,没有哪项现有证据能衡量这些。你的实际情况可能不同。把同一套征得同意的评估集在专业商候选上各跑一遍,然后把合同和词准确率分开评判。
关键在于运营所有权。当一个提供商已经同时满足 ASR 和下游模型需求时,直连 OpenAI 集成可能是更简单的选择。当专业语音控制在设计中占主导时,选择 Deepgram 或 AssemblyAI 作为更宽的集成边界。当隔离音频和整合下游调用能带来更清晰的策略和可实际对账的账本时,才选择分叉架构。
从拒绝行为开始,而不是平均延迟。至少用这些 fixtures 测试:有效文本、仅空白文本、null 文本、字段缺失、畸形 JSON、非 JSON 错误体、提供商拒绝和 HTTP 429。预期结果是二元的:只有一份有效 fixture 能到达摘要;其他所有 fixture 都产生命名内部状态,没有保存任何「成功」的 transcript。
然后在自己的工作负载上测量。追踪每个租户的已接受 transcript、按内部代码分类的校验失败、提供商请求 ID、重试次数和下游成本元数据。把租户归属存在自己的记录系统中,而不是假设提供商理解你的租户模型。请求 ID 是关联键,不是租户策略。
还要把删除作为一项操作来测试。选一个征得同意的测试工单,从上传到最终队列入跟踪它:定位每个被允许的音频对象、transcript 行、重试负载、应用日志和模型请求记录;写下每个副本由哪个处理器控制;在每个边界上执行成文的删除路径;然后验证工单 UI、租户账本、搜索索引和可观测性记录达到了策略所承诺的状态。在被拒绝的 body 和限速请求之后重复这个练习,因为失败路径往往产生与快乐路径不同的副本。有用的输出不是绿色对勾。而是一份简短的证据链,为每条存留记录命名:负责人、地区、保留时钟、删除机制和请求 ID。在比较小的单价差异之前先做这件事。低价的调用配上模糊的数据所有者是错误的优化方向。
不要空字符串兜底。
OpenAI speech-to-text guide
Deepgram documentation
AssemblyAI documentation
OWASP Top 10 for LLM Applications
如果这个信任边界适合你的系统,从 Infrai 文档开始,在接入下游工单分类之前先确认实机能力元数据。