上一篇我们管住了上下文窗口:该 trim 的 trim,该摘要的摘要,RAG 也不再一股脑乱塞。窗口干净了,Agent 就稳了吗?
并没有。生产里 LLM 会超时、会 429、Ollama 会挂、ReAct 会空转烧光额度——喂对了内容,照样可能直接崩给你看。本篇管「挂了怎么办」——重试、Fallback、防死循环,让异常变成优雅降级,而不是用户脸上的 stack trace。
老规矩,本文以官网最新文档核对过(Fault tolerance、Prebuilt middleware、Agents)。入口继续
createAgent——别再抄createReactAgent。重试 / Fallback / 调用上限,优先挂官方中间件,别先上手写一整套invokeWithRetry。
一、生产里 Agent 怎么死
先认清「会死在哪」,再谈怎么救。
| 场景 | 典型原因 | 表现 |
|---|---|---|
| 超时 | 模型负载高、网络慢、prompt 过长 | 挂起后 timeout |
| 429 限流 | API 配额用尽、并发过高 | Rate limit exceeded |
| 模型不可用 | Ollama 没起、模型没 pull、服务宕机 | ECONNREFUSED、model not found |
| 上下文超限 | messages + RAG 超窗 | 400 / context length exceeded |
| 失控循环 | 模型一直吐tool_calls | 烧 token、拖超时,最后 recursive 爆 |
官网把错误按「谁来修」分得更清楚——不同错误不该用同一招:
| 错误类型 | 谁来修 | 策略 | 官方手段 |
|---|---|---|---|
| 瞬时故障(网络、限流) | 系统自动 | 指数退避重试 | modelRetryMiddleware/toolRetryMiddleware |
| LLM 可恢复(工具失败、解析翻车) | 模型 | 错误进ToolMessage,让模型改主意 | 工具返回错误串(JS 尚无ToolErrorMiddleware) |
| 用户可修复(缺信息、指令不清) | 人 | 暂停等人 | interrupt/ HITL |
| 供应商宕机 | 系统自动 | 换备选模型 | modelFallbackMiddleware |
| 失控循环 | 系统自动 | 封顶调用次数 | modelCallLimitMiddleware/toolCallLimitMiddleware+recursionLimit |
| 未知异常 | 开发者 | 往上抛,别瞎 catch | 无中间件硬吞 |
工具失败时把错误信息回给模型,它往往能自己换招。官网 JS 侧ToolErrorMiddleware尚未提供——继续走「错误内容进 ToolMessage」即可,别等一个还不存在的 API。
二、瞬时故障:重试
网络抖一下、偶发 429,立刻失败只会逼用户狂点刷新。策略上:
| 策略 | 说明 |
|---|---|
| 固定间隔 | 每次等一样久;简单,但 429 时可能越重试越堵 |
| 指数退避 | 第 n 次等initialDelayMs * backoffFactor^n;给服务喘息时间 |
maxRetries | 一般 2~3 次封顶,别无限重试 |
Chat 模型自身也可能有maxRetries,但挂在 Agent 上时,官网主路径是中间件——模型调用和工具调用各管各的:
import{createAgent,modelRetryMiddleware,toolRetryMiddleware,}from"langchain";import{ChatOllama}from"@langchain/ollama";import{tool}from"@langchain/core/tools";import*aszfrom"zod";constsearchWeb=tool(async({q}:{q:string})=>`搜索结果:${q}`,{name:"search_web",description:"搜索网页(外部 API,值得重试)",schema:z.object({q:z.string()}),});constllm=newChatOllama({model:"qwen2.5:7b",temperature:0});constagent=createAgent({model:llm,tools:[searchWeb],systemPrompt:"需要外部信息时调用 search_web。",middleware:[// 模型:超时 / 限流 / 5xx 类瞬时错误modelRetryMiddleware({maxRetries:3,backoffFactor:2.0,initialDelayMs:1000,}),// 工具:只重试会抖的外部调用;本地 read 别无脑重试toolRetryMiddleware({maxRetries:2,tools:["search_web"],backoffFactor:2.0,initialDelayMs:500,}),],});要点:
- 默认就是指数退避;
backoffFactor: 0才退回固定间隔。 toolRetryMiddleware用tools: [...]收窄范围——官网原话:文件系统read失败多半重试也没用,网页搜索超时才值得再试。onFailure: "continue"时,重试用尽可返回带错误说明的AIMessage,让 Agent 有机会收尾,而不是整段炸穿。
手写for+sleep也能懂原理;生产别重复造轮子,中间件已经把退避、jitter、失败策略打包好了。
三、主模型挂了:Fallback
重试用尽、或者主模型整机不可用时,换备选继续服务:
主模型 qwen2.5:7b 失败 → 备选 llama3.1:8b(或其它已 pull 的模型)import{createAgent,modelFallbackMiddleware}from"langchain";import{ChatOllama}from"@langchain/ollama";constprimary=newChatOllama({model:"qwen2.5:7b",temperature:0});constfallback=newChatOllama({model:"llama3.1:8b",temperature:0});constagent=createAgent({model:primary,tools:[],middleware:[// 主模型失败后,按顺序尝试备选(可传多个)modelFallbackMiddleware(fallback),],});注意:
- 备选能力可能不同——回答风格、工具遵从度都会变。日志或 UI 最好标一句「已切换备选模型」,别假装什么都没发生。
- Fallback 解决的是「模型/供应商挂了」;解决不了「问题本身无解」。盯 fallback 触发率,太高说明主路径在持续抽风。
四、防死循环:recursionLimit+ Call Limit
ReAct 环里,模型若一直返回tool_calls而不给最终答案,就会空转:
import{GraphRecursionError}from"@langchain/langgraph";try{awaitagent.invoke({messages:[{role:"user",content:"帮我做一件超复杂的事"}]},{recursionLimit:15}// 生产可配环境变量,默认别太大);}catch(err){if(errinstanceofGraphRecursionError){// 别把 stack 甩给用户return"任务步骤过多,请简化问题后再试。";}throwerr;}但要分清两层闸门:
| 闸门 | 数的是什么 | 超限表现 |
|---|---|---|
recursionLimit | LangGraphsuper-step(调度滴答),不是「模型调用次数」 | 抛GraphRecursionError |
modelCallLimitMiddleware/toolCallLimitMiddleware | 业务语义上的模型/工具调用次数 | 可exitBehavior: "end"优雅收束 |
挂了beforeModel/afterModel这类会编译成独立节点的中间件,会多占 super-step;wrapModelCall包在原节点里,一般不另计。所以:recursionLimit要留余量,真正「最多调几次模型」交给 Call Limit 更直观。
import{createAgent,modelCallLimitMiddleware,toolCallLimitMiddleware,}from"langchain";import{ChatOllama}from"@langchain/ollama";constagent=createAgent({model:newChatOllama({model:"qwen2.5:7b",temperature:0}),tools:[/* ... */],middleware:[modelCallLimitMiddleware({runLimit:15,// 单次 invoke 内最多 15 次模型调用exitBehavior:"end",// 到顶优雅结束,而不是甩异常}),toolCallLimitMiddleware({runLimit:30,// 单次 invoke 内工具调用封顶}),],});runLimit:一次用户请求内计数,下轮重置。threadLimit:整条会话累计,需要 Checkpointer。两道闸一起上:Call Limit 管业务预算,recursionLimit防图调度层面真失控。
五、缓存与熔断(轻量)
中间件解决「这次调用怎么扛」;缓存和熔断解决「别把下游打爆 / 别重复烧钱」。
精确缓存 vs 语义缓存
| 类型 | 命中条件 | MVP 思路 |
|---|---|---|
| 精确缓存 | 问题字符串完全一致(可 hash) | 内存Map+ TTL |
| 语义缓存 | embedding 相似度过阈值 | 向量库;本篇不展开 |
typeCacheEntry={value:string;expiresAt:number};constexactCache=newMap<string,CacheEntry>();constTTL_MS=5*60*1000;functioncacheKey(question:string){returnquestion.trim().toLowerCase();}functiongetCached(question:string):string|undefined{consthit=exactCache.get(cacheKey(question));if(!hit)returnundefined;if(Date.now()>hit.expiresAt){exactCache.delete(cacheKey(question));returnundefined;}returnhit.value;}functionsetCached(question:string,value:string){exactCache.set(cacheKey(question),{value,expiresAt:Date.now()+TTL_MS,});}// 「现在几点」「今天天气」——实时题别进缓存注意:不同用户 / 不同thread_id是否共享缓存要想清楚;带隐私或个性化的回答,默认不要全局共享。
Circuit Breaker 简述
连续失败时,与其每次都去撞已经挂掉的 Ollama,不如短暂拒绝:
关闭 → 失败累积 → 打开(直接拒)→ 冷却后 → 半开(放一枪试探)→ 成功则关闭classSimpleBreaker{privatefailures=0;privateopenUntil=0;constructor(privatethreshold=5,privatecoolDownMs=30_000){}getisOpen(){returnDate.now()<this.openUntil;}beforeCall(){if(this.isOpen){thrownewError("服务暂时不可用,请稍后重试(熔断开启)");}}onSuccess(){this.failures=0;this.openUntil=0;}onFailure(){this.failures+=1;if(this.failures>=this.threshold){this.openUntil=Date.now()+this.coolDownMs;this.failures=0;}}}constbreaker=newSimpleBreaker();asyncfunctioninvokeWithBreaker(run:()=>Promise<unknown>){breaker.beforeCall();try{constresult=awaitrun();breaker.onSuccess();returnresult;}catch(e){breaker.onFailure();throwe;}}这是教学级 MVP。完整半开态、按依赖隔离,可后续对标 resilience4j 一类库;本篇目标是建立心智,不是重造运维平台。
六、组合:生产最小可靠骨架
把前面几招叠在一颗createAgent上:
import{createAgent,modelRetryMiddleware,toolRetryMiddleware,modelFallbackMiddleware,modelCallLimitMiddleware,toolCallLimitMiddleware,}from"langchain";import{ChatOllama}from"@langchain/ollama";import{GraphRecursionError}from"@langchain/langgraph";import{tool}from"@langchain/core/tools";import*aszfrom"zod";constgetWeather=tool(async({city}:{city:string})=>`${city}:晴,25°C`,{name:"get_weather",description:"查询城市天气",schema:z.object({city:z.string()}),});constprimary=newChatOllama({model:"qwen2.5:7b",temperature:0});constfallback=newChatOllama({model:"llama3.1:8b",temperature:0});constagent=createAgent({model:primary,tools:[getWeather],systemPrompt:"需要天气时调用 get_weather。",middleware:[modelRetryMiddleware({maxRetries:2,backoffFactor:2.0,initialDelayMs:1000,}),toolRetryMiddleware({maxRetries:2,tools:["get_weather"],}),modelFallbackMiddleware(fallback),modelCallLimitMiddleware({runLimit:15,exitBehavior:"end"}),toolCallLimitMiddleware({runLimit:30}),],});exportasyncfunctionsafeInvoke(userText:string){try{returnawaitagent.invoke({messages:[{role:"user",content:userText}]},{recursionLimit:25}// 比 callLimit 留余量);}catch(err){if(errinstanceofGraphRecursionError){return{messages:[{role:"assistant",content:"任务步骤过多,请简化问题后再试。",},],};}throwerr;}}retry / fallback / 触顶时你才能在 LangSmith 里看见「到底换过几次、卡在哪」。可靠性没有可观测,就是盲修。
常见坑
- 无重试直接失败:偶发抖动逼用户狂点;至少模型侧
maxRetries: 2。 - 重试无退避:429 时立刻重试只会更堵;用指数退避。
- 工具无脑全量重试:本地/确定性失败不值得重试;
tools: [...]收窄。 - 只有
recursionLimit、没有 Call Limit:super-step ≠ 业务调用次数;两道闸一起上更稳。 - Fallback 不告知:备选质量不同,日志/UI 应标注。
- 缓存误用:天气、时间、用户私有回答不该进全局精确缓存。
- 熔断阈值乱调:太敏感则误杀;太钝则雪崩。
- 瞎 catch 未知异常:官网建议:处理不了的往上抛,方便调试。