ReAct Agent 从零实现:我们如何用 Java 构建企业级 AI 应用

0 阅读8分钟

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