LangGraphjs可中断可恢复的AI工作流

01 先说结论:AI 工作流真正的敌人不是幻觉,是"不可恢复"

去年到今年,我给三个项目接过 AI 能力。第一个是文档摘要,第二个是客服工单预处理,第三个是营销文案生成加合规审查。

三个项目上线后,用户投诉最多的不是"AI 说错话"。说错话大家有心理预期,重新点一次就行。投诉最多的是这一类:

"我等了四十秒,进度条走到 80%,然后页面白了。我再点一次,它从头开始重新生成,又是四十秒。"

这才是真正的体验灾难。一次 LLM 调用两三秒可以接受,但一个稍微复杂点的 AI 流程------生成、检索、校验、改写、再校验------链路一长,随便哪一环抖一下,整条链路的中间产物就全没了。你在浏览器里点了刷新,服务端那边什么记忆都没留下。

我最开始的处理方式很典型:给每个环节包一层 try-catch,失败了就重试三次,重试还失败就 toast 一个"生成失败请重试"。这套写法能跑,但它有个根本缺陷------它把"流程"当成了"一段代码",而流程本质上是一个有状态的过程。

代码执行完就结束了,状态跟着调用栈一起消失。而流程需要的是:我现在走到哪一步、前面几步产出了什么、下一步该往哪拐、能不能从中间某一步接着往下走。

这就是状态机该干的事。LangGraph.js 提供的正是这个东西------不是又一个 LLM 封装库,而是一套把 AI 流程建模成有向图 + 持久化状态的运行时。

这篇文章我会从零搭一条真实的工作流:营销文案生成 → 合规风险扫描 → 有风险就交给人审批 → 通过后发布。它能中途停下来等人点确认,能在浏览器刷新之后从断点继续,能在改写失败时自己拐回上一步重来。全部代码 TypeScript,可以直接跑。


02 从 try-catch 到状态机:我踩过的三个坑

在切到 LangGraph.js 之前,我先说说手写编排会遇到什么。这三个坑不是理论推演,是我实打实趟过的。

第一个坑:重试粒度太粗。 手写的流程一般是 await stepA(); await stepB(); await stepC();。stepC 失败了,你想只重跑 stepC,但 stepC 依赖 stepB 的输出,而 stepB 的输出存在函数局部变量里。要么你把所有中间产物提到外层对象上手动管理,要么你只能整条重跑。前者写起来很快就会变成一坨互相赋值的意大利面。

第二个坑:人工介入没地方插。 合规审查这种场景,AI 只能给建议,最终发不发得人点头。这意味着流程必须能停在半路,把控制权交出去,然后在几分钟甚至几小时后被另一个 HTTP 请求唤醒继续。用普通的 async 函数根本无法表达这件事------你不可能让一个 Promise 挂起三小时等一个新请求进来。

我当时的临时方案是把流程拆成两个接口:/api/generate 生成完存数据库,/api/approve 读数据库继续。听起来合理,但流程一旦从两步变成五步,你就要维护五张表状态和十几个分支判断,而且每加一步都要动数据库结构。

第三个坑:可观测性为零。 出问题了,你只知道"失败了",不知道失败在哪个节点、当时的状态是什么、这次跑了几个循环。日志得自己一行行埋,埋得不全就等于没埋。

这三个坑的共同根因是同一个:流程状态没有一等公民的地位。它散落在闭包、局部变量和数据库字段里,没有统一的读写入口。

LangGraph.js 的解法很直接------先定义状态,再定义状态怎么被节点改写,最后定义节点之间怎么连。状态是显式的、可序列化的、可以随时快照的。剩下的能力(断点续跑、人工介入、流式追踪)全都是这个设计的自然推论。


03 三个抽象搞定:State、Node、Edge

LangGraph.js 的心智模型极简,就三样东西。理解了这三样,剩下的都是 API 细节。

State(状态):整条工作流共享的一个对象。你要显式声明它有哪些字段,以及每个字段被多个节点写入时怎么合并。这个"怎么合并"叫 reducer------默认是后写覆盖前写,但你也可以让它变成数组追加。

Node(节点):一个普通的异步函数,签名是 (state) => Partial<State>。它读取当前状态,返回一个局部更新,运行时会用 reducer 把这个局部更新合并进全局状态。注意,节点不直接修改 state,只返回要改的部分------这一点和 React 的 setState 心智一致。

Edge(边):连接节点。普通边是固定的 A→B。条件边则是一个函数,读取当前状态,返回下一个节点的名字------这就是"图自己决定往哪拐"的能力来源。

先把依赖装上,版本我用的是这几个:

kotlin 复制代码
npm i @langchain/langgraph@^0.4.0 @langchain/core@^0.3.0 @langchain/openai@^0.4.0 zod@^3.23.8

然后定义状态。这是整个 Demo 的地基:

php 复制代码
// src/workflow/state.ts
import { Annotation } from "@langchain/langgraph";

/**
 * 工作流状态定义。
 * 每个字段都要显式声明 reducer,否则并发写入时行为不可预期。
 */
export const CopyState = Annotation.Root({
  /** 用户输入的主题 */
  topic: Annotation<string>,

  /** 当前草稿,后写覆盖前写 */
  draft: Annotation<string>({
    reducer: (_prev, next) => next,
    default: () => "",
  }),

  /** 风险项,多次扫描的结果要累加而不是覆盖 */
  risks: Annotation<string[]>({
    reducer: (prev, next) => [...prev, ...next],
    default: () => [],
  }),

  /** 最近一次扫描的风险等级,条件路由靠它判断 */
  lastSeverity: Annotation<"none" | "low" | "high">({
    reducer: (_prev, next) => next,
    default: () => "none",
  }),

  /** 改写次数,用来防止无限循环 */
  revision: Annotation<number>({
    reducer: (prev, next) => prev + next,
    default: () => 0,
  }),

  /** 人工审批结果 */
  approved: Annotation<boolean>({
    reducer: (_prev, next) => next,
    default: () => false,
  }),

  /** 最终发布结果 */
  published: Annotation<string>({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
});

export type CopyStateType = typeof CopyState.State;

这段代码里最值得注意的是 risksrevision 这两个字段的 reducer。

risks 用的是数组拼接,因为风险扫描可能跑好几轮,我要看到完整的历史而不是只看最后一轮。revision 用的是累加,节点只需要 return { revision: 1 },不用先读旧值再加一------这避免了并发写入时的竞态。

这个细节我一开始没在意,全用了默认的覆盖 reducer,结果循环改写两轮之后,第一轮发现的风险神秘消失了,排查了半小时才反应过来是 reducer 的问题。reducer 不是可选的样板代码,它是你对"这个字段的语义是什么"的显式声明。


04 动手:把工作流画成一张图

状态定义好了,接下来写节点。四个节点:起草、扫描、人工闸门、发布。

先把模型客户端准备好。我用 ChatOpenAI 接 DeepSeek,因为它兼容 OpenAI 协议且便宜,换成任何兼容端点都一样:

php 复制代码
// src/workflow/llm.ts
import { ChatOpenAI } from "@langchain/openai";

export const llm = new ChatOpenAI({
  model: "deepseek-chat",
  temperature: 0.7,
  apiKey=***,
  configuration: {
    baseURL: "https://api.deepseek.com/v1",
  },
});

然后是节点。每个节点都是纯粹的 state => Partial<state>

javascript 复制代码
// src/workflow/nodes.ts
import { z } from "zod";
import { llm } from "./llm";
import type { CopyStateType } from "./state";

/** 节点一:根据主题起草文案;如果已有草稿和风险,则带着风险改写 */
export async function draftNode(state: CopyStateType) {
  const isRevision = state.draft.length > 0 && state.risks.length > 0;

  const prompt = isRevision
    ? [
        { role: "system" as const, content: "你是资深营销文案,需要在保留原意的前提下规避合规风险。" },
        {
          role: "user" as const,
          content: `原文案:
${state.draft}

需要规避的风险:
${state.risks.join("
")}

请输出改写后的文案,只输出正文。`,
        },
      ]
    : [
        { role: "system" as const, content: "你是资深营销文案,输出简洁有力的中文短文案。" },
        { role: "user" as const, content: `请围绕「${state.topic}」写一段 100 字以内的推广文案,只输出正文。` },
      ];

  const res = await llm.invoke(prompt);

  return {
    draft: String(res.content).trim(),
    revision: isRevision ? 1 : 0,
  };
}

/** 风险扫描的结构化输出 schema */
const RiskSchema = z.object({
  risks: z.array(z.string()).describe("发现的合规风险描述,没有则为空数组"),
  severity: z.enum(["none", "low", "high"]).describe("整体风险等级"),
});

/** 节点二:合规风险扫描,用结构化输出保证结果可判定 */
export async function reviewNode(state: CopyStateType) {
  const structured = llm.withStructuredOutput(RiskSchema, { name: "risk_report" });

  const report = await structured.invoke([
    {
      role: "system",
      content:
        "你是广告合规审查员。重点检查绝对化用语(最、第一、唯一)、疗效承诺、虚假促销、未标注的对比宣称。",
    },
    { role: "user", content: `请审查以下文案:
${state.draft}` },
  ]);

  return {
    risks: report.severity === "none" ? [] : report.risks,
    lastSeverity: report.severity,
  };
}

/** 节点四:发布(这里用打印代替真实的发布 API) */
export async function publishNode(state: CopyStateType) {
  const id = `post_${Date.now()}`;
  console.log(`[publish] ${id} 已发布:${state.draft}`);
  return { published: id };
}

reviewNode 里我用了 withStructuredOutput 配 Zod schema。这一点很关键:分支路由的判断依据必须是结构化的,不能靠正则去匹配自然语言。让模型返回 severity: "high" | "low" | "none",路由函数才有可靠的输入。

我之前偷懒让模型输出"有风险"或"无风险"四个字,然后 text.includes("无风险") 判断。上线三天就翻车了------模型有时候会输出"经审查,本文案无明显风险点",includes("无风险") 命中,看起来对了;但另一次输出"存在无风险表述的误导",也命中了,直接把高风险文案放行。


05 条件路由:让图自己决定下一步

现在把节点连成图。这里有个关键的分支逻辑:

  • 扫描结果无风险 → 直接发布
  • 有低风险,且改写次数没超上限 → 拐回起草节点自动改写
  • 有高风险,或改写已经超过 2 次 → 交给人工审批
javascript 复制代码
// src/workflow/graph.ts
import { StateGraph, START, END, MemorySaver } from "@langchain/langgraph";
import { CopyState, type CopyStateType } from "./state";
import { draftNode, reviewNode, publishNode } from "./nodes";
import { humanGateNode } from "./human-gate";

const MAX_REVISION = 2;

/** 条件路由:读状态,返回下一个节点名 */
function routeAfterReview(state: CopyStateType) {
  if (state.lastSeverity === "none") return "publish";
  if (state.lastSeverity === "low" && state.revision < MAX_REVISION) return "draft";
  return "humanGate";
}

const builder = new StateGraph(CopyState)
  .addNode("draft", draftNode)
  .addNode("review", reviewNode)
  .addNode("humanGate", humanGateNode)
  .addNode("publish", publishNode)
  .addEdge(START, "draft")
  .addEdge("draft", "review")
  .addConditionalEdges("review", routeAfterReview, {
    draft: "draft",
    humanGate: "humanGate",
    publish: "publish",
  })
  .addEdge("humanGate", "publish")
  .addEdge("publish", END);

/** 检查点存储:开发用内存,生产换成持久化实现 */
export const checkpointer = new MemorySaver();

export const workflow = builder.compile({ checkpointer });

addConditionalEdges 的第三个参数是一张映射表,把路由函数的返回值映射到真实节点名。这张表不是可有可无的------它同时也是给运行时的静态拓扑声明,LangGraph 用它来画图、做校验、生成可视化。少写一个分支,运行时会直接报错,而不是等到线上跑到那个分支才炸。

MAX_REVISION 这个上限一定要有。我第一版没加,遇到过模型和审查员互相不服的情况:改写完还是判高风险,判高风险又拐回去改写,跑了几十轮,烧掉几块钱 token 才被我掐掉。任何带循环的图,都必须有一个显式的收敛条件。

到这里,draft → review → draft → review → publish 这条自愈链路已经能跑了。图会自己判断要不要改写,改写几次,什么时候放弃自动修复交给人。


06 检查点:让流程刷新页面还能接着跑

compile({ checkpointer }) 这一行是整篇文章最有分量的一行。

加上 checkpointer 之后,每个节点执行完,运行时会自动把当前完整状态写一份快照,按 thread_id 归档。这意味着:

arduino 复制代码
const config = { configurable: { thread_id: "task-1024" } };

// 第一次调用,跑到某个节点崩了
await workflow.invoke({ topic: "夏季新品防晒霜" }, config);

// 进程重启、页面刷新、几小时之后......
// 读回完整快照
const snapshot = await workflow.getState(config);
console.log(snapshot.values.draft);   // 崩之前的草稿还在
console.log(snapshot.next);           // 下一个该跑的节点名

thread_id 就是这条流程实例的身份证。同一个 thread_id 的所有调用共享一份状态历史。你甚至可以翻历史:

arduino 复制代码
// 遍历所有历史检查点,做时间旅行调试
for await (const state of workflow.getStateHistory(config)) {
  console.log(state.config.configurable?.checkpoint_id, "→", state.next);
}

// 手动改写某个历史状态,然后从那个点重跑(人工纠偏的实现方式)
await workflow.updateState(config, { draft: "我手动改的文案" });
await workflow.invoke(null, config);

invoke(null, config)null 表示"不给新输入,从检查点接着跑"。这是断点续跑的标准姿势。

MemorySaver 只适合开发调试,进程一挂状态就没了。生产环境要换成持久化实现,官方提供了 SQLite 和 Postgres 版本:

css 复制代码
npm i @langchain/langgraph-checkpoint-postgres
ini 复制代码
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";

const checkpointer = PostgresSaver.fromConnString(process.env.DATABASE_URL!);
await checkpointer.setup(); // 首次运行建表,之后可以省略

export const workflow = builder.compile({ checkpointer });

换一行代码,整条工作流就获得了跨进程、跨部署的持久化能力。回想一下第 02 节说的第一个坑------手动管理五张状态表和十几个分支判断------现在被一行 checkpointer 收编了。


07 人工闸门 + 接进 Next.js

最后一块拼图:人工审批。LangGraph.js 提供了 interrupt(),它的语义不是"暂停",是把当前节点的执行切开,抛出一个待决策事件,等外部给一个 resume 值再从切口继续。

javascript 复制代码
// src/workflow/human-gate.ts
import { interrupt } from "@langchain/langgraph";
import type { CopyStateType } from "./state";

export function humanGateNode(state: CopyStateType) {
  // interrupt 会在这里中断执行,把 payload 冒泡给调用方
  const decision = interrupt({
    question: "该文案存在合规风险,是否仍要发布?",
    draft: state.draft,
    risks: state.risks,
  });

  // 收到 resume 之后,节点会从头重新执行一次,
  // 这次 interrupt 直接返回 resume 的值,不再中断
  return { approved: decision === "approve" };
}

有个必须知道的机制:恢复时,被中断的节点会整个重新执行一遍,只是 interrupt() 这次会直接返回 resume 值。所以 interrupt() 之前的代码要保证幂等,别在它前面写扣款、发短信这类有副作用的操作。

服务端接口这样写:

typescript 复制代码
// app/api/copy/route.ts
import { Command } from "@langchain/langgraph";
import { workflow } from "@/workflow/graph";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const { topic, threadId, resume } = await req.json();
  const config = { configurable: { thread_id: threadId } };

  // 有 resume 就是恢复,否则是新建
  const input = resume ? new Command({ resume }) : { topic };

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(encoder.encode(`event: ${event}
data: ${JSON.stringify(data)}

`));

      try {
        // streamMode: "updates" 只推每个节点产生的增量
        for await (const chunk of await workflow.stream(input, {
          ...config,
          streamMode: "updates",
        })) {
          const [node, update] = Object.entries(chunk)[0] ?? [];
          send("node", { node, update });
        }

        // 流跑完了,检查是不是停在了 interrupt 上
        const snapshot = await workflow.getState(config);
        const pending = snapshot.tasks?.[0]?.interrupts?.[0];

        if (pending) {
          send("interrupt", pending.value);
        } else {
          send("done", { published: snapshot.values.published, draft: snapshot.values.draft });
        }
      } catch (err) {
        send("error", { message: (err as Error).message });
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

注意 runtime = "nodejs"。如果你用 Postgres checkpointer,Edge runtime 跑不了数据库驱动。用 MemorySaver 也别用 Edge------Edge 实例之间不共享内存,恢复请求打到另一个实例上就找不到状态了。

前端这边,因为要发 POST,用不了原生 EventSource,得自己读流:

ini 复制代码
// app/components/CopyRunner.tsx
"use client";
import { useState, useRef } from "react";

type Pending = { question: string; draft: string; risks: string[] } | null;

export default function CopyRunner() {
  const [logs, setLogs] = useState<string[]>([]);
  const [pending, setPending] = useState<Pending>(null);
  const [result, setResult] = useState("");
  const threadRef = useRef<string>("");

  async function run(body: Record<string, unknown>) {
    const res = await fetch("/api/copy", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...body, threadId: threadRef.current }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      // SSE 以空行分隔消息
      const frames = buffer.split("

");
      buffer = frames.pop() ?? "";

      for (const frame of frames) {
        const event = frame.match(/^event: (.+)$/m)?.[1];
        const data = JSON.parse(frame.match(/^data: (.+)$/m)?.[1] ?? "{}");

        if (event === "node") setLogs((p) => [...p, `✓ ${data.node} 完成`]);
        if (event === "interrupt") setPending(data);
        if (event === "done") setResult(data.draft);
        if (event === "error") setLogs((p) => [...p, `✗ ${data.message}`]);
      }
    }
  }

  const start = () => {
    threadRef.current = `task-${Date.now()}`;
    setLogs([]); setResult(""); setPending(null);
    run({ topic: "夏季新品防晒霜" });
  };

  const decide = (choice: "approve" | "reject") => {
    setPending(null);
    run({ resume: choice });
  };

  return (
    <div style={{ fontFamily: "monospace", lineHeight: 1.8 }}>
      <button onClick~={start}>开始生成</button>
      <pre>{logs.join("
")}</pre>

      {pending && (
        <div style={{ border: "1px solid #e11", padding: 12, marginTop: 12 }}>
          <p>{pending.question}</p>
          <p>草稿:{pending.draft}</p>
          <ul>{pending.risks.map((r) => <li key={r}>{r}</li>)}</ul>
          <button onClick~={() => decide("approve")}>仍然发布</button>
          <button onClick~={() => decide("reject")}>驳回</button>
        </div>
      )}

      {result && <p>最终文案:{result}</p>}
    </div>
  );
}

跑起来是这样的:点开始,日志一行行冒出来 ✓ draft 完成✓ review 完成;如果判定高风险,界面弹出审批框,这时候你把浏览器关掉、服务重启,只要 thread_id 还在(存 localStorage 或 URL 里),重新打开发一个 resume 请求,流程照样从闸门继续往下走。

这就是状态机和一段 async 函数的本质区别:async 函数的生命周期绑在请求上,状态机的生命周期绑在 thread_id 上。

回头看整条链路,我们没写一行重试逻辑、没建一张状态表、没维护一个分支枚举。draftreviewpublish 三个节点各自只关心自己那点事,路由函数只关心怎么拐弯,checkpointer 只关心怎么存。复杂度没有消失,但它被切成了互不干扰的小块------这正是工程化该有的样子。


08

你现在项目里的 AI 流程,是几个 await 串起来的,还是已经上了编排框架?

如果是前者,最近一次线上事故是卡在哪一步?欢迎在评论区丢出来,我们一起看看画成状态图会长什么样。

觉得有用的话点个赞,代码可以直接抄走跑,跑通了回来告诉我一声。

相关推荐
@Mr_LiuYang1 小时前
状态栏动态上下文信息追加到Agent --《深入理解 AI Agent :设计原理与工程实践》实验2-8
人工智能·大模型·动态上下文·状态栏信息追加
NWU_白杨1 小时前
AI协作开发
ai编程
碳基猿1 小时前
新媒体运营的终局:从“内容创作”走向“运营系统竞争”
人工智能·新媒体运营·产品运营·新媒体矩阵·多账号管理·矩阵分发·矩阵运营方法论
阿里云大数据AI技术1 小时前
阿里云 EMR Daft AI Function:用 DataFrame 表达式搞定大模型调用与多模态向量化
人工智能·spark
jerryinwuhan1 小时前
鱼苗投放检测系统设计
人工智能
阿里云大数据AI技术2 小时前
官宣|Apache Fluss 毕业成为顶级项目,湖流一体开启 Agentic Lake 全面实时化时代
人工智能·flink
敲代码的玉米C2 小时前
测试一直在写你的真实数据根
前端·人工智能·架构
Mr.huang2 小时前
自注意力机制(Self‑Attention)
人工智能·深度学习
品牌测评2 小时前
大模型推理算力平台推荐分享|六家平台计费与架构拆解
大数据·人工智能·架构