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

📅 发布时间:2026/10/12 7:57:34
纯Java构建企业级Agent Harness平台:治理设计与实践
做企业级 Agent Harness 平台这件事用纯 Java 来扛很多人第一反应是不划算。但如果你真的在企业里做过 AI 应用你会明白我说的“企业级”到底卡在哪儿不是模型有多聪明而是权限、审计、隔离、降级、成本控制这些破事有多少。BizBuddy 就是我在“用纯 Java 做一个 Agent Harness 平台”这个方向上的一次系统性验证它解决了 Agent 应用从 Demo 到生产之间最大的问题——不是不会写而是没法放心跑。这篇文章不聊概念只聊设计和取舍适合那些打算在 Java 技术栈里落地多智能体系统又不愿意把核心逻辑全押在某家云厂商上的团队。1. 为什么企业要一个 Agent Harness 而不是抱着 Spring Boot 硬写1.1 框架、应用与 Harness 的分层先说一个很多人混淆的点Agent 框架、Agent 应用、Agent Harness 是三样东西。框架解决的是“Agent 怎么写”比如 LangChain4j、Spring AI、或自己写一个 Agent Loop应用解决的是“用户的某个具体需求怎么被一个 Agent 完成”比如工单助手、报表助手而 Harness 解决的是“一批 Agent 怎么被安全、可控、可观测地运行起来”。它不是某一个 Agent 的运行时而是所有 Agent 的公共运行底座。你可以把它理解成微服务时代的注册中心加网关加监控大盘——单个服务是把业务写出来但服务质量、发布、治理靠的是中间件。所以 BizBuddy 的第一条设计原则是不绑定任何具体 Agent 逻辑。它只约束 Agent 运行的边界提供生命周期管理、工具调用、状态持久化、策略执行与审计能力。具体你的 Agent 是 ReAct 风格还是 Plan-and-Execute是单轮答复还是长流程编排Harness 不关心。这个分层决定了后面所有模块的接口长什么样。1.2 从单体机器人到可治理的系统很多团队的第一版 Agent 应用是单体一个 Controller 接收用户消息调模型循环若干次工具调用返回结果。这种写法在实验阶段完全没问题我自己也这么写过。但一旦 Agent 承接了五个以上的真实业务动作问题就接踵而来。首先是不可观测。Agent 内部的每一轮“模型想了一下、调了一个工具、拿到结果再想”在单体代码里只是一段循环出了问题只能打日志猜。其次是权限失控。单体 Agent 的工具方法就是普通 Java 方法只要能拿到 Bean就能调LLM 一旦被恶意提示词引导去调一个不该调的内部接口代码层面没有任何拦截。第三是无状态假设失效。真实业务里 Agent 的任务经常要跑几分钟中间被限流、被审批挂起、被网络抖动打断没有持久化就没有恢复。这些问题的共性是 Agent 缺了一个“运行环境”。Harness 的价值就是把单体里的这些横切能力抽出来做成 Agent 与外部世界之间的治理层。1.3 纯 Java 选型我不是图它简单而是图生态为什么是纯 Java而不是 Python FastAPI LangGraph理由其实很现实大部分企业的核心业务系统是 Java。Agent Harness 要连接到订单、CRM、权限中心这些东西本质上就是连企业中间件。用 Java 最直接的好处是类型安全。Agent 的工具调用从 JSON 到 Java 对象的转换放在运行时靠 Python 的 dict 靠不住但用 Java record 加 Jackson 可以在编译器就拦住大部分拼写错误。另一个好处是生态Spring 的配置体系、Spring Security 的权限链路、Java 17 的虚拟线程、Micrometer 的指标体系这些都是现成的不用造轮子。还有一个隐性优势是团队。企业里能写 Java 的人比能写 Python 的人多得多Agent 平台一旦落地后续维护、扩展、排障的成本会低很多。用 Java 不是说 Python 不行而是 BizBuddy 的目标是融入企业软件体系不是取代它。1.4 团队用 Java 写 Agent 的隐性成本与回报当然纯 Java 也有代价。最明显的是模型生态的对接成本模型厂商的 SDK 基本都把 Python 当一等公民Java 得自己封装。这块 BizBuddy 的做法是把模型 Provider 做成 SPI各家模型自己实现一个适配层核心框架不直接依赖任何厂商 SDK。第二个成本是样板代码比 Python 多。你要写领域模型、定义接口、配置说明代码量上确实不占便宜。但回报也在这里一个上千行的 Java 服务任何人打开都能顺着类型定义理解边界而一个上千行的 Python Agent 脚本往往只有原作者能看明白。对企业来说可维护性比开发速度重要得多。2. BizBuddy 整体架构与核心设计思路2.1 内核分层模型无关的 Agent LoopBizBuddy 的运行时内核是一个模型无关的 Agent Loop核心接口只有三个AgentTask、AgentStep、AgentLoopRunner。public interface AgentTask { String taskId(); String goal(); // 用户原始目标 MapString, Object context(); // 初始化传入的上下文 } public interface AgentStep { StepResult execute(AgentContext ctx); }Agent Loop 的逻辑是拿到任务构建 Prompt调用模型模型如果返回工具调用请求就执行工具再把结果放回上下文重复直到模型输出最终回答。这个循环本身并不复杂复杂的是循环里的每一步都要经过 Harness 的策略点模型调用的预算还剩多少、这一步的工具权限有没有过、这个状态要不要持久化、这一步的链路追踪有没有上报。BizBuddy 把这些策略点抽象成AgentInterceptorpublic interface AgentInterceptor { void beforeToolCall(ToolCallContext ctx); void afterToolCall(ToolCallContext ctx, ToolResult result); void onModelResponse(ModelResponse resp); }所有横切能力——预算、审计、指标采集、熔断——都挂载在这些拦截点上。这样 Agent 业务代码保持干净治理能力却无处不在。2.2 工具网关与权限模型Agent 与外部世界的交互必须经过一个网关BizBuddy 管它叫ToolGateway。之所以强调“必须”是因为这是企业级后面的一块基石。我在设计网关时参考了 API Gateway 的思路LLM 发出一个工具调用网关先做四件事——解析工具名、校验参数 Schema、检查调用者权限、记录审计日志。权限模型不是简单的“有没有权限”而是按 scope 控制public record ToolPermission( String principalId, // 调用 Agent 的身份 String toolName, // 允许使用的工具名 SetString actions, // 允许的操作如 read/write SetString resources) // 允许访问的资源范围举个例子财务 Agent 可以调报销查询工具但 actions 里只有readresources 只能是自己部门的deptId1024。LLM 生成的目标可能是“查所有部门的报销单”但网关在参数校验层就把 resource 范围给钳制住了。这个设计解决了一个关键问题你没法保证大模型永远按规矩出牌但你可以保证大模型违反规矩时动不了任何真东西。2.3 状态持久化与执行上下文Agent 任务一跑就是几十轮工具调用任何一个环节断了状态丢了用户就得从头再来。这是单机内存无法承受的。BizBuddy 把每个任务的状态做成了可持久化的AgentRun对象存放在数据库中。create table agent_run ( run_id varchar(64) primary key, task_id varchar(64), status varchar(32), current_step int, context_json clob, created_at timestamp, updated_at timestamp );context_json就是 Agent 的短期工作记忆固化。每次 Agent Loop 迭代完成框架会序列化整个上下文并更新状态。这样即使 JVM 重启任务也能从最近一次已完成的步骤恢复而不是全部推倒重来。这里有一个取舍不是所有状态都值得全量持久化。对话历史、中间推理、工具原始返回可能非常庞大全序列化会让数据库和序列化开销成为瓶颈。我的做法是只持久化“决策所需的最小状态集”目标、已完成步骤、关键变量、下一步计划而把大文本存到对象存储或消息队列用state_ref引用。2.4 连接器层模型 Provider、检索与工作流BizBuddy 的核心不绑定具体模型所以ModelProvider是 SPI。每个 Provider 负责把统一的ChatRequest翻译成各家模型的请求格式再统一返回ChatResponse。public interface ModelProvider { ModelType type(); ChatResponse chat(ChatRequest request); }现在支持通义、GPT 风格接口的本地网关、以及内部自研的轻量模型服务。只要接入方实现了这个接口Agent 的运行时不需要感知模型差异。检索能力同理BizBuddy 定义为Retriever可以对接向量数据库、Elasticsearch、或者一个简单的数据库 Like 查询——对 Harness 来说都只是“给定查询返回候选文档”的服务。工作流编排我一开始想引入外部 BPM 引擎后来放弃了。原因是 Agent 的流程并发的确定性不如人工审批流程节点可能动态生成条件分支可能由模型决定。BizBuddy 采用自研的轻量步骤编排Agent 可以在执行中动态生成下一个步骤也可以主动 suspend 等待人工审批这些动作都被建模为AgentStep的变体。2.5 可观测性链路、Token 成本与运行指标企业跑 AI 应用最怕黑盒。BizBuddy 从第一版就内置了可观测性三件套链路追踪、成本核算、运行指标。链路追踪这块直接复用 OpenTelemetry 的标准每个 Agent Run 是一个根 Span每一轮模型调用、每一个工具执行、每一次外部 API 请求都是子 Span。这样问题定位可以从“你猜内部发生了什么”变成“打开链路看哪一步慢”。成本核算容易被忽略但企业里特别重要。模型调用不像数据库每次返回的 token 数量直接变成钱。BizBuddy 的事件流里记录了每次模型的输入输出 token 数和单价聚合后可以生成任务级成本、部门级成本报表。public record TokenUsage(int promptTokens, int completionTokens) {} public record CostRecord( String runId, String principalId, String modelName, TokenUsage usage, BigDecimal cost) {}运行指标用 Micrometer 暴露包括工具调用成功率、平均工具耗时、Agent 循环轮数分布、任务完成时长等。这些东西对容量规划和稳定性告警特别有用。2.6 编排层父子 Agent 与人工审批节点复杂任务靠一个 Agent 硬啃效果通常不好。BizBuddy 支持父子 Agent 编排主 Agent 负责拆解目标子 Agent 负责执行具体领域任务。每个子 Agent 也有自己的 ToolGateway、预算、权限边界。人工审批是另一块关键设计。Agent 永远不应该替用户做“删除生产数据”这种决策所以工具调用可以标记为requiresApprovaltrue。网关遇到这类调用不会直接执行而是生成一个ApprovalTicket把任务挂起。审批人通过一个简单的 Web 页面查看上下文、决定通过或拒绝然后任务流程继续。这个机制在银行、政务、企业内部场景是刚需。3. 关键实现细节与企业级取舍3.1 Agent 循环超时、重试与预算是三根柱子Agent Loop 写起来容易稳定运行难。我调试时发现大部分问题集中在三件事模型调用超时、工具调用失败后死循环、Token 成本失控。所以这三根柱子我分别做了明确策略。超时分两级单次模型调用超时默认 60 秒和整个 Agent Run 超时默认 10 分钟。单次超时触发重试重试次数默认 2 次退避策略是“500ms 开始指数退避最大 5 秒”。整个 Run 超时则不重试直接失败并持久化失败状态。预算分两层Token 预算和金额预算。BizBuddy 在拦截点记录累计消耗任何一次模型调用前先检查预算超了就终止循环并返回当前结果。这里有个细节终止前要把已收集的信息整理成用户可读的回答而不是干巴巴抛异常——用户不在乎“预算耗尽”只在乎“为什么没有结果”。3.2 结构化输出强制 Schema 而不是碰运气让大模型稳定给出 JSON 是 Agent 落地最烦的问题之一。BizBuddy 的做法是“两头堵”请求端在 Prompt 里给 JSON Schema 示例响应端对模型输出做严格解析。模型输出的格式校验不能只靠parseJSON因为模型经常输出 Markdown 代码块、前后有多余文字、字段名大小写漂移。BizBuddy 的解析器执行多步归一化剥离 Markdown 围栏、提取第一个{到最后一个}、用 Jackson 解析到目标类型、随后用 Schema Validator 校验必填字段。解析失败时不会直接给用户报错而是把错误信息拼成新的 Prompt 回传给模型要求修正最多修正两次。如果模型 Provider 原生支持 JSON Mode比如参数里声明 response_formatBizBuddy 会优先开启。这种方式能在源头抑制格式漂移比事后补救有效得多。3.3 记忆短期对话窗与长期向量记忆的取舍企业级 Agent 到底要不要长记忆这是个讨价还价的问题。BizBuddy 一开始就做出了取舍短期对话窗是刚需长期记忆是可选。短期对话窗的实现就是把最近 N 条消息塞进请求上下文N 的选取要考虑模型的上下文窗口和成本。我一般控制在 10 到 20 条且不是无脑全塞——工具返回结果特别长的时候会主动截断或摘要压缩。长期记忆我采用的是“按任务存储按需检索”的策略Agent 完成一个任务后把结论和关键实体写成MemoryEntry存入向量库。新任务开始时Harness 根据用户目标做一次语义检索把最相关的记忆条目注入 Prompt。这个设计比“把所有历史都塞进去”便宜得多也更容易说清楚隐私边界。实际跑下来它带来的提升集中在重复性事务场景效果不错。3.4 异步编排普通线程池、CompletableFuture还是虚拟线程Agent 执行天然适合异步一次要调多个工具、多个子 Agent 可以并发跑。BizBuddy 的编排引擎把并发和异步放在一个接口里统一处理。早期我用CompletableFuture手动组合代码非常难读异常传播也很痛苦。后来切换到 Java 21 的虚拟线程代码风格回归“同步阻塞”但运行时并发性好很多。每个 Agent Loop、每个工具调用跑在独立虚拟线程上没有线程池饥饿的问题。try (var executor Executors.newVirtualThreadPerTaskExecutor()) { ListFutureToolResult futures calls.stream() .map(call - executor.submit(() - toolGateway.execute(call))) .toList(); for (var future : futures) { results.add(future.get()); } }这个例子展示了怎么并发调多个工具并等待全部返回。虚拟线程在 IO 密集型 Agent 场景下尤其合适因为模型调用和工具调用都是 IO 等待虚拟线程可以把等待期间的资源让给别人用。当然如果线上 JDK 版本不够退而使用固定大小线程池也能支撑只是并发上限要算准。3.5 声明式工具接口用注解加反射生成工具契约工具是 Agent 的“手”要把 Java 方法暴露给模型不能每次都手写 JSON Schema。BizBuddy 用一套基于注解的声明式接口AgentTool( name query_order, description 按订单号查询订单信息, requiresApproval false ) public OrderInfo queryOrder( ToolParam(description 订单号, required true) String orderId ) { return orderService.query(orderId); }框架启动时扫描这些方法description和ToolParam的元数据被拼进工具 Schema方法名加参数名生成 OpenAPI 风格的 JSON Schema。运行时模型返回的工具调用参数会被校验后绑定到方法参数。这省掉了大量重复维护代码也保证了 Prompt 里的工具描述和真实执行代码永远同步。3.6 测试策略仿真 LLM、契约测试与录制回放Agent 系统最头疼的是测试不确定性。BizBuddy 从开始就建立了三层测试防线。第一层是仿真 LLM。单元测试里不连真实模型而用一个MockModelProvider按预设脚本返回响应先返回一个工具调用再返回最终回答。这能稳定复现 Agent Loop 的分支逻辑。第二层是契约测试定义“工具 Schema 与执行参数解析”的契约用例确保模型返回的任何合法参数都能被正确映射。第三层是录制回放把真实模型在生产环境的请求响应录制下来离线回放注入测试环境用来验证重构或升级没有破坏行为。这套组合不能说消除所有不确定性但至少把 Agent 的确定性逻辑和模型的不确定性切分开了。调试时不再互相甩锅。4. 常见问题与排查实录4.1 模型返回格式漂移导致任务失败我在 BizBuddy 上线初期遇到最多的故障就是格式解析失败。模型偶尔会在 JSON 前后加解释文字、把字段名改成另一个相近词、甚至返回一个 Markdown 表格。排查思路是分三层走先看原始响应确认是不是格式漂移再看解析器的归一化逻辑是不是没有覆盖这种漂移模式最后看重试机制模型第二次、第三次返回是不是收敛。最后我把策略定为“重试两次 兜底降级”两次重试仍然失败时返回“本次任务未能完成”并把失败原因和已收集信息组织成人类可读摘要。这比强行返回一个结构残缺的结果要好。4.2 工具调用长时间挂起不返回有一次某 Agent 任务卡了二十多分钟模型早就没在调用就是工具一直不返回。查链路发现是工具内部在等下游服务的一个同步接口而下游服务超时配置是 30 分钟。这个问题的根子是工具代码跑在虚拟线程里外部服务不返回虚拟线程也不释放。解决办法是把工具全部纳入统一超时管理BizBuddy 在 ToolGateway 层面默认对每个工具设置 30 秒超时超过直接抛ToolTimeoutExceptionAgent 捕获后记录失败并决定是否换一种方式继续。经验是不要把超时设置的主动权完全交给工具实现者。平台层必须有一刀切的安全兜底否则总有一个工具会让你体验“挂死”到底。4.3 高并发下工具调用把下游打崩Agent 并行调用工具时如果下游是关系数据库或旧系统很容易瞬间涌入大量请求。上线第一次压测某工具直接被打到数据库连接池耗尽。BizBuddy 的解法是给工具网关加信号量限流和队列缓冲。每个工具可以配置maxConcurrentCalls超过的调用先排队队列满则快速失败并让 Agent 重试。这里特意没有用“全局线程池限流”因为不同工具的下游容量差别很大查询类和支付类工具的压力承受能力完全不在一个量级。这个限流参数我推荐在监控下动态调整而不是拍脑袋定死。术语上叫“自适应限流”实现不复杂统计每个工具近 1 分钟的平均耗时和成功率成功率下降时主动降低并发上限恢复后再放开。4.4 消息乱序与状态丢失BizBuddy 后面加了消息队列让多个 Worker 消费 Agent 任务。很快就遇到两个问题同一个任务被两个 Worker 同时消费以及异步回调覆盖了状态。排查后发现是消费组的幂等没做好。解决办法是给每个任务加乐观锁版本号每次更新context_json必须带上expected_versionupdate agent_run set context_json ?, current_step ?, status ?, version version 1 where run_id ? and version ?更新行数为 0 说明版本冲突了当前 Worker 放弃处理等重新投递。这套机制在分布式环境里尤其重要Agent 任务的状态更新必须是一个事务性动作不能靠“大概不会冲突”。4.5 把 Agent 的所有决策记录成结构化事件流很多 Agent 问题排查困难是因为内部决策没有落痕。我在 BizBuddy 里加了一个贯穿始终的事件流模型输入输出、工具调用输入输出、拦截器决策、状态变更全部作为不可变事件追加写入事件存储。事件流是排查利器。用户投诉“Agent 删了我的数据”你可以精确回放在哪个时间点、哪个环节、模型基于什么上下文触发了一个删除动作。没有这个事件流你只能靠日志上下文拼接非常痛苦。这个设计不一定直接产生业务价值但在企业信任建设上是决定性的。4.6 避坑清单速查问题类型典型表现推荐解法格式漂移模型返回非标准 JSON强制 JSON 模式 归一化解析 重试修正工具挂死任务长时间无任何进展平台层统一工具超时兜底下游过载数据库连接池耗尽工具级信号量限流 自适应并发控制状态冲突同一任务重复执行版本号乐观锁 幂等消费成本失控单任务消耗 token 异常高预算拦截器 循环轮数限制权限绕过工具被越权调用强制 ToolGateway scope 校验记忆膨胀上下文越塞越多按需检索长期记忆 摘要压缩测试不稳同一用例时好时坏仿真 LLM 录制回放5. 演进路线与最后一点体会5.1 从 Harness 到多语言 Agent 网格BizBuddy 现在的演进方向是让 Harness 不再局限于 JVM 内部的 Agent。工具网关已经具备远程调用能力未来可以支持 Python、Node.js 编写的 Agent 通过标准协议接入。一旦有多个 Agent 同时跑就需要考虑服务发现、调度策略、跨 Agent 的消息路由这基本是另一个中间件工程。但我不会为了让架构“更先进”就提前引入分布式编排。Agent 系统的复杂度已经够高一个单体但健壮的 Harness 比一个分布式但脆弱的网格可靠得多。扩展的前提是单个节点的承载和治理能力真的拉满了再开始拆。5.2 一点掏心窝的体会做完 BizBuddy 之后我对企业级 Agent 这件事最大的感受是模型能力只是起点真正决定平台生死的是治理能力。你能不能让 Agent 安全地犯错、可控地重试、有据可查地决策、预算分明地运行这些才是企业敢不敢把核心业务流程交给 Agent 的关键。如果你也在用 Java 规划类似的 Agent 平台我建议从 ToolGateway 和可观测性做起而不是先追求花哨的编排。一套严格权限控制下的工具调用体系配上完整的事件流记录就足以支撑八成的企业级场景。框架会过时模型会迭代但这些治理底座会一直留在系统里成为你最值钱的资产。