最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
Java 自研 ReAct Agent 半年后,我借 LangGraph 复盘这些设计取舍
时间:2026-07-29 12:38:55 编辑:袖梨 来源:一聚教程网
这些设计取舍,在 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 后再继续,例如用户能够调整工具参数再作确认。
五、横向比较
| 维度 | 自研 Java | LangGraph |
|---|---|---|
| 循环控制 | while(true) 手动编写,完全可控 | 显式、可视化的 StateGraph 图 |
| 状态粒度 | 会话级(完整消息列表) | 每个节点后均可 checkpoint,属于步骤级 |
| 中断 / 恢复 | 靠 emitter remove 间接实现 | interrupt_before/after 原生支持 |
| 消息截断 | 自行实现三步逻辑(踩坑) | 没有内置;LangChain 有 trim_messages 但策略仍需自行配置 |
| 熔断降级 | 由 Resilience4j 完整支持 | 没有内置,需要自行包装 |
| 流式 | 自定义事件协议 + SseEmitter | stream_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 若也在你的开发范围内,自研与接入框架之间你会如何选择?你的权衡逻辑,欢迎放到评论区交流。
相关文章
- 一血敢死队跨服天梯可得到哪些奖励 07-29
- 劫后公司住房应当如何处理 07-29
- 放开那妖怪游戏有哪些兑换码 07-29
- 《上古卷轴5》炼金机制全面解析 附炼金模拟器 07-29
- 深海迷航2异星水域蝌蚪号蓝图位置解析 07-29
- 风之国世界锻造流派选择指南 07-29