用纯 Java 手写最小 ReAct Agent
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇咱们把 ReAct 范式讲透了:推理和行动交替运转的 Thought-Action-Observation 三元组,用退款和查物流两个场景走了完整轨迹。最后那段 agentRun 骨架代码,你已经能看懂每一行对应三元组的哪个环节。
但那毕竟是骨架——buildReActPrompt、parseAction、toolRegistry 全是方法名,里面一行实现都没有。这一篇,咱们把骨架变成能跑的代码。
目标很明确:用纯 Java + OkHttp,不依赖任何 Agent 框架,实现一个最小可运行的 ReAct Agent。跑起来之后,你在 IDE 里打个断点,能一圈一圈看比特严选智能体怎么想、怎么干、怎么看结果再接着想。代码量不大,但跑通这一趟,ReAct 就不再是概念,而是你手里实实在在的工程能力。
这篇的目标是跑通,不是做到完美。工具定义、提示词打磨、输出解析、终止控制,分别在第 06 到 09 篇逐一深入。这里先用最简版把整个循环串起来。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后,将代码分支切换到 1.0.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进 去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
搭积木前先看图纸
1. 五个组件
要把 ReAct 循环跑起来,咱们一共需要五个组件:
| 组件 | 职责 | 一句话说清楚 |
|---|---|---|
Action | 数据载体 | 记录一次工具调用的名称和参数 |
Tool | 工具契约 | 每个工具实现这个接口,提供名称、描述和执行逻辑 |
ToolRegistry | 工具注册表 | 管理所有工具,按名字查找并执行 |
LlmClient | 大模型调用层 | 用 OkHttp 调 OpenAI 兼容 API,发消息、收回复 |
ReActAgent | 主循环 | 串联以上所有,驱动 Thought-Action-Observation 循环 |
五个组件,自底向上组装:Tool 实现注册到 ToolRegistry,ToolRegistry 和 LlmClient 注入 ReActAgent,ReActAgent 对外暴露一个 run(userMessage) 方法——传入用户消息,返回最终答复。
下面这张图把组装关系画出来:

2. 组装顺序
接下来按从底向上的顺序,逐个搭积木:
- 工具层:
Action、Tool、ToolRegistry,再实现五个 Mock 工具 - 大模型调用层:
LlmClient,用 OkHttp 调 API - 主循环:
ReActAgent,把所有组件串起来 - 跑起来:组装、执行、看效果
只要两个依赖
整个实现只用两个外部依赖:OkHttp 负责 HTTP 调用,Jackson 负责 JSON 序列化和解析。如果你用 Spring Boot,Jackson 已经自带了,只需额外加一个 OkHttp:
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
如果不是 Spring Boot 项目,jackson-databind 需要显式写版本;Spring Boot 项目可以交给 parent 管理版本。
第一块积木:工具层
1. Action 和 Tool
Action 是一个简单的数据载体,记录大脑决定调哪个工具、传什么参数:
public record Action(String toolName, String toolInput) {
}
Tool 是工具的契约,每个工具实现三个方法——我叫什么、我能干什么、执行:
public interface Tool {
String name();
String description();
String invoke(String input);
}
2. ToolRegistry
ToolRegistry 做两件事:管理工具的注册,以及根据 Action 找到对应工具并执行。
public class ToolRegistry {
private final Map<String, Tool> tools = new LinkedHashMap<>();
public void register(Tool tool) {
Objects.requireNonNull(tool, "tool must not be null");
tools.put(tool.name(), tool);
}
public String execute(Action action) {
if (action == null || action.toolName() == null || action.toolName().isBlank()) {
return "{\"error\":\"未解析到可执行的工具名称\"}";
}
Tool tool = tools.get(action.toolName());
if (tool == null) {
return "{\"error\":\"未找到工具:" + action.toolName() + "\"}";
}
return tool.invoke(action.toolInput() == null ? "" : action.toolInput());
}
public String buildToolList() {
StringBuilder sb = new StringBuilder();
int index = 1;
for (Tool tool : tools.values()) {
sb.append(index++).append(". ")
.append(tool.name())
.append(" - ")
.append(tool.description())
.append("\n");
}
return sb.toString();
}
}
buildToolList() 把所有工具的名称和描述拼成文本,后面要塞进提示词里告诉大脑手里有哪些牌可打。用 LinkedHashMap 而不是 HashMap,是为了让工具列表的顺序稳定——每次生成的提示词 一模一样,大脑的表现才可预测。
注意 execute() 里对未知工具的处理:返回一个 JSON 错误信息而不是抛异常。因为大脑调错工具名是大模型的常见毛病,把错误信息作为 Observation 喂回去,大脑看到错误还有机会自我纠正。一旦抛异常,循环直接崩了,纠正的机会都没有。空 Action、空工具名和空入参也做了兜底,避免解析失败时直接触发空指针。
3. 五个 Mock 工具
第三篇蓝图里定好了第一版的五个工具。这里用 Mock 数据来模拟业务系统——重点是跑通循环,不是对接真实后端。
查询订单工具——服务查订单、查物流(取运单号)、退款(取订单信息)等多个场景:
public class QueryOrderTool implements Tool {
@Override
public String name() {
return "queryOrder";
}
@Override
public String description() {
return "查询订单详情。输入:订单号(如 88231)。"
+ "返回:商品名、下单时间、签收时间、订单状态、运单号。";
}
@Override
public String invoke(String input) {
String orderId = input.trim();
if ("88231".equals(orderId)) {
return "{\"orderId\":\"88231\",\"product\":\"比特 S10 Pro 扫地机\","
+ "\"price\":1999,\"orderTime\":\"2026-06-20\","
+ "\"signTime\":\"2026-06-22\",\"status\":\"已签收\","
+ "\"trackingNo\":\"SF1234567890\"}";
}
return "{\"error\":\"订单不存在:" + orderId + "\"}";
}
}
获取当前时间工具——这个不是 Mock,而是真实的。大脑判断退货时限需要知道今天几号,但模型本身不知道当前日期,必须通过工具获取:
public class GetCurrentTimeTool implements Tool {
@Override
public String name() {
return "getCurrentTime";
}
@Override
public String description() {
return "获取当前日期时间。无需输入参数,返回当前时间的 JSON。";
}
@Override
public String invoke(String input) {
String now = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"));
return "{\"currentTime\":\"" + now + "\"}";
}
}
退款申请工具——服务 T3(退款/换货),这是五个工具里唯一有副作用的——它会真的发起退款(Mock 里当然只是返回成功):
public class ApplyRefundTool implements Tool {
@Override
public String name() {
return "applyRefund";
}
@Override
public String description() {
return "发起退款申请。输入:JSON 格式,包含 orderId(订单号)和 reason(退款原因)。"
+ "返回:申请结果,包含退款单号。";
}
@Override
public String invoke(String input) {
return "{\"success\":true,\"refundId\":\"RF20260629001\","
+ "\"message\":\"退款申请已提交,预计 1-3 个工作日到账\"}";
}
}
另外两个工具——QueryLogisticsTool(查物流轨迹)和 SearchKnowledgeTool(检索知识库)——结构完全一致,只是 name()、description() 和 invoke() 的内容不同。这里不再展开,完整代码在仓库里。
SearchKnowledgeTool值得多说一句:它的invoke()里面跑的就是你在 RAG 系列写的那套检索链路——Embedding → 向量检索 → 重排序。只不过现在被包了一层Tool接口,大脑自己决定什么时候调。从 RAG 到 Agent 的那条线,就在这个工具上接通了。
工具层就绪,接下来搭大模型调用层。
第二块积木:大模型调用层
1. LlmClient 的职责
LlmClient 只做一件事:接收一组消息(messages),发给大模型 API,返回大模型的回复文本。它不关心消息内容是什么、回复要怎么处理——那是 ReActAgent 的事。
2. 完整实现
这里用 OpenAI 兼容的 Chat Completions 接口。国内的 DeepSeek、通义千问、智谱等主流大模型都兼容这个协议,换个 URL 和 API Key 就能跑:
public class LlmClient {
private static final MediaType JSON_MEDIA_TYPE
= MediaType.get("application/json; charset=utf-8");
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final String apiUrl;
private final String apiKey;
private final String model;
public LlmClient(String apiUrl, String apiKey, String model) {
this.apiUrl = apiUrl;
this.apiKey = apiKey;
this.model = model;
this.objectMapper = new ObjectMapper();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.build();
}
public String chat(List<Map<String, String>> messages) {
try {
ObjectNode requestBody = objectMapper.createObjectNode();
requestBody.put("model", model);
requestBody.put("temperature", 0.1);
// 构建 messages 数组
ArrayNode messagesArray = requestBody.putArray("messages");
for (Map<String, String> msg : messages) {
ObjectNode msgNode = messagesArray.addObject();
msgNode.put("role", msg.get("role"));
msgNode.put("content", msg.get("content"));
}
// stop 序列:让模型输出到 Action Input 就停下,别自己编 Observation
ArrayNode stopArray = requestBody.putArray("stop");
stopArray.add("Observation:");
Request request = new Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer " + apiKey)
.post(RequestBody.create(requestBody.toString(), JSON_MEDIA_TYPE))
.build();
try (Response response = httpClient.newCall(request).execute()) {
ResponseBody body = response.body();
String responseText = body == null ? "" : body.string();
if (!response.isSuccessful()) {
throw new RuntimeException("API 调用失败,状态码:" + response.code() + ",响应:" + responseText);
}
JsonNode responseJson = objectMapper.readTree(responseText);
JsonNode contentNode = responseJson.at("/choices/0/message/content");
if (contentNode.isMissingNode() || contentNode.isNull()) {
throw new RuntimeException("API 响应中缺少 choices[0].message.content:" + responseText);
}
return contentNode.asText();
}
} catch (IOException e) {
throw new RuntimeException("调用大模型失败:" + e.getMessage(), e);
}
}
}
几个值得注意的点:
temperature 设为 0.1。ReAct 循环里大脑要按固定格式输出、要做逻辑判断,稳定性比创造性重要。温度越低,输出越确定。
stop 序列是关键。你可能好奇:为什么要加一个 Observation: 的停止序列?
回想一下 ReAct 的流程:大脑输出 Thought + Action + Action Input 之后,应该停下来,等你的代码去调工具、拿真实结果。但问题来了——大模型是自回归生成器,它的本能就是一个字一个字地往下写,写完 Action Input 之后它不会自己停下来。它在训练数据里见过太多"Action 后面跟 Observation"的模式,会顺手把 Observation 也编出来。
用退款场景走一遍你就明白了。假设没有 stop 序列,用户说"我想退订单 88231",大脑可能会一口气输出这些:
── 第 1 轮(模型自己编的) ──────────────────────────
Thought: 用户想退订单 88231,我需要先查订单详情。
Action: queryOrder
Action Input: 88231
Observation: {"orderId":"88231","product":"比特 S10 Pro 扫地机","price":1999,"status":"已签收"}
↑ queryOrder 根本没被调用,这条数据是编的
── 第 2 轮(还是模型自己编的) ──────────────────────────
Thought: 已查到订单,签收状态,可以申请退款。
Action: applyRefund
Action Input: {"orderId":"88231","reason":"用户主动申请退款"}
Observation: {"success":true,"refundId":"RF20260629001","message":"退款已提交"}
↑ applyRefund 也没被调用,退款根本没发生
── 直接给答案 ──────────────────────────────────
Thought: 退款已完成,可以回复用户了。
Final Answer: 您好,订单 88231 的退款已提交,退款单号 RF20260629001,预计 1-3 个工作日到账。
看起来流程很完整,对吧?但仔细想想——queryOrder 和 applyRefund 这两个工具根本没有被真正调用过。整段输出都是大脑一口气编出来的。那两行 Observation 里的数据不是工具返回的,是模型自己想象的。
更危险的是:模型跳过了 getCurrentTime 和 searchKnowledge,没有检查退货时限,没有查售后政策,直接就"退了"。在真实系统里,退款 API 根本没有被调用,但模型已经告诉用户"退款已提交"了——这就是幻觉变成了承诺。
stop 序列就是解决这个问题的。 要真正理解它,得先搞清楚大模型生成文本的底层机制。
大模型不是一次性吐出整段文本的,而是一个 Token 一个 Token 地往外蹦。你可以把它想象成一台打字机:打完一个字,才决定下一个字打什么。模型写完 Action Input: 88231 之后,下一个要打的字大概率就是 Observation——因为它在训练数据里见过无数次这个模式。
stop 参数不是提示词,模型看不到它。它是你在请求参数里传给 API 服务器的一道指令,相当于告诉服务器:
"你帮我盯着这台打字机,一个字一个字地看。它一旦打出了
Observation:这几个字 ,你立刻把打字机的电源拔了,然后把Observation:这几个字也扔掉,只把前面已经打好的内容给我。"
关键点在这:API 返回给你的内容里,连 Observation: 本身都没有。 不是返回了再让你删,而是在返回之前就被吞掉了。你的代码收到的是干干净净的三行——Thought、Action、Action Input,后面什么都没有。
用退款场景走一圈你就彻底明白了。加上 stop: ["Observation:"] 后:
第 1 圈,你的代码把消息列表发给 API。模型这台打字机开始一个字一个字地打:T、h、o、u、g、h、t、:……一路打到 Action Input: 88231,接着它要打 O、b、s、e、r、v、a、t、i、o、n、:——API 检测到了 stop 关键词,拔电源,吞掉 Observation:,把前面的内容返回。你的代码收到的就是这些:
Thought: 用户想退订单 88231,我需要先查订单详情。
Action: queryOrder
Action Input: 88231