背景:710 行的「手工耿」Agent

EduMind 项目的 Agent 层经历了三代:

实现 Agent 循环 工具调用 HTTP 客户端 状态
1 OpenClaw 网关 网关托管 网关路由 RestClient 已删除
2 AgentLoopServiceImpl 手写 while 循环 手动 JSON Schema RestClient/WebClient 本文主角
3 LangChain4jAgentService AiServices 内置 @Tool 注解 ChatModel 当前

第二代 AgentLoopServiceImpl 是一个 714 行的单体类,核心逻辑大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 手写 Agent 循环(简化版)
while (step < maxSteps) {
step++;
// ① 手写请求体(含手动构建的 tools JSON Schema)
Map<String, Object> body = buildRequestBody(messages, toolSchemas, false);

// ② 调 LLM
Map<String, Object> response = restClient.post()
.uri("/chat/completions").body(body).retrieve().body(Map.class);

// ③ 解析 choices[0].message.tool_calls
List<Map<String, Object>> toolCalls = extractToolCalls(response);

if (toolCalls != null) {
// ④ 串行执行工具(后来改并行)
for (Map<String, Object> tc : toolCalls) {
String result = executeTool(tc.name, tc.args, sessionId);
messages.add(toolResult(result));
}
continue; // 回步骤①
}
// ⑤ 拿到 content → 返回
return response.choices[0].message.content;
}

这代码能跑,但问题也很明显:

  • while 循环:LangChain4j 和 Spring AI 都不需要你写,框架内置
  • 手动 JSON SchemabuildToolSchemas() 方法 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
2
3
4
5
6
7
8
9
10
// 定义接口,框架生成动态代理——Agent 循环、工具调用、记忆管理全自动
interface TeachingAgent {
String chat(@MemoryId String sessionId, @UserMessage String message);
}

TeachingAgent agent = AiServices.builder(TeachingAgent.class)
.chatModel(chatModel)
.tools(toolBridge) // @Tool 方法自动注册
.chatMemoryProvider(...) // 按 sessionId 隔离会话记忆
.build();

迁移过程:踩过的坑

坑一:API 大面积改名

LangChain4j 1.15.1 相比文档和教程里的版本,API 改了不少:

1
2
3
4
5
6
ChatLanguageModel   → ChatModel
StreamingChatLanguageModel → StreamingChatModel
.generate(messages) → .chat(messages)
TokenStream.onNext → TokenStream.onPartialResponse
TokenStream.onComplete → TokenStream.onCompleteResponse
maxSequentialToolInvocations → maxSequentialToolsInvocations (多一个 s)

都是编译期就能发现的,不算大坑,但改起来有十几个地方。

坑二:@MemoryId 强制要求 ChatMemoryProvider

TeachingAgent 接口用了 @MemoryId 做会话隔离。为结构化输出场景(作业批改)建了一个无状态的 agent 实例:

1
2
3
4
// ❌ 这样会报 IllegalConfigurationException
this.statelessAgent = AiServices.builder(TeachingAgent.class)
.chatModel(chatModel)
.build(); // 有 @MemoryId 但没配 ChatMemoryProvider → 启动爆炸

教训:带 @MemoryId 的接口必须配 chatMemoryProvider。不想配就单独建一个不带 @MemoryId 的接口。

坑三:自我反思的幻觉工具调用

我们在 AiServices 拿到最终回答后,会再用 ChatModel.chat() 做一轮自我反思审查。但 DeepSeek 模型在推理过程中可能决定”我需要调工具验证数据”,然后输出:

1
<tool_call>searchKnowledge</tool_call>

反思调用没带 tools,模型输出了一个字符串形式的工具调用标记——505 字的正常回答被替换成 38 字的坏输出。

修复:三层防护

1
2
3
① Prompt 明确禁止:禁止调用任何工具、禁止输出工具调用指令
② 关键词检测:输出含 <tool_call 就直接丢弃
③ 长度兜底:反思结果 < 原稿 30% → 丢弃

顺手做的优化

自我反思:不要每句话都反思

最初自我反思无条件触发。用户说”下午好”,AI 回 72 字,反思调用再耗 16 秒 + 900 token,返回的和原稿一模一样。

加了双重条件:

1
2
3
4
5
6
// 仅当工具被调用过 AND 回答 >= 100 字才触发反思
if (llmProperties.isSelfReflection()
&& ToolBridge.wasToolCalled() // ThreadLocal 标记
&& result.length() >= 100) {
result = performSelfReflection(...);
}

OneBot Markdown 剥离

AI 回复常带 Markdown 格式(**加粗**# 标题`代码`、表格等),QQ 文本框不渲染这些符号。加了一个 stripMarkdown() 方法在发送前剥离所有 Markdown 标记,12 条正则,50 行代码。

最终成果

1
2
3
4
5
6
7
迁移前:  AgentLoopServiceImpl(714) + OpenClawServiceImpl(406) + OpenClawProperties(25)
= 1,145 行

迁移后: LangChain4jAgentService(456) + ToolBridge(112) + Config(68) + 3个接口(62)
= 698 行

净减少: 447 行 (-39%)

代码少了四成,但能力反而多了:

能力 手写时代 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 行,少的不只是代码,更是心智负担。