LangChain自定义工具失灵?拆解ReAct循环定位问题
有一段时间我在学 LangChain 的 Agent,卡在了同一件事上:自己写了个工具,注册进去了,但 Agent 就是不调用它,或者调用了以后又报错,看起来“很不智能”。后来把整个流程拆开看,才发现问题不是模型笨,也不是 LangChain 有 bug,而是我没有真正理解 Agent 底层的执行机制。LangChain Agent 的核心是 ReAct 循环,也就是推理、行动、反馈这三个动作不断重复,直到模型给出最终答案。自定义工具不可用,绝大多数原因,都出在这个循环的某个环节没接上。
1. 先搞清楚问题出现在哪一环:是 Agent 没选工具,还是工具执行完没法继续
1.1 一个最容易复现的失败现场
先看最常见的现象。假设我在本地上写了一个自定义工具,功能很简单,就是返回当前时间:
from datetime import datetime from langchain.tools import tool @tool def get_current_time() -> str: """返回当前的日期和时间。""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S")然后创建一个 ReAct Agent,把这个工具传进去:
from langchain.agents import AgentExecutor, create_react_agent from langchain_openai import ChatOpenAI from langchain import hub llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = hub.pull("hwchase17/react") agent = create_react_agent(llm, [get_current_time], prompt) executor = AgentExecutor(agent=agent, tools=[get_current_time], verbose=True)接着问一句:
result = executor.invoke({"input": "现在几点?"})结果发现 Agent 没有调用 get_current_time,而是直接凭模型记忆给了一个大概时间,甚至可能编造一个时间。这不叫“工具不可用”,而是 Agent 根本没有走工具这条路径。
另一种情况更让人头疼:Agent 选了工具,但你看到日志里出现类似get_current_timeis not a valid tool 的提示,或者参数解析失败,或者执行完工具之后,整个请求卡住,最后报max_iterations超限。
这些问题表面上看都是“自定义工具不好使”,但在 ReAct 循环里,它们分别发生在不同位置。如果不把循环拆开看,就只能靠猜。
1.2 别急着怀疑 Agent 能力,先看执行循环
很多人遇到这种情况,第一反应是换更强的模型。模型能力确实会影响工具选择和参数生成,但在 LangChain 的 Agent 里,模型只是循环里的一个决策节点,不是全部。
一个 Agent 请求的完整路径是这样的:
- 用户输入进入 Prompt,里面带着工具列表、工具描述、工具参数格式。
- 模型输出一段推理和行动指令,LangChain 把这段输出解析成结构化动作。
- 执行器拿着动作去调用对应工具,拿到工具返回结果。
- 返回结果作为“观察”拼回对话历史。
- 再次调用模型,让它根据新的观察继续推理。
- 直到模型输出“最终答案”,循环结束。
这个流程就是 ReAct 的核心:推理与行动交替进行,而不是让模型一次性从输入直接跳到输出。自定义工具不可用,可能在第 2 步模型没选对工具,可能在第 3 步参数对不上,也可能在第 4 步返回值没有被正确传回给模型。
用一个比喻来理解:Agent 像是一个通过“写字条”来安排工作的主管,模型是决策者,工具是具体干活的人。主管只能根据工具清单上的描述来决定派谁去,收到回复后再接着判断。如果你的工具介绍写得模糊,或者干活的人不会把结果正确写回字条,整个协作就断了。
2. ReAct 循环到底是什么:推理、行动、反馈不是三个独立动作,而是一个闭环
2.1 为什么 ReAct 把决策写进了上下文
ReAct 的核心思路是让模型在每一步先输出一段推理,再输出一个动作。这个推理不是给用户看的解释,而是模型给自己看的“当前状态记录”。它能让模型在长任务里不忘记自己在做什么,也能让后续步骤依赖前一步的真实结果,而不是靠想象。
你看 Agent 的底层 Prompt 时,经常会看到类似这样的格式要求:
Question: {input} Thought: {thought} Action: {tool_name} Action Input: {input} Observation: {result} ... (循环) Thought: I now know the final answer. Final Answer: {answer}LangChain 做的事情,就是把这个格式变成一个可以执行的循环。模型只负责生成 Thought 和 Action 相关文本,执行器负责把 Action 翻译成真正的函数调用,再把结果包装成 Observation 还给模型。
这也是为什么不能把 ReAct 理解成“模型调用函数接口”。在 ReAct 里,工具调用不是结构化 API,而是模型和执行器之间的一段文本协议。工具能不能被正确使用,取决于模型能不能生成符合协议的文本。
2.2 LangChain 的 AgentExecutor 里发生了什么
常见 AgentExecutor 的内部流程,用一个简化伪代码可以表达:
def execute(agent, tools, user_input, max_iterations): messages = build_initial_messages(user_input) for i in range(max_iterations): output = agent.llm.invoke(messages) parsed = parse_agent_output(output) if parsed.type == "final_answer": return parsed.answer # 找到对应的工具 tool = find_tool(tools, parsed.action) observation = tool.func(parsed.action_input) # 把推理、行动、观察追加回消息列表 messages.append(parsed.thought + action_text) messages.append(observation_text) raise Exc("Max iterations exceeded")在这个循环里,每次工具调用之后,LangChain 不会自动“记住”结果,而是把结果写到 messages 里。所以这里有一个关键点:Observation 返回的内容,必须是模型能读懂的文本。如果工具返回的是复杂对象或不适合文本化的内容,模型下一轮就无法理解发生了什么。
这也是很多自定义工具不可用的隐藏原因之一。函数本身没毛病,返回结果也有数据,但是返回类型在拼接到 Prompt 时被显示成<object object at 0x...>,模型看到这种内容,自然无法继续。
从工程经验看,工具返回字符串是最稳妥的选择。如果必须返回结构化数据,先序列化成 JSON 或简洁文本,再返回给 Agent。
2.3 循环终止:从 Action 到 Final Answer
另一个容易忽略的点是循环怎么结束。ReAct 循环不是无限转的,它有两个终止条件:
- 模型输出 Final Answer,执行器直接返回。
- 或者迭代次数超过 max_iterations,执行器抛出异常。
第二个条件经常被新手误解为“工具执行太慢”,其实是因为循环没能收敛。比如你让 Agent 查天气,工具返回了一个很长的 JSON,模型看不懂,又没法给出最终答案,于是继续生成 Action,试了几次都不对,最后触发迭代上限。
所以自定义工具不可用,也可能是返回内容的“可读性”问题,而不只是函数逻辑问题。
3. 用自己制造的工具,演示四种典型的“不可用”
3.1 第一层:工具没有进入候选列表
最常见也最隐蔽的原因:工具没被真正注册进 Agent。
有人会写两个列表,一个传给 create_react_agent,另一个传给 AgentExecutor,结果两者不一致。或者工具是通过@tool装饰器定义的函数,局部作用域里存在,但没有传进 tools 参数。
这类问题用 verbose 模式一眼就能看出来。执行日志里会列出当前 Agent 有哪些可用工具。如果列表里没有你的工具,后面怎么调 Agent 都没用。
我一般先做一次最小验证:
print(agent.tools)确认自定义工具在列表里,再继续排查。
3.2 第二层:工具描述让模型不知道什么时候该用
ReAct 模型选择工具的依据,基本只有两个:工具名称和工具描述。描述写得不好,模型就不会选择它。
举个例子。工具本身是查询用户订单状态的:
@tool def query_order_status(order_id: str) -> str: """查询订单状态。""" ...这个描述太简单。“订单状态”是什么意思?输入格式是什么?返回结果长什么样?模型没有这些信息,遇到“我的快递到哪了”这类问题,可能就去编答案,或者选别的工具。
更好的描述是:
当用户询问订单、物流、配送、包裹、收货进度时使用。 输入参数为订单号,形如 ORD123456。 返回结果为订单当前状态和预计送达时间。在 ReAct 循环里,工具描述本质上就是给模型的“使用说明书”。说明书越具体,模型越容易在正确的时机调用工具。
注意,描述不是越长越好。要写清楚触发条件、输入格式、输出含义,但不要堆砌无关词汇。描述里的每一句话,都可能影响模型决策。
3.3 第三层:模型生成的动作,工具执行不了
即使模型选择了工具,还可能因为参数格式问题执行失败。
比如工具定义为:
@tool def search_user(name: str, age: int) -> str: ...模型生成的 Action Input 可能是{"name": "张三", "age": "25"},age 是字符串而不是 int。如果工具函数内部没有做类型转换和校验,调用就会报错。
这类问题常见于从 OpenAI Function Calling 或结构化工具切换到 ReAct Agent 时。不同范式对参数格式的要求不一样,模型在文本协议里生成的 JSON 不一定严格符合函数签名。
解决办法有两个方向:
- 工具参数尽量简单,能用字符串就用字符串,避免复杂嵌套结构。
- 工具函数内部做容错,把参数转成目标类型,或者捕获异常后返回一条友好的错误信息。
3.4 第四层:工具返回后,循环没有继续
这一层最容易忽略。工具执行成功了,结果也出来了,但循环还是断了。
原因是工具返回的内容没有形成有效的 Observation。比如你的工具不小心返回了一个空字符串,模型拿到的是一个空观察。这种情况下模型往往不知道该说什么,可能会尝试再次调用工具,或者给出一个含糊的 Final Answer。
还有一种情况是工具内部抛了异常,执行器把它包装成意外错误,Agent 输出中断。在 ReAct 循环里,你希望工具在出错时也能返回文本形式的错误信息,而不是直接让整个循环崩溃。
一个更稳妥的写法:
@tool def query_database(sql: str) -> str: """执行只读 SQL 查询并返回结果文本。""" try: result = run_query(sql) return str(result) except Exception as e: return f"查询失败: {e}"这样 Agent 在下一次推理时能看到失败原因,它可以换一个查询方式,或者直接告诉用户查询失败。这个“反馈回路”正是 ReAct 的价值所在。
4. 排查顺序:不是先改代码,而是先看 Agent 的决定
4.1 一个可靠的定位顺序
如果你正在调试自定义工具不可用的问题,建议按下面这个顺序排查,而不是一上来就改工具函数:
- 开启 verbose 或 debug 日志。
- 看 Agent 到底有没有选择你的工具。
- 如果没有选择,重点检查工具名称和描述。
- 如果选择了但报错,看模型生成的 Action Input。
- 如果工具执行成功但没有继续,看工具返回的 Observation 文本。
- 最后再检查 max_iterations、模型上下文长度、Prompt 版本。
这个顺序的核心逻辑是:先确认循环断在哪一层,再决定修哪一层。很多人直接改工具函数,但问题明明出在描述上,改函数当然没用。
4.2 把 verbose 日志当第一现场
LangChain AgentExecutor 有一个非常实用的参数:
executor = AgentExecutor( agent=agent, tools=[get_current_time], verbose=True, )开启后,每次调用都会打印模型输出、工具调用内容和观察结果。这比任何调试信息都更直观。
看日志时,重点关注几个位置:
> Entering new AgentExecutor chain... Thought: I need to get the current time. Action: get_current_time Action Input: {} Observation: 2025-01-01 12:30:00 Thought: I now know the current time. Final Answer: ...如果日志到这里完整,说明循环正常,工具可用。
如果日志里 Action Input 是空的,但工具需要参数,那就是参数生成问题。 如果 Thought 之后没有 Action,直接出现了 Final Answer,那就是模型没有选择工具,问题在描述或 Prompt。 如果 Action 和 Observation 之间卡了很久或者报错,那就是工具本身或异常处理有问题。
从工程经验看,打开 verbose 日志之后,90% 的“Agent 不调用自定义工具”问题都能直接定位到具体环节。
4.3 常见异常和对应修复
下面列几个常见情况,你可以对照排查:
| 现象 | 问题层级 | 常见修复 |
|---|---|---|
| Agent 从不选这个工具 | 工具描述 / Prompt | 重写描述,写清楚触发条件和输入输出 |
| 选了工具,但参数解析失败 | 工具参数定义 | 简化参数类型,增加默认值,做类型容错 |
| 工具执行报错 | 工具函数内部 | 捕获异常,返回文本错误信息 |
| 工具返回后循环卡住 | Observation 不可读 | 把返回内容改成简洁文本或 JSON 字符串 |
| 循环到 max_iterations | 反馈不充分 | 增加上下文长度,精简工具返回,检查 Action Input 质量 |
| 出现 invalid tool 提示 | 工具注册不一致 | 确认 tools 列表和 Agent 创建时一致 |
5. 自定义工具设计的原则:让循环更容易成功
5.1 一个工具是一个“协议”,不是一个函数
很多人写工具时,只关注函数逻辑,不关注工具和模型之间的协议。
但在 LangChain Agent 里,模型只能看到工具名、描述和参数定义。它看不到源码。它需要仅凭这些信息决定调不调用、传什么参数、怎么理解返回结果。
所以设计自定义工具,应该像设计一个 API 接口一样考虑:
- 工具名称要短且有语义区分度,不要用
tool1、func2这种命名。 - 描述要回答三个问题:什么时候用、传入什么、返回什么。
- 参数要让模型容易猜中。能用
order_id就不用order_info_id。 - 返回内容必须对 LLM 友好,避免输出二进制、对象地址、超长日志。
5.2 工具设计四步法
我把这套流程总结成四步,每次新增工具时按顺序过一遍:
第一步,先单测工具本身。在没有 LangChain 的环境里直接调用函数,确认输入输出都正常。
print(get_current_time.invoke({}))第二步,检查描述。用一句话说清楚触发场景,比如“当用户询问当前时间时使用”。
第三步,检查返回。把所有返回值统一转成字符串或 JSON,确保模型能看懂。
第四步,用小样本跑通循环。用一个最简单的问题触发工具,打开 verbose 日志确认 Action -> Observation -> Final Answer 的完整链路。
这四步不需要很久,但能避免大多数“测试时灵时不灵”的问题。
5.3 从单工具到多工具
工具数量变多以后,新的问题会出现:模型可能选错工具。
因为 ReAct 让模型在每一步都要从完整工具列表里选一个。工具越多,选择难度越大。这时候工具描述区分度就变得更重要。
比如你有两个工具:一个是查询天气,一个是添加日程。都要写清楚典型触发词,不要让描述含糊到模型不知道用哪一个。另外,可以给 Agent 设计一个“不知道调用什么时”的默认处理,比如一个查询帮助工具,而不是让模型硬猜。
在多工具场景下,还有一个常见建议:不要一次性把所有工具都丢给 Agent。先只注册这次任务需要的工具,必要时按任务拆分多个 Agent。这比让一个 Agent 面对十几二十个工具更可控。
6. 从 ReAct 到 Agent 工程化的边界
6.1 什么时候该换成更可控的编排
ReAct 循环适合理解 Agent 的基础原理,也适合做中小规模的自动化流程。但进入生产环境后,很多人会遇到几个头疼的问题:
- 循环不可控:模型可能会试很多次才收敛,消耗模型调用次数。
- 上下文字段快速膨胀:每次工具返回都拼进历史,长任务里 Prompt 越来越长。
- 缺乏条件分支:有些流程需要在某个步骤之后强制停止,ReAct 里不好表达。
- 日志和重试能力弱:AgentExecutor 的循环相对简单,无法满足复杂流程的精细控制。
这时候更合适的选择是 LangGraph 这类编排框架。LangGraph 把 Agent 流程建模成图,每个节点是一个步骤,你可以显式控制“如果工具返回失败,就走异常分支;如果成功,就走下一步”。可以把 LangGraph 理解成把 ReAct 循环里的每个环节拆成了可视化节点,方便你插入判断、限制循环次数、加入人工审核。
但我不建议一上来就学 LangGraph。先把 ReAct 循环跑通,理解 Thought、Action、Observation 的关系,再看 LangGraph 的节点和边,会容易得多。否则你只是在配置一堆抽象概念,出了问题更难定位。
6.2 给新手的建议路径
如果你也在学 LangChain Agent,尤其是卡在自定义工具这一步,我的建议是:
- 先用一个最简单的工具,比如返回当前时间,完整跑通 ReAct 循环。
- 打印 verbose 日志,亲手看一遍 Thought、Action、Observation、Final Answer 的顺序。
- 故意制造几种错误,比如工具描述写模糊、参数类型不匹配、返回空字符串,观察日志变化。
- 等理解了循环之后,再去研究不同的 Agent 类型、Prompt 优化和 LangGraph。
这个路径看起来慢,其实是最稳的。因为 Agent 的本质不是“模型会调用工具”,而是“一个循环机制能把模型和工具之间的反馈迭代起来”。工具只是这个循环里的一个齿轮,不理解循环,换再多工具都没有用。
回到最开始的问题:用自己制造的工具不可用,不一定是你写错了工具函数,而可能是循环里的某一环没有接上。工具注册、描述、参数生成、返回值、循环终止,任何一个环节出了问题,表面上都会表现为“Agent 不听话”。但只要你能从循环的角度去看,大多数问题都能在几分钟内定位到原因。
