ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零手写企业级Java Agent Harness平台:核心设计与落地实践

从零手写企业级Java Agent Harness平台:核心设计与落地实践

1. 为什么我会想用纯 Java 去做一个 Agent Harness 平台

先说结论:市面上不是没有 Agent 编排框架,LangChain、LangGraph、Semantic Kernel 这些我都试过,问题不在功能,而在落地。企业内部的 Java 技术栈占比极高,现有系统跑在 Spring Boot / Spring Cloud 上,运维体系、监控体系、权限中心、审计系统全都是围绕 JVM 生态建的。这时候贸然引入一套 Python 写的 Agent 编排链路,等于在核心业务旁边开了一条完全不搭界的旁路,网络要通、日志要通、鉴权要通、发布要通,哪哪都是事。

所以我启动了一个叫 BizBuddy 的内部平台项目,目标很明确:用纯 Java,不依赖任何第三方 Agent 框架,从零手写一个企业级 Agent Harness。这里的 Harness 不是框架,而是一个可以承载、调度、管控多路 Agent 运行时的平台底座。它不负责教 Agent 怎么“思考”,它负责的是让 Agent 能在企业环境里安全地、可控地、可观测地干活。

BizBuddy 要解决的核心问题有几个:多 Agent 实例统一管理、工具调用的权限与审计、会话上下文的生命周期控制、动态技能加载、以及跟现有企业基础设施的平滑集成。如果用一句话概括,就是给 Agent 铺安装环境、接电源、上监控、发工牌,至于 Agent 本身是什么模型、什么策略写出来的,它不关心。

这篇博客我会把 BizBuddy 从设计到实现过程中的核心决策、踩坑记录和最终取舍完整讲一遍。如果你是做 Java 后端、对 Agent 落地感兴趣,或者正在纠结要不要用 Java 硬写编排层,这篇文章应该能省你不少时间。

2. 纯 Java 技术选型这件事,到底在争什么

2.1 为什么不用 Python 系方案

先说个常被误解的点。很多人一提 Agent 就默认要走 Python,因为生态的东西都在 Python 那边:各类模型 SDK、向量库客户端、Agent 框架几乎都有 Python 优先的版本。但对企业级场景来说,技术选型不是给模型挑玩具,而是给整个系统选地基。

我曾经在一个内部试点项目里尝试过用 Python 的库把 Agent 编排逻辑跑通,原型的推理效果确实好,但到了集成阶段就难受了:模型调用前要先去公司的统一鉴权服务申请 token,Python 服务得单独配一套鉴权客户端;日志要走统一格式打到 ELK,Python 侧得自己实现一套结构化日志体系;更不要说灰度发布、流量染色、配置中心这些基础设施,全都要额外适配。最终那个原型项目停留在了 POC 阶段,不是模型不行,是底座接不进去。

Java 系的优势恰恰在于这些非功能性能力已经沉淀了十几年。服务注册发现、负载均衡、链路追踪、线程池治理、配置中心、熔断降级,这些在 Java 世界里都是现成的标准件。BizBuddy 的目标不是做一个模型玩具,而是要能在生产环境里长期跑、被运维团队接受、被安全团队审过。所以第一步就定下了基调:纯 Java。

2.2 JVM 生态在企业环境的隐性优势

很多人在讨论 JVM 对 Agent 场景的价值时,关注点还停留在“Java 性能好”,其实这个点是偏的。Agent 平台的瓶颈从来不在 CPU,而在内存模型、并发模型和故障隔离能力上。

虚拟线程是一个被低估的特性。BizBuddy 里每个 Agent 实例在会话期间都会持有独立的上下文对象,当一个会话触发了工具调用链,可能要经历多轮同步等待。传统线程池在这种场景下很难受,一个操作占住线程十几秒就为了等远程接口返回,线程池很容易被打满。虚拟线程把阻塞成本降到了一个很低的量级,我现在可以放心地为每个 Agent 会话分配一个生命周期跟踪单元,而不用天天担心线程资源不足。

再说类加载隔离。企业级平台意味着你不可能只跑一个 Agent,也不可能为每个 Agent 单独重启进程。BizBuddy 里每个 Agent 插件都是一个独立的三级类加载器,插件自己能带依赖,和宿主 JVM 的类库完全隔离。这套能力是 Java 平台与生俱来的,Python 生态要做到同样的隔离级别,得靠容器、进程级隔离才能实现,调度代价完全不是一个量级。

另外就是可观测性的成熟度。JMX、Micrometer、OpenTelemetry Java 探针,这些工具链在 Java 平台上的成熟度远高于其他语言。Agent 平台在线上运转,最怕的就是黑盒:Agent 内部在调度什么、工具调用耗时多少、模型响应有没有超时,这些必须有完整的指标暴露。JVM 生态恰好能给出最细粒度的观测能力。

2.3 纯 Java 的代价,我们也认了

当然,选择纯 Java 必然有代价。最直接的代价是模型调用层的封装得自己写。OpenAI 兼容接口、各种国产模型 API,它们的 HTTP 协议基本都是 REST/SSE 流式,Java 端自己封装一个统一的 ModelAdapter 并不难,但确实没有 Python 那边现成 SDK 用起来顺手。这个工作量我估算过,一个能跑通的封装大概 3000 行代码,包含协议封装、重试、流式解析、模型中间层,属于一次性投入。

其次是生态内的技术趋势总是偏保守。LangChain 出来一个创新概念,Java 社区往往要滞后三个月到半年才有像样的移植。这就意味着你需要有较强的模型抽象能力,不能跟着某个第三方库的 API 走太近,否则很容易被牵着鼻子走。BizBuddy 在设计上刻意把模型层、Agent 层、工具层做成三个独立抽象,目的就是任何一层的变化都能被隔离在局部。

我的取舍原则是:优先保证平台可控、集成顺畅、运维省心,牺牲一点开发速度和生态便利度。对企业级项目来说,这是一个划算的买卖。

3. BizBuddy 的整体架构与核心模块划分

3.1 平台分层思路

BizBuddy 不是一个重框架,我给它的定位是一个带“插槽”的平台。整体分成四层:

接入层负责对接公司内部的 HTTP 网关、消息队列、RPC 框架,外部请求进来后先统一做身份认证、租户识别,再把请求转换成平台内部的 Session 对象。编排层是核心,负责驱动 Agent 的整个运行生命周期,包括会话状态机、上下文管理、分支调度、意图路由。能力层是 Agent 能触达的所有外部工具的集合,通过统一的 Tool 接口暴露给 Agent。基础设施层包含配置中心、注册中心、监控链路、日志采集等,这一层的设计原则是“能复用现成的绝不自己写”。

这个分层看起来平平无奇,但真正的难点在边界划分。比如模型调用算哪一层?我的做法是单独抽象出一个 ModelGateway,它横跨接入层和编排层,既负责任务级的路由,又负责协议适配。为什么要单独拆一层?因为企业里绝对不可能只接一家模型服务。不同业务线的数据合规要求不同,有的只能走私有化部署的模型,有的可以走云端商用模型,这些策略都收敛到 ModelGateway 里做路由,Agent 业务代码不需要感知模型来源。

3.2 Agent 不是一个类,而是一套状态机

在早期设计里,我犯过错,把 Agent 直接建模成一个类:一个 Agent 有一个 System Prompt、一套工具列表、一个模型配置,跑一次就完成。用起来确实爽,但真正放到生产里立刻被现实教育了。

企业场景里的 Agent 并不是“一次问答”,而是一个有状态的工作流。比如一个售后客服 Agent,可能先要识别用户身份,再查询订单状态,再判断是否需要退款,每一步都涉及多轮对话和工具调用,中间用户可能停顿了 5 分钟再发来一条新消息。这种场景下,Agent 必须能在会话维度上挂起、恢复、超时、终止。

BizBuddy 里我把 Agent 运行态建模为一张状态机,状态包括:INIT、READY、RUNNING、WAITING_TOOL、AWAITING_USER、ENDED、ERROR。每个状态转移都会触发对应的事件处理器。核心会话对象是一个轻量级的上下文容器,持有当前状态的引用、会话级的内存存储、已经执行的工具调用记录、以及 token 用量统计。

状态机的好处是给 Agent 控制带来了极大的确定性。不管前端发来什么操作,平台层都能明确知道当前会话处于什么状态、允许哪些操作。比如用户在 Agent 正在调用工具时发来新消息,是会话冲突还是排队等待?在这个模型下,只需要判断当前状态是 WAITING_TOOL 还是 AWAITING_USER 就能做出决定。

3.3 工具调用编排的设计

Agent 的威力来自于工具调用,但工具调用也是事故高发区。BizBuddy 里对工具的抽象有一个核心原则:工具是一个纯函数式的描述,Agent 只能看到工具的入参 schema 和出参 schema,永远看不到工具的实现在哪里、跑在什么进程里。

一个工具在 BizBuddy 里被抽象成 ToolDefinition 和 ToolExecutor 两个接口。ToolDefinition 描述元信息:工具名称、功能摘要、参数 JSON Schema、权限级别、超时时间、限流阈值。ToolExecutor 是真正的执行体,可以是一个本地方法调用,也可以是一个远程 RPC 封装,甚至可以是一次 HTTP 转发。但 Agent 侧永远只跟 ToolDefinition 打交道。

这个设计的好处呼之欲出:功能沉底的灵活性。当一个工具从单机实现升级为独立微服务时,平台层只需要替换 ToolExecutor 的实现,Agent 的 Prompt 和工具描述无需变化。我在实践里把它叫做“Agent 不自知的基础设施升级”,至少在工具集成层面,这个模式最高效。

4. 核心实现细节:五个最容易被做砸的地方

4.1 Agent 会话上下文的生命周期管理

会话上下文是 Agent 平台最容易失忆的地方。上下文到底存内存还是存 Redis?会话结束之后上下文是销毁还是归档?一个会话最多关联多少条记忆?这些问题如果不在设计阶段定清楚,后面一定会被线上事故打脸。

我的做法是:会话上下文分两层,热数据和冷数据。热数据指的是当前状态机的状态、当前工具的中间参数、未提交的步骤结果,这些只存在 JVM 内存里,目标是一毫秒级访问。冷数据是用户消息历史、工具调用记录、每轮 summary 之后的上下文压缩结果,这些定期同步到 Redis,设置 TTL。TTL 的默认值我设成了 24 小时,超过了就视为会话过期。

内存管理上要特别小心。如果不设上限,一个用户在一个会话里发了 200 条消息,每轮带上历史记录,上下文 Token 数会指数级膨胀,最后要么模型调用直接报长度超限,要么内存被塞爆。我在 BizBuddy 里对上下文设置了阈值:累计 Token 数超过 8000 就启动摘要压缩流程,把早期的消息递归摘要成几条高密度记忆。这个压缩是异步的,不能阻塞主流程,压缩后的摘要写回上下文,原始消息版本归档到冷存储。

踩过的一次坑:早期版本没有做异步压缩,Agent 在长会话场景下的响应延迟从 1.2 秒骤升到 9 秒,测试环境根本发现不了,因为测试数据太短。后来加了异步化之后,延迟回归到了正常水平。

4.2 工具注册中心与权限模型

工具注册中心相当于平台的“服务目录”。每次 Agent 发起工具调用,不是直接执行方法,而是先到注册中心查一下这个工具的元信息、路由地址、限流规则和权限位。我把这个设计叫“访问前先验票”。

注册中心存了每个工具的权限标记,标记分为资源维度和操作维度。资源维度指的是工具能访问什么范围的数据,比如“订单查询”这个工具被限定为仅能查询当前租户内订单;操作维度指的是能做什么,比如只能读不能写。Agent 在调用工具时,平台会拿当前会话主体的权限快照跟工具权限标记做比对,全部通过才允许执行。

这个模型在业务上有什么价值?坦白讲,模型本身经常出现幻觉。你不可能靠系统提示词让 Agent 不去碰越权的数据。唯一生效的方法是在调用链上强制加一个 Authorization Barrier。BizBuddy 里这个拦截逻辑进入了框架层,不是 Agent 业务代码自己控制的,这样即使 Agent 的决策错了,平台层的拦截依然在。

4.3 动态技能加载:SPI + 类加载隔离

Agent 的技能(Skill)对应到平台里就是一组工具的集合,加上一份技能定义文件。为了让平台上线后还能不断新增技能,我设计了一套动态加载机制,基于 Java 的 ServiceLoader 机制实现。

一个技能包是一个 JAR 文件,里面包含了技能描述文件(JSON 格式)、工具实现类和依赖库。平台在启动时会扫描技能目录,每个 JAR 都加载到独立的类加载器里,确保不同技能之间不会出现类冲突。技能包上传后就自动注册到工具注册中心,Agent 只要在配置里声明自己启用哪些技能,就能看到哪些工具。

类加载隔离这事听着高级,实现起来也真是细活。技能 A 和技能 B 如果同时依赖了某个公共库的不同版本,它们的类加载器父级都是平台宿主加载器,但互相之间并不可见,因此不会互相污染。这里面最关键的坑是什么?是在技能包里不能试图去覆盖平台核心库的类,一旦出现跨加载器的类转换异常,排查成本极高。我的规则是:技能包能不放依赖就不放依赖,尽量用平台提供的公共 SDK。

4.4 模型适配层:流式响应与工具调用的协议衔接

Java 接大模型接口相对繁琐,核心难点在流式响应。OpenAI 兼容接口的流式返回格式是 SSE(Server-Sent Events),每个数据块里可能是增量文本,也可能是工具调用指令。Java 端处理 SSE 本身不难,难点在于要和工具调用流程做拼接。

BizBuddy 的 ModelGateway 里做了这样一个解析流程:搜集模型返回的全部流式数据块,按事件类型分流。文本增量直接推给前端,工具调用指令累积到一定数量后在本地组装成完整的 functionCall 对象。这一步必须等到流结束或者遇到停止标记才能提交给工具执行器。原因是模型可能在流的中段放弃了原来的计划,你如果每收到一个工具调用块就立刻执行,很可能执行了模型已经放弃的操作。

这个“先收齐,再执行”的模式在准召率上有可测的提升。我测过一组真实数据:未做收齐的版本,在一次多工具调用链路中,工具误执行率在 6% 左右;收齐之后再执行,误执行率降到 0.4%。对于一个需要反复和数据库打交道的 Agent 来说,6% 的误执行率是不能接受的。

4.5 可观测性与全链路追踪

Agent 平台的故障排查难度比传统 Web 应用高一个量级。传统 Web 应用的调用链是线性的:请求进来、处理、返回。Agent 平台的调用链是树状的:一个会话里可能触发多个工具调用,模型可能来回推理多次,任何一个节点失败都可能导致整体结果异常。

我把 OpenTelemetry 的 Trace 机制用到了 Agent 会话层面。每个会话启动时创建一个根 Span,会话过程中的每一次模型调用、每一次工具执行、每一次状态转移,都作为子 Span 挂到根 Span 下。Trace 里还注入对话 ID、用户 ID、租户 ID 作为业务标签,这样排查问题时可以直接从服务端日志按会话 ID 拉出整棵调用树。

日志这块我统一走 JSON 结构化日志,每个日志条目里带上 traceId、sessionId、agentId、eventType。这样无论是查监控还是做离线分析,都能直接按照结构化字段检索。不夸张地说,这套可观测性体系是 BizBuddy 能持续迭代的基础,没有它,后面任何优化都等于闭着眼开车。

5. 企业级落地中的关键取舍与妥协

5.1 编排引擎:自研状态机还是用工作流引擎

立项之初就有个选择题:Agent 编排是自研一套状态机,还是直接引入现成的工作流引擎?这两种路线在市场上都有先例,但针对 Agent 场景,我最终还是选择了自研。

原因有两点。第一是工作流引擎的设计理念偏向“确定性流程”,节点、条件、分支都画得清清楚楚,但 Agent 的行为天然不确定,一个模型的决策可能把你带上一个图里根本没画过的路径。硬套工作流引擎会出现非常尴尬的局面:为了适配引擎,你反而要给 Agent 加很多约束,结果 Agent 的智能程度被腰斩。

第二是自研状态机的成本没有想象中高。BizBuddy 里核心状态只有六个,状态转移事件也是有限的,加上持久化、超时恢复等基础设施,核心代码不到 4000 行。这点成本换来的是控制力和灵活性,值。

5.2 调度与限流:如何保护下游系统不被 Agent 打爆

Agent 调工具的频率远远高于人点按钮的频率。一个 Agent 在自动处理任务时,可能一分钟内连续调用同一个查询接口 30 次。如果是人为操作,这几乎不可能发生,但 Agent 会,尤其在模型陷入循环时。

限流方案我采用了三级控制。第一级是全局限流,Agent 平台整体每秒最多发起多少工具调用,用分布式令牌桶实现。第二级是租户限流,每个租户有独立的配额,防止一个租户的突发流量把全平台的资源打满。第三级是单工具限流,某些特别昂贵的工具(比如调用外部商业 API 的)需要单独限制 QPS 和日调用量。

这三级的配置全部走配置中心动态下发,不需要重新发布代码。有一次线上事故,某个 Agent 因为理解错用户问题,进入了一个死循环,不断调用订单查询接口。限流器在 10 秒内把该租户的配额耗完,后续请求全部返回超限提示,Agent 得到错误信号后自动终止了任务。这道防线保住了下游数据库没有被打垮。

5.3 会话持久化与恢复

Agent 的会话不能丢。用户跟 Agent 聊了 20 轮,已经提供了大量上下文,如果服务发布一次会话就断了,这个体验是灾难性的。

BizBuddy 的会话持久化策略是“Checkpoint + 增量事件”。状态机每次发生有效转移时,都会产生一个持久化事件,事件内容包括转移前状态、转移后状态、触发事件、关联的消息和工具结果。这个事件流同时写 Redis 和数据库,Redis 用于快速恢复,数据库用于长期归档。

恢复逻辑也简单:服务重启后,如果收到了一个会话的继续请求,就从这个会话最近一次 Checkpoint 开始,重放后续的事件摘要,把 Agent 上下文恢复到出厂状态。实测下来,恢复一个 20 轮长会话的耗时在 300ms 以内,用户基本无感。

5.4 模型能力的抽象与扩展

模型层是 BizBuddy 里抽象得最彻底的一层。对外暴露的核心接口只有两个:ChatSession(对话会话)、EmbeddingClient(向量化)。所有具体模型的差异都被封装在 ModelGateway 的 adapter 里。

这样做最直接的收益是切换模型时业务代码零改动。有一次我们在测试一个新的国产模型,评估它的工具调用能力是否稳定。我只写了一个新的 ModelAdapter,把模型地址、鉴权方式和响应解析规则填进去,然后给内测用户立刻切换。整个过程没有改动任何 Agent 代码。所以我的建议是:模型抽象从第一天就要做,不要等跑通了再回头补课。

6. 常见问题与排查实录

6.1 线上问题速查表

我在开发和运行 BizBuddy 的过程中整理了若干高频问题,这些问题在文档和博客里很少被系统性地讲清楚,但它们几乎决定了平台能不能稳定跑:

问题现象可能原因解决方案
Agent 工具调用链路发生内存溢出长会话的上下文 Token 数未触发压缩,导致请求体过大检查上下文压缩阈值,确认异步压缩任务是否被线程池饥饿阻塞
模型经常返回无效工具参数模型的 tool schema 描述不够严格,example 缺失在 ToolDefinition 里补充典型参数示例,并启用本地参数校验,非法参数直接返回校验错误而不执行工具
同一个技能包更新后,老实例仍加载旧代码类加载器未回收,技能包版本没有做标识为每个技能包生成带 hash 的类加载器,发布新版本时先注销旧加载器再加载新 JAR
会话恢复后上下文错乱事件重放时工具执行结果未正确写回上下文在重放逻辑中严格按事件序号递增恢复,禁止乱序写入
虚拟线程场景下出现死锁某个工具实现内部使用了同步锁,且锁的持有时间过长排查工具实现,尽量用无锁或锁分段策略;对虚拟线程环境做锁等待超时监控
流式响应中断后前端卡住SSE 连接未正确关闭,或心跳机制缺失在 SSE 连接上增加空闲连接超时自动关闭机制,前端增加读取超时兜底

6.2 排查链路的三板斧

Agent 平台问题排查,我自己的方法是三步走。

第一步看 Trace。输入一个 sessionId,把该会话的全链路 Span 树拉出来,先找哪个环节耗时最长。第二步看指标。打开 Prometheus 看该会话耗时的分布曲线,以及模型调用错误率、工具调用成功率。第三步看日志。JSON 日志里搜 sessionId + eventType,把状态机的每一步转移串起来,基本能还原出事发的完整经过。

有一个特别容易忽略的点:如果问题只在偶发场景出现,优先怀疑会话恢复和上下文压缩的逻辑。这两个模块是异步的,一旦触发时机不对,就会产生竞态条件,而这种问题在测试环境几乎复现不出来。

6.3 上线前的稳定性压测经验

Agent 平台的压测比 Web 应用要复杂,不能只测单接口 TPS,要按真实场景组合压测。我采取的方案是录制线上请求样本,生成一套包含多轮对话、工具调用、会话恢复的混合脚本。压测指标不只是响应时间,还有一个关键指标:会话完成率。在并发 200 个会话的压力下,BizBuddy 的会话完成率目前稳定在 99.2% 以上。低于 99% 就要警惕,大概率是某个工具或模型出现超时,而且超时问题会有累积效应。

7. 我的实践体会与下一步打算

BizBuddy 这个项目走到今天,我最想分享的一点体会是:Agent 平台的复杂度不在于某个点上的技术难度,而在于把所有企业级约束同时拉起来之后,系统整体的协同成本。模型会错、工具会挂、网络会抖、用户会乱操作,平台存在的意义就是让这些混乱保持在可控范围内。

我目前正在推进的一个方向是引入更细粒度的会话级成本核算。每个 Agent 会话从启动到结束,模型调用花了多少钱、工具调用占了多少资源、是否触发了限流告警,这些数据全部定义成指标,按月出报表。企业里的大模型项目最终都会面临“算账”问题,不提前做好成本观测,技术再先进也走不远。

如果你也在做 Java 系 Agent 平台,记住一句话:先确定平台的管控边界,再写第一行代码。边界没想清楚,后面翻工的成本可能是你最初写代码成本的十倍。

返回列表