从零手写一个 ReAct Agent:让大模型自己调用工具
从零手写一个 ReAct Agent:让大模型自己调用工具
这篇文章不会用 LangChain、不会用 Spring AI,也不会依赖任何第三方库。我们将用纯 Java 8 手写一个最小化的 ReAct Agent,并让它真的跑起来。
一、什么是 ReAct
ReAct 是Reason(推理)和Act(行动)两个词的组合。它的核心思想很简单:
让大语言模型在回答问题的时候,不直接给出答案,而是先写下自己的思考过程,再决定要不要调用外部工具。如果调用了工具,就把工具返回的结果再写回上下文,让模型继续思考,直到得到最终答案。
这个过程可以抽象成一个循环:
- 模型看到问题,输出一个
Thought; - 模型根据思考输出
Action和Action Input; - Agent 执行对应的工具,得到
Observation; - 把
Observation追加到上下文,再次交给模型; - 循环直到模型输出
Final Answer。
这就像是给大模型配了一张草稿纸和一组工具。它可以在草稿纸上反复演算,而不是靠一次性推理强行回答。
二、为什么不用框架
市面上有很多现成的 Agent 框架,比如 LangChain、LangGraph、Spring AI 等。它们功能强大,但对初学者来说有几个问题:
- 框架封装了很多细节,你很难看清 Agent 到底是怎么运转的;
- 框架版本更新快,API 容易变,学习成本不低;
- 很多框架默认依赖较新的 JDK,而企业里常见的还是 JDK 8。
所以这篇文章的目标是用最少的代码、最底层的工具,把 ReAct 的本质表达出来。理解了本质之后,再去看框架,会有一种“原来它只是把这些步骤封装了”的感觉。
三、我们要实现什么
我们将实现一个支持数学计算的 Agent。你可以问它:
(25 + 15) * 2 - 10 等于多少Agent 的思考过程大致如下:
Thought: 我需要计算这个表达式。 Action: calculator Action Input: (25 + 15) * 2 - 10 Observation: 70.0 Thought: 工具已经返回了结果,这就是最终答案。 Final Answer: 70.0整个项目只需要一个 Java 文件、一个pom.xml、一个.env文件,没有任何第三方依赖。
四、核心代码结构
完整代码只有一个文件:ReActAgent.java。它可以分成四个部分:
1. 工具接口
interfaceTool{Stringname();Stringdescription();Stringrun(Stringinput);}每个工具都需要有一个名字、一段自然语言描述,以及一个执行方法。名字是模型在Action:后面要填的内容,描述会写进 Prompt 里告诉模型这个工具是干什么的。
2. 计算器工具
staticclassCalculatorToolimplementsTool{@OverridepublicStringname(){return"calculator";}@OverridepublicStringdescription(){return"Useful for calculating math expressions. "+"Input should be a valid math expression like '2 + 2'.";}@OverridepublicStringrun(Stringinput){Stringexpr=input.trim();if(!expr.matches("[0-9+\\-*/().\\s]+")){return"Error: expression contains invalid characters.";}doubleresult=evaluate(expr);returnString.valueOf(result);}}计算器做了两件事:
- 用正则白名单过滤输入,防止模型把不安全的字符串传进来;
- 用双栈算法求值四则表达式,支持括号。
这里的双栈求值虽然和学习 Agent 没有直接关系,但它体现了“工具必须自己保证安全和正确”这件事。Agent 不能把一切都交给模型。
3. Prompt 设计
ReAct 的关键不是代码有多复杂,而是 Prompt 能不能把模型约束在固定格式里。
privateStringbuildPrompt(Stringquestion){return"Answer the following questions as best you can. "+"You have access to the following tools:\n\n"+toolDesc+"\n\nUse the following format:\n\n"+"Question: the input question you must answer\n"+"Thought: you should always think about what to do\n"+"Action: the action to take, should be one of [calculator]\n"+"Action Input: the input to the action\n"+"Observation: the result of the action\n"+"... (this Thought/Action/Action Input/Observation can repeat N times)\n"+"Thought: I now know the final answer\n"+"Final Answer: the final answer to the original input question\n\n"+"Begin!\n\n"+"Question: "+question+"\n"+"Thought:";}Prompt 的最后停在Thought:,后面什么都不写。这是为了让模型接着写第一个思考。
4. ReAct 循环
publicStringrun(Stringquestion,intmaxIterations)throwsException{StringBuilderscratchpad=newStringBuilder();scratchpad.append(buildPrompt(question));for(inti=0;i<maxIterations;i++){StringllmOutput=callLlm(scratchpad.toString());Actionaction=parseAction(llmOutput);if(action.isFinalAnswer){returnaction.content;}Stringobservation=tools.get(action.toolName).run(action.toolInput);scratchpad.append("\n").append(llmOutput);scratchpad.append("\nObservation: ").append(observation);scratchpad.append("\nThought: ");}return"Reached max iterations without final answer.";}这是整个 Agent 的心脏。
scratchpad是一个不断变长的字符串,它就是模型的“工作记忆”。每一轮循环,我们都会把上一轮模型说的话、工具返回的 Observation、以及一个新的Thought:都追加进去。这样模型在下一轮就能看到完整的历史。
parseAction()负责从模型输出里提取Action和Action Input。如果模型输出了Final Answer:,就直接结束循环。
maxIterations是一个保险丝,防止模型陷入死循环。
五、一个完整运行示例
假设我们问:
(25 + 15) * 2 - 10 等于多少第一次调用模型,Prompt 里只有问题和格式说明,模型输出:
Thought: I need to calculate this expression. Action: calculator Action Input: (25 + 15) * 2 - 10Agent 解析出Action = calculator、Action Input = (25 + 15) * 2 - 10,调用计算器工具,得到70.0。于是scratchpad变成:
... 前面的 Prompt ... Question: (25 + 15) * 2 - 10 等于多少 Thought: I need to calculate this expression. Action: calculator Action Input: (25 + 15) * 2 - 10 Observation: 70.0 Thought:第二次调用模型,模型看到Observation: 70.0,就知道计算已经完成,于是输出:
The calculator returned 70.0. Final Answer: 70.0Agent 检测到Final Answer:,把70.0返回给用户,循环结束。
六、这个例子能说明什么
通过这个最简实现,我们可以看到 ReAct 的几个本质特征:
- 模型只负责规划和决策,真正的执行由外部工具完成。 Observation 必须由代码生成,不能由模型编造。
- 上下文是 Agent 的记忆。如果没有
scratchpad把历史保留下来,模型每轮都是无状态的,无法做多步推理。 - 格式约束是 Prompt 工程的核心。ReAct 的威力来自“让模型按固定格式输出”,而不是来自什么高深的算法。
- 工具可以是任何东西。现在只是一个计算器,后面可以换成搜索引擎、数据库查询、文件读写、API 调用等等。只要符合
Tool接口,Agent 就能调用它。
七、局限和下一步
这个例子是最小化的,距离生产环境还很远。如果要继续深入,可以考虑:
- 支持多个工具,让模型根据问题自动选择;
- 把输出格式从纯文本改成 JSON,解析更健壮;
- 加入异常处理和重试机制;
- 用向量数据库存储历史对话,支持更长的上下文;
- 引入反思机制,让模型在出错时自我纠正。
但这些都是建立在你已经理解 ReAct 循环的基础上。先把循环跑通,比一上来就追框架重要得多。
八、总结
ReAct 不是什么神秘的框架特性,而是一种让大模型“边想边做”的协作模式。它的核心只有三件事:
- 给模型固定格式,让它输出
Thought、Action、Action Input; - Agent 解析这些输出,调用真实工具,得到
Observation; - 把
Observation写回上下文,让模型继续下一轮,直到输出Final Answer。
用 Java 手写一遍之后,你会发现 Agent 的实现并没有想象中复杂。复杂的不是代码,而是怎么让模型稳定地按你期望的格式思考。
希望这个例子能帮你把 Agent 的学习从“调用框架 API”推进到“理解底层机制”的层面。