Error Messages When the Model Fails
Error Messages When the Model Fails
当模型失败时,错误信息该如何呈现
“Something went wrong. Please try again.” is correct for about a third of AI failures and actively harmful for the rest, because for the rest, trying again cannot possibly help and you have just told the user to spend money finding that out. Everything that can go wrong Errors arrive from at least four layers, and the user-facing consequences differ enough that collapsing them into one message destroys the only information you had. “出错了,请重试。”这句话对于约三分之一的 AI 故障是正确的,但对于其余的情况则是有害的。因为在那些情况下,重试根本无济于事,而你却让用户在尝试中白白浪费金钱。所有可能出错的地方,错误至少来自四个层面,且对用户造成的影响各不相同。将它们合并为一条信息,会抹杀掉你原本拥有的唯一有效信息。
Failure Description
故障描述
- Transport: Connection dropped, DNS, TLS, the stream died mid-token. Retryable, usually transient, and the user did nothing wrong. This is the only class where ‘try again’ is straightforwardly true. 传输层: 连接中断、DNS、TLS 问题或流在 Token 生成中途断开。通常是暂时的,可重试,且用户没有做错任何事。这是唯一一种“重试”完全正确的情况。
- Rate limited (429): Yours or the provider’s capacity, not the request. Retryable but only after a wait, and the wait is often stated in a header. Telling the user to retry immediately guarantees a second 429. 速率限制 (429): 是你或服务商的容量问题,而非请求本身的问题。可重试,但必须等待,且等待时间通常在 Header 中注明。直接告诉用户立即重试只会导致第二次 429 错误。
- Provider 5xx / overloaded: Retryable with backoff, and the single best case for automatic failover to another provider rather than for any message at all. 服务商 5xx / 过载: 可通过退避策略重试。这是自动切换到其他服务商的最佳场景,甚至根本不需要向用户显示任何错误信息。
- Timeout: Ambiguous by construction: the request may have completed on the provider’s side and been billed. Retrying may duplicate a side effect, which is why idempotency matters more here than anywhere. 超时: 本质上具有歧义:请求可能已在服务商侧完成并扣费。重试可能会导致副作用重复,这就是为什么幂等性在这里比任何地方都重要。
- Context length exceeded: Deterministic. Retrying the identical request fails identically. The only fix is fewer tokens, and the interface knows that — so the message should offer the fix, not the retry. 上下文长度超限: 确定性错误。重试相同的请求只会得到相同的结果。唯一的解决方法是减少 Token,界面既然知道这一点,就应该提供解决方案,而不是重试。
- Content filter: The provider blocked the input or the output. Not retryable unchanged. Distinct from a model refusal, and users experience the two very differently. 内容过滤: 服务商拦截了输入或输出。原样重试无效。这与模型拒绝不同,用户对两者的体验截然不同。
- Truncated output: The generation hit max_tokens. Not an error at the transport layer at all — status 200, a finish reason of ‘length’, and an answer that stops mid-sentence. Silently the most common broken experience. 输出截断: 生成达到了 max_tokens 限制。这根本不是传输层错误——状态码为 200,结束原因为“长度”,且回答在句中中断。这是最常见但往往被忽视的故障体验。
- Malformed structured output: Valid HTTP, invalid JSON or a schema violation. Retryable and often succeeds on a second sample, because it is a sampling accident rather than a capability failure. 结构化输出格式错误: HTTP 正常,但 JSON 无效或违反了 Schema。可重试,且通常第二次采样就能成功,因为这只是采样偶然性,而非模型能力故障。
- Empty output: Zero tokens, no error. Rare, jarring, and easy to render as a blank box that looks like a UI bug rather than a model outcome. 空输出: 零 Token,无错误。罕见且令人困惑,容易被渲染成一个空白框,看起来像 UI Bug 而非模型结果。
- Confidently wrong: The failure with no error at all. Nothing in this page can detect it; it is why the rest of this cluster exists. 自信地胡说八道: 没有任何错误提示的失败。本页中的任何机制都无法检测到它;这就是为什么需要构建其余的错误处理体系。
The ones you cannot tell apart
那些无法区分的情况
Three of those genuinely cannot be separated from the client side, and pretending otherwise produces confident, wrong error copy. 其中有三种情况在客户端确实无法区分,假装能区分只会产生自信但错误的错误文案。
- Timeout versus slow success: Your deadline expiring tells you nothing about the provider’s state. The request may be finishing right now. An idempotency key is what converts this from a guess into a safe retry. 超时与缓慢成功: 你的截止时间过期并不能说明服务商的状态。请求可能正在完成中。幂等键(Idempotency key)是将这种猜测转化为安全重试的关键。
- Content filter versus model refusal: Some providers return a distinct code; some return a normal completion in which the model declines. The second one is a 200 with prose in it, and no error handling will ever see it. 内容过滤与模型拒绝: 一些服务商会返回特定的代码;另一些则返回正常的完成状态,只是模型在内容中表示拒绝。后者是状态码 200 的正常响应,任何错误处理机制都无法捕获它。
- Truncation versus a short answer: Only the finish reason tells you, and it is the field most commonly ignored. If you read one field out of the response other than the content, read that one. 截断与简短回答: 只有“结束原因”字段能告诉你真相,而这恰恰是最常被忽略的字段。如果你除了内容之外还要读取响应中的一个字段,请务必读取它。
The honest design response to genuine ambiguity is not a vaguer message. It is a message that describes what you observed — “the response stopped early” — and offers both plausible next actions, rather than asserting a cause you do not know. 面对真正的歧义,诚实的设计方案不是提供更模糊的信息,而是描述你所观察到的现象——“响应提前停止了”——并提供两种可能的后续操作,而不是断言一个你并不确定的原因。
Four rules for the copy
文案的四条原则
- Never say “try again” unless trying again can work. For context-length, content-filter and schema errors after several attempts, a retry is a bill with no upside. Offer the change that would help instead: shorten, rephrase, split. 除非重试有效,否则永远不要说“请重试”。 对于上下文长度、内容过滤和 Schema 错误,在多次尝试后,重试只会产生无意义的账单。应提供有帮助的修改建议:缩短、重写或拆分。
- Say who failed. “We couldn’t reach the model”, “the model declined” and “your request was too long” assign responsibility to three different parties, and the user’s next move is different in each case. 明确指出是谁的问题。 “我们无法连接模型”、“模型拒绝了请求”和“你的请求太长”分别将责任归咎于三个不同的主体,用户在每种情况下的后续操作也各不相同。
- Never render the provider’s raw error. It names models and internal fields, its wording changes without notice, and it occasionally leaks fragments of the request. Map it; do not forward it. 永远不要直接显示服务商的原始错误信息。 它会暴露模型名称和内部字段,措辞可能随时更改,有时还会泄露请求片段。请进行映射,不要直接转发。
- Carry a correlation id. One short id, copyable, in the error UI. It is the difference between a support ticket that can be diagnosed and one that cannot, and it costs a line of code. 携带关联 ID (Correlation ID)。 在错误 UI 中显示一个可复制的短 ID。这决定了支持工单是否可被诊断,而实现它只需一行代码。
Normalising the error
错误标准化
One function, at the boundary, converting whatever the provider said into the small set of things the interface actually needs to decide. Everything downstream reads this shape and never the original. 在边界处使用一个函数,将服务商返回的任何信息转换为界面真正需要决策的少数几种类型。下游的所有逻辑都只读取这种格式,绝不直接读取原始数据。
type AiFailure = {
kind: "transport" | "rate_limited" | "provider" | "timeout" | "too_long" | "filtered" | "truncated" | "malformed" | "empty";
retryable: boolean; // can an identical request succeed?
retryAfterMs?: number; // if the provider told us
userMessage: string; // what we show
action?: "shorten" | "rephrase" | "wait" | "regenerate";
correlationId: string;
};
const COPY: Record<AiFailure["kind"], Pick<AiFailure, "retryable" | "userMessage" | "action">> = {
transport: { retryable: true, action: "regenerate", userMessage: "The connection dropped before the answer finished." },
rate_limited: { retryable: true, action: "wait", userMessage: "We're at capacity right now." },
provider: { retryable: true, action: "regenerate", userMessage: "The model provider is having trouble." },
timeout: { retryable: true, action: "regenerate", userMessage: "That took longer than we allow. It may still finish." },
too_long: { retryable: false, action: "shorten", userMessage: "This conversation is too long to send. Start a new one, or remove an attachment." },
filtered: { retryable: false, action: "rephrase", userMessage: "This request was blocked by a safety filter." },
truncated: { retryable: true, action: "regenerate", userMessage: "The answer was cut off at the length limit." },
malformed: { retryable: true, action: "regenerate", userMessage: "The model returned something we couldn't read." },
empty: { retryable: true, action: "regenerate", userMessage: "The model returned nothing at all." },
};
Two things fall out of writing it this way. The action field drives which button is rendered, so the copy and the affordance. 用这种方式编写代码会带来两个好处。action 字段决定了渲染哪个按钮,从而确保了文案与交互功能的一致性。