回顾
之前已经基于 LangGraph 实现了 DeepRearchSystem 的核心流程,也基本能实现我们初衷想要的效果了。但是,好像我们忽略了一个问题:
- 我们是否过度信任 LLM 了?整个流程出了 "出题",人类并没有做任何干预。
- 那么,怎么保证 LLM 给出的就是符合我们初衷意愿的呢?
解决
之前学习 LangGraph 时有一个AI 自动处理 + 人类中断审核 的 LangGraph 工作流。也可以参看 LangGraph 官网示例。这里我们也基于 LangGraph 加入 HITL(Human-in-the-loop) 。
顾名思义,即是人类参与的循环。借助 HITL 来对 LLM 输出做修正,直到输出完全满足人类意图。这样在整个 Agent Loop中有了 Human 的参与,可以增大输出更满足人类意图的概率。
HITL
跳出这个具体 case, 我们先来思考下为什么 HITL 是 Agent 必不可少的环节?
-
HITL 具体作用
- Monitoring(监控) :实时监控 Agent 的行为
- Validating(校验) :在结构化的检查点对输出进行业务规则验证
- Intervening(干预) :当置信度阈值未达到时进行人工介入
- Training(训练) :通过系统化的反馈训练 Agent,使其随时间推移性能不断提升
-
HITL 重要性
- 有些决策本来就不该由 Agent 独自做。
- 操作不可逆:删数据、发邮件、执行变更------做了就很难撤回
- 证据不足:Agent 给出了结论,但证据链不完整,或无法区分多个可能根因
- 超出授权范围:Agent 被授权做诊断,但没有被授权做变更
- 有些决策本来就不该由 Agent 独自做。
Coding
废话少说上代码。我们这里对之前的流程做了一个优化:
优化前:用户输入 → 查询规划 → 并行搜索 → 反思评估 → 迭代深化 → 报告生成 优化后:用户输入 → 研究计划 → 查询规划 → 并行搜索 → 反思评估 → 迭代深化 → 报告生成
目前加的节点是在 generate_plan(研究主题拆解)后,评估拆解计划是否需要人类确认,只有经人类确认的计划才能进入下一步(web_search)。
先看下 加入 HITL 的流程图:

OverallState
首先,我们要在 OverallState 添加有关人类反馈的状态。
Python
# 人类参与后的相关字段
# 计划内容
plan: str
# 计划状态:1待确认、2已确认、3重新生成基础
plan_status: str
# 计划相关消息
plan_messages: Annotated[list, add_messages]
# 人类对计划的反馈信息。用于 interrupt 机制
user_feedback: str
generate_plan
scss
ef generate_plan(state: OverallState, config: RunnableConfig) -> OverallState:
"""
生成研究计划的节点
基于用户的问题和需求,使用LLM生成一个详细的研究计划。
Args:
state: 包含用户问题的当前图状态
config: 可运行配置,包括LLM提供商设置
Returns:
包含计划状态更新的字典,包括plan和plan_status键
"""
logger.info("正在生成计划...")
if state.get("plan_status", "unconfirmed") != "unconfirmed":
return {}
configurable = Configuration.from_runnable_config(config)
agent = Agent(model_id=configurable.query_generator_model)
agent.set_step_prompt(plan_instructions)
response = agent.step(
current_date=get_current_date(),
research_topic=get_research_topic(state["messages"], [msg.content for msg in state["plan_messages"]]),
research_proposal=state.get("plan", "")
)
response = JsonUtils.extract_pattern(response, pattern="markdown")
logger.info(state)
logger.info(response)
return {"messages": [AIMessage(content=response)],
"plan": response,
"plan_status": "unconfirmed",
"plan_messages": [AIMessage(content=response)]}
evaluate_plan 人类参与
Python
def evaluate_plan(state: OverallState, config: RunnableConfig) -> ReflectionState:
"""
评估研究计划的节点
分析当前计划并根据用户反馈决定下一步行动:
- 如果用户确认计划,继续生成查询
- 如果用户需要修改计划,重新生成
- 如果用户未确认,等待确认
Args:
state: 包含计划信息的当前图状态
config: 可运行配置
Returns:
字符串字面量,指示下一个要访问的节点
"""
configurable = Configuration.from_runnable_config(config)
plan = state.get("plan", None)
if state.get("plan_status", "unconfirmed") == "unconfirmed":
logger.info("等待用户确认...")
return AWAITING_PLAN_CONFIRMATION
if not plan:
logger.info("没有计划需要评估。")
return SEARCH_REPLAN
else:
context = get_last_user_response(state["messages"])
# 用户意图明确
if "开始研究" in context or "需求确认" in context:
return GENERATE_SEARCH_NODE
# 无程序化关键词,需要进行用户意图识别
agent = JsonAgent(model_id=configurable.query_generator_model, keys=PlanReflection)
agent.set_step_prompt(plan_reflection_instructions)
result = agent.step(
research_proposal=state.get("plan", ""),
context=context,
)
if result.satisfy:
return GENERATE_SEARCH_NODE
return SEARCH_REPLAN
HITL
方法1:人工干预
这是一种 "把 HITL 伪装成一个普通节点" 的做法:
- 节点不退出 :
AWAITING_PLAN_CONFIRMATION节点被调用后,函数立刻返回当前state,但图执行并没有真正"结束" - 业务层忙等:前端/API 层需要不断轮询或通过某种机制"卡住"在这个节点,等待人类输入
- 状态原地打转 :
evaluate_plan不断返回AWAITING_PLAN_CONFIRMATION,图在逻辑上"转圈"
- 本质:模拟了一个**"阻塞调用"**,但 LangGraph 的运行时并不知道你在等人。
Python
# 人类干预:Human-in-the-loop
# 方法1:手动模拟节点:业务层忙等,节点不退出,执行线程被持续占用。处理不好容易出死锁
# 等待确认,不做任何额外处理;用户确认后转发 state
builder.add_node(AWAITING_PLAN_CONFIRMATION, lambda state, config: state)
# 重新规划,忽略传入的 state,返回新的状态片段。
# LangGraph会自动将这个返回值合并(merge)到全局状态中。标记当前计划为"未确认",通常用于用户拒绝原计划后,触发重新规划的逻辑。
builder.add_node(SEARCH_REPLAN, lambda state, config: {"plan_status": "unconfirmed"})
方法2:interrupt 机制
这是 LangGraph 原生支持的 "真·HITL" :
-
运行时级中断 :
interrupt()被调用时,LangGraph Runtime 会:- 立即挂起整个图执行
- 将当前状态 持久化到 Checkpointer
- 抛出异常/信号,通知调用方"需要人工介入"
-
进程可释放:执行线程/协程可以被释放,不需要占着资源干等
-
显式恢复 :只有通过
graph.invoke/stream传入Command(resume=...)时,才会从interrupt处继续执行
Python
# 方法2:LangGraph 官方 interrupt 机制。能缩小范围明确用户意图,减少LLM意图识别的成本
def awaiting_plan_confirmation(state: OverallState):
# interrupt的参数会直接透传给前端,返回值就是用户的输入
feedback = interrupt({
"type": "plan_confirm",
"current_plan":state.get("plan"),
"prompt": "请确认当前执行计划,同意请输入confirm,拒绝请输入reject"
})
# 2. 当 Command(resume=...) 被调用时,代码从这里恢复执行
logger.info(f"收到人工反馈: {human_response}")
action = feedback.get("action", "reject")
feedback = feedback.get("feedback", "")
# 3. 根据人工反馈更新状态
if action == "confirm":
logger.info("计划已确认,标记为 confirmed")
return {
"plan_status": "confirmed",
"user_feedback": feedback,
"messages": [AIMessage(content=f"用户确认了计划: {feedback}")]
}
elif action == "reject":
logger.info("计划被拒绝,标记为 rejected")
return {
"plan_status": "rejected",
"user_feedback": feedback,
"messages": [AIMessage(content=f"用户拒绝了计划: {feedback}")]
}
else:
# 如果用户提供了修改意见,视为拒绝并要求重规划
logger.info("收到修改意见,将用于重新规划")
return {
"plan_status": "rejected",
"user_feedback": feedback,
"messages": [AIMessage(content=f"用户建议修改: {feedback}")]
}
Command 调用示例
Python
# --- 外部调用示例---
"""
# 1. 首次调用,触发中断
config = {"configurable": {"thread_id": "test_thread_001"}}
initial_state = {"messages": [("user", "研究一下LangGraph的HITL机制")]}
result = graph.invoke(initial_state, config)
# 此时 result 会包含 interrupt 信息,但不会完成执行
# 2. 恢复执行:用户确认计划
resume_command = Command(resume={
"action": "confirm",
"feedback": "计划看起来不错,开始吧"
})
result_after_resume = graph.invoke(resume_command, config)
# 此时图会从 interrupt 处继续执行
# 3. 恢复执行:用户拒绝计划
resume_command_reject = Command(resume={
"action": "reject",
"feedback": "请增加对生产环境稳定性的分析"
})
result_after_reject = graph.invoke(resume_command_reject, config)
# 此时图会进入 SEARCH_REPLAN 节点,重新生成计划
"""
🤔🤔🤔
我们目前已经在 研究计划节点加入了 HITL,如果需要,同样可以再在其他节点加入 HITL 。这个取决于具体的场景和 case,增加方式相同,这里不再做过多赘述。