[sdk] 04 - Deep Agents - Arch and SubAgent

Link: LangChain DeepAgents 速通指南(五)—— 快速了解DeepAgents框架及其核心特性

Link: LangChain DeepAgents 速通指南(六)—— DeepAgents SubAgent 子智能体机制

当大家使用 LangChain 的 create_agent API 构建智能体时,其底层本质是利用 LangGraph 创建了一个 ReAct 图。

最初的 DeepAgents 框架:其核心思路是将

  1. 详尽提示、
  2. 任务规划、
  3. 子代理和
  4. 文件系统

四个能力相结合,推动 Agent 实现从“浅”至“深”的转变。

 

 

规划与任务分解能力

如何增强?

from deepagents import create_deep_agent
from langchain.agents.middleware.todo import (
    TodoListMiddleware,
    WRITE_TODOS_SYSTEM_PROMPT,
)

# 1. 在官方默认 Prompt 基础上,追加企业自己的规划规则
enterprise_planning_prompt = WRITE_TODOS_SYSTEM_PROMPT + """

## Enterprise-specific planning rules

- 每个任务必须有明确的完成条件
- 高风险结论必须增加独立验证步骤
- 存在关键数据缺失时,不得标记为 completed
- 涉及人工审批的步骤必须单独列出
"""

# 2. 创建定制版 TodoListMiddleware
todo_middleware = TodoListMiddleware(
    system_prompt=enterprise_planning_prompt
)

# 3. 注入 Deep Agent
agent = create_deep_agent(
    model=model,
    middleware=[
        todo_middleware
    ]
)

 

在 LangChain 中定义的系统 prompt: WRITE_TODOS_SYSTEM_PROMPT。

WRITE_TODOS_SYSTEM_PROMPT = """## `write_todos`

You have access to the `write_todos` tool to help you manage and plan complex objectives.
Use this tool for complex objectives to ensure that you are tracking each necessary step and giving the user visibility into your progress.
This tool is very helpful for planning complex objectives, and for breaking down these larger complex objectives into smaller steps.

It is critical that you mark todos as completed as soon as you are done with a step. Do not batch up multiple steps before marking them as completed.
For simple objectives that only require a few steps, it is better to just complete the objective directly and NOT use this tool.
Writing todos takes time and tokens, use it when it is helpful for managing complex many-step problems! But not for simple few-step requests.

## Important To-Do List Usage Notes to Remember

- The `write_todos` tool should never be called multiple times in parallel.
- Don't be afraid to revise the To-Do list as you go. New information may reveal new tasks that need to be done, or old tasks that are irrelevant."""  # noqa: E501
En version
WRITE_TODOS_SYSTEM_PROMPT = """## `write_todos`

你可以使用 `write_todos` 工具来帮助你管理和规划复杂目标。

对于复杂目标,应使用此工具,以确保你能够:
- 跟踪每一个必要的步骤;
- 让用户能够看到你的执行进度。

这个工具非常适合用于规划复杂目标,以及把较大的复杂目标拆解成更小的执行步骤。

非常重要:
一旦某个步骤完成,就应立即把对应的 todo 标记为 completed。
不要等多个步骤都完成后,再批量标记为 completed。

对于只需要少量步骤就能完成的简单目标,最好直接完成任务,而不要使用这个工具。

编写 todos 会消耗时间和 token。
因此,应在处理复杂、包含多个步骤的问题时使用它;
对于简单、只有少量步骤的请求,则不要使用。

## 使用 To-Do List 时需要记住的重要事项

- `write_todos` 工具绝不能被并行调用多次。
- 在执行过程中,不要害怕修改 To-Do List。
  新出现的信息可能意味着需要增加新的任务,
  也可能意味着原来的某些任务已经不再相关。
"""
Cn version

 

 

上下文管理能力

企业真正要解决的是:

什么应该留在 context,什么应该写文件,什么应该压缩,压缩到什么程度,什么时候重新读取?

东西太多了

判断怎么处理
├── 以后可能还要精确使用  写入 File System
│
└── 只需要保留主要含义  Summarization

 

 

长期记忆能力

利用不同类型的后端(Backend)进行信息存储,DeepAgents 可以将具备持久记忆的 Agent 扩展到跨线程场景。

 

上下文管理 与 长期记忆 往往需要同时考量。

 

“调查客户 C-1042 最近 90 天的交易,判断是否存在欺诈行为。重要结论必须有证据,不能只根据摘要下结论。”

 

企业还有几条长期规则:

1. 高风险结论必须重新检查原始交易记录。
2. 风险 > 0.85 必须人工复核。
3. 已知:某类境外小额交易容易产生误报。
4. 未验证的案件结论不能进入长期记忆。
5. 原始客户交易属于案件证据,不能作为长期 Memory。

 

可见,以上我们可以看出,将会涉及到下面几个options:

  • 当前对话 → Working Context
  • 90天原始交易 → File / Backend
  • 很长的历史过程 → Summarization
  • 长期企业经验 → Long-term Memory

 

① 定义 create_deep_agent()
   创建 Agent、确定 system_prompt、middleware、tools...

② agent.invoke()
   MemoryMiddleware.before_agent()
   从 AGENTS.md 加载长期 Memory → state["memory_contents"]

③ 第一次 Model Call 前
   各种 wrap_model_call()
   把:
   System Prompt
   + Memory
   + Todo Prompt
   + Filesystem Prompt
   + Tools
   组装 成真正的 ModelRequest

 

以下这些,都已经为 model 准备好了。

ModelRequest(
    system_message=...,
    messages=[
        HumanMessage(
            "调查客户 C-1042 最近90天交易..."
        )
    ],
    tools=[
        write_todos,
        fetch_transactions,
        read_file,
        write_file,
        ...
    ]
)

 

>>> 组装的过程。

关键就在 LangChain create_agent() 内部生成的 model_node()。源码逻辑可以简化成下面这样:

def model_node(state, runtime):

    # 1. 先生成这一次 LLM 调用的 ModelRequest
    request = ModelRequest(
model
=model, tools=default_tools, system_message=system_message, messages=state["messages"], state=state, runtime=runtime, ) # 2. 如果没有 wrap_model_call middleware if wrap_model_call_handler is None: return _execute_model_sync(request) # 3. 如果有 middleware return wrap_model_call_handler( request, _execute_model_sync, # ← 注意!这就是第二个参数 handler )

 

真正执行的地方:

def _execute_model_sync(request):

    model_, response_format = _get_bound_model(request)

    messages = request.messages

    if request.system_message:
        messages = [
            request.system_message,
            *messages
        ]

    # ===== 真正调用大模型 =====
    output = model_.invoke(messages)

    return ModelResponse(
        result=[output]
    )

 

④ Main LLM 第一次真正执行:规划调查任务。注意:同时也返回了“处理方式 - 调用tool”

AIMessage(
    tool_calls=[
        {
            "name": "write_todos",
            "args": {
                "todos": [
                    {
                        "content": "获取 C-1042 最近90天交易",
                        "status": "in_progress"    # <---------------
                    },
                    {
                        "content": "识别异常交易模式",
                        "status": "pending"
                    },
                    {
                        "content": "重新验证高风险交易的原始证据",
                        "status": "pending"
                    },
                    {
                        "content": "必要时提交人工复核",
                        "status": "pending"
                    },
                    {
                        "content": "形成最终欺诈调查结论",
                        "status": "pending"
                    }
                ]
            }
        }
    ]
)

 

write_todos 真正执行:把计划写入 State

调用的是这个todo 中间件,返回的自然会是这个中间件中tools的一个。只是这里貌似也只有一个tool。

class TodoListMiddleware(AgentMiddleware[PlanningState[ResponseT], ContextT, ResponseT]):
"""Middleware that provides todo list management capabilities to agents. This middleware adds a `write_todos` tool that allows agents to create and manage structured task lists for complex multi-step operations. It's designed to help agents track progress, organize complex tasks, and provide users with visibility into task completion status. The middleware automatically injects system prompts that guide the agent on when and how to use the todo functionality effectively. It also enforces that the `write_todos` tool is called at most once per model turn, since the tool replaces the entire todo list and parallel calls would create ambiguity about precedence. Example: ```python from langchain.agents.middleware import TodoListMiddleware from langchain.agents import create_agent agent = create_agent("openai:gpt-5.5", middleware=[TodoListMiddleware()]) # Agent now has access to write_todos tool and todo state tracking result = await agent.invoke({"messages": [HumanMessage("Help me refactor my codebase")]}) print(result["todos"]) # Array of todo items with status tracking ``` """ state_schema = PlanningState # type: ignore[assignment] def __init__( self, *, system_prompt: str = WRITE_TODOS_SYSTEM_PROMPT, tool_description: str = WRITE_TODOS_TOOL_DESCRIPTION, ) -> None: """Initialize the `TodoListMiddleware` with optional custom prompts. Args: system_prompt: Custom system prompt to guide the agent on using the todo tool. tool_description: Custom description for the `write_todos` tool. """ super().__init__() self.system_prompt = system_prompt self.tool_description = tool_description self.tools = [ StructuredTool.from_function( name="write_todos", description=tool_description, func=_write_todos, coroutine=_awrite_todos, args_schema=WriteTodosInput, infer_schema=False, ) ]

 

write_todos

直接返回 LangGraph Command

更新 state["todos"]

def _write_todos(runtime, todos):

    return Command(
        update={
            "todos": todos,
            "messages": [
                ToolMessage(
                    f"Updated todo list to {todos}",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )

 

⑥ 下一轮 Main LLM:决定获取原始交易

“Tool 执行完了 → 回到 Model”。

触发,并构建ModelRequest送给LLM。

模型看到 放在自己面前的“资源”,如下。果断选择了 fetch_transactions。

Todo:
1. 获取 C-1042 最近90天交易 ← in_progress

Tools:
- write_todos
- fetch_transactions
- read_file
- ...

 大模型返回值,如下。

AIMessage(
    tool_calls=[
        {
            "name": "fetch_transactions",
            "args": {
                "customer_id": "C-1042",
                "days": 90
            },
            "id": "call_123"
        }
    ]
)

LangGraph/Agent Runtime接收执行。

tool = tools_by_name["fetch_transactions"]

result = tool.invoke( { "customer_id": "C-1042", "days": 90 } )  # ---> 执行下载任务。

 

⑦ Tool 返回后:FilesystemMiddleware 拦截大结果。

因为 FilesystemMiddleware 被设计成了一个“通用 Tool 返回值检查器”,所以它主动实现了:wrap_tool_call()

于是任何 Tool 执行时,它都有机会看一眼结果。所以,现在开始拦截。

def wrap_tool_call(self, request, handler):

    # 1. 先真正执行 Tool
    tool_result = handler(request)

    # 2. 如果没启用“大结果驱逐”,直接返回
    if self._tool_token_limit_before_evict is None:
        return tool_result

    # 3. 某些工具明确排除,不检查
    if request.tool_call["name"] in TOOLS_EXCLUDED_FROM_EVICTION:
        return tool_result

    # 4. 对其余 Tool Result 做统一的大结果检查
    return self._intercept_large_tool_result(tool_result)

以上代码体现的逻辑如下:

Tool Result
   ↓
提取其中的文本
   ↓
估算大小
   ↓
没有超过阈值
   → 原样返回

超过阈值
   → 完整文本写入 Backend
   → ToolMessage 换成“小预览 + 文件路径”

也就是说它根本不关心这个 Tool 返回的是交易、网页、数据库结果还是其他业务数据。

如果数据太大,返回的结果会 类似如下

ToolMessage(
    content="""
    Result too large.

    Full content saved to:
    /large_tool_results/call_123

    Preview:
    ...
    """
)

 

⑧ 下一次 Model Call 前:SummarizationMiddleware 检查 Working Context

这是另一个非常关键的位置。从这里开始,Agent 可能已经执行很多轮:查账户、查设备等等。

每次模型调用前都会检查,超阈值才真正摘要。查询的结果多了,到阈值,就会触发 “总结中间件”。

这里做了一个假设:80条内容进行总结,最近的20条保留原文。但那80条中的某一条也可能仅仅是一个“索引”,类似上面在large_tool_results中保存的内容。

You are in the middle of a conversation that has been summarized.

The full conversation history has been saved to
/conversation_history/session_xxx.md
should you need to refer back to it for details.

A condensed summary follows:

<summary>
...
</summary>

  

这个路径由 SummarizationMiddleware 生成并保存,同时也记录在私有状态:

state["_summarization_event"] = {
    "cutoff_index": 80,
    "summary_message": ...,
    "file_path": "/conversation_history/session_xxx.md"
}

 

⑨ Main LLM 分析后发现高风险:必须重新取原始证据

LangGraph 回到 Model Node,准备本轮调用。

def model_node(state, runtime):

    request = ModelRequest(
        model=model,
        tools=default_tools,
        system_message=system_message,
        messages=state["messages"],
        state=state,
        runtime=runtime,
    )

    result = wrap_model_call_handler(
        request,
        _execute_model_sync,
    )

    return _build_commands(result)

在 wrap_model_call_handler 中,再次依次触发各个中间件,最后调用模型。

ModelRequest
    ↓
TodoListMiddleware.wrap_model_call()
    → 加 Planning Prompt
    ↓
FilesystemMiddleware.wrap_model_call()
    → 加 Filesystem 使用说明
    ↓
SummarizationMiddleware.wrap_model_call()
    → 发现之前已经发生 Summary
    → 把本轮真正使用的 messages 变成:
       [Summary] + [最近20条]
    ↓
MemoryMiddleware.wrap_model_call()
    → 加长期 Memory
    ↓
_execute_model_sync()

 

大模型看到了这些,如下。

System:
企业 Prompt
+ Todo Rules
+ Filesystem Rules
+ Long-term Memory

Messages:
[前80条的 Summary]
[最近20条原文]

大模型返回了这些,如下。(大模型的内心想法:这个风险必须追查原始依据,但不会出现在这里)

AIMessage(
    content="该风险结论需要核对原始证据,我将读取早期调查记录。",
    tool_calls=[
        {
            "name": "read_file",
            "args": {
                "file_path": "/conversation_history/session_001.md"
            },
            "id": "call_001"
        }
    ]
)

于是,接下来 去读取 原始数据,进入 Tool Call 生命周期:

LangGraph Tool Node
      ↓
FilesystemMiddleware.wrap_tool_call()
      ↓
handler(request)
      ↓
真正执行 read_file(...)

读内容的细节大致如下。

read_file(...)
    ↓
backend.read(
    "/conversation_history/session_001.md",
    offset=0,
    limit=100  # 不会一次性全部发给模型,没必要;可以分批发送,再持续问模型:够不够?不够再发下面一部分。
)

Tool 返回如下。

ToolMessage(
    content="""
    ...
    fetch_transactions returned a large result.

    Preview:
    suspicious overseas transactions ...

    Full result:
    /large_tool_results/call_123
    ...
    """
)

 

大模型读取 Tool 返回的消息后,认为需要进一步深究,返回如下。

AIMessage(
    tool_calls=[{
        "name": "read_file",
        "args": {
            "file_path": "/large_tool_results/call_123",
            "offset": 1200,
            "limit": 200
        }
    }]
)

于是,接下来 去读取 原始数据,进入 Tool Call 生命周期。Tool 返回ToolMessage。步骤类似上一次 Tool Call 过程。

 

最后,大模型终于可以完成最终的判断,如下。

LangGraph 回到 Model Node
    ↓
重新形成 ModelRequest
    ↓
各 wrap_model_call() 再加工一次
    ↓
Main LLM 再调用一次 ✅
    ↓
这次模型终于看到了原始证据
    ↓
分析:
“之前的高风险判断是否成立?”

 

 

 

子智能体机制

Why?

随着任务步骤增多,单一智能体的上下文会变得臃肿,不仅影响性能,还容易让模型“迷失”在细节中。
对此,DeepAgents 给出的解决方案之一就是引入子智能体。
 

How?

  1. SubAgent 配置方式
    用字典/配置描述,让 DeepAgents 帮你创建。
  2. CompiledSubAgent
    你先自己创建好 Agent,再包装注册进去。 

 3. 第三种方式的自然演化:其实非常自然,是三个复杂度层级:

Level 1
“帮我快速造一个 Agent”
↓
SubAgent


Level 2
“我已经有一个高级 Agent”
↓
CompiledSubAgent


Level 3
“这个 Agent 是 独立运行的服务/后台 Worker”
↓
AsyncSubAgent

 

假设公司采购部门准备采用一家叫 NovaAI 的 AI 供应商。
“调查 NovaAI 是否适合作为我们的 AI 供应商。1)外部信息必须有来源,内部风险必须有证据编号。2)如果存在高风险,需要人工批准。”
可见,两个子代理各自独立,分工明确,有明显的边界感。
                    Main Agent
                 供应商尽调负责人
                       │
           ┌───────────┴───────────┐
           ▼                       ▼
    web-researcher             risk-reviewer
     外部资料调查                内部风险审查
           │                       │
     新建的简单 Agent         公司已有成熟 Agent
           │                       │
       SubAgent             CompiledSubAgent

 

1. 定义工具 

主要是三个工具:网页搜索查询、公司内部风险查询、向风险负责人提交人工审批。

from langchain_core.tools import tool


@tool
def web_search(query: str) -> str:
    """搜索公开互联网信息。教学版使用模拟数据。"""
    return """
    Company: NovaAI
    Finding: NovaAI announced an enterprise AI platform in 2026.
    Source: https://example.com/novaai-product

    Finding: NovaAI raised a Series B round.
    Source: https://example.com/novaai-funding
    """


@tool
def lookup_internal_risk(vendor_name: str) -> str:
    """查询公司内部供应商风险数据库。"""
    return """
    vendor: NovaAI
    risk_level: HIGH
    issue: Previous contract delivery dispute
    evidence_id: RISK-2026-0182
    """


@tool
def request_approval(vendor_name: str, reason: str) -> str:
    """向风险负责人提交人工审批。"""
    return f"Approval recorded for {vendor_name}: {reason}"
Tools 定义

 

2. 定义第一个 SubAgent

from deepagents.middleware.subagents import SubAgent

MODEL = "openai:gpt-5.5"

web_researcher: SubAgent = {
    "name": "web-researcher",

    # 给 Main LLM 看:
    # Main Agent 根据 description 判断什么时候应该派它出去。
    "description":
        "调查供应商的公开信息,包括产品、融资、客户和公开争议。"
        "所有重要结论必须返回 source URL。",

    # 给 Web Researcher 自己的 LLM 看:
    "system_prompt": """
        你是一名企业供应商外部调查员。

        你的工作:
        1. 使用 web_search 搜索公开资料;
        2. 不要根据没有来源的信息下结论;
        3. 每条重要发现必须保留 source URL;
        4. 最后返回简洁的调查结果。
    """,

    # 这个 SubAgent 只有公开搜索能力
    "tools": [web_search],

    "model": MODEL,
}

 

3. 调用一个 SubAgent

假设 Risk Agent 不是为了这个项目临时开发的

公司风险部门原本就存在的 Agent。所以,在此之前已“独立”被创建。

risk-team-project/
│
└── risk_agent.py
       ↓
   create_agent(...)
   tools
   prompt
   middleware
   state
   HITL
risk_agent.py的内容如下。
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver


risk_agent = create_agent(
    model=MODEL,

    tools=[
        lookup_internal_risk,
        request_approval,
    ],

    system_prompt="""
        你是公司的供应商风险审查 Agent。

        规则:

        1. 必须使用 lookup_internal_risk 检查内部记录。
        2. 风险结论必须引用 evidence_id。
        3. 如果发现 HIGH 风险,不得自行批准合作。
        4. HIGH 风险必须调用 request_approval,进入人工审批。
    """,

    # 企业规则:
    # request_approval 真正执行前必须暂停,让人批准。
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "request_approval": {
                    "allowed_decisions": ["approve", "reject"]
                }
            }
        )
    ],

    # HITL 需要 persistence,
    # 因为 Agent 暂停后还要能够恢复。
    checkpointer=InMemorySaver(),
)

 

在本项目中,如下方式调用。

from company_agents.risk import risk_agent

risk_reviewer: CompiledSubAgent = {
    "name": "risk-reviewer",
    "description": "负责企业内部供应商风险审查,高风险需要人工审批。",
    "runnable": risk_agent,
}

 

4. 都交给 Main Agent

from deepagents import create_deep_agent

main_agent = create_deep_agent(
    model=MODEL,

    system_prompt="""
        你负责企业 AI 供应商尽调。

        调查供应商时:

        - 外部公开信息交给 web-researcher;
        - 内部风险审查交给 risk-reviewer;
        - 不允许编造证据;
        - 最终报告必须保留公开 source URL
          和内部 evidence_id;
        - 高风险事项必须体现人工审批状态。
    """,

    tools=[],  # 子代理中间件之后会为其增加 task tools.

    subagents=[
        web_researcher,   # ← 配方
        risk_reviewer,    # ← 已经做好的 Agent
    ],

    checkpointer=InMemorySaver(),
)

内部自动新增了tools列表中的内容,也就是构造时就已经注册。

subagents=[web_researcher, risk_reviewer]
            ↓
自动创建 SubAgentMiddleware(subagents=这两个)
            ↓
SubAgentMiddleware.__init__()
            ↓
根据这两个 Agent 生成一个 task Tool  # ----> 关键构建过程。
            ↓
self.tools = [task_tool]

关键构建过程:

compiled_subagents = [_compile_spec(spec) for spec in subagents]

subagent_graphs = {
    spec["name"]: spec["runnable"]
    for spec in compiled_subagents
}

在 SubAgentMiddleware 创建 task Tool 的时候,如上,就构建好了,得到如下。

subagent_graphs = { 
    "web-researcher": runnable_A, 
    "risk-reviewer": runnable_B 
}

runnable_A = create_agent(
    model=model,
    tools=[web_search],
    system_prompt="..."
)

 

如上装配;如下触发、启动。

config = {
    "configurable": {
        "thread_id": "novaai-due-diligence-001"
    }
}

result = main_agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content":
                    "调查 NovaAI 是否适合作为我们的 AI 供应商。"
            }
        ]
    },
    config=config,
)

 

5. 关于 task tool as input param.

“主模型把 SubAgent 当 Tool 看”,从模型视角基本可以这么理解,但再精确一点:它不是看到两个 Agent = 两个 Tools,而是看到一个统一的 task Tool。

根据所有 SubAgent 的 name + description 构造 task tool 的描述。
Tool name:
    task

Tool description:  # 两个 sub agents都挤在一起
    把复杂任务交给 SubAgent。

    可用 SubAgents:

    1. web-researcher
       负责调查公开网络信息

    2. risk-reviewer
       负责查询企业内部风险

Tool arguments:
    subagent_type: string
    description: string

也就是如下在tools key中看到的样子。这也是大模型看到的输入参数的大概样子

[llm input]

{
"messages": [ { "role": "system", "content": "你负责企业供应商尽调..." }, { "role": "user", "content": "调查 NovaAI 是否适合作为我们的 AI 供应商。" } ], "tools": [ { "name": "task", "description": "把任务委派给一个子智能体。\n\nAvailable subagents:\n- web-researcher: 负责调查公开网络信息\n- risk-reviewer: 负责查询企业内部风险", "parameters": { "type": "object", "properties": { "subagent_type": { "type": "string" }, "description": { "type": "string" } }, "required": [ "subagent_type", "description" ] } } ] }
大模型思考后,返回自己的判断结果。 
[llm output]

{
  "tool_calls": [
    {
      "id": "call_web_001",
      "name": "task",
      "args": {
        "subagent_type": "web-researcher",
        "description":
          "调查 NovaAI 的产品、融资、客户和公开争议。
           每项重要结论必须包含 source URL。"
      }
    },
    {
      "id": "call_risk_001",
      "name": "task",
      "args": {
        "subagent_type": "risk-reviewer",
        "description":
          "检查 NovaAI 的内部供应商风险记录。
           返回 risk_level、问题说明和 evidence_id。"
      }
    }
  ]
}

 

6. 子代理的执行与结果合并

ToolNode:这一条 AIMessage 有两个 tool_calls。
然后在下面(之前已装配好)找到对应的runnable,直接 invoke 就好
subagent_graphs = { 
    "web-researcher": runnable_A, 
    "risk-reviewer": runnable_B 
}

 

6.1 两个 SubAgent 各跑自己的 Agent Loop

Web Researcher
Task → LLM → Web Search Tool → LLM → Result + source URL

Risk Reviewer
Task → LLM → Internal Risk Tool → LLM → Result + evidence_id


Main Agent 汇总

Human Review

 

6.2 两个 SubAgent 的汇总细节

Main agent看到下面的返回结果。
ToolMessage(
    content="""
    {
      "findings": ["NovaAI 获得 B 轮融资"],
      "sources": ["https://..."]
    }
    """,
    tool_call_id="call_web"
)

ToolMessage(
    content="""
    {
      "risk": "HIGH",
      "finding": "曾发生重大履约争议",
      "evidence_id": "RISK-2026-0182"
    }
    """,
    tool_call_id="call_risk"
)

 

大模型 给出分析结果。

AIMessage(
    content="""
    综合判断:
    NovaAI 存在较高合作风险。

    外部证据:
    - ...
    - source_url: ...

    内部证据:
    - ...
    - evidence_id: RISK-2026-0182

    Recommendation:
    进入人工审核。
    """
)

 

 

7. 人工审批

可以通过 Python 语法判断出 requires_review = true.
Main LLM 汇总
      ↓
risk_level = HIGH
      ↓
Python规则:
if risk_level == "HIGH":
    requires_review = True
      ↓
Human Review

 

参考:[langgraph] Build Agent

  • 以前: 你亲自画 approval node。
  • 现在: HumanInTheLoopMiddleware 帮你把 interrupt 逻辑嵌入 Agent Graph。
 
因为我们把人工审批从 risk_review 中拿出来放在了合并后,所以我们重新考虑。
 
 
[1] 这是一个 Deep Agent + LangGraph 的经典例子:

ChatGPT Image Aug 25, 2026, 05_48_12 PM

from typing import Literal, TypedDict
from pydantic import BaseModel

from deepagents import create_deep_agent
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver


MODEL = "openai:gpt-5.5"


# ============================================================
# 1. Main DeepAgent 最终必须 输出的结构
# ============================================================

class VendorAssessment(BaseModel):
    vendor: str
    risk_level: Literal["LOW", "MEDIUM", "HIGH"]
    summary: str
    source_urls: list[str]
    evidence_ids: list[str]


# ============================================================
# 2. DeepAgent
#    web_researcher / risk_reviewer 已在前面定义
# ============================================================

deep_agent = create_deep_agent(
    model=MODEL,

    tools=[],

    subagents=[
        web_researcher,
        risk_reviewer,
    ],

    system_prompt="""
    你负责供应商尽调。

    必须:
    1. 调用 web-researcher 调查公开信息;
    2. 调用 risk-reviewer 调查内部风险;
    3. 两个结果返回后再综合判断;
    4. 保留 source_url 和 evidence_id;
    5. 最终输出结构化 VendorAssessment。

    你只负责风险评估,不负责人工审批。
    """,

    # Main Agent 汇总结果强制结构化
    response_format=VendorAssessment,
)


# ============================================================
# 3. 外层企业 Workflow State
# ============================================================

class WorkflowState(TypedDict, total=False):
    request: str
    assessment: dict
    approval: Literal["approved", "rejected", "not_required"]


# ============================================================
# 4. 调用 DeepAgent:完成调查 + 汇总
# ============================================================

def investigate(state: WorkflowState):
    result = deep_agent.invoke({
        "messages": [
            {
                "role": "user",
                "content": state["request"],
            }
        ]
    })

    assessment = result["structured_response"]

    return {
        "assessment": assessment.model_dump()
    }


# ============================================================
# 5. 确定性审批规则 —— 不调用 LLM
# ============================================================

def route_after_assessment(state: WorkflowState):
    if state["assessment"]["risk_level"] == "HIGH":
        return "NEEDS_REVIEW"

    return "NO_REVIEW"


# ============================================================
# 6. 真正的 LangGraph Human Review
# ============================================================

def human_review(state: WorkflowState):
    approved =interrupt({
        "question": "该供应商被评为 HIGH risk,是否批准继续?",
        "assessment": state["assessment"],
    })

    return {
        "approval": "approved" if approved else "rejected"
    }


# ============================================================
# 7. 正常结束
# ============================================================

def finish(state: WorkflowState):
    if state["assessment"]["risk_level"] != "HIGH":
        return {"approval": "not_required"}

    return {}


# ============================================================
# 8. 企业 Workflow Graph
# ============================================================

builder = StateGraph(WorkflowState)

builder.add_node("investigate", investigate)
builder.add_node("human_review", human_review)
builder.add_node("finish", finish)

builder.add_edge(START, "investigate")
builder.add_conditional_edges(
    "investigate",
    route_after_assessment,
    {
        "NEEDS_REVIEW": "human_review",  # 根据 route_after_assessment的返回值 引向下一个node。
        "NO_REVIEW": "finish",
    },
)
builder.add_edge("human_review", "finish")  # 这里的 finish node 只是做了个收尾工作:设置了 approved 的key。
builder.add_edge("finish", END)

workflow = builder.compile(
    checkpointer=InMemorySaver()
)
 
 
[2] DeepAgents interrupt_on 的例子:退款
 

不应该 “一涉及 Human Approval 就优先 LangGraph”。判断标准其实很简单:

审批的是“某个动作” → 用 DeepAgents interrupt_on
审批的是“整个流程到了某个业务阶段” → 用 LangGraph interrupt()

 
一个真正适合 DeepAgents HITL(Human in the Loop)的业务例子:退款 Agent。
 
"""
DeepAgents Tool-level HITL 教学示例:大额退款人工审批

目标:
1. 看清 lookup_order 为什么存在;
2. 看清 issue_refund 为什么是“真正需要审批”的 Tool;
3. 看清 create_deep_agent(interrupt_on=...) 如何自动提供 Tool-level HITL;
4. 看清第一次 invoke() 为什么可能只是“暂停返回”;
5. 看清第二次 invoke(Command(resume=...)) 为什么是在恢复同一条 Graph 执行,而不是重新运行一个新 Agent。
"""

from deepagents import create_deep_agent
from langchain.agents.middleware import ToolCallRequest
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command


MODEL = "openai:gpt-5.5"


# ============================================================
# 0. 模拟企业数据
# ============================================================

ORDERS = {
    "ORD-1001": {
        "customer": "Alice",
        "paid_amount": 200.0,
        "status": "paid",
    },
    "ORD-1002": {
        "customer": "Bob",
        "paid_amount": 1500.0,
        "status": "paid",
    },
}

REFUND_LEDGER: list[dict] = []


def _already_refunded(order_id: str) -> float:
    """内部辅助函数:计算某订单已经实际退款的总额。"""
    return sum(
        record["amount"]
        for record in REFUND_LEDGER
        if record["order_id"] == order_id
        and record["status"] == "refunded"
    )


# ============================================================
# 1. 查询 Tool:给 Agent 提供“做退款决定所需的权威事实”
# ============================================================

@tool
def lookup_order(order_id: str) -> dict:
    """
    查询订单的权威记录。

    它不是“为了看一遍订单”,而是给 Agent 提供退款决策所需的事实:
    - 订单是否存在
    - 当前状态是否允许退款
    - 实际支付金额
    - 已退款金额
    - 当前最多还能退款多少

    Agent 后续“能不能退、退多少”必须基于这些事实,
    而不能相信用户自己声明的金额。
    """
    order = ORDERS.get(order_id)

    if order is None:
        return {
            "found": False,
            "order_id": order_id,
        }

    refunded = _already_refunded(order_id)
    refundable_balance = max(order["paid_amount"] - refunded, 0.0)

    return {
        "found": True,
        "order_id": order_id,
        "customer": order["customer"],
        "status": order["status"],
        "paid_amount": order["paid_amount"],
        "already_refunded": refunded,
        "refundable_balance": refundable_balance,
    }


# ============================================================
# 2. 副作用 Tool:真正调用支付系统执行退款
# ============================================================

@tool
def issue_refund(
    order_id: str,
    amount: float,
    reason: str,
) -> dict:
    """
    真正执行退款。 --> 模型选择它,则触发“中断之人工审查”

    一旦这个函数执行成功,就代表钱已经退给客户。
    因此 HITL 应该拦截的是这个 Tool,而不是 lookup_order。

    注意:
    即使 LLM 前面已经调用 lookup_order,
    真正执行资金操作时仍重新校验关键业务约束。
    这是 side-effect boundary 上的最终保护。
    """
    order = ORDERS.get(order_id)

    if order is None:
        return {
            "ok": False,
            "error": "ORDER_NOT_FOUND",
            "order_id": order_id,
        }

    if order["status"] != "paid":
        return {
            "ok": False,
            "error": "ORDER_NOT_REFUNDABLE",
            "order_id": order_id,
        }

    refundable_balance = max(
        order["paid_amount"] - _already_refunded(order_id),
        0.0,
    )

    if amount <= 0:
        return {
            "ok": False,
            "error": "INVALID_REFUND_AMOUNT",
            "amount": amount,
        }

    if amount > refundable_balance:
        return {
            "ok": False,
            "error": "REFUND_EXCEEDS_AVAILABLE_BALANCE",
            "requested_amount": amount,
            "refundable_balance": refundable_balance,
        }

    refund_record = {
        "refund_id": f"REF-{len(REFUND_LEDGER) + 1:04d}",
        "order_id": order_id,
        "amount": amount,
        "reason": reason,
        "status": "refunded",
    }

    # 模拟 payment_api.refund(...) / 写真实账务系统
    REFUND_LEDGER.append(refund_record)

    return {
        "ok": True,
        **refund_record,
    }


# ============================================================
# 3. HITL 条件:什么样的 issue_refund 需要人工审批?
# ============================================================

def is_large_refund(request: ToolCallRequest) -> bool:
    """
    HumanInTheLoopMiddleware 在 issue_refund 真正执行前,
    检查 Main LLM 刚刚提出的 Tool Call。

    True  -> interrupt(),暂停 Graph,等待人工决定
    False -> 不暂停,Tool 继续执行
    """
    proposed_amount = float(
        request.tool_call["args"].get("amount", 0)
    )
    return proposed_amount >= 1000.0


# ============================================================
# 4. 创建 DeepAgent
# ============================================================

agent = create_deep_agent(
    model=MODEL,

    system_prompt="""
你是公司的退款处理 Agent。

退款流程:

1. 在决定是否退款以及退款金额之前,必须先调用 lookup_order。
   lookup_order 提供订单当前的权威状态和 refundable_balance。

2. 只有订单存在、状态允许退款,并且 requested amount
   不超过 refundable_balance 时,才可以调用 issue_refund。

3. issue_refund 是真正产生资金副作用的动作。
   不允许在 issue_refund 尚未成功执行时向用户声称“退款成功”。

4. 如果 issue_refund 被人工拒绝,应明确告诉用户退款没有执行,
   不得把“已申请退款”描述成“已经退款”。
""",

    tools=[
        lookup_order,
        issue_refund,
    ],

    # interrupt_on 不会创建 issue_refund Tool。
    # "issue_refund" 必须已经存在于 tools=[...] 中。
    #
    # create_deep_agent 会根据该配置自动安装
    # HumanInTheLoopMiddleware,并在这个 Tool 真正执行前检查它。
    interrupt_on={
        "issue_refund": {
            # approve -> 按模型原参数执行
            # edit    -> 修改 Tool 参数后执行
            # reject  -> 不执行,并把拒绝反馈回 Agent
            "allowed_decisions": [
                "approve",
                "edit",
                "reject",
            ],

            # 只有 >= $1000 的退款才触发 interrupt。
            "when": is_large_refund,  # <---- tool call 触发这里 by HITL middleware
        }
    },

    # interrupt() 暂停后,要保存 Graph 执行现场。
    # 教学/测试使用 InMemorySaver;
    # 生产环境应换持久化 checkpointer。
    checkpointer=InMemorySaver(),
)


# ============================================================
# 5. 第一次 invoke:
#    一直运行到 END,或者运行到 interrupt 暂停点
# ============================================================

config = {
    "configurable": {
        # resume 时必须使用同一个 thread_id,
        # LangGraph 才能找到刚才的 checkpoint。
        "thread_id": "refund-demo-001",
    }
}

pause_or_finish = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": (
                    "请处理 ORD-1002 的退款。"
                    "如果订单符合条件,按当前可退款余额全额退款,"
                    "原因为:客户取消服务。"
                ),
            }
        ]
    },
    config=config,
    version="v2",
)
 
开始用户交互,终端显示。
if pause_or_finish.interrupts:

    # ========================================================
    # 6. Graph 已暂停:把 HITL 信息转换成人类可读的终端界面
    # ========================================================

    interrupt_data = pause_or_finish.interrupts[0].value

    # action_requests:当前准备执行、但尚未真正执行的 Tool Call
    action = interrupt_data["action_requests"][0]

    # review_configs:这次人工审批允许做哪些操作
    review = interrupt_data["review_configs"][0]

    print("\n===== HUMAN APPROVAL REQUIRED =====")

    print("\n准备执行 Tool:")
    print(action["name"])

    print("\nTool 参数:")
    for key, value in action["args"].items():
        print(f"  {key}: {value}")

    print("\n允许的人工操作:")
    print("1. Approve  - 按原参数执行退款")
    print("2. Edit     - 修改参数后再执行")
    print("3. Reject   - 拒绝执行退款")

    # --------------------------------------------------------
    # 此时程序真正停在这里等待人的输入
    # --------------------------------------------------------
    choice = input("\n请输入 1 / 2 / 3: ").strip()


    # ========================================================
    # 7. 把人的终端输入转换成 LangGraph HITL decision
    # ========================================================

    if choice == "1":

        # Approve:
        # 原来的 issue_refund Tool Call 保持不变,
        # Graph 恢复后直接执行它。
        decision = {
            "type": "approve"
        }


    elif choice == "2":

        # Edit:
        # 人工修改原来的 Tool Call 参数。
        #
        # 这里为了教学,只允许修改退款金额;
        # order_id 和 reason 沿用原来的值。
        original_args = action["args"]

        new_amount = float(
            input("请输入修改后的退款金额: ").strip()
        )

        decision = {
            "type": "edit",

            "edited_action": {
                "name": action["name"],

                "args": {
                    "order_id": original_args["order_id"],
                    "amount": new_amount,
                    "reason": original_args["reason"],
                }
            }
        }


    elif choice == "3":

        # Reject:
        # issue_refund 不会执行。
        #
        # 拒绝原因会作为 ToolMessage 类似的信息
        # 返回给 Main Agent,使 LLM 知道退款被人工拒绝。
        reject_reason = input(
            "请输入拒绝原因: "
        ).strip()

        decision = {
            "type": "reject",
            "message": reject_reason,
        }

    else:
        raise ValueError(
            "无效选择,只允许输入 1、2 或 3。"
        )


    # ========================================================
    # 8. 恢复刚才暂停的同一条 Graph 执行
    # ========================================================

    # 非常重要:
    #
    # 这不是“重新运行 Agent”。
    #
    # Command(resume=...) + 相同 thread_id
    # 会让 LangGraph:
    #
    # 1. 从 checkpointer 找到刚才 interrupt 时保存的现场;
    # 2. 把人工 decision 送回 HumanInTheLoopMiddleware;
    # 3. 从刚才暂停的位置继续执行。
    resumed_run = agent.invoke(  ----> 等待 tool message from issue_refund --> llm --> AI message:"退款已成功” --> END
        Command(
            resume={
                "decisions": [
                    decision
                ]
            }
        ),

        # 必须使用和第一次 invoke 相同的 thread_id。
        config=config,
        version="v2",
    )


    # ========================================================
    # 9. 恢复之后的结果
    # ========================================================

    print("\n===== GRAPH RESUMED =====")

    # 注意:
    # resumed_run 理论上仍然可能再次触发新的 interrupt,
    # 所以不要简单理解成“一定已经结束”。
    if resumed_run.interrupts:

        print(
            "\nGraph 再次暂停,"
            "说明后续又出现了新的人工审批点。"
        )

        print(resumed_run.interrupts)

    else:

        print("\nGraph 已正常运行到结束。")
        print(resumed_run.value)  --> 


else:

    # ========================================================
    # 10. 第一次 invoke 没有触发 HITL
    # ========================================================

    # 例如退款金额 < $1000:
    #
    # is_large_refund(...) == False
    #
    # issue_refund 不会被 interrupt,
    # Agent 会直接继续执行直到 END。
    print("\n===== GRAPH FINISHED WITHOUT HUMAN REVIEW =====")
    print(pause_or_finish.value)


# ============================================================
# 11. 查看最终真实业务副作用
# ============================================================

print("\n===== REFUND LEDGER =====")
print(REFUND_LEDGER)

 

posted @ 2026-08-23 23:19  郝壹贰叁  阅读(25)  评论(0)    收藏  举报