1. 真正的审批流程,不是停一次就结束
上一篇我们已经把 interrupt() 跑通了,知道图可以在节点里暂停,等外部输入后再继续。
但真实业务里的 Human-in-the-Loop,通常不只是「停一下,点个确认」这么简单。
更常见的是这样一条链:先生成一版内容,交给编辑审核;编辑通过以后,再交给法务审核;任意一关打回,都回到修改节点重新出一版;整个过程还得留痕,后面能查是谁在什么时候给了什么意见。
如果只会把 interrupt() 塞进一个节点里,你能做出「人工确认」。
但如果要做「审批工作流」,还得把这些暂停点和整张图的状态流转连起来。
这一篇就把这件事完整走一遍。
2. 先把审批链路拆出来
先把流程拆开看,会更容易写。
这一篇用一个两级审批的例子来讲:generateDraft 先生成文案,editorReview 负责编辑审核,legalReview 负责法务审核,全部通过以后才进入 publish,只要有一关打回,就先进入 reviseDraft,再重新生成。
这条链里真正重要的,不是节点有几个,而是职责要分开。生成节点只管产出草稿,审核节点只管暂停、接收人工决定、更新审批状态,修改节点只管把流程切回待修改,发布节点只负责把最后结果落成已发布。这样后面你要把「编辑」换成「运营」,或者再加一个「品牌审核」,整张图都不用重写。
3. 状态里要放什么
审批工作流和前面那些纯自动图不一样。状态里至少要放三类东西:当前草稿本身、当前走到哪一个审批阶段、还有审批记录。
前两个字段直接覆盖就够了,审批记录不行。因为每次审核都会往里追加一条记录,最后应该保留完整历史。
01import { StateSchema, ReducedValue } from '@langchain/langgraph'02import { z } from 'zod'0304const appendLogs = (05current: Array<{06stage: string07reviewer: string08approved: boolean09comment: string10}>,11update: Array<{12stage: string13reviewer: string14approved: boolean15comment: string16}>,17) => {18return [...current, ...update]19}2021const State = new StateSchema({22topic: z.string().default(''),23draft: z.string().default(''),24feedback: z.string().default(''),25version: z.number().default(0),26status: z.string().default('drafting'),27reviewLogs: new ReducedValue(28z.array(29z.object({30stage: z.string(),31reviewer: z.string(),32approved: z.boolean(),33comment: z.string(),34}),35).default([]),36{ reducer: appendLogs },37),38})
这一段里最值得注意的是 reviewLogs。
它不是普通数组,而是用 ReducedValue 明确声明成「追加日志」。这样编辑审核写一条、法务审核再写一条时,结果会自然接到后面。
4. 审核节点真正做的事
审批节点不是只负责弹一个确认框。它还得把当前草稿和阶段信息带给调用方,接收调用方传回来的审核结果,再根据审核结果决定下一步去哪。
也正因为这样,这类节点通常都会把 interrupt() 和 Command 放在一起用。
01import type { GraphNode } from '@langchain/langgraph'02import { Command, interrupt } from '@langchain/langgraph'0304const editorReview: GraphNode<typeof State> = (state) => {05// 先暂停,把当前草稿和说明带出去06// 这里传给 interrupt 的内容最好保持可序列化,外部系统才容易接住它07const decision = interrupt({08stage: 'editor',09title: '请进行编辑审核',10draft: state.draft,11version: state.version,12})1314// 恢复后,decision 就是外部传回来的审核结果15if (decision.approved) {16return new Command({17update: {18status: 'editor-approved',19reviewLogs: [{20stage: 'editor',21reviewer: decision.reviewer,22approved: true,23comment: decision.comment ?? '',24}],25},26goto: 'legalReview',27})28}2930return new Command({31update: {32status: 'editor-rejected',33feedback: decision.comment ?? '请继续修改',34reviewLogs: [{35stage: 'editor',36reviewer: decision.reviewer,37approved: false,38comment: decision.comment ?? '',39}],40},41goto: 'reviseDraft',42})43}
这里最核心的点是:
审核节点自己就知道下一步该去哪,所以不需要再额外挂一条条件边。通过就往下走,打回就回修改节点,这个决定直接留在节点内部就够了。
5. 把整条审批工作流接起来
下面把完整流程连起来。
001import {002StateGraph,003StateSchema,004ReducedValue,005Command,006interrupt,007MemorySaver,008START,009END,010} from '@langchain/langgraph'011import type { GraphNode } from '@langchain/langgraph'012import { z } from 'zod'013014const appendLogs = (015current: Array<{016stage: string017reviewer: string018approved: boolean019comment: string020}>,021update: Array<{022stage: string023reviewer: string024approved: boolean025comment: string026}>,027) => {028return [...current, ...update]029}030031const State = new StateSchema({032topic: z.string().default(''),033draft: z.string().default(''),034feedback: z.string().default(''),035version: z.number().default(0),036status: z.string().default('drafting'),037reviewLogs: new ReducedValue(038z.array(039z.object({040stage: z.string(),041reviewer: z.string(),042approved: z.boolean(),043comment: z.string(),044}),045).default([]),046{ reducer: appendLogs },047),048})049050// 生成节点:第一次生成,或者根据 feedback 重新出一版051const generateDraft: GraphNode<typeof State> = (state) => {052const nextVersion = state.version + 1053054if (!state.feedback) {055return {056draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。`,057feedback: '',058version: nextVersion,059status: 'waiting-editor-review',060}061}062063return {064draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。它适合那些需要分支、暂停、恢复和审批的工作流。(本版根据反馈修改:${state.feedback})`,065feedback: '',066version: nextVersion,067status: 'waiting-editor-review',068}069}070071// 修改节点:这里只做状态切换,真正的改稿还是回 generateDraft072const reviseDraft: GraphNode<typeof State> = () => {073return {074status: 'revising',075}076}077078// 编辑审核:通过后去法务,打回后去修改079const editorReview: GraphNode<typeof State> = (state) => {080const decision = interrupt({081stage: 'editor',082title: '请进行编辑审核',083draft: state.draft,084version: state.version,085instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',086})087088if (decision.approved) {089return new Command({090update: {091status: 'waiting-legal-review',092reviewLogs: [{093stage: 'editor',094reviewer: decision.reviewer,095approved: true,096comment: decision.comment ?? '',097}],098},099goto: 'legalReview',100})101}102103return new Command({104update: {105status: 'editor-rejected',106feedback: decision.comment ?? '请继续修改',107reviewLogs: [{108stage: 'editor',109reviewer: decision.reviewer,110approved: false,111comment: decision.comment ?? '',112}],113},114goto: 'reviseDraft',115})116}117118// 法务审核:通过后发布,打回后也回修改119const legalReview: GraphNode<typeof State> = (state) => {120const decision = interrupt({121stage: 'legal',122title: '请进行法务审核',123draft: state.draft,124version: state.version,125instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',126})127128if (decision.approved) {129return new Command({130update: {131status: 'approved',132reviewLogs: [{133stage: 'legal',134reviewer: decision.reviewer,135approved: true,136comment: decision.comment ?? '',137}],138},139goto: 'publish',140})141}142143return new Command({144update: {145status: 'legal-rejected',146feedback: decision.comment ?? '请继续修改',147reviewLogs: [{148stage: 'legal',149reviewer: decision.reviewer,150approved: false,151comment: decision.comment ?? '',152}],153},154goto: 'reviseDraft',155})156}157158const publish: GraphNode<typeof State> = (state) => {159return {160status: 'published',161draft: `[已发布 V${state.version}] ${state.draft}`,162}163}164165const graph = new StateGraph(State)166.addNode('generateDraft', generateDraft)167.addNode('reviseDraft', reviseDraft)168.addNode('editorReview', editorReview, { ends: ['legalReview', 'reviseDraft'] })169.addNode('legalReview', legalReview, { ends: ['publish', 'reviseDraft'] })170.addNode('publish', publish)171.addEdge(START, 'generateDraft')172.addEdge('generateDraft', 'editorReview')173.addEdge('reviseDraft', 'generateDraft')174.addEdge('publish', END)175.compile({ checkpointer: new MemorySaver() })
这张图里最值得看的,是两个审核节点和那条回退边。
editorReview 和 legalReview 都在节点内部完成了暂停、接收人工结果、决定下一步路由这三件事。reviseDraft -> generateDraft 这条普通边则把「打回以后重走一版」这件事接了起来,所以流程不会停在打回那里,而是会重新进入下一轮审批。
6. 外部怎么驱动这条图
图写完以后,外部驱动的节奏其实很固定:先启动工作流,让它跑到第一个审批点;再通过 getState() 看当前停在哪个节点、带出来了什么审核信息;最后用 Command({ resume }) 把人工审核结果传回去。
01// 整条审批链都要沿用同一个 thread_id,这样恢复时才能接回原来的流程02const config = { configurable: { thread_id: 'approval-001' } }0304// ① 启动:先生成第一版,然后暂停在编辑审核05const initial = await graph.invoke({ topic: 'LangGraph 审批流入门' }, config)0607let snapshot = await graph.getState(config)08console.log(snapshot.next)09// → ['editorReview']1011// 第一次 invoke 返回的 __interrupt__ 就是外部界面最适合直接读取的审批信息12console.log(initial.__interrupt__)13// → {14// stage: 'editor',15// title: '请进行编辑审核',16// draft: '【LangGraph 审批流入门】...',17// version: 1,18// }1920// ② 编辑通过,继续往法务走21await graph.invoke(22new Command({23resume: {24approved: true,25reviewer: '编辑 Alice',26comment: '内容没问题,可以继续',27},28}),29config,30)3132snapshot = await graph.getState(config)33console.log(snapshot.next)34// → ['legalReview']3536// ③ 法务通过,图走到发布节点并结束37const result = await graph.invoke(38new Command({39resume: {40approved: true,41reviewer: '法务 Bob',42comment: '可以发布',43},44}),45config,46)4748console.log(result.status)49// → published5051console.log(result.reviewLogs)52// → [53// { stage: 'editor', reviewer: '编辑 Alice', approved: true, comment: '内容没问题,可以继续' },54// { stage: 'legal', reviewer: '法务 Bob', approved: true, comment: '可以发布' },55// ]
如果其中任意一关打回,恢复时把 approved 设成 false 就行。
图会自己回到 reviseDraft -> generateDraft 这条链,然后再重新停到下一轮审批节点。
7. 这种工作流里最容易踩的坑
最容易出问题的,往往不是 interrupt() 本身,而是审批状态怎么设计。
如果把所有审核信息都塞进一段文本里,短期看着省事,后面一旦要查「第几版是谁打回的」或者统计某一关的通过率,马上就会变得很难处理。审批记录最好像前面的 reviewLogs 一样,用结构化字段存。
另外一个常见问题是恢复时换了 thread_id。审批流暂停以后,外部系统再回来恢复时,必须把同一条线程重新接上。只要线程换了,恢复就不是沿着原来的审批流继续跑。
还有一个坑是把副作用放在 interrupt() 前面。比如节点一进来先发通知、写数据库、调第三方接口,然后才 interrupt()。恢复时节点会从头再跑一遍,这些动作就会重复发生。所以审批节点前半段尽量只做准备数据,真正的副作用放到拿到审核结果以后。
8. 总结
到了这里,interrupt() 就不再只是一个「暂停函数」了。它和 Command、Checkpointer 放在一起以后,已经能落成一条完整的人工审批工作流。
前面几篇讲的状态、路由、持久化和控制流,这一篇都真正放进了一个能工作的业务流程里。现在这条链已经不只是能暂停,还能打回、重走、留痕、再继续往下发布。
下一篇讲 子图。到那时,问题会从「怎么把审批流跑起来」变成「这条审批流能不能拆出来,变成别的图也能复用的一块模块」。