ReAct Agent 从零实现:我们如何用 Java 构建企业级 AI 应用
作者:[潘ReAct]
发布时间:2026-04-03
一、背景:不是套壳,是真的 Agent
我们做了一个运营 AI 助手,最初的想法很简单——把设备数据扔给 LLM,让它给运营人员出售卖规划建议。
最初的实现大概是这样:
// 最初版本:直接拼数据 + 问 LLM
String prompt = "设备" + deviceId + "的销量是" + salesData + ",请给建议";
String answer = llmClient.chat(prompt);
上线后发现问题很快暴露:
- 用户问"设备 A001 最近卖得怎么样",LLM 不知道要查哪些数据,回答的是废话
- 用户问"帮我补货",LLM 会编一个根本不存在的补货单号
- 数据截止时间是模型训练时间,查不到实时库存
根本问题:LLM 不能主动获取信息,也不能执行操作。
这就是 ReAct(Reasoning + Acting)模式要解决的问题:让 LLM 在推理过程中主动调用工具,通过"思考 → 行动 → 观察"的循环,最终给出有据可依的答案。
二、整体架构
我们的 Agent 模块是一个独立微服务,核心结构如下:
用户请求(HTTP / 钉钉 Stream)
↓
AgentService(ReAct 循环)
↓ ↓
ModelGateway ToolRegistry
(Kimi 主 / (20 个 Tool)
DeepSeek 备)
↓
MessageHistoryManager
(会话历史截断)
↓
Milvus(RAG) + 业务 Feign 接口
设计原则:
- LLM 负责推理,Tool 负责执行,两者完全解耦
- 会话历史不能无限增长,必须有截断策略
- 主模型挂了要能自动切备用模型
三、Tool 接口:标准化是关键
每个工具实现同一个接口:
public interface AgentTool {
// Tool 名称,snake_case,LLM 按此名称调用
String getName();
// 给 LLM 看的功能描述,决定 LLM 会不会调这个 Tool
String getDescription();
// JSON Schema,告诉 LLM 参数格式
Map<String, Object> getInputSchema();
// 真正的执行逻辑
Object execute(Map<String, Object> input);
// 是否写操作(写操作需要角色权限校验)
default boolean isWriteOperation() { return false; }
}
注册方式是 Spring 自动装配,启动时扫描所有实现类:
@PostConstruct
public void init() {
toolMap = tools.stream()
.collect(Collectors.toMap(AgentTool::getName, t -> t));
}
Tool 调用时传的是 Map<String, Object>,这样 JSON Schema 和执行逻辑完全对齐,LLM 组装的参数可以直接用。
我们目前有 20 个 Tool,覆盖设备查询、销售分析、补货、简道云、钉钉、RAG 检索等。核心设计原则:每个 Tool 只做一件事,不做假设,参数缺少宁可报错也不猜。
四、ReAct 循环核心实现
ReAct 循环的精髓是 while(true):
public String chat(ChatRequest request) {
checkQuota(userId); // 配额检查
List history = loadHistory(sessionId); // 加载历史
while (true) {
// 1. 截断历史,防止 context 撑爆
List<Message> messages = historyManager.truncate(history);
// 2. 调 LLM
LLMResponse response = modelGateway.chat(messages, toolDefinitions);
if (response.isTextOnly()) {
// 纯文字回答,结束循环
saveHistory(sessionId, history);
return response.getText();
}
// 3. 有 tool_calls,逐个执行
for (ToolCall call : response.getToolCalls()) {
// 权限校验(写操作需要角色)
if (getTool(call.name).isWriteOperation()) {
permissionService.check(userId, call.name);
}
Object result = getTool(call.name).execute(call.arguments);
// 压缩结果,超长的截断
String compressed = compressor.compress(result);
history.add(toolResultMessage(call.id, compressed));
}
// 4. 继续下一轮推理
}
}
**流式版本(SSE)**本质一样,区别是 LLM 返回的是 Stream,每个 token 通过 SSE 推给前端,同时在后台维护同一个 while 循环。
五、消息历史管理:三步截断
这是整个 Agent 最容易忽视、坑最多的地方。
问题:每轮对话都要把完整历史带给 LLM,历史越来越长,要么超过 context 窗口报错,要么 token 费用爆炸。
我们的 MessageHistoryManager 做三步处理:
public List truncate(List history) {
// Step 1:数量截断,只保留最新 30 条
if (history.size() > MAX_COUNT) {
history = history.subList(history.size() - MAX_COUNT, history.size());
}
// Step 2:长度截断,总字符超 8000 从最旧开始丢弃
while (totalChars(history) > MAX_SIZE && history.size() > 1) {
history.remove(0);
}
// Step 3:修复孤立 tool 消息(最关键,见下文踩坑)
history = fixOrphanedToolMessages(history);
return history;
}
六、踩坑实录
坑 1:Kimi 的 assistant 消息格式导致 400 报错
现象:对话在某些情况下突然报 400,错误信息极其模糊,没有具体字段提示。
排查过程:打印完整请求 body,最终发现:
// ❌ 错误:有 tool_calls 的 assistant 消息带了 content 字段
{
"role": "assistant",
"content": "", // ← 这个字段不能存在!
"tool_calls": [...]
}
// ✅ 正确:有 tool_calls 时,content 键不能出现(null 也不行)
{
"role": "assistant",
"tool_calls": [...]
}
Kimi 的规则:
- 有 tool_calls → content 键不能存在(null 和空字符串都会报 400)
- 无 tool_calls → content 必须存在且非空
修复方式:序列化时按条件包含 content 字段:
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("role", "assistant");
if (!toolCalls.isEmpty()) {
msg.put("tool_calls", toolCalls);
// 注意:不放 content 字段
} else {
msg.put("content", content);
}
更隐蔽的变种:LLM 偶尔返回空 content 的 assistant 消息(content 为空字符串或 null),如果存进历史缓存,下轮带入请求时同样触发 400。解决方案:存历史时过滤掉无效消息:
// 有 content 或有 tool_calls 才存入历史
if (hasContent(response) || hasToolCalls(response)) {
history.add(response);
}
坑 2:OkHttpClient 默认超时踩掉 LLM 推理
现象:简单问题正常,复杂推理(需要多步 Tool 调用)必定超时报错。
原因:new OkHttpClient() 默认 read timeout = 10 秒,而 LLM 推理轻松跑 20-40 秒。
// ❌ 错误:用默认配置
OkHttpClient client = new OkHttpClient();
// ✅ 正确:LLM 客户端必须显式设置超时
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(90, TimeUnit.SECONDS) // 不低于 60s,建议 90s
.writeTimeout(30, TimeUnit.SECONDS)
.build();
这个坑很低级,但很常见——很多教程的示例代码都是 new OkHttpClient(),直接复制就踩进去了。
坑 3:Tool 结果太长把 context 撑爆
现象:查销售历史的 Tool 返回了 500 条订单 JSON,直接把 context 窗口占满,后续 LLM 没有空间输出。
方案:在 Tool 结果进入历史之前,强制压缩:
public String compress(Object result) {
if (result instanceof List) {
List list = (List) result;
if (list.size() > 20) {
// 截取前 20 条,附加说明
return serialize(list.subList(0, 20))
+ "\n(共 " + list.size() + " 条,已截取前 20 条)";
}
}
String json = serialize(result);
if (json.length() > 4000) {
return json.substring(0, 4000) + "...(已截断)";
}
return json;
}
关键原则:数字型数据不进向量库,只进结构化查询。 销量/库存/价格走 Feign 实时查,经验文字/规则/FAQ 才进 Milvus。
坑 4:孤立 tool 消息导致 API 报错
背景:截断历史时,如果把 assistant(tool_calls) 消息截掉了,但后面对应的 tool(result) 消息还在——这就是"孤立 tool 消息",LLM API 会拒绝这种请求。
修复:截断后扫描一遍,从第一条 user 消息开始,确保 tool result 前面一定有对应的 tool_call:
private List fixOrphanedToolMessages(List messages) {
// 找到第一条 user 消息的位置
int firstUserIdx = findFirstUserMessage(messages);
// 收集所有 tool_call id
Set<String> validCallIds = collectToolCallIds(messages);
// 过滤掉没有对应 call 的 tool result
return messages.stream()
.filter(m -> !isOrphanedToolResult(m, validCallIds))
.collect(toList());
}
七、双模型网关:主备熔断降级
单模型 API 不稳定是现实,我们用 Kimi 做主模型,DeepSeek 做备用,通过 Resilience4j 做熔断:
public LLMResponse chat(List messages) {
try {
return circuitBreaker.executeSupplier(
() -> kimiProvider.chat(messages)
);
} catch (CallNotPermittedException e) {
// 熔断器打开,直接走备用
return deepSeekProvider.chat(messages);
} catch (Exception e) {
// 主模型失败,发钉钉告警 + 切备用
alarmService.sendModelSwitchAlarm(e);
return deepSeekProvider.chat(messages);
}
}
流式场景的特殊处理:流式调用无法用 try-catch 拦截中途断流,需要在 callback 层判断——如果 hasData=false(一个字都没回来)才切换,hasData=true 时中途断了就透传错误(已有数据无法回退)。
熔断参数(供参考):
resilience4j:
circuitbreaker:
instances:
llm:
failure-rate-threshold: 50 # 失败率超 50% 打开
wait-duration-in-open-state: 30s # 30s 后半开
sliding-window-size: 10 # 最近 10 次请求统计
八、生产经验总结
关于 ReAct 循环:
while(true)必须有最大轮次限制(我们设 10 轮),防止 Tool 调用死循环- 每轮开始前检查 emitter 是否还存在,支持用户中断
关于 Tool 设计:
- Tool 粒度宁小勿大,query_device 和 analyze_stock 分开,不要合并成一个"万能查询 Tool"
getDescription()是发给 LLM 的文字,要写清楚"什么时候调、输入什么、返回什么",这决定了 LLM 会不会正确调用
关于多租户(如果你的系统有租户隔离):
- 钉钉 Stream 回调不走 HTTP 线程,ThreadLocal 的 tenantId 不会自动传递
- 定时任务同理,必须手动
TenantContextHolder.setTenantId()+ finally clear(),这个细节不处理会引发诡异的 NPE
关于 context 管理:
- Tool 结果是 context 的最大消耗源,必须在进入历史前压缩
- 消息历史不要只做数量截断,要做语义保留——优先保留 user 和最终 assistant 消息,Tool 中间结果可以先丢
总结
ReAct Agent 在 Java 中落地并不复杂,核心代码量其实不大,难的是:
- 消息格式的细节:不同 LLM 提供商对 assistant 消息格式有差异,必须严格遵守
- 历史管理的正确性:孤立 tool 消息这类问题,不踩一次很难想到
- 生产稳定性:超时、熔断、多租户,每一个都是真实故障的教训
我们从简单调 LLM 到跑通完整 ReAct 循环,中间踩的坑比预想的多,但每个坑都有明确的解法。希望这篇文章能帮你少走弯路。
如果你在实现过程中遇到具体问题,欢迎评论区交流。
本文由 作者 原创,转载请注明出处。
标签:#Java #AI #LLM #Agent #ReAct #RAG #Milvus #Kimi #DeepSeek