
TL;DR(30 秒速览)
- Agent 调用 LLM API 报错了——API Key 过期?上下文超长?网络超时?限流?不同 Provider 的错误格式完全不同
- 四层错误分类:结构化异常 → HTTP 状态码 → Provider 特定异常 → 关键词兜底
- 三种错误类型:Context Overflow(压缩后重试)、Transient(指数退避重试)、Non-Transient(立即失败)
- 双流停滞检测:TTFT 240s(首 token 超时)+ Inter-chunk 120s(chunk 间隔超时)
- 异常链遍历:MAX_CAUSE_DEPTH = 16 防止循环引用,每层检查所有四种策略
- 核心代码:LlmErrorClassifier(237 行)+ AgentLoopRunner 流式超时(335 行)
前情提要:上一篇我们讲了 全异步 MCP 集成——双传输协议、Reactor → Coroutine 桥接、按用户懒加载。今天讲 LLM 调用出错时怎么办。
核心矛盾
Agent 调用 LLM API 报错了。
OpenAI 说 "context_length_exceeded",Anthropic 说 "prompt is too long",DashScope 说 "maximum context length is"——意思一样,格式完全不同。怎么处理?
| OpenAI | 400 – {"error":{"code":"context_length_exceeded"}} |
| Anthropic | 400 – {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}} |
| DashScope | 400 – {"code":"InvalidParameter","message":"maximum context length is 128000"} |
EasyAI 的方案:四层检测策略 + 统一分类。
四层错误分类

Layer 1: 结构化异常(NonTransientAiException / TransientAiException)
↓ 未命中
Layer 2: HTTP 状态码(400 = 上下文溢出, 429 = 限流, 5xx = 服务不可用)
↓ 未命中
Layer 3: Provider 特定异常(OpenAI ApiError, Anthropic ApiException)
↓ 未命中
Layer 4: 关键词兜底("context_length", "rate_limit", "timeout" 等)
上下文溢出检测
// LlmErrorClassifier.isContextOverflow()
fun isContextOverflow(e: Throwable): Boolean {
var current: Throwable? = e
while (current != null) {
// Layer 1: Spring AI 结构化异常
if (current is NonTransientAiException) {
if (isContextOverflowMessage(current.message)) return true
}
// Layer 2: HTTP 400 状态码
val message = current.message?.lowercase() ?: ""
if ((message.startsWith("400") || message.contains("400 -")) &&
isContextOverflowMessage(message)) return true
// Layer 3: Provider 特定异常
if (isProviderContextOverflowException(current)) return true
current = current.cause
}
// Layer 4: 关键词兜底(只检查最外层异常)
return isContextOverflowMessage(e.message?.lowercase() ?: "")
}
关键词匹配
private fun isContextOverflowMessage(message: String): Boolean {
return message.contains("context_length") || // OpenAI
message.contains("prompt is too long") || // Anthropic
message.contains("maximum context length") || // DashScope
message.contains("context length exceeded") ||
message.contains("too many tokens")
}
关键:不能把 API Key 错误误判为上下文溢出——否则无限重试。
超时检测
fun isTimeout(e: Throwable): Boolean {
var current: Throwable? = e
while (current != null) {
if (current is TransientAiException) return true // Layer 1
if (current is SocketTimeoutException || // Layer 2
current is TimeoutException ||
current is ResourceAccessException) return true
// Layer 3-4: 消息关键词
val message = current.message?.lowercase() ?: ""
if (message.contains("timeout") || message.contains("timed out")) return true
current = current.cause
}
return false
}
异常链遍历
// 异常可能嵌套多层:RuntimeException → IOException → SocketTimeoutException
// MAX_CAUSE_DEPTH = 16 防止循环引用
private const val MAX_CAUSE_DEPTH = 16
每层都检查所有四种策略,确保深层嵌套的异常也能被正确分类。
双流停滞检测

问题
LLM 流式响应可能"卡住":HTTP 连接还活着,但不再发送新 chunk。传统的 HTTP 超时不检测这种情况。
双超时机制
// AgentLoopRunner
companion object {
/** TTFT: 从发送请求到收到第一个 chunk */
private const val FIRST_CHUNK_TIMEOUT_SECONDS = 240L
/** Inter-chunk: 两个 chunk 之间的最大间隔 */
private const val STREAM_STALL_TIMEOUT_SECONDS = 120L
}
| TTFT | 240s | 从发送请求到收到第一个 chunk(LLM 需要时间思考) |
| Inter-chunk | 120s | 两个 chunk 之间的最大间隔(流式响应中途停滞) |
实现
// AgentLoopRunner 中的流式消费循环
var receivedContentChunk = false
while (true) {
// TTFT 用更长的超时(LLM 需要处理 Prompt)
val baseTimeout = if (!receivedContentChunk && chunkCount == 0) {
FIRST_CHUNK_TIMEOUT_SECONDS.seconds // 240s
} else {
STREAM_STALL_TIMEOUT_SECONDS.seconds // 120s
}
val result = withTimeoutOrNull(baseTimeout) {
channel.receiveCatching()
}
if (result == null) {
// 超时!
val phase = if (chunkCount == 0) "first token (TTFT)" else "subsequent chunk"
throw TimeoutException("LLM stream stalled: no $phase received within ${timeoutSec}s")
}
val chunk = result.getOrNull() ?: break
// 跳过空 chunk(SSE keepalive),不重置计时器
if (chunk.results.isEmpty() || chunk.results.all { it.output.text.isNullOrEmpty() }) {
continue // 空 chunk 不表示 LLM 在工作
}
receivedContentChunk = true
// 处理 chunk…
}
独立于 HTTP 层
不在 Netty/OkHttp 层设置超时(那是连接级超时),而是在应用层检测:每个 chunk 到达时重置计时器。
Agent 循环中的错误处理
上下文溢出自愈
AgentLoop.runInnerLoop()
→ callLLMWithOverflowHandling()
→ 调用 LLM
→ 捕获异常
→ LlmErrorClassifier.isContextOverflow(e)?
→ true: 触发 ContextCompactionOrchestrator 压缩
→ 压缩完成后透明重试(同一轮)
→ false: 正常异常处理
重试策略
// AgentLoopRunner 中的重试逻辑
if (retryCount < context.maxRetries && isTimeoutException(e)) {
retryCount++
val backoffMs = retryCount * 1000L // 线性退避
logger.warn("LLM call timed out, retrying ({}/{}) after {}ms", retryCount, context.maxRetries, backoffMs)
push(RetryEvent(messageId, retryCount, context.maxRetries, backoffMs, ...))
delay(backoffMs.milliseconds)
// 重置累加器
fullText.clear()
fullThinking.clear()
} else {
throw e // Non-Transient 或重试次数用尽
}
| Context Overflow | 压缩上下文 → 重试 | 最多 1 次 |
| Transient(超时/5xx/限流) | 指数退避重试 | 最多 N 次 |
| Non-Transient(API Key 无效) | 立即失败 | 不重试 |
熔断器集成
// 连接错误、流停滞、5xx → 报告给端点熔断器
if (LlmErrorClassifier.isEndpointOutage(e)) {
breaker?.recordFailure()
}
// 限流和客户端错误不计入熔断
跨 Provider 兼容性
| OpenAI | 400 – {"error":{"code":"context_length_exceeded"}} |
| Anthropic | 400 – {"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}} |
| DashScope | 400 – {"code":"InvalidParameter","message":"maximum context length is 128000"} |
四层检测确保即使 Provider 改变了错误格式,关键词兜底也能覆盖。新增 Provider 时只需在 Layer 3 添加 Provider 特定检测。
监控与日志
// 每次错误分类都记录日志
logger.warn("LLM error classified as {}: {}", type, message)
// 上下文溢出自愈时推送 SSE 事件
// 前端可以看到"上下文超长,正在自动压缩"
// 流停滞超时时记录
logger.error("LLM stream stalled: no {} received within {}s (provider={}, lastChunk={})",
phase, timeoutSec, providerName, lastChunkSummary)
踩坑记录
| Anthropic message_delta 不含 input_tokens | usage 报告不完整 | UsageAwareTokenEstimator 合理性窗口检测 |
| 429 误判 | “processed 429 items” 中的数字 | \\b429\\b 全词匹配 |
| Spring AI TransientAiException 不含 5xx | 某些版本遗漏 | Layer 2 HTTP 状态码检测作为补充 |
| 异常链循环引用 | e.cause 形成环 | MAX_CAUSE_DEPTH = 16 |
| 空 chunk 重置计时器 | SSE keepalive 被误认为 LLM 在工作 | 只在实际内容 chunk 时重置 |
总结
| 错误识别 | 只看 message | 四层策略逐层检测 |
| 跨 Provider | 每个 Provider 单独处理 | 统一分类接口 |
| 上下文溢出 | 报错终止 | 自动压缩 → 重试 |
| 流停滞 | HTTP 超时(连接级) | 应用层双超时检测 |
| 重试策略 | 固定间隔 | 分类驱动(压缩/退避/终止) |
EasyAI 的 LlmErrorClassifier 用 237 行 Kotlin 代码实现了完整的错误分类——四层检测、三种类型、双流停滞检测、跨 Provider 兼容。
核心思想:好的错误处理不是 try-catch 打印日志,而是分类驱动——不同类型的错误有不同的自愈策略。
下一篇:让 LLM 稳定输出 JSON,我们踩了四道坎
Agent 的最终输出要以 JSON 交给下游系统——格式错一个字符全链路就断。EasyAI 的方案:宽容提取 → Schema 校验重试 → 原生结构化输出 → 大 JSON 分块提交,四级纵深防御。
开源地址:https://github.com/haibingzhao/easyai
欢迎 Star、Issue 和 PR。
网硕互联帮助中心



评论前必须登录!
注册