首页 / 资讯中心 / 文章详情

纯Java构建企业级Agent Harness平台:架构设计与实践

纯Java构建企业级Agent Harness平台:架构设计与实践 ★ FEATURED ARTICLE
去年年中我们团队接了件挺折腾的事把散落在各业务线的 Agent 脚本统一收拢做成一个能支撑内部多个场景的“Agent 底座”。一开始大家默认会选 Python 那套生态毕竟模型工具链都在那边。但一轮技术评审下来我们反而不情不愿地走上了一条更“笨”的路——用纯 Java 从零搭一个企业级 Agent Harness 平台代号就叫 BizBuddy。这篇文章不聊模型本身多聪明也不聊 Prompt 怎么写就聊企业级 Agent 基建在 Java 技术栈里到底该怎么设计、怎么取舍。如果你所在团队也是以 Java/Spring 为主又不想为了几个 Agent 场景引入一套独立的 Python 运行时这篇东西应该对你有用。里面所有结论都不是拍脑袋得出的大部分是 BizBuddy 从 demo 到内部试运行期间被线上问题和同事吐槽逼出来的。我会尽量把关键设计讲透顺带附上能直接抄的 Java 代码骨架。1. 为什么在 Python 占据主流的 Agent 生态里押注纯 Java1.1 技术栈一致的隐性收益Agent 开发这几年基本被 Python 语言生态统治框架多、样例多、踩坑帖也多。但我们公司的情况非常典型核心业务服务清一色 Java中间件是 Kafka、Redis、MySQL 那一套运维和发布体系也都是围绕 JVM 应用搭建的。如果为一个 Agent 平台单独引入 Python 运行时等于同时养两套基础设施。两套运行时的代价不只是多装一个解释器。安全基线的扫描、依赖漏洞治理、日志采集、监控埋点、发布流程每一层都要重复适配一遍。小团队还能靠热情扛一扛一旦到了十几个人的平台组这种双轨维护累到怀疑人生。纯 Java 方案最直接的好处就是平台本身融入现有发布链路问题排查、依赖升级、容量扩容都走老一套成熟方案。还有一个常被忽略的点Java 中间件生态足够厚。Agent 平台真正跑起来以后要接企业内部的数据源、消息队列、RPC 服务、定时任务这些在 Java 侧本来就是“老本行”。如果选了 Python很多内部 SDK 根本不提供官方支持最后大概率还是得让 Agent 平台通过 HTTP 去调 Java 服务白白多一层跳板和故障点。1.2 类型系统是企业级 Agent 的隐藏防线Python 做 Agent demo 确实快但企业级场景里“快”的代价是问题发生得晚。Agent 平台的核心工作之一是管理工具的调用契约模型的输出最终要映射成一串 Java 方法的真实参数。Python 那边靠字典和运行时判断字段名拼错了要到调用的那一刻才炸Java 这边通过注解、Schema 生成和编译期检查能把一大批错误挡在发布之前。BizBuddy 里每个可被 Agent 调用的工具都有自己的 Schema 定义这个定义用一种中间 DSL 描述再在编译期生成校验器和文档。模型只要给出符合 Schema 的 JSON框架就能安全地把它转成工具方法的入参。这种“契约先行”的做事方式在 Java 里做得非常顺手而换成脚本语言反而没有这么自然的落点。1.3 “纯 Java” 的边界不是说不能用周边生态这里必须澄清一下“纯 Java”到底指什么。我们的意思是Agent 运行时内核不依赖任何非 JVM 脚本语言不把 Python 当成子进程拉起再通信。但这不代表要拒绝成熟类库。Jackson、JDK HttpClient、Spring Boot、PostgreSQL 这些该用就用它们本来就是 JVM 生态的一部分。实际上 BizBuddy 的核心引擎刻意保持了 POJO 化不绑定 Spring 的注解和 IoC 容器但对外接入层还是用 Spring Boot 暴露管理和运维接口。这么设计的理由后面会细说核心引擎要保持纯净和可测试接入层则要享受 Spring 生态的便利两者靠接口隔离。2. BizBuddy 的总体架构与模块边界2.1 Harness 与 Framework 的区别我们到底在做什么开始动工之前我们先掰扯清楚一个概念Agent Harness 和 Agent Framework 不是一回事。很多人把它们混为一谈结果架构设计从一开始就歪了。Framework 是一套库嵌入在业务应用里由开发者自己调用把 Prompt 组装、模型调用、工具循环这些能力作为 API 提供给你而 Harness 是一个以独立进程形态存在的平台它负责 Agent 的完整生命周期管理。一个 Agent 在 Harness 上不是一段代码而是一个可发布、可调度、可观测、可回滚的“工作负载”。维度Agent FrameworkAgent Harness部署形态库嵌入业务进程独立平台进程调用方业务开发者平台使用者/管理员生命周期管理无随宿主进程有发布/重启/停止/回滚治理能力基本没有权限、审计、配额、成本统计可观测性应用自行打日志平台统一链路与执行快照BizBuddy 的目标非常明确做后者。它不是又一个 LangChain 式的 Java 复刻版而是一套让企业内部 Agent 规模化落地的运行环境。单机一个脚本怎么循环调用模型我们不管但一旦有多个团队、几十个 Agent、上百个工具在平台上跑治理能力就比模型本身更重要。2.2 内核、接入、能力三层划分BizBuddy 的整体架构按三层切分这个划分后来被证明是整套系统最值得的一笔投入内核层kernel负责 Agent 编排状态机、工具注册中心、会话上下文、Token 预算控制。这一层不依赖任何 Web 框架依赖只有 Jackson 和 JDK 自带的并发工具。接入层runtime adapter负责 Spring Boot 启动、HTTP API、LLM 网关适配、存储持久化。这一层是把内核接入具体运行环境的胶水。能力层capability registry负责各种具体 Agent 能力比如查询订单、调内部知识库、生成报表。能力层以插件形式注册进内核权限和审计由内核统一管控。层与层之间的唯一通信方式就是接口和事件。内核不感知自己跑在 Spring Boot 里也不知道调用的是哪家大模型服务商的接口它只面向一组抽象协议编程。2.3 与 Spring 的依赖隔离核心引擎保持 POJO这里单独展开一下为什么要做依赖隔离。最开始我们图省事直接让核心引擎用 Spring 的Component管理 Bean写起来确实快。但测试到第二轮就受不了了每写一个单元测试都要启动 Spring 上下文慢不说还经常被配置牵连。后来做了一次大重构把核心引擎里所有 Spring 注解全部剥离改成纯 POJO 构造器注入。引擎对外只暴露几个入口类由接入层负责把它们组装成 Spring Bean。重构之后单元测试从“启动一个应用”变成“new 一个 Engine”速度提升了几个数量级心智负担也小很多。这是纯 Java 路线里我个人最推荐的一个习惯能不用框架的地方尽量不用框架。3. 编排引擎与 Agent 主循环的取舍3.1 从死循环到显式状态机限制即自由Agent 主循环这个概念大家都懂不断把当前对话历史和工具结果拼给模型模型决定是继续调工具还是给出最终答案。理论上一个 while 循环就能写出来while (!session.isFinished() session.getIteration() maxIterations) { LlmResponse resp gateway.complete(session.toChatRequest()); if (resp.hasToolCalls()) { for (ToolCall call : resp.toolCalls()) { ToolResult result registry.invoke(call.name(), call.arguments()); session.appendToolResult(call.id(), result); } } else { session.setFinalAnswer(resp.content()); } session.incrementIteration(); }这个循环跑通 demo 只需要半天。但 BizBuddy 没有停在 demo因为企业级场景根本不敢用这种松散的循环上生产循环没跑到哪一步、工具调用是成功还是失败、卡在某一步需要人工介入这些东西在朴素循环里全都看不见。我们的选择是把循环重构成显式状态机。每个 Agent 执行都被建模成一组状态和状态间的迁移条件比如READY - REASONING - TOOL_EXECUTING - TOOL_PROCESSING - FINAL_ANSWER。状态机的好处是每一步都有明确的“身份”保存、恢复、观测都变成了状态自身的属性而不是在循环体外围硬凑日志。3.2 中断、恢复与长时任务处理企业内部 Agent 很少能一条链路跑到底。常见场景是Agent 查出结果后需要上级审批或者某个工具调用依赖人工补充的信息。如果主循环不设计中断恢复这种场景就只能外挂一个“暂停”标志非常丑陋。BizBuddy 的编排引擎把“等待外部输入”实现成一种特殊状态。Agent 执行到该状态时会持久化当前快照然后释放线程资源外部系统通过 API 回传结果后引擎从快照恢复现场接着往下跑。这个设计让 Agent 处理小时级甚至天级的长时任务也不会占着线程不放同时也为后续做任务队列和优先级调度留了余地。快照恢复还有一个非常大的隐藏价值可重现性。出问题时可以直接把线上某个失败的会话快照取到本地重放这在排障时救了我们无数次。3.3 编排器的运行时抽象与代码骨架编排器最核心的抽象是 Session、State、Transition 三件套。下面给一个精简版的骨架真实实现比这多一些策略扩展点但主线逻辑就是这样public final class AgentSession { private final String sessionId; private final StateGraph graph; private State currentState; private final ContextSnapshot snapshot; public void resume() { while (graph.hasNext(currentState)) { currentState graph.next(currentState); currentState.execute(snapshot); } } public void pause() { snapshot.persist(); } public void restore(byte[] serialized) { snapshot.load(serialized); currentState graph.restoreState(snapshot); } }实际使用时状态的执行逻辑是一个个实现了StateHandler的类每个类只负责一件事调用模型、执行工具、走审批分支、产出终答。这样即使某个状态出问题也只需要看那一个类的日志不用像朴素循环那样从头理到尾。要特别说明的取舍是BizBuddy 第一版没有上复杂的 DAG 流程引擎。多方讨论后我们认定顺序状态链已经覆盖了约 90% 的场景而 DAG 的并行分支、条件回环、节点重试这些能力虽然听起来很厉害但会急剧抬高使用门槛和排障难度。这部分能力我们选择下沉到异步任务层而不是塞进编排引擎里。等真的有人需要多个 Agent 并行协作时再加不迟。4. 工具注册、权限与动态调用4.1 三个必须的元信息名称、描述、参数声明Agent 平台里工具就是模型可以调用的“函数”。让模型能正确使用工具只靠 Java 方法名是不够的因为模型根本看不到 Java 方法它只看到我们喂给它的 JSON Schema。所以每个工具必须显式声明三样东西名称、描述、参数规则。名称要短且唯一描述要用模型能理解的自然语言说清楚“这个工具什么时候用、会返回什么”参数规则要精确到每个字段的类型、是否必填、枚举值、示例。这里有个容易偷懒的坑很多人写描述就一句话模型到了模糊场景就抽风。BizBuddy 的实践是描述必须包含“成功/失败返回结构”和“典型调用场景”这些话术提上去之后工具误调用率肉眼可见地降了。参数声明我们不是让开发者手写 JSON Schema而是用 Java 注解描述编译期生成 Schema 和校验器。这样做的好处是 Java 方法签名和 Schema 永远不会失同步。4.2 从注解到调用的完整链路先看工具类的定义方式AgentTool( name query_order, description 按订单号查询订单状态适用于用户咨询订单进展的场景。返回订单状态、金额和更新时间。, params { ToolParam(name orderId, type string, required true, description 订单号例如 ORD202501010001), ToolParam(name withDetail, type boolean, required false, defaultValue false, description 是否返回商品明细) } ) public class QueryOrderTool { public JsonNode execute(String orderId, boolean withDetail) { // 内部调用订单服务并组装结果 } }框架在启动时扫描注册的工具类解析注解生成工具元数据同时用反射创建一个调用器。当模型发出工具调用请求时调用链路是框架从模型响应里解析出工具名和参数 JSON按照工具 Schema 校验参数完整性缺字段当场报错回填给模型校验通过后通过MethodHandle或反射调用真实方法调用返回值统一转成 JsonNode必要时做截断异常统一包装成机器可读的错误结构把工具结果作为一条tool消息追加进会话历史重新请求模型。4.3 工具权限与审计为什么不能只靠白名单工具注册只是第一步企业级更重要的是“谁能调什么”。BizBuddy 给每个工具划分了可见范围全员可用、特定角色可用、管理员专用。比如查公开库存信息全员可用但查客户联系方式必须有专门授权。权限判断是在每次工具调用前实时执行的而不是注册时过滤一次。为什么因为 Agent 的调用者是用户用户的身份要透传到工具层。同样是query_order工具普通用户只能查自己的订单客服可以查用户全量订单。这些行级权限不能由框架闭着眼睛放行必须落在工具实现里和业务系统打通。我们的做法是框架负责功能级权限这个用户能不能调这个工具工具实现负责数据级权限这个用户能查哪些数据。框架层面还把每次工具调用都记录成审计流水包括调用人、会话、入参出参摘要、耗时、费用归属所有记录完全只读不受工具执行结果影响。审计流水不只为合规它还是后面做成本核算和问题回溯的数据基础。5. LLM 接入层协议统一与函数调用的工程化5.1 统一网关抽象屏蔽厂商差异Java 侧接入大模型最怕的就是每接一家厂商就引入一个专用 SDK接口风格还不一样。BizBuddy 的做法是定义一个极薄的LlmGateway接口只保留两个语义普通对话补全和带工具调用的补全。因为目前各家模型服务商基本都提供/v1/chat/completions这一事实标准的 HTTP 端点的兼容实现网关层在技术上很容易收敛。我们内部只维护一个基于 JDK HttpClient 的默认实现通过配置切换不同厂商的 baseUrl、模型名和密钥。真遇到协议不兼容的特殊厂商再单独写一个适配器实现LlmGateway接口。public interface LlmGateway { ChatResponse complete(ChatRequest request); StreamChatChunk stream(ChatRequest request); }ChatRequest和ChatResponse都是我们自己定义的轻量模型内部就是 JsonNode 的薄包装。这样核心引擎依赖的是我们自己的接口而不是某个厂商的 SDK 类型换厂商或换版本都不至于牵一发动全身。5.2 Tool Call 在 Java 里的映射与回填工具调用Tool Call / Function Calling是 Agent 和模型交互最要紧的环节。模型返回的响应里有一个工具调用数组每个调用包含id、toolName、arguments三样东西。Java 侧的映射干净直接public record ToolCall(String id, String name, JsonNode arguments) {} public record ChatResponse( String content, String finishReason, ListToolCall toolCalls, Usage usage ) {}实际调试里最折磨人的不是调用而是回填。模型要求你严格按照tool消息回传每个工具结果且tool_call_id必须和调用 id 严格对应否则模型会“失忆”不承认自己发起过这个调用。我们用一个小技巧规避这事每个ToolCall都原样记录工具结果回传时带着同一个 id顺序也保持不变。曾有同事图省事把结果批成一坨文本塞进 user 消息结果模型当场开始瞎编教训惨痛。5.3 重试、限流与熔断的落地姿势模型服务是外部依赖必然有抖动。BizBuddy 把重试策略做得非常克制只对三类情况重试网络超时、HTTP 429、5xx 服务端错误。4xx 业务错误一律不重试因为那是参数或者鉴权问题重试只会浪费钱。重试必须带指数退避和抖动。我们没引入额外库自己实现了一个简单的RetryPolicypublic static final class RetryPolicy { private final int maxAttempts; private final Duration baseDelay; private final Duration maxDelay; public Duration nextDelay(int attempt) { long exp (long) Math.pow(2, Math.min(attempt, 8)); long delta ThreadLocalRandom.current().nextLong(0, 200); return Duration.ofMillis( Math.min(baseDelay.toMillis() * exp delta, maxDelay.toMillis()) ); } }熔断设计成了每个模型服务商一个信号量限制并发 pending 的 LLM 请求数。超过阈值后新的请求直接走降级逻辑返回“模型服务繁忙请稍后重试”而不是让流量继续打到上游。这个信号量非常便宜但从实测看效果极好模型服务被我们自己的并发流量打爆的事再没发生过。6. 上下文管理与记忆Token 预算的运营思维6.1 Token 预算不是技术问题是业务问题上下文管理是 Agent 平台最容易被低估的部分。问题不在“塞不下”而在于塞进去的东西会稀释模型注意力还都在烧钱。BizBuddy 把 Token 预算当作一个显式的业务决策而不是技术细节。每个会话在创建时就会设置一个输入 Token 上限超了就要触发压缩。我们没有一开始就上多复杂的压缩算法而是先用最简单的方式历史摘要。当会话历史接近预算红线时用一个独立的摘要模型把早期对话压成一段 summary然后替换掉原始消息序列。模型看到的是“压缩摘要 最近几轮完整对话”效果平庸但稳定够用。后来发现一些工具返回的大 JSON 是真正的吃 token 大户再好的摘要也兜不住巨型的工具结果。所以我们给工具返回值加了截断策略超过一定字符数的结果不再原样塞进上下文而是替换成“结果过长已截断总大小 N 字符请使用 XX 工具进一步查询”。同时引导工具实现提供分页或聚合查询能力让模型能“按需拉取”而不是“一次拉全”。6.2 压缩与检索两层思路并用只靠摘要压缩有一个极限摘要会丢掉细节模型后面对某些细节又是需要的。BizBuddy 的第二层是外部记忆检索。我们把每个会话里产生的重要实体、结论、关键工具结果抽出来存成带标签的结构化记忆必要时通过相似度召回拼装进上下文。这里有个技术选型上的取舍向量检索没有自己硬造。纯 Java 很难实现高性能的 ANN 索引即使强行用 JDK 写一个也远不如成熟方案。我们的做法是定义一个RetrieverSPI内部实现对接现有的检索中间件或者数据库自带向量能力核心引擎完全不关心具体召回逻辑。内核保持简单能力交给成熟组件这比什么都想自己干靠谱得多。7. 可观测性与排障敢上生产的前提7.1 执行快照把 Agent 会话变成可回放的黑盒Agent 应用和普通 Web 服务的排障体验差别很大。普通接口日志只要记录入参出参和耗时基本就够但 Agent 一个会话里可能有几十次模型调用、十几次工具调用还有状态迁移和上下文压缩。靠 grep 日志拼过程能拼到崩溃。BizBuddy 的解法是给每个会话维护一个执行快照表每一次状态迁移、每一次模型请求响应、每一次工具调用都作为不可变事件追加进去。出问题时直接把整个会话的事件序列导出来在本地模拟器里逐步回放哪一步开始行为不对一目了然。执行快照还和前面的中断恢复共用了同一份数据恢复现场和回放现场本质上是一件事。7.2 Token 账单与工具超时治理成本核算是企业级 Agent 平台绕不开的硬指标。我们在接入层设计了最朴素的计费落点每次模型调用返回的usage字段都会落库按会话、按业务线、按天汇总。表格结构大致是这样CREATE TABLE agent_call_audit ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(64) NOT NULL, business_line VARCHAR(64) NOT NULL, state_name VARCHAR(64) NOT NULL, llm_model VARCHAR(128) NOT NULL, prompt_tokens INT NOT NULL, completion_tokens INT NOT NULL, tool_name VARCHAR(128), tool_latency_ms BIGINT, error_message VARCHAR(1024), created_at DATETIME NOT NULL, INDEX idx_session (session_id), INDEX idx_biz_time (business_line, created_at) );在这个表上跑统计和告警极其顺手。我们每天固定跑三个告警单会话成本超过预设阈值、工具超时率超过 5%、同一天内同一个工具连续出错超过十次。这三个指标比单看模型响应成功率更能暴露真实问题因为多数 Case 是工具侧惹的祸模型只是把工具排错的信息原样吐了出来。8. 踩坑实录纯 Java 做 Agent 的隐性陷阱8.1 反射异常包装让模型拿到一堆没用的错误信息第一个大坑来自反射调用。工具方法通过反射执行一旦内部抛异常反射层会把它包成InvocationTargetException。如果不做解包模型看到的错误就是“java.lang.reflect.InvocationTargetException”这行毫无信息量的文字它再怎么思考也猜不出是订单服务超时还是参数非法。修复很直接工具调用器必须把目标异常一层层解包解到最里面的业务异常再组装成错误描述。Throwable cause ex instanceof InvocationTargetException ? ex.getCause() : ex;这个解包逻辑我们写进工具调用器公共路径之后模型面对工具错误时的“智力水平”明显上升因为它终于能看到“OrderTimeoutException: 下游调用超时请稍后重试”这种能理解的信息。8.2 金额与时区的序列化陷阱第二个坑是类型精度和时区。钱在 Java 里必须用 BigDecimal 或专门的金额类型但模型回传的参数是 JSON 文本工具结果也要被序列化成 JSON 给模型看。BigDecimal 在 Jackson 里默认序列化可能变成浮点数1.10 会被写成 1.1两个值在模型眼里是同一个数字但在业务系统里可能是两个完全不同的钱数。我们的做法是金额字段输出时一律格式化为带固定小数位的字符串例如123.45而不是裸数字。时间字段同理一律输出 ISO8601 带时区偏移的字符串。模型从工具拿到的数据必须是“自解释”的不能留任何可能产生歧义的裸格式。这个规则后来写进了 BizBuddy 的工具开发者规范里宁可输出笨一点也不能让模型瞎猜。8.3 线程池隔离一个慢工具拖垮一个 Agent第三个坑是线程模型。朴素实现里工具调用和 Agent 循环共用一个线程遇到慢工具只能干等。更隐蔽的是我们最初用的共享线程池里有几种不同类型的任务混跑模型请求、工具执行、状态持久化。结果一次某查询工具发布异常上千个请求挂着把线程池占满连模型调用都进不去整个平台雪崩。修复方案是把工具执行单独隔离成一个有界线程池并且每个工具调用强制设置超时ExecutorService toolPool Executors.newFixedThreadPool(32); FutureJsonNode future toolPool.submit(() - tool.invoke(args)); JsonNode result; try { result future.get(30, TimeUnit.SECONDS); } catch (TimeoutException e) { future.cancel(true); throw new ToolTimeoutException(tool.name(), 30); }这一步做完最坏情况也就是某个工具自身的请求超时不会再拖垮整个平台。这个教训我记到今天Agent 平台本质是个 IO 密集型调度系统线程隔离和超时控制比任何花哨算法都重要。8.4 Agent 死循环与 maxIterations 之外的控制最后这个坑来自模型的“执念”。虽然主循环有maxIterations兜底但很快我们发现单纯限制轮数远远不够模型可能每轮都在调同一个工具参数还一样场景是某种固定模式比如“当前结果不满足预期那就再查一次”。这种循环不超轮数上限但明显在做无用功。后来我们加入了“行为指纹”检测如果连续三轮模型生成的工具调用参数完全相同且工具结果没变化就判定为循环主动打断并向模型指出“你正在重复发起相同调用结果没有变化请改变策略或结束任务”。这个启发式规则糙但管用挽救了不少白白烧掉的 Token。最后再说两句实操体会BizBuddy 做了大半年最大的体会是在企业级场景里Agent 平台的技术难点根本不在“让模型变聪明”而在“让不确定的过程变得可控”。纯 Java 这条路选得不算轻松很多 Agent 生态的现成轮子用不了一切都要自己包装一层但换来的是和现有技术栈的深度融合以及长期可维护的心理踏实。如果你们团队也是 Java 为主、又需要把 Agent 交付成正经系统我给的建议就三条先把一个朴素主循环跑通再谈状态机和治理先把工具调用链路做完整再谈记忆和向量先把执行快照落库再谈优化和成本控制。这样走坑会少很多。
阅读完成 · 觉得有帮助?
咨询建站