手写 Agent 循环到 LangChain4j:一场代码减半、能力翻倍的迁移
背景:710 行的「手工耿」Agent
EduMind 项目的 Agent 层经历了三代:
| 代 | 实现 | Agent 循环 | 工具调用 | HTTP 客户端 | 状态 |
|---|---|---|---|---|---|
| 1 | OpenClaw 网关 | 网关托管 | 网关路由 | RestClient | 已删除 |
| 2 | AgentLoopServiceImpl | 手写 while 循环 | 手动 JSON Schema | RestClient/WebClient | 本文主角 |
| 3 | LangChain4jAgentService | AiServices 内置 | @Tool 注解 | ChatModel | 当前 |
第二代 AgentLoopServiceImpl 是一个 714 行的单体类,核心逻辑大概长这样:
1 | // 手写 Agent 循环(简化版) |
这代码能跑,但问题也很明显:
- while 循环:LangChain4j 和 Spring AI 都不需要你写,框架内置
- 手动 JSON Schema:
buildToolSchemas()方法 30 行,把ToolDefinition.inputSchema()转成 OpenAI function calling 格式——这件事框架可以自动做 - SSE 解析:
parseDeltaContent()手写data:行切割 + JSON 解析——框架有现成的TokenStream - 工具并行化:自己用
CompletableFuture+ 虚拟线程池搭的——框架原生支持
选型:Spring AI vs LangChain4j
两个框架都考察了,最终选 LangChain4j。理由很直接:
| 维度 | Spring AI (1.1.7) | LangChain4j (1.15.1) |
|---|---|---|
| Agent 能力 | ⚠️ Advisors 拦截器链(参考实现级别) | ✅ AiServices 内置循环 + langchain4j-agentic 实验模块 |
| 工具调用 | @Tool 注解 |
@Tool 注解 + 自动并行执行 |
| 多 Agent | ❌ 无原生支持 | ✅ SupervisorAgent + @LoopAgent |
| 声明式 AI | ChatClient API | ✅ @AiService 接口自动代理 |
| 流式 | Flux 手动处理 | TokenStream → Flux 桥接 |
| 可观测性 | ✅ Micrometer 原生 | ⚠️ 需手动配置 |
| Spring 集成 | ✅ 深度绑定 | ✅ 支持但不强制 |
核心差距在 Agent 编排。Spring AI 的 Agent 是通过 Advisors 链模拟的——先加一个 MessageChatMemoryAdvisor、再加一个 ToolCallingAdvisor,行为上像 Agent 但缺少原生循环、缺少声明式 Agent 定义、缺少多 Agent 路由。
LangChain4j 的 AiServices 不需要写一行循环代码:
1 | // 定义接口,框架生成动态代理——Agent 循环、工具调用、记忆管理全自动 |
迁移过程:踩过的坑
坑一:API 大面积改名
LangChain4j 1.15.1 相比文档和教程里的版本,API 改了不少:
1 | ChatLanguageModel → ChatModel |
都是编译期就能发现的,不算大坑,但改起来有十几个地方。
坑二:@MemoryId 强制要求 ChatMemoryProvider
TeachingAgent 接口用了 @MemoryId 做会话隔离。为结构化输出场景(作业批改)建了一个无状态的 agent 实例:
1 | // ❌ 这样会报 IllegalConfigurationException |
教训:带 @MemoryId 的接口必须配 chatMemoryProvider。不想配就单独建一个不带 @MemoryId 的接口。
坑三:自我反思的幻觉工具调用
我们在 AiServices 拿到最终回答后,会再用 ChatModel.chat() 做一轮自我反思审查。但 DeepSeek 模型在推理过程中可能决定”我需要调工具验证数据”,然后输出:
1 | <tool_call>searchKnowledge</tool_call> |
反思调用没带 tools,模型输出了一个字符串形式的工具调用标记——505 字的正常回答被替换成 38 字的坏输出。
修复:三层防护
1 | ① Prompt 明确禁止:禁止调用任何工具、禁止输出工具调用指令 |
顺手做的优化
自我反思:不要每句话都反思
最初自我反思无条件触发。用户说”下午好”,AI 回 72 字,反思调用再耗 16 秒 + 900 token,返回的和原稿一模一样。
加了双重条件:
1 | // 仅当工具被调用过 AND 回答 >= 100 字才触发反思 |
OneBot Markdown 剥离
AI 回复常带 Markdown 格式(**加粗**、# 标题、`代码`、表格等),QQ 文本框不渲染这些符号。加了一个 stripMarkdown() 方法在发送前剥离所有 Markdown 标记,12 条正则,50 行代码。
最终成果
1 | 迁移前: AgentLoopServiceImpl(714) + OpenClawServiceImpl(406) + OpenClawProperties(25) |
代码少了四成,但能力反而多了:
| 能力 | 手写时代 | LangChain4j |
|---|---|---|
| Agent 循环 | while 循环,30 行 | AiServices 内置 |
| 工具 Schema | 手写 JSON,20 行 | @Tool 自动生成 |
| 工具并行 | CompletableFuture,40 行 | executeToolsConcurrently() |
| SSE 解析 | 手写切割,20 行 | TokenStream → Flux |
| 会话记忆 | 无 | ChatMemory + @MemoryId |
| 多 Agent | 无 | langchain4j-agentic(待接入) |
总结
最初犹豫要不要上框架——毕竟手写版本也能跑,而且完全可控。但迁移完后回头看,手写 Agent 循环本质上是在应用层重写了一个简化版的 LangChain4j——而且写得远不如框架好(没有内存管理、没有自动并行、没有声明式接口)。
如果你也在纠结要不要把手写 Agent 换成框架,我的建议是:只要你的 Agent 逻辑超过了「调一次 LLM + 返回结果」的简单程度,就值得换。 用框架不是放弃控制,而是把控制权从”怎么调 API”上升到”怎么编排 Agent”的层次。
710 → 456 行,少的不只是代码,更是心智负担。






