Java 自研 ReAct Agent 半年后,我借 LangGraph 复盘这些设计取舍

作者:袖梨 2026-07-29

这些设计取舍,在 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<sessionId, SseEmitter> 管理连接,前端发停止请求时 remove 掉 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());}

步骤二:按长度截断

即便消息数量符合限制,传入 LLM 的上下文仍可能因总字符数超过 8000 而超出预算。处理方式是逐条移除消息,顺序从最旧的一条开始,直至总长度满足要求。

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

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

完成前两步截断后,可能出现以下情况:tool_result 消息仍被保留,但与它对应的 tool_call 截掉消息后,返回表现不同:DeepSeek 的回复会乱序,而 Kimi 直接给出 400。

修复方法:遍历消息列表,遇到 tool_result 时,检查前面有没有与之匹配的 tool_call id,若不存在就直接删掉,同时还要处理 content 为空的 assistant 消息——assistant 消息的 content 一旦为空,某些模型就会报错。

// 收集所有 assistant 消息里发出的 tool_call id(tool_call 在 assistant 消息的 tool_calls 数组里)Set<String> toolCallIds = messages.stream().filter(m -> "assistant".equals(m.getRole())).flatMap(m -> m.getToolCalls().stream()).map(ToolCall::getId).collect(Collectors.toSet());// 删除找不到对应 tool_call 的孤立 tool_resultmessages.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, ENDfrom typing import TypedDictclass AgentState(TypedDict):messages: listtool_calls: listgraph = 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 使用 TypedDict 表示 State,并在每一步对其进行更新。这代表:

  • 持久化:State 交由 Checkpointer(SQLite/Redis)存储,系统崩溃后可从断点恢复
  • 回放:提供任意一个 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 完整支持没有内置,需要自行包装
流式自定义事件协议 + SseEmitterstream_mode 内置多种模式
Tool 注册Spring List<AgentTool> 自动装配@tool 装饰器 + 通过列表传入
调试可见性自行编写日志 + SSE 事件verbose=True + LangGraph Studio
多租户手动传递交给 TenantContextHolder没有相关概念,需要自行处理
部署现有体系可天然融入 Spring Boot 微服务额外集成是 FastAPI / 独立服务所必需的

六、我的结论

适合选择自研 Java 的情况:

  • 要与现有 Feign/MyBatis/Redis 无缝集成,业务需处在 Spring Cloud 生态内
  • Resilience4j 等工具已相当成熟,适合熔断、多租户、权限等横切需求
  • 不必持久化状态的前提是循环逻辑简单,同时 Tool 数量可控

适合迁移至 LangGraph 的情况:

  • 审批、确认、二次输入等环节要求节点支持人工干预
  • 长时间任务、多步规划中,任务即使中断也要能从断点继续
  • 节点存在并行分支且 Agent 逻辑复杂时,推理可由图结构辅助

现实结论是:对我们的项目而言,自研 Java 目前已经够用。不过,在通过 LangGraph 复现核心功能时,我最有价值的收获是把 Agent 的控制流画出来。即使最终不用 LangGraph,这种图思维也促使我重新检查自研代码,并发现了几个隐蔽的状态管理 bug。

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

参考

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

企业级 AI Agent 若也在你的开发范围内,自研与接入框架之间你会如何选择?你的权衡逻辑,欢迎放到评论区交流。

相关文章

精彩推荐