1. 这不是“又一个Agent Demo”,而是一套能写进简历的、经得起面试官深挖的Plan-and-Execute落地实践
最近刷技术群和招聘JD,总能看到“熟悉AI Agent开发”“有LLM应用经验”这类描述,但一聊到细节就卡壳——问“你用的什么框架?”答“LangChain”;再问“如果用户说‘帮我对比2023年和2024年Q1各产品线营收,并生成带趋势图的Word报告’,你的Agent怎么拆解?中间哪步失败了怎么恢复?状态怎么存?Java里怎么串起LLM调用、Excel解析、图表生成、Word渲染这四步?”往往沉默三秒,然后说“这个……我们当时用的是现成模板”。
这不是能力问题,是缺乏对Plan-and-Execute范式在工程侧真实落地的理解。它不是PPT里的流程图,而是要你在Java里亲手写出来:从自然语言输入中提取结构化意图,生成可执行的步骤序列,逐个调用工具并传递上下文,处理中间失败,最终聚合结果。这套逻辑不依赖任何大模型SDK封装,它扎根于Java的面向对象设计、异常控制流、状态管理与模块解耦能力——而这恰恰是Java岗位面试官最想看到的“基础功+工程思维”双验证点。
我去年带团队重构内部BI助手时,就用纯Java手撸了一套Plan-and-Execute引擎,没引入Spring AI或LangChain,核心代码不到800行,但支撑了日均3000+次跨系统数据查询+报告生成任务。它被HR放进“高潜力候选人技术栈清单”,也被技术总监在终面时作为压轴题:“请画出你设计的状态机,并解释为什么第3步失败后不能直接重试第1步”。今天这篇,就是把那套代码掰开揉碎讲清楚:它不是教你怎么调API,而是教你如何用Java的原生能力,把“多步推理”这件事,变成可调试、可测试、可写进简历项目栏的硬核工程实践。适合正在准备Java后端/全栈面试、想用真实项目突破简历筛选关的同学,也适合想摆脱“调包侠”标签、真正理解Agent底层逻辑的开发者。
2. 为什么必须放弃“黑盒Agent框架”,选择手写Plan-and-Execute引擎?
2.1 简历筛选的真相:HR看关键词,面试官抠实现细节
先说个扎心事实:当前主流Java岗位JD里,“AI Agent”出现频次已超“Redis集群”,但90%的简历写着“基于LangChain开发智能客服Agent”,实际贡献却是配置YAML文件+写几个Prompt模板。当面试官打开你的GitHub,看到pom.xml里一堆langchain4j-spring-boot-starter依赖,再翻src/main/java下只有ChatClientConfig.java和PromptTemplate.java,基本就判定为“概念搬运工”。而如果你的仓库里有这样一个包结构:
com.example.agent.plan ├── PlanExecutor.java // 核心执行器,含状态机与重试策略 ├── StepDefinition.java // 每步的元数据:工具名、参数Schema、超时阈值 ├── ExecutionContext.java // 跨步骤传递的上下文容器(含临时文件路径、中间数据) └── PlanGenerator.java // 基于LLM输出解析步骤序列的规则引擎哪怕没有大模型,光看类命名和方法签名,面试官就知道你懂“计划生成”“步骤编排”“上下文隔离”这些本质概念。这就是为什么标题强调“可写进简历”——它不是炫技,而是精准匹配Java岗位的技术评估靶心:用扎实的Java功底解决AI时代的新问题。
2.2 黑盒框架的三大致命短板,手写方案如何针对性破解
| 痛点 | 黑盒框架(如LangChain4J)表现 | 手写Plan-and-Execute引擎对策 | Java实现关键点 |
|---|---|---|---|
| 步骤失败不可控 | execute()抛出RuntimeException,上层只能捕获后整体失败 | 设计StepStatus枚举(PENDING/SUCCESS/FAILED_RETRYABLE/FAILED_FATAL),每步独立标记状态 | enum StepStatus { PENDING, SUCCESS, FAILED_RETRYABLE, FAILED_FATAL }+Map<String, StepStatus>状态快照 |
| 中间状态难调试 | 所有变量存在Map<String, Object>里,IDE无法跳转,日志全是JSON字符串 | ExecutionContext继承HashMap<String, Object>,但强制泛型约束(如context.put("salesData", List<SalesRecord>)) | 使用@SuppressWarnings("unchecked")配合Class<T>参数做类型安全转换,避免运行时ClassCastException |
| 工具调用强耦合 | Tool接口要求实现invoke(String input),输入输出都是String,无法利用Java类型系统 | 定义Tool<T, R>泛型接口,T为输入参数类(如SalesQueryRequest),R为返回结果类(如List<SalesRecord>) | public interface Tool<T, R> { R execute(T params) throws ToolException; },让IDE自动提示参数字段 |
我实测过:同样处理“生成销售报告”需求,LangChain4J版本在步骤3(Excel解析)失败时,整个链路回滚,日志只显示ToolExecutionException: Failed to parse Excel;而手写引擎会记录Step[3].status=FAILED_RETRYABLE,并保存ExecutionContext中已成功的步骤2(SQL查询结果)和步骤1(时间范围解析)。工程师能直接定位到ExcelParserTool的catch (IOException e)块,加一行log.warn("Excel file {} corrupted, retrying with fallback parser", filePath, e)——这才是生产环境需要的可观测性。
2.3 Plan-and-Execute不是LLM专属,而是经典软件工程模式的AI化演进
很多人误以为Plan-and-Execute是LLM带来的新范式,其实它本质是命令模式(Command Pattern)+ 状态机(State Machine)+ 工作流引擎(Workflow Engine)的组合升级。我们拆解一个典型场景:
用户输入:“对比2023Q1和2024Q1各产品线营收,按增长率排序,生成Word报告”
传统Java开发会怎么做?
- 写
SalesReportService,内含generateReport()方法 - 方法里调
salesDao.queryByQuarter("2023Q1")→salesDao.queryByQuarter("2024Q1")→calculateGrowthRate()→exportToWord() - 如果
exportToWord()失败,整个事务回滚,用户重试
Plan-and-Execute只是把这个过程显式化、动态化:
- Plan阶段:LLM输出JSON格式步骤序列(非固定代码,而是运行时生成)
- Execute阶段:Java引擎按序调用对应
Tool,每步结果存入ExecutionContext - Error Handling:某步失败时,根据
StepDefinition.retryPolicy决定是否重试,或跳过执行后续步骤
关键差异在于控制权转移:传统服务里步骤顺序写死在代码里;Plan-and-Execute里顺序由LLM动态生成,Java引擎只负责可靠执行。这要求Java代码必须足够健壮——比如PlanExecutor的execute(Plan plan)方法,必须处理plan.getSteps().get(0).getToolName()不存在的情况(用户指令歧义导致LLM生成无效工具名),这时要抛出PlanValidationException而非NullPointerException,让上层能友好提示“未识别的操作,请明确说明要查询的数据源”。
3. 核心模块详解:从LLM输出解析到多步执行的完整Java实现
3.1 PlanGenerator:把LLM的JSON输出变成可执行的步骤列表
LLM(如Qwen或本地部署的Phi-3)返回的原始响应通常是这样的:
{ "steps": [ { "step_id": "1", "tool": "sql_query_executor", "params": {"query": "SELECT product_line, revenue FROM sales WHERE quarter = '2023Q1'"}, "description": "查询2023年第一季度各产品线营收" }, { "step_id": "2", "tool": "sql_query_executor", "params": {"query": "SELECT product_line, revenue FROM sales WHERE quarter = '2024Q1'"}, "description": "查询2024年第一季度各产品线营收" }, { "step_id": "3", "tool": "revenue_calculator", "params": {"data_2023q1": "{{step_1.result}}", "data_2024q1": "{{step_2.result}}"}, "description": "计算各产品线营收增长率" }, { "step_id": "4", "tool": "word_report_generator", "params": {"growth_data": "{{step_3.result}}", "title": "2023vs2024Q1营收对比报告"}, "description": "生成Word格式报告" } ] }注意两个关键设计点:
- 参数占位符:
{{step_1.result}}表示引用步骤1的返回值,这是Plan-and-Execute的核心机制——步骤间数据传递 - 工具名标准化:
sql_query_executor等名称需与Java中注册的ToolBean名严格一致,避免字符串硬编码
PlanGenerator的实现要点:
- JSON Schema校验:定义
PlanSchema.json,用Jackson的JsonNode校验steps数组非空、每个step必含tool和params - 占位符解析:遍历
params所有字段,用正则\\{\\{step_(\\d+)\\.result\\}\\}提取依赖步骤ID,构建stepDependencyGraph - 循环检测:若
step_3依赖step_1,而step_1又依赖step_3,抛出CircularDependencyException
public class PlanGenerator { private final ObjectMapper objectMapper = new ObjectMapper(); public Plan generatePlan(String llmResponse) throws PlanGenerationException { try { JsonNode rootNode = objectMapper.readTree(llmResponse); // 校验schema if (!rootNode.has("steps") || rootNode.get("steps").isEmpty()) { throw new PlanGenerationException("LLM output missing 'steps' field"); } List<StepDefinition> steps = new ArrayList<>(); for (JsonNode stepNode : rootNode.get("steps")) { String toolName = stepNode.get("tool").asText(); JsonNode paramsNode = stepNode.get("params"); // 解析占位符,构建依赖关系 Map<String, String> resolvedParams = resolvePlaceholders(paramsNode); steps.add(new StepDefinition( stepNode.get("step_id").asText(), toolName, resolvedParams, stepNode.get("description").asText() )); } // 检测循环依赖 validateNoCircularDependency(steps); return new Plan(steps); } catch (JsonProcessingException e) { throw new PlanGenerationException("Invalid JSON format from LLM", e); } } private Map<String, String> resolvePlaceholders(JsonNode paramsNode) { Map<String, String> result = new HashMap<>(); paramsNode.fields().forEachRemaining(entry -> { String value = entry.getValue().asText(); // 简单占位符替换(生产环境建议用更健壮的模板引擎) if (value.matches("\\{\\{step_\\d+\\.result\\}\\}")) { result.put(entry.getKey(), value); // 保留占位符,执行时再替换 } else { result.put(entry.getKey(), value); } }); return result; } }提示:这里
resolvePlaceholders不立即替换占位符,而是存原始字符串,因为执行时ExecutionContext可能包含多个步骤结果,需动态绑定。这是避免内存泄漏的关键——如果提前解析成具体值,当步骤1结果是10MB的Excel字节数组,StepDefinition就会持有该引用,导致后续步骤无法GC。
3.2 ToolRegistry:统一管理所有可调用工具,支持热插拔
Tool不是简单的方法,而是具备生命周期管理的组件。我们定义Tool<T, R>接口:
public interface Tool<T, R> { String getName(); // 工具唯一标识,如"sql_query_executor" Class<T> getParamType(); // 输入参数类型,用于反射实例化 Class<R> getReturnType(); // 返回类型,用于类型安全转换 R execute(T params) throws ToolException; // 核心执行逻辑 default int getTimeoutSeconds() { return 30; } // 默认超时 default boolean isRetryable() { return true; } // 是否允许重试 }注册中心ToolRegistry采用双重检查锁单例,支持运行时注册:
public class ToolRegistry { private static volatile ToolRegistry instance; private final Map<String, Tool<?, ?>> tools = new ConcurrentHashMap<>(); public static ToolRegistry getInstance() { if (instance == null) { synchronized (ToolRegistry.class) { if (instance == null) { instance = new ToolRegistry(); } } } return instance; } public <T, R> void register(String name, Tool<T, R> tool) { tools.put(name, tool); } @SuppressWarnings("unchecked") public <T, R> Tool<T, R> getTool(String name) throws ToolNotFoundException { Tool<?, ?> tool = tools.get(name); if (tool == null) { throw new ToolNotFoundException("Tool not found: " + name); } return (Tool<T, R>) tool; } }关键技巧:工具注册与Spring Bean解耦。很多同学直接用@Autowired注入Tool,导致测试困难。正确做法是在ApplicationRunner中手动注册:
@Component public class ToolInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { ToolRegistry.getInstance().register( "sql_query_executor", new SqlQueryExecutorTool(dataSource) ); ToolRegistry.getInstance().register( "revenue_calculator", new RevenueCalculatorTool() ); ToolRegistry.getInstance().register( "word_report_generator", new WordReportGeneratorTool(wordTemplatePath) ); } }这样单元测试时,可直接new ToolRegistry()并注册Mock工具,无需启动Spring容器。
3.3 ExecutionContext:跨步骤传递数据的类型安全容器
这是最容易被忽视却最关键的模块。很多实现用Map<String, Object>存中间结果,导致:
- IDE无法提示
context.get("salesData")的类型 - 运行时
ClassCastException难以定位 - 内存泄漏(大对象长期驻留)
我们的ExecutionContext继承HashMap,但提供泛型安全的put/get:
public class ExecutionContext extends HashMap<String, Object> { public <T> void put(String key, T value) { super.put(key, value); } @SuppressWarnings("unchecked") public <T> T get(String key, Class<T> type) { Object value = super.get(key); if (value == null) { return null; } if (!type.isInstance(value)) { throw new ExecutionContextException( String.format("Value for key '%s' is not of type %s, got %s", key, type.getSimpleName(), value.getClass().getSimpleName()) ); } return (T) value; } // 专用方法:存取List<SalesRecord> public void putSalesData(List<SalesRecord> data) { put("salesData", data); } public List<SalesRecord> getSalesData() { return get("salesData", List.class); } }实操心得:为高频数据类型提供专用getter/setter。比如getSalesData()比get("salesData", List.class)少写12个字符,且IDE能自动补全。我在项目里为5个核心业务对象(SalesRecord,GrowthRateResult,WordDocument,ExcelFile,ChartData)都写了专用方法,代码可读性提升40%,新人上手时间从半天缩短到20分钟。
3.4 PlanExecutor:状态机驱动的多步执行核心
PlanExecutor是引擎心脏,它必须处理:
- 步骤顺序执行与依赖等待
- 失败重试与降级策略
- 执行超时与资源清理
- 状态持久化(可选)
核心算法伪代码:
for each step in plan.steps: if step has dependencies: wait until all dependencies are SUCCESS if step.status == PENDING: execute step with timeout if success: step.status = SUCCESS, store result in context if failure and retryable: step.status = FAILED_RETRYABLE, increment retry count if failure and non-retryable: step.status = FAILED_FATAL, break loop if step.status == FAILED_RETRYABLE and retry count < max: re-execute stepJava实现关键点:
public class PlanExecutor { private final ToolRegistry toolRegistry = ToolRegistry.getInstance(); public ExecutionResult execute(Plan plan, ExecutionContext context) throws PlanExecutionException { long startTime = System.currentTimeMillis(); List<StepExecutionResult> results = new ArrayList<>(); for (StepDefinition step : plan.getSteps()) { try { // 检查依赖是否完成 validateDependencies(step, context); // 获取工具并执行 Tool<?, ?> tool = toolRegistry.getTool(step.getToolName()); Object params = resolveStepParams(step, context); // 执行带超时控制 Object result = executeWithTimeout(tool, params, step.getTimeoutSeconds()); // 存储结果到上下文 context.put(step.getStepId() + ".result", result); results.add(new StepExecutionResult(step.getStepId(), StepStatus.SUCCESS, result)); } catch (ToolException e) { handleStepFailure(step, e, context, results); } } return new ExecutionResult(results, System.currentTimeMillis() - startTime); } private void handleStepFailure(StepDefinition step, ToolException e, ExecutionContext context, List<StepExecutionResult> results) { if (step.isRetryable() && step.getRetryCount() < 3) { step.incrementRetryCount(); results.add(new StepExecutionResult( step.getStepId(), StepStatus.FAILED_RETRYABLE, e.getMessage())); } else { results.add(new StepExecutionResult( step.getStepId(), StepStatus.FAILED_FATAL, e.getMessage())); throw new PlanExecutionException( String.format("Step %s failed fatally: %s", step.getStepId(), e.getMessage()), e); } } }注意:
executeWithTimeout不是简单Thread.sleep(),而是用CompletableFuture配合ExecutorService实现真正的超时中断:
private Object executeWithTimeout(Tool<?, ?> tool, Object params, int timeoutSeconds) throws ToolException { try { return CompletableFuture.supplyAsync(() -> { try { return tool.execute(params); } catch (Exception ex) { throw new CompletionException(ex); } }, executorService) .orTimeout(timeoutSeconds, TimeUnit.SECONDS) .join(); } catch (CompletionException e) { throw (ToolException) e.getCause(); } catch (TimeoutException e) { throw new ToolTimeoutException("Tool execution timed out after " + timeoutSeconds + "s", e); } }4. 实操全流程:从零搭建一个可运行的Plan-and-Execute引擎
4.1 环境准备与依赖配置
使用JDK 17+(因CompletableFuture.orTimeout()在17引入),Maven依赖精简到极致:
<dependencies> <!-- 核心 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <!-- HTTP客户端(调用LLM API) --> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-core</artifactId> <version>12.4</version> </dependency> <!-- Excel处理 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.4</version> </dependency> <!-- Word生成 --> <dependency> <groupId>org.docx4j</groupId> <artifactId>docx4j</artifactId> <version>8.3.3</version> </dependency> <!-- 日志 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.9</version> </dependency> </dependencies>关键取舍:不引入Spring Boot。虽然会多写几行配置代码,但换来的是:
- 启动速度从3秒降到0.2秒(
main()直接跑)- 依赖树清晰可见,不会因
spring-boot-starter-web间接引入Tomcat- 单元测试无需
@SpringBootTest,@Test方法秒级执行
4.2 编写第一个Tool:SqlQueryExecutorTool(连接数据库查询)
public class SqlQueryExecutorTool implements Tool<SqlQueryRequest, List<Map<String, Object>>> { private final DataSource dataSource; public SqlQueryExecutorTool(DataSource dataSource) { this.dataSource = dataSource; } @Override public String getName() { return "sql_query_executor"; } @Override public Class<SqlQueryRequest> getParamType() { return SqlQueryRequest.class; } @Override public Class<List<Map<String, Object>>> getReturnType() { return (Class<List<Map<String, Object>>>) (Class<?>) List.class; } @Override public List<Map<String, Object>> execute(SqlQueryRequest request) throws ToolException { try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(request.getQuery())) { ResultSet rs = stmt.executeQuery(); return resultSetToList(rs); } catch (SQLException e) { throw new ToolException("Database query failed", e); } } private List<Map<String, Object>> resultSetToList(ResultSet rs) throws SQLException { List<Map<String, Object>> result = new ArrayList<>(); ResultSetMetaData metaData = rs.getMetaData(); int columnCount = metaData.getColumnCount(); while (rs.next()) { Map<String, Object> row = new HashMap<>(); for (int i = 1; i <= columnCount; i++) { row.put(metaData.getColumnName(i), rs.getObject(i)); } result.add(row); } return result; } }配套SqlQueryRequest类(体现Java Bean优势):
public class SqlQueryRequest { private String query; private Map<String, Object> parameters; // 支持预编译参数 // getter/setter public String getQuery() { return query; } public void setQuery(String query) { this.query = query; } public Map<String, Object> getParameters() { return parameters; } public void setParameters(Map<String, Object> parameters) { this.parameters = parameters; } }实操心得:为每个Tool编写独立的单元测试。比如SqlQueryExecutorToolTest用H2内存数据库:
@Test void shouldExecuteQueryAndReturnResults() { // Given DataSource dataSource = new H2DataSourceBuilder().build(); SqlQueryExecutorTool tool = new SqlQueryExecutorTool(dataSource); // When List<Map<String, Object>> result = tool.execute( new SqlQueryRequest().setQuery("SELECT 'test' as name, 123 as id") ); // Then assertThat(result).hasSize(1); assertThat(result.get(0).get("name")).isEqualTo("test"); assertThat(result.get(0).get("id")).isEqualTo(123L); }4.3 构建PlanGenerator:对接真实LLM API
我们用OpenAI兼容API(如Ollama的Qwen),避免厂商锁定:
public class OpenAiPlanGenerator implements PlanGenerator { private final String baseUrl = "http://localhost:11434/v1"; // Ollama默认地址 private final String model = "qwen:7b"; @Override public Plan generatePlan(String userQuery) throws PlanGenerationException { String prompt = buildPrompt(userQuery); String response = callLlmApi(prompt); return parsePlanFromJson(response); } private String buildPrompt(String userQuery) { return """ 你是一个Plan-and-Execute引擎的规划器。请将用户需求分解为可执行的步骤序列。 输出严格遵循JSON格式,包含steps数组,每个step包含step_id、tool、params、description字段。 可用工具:sql_query_executor(执行SQL查询)、revenue_calculator(计算增长率)、word_report_generator(生成Word报告)。 用户需求:%s """.formatted(userQuery); } private String callLlmApi(String prompt) { // 使用Feign Client调用 Feign.builder() .decoder(new JacksonDecoder()) .encoder(new JacksonEncoder()) .target(OpenAiApi.class, baseUrl); // 实际调用代码略,重点是返回原始JSON字符串 return "{\"steps\":[{\"step_id\":\"1\",\"tool\":\"sql_query_executor\",...}]}"; } }注意:
buildPrompt中明确列出可用工具名,这是防止LLM幻觉的关键。测试发现,不声明工具列表时,LLM有17%概率生成"tool":"excel_parser"这种不存在的名称,导致ToolRegistry抛出ToolNotFoundException。
4.4 集成测试:端到端验证“生成销售报告”流程
编写PlanExecutorIntegrationTest,模拟完整链路:
@Test void shouldGenerateSalesReportSuccessfully() { // Given ToolRegistry registry = ToolRegistry.getInstance(); registry.register("sql_query_executor", mockSqlTool); registry.register("revenue_calculator", new RevenueCalculatorTool()); registry.register("word_report_generator", mockWordTool); PlanExecutor executor = new PlanExecutor(); ExecutionContext context = new ExecutionContext(); // When ExecutionResult result = executor.execute( new Plan(Arrays.asList( new StepDefinition("1", "sql_query_executor", Map.of("query", "SELECT * FROM sales WHERE quarter='2023Q1'"), "2023Q1数据"), new StepDefinition("2", "sql_query_executor", Map.of("query", "SELECT * FROM sales WHERE quarter='2024Q1'"), "2024Q1数据"), new StepDefinition("3", "revenue_calculator", Map.of("data_2023q1", "{{step_1.result}}", "data_2024q1", "{{step_2.result}}"), "计算增长率") )), context ); // Then assertThat(result.getStepResults()).hasSize(3); assertThat(result.getStepResults().get(0).getStatus()).isEqualTo(StepStatus.SUCCESS); assertThat(result.getStepResults().get(2).getStatus()).isEqualTo(StepStatus.SUCCESS); assertThat(context.getSalesData()).isNotEmpty(); // 验证上下文数据 }5. 简历包装与面试应答:如何把这项实践转化为竞争力
5.1 项目描述的黄金公式:STAR-L
不要写“使用Java开发AI Agent”,要用STAR-L法则(Situation-Task-Action-Result-Learning):
S(情境):公司BI系统面临“自然语言生成分析报告”需求,原有脚本化方案需人工编写SQL+Python脚本,平均响应时间15分钟
T(任务):设计可扩展的Plan-and-Execute引擎,支持LLM动态生成执行步骤,降低非技术人员使用门槛
A(行动):
- 手写Java引擎,包含PlanGenerator(LLM输出解析)、ToolRegistry(工具热插拔)、ExecutionContext(类型安全上下文)、PlanExecutor(状态机执行)四大模块
- 实现步骤依赖解析、失败重试、超时熔断、结果缓存机制
- 对接Qwen-7B本地模型,定制Prompt确保步骤工具名准确率99.2%
R(结果):- 报告生成耗时从15分钟降至42秒(P95)
- 支持23种业务查询场景,零SQL编写即可生成图表报告
- 代码通过SonarQube扫描,覆盖率87%,无阻塞线程
L(学习):深刻理解AI Agent不是“调API”,而是工程化的工作流编排,Java的类型安全与异常控制是保障可靠性的基石
5.2 面试高频问题应答指南
Q1:为什么不用LangChain4J,而选择手写?
“LangChain4J解决了LLM交互的便利性,但隐藏了执行细节。在金融场景中,我们需要精确控制每步超时(如SQL查询必须≤5秒)、失败后降级(如Excel解析失败时改用CSV)、结果审计(每步输入输出存DB)。手写引擎让我能用Java的
try-catch、CompletableFuture、ConcurrentHashMap直接实现这些,而不是在框架抽象层上打补丁。”
Q2:步骤间数据传递怎么保证线程安全?
“
ExecutionContext本身是ConcurrentHashMap子类,但关键在PlanExecutor的执行模型——它是单线程顺序执行的。因为Plan-and-Execute本质是DAG(有向无环图),同一Plan的步骤天然有序,无需并发修改。我们只在Tool内部做并发(如数据库连接池),ExecutionContext作为只读上下文传递,避免了锁竞争。”
Q3:LLM生成错误步骤怎么办?
“我们在
PlanGenerator加入三层防护:第一层JSON Schema校验,第二层工具名白名单检查(ToolRegistry.contains(toolName)),第三层执行时ToolNotFoundException捕获并记录告警。上线后统计,LLM生成无效工具名的概率是0.3%,全部被拦截并触发人工审核流程。”
5.3 GitHub仓库组织建议:让面试官一眼看到价值
agent-plan-executor/ ├── README.md // 用截图展示:用户输入→LLM输出→步骤执行日志→Word报告 ├── core/ // 核心引擎(PlanGenerator/ToolRegistry等) ├── tools/ // 具体工具实现(SqlQueryTool/WordGenerator等) ├── examples/ // 可运行示例(含application.properties配置) │ └── SalesReportExample.java // 主函数,30行代码演示完整流程 ├── test/ // 单元测试+集成测试 ├── docs/ // 架构图(PlantUML文本,非图片)、性能压测报告 └── pom.xml // 依赖精简,注释说明每个依赖用途实操心得:在
README.md顶部放一张终端执行日志截图,包含:
- 用户输入:“生成2023vs2024Q1销售对比报告”
- LLM输出步骤序列(JSON格式)
- 步骤1执行日志:“[INFO] Executing step_1: sql_query_executor... SUCCESS (2.3s)”
- 最终输出:“Report generated: /tmp/report_20240520.docx” 这比1000字文字描述更有说服力。
6. 常见问题与避坑指南:来自生产环境的血泪教训
6.1 LLM输出JSON格式错乱,导致PlanGenerator解析失败
现象:LLM偶尔返回{ "steps": [...] }前面多一个空格,或末尾少逗号,Jackson抛JsonProcessingException。
解决方案:
- 在
PlanGenerator中添加JSON清洗:用正则删除首尾空白,修复常见语法错误 - 设置LLM的
response_format为{ "type": "json_object" }(OpenAI API v1.0+支持) - 添加fallback机制:当JSON解析失败,尝试用正则提取
"steps": \[.*?\]片段
private String sanitizeJson(String rawJson) { // 移除BOM和首尾空白 String clean = rawJson.trim().replaceFirst("\uFEFF", ""); // 修复常见错误:末尾多逗号、缺少引号 clean = clean.replaceAll(",\\s*}", "}"); clean = clean.replaceAll("([^\"\\w])'([^\"\\w])", "$1\"$2"); return clean; }6.2 Tool执行耗时过长,拖垮整个Plan执行
现象:word_report_generator生成复杂图表需8秒,而Plan超时设为5秒,导致步骤失败。
根因分析:超时设置是全局的,但不同Tool合理耗时差异巨大(SQL查询应≤2秒,Word生成可≤10秒)。
解决方案:
StepDefinition中增加timeoutSeconds字段,允许每步单独配置PlanExecutor执行时读取该字段,而非使用固定值- 在
Tool接口中定义getTimeoutSeconds()默认方法,供具体工具覆盖
// StepDefinition新增字段 private int timeoutSeconds = 30; // 默认30秒 // PlanExecutor中调用 int timeout = step.getTimeoutSeconds(); Object result = executeWithTimeout(tool, params, timeout);6.3 ExecutionContext内存泄漏,OOM频繁发生
现象:长时间运行后Full GC频繁,堆内存持续增长。
排查过程:
jmap -histo发现byte[]对象占堆70%- 追踪到
WordReportGeneratorTool生成的XWPFDocument对象未关闭流
修复措施:
- 所有
Tool实现AutoCloseable接口,在PlanExecutor的finally块中调用close() ExecutionContext增加clear()方法,执行完主动清空大对象- 在
StepExecutionResult中存储result.toString()摘要,而非原始大对象
public class PlanExecutor { public ExecutionResult execute(Plan plan, ExecutionContext context) { try { // ... 执行逻辑 } finally { // 清理上下文中的大对象 context.clearLargeObjects(); } } } // ExecutionContext中 public void clearLargeObjects() { key