03 加入人工确认与任务续跑
03 加入人工确认与任务续跑

本文对应提交:b78072f7dfa920e4758abf427a524b8c1f90b7ef
上一篇我们已经把 Graph 做成了双分支多节点:
START -> ROUTE -> (TRAVEL_PLAN | STUDY_PLAN) -> WRAP_UP -> END
这一篇继续往前走一步,把它升级成一个真正可交互的流程:
START -> ROUTE -> CONFIRM -> (TRAVEL_PLAN | STUDY_PLAN | END) -> WRAP_UP -> END
也就是说,系统先帮你判断场景,但不会立刻继续执行,而是先停在确认节点,等用户决定“继续”还是“取消”。
本文目标
- 理解 Graph 是怎么在
CONFIRM_NODE前中断的。 - 看懂后端如何用 checkpoint 把任务状态保存下来。
- 理解前端为什么必须复用同一个
taskId和contextId。 - 看懂“暂停 -> 人工确认 -> 续跑/结束”这条链路是怎么闭环的。
改动位置
- 图规范:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/graph/ToyGraphSpec.kt - 图配置:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/graph/GraphConfiguration.kt - 确认分支边:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/graph/edges/ToyConfirmationBranchEdge.kt - 确认节点:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/graph/nodes/ToyResumeNode.kt - A2A 执行器:
data-agent-backend/src/main/kotlin/io/github/qifan777/server/a2a/ToyAgentExecutor.kt - 前端常量:
data-agent-frontend/src/constants/toy-graph-spec.ts - 前端确认卡片:
data-agent-frontend/src/components/confirm-node-card.vue - 前端主流程:
data-agent-frontend/src/App.vue
1. 这次提交真正解决了什么问题
如果上一版是:
系统判断场景 -> 立刻跑完整条分支
那这一版变成了:
系统判断场景 -> 暂停 -> 等用户确认 -> 再决定继续还是结束
这件事的价值很大,因为很多真实 Agent 流程都不是“一口气自动跑到底”,而是:
- 先做一个机器判断
- 再给人一个确认机会
- 人确认后才继续执行后面的高成本动作
所以这一篇其实是在把 toy graph 从“自动分支 Demo”推进到“HITL 工作流”。
2. ToyGraphSpec 新增了确认阶段用到的协议字段
这次 ToyGraphSpec.kt 增加了三类常量。
第一类是状态键:
object StateKey {
const val INPUT = "input"
const val SCENE = "scene"
const val SCENE_LABEL = "sceneLabel"
const val DRAFT = "draft"
const val FINAL_OUTPUT = "finalOutput"
const val CONFIRMATION_APPROVED = "confirmationApproved"
const val CONFIRMATION_FEEDBACK = "confirmationFeedback"
}第二类是 artifact 输出类型:
object ArtifactOutputType {
const val HUMAN_CONFIRMATION = "HUMAN_CONFIRMATION"
const val HUMAN_CONFIRMED = "HUMAN_CONFIRMED"
}第三类是消息 metadata 键:
object MessageMetadataKey {
const val CONFIRMATION_APPROVED = "confirmationApproved"
const val CONFIRMATION_FEEDBACK = "confirmationFeedback"
}这三类常量分别承担不同职责:
StateKey是 Graph 内部状态ArtifactOutputType是后端告诉前端“这是不是一个人工确认卡片”MessageMetadataKey是前端续跑时回传给后端的消息字段
如果没有这层统一约定,前后端会非常容易在“确认通过”“确认取消”“是否需要人工输入”这些状态上对不齐。
3. GraphConfiguration 这次最大的变化,是把 CONFIRM_NODE 真正接进图里
图配置核心片段如下:
return StateGraph(keyStrategyFactory)
.addNode(ToyGraphSpec.Node.ROUTE, AsyncNodeAction.node_async(toySceneRouterNode))
.addNode(ToyGraphSpec.Node.CONFIRM, AsyncNodeAction.node_async(toyResumeNode))
.addNode(ToyGraphSpec.Node.TRAVEL_PLAN, AsyncNodeAction.node_async(toyTravelPlanNode))
.addNode(ToyGraphSpec.Node.STUDY_PLAN, AsyncNodeAction.node_async(toyStudyPlanNode))
.addNode(ToyGraphSpec.Node.WRAP_UP, AsyncNodeAction.node_async(toyWrapUpNode))
.addEdge(StateGraph.START, ToyGraphSpec.Node.ROUTE)
.addConditionalEdges(
ToyGraphSpec.Node.ROUTE,
AsyncEdgeAction.edge_async(toySceneBranchEdge),
mapOf(
ToyGraphSpec.Scene.TRAVEL to ToyGraphSpec.Node.CONFIRM,
ToyGraphSpec.Scene.STUDY to ToyGraphSpec.Node.CONFIRM,
),
)
.addConditionalEdges(
ToyGraphSpec.Node.CONFIRM,
AsyncEdgeAction.edge_async(toyConfirmationBranchEdge),
mapOf(
ToyGraphSpec.Scene.TRAVEL to ToyGraphSpec.Node.TRAVEL_PLAN,
ToyGraphSpec.Scene.STUDY to ToyGraphSpec.Node.STUDY_PLAN,
StateGraph.END to StateGraph.END,
),
)这里有两个关键变化。
第一,路由节点不再直接跳业务分支,而是统一跳到 CONFIRM_NODE。
第二,CONFIRM_NODE 之后再由确认边决定:
- 如果通过,就按原来的
scene进入旅行或学习分支 - 如果不通过,就直接
END
这就把“自动判断”和“人工确认”明确拆成了两个阶段。
4. ToyResumeNode 本身几乎什么都不做,但它是一个非常关键的中断锚点
确认节点的实现非常简单:
@Component
class ToyResumeNode : NodeAction {
override fun apply(state: OverAllState): Map<String, Any> {
return emptyMap()
}
}看起来它像是“空节点”,但它的作用不是产出业务数据,而是充当一个稳定的暂停位置。
你可以把它理解成:
- Graph 先把路由结果准备好
- 在进入真正业务分支之前,专门留一个确认站点
- 这个站点本身不处理业务,只负责成为
interruptBefore(...)的目标
所以这类节点常常是工作流里的“控制节点”,不是“业务节点”。
5. 真正让图停下来的,不是 CONFIRM_NODE 本身,而是 interruptBefore(CONFIRM_NODE)
ToyAgentExecutor 里最关键的配置是:
val compiledGraph = stateGraph.compile(
CompileConfig.builder()
.saverConfig(SaverConfig.builder().register(saver).build())
.interruptBefore(ToyGraphSpec.Node.CONFIRM)
.build()
)这里有两层意思。
第一层,interruptBefore(CONFIRM_NODE) 表示:
图在真正执行确认节点之前先停下来
第二层,SaverConfig + MemorySaver 表示:
图停下来之前,要把当前执行状态保存起来,后面才能恢复
这两个配置必须一起看:
- 只有中断,没有保存,后面就续不上
- 只有保存,没有中断,前端也拿不到人工确认机会
所以 HITL 的关键不是“多一个按钮”,而是“图要先可暂停,再可恢复”。
6. 第一次执行时,后端会主动发出一个“需要人工确认”的 artifact
在新任务分支里,执行器先正常跑:
compiledGraph.stream(mapOf(ToyGraphSpec.StateKey.INPUT to input), runnableConfig)但这次的 doOnComplete 不再只是简单 complete(),而是先读取中断时的状态快照:
val stateSnapshot = compiledGraph.getState(runnableConfig)
val stateData = LinkedHashMap(stateSnapshot.state().data())然后手动补发一个确认节点 artifact:
taskUpdater.addArtifact(
listOf(
DataPart(
mapOf(
ToyGraphSpec.StateKey.SCENE to stateData[ToyGraphSpec.StateKey.SCENE],
ToyGraphSpec.StateKey.SCENE_LABEL to stateData[ToyGraphSpec.StateKey.SCENE_LABEL],
ToyGraphSpec.ArtifactDataKey.NEED_CONFIRMATION to true,
)
)
),
artifactNum.incrementAndGet().toString(),
ToyGraphSpec.Node.CONFIRM,
mapOf("outputType" to ToyGraphSpec.ArtifactOutputType.HUMAN_CONFIRMATION)
)紧接着再发一个 requiresInput(...):
taskUpdater.requiresInput(
taskUpdater.newAgentMessage(
listOf(TextPart("已判断当前场景为${stateData[ToyGraphSpec.StateKey.SCENE_LABEL]},请确认是否继续执行。")),
...
)
)这两步合起来非常关键:
addArtifact(...)让前端时间线上出现CONFIRM_NODErequiresInput(...)让任务状态进入input-required
也就是说,前端既能“看到确认卡片”,也能“知道当前真的在等用户输入”。
7. 恢复执行时,关键不是重新传原始输入,而是用 taskId 找回旧线程
ToyAgentExecutor 的恢复分支是这样判断的:
val existingTask = message.taskId?.let(taskStore::get)
if (existingTask != null) {
...
}这意味着第二次请求并不是“新开一条消息重新跑图”,而是:
- 前端带回旧的
taskId - 后端从
taskStore找到原任务 - 然后按这个任务的线程状态继续往下跑
接着执行器会构造:
val runnableConfig = RunnableConfig.builder().threadId(existingTask.id).build()这里的 threadId 非常重要,因为 checkpoint 恢复依赖的就是这一条执行线程。
8. 恢复前要先把人工确认结果写回 Graph 状态
续跑不是直接 stream(),而是先:
val resumedConfig = compiledGraph.updateState(
runnableConfig,
mapOf(
ToyGraphSpec.StateKey.CONFIRMATION_APPROVED to approved,
ToyGraphSpec.StateKey.CONFIRMATION_FEEDBACK to feedback,
)
)然后再:
compiledGraph.stream(null, resumedConfig)这一步一定要看懂。
Graph 在中断点恢复时,需要先知道“用户到底点了继续还是取消”。
所以恢复之前,后端先把这两个信息写进状态:
confirmationApprovedconfirmationFeedback
只有这样,后面的确认边才能根据最新状态做跳转。
9. ToyConfirmationBranchEdge 真正决定了“继续”还是“结束”
确认边的逻辑是:
@Component
class ToyConfirmationBranchEdge : EdgeAction {
override fun apply(state: OverAllState): String {
val approved = state.value(ToyGraphSpec.StateKey.CONFIRMATION_APPROVED, false)
if (!approved) {
return StateGraph.END
}
return state.value(ToyGraphSpec.StateKey.SCENE, ToyGraphSpec.Scene.STUDY)
}
}它的规则很直白:
- 如果
confirmationApproved是false,直接结束 - 如果是
true,再按原来已经算好的scene跳去旅行或学习分支
这说明这次确认并不是“重新判断场景”,而是:
对已有路由结果做一次人工放行
这正是 HITL 最常见的模式之一。
10. 前端这次最重要的任务,是保住同一个任务上下文
App.vue 新增了这几个状态:
const currentTaskId = ref<string>()
const currentContextId = ref<string>()
const awaitingConfirmation = ref(false)它们的作用分别是:
currentTaskId记录当前任务 ID,用于恢复currentContextId记录上下文 ID,保持会话连续性awaitingConfirmation标记当前是否处于等待人工确认
在流式消费事件时,前端会不断把服务端回传的 taskId/contextId 存起来:
currentTaskId.value = 'taskId' in event ? event.taskId : currentTaskId.value
currentContextId.value = 'contextId' in event ? event.contextId : currentContextId.value这一步如果不做,后面的“确认继续”就会变成一条全新的请求,而不是对原流程的续跑。
11. 确认卡片是通过一个专门的 artifact outputType 渲染出来的
前端常量里新增了:
export const TOY_GRAPH_ARTIFACT_OUTPUT = {
HUMAN_CONFIRMATION: 'HUMAN_CONFIRMATION',
HUMAN_CONFIRMED: 'HUMAN_CONFIRMED',
GRAPH_NODE_FINISHED: 'GRAPH_NODE_FINISHED',
} as const然后 NODE_COMPONENTS 正式把 CONFIRM_NODE 注册进来:
const NODE_COMPONENTS = {
[TOY_GRAPH_NODE.ROUTE]: markRaw(RouteNodeCard),
[TOY_GRAPH_NODE.CONFIRM]: markRaw(ConfirmNodeCard),
[TOY_GRAPH_NODE.TRAVEL_PLAN]: markRaw(TravelPlanNodeCard),
[TOY_GRAPH_NODE.STUDY_PLAN]: markRaw(StudyPlanNodeCard),
[TOY_GRAPH_NODE.WRAP_UP]: markRaw(WrapUpNodeCard),
}于是后端一旦发出名为 CONFIRM_NODE 的 artifact,前端就会把它渲染成专门的确认卡片,而不是普通文本节点。
12. confirm-node-card.vue 做的不是聊天输入,而是一个明确的二选一控制面板
确认卡片组件只暴露两个事件:
const emit = defineEmits<{
confirm: []
cancel: []
}>()界面上也只有两个动作:
确认继续取消本次执行
这说明这次提交的目标很明确:
先把“是否继续”这条人工交互链路跑通,而不是把它做成一个通用反馈表单。
13. 前端确认和取消,其实都是“带 metadata 的续跑消息”
确认继续时:
await streamMessage('确认继续', true, {
[TOY_GRAPH_MESSAGE_METADATA.CONFIRMATION_APPROVED]: true,
[TOY_GRAPH_MESSAGE_METADATA.CONFIRMATION_FEEDBACK]: '用户确认继续执行',
})取消时:
await streamMessage('取消', true, {
[TOY_GRAPH_MESSAGE_METADATA.CONFIRMATION_APPROVED]: false,
[TOY_GRAPH_MESSAGE_METADATA.CONFIRMATION_FEEDBACK]: '用户取消本次执行',
})这里有三个特别关键的点:
keepSteps = true,说明前端不会清空已有时间线- 会继续带上
currentTaskId和currentContextId - 真正决定恢复行为的是 metadata,而不是文本
"确认继续"或"取消"本身
所以你可以把续跑请求理解成:
向同一条任务线程提交一份人工确认结果
14. 前端如何判断当前是不是在等待确认
streamMessage(...) 里会监听状态更新:
if (event.kind === 'status-update' && event.status.state === 'input-required') {
awaitingConfirmation.value = true
}
if (event.kind === 'status-update' && event.status.state === 'completed') {
awaitingConfirmation.value = false
}这意味着:
- 后端调用
requiresInput(...)后,前端会进入等待确认态 - 用户确认继续或取消,并且任务结束后,等待态会被清掉
虽然当前页面没有单独用一个大提示条去展示 awaitingConfirmation,但这个状态已经被前端保存下来了,后续要做禁用态或顶部提示都很方便。
15. 这一版最值得记住的完整闭环
把整条流程串起来,可以记成下面 8 步:
- 用户发送初始需求
- Graph 跑完
ROUTE_NODE - 图在
interruptBefore(CONFIRM_NODE)处暂停 - 后端读取 checkpoint 状态,补发
CONFIRM_NODEartifact - 后端再发
requiresInput,任务进入input-required - 用户点击“确认继续”或“取消本次执行”
- 前端带着同一个
taskId/contextId和确认 metadata 发起续跑 - 后端
updateState(...)后恢复执行,由ToyConfirmationBranchEdge决定继续还是结束
这就是一个最小但完整的 HITL 模式。
16. 这次最容易踩坑的地方
1) 续跑时没有带回原 taskId
结果就是后端会把它当成全新任务,原来的 checkpoint 完全接不上。
2) 只发了续跑消息,但没把确认结果放进 metadata
这样 updateState(...) 拿不到 confirmationApproved,确认边就无法正确判断。
3) 没有配置 MemorySaver 或其他 saver
图虽然能中断,但恢复时找不到之前保存的执行状态。
4) 前端确认时把已有步骤清空了
那用户就看不到“同一条任务从暂停走到继续”的连续过程,体验会像重新开了一次新任务。
5) 误以为 CONFIRM_NODE 会自己产出内容
这次实现里它只是一个控制节点,真正用于展示的确认信息,是执行器在中断后手动补发的 artifact。
17. 建议怎么验证这一章
建议按这两个流程分别验证。
第一条,确认继续:
- 输入旅行或学习需求
- 观察路由节点执行完成
- 观察确认节点出现,任务进入等待输入
- 点击“确认继续”
- 观察进入对应业务分支并最终到
WRAP_UP_NODE
第二条,取消执行:
- 输入旅行或学习需求
- 等待确认节点出现
- 点击“取消本次执行”
- 观察后续业务分支不再执行,任务直接结束
如果这两条都成立,说明“中断 + 人工确认 + 续跑/结束”已经闭环。
常见问题
1) 为什么中断点放在 interruptBefore(CONFIRM_NODE),而不是在 CONFIRM_NODE 里 return
因为中断是 Graph 运行时控制能力,不是节点业务逻辑。把它放在 compile 配置里,图的暂停语义会更清晰。
2) 为什么恢复时 stream(null, resumedConfig) 的第一个参数是 null
因为这时不需要重新传初始输入了,图要基于之前保存的状态继续跑,关键是新的 runnable config 和更新后的状态。
3) 为什么取消时直接 END,而不是回到路由节点
因为这次提交实现的是“确认是否继续当前流程”,不是“修改路由结果后重试”。如果你要做“打回重选”,那会是下一层更复杂的工作流。
4) 这次 confirmationFeedback 有什么作用
当前提交里它主要是作为协议字段打通,方便前后端在恢复时携带人工意见。后续如果要把人工意见真正写进 Prompt 或审计日志,这个字段就能直接用上。
本章完成标准
读完这篇,你至少应该能解释清楚这 4 件事:
- Graph 为什么能在确认节点前暂停
- 后端靠什么把暂停时的状态保存下来
- 为什么续跑一定要复用原
taskId/contextId - 确认继续和取消,最终是怎么由确认边决定下一跳的
如果把这一章压缩成一句话,可以记成:
上一章让图会分支,这一章让图学会停下来等人。
