posts/ai-sdk-deepgram-silent-options.md
AI SDK 配置通过类型检查,仍然不值得信
五个参数写进 TypeScript,IDE 没飘红,测试没挂,程序也正常返回结果。
然后它们一个都没进 HTTP 请求。
好家伙,这不是假设题。Vercel 在 8 月 23 日发布了 @ai-sdk/deepgram 3.1.0,官方更新记录直接写明,keyterm、paragraphs、intents、sentiment 和 replace 虽然能被 providerOptions.deepgram 接受,却会在发往 /v1/listen 之前静默消失。
同一个版本还改了另一件更微妙的事。旧适配器会给每次预录音频请求默认带上 diarize=true,也就是自动做说话人分离。这个能力是付费附加项,用户不显式关掉,它就一直开着。新版改成只有明确配置才发送。
一个让你以为功能开了,其实没开。
一个让你以为没特别配置,其实一直在付费。
两边都不报错。
我一直觉得,工程里最难防的从来不是红色报错。报错至少肯喊,监控也容易抓。真正磨人的是这种看起来全绿、结果悄悄偏掉的状态。它不会在发布夜里把服务打崩,只会让领域词识别一直不准,让段落和意图分析始终没生效,让账单比预期多出一截。等团队察觉,通常已经隔了好几个版本。
这次更新表面上只是一个 provider 包的小版本,背后却戳中了所有 AI 应用都会撞上的问题。
类型安全,不等于传输安全。统一接口,也不等于行为统一。
配置进了对象,没进请求
AI SDK 的价值很直观。不同厂商的模型、转写、语音接口各有一套参数和返回结构,应用层不想给每家都写一遍胶水,就用 provider 把差异压进同一个抽象里。Vercel 的 provider 架构文档 讲的正是这件事,开发者可以用相似的调用方式切换不同服务,少背一点厂商 API 的细枝末节。
这套设计有点子牛逼,也确实省事。
可抽象层每多一层,合同就多一个可能断掉的位置。
拿 keyterm 来说,Deepgram 官方文档 建议 Nova-3 用它增强品牌名、专有名词和行业术语的识别。你在业务代码里写下这样的配置,TypeScript 能检查字段名,编辑器能给补全,运行时 schema 也能顺利解析。
providerOptions: {
deepgram: {
keyterm: 'Kubernetes',
paragraphs: true,
intents: true,
sentiment: true,
replace: '[已隐去]',
},
}
问题出在下一跳。
适配器把业务对象翻译成上游查询参数时,没有把这五个字段映射进去。对 TypeScript 来说,配置对象完全合法。对 Deepgram 来说,请求里压根没有这些参数。双方各自都没错,中间那层翻译却把信息吃了。
段错误都比它体面。
官方的 修复提交 很朴素,就是把缺失映射补回去,并增加请求 URL 断言。可这几行改动暴露出的工程问题不小。很多团队的 provider 测试只覆盖两头,输入对象能不能通过,返回结果能不能解析。中间真正发出去的请求长什么样,反而没人看。
假设一个常见的会议转写服务。转写文本本来就能返回,所以健康检查一直是绿色。领域词错了,团队可能怪音频质量。段落没生成,前端可能自己再切一次。意图和情绪没出来,产品可能以为模型效果不稳。五个独立现象会被分散到五个排查方向,没人第一时间想到,它们在同一个 URL 构造函数里一起丢了。
这块需要补的不是更多类型,而是一条出站请求合同。
测试不要只写「传入 keyterm 不抛异常」,要写「最终 URL 中存在 keyterm=Kubernetes」。不要只断言转写函数返回文本,要断言没有显式开启说话人分离时,URL 里根本不存在 diarize。布尔值、数组、重复参数、空字符串和特殊字符都应该落到网络形态上验一次。
可以把它理解成编译器测试。AST 看着对还不够,最终生成的机器码也得看。

一个最小的契约测试,大致要盯住这些东西。
const url = new URL(capturedRequest.url)
expect(url.searchParams.get('keyterm')).toBe('Kubernetes')
expect(url.searchParams.get('paragraphs')).toBe('true')
expect(url.searchParams.get('intents')).toBe('true')
expect(url.searchParams.get('sentiment')).toBe('true')
expect(url.searchParams.get('replace')).toBe('[已隐去]')
expect(url.searchParams.has('diarize')).toBe(false)
这种测试不酷,甚至有点土。
但它抓的就是最贵的那类 bug。
默认值不是便利,是一张没有签名的支票
再看 diarize。
说话人分离会识别音频里的发言人变化,并给每个词标上 speaker。会议纪要、客服质检、访谈整理都很需要它。Deepgram 的 说话人分离文档 也把返回结构讲得很清楚,预录音频会得到 speaker 和 speaker_confidence。
问题不在能力,而在默认值。
旧版 provider 创建请求体时直接写了 diarize: true。应用代码里看不到它,配置文件里找不到它,代码评审也不会有人讨论要不要买这个能力。只要走预录音频转写,它就自动进入请求。
厉害了,一行没写,功能和费用一起生效。
这类默认值在本地 demo 里很讨喜。你传一段多人对话,返回结果自动分好 speaker,开箱体验确实漂亮。到了生产环境,判断标准完全不同。有人只转写单人语音,有人已经用多声道把客服和客户分开,有人只是做搜索索引,根本不需要说话人标签。统一替所有请求打开付费能力,等于 provider 在替业务做采购决定。
更麻烦的是,上游合同还在继续变化。Deepgram 当前文档已经把布尔型 diarize=true 标为废弃,推荐用 diarize_model=latest 或固定版本。前者会跟随最新可用模型,后者能锁住批处理或流式能力。自托管部署里,如果环境只有新版 diarizer,旧布尔参数甚至可能得到成功响应,却没有 speaker 标签。
看见没有,又是成功响应。
所以默认值不能只问「好不好用」,还得问四件事。谁决定的,是否收费,能否从调用现场看见,上游改变语义时会不会留下迁移信号。
我自己的感受是,provider 层最好少做善意猜测。会影响结果、费用、权限或数据处理方式的选项,都应该显式。应用层可以提供自己的产品默认值,但要让它们出现在配置和评审里,而不是藏在依赖包的内部常量中。
const transcriptionPolicy = {
diarize: false,
paragraphs: true,
intents: false,
sentiment: false,
} as const
这段配置一点也不聪明,却有三个好处。团队能审,环境能改,账单异常时也能追到谁开了什么。
别小看这点。AI 应用的成本不只来自模型 token。语音按分钟、语音合成按字符、联网搜索按次、向量存储按容量、工具调用按服务计费。provider 如果替你悄悄开一个附加项,财务看到的只是总额,工程侧却很难把费用还原到具体请求。
新版顺手补上的 providerMetadata.deepgram 很关键。它会带回 modelName、modelUuid、charCount、requestId 等数据。特别是 charCount,它不是一条可有可无的调试信息,而是语音合成的计费字符数。requestId 则能把应用日志和上游工单接起来。Deepgram 的语音控制文档 也列出了 dg-char-count、dg-model-name 与 dg-request-id 等响应头。
坦率讲,成本可观测性应该和延迟、错误率一样,成为每次调用的基础遥测。

一次请求用了哪个上游模型,计费单位是多少,打开了哪些附加能力,重试了几次,最终费用归到哪个租户,这些字段都该进结构化日志。等月底拿总账单再猜,跟线上服务只看 CPU 平均值差不多,能看,但救不了现场。
错误信息也是接口,不是装饰
3.1.0 还修了一个特别容易被低估的地方。
Deepgram 返回错误时使用 { err_code, err_msg, request_id }。旧 provider 按另一套并不存在的结构解析,所以 APICallError.message 往往只剩一个笼统的 HTTP 状态短语。参数越界、语音不存在、鉴权失败,到了应用日志里可能都长得像「Bad Request」。
不是哥们,这怎么排。
新版改成读取真实的 err_msg,并把 request_id 留下来。看起来只是错误文案更具体,实际影响的是整条故障处理链。重试器需要知道这是不是临时失败,告警要区分配置错误和上游故障,客服工单要拿 request ID 去对账,自动修复逻辑更不能把所有 400 都当成同一种问题。
比如 speed。这次更新后,AI SDK 会把语速正确透传给 Deepgram,上游接受 0.7 到 1.5。Deepgram 官方说明里还明确写了,超出范围会返回错误。旧版只发一个不支持警告然后忽略,新版会真正调用,也会真正面对上游校验。
功能从「被忽略」变成「会执行」,错误合同必须同步升级。
说真的,很多 AI SDK 的兼容层测试太爱盯 happy path。能生成一段音频,棒棒的,测试结束。可生产里真正消耗值班时间的,往往是无效 voice、错误语言组合、超范围 speed、过期 key、限流和上游返回结构变化。成功路径证明这玩意能跑,错误路径才证明团队养得起它。
一套靠谱的 provider 契约,至少要把错误分成可重试、不可重试、需要改配置、需要换凭据和需要人工处理。原始状态码要留,厂商错误码要留,请求 ID 要留,解析失败时的原始响应也要安全留存。业务层收到的不能只有一个 Error 字符串。
升级 provider,别只跑一遍单元测试
可能有朋友会问,一个第三方适配包的小版本,真需要搞这么重吗?
如果它只负责换几个字段名,当然可以轻一点。可一旦它站在业务和计费 API 中间,它就在决定四件很现实的事。请求发了什么,费用算了什么,错误留下什么,升级改变什么。
这已经不是普通工具函数。
我会把 provider 升级验收压成一份很短的合同,不追求花哨,但每条都能在 CI 里给出证据。
request_shape:
assert_documented_options_reach_wire: true
assert_unspecified_paid_features_absent: true
defaults:
require_explicit_cost_affecting_flags: true
snapshot_before_and_after_upgrade: true
observability:
store_provider_request_id: true
store_billing_units: true
store_resolved_model: true
errors:
preserve_vendor_code_and_message: true
classify_retryability: true
请求形态用 mock server 或自定义 fetch 截下来,不依赖真账号。默认值做升级前后快照,尤其盯收费、权限和数据处理相关字段。遥测字段进日志 schema,不能只是临时 console.log。错误路径准备几组固定响应,确保厂商改结构时 CI 会红。
然后再加一小批在线探针,只跑最便宜的样本。离线契约能证明映射逻辑,在线探针能证明真实 API 没换门锁。Vercel 这次提交里新增了 38 项包级测试,还对成功和失败路径做了真实 API 验证,这个组合就很合理。
这块还有一个很实用的习惯。每次升级依赖,不要只读最上面的 changelog,要顺手搜几个词。
default、behavior change、billing、ignored、deprecated、error schema。
它们比「新增支持某模型」更容易藏着生产事故。
回到这次 Deepgram 更新。五个参数终于进入请求,付费能力不再默认打开,语音和语言能按统一方式组合,计费字符数可以被观测,真实错误也能浮到应用层。每一项单独看都不大,拼起来却是在修同一件事。
让代码里表达的意图,真的抵达网络另一端。
统一 SDK 会继续变多。模型 API、语音 API、图像 API、搜索 API,都在被包进越来越漂亮的抽象。漂亮当然好,谁不喜欢少写胶水代码。
但航海图画得再整齐,暗礁不会因为类型定义优雅就消失。
以后再看到 IDE 没飘红、schema 校验通过、函数正常返回,不妨多问一句。
它真的发出去了吗?