- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
【AI Agent 从零到一】Day 1:Function Calling 完整流程------让大模型学会"动手"
本系列共 4 篇,带你从零手写一个"科研问答 Agent":
- Day 1:Function Calling 完整流程(本篇)
- Day 2:ReAct 模式------边想边做
- Day 3:多步 Agent------错误处理 + Plan-Execute
- Day 4:科研问答系统 v1.0------整合收官
技术栈:Python 3.9+ | LangChain 1.x | DeepSeek(兼容 OpenAI 接口)
📌 本篇你将学会
- 理解 Agent 的 3 大组件:LLM 推理 + 工具集 + 决策循环
- 理解 Function Calling 的 4 步流程
- 亲手写 3 个基础工具:
calculator/word_count/current_time - 掌握 Agent 最核心的代码模式:解析
tool_calls+ 用ToolMessage把结果喂回 LLM
本篇交付的两个文件:
week06/
├── agent_core.py # Agent 基础设施:工具注册表 + LLM 工厂
└── day1_function_calling.py # 3 个工具 + Function Calling 主循环
一、先搞懂概念:Agent 到底是什么?
1.1 一个生活化的比喻
想象你有两个助手:
| 助手 A(普通 LLM) | 助手 B(Agent) | |
|---|---|---|
| 你问"现在几点?" | "我没法知道现在的时间"或者瞎编一个 | 看一眼手表:"21:22" |
| 你问"37×89 等于多少?" | 心算,可能算错 | 掏出计算器,按出 3293 |
| 你问"帮我查一下论文里用的什么优化器" | 凭记忆回答,可能张冠李戴 | 翻开论文找到原文,再告诉你 |
助手 A 只会说 ,助手 B 会动手。这个"动手"的能力,就是 Agent 和普通 LLM 的本质区别。
1.2 正式定义
Agent(智能体) :以大语言模型(LLM)为核心大脑,通过感知环境、自主规划、调用工具并执行行动,从而达成特定目标任务的智能系统。
它区别于传统"一问一答"式 AI 的关键,在于具备完整的自主决策循环:
理解任务目标 → 拆解执行计划 → 选择并调用合适工具 → 依据执行反馈动态调整 → ...... → 交付结果
一句话记住:
Agent = LLM + 工具集 + 自主决策循环------能"想、说、做、记住"的 AI。
1.3 三大组件拆解
#mermaid-svg-drzvCxQI8HFKRRep{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-drzvCxQI8HFKRRep .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-drzvCxQI8HFKRRep .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-drzvCxQI8HFKRRep .error-icon{fill:#552222;}#mermaid-svg-drzvCxQI8HFKRRep .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-drzvCxQI8HFKRRep .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-drzvCxQI8HFKRRep .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-drzvCxQI8HFKRRep .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-drzvCxQI8HFKRRep .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-drzvCxQI8HFKRRep .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-drzvCxQI8HFKRRep .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-drzvCxQI8HFKRRep .marker{fill:#333333;stroke:#333333;}#mermaid-svg-drzvCxQI8HFKRRep .marker.cross{stroke:#333333;}#mermaid-svg-drzvCxQI8HFKRRep svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-drzvCxQI8HFKRRep p{margin:0;}#mermaid-svg-drzvCxQI8HFKRRep .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-drzvCxQI8HFKRRep .cluster-label text{fill:#333;}#mermaid-svg-drzvCxQI8HFKRRep .cluster-label span{color:#333;}#mermaid-svg-drzvCxQI8HFKRRep .cluster-label span p{background-color:transparent;}#mermaid-svg-drzvCxQI8HFKRRep .label text,#mermaid-svg-drzvCxQI8HFKRRep span{fill:#333;color:#333;}#mermaid-svg-drzvCxQI8HFKRRep .node rect,#mermaid-svg-drzvCxQI8HFKRRep .node circle,#mermaid-svg-drzvCxQI8HFKRRep .node ellipse,#mermaid-svg-drzvCxQI8HFKRRep .node polygon,#mermaid-svg-drzvCxQI8HFKRRep .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-drzvCxQI8HFKRRep .rough-node .label text,#mermaid-svg-drzvCxQI8HFKRRep .node .label text,#mermaid-svg-drzvCxQI8HFKRRep .image-shape .label,#mermaid-svg-drzvCxQI8HFKRRep .icon-shape .label{text-anchor:middle;}#mermaid-svg-drzvCxQI8HFKRRep .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-drzvCxQI8HFKRRep .rough-node .label,#mermaid-svg-drzvCxQI8HFKRRep .node .label,#mermaid-svg-drzvCxQI8HFKRRep .image-shape .label,#mermaid-svg-drzvCxQI8HFKRRep .icon-shape .label{text-align:center;}#mermaid-svg-drzvCxQI8HFKRRep .node.clickable{cursor:pointer;}#mermaid-svg-drzvCxQI8HFKRRep .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-drzvCxQI8HFKRRep .arrowheadPath{fill:#333333;}#mermaid-svg-drzvCxQI8HFKRRep .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-drzvCxQI8HFKRRep .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-drzvCxQI8HFKRRep .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drzvCxQI8HFKRRep .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-drzvCxQI8HFKRRep .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drzvCxQI8HFKRRep .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-drzvCxQI8HFKRRep .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-drzvCxQI8HFKRRep .cluster text{fill:#333;}#mermaid-svg-drzvCxQI8HFKRRep .cluster span{color:#333;}#mermaid-svg-drzvCxQI8HFKRRep div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-drzvCxQI8HFKRRep .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-drzvCxQI8HFKRRep rect.text{fill:none;stroke-width:0;}#mermaid-svg-drzvCxQI8HFKRRep .icon-shape,#mermaid-svg-drzvCxQI8HFKRRep .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-drzvCxQI8HFKRRep .icon-shape p,#mermaid-svg-drzvCxQI8HFKRRep .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-drzvCxQI8HFKRRep .icon-shape .label rect,#mermaid-svg-drzvCxQI8HFKRRep .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-drzvCxQI8HFKRRep .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-drzvCxQI8HFKRRep .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-drzvCxQI8HFKRRep :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 决定调用
返回结果
信息够了
信息不够,继续循环
用户问题
🧠 LLM 大脑
负责思考和决策
🔧 工具集
计算器/搜索/时间/文件...
✅ 最终答案
| 组件 | 作用 | 类比 |
|---|---|---|
| LLM 推理 | 理解问题、判断要不要用工具、用哪个、传什么参数 | 大脑 |
| 工具集 | 真正干活的函数(计算、搜索、查库、读文件......) | 双手 |
| 决策循环 | "思考→行动→观察→再思考",直到任务完成 | 做事的习惯 |
1.4 从"被动响应"到"主动决策"
在此之前(W1-W5)我们写的 LLM 调用都是被动响应:用户问,LLM 答,结束。
而 Agent 是主动决策:LLM 自己决定------
- 该不该调工具?
- 调哪个工具?
- 传什么参数?
- 结果够不够?要不要再调一次?
💡 本周(W6)的核心,就是把"决策权"从程序员手里交给 LLM。
以前是程序员写
if "计算" in question: calculator(...),现在是 LLM 自己判断。
二、Function Calling:Agent 的"手"是怎么接上的
2.1 没有工具时会发生什么?
python
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "5 加 3 乘以 2 等于多少?"}],
)
# LLM 直接返回:"5 + 3 × 2 = 11"
这题答对了,但它是"猜"出来的 ------LLM 本质是在预测下一个字,不是在做运算。数字一大(比如 98765 × 43219),它就很容易出错。
2.2 Function Calling 的思路
与其让 LLM 硬算,不如给它一个计算器,让它自己决定要不要用:
python
# 1. 定义工具
def calculator(expression: str) -> str:
return str(eval(expression))
# 2. 把工具"告诉"LLM
llm_with_tools = llm.bind_tools([calculator])
# 3. LLM 看到问题,自己决定调 calculator,参数是 "5+3*2"
# 4. 你的程序执行 calculator("5+3*2"),拿到 "11"
# 5. 把 "11" 喂回 LLM,LLM 组织语言输出 "5 + 3 × 2 = 11"
2.3 最关键的一点:LLM 并不会直接执行函数!⭐
这是初学者最容易误解的地方,请务必记住:
LLM 不直接调函数------它只输出一段 JSON,说明"我想调哪个工具 + 传什么参数"。
真正执行函数的是你的 Python 程序。 执行完再把结果喂回 LLM,LLM 综合"自己的语言能力 + 工具结果"给出最终答案。
LLM 返回的 tool_calls 长这样(LangChain 格式):
python
[
{
"name": "calculator", # 调哪个工具
"args": {"expression": "5+3*2"}, # 传什么参数
"id": "call_abc123", # 这次调用的唯一编号
"type": "tool_call"
}
]
它就像一张"工单"------LLM 开单,你的程序照单执行。
2.4 完整流程图
工具函数 LLM 你的程序 用户 工具函数 LLM 你的程序 用户 #mermaid-svg-IjIv8xl2OOodWWJu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IjIv8xl2OOodWWJu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IjIv8xl2OOodWWJu .error-icon{fill:#552222;}#mermaid-svg-IjIv8xl2OOodWWJu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IjIv8xl2OOodWWJu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IjIv8xl2OOodWWJu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IjIv8xl2OOodWWJu .marker.cross{stroke:#333333;}#mermaid-svg-IjIv8xl2OOodWWJu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IjIv8xl2OOodWWJu p{margin:0;}#mermaid-svg-IjIv8xl2OOodWWJu .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IjIv8xl2OOodWWJu text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-IjIv8xl2OOodWWJu .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IjIv8xl2OOodWWJu .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-IjIv8xl2OOodWWJu .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-IjIv8xl2OOodWWJu .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-IjIv8xl2OOodWWJu #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-IjIv8xl2OOodWWJu .sequenceNumber{fill:white;}#mermaid-svg-IjIv8xl2OOodWWJu #sequencenumber{fill:#333;}#mermaid-svg-IjIv8xl2OOodWWJu #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-IjIv8xl2OOodWWJu .messageText{fill:#333;stroke:none;}#mermaid-svg-IjIv8xl2OOodWWJu .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IjIv8xl2OOodWWJu .labelText,#mermaid-svg-IjIv8xl2OOodWWJu .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-IjIv8xl2OOodWWJu .loopText,#mermaid-svg-IjIv8xl2OOodWWJu .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-IjIv8xl2OOodWWJu .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-IjIv8xl2OOodWWJu .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-IjIv8xl2OOodWWJu .noteText,#mermaid-svg-IjIv8xl2OOodWWJu .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-IjIv8xl2OOodWWJu .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IjIv8xl2OOodWWJu .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IjIv8xl2OOodWWJu .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-IjIv8xl2OOodWWJu .actorPopupMenu{position:absolute;}#mermaid-svg-IjIv8xl2OOodWWJu .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-IjIv8xl2OOodWWJu .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-IjIv8xl2OOodWWJu .actor-man circle,#mermaid-svg-IjIv8xl2OOodWWJu line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-IjIv8xl2OOodWWJu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "5 加 3 乘以 2 等于多少?" ① 问题 + 工具说明书(bind_tools) ② tool_calls: calculator({"expression":"5+3*2"}) ③ 执行 calculator("5+3*2") "11" ④ ToolMessage("11", tool_call_id=...) ⑤ "5 + 3 × 2 = 11"(若还需工具,回到 ②) 最终答案
归纳成 4 步:
bind_tools:把工具说明书交给 LLM- LLM 输出
tool_calls(JSON 工单) - 程序执行工具
- 用
ToolMessage把结果喂回 LLM → LLM 给出答案(或继续开新工单)
2.5 消息列表是怎么"长"出来的?
LangChain 把对话抽象成一个消息列表。理解它的变化过程,就理解了 Function Calling 的全部:
第 1 次调用 LLM 前:
[HumanMessage("5 加 3 乘以 2 等于多少?")]
LLM 返回 AIMessage,里面带 tool_calls,把它追加进去:
[HumanMessage(...),
AIMessage(content="", tool_calls=[{name: calculator, id: call_abc123, ...}])]
执行工具后,把结果作为 ToolMessage 追加:
[HumanMessage(...),
AIMessage(tool_calls=[...]),
ToolMessage(content="11", tool_call_id="call_abc123")] ← id 必须对得上!
第 2 次调用 LLM:LLM 看到完整上下文 → 输出最终答案
⚠️
ToolMessage的tool_call_id必须和AIMessage里对应 tool_call 的id一致,LLM 才知道"这个结果是哪张工单的"。漏了就会报错。
三、环境准备
3.1 安装依赖
bash
pip install langchain-core langchain-openai python-dotenv
3.2 配置 API Key
在项目根目录新建 .env 文件:
ini
DEEPSEEK_API_KEY=sk-你的key
DeepSeek 的接口与 OpenAI 完全兼容,所以可以直接用
langchain_openai.ChatOpenAI,只需把base_url换成 DeepSeek 的地址。
四、搭基础设施:agent_core.py
这个文件是整周 Agent 代码的"地基",后面每天都会 import 它。它提供两样东西:工具注册表 和 LLM 工厂。
4.1 为什么要搞一个"工具注册表"?
LangChain 自带 @tool 装饰器,但本系列选择用一个全局字典 + register_tool() 函数来管理工具,原因是:
- 更直观 :注册表就是一个普通
dict,打印出来一目了然 - 更灵活:同一个函数可以随时换名字、换描述重新注册
- 后面好扩展:Day 3 的安全调用、Day 2 的 ReAct 都能直接查这张表
4.2 StructuredTool.from_function 是干什么的?
LLM 看不懂 Python 函数,它只看得懂"工具说明书"(JSON Schema)。StructuredTool.from_function(func, name, description) 就是个翻译官:
| 参数 | 含义 | 重要性 |
|---|---|---|
func |
原始 Python 函数 | 真正干活的 |
name |
工具对外名称 | LLM 靠它指定"调哪个" |
description |
功能描述 | ⭐⭐⭐ LLM 决定"何时调用、怎么调用"的唯一依据 |
它会自动读取函数的类型注解 (如 expression: str)生成参数结构,所以支持多参数、强类型。
4.3 完整代码
python
# week06/agent_core.py
import os
from dotenv import load_dotenv
from langchain_core.tools import StructuredTool
from langchain_openai import ChatOpenAI
load_dotenv() # 读取 .env 里的 DEEPSEEK_API_KEY
# ============ 1. 工具注册表 ============
_TOOL_REGISTRY: dict = {} # 下划线开头:约定为"模块内部变量"
def register_tool(name: str, description: str, func):
"""注册工具到全局注册表(函数式调用,不是装饰器!)"""
_TOOL_REGISTRY[name] = {"name": name, "description": description, "func": func}
def get_all_tools() -> list:
"""把注册表里的所有工具,统一转成 LangChain 的 StructuredTool 格式"""
return [
StructuredTool.from_function(
func=info["func"],
name=info["name"],
description=info["description"],
)
for info in _TOOL_REGISTRY.values()
]
def call_tool(name: str, **kwargs) -> str:
"""绕过 LLM,直接按名字调用工具(Day 2 的 ReAct 会用到)"""
if name not in _TOOL_REGISTRY:
return f"❌ 工具 {name} 不存在"
try:
return _TOOL_REGISTRY[name]["func"](**kwargs)
except Exception as e:
return f"❌ 工具 {name} 执行错误:{e}"
# ============ 2. LLM 工厂 ============
def get_llm(temperature: float = 0) -> ChatOpenAI:
"""统一的 DeepSeek LLM 创建入口"""
return ChatOpenAI(
model="deepseek-chat",
temperature=temperature, # 0 = 输出最稳定,Agent 场景推荐
openai_api_key=os.getenv("DEEPSEEK_API_KEY"),
openai_api_base="https://api.deepseek.com/v1",
)
逐段解读:
-
_TOOL_REGISTRY的结构类似:python{ "calculator": {"name": "calculator", "description": "数学计算", "func": <function calculator>}, "word_count": {...}, } -
call_tool自带try/except:工具出错时返回错误字符串而不是抛异常,这样 Agent 不会因为一个工具崩掉整个程序------这是 Agent 开发的一个重要习惯,Day 3 会系统展开。 -
get_llm把模型配置集中在一处:以后想换模型(比如换成通义、GPT),只改这一个函数。 -
temperature=0:让 LLM 输出尽量确定。Agent 需要稳定地输出合法的工具调用,太"有创意"反而坏事。
五、写工具 + 主循环:day1_function_calling.py
5.1 定义 3 个基础工具
python
# week06/day1_function_calling.py
from langchain_core.messages import HumanMessage, ToolMessage
from agent_core import get_llm, register_tool, get_all_tools
def calculator(expression: str) -> str:
"""简单数学计算(仅支持 + - * / ( ))"""
# 1) 白名单校验:只允许数字、四则运算符、小数点、括号、空格
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return f"❌ 表达式含非法字符:{expression}" # 防止 eval 注入
try:
# 2) eval 计算(仅教学用,生产环境请换成 ast 解析或专用库)
return f"{eval(expression)}"
except Exception as e:
return f"❌ 计算错误:{e}" # 除零、语法错误等
def word_count(text: str) -> str:
"""统计字符数"""
return f"{len(text)}" # len() 统计的是字符数,含空格和中文
def current_time() -> str:
"""返回当前时间"""
from datetime import datetime # 函数内 import,用到时才加载
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 注册到工具注册表 ------ description 是 LLM 决策的依据,一定要写清楚!
register_tool(name="calculator", description="数学计算(+ - * / ())", func=calculator)
register_tool(name="word_count", description="统计字符数", func=word_count)
register_tool(name="current_time", description="返回当前时间(YYYY-MM-DD HH:MM:SS)", func=current_time)
几个值得注意的细节:
-
为什么工具都返回
str? 因为结果最终要装进ToolMessage喂给 LLM,而 LLM 只读文本。统一返回字符串,省去后续转换。 -
eval的安全问题 :eval能执行任意 Python 代码,如果 LLM 被诱导传入__import__('os').system('rm -rf /')就完了。白名单挡住了字母和下划线,大部分攻击都被拦下。⚠️ 但白名单仍非万无一失:比如
9**9**9**9只含合法字符,却会让程序算到天荒地老。生产环境请用ast模块解析表达式,或使用numexpr、simpleeval等库。 -
错误用
❌前缀返回:这是一个约定,Day 3 会利用这个前缀把"业务错误"识别出来。 -
description为什么这么重要? LLM 根本看不到你的函数实现,它只看得到名字和描述。描述写成"数学计算(+ - * / ())",LLM 就知道这个工具不支持开方、三角函数;写成"返回当前时间(YYYY-MM-DD HH:MM:SS)",LLM 就知道返回格式。
5.2 两个关键 API
llm.bind_tools(tools)
把工具列表"绑定"到 LLM 上,返回一个新的 LLM 实例 (原来的 llm 不受影响)。之后调用 .invoke() 时,LLM 会参考每个工具的 description,自己决定要不要调用。LangChain 1.x 起所有 ChatModel 都支持此方法。
HumanMessage / ToolMessage
| 消息类型 | 谁说的 | 备注 |
|---|---|---|
HumanMessage |
用户 | 用户的问题 |
AIMessage |
LLM | .invoke() 的返回值,可能带 tool_calls |
ToolMessage |
工具 | 必须带 tool_call_id,与 LLM 的工单对应 |
5.3 Function Calling 主循环(核心!)
python
def run_with_tools(question: str, max_iterations: int = 3) -> str:
"""Function Calling 完整流程"""
llm = get_llm() # 1) 创建 LLM
tools = get_all_tools() # 2) 拿到所有已注册工具
llm_with_tools = llm.bind_tools(tools) # 3) 把工具说明书交给 LLM
messages = [HumanMessage(content=question)] # 4) 初始对话:只有用户问题
response = llm_with_tools.invoke(messages) # 5) 第一次调用 LLM
# 6) 循环:LLM 开工单 → 程序执行 → 喂回结果 → LLM 再决策
for i in range(max_iterations):
if not response.tool_calls:
# 7a) LLM 没开工单 = 它认为可以直接回答了
return response.content
# 7b) LLM 开了工单:先把这条 AIMessage 记入历史
messages.append(response)
for tool_call in response.tool_calls: # 可能一次开多张工单
tool_name = tool_call["name"]
tool_args = tool_call["args"]
print(f" [Tool Call] {tool_name}({tool_args})")
tool = next((t for t in tools if t.name == tool_name), None)
if tool is None:
result = f"❌ 工具 {tool_name} 不存在" # LLM 幻觉出了不存在的工具
else:
try:
result = tool.invoke(tool_args) # 真正执行工具
except Exception as e:
result = f"❌ 工具执行错误:{e}"
print(f" [Tool Result] {result}")
# 7c) 结果装进 ToolMessage 喂回,必须带 tool_call_id!
messages.append(ToolMessage(content=str(result), tool_call_id=tool_call["id"]))
# 7d) 带着工具结果,再问一次 LLM
response = llm_with_tools.invoke(messages)
# 8) 超过最大轮数仍未收敛,返回最后一次回复
return response.content
用一张表看清楚每个角色:
| 概念 | 作用 |
|---|---|
bind_tools(tools) |
把工具说明书交给 LLM------LLM 自己决定何时调 |
response.tool_calls |
LLM 开出的"工单":调哪个工具 + 传什么参数 |
ToolMessage |
把工具执行结果喂回 LLM 的标准格式 |
max_iterations |
安全阀,防止 LLM 无限调工具导致死循环 |
三个容易被忽略的设计点:
messages.append(response)不能省 。如果只追加ToolMessage而不追加带tool_calls的AIMessage,LLM 会看到"凭空冒出来一个工具结果",接口直接报错。顺序永远是:AIMessage(tool_calls) → ToolMessage(s)。- 内层用
for遍历所有 tool_calls :现代模型支持并行工具调用,一次回复可能开出多张工单(下面的演示 2 就是这样),每张都要执行并回复。 - 容错:LLM 可能"幻觉"出一个不存在的工具名,也可能传错参数,所以两处都兜住,把错误作为文本喂回------LLM 看到错误信息后往往会自己纠正。
5.4 主程序:3 个演示
python
if __name__ == "__main__":
# 演示 1:单工具调用
print("=== 演示 1:单工具调用(计算 5+3*2)===")
answer = run_with_tools("5 加 3 乘以 2 等于多少?")
print(f"最终答案:{answer}\n")
# 演示 2:多工具调用
print("=== 演示 2:多工具调用(字符数 + 当前时间)===")
answer = run_with_tools("'Hello World' 有多少字符?然后告诉我现在几点。")
print(f"最终答案:{answer}\n")
# 演示 3:不用工具,直接回答
print("=== 演示 3:直接回答(不用工具)===")
answer = run_with_tools("Python 是什么?")
print(f"最终答案:{answer}")
六、运行与结果解读
bash
python week06/day1_function_calling.py
实际输出:
=== 演示 1:单工具调用(计算 5+3*2)===
[Tool Call] calculator({'expression': '5+3*2'})
[Tool Result] 11
最终答案:**5 + 3 × 2 = 11**
=== 演示 2:多工具调用(字符数 + 当前时间)===
[Tool Call] word_count({'text': 'Hello World'})
[Tool Result] 11
[Tool Call] current_time({})
[Tool Result] 2026-07-26 21:22:20
最终答案:1. "Hello World" 的字符数:共 11 个字符
2. 当前时间:2026年7月26日 21:22:20
=== 演示 3:直接回答(不用工具)===
最终答案:Python 是一种高级、通用、解释型的编程语言...
逐个看 LLM 做了什么决策:
| 演示 | LLM 的决策 | 说明 |
|---|---|---|
| 演示 1 | 调 1 个工具(calculator) | 它把中文"5 加 3 乘以 2"自动翻译成了 5+3*2------参数也是 LLM 生成的 |
| 演示 2 | 同时 调 2 个工具 | 一次 LLM 回复开出 2 张工单,这就是"并行工具调用" |
| 演示 3 | 不调任何工具 | "Python 是什么"是常识,工具帮不上,LLM 直接答 |
🎯 这就是 Function Calling 的核心价值:LLM 自己判断"该不该用工具 / 用哪个 / 传什么参数"。 全程我们没有写一行
if去判断问题类型。
七、🚨 踩坑指南
| 症状 | 原因 | 解决 |
|---|---|---|
OpenAIError: Missing credentials |
.env 里没配 DEEPSEEK_API_KEY,或没调用 load_dotenv() |
加 DEEPSEEK_API_KEY=sk-...,确认运行目录能找到 .env |
ToolMessage 报 tool_call_id 相关错误 |
没把 tool_call["id"] 传过去 |
ToolMessage(content=..., tool_call_id=tool_call["id"]) |
| 报错说 tool 消息前面缺少 tool_calls | 忘了 messages.append(response) |
先追加 AIMessage,再追加 ToolMessage |
| LLM 一直调工具不返回 | 没有最大轮数限制 | 用 for i in range(max_iterations) 控制上限 |
register_tool 报 missing 1 required positional argument: 'func' |
当成装饰器用了:@register_tool(...) |
改成函数式:register_tool(name=..., description=..., func=...) |
| LLM 该用工具时不用 | description 写得太含糊 | 把 description 写具体:能做什么、参数格式、返回格式 |
八、🎓 今日小结
2 个核心认知:
- Agent = LLM + 工具集 + 决策循环------把 W1-W5 的"被动响应"升级为"主动决策"。
- Function Calling 4 步 :
bind_tools→ LLM 输出tool_calls→ 程序执行工具 →ToolMessage喂回 LLM。
2 句话刻进脑子:
Function Calling 的本质:LLM 不直接调函数,它只输出 JSON 工单(调哪个工具 + 传什么参数)。你执行工具、喂回结果,LLM 综合"语言能力 + 工具结果"给出答案。
description 是 LLM 决策的"说明书"------写得越清楚,LLM 决策越准。
今日交付:
- ✅
agent_core.py - ✅
day1_function_calling.py
💭 课后思考
- 如果把
calculator的 description 改成"一个工具",演示 1 还会调用它吗?动手试试。 - 问 LLM:"先算 12×8,再把结果加上当前小时数"。这需要几轮循环?观察一下
messages列表的变化。 - 本篇的循环里 LLM 每一步"为什么这么做"是看不见的。能不能让它把思考过程也写出来?------这就是 Day 2:ReAct 模式 要解决的问题。
📚 下一篇:Day 2 · ReAct 模式------让 Agent 边想边做
如果本文对你有帮助,欢迎点赞👍 收藏⭐ 关注,系列持续更新!
【AI Agent 从零到一】Day 2:ReAct 模式------手写一个"边想边做"的 Agent
本系列共 4 篇,带你从零手写一个"科研问答 Agent":
- Day 1:Function Calling 完整流程
- Day 2:ReAct 模式------边想边做(本篇)
- Day 3:多步 Agent------错误处理 + Plan-Execute
- Day 4:科研问答系统 v1.0------整合收官
📌 本篇你将学会
- 理解 ReAct 是什么、为什么它是 Agent 的主流范式
- 理解 Thought / Action / Observation 三段式
- 不依赖任何 Agent 框架,纯手写一个 ReAct 循环
- 给 Agent 接上第 4 个工具:基于混合检索的论文搜索
paper_search
本篇交付:
src/week06/
├── agent_core.py # Day 1 已完成
├── day1_function_calling.py # Day 1 已完成
├── vector_store.py # 新增:复用 W5 的混合检索(BM25 + 向量 + RRF)
└── day2_react.py # 新增:手写 ReAct loop + 4 个工具
一、Day 1 的方案还缺什么?
来看一个稍复杂的问题:
用户问:"Transformer 论文 2017 年发布,到 2026 年过了多少年?论文用了什么优化器?"
它需要两件事:
calculator('2026-2017')→ 算年份paper_search('optimizer')→ 查优化器
Day 1 的 run_with_tools 其实能处理这个问题(它有循环,也支持一次开多张工单)。但它有几个明显的短板:
| 短板 | 具体表现 |
|---|---|
| 看不见思考过程 | LLM 为什么选这个工具?为什么传这个参数?我们完全不知道,出错了很难排查 |
| 依赖原生 Function Calling | 必须是支持 tool_calls 的模型,很多开源小模型、老模型不支持 |
| 容易"盲目行动" | 复杂任务里,LLM 不先想清楚就直接调工具,经常调错、调多 |
💡 ReAct 的解法:让 LLM 每一步都先把"想法"写出来(Thought),再决定行动(Action),看到结果(Observation)后再想下一步。
一次只决定 1 个行动,看完结果再决定下一个------"走一步看一步"。
二、ReAct 是什么?
2.1 一句话理解
ReAct = Reasoning(推理)+ Acting(行动)
让 LLM 回答复杂问题时边推理、边行动:先想清楚下一步做什么(Thought),再调用工具获取信息(Action),根据工具返回的结果(Observation)继续推理,循环往复,直到信息足够给出最终答案。
2.2 它从哪来?
ReAct 出自 Yao 等人 2022 年的论文 ReAct: Synergizing Reasoning and Acting in Language Models(发表于 ICLR 2023)。研究者发现:
- 只推理 (Chain-of-Thought,CoT):模型缺少外部信息,容易凭空编造(幻觉)
- 只行动 (Act-only):模型不思考直接调工具,容易盲目试错
- 推理 + 行动交替:效果显著更好 ✅
2.3 核心机制:三段式循环
#mermaid-svg-uayS3mAnYYs3YtBU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uayS3mAnYYs3YtBU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uayS3mAnYYs3YtBU .error-icon{fill:#552222;}#mermaid-svg-uayS3mAnYYs3YtBU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uayS3mAnYYs3YtBU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uayS3mAnYYs3YtBU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uayS3mAnYYs3YtBU .marker.cross{stroke:#333333;}#mermaid-svg-uayS3mAnYYs3YtBU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uayS3mAnYYs3YtBU p{margin:0;}#mermaid-svg-uayS3mAnYYs3YtBU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uayS3mAnYYs3YtBU .cluster-label text{fill:#333;}#mermaid-svg-uayS3mAnYYs3YtBU .cluster-label span{color:#333;}#mermaid-svg-uayS3mAnYYs3YtBU .cluster-label span p{background-color:transparent;}#mermaid-svg-uayS3mAnYYs3YtBU .label text,#mermaid-svg-uayS3mAnYYs3YtBU span{fill:#333;color:#333;}#mermaid-svg-uayS3mAnYYs3YtBU .node rect,#mermaid-svg-uayS3mAnYYs3YtBU .node circle,#mermaid-svg-uayS3mAnYYs3YtBU .node ellipse,#mermaid-svg-uayS3mAnYYs3YtBU .node polygon,#mermaid-svg-uayS3mAnYYs3YtBU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uayS3mAnYYs3YtBU .rough-node .label text,#mermaid-svg-uayS3mAnYYs3YtBU .node .label text,#mermaid-svg-uayS3mAnYYs3YtBU .image-shape .label,#mermaid-svg-uayS3mAnYYs3YtBU .icon-shape .label{text-anchor:middle;}#mermaid-svg-uayS3mAnYYs3YtBU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uayS3mAnYYs3YtBU .rough-node .label,#mermaid-svg-uayS3mAnYYs3YtBU .node .label,#mermaid-svg-uayS3mAnYYs3YtBU .image-shape .label,#mermaid-svg-uayS3mAnYYs3YtBU .icon-shape .label{text-align:center;}#mermaid-svg-uayS3mAnYYs3YtBU .node.clickable{cursor:pointer;}#mermaid-svg-uayS3mAnYYs3YtBU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uayS3mAnYYs3YtBU .arrowheadPath{fill:#333333;}#mermaid-svg-uayS3mAnYYs3YtBU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uayS3mAnYYs3YtBU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uayS3mAnYYs3YtBU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uayS3mAnYYs3YtBU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uayS3mAnYYs3YtBU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uayS3mAnYYs3YtBU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uayS3mAnYYs3YtBU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uayS3mAnYYs3YtBU .cluster text{fill:#333;}#mermaid-svg-uayS3mAnYYs3YtBU .cluster span{color:#333;}#mermaid-svg-uayS3mAnYYs3YtBU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uayS3mAnYYs3YtBU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uayS3mAnYYs3YtBU rect.text{fill:none;stroke-width:0;}#mermaid-svg-uayS3mAnYYs3YtBU .icon-shape,#mermaid-svg-uayS3mAnYYs3YtBU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uayS3mAnYYs3YtBU .icon-shape p,#mermaid-svg-uayS3mAnYYs3YtBU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uayS3mAnYYs3YtBU .icon-shape .label rect,#mermaid-svg-uayS3mAnYYs3YtBU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uayS3mAnYYs3YtBU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uayS3mAnYYs3YtBU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uayS3mAnYYs3YtBU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 信息足够
问题
💭 Thought
思考:下一步做什么
🔧 Action
行动:调用一个工具
👁 Observation
观察:工具返回结果
✅ Final Answer
- Thought(思考):用自然语言推理,说明现在要做什么、为什么
- Action(行动):调用一个具体工具(搜索、计算、查库等)
- Observation(观察):拿到工具结果,作为下一步推理的依据
循环若干轮,直到 LLM 认为信息足够,输出 Final Answer。
2.4 一个直观的例子
用上面的问题,ReAct 的"内心独白"大致是这样的:
Thought: 问题有两部分。先算年份差,用 calculator。
Action: calculator({"expression": "2026-2017"})
Observation: 9
Thought: 年份差是 9 年。接下来查论文用了什么优化器,用 paper_search。
Action: paper_search({"question": "What optimizer did the paper use?"})
Observation: 答案:论文使用了 Adam 优化器,β1=0.9,β2=0.98,ε=1e-9 ...
Thought: 两部分信息都拿到了,我现在可以给出最终答案了
Final Answer: 到 2026 年过了 9 年;论文使用 Adam 优化器(β1=0.9, β2=0.98, ε=1e-9)。
是不是很像一个人在草稿纸上边想边算?
2.5 为什么 ReAct 重要?
- 减少幻觉------答案建立在工具返回的事实上,不是靠记忆编
- 突破知识边界------能通过工具获取训练数据之外的实时信息
- 可解释、可纠错------每一步思考都看得见,出错了能根据 Observation 修正方向
- 通用可组合------同一套循环可以驱动搜索、计算、代码执行等任意工具
| 范式 | 特点 | 问题 |
|---|---|---|
| CoT | 只"想"不"做" | 全靠内部知识,易幻觉 |
| Act-only | 只"做"不"想" | 易跑偏、瞎试 |
| ReAct | 边想边做 | 当前 LLM Agent 的主流范式 ✅ |
三、准备第 4 个工具:paper_search
3.1 vector_store.py 是什么?
paper_search 背后是 W5 做的进阶 RAG:BM25 关键词检索 + 向量语义检索 → RRF 融合排序 → Re-ranking → LLM 基于检索内容生成答案并附引用。
本篇把 W5 的逻辑整理成一个自包含的 vector_store.py,对外只暴露一个函数:
python
rag_search_with_citation(question: str, top_k: int = 3) -> dict
# 返回:
# {
# "answer": "论文使用了 Adam 优化器 ...",
# "citations": ["[1] sample_text.txt 第 ? 页", "[2] ...", "[3] ..."]
# }
💡 没做过 W5 的读者 :只要实现一个同样签名、同样返回结构的函数即可跑通本文。下面是一个极简替代版(只做关键词匹配,不含向量检索,仅用于跑通流程):
python# vector_store.py ------ 极简替代版(仅演示接口,效果远不如真正的混合检索) from pathlib import Path from langchain_core.messages import HumanMessage from agent_core import get_llm _TEXT = Path("outputs/week04/papers/sample_text.txt").read_text(encoding="utf-8") _CHUNKS = [c.strip() for c in _TEXT.split("\n\n") if c.strip()] def rag_search_with_citation(question: str, top_k: int = 3) -> dict: words = set(question.lower().split()) scored = sorted(_CHUNKS, key=lambda c: -sum(w in c.lower() for w in words))[:top_k] context = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(scored)) prompt = f"仅根据以下内容回答,无法回答就说'根据提供的内容无法回答'。\n{context}\n\n问题:{question}" answer = get_llm().invoke([HumanMessage(content=prompt)]).content return {"answer": answer, "citations": [f"[{i+1}] sample_text.txt" for i in range(len(scored))]}
3.2 注册 4 个工具
python
# src/week06/day2_react.py
import re
import json
from langchain_core.messages import HumanMessage
from agent_core import get_llm, register_tool, get_all_tools, call_tool
from day1_function_calling import calculator, word_count, current_time
from vector_store import rag_search_with_citation
# 注册 Day 1 的 3 个工具
register_tool(name="calculator", description="数学计算", func=calculator)
register_tool(name="word_count", description="统计字符数", func=word_count)
register_tool(name="current_time", description="返回当前时间", func=current_time)
def paper_search(question: str) -> str:
"""RAG 论文搜索:混合检索 + LLM 生成 + 引用"""
# 1) 调 W5 进阶 RAG(BM25 + 向量 + RRF + Re-ranking)
result = rag_search_with_citation(question, top_k=3)
# 2) 拼成"答案 + 引用"的文本(工具必须返回字符串)
parts = [f"答案:{result['answer']}"]
parts.append("引用:")
parts.extend(result["citations"])
return "\n".join(parts)
# 注册 Day 2 新增的第 4 个工具
register_tool(
name="paper_search",
description="论文检索:传入研究问题(如 'What optimizer did the paper use?'),返回基于 RAG 的答案 + 引用。",
func=paper_search,
)
注意
paper_search的 description 里给了一个参数示例。这能明显提升 LLM 传参的准确度------这就是 Day 1 反复强调的"写好 description"。
四、ReAct 的灵魂:Prompt 模板
关键认知 :本篇的 ReAct 不使用 bind_tools 。我们不依赖模型的原生 Function Calling,而是通过 Prompt 规定输出格式,再用正则从文本里把 Action 抠出来 。这意味着------任何能按格式输出文本的 LLM 都能当 Agent 大脑。
python
REACT_PROMPT = """你是一个严谨的研究助手,可以调用以下工具:
{tools}
**输出格式**(每轮必须严格按这个格式):
Thought: <你的思考------下一步该做什么>
Action: <工具名>(<参数 JSON>)
Observation: <工具返回结果>
... (Thought/Action/Observation 可重复多次)
Thought: 我现在可以给出最终答案了
Final Answer: <最终答案>
**问题**:{question}
**已执行的步骤**:{history}
下一步:"""
每个元素的作用:
| 元素 | 作用 |
|---|---|
{tools} 占位符 |
运行时由 build_tools_description() 动态填入------加新工具不用改 Prompt |
Thought |
逼 LLM "想清楚再做",也让我们看到它的推理 |
Action |
调哪个工具 + 什么参数,JSON 格式便于程序解析 |
Observation |
工具结果,由程序填入,不是 LLM 写的 |
Final Answer |
终止信号------LLM 用它表示"信息够了" |
{history} |
之前所有轮次的记录,让 LLM 知道自己做过什么 |
⚠️ 一个细节 :
Observation应该由程序填写。但 LLM 有时会"自问自答",自己编一个 Observation 接着往下写。本文的解析逻辑只取第一个 Action 去真正执行,编造的内容会被丢弃,所以影响不大。更严格的做法是调用 LLM 时设置stop=["Observation:"],让它写到 Action 就停下。
五、手写 ReAct Loop
5.1 解析 Action:用正则从文本里抠工单
python
def parse_action(text: str) -> tuple[str, dict] | None:
"""从 LLM 输出中提取 Action 的工具名和参数"""
# 匹配形如:Action: calculator({"expression": "2026-2017"})
# (\w+) 捕获工具名
# (\{.*?\}) 捕获花括号包裹的 JSON(非贪婪)
pattern = r'Action:\s*(\w+)\((\{.*?\})\)'
m = re.search(pattern, text, re.DOTALL) # DOTALL:让 . 也能匹配换行
if not m:
return None # 没有 Action 行
try:
return m.group(1), json.loads(m.group(2))
except json.JSONDecodeError:
return None # LLM 输出的 JSON 不合法
举个例子:
python
text = 'Thought: 先算年份\nAction: calculator({"expression": "2026-2017"})'
parse_action(text)
# → ("calculator", {"expression": "2026-2017"})
💡 小坑 :
\{.*?\}是非贪婪匹配,遇到嵌套 JSON (如{"a": {"b": 1}})会在第一个}处截断导致解析失败。本文的工具参数都是扁平的,够用;复杂场景可以改为从Action:后第一个{开始做括号计数。另外,无参工具(如
current_time)LLM 应输出Action: current_time({}),空字典{}能被正则匹配,call_tool("current_time", **{})也能正常执行。
5.2 动态生成工具说明
python
def build_tools_description() -> str:
"""把注册表里所有工具拼成 "- name: description" 的文本"""
tools = get_all_tools()
lines = []
for t in tools:
lines.append(f"- {t.name}: {t.description}")
return "\n".join(lines)
生成结果类似:
- calculator: 数学计算
- word_count: 统计字符数
- current_time: 返回当前时间
- paper_search: 论文检索:传入研究问题(如 'What optimizer did the paper use?'),返回基于 RAG 的答案 + 引用。
关键设计:
- 动态生成 ------新增工具只需
register_tool,Prompt 一个字都不用改 - LLM 看到的就是 description 原文------这再次说明了为什么 description 这么重要
5.3 ReAct 主循环
逻辑只有 5 步:
- 把"问题 + 历史"喂给 LLM
- 解析 LLM 输出,看是 Action 还是 Final Answer
- 如果是 Action:执行工具,把 Thought/Action/Observation 追加到历史
- 如果是 Final Answer:终止,返回答案
- 步数超限:终止
python
def run_react(question: str, max_steps: int = 5) -> str:
llm = get_llm()
tools_desc = build_tools_description() # 填 {tools}
history = "" # 累积的 Thought/Action/Observation
for step in range(1, max_steps + 1):
# 1) 拼 prompt(3 个占位符都要填)并调用 LLM
prompt = REACT_PROMPT.format(
tools=tools_desc,
question=question,
history=history if history else "(暂无)",
)
response = llm.invoke([HumanMessage(content=prompt)])
text = response.content
print(f"\n--- Step {step} ---\n{text}")
# 2) 优先检查终止信号
if "Final Answer:" in text:
return text.split("Final Answer:")[-1].strip()
# 3) 解析 Action
action = parse_action(text)
if action is None:
return "❌ Agent 无法解析下一步行动"
tool_name, tool_args = action
# 4) 执行工具(用 Day 1 写的 call_tool,自带容错)
result = call_tool(tool_name, **tool_args)
print(f"Observation: {result}")
# 5) 把这一轮追加进历史,下一轮 LLM 就能"看到"
thought = text.split("Thought:")[-1].split("Action:")[0].strip()
history += f"\nThought: {thought}\n"
history += f"Action: {tool_name}({tool_args})\n"
history += f"Observation: {result}\n"
return f"❌ 超过最大步数 {max_steps},未找到 Final Answer"
和 Day 1 对比,看清本质区别:
| Day 1 Function Calling | Day 2 手写 ReAct | |
|---|---|---|
| 工具怎么告诉 LLM | bind_tools() 生成 JSON Schema |
写进 Prompt 的纯文本 |
| LLM 怎么表达"调工具" | 结构化字段 response.tool_calls |
文本里的 Action: xxx({...}) |
| 怎么解析 | 直接读字段 | 正则 抠出来 |
| 结果怎么喂回 | ToolMessage |
拼进 history 字符串 |
| 思考过程 | 不可见 | 每步 Thought 都可见 ✅ |
| 模型要求 | 必须支持原生 Function Calling | 能按格式输出文本就行 ✅ |
| 稳定性 | 高(格式由接口保证) | 较低(LLM 可能不按格式输出) |
🧠 理解这张表,你就理解了 Agent 框架的本质:LangChain、LlamaIndex 这些框架里的 ReAct Agent,底层做的就是"Prompt 规定格式 + 解析文本 + 循环累积历史"这三件事。
几个细节:
- 为什么先判断
Final Answer再解析 Action? 如果 LLM 一次输出里同时写了 Action 和 Final Answer,说明它认为已经可以回答了,直接收尾。 history为什么每轮整体重新拼进 Prompt? 因为每次llm.invoke都是独立的一次调用,LLM 没有记忆,历史必须我们自己带上。max_steps是安全阀:防止 LLM 反复调工具陷入死循环(也防止费用失控)。
5.4 主程序
python
if __name__ == "__main__":
# 演示 1:多步推理(算年份 + 查论文 + 统计字符)
print("=== 演示 1:ReAct 多步推理 ===")
question = "Transformer 论文 2017 年发布,到 2026 年过了多少年?顺便告诉我论文摘要的字符数"
answer = run_react(question, max_steps=5)
print(f"\n最终答案:{answer}\n")
# 演示 2:单步 RAG 检索
print("=== 演示 2:单步 RAG 检索 ===")
answer = run_react("What optimizer did the paper use?", max_steps=3)
print(f"\n最终答案:{answer}\n")
运行:
bash
python src/week06/day2_react.py
演示 1 中,你会看到 LLM 依次调用 calculator → paper_search(拿摘要)→ word_count(数字符),每一步前面都有一段 Thought 解释原因------这就是 ReAct "可解释"的直观体现。
⚠️ 运行前请确保 W4 的向量库已建好(见下方踩坑表),否则
paper_search会返回"无法回答"。
六、🚨 踩坑指南
| 症状 | 原因 | 解决 |
|---|---|---|
KeyError: '\n "tasks"' 之类的奇怪 KeyError |
Prompt 里有字面量 {},被 .format() 当成占位符 |
字面量花括号写成 {``{ }} |
| LLM 输出格式乱七八糟 | Prompt 没强调格式 | 使用严格的 ReAct 模板,必要时加一个示例 |
| ReAct 死循环 | 没有步数上限 | 设置 max_steps=5 |
Action: tool({...}) 解析失败 |
LLM 用了单引号、多了注释,或 JSON 嵌套 | Prompt 里强调"参数必须是严格 JSON(双引号)" |
| LLM 自己编造 Observation | 模型"自问自答" | 调用时加 stop=["Observation:"] |
paper_search 返回空 / 无法回答 |
W4 向量库未建 | 先运行 python src/week04/chroma_init.py |
七、🎓 今日小结
3 个核心能力:
- ReAct Prompt 模板:Thought / Action / Observation / Final Answer 四要素
- 手写 ReAct loop:LLM 决策 → 正则解析 → 工具执行 → 历史累积 → 循环
- 混合检索 RAG 工具 :
paper_search基于 W5 进阶 RAG(BM25 + 向量 + RRF)
今日交付:
- ✅
vector_store.py------自包含混合检索 - ✅
day2_react.py------手写 ReAct + 4 个工具
💭 课后思考
ReAct 每走一步都要调用一次 LLM。如果一个问题需要 5 步,就要调 5 次 LLM------又慢又贵。而且如果某个工具出错(比如参数非法),Agent 可能在错误里反复打转。
有没有办法:让 LLM 一次性把计划想好,然后程序一口气执行完?工具出错时又该怎么优雅地处理?
------这就是 Day 3:多步 Agent 要解决的两个问题。
📚 下一篇:Day 3 · 多步 Agent------错误处理 + Plan-Execute
如果本文对你有帮助,欢迎点赞👍 收藏⭐ 关注!
【AI Agent 从零到一】Day 3:多步 Agent------错误处理让它"跑得稳",Plan-Execute 让它"跑得省"
本系列共 4 篇,带你从零手写一个"科研问答 Agent":
- Day 1:Function Calling 完整流程
- Day 2:ReAct 模式------边想边做
- Day 3:多步 Agent------错误处理 + Plan-Execute(本篇)
- Day 4:科研问答系统 v1.0------整合收官
📌 本篇你将学会
- Part A 稳健性:理解 5 类工具错误,用 Pydantic 校验输入,写带分类、重试、降级的安全调用
- Part B 效率:理解 Plan-Execute 三阶段流水线(Plan / Execute / Synthesize),并与 ReAct 对比选型
本篇交付:
src/week06/
├── day3_tool_errors.py # Part A:Pydantic 校验 + 错误分类 + 重试 + Fallback
└── day4_plan_execute.py # Part B:Plan-Execute 三阶段流程(Day 4 会直接复用)
文件名
day4_plan_execute.py是因为它会在 Day 4 被整合复用,属于本篇内容,不要搞混。
🤔 Step 0:Day 2 的 ReAct 还有什么问题?
python
# Day 2 的 run_react:每轮决定 1 个行动
def run_react(question, max_steps=5):
for step in range(1, max_steps + 1):
response = llm.invoke(prompt) # 每一步都调 LLM
...
result = call_tool(tool_name, **tool_args)
问题 1:不稳健。 LLM 可能传非法参数(比如问题只有 3 个字符)、调用不存在的工具、或者碰上网络超时。错误信息如果处理不当,Agent 就会在错误里打转,甚至把整个程序搞崩。
问题 2:不省钱。 每走一步都要调一次 LLM,5 步 = 5 次调用,又慢又贵。
本篇的两个解法:
| 解决什么 | 手段 | |
|---|---|---|
| Part A:错误处理 | 稳健性 | Pydantic 校验 + 5 类错误分类 + 指数退避重试 + Fallback 降级 |
| Part B:Plan-Execute | 效率 | LLM 一次拆好子任务 → 程序顺序执行(0 次 LLM)→ LLM 一次综合 |
📘 Part A:错误处理------让 Agent 跑得稳
先看整体数据流:
#mermaid-svg-otUeMphloUXKwSTC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-otUeMphloUXKwSTC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-otUeMphloUXKwSTC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-otUeMphloUXKwSTC .error-icon{fill:#552222;}#mermaid-svg-otUeMphloUXKwSTC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-otUeMphloUXKwSTC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-otUeMphloUXKwSTC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-otUeMphloUXKwSTC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-otUeMphloUXKwSTC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-otUeMphloUXKwSTC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-otUeMphloUXKwSTC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-otUeMphloUXKwSTC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-otUeMphloUXKwSTC .marker.cross{stroke:#333333;}#mermaid-svg-otUeMphloUXKwSTC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-otUeMphloUXKwSTC p{margin:0;}#mermaid-svg-otUeMphloUXKwSTC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-otUeMphloUXKwSTC .cluster-label text{fill:#333;}#mermaid-svg-otUeMphloUXKwSTC .cluster-label span{color:#333;}#mermaid-svg-otUeMphloUXKwSTC .cluster-label span p{background-color:transparent;}#mermaid-svg-otUeMphloUXKwSTC .label text,#mermaid-svg-otUeMphloUXKwSTC span{fill:#333;color:#333;}#mermaid-svg-otUeMphloUXKwSTC .node rect,#mermaid-svg-otUeMphloUXKwSTC .node circle,#mermaid-svg-otUeMphloUXKwSTC .node ellipse,#mermaid-svg-otUeMphloUXKwSTC .node polygon,#mermaid-svg-otUeMphloUXKwSTC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-otUeMphloUXKwSTC .rough-node .label text,#mermaid-svg-otUeMphloUXKwSTC .node .label text,#mermaid-svg-otUeMphloUXKwSTC .image-shape .label,#mermaid-svg-otUeMphloUXKwSTC .icon-shape .label{text-anchor:middle;}#mermaid-svg-otUeMphloUXKwSTC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-otUeMphloUXKwSTC .rough-node .label,#mermaid-svg-otUeMphloUXKwSTC .node .label,#mermaid-svg-otUeMphloUXKwSTC .image-shape .label,#mermaid-svg-otUeMphloUXKwSTC .icon-shape .label{text-align:center;}#mermaid-svg-otUeMphloUXKwSTC .node.clickable{cursor:pointer;}#mermaid-svg-otUeMphloUXKwSTC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-otUeMphloUXKwSTC .arrowheadPath{fill:#333333;}#mermaid-svg-otUeMphloUXKwSTC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-otUeMphloUXKwSTC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-otUeMphloUXKwSTC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-otUeMphloUXKwSTC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-otUeMphloUXKwSTC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-otUeMphloUXKwSTC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-otUeMphloUXKwSTC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-otUeMphloUXKwSTC .cluster text{fill:#333;}#mermaid-svg-otUeMphloUXKwSTC .cluster span{color:#333;}#mermaid-svg-otUeMphloUXKwSTC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-otUeMphloUXKwSTC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-otUeMphloUXKwSTC rect.text{fill:none;stroke-width:0;}#mermaid-svg-otUeMphloUXKwSTC .icon-shape,#mermaid-svg-otUeMphloUXKwSTC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-otUeMphloUXKwSTC .icon-shape p,#mermaid-svg-otUeMphloUXKwSTC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-otUeMphloUXKwSTC .icon-shape .label rect,#mermaid-svg-otUeMphloUXKwSTC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-otUeMphloUXKwSTC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-otUeMphloUXKwSTC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-otUeMphloUXKwSTC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不合法
通过
成功
失败
- 输入
question, top_k
2. Pydantic 校验
ValidationError
不重试
3. safe_tool_call
查注册表 + try/except + 重试
4. 执行工具
✅ 返回结果
错误分类
validation/timeout/not_found/permission/unknown
⚠️ Fallback 降级答案
Step 1:Pydantic 输入校验
1.1 Pydantic 是什么?
Pydantic 是 Python 最流行的数据校验库 :用类型注解声明字段约束,自动校验输入是否合法。W3 用过的 LangChain OutputParser、LangSmith 的 trace schema 底层都是它。
为什么 Agent 特别需要它?因为工具的参数是 LLM 生成的 ,LLM 可能传空字符串、传超长文本、把 top_k 设成 100......与其在函数里写一堆 if,不如用 Pydantic 一次声明清楚。
1.2 三个核心概念
| 概念 | 作用 | 例子 |
|---|---|---|
BaseModel |
所有校验类的基类,继承后自动按类型注解校验 | class SearchInput(BaseModel) |
Field(...) |
给字段加约束 | ... 必填;min_length/max_length 限长度;ge/le 限数值范围;default 默认值;description 说明 |
@field_validator("字段名") |
自定义复杂校验逻辑 | 返回处理后的值,或 raise ValueError 拒绝(Pydantic 2.x 需同时加 @classmethod) |
1.3 代码
python
# src/week06/day3_tool_errors.py
import time
from pydantic import BaseModel, Field, field_validator
from agent_core import register_tool
from day1_function_calling import calculator # 导入时会顺带注册 Day 1 的 3 个工具
from vector_store import rag_search_with_citation
class SearchInput(BaseModel):
"""论文搜索的输入 schema"""
question: str = Field(..., min_length=5, max_length=200, description="研究问题")
top_k: int = Field(default=3, ge=1, le=10, description="返回 top-K 个 chunk")
@field_validator("question")
@classmethod
def question_must_not_be_empty(cls, v: str) -> str:
if not v.strip(): # 全是空格也不行
raise ValueError("问题不能为空")
return v.strip() # 返回清洗后的值
def search_paper_validated(question: str, top_k: int = 3) -> str:
"""带 Pydantic 校验的论文搜索"""
validated = SearchInput(question=question, top_k=top_k) # 不合法会直接抛 ValidationError
result = rag_search_with_citation(validated.question, top_k=validated.top_k)
return f"{result['answer']}\n引用:{', '.join(result['citations'])}"
# 别忘了注册!后面 safe_tool_call 要按名字找它
register_tool(name="search_paper_validated",
description="带输入校验的论文检索(RAG + 引用)",
func=search_paper_validated)
效果:
python
SearchInput(question="Hi?") # ❌ 3 个字符 < 5 → ValidationError
SearchInput(question=" ") # ❌ 空白 → min_length 或自定义校验拦截
SearchInput(question="What optimizer?", top_k=50) # ❌ top_k > 10
SearchInput(question="What optimizer?") # ✅ top_k 默认 3
💡 小知识 :Pydantic v2 的
ValidationError是ValueError的子类,所以下面用except ValueError也能捕获它。
Step 2:错误分类 + 安全调用
2.1 为什么要给错误"分类"?
不同的错误,处理方式完全不同:
| 错误类型 | 典型场景 | 要不要重试? | 为什么 |
|---|---|---|---|
validation |
参数非法(问题太短、非法字符) | ❌ 不重试 | 同样的输入重试 100 次也是错 |
not_found |
工具不存在 | ❌ 不重试 | 确定性错误 |
permission |
API key 无效、无权限 | ❌ 不重试 | 重试也没用,得人工处理 |
timeout |
网络超时 | ✅ 重试 | 临时问题,等一会儿可能就好了 |
unknown |
其他未知异常 | ✅ 重试 | 兜底,可能是临时故障 |
核心原则:确定性错误不重试,临时性错误才重试。 否则就是在浪费时间和钱。
2.2 ToolError:统一的错误表示
python
class ToolError:
"""工具错误统一表示------所有错误包装成 ToolError,便于分类处理"""
def __init__(self, error_type: str, message: str, fallback_result: str | None = None):
self.error_type = error_type # 5 类之一
self.message = message # 错误详情
self.fallback_result = fallback_result # 降级答案(可选)
def __str__(self):
# 打印格式:[类型] 消息 → fallback: 降级结果
prefix = f"[{self.error_type}] {self.message}"
if self.fallback_result:
return f"{prefix} → fallback: {self.fallback_result}"
return prefix
统一格式的好处:日志好看、LLM 好理解([timeout] ... 一眼就知道是什么问题)。
2.3 safe_tool_call:分类 + 指数退避重试
python
def safe_tool_call(tool_name: str, max_retries: int = 2, **kwargs) -> str:
"""安全调用工具------捕获错误 + 自动重试 + 分类"""
from agent_core import _TOOL_REGISTRY
# 1) 工具存在吗?(确定性错误,不重试)
if tool_name not in _TOOL_REGISTRY:
return str(ToolError("not_found", f"工具 {tool_name} 不存在"))
func = _TOOL_REGISTRY[tool_name]["func"]
# 2) 重试循环:max_retries=2 → 最多执行 3 次(attempt = 0, 1, 2)
for attempt in range(max_retries + 1):
try:
result = func(**kwargs)
# 2b) 业务错误:工具返回 "❌ ..." 前缀,也当作错误处理
if isinstance(result, str) and result.startswith("❌"):
raise ValueError(result)
return f"✅ {result}" # 成功:加 ✅ 前缀
except ValueError as e:
# 3) 输入校验类错误 → 不重试
err = str(e)
if any(kw in err for kw in ["ValidationError", "Pydantic", "非法字符",
"不能为空", "string_too_short", "validation"]):
return str(ToolError("validation", err))
return str(ToolError("unknown", err))
except TimeoutError:
# 4) 超时 → 指数退避后重试
if attempt < max_retries:
time.sleep(0.5 * (2 ** attempt)) # 0.5s → 1s → 2s
continue
return str(ToolError("timeout", f"重试 {max_retries} 次后仍超时"))
except PermissionError as e:
# 5) 权限错误 → 不重试
return str(ToolError("permission", str(e)))
except Exception as e:
# 6) 兜底 → 重试
if attempt < max_retries:
time.sleep(0.5 * (2 ** attempt))
continue
return str(ToolError("unknown", f"重试后仍失败:{e}"))
逐点讲透:
① "业务错误"的巧妙处理
还记得 Day 1 的 calculator 吗?遇到非法字符它不抛异常 ,而是返回 "❌ 表达式含非法字符:..."。如果不特殊处理,safe_tool_call 会以为调用成功了。所以这里约定:以 ❌ 开头的返回值 = 业务错误 ,主动 raise ValueError 把它转进错误分类流程,再根据关键词"非法字符"归为 validation。
② 什么是指数退避?
每次失败后等待的时间翻倍:0.5 秒 → 1 秒 → 2 秒。
为什么不固定等 1 秒?因为如果服务端正在过载,所有客户端都密集重试只会雪上加霜。指数退避让重试越来越"克制",给服务端喘息的时间。这是调用任何网络 API(包括 LLM API)的标准做法。
③ except 的顺序很重要
except Exception 必须放最后。Python 按顺序匹配,如果放在前面,所有异常都会被它吞掉,前面的精细分类就失效了。
2.4 Fallback chain:失败了也要"体面"
python
def safe_paper_search_with_fallback(question: str, top_k: int = 3) -> str:
"""带 fallback 的论文搜索"""
result = safe_tool_call("search_paper_validated", question=question, top_k=top_k)
if result.startswith("✅"):
return result[2:] # 去掉 "✅ " 前缀
return f"⚠️ 论文搜索失败:{result}\n降级为:'根据提供的内容无法回答'。"
Fallback 策略:
- 工具成功 → 返回结果
- 工具失败 → 明确告知失败原因 + 给出降级答案
这比直接崩溃好得多,也比"假装成功、瞎编一个答案"诚实得多。对科研问答来说,"无法回答"永远好过"编造答案"。
Step 3:跑 Part A
python
if __name__ == "__main__":
print("=== 演示 1:成功调用 search_paper_validated ===")
print(f"结果:{safe_paper_search_with_fallback('What optimizer did the paper use?')}\n")
print("=== 演示 2:输入校验失败(问题 3 字符 < 5)===")
print(f"结果:{safe_paper_search_with_fallback('Hi?')}\n")
print("=== 演示 3:工具不存在(not_found)===")
print(f"结果:{safe_tool_call('nonexistent_tool')}\n")
print("=== 演示 4:calculator 非法字符 ===")
print(f"结果:{safe_tool_call('calculator', expression='import os')}\n")
bash
python src/week06/day3_tool_errors.py
⚠️ 前置条件:演示 1 依赖 RAG 检索,需确认 W4 数据就位:
outputs/week04/papers/sample_text.txt存在(论文原文)outputs/week04/chroma_db/chroma.sqlite3已建库(运行过python src/week04/chroma_init.py)否则演示 1 会返回"无法回答",演示 2-4 不受影响。
实际输出:
=== 演示 1:成功调用 search_paper_validated ===
结果:根据提供的内容,论文使用了Adam优化器,其中beta_1 = 0.9,beta_2 = 0.98,epsilon = 1e-9。
引用:[1] sample_text.txt 第 ? 页, [2] sample_text.txt 第 ? 页, [3] sample_text.txt 第 ? 页
=== 演示 2:输入校验失败(问题 3 字符 < 5)===
结果:⚠️ 论文搜索失败:[validation] 1 validation error for SearchInput
question
String should have at least 5 characters [type=string_too_short, input_value='Hi?', input_type=str]
降级为:'根据提供的内容无法回答'。
=== 演示 3:工具不存在(not_found)===
结果:[not_found] 工具 nonexistent_tool 不存在
=== 演示 4:calculator 非法字符 ===
结果:[validation] ❌ 表达式含非法字符:import os
解读: 4 种情况,4 种不同的处理路径,没有一种让程序崩溃 。尤其是演示 4,import os 这种潜在的代码注入被白名单拦下,并被正确归类为 validation。
📗 Part B:Plan-Execute------让 Agent 跑得省
Step 4:为什么需要 Plan-and-Execute?
再看一个更复杂的问题:
"Transformer 论文 2017 年发布,到 2026 年过了多少年?论文用了什么优化器?参数是什么?"
ReAct 的处理方式:每轮决定 1 个行动
Step 1: current_time() → 2026 (LLM 调用 #1)
Step 2: calculator(2026-2017) → 9 (LLM 调用 #2)
Step 3: paper_search("优化器") → Adam (LLM 调用 #3)
Step 4: paper_search("参数") → β1/β2/ε (LLM 调用 #4)
Step 5: Final Answer (LLM 调用 #5)
5 次 LLM 调用。仔细想想,这几步之间其实没什么依赖,完全可以一开始就规划好。
Plan-and-Execute 的处理方式:
Plan 阶段: 1 次 LLM → 一次性拆出所有子任务
[calculator(2026-2017), paper_search("优化器"), paper_search("参数")]
Execute 阶段: 3 次工具调用(0 次 LLM!)
Synthesize 阶段:1 次 LLM → 综合所有结果
只需 2 次 LLM 调用。
#mermaid-svg-aawF5cItk98pHYsX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aawF5cItk98pHYsX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aawF5cItk98pHYsX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aawF5cItk98pHYsX .error-icon{fill:#552222;}#mermaid-svg-aawF5cItk98pHYsX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aawF5cItk98pHYsX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aawF5cItk98pHYsX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aawF5cItk98pHYsX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aawF5cItk98pHYsX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aawF5cItk98pHYsX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aawF5cItk98pHYsX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aawF5cItk98pHYsX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aawF5cItk98pHYsX .marker.cross{stroke:#333333;}#mermaid-svg-aawF5cItk98pHYsX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aawF5cItk98pHYsX p{margin:0;}#mermaid-svg-aawF5cItk98pHYsX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aawF5cItk98pHYsX .cluster-label text{fill:#333;}#mermaid-svg-aawF5cItk98pHYsX .cluster-label span{color:#333;}#mermaid-svg-aawF5cItk98pHYsX .cluster-label span p{background-color:transparent;}#mermaid-svg-aawF5cItk98pHYsX .label text,#mermaid-svg-aawF5cItk98pHYsX span{fill:#333;color:#333;}#mermaid-svg-aawF5cItk98pHYsX .node rect,#mermaid-svg-aawF5cItk98pHYsX .node circle,#mermaid-svg-aawF5cItk98pHYsX .node ellipse,#mermaid-svg-aawF5cItk98pHYsX .node polygon,#mermaid-svg-aawF5cItk98pHYsX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aawF5cItk98pHYsX .rough-node .label text,#mermaid-svg-aawF5cItk98pHYsX .node .label text,#mermaid-svg-aawF5cItk98pHYsX .image-shape .label,#mermaid-svg-aawF5cItk98pHYsX .icon-shape .label{text-anchor:middle;}#mermaid-svg-aawF5cItk98pHYsX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aawF5cItk98pHYsX .rough-node .label,#mermaid-svg-aawF5cItk98pHYsX .node .label,#mermaid-svg-aawF5cItk98pHYsX .image-shape .label,#mermaid-svg-aawF5cItk98pHYsX .icon-shape .label{text-align:center;}#mermaid-svg-aawF5cItk98pHYsX .node.clickable{cursor:pointer;}#mermaid-svg-aawF5cItk98pHYsX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aawF5cItk98pHYsX .arrowheadPath{fill:#333333;}#mermaid-svg-aawF5cItk98pHYsX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aawF5cItk98pHYsX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aawF5cItk98pHYsX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aawF5cItk98pHYsX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aawF5cItk98pHYsX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aawF5cItk98pHYsX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aawF5cItk98pHYsX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aawF5cItk98pHYsX .cluster text{fill:#333;}#mermaid-svg-aawF5cItk98pHYsX .cluster span{color:#333;}#mermaid-svg-aawF5cItk98pHYsX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aawF5cItk98pHYsX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aawF5cItk98pHYsX rect.text{fill:none;stroke-width:0;}#mermaid-svg-aawF5cItk98pHYsX .icon-shape,#mermaid-svg-aawF5cItk98pHYsX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aawF5cItk98pHYsX .icon-shape p,#mermaid-svg-aawF5cItk98pHYsX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aawF5cItk98pHYsX .icon-shape .label rect,#mermaid-svg-aawF5cItk98pHYsX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aawF5cItk98pHYsX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aawF5cItk98pHYsX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aawF5cItk98pHYsX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} tasks 列表
results 列表
用户问题
- Plan
🧠 调 LLM
拆成子任务 JSON
2. Execute
🔧 不调 LLM
顺序执行工具
3. Synthesize
🧠 调 LLM
综合所有结果
最终答案
一个类比:ReAct 像自驾游 ,每到一个路口再看地图决定怎么走;Plan-Execute 像跟团游,出发前行程就定好了,路上只管执行,最后导游总结。
两种模式对比
| 维度 | ReAct(Day 2) | Plan-and-Execute(本篇) |
|---|---|---|
| LLM 调用次数 | N 次(每步 1 次) | 固定 2 次(Plan + Synthesize) |
| 工具调用次数 | N 次 | N 次 |
| 适用场景 | 步骤不确定、需要根据结果调整 | 问题可拆、步骤明确 |
| 灵活性 | 高(走一步看一步) | 低(计划定了就不改) |
| 速度 / 成本 | 🐢 慢、贵 | ⚡ 快、省(节省 N-2 次 LLM) |
🎯 选型口诀 :问题能拆 → Plan-Execute(省钱);步骤不确定 → ReAct(边走边看)。
实际中大约 80% 的问题可以提前拆解,适合 Plan-Execute。
Step 5:先注册工具
python
# src/week06/day4_plan_execute.py
from __future__ import annotations # Python 3.9 也能用 list[dict] | None 等新语法
import json
import re
from langchain_core.messages import HumanMessage
from agent_core import get_llm, call_tool, register_tool
from vector_store import rag_search_with_citation
from day1_function_calling import calculator, current_time
def paper_search(question: str) -> str:
"""RAG 论文搜索(比 Day 2 版本更简洁)"""
result = rag_search_with_citation(question, top_k=3)
return f"{result['answer']}\n引用:{', '.join(result['citations'])}"
register_tool(name="calculator", description="数学计算", func=calculator)
register_tool(name="current_time", description="返回当前时间", func=current_time)
register_tool(name="paper_search", description="论文检索(RAG + 引用)", func=paper_search)
关键设计:
- 和 Day 2 的
paper_search相比,这里直接一行 f-string 拼接,去掉了"答案:"前缀,输出更紧凑,便于后面综合。 - W6 复用 W5 :
rag_search_with_citation直接从vector_store导入,W5 代码零改动。 - 3 个工具的 description 决定了 Planner 会怎么拆任务------写好 description 依然是关键。
Step 6:Plan 阶段------让 LLM 拆任务
6.1 Planner Prompt:强制 JSON 输出
python
PLANNER_PROMPT = """你是一个任务规划专家。给定一个复杂研究问题,拆分成 2-4 个可执行的子任务。
可用工具:
- paper_search(question): 论文检索(RAG + 引用)
- calculator(expression): 数学计算
- current_time(): 当前时间
**输出格式**(严格 JSON,不要其他文字):
{{
"tasks": [
{{"id": 1, "tool": "工具名", "args": {{"参数": "值"}}, "purpose": "为什么需要这一步"}},
{{"id": 2, "tool": "...", "args": {{}}, "purpose": "..."}}
]
}}
**问题**:{question}
只输出 JSON:"""
每个字段的作用:
| 元素 | 作用 |
|---|---|
"tasks" |
子任务数组 |
"tool" |
工具名,必须是上面列出的工具之一 |
"args" |
工具参数(JSON 对象),会被 ** 解包传给函数 |
"purpose" |
让 LLM 说明"为什么要这一步"------逼它想清楚,也方便我们调试 |
{``{ }} |
转义 !.format() 会把单个 {} 当占位符,JSON 里的花括号必须双写 |
⚠️ 这是本周最高频的坑:Prompt 里的 JSON 示例忘了转义,运行时报
KeyError: '\n "tasks"'。看到这种莫名其妙的 KeyError,第一时间检查花括号。
6.2 解析 Planner 输出
python
def plan_tasks(question: str) -> list[dict]:
"""Plan 阶段:把问题拆成子任务"""
llm = get_llm()
prompt = PLANNER_PROMPT.format(question=question)
response = llm.invoke([HumanMessage(content=prompt)])
# 用正则提取 JSON,容忍 LLM 在外面包 ```json ... ```或加解释文字
text = response.content
m = re.search(r'\{.*\}', text, re.DOTALL)
if not m:
raise ValueError(f"Plan 输出无法解析:{text[:200]}")
data = json.loads(m.group(0))
return data.get("tasks", [])
为什么用正则而不是直接 json.loads(text)? 因为 LLM 即使被要求"只输出 JSON",也经常自作主张地在外面包一层 Markdown 代码块,或者加一句"好的,以下是计划:"。正则 \{.*\}(贪婪匹配)会从第一个 { 一直匹配到最后一个 },正好把完整的 JSON 抠出来。
💡 进阶:DeepSeek、OpenAI 等都支持
response_format={"type": "json_object"}的 JSON 模式,能从接口层面保证输出合法 JSON,比正则更可靠。
Step 7:Execute 阶段------顺序执行(不调 LLM)
python
def execute_tasks(tasks: list[dict]) -> list[dict]:
"""Execute 阶段:按顺序执行每个子任务(纯工具调用,不调 LLM)"""
results = []
for task in tasks:
tool_name = task["tool"]
tool_args = task.get("args", {}) # 无参工具默认空字典
# 1) 打印执行信息(便于调试)
print(f" [Task {task['id']}] {tool_name}({tool_args}) --- {task.get('purpose', '')}")
# 2) 直接调工具 ------ 不调 LLM,这是 Plan-Execute 省钱的关键
result = call_tool(tool_name, **tool_args)
print(f" [Task {task['id']} Result] {str(result)[:150]}") # 截断避免刷屏
# 3) 累积结果,交给 Synthesize 阶段
results.append({
"task_id": task["id"],
"tool": tool_name,
"args": tool_args,
"result": result,
})
return results
关键设计:
- 不用 LLM:Execute 只是"照单执行",不做决策
- 累积结果:Synthesizer 需要看到全部结果才能综合
- 容错 :
call_tool自带 try/except,某个子任务失败只会得到一条错误字符串,不会中断其他任务。想要更稳,可以把这里换成 Part A 的safe_tool_call
🔍 一个值得思考的局限 :子任务的参数在 Plan 阶段就写死 了。如果任务 2 需要用到任务 1 的结果(比如"先查当前年份,再用这个年份减 2017"),这个简单版本是做不到的------Planner 只能提前"猜"好参数。这正是 Plan-Execute 灵活性不如 ReAct 的地方。进阶做法是支持
{task_1_result}这样的占位符在执行时替换,或者在执行失败时触发"重新规划"(Replan)。
Step 8:Synthesize 阶段------LLM 综合
python
SYNTHESIZE_PROMPT = """你是一个研究助手。给定一个研究问题和多个子任务的执行结果,合成一个完整、准确的最终答案。
**研究问题**:{question}
**子任务结果**:
{results}
**要求**:
1. 综合所有子任务的结果
2. 如果有 paper_search 的引用,保留
3. 回答简洁(不超过 300 字)
4. 中文输出
**最终答案**:"""
def synthesize_answer(question: str, task_results: list[dict]) -> str:
"""Synthesize 阶段:综合所有子任务结果(1 次 LLM 调用)"""
llm = get_llm()
# 1) 把所有结果拼成一段文本,让 LLM "看到全局"
results_text = "\n\n".join(
f"任务 {r['task_id']} ({r['tool']}):\n{r['result']}"
for r in task_results
)
# 2) 填充模板并调用 LLM
prompt = SYNTHESIZE_PROMPT.format(question=question, results=results_text)
response = llm.invoke([HumanMessage(content=prompt)])
return response.content
关键设计:
| 要求 | 作用 |
|---|---|
| 喂入所有任务结果 | LLM 能看到全局,做真正的"综合"而不是复述某一条 |
| "如果有引用,保留" | 防止 LLM 在总结时把 RAG 引用丢掉------科研场景引用很重要 |
| "中文输出" | 论文检索结果常是英文,不约束的话 LLM 可能切换成英文回答 |
Step 9:串起来------完整流水线
python
def plan_and_execute(question: str) -> str:
"""Plan-Execute 完整流程"""
# 1) Plan
print(f"\n[Plan 阶段] 把问题拆成 sub-tasks")
tasks = plan_tasks(question)
print(f" 拆出 {len(tasks)} 个 sub-tasks:")
for t in tasks:
print(f" - Task {t['id']}: {t['tool']}({t.get('args', {})})")
# 2) Execute
print(f"\n[Execute 阶段] 顺序执行 sub-tasks")
task_results = execute_tasks(tasks)
# 3) Synthesize
print(f"\n[Synthesize 阶段] LLM 综合所有结果")
return synthesize_answer(question, task_results)
if __name__ == "__main__":
print("=== 演示 5:多步研究问题(搜论文 + 算年份)===")
answer = plan_and_execute("Transformer 论文 2017 年发布,到 2026 年过了多少年?论文用了什么优化器?")
print(f"\n最终答案:{answer}\n")
print("=== 演示 6:纯 RAG 检索问题 ===")
answer = plan_and_execute("What is the multi-head attention mechanism?")
print(f"\n最终答案:{answer}\n")
总计:Plan 1 次 LLM + Execute 0 次 LLM + Synthesize 1 次 LLM = 只要 2 次 LLM 调用。
bash
python src/week06/day4_plan_execute.py
实际输出:
=== 演示 5:多步研究问题(搜论文 + 算年份)===
[Plan 阶段] 把问题拆成 sub-tasks
拆出 2 个 sub-tasks:
- Task 1: calculator({'expression': '2026 - 2017'})
- Task 2: paper_search({'question': 'Transformer 2017 论文中使用的优化器是什么?'})
[Execute 阶段] 顺序执行 sub-tasks
[Task 1] calculator(...) --- 计算从2017年到2026年经过的年数
[Task 1 Result] 9
[Task 2] paper_search(...) --- 查找原始Transformer论文中使用的优化器名称
[Task 2 Result] 根据提供的内容无法回答。
引用:[1] sample_text.txt 第 ? 页, ...
[Synthesize 阶段] LLM 综合所有结果
最终答案:从2017年到2026年共经过 **9年**(2017→2026,按年份差计算)。
关于论文使用的优化器,子任务2(paper_search)未能从提供的内容中获取相关信息,因此无法回答该部分。
这个输出非常有教学价值,值得仔细看:
- Planner 很聪明 :它知道年份已给出,没有多此一举调
current_time,直接拆成 2 个任务。 - Task 2 检索失败了 :注意 Planner 把问题翻译成了中文 ("Transformer 2017 论文中使用的优化器是什么?"),而论文原文是英文。对英文语料做中文检索,关键词(BM25)部分几乎匹配不上,于是检索失败。Part A 演示 1 用英文 "What optimizer did the paper use?" 就成功了。 💡 改进方法:在 Planner Prompt 里加一句"paper_search 的 question 参数请使用英文"。这也再次说明------Prompt 和 description 的细节直接决定 Agent 的效果。
- Synthesizer 很诚实:它没有编造"Adam",而是明确说"未能获取,无法回答"。这正是我们通过 RAG + "无法回答"降级策略想要的行为。
🚨 踩坑指南
| 症状 | 原因 | 解决 |
|---|---|---|
KeyError: '\n "tasks"' |
Prompt 里的 JSON 花括号没转义 | { → {``{,} → }} |
| Plan 输出无法解析 | LLM 在 JSON 外加了文字或代码块 | 用正则 r'\{.*\}' 提取,或开启 JSON 模式 |
| 某个子任务执行失败 | 工具不存在 / 参数错误 | 用 Part A 的 safe_tool_call 替换 call_tool,失败不中断 |
| Synthesize 答案不全 | 没把所有结果喂进去 | 打印检查 results_text 是否包含所有任务 |
Pydantic 错误被归为 unknown |
关键词列表没匹配上 | 往关键词列表加 "string_too_short"、"validation" |
eval("import os") 报错但没被归类 |
工具返回的错误没被识别 | 工具统一返回 "❌ ..." 前缀,触发 ValueError 路径 |
| 重试时等太久 | 退避基数太大 | 用 time.sleep(0.5 * (2 ** attempt)) |
| 中文问题检索不到英文论文 | 检索语言与语料语言不一致 | Planner Prompt 要求检索参数用英文 |
🎓 今日小结
6 个核心能力:
- 5 类工具错误分类:validation / timeout / not_found / permission / unknown
- Pydantic 字段约束 :
min_length/ge/le/default/description/field_validator safe_tool_call:确定性错误不重试 + 临时错误指数退避重试- Fallback chain:工具失败时明确告知 + 降级为"无法回答"
- Plan-Execute 三阶段:Plan 拆任务 → Execute 顺序执行 → Synthesize 综合
- 选型:可拆的问题用 Plan-Execute 省钱,不可拆的用 ReAct 边走边看
今日交付:
- ✅
day3_tool_errors.py(约 160 行)------4 个 demo:成功 / 校验失败 / not_found / 非法字符 - ✅
day4_plan_execute.py(约 170 行)------2 个 demo:多步研究 + 纯 RAG
💭 课后思考
- 把
execute_tasks里的call_tool换成safe_tool_call,再故意让 Planner 调一个不存在的工具,观察 Synthesizer 如何处理错误信息。 - 三个子任务互不依赖时,能不能并行 执行?(提示:
concurrent.futures.ThreadPoolExecutor) - 到目前为止,我们有了 Function Calling、ReAct、Plan-Execute、错误处理、RAG 检索......能不能把它们打包成一个真正能交互使用的系统 ?------这就是 Day 4 的收官项目。
📚 下一篇:Day 4 · 科研问答系统 v1.0------整合收官
如果本文对你有帮助,欢迎点赞👍 收藏⭐ 关注!
【AI Agent 从零到一】Day 4:科研问答系统 v1.0------把所有能力打包成一个可交互的 Agent
本系列共 4 篇,带你从零手写一个"科研问答 Agent":
- Day 1:Function Calling 完整流程
- Day 2:ReAct 模式------边想边做
- Day 3:多步 Agent------错误处理 + Plan-Execute
- Day 4:科研问答系统 v1.0------整合收官(本篇)
📌 本篇你将学会
- 整合 W5 RAG:混合检索 + Re-ranking + 引用
- 整合 W6 Agent:工具注册表 + Plan-Execute 三阶段
- 用一个
ResearchAgent类封装系统,输出结构化结果 - 写一个 CLI 命令行交互界面,支持 demo 模式和交互模式
本篇交付:
src/week06/
├── agent_core.py
├── day1_function_calling.py
├── vector_store.py
├── day2_react.py
├── day3_tool_errors.py
├── day4_plan_execute.py
└── day5_research_agent.py # 新增:科研问答系统 v1.0(Phase 2 收官)
🎯 W6 Day 4 = Phase 2 收官------前三天写的零件,今天组装成一台能跑的机器。
一、系统全景:7 层架构
先从整体上看清楚,我们这几天写的东西都在什么位置:
#mermaid-svg-pvFC4jKfZQL9MytC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pvFC4jKfZQL9MytC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pvFC4jKfZQL9MytC .error-icon{fill:#552222;}#mermaid-svg-pvFC4jKfZQL9MytC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pvFC4jKfZQL9MytC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pvFC4jKfZQL9MytC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pvFC4jKfZQL9MytC .marker.cross{stroke:#333333;}#mermaid-svg-pvFC4jKfZQL9MytC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pvFC4jKfZQL9MytC p{margin:0;}#mermaid-svg-pvFC4jKfZQL9MytC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pvFC4jKfZQL9MytC .cluster-label text{fill:#333;}#mermaid-svg-pvFC4jKfZQL9MytC .cluster-label span{color:#333;}#mermaid-svg-pvFC4jKfZQL9MytC .cluster-label span p{background-color:transparent;}#mermaid-svg-pvFC4jKfZQL9MytC .label text,#mermaid-svg-pvFC4jKfZQL9MytC span{fill:#333;color:#333;}#mermaid-svg-pvFC4jKfZQL9MytC .node rect,#mermaid-svg-pvFC4jKfZQL9MytC .node circle,#mermaid-svg-pvFC4jKfZQL9MytC .node ellipse,#mermaid-svg-pvFC4jKfZQL9MytC .node polygon,#mermaid-svg-pvFC4jKfZQL9MytC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pvFC4jKfZQL9MytC .rough-node .label text,#mermaid-svg-pvFC4jKfZQL9MytC .node .label text,#mermaid-svg-pvFC4jKfZQL9MytC .image-shape .label,#mermaid-svg-pvFC4jKfZQL9MytC .icon-shape .label{text-anchor:middle;}#mermaid-svg-pvFC4jKfZQL9MytC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pvFC4jKfZQL9MytC .rough-node .label,#mermaid-svg-pvFC4jKfZQL9MytC .node .label,#mermaid-svg-pvFC4jKfZQL9MytC .image-shape .label,#mermaid-svg-pvFC4jKfZQL9MytC .icon-shape .label{text-align:center;}#mermaid-svg-pvFC4jKfZQL9MytC .node.clickable{cursor:pointer;}#mermaid-svg-pvFC4jKfZQL9MytC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pvFC4jKfZQL9MytC .arrowheadPath{fill:#333333;}#mermaid-svg-pvFC4jKfZQL9MytC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pvFC4jKfZQL9MytC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pvFC4jKfZQL9MytC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pvFC4jKfZQL9MytC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pvFC4jKfZQL9MytC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pvFC4jKfZQL9MytC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pvFC4jKfZQL9MytC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pvFC4jKfZQL9MytC .cluster text{fill:#333;}#mermaid-svg-pvFC4jKfZQL9MytC .cluster span{color:#333;}#mermaid-svg-pvFC4jKfZQL9MytC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pvFC4jKfZQL9MytC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pvFC4jKfZQL9MytC rect.text{fill:none;stroke-width:0;}#mermaid-svg-pvFC4jKfZQL9MytC .icon-shape,#mermaid-svg-pvFC4jKfZQL9MytC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pvFC4jKfZQL9MytC .icon-shape p,#mermaid-svg-pvFC4jKfZQL9MytC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pvFC4jKfZQL9MytC .icon-shape .label rect,#mermaid-svg-pvFC4jKfZQL9MytC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pvFC4jKfZQL9MytC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pvFC4jKfZQL9MytC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pvFC4jKfZQL9MytC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} paper_search 调用 RAG
L1 · CLI 命令行(main 交互循环)
L2 · Plan 🧠 → Execute 🔧 → Synthesize 🧠
L3 · get_llm() | 工具注册表 | ResearchAgent
L4 · calculator | current_time | word_count | paper_search | list_tools
L5 · Embedding 后端:OpenAI / 硅基流动 BAAI/bge-m3
L6 · hybrid_search(BM25+向量+RRF) → rag_search_with_citation(检索+LLM+引用)
L7 · Chroma 向量库 + sample_text.txt | DeepSeek API | Embedding API
| 层级 | 内容 | 来自哪天 |
|---|---|---|
| L1 | CLI 交互界面 | Day 4(本篇) |
| L2 | Plan-Execute 三阶段流水线 | Day 3 |
| L3 | LLM 工厂、工具注册表、Agent 封装 | Day 1 + 本篇 |
| L4 | 5 个工具 | Day 1-2 + 本篇 |
| L5-L6 | 混合检索 + RAG 生成 + 引用 | W5(vector_store.py) |
| L7 | 向量库、LLM API、Embedding API | 外部依赖 |
一次提问的完整旅程:
工具/RAG LLM ResearchAgent CLI 用户 工具/RAG LLM ResearchAgent CLI 用户 #mermaid-svg-MNuq02xaCZyUQaNy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MNuq02xaCZyUQaNy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MNuq02xaCZyUQaNy .error-icon{fill:#552222;}#mermaid-svg-MNuq02xaCZyUQaNy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MNuq02xaCZyUQaNy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MNuq02xaCZyUQaNy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MNuq02xaCZyUQaNy .marker.cross{stroke:#333333;}#mermaid-svg-MNuq02xaCZyUQaNy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MNuq02xaCZyUQaNy p{margin:0;}#mermaid-svg-MNuq02xaCZyUQaNy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MNuq02xaCZyUQaNy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-MNuq02xaCZyUQaNy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MNuq02xaCZyUQaNy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-MNuq02xaCZyUQaNy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-MNuq02xaCZyUQaNy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-MNuq02xaCZyUQaNy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-MNuq02xaCZyUQaNy .sequenceNumber{fill:white;}#mermaid-svg-MNuq02xaCZyUQaNy #sequencenumber{fill:#333;}#mermaid-svg-MNuq02xaCZyUQaNy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-MNuq02xaCZyUQaNy .messageText{fill:#333;stroke:none;}#mermaid-svg-MNuq02xaCZyUQaNy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MNuq02xaCZyUQaNy .labelText,#mermaid-svg-MNuq02xaCZyUQaNy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-MNuq02xaCZyUQaNy .loopText,#mermaid-svg-MNuq02xaCZyUQaNy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-MNuq02xaCZyUQaNy .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MNuq02xaCZyUQaNy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-MNuq02xaCZyUQaNy .noteText,#mermaid-svg-MNuq02xaCZyUQaNy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-MNuq02xaCZyUQaNy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MNuq02xaCZyUQaNy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MNuq02xaCZyUQaNy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MNuq02xaCZyUQaNy .actorPopupMenu{position:absolute;}#mermaid-svg-MNuq02xaCZyUQaNy .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-MNuq02xaCZyUQaNy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MNuq02xaCZyUQaNy .actor-man circle,#mermaid-svg-MNuq02xaCZyUQaNy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-MNuq02xaCZyUQaNy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop 每个子任务 输入问题 agent.ask(question) ① Plan:拆成子任务 tasks JSON ② Execute:call_tool result ③ Synthesize:综合所有结果 最终答案 结构化 result(plan/results/answer/steps) 打印最终答案
二、工具注册:Phase 2 的完整工具集
python
# src/week06/day5_research_agent.py
import sys, os
from pathlib import Path
sys.path.insert(0, str(Path(os.getcwd()))) # 把当前目录加进模块搜索路径
from agent_core import register_tool
from vector_store import rag_search_with_citation
from day1_function_calling import calculator, word_count, current_time
def paper_search(question: str) -> str:
"""RAG 论文搜索:返回基于混合检索的答案 + 引用"""
result = rag_search_with_citation(question, top_k=3)
return f"{result['answer']}\n引用:{', '.join(result['citations'])}"
def list_tools() -> str:
"""列出所有可用工具(让 LLM 知道自己有哪些能力)"""
tools = [
"paper_search: 论文检索(RAG + 引用)",
"calculator: 数学计算(+ - * /)",
"word_count: 统计字符数",
"current_time: 返回当前时间",
]
return "可用工具:\n- " + "\n- ".join(tools)
# 注册 5 个工具
register_tool(name="calculator", description="数学计算", func=calculator)
register_tool(name="word_count", description="统计字符数", func=word_count)
register_tool(name="current_time", description="返回当前时间", func=current_time)
register_tool(name="paper_search", description="论文检索(RAG + 引用)", func=paper_search)
register_tool(name="list_tools", description="列出可用工具", func=list_tools)
要点说明:
sys.path.insert(0, ...):确保从项目目录运行时,from agent_core import ...这类同级导入能找到文件。如果遇到ModuleNotFoundError,先检查你是在哪个目录下运行的。list_tools是个"元工具":它不干具体活,而是告诉 LLM(或用户)"我能干什么"。用户问"你有什么功能"时就能派上用场。- 重复注册没关系 :
day1_function_calling.py、day4_plan_execute.py被导入时也会注册同名工具。因为注册表是字典,同名注册只会覆盖,不会重复,最终以最后一次注册为准。
💡 一个可以改进的地方 :Day 3 的
PLANNER_PROMPT里只写了 3 个工具(paper_search / calculator / current_time),所以 Planner 不知道word_count和list_tools的存在,也就永远不会调用它们。改进办法是把 Planner Prompt 里写死的工具列表换成 Day 2 的build_tools_description()动态生成------这样注册什么工具,Planner 就能看到什么工具。
三、构建 ResearchAgent:把流水线封装成对象
python
from day4_plan_execute import plan_tasks, execute_tasks, synthesize_answer # 复用 Day 3 的三阶段
class ResearchAgent:
def __init__(self):
self.history: list[dict] = [] # 对话历史
def ask(self, question: str) -> dict:
print(f"问题:{question}")
# Step 1: Plan ------ LLM 拆任务
print(f"\n[Plan]")
tasks = plan_tasks(question)
for t in tasks:
print(f" Task {t['id']}: {t['tool']}({t.get('args', {})})")
# Step 2: Execute ------ 顺序调工具
print(f"\n[Execute]")
task_results = execute_tasks(tasks)
# Step 3: Synthesize ------ LLM 综合
print(f"\n[Synthesize]")
final_answer = synthesize_answer(question, task_results)
# 记录结构化结果
result = {
"question": question, # 原问题
"plan": tasks, # 拆出的子任务
"task_results": task_results, # 每个子任务的执行结果
"answer": final_answer, # 最终答案
"steps": len(task_results), # 步数
}
self.history.append(result)
return result
为什么要封装成类,而不是直接调 plan_and_execute()?
Day 3 的 plan_and_execute() 只返回一个字符串答案。而 ResearchAgent.ask() 返回的是一个结构化字典:
python
{
"question": "Transformer 论文 2017 年发布,到 2026 年过了多少年?",
"plan": [{"id": 1, "tool": "calculator", "args": {"expression": "2026-2017"}, "purpose": "..."}],
"task_results": [{"task_id": 1, "tool": "calculator", "args": {...}, "result": "9"}],
"answer": "从 2017 年到 2026 年共经过 9 年。",
"steps": 1
}
这样做有 3 个好处:
| 好处 | 说明 |
|---|---|
| 可追溯 | 答案错了?翻 plan 看是不是拆错了,翻 task_results 看是不是工具返回有问题------一眼定位是哪个阶段出的错 |
| 可扩展 | 以后要接 Web 界面、存数据库、做评测,直接用这个字典,不需要重新解析字符串 |
| 有状态 | self.history 让 Agent 记住聊过什么,为后续的多轮对话(Phase 3)打好基础 |
🧠 这是从"脚本"到"系统"的关键一步:脚本只关心最终输出,系统要关心过程是否可观测、可调试。前面 Agent 架构图里的"可观测性(Observability)"说的就是这个。
四、CLI 交互界面
4.1 主循环
python
def main():
"""CLI 主循环:用户持续输入问题,Agent 持续回答"""
agent = ResearchAgent()
# 1) 欢迎信息
print("=" * 60)
print("🔬 科研问答系统 v1.0(W6 Phase 2 项目)")
print("=" * 60)
print("\n可用工具:")
print(" - paper_search: 论文检索(RAG + 引用)")
print(" - calculator: 数学计算(+ - * /)")
print(" - word_count: 统计字符数")
print(" - current_time: 返回当前时间")
print("\n输入 'exit' / 'quit' 退出")
print("输入 'history' 查看对话历史\n")
# 2) 持续接收输入
while True:
try:
question = input("\n🔬 你想问:").strip()
except (EOFError, KeyboardInterrupt):
# Ctrl+D / Ctrl+C 优雅退出,不打印一堆报错
print("\n退出")
break
# 3) 特殊命令
if not question:
continue # 空输入,忽略
if question.lower() in ("exit", "quit", "q"):
print("退出")
break
if question.lower() == "history":
for i, h in enumerate(agent.history, 1):
print(f" [{i}] {h['question']} → {h['answer'][:80]}...")
continue
# 4) 正常提问
result = agent.ask(question)
print(f"\n>>> 最终答案:{result['answer']}")
几个贴心的细节:
try/except (EOFError, KeyboardInterrupt):用户按Ctrl+C时,程序礼貌地说"退出",而不是甩出一大段 Traceback。.strip()+ 空输入跳过:用户手滑按了回车不会触发一次 LLM 调用(省钱)。.lower():输入EXIT、Quit也能识别。history命令:只显示答案前 80 个字符,避免刷屏。
💡 健壮性建议 :当前
agent.ask(question)没有被try/except包住。如果 Planner 输出无法解析(plan_tasks会抛ValueError)或网络异常,整个 CLI 会直接退出。建议改成:
pythontry: result = agent.ask(question) print(f"\n>>> 最终答案:{result['answer']}") except Exception as e: print(f"\n⚠️ 本次提问失败:{e},请换个问法再试")这样一次失败不会影响后续提问------这正是 Day 3 "让 Agent 跑得稳"思想的延续。
4.2 程序入口:两种运行模式
python
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--demo":
# Demo 模式:不进交互,直接跑 3 个预设问题,快速验证系统
agent = ResearchAgent()
demo_questions = [
"Transformer 论文 2017 年发布,到 2026 年过了多少年?",
"论文用了什么优化器?参数是什么?",
"What is the multi-head attention mechanism?",
]
for q in demo_questions:
result = agent.ask(q)
print(f"\n>>> 最终答案:{result['answer']}\n")
print("=" * 60)
else:
# 交互模式
main()
sys.argv 是命令行参数列表,sys.argv[0] 是脚本名,sys.argv[1] 就是第一个参数。--demo 模式 适合快速自测、演示、或写进自动化测试;交互模式适合真正使用。
3 个 demo 问题分别覆盖了三类场景:
| 问题 | 考察点 |
|---|---|
| Transformer 到 2026 年过了多少年? | 纯计算,Planner 应只拆出 calculator |
| 论文用了什么优化器?参数是什么? | 纯检索,可能拆成 1-2 个 paper_search |
| What is the multi-head attention mechanism? | 英文检索,检验 RAG 对英文语料的效果 |
五、运行
5.1 Demo 模式(跳过交互,自动跑 3 个问题)
bash
python src/week06/day5_research_agent.py --demo
5.2 CLI 交互模式
bash
python src/week06/day5_research_agent.py
交互过程大致如下:
============================================================
🔬 科研问答系统 v1.0(W6 Phase 2 项目)
============================================================
可用工具:
- paper_search: 论文检索(RAG + 引用)
- calculator: 数学计算(+ - * /)
- word_count: 统计字符数
- current_time: 返回当前时间
输入 'exit' / 'quit' 退出
输入 'history' 查看对话历史
🔬 你想问:Transformer 论文 2017 年发布,到 2026 年过了多少年?
问题:Transformer 论文 2017 年发布,到 2026 年过了多少年?
[Plan]
Task 1: calculator({'expression': '2026 - 2017'})
[Execute]
[Task 1] calculator({'expression': '2026 - 2017'}) --- 计算年份差
[Task 1 Result] 9
[Synthesize]
>>> 最终答案:从 2017 年到 2026 年共经过 9 年。
🔬 你想问:history
[1] Transformer 论文 2017 年发布,到 2026 年过了多少年? → 从 2017 年到 2026 年共经过 9 年。...
🔬 你想问:exit
退出
具体的任务拆分和措辞每次运行可能略有不同,这是 LLM 的正常现象。
⚠️ 运行前检查清单:
.env中配置了DEEPSEEK_API_KEY(以及 Embedding 后端所需的 key)- 已运行
python src/week04/chroma_init.py建好向量库outputs/week04/papers/sample_text.txt存在- 在项目根目录下运行命令(避免模块导入失败)
六、回顾:这一周我们到底造了什么?
| 天 | 学到的能力 | 在 v1.0 中的角色 |
|---|---|---|
| Day 1 | Function Calling、工具注册表、LLM 工厂 | 系统地基(agent_core.py)+ 3 个基础工具 |
| Day 2 | ReAct、Prompt 驱动的 Agent、RAG 工具 | paper_search 工具 + vector_store.py |
| Day 3 | 错误处理、Plan-Execute | 核心决策引擎(Plan → Execute → Synthesize) |
| Day 4 | 系统整合、结构化输出、CLI | 把一切装进 ResearchAgent + 交互界面 |
回到 Day 1 的那句话:
Agent = LLM + 工具集 + 自主决策循环
现在,你已经亲手实现了其中的每一个部分:
- LLM :
get_llm()统一接入 DeepSeek - 工具集 :注册表 + 5 个工具,其中
paper_search背后是完整的混合检索 RAG - 决策循环:既会 ReAct(边走边看),也会 Plan-Execute(先规划后执行)
- 稳健性:Pydantic 校验、错误分类、指数退避、Fallback 降级
- 可观测:每一步 Plan / Execute / Synthesize 都打印出来,结果结构化保存
七、v1.0 的局限与 v2.0 路线图
一个诚实的系统,也要知道自己哪里还不够好:
| 局限 | 表现 | 升级方向 |
|---|---|---|
| 没有真正的多轮对话 | history 只存不用,第二个问题问"它的参数呢?",Agent 不知道"它"是谁 |
把历史拼进 Planner / Synthesizer Prompt,或用 LangGraph 的 checkpointer 做记忆 |
| Planner 工具列表写死 | word_count、list_tools 注册了但 Planner 看不到 |
用 build_tools_description() 动态生成 |
| Execute 没用安全调用 | 工具出错时错误信息不规范 | 把 call_tool 换成 Day 3 的 safe_tool_call |
| 子任务无法传递结果 | 任务 2 用不了任务 1 的输出 | 支持结果占位符,或引入 Replan 机制 |
| 只能串行 | 独立子任务也一个个等 | 线程池 / asyncio 并行执行 |
| 只有一种策略 | 所有问题都走 Plan-Execute | 加一个"路由器":简单问题直接答,可拆问题走 Plan-Execute,复杂探索走 ReAct |
| CLI 一处失败全退出 | ask 抛异常时程序终止 |
外层包 try/except |
这些正是 Phase 3 要解决的问题:多轮记忆、状态管理、人在环(Human-in-the-Loop)、持久化。
🎓 今日小结
3 个核心能力:
- Phase 2 整合:W5 RAG + W6 Plan-Execute + 5 个工具 = 科研问答 Agent v1.0
ResearchAgent类:结构化输出(plan + task_results + answer + steps),可追溯、可扩展- CLI 交互 :
main()的 while 循环 +--demo模式快速验证
今日交付:
- ✅
day5_research_agent.py------Phase 2 收官 🎉
💭 课后挑战
- 动手修一个局限:从上面的路线图里挑一项(推荐"Planner 动态工具列表"或"CLI 异常兜底"),自己实现并测试。
- 实现最简单的多轮对话 :在
ask()里把self.history最近 3 轮的问答拼进问题前面,再测试"论文用了什么优化器?" → "它的参数是多少?"。 - 做一个路由器 :先用一次 LLM 判断问题类型,再决定用 Day 1 的
run_with_tools、Day 2 的run_react还是 Day 3 的plan_and_execute。
🎉 W6 · Phase 2 完结! 从一个只会"说"的 LLM,到一个会规划、会用工具、会查论文、会处理错误的科研助手------恭喜你走完了这一程。
如果这个系列对你有帮助,欢迎点赞👍 收藏⭐ 关注,Phase 3 见!