资讯详情

资讯详情

Pi Agent源码解析:生产级智能体框架的架构设计与工程实践

1. 为什么从 Pi Agent 入手读源码不是因为“它火”而是因为它暴露了智能体框架最真实的生存逻辑最近翻 GitHub Trending 的时候Pi Agent 这个仓库的 star 数涨得有点反常——不是那种靠营销推上去的虚高而是每天稳定新增 200 starissue 区里讨论的全是“如何在离线环境部署”“怎么替换掉默认的 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 Enginebootstrap/目录甚至保留着 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里的ThreadLocalPluginInstance是怎么避免上下文污染的它不讲向量检索原理但它在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.1WorkflowDefinitionYAML 不是配置而是可执行的 DSLPi 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 读成MapString, 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被加载时都会创建独立的PluginClassLoaderpublic 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, ClassT 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, ClassT 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 CompletableFutureAgentResult 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而是构建了一个ConcurrentHashMapString, OkHttpClientprivate final MapString, 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()被禁用改用自定义的TimeoutInterceptorpublic 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设为 100msread设为 5000ms然后故意让服务端延迟 200ms 返回结果TimeoutInterceptor在 100ms 时就抛出了异常——证明总超时优先级最高。3.3 敏感头过滤不是黑名单而是白名单 动态脱敏HttpClientPlugin的prepareRequest()方法里对 headers 的处理逻辑是private Request prepareRequest(StepDefinition step) { MapString, String rawHeaders step.getConfig().getHeaders(); MapString, String safeHeaders new HashMap(); // 白名单头只允许传递预定义的安全头 SetString allowedHeaders Set.of(Content-Type, Accept, X-Request-ID); for (Map.EntryString, 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 serverHttpClientPlugin的内存占用稳定在 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 Serverpublic 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 ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new HttpObjectAggregator(65536)); ch.pipeline().addLast(new SimpleChannelInboundHandlerFullHttpRequest() { 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); CompletableFutureAgentResult 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 ResponseEntityAgentResult 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.skiptrue打包后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 的状态中心。我在测试时写了这样的 workflowsteps: - id: set_user type: memory-set config: key: user.id value: 12345 ttl: 30s - id: get_user
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →