TL;DR30 秒速览Agent 调用 LLM API 报错了——API Key 过期上下文超长网络超时限流不同 Provider 的错误格式完全不同四层错误分类结构化异常 → HTTP 状态码 → Provider 特定异常 → 关键词兜底三种错误类型Context Overflow压缩后重试、Transient指数退避重试、Non-Transient立即失败双流停滞检测TTFT 240s首 token 超时 Inter-chunk 120schunk 间隔超时异常链遍历MAX_CAUSE_DEPTH 16防止循环引用每层检查所有四种策略核心代码LlmErrorClassifier237 行AgentLoopRunner流式超时335 行前情提要上一篇我们讲了 全异步 MCP 集成——双传输协议、Reactor → Coroutine 桥接、按用户懒加载。今天讲 LLM 调用出错时怎么办。核心矛盾Agent 调用 LLM API 报错了。OpenAI 说context_length_exceededAnthropic 说prompt is too longDashScope 说maximum context length is——意思一样格式完全不同。怎么处理Provider错误格式OpenAI400 - {error:{code:context_length_exceeded}}Anthropic400 - {type:error,error:{type:invalid_request_error,message:prompt is too long}}DashScope400 - {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()funisContextOverflow(e:Throwable):Boolean{varcurrent:Throwable?ewhile(current!null){// Layer 1: Spring AI 结构化异常if(currentisNonTransientAiException){if(isContextOverflowMessage(current.message))returntrue}// Layer 2: HTTP 400 状态码valmessagecurrent.message?.lowercase()?:if((message.startsWith(400)||message.contains(400 -))isContextOverflowMessage(message))returntrue// Layer 3: Provider 特定异常if(isProviderContextOverflowException(current))returntruecurrentcurrent.cause}// Layer 4: 关键词兜底只检查最外层异常returnisContextOverflowMessage(e.message?.lowercase()?:)}关键词匹配privatefunisContextOverflowMessage(message:String):Boolean{returnmessage.contains(context_length)||// OpenAImessage.contains(prompt is too long)||// Anthropicmessage.contains(maximum context length)||// DashScopemessage.contains(context length exceeded)||message.contains(too many tokens)}关键不能把 API Key 错误误判为上下文溢出——否则无限重试。超时检测funisTimeout(e:Throwable):Boolean{varcurrent:Throwable?ewhile(current!null){if(currentisTransientAiException)returntrue// Layer 1if(currentisSocketTimeoutException||// Layer 2currentisTimeoutException||currentisResourceAccessException)returntrue// Layer 3-4: 消息关键词valmessagecurrent.message?.lowercase()?:if(message.contains(timeout)||message.contains(timed out))returntruecurrentcurrent.cause}returnfalse}异常链遍历// 异常可能嵌套多层RuntimeException → IOException → SocketTimeoutException// MAX_CAUSE_DEPTH 16 防止循环引用privateconstvalMAX_CAUSE_DEPTH16每层都检查所有四种策略确保深层嵌套的异常也能被正确分类。双流停滞检测问题LLM 流式响应可能卡住HTTP 连接还活着但不再发送新 chunk。传统的 HTTP 超时不检测这种情况。双超时机制// AgentLoopRunnercompanionobject{/** TTFT: 从发送请求到收到第一个 chunk */privateconstvalFIRST_CHUNK_TIMEOUT_SECONDS240L/** Inter-chunk: 两个 chunk 之间的最大间隔 */privateconstvalSTREAM_STALL_TIMEOUT_SECONDS120L}超时值含义TTFT240s从发送请求到收到第一个 chunkLLM 需要时间思考Inter-chunk120s两个 chunk 之间的最大间隔流式响应中途停滞实现// AgentLoopRunner 中的流式消费循环varreceivedContentChunkfalsewhile(true){// TTFT 用更长的超时LLM 需要处理 PromptvalbaseTimeoutif(!receivedContentChunkchunkCount0){FIRST_CHUNK_TIMEOUT_SECONDS.seconds// 240s}else{STREAM_STALL_TIMEOUT_SECONDS.seconds// 120s}valresultwithTimeoutOrNull(baseTimeout){channel.receiveCatching()}if(resultnull){// 超时valphaseif(chunkCount0)first token (TTFT)elsesubsequent chunkthrowTimeoutException(LLM stream stalled: no$phasereceived within${timeoutSec}s)}valchunkresult.getOrNull()?:break// 跳过空 chunkSSE keepalive不重置计时器if(chunk.results.isEmpty()||chunk.results.all{it.output.text.isNullOrEmpty()}){continue// 空 chunk 不表示 LLM 在工作}receivedContentChunktrue// 处理 chunk...}独立于 HTTP 层不在 Netty/OkHttp 层设置超时那是连接级超时而是在应用层检测每个 chunk 到达时重置计时器。Agent 循环中的错误处理上下文溢出自愈AgentLoop.runInnerLoop() → callLLMWithOverflowHandling() → 调用 LLM → 捕获异常 → LlmErrorClassifier.isContextOverflow(e)? → true: 触发 ContextCompactionOrchestrator 压缩 → 压缩完成后透明重试同一轮 → false: 正常异常处理重试策略// AgentLoopRunner 中的重试逻辑if(retryCountcontext.maxRetriesisTimeoutException(e)){retryCountvalbackoffMsretryCount*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{throwe// Non-Transient 或重试次数用尽}错误类型处理策略重试Context Overflow压缩上下文 → 重试最多 1 次Transient超时/5xx/限流指数退避重试最多 N 次Non-TransientAPI Key 无效立即失败不重试熔断器集成// 连接错误、流停滞、5xx → 报告给端点熔断器if(LlmErrorClassifier.isEndpointOutage(e)){breaker?.recordFailure()}// 限流和客户端错误不计入熔断跨 Provider 兼容性Provider已验证的错误格式OpenAI400 - {error:{code:context_length_exceeded}}Anthropic400 - {type:error,error:{type:invalid_request_error,message:prompt is too long}}DashScope400 - {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)踩坑记录坑原因解法Anthropicmessage_delta不含input_tokensusage 报告不完整UsageAwareTokenEstimator合理性窗口检测429 误判“processed 429 items” 中的数字\b429\b全词匹配Spring AITransientAiException不含 5xx某些版本遗漏Layer 2 HTTP 状态码检测作为补充异常链循环引用e.cause形成环MAX_CAUSE_DEPTH 16空 chunk 重置计时器SSE keepalive 被误认为 LLM 在工作只在实际内容 chunk 时重置总结维度简单 try-catchEasyAI 四层分类错误识别只看 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。