Java 手写 AI Agent:ZenoAgent 核心设计与实现
Java 开发者通过 ZenoAgent 项目深入理解 AI Agent 内部机制。文章涵盖 DDD 分层架构、ReAct 循环手写实现、分布式 Human-in-the-loop 方案、流式思考引擎适配及 RAG 知识库优化。重点解决模型输出格式解析、并发执行延迟、人工确认信号同步等工程问题,提供高鲁棒性的 Agent 系统构建参考。

Java 开发者通过 ZenoAgent 项目深入理解 AI Agent 内部机制。文章涵盖 DDD 分层架构、ReAct 循环手写实现、分布式 Human-in-the-loop 方案、流式思考引擎适配及 RAG 知识库优化。重点解决模型输出格式解析、并发执行延迟、人工确认信号同步等工程问题,提供高鲁棒性的 Agent 系统构建参考。

市面上已经有了像 LangChain 和 AutoGen 这样非常优秀的成熟框架,它们功能强大且生态丰富。但作为一名对 AI 技术充满热情的 Java 开发者,我总觉得光是调用 API 或使用现成的 SDK,很难真正触达 Agent 技术的核心。
我发起 ZenoAgent 这个项目的初衷非常简单:我想通过亲手造一遍轮子,来弄清楚 Agent 到底是怎么跑起来的。
在这个过程中,我主要关注以下几个学习点:
ZenoAgent 是我学习过程中的一份'大作业',虽然它还很稚嫩,但这趟探索之旅让我收获颇丰。
ZenoAgent 采用经典的 DDD(领域驱动设计) 分层架构,确保了代码的高内聚低耦合。
Data & External
Backend (Spring Boot)
Infrastructure
Core Engine
HTTP/SSE
Call
Retrieve
Load Context
User
API Layer (Controller)
Application Service
ReAct Engine
Agent State Machine
Thinking Engine
Action Executor
Observation Engine
MCP Tool Client
RAG Enhancer
Memory System
Redis
Short-term Memory
Dist. Lock
Mysql
long-term Memory
PostgreSQL
pgvector
LLM Provider
(OpenAI/DeepSeek)
External Tools
Agent 聪明的关键在于如何构建 Prompt。ZenoAgent 的 ThinkingEngine 采用了 Hybrid Context Assembly(混合上下文组装) 的策略。
我们不再简单地将所有信息拼接到一个巨大的 User Prompt 中,而是充分利用 LLM 的 Native Messages 结构,将上下文拆解为三个部分:
List<ChatMessage>,保留 User/Assistant 的原生角色信息。这让模型能完美理解对话流转,且有利于 KV Cache 加速。// ThinkingEngine.java 核心重构
List<ChatMessage> messages = new ArrayList<>();
// 1. System Prompt (规则与约束)
messages.add(new SystemMessage(sysPrompt));
// 2. Native History (利用 KV Cache)
messages.addAll(context.getMessages());
// 3. Current Context (动态任务背景)
String currentStepPrompt = """
## 当前目标 %s
## 可用工具 %s
## 最近执行结果 %s
""".formatted(goal, tools, actionHistory);
messages.add(new UserMessage(currentStepPrompt));
这种设计既保留了 Agent 执行过程的灵活性(通过 Text 描述复杂的 Action/Result),又利用了 LLM 原生对话结构的优势(语义清晰、Token 复用)。
我们通过 Prompt Engineering 强制 LLM 遵循 Thought-Action-Observation 范式。
<thinking> 标签:在 System Prompt 中明确要求 LLM 先输出 XML 格式的思考过程,再输出 JSON 格式的动作指令。'三段式'输出协议(踩坑经验):
⚠️ 踩坑记录:最初我只要求模型输出
<thinking>和<actions>两部分。结果发现,许多模型(尤其是非顶流模型)在输出完</thinking>后,会习惯性地加一句'好的,根据以上分析,我现在的计划是…',然后才输出 JSON。这会导致 JSON 解析器直接报错。
最终解决方案:引入一个强制的'截断标记' <THINKING_DONE>。
<thinking>...</thinking><THINKING_DONE> <-- 强制刹车信号<actions>{...}</actions>后端检测到 <THINKING_DONE> 后,会触发状态流转,并忽略该标记之前的所有非 JSON 文本,确保 <actions> 的纯净。
| 阶段 | 状态 | 动作 |
|---|---|---|
| Stream Delta | Yes | Detect |
| State: THINKING | Yes | Extract JSON |
| Frontend UI | - | Start |
| State: PARSING_JSON | - | Action Executor |
这种机制不仅让 Agent 的决策更理性(Chain of Thought),还让用户能实时看到 AI 的'心路历程',体验感拉满。
为了让 LLM 稳定输出符合 Java 强类型要求的 JSON,我采取了一套四重保障机制:
```json 和 ``` 包裹。}。自愈式重试循环 (Self-Correction Loop)这是最关键的一道防线。当解析失败时,不要直接抛出异常,而是将错误堆栈作为 Prompt 反馈给模型。
try {
final Actions = parseThinkingResult(fullText);
} catch (LLMParseException e) {
// 将错误信息回传给模型,进行'反思修正'
String retryHint = "上次输出解析失败:" + e.getMessage() + ",请严格修正格式。";
// 进入下一轮循环
retry...
}
实测表明,这种机制能将复杂任务的执行成功率从 60% 提升至 95% 以上。
**鲁棒性解析 (Robust Parsing)**后端解析器不能有'洁癖'。我的 cleanJsonResponse 方法会自动清洗常见的 LLM 格式错误:
// PromptGuidedThinkingEngine.java
if (depth > 0 && start >= 0) {
log.warn("检测到 JSON 截断,尝试自动补全...");
// 自动补齐剩余的 '}'
}
**原生 Response Format (Native API)**对于支持 JSON Mode 的模型,在调用时直接注入约束:
// 强制模型以 JSON Object 模式输出
ResponseFormat.builder().type(ResponseFormatType.JSON).build();
Prompt 强约束 (Prompt Engineering)在 DECISION_FRAMEWORK_PROMPT 中,不仅定义 Schema,更要预判错误。例如,明确禁止 Markdown 代码块:
private static final String JSON_OUTPUT_FORMAT_PROMPT = """
## 输出格式强制约束(必须 100% 遵守,任何违规都会导致输出无效)
1. 输出内容必须是**纯合法 JSON 对象**,无任何非 JSON 文本(如<thinking>标签、注释、说明文字等);
2. JSON 对象包含 2 个必填顶级字段:
- thinking:字符串类型,填写你的逻辑推演过程(需清晰说明'为什么选择该动作类型''参数如何确定'等);
- actions:数组类型,仅包含 1 个动作指令对象(单轮仅输出 1 个动作);
3. actions 数组中的每个动作对象必须包含以下基础字段:
- actionType:字符串类型,仅允许取值【TOOL_CALL/RAG_RETRIEVE/LLM_GENERATE/DIRECT_RESPONSE】,严禁使用其他值;
- actionName:字符串类型,填写动作名称(如 search_weather/retrieve_knowledge/generate_content/reply_user);
- reasoning:字符串类型,填写选择该动作的简短理由(区别于 thinking 的详细推演);
4. 不同 actionType 需额外包含对应必填参数字段(缺失会判定为无效):
.....
""";
ZenoAgent 的核心并未采用现成的 Agent 框架,而是手写了一个基于状态机的 ReAct 循环引擎。在设计过程中,我重点思考了Action 的原子化与交互的低延迟。
为了让 LLM 的输出更可控,我将 Agent 的所有能力抽象为 4 种原子 Action:
这种设计让 ReAct 循环的边界非常清晰:Thinking -> Action List -> Execution -> Observation。
在早期的 ReAct 实践中,我发现一个严重的性能浪费: 对于简单的闲聊(如'你好')或知识性问答,标准 ReAct 模式太重了。
talk 工具,增加了一次毫无意义的 RTT(网络往返)。DIRECT_RESPONSE)。优化收益:
当模型判断无需使用工具时,它可以直接在决策阶段生成 DIRECT_RESPONSE 动作。这意味着'思考'和'回复'在一次 LLM 调用中同时完成了,对于简单场景,延迟降低了 50% 以上,且大幅节省了 Token。
传统的 ReAct 模式是严格串行的(Think -> Act -> Observe -> Think),这会导致严重的性能瓶颈。
ZenoAgent 对此进行了改良:允许 LLM 在一次 Thinking 过程中输出多个 Action。
例如,当用户问'对比北京和上海的天气'时,Agent 会同时生成两个查询动作,后端利用 Java 的 CompletableFuture 并行分发请求。
// ActionExecutor.java 核心逻辑
public List<ActionResult> executeParallel(List<AgentAction> actions) {
List<CompletableFuture<ActionResult>> futures = actions.stream()
.map(action -> CompletableFuture.supplyAsync(() -> execute(action), executor))
.toList();
// 并行等待所有结果
return futures.stream().map(CompletableFuture::join).toList();
}
这种设计将多步串行任务的耗时压缩到了最慢的那一个 Action 的耗时,响应速度提升显著。
在实现 DIRECT_RESPONSE 时,我引入了一个 isComplete 字段来控制循环是否结束。看似简单,却踩了一个大坑。
问题复现:
当用户说'查天气',Agent 需要反问'请问是哪个城市?'。
此时,Agent 认为任务并未完成,于是将 isComplete 设为 false。
结果:后端 ReAct 引擎收到 false 后,认为 Agent 还要继续干活,于是不等待用户回复,直接进入下一轮循环。Agent 发现没拿到城市名,又问一遍,导致死循环。
深度反思:
大模型对 Complete(完成)的理解是'任务终结'。但在交互式场景中,我们需要的是'控制权移交'。
解决方案:
我重构了 Prompt 中对 isComplete 的语义定义,从'任务状态'转变为'流控制状态':
isComplete=true:移交控制权。
isComplete=false:保持控制权。
修正后的 Prompt 片段:
// PromptGuidedThinkingEngine.java
3. 需要向用户输出内容时使用 DIRECT_RESPONSE,并通过 isComplete 字段控制流程:
- isComplete=true:**终止本轮**。用于:1. 任务彻底完成;2.**需要提问并等待用户回复**。此时意味着 Agent 暂停工作移交控制权给用户;
- isComplete=false:**过程通知**。用于:任务仍在进行中,仅向用户发送进度提示,**不等待用户回复**,流程自动继续。
通过这种语义重构,配合 Few-Shot 示例(明确展示'反问用户'时设为 true),Agent 终于学会了在适当的时候'闭嘴等待'。
在企业级应用中,敏感操作(如删除文件、转账)必须经过人工确认。但在 集群部署 场景下,这面临一个巨大挑战:
问题:Agent 的推理线程(持有 SSE 连接)可能在 Pod A 上运行,而用户点击'确认'的 HTTP 请求可能打到了 Pod B。如何让 Pod A 知道用户在 Pod B 做了确认?
解决方案:基于 Redis 阻塞队列的分布式信号量
ZenoAgent 利用 Redisson 实现了跨实例的线程挂起与唤醒。
Backend (Pod B) Redis Backend (Pod A) Frontend User
Agent runs thinking loop...
Thread BLOCKED on Redis Queue
Received by different Pod!
Thread Resumes
Decide to call 'delete_file'
Create BlockingQueue (key=exec_123)
SSE: confirmation_request (id=exec_123)
Click "Approve"
POST /confirm (id=exec_123, action=APPROVE)
Queue.push("APPROVED")
Unblock: "APPROVED"
Execute Tool
SSE: tool_result
代码实现参考:
// ToolConfirmationManager.java
public ToolExecutionDecision waitForDecision(String executionId) {
RBlockingQueue<ToolExecutionDecision> queue = redissonClient.getBlockingQueue(KEY_PREFIX + executionId);
// 阻塞等待用户决策,支持超时自动拒绝
return queue.poll(60, TimeUnit.SECONDS);
}
为了让用户感知到 AI 的'思考过程',ZenoAgent 实现了基于 Prompt 注入和原生支持的思考引擎,支持根据配置自动注入,分别适配普通模型和推理模型。
对于普通模型(如 GPT-4, Qwen-2.5),我们通过 System Prompt 强制要求模型在输出最终 JSON 动作前,先在 thinking 字段中输出思维链。
核心原理: 将'思考'作为 JSON 结构的一部分。虽然这违反了'纯数据'的原则,但能确保模型在生成 Action 之前必须先进行逻辑推演(CoT)。
实现挑战:
在流式响应中,JSON 是逐字生成的。我们需要在后端实时解析这个不完整的 JSON 字符串,提取出 thinking 字段的增量内容推送到前端。
代码参考: PromptGuidedThinkingEngine.java 使用了状态机来解析流式 JSON:
// 伪代码:从流式 JSON 中提取 thinking 字段
if (token.contains("\"thinking\"")) {
isThinking = true;
}
if (isThinking && !token.contains("\"actions\"")) {
sseEmitter.send(token); // 实时推送思考过程
}
随着 DeepSeek-R1 和 OpenAI o1 的发布,模型开始原生支持'推理'。这种模式下,思考过程不再占用 content 字段,而是通过独立的 reasoning 通道传输。
核心原理:
模型在输出 Response 之前,会先在一个独立的 Channel 中输出思维链(CoT)。这个过程对 Prompt 是透明的,属于模型原本的能力。客户端需要监听特定的 API 字段(如 reasoning_content)来获取这部分数据。
代码参考: OpenAIReasoningThinkingEngine.java 实现了对原生推理字段的监听。
实战遇到的协议不匹配问题: 除了模拟思考,我也尝试接入模型原生的思考能力(如 DeepSeek-R1),但在对接 Ollama 时遇到了协议标准不统一的深坑。
问题复现: 我在 OpenAIReasoningThinkingEngine.java 中尝试解析流式响应,发现虽然模型在'思考',但后端却拿不到内容。
排查过程:
源码比对:LangChain4j 框架底层期望的字段却是 reasoning_content。
// LangChain4j Delta.java
@JsonProperty
private final String reasoningContent;
// 字段名不匹配!
抓包分析:Ollama 的 API 返回字段是 reasoning。
{"choices":[{"delta":{"role":"assistant","reasoning":"好的,用户想要..."// Ollama 使用 reasoning}}]}
后果与反思:
这意味着,如果我们直接用 LangChain4j 调用 Ollama 部署的 R1 模型,模型在耗费算力进行'昂贵'的思考(首 Token 延迟高),但客户端却直接丢弃了这部分数据。这不仅是体验缺失,更是算力的无端浪费。目前的解决方案只能是通过自定义 StreamingResponseHandler 手动适配字段。
在实现方案一(Prompt 模拟)时,我遇到了一个非常棘手的问题:前端偶尔会闪现出 </thinking 这样残缺的标签。
问题根源:LLM 的 Token 是逐字吐出的。当模型输出 </thinking> 时,可能先吐出 </,此时后端检测不到完整标签,误以为是普通文本就直接发给了前端;紧接着模型吐出 thinking>,后端这才反应过来是标签,拦截了后半段,但前半段 </ 已经泄露了。
解决方案:引入 Lookahead Buffer(前缀缓冲) 机制。
我们在后端增加了一个判断逻辑:在流未结束时,如果缓冲区末尾匹配 </thinking> 的任意前缀(如 <、</、</t),先扣留这部分内容不发。只有当下一个 Token 进来,证明它拼不成完整标签时,才把缓冲区的内容释放出去。
// ThinkingEngine.java
if (thinkingEndIdx == -1 && !isComplete) {
// 检查末尾是否是 </thinking> 的一部分
int bufferLen = calculatePartialTagMatchLength(currentContent);
if (bufferLen > 0) {
// 扣留这部分内容,暂不发送
logicalEnd -= bufferLen;
}
}
为了解决这个问题,我重构了事件系统,不再发送自然语言,而是发送结构化状态事件(如 agent:status:thinking_process)。前端接收到事件 ID 后,再根据当前用户的语言设置(zh-CN/en-US)渲染对应的文案。这不仅解耦了前后端,也让国际化变得异常简单。
ZenoAgent 的 RAG 模块(RAGEnhancer)不仅仅是简单的向量相似度搜索,而是构建了一套完整的知识流转体系。
我们使用 Apache Tika 进行全格式解析,并配合 Recursive Character Splitter 进行语义分块。
Ingestion Pipeline
Tika Parser
Recursive Splitter
MetaData Injection
Embedding Model
pgvector
User Upload
Document Service
Raw Text
Text Segments
Segments + Meta
Vectors
PostgreSQL
knowledgeId 进行物理/逻辑隔离,确保多用户数据安全。// RAGEnhancer.java 核心逻辑
embeddingStore.search(EmbeddingSearchRequest.builder()
.queryEmbedding(queryVector)
.filter(metadataKey("knowledgeId").isEqualTo(currentKnowledgeId)) // 关键:租户隔离
.maxResults(5)
.minScore(0.75) // 过滤低质量结果
.build());
ZenoAgent 不仅能执行工具,还能从错误中学习。当工具调用失败(如参数错误、网络超时)时,Agent 不会直接崩溃,而是进入自愈循环。
ActionExecutor 捕获异常,生成包含错误信息的 ActionResult。AgentContext 中。Thinking 阶段,Prompt 会包含:'上一步操作失败,错误信息:Invalid argument…'。这种机制让 ZenoAgent 具备了极强的鲁棒性,能够自动应对 API 变更或临时性网络故障。
基于 MCP (Model Context Protocol),ZenoAgent 实现了工具的动态管理。
McpConfigLoader 监听配置文件变更。一旦修改 mcp.json,系统会自动重新连接 MCP Server 并刷新工具列表,无需重启服务。为了让大家更直观地感受 ZenoAgent,这里放几张系统截图:
[图片:欢迎页界面]
[图片:智能对话界面]
[图片:Agent 配置(MCP)界面]
[图片:知识库界面]
ZenoAgent 是我边学习边实践的产物。通过 Spring Boot 的工程化能力,我们可以构建出比 Python 原型更稳定、更易于扩展的 Agent 系统。
目前项目已开源,欢迎感兴趣的朋友 Star ⭐️ 和 PR!
让我们一起探索 AI Agent 的无限可能!

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online