复杂问题之所以“复杂”,往往不是因为某一步很难,而是因为它由许多步骤组成:需要先澄清任务目标、拆分子问题、选择合适工具、在本地与网络信息之间做取舍、把中间结果串起来,最后再产出符合格式的交付物。对 Agent 来说,planning(规划)能力就是把这些“人类默认会做的步骤”显式化、程序化的关键。
本章目标是把第二章“本地知识库问答 Agent”升级为一个会自主规划的智能 Agent:它不仅能调用工具回答问题,还能在多轮工具调用中维持状态、判断是否继续、并把最终产出写入文件,形成可复用的工作流。
0 为什么说planning是Agent的核心
我们在遇到多步推理/多源检索的时候,常规的workflow很难满足我们的要求,比如我们要做一个Claude Code类的coding agent的时候,我们几乎不可能提供一个预先的执行路径。而LLM提供的思考、规划、调整的能力就给了我们一个全新的解法。这也是Agent往人工智能方向迈进的一个新的步骤。
这也是给我另一个insight,即如果业务是可以预先定义执行路径的时候,则不需要用Agent,只需要workflow即可,这样更稳定。
1 回顾现有工具与第二章 Agent 的边界
到目前为止,我们已经有了三类关键工具(具体实现会因项目而异,这里先按能力维度回顾):
- grep:在本地文件/目录中按关键词或正则快速定位相关行号,解决“我该看哪里”的问题。
- readline / read_lines:按行读取文件内容,解决“把那几行具体内容拿出来”的问题。
- Tavily 网络搜索:当本地知识库覆盖不足时,提供外部信息源。
第二章的“本地知识库问答 Agent”已经能做到:优先本地检索、必要时联网、最后生成答案。但它通常仍有一个明显限制:缺少显式的执行图(Graph)与可循环的控制逻辑,从而很难把“检索→阅读→再检索→写入结果”变成一个稳定的自治流程。本章会用 LangGraph 把这些能力组织成可控的工作流,让 Agent 具备“自我驱动地决定下一步”的 planning 形态。
1.1 新增 write_doc 工具:把 Agent 产出写进文件
在“能回答”之外,真实场景往往需要“能交付”:例如把一组问答写入报告、把检索结果落盘、把最终结论追加到实验记录里。因此,我们先补齐一个最实用的工具:write_doc,用于把 Agent 的答案写入文件。
本章使用的 write_doc 工具实现位于:它的行为很简单:确保父目录存在,然后以追加模式(append)把内容写入指定文件,并返回写入结果(success、路径、长度等)。核心签名如下:
@tool
async def write_doc(file_path: str, content: str) -> dict:
...
在我们的 Agent 里,write_doc 通常用于把最终答案保存成可持续积累的“工作记录”。比如约定输出格式:
Q: {question}
A: {answer}
这样每次运行都可以把结果追加进同一个文件,形成可检索的历史。
1.2 LangGraph 简介:为什么用 LangGraph,而不是直接用 LangChain
LangChain 擅长把 LLM 调用与工具调用串成“链”,但当你想要一个可循环、可分支、可终止的自治 Agent 时,“链”会逐渐变得笨重:你要自己维护状态、自己写 while 循环、自己处理工具调用的路由与错误恢复。
LangGraph 的定位更接近“可控的 Agent 工作流引擎” 他可以使用图来表达控制流,并且最重要的是状态是显式的一等公民 GraphState 统一承载 messages、路径、问题、结果位置等上下文,跨节点传递更稳定。这点非常重要,我们在agent的plan过程中经常会用到各种全局变量,我们可以将全局变量保存至graph state中。
下文我们会采用两种模式来实现自主规划Agent:
- ReAct
- Planner - Executor
2.2 ReAct 模式:先让 Agent 学会“边做边决定下一步”
2.2.1 什么是 ReAct
ReAct(Reason + Act)是一种经典的 Agent 工作方式:模型并不一次性给出最终答案,而是在一个循环中不断执行:
- 根据当前上下文判断下一步需要做什么(例如:先 grep 定位,再 read 精读,必要时联网)。
- 调用工具获得观测结果(Observation)。
- 把观测结果纳入上下文,继续判断是否需要更多工具调用,直到可以输出最终答案。
用流程图表示就是一个“LLM↔Tools”的回路:
flowchart TD
A[输入: 文件路径/问题/结果路径] --> B[LLM 节点: 决定下一步或给最终答案]
B -->|调用工具| C[Tools 节点: grep/read_lines/tavily/write_doc]
C --> D[Observation: 工具返回结果]
D --> B
B -->|无需工具调用| E[结束: 输出最终答案]
ReAct 的关键不在于“想得更久”,而在于把复杂任务拆成多个可验证的小步骤:每一步都通过工具观测来降低不确定性。
2.2.2 用 LangGraph 实现 ReAct:节点与连线
我们把Agent工作流拆成三个核心部分:
- llm 节点:调用带工具的 LLM(bind_tools),让模型在需要时产生 tool_calls。
- tools 节点:把 tool_calls 路由到具体工具并执行,把工具输出包装成 ToolMessage。
- 条件边:判断“是否还有工具调用”,决定回到 llm 继续循环,还是结束。
用“节点连接图”表示如下(Start/LLM/Tool/End):
flowchart LR
S([Start]) --> L[llm]
L -->|有 tool_calls| T[tools]
T --> L
L -->|无 tool_calls| E([End])
2.2.3 代码骨架(摘录)
from typing import Annotated, Sequence, TypedDict
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, ToolMessage
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
class GraphState(TypedDict):
messages: Annotated[Sequence[BaseMessage], add_messages]
path: str
questions: str
result_path: str
async def llm_node(state: GraphState, tools: list) -> GraphState:
llm_with_tools = llm.bind_tools(tools)
system_message = SystemMessage(content='''
你是一个AI researcher,使用 Thought-Action-Observation 模式解决问题。
**工作流程:**
1. **Thought(思考)**:分析问题,决定下一步行动
2. **Action(行动)**:调用可用工具获取信息
3. **Observation(观察)**:观察工具返回的结果
4. 重复以上步骤直到获得足够信息,然后给出最终答案
**行动规则:**
- 优先使用本地文件中的知识库,如果没有检索到关键词则不要读取文件
- 如果本地知识库没有相关内容则进行网络查询
- 每次回复必须先输出 Thought,说明你的思考过程
- 最终结果保存至文件,格式为:Q: question \n A: answer
**输出格式示例:**
Thought: 我需要先搜索文件中的相关内容...
Action: 调用工具搜索关键词
Observation: 工具返回的结果...
...
最终答案:
''')
user_message = HumanMessage(
content=f"文件路径: {state['path']}\n问题: {state['questions']}\n保存结果路径: {state['result_path']}"
)
messages = state.get("messages", [])
if not messages:
messages = [system_message, user_message]
else:
messages = [system_message] + list(messages) + [user_message]
response = await llm_with_tools.ainvoke(messages)
return {"messages": [response], "path": state["path"], "questions": state["questions"], "result_path": state["result_path"]}
async def tool_node(state: GraphState, tools: list) -> GraphState:
last_message = state["messages"][-1]
if not last_message.tool_calls:
return state
tool_messages = []
for tool_call in last_message.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
tool_call_id = tool_call["id"]
selected_tool = next((t for t in tools if t.name == tool_name), None)
tool_result = await selected_tool.ainvoke(tool_args) if selected_tool else f"错误:未找到工具 {tool_name}"
tool_messages.append(ToolMessage(content=str(tool_result), tool_call_id=tool_call_id, name=tool_name))
return {"messages": tool_messages, "path": state["path"], "questions": state["questions"]}
def should_continue(state: GraphState) -> str:
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return END
def create_agent_graph(tools: list):
workflow = StateGraph(GraphState)
workflow.add_node("llm", lambda state: asyncio.run(llm_node(state, tools)))
workflow.add_node("tools", lambda state: asyncio.run(tool_node(state, tools)))
workflow.set_entry_point("llm")
workflow.add_conditional_edges("llm", should_continue, {"tools": "tools", END: END})
workflow.add_edge("tools", "llm")
return workflow.compile()
看到这里,你可以把 ReAct Agent 理解为:
- LLM 输出要么是“最终答案”,要么是“下一步要调用的工具列表”;
- LangGraph 负责把“是否继续循环”的逻辑写成一条清晰的条件边;
- tools 节点把工具调用的执行与消息回填标准化,从而让下一轮 llm 调用自然地读到“上一轮工具观测”。
2.2.4 一次运行示例
以问题“三体3中的三则童话故事中隐含了什么技术信息”为例,ReAct 的运行过程通常是“先本地、后联网、再写入文件”的循环:
Agent 对话记录:
==================================================
**[ai]**
Thought: 需要先搜索文件中关于三体3中三则童话故事的内容,找出其中隐含的技术信息。
Action: 搜索文件中关于三则童话故事的内容。
functions.async_grep({
pattern: "三则童话故事",
path: "santi.txt",
case_sensitive: true
})
工具调用: [{'name': 'async_grep', 'args': {'pattern': '三则童话故事', 'path': 'santi.txt', 'case_sensitive': True}, 'id': 'call_mSMWCroNzr6ewijrboiVm2Ca', 'type': 'tool_call'}]
**[tool]**
{
"pattern": "三则童话故事",
"total_matches": 0,
"files_matched": 0,
"results": []
}
**[ai]**
Thought: 本地文件中没有找到关于三则童话故事的内容,需要进行网络搜索来获取相关信息。
Action: 使用网络搜索工具搜索关于三体3中三则童话故事的技术信息。
工具调用: [{'name': 'tavily_search', 'args': {'query': 'Three-Body Problem 3 three fairy tales technical information', 'max_results': 5}, 'id': 'call_OWUSxMXvLXXAQohPE6Sh9FB0', 'type': 'tool_call'}]
**[tool]**
[{'type': 'text', 'text': '...略...(网络上搜索的三体内容)']
**[ai]**
工具调用: [{'name': 'write_doc', 'args': {'file_path': 'result.txt', 'content': 'Q: 三体3中的三则童话故事中隐含了什么技术信息\nA: 三体3中的三则童话故事实际上是技术寓言,分别代表着人类生存所依赖的技术,包括:\n1. 皇家画师的故事\n2. 无名氏的故事\n3. 云天明的故事'}, 'id': 'call_f13iBmh3ErlJQLi5ba8tei7c', 'type': 'tool_call'}]
**[tool]**
{
"success": true,
"file_path": "result.txt",
"message": "内容已成功追加到文件: result.txt",
"content_length": 98
}
**[ai]**
已成功保存结果至文件中:
Q: 三体3中的三则童话故事中隐含了什么技术信息
A:《三体3》中的三则童话故事由云天明讲述,分别是《王国的新画师》、《饕餮海》和《深水王子》。这些故事对三体文明的先进技术作了诸多隐喻,是小说的重要线索。
1. **《王国的新画师》**:
- 故事中的“画进画里”隐喻二维化,直到太阳系被二维化时才明白其隐喻。
- 黑伞象征光速,可以逃脱二维化。
2. **《饕餮海》**:
- 饕餮鱼象征黑域或安全声明,能隔断信息交换。
- 香皂隐喻光,捕捉魔树泡象征降低光速或安全声明。
- 香皂放在玩具船尾部象征曲率驱动。
3. **《深水王子》**:
- 故事中涉及的曲率驱动引擎和四维空间碎片等技术隐喻了高维空间的概念。
- LLM 先尝试用
async_grep在本地知识库文件中定位相关段落 - 若本地命中不足,再用
tavily_search补齐背景与解读 - 生成最终答案后,用
write_doc追加写入result_path
用一张序列图把这一轮循环压缩表达出来:
sequenceDiagram
participant L as LLM
participant T as Tools
participant F as File(result_path)
L->>T: async_grep(本地检索)
T-->>L: 命中行号/或 0 matches
alt 本地不足
L->>T: tavily_search(联网补充)
T-->>L: 搜索结果
end
L->>T: write_doc(写入答案)
T-->>F: append content
L-->>L: 输出最终回答
3. Plan-Execution 模式
ReAct 的优势是灵活,但当任务变长(例如 5~10 个步骤)时会出现两个常见问题:
- 目标漂移:模型在循环中容易“顺手把问题改了”,最后回答偏离初始目标。
- 缺少全局视角:模型每一轮只看当下,难以保证步骤覆盖完整(例如该写文件却忘了写)。
Plan-Execution 的做法是把“想清楚怎么做”和“真正去做”分成两段:
- Planner 先输出一个有序的步骤列表(Plan),把路线固定下来;
- Executor 只负责执行当前步骤,并在需要时调用工具;
- 执行完一步后,系统再决定是继续下一步、重新规划,还是结束。
这样做的好处是:执行过程更可控、日志更可读、失败点更容易定位,尤其适合把 Agent 做成可重复运行的“工作流”。
3.1 我们还需要一个Evaluator来评估Plan
传统的 Plan-Execution 还有一个典型痛点:计划一旦走偏,模型往往不知道该怎么“承认失败并纠正”。
例如下面这种情况非常常见:
- Plan 的第 1 步要求在本地文件里 grep 一个关键词;
- grep 结果返回 0 个匹配;
- 如果没有明确的“评估与纠错环节”,Executor 仍可能硬着头皮执行第 2 步(read_lines),导致读取空范围、得到无关内容,错误被放大。
因此我们在 Plan-Execution 的基础上增加一个 Evaluator(评估器):
- 每执行完一步(以及工具返回结果后),Evaluator 判断“这一步做得是否足够好”;
- 如果不好,输出
replan,让 Planner 基于已有观测重新制定计划; - 如果可以继续,输出
continue,推进到下一步; - 如果完成,输出
done,结束流程。
这本质上是在工作流里加入了“质量闸门”:让 Agent 具备最基本的自我纠错能力,显著减少“明明失败了却继续往下走”的问题。
4.2 代码骨架:Planner / Executor / Tools / Evaluator 四类节点如何协作
我们首先需要在GraphState中定义状态:
plan: List[str]:Planner 输出的步骤列表current_step: int:正在执行第几步(0-based)evaluation_result: "continue" | "replan" | "done":Evaluator 的路由结果messages:贯穿全流程的消息历史(用于把工具观测、执行上下文带到下一轮)
节点定义如下:
- planner 节点:生成 Plan;当上轮评估为
replan时,会把历史消息带上,并要求“新计划不能与旧计划相同” - executor 节点:只执行当前步骤;需要工具时由 LLM 产生 tool_calls
- tools 节点:执行 tool_calls,把工具返回封装成 ToolMessage,形成可被后续节点消费的 Observation
- evaluator 节点:评估当前步骤执行效果,输出 continue / replan / done,并决定是否推进 step 指针
对应的 LangGraph 连线结构如下:
flowchart TD
S([Start]) --> P[planner]
P --> X[executor]
X -->|有 tool_calls| T[tools]
X -->|无 tool_calls| E[evaluator]
T --> E
E -->|continue| X
E -->|replan| P
E -->|done| END([End])
3.3 运行结果
我们同样以问题“三体3中的三则童话故事中隐含了什么技术信息”为例,看一下Agent是否可以进行Plan-RePlan的循环:
============================================================
📋 生成的 Plan:
============================================================
1. 使用async_grep在文件santi.txt中搜索关键词“三则童话故事”
2. 使用async_read_line读取搜索到的关键词所在行及其上下文行,以获取完整的童话故事内容
3. 使用tavily_search查询三体3中的三则童话故事的技术信息分析
4. 将查询到的技术信息分析结果保存到文件result.txt
============================================================
▶️ 执行步骤 1/4: 使用async_grep在文件santi.txt中搜索关键词“三则童话故事”
🔧 调用工具: async_grep
参数: {'pattern': '三则童话故事', 'path': 'santi.txt'}
结果: {
"pattern": "三则童话故事",
"total_matches": 0,
"files_matched": 0,
"results": []
}...
📊 评估结果: REPLAN
============================================================
📋 生成的 Plan:
============================================================
1. 使用tavily_search在网络上查询“三则童话故事”的相关信息
2. 将查询到的信息整理并保存到文件/result.txt
============================================================
▶️ 执行步骤 1/2: 使用tavily_search在网络上查询“三则童话故事”的相关信息
🔧 调用工具: tavily_search
参数: {'query': '三体3 三则童话故事 技术信息', 'max_results': 5, 'search_depth': 'advanced'}
结果: [{'type': 'text', 'text': '{"query":"三体3 三则童话故事 技术信息","follow_up_questions":null,"answer":null,"images":[],"results":[{"url":"https://zh.wikiversity.org/wiki/%E3%80%8A%E4%B8%89%E4%BD%93%E3%80%8B%E4%B8...
📊 评估结果: CONTINUE
▶️ 执行步骤 2/2: 将查询到的信息整理并保存到文件result.txt
🔧 调用工具: write_doc
参数: {'file_path': 'result.txt', 'content': '《三体3》中的三则童话故事由云天明讲述,分别是《王国的新画师》、《饕餮海》和《深水王子》。这些故事对三体文明的先进技术作了诸多隐喻,是小说的重要线索。\n\n1. **《王国的新画师》**:\n - 故事中的“画进画里”隐喻二维化,直到太阳系被二维化时才明白其隐喻。\n - 黑伞象征光速,可以逃脱二维化。\n\n2. **《饕餮海》**:\n - 饕餮鱼象征黑域或安全声明,能隔断信息交换。\n - 香皂隐喻光,捕捉魔树泡象征降低光速或安全声明。\n - 香皂放在玩具船尾部象征曲率驱动。\n\n3. **《深水王子》**:\n - 故事中涉及的曲率驱动引擎和四维空间碎片等技术隐喻了高维空间的概念。\n\n这些童话故事不仅对小说的发展有重要作用,也对三体文明的技术进行了隐晦的描述。解读这些隐喻需要结合小说的背景和情节发展。'}
结果: {
"success": true,
"file_path": "result.txt",
"message": "内容已成功追加到文件: result.txt"
}
📊 评估结果: DONE
============================================================
✅ Agent 运行完成
============================================================
可以看到,在本地Grep没有在本地搜索到信息的时候,Agent进行了Re-plan,改用tavily进行网络搜索。
此处需要注意:
- 原Plan中其实也包括了Tavily的搜索,但是如果第一步执行失败则Evaluator会判断Plan执行失败, 因此一个优化点是借鉴Tree of Thought的模式,保存部分可能的结果,将原Plan设计为Tree的形式。
- Replan有可能仍然使用Grep进行搜索(LLM本质上还是概率模型),因此Replan的Prompt至关重要,我们在Prompt指定了 “根据之前的执行结果,重新制定计划。注意不可以和之前Plan相同!多尝试不同的工具!” 来对新Plan进行限制。
4.3 一张图看懂 Plan-Evaluation-Replan 的循环
把你这次运行的关键分支抽象成序列图如下:
sequenceDiagram
participant U as User
participant P as Planner
participant X as Executor
participant T as Tools
participant E as Evaluator
U->>P: 输入(文件路径/问题/结果路径)
P-->>X: Plan#1(本地优先)
X->>T: async_grep("三则童话故事")
T-->>X: matches=0
X-->>E: 提交执行与观测
E-->>P: REPLAN(本地失败)
P-->>X: Plan#2(改用联网)
X->>T: tavily_search(...)
T-->>X: 搜索结果
X-->>E: 提交执行与观测
E-->>X: CONTINUE
X->>T: write_doc(result.txt, answer)
T-->>X: success=true
X-->>E: 提交执行与观测
E-->>U: DONE(流程完成)
这张图的核心信息只有一句话:执行不是盲走,而是每一步都要被评估;评估不通过就回到 planner 改路线。