"开口练"的 AI 能力(实时教练提示 + 七段复盘报告)走 DeepSeek,通过 @arkagent/core(一个 ArkTS AI 运行时库)发请求。ADR-005 冻结了几个反直觉的决策:每请求无状态 Runtime、并发只许一个在飞、退后台 cancel+dispose。这篇讲落地层。

1. 单 active coordinator:AI 请求的"单行道"

所有 AI 请求经过一个协调器 SpeakLabAiRequestCoordinator,核心不变式:任何时刻最多一个请求在飞。新请求在旧请求运行中到达,不排队、不抢占,直接拒绝:

// 领域错误工厂:用户看到的是"上一个请求还没结束"
createBusyError()

为什么选 BUSY 而不是排队?排队意味着延迟不可控(教练提示等三轮才回来,语境早过了),抢占意味着复杂的中断语义。BUSY 把并发问题变成产品问题:UI 在请求进行中禁用触发入口,用户视角是"正在生成中,稍候",而不是神秘的等待或错乱的结果。

协调器内部的状态是三重隔离generation(代际,dispose/重置递增)、activeRequestIdactiveSessionId。回调到达时校验三者,任何对不上都是过期世界的消息——B10 ASR 回调隔离的同款思想,这是第三处用了。

状态迁移本身是串行的:transitionChain: Promise<void> 把所有"开始/完成/失败/取消"的转移挂进一条 promise 链,并发触发(比如用户点取消的同时网络错误到达)按到达顺序逐个结算,不会出现两个转移交错把状态机拧成麻花。

2. 凭据门控与可用性同步

协调器持有凭据控制器引用,每次请求前检查:Key 未配置(NOT_CONFIGURED)→ 直接 createNotConfiguredError(),不发请求。可用性(SpeakLabAiAvailability)是从凭据状态推导出来的显式字段,随凭据变化同步(syncAvailabilityFromCredential)——UI 不猜"能不能用",订阅状态即可。

模型 ID 也可注入(setModelId),但默认值兜底、trim 判空——配置项的健壮性在 setter 里解决,下游永远拿到合法值。

3. 鸿蒙流式 HTTP 的真实时序坑

调用层最硬的 bug 出在传输层,写在 SpeakLabHttpTransport 的文件头:

HarmonyOS requestInStream 常见时序:headersReceive 先于 responseCode 返回,dataEnd 也可能早于 requestInStream resolve。上游 HarmonyHttpTransport 会先 onHeaders(0) 并在 dataEnd 时 onComplete,真实 401/400 随后丢失 → EMPTY_OUTPUT。

翻译一下这个坑:鸿蒙的 requestInStream 事件序里,数据流结束(dataEnd)可能比"拿到真实 HTTP 状态码"更早。上游库的默认实现看到 dataEnd 就宣布"请求完成",此时状态码还是占位的 0——于是一个真实的 401(Key 错了)被报告成"空响应",用户看到的是"网络好像有问题"而不是"请检查 API Key"。

修复是一个**释放门(SpeakLabStreamReleaseGate)**重排事件:

权威 status 可用前不 onComplete;status 到达后 onHeaders(真实码) → body → complete。

缓冲流事件,等权威状态码到达再按正确顺序放行。原则:完整性优先于及时性——宁可晚 50ms 报告结果,也不报告一个由时序竞态拼出来的错误结果。

文件头还有一条纪律值得抄:不修改 ArkAgent 源码/HAR。第三方库是固定资产(ADR-005:固定 agent_core.har), bug 在我们的适配层修,不去 fork 库——fork 一时爽,升级火葬场。

4. 错误映射:从传输错误到用户语义

错误链分三层翻译,每层只说自己的语言:

  1. 传输层(HttpTransport):超时、连接失败、HTTP 状态码;

  2. 执行契约层SpeakLabAiExecutionContract):归一成 SpeakLabAiExecutionFailure,带 ErrorRetryability(可重试/不可重试);

  3. 领域层SpeakLabDomainError):错误域 + 错误码 + 重试性,UI 按码映射到用户文案("网络不给力" / "Key 无效" / "服务繁忙稍后再试")。

用户文案不出现在下两层——传输层不知道什么是"API Key",UI 不知道什么是 ECONNRESET。可重试性在契约层就定好(401 不可重试,超时可重试),UI 据此决定要不要显示"重试"按钮。

5. 生命周期的配合

B12 讲了退后台 cancel+dispose、绝不自动重放,这里补调用层视角:每个请求持有 CancellationSignal,cancel 只作废当前 generation(新请求可再成功),dispose 则永久拒绝后续。两者语义不同:cancel 是"这次算了",dispose 是"这个协调器死了"。混用会导致"退一次后台 AI 永久不可用"或"dispose 后幽灵请求复活"——语义清晰的 API 是防呆的第一道墙。

6. 小结

  • 单 active coordinator + BUSY:并发问题产品化,拒绝比排队/抢占简单且诚实。

  • generation/requestId/sessionId 三重隔离 + promise 链串行化迁移。

  • requestInStream 时序坑:dataEnd 可能早于状态码;用释放门等权威 status 再放行。

  • 不改第三方 HAR,bug 在自己的适配层修。

  • 错误三层翻译,可重试性在契约层定型;cancel 与 dispose 语义分明。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐