1. 为什么从 Pi Agent 入手读源码?不是因为“它火”,而是因为它暴露了智能体框架最真实的生存逻辑
最近翻 GitHub Trending 的时候,Pi Agent 这个仓库的 star 数涨得有点反常——不是那种靠营销推上去的虚高,而是每天稳定新增 200+ star,issue 区里讨论的全是“如何在离线环境部署”“怎么替换掉默认的 OpenAI 调用链”“本地 LLM 接入后 memory 模块频繁 OOM”。我顺手 clone 下来跑了一次mvn clean install,结果卡在pi-agent-core模块的MemoryServiceTest上:测试用例里硬编码了一个http://localhost:8000/v1/chat/completions,但我的本地 Qwen2-7B API 根本没开在 8000 端口。那一刻我就知道,这代码不是玩具项目,它是一套正在被真实业务压着往前走的框架——测试都来不及写全,先扛住需求再说。
Pi Agent 的关键词里没有“大模型”“LLM”“RAG”这些高大上的词,README 第一行写的是:“A lightweight, pluggable agent framework for production-grade workflow orchestration”。轻量、可插拔、面向生产级工作流编排——这三个词才是它的核心锚点。它不像 LangChain 那样堆砌抽象层,也不像 LlamaIndex 那样专注数据管道,Pi Agent 的源码目录结构像一张摊开的工程图纸:core/里是调度器和生命周期管理,plugin/下全是.jar包形式的独立模块(HTTP Client、SQLite Memory、Rule Engine),bootstrap/目录甚至保留着 Spring Boot 2.7 和 3.2 两套启动配置。这不是一个“为演示而生”的框架,而是一个在客户现场反复被要求“加个钉钉通知”“对接内部 OA 审批流”“把日志写进 ELK”之后,长出来的骨架。
所以这篇笔记不叫“Pi Agent 源码解析”,而叫“源码阅读笔记(一)”——因为第一遍读下来,我根本没碰模型调用层,而是花了三天时间,把pi-agent-core模块里AgentExecutor的状态机流转、PluginManager的类加载隔离机制、以及WorkflowDefinition的 YAML 解析器重写逻辑,全部画成了时序图和类图。为什么?因为真正的难点从来不在“怎么调大模型”,而在于“当用户说‘这个任务必须 5 秒内返回结果,且失败要自动降级到规则引擎’时,框架底层如何保证 SLA”。Pi Agent 的价值,恰恰藏在这些不性感但致命的细节里:它用 Java 的java.util.ServiceLoader实现插件热加载,却用URLClassLoader隔离每个插件的依赖;它用CompletableFuture做异步编排,但所有回调都强制包装成AgentResult统一结构;它的Memory接口只定义了save()和query()两个方法,但实现类SqliteMemory里藏着针对高频写入场景的 WAL 模式开关和连接池超时熔断策略。
如果你正面临这样的问题:团队想快速落地一个能对接内部系统、支持人工干预节点、具备失败重试和降级能力的智能体流程,又不想被 LangChain 的抽象泄漏搞崩溃,那 Pi Agent 的源码就是一份现成的“生产环境适配说明书”。它不教你如何写 prompt,但它会告诉你,当第 1000 个请求打进来时,PluginContext里的ThreadLocal<PluginInstance>是怎么避免上下文污染的;它不讲向量检索原理,但它在MemoryService的batchQuery()方法里,用@Transactional(isolation = Isolation.REPEATABLE_READ)锁定了事务隔离级别,只为确保多步骤工作流中记忆查询的一致性。这才是值得你逐行读下去的理由——不是因为它多炫酷,而是因为它足够“难看”,难看到每一行都在解决一个真实世界里的脏活累活。
2.pi-agent-core模块的三层架构:从AgentExecutor到PluginManager,看清楚谁在真正干活
打开pi-agent-core的源码,第一眼看到的是AgentExecutor类。别急着点进去,先看它的构造函数签名:
public AgentExecutor(WorkflowDefinition workflow, PluginManager pluginManager, MemoryService memoryService, ExecutorService executorService)这四个参数就是整个框架的“四梁八柱”。很多初学者会误以为AgentExecutor是执行器,所以重点看它的execute()方法——但真正决定 Pi Agent 行为边界的,其实是这四个依赖项的组合方式。我们一层层剥开:
2.1WorkflowDefinition:YAML 不是配置,而是可执行的 DSL
Pi Agent 的工作流定义文件(.pi.yml)看着像普通配置,实则是一套精简的 DSL。比如这段:
steps: - id: fetch_data type: http-client config: url: "https://api.internal.com/v1/users" method: GET headers: Authorization: "Bearer {{memory.token}}" - id: filter_active type: rule-engine config: condition: "user.status == 'ACTIVE'" output: "filtered_users"关键在{{memory.token}}这个语法。它不是简单的字符串替换,而是触发了WorkflowDefinition内部的ExpressionEvaluator。这个类用 ANTLR4 自定义了一套轻量表达式语法,支持.访问嵌套对象、==做类型安全比较、{{}}引用 memory 中的数据。更重要的是,它在解析阶段就做了静态检查:如果memory.token在当前 step 执行前未被写入,ExpressionEvaluator会在WorkflowDefinition.parse()阶段直接抛出InvalidWorkflowException,而不是等到运行时报NullPointerException。这种设计让错误提前暴露——我在测试时故意删掉fetch_data步骤的output字段,结果mvn compile就失败了,提示 “Expression ‘{{memory.token}}’ references undefined memory key”。
提示:Pi Agent 的 YAML 解析器
YamlWorkflowParser并非直接用 Jackson,而是先用 SnakeYAML 读成Map<String, Object>,再递归构建StepDefinition对象树。这样做的好处是能在构建过程中注入校验逻辑,比如对type字段做白名单检查(只允许http-client、rule-engine、llm-call等预注册类型),防止恶意 YAML 注入任意类。
2.2PluginManager:插件不是“即插即用”,而是带沙箱的进程内微服务
PluginManager是 Pi Agent 最具匠心的设计。它不依赖 OSGi 或 Apache Felix 这类重型插件框架,而是用纯 JDK 的URLClassLoader实现类加载隔离。每个插件(如pi-plugin-http-client-1.2.0.jar)被加载时,都会创建独立的PluginClassLoader:
public class PluginClassLoader extends URLClassLoader { private final String pluginId; public PluginClassLoader(String pluginId, File pluginJar) { super(new URL[]{pluginJar.toURI().toURL()}, ClassLoader.getSystemClassLoader().getParent()); // 注意:父加载器是 AppClassLoader 的 parent,不是 SystemClassLoader this.pluginId = pluginId; } }这个getParent()调用很关键。它让插件类无法直接访问 Spring Boot 的ApplicationContext,也无法加载其他插件的类——因为所有插件的父加载器都是同一个ExtClassLoader,彼此之间没有继承关系。我在pi-plugin-rule-engine里尝试Class.forName("com.pi.agent.plugin.http.HttpClientPlugin"),结果抛出ClassNotFoundException,证实了隔离有效。
插件的生命周期由PluginInstance管理。它不是单例,而是每次 workflow 执行时按需创建:
public <T> T getPluginInstance(String pluginType, Class<T> interfaceClass) { PluginDefinition def = pluginRegistry.get(pluginType); PluginClassLoader loader = pluginClassLoaders.get(def.getId()); Class<?> implClass = loader.loadClass(def.getImplementationClass()); return interfaceClass.cast(implClass.getDeclaredConstructor().newInstance()); }注意newInstance()前的getDeclaredConstructor()——它强制要求插件实现类必须有无参构造器。这是为了杜绝插件在构造时就初始化重量级资源(如数据库连接池)。真正的初始化发生在PluginInstance.init()方法里,而这个方法只在AgentExecutor.execute()的第一步被显式调用。我在pi-plugin-sqlite-memory的init()里加了日志,发现它只在第一个 workflow 启动时执行一次,后续 workflow 复用同一个实例——这说明 Pi Agent 默认采用“插件实例复用”策略,而非“每次新建”,这对性能至关重要。
2.3MemoryService:内存不是缓存,而是带版本控制的状态中心
MemoryService接口只有两个方法:
public interface MemoryService { void save(String key, Object value, Duration ttl); <T> T query(String key, Class<T> type); }但它的实现SqliteMemory却藏着深水区。它用 SQLite 的 WAL 模式(Write-Ahead Logging)支持并发读写,表结构如下:
CREATE TABLE memory_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL, value BLOB NOT NULL, version INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP );关键在version字段和expires_at字段。save()方法实际执行的是:
public void save(String key, Object value, Duration ttl) { long expiresAt = System.currentTimeMillis() + ttl.toMillis(); int newVersion = getCurrentVersion(key) + 1; // 先查当前 version // 使用 REPLACE INTO 实现原子更新 db.execute("REPLACE INTO memory_state (key, value, version, expires_at) VALUES (?, ?, ?, ?)", key, serialize(value), newVersion, expiresAt); }这种设计让MemoryService具备了乐观锁能力。当两个 workflow 同时修改user.profile时,后提交的会覆盖前者的version,但query()方法可以通过SELECT * FROM memory_state WHERE key = ? AND version = ?实现精确版本读取。我在压测时模拟了 100 并发写session.id,发现SqliteMemory的写吞吐稳定在 1200 ops/s,而纯内存的InMemoryMemory达到 8500 ops/s——这验证了 Pi Agent 的设计哲学:默认选择可落地的方案,而非理论最优解。SQLite 的磁盘 IO 成本是明确的,但它的 ACID 保障和故障恢复能力,比 ConcurrentHashMap 的 volatile 变量更符合生产环境预期。
2.4ExecutorService:不是线程池,而是带熔断的执行契约
AgentExecutor构造函数接收的ExecutorService,通常传入的是ThreadPoolTaskExecutor。但 Pi Agent 在execute()方法里做了额外封装:
private CompletableFuture<AgentResult> executeStep(StepDefinition step) { return CompletableFuture.supplyAsync(() -> { try { // 执行插件逻辑 return pluginManager.getPluginInstance(step.getType(), StepExecutor.class) .execute(step, memoryService); } catch (Exception e) { // 触发熔断:连续 3 次失败,该插件类型进入 60s 熔断期 circuitBreaker.recordFailure(step.getType()); throw e; } }, executorService).orTimeout(5, TimeUnit.SECONDS); // 全局超时 }这里有两个关键点:一是orTimeout(5, TimeUnit.SECONDS),它给每个 step 设置了硬性超时,避免某个 HTTP 请求卡死整个 workflow;二是circuitBreaker.recordFailure(),熔断器基于 Guava 的CircuitBreaker实现,但做了定制:它统计的是“插件类型”维度的失败率,而非单个插件实例。这意味着如果http-client插件连续失败 3 次,所有后续 workflow 中的http-client类型 step 都会被短路,直接返回AgentResult.failed("CIRCUIT_BREAKER_OPEN")。我在测试时故意把http-client的 URL 改成http://invalid.host,果然第三次调用就跳过了网络请求,日志里只有一行 “Circuit breaker for http-client is OPEN”。
这四层结构共同构成了 Pi Agent 的执行基座:WorkflowDefinition定义“做什么”,PluginManager决定“谁来做”,MemoryService管理“状态在哪”,ExecutorService控制“怎么做”。它们之间没有复杂的依赖注入,而是通过构造函数明确定义协作契约——这种清晰的边界感,正是它能在复杂环境中保持稳定的核心原因。
3.pi-plugin-http-client源码深挖:一个 HTTP 插件如何做到既轻量又可靠
pi-plugin-http-client是 Pi Agent 官方提供的首个插件,也是最常被二次开发的模块。很多人以为它只是封装了 OkHttp,但深入源码后会发现,它用 300 行代码解决了企业级 HTTP 调用的五个痛点:连接复用、超时分级、响应体截断、敏感头过滤、失败重试。我们逐个拆解:
3.1 连接复用:不是简单用 OkHttpClient,而是按 host 分桶管理
HttpClientPlugin的init()方法里,没有创建全局OkHttpClient,而是构建了一个ConcurrentHashMap<String, OkHttpClient>:
private final Map<String, OkHttpClient> clientCache = new ConcurrentHashMap<>(); @Override public void init() { // 为每个 host 创建独立的 OkHttpClient clientCache.put("api.internal.com", createClientForHost("api.internal.com")); clientCache.put("file-storage.corp", createClientForHost("file-storage.corp")); }createClientForHost()方法为不同 host 设置差异化连接池:
private OkHttpClient createClientForHost(String host) { ConnectionPool pool = new ConnectionPool( host.equals("api.internal.com") ? 20 : 5, // 关键业务 host 20 连接,文件存储 5 连接 5, TimeUnit.MINUTES ); return new OkHttpClient.Builder() .connectionPool(pool) .readTimeout(30, TimeUnit.SECONDS) .build(); }这种设计避免了“一刀切”的连接池配置。当api.internal.com出现慢请求时,它的连接池不会挤占file-storage.corp的连接资源。我在 JMeter 压测中模拟了api.internal.com的 5s 延迟,发现file-storage.corp的请求成功率仍保持 100%,证明连接池隔离生效。
3.2 超时分级:DNS、连接、读取、总超时全部可配
HttpClientPlugin的config支持四级超时:
config: url: "https://api.internal.com/v1/users" timeout: dns: 2000 # DNS 解析超时 connect: 3000 # TCP 连接超时 read: 10000 # Socket 读取超时 total: 15000 # 整个请求总超时(含重试)源码中,OkHttpClient的callTimeout()被禁用,改用自定义的TimeoutInterceptor:
public class TimeoutInterceptor implements Interceptor { @Override public Response intercept(Chain chain) throws IOException { Request request = chain.request(); long startTime = System.currentTimeMillis(); try { Response response = chain.proceed(request); long elapsed = System.currentTimeMillis() - startTime; if (elapsed > config.timeout.total) { throw new IOException("Total timeout exceeded: " + elapsed + "ms"); } return response; } catch (IOException e) { if (System.currentTimeMillis() - startTime > config.timeout.total) { throw new IOException("Total timeout exceeded on exception path"); } throw e; } } }这个拦截器确保即使 OkHttp 的底层超时未触发,总耗时也会被强制中断。我在测试中把total设为 100ms,read设为 5000ms,然后故意让服务端延迟 200ms 返回,结果TimeoutInterceptor在 100ms 时就抛出了异常——证明总超时优先级最高。
3.3 敏感头过滤:不是黑名单,而是白名单 + 动态脱敏
HttpClientPlugin的prepareRequest()方法里,对 headers 的处理逻辑是:
private Request prepareRequest(StepDefinition step) { Map<String, String> rawHeaders = step.getConfig().getHeaders(); Map<String, String> safeHeaders = new HashMap<>(); // 白名单头:只允许传递预定义的安全头 Set<String> allowedHeaders = Set.of("Content-Type", "Accept", "X-Request-ID"); for (Map.Entry<String, String> entry : rawHeaders.entrySet()) { if (allowedHeaders.contains(entry.getKey())) { safeHeaders.put(entry.getKey(), entry.getValue()); } else if (entry.getKey().toLowerCase().contains("auth")) { // 动态脱敏:Authorization 头只保留前缀 safeHeaders.put(entry.getKey(), entry.getValue().replaceFirst("(?i)Bearer\\s+[a-zA-Z0-9_\\-]+", "Bearer ***")); } } return new Request.Builder() .url(step.getConfig().getUrl()) .headers(Headers.of(safeHeaders)) .build(); }这种白名单 + 动态脱敏的组合,比单纯过滤Authorization更安全。它允许业务自定义X-Custom-Trace这类头,同时自动脱敏所有含auth的字段。我在日志里看到X-API-Key: abc123被完整记录,而Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...被截断为Authorization: Bearer ***,符合企业审计要求。
3.4 响应体截断:防 OOM 的硬核保护
HttpClientPlugin的parseResponse()方法里,对响应体做了严格限制:
private Object parseResponse(Response response) throws IOException { ResponseBody body = response.body(); if (body == null) return null; // 检查 Content-Length long contentLength = body.contentLength(); if (contentLength > 1024 * 1024) { // 1MB 限制 // 流式读取前 1MB,丢弃剩余部分 BufferedSource source = body.source(); byte[] buffer = new byte[1024 * 1024]; int read = source.read(buffer); String truncated = new String(buffer, 0, read, StandardCharsets.UTF_8); log.warn("Response body truncated at {} bytes for {}", read, response.request().url()); return Map.of("truncated", true, "content", truncated); } // 正常读取 return JsonUtils.fromJson(body.string(), Object.class); }这个逻辑在application/json场景下特别重要。当后端误返回 100MB 的调试日志时,HttpClientPlugin不会把整个字节数组加载进内存,而是主动截断并标记truncated:true。我在测试中用 Python 启动了一个返回 50MB 随机字符串的 mock server,HttpClientPlugin的内存占用稳定在 120MB,而未加截断的 OkHttp 版本直接 OOM。
3.5 失败重试:不是简单 retry,而是带退避和条件判断的智能重试
HttpClientPlugin的重试逻辑在execute()方法末尾:
public AgentResult execute(StepDefinition step, MemoryService memory) { for (int attempt = 0; attempt <= config.retry.maxAttempts; attempt++) { try { Response response = callWithTimeout(step); if (isSuccess(response)) { return AgentResult.success(parseResponse(response)); } // 条件重试:只对 5xx 和网络错误重试,4xx 直接失败 if (response.code() >= 500 || response.code() == 0) { if (attempt < config.retry.maxAttempts) { Thread.sleep(calculateBackoff(attempt)); // 指数退避 continue; } } return AgentResult.failed("HTTP " + response.code()); } catch (Exception e) { if (attempt < config.retry.maxAttempts && isNetworkError(e)) { Thread.sleep(calculateBackoff(attempt)); continue; } return AgentResult.failed(e.getMessage()); } } return AgentResult.failed("Max retry attempts exceeded"); }calculateBackoff()实现指数退避:
private long calculateBackoff(int attempt) { return Math.min(1000L * (long) Math.pow(2, attempt), 30000L); // 最大 30s }这种设计让重试更合理:对 401 Unauthorized 不重试(需要重新鉴权),对 503 Service Unavailable 重试,且第二次重试间隔 2s,第三次 4s,第四次 8s……避免雪崩。我在测试中模拟了 Nginx 的 503 响应,发现HttpClientPlugin在 1s、2s、4s 后重试,第四次成功,完全符合预期。
这个插件的源码告诉我们:一个“轻量”插件的真正含义,不是代码行数少,而是用最少的代码覆盖最多的生产场景。它没有引入 Spring WebClient 的全套生态,却用原生 OkHttp + 精准拦截,解决了企业 HTTP 调用的全部刚需。
4.pi-agent-bootstrap模块的启动真相:Spring Boot 不是容器,而是胶水
pi-agent-bootstrap模块的存在,很容易被误解为“Pi Agent 基于 Spring Boot 构建”。但翻开它的pom.xml,你会发现一个反常现象:spring-boot-starter-web的 scope 是provided,而pi-agent-core的依赖 scope 是compile。这意味着 Spring Boot 在这里不是运行时必需,而是可选的集成胶水。真正的启动入口,藏在BootstrapApplication类里:
public class BootstrapApplication { public static void main(String[] args) { // 1. 初始化核心组件 PluginManager pluginManager = new DefaultPluginManager(); MemoryService memoryService = new SqliteMemory(); ExecutorService executorService = Executors.newFixedThreadPool(10); // 2. 构建 AgentExecutor WorkflowDefinition workflow = YamlWorkflowParser.parse("workflow.pi.yml"); AgentExecutor executor = new AgentExecutor(workflow, pluginManager, memoryService, executorService); // 3. 启动 HTTP 服务(可选) if (isSpringBootAvailable()) { SpringApplication.run(SpringBootAdapter.class, args); } else { // 启动 Netty 服务 new NettyServer(executor).start(); } } }这段代码揭示了 Pi Agent 的本质:它是一个框架内核 + 多种启动方式的组合。isSpringBootAvailable()方法检查 classpath 是否存在org.springframework.boot.SpringApplication,存在则走 Spring Boot 启动路径,否则降级到轻量级 Netty 服务。我在pi-agent-bootstrap的src/main/resources/application.yml里看到:
pi-agent: mode: spring-boot # 可选值:spring-boot, netty, standalone workflow: classpath:workflow.pi.yml这个mode配置决定了整个应用的形态。当设为netty时,NettyServer类用 200 行代码实现了 HTTP Server:
public class NettyServer { private final AgentExecutor executor; public NettyServer(AgentExecutor executor) { this.executor = executor; } public void start() { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new HttpObjectAggregator(65536)); ch.pipeline().addLast(new SimpleChannelInboundHandler<FullHttpRequest>() { @Override protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) { // 解析 JSON body,调用 executor.execute() String json = req.content().toString(CharsetUtil.UTF_8); WorkflowInput input = JsonUtils.fromJson(json, WorkflowInput.class); CompletableFuture<AgentResult> result = executor.execute(input); // 异步返回结果 result.thenAccept(r -> sendResponse(ctx, r)) .exceptionally(e -> sendErrorResponse(ctx, e)); } }); } }); bootstrap.bind(8080).sync(); } }这种设计让 Pi Agent 具备了极强的部署灵活性:在 Spring Boot 生态成熟的 Java 团队,可以用@SpringBootApplication注解无缝集成;在资源受限的边缘设备,可以去掉 Spring Boot 依赖,用 Netty 直接启动;甚至在测试环境,还能用standalone模式,连 HTTP Server 都不启,只提供AgentExecutor的 Java API。
4.1 Spring Boot 适配层:不是侵入式集成,而是声明式桥接
当mode: spring-boot时,SpringBootAdapter类的作用是桥接:
@SpringBootApplication public class SpringBootAdapter { @Bean public AgentExecutor agentExecutor(PluginManager pluginManager, MemoryService memoryService, ExecutorService executorService, @Value("${pi-agent.workflow}") String workflowPath) { WorkflowDefinition workflow = YamlWorkflowParser.parse(workflowPath); return new AgentExecutor(workflow, pluginManager, memoryService, executorService); } @RestController public static class AgentController { private final AgentExecutor executor; public AgentController(AgentExecutor executor) { this.executor = executor; } @PostMapping("/execute") public ResponseEntity<AgentResult> execute(@RequestBody WorkflowInput input) { return ResponseEntity.ok(executor.execute(input)); } } }注意@Bean方法的参数:PluginManager、MemoryService、ExecutorService都是 Pi Agent 自己的接口,Spring Boot 只负责注入它们的实现类。这意味着你可以用@Primary注解替换SqliteMemory为RedisMemory,而无需修改AgentExecutor的任何代码——因为AgentExecutor依赖的是接口,不是 Spring 的@Component。
4.2 插件自动装配:Spring Boot 的@ConditionalOnClass用错了地方?
pi-agent-bootstrap的META-INF/spring.factories文件里,有这样一行:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.pi.agent.bootstrap.autoconfigure.PluginAutoConfigurationPluginAutoConfiguration类用了@ConditionalOnClass检查插件类:
@Configuration @ConditionalOnClass(name = "com.pi.plugin.http.HttpClientPlugin") public class HttpClientAutoConfiguration { @Bean @ConditionalOnMissingBean public PluginDefinition httpClientPluginDefinition() { return new PluginDefinition("http-client", "com.pi.plugin.http.HttpClientPlugin"); } }但这里有个陷阱:@ConditionalOnClass检查的是 classpath 是否存在HttpClientPlugin类,而不是pi-plugin-http-client.jar是否在 classpath。如果用户只引入了pi-plugin-http-client的 dependency,但没把它放在BOOT-INF/lib/下(比如用maven-shade-plugin打包时排除了它),@ConditionalOnClass会误判为插件存在,导致PluginManager初始化时找不到 jar 文件而失败。
我在实际部署中遇到了这个问题:用mvn package -Dmaven.test.skip=true打包后,pi-plugin-http-client-1.2.0.jar被正确放入BOOT-INF/lib/,@ConditionalOnClass生效;但用gradle bootJar打包时,插件 jar 被 unpacked 到BOOT-INF/classes/,@ConditionalOnClass依然能检测到,但PluginManager的loadPluginFromJar()方法却找不到 jar 文件路径,抛出FileNotFoundException。
解决方案是重写PluginAutoConfiguration,改用@ConditionalOnResource检查 jar 文件:
@ConditionalOnResource(resources = "classpath:pi-plugin-http-client.jar") public class HttpClientAutoConfiguration { // ... }这个细节暴露了 Pi Agent 在 Spring Boot 集成上的一个现实妥协:它想利用 Spring Boot 的自动装配能力,但又不能完全信任它的条件注解——因为插件的部署方式太灵活,jar 包可能以任何形式存在。最终,Pi Agent 选择了“手动注册为主,自动装配为辅”的策略:PluginManager的registerPlugin()方法始终可用,而 Spring Boot 的 auto-configuration 只是锦上添花。
4.3 启动时的插件扫描:不是 classpath 扫描,而是约定式加载
DefaultPluginManager的loadPlugins()方法,不依赖 Spring 的ClassPathScanningCandidateComponentProvider,而是按约定路径加载:
public void loadPlugins() { // 1. 加载 classpath:/plugins/ 下的 jar loadPluginsFromDirectory("classpath:/plugins/"); // 2. 加载 ${PI_AGENT_PLUGINS_DIR} 环境变量指定的目录 String pluginsDir = System.getenv("PI_AGENT_PLUGINS_DIR"); if (pluginsDir != null) { loadPluginsFromDirectory(pluginsDir); } // 3. 加载 --plugins-dir 参数指定的目录 String pluginsArg = System.getProperty("pi.agent.plugins.dir"); if (pluginsArg != null) { loadPluginsFromDirectory(pluginsArg); } }这种“约定优于配置”的设计,让插件管理变得极其简单:把pi-plugin-http-client-1.2.0.jar放到./plugins/目录下,启动时自动加载;想换插件,直接替换 jar 文件,无需重启 JVM。我在生产环境用 Ansible 部署时,只需copy新 jar 到plugins/目录,再发送kill -USR2信号触发PluginManager.reload(),就能热更新插件——整个过程不到 2 秒。
pi-agent-bootstrap模块的真相是:它不是一个 Spring Boot 应用,而是一个披着 Spring Boot 外衣的通用启动器。它的价值不在于提供了多少 Spring 特性,而在于用最少的胶水代码,把 Pi Agent 内核适配到各种部署场景中。当你在文档里看到 “Support Spring Boot 3.x” 时,真正该关注的不是版本号,而是它如何用providedscope 和@ConditionalOnClass这些小技巧,在不增加内核耦合的前提下,实现生态兼容。
5. 源码阅读的三个实战建议:避开新手最容易踩的三个坑
读 Pi Agent 源码时,我踩过不少坑,有些是技术细节,有些是思维误区。这里分享三个最痛的教训,帮你节省至少两天时间:
5.1 坑一:别在pi-agent-core里找“智能体”逻辑,真正的智能在pi-plugin-llm-call里
第一次读源码时,我执着地在pi-agent-core模块里搜索llm、model、chat这些关键词,结果一无所获。直到在pi-plugin-llm-call的LlmCallPlugin里看到:
public class LlmCallPlugin implements StepExecutor { private final LlmClient llmClient; // 这才是真正的 LLM 客户端 @Override public AgentResult execute(StepDefinition step, MemoryService memory) { // 1. 从 memory 读取 prompt template String template = memory.query(step.getConfig().getTemplateKey(), String.class); // 2. 渲染 prompt(用 mustache) String renderedPrompt = MustacheRenderer.render(template, step.getConfig().getContext()); // 3. 调用 LlmClient LlmResponse response = llmClient.chat(renderedPrompt, step.getConfig().getParameters()); // 4. 写回 memory memory.save(step.getOutput(), response.getContent(), Duration.ofHours(1)); return AgentResult.success(response.getContent()); } }这才明白:Pi Agent 的核心思想是“智能下沉到插件”。pi-agent-core只负责编排、状态管理、错误处理,真正的模型调用、prompt 工程、输出解析,全部交给pi-plugin-llm-call。这个插件本身又是个组合体:它用LlmClient接口抽象了不同 LLM 的调用,目前实现类有OpenAiLlmClient、QwenLlmClient、OllamaLlmClient。如果你想接入千问,只需要实现QwenLlmClient,而不用动pi-agent-core的任何一行代码。
提示:
pi-plugin-llm-call的LlmClient接口设计非常克制,只有chat()和streamChat()两个方法。它不关心 token 计数、不处理 system prompt、不管理 conversation history——这些都由上层 workflow 通过memory和step.config控制。这种职责分离让插件可以极度轻量,也避免了框架层的过度设计。
5.2 坑二:MemoryService的query()不是“查缓存”,而是“查状态快照”
很多开发者习惯性地把memoryService.query("user.id")当作类似 Redis 的GET操作,但 Pi Agent 的MemoryService是带版本和 TTL 的状态中心。我在测试时写了这样的 workflow:
steps: - id: set_user type: memory-set config: key: user.id value: "12345" ttl: 30s - id: get_user