首页 / 文章

在LangGraph中构建规划智能体:规划、执行、重新规划、报告

用于金融研究的四节点LangGraph规划代理:结构化计划、支持命名空间识别的工具注册表、重新规划限制以及可追踪的示例运行过程。

发布者:REACTAPP.TOP阅读约 16 分钟3625 词

来源: Your AI Agent Isn’t Smart. Here’s How to Build One That Actually Thinks

大多数智能体不过是伪装起来的提示链

关于AI智能体有一个令人不安的事实:很多其实只是披着外衣的升级版提示链。其构成方式很常见——一个系统提示、几种工具,或许再加上一个ReAct循环,这样做出来的东西就被称作智能体。在演示环境中或一切顺利的情况下它能正常工作。但一旦某个工具返回错误,数据出现异常,或者查询需要超过三步的处理,整个系统就会崩溃。

另一种选择是规划智能体。它在使用任何工具之前先制定完整的研究计划,然后按步骤逐一执行该计划;当中间结果与预期不符时,它会重新编写剩余部分的内容。这个演示案例是一个金融研究系统。只需给出用简单英语表达的请求,比如“比较NVDA和AAPL的估值,告诉我目前哪个看起来更优”,它就会制定多步骤计划,利用数据工具执行每一步操作,出问题时自行修正,并生成类似分析师的报告。该系统基于LangGraph构建,可通过Groq调用云端模型,或通过Ollama使用本地模型,整个系统大约包含四个文件。

为何普通的ReAct不够用

ReAct(推理-行动-观察)是大多数智能体起始采用的模式。这类大语言模型会先思考下一步该做什么,调用某个工具,读取返回结果后再次思考,然后调用下一个工具。这种模式很受欢迎,操作简单,适合处理小型任务。

它的核心缺陷在于贪心,因为ReAct智能体只能规划一步之后的行动。如果要求根据三项指标分析五只股票并附带最新新闻,它往往会从NVDA的市盈率开始计算,而第一个得到的结果就会转移它的注意力。有一个原型在记住需要对比多只股票的任务之前,就为每项特斯拉相关的指标调用了七次工具,最终的报告不过是特斯拉的盈利概况加上关于其他股票的短短一句话而已。

问题不在于模型能力,而在于架构设计。如果每次推理过程都必须决定要研究什么、如何研究以及如何处理研究结果,那么这些决策都无法得到妥善处理。规划智能体能够将思考与执行分开:先进行规划,再执行计划,最后检查计划是否仍然有效。每个节点只处理一项任务,它们依次运行。

四个节点与一个循环

规划智能体是一个包含四个节点的有向图,类似于流程图,其中每个方块负责处理一项任务并传递状态。

START --> Planner --> Executor <--> Replanner --> Reporter --> END
  1. 规划器会接收用户的查询,进而生成一份结构化、分步骤的研究计划。每一步都会明确指出需要调用的工具、相关参数以及选择该工具的原因。
  2. 执行器会依次执行计划中的每一步,调用指定的工具,并将执行结果记录在临时笔记中。
  3. 重新规划器会在每次执行完成后审查目前已收集的所有信息。如果某个工具出现错误或数据呈现异常情况,它会重新编写剩余的步骤;否则则保持原计划不变。
  4. 报告生成器会将所有临时笔记的内容整合成最终的分析师报告,报告中会包含具体的数据及相应的建议。

驱动该设计的正是执行器-重规划器循环。这两个节点交替工作直至计划完成,这种交替机制带来了自我修正的能力,因为每次执行动作后,智能体都可以重新考虑。

状态:共享内存

在LangGraph中,状态就是一切,即节点之间传递的共享内存。其完整定义如下:

import operator
from typing import Annotated, TypedDict

from pydantic import BaseModel, Field

class StrategyState(TypedDict, total=False):
    """Global state that flows through the LangGraph nodes."""
    query: str
    plan: list[dict]
    scratchpad: Annotated[list[dict], operator.add]
    current_step: int
    final_report: str
    replan_count: int

在这种状态下,query 会原封不动地保存用户的原始请求,plan 是步骤的有序列表,scratchpad 用于收集所有工具的运行结果,current_step 指向计划中的当前步骤,而 replan_count 则用于控制 MAX_REPLANS 这一安全上限。

scratchpad 上的标注 Annotated[list[dict], operator.add] 非常重要。其 reducer 即 operator.add,会指示 LangGraph 将新条目追加到列表中,同时保留原有条目不变。当执行器返回 {"scratchpad": [new_entry]} 时,LangGraph 会将其添加到当前列表中,这样节点就无需手动处理列表,也就不会出现修改错误。

结构化计划与决策

Planner从不返回纯文本。它的输出是Pydantic模型,因此大语言模型必须以代码能够解析的格式来生成计划:

AVAILABLE_TOOLS_TEXT = """
- get_metrics(ticker, metric?): Return stock metrics. 'metric' is optional
  (P/E, EPS, Revenue, Market Cap, Sector).
- search_news(ticker): Return recent news headlines for a ticker.
- compare_metrics(tickers: list, metric): Compare one metric across
  multiple tickers.
"""

class PlanStep(BaseModel):
    """A single executable step inside an analysis plan."""
    step_id: int = Field(description="Sequential step number")
    tool: str = Field(
        description=f"Tool to use. Must be one of:\n{AVAILABLE_TOOLS_TEXT}"
    )
    args: dict = Field(description="Arguments for the tool call")
    purpose: str = Field(description="Why this step is needed")

class Plan(BaseModel):
    """The full plan generated by the planner node."""
    goal: str = Field(description="The overall analysis goal")
    steps: list[PlanStep] = Field(
        description="Ordered list of steps to execute"
    )

purpose字段的价值超出预期。它不仅说明某个步骤的功能,还解释了其存在的理由。与单纯的“call compare_metrics,”相比,若能看到“通过比较市盈率来确定相对估值”,Replanner就能更准确地判断是保留、修改还是删除该步骤。

Replanner拥有自己的架构:

class ReplanDecision(BaseModel):
    """Output of the replanner node."""

reasoning: str = Field(
        description="Analysis of current progress and findings"
    )
    should_replan: bool = Field(
        description="Whether the plan needs modification"
    )
    updated_steps: list[PlanStep] = Field(
        default_factory=list,
        description="Remaining steps if replan is needed. Empty if no changes.",
    )

将should_replan设为布尔值可以迫使大语言模型做出明确选择:计划是否需要更改?如果在结构化输出中给大语言模型留出灵活空间,它每次都会做出不同选择。

工具系统

有三款金融研究工具基于真实的模拟数据运行。实际应用中会使用真实的API调用,但在开发阶段模拟数据具有显著优势:即可确定的调试能力。当智能体行为异常时,可以立即判断问题出在其逻辑上还是API上。

另一种值得更多关注的设计模式是具备命名空间意识的工具注册表,它取代了为每个节点配备所有工具的做法:

from langchain_core.tools import BaseTool
from typing import Iterable, Mapping, Any

class ToolRegistry:
    """Namespace-aware container for LangChain tools."""
    def __init__(self) -> None:
        self._tools_by_toolset: dict[str, dict[str, BaseTool]] = {}
    def add_tools(self, toolset: str, tools: Iterable[BaseTool]) -> None:
        bucket = self._tools_by_toolset.setdefault(toolset, {})
        bucket.update({t.name: t for t in tools})
    def get_tools(self, toolset: str) -> tuple[BaseTool, ...]:
        return tuple(self._tools_by_toolset.get(toolset, {}).values())
    def invoke(
        self, toolset: str, tool_name: str, tool_args: Mapping[str, Any]
    ) -> Any:
        t = self._tools_by_toolset.get(toolset, {}).get(tool_name)
        if t is None:
            raise ValueError(
                f"Unknown tool '{tool_name}' in toolset '{toolset}'"
            )
        return t.invoke(dict(tool_args))

注册在 "planner"、"executor" 和 "replanner" 这些命名空间下进行。所有工具都对 Planner 可见,以便其能够利用这些工具进行规划,但只有 Executor 才有权限调用它们。即便 Replanner 因程序错误而发出了工具调用请求,注册表也会予以拒绝,因为该命名空间没有调用权限。这就是针对智能体的基于角色的访问控制,而职责分离同时也作为一种安全机制,在规划过程中防止不必要的状态变化。

Planner 节点

def planner_node(state: StrategyState) -> dict:
    """Create a step-by-step research plan using structured output."""
    planner = model.with_structured_output(Plan)

prompt = PLAN_PROMPT.format(
        available_tools=AVAILABLE_TOOLS_TEXT,
        ticker_choices=ticker_choices_text(),
        metric_choices=metric_choices_text(),
        query=state["query"],
    )
    plan: Plan = planner.invoke(prompt)
    steps = [s.model_dump() for s in plan.steps]
    return {"plan": steps, "current_step": 0}

model.with_structured_output(Plan) 可让大语言模型以兼容 Pydantic 的 JSON 对象形式回复,若输出格式不正确则自动触发重试。这比用正则表达式解析自由文本要高效得多。

计划提示中的约束规则

PLAN_PROMPT = """\
You are a financial research planner. Given a user's analysis request,
create a step-by-step research plan using the available tools.

Available tools:
{available_tools}
Rules:
- Use only the tools listed above.
- Every plan step must be executable with one of those tools.
- When a tool accepts 'ticker' or 'tickers', use only these exact values:
  {ticker_choices}
- When a tool accepts 'metric', use one of these exact values:
  {metric_choices}
- There are no other tools available. Final synthesis is handled separately.
Create an efficient plan. Group related lookups. Aim for 4-8 steps.
User request: {query}"""

有两个要点值得注意。提示语会列出有效的参数值,这样大语言模型就不必猜测有哪些股票代码或指标存在。这或许是针对工具调用型智能体最有效的防幻觉方法。如果没有这一提示,模型会自信地规划诸如 get_metrics(ticker="BITCOIN", metric="vibes") 这样的步骤,而当这些步骤失败时又会显得十分惊讶。

另一个要点是“没有其他可用工具”这句话。如果缺少这句话,大语言模型就会编造出 analyze_sentiment(ticker="NVDA") 或 generate_report(findings=...) 这类并不存在的工具步骤。

“目标为4到8步”的要求刻意保持了灵活性。诸如“必须恰好5步”这样的严格限制反而适得其反,它会导致简单查询需要多余的步骤,而复杂查询则被迫塞进已超负荷的步骤中。设定范围可以让模型有调整空间,以指导原则代替强制要求通常能使其做出正确的决策。

执行节点

执行节点被设计得极为简单:它负责执行下一步操作,通过注册表调用工具,并存储输出结果。

MAX_STEPS = 12  # Safety limit on total steps

def executor_node(state: StrategyState) -> dict:
    """Execute the next pending step from the plan."""
    plan = state.get("plan", [])
    current_step = state.get("current_step", 0)
    if current_step >= len(plan):
        return {}
    if current_step >= MAX_STEPS:
        return {"current_step": len(plan)}
    step = plan[current_step]
    tool_name = step["tool"]
    tool_args = step["args"]
    try:
        result = str(
            TOOL_REGISTRY.invoke(
                AgentName.EXECUTOR.value, tool_name, tool_args
            )
        )
        status = "Error" if result.startswith("Error:") else "Success"
    except Exception as exc:
        result = f"Error: {exc}"
        status = "Error"
    entry = {
        "step": current_step + 1,
        "tool": tool_name,
        "args": tool_args,
        "result": result,
        "status": status,
    }
    return {
        "scratchpad": [entry],
        "current_step": current_step + 1,
    }

MAX_STEPS = 12 是一个硬性上限。如果重规划器不断增加步骤数或陷入僵局,智能体也无法无限消耗 API 信用额度。一旦达到该上限,执行器会将 current_step 设为 len(plan),然后流程会将目前收集到的数据传递给报告器。

失败情况通过约定方式识别:status = "Error" if result.startswith("Error:") else "Success"。三个工具中的每一个都会在错误信息前加上 "Error:",这样执行器无需进行 JSON 解析或针对特定异常的处理即可标记失败。重规划器在决定是重新尝试还是调整计划时,会读取此状态值。

重规划器节点

该节点体现了规划代理与简单协调器之间的区别。在每次工具执行后,它会综合所有证据,判断剩余的规划方案是否依然合理:

MAX_REPLANS = 2  # Prevent infinite replanning

def replanner_node(state: StrategyState) -> dict:
    """Review progress and optionally modify the remaining plan."""
    plan = state.get("plan", [])
    current_step = state.get("current_step", 0)
    replan_count = state.get("replan_count", 0)
    scratchpad = state.get("scratchpad", [])
    remaining = plan[current_step:]
    if len(remaining) = MAX_REPLANS:
        return {}
    scratchpad_text = "\n".join(
        f"Step {e['step']}: {format_tool_call(e['tool'], e['args'])} "
        f"-> [{e['status']}] {e['result'][:150]}..."
        for e in scratchpad
    )
    remaining_text = "\n".join(
        f"Step {s['step_id']}: {format_tool_call(s['tool'], s['args'])} "
        f"- {s['purpose']}"
        for s in remaining
    )
    replanner = model.with_structured_output(ReplanDecision)
    prompt = REPLAN_PROMPT.format(
        goal=state["query"],
        scratchpad=scratchpad_text,
        remaining_steps=remaining_text,
    )
    decision: ReplanDecision = replanner.invoke(prompt)
    if decision.should_replan and decision.updated_steps:
        new_steps = plan[:current_step] + [
            s.model_dump() for s in decision.updated_steps
        ]
        return {"plan": new_steps, "replan_count": replan_count + 1}
    return {"replan_count": replan_count + 1}

查看 e['result'][:150]。Replanner 会将每个工具的返回结果截取为150个字符,因为它需要的是核心内容而非完整的 JSON 数据,而 Reporter 后续会收到未截断的数据。在细节无关紧要的地方会节省令牌,在需要详细信息的地方则使用令牌。

当重新规划时,plan[:current_step] + [s.model_dump() for s in decision.updated_steps] 会保留已完成的步骤,仅替换未完成的步骤,从而不会改动历史记录。

重新规划提示

REPLAN_PROMPT = """\
You are a financial research planner reviewing progress on a research task.

Original goal: {goal}
Completed steps and findings so far:
{scratchpad}
Remaining steps in the plan:
{remaining_steps}
Based on the findings so far, should the remaining plan change?
If an expected tool failed or revealed something unexpected, add a step
to investigate.
If a step is now redundant, remove it.
Use only the available executable tools already shown in the plan.
Do not add recommendation, summary, or report-writing steps."""

最后那行代码起着关键作用。如果去掉“不要添加推荐、总结或报告撰写步骤”这一限制,Replanner就会在每个计划中添加一个综合步骤,尽管实际上应由Reporter来承担这项工作。提示语需要明确界定各个节点之间的边界。

MAX_REPLANS = 2这一上限恰到好处。在早期版本中,一个三步任务需要经过六轮重新规划,而大型语言模型找到的“改进”实际上并无任何实际效果。两轮重新规划就足以实现真正的自我修正,比如处理出错的工具或删除冗余步骤,同时避免分析瘫痪的情况。最终必须有人指示智能体停止犹豫并完成工作。

记者会获取完整的未截断草稿,然后编写结构化的 Markdown 报告。该流程会调用 model.invoke(prompt).content,得到的是纯文本而非结构化输出,这样大型语言模型就可以按自己认为合适的方式呈现分析结果。其中最有效的指令是:“请具体说明,并引用数据中的数字。”如果没有这一要求,报告就会显得含糊不清,只会说“NVDA的基本面很强劲”,而不会提及实际的市盈率与营收数据。

在 LangGraph 中整合这些组件

这里四个节点会相互连接:

from langgraph.graph import StateGraph, END
from enum import Enum

class AgentName(Enum):
    PLANNER = "planner"
    EXECUTOR = "executor"
    REPLANNER = "replanner"
    REPORT = "report"

def build_graph():
    """Build and compile the LangGraph planning-agent workflow."""
    workflow = StateGraph(StrategyState)
    workflow.add_node(AgentName.PLANNER.value, planner_node)
    workflow.add_node(AgentName.EXECUTOR.value, executor_node)
    workflow.add_node(AgentName.REPLANNER.value, replanner_node)
    workflow.add_node(AgentName.REPORT.value, report_node)
    workflow.set_entry_point(AgentName.PLANNER.value)
    workflow.add_edge(AgentName.PLANNER.value, AgentName.EXECUTOR.value)
    workflow.add_edge(AgentName.EXECUTOR.value, AgentName.REPLANNER.value)
    workflow.add_conditional_edges(
        AgentName.REPLANNER.value,
        should_continue_execution,
        {
            AgentName.EXECUTOR.value: AgentName.EXECUTOR.value,
            AgentName.REPORT.value: AgentName.REPORT.value,
        },
    )
    workflow.add_edge(AgentName.REPORT.value, END)
    return workflow.compile()

def should_continue_execution(state: StrategyState) -> str:
    """Return the next node name after re-planning."""
    if state.get("current_step", 0) >= len(state.get("plan", [])):
        return AgentName.REPORT.value
    return AgentName.EXECUTOR.value

该流程图几乎就像英文句子一样。计划器将任务交给执行器,执行器再将任务交给重新规划器;如果还有步骤未完成,重新规划器会将任务转回执行器,而一旦所有步骤都处理完毕,则转交给报告器。报告器最终会到达终点。

AgentName 枚举看似简单,但实际上很有用。代码中散布的诸如 "planner" 这样的纯字符串容易引发拼写错误,而 "plannr" 则会悄悄创建出一个孤立节点。使用枚举后,这类错误会在导入时就被发现。

路由逻辑只检查一个条件:所有步骤是否都已完成?当 current_step >= len(plan) 时,控制权会交给报告器,否则则返回执行器。由于每次执行时 current_step 的值都会增加,且 MAX_STEPS = 12 设定了上限,即使重新规划器添加了新步骤,循环也会终止。

一次查询,全程追踪

该示例请求让智能体分析NVDA和AMD,将两者的市盈率及最新新闻并列展示,然后再从中选择一个。

规划阶段。规划器会生成五个步骤:NVDA的完整指标、AMD的完整指标、市盈率的直接对比、NVDA的新闻检索以及AMD的新闻检索。首先呈现各公司的详细资料以获得全面了解,接着进行直接对比,最后是新闻内容,因为新闻属于补充性背景信息而非核心估值数据。

执行阶段。第一步和第二步会返回结构清晰的指标JSON数据,每次执行后Replanner都会确认数据无误且计划未变。第三步则会返回市盈率的对比结果:

{
  "metric": "P/E",
  "values": {
    "NVDA": 58.3,
    "AMD": 102.5
  }
}

两个数值均存在且有效,因此无需重新规划。第4步和第5步各会返回三篇新闻文章。第5步完成后,current_step的值将等于len(plan),控制权便会交给报告员。

报告。收到五份完整的草稿记录后,报告员会列出具体数字,例如NVDA的市盈率为58.3,而AMD为102.5,尽管NVDA的市值高出约2.8万亿美元,但其相对价值仍更优。在Groq上完成一次完整处理大约需要8到10秒,而在本地使用40亿参数的Ollama模型则需30到40秒。

最重要的升级

当前版本使用的是模拟数据,但其架构已可投入实际应用。其中三项升级最为重要:

  • 真实的财务数据。 可以用调用 Yahoo Finance 或 Alpha Vantage 的方式替代字典查询。由于 @tool 装饰器模式的作用,签名保持不变,只有函数体发生改变,因此规划器和执行器永远无法知道这些数据来自模拟的 JSON 文件还是 REST API。
  • 持久化内存。 目前,临时存储的数据仅在一次运行中有效。借助 ChromaDB 或 Pinecone 等向量存储工具,代理可以查询之前的分析结果,从而回答“NVDA 与我们上个月看到的情况相比如何?”这样的问题,进而成为能够持续进化的分析工具,而非无状态的工具。
  • 人工干预机制。LangGraph 提供了现成的中断点功能。在规划器之后设置一个中断点,就可以在任何操作执行之前让人工审核计划,同时会出现类似“智能体想要执行这6个步骤,是否批准?”的提示。在自主系统操作可能带来实际财务风险的情况下,这一点尤为重要。
  • 该结构无法提供的功能

    计划-执行-重新规划循环能够完成其任务。结构化的输出能让智能体按既定路径行事,工具注册机制可避免混乱,而安全限制则能防止过度支出。这样的设计十分稳健,代码也保持整洁。

    即便如此,整个系统的智能仍依赖于其底层的大语言模型。更强大的模型能够制定更优的计划,在重新规划时发现更多错误,并生成更精准的报告;而较弱的模型则只会机械地遵循既定结构,给出平庸的分析结果。图表无法让模型变得更聪明,它所做的只是为模型提供更好的框架,让其能够更好地运用已有的智能。

    项目管理层面在很大程度上已经得到解决:状态管理、工具协调、执行流程以及自我修正都遵循明确的模式。但决策环节仍存在不确定性——重新规划器究竟应在何时进行重新规划,又应在何时保持沉默?何时数值属于异常情况,何时只是令人意外?目前这些判断都体现在提示词中,期望大语言模型能将其处理妥当,实际效果往往超出预期,但也常常不尽如人意。

    相关阅读

  • 实用笔记:我测试了块大小是否比分块方式更重要 — 该实用笔记的详细操作指南:包括适用于采用此模式的团队的契约、检查项以及可直接插入的代码片段。
  • 实用笔记:sys.exit(0)漏洞:AI智能体如何伪装成功 — 该实用笔记的详细操作指南:包括适用于采用此模式的团队的契约、检查项以及可直接插入的代码片段。
  • 更大的上下文窗口无法解决你的智能体问题:注意力预算与缓存机制 — 上下文窗口的功能类似于注意力预算。了解“Lost in the Middle”机制的运作原理,以及何时该使用压缩、滑动窗口、数据压缩或提示词缓存。
  • 实用笔记:既能管理数据库又能限制AI智能体行为的GUI — 详细讲解实用笔记:既能管理数据库又能限制AI智能体行为的GUI,包括相关合同、校验规则,以及供采用该模式的团队直接使用的代码模板。
  • Managed Deep Agents Beta:基于目录驱动的LangChain智能体部署方式 — LangChain的Managed Deep Agents功能可将项目文件夹转换为托管型智能体。本文介绍了各文件的功能、mda部署的运作方式,以及值得了解的测试版缺陷。
  • MiniCPM5-2B与4B级模型对比:为何在智能体框架下表现差异显著 — 这款25亿参数的模型在单次任务测试中能超越40亿参数的同类模型,但在智能体框架下的表现却落后两到三倍。文章还探讨了版本混淆问题、本地处理效率以及适用场景。
  • 在 GPT-5.6 Sol、Grok 4.5 和 Gemini 3.6 Flash 之间调度编码智能体 —— 不必再纠结哪个模型最优秀。可根据风险、上下文、工具及输出规模来分配各项任务,同时支持长上下文定价、分级处理机制以及 TypeScript 路由器。
  • 三种能防止智能体泄露机密的 LangChain 中间件防护措施 —— 阻止智能体向模型发送 API 密钥或未经许可删除文件:PIIMiddleware、HumanInTheLoopMiddleware、调用限制,以及为何中间件的顺序决定了安全性。
  • 值得关注的15个Agent仓库以及应避免的“明星”仓库 —— 为构建Agent而整理的各类选择:任务调度、评估、内存管理、工具、浏览器与语音功能相关仓库;此外还有值得避开的知名仓库,以及用于评估任何依赖项的七点测试标准。
  • 一个Agent,多个专家:为何个人AI应负责任务分配而非包办一切 —— 为何个人AI助手更适合作为任务调度者,将工作分配给不同的编码Agent、模型和普通脚本,并通过分级权限和可替换组件来实现高效运作。
  • 构建无需囤积数据即可延续对话的智能体记忆 —— 一种针对智能体的三层记忆设计:让会话状态自动清除,保持长期笔记的规模较小且可编辑,并实时获取最新数据。此外还涉及多智能体之间的任务交接失败处理。
  • 实用笔记:OpenAI的AI智能体如何自主入侵Hugging Face —— 对《实用笔记:OpenAI的AI智能体如何自主入侵Hugging Face》的详细操作指南:包括相关契约、检查项以及供采用该模式的团队直接使用的代码模板。
  • GPT-Live-1语音智能体:会话流程、任务委托模式及实际电话线路成本 —— 对OpenAI全双工语音模型的实用解析:会话如何运行、响应机制与客户端委托的区别、成本估算方法以及文档中的陷阱。
  • 智能体的反叛:700个OpenAI智能体如何进入Hugging Face —— 关于数百个OpenAI智能体协同行动、进入Hugging Face并试图抹去自身痕迹这一事件的西班牙语播客总结。