Nodejs也能写Agent - 23.LangGraph篇 - 可靠性与容错
上一篇我们管住了上下文窗口:该 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 * as z from "zod";
const searchWeb = tool(
async ({ q }: { q: string }) => `搜索结果:${q}`,
{
name: "search_web",
description: "搜索网页(外部 API,值得重试)",
schema: z.object({ q: z.string() }),
}
);
const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const agent = 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";
const primary = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const fallback = new ChatOllama({ model: "llama3.1:8b", temperature: 0 });
const agent = createAgent({
model: primary,
tools: [],
middleware: [
// 主模型失败后,按顺序尝试备选(可传多个)
modelFallbackMiddleware(fallback),
],
});
注意:
- 备选能力可能不同——回答风格、工具遵从度都会变。日志或 UI 最好标一句「已切换备选模型」,别假装什么都没发生。
- Fallback 解决的是「模型/供应商挂了」;解决不了「问题本身无解」。盯 fallback 触发率,太高说明主路径在持续抽风。
四、防死循环:recursionLimit + Call Limit
ReAct 环里,模型若一直返回 tool_calls 而不给最终答案,就会空转:
import { GraphRecursionError } from "@langchain/langgraph";
try {
await agent.invoke(
{ messages: [{ role: "user", content: "帮我做一件超复杂的事" }] },
{ recursionLimit: 15 } // 生产可配环境变量,默认别太大
);
} catch (err) {
if (err instanceof GraphRecursionError) {
// 别把 stack 甩给用户
return "任务步骤过多,请简化问题后再试。";
}
throw err;
}
但要分清两层闸门:
| 闸门 | 数的是什么 | 超限表现 |
|---|---|---|
recursionLimit |
LangGraph super-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";
const agent = createAgent({
model: new ChatOllama({ 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 相似度过阈值 | 向量库;本篇不展开 |
type CacheEntry = { value: string; expiresAt: number };
const exactCache = new Map<string, CacheEntry>();
const TTL_MS = 5 * 60 * 1000;
function cacheKey(question: string) {
return question.trim().toLowerCase();
}
function getCached(question: string): string | undefined {
const hit = exactCache.get(cacheKey(question));
if (!hit) return undefined;
if (Date.now() > hit.expiresAt) {
exactCache.delete(cacheKey(question));
return undefined;
}
return hit.value;
}
function setCached(question: string, value: string) {
exactCache.set(cacheKey(question), {
value,
expiresAt: Date.now() + TTL_MS,
});
}
// 「现在几点」「今天天气」——实时题别进缓存
注意:不同用户 / 不同 thread_id 是否共享缓存要想清楚;带隐私或个性化的回答,默认不要全局共享。
Circuit Breaker 简述
连续失败时,与其每次都去撞已经挂掉的 Ollama,不如短暂拒绝:
关闭 → 失败累积 → 打开(直接拒)→ 冷却后 → 半开(放一枪试探)→ 成功则关闭
class SimpleBreaker {
private failures = 0;
private openUntil = 0;
constructor(
private threshold = 5,
private coolDownMs = 30_000
) {}
get isOpen() {
return Date.now() < this.openUntil;
}
beforeCall() {
if (this.isOpen) {
throw new Error("服务暂时不可用,请稍后重试(熔断开启)");
}
}
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;
}
}
}
const breaker = new SimpleBreaker();
async function invokeWithBreaker(run: () => Promise<unknown>) {
breaker.beforeCall();
try {
const result = await run();
breaker.onSuccess();
return result;
} catch (e) {
breaker.onFailure();
throw e;
}
}
这是教学级 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 * as z from "zod";
const getWeather = tool(
async ({ city }: { city: string }) => `${city}:晴,25°C`,
{
name: "get_weather",
description: "查询城市天气",
schema: z.object({ city: z.string() }),
}
);
const primary = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const fallback = new ChatOllama({ model: "llama3.1:8b", temperature: 0 });
const agent = 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 }),
],
});
export async function safeInvoke(userText: string) {
try {
return await agent.invoke(
{ messages: [{ role: "user", content: userText }] },
{ recursionLimit: 25 } // 比 callLimit 留余量
);
} catch (err) {
if (err instanceof GraphRecursionError) {
return {
messages: [
{
role: "assistant",
content: "任务步骤过多,请简化问题后再试。",
},
],
};
}
throw err;
}
}
retry / fallback / 触顶时你才能在 LangSmith 里看见「到底换过几次、卡在哪」。可靠性没有可观测,就是盲修。
常见坑
- 无重试直接失败:偶发抖动逼用户狂点;至少模型侧
maxRetries: 2。 - 重试无退避:429 时立刻重试只会更堵;用指数退避。
- 工具无脑全量重试:本地/确定性失败不值得重试;
tools: [...]收窄。 - 只有
recursionLimit、没有 Call Limit:super-step ≠ 业务调用次数;两道闸一起上更稳。 - Fallback 不告知:备选质量不同,日志/UI 应标注。
- 缓存误用:天气、时间、用户私有回答不该进全局精确缓存。
- 熔断阈值乱调:太敏感则误杀;太钝则雪崩。
- 瞎 catch 未知异常:官网建议:处理不了的往上抛,方便调试。
更多推荐



所有评论(0)