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"——意思一样,格式完全不同。怎么处理?
| 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"} |
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 状态码valmessage=current.message?.lowercase()?:""if((message.startsWith("400")||message.contains("400 -"))&&isContextOverflowMessage(message))returntrue// Layer 3: Provider 特定异常if(isProviderContextOverflowException(current))returntruecurrent=current.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: 消息关键词valmessage=current.message?.lowercase()?:""if(message.contains("timeout")||message.contains("timed out"))returntruecurrent=current.cause}returnfalse}异常链遍历
// 异常可能嵌套多层:RuntimeException → IOException → SocketTimeoutException// MAX_CAUSE_DEPTH = 16 防止循环引用privateconstvalMAX_CAUSE_DEPTH=16每层都检查所有四种策略,确保深层嵌套的异常也能被正确分类。
双流停滞检测
问题
LLM 流式响应可能"卡住":HTTP 连接还活着,但不再发送新 chunk。传统的 HTTP 超时不检测这种情况。
双超时机制
// AgentLoopRunnercompanionobject{/** TTFT: 从发送请求到收到第一个 chunk */privateconstvalFIRST_CHUNK_TIMEOUT_SECONDS=240L/** Inter-chunk: 两个 chunk 之间的最大间隔 */privateconstvalSTREAM_STALL_TIMEOUT_SECONDS=120L}| 超时 | 值 | 含义 |
|---|---|---|
| TTFT | 240s | 从发送请求到收到第一个 chunk(LLM 需要时间思考) |
| Inter-chunk | 120s | 两个 chunk 之间的最大间隔(流式响应中途停滞) |
实现
// AgentLoopRunner 中的流式消费循环varreceivedContentChunk=falsewhile(true){// TTFT 用更长的超时(LLM 需要处理 Prompt)valbaseTimeout=if(!receivedContentChunk&&chunkCount==0){FIRST_CHUNK_TIMEOUT_SECONDS.seconds// 240s}else{STREAM_STALL_TIMEOUT_SECONDS.seconds// 120s}valresult=withTimeoutOrNull(baseTimeout){channel.receiveCatching()}if(result==null){// 超时!valphase=if(chunkCount==0)"first token (TTFT)"else"subsequent chunk"throwTimeoutException("LLM stream stalled: no$phasereceived within${timeoutSec}s")}valchunk=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++valbackoffMs=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{throwe// Non-Transient 或重试次数用尽}| 错误类型 | 处理策略 | 重试 |
|---|---|---|
| Context Overflow | 压缩上下文 → 重试 | 最多 1 次 |
| Transient(超时/5xx/限流) | 指数退避重试 | 最多 N 次 |
| Non-Transient(API Key 无效) | 立即失败 | 不重试 |
熔断器集成
// 连接错误、流停滞、5xx → 报告给端点熔断器if(LlmErrorClassifier.isEndpointOutage(e)){breaker?.recordFailure()}// 限流和客户端错误不计入熔断跨 Provider 兼容性
| 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)踩坑记录
| 坑 | 原因 | 解法 |
|---|---|---|
Anthropicmessage_delta不含input_tokens | usage 报告不完整 | 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-catch | EasyAI 四层分类 |
|---|---|---|
| 错误识别 | 只看 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。