Java自研ReAct Agent与LangGraph对比测评:半年实战取舍
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(尚未收到任何数据)→ 可无感切换到 DeepSeekhasData = 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 再继续——例如用户可以调整工具参数后再确认。
五、横向对比
| 维度 | 自研 Java | LangGraph |
|---|---|---|
| 循环控制 | 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,你会选自研还是接框架?欢迎评论区聊聊你的权衡逻辑。