如果你最近在 Java 项目里落地 AI Agent,大概率会遇到一个现象:业务功能看起来不复杂,但代码越写越重。我第一次把业务分析、知识库检索、多轮问答串起来时,一个while循环就能跑通整个链路,可一旦接入知识库、报表工具和复核逻辑,循环里的if/else就开始失控。这其实不是模型能力的问题,而是工程范式的问题。
本文结合“业务分析 → 知识库 → Agent”这条真实落地链路,梳理从 Loop 到 Graph Engineering 的演进思路,并给出可复制的 Java 工程化方案。内容覆盖概念拆解、核心代码、完整实战和常见坑点,适合有一定 Java 基础、正在做 Agent 应用开发的同学。
1. 背景与核心概念
1.1 为什么 Java 生态需要 Agent 工程化
Agent 可以理解为“一个能自主使用工具的 LLM 应用”。它不同于单次 Prompt 调用,会经历规划、调用工具、根据结果继续规划的多轮过程。落到具体业务场景里,Agent 往往不只是回答一个问题,而是需要:
- 理解用户输入的业务诉求;
- 检索企业内部知识库,找到口径、规范、历史结论;
- 调用报表系统或数据库工具,拿到真实数据;
- 综合所有信息生成可读结论;
- 对结论做必要的校验和兜底。
这几个环节每一环都依赖外部系统,链路一旦变长,工程问题就集中暴露出来:状态如何保存?失败如何重试?分支如何管理?Token 成本如何控制?如果只是把 ChatGPT 的 API 包一层,在代码里写死一个循环,很难应对这些问题。
我把这类问题统称为“Agent 工程化”。Java 在后端业务系统中占据大量份额,企业级的权限、数据、报表能力也大多围绕 Java 构建。因此,在 Java 体系内做好 Agent 工程化,比跨语言方案更能直接复用现有服务能力。
1.2 Loop 模式的本质与困境
早期 Agent 项目大多采用 Loop 模式,也叫 ReAct 循环。它的核心逻辑非常短:
- 把系统提示词、用户问题、历史消息放进同一个消息列表;
- 调用 LLM,让模型返回文本或“工具调用请求”;
- 如果模型请求调用工具,就执行对应工具,把结果追加到消息列表;
- 回到第 2 步,直到模型不再请求工具,直接给出最终回答。
从代码结构上看,Loop 模式像一条可以自我循环的流水线。它适合“单链路、少分支”的简单任务,比如让 Agent 查询天气、计算数学题、调用一个内部接口。
但业务分析场景不是单链路。一个完整的分析流程通常包含条件判断、多路并行、失败重试、结果合并,Loop 模式很难优雅表达这些控制逻辑。我在初版代码里就遇到过几个具体问题:
- 循环退出的判断完全依赖 LLM 的自觉,缺少硬性边界;
- 多轮工具调用会让消息列表越来越长,Token 成本失控;
- 分支逻辑被迫堆在循环内部的
if/else里,代码难以维护和测试; - 无法精准重试某个失败的工具调用,只能整轮重跑;
- 可观测性弱,排查问题只能靠翻日志。
这些问题不是某一次 LLM 回答质量不高造成的,而是“循环”这个基础结构导致的。
1.3 Graph Engineering 做了什么改进
Graph Engineering 的核心思想是:把 Agent 的任务执行过程建模成一张有向图。每个环节是一个节点(Node),节点之间的流转是边(Edge),节点之间通过共享状态(State)传递数据。执行不再是一个大循环,而是状态在图上按条件流动。
举个例子,一个业务分析 Agent 的流程可以建模为:
用户提问 → 意图识别节点 → 指标口径检索节点(知识库) → 是否需要查询数据? 是 → 数据查询节点 否 → 直接进入报告生成 → 报告生成节点 → 质量校验节点 → 输出结果在这张图中,每个节点只做一件明确的事,节点之间的流转通过条件边显式配置。相比 Loop 模式,它的可读性、可控性和可观测性都更强。
Loop 与 Graph 的核心差异可以总结为:
| 对比维度 | Loop 模式 | Graph 模式 |
|---|---|---|
| 退出条件 | 由模型回答判断,边界模糊 | 有明确的终止节点 |
| 分支逻辑 | 循环内堆if/else | 条件边显式配置 |
| 状态管理 | 藏在消息列表里 | 统一的状态对象 |
| 并行能力 | 极难实现 | 天然支持多节点并行 |
| 失败重试 | 整轮重跑 | 可局部处理节点异常 |
| 可观测性 | 依赖日志 | 节点级埋点和链路追踪 |
2. 技术选型与环境准备
2.1 Java 技术栈说明
在开始写代码之前,先说清楚本文示例的技术栈:
- JDK:17 及以上。高版本带来的
record、var、增强的switch对写图引擎很友好。实际项目请按公司规范选择,这里以 17 为参考。 - 构建工具:Maven。
- Web 框架:Spring Boot 3.x(可选)。如果 Agent 需要以接口形式对外暴露,建议使用 Spring Boot;本文示例为了聚焦工程结构,默认不写完整的 Controller。
- LLM 接入:OpenAI 兼容接口或大模型平台 SDK。为了避免依赖某个特定厂商的 SDK,我在代码里抽象一个
LlmPort接口。 - 知识库:向量数据库,如 Milvus、pgvector、Elasticsearch、Redis 等。本文示例使用内存版向量存储,方便本地复现,生产环境替换为真实向量库即可。
- Agent 编排:可以使用 LangGraph4j、Spring AI AgentRuntime,也可以自研轻量图引擎。本文以自研轻量引擎为例,方便看清楚原理。
版本说明一下:该领域变化较快,不同版本之间的 API 差异明显。本文的重点是工程思路和可运行的最小实现,实际项目中的依赖版本需要根据你使用的框架和模型平台调整。
2.2 项目结构与依赖
假设项目名为agent-demo,目录结构如下:
agent-demo ├── pom.xml └── src/main/java/com/demo/agent ├── graph // 图引擎与节点定义 ├── llm // LLM 调用抽象 ├── knowledge // 知识库切分、向量化、检索 ├── model // 状态对象与数据模型 └── Application.java如果使用 Maven,核心依赖大致如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.x</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <!-- Web 层,按需引入 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 如果接入 OpenAI 兼容接口,可以使用 Spring AI; 版本不同 API 差异较大,本文示例只做抽象,不依赖具体实现 --> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>${spring-ai.version}</version> </dependency> <!-- 测试依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>如果你不使用 Spring Boot,可以把这些依赖全部换成普通 Java 工程。下面所有的核心代码都只依赖 JDK 自带 API 和简单接口,方便你直接复制运行。
3. 从 Loop 到 Graph 的代码级演进
3.1 Loop 模式最小实现
先来看一个典型的 Loop 模式实现。这里定义一个简单的LlmPort接口,屏蔽具体模型 SDK:
// 文件路径:src/main/java/com/demo/agent/llm/LlmPort.java package com.demo.agent.llm; import java.util.List; public interface LlmPort { /** * 发送消息列表,返回模型回答。 * 实际项目中,可以在此处接入 OpenAI、通义、文心等模型的 SDK。 */ String chat(List<ChatMessage> messages); }为了不引入复杂依赖,ChatMessage可以设计为带role和content的简单类。假设工具调用已经通过特殊标记嵌入在模型返回文本中,那么 Loop 模式的 Agent 核心代码如下:
// 文件路径:src/main/java/com/demo/agent/loop/LoopAgent.java package com.demo.agent.loop; import com.demo.agent.llm.LlmPort; import java.util.ArrayList; import java.util.List; public class LoopAgent { private final LlmPort llm; public LoopAgent(LlmPort llm) { this.llm = llm; } public String run(String userGoal) { List<String> messages = new ArrayList<>(); messages.add("system: 你是业务分析助手,你可以调用工具完成复杂任务。"); messages.add("user: " + userGoal); int maxRounds = 8; int round = 0; while (round < maxRounds) { // 1. 调用模型 String answer = llm.chat(toMessages(messages)); // 2. 判断模型是否需要调用工具 ToolPlan plan = ToolPlanParser.parse(answer); if (plan.getActions().isEmpty()) { // 模型认为任务完成,直接返回 return answer; } // 3. 如果有工具调用,逐个执行并回填结果 for (ToolAction action : plan.getActions()) { String result = executeTool(action); messages.add("toolResult(" + action.getId() + "): " + result); } round++; } throw new IllegalStateException("超过最大迭代轮次,Agent 未能在限定轮次内完成"); } }这段代码虽然能跑,但它有很明显的结构性问题:
- 模型的任何一次错误输出都可能破坏消息列表结构;
- 多个工具调用之间如果存在依赖关系,Loop 无法表达;
- 如果某个工具失败,只能整轮重跑,代价很大;
- 如果不看日志,完全无法知道当前处于哪一步。
也就是说,Loop 模式适合“验证想法”,不适合“承载复杂业务”。
3.2 图引擎核心抽象
Graph Engineering 的第一步是定义一个足够小巧的图执行引擎。我在生产环境中用过自研引擎,也调研过 LangGraph4j,两者的本质是一样的。下面给出一个非常精简的版本。
首先定义节点接口:
// 文件路径:src/main/java/com/demo/agent/graph/GraphNode.java package com.demo.agent.graph; public interface GraphNode<S> { S execute(S state); }再定义边。边有一个“可选的条件”,只有条件满足时才会走到目标节点:
// 文件路径:src/main/java/com/demo/agent/graph/Edge.java package com.demo.agent.graph; import java.util.function.Predicate; public class Edge<S> { final String from; final String to; final Predicate<S> condition; public Edge(String from, String to, Predicate<S> condition) { this.from = from; this.to = to; this.condition = condition; } }图引擎本身只做三件事:按节点名执行节点、计算下一个节点、检测终止。这里的设计取舍是:状态对象是可变容器,节点可以直接修改状态,这样在顺序执行时代码最直观。
// 文件路径:src/main/java/com/demo/agent/graph/GraphEngine.java package com.demo.agent.graph; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; public class GraphEngine<S> { private final Map<String, GraphNode<S>> nodes; private final Map<String, List<Edge<S>>> outEdgeMap; private final String startNode; private final Set<String> endNodes; public GraphEngine(Map<String, GraphNode<S>> nodes, List<Edge<S>> edges, String startNode, Set<String> endNodes) { this.nodes = nodes; this.outEdgeMap = edges.stream() .collect(Collectors.groupingBy(e -> e.from)); this.startNode = startNode; this.endNodes = endNodes; } public S run(S initialState) { String current = startNode; S state = initialState; while (!endNodes.contains(current)) { GraphNode<S> node = nodes.get(current); if (node == null) { throw new IllegalStateException("图中不存在节点: " + current); } // 这里可以插入日志埋点,记录节点调用前后状态 state = node.execute(state); current = next(current, state); } return state; } private String next(String from, S state) { List<Edge<S>> edges = outEdgeMap.get(from); if (edges == null || edges.isEmpty()) { throw new IllegalStateException("节点没有出边,且不是终止节点: " + from); } for (Edge<S> edge : edges) { if (edge.condition == null || edge.condition.test(state)) { return edge.to; } } throw new IllegalStateException("所有条件边均不满足: " + from); } }为了方便装配,可以再提供一个 Builder:
// 文件路径:src/main/java/com/demo/agent/graph/GraphBuilder.java package com.demo.agent.graph; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Predicate; public class GraphBuilder<S> { private final Map<String, GraphNode<S>> nodes = new LinkedHashMap<>(); private final List<Edge<S>> edges = new ArrayList<>(); private final Set<String> endNodes = new HashSet<>(); private String startNode; public GraphBuilder<S> node(String name, GraphNode<S> node) { nodes.put(name, node); return this; } public GraphBuilder<S> edge(String from, String to) { edges.add(new Edge<>(from, to, null)); return this; } public GraphBuilder<S> condition(String from, String to, Predicate<S> condition) { edges.add(new Edge<>(from, to, condition)); return this; } public GraphBuilder<S> start(String startNode) { this.startNode = startNode; return this; } public GraphBuilder<S> end(String endNode) { endNodes.add(endNode); return this; } public GraphEngine<S> build() { if (startNode == null || !nodes.containsKey(startNode)) { throw new IllegalStateException("缺少有效的 start 节点"); } return new GraphEngine<>(nodes, edges, startNode, endNodes); } }这样我们就拥有了一个可用的 Graph 基础设施。它虽然简单,但已经足够表达大部分顺序、条件分支场景。如果要支持并行节点,可以在这个基础上引入“扇出/汇聚”两类特殊节点,这里不展开。
3.3 两种模式的差异对比
把 Loop 和 Graph 放在一起对比,选型思路就会清晰很多:
- 如果任务是“单链条、步骤固定、不涉及复杂分支”,Loop 足够,甚至直接一次 LLM 调用就可以;
- 如果任务是“业务分析 类”的多阶段流程,存在“是否查询数据”“是否重试”“是否合并结果”等分支,Graph 更合适;
- 如果团队已经开始开发多个独立 Agent,计划让它们协作完成复杂任务,Graph 几乎是必然选择。
从工程演进角度看,我建议团队不要一上来就上 Graph。先用 Loop 跑通产品验证,当代码出现“循环里堆积了大量业务分支”的坏味道时,再把流程迁移到 Graph。这个过程我经历过:从 Loop 迁到 Graph 最耗时的不是代码,而是重新梳理边界和状态。
4. 实战:业务分析 → 知识库 → Agent 完整案例
4.1 业务场景与流程设计
假设我们要做一个面向业务团队的“经营分析助手”。业务人员输入一句话:
上季度华东区域销售额为什么下降了?Agent 需要完成以下步骤:
- 识别用户的真实意图,把问题拆解为“指标定义确认”“数据查询”“结论分析”三个子任务;
- 从知识库中检索“销售额”“华东区域”“季度”对应的指标口径和业务背景;
- 根据知识库返回的结果,判断是否需要调用报表查询工具;
- 如果需要,查询数据库或报表服务,拿到实际数据;
- 综合知识库、查询结果,生成最终分析报告;
- 对报告做一次质量校验,发现信息缺失时自动重试或补充。
对应到图上,节点设置如下:
| 节点名 | 职责 |
|---|---|
intent | 意图识别与问题拆解 |
knowledge | 知识库检索 |
dataQuery | 数据查询(可选) |
report | 报告生成 |
quality | 质量校验与最终输出 |
边与条件:
intent→knowledge:意图识别完成后,必定进入知识库检索;knowledge→dataQuery:判断条件为“需要查询数据”;knowledge→report:判断条件为“不需要查询数据”;dataQuery→report:数据查询完成后,进入报告生成;report→quality:报告生成后,必须做质量校验;quality为终止节点。
4.2 知识库构建与检索
知识库是整个分析链路的地基。企业里的知识往往散落在文档、表格、历史报告和 IM 聊天记录里,不能直接喂给大模型,需要经过“切分 → 向量化 → 存储 → 检索”。
首先,我们用一个工具类做文本切分。切分策略有很多种,这里按“固定长度 + 句子边界”切分,保证每块内容相对完整:
// 文件路径:src/main/java/com/demo/agent/knowledge/TextSplitter.java package com.demo.agent.knowledge; import java.util.ArrayList; import java.util.List; public class TextSplitter { public static List<String> split(String content, int chunkSize, int overlap) { if (chunkSize <= overlap) { throw new IllegalArgumentException("chunkSize 必须大于 overlap"); } String text = content.replaceAll("\\s+", " ").trim(); List<String> chunks = new ArrayList<>(); int start = 0; while (start < text.length()) { int end = Math.min(start + chunkSize, text.length()); int cut = findSentenceBoundary(text, end); if (cut <= start) { cut = end; } chunks.add(text.substring(start, cut)); start = Math.max(cut - overlap, start + 1); } return chunks; } private static int findSentenceBoundary(String text, int from) { int limit = Math.min(text.length(), from + 20); for (int i = from; i < limit; i++) { char c = text.charAt(i); if (c == '。' || c == '!' || c == '?' || c == '.' || c == '!' || c == '?' || c == '\n') { return i + 1; } } return from; } }接下来定义 Embedding 接口和知识库存储接口。这样设计的好处是:本地可以使用内存实现,生产环境可以无缝替换为 Milvus、pgvector 或 Elasticsearch 实现。
// 文件路径:src/main/java/com/demo/agent/knowledge/EmbeddingService.java package com.demo.agent.knowledge; public interface EmbeddingService { float[] embed(String text); }// 文件路径:src/main/java/com/demo/agent/knowledge/KnowledgeStore.java package com.demo.agent.knowledge; import java.util.List; public interface KnowledgeStore { void upsert(String docId, String content, float[] vector); List<KnowledgeHit> search(String query, float[] queryVector, int topK); }其中KnowledgeHit是返回结果对象,包含文档内容、分片内容、相似度分数。为了本地可运行,这里给出一个基于内存的向量检索实现。为了不依赖第三方向量库,只实现余弦相似度:
// 文件路径:src/main/java/com/demo/agent/knowledge/InMemoryKnowledgeStore.java package com.demo.agent.knowledge; import java.util.ArrayList; import java.util.Comparator; import java.util.List; public class InMemoryKnowledgeStore implements KnowledgeStore { private final List<DocVector> docs = new ArrayList<>(); @Override public void upsert(String docId, String content, float[] vector) { docs.add(new DocVector(docId, content, vector)); } @Override public List<KnowledgeHit> search(String query, float[] queryVector, int topK) { return docs.stream() .map(doc -> new KnowledgeHit( doc.docId, doc.content, cosineSimilarity(queryVector, doc.vector))) .sorted(Comparator.comparingDouble(KnowledgeHit::score).reversed()) .limit(topK) .toList(); } private double cosineSimilarity(float[] a, float[] b) { double dot = 0; double normA = 0; double normB = 0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } private record DocVector(String docId, String content, float[] vector) { } }实际生产环境中的知识库会有几个关键差异:
- 需要把“权限”纳入检索范围,比如华东区域的数据不能让普通员工随意检索;
- 需要支持多路召回,包括关键词召回和向量召回;
- 切分策略要根据文档类型单独调整,不能一刀切;
- 向量化模型要与后续检索语义匹配,不同的 Embedding 模型效果差异很大。
4.3 核心 Agent 节点实现
有了图引擎和知识库基础设施,接下来实现业务分析中的节点。
所有节点共享一个状态对象AnalysisState。这个对象包含整条链路的输入、中间结果和最终输出:
// 文件路径:src/main/java/com/demo/agent/model/AnalysisState.java package com.demo.agent.model; import java.util.ArrayList; import java.util.List; public class AnalysisState { private String userQuestion; private String analysisPlan; private List<KnowledgeHit> knowledgeHits = new ArrayList<>(); private String querySql; private String queryResult; private String finalReport; private boolean needDataQuery; private String errorMessage; // 省略 getter / setter }第一个节点:意图识别。它的职责是把用户的模糊问题拆解成分析计划:
// 文件路径:src/main/java/com/demo/agent/node/IntentNode.java package com.demo.agent.node; import com.demo.agent.graph.GraphNode; import com.demo.agent.llm.LlmPort; import com.demo.agent.model.AnalysisState; public class IntentNode implements GraphNode<AnalysisState> { private final LlmPort llm; public IntentNode(LlmPort llm) { this.llm = llm; } @Override public AnalysisState execute(AnalysisState state) { String prompt = """ 你是业务分析师。请把用户问题拆解为分析计划,输出 JSON 格式: {"subTasks": ["确认指标口径", "查询历史数据", "分析下降原因"], "needData": true} 用户问题:%s """.formatted(state.getUserQuestion()); String raw = llm.chat(List.of(new ChatMessage("user", prompt))); // 实际项目建议使用“结构化输出”,避免野格式 if (raw.contains("true")) { state.setNeedDataQuery(true); } state.setAnalysisPlan(raw); return state; } }第二个节点:知识库检索。它使用 Embedding 服务把问题向量化,然后在知识库中查询 Top-K 分片:
// 文件路径:src/main/java/com/demo/agent/node/KnowledgeRetrievalNode.java package com.demo.agent.node; import com.demo.agent.graph.GraphNode; import com.demo.agent.knowledge.EmbeddingService; import com.demo.agent.knowledge.KnowledgeStore; import com.demo.agent.model.AnalysisState; import java.util.List; public class KnowledgeRetrievalNode implements GraphNode<AnalysisState> { private final EmbeddingService embeddingService; private final KnowledgeStore knowledgeStore; public KnowledgeRetrievalNode(EmbeddingService embeddingService, KnowledgeStore knowledgeStore) { this.embeddingService = embeddingService; this.knowledgeStore = knowledgeStore; } @Override public AnalysisState execute(AnalysisState state) { String query = state.getUserQuestion() + ",重点确认销售额指标口径和华东区域背景"; float[] queryVector = embeddingService.embed(query); List<KnowledgeHit> hits = knowledgeStore.search(query, queryVector, 5); state.setKnowledgeHits(hits); return state; } }第三个节点:数据查询。这里不直接写死 SQL,而是根据知识库返回的指标口径,由 LLM 生成查询 SQL,再交给一个模拟的报表客户端执行:
// 文件路径:src/main/java/com/demo/agent/node/DataQueryNode.java package com.demo.agent.node; import com.demo.agent.graph.GraphNode; import com.demo.agent.llm.LlmPort; import com.demo.agent.model.AnalysisState; public class DataQueryNode implements GraphNode<AnalysisState> { private final LlmPort llm; private final ReportClient reportClient; public DataQueryNode(LlmPort llm, ReportClient reportClient) { this.llm = llm; this.reportClient = reportClient; } @Override public AnalysisState execute(AnalysisState state) { String knowledgeText = state.getKnowledgeHits().toString(); String prompt = """ 根据知识库口径生成 SQL,只需要输出 SQL 文本。 知识库内容:%s 查询需求:上季度华东区域销售额 """.formatted(knowledgeText); String sql = llm.chat(List.of(new ChatMessage("user", prompt))); // 真实项目中 SQL 需要经过白名单校验、权限校验和参数绑定,不能直接执行 state.setQuerySql(sql); state.setQueryResult(reportClient.query(sql)); return state; } }第四个节点:报告生成。它把知识库内容、SQL 和查询结果组合起来,交给 LLM 生成最终报告:
// 文件路径:src/main/java/com/demo/agent/node/ReportGenerationNode.java package com.demo.agent.node; import com.demo.agent.graph.GraphNode; import com.demo.agent.llm.LlmPort; import com.demo.agent.model.AnalysisState; import java.util.List; public class ReportGenerationNode implements GraphNode<AnalysisState> { private final LlmPort llm; public ReportGenerationNode(LlmPort llm) { this.llm = llm; } @Override public AnalysisState execute(AnalysisState state) { String prompt = """ 请基于以下材料生成业务分析报告,要求结论明确、数据准确。 知识库内容:%s 查询SQL:%s 查询结果:%s """.formatted( state.getKnowledgeHits(), state.getQuerySql(), state.getQueryResult()); String report = llm.chat(List.of(new ChatMessage("user", prompt))); state.setFinalReport(report); return state; } }质量校验节点是这条链路里容易被忽略但很重要的节点。它用规则化的方式检查报告是否包含必要字段,比如“是否有结论”“是否提到数据来源”:
// 文件路径:src/main/java/com/demo/agent/node/QualityCheckNode.java package com.demo.agent.node; import com.demo.agent.graph.GraphNode; import com.demo.agent.model.AnalysisState; public class QualityCheckNode implements GraphNode<AnalysisState> { @Override public AnalysisState execute(AnalysisState state) { String report = state.getFinalReport(); if (report == null || report.length() < 20) { state.setErrorMessage("报告质量校验失败:内容过短"); } else if (!report.contains("结论")) { state.setErrorMessage("报告质量校验失败:缺少结论"); } else { state.setErrorMessage(""); } return state; } }4.4 图编排与运行验证
最后,使用GraphBuilder把这些节点装配成一张可执行的图,并在主类中运行:
// 文件路径:src/main/java/com/demo/agent/Application.java package com.demo.agent; import com.demo.agent.graph.GraphBuilder; import com.demo.agent.graph.GraphEngine; import com.demo.agent.knowledge.EmbeddingService; import com.demo.agent.knowledge.InMemoryKnowledgeStore; import com.demo.agent.knowledge.KnowledgeStore; import com.demo.agent.knowledge.TextSplitter; import com.demo.agent.llm.LlmPort; import com.demo.agent.model.AnalysisState; import com.demo.agent.node.*; import java.util.List; public class Application { public static void main(String[] args) { // 1. 准备知识库 InMemoryKnowledgeStore store = new InMemoryKnowledgeStore(); String doc = """ 销售额是指企业在报告期内销售商品或提供劳务所取得的收入。 华东区域包含上海、江苏、浙江、安徽、福建。 上季度华东区域销售额下滑,主要原因是华东大客户项目验收延迟, 以及新签合同转化为收入的周期拉长。 """; List<String> chunks = TextSplitter.split(doc, 100, 20); EmbeddingService embeddingService = text -> new float[]{text.length() % 7, text.hashCode() % 11}; for (String chunk : chunks) { store.upsert("doc-1", chunk, embeddingService.embed(chunk)); } // 2. LLM 模拟实现 LlmPort llm = messages -> { String last = messages.get(messages.size() - 1).content(); if (last.contains("SQL")) { return "SELECT SUM(revenue) FROM sales WHERE region='华东' AND quarter='Q3'"; } if (last.contains("知识库口径")) { return "指标确认完成:销售额按实际回款确认,华东区域上季度目标 1.2 亿,实际 0.9 亿"; } return "结论:华东区域上季度销售额下降,主要原因是验收延迟和新签合同转化周期拉长。建议加快项目验收并优化回款流程。"; }; // 3. 装配图 GraphEngine<AnalysisState> engine = new GraphBuilder<AnalysisState>() .node("intent", new IntentNode(llm)) .node("knowledge", new KnowledgeRetrievalNode(embeddingService, store)) .node("dataQuery", new DataQueryNode(llm, sql -> "0.9 亿")) .node("report", new ReportGenerationNode(llm)) .node("quality", new QualityCheckNode()) .start("intent") .end("quality") .edge("intent", "knowledge") .condition("knowledge", "dataQuery", AnalysisState::isNeedDataQuery) .condition("knowledge", "report", state -> !state.isNeedDataQuery()) .edge("dataQuery", "report") .edge("report", "quality") .build(); // 4. 执行 AnalysisState state = new AnalysisState(); state.setUserQuestion("上季度华东区域销售额为什么下降了?"); AnalysisState result = engine.run(state); System.out.println("分析计划:" + result.getAnalysisPlan()); System.out.println("知识库命中:" + result.getKnowledgeHits().size()); System.out.println("最终报告:" + result.getFinalReport()); } }在这个最小示例中,EmbeddingService和LlmPort都是模拟实现,作用是让整条链路可以本地运行。真实项目里,Embedding 会使用模型接口,LLM 也会使用模型平台的 SDK。像AnalysisState::isNeedDataQuery这样的方法引用用在了条件边上,这正好体现了 Graph 模式的核心优势:流程控制被显式定义,而不是藏在循环分支里。
预期输出大致如下:
分析计划:根据知识库口径确认销售额指标,查询上季度华东数据,分析下降原因。 知识库命中:3 最终报告:结论:华东区域上季度销售额下降主要是验收延迟和新签合同转化周期拉长导致……5. 常见问题与排查思路
在落地 Graph Agent 的过程中,我整理过一份高频问题清单,分享给你:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| LLM 返回格式不稳定,解析失败 | 没有使用结构化输出,直接让模型返回 JSON 文本 | 使用 function calling 或 JSON Schema 约束输出格式 |
| 知识库检索结果不相关 | 文本切分粒度不合理,或者 Embedding 模型与业务不匹配 | 调整 chunk 大小与 overlap,增加元数据过滤,评估不同 Embedding 模型 |
| Agent 陷入死循环 | Loop 模式下没有最大轮次限制,或图的终止条件模糊 | 增加最大迭代次数,为图设置明确的终止节点和默认边 |
| 图节点状态不更新 | 节点返回了全新对象,但后续节点还在读取旧引用 | 统一使用可变状态容器,并在每个节点执行后打印关键字段 |
| Token 成本快速上涨 | 多轮工具调用把历史消息越撑越大 | 使用摘要节点压缩历史上下文,控制每轮消息数量 |
| 某个工具失败导致整条链路失败 | 图中没有局部重试机制 | 为 Tool 节点增加重试策略,失败时进入错误处理节点 |
| 条件边全部不满足,直接抛异常 | Builder 中漏配了兜底边 | 每个存在分支的节点都配置一个无条件默认边 |
如果遇到运行异常,我一般按这个顺序排查:
- 先缩小范围:是单个节点的问题,还是节点间流转的问题?
- 打印状态对象:每个节点执行后,输出当前
AnalysisState的关键字段。 - 用最小用例复现:把 LLM 返回的文本打印出来,判断问题是不是出在解析层。
- 检查条件边:确认状态中的布尔字段在对应阶段确实被正确更新。
- 最后再怀疑图引擎本身。自研引擎结构简单,出错概率通常比业务节点低。
6. 最佳实践与工程建议
6.1 状态与数据模型设计
Graph Agent 的状态对象是整条链路的“数据库”。我的经验是:
- 不要把所有中间结果都塞进一个巨大的 Map,建议定义强类型状态类,例如
AnalysisState; - 不要在节点里直接操作
Map的字符串键,容易拼写错误,也难以追踪; - 为状态类增加版本号或 traceId,方便链路追踪;
- 如果一个节点的输入输出需要落库,建议为状态类定义持久化模型。
另外,状态对象尽量设计成“可序列化”。这样在服务重启、任务迁移、断点续跑时,可以很自然地把图状态保存到 Redis 或数据库里。
6.2 可观测性与成本控制
Graph 模式最大的好处之一就是可观测性。我在生产环境为每个节点做了三层埋点:
- 节点执行前后日志:记录进入节点的时间、状态快照、节点耗时;
- 条件边命中记录:记录从哪个节点通过哪条边流转到哪个节点;
- Token 用量统计:在
LlmPort层统一统计每次调用的输入、输出 token。
成本控制方面,核心方法有三条:
- 限制最大迭代轮次和单次上下文长度;
- 对重复的知识库结果做缓存,避免重复召回;
- 对历史消息做摘要压缩,而不是无限追加。
6.3 安全与权限边界
业务分析 Agent 会接触到大量敏感数据,例如销售额、客户信息、内部经营指标。这块必须谨慎:
- 知识库检索结果必须按用户权限过滤,不能让普通用户通过语义检索绕过权限管理;
- 数据查询节点生成的 SQL 不能直接执行。生产环境应使用参数绑定、SQL 白名单、查询超时、行数限制等手段;
- 工具调用必须校验调用者身份和授权范围,最小权限原则是底线;
- 涉及生产环境变更、数据库写入、文件删除等高风险操作时,必须先经过人工审批。
6.4 测试策略
Graph Agent 的测试可以拆成三个层次:
- 单元测试:每个节点独立测试。LLM 调用使用固定的 mock 返回,验证节点对状态字段的修改是否正确;
- 图集成测试:从入口节点跑到终止节点,验证条件边是否按预期命中,状态传递是否完整;
- 回归测试:把历史问题沉淀为测试用例,防止图结构调整后旧功能失效。
补充一点:LLM 的输出天然有不确定性,因此测试时不要断言完整文本,而是断言关键结构,比如“是否包含结论”“是否有数据引用”等。
7. 总结与学习路线
本文从 Loop 模式在业务分析场景中的痛点出发,给出了 Graph Engineering 的演进思路,并用 Java 实现了一个最小可用的轻量图引擎,然后带大家完整走通了“业务分析 → 知识库 → Agent”的链路。整体来看,Graph 模式用结构化的状态流转替代了“大循环 + if/else”的隐式流程控制,让复杂业务变得更可控、可观测、可维护。
如果你打算继续深入学习,我的建议路径是:
- 先把本文的代码跑通,理解节点、边、状态三者之间的关系;
- 再研究 LangGraph4j,对比它和自研引擎的差异,理解条件路由、并行节点、错误恢复这些高级能力;
- 接着实践知识库工程,重点研究文档解析、切分策略、Embedding 模型选型和向量库索引调优;
- 最后再考虑多 Agent 协作、流式输出、断点续跑、Agent 效果评测等进阶课题。
Graph Engineering 并不是银弹。如果你的 Agent 只需要调用一次工具回答一个简单问题,Loop 甚至直接调用一次 LLM 就够了。但一旦场景开始涉及多分支、多工具、多阶段,图这套结构化思想会让后续需求膨胀时少走很多弯路。如果你在迁移或者落地过程中遇到更具体的问题,欢迎在评论区留言交流。