Agent
本章配套可运行示例:demo/demoagent.py。demo 用四个本地纯函数工具(查书、门店信息、滞纳金计算、活动查询)搭了一个「拾光书屋小助手」,一个 invoke 跑完一个多工具协作任务,并打印完整决策路径;本…
本章配套可运行示例:demo/demo_agent.py。demo 用四个本地纯函数工具(查书、门店信息、滞纳金计算、活动查询)搭了一个「拾光书屋小助手」,一个
invoke跑完一个多工具协作任务,并打印完整决策路径;本机没启动 Ollama 时自动降级,改为展示工具定义与 agent 构造代码。本章你将学会
- 一句话说清 Agent:模型 + 工具 + 循环决策——以及它跟第 10 章那个手写
while循环的血脉关系- 链与 Agent 的分界线:固定管线 vs 动态决策,什么活儿该交给谁
- ReAct 思想:思考 → 行动 → 观察 的转圈循环
create_agent:LangChain 1.x 的官方入口——model/tools/system_prompt三个参数,循环框架包办- 读懂返回值:
messages里的 ToolMessage 就是决策路径,最终答案在messages[-1].content- 书屋小助手综合实战:一个任务、多个工具、一条完整的决策路径
- 工具设计三原则:描述写清楚、粒度拿捏、错误返回字符串(而不是抛异常)
第 10 章我们干了一件挺硬核的事:用 bind_tools 把工具挂上模型,然后亲手写了一个循环——模型说「我要调工具」,我们就执行工具、把结果包成 ToolMessage 塞回消息列表,再问模型「好了吗」……直到模型不再要工具为止。那套代码跑是能跑,但你大概也注意到了:真正干活的十几行之外,剩下的全是「司机操作」——判断循环何时停、装配消息、回填结果。
本章就干一件事:把方向盘留下,把换挡交给变速箱。
比喻:第 10 章你开的是手动挡——什么时候换挡(判断 tool_calls)、怎么踩离合(执行工具)、何时松油门(回填结果),全靠自己。本章换自动挡:油门(提问)还是你踩,但换挡逻辑由框架承包,你只管看路。
技术基线:Python + LangChain 1.x(langchain + langchain-ollama),本地 Ollama 跑 qwen3:4b。没装的话回 第 01 章看环境准备。
1. 重新认识 Agent:模型 + 工具 + 循环决策
比喻:链是地铁,Agent 是网约车。坐地铁,路线图开工前就焊死了——从 A 站到 B 站怎么走、在哪换乘,一毫米都不能改;打网约车,你只报目的地,司机看着路况现场拍板——前方拥堵就绕行,乘客临时改口就重新规划。本章讲的 Agent(智能体),就是那位「看着路况开车的司机」。
Agent 是什么:一个能自主决定下一步做什么的程序,可以拆成一个三件套公式:
Agent = 模型(大脑:理解任务、决定下一步)
+ 工具(手脚:查库存、算费用、读日程)
+ 循环决策(工作法:干一步、看一眼结果、再决定下一步)
注意这个公式里没有「更聪明的模型」这一项。拾光书屋的 Agent 用的还是同一个 qwen3:4b,模型本身没变——变的是外面这层循环:以前是链替它规划好每一步(或者像第 10 章那样,你手写循环伺候它),现在是它自己看着办。学界给这类系统总结了规划、工具使用、行动执行、记忆管理四大能力,你不用背——规划就是「决定下一步」,记忆在 第 09 章 已经见过(create_agent 也有个 checkpointer 参数可以把记忆挂上),本章把前两样打通。
1.1 和链的分界线
前面十章我们一直在拼链:检索器 | 提示词 | 模型 | 解析器。链和 Agent 不是替代关系,而是分工关系:
| Chain(链) | Agent(智能体) | |
|---|---|---|
| 执行路线 | 开工前焊死:A → B → C,一步不差 | 跑一步看一步,下一步现场决定 |
| 用不用工具、用哪个 | 代码里写死 | 模型自己判断 |
| 循环 | 没有,一遍过 | 有,直到模型给出最终答案 |
| 结果不对时 | 回来改代码 | 模型看工具返回,自己纠偏重试 |
| 成本与速度 | 一次模型调用,快、便宜、可预测 | 可能多轮调用,慢、贵、有悬念 |
| 典型场景 | RAG 问答、固定格式抽取 | 多工具协作的开放任务 |
一条实用的判断标准:这个任务的步骤能不能在写代码时就数清楚。「读者问营业时间 → 检索公告 → 生成答案」,三步焊死,用链;「帮读者查书、算滞纳金、看看读书会赶不赶得上」,查完书才知道算什么、算完钱才决定推不推荐周五活动——步骤数不清,用 Agent。
1.2 ReAct:转圈的工作法
Agent 内部那个循环,业界最经典的描述叫 ReAct——Reason(推理)+ Act(行动)。说穿了就是你做菜尝咸淡的过程:
加一勺盐(行动)→ 尝一口(观察)→ 不够咸?再加盐(思考后再行动)
→ 咸淡正好?出锅(最终答案)
换成 Agent 的语言,一圈是这样转的:
- 思考:模型看着当前的消息列表,判断还缺什么信息;
- 行动:缺什么就发一次工具调用(
tool_calls); - 观察:框架执行工具,把结果作为
ToolMessage追加回消息列表; - 回到第 1 步,直到模型认为信息齐了,输出不带
tool_calls的最终回答——循环结束。
眼熟吗?这就是第 10 章你手写的那个循环,一步都没多。ReAct 早期的实现靠提示词哄着模型输出 Thought: ... Action: ... 这种固定格式的文本,再拿正则去抠;现在的模型(包括 Ollama 里的 qwen3)原生支持工具调用,tool_calls 是结构化字段,不用再靠正则猜。思想还是那个思想,接口早就鸟枪换炮了。
常见坑:
- 把 Agent 神化。它不是更强的模型,而是同一个模型加了循环和工具。模型答不对的问题,包成 Agent 一样答不对——先怀疑工具描述和提示词,再怀疑人生。
- 为了炫技上 Agent。能用链解决的别上 Agent:链更快、更便宜、行为更可预测。Agent 的多轮循环意味着一次提问可能触发三五次模型调用,延迟和 token 都翻着倍涨。
- 术语打架。「Agent」在别处还指代理服务器(网络的中间人),跟本章的「智能体」半毛钱关系没有。看旧资料时先分清场合。
2. create_agent:从手动挡换自动挡
作用:一个函数把第 10 章的三件事全包了——给模型绑工具、跑「决策 → 执行 → 回填」的循环、在模型给出最终答案时收尾。你只需要提供三样原料:模型、工具、一句系统提示词。
签名:
from langchain.agents import create_agent
agent = create_agent(
model, # 模型:ChatOllama 实例(或模型字符串 "ollama:qwen3:4b")
tools, # 工具列表:@tool 装饰的函数;裸函数也收(docstring 当描述)
system_prompt="...", # 系统提示词:Agent 的人设与规矩
)
完整签名还有一长串关键字参数(middleware、checkpointer、response_format、debug……),本章用前三个就能干活,其余遇到再查。返回值是一个编译好的 LangGraph 图(CompiledStateGraph)——不理解也没关系,把它当成「一个自带循环的特殊 Runnable」就行,照样 .invoke()。
第 10 章手写循环 ↔ 本章的对照表,换挡点一目了然:
| 第 10 章(手动挡) | create_agent(自动挡) |
|---|---|
model.bind_tools(tools) |
框架在图里自动绑定 |
while True: 判断还有没有 tool_calls |
图的循环边,框架判断 |
手动执行工具、包 ToolMessage、塞回列表 |
工具节点代劳 |
| 自己决定何时退出 | 模型不再发 tool_calls 时框架收尾 |
最小示例:
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
@tool
def get_store_info() -> str:
"""查询拾光书屋的营业时间与借阅规则。"""
return "营业时间:每天 10:00-22:00;会员一次最多借 5 本,借期 14 天。"
model = ChatOllama(model="qwen3:4b", temperature=0, reasoning=False)
agent = create_agent(
model,
[get_store_info],
system_prompt="你是拾光书屋的店员,回答读者的问题;需要店内数据时必须调用工具,不要凭空编造。",
)
result = agent.invoke({"messages": [{"role": "user", "content": "书屋几点关门?"}]})
print(result["messages"][-1].content) # 最终答案:最后一条 AIMessage 的正文
注意输入输出形状:进是 {"messages": [...]},出是 {"messages": [...], "structured_response": ...}——两边都不是裸字符串。structured_response 要配合 response_format 参数(结构化输出)才有值,本章不用它,拿到手永远是 None。
常见坑:
- 裸字符串进不去。
agent.invoke("几点关门?")会直接报InvalidUpdateError: Expected dict——必须包成{"messages": [{"role": "user", "content": "几点关门?"}]}。消息用 dict 形式、("user", "几点关门?")元组形式都可以。 - 返回值不是答案本身。
agent.invoke(...)给的是一个 dict,直接print(result)只会看到一堆消息对象;答案取result["messages"][-1].content。 system_prompt别省。不写的话,模型对「该不该调工具」的判断会相当随意——qwen3:4b 有时不调工具直接编一个营业时间。人设 + 「必须调用工具」的硬规矩,是 Agent 稳定干活的地基。- qwen3 的思考标签。老朋友了:
ChatOllama(..., reasoning=False)关掉<think>,不然决策文本里混着思考过程。 - 认不出旧教程的 API。JS 版教程里的
createReactAgent+AgentExecutor两件套、Python 老教程的initialize_agent,都是上一代写法;LangChain 1.x 的正门就是create_agent——agent 和执行器合并成了一个函数,Hub 拉提示词那步也省了(system_prompt参数直接给)。看到旧代码心里有数即可,新项目别再照抄。 tools传空列表(或None)不报错,但图里没有工具节点、没有循环——退化成「单发模型」,Agent 名存实亡。
3. 读懂返回值:messages 就是行车记录仪
比喻:invoke 是一脚油门跑完全程,返回的 messages 是行车记录仪的回放。读者提问、模型每次调了什么工具、工具回了什么、模型最后怎么总结——一趟行程的每一脚油门刹车全在里面。调 Agent 的第一件事,就是把这段回放打印出来看。
一次带工具调用的运行,result["messages"] 长这个样(四种消息接力):
HumanMessage # 读者的原始提问
AIMessage # 决策:正文可能只有一句,重点在 tool_calls 属性里
ToolMessage # 观察:工具的执行结果
AIMessage # 最终答案:不再带 tool_calls,循环到此为止
两个关键字段要认识:
AIMessage.tool_calls:一个列表,每项是{"name": 工具名, "args": 参数字典, "id": 调用编号}。它是 AIMessage 的属性,不是独立的消息类型——模型一次可以发多个调用(并行工具调用)。ToolMessage.tool_call_id:和上面那个id配对,说明「这是哪次调用的结果」。ToolMessage.name则是工具名,框架自动填。
打印决策路径的固定写法(demo 里同款):
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
def print_trace(messages: list[BaseMessage]) -> None:
"""把一次 agent 运行的完整决策路径打印出来。"""
for m in messages:
if isinstance(m, AIMessage) and m.tool_calls:
for call in m.tool_calls:
print(f"[决策] 调用 {call['name']}({call['args']})")
elif isinstance(m, ToolMessage):
print(f"[观察] {m.name} 返回:{m.content}")
else:
print(f"[{type(m).__name__}] {m.content}")
常见坑:
- 别自己造 ToolMessage。工具函数只管返回字符串(或可序列化对象),包成
ToolMessage、配tool_call_id都是框架的活。你的工具返回str,剩下的不用管。 messages[-1]一般就是最终答案,但更稳的写法是「找最后一条tool_calls为空的 AIMessage」——万一将来加了后处理节点,末尾就不一定是模型的消息了。invoke只有回放,没有直播。所有消息都是跑完一次性给你的;想边跑边看每一轮决策(直播),用流式和事件回调——这正是 第 12 章的主角,这里埋个钩子。
4. 书屋小助手:多工具协作实战
比喻:一位新店员上岗,柜台后挂着四张工具卡。顾客一口气抛来三个问题(「书还有吗、滞纳金多少、周五活动几点」),店员不会傻站着一次全答——他一会儿伸手抽「查库存」的卡,一会儿抽「算费用」的卡,抽一张、看一眼结果、再决定抽下一张。你要做的只是把四张卡挂上去,剩下的交给他。
作用:把多个工具交给一个 create_agent,用一个开放式的复合任务检验它——观察模型怎么自主拆解任务、怎么决定调用顺序。
工具还是第 10 章的写法(@tool + 类型标注 + docstring),四个全是本地纯函数,没有任何外部依赖:
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
# 书屋的「数据库」,全部硬编码(同款数据在 demo 里)
BOOKS: dict[str, dict] = {
"夜航船": {"category": "古籍", "stock": 3, "note": "张岱的类书式笔记,条目短小"},
"山月记": {"category": "小说", "stock": 0, "note": "中岛敦短篇,一晚读完"},
"小王子": {"category": "童话", "stock": 5, "note": "亲子共读常客"},
}
@tool
def search_books(keyword: str) -> str:
"""按书名、类别或主题关键词搜索拾光书屋的在库图书,返回书名、库存与一句推荐语。"""
hits = [name for name, info in BOOKS.items() if keyword in name or keyword in info["category"]]
if not hits:
return f"没有找到与「{keyword}」相关的书。"
return "\n".join(f"《{n}》({BOOKS[n]['category']},库存 {BOOKS[n]['stock']} 本)" for n in hits)
@tool
def calculate_late_fee(overdue_days: int, book_count: int) -> str:
"""计算会员逾期还书的滞纳金。书屋规定:逾期每本书每天收取 0.5 元。
Args:
overdue_days: 逾期天数
book_count: 逾期未还的书籍数量
"""
if overdue_days < 0 or book_count < 0:
return "参数不合法:逾期天数和书籍数量都必须是非负整数。"
return f"滞纳金共 {overdue_days * book_count * 0.5:.1f} 元({book_count} 本 × {overdue_days} 天 × 0.5 元)。"
model = ChatOllama(model="qwen3:4b", temperature=0, reasoning=False)
agent = create_agent(
model,
[search_books, calculate_late_fee], # 实际 demo 里还有 get_store_info / get_member_events
system_prompt=(
"你是拾光书屋的店员。回答读者问题需要店内数据时必须调用工具,不要编造;"
"读者问了多个问题时,依次回答,别漏项。"
),
)
question = "《夜航船》还有吗?另外我上次借的 3 本书逾期 5 天了,要交多少滞纳金?"
result = agent.invoke({"messages": [{"role": "user", "content": question}]})
print_trace(result["messages"]) # 第 3 节的打印函数
print(result["messages"][-1].content) # 最终答案
跑起来你会看到一条典型的决策路径(示意):
[HumanMessage] 《夜航船》还有吗?另外我上次借的 3 本书逾期 5 天了,要交多少滞纳金?
[决策] 调用 search_books({'keyword': '夜航船'})
[观察] search_books 返回:《夜航船》(古籍,库存 3 本)
[决策] 调用 calculate_late_fee({'overdue_days': 5, 'book_count': 3})
[观察] calculate_late_fee 返回:滞纳金共 7.5 元(3 本 × 5 天 × 0.5 元)。
[AIMessage] 《夜航船》还有 3 本;滞纳金 7.5 元,下次记得按时还~
两个值得盯着看的细节:
- 任务是被模型自己拆开的。我们没有写任何「先查书、再算钱」的代码——顺序是模型读题后自己定的。甚至两个工具谁先谁后都可能换:这正说明「动态决策」四个字不是白叫的。
- 每一步都踩在工具的真实返回上。库存 3、滞纳金 7.5,全是工具算出来的数字,模型只负责组织语言。数据错了改工具,话术错了改
system_prompt——排错时分得清清楚楚。
完整版(四个工具 + 备货库存 + 活动日程)在 demo/demo_agent.py 里,一条龙可跑。
常见坑:
- 复合问题漏答。一次问三件事,小模型偶尔只答两件——要么像上面那样在
system_prompt里写死「依次回答,别漏项」,要么把任务拆成多次invoke。 - 模型不调工具、张口就编。八成是工具描述没写到点子上(下节细说),或者问题太泛(「推荐本书」)让模型觉得不用查。先把描述写细,再把提示词的「必须调用工具」加上。
temperature压到 0。决策类任务要的是老实,不是创造性——温度一高,同一问题每次走的工具路径都可能不一样,排查起来想哭。
5. 工具设计的三个细节:说明书、粒度、报错方式
第 10 章已经教了工具的「形」(@tool + 类型标注 + docstring);Agent 时代,工具设计直接决定 Agent 的「命」。三个细节,个个都是踩过坑才知道的。
5.1 描述是写给模型看的说明书
比喻:工具柜抽屉上的标签。标签只写一个「东西」俩字,没人敢开;写上「电池(5 号 / 7 号,全新)」,伸手就拿对了。模型选工具时唯一的依据就是工具名、参数名和那段描述——它看不到你的函数体,猜不到你的心思。
反例与正例:
# 反例:模型不知道什么时候该用它、参数填什么
@tool
def search(data: str) -> str:
"""搜索"""
...
# 正例:干什么用、吃什么参数、返回什么,一句话交代齐全
@tool
def search_books(keyword: str) -> str:
"""按书名、类别或主题关键词搜索拾光书屋的在库图书,返回书名、库存与一句推荐语。"""
...
写描述的三个自查项:什么时候用(「回答店内图书相关问题时」)、参数怎么填(「keyword 是书名或类别关键词」)、返回什么(「书名、库存与推荐语」)。参数复杂时,就在 docstring 里用 Args: 段落逐个说明(上面 calculate_late_fee 的写法)——这些文字会原样进入参数 schema,模型看得见。
5.2 粒度:一个工具干一件事
比喻:厨房刀具。一把瑞士军刀包打天下,结果每样都费劲;满墙挂三十把专业刀,光认刀就累死人。家用厨房的正确答案是三四把顺手的。
放到书屋场景:
- 太粗:
book_store_do_everything(action: str, params: str)——模型得先学会「填表」,action写成"search"、"查询"、"找书"都得你的代码兜着,参数一复杂就填错,错了还不好重试; - 太碎:
search_book_by_name/search_book_by_category/search_book_by_author各一个——模型光在三个兄弟工具里挑就要多想半天,还容易挑错; - 刚好:一个
search_books(keyword),书名、类别、主题一把抓。
拿捏的口诀:一个工具对应店员的一句口头禅(「帮我查下书」「算下滞纳金」「看下这周活动」)。口头禅数得出来,工具数量就刚好。
5.3 报错:返回字符串,别抛异常
比喻:后厨暗号。店员朝仓库喊「还有《夜航船》吗」,仓库要是直接摔门(抛异常),整个店当场停摆;回一句「没货了,下周补」(返回字符串),店员立刻改口推荐别的书,顾客毫无感知。
这不是风格建议,是 LangChain 1.x 的实测行为——工具报错分两条路,命运天差地别:
- 参数不合法(枚举外取值、类型对不上、必填项缺失):框架兜底。错误被转成一条带说明的
ToolMessage回给模型,模型看到后自己修正参数重试——整个过程 agent 不崩,像打了个喷嚏继续跑。 - 函数内部抛异常(
raise ValueError("库存为 0")这类业务错误):默认直接击穿整个 agent,invoke当场抛异常,前面跑的几步全白费。
所以业务错误要自己接住、翻译成模型能看懂的字符串:
@tool
def borrow_book(title: str) -> str:
"""为会员办理借书,成功与否都会返回说明。"""
if BOOKS.get(title, {}).get("stock", 0) <= 0:
# 不抛异常!把失败原因说清楚,模型自己会想下一步(换书/预约)
return f"借书失败:《{title}》库存为 0,可以试试预约,或让我推荐同类书。"
BOOKS[title]["stock"] -= 1
return f"借书成功:《{title}》,请在 14 天内归还。"
错误字符串里最好带上出路(「可以试试预约」「让我推荐同类书」)——模型读到出路,下一轮就会真的沿着出路走;只给一句「失败」,它多半原样转述给用户,场面一度尴尬。
常见坑:
- 描述里写实现细节。模型不关心你连了哪个数据库、用了什么缓存——它只需要「干什么用、参数怎么填」。废话越多,重点越糊。
- 错误字符串太含糊。返回一个
"error",模型猜不出发生了什么,要么复读要么瞎编。把「哪里错、怎么办」写进去。 - 把参数校验也自己写。参数合法性交给框架的 schema 校验就行(那条路有兜底、能自愈);你只处理「参数合法但业务不成立」的情况(库存为零、日期已过)。
- 用
print调试工具。工具在循环里被反复调用,print 满天飞还看不出轮次——用第 3 节的print_trace打印整条路径,一眼看清哪一轮、哪个工具出了问题。
6. 本章小结
一张速查表带走本章:
| 需求 | 用法 |
|---|---|
| 创建 Agent | create_agent(model, tools, system_prompt=...)(from langchain.agents) |
| 调用 | agent.invoke({"messages": [{"role": "user", "content": ...}]}) |
| 拿最终答案 | result["messages"][-1].content |
| 看决策路径 | 遍历 result["messages"],AIMessage 看 .tool_calls,ToolMessage 看 .content |
| 工具定义 | @tool + 类型标注 + docstring(描述写「何时用、参数怎么填、返回什么」) |
| 工具粒度 | 一个工具对应店员的一句口头禅 |
| 工具报错 | 业务错误返回描述性字符串(带出路),别 raise;参数错误交给框架自愈 |
| 挂记忆 | create_agent(..., checkpointer=...)(第 09 章的方案) |
配套 demo(本章 demo/ 目录):
demo_agent.py:单文件书屋小助手——四个本地纯函数工具(查书 / 门店信息 / 滞纳金计算 / 活动查询),create_agent组装后用一个「三连问」任务跑完整决策路径并逐条打印;另附「描述好坏对照 + 错误返回字符串」的演示。需要本机 Ollama 已启动并拉取qwen3:4b;连不上时自动降级为「打印工具定义与 agent 构造代码」,文件头部注释写了全部前提。
到这里,拾光书屋的应用已经会自己拿主意了。但 invoke 跑完只能看回放——Agent 内部转了几圈、每圈调了什么、token 花在哪,跑的过程中一片安静。下一章我们给车装上行车记录仪的「实时画面」:回调与调试。
下一章:12 · 回调与调试