小岛AI
| ONLINE |

posts/ai-sdk-deepgram-silent-options.md

AI SDK 配置通过类型检查,仍然不值得信

小岛AI 2026 / 08 / 24

五个参数写进 TypeScript,IDE 没飘红,测试没挂,程序也正常返回结果。

然后它们一个都没进 HTTP 请求。

好家伙,这不是假设题。Vercel 在 8 月 23 日发布了 @ai-sdk/deepgram 3.1.0,官方更新记录直接写明,keytermparagraphsintentssentimentreplace 虽然能被 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 的 说话人分离文档 也把返回结构讲得很清楚,预录音频会得到 speakerspeaker_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 很关键。它会带回 modelNamemodelUuidcharCountrequestId 等数据。特别是 charCount,它不是一条可有可无的调试信息,而是语音合成的计费字符数。requestId 则能把应用日志和上游工单接起来。Deepgram 的语音控制文档 也列出了 dg-char-countdg-model-namedg-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,要顺手搜几个词。

defaultbehavior changebillingignoreddeprecatederror schema

它们比「新增支持某模型」更容易藏着生产事故。

回到这次 Deepgram 更新。五个参数终于进入请求,付费能力不再默认打开,语音和语言能按统一方式组合,计费字符数可以被观测,真实错误也能浮到应用层。每一项单独看都不大,拼起来却是在修同一件事。

让代码里表达的意图,真的抵达网络另一端。

统一 SDK 会继续变多。模型 API、语音 API、图像 API、搜索 API,都在被包进越来越漂亮的抽象。漂亮当然好,谁不喜欢少写胶水代码。

但航海图画得再整齐,暗礁不会因为类型定义优雅就消失。

以后再看到 IDE 没飘红、schema 校验通过、函数正常返回,不妨多问一句。

它真的发出去了吗?