Appearance
第4章 翻译局如何接通模型——Provider
团队决定把模型服务从 A 厂商换成 B 厂商。立项的时候,产品经理拍着胸脯说:“接口不都差不多嘛,改一行配置就行。”这个任务落到了小a头上。
他满口答应,转身就去翻文档。三天后,他顶着一对黑眼圈出现在老z面前,面前摊着两张 API 文档。
“我错了。”他说,“A 厂的消息是个数组,B 厂要单独传 system;A 厂的流式是 data: 一行行,B 厂是 event: 加 data:;连鉴权都不一样,一个用 Bearer,一个用 x-api-key。这哪是改一行,这是重写一遍。”
“你确定要换的只是厂商,不是业务逻辑?”老z问。
小a的声音越来越小:“接口不是都一样吗?都是发消息、收回复……”
“问题就出在‘都差不多’。”老z说,“等你真把两边接上,会发现认证、模型名、流式事件、工具格式、限流和错误码全不一样。本章要解决的,就是怎么把差异挡住,又不假装差异不存在。”
“可这差异的根在哪?为什么不统一一下?”小a追问。
“根在于每家厂商是在自己的协议、模型架构和产品演进里长出来的。”老z说,“A 厂早期为聊天场景设计,所以消息是数组、role 字段挂在每条上;B 厂从补全场景切入,system 是单独字段。流式事件、错误码、限流头都是各自历史的产物,没有哪家是按一个跨厂商标准从零设计的。指望厂商收敛到一个统一协议,等于指望历史倒退回写它。”
本章讨论 Provider 抽象,不比较具体产品优劣。
先把名字对齐
| 中文术语 | 英文全称或缩写 | 本书工作定义 | 不等于什么 |
|---|---|---|---|
| 提供方 | Provider | 认证、模型目录和请求行为的服务边界 | 单一 HTTP SDK |
| 适配器 | adapter | 统一契约与厂商协议之间的转换实现 | 抹平所有能力差异 |
| 统一消息 | unified message | 角色、内容块、调用与结果的业务表示 | 所有原生字段的并集 |
| 流式传输 | streaming | 有生命周期的增量事件 | 只不断追加字符串 |
| 幂等性 | idempotency | 重复操作不额外改变目标状态的性质 | “重试通常没问题” |
4.1 抽象的对象是差异
小a把两份文档摊开,发现差异比他想的还要多——连“模型叫什么名字”都不一样。他问老z:“那 Provider 层是不是要把这些全抹平?”
“恰恰相反。”老z说,“不同模型服务在认证、模型标识、请求字段、流式事件、工具格式、速率限制和错误编码上存在差异。Provider 层的职责不是假装这些差异不存在,而是将业务所需的共同语义定义成稳定接口,并把不可统一的能力明确暴露出来。”
“等等,哪些差异真的‘不可统一’?”小a问。
“和模型能力本身绑死的那部分。”老z说,“比如 A 厂支持图片输入、B 厂不支持——这不是字段格式问题,是模型架构问题,Provider 层再聪明也变不出来。但‘图片怎么传’可以统一成同一个字段,只是当 B 厂收到这个字段时,得返回‘不支持’而不是悄悄丢掉。字段格式是表层,能力差异是底层,Provider 只能抹表层,不能填底层。”
比喻:翻译局
翻译员不假装各国语言一样,而是把你要表达的意思准确翻过去,翻不了的会告诉你“这句没法直译”。Provider 之于厂商,就是翻译局之于各国。
ts
// 示意:业务层依赖自己的稳定请求与事件类型
type ModelClient = {
stream(request: UnifiedRequest, signal?: AbortSignal): AsyncIterable<UnifiedEvent>
}上例是示意,不是任何 SDK 的真实接口。
4.2 统一消息与能力协商
小a决定先给两家定义一套统一消息格式。他列了半天字段,拿去给老z过目。老z看了一遍,问:“图片呢?A 厂支持图片,B 厂不支持,你打算怎么办?”
小a愣住了。
“统一消息应表达角色、文本与内容块、工具调用、工具结果和必要的关联 ID。”老z说,“图片、音频、推理控制和结构化输出并非所有服务都支持;接口需要能声明能力,而不是悄悄丢弃内容。”
“那统一接口是不是意味着,换服务永远不用改代码?”小a问。
“不是。抽象能降低迁移成本,但语义不同仍会露出来。”老z说,“例如一个服务不支持图片或并行工具调用,调用方必须选择降级、拒绝或改用兼容模型。把‘不支持’变成可见错误,比生成一个看似成功但缺内容的请求更可靠。”
“那降级和拒绝,什么时候选哪个?”小a追问。
“看任务能不能接受降级。”老z说,“如果任务是‘描述这张图’,图片能力不支持时降级成‘只读 alt 文本’也许够用;如果任务是‘识别图中的缺陷’,降级就等于任务失败,不如直接拒绝并提示换模型。降级要明示——调用方得知道‘这次返回的是降级结果’,而不是被蒙在鼓里以为拿到了完整能力。”
“那能力协商具体怎么落地?”
“通常在初始化时声明和确认。”老z说,“Provider 在建立连接或首次请求时,查询或已知目标服务支持哪些能力(图片、并行调用、结构化输出等),把它们记录成一份能力清单。上层代码根据这份清单决定发什么请求。能力清单是运行时事实,不是文档里的承诺——文档说支持,不代表当前账号、当前版本、当前配额下都支持。”
“那能力清单放哪一层?”小a问。
“放 Provider 自己。”老z说,“Provider 在初始化时把能力清单缓存一份,上层读这份缓存决定怎么发请求。但能力清单可能过期——某次配额耗尽、模型下线,运行时事实就变了。更稳的做法是:关键能力在请求前再确认一次,而不是全程只信启动时的快照。”
| 能力差异类型 | 抹得平吗 | 处理方式 |
|---|---|---|
| 字段命名(如 system 在数组还是单独字段) | 是 | adapter 翻译 |
| 错误码命名(401 vs invalid_api_key) | 是 | adapter 映射 |
| 模型支持图片输入 | 否 | 能力清单声明,不支持时报错 |
| 并行工具调用上限 | 否 | 能力清单声明,超限时报错 |
| 限流策略(按 token 还是按 RPM) | 部分 | adapter 映射可读字段,策略本身仍各厂不同 |
“那能力清单里没列的能力呢?比如文档里根本没写支不支持。”小a问。
“没写的要按‘不支持’处理,而不是按‘默认支持’。”老z说,“厂商的能力文档往往不全,清单上没出现的能力,最安全的假设是不支持——请求里带上它,得到的就是报错或悄悄丢弃。未声明能力按不支持,比按支持更安全:前者顶多多绕一次确认,后者可能把请求送出去才发现结果残缺。等实测确认支持了,再把能力记进清单。”
4.3 流式传输要保留边界
迁移到一半,小a在联调流式输出时又卡住了。他发现 B 厂的工具参数是分好几段吐出来的,前两段连不成一个完整的 JSON。
“流式就是一直收到字符串吗?”他问老z。
“没这么简单。”老z说,“流式响应不是‘不断收到字符串’这么简单。上层至少要区分文本增量、工具参数增量、用量、完成和错误事件;工具参数在流中可能暂时不是完整 JSON,只有完成事件后才能作为可执行输入。”
“为什么工具参数不能边收边执行?”小a追问。
“因为它可能跨多段才完整。”老z说,“B 厂一次工具调用的参数可能切成 {"path":"src/、core/ut、ils.ts"} 三段到达,第一段拿到时连字符串都没闭合,没法解析。如果这时候就尝试执行,你会得到一个语法错误或一个被截断的路径,去读一个根本不存在的文件。文本增量是给人看的,截断了也能显示;工具参数是给机器执行的,截断了就是个错误输入。”
text
请求 → 认证与发送 → 提供方事件
↓
Provider 解析/映射
↓
文本增量 | 工具增量 | 用量 | 完成 | 错误“那文本增量能不能边收边显示?”小a追问。
“能,而且通常应该。”老z说,“文本增量的特点就是‘可以立即展示,不需要等完整’——用户看到的逐字效果,就是文本增量实时追加的结果。但工具参数不一样:它是结构化的,半截 JSON 没法解析、没法校验、更没法执行。文本增量是展示用的,工具增量是累积用的——两者处理方式根本不同。”
“那用量和完成事件呢?”
“用量通常在结束时才到,包含这一轮的 token 计数。”老z说,“完成事件是‘这条流结束了’的信号,它封存最终结果、附上停止原因。没有完成事件,你手里的都只是中间状态——即使已经收到了大量文本,也不能当作一条完整的模型响应来审计或持久化。”
“那用量事件没到,流就断了呢?”小a追问。
“那就没有这轮的用量——记录里得留空,而不是补一个估算值。”老z说,“断流时模型可能已经吐了一部分,但服务端没来得及发用量事件,这笔账只能按已收到的实际部分算。缺了用量,比错了用量好:估算值进成本报表,会和真实账单对不上,排查成本统计时反而更难。”
“那用户中途不想等了怎么办?”小a问。
“应用应支持取消:调用方取消时传播 AbortSignal 或等价机制;Provider 负责关闭传输,并把已得到的部分结果标为中断,而不是正常完成。”
“那取消之后,已经吐出来的工具参数怎么办?”小a问。
“丢弃,不执行。”老z说,“取消语义是‘这一轮作废’——即使工具参数已经收齐了,只要还没等到完成事件、或取消信号先到,都不得交给执行器。最坑的是取消信号和完成事件几乎同时到达:这时候必须有一个明确的优先级,通常是取消优先,把这条流标成 aborted 而不是 completed,避免半成品参数被当作完整调用跑出去。”
“那流式事件会不会乱序?完成事件先到,文本增量还在路上怎么办?”小a问。
“事件顺序也是一种契约,只是各家签的不完全一样。”老z说,“通常增量事件在完成事件之前到达,但不同厂商的批次划分差别很大——有的把一段文本拆成很多小片逐片送达,有的一次就送一大块。adapter 不能假设‘一次增量恰好是一个字’,也不能假设增量必然按固定粒度到达,界面更不能按增量次数去算进度。完成、失败和中断都是终态:收到任何一个,这条流就结束,之后不应再期待任何增量——如果界面还在等‘最后一个字’,那是应用状态机没把终态当终态,不是模型还没吐完。”
4.4 鉴权、超时与重试
夜里十一点,小a在日志里发现 B 厂把报错信息原样打了出来,里面赫然有一串密钥。他吓出一身冷汗,连夜删了日志,第二天一早去问老z。
“密钥这类东西放在哪?”他问。
“密钥、OAuth 凭据和端点配置属于 Provider 或其运行环境的责任,不能拼进用户消息或日志。”老z说,“应为每次请求设置超时,区分可重试的瞬时故障(例如部分网络错误、服务端暂时不可用)和不可重试的配置、鉴权、参数错误。”
“那日志里到底能记什么?”小a问。
“记模型名、端点主机名、请求摘要、响应状态码和耗时——这些足够排查问题,又不泄露凭据。”老z说,“绝对不要原样打印请求头和原始响应体,因为错误信息里可能回显你传的鉴权字段。日志要先过一道脱敏,把 Authorization、x-api-key 这类字段替换成占位符,再落盘。否则你只是把一次本地排查,变成一次更大的泄露。”
| 故障类型 | 通常是否重试 | 理由 |
|---|---|---|
| 网络抖动、连接重置 | 是(有限退避) | 瞬时,重发大概率恢复 |
| 服务端 5xx 临时不可用 | 是(有限退避) | 可能正在重启 |
| 429 限流 | 是(读 Retry-After) | 等待后通常恢复 |
| 401 鉴权失败 | 否 | 重发只会重复失败 |
| 400 参数错误 | 否 | 输入错了,重发不解决问题 |
| 404 模型名无效 | 否 | 配置错,重发无用 |
“那‘请求失败就重发’总没错吧?”小a想当然地说。
“这里有个常被忽略的边界。”老z的语气认真起来,“**生成请求通常不是免费也不天然幂等。**即使输入相同,可能产生不同输出并产生额外用量。若请求会触发外部动作,必须由上层用请求 ID、审批或幂等键约束动作,**不能把‘重试模型请求’当作安全重放。”
“那‘幂等’到底谁来保证?”小a追问。
“不是 Provider,是调用方。”老z说,“Provider 只负责把请求送过去、把响应带回来;它不知道这一次生成会不会创建工单、写文件、发邮件。幂等键必须挂在会触发副作用的执行边界上——比如调用工具时附带一个请求 ID,工具端检查这个 ID 是否已执行过。Provider 的重试只是‘再问一次模型’,模型可能给出不同答案,这一次答案再去触发动作,副作用可能就重复了。”
“那错误分类呢?两家都返回 429,能当成同一类错处理吗?”小a追问。
“不能。状态码只能当入口,分类要按语义。”老z说,“同一家厂商的 429 都可能是两种意思:一种是‘请求太频繁,歇一会儿就好’,另一种是‘配额已耗尽,需要人工处理’——前者重试有效,后者重试只会越等越久。反过来,另一家厂商可能用完全不同的状态码表达同类错误,也可能用同一个状态码对应不同阶段的问题。分类错误比重试错误更隐蔽:它不会当场失败,而是让整条重试策略安静地往错误方向使劲,从日志里看不出异常,只有成本在涨。”
| 只看状态码 | 实际语义 | 正确分类 |
|---|---|---|
| 429 → 按限流重试 | 配额已耗尽 | 不可重试,转人工或等配额恢复 |
| 400 → 按参数修正 | 模型名已下线 | 配置错误,查配置而不是改参数 |
| 401 → 按凭据重发 | 凭据已过期 | 刷新凭据,不是重发同一份 |
要点
重试只挑瞬时故障。生成请求重发 = 重新生成 = 再扣钱,还可能重复触发外部动作。
4.5 设计收益与代价
迁移终于跑通了,小a瘫在椅子上。他算了一笔账:为了这次迁移,他写了四百多行适配代码,还没算测试。他问老z:“这套抽象到底值不值?”
“Provider 抽象带来集中治理、统一观测和较低的切换成本;代价是类型变宽、最低共同能力限制,以及为保留高级能力而出现的扩展字段。”老z说,“建议先定义自己真正要用的语义,再添加适配器;过早追求覆盖所有厂商,常会把业务层重新变成厂商条件分支。翻译局很好,但只为两家厂商就养一支翻译团,不划算。”
“那‘类型变宽’具体指什么?”小a问。
“指统一消息的字段,必须能容纳所有厂商的并集。”老z说,“A 厂消息有 role,B 厂消息有 role 加 name,统一消息就得带上 name 这个可选字段——即使 A 厂永远用不到。字段越多,类型越宽,调用方就越难一眼看出哪些字段在当前厂商下真正生效。类型变宽的代价,是边界变模糊。”
“那扩展字段怎么加,才不会让统一接口又退化成厂商分支?”小a追问。
“靠明确的命名空间和可见性约定。”老z说,“统一字段是所有厂商都有的语义;厂商特有字段放进 provider_extra 这类带前缀的命名空间,调用方必须显式取它,不能误以为它是通用字段。判断标准是:删掉所有 provider_extra 字段后,业务逻辑应该还能跨厂商跑通——如果删了就崩,说明你把厂商特有能力当成通用能力在用了。”
| 设计代价 | 具体表现 | 应对 |
|---|---|---|
| 类型变宽 | 字段越来越多,可空字段占大半 | 区分核心字段与扩展命名空间 |
| 最低共同能力 | 为了兼容最弱的厂商,砍掉高级能力 | 用扩展字段保留高级能力,按能力清单启用 |
| 适配器臃肿 | 每家厂商一份转换代码 | 只接真正用到的厂商,别预先铺路 |
| 测试矩阵爆炸 | N 个厂商 × M 个能力 | 优先测核心路径,扩展能力按需验证 |
“那测试矩阵爆炸,有没有具体的例子?”小a问。
“有,而且几乎每家迁移都会撞上。”老z说,“三家厂商、五种能力,全组合就是十五份用例,其中大多数组合业务里根本不会同时用到。测试矩阵不是全组合,而是‘实际会发生’的组合——按真实路径圈出核心组合优先测,扩展能力只在启用时补测。全组合听着严谨,实际是把预算浪费在从不发生的路径上。”
4.6 适配层的事件契约
第二个星期,小a开始处理 B 厂的流式事件解析。他发现最容易出错的地方,就是把“还在路上的事件”和“已经完成的事件”混在一起处理。
“统一接口最容易在流式场景失真。”老z说,“建议把内部事件设计成有生命周期的记录,而不只是字符串:text_delta 只追加展示;tool_call_delta 只能累积参数;usage 用于观测;completed 才封存结果;failed 和 aborted 则终止但保留已收到部分。”
text
open → text_delta* / tool_call_delta* → completed
↘ failed | aborted“重试放在哪一层?”小a问。
“重试也应位于明确层级。”老z说,“连接尚未建立、临时服务失败或明确的限流信号,可能适合有限退避;鉴权失败、无效模型名和参数校验失败通常应立即反馈。一次生成即使没有外部副作用,也可能产生额外用量和不同文本;若后续会据其触发动作,调用方仍需用请求 ID、执行记录或审批保证不会重复生效。”
“那连接中途断了又重连,收到的增量会不会重复?”小a追问。
“可能,所以增量消费要能容忍重复。”老z说,“连接中断后重连,有的服务从头补发,有的从断点续传——无论哪种,adapter 拿到的增量都可能和断前重叠。文本增量重复了,界面顶多多显示一两个字,无伤大雅;工具参数增量重复了,拼接时就多出半个字段,整段 JSON 直接解析失败。增量消费按类型分开:文本增量直接展示、容忍重复;工具参数等收齐后整体校验,解析失败就按错误事件处理——逐段判重本身也是要维护的状态,别轻易背这个包袱。”
“展示层和解析层的容忍度不一样?”小a确认。
“对,这是两条线的分岔。”老z说,“展示线追求实时,允许重复、允许半截;解析线追求完整,宁可等完成事件,也不要拿半成品去赌。把两线分开,重复和乱序才不会同时伤到两条线。”
示意: 收到
{"path":"src/时,适配层可以显示“正在形成工具参数”,但不得解析后执行;只有调用 ID、工具名和完整参数都完成并通过校验,才可交给执行器。不同 API 的事件名字和字段并不统一,契约应以目标 Provider 的官方文档为准。
4.7 把请求生命周期写成状态
迁移收尾那天,小a把整套调用流程画成了一张状态图,贴在工位旁边,问老z:“你看这样对不对?”
“可以,而且应该把它当成状态机来写,而不是一团 if。”老z看完说:
text
准备统一消息 → adapter 转换请求 → 发送
→ rate limit:读取服务提示或退避策略,限次重试
→ timeout:取消底层请求,标为未完成
→ streaming:累积 text/tool delta,不执行半成品参数
→ completed:封存最终消息、usage、stop reason
→ failed/aborted:保留部分结果与可诊断错误“这些状态能跨厂商通用吗?”小a问。
“不能。rate limit、timeout、错误对象和最大等待时间没有跨提供方统一语义,必须由 adapter 映射。”老z说,“正常路径可以有限重试尚未产生副作用的临时请求;401、无效模型名或参数错误通常只会重复失败;客户端超时也不证明远端未接收请求,重发可能产生第二次生成和用量。若生成结果会创建工单、写文件或发消息,幂等键必须由执行边界实现,而非交给 retry 次数解决。”
“那重试次数和退避间隔,有什么讲究?”小a问。
“没有跨厂商的通用值,只有要遵守的原则。”老z说,“重试次数要有限,退避要递增——第一次等短一点,后面逐次拉长,而不是每次等一样长,更不是无限重试。厂商文档里给了重试头(比如 Retry-After)就按它的值来,没给就用保守的递增退避。重试的窗口要小于一次故障能持续的时间,超过上限直接转失败或转人工,别让一次瞬时故障变成十分钟的重试循环。”
4.8 迁移不是改配置,是重验契约
“迁移走完,你有没有觉得哪里和最初设想的‘改一行配置’不一样?”老z问。
“完全不一样。”小a苦笑,“最初以为改个 base URL 就完事,实际是把认证、消息格式、流事件、错误码全重验了一遍。”
“这就是迁移的真实形态。”老z说,“Provider 抽象降低的是重复实现的成本,不是核对契约的成本。换一家厂商,下面这些都要重新核对一遍,一条都不能凭印象跳过:”
| 核对项 | 要确认什么 | 跳过的后果 |
|---|---|---|
| 鉴权方式 | Bearer?API key?OAuth? | 请求全部 401,日志泄露密钥 |
| 消息角色 | system 单独传还是并进数组? | 角色语义错位,规则失效 |
| 流式事件 | 事件名、字段、完成标记 | 半截参数被当完整调用执行 |
| 停止原因 | 枚举值、语义是否一致 | 截断被当成正常完成 |
| 工具格式 | 调用请求与结果的字段名 | 结果关联不上请求 |
| 限流与错误码 | 429 的 Retry-After、5xx 语义 | 重试策略失效或误重试 |
| 用量与计费 | token 字段名、计费单位 | 成本统计失真 |
“这七项里,哪项最容易埋雷?”小a问。
“停止原因。”老z说,“两家厂商的 stop_reason 值可能不同——A 厂用 stop,B 厂用 end_turn;含义相同但值不同,adapter 必须映射。如果漏了映射,下游循环会把‘正常结束’误判成‘工具调用’或‘截断’,整个 Agent 的行为都会错位。这种错误最难排查:模型没报错、调用没失败,只是停止语义对不上,任务在中间悄悄停住。”
“那迁移完怎么验证没埋雷?”
“用和旧厂商同一组测试用例各跑一遍,对比行为。”老z说,“不要求输出逐字相同,但要求停止原因、工具调用数、错误分类这些结构性结果一致。迁移的验收标准是‘同一份测试在新厂商下通过’,不是‘新厂商看起来能用’。”
4.9 让一次请求可被排障
“这些天排障最大的感受,是很多问题第一眼根本看不出是哪一层的。”小a说。
“所以 Provider 层要做的最后一件事,是让每次请求留下可排障的记录。”老z说,“一次模型请求从发起到结束,至少有四个时刻值得记录:请求发出时、首字节到达时、流结束时、错误发生时。四个时刻各记什么,直接决定出事时你能往回查多远。”
| 记录时刻 | 记什么 | 能回答什么问题 |
|---|---|---|
| 请求发出 | 模型名、输入 token 估算、请求 ID | 这次请求是谁发起的、花了什么 |
| 首字节到达 | 首 token 延迟(TTFT) | 是不是卡在网络或服务端排队 |
| 流结束 | 总 token、停止原因、耗时 | 输出是否被截断、是否正常结束 |
| 错误发生 | 错误码、可脱敏的消息、上下文 | 是哪一类失败、该不该重试 |
“这些记录和前面说的脱敏怎么配合?”
“记录前先脱敏,脱敏后再落盘。”老z说,“模型名、端点、token 数、状态码这些可以完整记;请求头、原始响应体、鉴权字段这些必须替换或截断。一条好的日志,是能让你复现‘当时发生了什么’而不泄露‘当时带了什么密钥’。”
“那这些记录存在哪?”
“按排障的需求分层。”老z说,“结构化字段(模型名、token、耗时)进指标或结构化日志,方便聚合查询;原始事件流进带保留期的详细日志,方便单条回溯。两层分开存,日常看指标,出事翻明细。”
“那排障时,怎么用这些记录定位问题?”小a追问。
“按‘证据在谁手里’分。”老z说,“如果首字节迟迟不来,TTFT 异常——问题在网络或服务端排队,查网络层和服务状态;如果流中途断了、已收到部分文本——问题在连接层,查断线原因;如果流完整结束但停止原因不对——问题在适配层或调用方的停止语义处理。每类问题的证据位置不同,按证据落点去查,而不是从头到尾猜。”
“那这套记录,本身会不会也是开销?”小a问。
“会,而且是持续的。”老z说,“四个时刻全量落盘,存储和查询都要花钱;但省掉关键时刻,出事时又拿不出证据。通常的取舍是:结构化字段(模型名、耗时、状态码)全记,成本很低;原始事件流设保留期,过期自动清理。记录是有生命周期的数据,不是永久档案——不设保留期,日志迟早把存储撑爆,届时只能整体删掉,排障能力反而被一次清空。”
“还有没有最后一句忠告?”
“有。”老z说,“Provider 是挡在厂商差异前面的一层,但它不是万能胶——它降低的是耦合,不是复杂度。每次抽象都引入它自己的规则:错误怎么分类、重试怎么退避、日志怎么脱敏。这些规则和厂商的差异一样需要维护,忘了维护,抽象本身就会变成新的排障难题。”
小结
Provider 解决的核心问题是:上层应用不应该关心背后是哪一家模型服务。它做两件事——把统一格式的请求翻译成某家厂商能理解的服务请求,再把厂商返回的流式响应翻译回统一的事件序列。这层翻译封装了认证、模型目录和请求行为,所以上层永远面对同一套接口,换厂商只换 Provider。流式传输不是一次性返回全部结果,而是有生命周期的事件流:开始、增量、结束,每一段都有自己的语义。超时和重试也是 Provider 的职责,但它们是显式控制——超时取消未完成的请求,重试只针对明确的瞬时故障且有次数上限,不是无限重试到成功。
但 Provider 能减少耦合,不能消灭能力差异。换一家厂商,能力协商的范围、错误码的语义、限流的策略都可能不同,这些差异必须显式处理,适配器可能在转换中丢失厂商专有的信息。这里还有两个容易踩的坑:一是超时和重试的错误分类必须一致,如果一处把网络错误当成功、另一处当失败,整条链路的失败语义就会漂移;二是幂等性是重试安全的前提,如果一个操作执行两次会重复扣款或重复发邮件,那盲目重试就会制造事故。
动手核验
为一个 Provider 接口写一个假实现,按顺序发出文本增量、一个未完成的工具参数、完成事件和错误事件。确认 UI 不会把半个 JSON 当作调用参数;再取消一次请求,确认业务层得到的是“中断”而非“成功”。