《互联网产品的 AI 架构·模型接入与能力边界》5.3 流式下的错误与重试语义

从一次首帧后自动重试导致回复重复的事故出发,把流式错误分成首帧前与首帧后两段,给出协议内错误事件与连接断开的分工、可测试的重试决策函数,并用 node --test 验证重试上限、不可重试错误、续传位置与客户端呈现,以及首帧后失败的界面取舍。

重复的两句话

「回声」的私信智能回复上线流式后,客服收到一条奇怪的反馈:用户点了一次“智能回复”,界面上出现了两段一模一样的开头,第二段接在第一段后面,读起来像复读。

排查日志发现,客户端在首帧之后遇到了一次连接超时,自动重试了整个请求。第二次请求从头生成,客户端把新流的文本追加到旧文本后面,于是同一句话出现了两遍。服务端没有重复计费错误,两段文本确实是两次真实生成。

这个事故的关键不是“重试太多”,而是“在错误的位置重试”。首帧之前,用户什么都没看到,重试是安全的;首帧之后,用户已经看到部分文本,透明重试会把同一段话再说一遍。流式的错误处理必须先回答一个问题:现在重试,用户会看到什么?

文中出现的重试率、延迟与金额,除非特别标注为本机测试输出,都是案例假设值。

首帧是重试的分界线

把一次流式请求分成三个阶段。

阶段判据用户看到什么能否透明重试
首帧前还没有发出 start,或 start 后没有任何 delta只有“生成中”可以,从头重试
首帧后已经至少发出一个非空 delta部分文本不可以,只能续传或重新生成
已完成收到 done完整文本不需要

“首帧”在这里指第一段可展示文本,不是协议里的 start 事件。start 只是告诉客户端“请求已被接受”,用户还没有看到内容。如果代理在 start 之后、第一个 delta 之前断连,重试仍然安全,因为界面上的文本没有变化。

这个区分看起来简单,落地时最容易犯的错是用“已经收到的字节数”判断。start、usage、心跳都会增加字节数,但不增加用户可见文本。决策函数应该接收“已输出字符数”,而不是“已接收帧数”。

错误事件与连接断开的分工

流式失败有两种完全不同的表现形式。

第一种是协议内错误事件:连接还在,服务端主动发来一帧 error,里面带着 kind、message、retryable。这种失败是“有结论的失败”,客户端知道发生了什么,也知道服务端认为能不能重试。

第二种是连接断开:没有 done,也没有 error,连接直接没了。客户端只知道“流没有正常结束”,不知道是网络、代理还是上游崩溃。这种失败是“没有结论的失败”,必须由客户端补一个默认判断。

两者不能混为一谈。如果把断连也当成 error 事件,客户端会以为服务端已经给出了 retryable,但实际上这个值是自己猜的;如果把错误事件也当成断连,客户端会丢掉服务端给出的 kind 与 retryAfterMs。

协议内错误事件的可信度高,断连的可信度低。可信度不同,处理策略也应该不同:

  • 错误事件:直接采用事件里的 kind 与 retryable,只在首帧前允许透明重试。
  • 连接断开:归为 unavailable,可重试,但同样受首帧分界线约束。
  • 正常结束:不重试,即使之前出现过瞬时错误。

还有一种边界情况:连接断开发生在 done 之后。客户端可能已经收到完整文本,只是没收到关闭信号。这时不应该重试,因为用户已经看到了完整回复。判断依据是“有没有收到 done”,而不是“连接有没有异常关闭”。

把决策写成函数

下面这段代码把上面的规则收敛成三个函数:阶段判断、失败翻译、重试决策,外加一个收尾分类器。

// file: src/stream/errors.ts
// tier: L1
// verify: node --test test/stream/errors.test.ts

import { normalizeError } from "../core/errors.ts";
import type { StreamEvent } from "../protocol/stream.ts";

/**
 * 流式重试的分界线是“首帧有没有发出去”,不是“已经生成了多少文本”。
 * 首帧前用户什么都没看到,重试是安全的;首帧后重试会把同一段话再说一遍。
 */
export type StreamPhase = "pre-first-frame" | "in-flight" | "completed";

export interface RetryPolicy {
  /** 总尝试次数,包含第一次;默认 2 表示“首次 + 一次重试”。 */
  maxAttempts: number;
}

export const DEFAULT_RETRY_POLICY: RetryPolicy = { maxAttempts: 2 };

/** 与 core/errors 的 AiError 解耦,便于把协议里的 error 事件直接转成决策输入。 */
export interface StreamFailure {
  kind: string;
  message: string;
  retryable: boolean;
}

export interface RetryDecision {
  retry: boolean;
  /** 重试时客户端应从第几个字符之后续接;0 表示从头再来。 */
  resumeFromChars: number;
  reason: string;
}

export function streamPhase(emittedChars: number, completed: boolean): StreamPhase {
  if (completed) {
    return "completed";
  }
  return emittedChars === 0 ? "pre-first-frame" : "in-flight";
}

export function failureFromError(error: unknown): StreamFailure {
  const normalized = normalizeError(error);
  return {
    kind: normalized.kind,
    message: normalized.message,
    retryable: normalized.retryable,
  };
}

export function decideRetry(input: {
  phase: StreamPhase;
  emittedChars: number;
  failure: StreamFailure;
  /** 刚刚失败的这一次是第几次尝试,从 1 开始。 */
  attempt: number;
  policy?: RetryPolicy;
}): RetryDecision {
  const policy = input.policy ?? DEFAULT_RETRY_POLICY;

  if (!input.failure.retryable) {
    return {
      retry: false,
      resumeFromChars: input.emittedChars,
      reason: `${input.failure.kind} 不可重试,重复请求只会重复失败`,
    };
  }

  if (input.phase === "completed") {
    return {
      retry: false,
      resumeFromChars: input.emittedChars,
      reason: "流已经正常结束,没有需要重试的失败",
    };
  }

  if (input.phase === "in-flight") {
    return {
      retry: false,
      resumeFromChars: input.emittedChars,
      reason: "首帧已经发出,透明重试会让用户看到重复文本;只能续传或让用户重新生成",
    };
  }

  if (input.attempt >= policy.maxAttempts) {
    return {
      retry: false,
      resumeFromChars: 0,
      reason: `已达到重试上限 ${policy.maxAttempts},继续重试只会扩大故障面`,
    };
  }

  return {
    retry: true,
    resumeFromChars: 0,
    reason: "首帧之前失败,客户端没有看到任何文本,可以安全重试",
  };
}

/** 三种收尾方式必须分开:正常结束、协议内错误事件、连接被断开。 */
export type TerminationSource = "done" | "error-event" | "connection-drop";

export interface StreamTermination {
  source: TerminationSource;
  emittedChars: number;
  finishReason?: "stop" | "length" | "tool_call" | "content_filter";
  failure?: StreamFailure;
}

export function classifyTermination(events: readonly StreamEvent[]): StreamTermination {
  let emittedChars = 0;
  let done: Extract<StreamEvent, { type: "done" }> | null = null;
  let errorEvent: Extract<StreamEvent, { type: "error" }> | null = null;

  for (const event of events) {
    if (event.type === "delta") {
      emittedChars += event.text.length;
    } else if (event.type === "done") {
      done = event;
    } else if (event.type === "error") {
      errorEvent = event;
    }
  }

  if (done !== null) {
    return { source: "done", emittedChars, finishReason: done.finishReason };
  }

  if (errorEvent !== null) {
    return {
      source: "error-event",
      emittedChars,
      failure: {
        kind: errorEvent.kind,
        message: errorEvent.message,
        retryable: errorEvent.retryable,
      },
    };
  }

  return {
    source: "connection-drop",
    emittedChars,
    failure: {
      kind: "unavailable",
      message: "连接在结束帧之前断开",
      retryable: true,
    },
  };
}

/** 把收尾分类直接接到重试决策上,避免调用方自己拼 phase。 */
export function retryForTermination(
  termination: StreamTermination,
  attempt: number,
  policy: RetryPolicy = DEFAULT_RETRY_POLICY,
): RetryDecision {
  if (termination.source === "done") {
    return {
      retry: false,
      resumeFromChars: termination.emittedChars,
      reason: "流已经正常结束,没有需要重试的失败",
    };
  }

  const failure = termination.failure ?? {
    kind: "provider",
    message: "unknown stream failure",
    retryable: true,
  };

  return decideRetry({
    phase: streamPhase(termination.emittedChars, false),
    emittedChars: termination.emittedChars,
    failure,
    attempt,
    policy,
  });
}

streamPhase 只接收两个输入:已输出字符数和是否完成。它不关心连接状态、帧数或耗时。这样测试可以直接构造边界,不需要模拟网络。

failureFromError 把核心错误类型翻译成协议友好的结构。AiError 已经带了 retryable,这里只是换一个形状,方便与 StreamEvent 里的 error 事件对齐。协议事件和内部异常最终都变成同一个 StreamFailure,决策函数只认这一种输入。

decideRetry 的判断顺序是固定的,而且顺序本身就是语义:

  1. 不可重试的错误直接拒绝,不管在哪个阶段。
  2. 已完成的流不重试。
  3. 首帧之后不透明重试,返回已输出位置供续传。
  4. 首帧之前检查重试上限,未超限则从头重试。

把“不可重试”放在最前面,是因为鉴权失败、内容拦截这类错误重试多少次都一样。把“已完成”放在“首帧后”之前,是因为完成的流不应该被当成失败。顺序错了,测试能发现,但线上可能已经多了一次无意义的重试。

classifyTermination 把事件序列分成 done、error-event、connection-drop 三种收尾。它只数 delta 的字符数,不数 start、usage 或心跳。连接断开的默认失败被归为 unavailable,retryable 为真,但最终是否重试仍由 decideRetry 根据阶段决定。

retryForTermination 是两者的粘合层。调用方拿到一串事件后,不需要自己判断“这是错误事件还是断连”,也不需要自己算阶段,直接把收尾结果和尝试次数传进去。

测试与真实输出

测试覆盖十个场景:阶段划分、错误翻译、首帧前重试、首帧后拒绝、不可重试错误、重试上限、正常结束、错误事件、连接断开、两种收尾的对比。执行:

node --test test/stream/errors.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 首帧前后由已输出字符数划分 (1.005125ms)
✔ 核心错误被翻译成可重试标记 (0.125791ms)
✔ 首帧前可重试,且从头开始 (0.149125ms)
✔ 首帧后不透明重试,保留已输出位置 (0.139917ms)
✔ 不可重试错误即使发生在首帧前也不重试 (0.082625ms)
✔ 达到重试上限后停止 (0.069041ms)
✔ 正常结束的流不触发重试 (0.133875ms)
✔ 协议内错误事件保留原始 kind 与 retryable (0.438ms)
✔ 连接断开被归为 unavailable,与错误事件分工明确 (0.095959ms)
✔ 错误事件在首帧前失败时可以重试,断连在首帧前也可以重试 (0.124334ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 195.000417

有两个测试值得单独看。

“首帧后不透明重试,保留已输出位置”断言 resumeFromChars 等于已输出字符数。这个值不是让客户端自动续传,而是告诉上层“如果要继续,必须从第 18 个字符之后接”。真正的续传需要提供方支持按位置继续生成,本节只负责把位置算出来。

“错误事件在首帧前失败时可以重试,断连在首帧前也可以重试”把两种收尾放在一起对比。错误事件带 kind: "timeout",断连被归为 unavailable,两者的 retryable 都是真,阶段都是首帧前,所以决策一致。这个测试防止有人把断连误判成不可重试,导致网络抖动时用户只能手动重来。

退避与重试预算

允许重试不等于应该立刻重试。首帧前的失败如果是限流或上游瞬时不可用,立即重试往往撞上同一堵墙。常见的做法是指数退避加抖动:

尝试案例假设延迟说明
第 1 次立即首次请求
第 2 次200–400 ms退避加随机抖动
第 3 次800–1,600 ms仍受总预算约束

总预算比单次退避更重要。如果用户端到端预算只有 5 秒,重试三次已经吃掉 2 秒,剩下的时间可能不够模型生成首字。重试决策函数接收 maxAttempts,但调用方还要把 deadlineMs 一起考虑:剩余预算不足以完成一次首帧时,就不应该再重试。

服务端返回 retryAfterMs 时优先采用它,不要用自己的退避覆盖。限流场景下,服务端的等待建议通常比客户端猜测更准确。

一次自动重试把文本说两遍

回到开头的事故。客户端的重试逻辑写在一个通用的 HTTP 拦截器里:只要请求失败且错误可重试,就重新发起。这个逻辑对一次性请求没问题,对已经产生副作用的流式请求是灾难。

修复分三步。

第一步,把“是否已经向用户展示文本”作为重试决策的输入。客户端记录第一个非空 delta 的到达时刻,之后的任何失败都不再走透明重试。

第二步,把流式请求从通用拦截器里移出来,单独实现重试。通用拦截器只知道 HTTP 状态,不知道流式阶段;让它处理流式,等于让它猜。

第三步,给首帧后的失败提供产品出口:显示“生成中断”与“重新生成”按钮,保留已生成文本,用户可以复制或忽略。重新生成会创建新的请求,而不是在原文本后面追加。

修复后,同类事故的重复文本消失,但“生成中断”的提示变多。这是正确的取舍:宁可让用户知道中断,也不要在背后偷偷重试,把界面弄乱。

客户端应该怎么呈现

错误语义最终要落到界面。三种收尾对应三种呈现:

收尾界面主按钮是否自动重试
done完整文本,光标消失复制 / 重新生成否
首帧前失败错误提示,无文本重试是,受预算约束
首帧后失败半截文本 + 中断标记重新生成否

首帧后失败时,不要清空已经显示的文本。用户可能已经读了一部分,清空会让界面看起来像“什么都没发生”,反而更困惑。保留文本、加一条中断提示,是更诚实的做法。

也不要把 cancelled 显示成错误。用户主动停止生成不是故障,界面应该安静地停在当前文本,最多提供一个“继续生成”的入口。

小结

流式重试的分界线是首帧,不是字节数,也不是 start 事件。首帧前重试安全,首帧后透明重试会导致重复文本,只能续传或让用户重新生成。

协议内错误事件与连接断开必须分开处理:前者带可信的 kind 与 retryable,后者需要客户端补默认判断。classifyTermination 把三种收尾分开,decideRetry 把阶段、失败类型与重试上限收敛成一个可测试的决策。

下一章进入提示工程的产品化视角:提示不是一段字符串,而是需要版本、评测与回滚的产物。

阅读导航:上一节:5.2 背压、取消与半截响应 · 下一节:6.1 提示是产物,不是字符串 。

常见错误与边界

  • 用已接收字节数判断首帧。start、usage、心跳都会增加字节,但用户没有看到文本。
  • 把 start 事件当成首帧。首帧指第一段可展示文本,不是请求被接受的信号。
  • 在通用 HTTP 拦截器里重试流式请求。拦截器不知道流式阶段,容易在首帧后重放。
  • 把连接断开当成不可重试。断连应归为 unavailable,首帧前仍可重试。
  • 把错误事件当成断连。会丢掉服务端给出的 kind、retryable 与 retryAfterMs。
  • 收到 done 后仍因连接异常而重试。用户已经看到完整文本,重试只会制造重复。
  • 首帧后失败时清空界面。保留半截文本并标记中断,比假装什么都没发生更诚实。
  • 忽略端到端预算。重试次数没超限,但剩余时间已经不够生成首字,重试仍然会失败。
  • 把 cancelled 当错误弹窗。用户主动停止不是故障,界面应该安静收尾。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查