首页
看点啥
插画图片
首页 看点啥 Java 自研 ReAct Agent 半年后,我借 LangGraph 重新审视设计取舍

Java 自研 ReAct Agent 半年后,我借 LangGraph 重新审视设计取舍

2026-07-29 0

自研 Java ReAct Agent 半年之后,我通过 LangGraph 检验这些设计取舍

背景

半年前我用 Java 从零实现了一个 ReAct Agent,接了 Kimi 和 DeepSeek 双模型,做了 25 个业务 Tool,跑在 Spring Boot 微服务里。最近在补 Python AI 生态,把 LangGraph 认真看了一遍。

看完后的直观感受是:并非 LangGraph 更优秀,而是它将你原本写在 while 循环中的内容全部显式呈现出来。

两种实现方式都足以让 Agent 先运行起来;但需求一旦包含任务中断、人工干预和状态持久化,真正的痛点才会由 LangGraph 处理。这是本文的核心判断,也是我以 Agent 应用 工程师身份形成的真实认识,并非一篇教程。

一、自研方案是什么结构

先介绍我自行编写的实现,方便后续进行比较。

核心结构

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 管理连接,前端发停止请求时 remove 掉 emitter,下一轮循环检测到连接不存在就退出——这是自研实现"中断"的方式,后面会对比 LangGraph 的中断。

图一:两类实现的结构比较

左边展示自研 Java 实现:while(true) 右侧的 LangGraph 把控制过程组织成显式有向图(StateGraph):函数构成各个节点,条件附着于边,TypedDict 承载状态,因此状态天然能够序列化。另一侧则把状态留在内存,以隐式循环运行,并交由 Resilience4j 处理熔断,代码中的整体控制流呈线性。

两种方案都可以跑通 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 标志位:

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) 里面的逻辑画成了一张图(即文章开头的图一)。功能上等价,但有两个重要差别:

差别一:状态属于一等公民

每执行一步,TypedDict 承载的内容都会更新;这正是 LangGraph 的 State,意味着:

我的 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 自动装配@tool 装饰器 + 通过列表传入
调试可见性自行编写日志 + SSE 事件verbose=True + LangGraph Studio
多租户手动传递交由 TenantContextHolder 完成没有相关概念,需要自行处理
部署现有体系可天然接入 Spring Boot 微服务额外集成是 FastAPI / 独立服务的必要环节

六、我的结论

适合选择自研 Java 的情况:

值得迁移到 LangGraph 的情况:

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

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

参考

自研和接入框架之间,你会怎样为企业级 AI Agent 作选择?欢迎把自己的权衡逻辑留在评论区。

喜欢(0)

上一篇

从零搭建云端AI智能体:阿里云ECS云服务器 OpenClaw 部署及百炼模型Token Plan适配教程

从零搭建云端AI智能体:阿里云ECS云服务器 OpenClaw 部署及百炼模型Token Plan适配教程

下一篇

我将 12 个经典方法论做成 AI Skill,现在一句话即可激活整套思维框架

我将 12 个经典方法论做成 AI Skill,现在一句话即可激活整套思维框架
猜你喜欢