# ADR-0021: 语音转写 provider 能力 -- flyto.TranscriptionProvider + 健康探活 + 平台转写代理 - **Status**: Accepted (PM 拍板 2026-07-12 "你要给引擎新增这个 provider, 落入该模型的 spec, 这个本来就该做的事") - **Date**: 2026-07-12 - **Consumers**: flytoCall (电销通话录音转写, ASR 依赖类 flow qa_score / violation / call.summary 的上游); 任意需要语音转文字的行业 platform - **关联**: ADR-0017 (命名实例, provider 从哪来) · ADR-0018 (能力面, live discovery 第二档) · ADR-0020 v2 (provider 来源纪律, 本 ADR 的端点沿用) · flyto/vision.go (可选能力先例, 本 ADR 的模板) ## 1. 背景 / Context M5Max (飞驼 Mac 推理机, oMLX) 上线了 OpenAI 兼容的语音转写服务 (`POST /v1/audio/transcriptions`, model `qwen3-asr-1.7b-audio8-text4`): 任意长度长音频兜底 / 立体声说话人分离 (energy_tripass, FreeSWITCH 电销录音) / 词级时间戳 / SRT/VTT / 300 MB 上传上限 / `GET /health` 免鉴权探活 (接口全文 `platform/common/docs/m5max-asr-api.md`). 引擎此前只有 chat (Stream) 与 vision (ExtractVision) 两类模型交互; 转写无契约, 消费者只能绕过引擎裸调 -- 又一个 "平台层偷懒" 的候选. 且该后端**不常在线** (Mac 睡眠/断电), 消费者与 provider 间缺一个 "先探活再上传 300 MB" 的机制. flytoCall 等消费者也**够不着**它 (仅 Tailscale 可达), 必须平台代理. ## 2. 决策 / Decision 三层, 全部对齐既有先例: 1. **core 契约** (`flyto/transcription.go`, 仿 vision.go): `TranscriptionProvider { Transcribe(ctx, *TranscriptionRequest) (*TranscriptionResponse, error) }` 是**可选**能力, 消费者 type-assert, 缺失时降级. `TranscriptionRequest.Audio` 是 `io.Reader` **流**而非 `[]byte` (录音数百 MB, 不整段进内存); 字段 = OpenAI 转写 API + 自托管扩展 (diarize/long_audio/word_timestamps) + `Extra` 前向兼容透传. 契约场景中性 (说话人标签由调用方传, 不假设呼叫中心). 单独于 Provider.Stream: 转写是单次 RPC 非多轮编排. 2. **core 探活契约** (`flyto/health.go`): `HealthChecker { CheckHealth(ctx) error }` 可选能力. openai provider 由 `Config.HealthCheckPath` 启用 (oMLX="/health"; 真 OpenAI 留空 -> no-op nil, 绝不误报云端为挂). `Transcribe` **前置探活**: 后端离线时秒级 fail-loud, 不把 300 MB 灌进死端点 (PM: "使用前一定要调用此接口, 因为他不是一直在线"). 探活自带 5s 独立 timeout, 不吃调用方分钟级预算; HTTP 200 + `status!=healthy` 也算失败. 3. **模型 spec** ("落入该模型的 spec"): `flyto.ModelInfo.SupportsTranscription` 新能力位; live discovery (`wire.FetchOpenAIModels`) 解析 oMLX 目录扩展 `model_type` -- `audio_stt` -> SupportsTranscription, `vlm` -> SupportsVision (此前 model_type 被忽略, vision 位顺带修正). platform `ModelSpec` 增 `supports_transcription`, discover/capabilities 端点带出. 4. **平台代理** (`POST /api/v1/audio/transcriptions`, multipart): 沿用 ADR-0020 v2 provider 来源纪律 -- `provider_instance` 必填点名消费者自有 ADR-0017 实例, 无平台默认, fail-loud 矩阵同 flow (503 配置禁用 / 400 实例缺失未知 / 400 model/file 缺). 点名到无转写能力的实例 (anthropic 等) 返 400 讲明白, 不静默. 上传经 `MaxBytesReader` 320 MB + `ParseMultipartForm` 32 MB 内存阈值 (大文件落临时盘, 用完 RemoveAll). fmlx 工厂统一带 `HealthCheckPath: "/health"`. ## 3. 替代方案 / Alternatives - **消费者直连 m5max**: 够不着 (Tailscale 私网) 且绕开配置/审计/实例纪律. 否决. - **把音频接进 Provider.Stream (BlockAudio)**: 转写是单次 RPC, 不是引擎多轮编排; vision 已论证过同一取舍 (flyto/vision.go 头注). 引擎多模态轮次是另一主题. - **`Audio []byte` 对齐 VisionRequest.Image**: 图片 KB 级, 录音数百 MB 级 -- []byte 强制整段驻内存. io.Reader + io.Pipe 流式上线. - **平台层自己 GET /health 再转发**: 探活会散落每个消费点; 放 provider `Transcribe` 内, 所有消费者 (平台/未来 SDK/CLI) 免费获得. - **健康检测做成 fmlx 专有**: 做成中性 `HealthCheckPath` 配置 + `flyto.HealthChecker` 可选接口, 任何有存活端点的自托管后端可复用 (叠加而非替换). ## 4. 影响 / Consequences - **正面**: 转写成为引擎一等可选能力, ASR 依赖类 flow 的上游打通; model spec 诚实承载模型身份 (audio_stt 不是 chat 模型); 离线后端快速失败带可操作报错; vision 能力位在 live discovery 下顺带修正. - **负面 / 债务**: 仅 openai 兼容路径实现 (够用: oMLX/真 OpenAI 同线协议); 平台代理不落转写结果 (审计/回放属 INF-4 主题); 300 MB 常数跟随上游, 上游再调需同步; `/health` 探活与真调之间仍有竞态窗口 (机器可在两者间睡着), 真调错误处理兜底. ## 5. 验证 / Validation - 单测 (`-race` 全绿): openai `Transcribe` 线上契约 (路径/鉴权/multipart 字段/流式音频逐字/typed-wins-Extra/零值不上线) + 响应解析分支 (verbose_json/text/非 2xx fail-loud) + 入参校验; `CheckHealth` 四分支 (无路径 no-op / healthy / 200-but-degraded / 不可达); 前置探活挡上传 (离线时音频**未**上线); live discovery model_type 映射; 平台 handler 全链 (真 openai provider 接假 oMLX: 200 透传真值 + provider_instance 不漏给后端 / 502 离线带 pre-flight 错 / 400x4 纪律 / 503 配置禁用 / 400 无能力实例). - **真跑闭环** (对照真值): `transcription_live_test.go` (`//go:build live`, env 闸) 对真 M5Max 走 CheckHealth + Transcribe, 合成语音 "你好, 我想咨询一下某型号的库存情况, 麻烦明天给我答复" 转写**逐字正确**. ## 6. 触发重新评估的条件 / Trigger conditions - 第二个转写后端 (非 OpenAI 兼容线协议) 出现 -> 评估契约字段是否够中性. - ASR 依赖类 flow 落地 -> 定 "谁调转写 / 转写文本落哪 / flow input 契约" 编排 (本 ADR 只管 provider 能力与代理). - 转写结果需持久化审计/回放 -> 并入 INF-4. - 引擎轮次要多模态音频 -> 重开 Provider.Stream BlockAudio 议题. ## 7. 工程量 / Engineering footprint core: 新 `flyto/transcription.go` + `flyto/health.go` + `providers/openai/transcription.go` + `providers/openai/health.go` (+3 test 文件, 1 live); 改 `flyto/provider.go` (ModelInfo 1 字段) + `internal/wire/openai.go` (model_type 解析) + openai `Config` (1 字段). platform: 新 `internal/server/transcriptions.go` (+test); 改 `server.go` (1 路由) + `server_config.go` (ModelSpec 1 字段) + `cmd/common/main.go` (fmlx 工厂 1 参数) + swagger 重生. 零新依赖. ## 8. 修订记录 / Revision history - 2026-07-12 v1 Accepted: 契约 + openai 实现 + 探活 + 模型 spec + 平台代理落地, 真 M5Max 端到端验证. - 2026-07-13 v2: §6 触发条件 "第二个转写后端 (非 OpenAI 兼容线协议) 出现" 被消费 -- 新增 `providers/aliyunasr` (阿里云 DashScope 录音文件识别 paraformer-8k-v2, m5max 的云端备选, PM 2026-07-11/12 调研拍板). **契约中性性评估结论: 契约不动**. 三个线协议差异全部在 provider 内吸收: ① 异步任务 -> Transcribe 内部轮询到 SUCCEEDED 一次性返回 (同步语义保持, 调用方本就按分钟级预算 deadline); ② URL 输入 -> 音频流落临时盘 (要 Content-Length, 走盘不走内存) -> OSS V1 签名 PUT -> 限时签名 GET URL 交任务 -> 尽力删除 (真清理靠 bucket 生命周期); ③ 声道分轨 -> 双 speaker 标签时提交 channel_id=[0,1], 声道 0/1 映射 LeftSpeaker/RightSpeaker, 各声道 sentences 按 begin_time 合并统一时间线, 毫秒转秒. 消费方零差异: 同 multipart 进, 同 verbose_json 形状出, 换 provider_instance 即切换. 契约字段中的 DiarizeBackend/LongAudio/ChunkMinutes/WordTimestamps 对此后端不适用即不发 (契约注释 "provider 不支持的字段就不发" 原则直接覆盖). 平台侧: TypeAliyunASR + factory (实例 key=DashScope key, url=业务空间 API 根; OSS 凭据走部署级 env ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET + FLYTO_ASR_OSS_ENDPOINT/BUCKET, 缺失在 Transcribe fail-loud 而非启动). 不实现 HealthChecker: 云端 7x24, 平台 pre-flight 的 type-assert 未命中即跳过 = 恒在线语义. Stream fail-loud (纯转写 provider). 测试全 fake 后端 (httptest OSS+DashScope), 零真云调用.