AI 电子伴侣
创建时间: 2026-04-03最后更新: 2026-04-23

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

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

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

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

如果只会把 interrupt() 塞进一个节点里,你能做出「人工确认」。
但如果要做「审批工作流」,还得把这些暂停点和整张图的状态流转连起来。

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

2. 先把审批链路拆出来

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

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

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

3. 状态里要放什么

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

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

approval-state.ts
01
import { StateSchema, ReducedValue } from '@langchain/langgraph'
02
import { z } from 'zod'
03
04
const appendLogs = (
05
current: Array<{
06
stage: string
07
reviewer: string
08
approved: boolean
09
comment: string
10
}>,
11
update: Array<{
12
stage: string
13
reviewer: string
14
approved: boolean
15
comment: string
16
}>,
17
) => {
18
return [...current, ...update]
19
}
20
21
const State = new StateSchema({
22
topic: z.string().default(''),
23
draft: z.string().default(''),
24
feedback: z.string().default(''),
25
version: z.number().default(0),
26
status: z.string().default('drafting'),
27
reviewLogs: new ReducedValue(
28
z.array(
29
z.object({
30
stage: z.string(),
31
reviewer: z.string(),
32
approved: z.boolean(),
33
comment: z.string(),
34
}),
35
).default([]),
36
{ reducer: appendLogs },
37
),
38
})

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

4. 审核节点真正做的事

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

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

review-node.ts
01
import type { GraphNode } from '@langchain/langgraph'
02
import { Command, interrupt } from '@langchain/langgraph'
03
04
const editorReview: GraphNode<typeof State> = (state) => {
05
// 先暂停,把当前草稿和说明带出去
06
// 这里传给 interrupt 的内容最好保持可序列化,外部系统才容易接住它
07
const decision = interrupt({
08
stage: 'editor',
09
title: '请进行编辑审核',
10
draft: state.draft,
11
version: state.version,
12
})
13
14
// 恢复后,decision 就是外部传回来的审核结果
15
if (decision.approved) {
16
return new Command({
17
update: {
18
status: 'editor-approved',
19
reviewLogs: [{
20
stage: 'editor',
21
reviewer: decision.reviewer,
22
approved: true,
23
comment: decision.comment ?? '',
24
}],
25
},
26
goto: 'legalReview',
27
})
28
}
29
30
return new Command({
31
update: {
32
status: 'editor-rejected',
33
feedback: decision.comment ?? '请继续修改',
34
reviewLogs: [{
35
stage: 'editor',
36
reviewer: decision.reviewer,
37
approved: false,
38
comment: decision.comment ?? '',
39
}],
40
},
41
goto: 'reviseDraft',
42
})
43
}

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

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

下面把完整流程连起来。

approval-workflow.ts
001
import {
002
StateGraph,
003
StateSchema,
004
ReducedValue,
005
Command,
006
interrupt,
007
MemorySaver,
008
START,
009
END,
010
} from '@langchain/langgraph'
011
import type { GraphNode } from '@langchain/langgraph'
012
import { z } from 'zod'
013
014
const appendLogs = (
015
current: Array<{
016
stage: string
017
reviewer: string
018
approved: boolean
019
comment: string
020
}>,
021
update: Array<{
022
stage: string
023
reviewer: string
024
approved: boolean
025
comment: string
026
}>,
027
) => {
028
return [...current, ...update]
029
}
030
031
const State = new StateSchema({
032
topic: z.string().default(''),
033
draft: z.string().default(''),
034
feedback: z.string().default(''),
035
version: z.number().default(0),
036
status: z.string().default('drafting'),
037
reviewLogs: new ReducedValue(
038
z.array(
039
z.object({
040
stage: z.string(),
041
reviewer: z.string(),
042
approved: z.boolean(),
043
comment: z.string(),
044
}),
045
).default([]),
046
{ reducer: appendLogs },
047
),
048
})
049
050
// 生成节点:第一次生成,或者根据 feedback 重新出一版
051
const generateDraft: GraphNode<typeof State> = (state) => {
052
const nextVersion = state.version + 1
053
054
if (!state.feedback) {
055
return {
056
draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。`,
057
feedback: '',
058
version: nextVersion,
059
status: 'waiting-editor-review',
060
}
061
}
062
063
return {
064
draft: `【${state.topic}】LangGraph 让复杂 Agent 的流程控制、状态管理和持久化都回到代码里。它适合那些需要分支、暂停、恢复和审批的工作流。(本版根据反馈修改:${state.feedback})`,
065
feedback: '',
066
version: nextVersion,
067
status: 'waiting-editor-review',
068
}
069
}
070
071
// 修改节点:这里只做状态切换,真正的改稿还是回 generateDraft
072
const reviseDraft: GraphNode<typeof State> = () => {
073
return {
074
status: 'revising',
075
}
076
}
077
078
// 编辑审核:通过后去法务,打回后去修改
079
const editorReview: GraphNode<typeof State> = (state) => {
080
const decision = interrupt({
081
stage: 'editor',
082
title: '请进行编辑审核',
083
draft: state.draft,
084
version: state.version,
085
instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',
086
})
087
088
if (decision.approved) {
089
return new Command({
090
update: {
091
status: 'waiting-legal-review',
092
reviewLogs: [{
093
stage: 'editor',
094
reviewer: decision.reviewer,
095
approved: true,
096
comment: decision.comment ?? '',
097
}],
098
},
099
goto: 'legalReview',
100
})
101
}
102
103
return new Command({
104
update: {
105
status: 'editor-rejected',
106
feedback: decision.comment ?? '请继续修改',
107
reviewLogs: [{
108
stage: 'editor',
109
reviewer: decision.reviewer,
110
approved: false,
111
comment: decision.comment ?? '',
112
}],
113
},
114
goto: 'reviseDraft',
115
})
116
}
117
118
// 法务审核:通过后发布,打回后也回修改
119
const legalReview: GraphNode<typeof State> = (state) => {
120
const decision = interrupt({
121
stage: 'legal',
122
title: '请进行法务审核',
123
draft: state.draft,
124
version: state.version,
125
instruction: '回复 { approved: true, reviewer, comment } 或 { approved: false, reviewer, comment }',
126
})
127
128
if (decision.approved) {
129
return new Command({
130
update: {
131
status: 'approved',
132
reviewLogs: [{
133
stage: 'legal',
134
reviewer: decision.reviewer,
135
approved: true,
136
comment: decision.comment ?? '',
137
}],
138
},
139
goto: 'publish',
140
})
141
}
142
143
return new Command({
144
update: {
145
status: 'legal-rejected',
146
feedback: decision.comment ?? '请继续修改',
147
reviewLogs: [{
148
stage: 'legal',
149
reviewer: decision.reviewer,
150
approved: false,
151
comment: decision.comment ?? '',
152
}],
153
},
154
goto: 'reviseDraft',
155
})
156
}
157
158
const publish: GraphNode<typeof State> = (state) => {
159
return {
160
status: 'published',
161
draft: `[已发布 V${state.version}] ${state.draft}`,
162
}
163
}
164
165
const 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() })

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

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

6. 外部怎么驱动这条图

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

approval-run.ts
01
// 整条审批链都要沿用同一个 thread_id,这样恢复时才能接回原来的流程
02
const config = { configurable: { thread_id: 'approval-001' } }
03
04
// ① 启动:先生成第一版,然后暂停在编辑审核
05
const initial = await graph.invoke({ topic: 'LangGraph 审批流入门' }, config)
06
07
let snapshot = await graph.getState(config)
08
console.log(snapshot.next)
09
// → ['editorReview']
10
11
// 第一次 invoke 返回的 __interrupt__ 就是外部界面最适合直接读取的审批信息
12
console.log(initial.__interrupt__)
13
// → {
14
// stage: 'editor',
15
// title: '请进行编辑审核',
16
// draft: '【LangGraph 审批流入门】...',
17
// version: 1,
18
// }
19
20
// ② 编辑通过,继续往法务走
21
await graph.invoke(
22
new Command({
23
resume: {
24
approved: true,
25
reviewer: '编辑 Alice',
26
comment: '内容没问题,可以继续',
27
},
28
}),
29
config,
30
)
31
32
snapshot = await graph.getState(config)
33
console.log(snapshot.next)
34
// → ['legalReview']
35
36
// ③ 法务通过,图走到发布节点并结束
37
const result = await graph.invoke(
38
new Command({
39
resume: {
40
approved: true,
41
reviewer: '法务 Bob',
42
comment: '可以发布',
43
},
44
}),
45
config,
46
)
47
48
console.log(result.status)
49
// → published
50
51
console.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 放在一起以后,已经能落成一条完整的人工审批工作流。

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

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