重复的两句话
「回声」的私信智能回复上线流式后,客服收到一条奇怪的反馈:用户点了一次“智能回复”,界面上出现了两段一模一样的开头,第二段接在第一段后面,读起来像复读。
排查日志发现,客户端在首帧之后遇到了一次连接超时,自动重试了整个请求。第二次请求从头生成,客户端把新流的文本追加到旧文本后面,于是同一句话出现了两遍。服务端没有重复计费错误,两段文本确实是两次真实生成。
这个事故的关键不是“重试太多”,而是“在错误的位置重试”。首帧之前,用户什么都没看到,重试是安全的;首帧之后,用户已经看到部分文本,透明重试会把同一段话再说一遍。流式的错误处理必须先回答一个问题:现在重试,用户会看到什么?
文中出现的重试率、延迟与金额,除非特别标注为本机测试输出,都是案例假设值。
首帧是重试的分界线
把一次流式请求分成三个阶段。
| 阶段 | 判据 | 用户看到什么 | 能否透明重试 |
|---|---|---|---|
| 首帧前 | 还没有发出 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 的判断顺序是固定的,而且顺序本身就是语义:
- 不可重试的错误直接拒绝,不管在哪个阶段。
- 已完成的流不重试。
- 首帧之后不透明重试,返回已输出位置供续传。
- 首帧之前检查重试上限,未超限则从头重试。
把“不可重试”放在最前面,是因为鉴权失败、内容拦截这类错误重试多少次都一样。把“已完成”放在“首帧后”之前,是因为完成的流不应该被当成失败。顺序错了,测试能发现,但线上可能已经多了一次无意义的重试。
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当错误弹窗。用户主动停止不是故障,界面应该安静收尾。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。