容错

要点

  • 前面这一章一路写下来,图已经能分支、暂停、恢复、拆子图,也能组织多 Agent 协作
  • 最直接的失败,通常就发生在节点内部
  • 如果节点里只是 catch 住错误,但后面的图根本不知道失败了,那这层兜底就只做了一半
  • 有些失败不是立刻就该降级,而是值得再试一次
  • 不是所有失败都值得重试

内容

1. 图一旦真的跑业务,失败就是日常

前面这一章一路写下来,图已经能分支、暂停、恢复、拆子图,也能组织多 Agent 协作。

但只要开始接真实业务,另一个问题就会立刻冒出来:

图不会一直顺着你的理想路径往下跑。

模型可能超时,工具可能报错,外部接口可能返回空值,人工审批可能迟迟不回来,某个并行节点也可能只成功了一半。开发阶段最容易忽略这一点,因为示例里的流程通常都很干净。可一旦放进真实系统,失败不是例外,而是常态。

所以这一篇要处理的,不是怎么把图写出来,而是图出问题以后,怎么别一下子整条断掉。

2. 先把“错误”分成几类看

这件事最好别一上来就想成一个大词。图里的失败来源,其实经常是不同层的。

有些失败来自节点内部。比如工具调用报错、模型返回异常、数据解析失败。

有些失败来自图的流程设计。比如本来应该等两个并行节点都回来,结果汇总节点过早开始执行;或者某个字段本来应该追加,结果写成了覆盖。

还有些失败来自外部系统。比如恢复时 thread 配错了、审批结果迟迟不回来、长期记忆没读到。

把这些都叫“错误”当然没问题,但处理方式不一样。

有的要在节点里兜住,有的要在状态里留痕,有的要给图留一条备用路径。

Canvas actions73%Exit zen mode

Drawing canvas 所以这一篇不会只讲 try/catch,而是把几种最常见的容错位置都走一遍。

3. 第一层兜底,通常就在节点里

最直接的失败,通常就发生在节点内部。比如某个节点里调了外部工具,结果工具超时了。

这时候最稳的第一步,往往不是急着改整张图,而是先让节点自己别炸掉。

// safe-node.ts
import type { GraphNode } from '@langchain/langgraph'
 
const callSearchApi: GraphNode<typeof State> = async (state) => {
 
  try {
 
    // 这里模拟一个可能失败的外部调用
 
    const result = await fetchSearchResult(state.query)
 
    return {
 
      searchResult: result,
 
      status: 'search-success',
 
      lastError: '',
 
    }
 
  } catch (error) {
 
    // 节点先把失败写回状态,而不是直接把整条图打崩
 
    return {
 
      searchResult: '',
 
      status: 'search-failed',
 
      lastError: error instanceof Error ? error.message : '搜索接口调用失败',
 
    }
 
  }
 
}

这一层的关键,不是把错误吞掉,而是把错误留在状态里。

只要状态里还有 statuslastError 这些字段,后面的节点、条件边或者汇总节点就还有机会决定:是重试、降级,还是结束。

4. 只会 try/catch 还不够,后面还得知道怎么走

如果节点里只是 catch 住错误,但后面的图根本不知道失败了,那这层兜底就只做了一半。

更完整的写法,通常是:

先让节点把成功或失败写进状态,再让下一步根据状态决定往哪走。

// error-routing.ts
import {
 
  StateGraph,
 
  StateSchema,
 
  START,
 
  END,
 
} from '@langchain/langgraph'
 
import type { GraphNode, ConditionalEdgeRouter } from '@langchain/langgraph'
 
import { z } from 'zod'
 
const State = new StateSchema({
 
  query: z.string().default(''),
 
  searchResult: z.string().default(''),
 
  status: z.string().default('idle'),
 
  lastError: z.string().default(''),
 
})
 
const callSearchApi: GraphNode<typeof State> = async (state) => {
 
  try {
 
    const result = await fetchSearchResult(state.query)
 
    return {
 
      searchResult: result,
 
      status: 'search-success',
 
      lastError: '',
 
    }
 
  } catch (error) {
 
    return {
 
      searchResult: '',
 
      status: 'search-failed',
 
      lastError: error instanceof Error ? error.message : '搜索接口调用失败',
 
    }
 
  }
 
}
 
const fallbackAnswer: GraphNode<typeof State> = (state) => {
 
  return {
 
    searchResult: `降级回答:当前外部搜索暂时不可用,错误信息是 ${state.lastError}`,
 
  }
 
}
 
const shouldContinue: ConditionalEdgeRouter<typeof State, 'fallbackAnswer'> = (state) => {
 
  if (state.status === 'search-failed') return 'fallbackAnswer'
 
  return END
 
}
 
const graph = new StateGraph(State)
 
  .addNode('callSearchApi', callSearchApi)
 
  .addNode('fallbackAnswer', fallbackAnswer)
 
  .addEdge(START, 'callSearchApi')
 
  .addConditionalEdges('callSearchApi', shouldContinue, ['fallbackAnswer'])
 
  .addEdge('fallbackAnswer', END)
 
  .compile()

这里的变化很重要。错误不再只是节点内部的一次异常,而是变成了图可以继续读取的一种状态。

一旦图能读到它,就可以把失败也纳入流程控制里。

5. 如果可以重试,就把重试写进图里

有些失败不是立刻就该降级,而是值得再试一次。

比如模型超时、搜索接口临时抖一下、外部服务偶发返回空。碰到这种情况,最常见的做法就是在状态里加一个计数器,再留一条回边。

// retry-loop.ts
import {
 
  StateGraph,
 
  StateSchema,
 
  START,
 
  END,
 
  Command,
 
} from '@langchain/langgraph'
 
import type { GraphNode } from '@langchain/langgraph'
 
import { z } from 'zod'
 
const State = new StateSchema({
 
  query: z.string().default(''),
 
  result: z.string().default(''),
 
  retryCount: z.number().default(0),
 
  status: z.string().default('idle'),
 
  lastError: z.string().default(''),
 
})
 
const callSearchApi: GraphNode<typeof State> = async (state) => {
 
  try {
 
    const result = await fetchSearchResult(state.query)
 
    return {
 
      result,
 
      status: 'success',
 
      lastError: '',
 
    }
 
  } catch (error) {
 
    // 到这里说明这一次调用失败了,
 
    // 下面要决定的是继续重试,还是直接切到降级节点
 
    if (state.retryCount >= 2) {
 
      return new Command({
 
        update: {
 
          status: 'failed',
 
          lastError: error instanceof Error ? error.message : '搜索接口调用失败',
 
        },
 
        goto: 'fallbackAnswer',
 
      })
 
    }
 
    return new Command({
 
      update: {
 
        retryCount: state.retryCount + 1,
 
        status: 'retrying',
 
        lastError: error instanceof Error ? error.message : '搜索接口调用失败',
 
      },
 
      goto: 'callSearchApi',
 
    })
 
  }
 
}
 
const fallbackAnswer: GraphNode<typeof State> = (state) => {
 
  return {
 
    result: `最终降级:重试 ${state.retryCount} 次后仍然失败。`,
 
  }
 
}
 
const graph = new StateGraph(State)
 
  .addNode('callSearchApi', callSearchApi, { ends: ['callSearchApi', 'fallbackAnswer'] })
 
  .addNode('fallbackAnswer', fallbackAnswer)
 
  .addEdge(START, 'callSearchApi')
 
  .addEdge('fallbackAnswer', END)
 
  .compile()

这一段和前面的差别在于,失败以后图没有立刻结束,而是明确地沿着另一条路径再试一次。

只要重试条件和退出条件写清楚,图里的失败处理就会比散落在各处的 if/else 更稳。

这里也能顺手把边界看清楚:

  • 值得重试的问题,通常是超时、抖动、偶发空结果
  • 不值得重试的问题,通常是参数错误、权限错误、输入本身就不成立

6. 有些错误不该重试,而该直接降级

不是所有失败都值得重试。

如果是权限错误、参数不合法、用户输入本身就不完整,那你再试三次通常也不会变好。这个时候更合适的办法往往不是回边,而是直接切到降级节点。

比如:

工具不可用时,先给一个保守回答;外部搜索失败时,退回本地知识库;审批节点拿不到结果时,先把状态标记成待人工处理。

这类节点和前面的 fallbackAnswer 一样,真正的价值不是「回答得多聪明」,而是图没有因此直接死掉。

7. 并行场景里,最怕的是一边成功一边失败

前面讲层级团队和并行协作时,图里最容易被忽略的一个现实问题是:

两个并行节点,不一定会一起成功。

比如研究员拿回来了竞品信息,但风险分析接口超时了。这个时候,汇总节点就会面临一个选择:

是拿到一半结果就继续收口,还是先标记失败,还是再补一次重试。

所以并行图里最好别只存「结果」,还要存每个分支自己的状态。

// parallel-status.ts
const State = new StateSchema({
 
  researchResult: z.string().default(''),
 
  riskResult: z.string().default(''),
 
  researchStatus: z.string().default('idle'),
 
  riskStatus: z.string().default('idle'),
 
  finalAnswer: z.string().default(''),
 
})

这样到了汇总节点,你才能看清楚到底是哪一边出了问题,而不是只看到「最终答案怎么少了一块」。

8. 人工介入,其实也是一种容错方式

前面那篇审批流已经讲过,interrupt() 能让图暂停,等外部决定以后再继续。

把它放到这一篇里看,会发现它其实也是一种容错。

有些问题,系统不应该自己硬判断。比如:

模型打分处在灰区,检索结果彼此冲突,或者工具执行结果看起来可疑。

这时候最稳的做法往往不是再让模型自己猜,而是停下来,把当前状态交给人工确认。

所以图里的容错不一定都是自动恢复,有时候恰恰是「别自动往下跑」。

9. 写这类图时最容易出的问题

最常见的问题,是只在日志里记错误,不把错误写进状态。这样节点内部看起来已经兜住了,图本身却根本不知道发生了什么,后面也就没法继续做判断。

第二个问题,是重试没有出口。只要失败就往回跳,却没有最大次数、没有降级路径,最后图会一直在原地打转。

还有一种情况,是把所有失败都归成一种。模型超时、参数错误、人工审批未完成、外部工具 500,全被塞进同一个 error 字段里。这样后面虽然看起来「有错误状态」,但实际上你还是不知道该怎么处理。

10. 总结

走到这里,这一章已经不只是讲怎么把图写出来了,也开始讲怎么让图在真实环境里活下来。

前面的状态、路由、暂停、子图、多 Agent,解决的是图怎么组织;这一篇的重点是图出问题以后怎么继续工作。节点内兜底、状态留痕、重试、降级、人工介入,这几层合在一起,图才会真正有韧性。

下一篇就进入最后一篇实战:用 LangGraph 重构 AI 伴侣管线。到那时,前面这些状态、控制流、长期记忆、多 Agent 和容错能力,都会落到同一条完整系统里。