1. 为什么我要用纯 Java 造一个 Agent Harness 平台
先说清楚一件事:Agent Harness 这个词最近被聊得很多,但很多人把它和 Agent 本身混为一谈。我刚开始接触的时候也绕过弯路,以为写个能调大模型的循环就算 Harness 了,后来踩了几个坑才明白,Agent 是“干活的”,Harness 是“管干活的”。Agent 负责推理、决策、调用工具,Harness 负责给 Agent 提供运行环境、生命周期管理、上下文注入、权限控制、可观测性、失败重试这一整套基础设施。打个比方,Agent 是赛车手,Harness 是赛道、维修站、无线电和裁判系统的总和。
BizBuddy 这个项目,就是我在这个认知下用纯 Java 从零搭起来的一套企业级 Agent Harness 平台。为什么强调“纯 Java”?因为市面上大部分 Agent 框架要么是 Python 生态(LangChain、AutoGen 那一挂),要么是 Node/TypeScript 生态,企业里那些跑了十几年的 Java 单体、Spring Boot 微服务、RuoYi-Vue-Plus 这类后台管理系统,想接 Agent 能力的时候非常别扭——要么起一个 Python 服务做胶水层,要么把业务逻辑重写一遍。这两种方案我都试过,第一种运维复杂度直接翻倍,第二种纯属给自己找罪受。
BizBuddy 想解决的问题很具体:让一个已有的 Java 企业系统,在不引入第二套技术栈的前提下,把 Agent 能力当成一个普通的基础设施模块接进来。它适合谁参考?我认为有三类人:一是手里有 Java 后台系统、想加 AI 能力但不想换栈的工程师;二是正在做 Agent 平台选型、纠结自研还是套壳的架构师;三是对 AgentScope 这类框架感兴趣、但想看看“如果我自己实现一遍会怎么做”的开发者。整篇文章我会把设计取舍、核心实现、踩过的坑都摊开讲,代码和配置能给的我尽量给全,你可以直接抄作业。
2. 整体架构设计与技术选型取舍
2.1 为什么不用现成的 Agent 框架做底座
我一开始的路线是“站在巨人肩膀上”:用 AgentScope 或者类似的框架做核心,外面套一层 Java 的 REST 接口。做了两周就放弃了,原因有三个,都是实打实的痛点。
第一是进程边界带来的上下文损耗。Agent 执行过程中需要频繁读写会话状态、工具调用记录、中间推理结果,如果核心在 Python 进程、业务在 Java 进程,每次交互都要序列化过网络,延迟和调试成本都上去了。我实测过一个中等复杂度的多轮工具调用场景,跨进程方案比同进程方案平均多出 40% 到 60% 的耗时,而且日志分散在两个地方,排查问题像拼图。
第二是企业级能力对不上。企业系统里最看重的东西——行级权限、审计日志、事务一致性、灰度发布——这些在通用 Agent 框架里要么没有,要么是弱实现。比如行级权限,业务系统里一个用户只能看自己部门的数据,这个约束必须能下沉到 Agent 的工具调用层,而不是靠 Prompt 里写一句“请不要访问其他部门数据”来约束。Prompt 约束在工程上是不可靠的。
第三是依赖治理。引入一个 Python 运行时,意味着 CI/CD 流水线要维护两套环境,镜像体积、安全扫描、版本升级全都要做双份。对于已经在 RuoYi-Vue-Plus 这类成熟脚手架上跑了好几年的团队来说,这个成本不划算。
所以最终决定:核心用纯 Java 实现,只把大模型调用当成一个外部 HTTP 依赖。这样整个平台就是一个标准的 Spring Boot 应用,能无缝塞进现有的 Java 技术体系里。
2.2 BizBuddy 的分层结构
整个平台我分成了五层,从下往上说。
最底层是模型接入层,负责对接各家大模型的 HTTP API,做统一的请求封装、流式响应解析、Token 计数、失败重试。这一层的关键设计是把模型当成可替换的驱动,用接口抽象出来,换模型只改配置不改代码。
往上一层是工具执行层,这是 Harness 的核心。所有 Agent 能调用的能力——查数据库、调内部 API、读文件、发消息——都注册成工具(Tool),每个工具有明确的入参 schema、权限声明、超时设置和幂等标记。工具执行层负责参数校验、权限拦截、超时控制、结果序列化。
再往上是会话与上下文层,管理多轮对话的状态、上下文窗口的裁剪策略、长期记忆的存取。这一层最容易被低估,但实际做下来它决定了 Agent 能不能在长任务里保持稳定。
然后是编排层,负责 Agent 的生命周期:创建、执行、暂停、恢复、终止,以及多 Agent 之间的协作调度。这一层我用的是状态机模型,每个 Agent 实例有明确的状态流转,避免出现“Agent 卡死但没人知道”的情况。
最上面是接入层,提供 REST API、SSE 流式推送、WebSocket,以及和 RuoYi-Vue-Plus 权限体系的对接。业务系统通过这一层来创建 Agent 任务、查询执行状态、接收流式输出。
2.3 关键取舍:同步还是异步,状态放哪
有两个设计决策我想单独拎出来讲,因为它们直接决定了平台的性格。
第一个是执行模型选同步还是异步。我最终选了异步为主、同步为辅。原因是 Agent 任务天然是长任务,一次多轮工具调用可能跑几十秒甚至几分钟,如果用同步 HTTP 请求,连接超时、网关限制、用户体验全是问题。所以核心执行走异步任务队列,客户端拿到一个 taskId,然后通过 SSE 或者轮询拿结果。但我也保留了一个同步接口,用于那些确定很快返回的简单场景,比如单轮问答,避免过度设计。
第二个是会话状态放哪。内存、Redis、数据库三个选项我都考虑过。纯内存最简单但重启就丢,不适合企业场景;纯数据库最稳但高频读写扛不住;最后选的是Redis 做热状态、数据库做冷归档的组合。正在执行的会话状态放 Redis,带 TTL;任务结束后把完整轨迹落库,用于审计和回放。这个组合在实测里既能扛住并发,又满足了企业审计的硬要求。
3. 核心模块的细节拆解与实操要点
3.1 模型接入层:把大模型当成可替换驱动
模型接入层看起来简单,其实坑不少。我抽象了一个ModelProvider接口,核心方法就两个:chat和chatStream。所有具体模型实现这个接口,通过 Spring 的@ConditionalOnProperty按配置加载。
public interface ModelProvider { ChatResponse chat(ChatRequest request); void chatStream(ChatRequest request, StreamCallback callback); String providerName(); }这里有个关键细节:流式响应的解析必须做增量拼接和异常兜底。大模型的 SSE 流经常出现半截 JSON、心跳空行、意外断流,如果解析逻辑写得糙,线上就会偶发解析异常。我的做法是维护一个缓冲区,按行切分,遇到不完整的行就留着等下一批数据,同时给整个流设置一个空闲超时,超过 30 秒没有新数据就主动断开并标记为超时失败。
Token 计数这块我要多说一句。企业场景下成本是要算清楚的,所以每次调用都要记录输入输出 Token 数。但不同模型的计数方式不一样,有的返回在响应体里,有的要自己估算。我的策略是优先用响应体里的官方计数,拿不到就用字符数除以一个经验系数估算,并且把这个估算标记出来,避免和真实账单对不上时抓瞎。
注意:模型接入层一定要做熔断和降级。我踩过的坑是某次上游模型服务抖动,导致所有 Agent 任务全部卡在等待响应上,线程池被占满,整个平台雪崩。后来加了 Resilience4j 做熔断,连续失败达到阈值就快速失败,同时支持配置一个备用模型做降级。
3.2 工具执行层:权限、超时、幂等一个都不能少
工具执行层是 Harness 区别于普通 Agent 框架的核心。我定义了一个Tool抽象:
public interface Tool { String name(); JsonSchema inputSchema(); ToolResult execute(ToolContext context, JsonNode input); default Duration timeout() { return Duration.ofSeconds(30); } default boolean idempotent() { return false; } }权限拦截是重点。每个工具可以声明自己需要的权限标识,执行前 Harness 会拿当前会话的用户身份去校验。这里我直接复用了 RuoYi-Vue-Plus 的权限模型,工具权限和菜单权限走同一套@PreAuthorize逻辑,这样运维只需要维护一套权限数据。行级权限则通过ToolContext里携带的用户上下文,在工具实现内部做数据过滤,比如查数据库时自动拼上部门 ID 条件。
超时控制我用的是独立的线程池加Future.get(timeout)。这里有个坑:如果工具内部是阻塞 IO,超时后线程并不会真正中断,只是调用方不再等待。所以对于可能长时间阻塞的工具,我会额外要求实现方支持中断,或者在工具内部自己检查中断标志。
幂等标记是为了重试安全。Agent 执行过程中如果某一步失败需要重试,非幂等的工具(比如“发送通知”)重试就会造成重复副作用。所以我在编排层做重试时,只对标记为幂等的工具自动重试,非幂等的工具失败后交给上层决策。
| 工具属性 | 作用 | 配置建议 |
|---|---|---|
| name | 唯一标识,Agent 调用时使用 | 用动词加名词,如 queryOrder |
| inputSchema | 参数校验和给模型看的描述 | 描述要写清楚,模型靠它理解怎么传参 |
| timeout | 单次执行超时 | 查询类 10s,写入类 30s,外部调用 60s |
| idempotent | 是否可安全重试 | 只读操作 true,写操作默认 false |
| requiredPermission | 所需权限标识 | 与业务系统权限体系对齐 |
3.3 会话与上下文层:窗口裁剪是门手艺
上下文窗口管理是我花时间最多的地方。大模型的上下文长度是有限的,而 Agent 任务可能产生大量中间结果,如果不做裁剪,很快就会超限。
我的裁剪策略是分层保留:系统提示词永远保留;最近 N 轮对话完整保留;更早的历史做摘要压缩;工具调用的原始结果如果很长,只保留摘要加一个引用 ID,需要时再按 ID 取回。这个策略的核心思想是把“必须精确”和“可以模糊”的内容分开处理。
摘要压缩我用的是模型自己来做,把一段历史对话喂给模型,让它输出一段结构化摘要。这里要注意,摘要本身也会消耗 Token,所以要设置一个触发阈值,比如历史超过窗口的 60% 才开始压缩,避免频繁压缩浪费成本。
长期记忆我用的是向量检索加关键词检索的混合方案。纯向量检索在精确匹配场景下会翻车,比如用户问“订单号 12345 的状态”,向量检索可能召回一堆语义相似但订单号不对的记录。所以我的做法是先做关键词精确匹配,命中就直接用,没命中再走向量检索。
3.4 编排层:用状态机管住 Agent 的生命周期
编排层我用状态机来建模,Agent 实例的状态包括:CREATED、RUNNING、WAITING_TOOL、WAITING_USER、COMPLETED、FAILED、CANCELLED。每次状态流转都要落库,这样任何时刻都能查到 Agent 在干什么。
为什么要这么较真?因为我遇到过 Agent “假死”的情况:模型返回了一个工具调用,但工具执行卡住了,整个任务既没完成也没报错,用户那边一直转圈。有了明确的状态机,我就能设置状态超时——比如WAITING_TOOL超过工具超时时间还没流转,就自动标记为失败并触发告警。
多 Agent 协作我用的是主从模式:一个协调者 Agent 负责拆解任务,把子任务分发给工作者 Agent,收集结果后汇总。这里的关键是子任务的隔离,每个工作者 Agent 有独立的上下文和工具权限,避免互相污染。协调者和工作者之间通过消息队列通信,而不是直接方法调用,这样单个工作者失败不会拖垮整个编排。
4. 完整实操流程与关键环节实现
4.1 环境准备与项目骨架搭建
先说环境。JDK 我用的是 17,Spring Boot 3.2.x,构建工具 Maven。数据库 MySQL 8,缓存 Redis 7。这些版本不是随便选的:Spring Boot 3.x 要求 JDK 17 起步,而 JDK 17 的虚拟线程(虽然是 21 才正式,但 17 已经有预览)对 Agent 这种高并发 IO 场景很友好。如果你还在 JDK 8,建议至少升到 17,否则后面很多并发工具用起来会别扭。
项目骨架我参考了 RuoYi-Vue-Plus 的分层习惯,但做了精简。核心模块划分如下:
bizbuddy/ ├── bizbuddy-common # 通用工具、常量、异常 ├── bizbuddy-model # 模型接入层 ├── bizbuddy-tool # 工具执行层 ├── bizbuddy-session # 会话与上下文层 ├── bizbuddy-orchestrator # 编排层 ├── bizbuddy-api # 接入层,REST/SSE └── bizbuddy-admin # 管理后台,复用 RuoYi 前端依赖上,除了 Spring Boot 全家桶,我引入了几个关键库:resilience4j做熔断限流,mybatis-plus做数据访问(和 RuoYi 保持一致),redisson做 Redis 客户端(比 Lettuce 在分布式锁上更省心),jackson做 JSON 处理。没有引入任何 Python 相关的东西,整个构建产物就是一个可执行 jar。
4.2 定义一个工具并注册到平台
我拿一个最典型的场景举例:查询订单。先定义工具类:
@Component public class QueryOrderTool implements Tool { @Autowired private OrderMapper orderMapper; @Override public String name() { return "queryOrder"; } @Override public JsonSchema inputSchema() { return JsonSchema.builder() .addProperty("orderNo", "string", "订单号,必填") .addRequired("orderNo") .build(); } @Override public ToolResult execute(ToolContext context, JsonNode input) { String orderNo = input.get("orderNo").asText(); Long deptId = context.getUserContext().getDeptId(); // 行级权限:只能查本部门订单 Order order = orderMapper.selectByOrderNoAndDept(orderNo, deptId); if (order == null) { return ToolResult.notFound("订单不存在或无权访问"); } return ToolResult.success(order); } @Override public Duration timeout() { return Duration.ofSeconds(10); } @Override public boolean idempotent() { return true; } @Override public String requiredPermission() { return "biz:order:query"; } }工具注册我用的是 Spring 的自动扫描加一个注册中心。启动时把所有Tool实现收集起来,按 name 建索引,同时把 inputSchema 导出成模型能理解的工具描述。这里有个细节:工具描述的质量直接决定模型调用准确率。我一开始把描述写得很简略,结果模型经常传错参数。后来我把每个参数的说明写清楚,包括格式示例,调用准确率明显提升。
4.3 一次完整的 Agent 执行流程
我把一次典型的 Agent 执行拆成下面这些步骤,你可以对照着看整个链路。
接收请求:接入层收到
POST /agent/task,参数包括用户问题、会话 ID、可用的工具集。校验用户身份和权限。创建任务:编排层生成 taskId,初始化 Agent 状态为
CREATED,把任务丢进异步执行队列。加载上下文:会话层根据会话 ID 从 Redis 加载历史,做窗口裁剪,拼装成完整的消息列表。
调用模型:模型接入层发起流式请求,边接收边通过 SSE 推送给客户端。
解析工具调用:如果模型返回工具调用意图,编排层把状态切到
WAITING_TOOL,调用工具执行层。执行工具:工具执行层做权限校验、参数校验、超时控制,执行后把结果追加到上下文。
循环:把工具结果回传给模型,继续下一轮,直到模型不再请求工具、直接给出最终回答。
收尾:状态切到
COMPLETED,完整轨迹落库,清理 Redis 热状态,推送结束事件。
这个流程里,第 5 到第 7 步的循环是核心,也是最容易出问题的地方。我设置了最大循环次数(默认 10 轮),防止模型陷入无限工具调用。同时每一轮都记录耗时和 Token 消耗,方便事后分析。
4.4 流式输出的实现细节
流式输出我用的是 Spring 的SseEmitter。这里有几个实操要点。
第一,SSE 连接要设置合理的超时。默认超时可能太短,长任务会被切断。我设置的是 5 分钟,同时客户端要能处理重连。
第二,事件类型要区分。我定义了message(模型输出片段)、tool_call(工具调用通知)、tool_result(工具结果)、done(结束)、error(错误)几种事件类型,前端根据类型做不同渲染。这样用户能看到 Agent 在“思考”和“干活”的过程,体验比干等好很多。
第三,背压处理。如果模型输出很快但客户端消费慢,SSE 缓冲区会堆积。我的做法是给 emitter 设置一个发送队列上限,超过就丢弃中间的心跳事件,保证关键事件不丢。
@GetMapping("/agent/stream/{taskId}") public SseEmitter stream(@PathVariable String taskId) { SseEmitter emitter = new SseEmitter(300_000L); emitter.onTimeout(() -> log.warn("SSE timeout, taskId={}", taskId)); emitter.onError(e -> log.error("SSE error, taskId={}", taskId, e)); streamManager.register(taskId, emitter); return emitter; }5. 常见问题排查与避坑经验实录
5.1 模型返回的工具调用参数格式错误
这是最高频的问题。模型有时候会把参数包成字符串,有时候会漏字段,有时候类型不对。我的处理分三层:第一层是 JSON Schema 校验,不通过直接返回错误给模型让它重试;第二层是类型容错,比如字符串 "123" 能自动转成数字 123;第三层是重试次数限制,同一个工具连续失败 3 次就放弃并告知用户。
实操心得:在系统提示词里明确写出工具调用的格式要求,并且给一两个正确示例,能显著降低格式错误率。我试过加示例和不加示例,错误率差了将近一半。
5.2 Agent 任务卡死无响应
前面提过状态机的作用,这里说具体排查。我遇到过几种卡死场景:模型服务无响应、工具执行阻塞、Redis 连接池耗尽。排查思路是先看状态机当前状态,再看该状态的超时设置,最后看依赖服务的健康度。为此我在管理后台做了一个任务监控页,实时展示每个任务的状态和停留时长,超过阈值标红。
| 卡死场景 | 现象 | 排查方法 | 解决手段 |
|---|---|---|---|
| 模型无响应 | 状态停在 RUNNING | 看模型调用日志和熔断器状态 | 熔断降级到备用模型 |
| 工具阻塞 | 状态停在 WAITING_TOOL | 看工具执行线程栈 | 超时中断,标记工具失败 |
| Redis 耗尽 | 大量任务同时卡住 | 看连接池指标 | 扩容连接池,加限流 |
| 死循环调用 | 循环次数飙升 | 看循环计数 | 达到上限强制终止 |
5.3 上下文超限导致调用失败
上下文超限的表现是模型返回 400 错误,提示 token 超限。根因通常是工具返回的结果太长,比如查了一个大列表直接塞进上下文。我的解决方法是在工具层就做结果截断,超过一定长度的结果只返回摘要和前 N 条,完整结果存起来给一个引用 ID。这样从源头控制上下文增长,比事后裁剪更有效。
5.4 并发场景下的会话串扰
这个坑我踩得比较深。早期版本里,同一个用户开两个会话,偶尔会出现 A 会话的回答跑到 B 会话里。根因是会话状态用了共享的 ThreadLocal 或者缓存 key 设计有误。修复方法是所有会话状态必须以 sessionId 为隔离维度,任何缓存 key、线程上下文都要带上 sessionId,并且在代码审查时把这条当成硬性规范。
5.5 权限绕过风险
Agent 场景下的权限绕过是个隐蔽的安全问题。比如用户没有某个工具的权限,但通过精心构造的 Prompt 让模型去调用,如果 Harness 只在入口校验一次权限,就可能被绕过。我的做法是每次工具调用都独立校验权限,不依赖入口的一次性校验。同时所有工具调用都记审计日志,包括调用者、工具名、参数、结果状态,方便事后追溯。
6. 和 RuoYi-Vue-Plus 生态的整合实践
6.1 权限体系复用
RuoYi-Vue-Plus 的权限模型是基于角色和菜单的,我把 Agent 工具权限映射成一种特殊的菜单权限。这样在角色管理界面里,管理员可以直接勾选某个角色能用哪些 Agent 工具,不需要额外维护一套权限数据。实现上,工具的requiredPermission返回的标识,和 RuoYi 的权限标识格式保持一致,校验时直接调用 RuoYi 的权限服务。
6.2 管理后台的对接
管理后台我直接复用了 RuoYi 的前端框架,新增了几个页面:Agent 任务列表、工具管理、模型配置、执行轨迹回放。执行轨迹回放这个功能很实用,它把一次任务的完整消息流、工具调用、耗时、Token 消耗都展示出来,排查问题时一目了然。数据来源就是前面说的落库的完整轨迹。
6.3 部署与运维
部署上 BizBuddy 就是一个标准的 Spring Boot 应用,打成 jar 后可以独立部署,也可以作为模块嵌入现有系统。我推荐独立部署,通过内网 API 和业务系统通信,这样升级 Agent 平台不影响业务系统。配置方面,模型密钥、超时参数、循环上限这些都放在配置中心,支持热更新,不用重启。
注意:模型密钥一定要加密存储,不要明文写在配置文件里。我用的是 Jasypt 做配置加密,密钥通过环境变量注入,这样即使配置文件泄露,密钥也是安全的。
7. 我对这套方案的一些真实体会
做 BizBuddy 这段时间,最大的感受是:Agent Harness 的难点不在 AI,在工程。模型能力是外部给定的,你能控制的是怎么把它稳定、安全、可观测地集成进现有系统。我见过太多项目把精力全花在 Prompt 调优上,结果上线后因为权限、超时、并发这些工程问题翻车。
另一个体会是不要过度设计。我一开始想做一个支持任意复杂编排的通用引擎,后来发现 80% 的场景就是“单 Agent 加几个工具”,把这条路径做扎实比什么都强。复杂的多 Agent 协作我保留了接口,但默认不启用,等真有需求再打开。
最后分享一个我一直在用的小技巧:给每个工具写一个“自测用例”。就是一段固定的输入和期望输出,每次改完工具代码跑一遍。这看起来笨,但能挡住很多低级错误,尤其是参数校验和权限逻辑这种容易改坏的地方。工具多了以后,这套自测用例就是你的回归测试网,比事后救火省心得多。