LangChain 的 Agent 模块一直被当作“让大模型调用工具”的入口,但很多人只记住了怎么用现成工具,没想清楚它底层的执行方式。一旦自己写一个自定义工具,并且这个工具在运行中报错,整个调用链就会从一次简单的问答变成一个多轮循环:模型先基于用户问题做推理,选出一个行动,执行工具后拿到反馈,再把反馈交给模型做下一次推理。如果问题没有得到解决,这个循环会继续,直到模型给出最终答案,或者 Agent 的迭代次数被强制截断。今天这篇文章就用“自造工具不可用”这个例子,把 LangChain Agent 的推理-行动-反馈循环完整地拆开,看看这个循环是如何跑起来、如何犯错、如何被限制的。
这篇文章适合刚接触 LangChain、想自定义工具但还没完全理解 Agent 运行机制的开发者。学完后,你不光能写自己的工具,还能在工具失败时快速判断问题出在推理环节、工具执行环节,还是 Agent 配置环节。如果以后要往 LangGraph、多 Agent 协作方向发展,这篇文章里的“循环”理解也能直接迁移过去。
1. 推理-行动-反馈循环:Agent 看似聪明,本质是循环决策
1.1 为什么工具失败反而能放大 Agent 的底层机制
很多教程都告诉你“用 @tool 装饰一个函数,Agent 就能调用它”。这句话给初学者留下一个错觉:调用工具是自动发生的,就像普通函数调用一样。实际上,Agent 并不是把用户的提问直接发给你写的函数,而是先由大模型理解问题,再生成一段“思考文本”,这段文本里会包含一个工具名和一个输入参数,LangChain 再把这段文本解析出来,去调用对应工具。
当工具正常返回时,循环不明显。模型调用工具,拿到结果,说一句“答案是 128”,整个过程看起来就像一次问答。但当工具不可用时,也就是函数内部抛出异常,或返回一段错误信息,模型的下一轮推理就会被迫出现。这个被误认为是“出错”的瞬间,恰好是整个 Agent 机制最容易观察的地方:
- 模型必须面对一段失败反馈。
- 模型不能简单地假装问题已经解决。
- 模型必须重新思考,选择是否换一个工具、换一个参数,还是直接给出最终答案。
换句话说,工具失败不是 Agent 的 bug,而是理解 Agent 循环的最佳调试窗口。
1.2 循环的最小组成单元
ReAct 模式把 Agent 的一轮执行拆成三个核心节点:
- 推理:模型根据用户问题和已有的上下文,写出当前判断,通常是“Thought: 我需要先查询库存”。
- 行动:模型指定要调用的工具和参数,格式通常是“Action: inventory_query”和“Action Input: {"sku": "ABC123"}”。
- 反馈:工具执行后返回结果。结果可能是正常数据,也可能是错误文本,LangChain 会把这段结果作为 Observation 放回上下文。
一轮推理-行动-反馈结束后,如果模型还拿不出最终答案,它就会再次进入推理环节。这就是循环。理解这个结构后,再看 LangChain 里那些提示词模板、AgentExecutor 配置,思路会清晰很多。
| 循环节点 | 常见格式 | 作用 |
|---|---|---|
| 推理 | Thought: ... | 让模型解释当前判断 |
| 行动 | Action: tool_name / Action Input: {...} | 指定要调用哪个工具及参数 |
| 反馈 | Observation: result | 把工具执行结果交回模型 |
| 终止 | Final Answer: ... | 模型认为可以回答原始问题 |
这里的“反馈”不是简单的成功回传。Observation 里可以放正常返回值,也可以放异常信息、错误提示、超时提示。模型不会自动区分“这是正确答案”还是“这是报错”,它只会把这段文本当成新的上下文继续推理。所以,工具开发者如果想控制 Agent 的行为,核心工作就变成了“设计反馈文本”。
2. 准备实验环境:版本、模型和项目结构
2.1 安装 LangChain 及配套依赖
现阶段 LangChain 的 API 变化很快,网上很多老代码用的是initialize_agent,这类接口在新版本里已经很难直接使用。下面示例按 LangChain 0.2.x 的常见写法给出。落地前,先确认你要用的版本,再决定代码怎么写。
建议使用虚拟环境,避免把不同项目的依赖混在一起:
python -m venv .venv source .venv/bin/activate然后安装依赖:
pip install --upgrade pip pip install 'langchain>=0.2,<0.4' langchain-core langchain-openai langchain-ollama python-dotenv| 依赖 | 用途 |
|---|---|
| langchain | Agent、工具调用等核心编排能力 |
| langchain-core | BaseTool、PromptTemplate 等基础抽象 |
| langchain-openai | 调用 OpenAI 兼容 API |
| langchain-ollama | 调用本地 Ollama 模型 |
| python-dotenv | 从 .env 文件加载密钥 |
如果使用的是钉钉、智谱、DeepSeek 等国内模型平台的 OpenAI 兼容接口,langchain-openai也能通过base_url指向对应地址,并不一定非要langchain-ollama。
2.2 选择模型:本地 Ollama 或云 API
演示 Agent 循环需要一个真正支持函数调用的模型,但模型能力不必很强。本地 Ollama 是最容易起步的方式。先安装 Ollama,然后拉取一个中文能力较好的模型:
ollama pull qwen2.5:7b代码里用ChatOllama连接:
from langchain_ollama import ChatOllama llm = ChatOllama(model="qwen2.5:7b")如果使用云厂商的 OpenAI 兼容接口,则用ChatOpenAI并配置环境变量:
import os from langchain_openai import ChatOpenAI llm = ChatOpenAI( model=os.getenv("LLM_MODEL", "qwen-plus"), api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL", "https://api.example.com/v1"), )这里能看到一个很关键的取舍:本地模型可以反复调试,出错成本低,但生成格式可能不稳定;云 API 更稳定,但连续循环会明显增加 token 消耗。所以本文推荐先在本地跑通,再换云模型验证。
注意:不同模型的 ReAct 文本生成格式存在差异。如果模型输出的 Action Input 格式不标准,LangChain 解析时会报错,这个问题会在第 6 节重点排查。
2.3 准备一个最小的项目结构
实验项目只需要两个文件:
agent_debug/ ├── .env # 存放 API Key 等环境变量 ├── main.py # Agent 与工具代码 └── requirements.txt # 依赖清单把依赖写进requirements.txt,防止以后升级时需要翻历史记录:
langchain>=0.2,<0.4 langchain-core langchain-ollama langchain-openai python-dotenv如果只用 Ollama 本地模型,langchain-openai可以暂时不装。
3. 故意制造一个不可用的自定义工具
3.1 用 @tool 声明一个业务函数给 Agent
先创建一个正常工作的“商品信息查询”工具,用于对比。这个工具返回一段结构化文本,逻辑非常简单:
from langchain_core.tools import tool @tool def product_lookup(sku: str) -> str: """查询指定 SKU 的商品基础信息,包括商品名、分类和品牌。""" return f"SKU {sku} 对应商品名:LangChain 实战手册,分类:技术图书"这里有几个关键点:
- 函数名
product_lookup会成为工具名,Agent 在生成 Action 时必须使用这个名字。 - 参数
sku会被 LangChain 自动解析成工具的输入 schema。 - 函数注释就是工具的 description。模型主要依靠这段描述来决定“什么情况下该调用这个工具”。
再创建一个“库存查询”工具。这个工具的代码里故意加了一个环境变量开关,模拟外部库存服务不可用的情况:
import os def _call_inventory_api(sku: str) -> str: if os.environ.get("INVENTORY_FORCE_ERROR") == "1": raise ConnectionError(f"inventory service unavailable, sku={sku}") return f"SKU {sku} 当前库存数量:128" @tool def inventory_query(sku: str) -> str: """查询指定 SKU 的实时库存数量,只有业务系统才能返回准确结果。""" return _call_inventory_api(sku)3.2 让工具失败:抛异常与返回错误信息是两条路
上面代码里,_call_inventory_api在条件满足时直接抛异常。工具内部抛异常后,LangChain 会根据版本和 AgentExecutor 配置,把异常包装成错误文本,或者直接中断流程。为了让行为可控,生产环境中更推荐让工具返回错误信息,而不是抛出异常:
@tool def inventory_query(sku: str) -> str: """查询指定 SKU 的实时库存数量,只有业务系统才能返回准确结果。""" if os.environ.get("INVENTORY_FORCE_ERROR") == "1": return "ERROR: inventory service unavailable" return f"SKU {sku} 当前库存数量:128"这条路径的差异很重要:
| 实现方式 | 反馈给模型的内容 | 适用场景 |
|---|---|---|
| 抛异常 | 由框架按策略处理,可能变成错误文本 | 适合测试框架错误处理,不适合直接给模型看 |
| 返回错误字符串 | 错误直接成为 Observation | 可预期、可日志记录,生产环境更推荐 |
| 返回 JSON 结构 | 模型容易提取错误码和消息 | 需要后续代码根据错误码做判断时更合适 |
本文演示选择抛异常,因为它的不可用感更强,也更能看出 Agent 的反馈环节。理解后再切换成返回错误字符串,你就能清楚两种设计的差别。
3.3 先单独验证工具本身,别急着接 Agent
直接接 Agent 调试时,问题会混在一起:你很难分清是工具代码错了,还是 Agent 没有正确解析模型输出。稳妥做法是先单独调用工具:
# 单独验证工具 print(inventory_query.invoke({"sku": "ABC123"}))这里要注意一点:@tool生成的对象可以直接用invoke调用,参数保持 dict 形式。可以先不加INVENTORY_FORCE_ERROR,看到正常返回值:
SKU ABC123 当前库存数量:128再模拟失败:
INVENTORY_FORCE_ERROR=1 python main.py如果抛异常,你会在控制台看到ConnectionError。这时再继续组装 Agent,就能确定“工具本身确实报错”,而不是 Agent 配置问题。
4. 组装最小 Agent,观察循环如何跑起来
4.1 使用 ReAct 提示词构建 Agent
LangChain 中常见做法是使用create_react_agent配合一段带{tools}、{tool_names}、{input}、{agent_scratchpad}的提示词。agent_scratchpad是核心变量,它保存前面已经执行过的推理、行动、反馈内容,让模型在做下一轮思考时能看到自己刚才的行动过程。
下面这段提示词是从 ReAct 的经典格式简化来的:
from langchain.agents import create_react_agent, AgentExecutor from langchain_core.prompts import PromptTemplate prompt = PromptTemplate.from_template( """你是一个电商库存助手。请使用下面的工具回答问题。 你可以使用这些工具: {tools} 请严格按以下格式输出: Question: 用户输入的问题 Thought: 你应该先思考要做什么 Action: 要调用的工具名,只能是 [{tool_names}] 中的一个 Action Input: 调用工具时输入的参数,必须是 JSON 格式 Observation: 工具返回的结果 Thought: 根据结果继续思考 ... 可以重复多轮 Thought: 我现在知道最终答案了 Final Answer: 对原始问题的最终回答 开始。 Question: {input} Thought: {agent_scratchpad}""" ) tools = [inventory_query, product_lookup] agent = create_react_agent(llm=llm, tools=tools, prompt=prompt) executor = AgentExecutor( agent=agent, tools=tools, verbose=True, handle_parsing_errors=True, max_iterations=5, )这里每个参数都有明确目的:
verbose=True会把 Agent 每一步的思考文本显示在控制台,是观察循环的最重要开关。handle_parsing_errors=True允许模型输出格式不符合 ReAct 模板时,把解析错误文本返回给模型,让模型重新修正。这个设置也会推动循环继续,而不是直接崩溃。max_iterations=5是循环的安全阀。没有它,碰到一个反复失败的问题时,模型可能会无限循环,白白消耗 token。
4.2 带着“坏工具”执行一次查询
现在执行一个看起来非常简单的问题:
result = executor.invoke({"input": "SKU ABC123 目前还有多少库存?"}) print(result["output"])这次执行不是从 Agent 里去掉工具,而是把失败开关打开。运行命令:
INVENTORY_FORCE_ERROR=1 python main.py当verbose=True时,终端会打印类似下面的过程。不要把它看成普通日志,这是 Agent 完整的推理轨迹:
> Entering new AgentExecutor chain... Thought: 用户需要查询库存数量,我需要先调用库存查询工具。 Action: inventory_query Action Input: {"sku": "ABC123"} Error: ConnectionError: inventory service unavailable, sku=ABC123 Thought: 库存接口报错了。库存服务可能暂时不可用,但商品本身可能仍然存在,我先查一下商品信息。 Action: product_lookup Action Input: {"sku": "ABC123"} Observation: SKU ABC123 对应商品名:LangChain 实战手册,分类:技术图书 Thought: 商品存在,但库存服务当前不可用,无法返回具体数量。 Final Answer: SKU ABC123 的商品存在,但库存服务当前不可用,无法返回库存数量。 > Finished chain.打印的具体文案会因模型和版本不同而变化,但关键结构一致:第一轮工具失败后,Agent 没有直接终止,而是把错误作为 Observation 再次送给模型,模型继续推理,并决定换一个工具。
4.3 对照组:关闭失败开关,循环明显缩短
为了确认循环不是因为代码写错才多跑几轮,可以关闭失败开关再执行一次:
python main.py正常情况下输出会变短,模型调用一次库存工具就给出答案,不再查询商品信息。对比两种输出,你能直观看到“不可用工具”如何把循环次数从 1 轮推到 2 轮甚至 3 轮。循环不是语言模型自己开启的,而是“模型生成文本 -> 执行工具 -> 反馈回上下文 -> 模型再次生成文本”这个结构天然产生的。
5. 逐行拆解执行轨迹,认识循环的边界
5.1 第一个 Thought:模型在推理,不是在命令
执行轨迹的第一行:
Thought: 用户需要查询库存数量,我需要先调用库存查询工具。很多初学者会把这行当成“模型在自言自语”,其实它是 Agent 循环的第一阶段。大模型是一个文本生成器,它需要先“生成”一个计划,再“生成”行动指令。如果没有 Thought,模型就缺少把用户问题映射到工具选择的中间步骤。
站在工程角度看,Thought 文本是很有价值的调试信息。如果 Agent 始终不调用你希望的工具,第一件事不是改 Agent 代码,而是看它的 Thought 是否提到了你的工具描述。如果模型根本没理解工具用途,那就要调整工具的 description。
5.2 工具报错后的 Observation:反馈不只是正常结果
轨迹里最醒目的一段是:
Error: ConnectionError: inventory service unavailable, sku=ABC123这段内容之所以能触发模型重新推理,是因为 LangChain 把工具执行结果原样放回了agent_scratchpad。模型能看到“我刚才调用了inventory_query,它的结果是 ConnectionError”。于是第二轮推理里出现了“库存接口报错了,可以先查商品信息”的判断。
这正是把“推理-行动-反馈”称为循环系统的原因:
- 第一次反馈是库存服务不可用。
- 模型把反馈当成新的上下文,继续推理。
- 第二次行动是查询商品信息。
- 第二次反馈是商品存在。
- 模型判断已经拿到足够信息,输出 Final Answer。
如果第一次反馈是正常库存数字,循环会在第二轮直接结束。如果第一次反馈是一堆乱码,模型大概率会再尝试调用一次同一个工具,或者干脆告诉用户“输入有误”。反馈文本的质量,直接决定循环的质量。
5.3 Final Answer:循环的退出条件
模型输出 Final Answer 后,AgentExecutor 会停止继续调用 LLM 和工具,把该字段作为最终结果返回。循环不是永远转下去,它有两个常见退出条件:
- 模型认为已经可以回答用户问题,主动输出 Final Answer。
- 循环次数达到
max_iterations,AgentExecutor 强制终止。
max_iterations是一个非常实用的参数。实际项目中,如果设置过小,复杂任务会在还没拿到关键信息时就被截断;如果设置过大,模型在失败反馈中陷入反复尝试时,会产生大量 token 消耗。建议先设一个较小值观察行为,再根据任务复杂度逐步加大。
注意:
max_iterations限制的是 Agent 循环的完整轮数,不是 LLM 调用次数。每轮推理都会调用 LLM,一轮工具执行也算一次循环。调试前最好先看官方文档确认版本对这组参数的解释。
5.4 工具成功与失败时,循环的差异
用一个简单表格总结上面的观察:
| 场景 | Thought 轮数 | 工具调用 | Observation | 最终输出 |
|---|---|---|---|---|
| 工具可用 | 1 | inventory_query | 库存数量 128 | 直接给出库存 |
| 工具不可用 | 至少 2 | inventory_query 后换 product_lookup | 错误后商品信息 | 说明库存服务不可用 |
| 工具反复失败且无其他工具 | 多次 | 同一工具反复调用 | 持续错误 | 被 max_iterations 截断或最终输出错误说明 |
第三个场景非常容易测试:上面的示例里如果把product_lookup也改成失败,模型就会在同样的工具上来回尝试。这就是那些“Agent 卡住了”现象的本质,不是模型卡住了,而是循环没有外部手段让它收敛。
6. 常见问题排查:工具不被调用、异常被吞掉、重复调用
6.1 工具没有被调用
现象:Agent 最终直接回答“我是一个语言模型,无法查询库存”,但控制台完全没有出现 Action 输出。
可能原因:
- 工具的 description 不清晰,模型不知道什么情况该调用它。
- 提示词模板里没有正确渲染
{tools},Agent 根本看不到工具。 - 模型没有经过指令微调,不擅长输出 ReAct 格式。
检查方式:
print(prompt.format(input="SKU ABC123 目前还有多少库存?", tools=tools, tool_names=", ".join([t.name for t in tools]), agent_scratchpad=""))看控制台输出里是否出现工具名和描述。如果工具名没有出现在 prompt 中,问题在提示词模板,不是模型能力问题。
处理建议:
- 加强 description,明确“什么时候调用、参数有什么用、返回什么”。
- 换一个能力更强的模型再试。
- 调低
temperature,避免模型自由发挥格式。
6.2 工具抛异常后 Agent 整体崩溃
现象:工具内部raise ConnectionError后,程序直接报错退出,而不是继续循环。
常见原因:
- 某些 LangChain 版本里,工具异常需要显式配置
handle_tool_error。 - 使用了不兼容的自定义 BaseTool 子类,错误没有被统一捕获。
检查方式:
- 查看堆栈异常是否指向 AgentExecutor 的
_execute_tool方法。 - 检查工具是否用
@tool正常声明,没有被 try/except 提前吞掉。
处理建议:
- 先改成“返回错误字符串”的方式,让错误直接成为 Observation,这是最可控的路径。
- 如果必须抛异常,再研究当前版本的
handle_tool_error参数。
6.3 同一个失败工具被反复调用,直到 max_iterations
现象:模型反复调用inventory_query,每次都得到同样的错误,但还是不放弃。
原因:模型只看到了“工具返回错误”的文本,没有看到新的线索,所以它可能认为换个参数或者重试一次会成功。
处理建议:
- 给错误反馈里补充更明确的提示,例如“该服务 30 秒内不可重试”。
- 增加一个可用的备份工具,让模型有别的路径可走。
- 设置
max_iterations=3之类的较小上限,避免无限消耗 token。
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 工具没被调用 | 提示词没渲染工具或 description 不清晰 | 打印格式化后的 prompt | 修模板、改写描述 |
| 异常导致崩溃 | 工具异常没有被 AgentExecutor 捕获 | 看堆栈是否在工具执行阶段 | 改用返回错误字符串 |
| 同一工具反复调用 | 反馈文本没有给模型新信息 | 查看 verbose 日志里的 Observation | 增加更明确的错误提示 |
| 模型输出格式无法解析 | ReAct 模板约束不够 | 看 parsing error 日志 | 打开 handle_parsing_errors,换更强的模型 |
6.4 新版本 API 变化
LangChain 0.3 之后,官方推荐使用新的create_agent,部分旧代码需要迁移。不要直接照抄网上代码,先跑通当前版本的官方示例,再替换自己的工具。版本升级时最容易出问题的三类地方:
BaseTool导入路径变化。handle_parsing_errors行为变化。agent_scratchpad变量是否还需要在 prompt 中声明。
7. 生产环境的 Agent 工具设计:不能只写一个会返回字符串的函数
7.1 让工具失败信息变成结构化反馈
把工具变成不可用是最容易的调试手段,但生产环境需要完全不同的思路。建议工具内部不要直接抛裸异常,而是返回结构化错误:
@tool def inventory_query(sku: str) -> str: """查询指定 SKU 的实时库存数量,只有业务系统才能返回准确结果。""" try: return f"SKU {sku} 当前库存数量:128" except Exception as exc: return f"ERROR: code=INVENTORY_SERVICE_ERROR, message={exc}"这样 Agent 能在 Observation 里看到错误码,外围日志也能把这段文本完整记下来。更重要的是,模型不需要去理解堆栈跟踪,它可以直接在下一轮推理中决定要不要换工具。
7.2 给不稳定工具加重试、限流和降级
Agent 背后的工具不一定是稳定的内部接口,可能是外部 HTTP API、数据库查询或另一个模型调用。对于真实业务系统,至少要考虑:
- 重试:配置指数退避,最多重试 2 到 3 次。
- 限流:控制 Agent 循环里的工具调用频率,避免把下游系统打挂。
- 降级:当主工具失败时,返回缓存数据或一个明确错误码。
这些策略应该放在工具内部实现,而不是放在 Agent 的 prompt 里。因为 prompt 只是文本,不能保证模型遵守;而工具内部逻辑是确定的,能按代码顺序执行。
7.3 从 AgentExecutor 走向 LangGraph 的显式循环
AgentExecutor 帮我们隐藏了很多循环细节,学习和调试都很方便。但它把循环控制都封装在框架内部,在复杂多步骤、需要人工中断、需要并行调用工具的场景里,控制力不够。
LangGraph 是 LangChain 生态里更适合做状态机编排的方案。它可以把 Agent 的 Thought、Action、Observation 建模成节点和边,循环路径一目了然,也方便加入人工审批、错误恢复、条件分支。理解本文的循环机制后,再去看 LangGraph 会轻松很多,因为核心概念仍然是“状态在节点之间流转,每个节点根据反馈决定下一步”。
7.4 可复用的 Agent 工具发布检查清单
发布一个自定义工具进生产环境前,按这个清单逐项检查:
- description 是否说明了触发场景、参数含义和返回结构。
- 工具内部是否捕获了所有第三方异常,并返回可读错误码。
- 是否设置了超时时间,避免 Agent 一直等待一个不响应的服务。
- 是否配置了
max_iterations,防止循环失控。 - 是否开启
verbose=False后仍然有关键日志输出到日志系统。 - Agent 执行时间是否可控,是否记录了每轮 LLM 调用 token 数。
- 是否有对应回滚方案,比如把 Agent 切换回固定工作流。
这个清单可以贴在项目文档里,每次新增工具时逐项勾选。很多 Agent 问题在工具上线阶段就能被发现,而不是等到模型在线上反复调用后才暴露。
工具失败并不可怕,可怕的是无法从反馈文本中看出失败原因。本文用“自造不可用工具”演示的底层逻辑,最终要落地成一句话:Agent 是一个循环系统,Thought 决定方向,Action 执行动作,Observation 提供反馈;作为开发者,你的核心职责不是帮模型思考,而是设计好反馈内容,让它每一轮循环都能得到足够的信息。下一步建议先把自己的业务接口做成工具,再故意注入一个异常,用同样的方法观察循环,这套经验会很快变成你的 Agent 调试直觉。