小时级录音转录后,从文本中提取PO号、金额、日期等结构化字段的精度比词错误率更重要。推荐分离转录和提取为独立任务。
用专用的异步语音转文字提供商来处理音频部分——传入文件、返回任务 ID、完成后通过 webhook 通知——把转录之后的所有工作当作第二个任务,有它自己的契约。对于长录音(一小时的客服电话、供应商评审电话、播客节目),决定这条流水线是否合算的数字很少是词错误率(WER),而是你后续构建的结构化记录上每个字段的准确率。任何值得纳入候选名单的批量音频转录 API 都已能处理一小时长的文件、异步任务和 webhook 回调;那部分是基本功。真正烧钱的地方是上层的提取环节。
这里的系统刻意朴实无华:一家小型游戏工作室将供应商发票与录制的供应商电话进行对账。美术外包、本地化厂商、QA 承包商。PDF 发票上写的是一套,而谈判修改里程碑的 90 分钟通话里说的又是另一套,所以财务希望每通电话提取六个字段——供应商、PO 编号、里程碑、金额、货币、到期日——并附上每个字段被说出的时间戳链接。
这是一个穿着音频外衣的结构化输出问题。
便宜的做法是一趟搞定:转录,把完整 transcript 粘贴到对话模型里,要 JSON。头几通电话试下来看起来能 work。
然后你去看它哪里出了问题,就会发现失败模式平淡无奇但很系统性。PO 编号在第 6 分钟被读出来,在第 71 分钟被修正;单次遍历完整 transcript 的方式往往返回模型最先抓住的那个提及。金额被说成"eighteen four"(十八点四),结果回来的是 18 或 1804。最糟糕的是,无约束的 schema 让模型无处表达"这通电话里没说",所以它会生成一个看似合理的内容——一个看似合理的 PO 编号比缺失的代价高得多,因为没有人会去检查一个看起来已经填好的字段。
我会上线的版本是把 transcript 切成带时间戳的几分钟块,每块做一次带约束的提取,所有字段都允许为 null,然后按时间戳顺序对块结果进行对账,后续的修正覆盖之前的陈述。对账是纯代码,不是 prompt。把模型留给读英语,把算术、排序和优先级判断留在 TypeScript 里,那里可以写单元测试。
对于任何超过约十分钟的内容,答案是肯定的——提交文件、获取任务 ID、等 webhook 通知你文本已就绪。流式 API 存在有它的道理,但那个道理是实时辅助代理,不是夜间对账跑批。每三十秒轮询一个异步任务九十分钟,不过是一个你用多余请求买单的 webhook。
选择那个提供商时,有两点比供应商的 WER 营销更重要。** diarization**(说话人分离),因为"谁说的那个数字"是让提取变得可解的前提的一半。以及一个你实际上能做到幂等的 webhook 契约:稳定的任务 ID、可验证的签名、以及你要假设至少交付一次的投递。写你的消费者时,要做到收到两次相同的完成任务只产生一行,而不是两行。
播客存档是简单的场景——单一声源、清晰音频、没有截止时间。客服电话是困难的那个:多人交叉说话、电话编解码器、以及你关心的字段往往在最后两分钟才出现——那时候大家已经在说再见了。
同时买两段服务没什么障碍。只是很少能产生最佳的单位集成工作收益,因为擅长解码一小时长多人音频的供应商,不会是你会选择来做廉价高频结构化提取的那家。
那张表格里每一行的陷阱是相同的:没有一家会替你给字段打分。那是你的活儿,而且这是团队会跳过的部分。
一个块,一次带约束的调用,允许 null。Infrai's chat 接口是 OpenAI 兼容的,所以这是一次普通的 REST 请求,用的是覆盖后端其余工作的同一套 key——不需要额外 SDK,而且每次调用成本会随响应返回,这样你可以计费到拥有这通电话的工作室。
const KEY = process.env.INFRAI_API_KEY;
const BASE_URL = process.env.INFRAI_BASE_URL; // the platform's REST root, from its API reference
if (!KEY || !BASE_URL) throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL must be set");
// One timestamped slice of a supplier call, already transcribed and diarized.
const chunk = {
callId: "nova-loc-2026-08-04",
startedAt: "00:41:12",
text: [
"Rin: the Korean VO pickup ran two sessions over, that's all on PO 4417-B.",
"Marta: and we're billing it as M3 delivery now, not M2.",
"Rin: total lands at eighteen thousand four hundred euros, net 30 from the fifth.",
].join("\n"),
};
const invoiceFields = {
name: "supplier_invoice_fields",
strict: true,
schema: {
type: "object",
properties: {
supplier: { type: ["string", "null"] },
po_number: { type: ["string", "null"] },
milestone: { type: ["string", "null"] },
total_amount: { type: ["number", "null"] },
currency: { type: ["string", "null"] },
due_date: { type: ["string", "null"] },
},
required: ["supplier", "po_number", "milestone", "total_amount", "currency", "due_date"],
additionalProperties: false,
},
};
async function extract(attempt = 0): Promise<Response> {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
// Same chunk, same key: a retry after a network timeout reuses the first result.
"Idempotency-Key": `invoice-fields:${chunk.callId}:${chunk.startedAt}:v3`,
},
body: JSON.stringify({
model: "qwen3.7-plus",
temperature: 0,
messages: [
{
role: "system",
content: "Extract supplier invoice fields from one segment of a recorded vendor call. "
+ "Use null for any field not stated in this segment. Never infer or complete a number.",
},
{ role: "user", content: chunk.text },
],
response_format: { type: "json_schema", json_schema: invoiceFields },
}),
});
if (res.status === 429 && attempt < 5) {
const retryAfter = Number(res.headers.get("Retry-After"));
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000;
await new Promise((done) => setTimeout(done, waitMs));
return extract(attempt + 1);
}
return res;
}
const res = await extract();
if (!res.ok) throw new Error(`extract ${res.status}: ${await res.text()}`);
const payload = await res.json();
const fields = JSON.parse(payload.choices[0].message.content);
console.log(chunk.startedAt, fields, payload.infrai?.cost_usd);
这里面有两个细节值得它们的位置。temperature: 0 加上带 nullable 字段的严格 schema,意味着一个空块返回六个 null 而不是一次猜测,这使得后续每个字段的得分有意义。幂等 key 来自块的标识,所以在超时后重试的请求会折叠为原始结果,而不是为同一段付两次钱。对于一次性回填现有存档,同样的请求体发给 POST /v1/ai/batch/submit,在任务报告完成时收集结果,这样几千通旧电话就不会占用你的在线速率限制。
先建 golden set。三十到五十通真实电话,每通六个字段,由目前做对账的人手工标注——一个慢悠悠的下午,但第一次换模型时就回本了。
然后按字段度量三样东西,而不是一个综合准确率:一个被说出的字段被正确提取的比率、一个在电话中不存在的字段被正确返回为 null 的比率,以及——真正预测审核工作量的那个——一个错误值被返回时看起来很有把握的比率。准确率 92% 且自信错误率 1% 的流水线是可用的。同样的流水线准确率 96% 但自信错误率 6% 则更糟,因为现在人工需要检查一切。要把每通已处理电话的成本与这些指标一起跟踪,因为分块增加了你的请求数量,很容易把一个便宜的任务变成贵的而不自知。
如果你的录音很短,需要在通话者还在线时得到答案,这种批量形态完全是错误的工具——用流式 ASR 供应商并接受较低的字段准确率。如果你要的字段已经在供应商计费系统的结构化导出里,根本不要建任何这些。而如果单一提供商的合规故事是决定性约束,就用法律团队已经审批通过的那家,即使准确率有所损失。
老实说,我不确定六字段 schema 能推广到发票对账之外。字段级评分可以。无论你从长音频中提取什么,transcript 是一个中间产物,而把它当作产品来评分正是这类项目最终交付了没人信任的东西的方式。
OpenAI — Speech to text guide: https://platform.openai.com/docs/guides/speech-to-text
OpenAI — Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs
Groq — Speech to text: https://console.groq.com/docs/speech-to-text
Deepgram — Callback (webhook) delivery: https://developers.deepgram.com/docs/callback
RFC 9110, HTTP Semantics (idempotency and retry semantics): https://www.rfc-editor.org/rfc/rfc9110
Prompt Engineering Guide: https://www.promptingguide.ai