Human-in-the-Loop

要点

  • 上一篇我们已经把 interrupt() 跑通了,知道图可以在节点里暂停,等外部输入后再继续
  • 审批工作流和前面那些纯自动图不一样
  • 审批节点不是只负责弹一个确认框
  • 下面把完整流程连起来
  • 图写完以后,外部驱动的节奏其实很固定:先启动工作流,让它跑到第一个审批点;再通过 getState() 看当前停在哪个节点、带出来了什么审核信息;最后用

内容

1. 真正的审批流程,不是停一次就结束

上一篇我们已经把 interrupt() 跑通了,知道图可以在节点里暂停,等外部输入后再继续。

但真实业务里的 Human-in-the-Loop,通常不只是「停一下,点个确认」这么简单。

更常见的是这样一条链:先生成一版内容,交给编辑审核;编辑通过以后,再交给法务审核;任意一关打回,都回到修改节点重新出一版;整个过程还得留痕,后面能查是谁在什么时候给了什么意见。

如果只会把 interrupt() 塞进一个节点里,你能做出「人工确认」。

但如果要做「审批工作流」,还得把这些暂停点和整张图的状态流转连起来。

这一篇就把这件事完整走一遍。

2. 先把审批链路拆出来

先把流程拆开看,会更容易写。

这一篇用一个两级审批的例子来讲:generateDraft 先生成文案,editorReview 负责编辑审核,legalReview 负责法务审核,全部通过以后才进入 publish,只要有一关打回,就先进入 reviseDraft,再重新生成。

这条链里真正重要的,不是节点有几个,而是职责要分开。生成节点只管产出草稿,审核节点只管暂停、接收人工决定、更新审批状态,修改节点只管把流程切回待修改,发布节点只负责把最后结果落成已发布。这样后面你要把「编辑」换成「运营」,或者再加一个「品牌审核」,整张图都不用重写。

3. 状态里要放什么

审批工作流和前面那些纯自动图不一样。状态里至少要放三类东西:当前草稿本身、当前走到哪一个审批阶段、还有审批记录。

前两个字段直接覆盖就够了,审批记录不行。因为每次审核都会往里追加一条记录,最后应该保留完整历史。

// approval-state.ts
import { StateSchema, ReducedValue } from '@langchain/langgraph'
 
import { z } from 'zod'
 
const appendLogs = (
 
  current: Array<{
 
    stage: string
 
    reviewer: string
 
    approved: boolean
 
    comment: string
 
  }>,
 
  update: Array<{
 
    stage: string
 
    reviewer: string
 
    approved: boolean
 
    comment: string
 
  }>,
 
) => {
 
  return [...current, ...update]
 
}
 
const State = new StateSchema({
 
  topic: z.string().default(''),
 
  draft: z.string().default(''),
 
  feedback: z.string().default(''),
 
  version: z.number().default(0),
 
  status: z.string().default('drafting'),
 
  reviewLogs: new ReducedValue(
 
    z.array(
 
      z.object({
 
        stage: z.string(),
 
        reviewer: z.string(),
 
        approved: z.boolean(),
 
        comment: z.string(),
 
      }),
 
    ).default([]),
 
    { reducer: appendLogs },
 
  ),
 
})

这一段里最值得注意的是 reviewLogs

它不是普通数组,而是用 ReducedValue 明确声明成「追加日志」。这样编辑审核写一条、法务审核再写一条时,结果会自然接到后面。

4. 审核节点真正做的事

审批节点不是只负责弹一个确认框。它还得把当前草稿和阶段信息带给调用方,接收调用方传回来的审核结果,再根据审核结果决定下一步去哪。

也正因为这样,这类节点通常都会把 interrupt()Command 放在一起用。

// review-node.ts
import type { GraphNode } from '@langchain/langgraph'
 
import { Command, interrupt } from '@langchain/langgraph'
 
const editorReview: GraphNode<typeof State> = (state) => {
 
  // 先暂停,把当前草稿和说明带出去
 
  // 这里传给 interrupt 的内容最好保持可序列化,外部系统才容易接住它
 
  const decision = interrupt({
 
    stage: 'editor',
 
    title: '请进行编辑审核',
 
    draft: state.draft,
 
    version: state.version,
 
  })
 
  // 恢复后,decision 就是外部传回来的审核结果
 
  if (decision.approved) {
 
    return new Command({
 
      update: {
 
        status: 'editor-approved',
 
        reviewLogs: [{
 
          stage: 'editor',
 
          reviewer: decision.reviewer,
 
          approved: true,
 
          comment: decision.comment ?? '',
 
        }],
 
      },
 
      goto: 'legalReview',
 
    })
 
  }
 
  return new Command({
 
    update: {
 
      status: 'editor-rejected',
 
      feedback: decision.comment ?? '请继续修改',
 
      reviewLogs: [{
 
        stage: 'editor',
 
        reviewer: decision.reviewer,
 
        approved: false,
 
        comment: decision.comment ?? '',
 
      }],
 
    },
 
    goto: 'reviseDraft',
 
  })
 
}

这里最核心的点是:

审核节点自己就知道下一步该去哪,所以不需要再额外挂一条条件边。通过就往下走,打回就回修改节点,这个决定直接留在节点内部就够了。

5. 把整条审批工作流接起来

下面把完整流程连起来。

// approval-workflow.ts
import {
 
  StateGraph,
 
  StateSchema,
 
  ReducedValue,
 
  Command,
 
  interrupt,
 
  MemorySaver,
 
  START,
 
  END,
 
} from '@langchain/langgraph'
 
import type { GraphNode } from '@langchain/langgraph'
 
import { z } from 'zod'
 
const appendLogs = (
 
  current: Array<{
 
    stage: string
 
    reviewer: string
 
    approved: boolean
 
    comment: string
 
  }>,
 
  update: Array<{
 
    stage: string
 
    reviewer: string
 
    approved: boolean
 
    comment: string
 
  }>,
 
) => {
 
  return [...current, ...update]
 
}
 
const State = new StateSchema({
 
  topic: z.string().default(''),
 
  draft: z.string().default(''),
 
  feedback: z.string().default(''),
 
  version: z.number().default(0),
 
  status: z.string().default('drafting'),
 
  reviewLogs: new ReducedValue(
 
    z.array(
 
      z.object({
 
        stage: z.string(),
 
        reviewer: z.string(),
 
        approved: z.boolean(),
 
        comment: z.string(),
 
      }),
 
    ).default([]),
 
    { reducer: appendLogs },
 
  ),
 
})
 
// 生成节点:第一次生成,或者根据 feedback 重新出一版
 
const generateDraft: GraphNode<typeof State> = (state) => {
 
  const nextVersion = state.version + 1
 
  if (!state.feedback) {
 
    return {
 
      draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。`,
 
      feedback: '',
 
      version: nextVersion,
 
      status: 'waiting-editor-review',
 
    }
 
  }
 
  return {
 
    draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。它适合那些需要分支、暂停、恢复和审批的工作流。(本版根据反馈修改:${state.feedback})`,
 
    feedback: '',
 
    version: nextVersion,
 
    status: 'waiting-editor-review',
 
  }
 
}
 
// 修改节点:这里只做状态切换,真正的改稿还是回 generateDraft
 
const reviseDraft: GraphNode<typeof State> = () => {
 
  return {
 
    status: 'revising',
 
  }
 
}
 
// 编辑审核:通过后去法务,打回后去修改
 
const editorReview: GraphNode<typeof State> = (state) => {
 
  const decision = interrupt({
 
    stage: 'editor',
 
    title: '请进行编辑审核',
 
    draft: state.draft,
 
    version: state.version,
 
    instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',
 
  })
 
  if (decision.approved) {
 
    return new Command({
 
      update: {
 
        status: 'waiting-legal-review',
 
        reviewLogs: [{
 
          stage: 'editor',
 
          reviewer: decision.reviewer,
 
          approved: true,
 
          comment: decision.comment ?? '',
 
        }],
 
      },
 
      goto: 'legalReview',
 
    })
 
  }
 
  return new Command({
 
    update: {
 
      status: 'editor-rejected',
 
      feedback: decision.comment ?? '请继续修改',
 
      reviewLogs: [{
 
        stage: 'editor',
 
        reviewer: decision.reviewer,
 
        approved: false,
 
        comment: decision.comment ?? '',
 
      }],
 
    },
 
    goto: 'reviseDraft',
 
  })
 
}
 
// 法务审核:通过后发布,打回后也回修改
 
const legalReview: GraphNode<typeof State> = (state) => {
 
  const decision = interrupt({
 
    stage: 'legal',
 
    title: '请进行法务审核',
 
    draft: state.draft,
 
    version: state.version,
 
    instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',
 
  })
 
  if (decision.approved) {
 
    return new Command({
 
      update: {
 
        status: 'approved',
 
        reviewLogs: [{
 
          stage: 'legal',
 
          reviewer: decision.reviewer,
 
          approved: true,
 
          comment: decision.comment ?? '',
 
        }],
 
      },
 
      goto: 'publish',
 
    })
 
  }
 
  return new Command({
 
    update: {
 
      status: 'legal-rejected',
 
      feedback: decision.comment ?? '请继续修改',
 
      reviewLogs: [{
 
        stage: 'legal',
 
        reviewer: decision.reviewer,
 
        approved: false,
 
        comment: decision.comment ?? '',
 
      }],
 
    },
 
    goto: 'reviseDraft',
 
  })
 
}
 
const publish: GraphNode<typeof State> = (state) => {
 
  return {
 
    status: 'published',
 
    draft: `[已发布 V${state.version}] ${state.draft}`,
 
  }
 
}
 
const graph = new StateGraph(State)
 
  .addNode('generateDraft', generateDraft)
 
  .addNode('reviseDraft', reviseDraft)
 
  .addNode('editorReview', editorReview, { ends: ['legalReview', 'reviseDraft'] })
 
  .addNode('legalReview', legalReview, { ends: ['publish', 'reviseDraft'] })
 
  .addNode('publish', publish)
 
  .addEdge(START, 'generateDraft')
 
  .addEdge('generateDraft', 'editorReview')
 
  .addEdge('reviseDraft', 'generateDraft')
 
  .addEdge('publish', END)
 
  .compile({ checkpointer: new MemorySaver() })

这张图里最值得看的,是两个审核节点和那条回退边。

editorReviewlegalReview 都在节点内部完成了暂停、接收人工结果、决定下一步路由这三件事。reviseDraft -> generateDraft 这条普通边则把「打回以后重走一版」这件事接了起来,所以流程不会停在打回那里,而是会重新进入下一轮审批。

6. 外部怎么驱动这条图

图写完以后,外部驱动的节奏其实很固定:先启动工作流,让它跑到第一个审批点;再通过 getState() 看当前停在哪个节点、带出来了什么审核信息;最后用 Command(&#123; resume &#125;) 把人工审核结果传回去。

// approval-run.ts
// 整条审批链都要沿用同一个 thread_id,这样恢复时才能接回原来的流程
 
const config = { configurable: { thread_id: 'approval-001' } }
 
// ① 启动:先生成第一版,然后暂停在编辑审核
 
const initial = await graph.invoke({ topic: 'LangGraph 审批流入门' }, config)
 
let snapshot = await graph.getState(config)
 
console.log(snapshot.next)
 
// → ['editorReview']
 
// 第一次 invoke 返回的 __interrupt__ 就是外部界面最适合直接读取的审批信息
 
console.log(initial.__interrupt__)
 
// → {
 
//     stage: 'editor',
 
//     title: '请进行编辑审核',
 
//     draft: '【LangGraph 审批流入门】...',
 
//     version: 1,
 
//   }
 
// ② 编辑通过,继续往法务走
 
await graph.invoke(
 
  new Command({
 
    resume: {
 
      approved: true,
 
      reviewer: '编辑 Alice',
 
      comment: '内容没问题,可以继续',
 
    },
 
  }),
 
  config,
 
)
 
snapshot = await graph.getState(config)
 
console.log(snapshot.next)
 
// → ['legalReview']
 
// ③ 法务通过,图走到发布节点并结束
 
const result = await graph.invoke(
 
  new Command({
 
    resume: {
 
      approved: true,
 
      reviewer: '法务 Bob',
 
      comment: '可以发布',
 
    },
 
  }),
 
  config,
 
)
 
console.log(result.status)
 
// → published
 
console.log(result.reviewLogs)
 
// → [
 
//   { stage: 'editor', reviewer: '编辑 Alice', approved: true, comment: '内容没问题,可以继续' },
 
//   { stage: 'legal', reviewer: '法务 Bob', approved: true, comment: '可以发布' },
 
// ]

如果其中任意一关打回,恢复时把 approved 设成 false 就行。

图会自己回到 reviseDraft -> generateDraft 这条链,然后再重新停到下一轮审批节点。

7. 这种工作流里最容易踩的坑

最容易出问题的,往往不是 interrupt() 本身,而是审批状态怎么设计。

如果把所有审核信息都塞进一段文本里,短期看着省事,后面一旦要查「第几版是谁打回的」或者统计某一关的通过率,马上就会变得很难处理。审批记录最好像前面的 reviewLogs 一样,用结构化字段存。

另外一个常见问题是恢复时换了 thread_id。审批流暂停以后,外部系统再回来恢复时,必须把同一条线程重新接上。只要线程换了,恢复就不是沿着原来的审批流继续跑。

还有一个坑是把副作用放在 interrupt() 前面。比如节点一进来先发通知、写数据库、调第三方接口,然后才 interrupt()。恢复时节点会从头再跑一遍,这些动作就会重复发生。所以审批节点前半段尽量只做准备数据,真正的副作用放到拿到审核结果以后。

8. 总结

到了这里,interrupt() 就不再只是一个「暂停函数」了。它和 Command、Checkpointer 放在一起以后,已经能落成一条完整的人工审批工作流。

前面几篇讲的状态、路由、持久化和控制流,这一篇都真正放进了一个能工作的业务流程里。现在这条链已经不只是能暂停,还能打回、重走、留痕、再继续往下发布。

下一篇讲 子图。到那时,问题会从「怎么把审批流跑起来」变成「这条审批流能不能拆出来,变成别的图也能复用的一块模块」。