DeepResearchSystem 0x03:HITL

回顾

  1. DeepResearchSystem 0x00:初识
  2. DeepResearchSystem 0x01:Agent 基础
  3. DeepResearchSystem 0x02:Graph 构建

之前已经基于 LangGraph 实现了 DeepRearchSystem 的核心流程,也基本能实现我们初衷想要的效果了。但是,好像我们忽略了一个问题:

  • 我们是否过度信任 LLM 了?整个流程出了 "出题",人类并没有做任何干预。
  • 那么,怎么保证 LLM 给出的就是符合我们初衷意愿的呢?

解决

之前学习 LangGraph 时有一个AI 自动处理 + 人类中断审核 的 LangGraph 工作流。也可以参看 LangGraph 官网示例。这里我们也基于 LangGraph 加入 HITL(Human-in-the-loop)

顾名思义,即是人类参与的循环。借助 HITL 来对 LLM 输出做修正,直到输出完全满足人类意图。这样在整个 Agent Loop中有了 Human 的参与,可以增大输出更满足人类意图的概率。

HITL

跳出这个具体 case, 我们先来思考下为什么 HITL 是 Agent 必不可少的环节?

  1. HITL 具体作用

    • Monitoring(监控) :实时监控 Agent 的行为
    • Validating(校验) :在结构化的检查点对输出进行业务规则验证
    • Intervening(干预) :当置信度阈值未达到时进行人工介入
    • Training(训练) :通过系统化的反馈训练 Agent,使其随时间推移性能不断提升
  2. HITL 重要性

    • 有些决策本来就不该由 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 伪装成一个普通节点" 的做法:

  1. 节点不退出AWAITING_PLAN_CONFIRMATION 节点被调用后,函数立刻返回当前 state,但图执行并没有真正"结束"
  2. 业务层忙等:前端/API 层需要不断轮询或通过某种机制"卡住"在这个节点,等待人类输入
  3. 状态原地打转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"

  1. 运行时级中断interrupt() 被调用时,LangGraph Runtime 会:

    • 立即挂起整个图执行
    • 将当前状态 持久化到 Checkpointer
    • 抛出异常/信号,通知调用方"需要人工介入"
  2. 进程可释放:执行线程/协程可以被释放,不需要占着资源干等

  3. 显式恢复 :只有通过 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,增加方式相同,这里不再做过多赘述。

相关推荐
OpenTiny社区1 小时前
Loop Engineering:让 AI Agent 自己跑起来的工程方法
前端·github
都叫我大帅哥2 小时前
从Python到Java:为什么企业级Agent最终会选择Java?
java·ai编程
stormzhangV2 小时前
AI 的玩法,该做减法了
人工智能·ai编程·claude
木心术12 小时前
GitHub Actions自动化运维实战:从CI/CD到全链路DevOps
运维·自动化·github
kyriewen2 小时前
我review了一份Vibe Coding写的前端代码——能跑,但5个地方迟早要命
前端·javascript·ai编程
东小西2 小时前
第12篇:《AI的幻觉差点让我背锅:于是我给它开了一场"开卷考试"》
openai·ai编程
武子康3 小时前
GAIA、Cosmos、Genie 等视频模型纷纷自称"世界模型",为什么说视觉逼真度不构成决策证据?
人工智能·llm·agent
腻害兔3 小时前
【若依项目-产品经理视角】RuoYi-Vue-Pro 源码拆解:IM 即时通讯模块,一个被低估的「全功能聊天系统」
java·前端·vue.js·产品经理·ai编程
炸膛坦客3 小时前
Git 和 GitHub:(七)将本地新建仓库与 GitHub 远程仓库关联起来(SSH)
git·ssh·github