
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;
这段代码里最值得注意的是 risks 和 revision 这两个字段的 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 上。
回头看整条链路,我们没写一行重试逻辑、没建一张状态表、没维护一个分支枚举。draft、review、publish 三个节点各自只关心自己那点事,路由函数只关心怎么拐弯,checkpointer 只关心怎么存。复杂度没有消失,但它被切成了互不干扰的小块------这正是工程化该有的样子。
08
你现在项目里的 AI 流程,是几个 await 串起来的,还是已经上了编排框架?
如果是前者,最近一次线上事故是卡在哪一步?欢迎在评论区丢出来,我们一起看看画成状态图会长什么样。
觉得有用的话点个赞,代码可以直接抄走跑,跑通了回来告诉我一声。