Java自研ReAct Agent与LangGraph对比测评:半年实战取舍

2026-06-22阅读 0热度 0
其他

Java 自研 ReAct Agent 半年后,我用 LangGraph 验证了这些设计取舍

半年前用 Java 从零硬撸了一个 ReAct Agent,对接了 Kimi 与 DeepSeek 双模型,构建了 25 个业务 Tool,部署在 Spring Boot 微服务中。最近系统补全 Python AI 生态,深入啃完了 LangGraph 的全部文档。看完最大的震撼在于:LangGraph 并没有更厉害,而是把你原本写在 while 循环里的那些隐形逻辑全部摊开到了图结构上。

这篇文章不是教程,而是一个 Agent 应用工程师对两种实现方式的真实复盘。结论很直接:如果只是让 Agent 跑起来,两种方式都能胜任;但一旦涉及状态持久化、任务中断、人工审批这类场景,LangGraph 恰好解决了你最痛的难题——自研方案往往需要自己填这些坑。

一、自研实现长什么样

先介绍自己写的实现,便于后续对比分析。

核心结构

AgentServiceImpl.java 的核心骨架如下:

检查用户配额(Redis)
加载会话历史(Caffeine Cache)
while (true):
    MessageHistoryManager.truncate() ← 三步截断
    ModelGateway.chat(messages, tools) ← 双模型
    解析 LLM 响应
      ├─ 纯文本 → 返回,退出循环
      └─ tool_calls → 校验权限 → 执行 Tool → 结果压缩 → 加入历史
    继续下一轮

Tool 注册通过 Spring 自动装配实现,@PostConstruct 扫描所有 AgentTool Bean 并建立索引,每次循环将工具描述打包成 JSON Schema 发送给 LLM。

流式版本(SSE)

非流式逻辑虽清晰,但用户等待体验极差。流式版 StreamingAgentServiceImpl 改为 SSE 协议,核心采用 SseEmitter 配合事件分类:

session_start → text(逐字) → tool_call(running) → tool_result → tool_call(done/❌) → done

使用 ConcurrentHashMap 管理连接,前端发送停止请求时移除对应 emitter,下一轮循环检测到连接不存在即退出——这是自研实现“中断”的方式,后续会对比 LangGraph 的中断机制。

图一:两种实现的结构对比

左侧是自研 Java 实现:while(true) 隐式循环,状态驻留在内存,Resilience4j 负责熔断,整个控制流在代码中呈线性。右侧是 LangGraph:显式有向图(StateGraph),每个节点是一个函数,边带有条件,状态为 TypedDict,天然可序列化。

两种方式都能跑通 ReAct 逻辑。核心差异在于对“循环状态”的处理:自研是隐式的,LangGraph 是显式的。

二、最难搞的部分:消息历史管理

用过 OpenAI / Kimi API 的同学,一定遇到过这个错误:

400 Bad Request: messages[3].content is required

或者超长上下文带来的费用飙升。这是每个自研 Agent 必须跨过的坎。

三步截断策略

MessageHistoryManager 采用三步截断,执行顺序不可颠倒:

步骤一:数量截断

保留最新 30 条消息。超出部分从最旧开始删除。

if (messages.size() > MAX_COUNT) {
    messages = messages.subList(messages.size() - MAX_COUNT, messages.size());
}

步骤二:长度截断

数量未超限但总字符可能超过 8000(传给 LLM 的上下文预算)。从最旧消息开始逐条删除,直到总长度达标。

while (totalChars(messages) > MAX_CHARS && messages.size() > 1) {
    messages.remove(0); // ArrayList remove(0) 是 O(n),消息量大时可改用 LinkedList
}

步骤三:孤立修复(最关键也最容易被遗漏)

前两步截断后可能出现一种情况:tool_result 消息还在,但其对应的 tool_call 消息已被截掉。Kimi 会直接返回 400,DeepSeek 则会返回乱序回复。

修复逻辑:遍历消息列表,遇到 tool_result 时检查前面是否有匹配的 tool_call id,无则直接删除。同时处理空 content 的 assistant 消息——某些模型对空 content 的 assistant 消息会报错。

// 收集所有 assistant 消息里发出的 tool_call id(tool_call 在 assistant 消息的 tool_calls 数组里)
Set toolCallIds = messages.stream()
    .filter(m -> "assistant".equals(m.getRole()))
    .flatMap(m -> m.getToolCalls().stream())
    .map(ToolCall::getId)
    .collect(Collectors.toSet());

// 删除找不到对应 tool_call 的孤立 tool_result
messages.removeIf(m -> "tool".equals(m.getRole()) && !toolCallIds.contains(m.getToolCallId()));

图二:三步截断示意图

第三步的坑最容易踩,也最容易被忽略。上线前测试未能发现,真实用户使用一周后反馈“偶尔返回 400”才排查出来。根本原因是长会话配合密集工具调用时,截断后孤立 tool_result 的概率大幅上升。

三、双模型网关:比想象中更复杂

将 Kimi 作为主力、DeepSeek 作为备用,看似简单,实则有几个细节需要处理:

非流式降级很直接:主模型抛出异常即切换备用,同时触发钉钉报警。

流式降级则复杂得多:HTTP 流式回包一旦开始,回调已在 onData 中,try-catch 无法捕获——你必须在 onError 回调中判断 hasData 标志位:

  • hasData = false(尚未收到任何数据)→ 可无感切换到 DeepSeek
  • hasData = true(已有数据流出)→ 无法撤回,只能透传错误

这个细节 LangGraph 并不帮你解决,框架层面不感知你使用哪家模型。

四、再看 LangGraph:它解决了什么

好了,说完自研实现中真实踩过的坑,现在再来看 LangGraph,就能理解它为何如此设计。

LangGraph 的核心抽象是 StateGraph

from langgraph.graph import StateGraph, END
from typing import TypedDict

class AgentState(TypedDict):
    messages: list
    tool_calls: list

graph = StateGraph(AgentState)
graph.add_node("llm_call", call_llm)
graph.add_node("tool_exec", execute_tools)
graph.add_conditional_edges(
    "llm_call",
    lambda s: "continue" if s["tool_calls"] else "end",
    {"continue": "tool_exec", "end": END})
graph.add_edge("tool_exec", "llm_call")
graph.set_entry_point("llm_call")
app = graph.compile()

这段代码将 while(true) 中的逻辑画成了一张图(即文章开头的图一)。功能上等价,但有两个重要区别。

差别一:状态是一等公民

LangGraph 的 State 是一个 TypedDict,每一步都在更新它。这意味着:

  • 持久化:使用 Checkpointer(SQLite/Redis)存储 State,崩溃后可从断点恢复
  • 回放:给定任意 State,重新跑一遍
  • 时间旅行:在 LangGraph Studio 中可回到某一步重新执行

而自研的 Caffeine Cache 只是将整个消息列表序列化存储,粒度是“会话”,不是“每一步的中间状态”。

差别二:中断(Human-in-the-loop)

LangGraph 的 interrupt_before / interrupt_after 可以在节点执行前后暂停,等待外部输入再继续。

graph.compile(interrupt_before=["tool_exec"])

在“执行写操作前需要人工确认”的场景下极其有用。

自研实现里,写操作权限在 Tool execute 方法中校验,用户若没有权限则直接报错返回。而 LangGraph 的中断是在图执行层面,暂停期间可修改 State 再继续——例如用户可以调整工具参数后再确认。

五、横向对比

维度自研 JavaLangGraph
循环控制while(true) 手写,完全可控StateGraph 显式图,可视化
状态粒度会话级(消息列表整体)步骤级(每个节点后均可 checkpoint)
中断 / 恢复靠 emitter remove 间接实现interrupt_before/after 原生支持
消息截断自己写三步逻辑(踩坑)无内置;LangChain 有 trim_messages 但仍需自行配置策略
熔断降级Resilience4j 完整支持无内置,需自己包装
流式SseEmitter + 自定义事件协议stream_mode 内置多种模式
Tool 注册Spring List 自动装配@tool 装饰器 + 列表传入
调试可见性自写日志 + SSE 事件verbose=True + LangGraph Studio
多租户TenantContextHolder 手动传递无概念,需自己处理
部署Spring Boot 微服务,天然融入现有体系FastAPI / 独立服务,需额外集成

六、判断

什么时候选自研 Java:

  • 业务处于 Spring Cloud 生态,需要与 Feign/MyBatis/Redis 无缝集成
  • 存在熔断、多租户、权限等横切需求,Resilience4j 等工具成熟
  • 循环逻辑简单,Tool 数量可控,无需状态持久化

什么时候 LangGraph 值得迁移:

  • 需要人工干预节点(审批、确认、二次输入)
  • 需要任务中断后从断点继续(长时间任务、多步规划)
  • Agent 逻辑复杂,节点有并行分支,图结构有助于推理

现实答案:对我们的项目来说,自研 Java 目前足够用。但在用 LangGraph 复现核心功能的过程中,学到的最有价值的一点是:把 Agent 的控制流画出来——即便最终不用 LangGraph,这种“图思维”也促使我将自研代码重新审视了一遍,并发现了几个隐藏的状态管理 bug。

工具只是手段,清晰的思维模型才是真正的价值。

参考

  • LangGraph 官方文档
  • LangGraph Human-in-the-loop

如果你也在做企业级 AI Agent,你会选自研还是接框架?欢迎评论区聊聊你的权衡逻辑。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策