这两年“Agent”这个词在技术圈已经快被聊烂了,但真正落过地、上过生产环境、扛过真实业务流量的人其实并不多。市面上大多数开源的 Agent 编排框架,Demo 跑起来很惊艳,一面对企业内部的权限、审计、限流、重试、多租户这些硬要求,立刻就露怯。我在某公司做平台基础设施,去年开始用纯 Java 从零做了一个叫 BizBuddy 的企业级 Agent Harness 平台,不是什么花哨的编排工具,而是把 Agent 当作一个严肃的运行时来设计。这篇文章把当时的技术选型、模块划分、核心代码骨架,以及踩过的几个印象深刻的坑,一次性讲清楚。
如果你正在纠结“要不要自研 Agent 平台”“Java 能不能做 AI 应用层”“企业里的 Agent 到底该怎么管”,这篇应该能给你不少参考。
1. 先想清楚再动手:Agent 平台究竟要解决什么问题
很多人一听到 Agent Harness 就要拿来和开源的编排框架比,其实两者定位完全不一样。Harness 这个词在硬件圈和测试圈很早就有了,意思是“给被控对象提供一个稳定的运行底座”,核心关注点不是控制逻辑本身,而是保障它在复杂环境中不出错、可管理、可恢复。到了 Agent 场景,这个概念就变成了:当一个 Agent 具备“推理+调工具”的能力之后,怎么让它可靠、可控、可观测地跑在企业生产环境里,这才是平台要解决的事。
1.1 不是又一个编排框架,而是“运行时”
如果一个 Agent 只是单轮问答,那的确用不上 Harness。但企业业务里的 Agent 往往需要多步推理、多次调用内部工具、等待人工审批、跨小时甚至跨天执行,这时候它就已经不是一个“函数调用”,而是一个“长时任务”了。BizBuddy 处理的正是这类长时任务:它把一次 Agent 执行建模成一个可持久化、可暂停、可恢复的单元,而不是一次性的函数调用。
所以我给团队定了一个原则:BizBuddy 不写任何具体业务 Agent 逻辑,只提供“承载 Agent 运行所需的公共能力”。具体来说就是执行调度、生命周期管理、工具网关、上下文记忆、多租户隔离、观测与审计。这些能力听起来不性感,但缺了任何一个,Agent 都上不了生产。
提示:做 Agent 平台之前,先把“平台”和“应用”的边界画清楚。平台部门最忌讳的就是把业务逻辑焊死在框架里,最后变成一个谁也改不动的巨兽。
1.2 为什么坚持用纯 Java 来做
选型的时候不是没有争议。当时 AI 圈子主流的 Agent 框架几乎都是 Python 写的,生态丰富,语法灵活,直接拿过来用最省事。但我所在的公司基础设施基本是 JVM 一套,现有的权限中心、任务调度、配置中心、网关全是 Java 体系。如果为了 Agent 单独引入一套 Python 微服务,就意味着这个系统必须自己搞运维、搞部署、搞监控、搞 CI/CD,整个链路都是另起炉灶,成本远高于语言本身的便利。
纯 Java 带来的第二大优势是融合速度。企业里的 Agent 不可能只调用外部 API,它更多要操作内部的 RPC 接口、读写数据库、走统一的消息队列。这些能力在 Java 生态里都是现成的客户端 SDK,权限上下文、租户信息、链路追踪透传都能在同一个线程上下文里完成,非常顺滑。
还有一点经常被忽视:Java 在资源占用上的可预期性。虽然 Python 写起来快,但上线后内存管理容易失控,GC 没有 JVM 成熟。我们的 Agent 任务通常并发几百上千,JVM 配合虚拟线程能扛住,Python 的 GIL 在高并发工具调用下会非常难受。
1.3 你和我的处境可能不一样:语言选型要诚实
我并不想鼓吹“Java 比 Python 好”。如果是做一个快速验证的 AI 原型,或者团队全是 Python 背景,那用 Python 完全合理。开发语言这件事没有绝对的对错,只有匹配不匹配。你要先回答自己三个问题:
- 这个 Agent 平台是要独立运营,还是要嵌进现有的技术栈?
- 团队里面谁的运维能力最强?谁最熟悉现有监控体系?
- 未来这个平台是给 Java 团队扩展,还是给 AI 团队扩展?
我当时给这三个问题的答案都指向 Java,所以选了 Java。如果你的答案不一样,不要硬抄我的方案。
2. BizBuddy 的整体架构与核心抽象
确定用 Java 之后,我花了两周时间画架构图和定接口抽象。那两周的产出基本决定了后面大半年的开发节奏,能让团队在正确的赛道上狂奔,靠的就是开头抽象做得准。
2.1 顶层模块拆解
BizBuddy 采用多模块 Maven 工程,最核心的是bizbuddy-core、bizbuddy-runtime、bizbuddy-gateway、bizbuddy-memory、bizbuddy-observability五个模块,加上一个bizbuddy-spring-boot-starter用来跟 Spring Boot 容器无缝集成。
bizbuddy-core:只放领域模型和接口定义,比如 Agent 描述、任务上下文、工具定义、状态枚举。这个模块不依赖任何外部框架,就是几组纯 Java 的接口和 POJO。这样设计的好处是后续换成 Spring 或者 Quarkus 都没压力,核心模型不绑定任何容器。bizbuddy-runtime:执行引擎和调度器。负责加载 Agent 定义,驱动“大模型推理 -> 工具调用 -> 观察结果 -> 再次推理”这个循环,同时维护任务状态机的流转。bizbuddy-gateway:工具网关。统一接收 Agent 发出的工具调用,做鉴权、限流、超时控制、结果校验,最后才真正触达后端系统。bizbuddy-memory:记忆与上下文管理。维护窗口上下文、token 预算估算、历史会话摘要。bizbuddy-observability:日志、指标、链路追踪的接入层。bizbuddy-spring-boot-starter:自动配置模块,把上面五个模块以“启动器”方式暴露给业务方。
这样拆的依据只有一个:让依赖方向保持单向。核心不依赖运行时,运行时依赖核心,网关依赖核心,所有实现都依赖接口。新同事接手的头一天就能看懂这个项目,不至于陷入“哪里都能改、哪里都不敢改”的困境。
2.2 一次任务的全生命周期
一个企业级 Agent 任务,在 BizBuddy 里会经历下面这条链路:
- 客户端通过 Rest API 提交一个任务,带上租户 ID、Agent 名称、初始参数。
- 调度器把任务持久化到数据库,状态置为
PENDING。 - 执行器拉取任务,构造
TaskContext,把它交给 Agent 主循环。 - Agent 主循环里,大模型根据当前上下文决定是输出最终答案还是发起工具调用。
- 如果是工具调用,经过网关鉴权、限流后执行;如果步骤中有人工审批节点,任务状态会变成
WAITING_APPROVAL,挂起等待回调。 - 每次一步完成之后,整个上下文和中间结果都会写回存储,做到“每步可回溯”。
- 任务跑完,状态置为
COMPLETED或FAILED,同时保留完整的执行记录。
这里面最关键的两点:一是任务可以中途挂起和恢复;二是每步的中间结果必须落盘。你去调一个内部系统的接口,可能三分钟就返回了,但是一个人工审批可能要等两天,这期间进程可能重启、机器可能故障,如果不持久化,任务就彻底丢了。
2.3 状态机:把 Agent 把任务跑乱了这件事管起来
Agent 是自主决策的,但这不代表它的生命周期可以没有纪律。我实现了一个严格的状态机,枚举就是这几个:
PENDING:已入队,等待调度RUNNING:正在执行推理或工具调用WAITING_APPROVAL:等待人工审批PAUSED:被运维或系统主动暂停COMPLETED:正常结束FAILED:不可恢复的错误CANCELLED:被用户取消
状态迁移必须合法,不允许从RUNNING直接跳到COMPLETED,必须经过任务结束的回调把所有资源清理干净。这个状态机用 Java 的EnumMap存合法迁移表,任何非法流转直接抛异常。不要小看这个设计,它避免了一堆“状态飘了”的线上问题。
注意:Agent 平台的运维人员经常需要强杀任务。状态机里一定要预留
PAUSED和CANCELLED分支,并定义好强杀后的清理动作,否则任务卡死在 RUNNING 上,资源永远释放不了。
3. 几个关键的设计决策与取舍
这部分才是整个项目里最有价值的东西。架构图谁都会画,真正的功力体现在每个决策背后的权衡。我挑五个当时争论最激烈的问题,逐一说明当时是怎么想的。
3.1 并发模型:虚拟线程带来的舒适区
AI 应用场景里有一个很典型的特性:大量等待。调用大模型接口,通常要等 2~10 秒;调内部 RPC,要等几百毫秒到几秒。如果每个 Agent 任务占用一个平台线程,那并发 500 个任务就需要 500 个线程,而线程栈默认是 1MB,光线程内存就 500MB,加上频繁上下文切换,系统很快就虚了。
所以 BizBuddy 明确建立在 JDK 21 虚拟线程之上。虚拟线程的创建开销极小,可以每个任务开一个虚拟线程,阻塞时只是让出载体线程,内存占用几乎可以忽略。这个改动真的称得上“舒适区”:代码完全用同步风格写,不用反人类的回调式编排,却拿到了接近异步模型的并发能力。
当然它也有坑。第一个坑是虚拟线程里 ThreadLocal 的继承行为不像传统线程那么可靠,尤其在写多租户上下文的时候,我发现基于 ThreadLocal 的透传偶尔丢失。后来统一改用显式的TaskContext参数传递,核心链路里禁止依赖 ThreadLocal。第二个坑是 JDBC 连接池的等待,如果虚拟线程大量阻塞在拿连接上,连接池本身会变成瓶颈,所以连接池的 maxActive 要根据虚拟线程数量单独调参。
3.2 工具网关:统一鉴权、限流与审计
Agent 调工具和人工调工具是两种完全不同的场景。人会小心谨慎地看提示、填参数,但 Agent 是按概率生成参数的,同一个工具它可能会在几秒内连续调用十几次,也可能生成一个非常离谱的参数组合。如果没有防护,一个号码验证接口能被 Agent 打到后台系统直接限流熔断。
BizBuddy 的工具网关说白了就是一个接口,所有工具调用统一经过它:
public ToolResult execute(String tenantId, String agentId, ToolInvocation invocation) { checkTenantAccess(tenantId, invocation.toolName()); // 租户是否有权限 RateLimiter limiter = getLimiter(tenantId, invocation.toolName()); if (!limiter.tryAcquire()) { throw new ToolExecutionException("rate_limited", "工具调用过于频繁,请稍后重试"); } auditLogger.log(tenantId, agentId, invocation); // 审计 try { Tool tool = toolRegistry.get(invocation.toolName()); return tool.execute(invocation.arguments()); } catch (Throwable t) { return ToolResult.failure(t.getMessage()); } }这个设计在早期被团队吐槽“多此一举”,后来真正跑起业务的时候全靠它兜住了很多事故。比如某业务 Agent 在循环里反复调用“查余额”接口,如果没有限流,后台数据库早被打挂了。网关层配合熔断器,能在单个 Agent 行为异常时快速切断,不让一个任务的失控拖垮全租户。
心得:工具网关不要只做转发,要把“异常返回值”当成一次正常结果处理。Agent 拿到失败结果不可怕,可怕的是网关抛异常导致 Agent 主循环挂掉。工具调用失败应该是一个可恢复的语义,由 Agent 自行决定重试还是换一种方式。
3.3 记忆与上下文管理:先不用向量库
当时团队里有人一上来就建议引入向量数据库做长期记忆。我的态度是:长期记忆确实有价值,但不是第一版的核心问题。第一版需要解决的是“怎么把上下文控制在模型窗口内”。
BizBuddy 的记忆模块分为两层:
- 短期上下文:就是当前任务运行中逐步累积的消息列表。直接放内存,但要做滑动窗口裁剪。
- 长期摘要:当短期上下文超过阈值,把早期消息丢给大模型生成一段摘要,然后替换掉原始消息,把摘要放进上下文。
这个策略避免了第一个版本就要引入向量库。说实话,企业场景里 90% 的任务上下文都在几十轮之内,滑动窗口加摘要完全够用。硬塞一个向量库进来,反而要处理索引一致性、备份恢复、维度选择这些问题,纯属给自己加戏。等到业务真的出现“海量历史记忆检索”的需求,再演进不迟。
3.4 可观测性:日志、指标、链路一起上
Agent 执行的不可预测性决定了可观测性必须从第一天就设计进去,而不是事后补。BizBuddy 在核心链路里埋了几类关键观测点:
- 每次大模型调用的 token 数、耗时、返回码
- 每次工具调用的名称、租户、耗时、结果状态
- 每次任务状态迁移的事件流
- 运行中的活跃任务数、队列积压数
统一用结构化日志输出,指标接入监控系统,调用链路上透传 traceId。定位问题的时候,全程都是一条线串下来的。这也是 IMO 企业级平台和玩具 Demo 的分水岭:玩具 Demo 只关心“能不能跑通”,企业平台更关心“出问题后能不能快速定位”。
4. 核心代码骨架与实操细节
前面讲的是“想”,这部分讲“做”。我挑几个最核心的代码片段,还原当时怎么实现出来,每个片段背后都有实际踩过的坑。
4.1 Agent 主循环的实现
Agent 的执行本质就是一个循环:推理 -> 调用工具 -> 把结果放回上下文 -> 再推理。这个循环在 BizBuddy 里长这样:
public AgentResult run(TaskContext ctx) { int maxSteps = ctx.agentDef().maxSteps(); for (int step = 1; step <= maxSteps; step++) { ctx.appendStepStart(step); // 1. 调用大模型,得到本次决策 LLMResponse response = llmProvider.chat( ctx.agentDef().systemPrompt(), ctx.currentMessages(), ctx.toolSchemas() ); ctx.recordLLMCall(response); // 2. 判断是结束还是调用工具 if (response.hasFinalAnswer()) { ctx.markCompleted(response.finalAnswer()); return ctx.toResult(); } // 3. 解析工具调用请求并走网关 ToolCallRequest toolCall = response.toolCall(); ToolResult toolResult = toolGateway.execute(ctx.tenantId(), ctx.agentId(), toolCall); ctx.recordToolResult(toolCall, toolResult); // 4. 上下文中继续推理 ctx.appendUserMessage(formatToolResult(toolCall, toolResult)); } throw new MaxStepExceededException(ctx.taskId()); }这里有个很实用的细节:第 4 步把工具结果包裹成一条“用户消息”放回上下文,而不是直接把原始 JSON 丢进去。因为大模型见过太多格式不一的 JSON 后会逐渐跑偏,把它格式化成规范的工具 X 返回了结果: ...,行为会稳定很多。这也是后来在评测集上对比验证出来的。
4.2 上下文与任务快照的持久化
每一轮循环结束后,整个TaskContext需要做一次快照,存到数据库。这个设计听起来笨拙,但对恢复能力至关重要。实现上用了最简单可靠的 JSON 序列化,反序列化时再变回对象。
public void saveCheckpoint(TaskContext ctx) { String snapShot = JsonUtils.toJson(ctx); taskStore.updateSnapshot(ctx.taskId(), snapShot, ctx.version()); }这里我踩过一个很坑的并发问题。一开始没有版本号,结果回收线程和主执行线程同时写,后写的把先写的覆盖了,任务数据莫名丢失。后来给快照加了乐观锁版本号,每次更新带version + 1,更新 SQL 里带上where version = ?,直接拦截了并发覆盖问题。
持久化频率也要控制。最开始每轮循环都写全量快照,跑一个 30 步的任务要写 30 次大 JSON,数据库压力很大。后面优化成“每 3 步强制快照 + 每步增量日志追加”,既保证可恢复,又不至于把 DB 当日志存储打爆。
4.3 插件式工具接入:那些反射与 SPI 的坑
工具注册这块我一开始选了注解扫描,后来在实践里发现简单场景好用,一旦工具数量上了几十个,管理和权限分配就会乱套。最终 BizBuddy 改成了 Java SPI 机制加注册中心的组合:
- 工具实现类通过 SPI 配置文件声明。
- 启动时通过
ServiceLoader加载全部候选工具。 - 每个工具实现
ToolSpec接口,声明工具名称、参数 JSON Schema、所属权限点。 - 网关启动时把工具注册进
ToolRegistry,并为每个工具绑定鉴权规则。
这个方案的优点是不需要写一堆反射扫描逻辑,SPI 本身就是 Java 标准能力,团队理解成本低,还把工具实现和框架解耦了。业务方加一个新工具,只需要新增一个类、改一行配置文件,不用动框架代码。
5. 落地过程中踩过的坑和最终结论
这部分写点真金白银换来的经验。没有哪一套设计能在第一版就完美贴合业务,上线过程中遇到的问题才是成长最快的教材。
5.1 结构化输出的坑与兜底策略
Agent 的主循环依赖大模型稳定输出“JSON 格式的工具调用指令”。理论上大模型都宣称支持 JSON 输出,实际跑起来,模型偶尔会给你一段 Markdown 代码块包着 JSON,或者 JSON 里多个逗号,甚至干脆输出一段话。如果你的平台直接JSON.parse,这种偶发问题会让任务直接挂掉。
BizBuddy 的解决策略是多层兜底:
- 第一层,按模型厂商提供的结构化输出模式约束,输出 JSON Schema。
- 第二层,解析失败后做一次“提取矫正”,尝试从响应体里抠出 JSON 片段再解析。
- 第三层,二次矫正失败后,把错误信息拼进上下文,让大模型自己修正后重新输出。
这些兜底逻辑看着繁琐,但确实能大幅降低无效失败率。我统计过,加了提取矫正之后,工具调用解析失败率从 3.8% 降到了 0.4% 以下,在整个平台稳定性上贡献非常大。
5.2 工具调用超时与熔断
Agent 会调很多第三方工具,有些接口本身就不靠谱。单次工具调用超时如果设得太短,一个慢接口会导致 Agent 任务直接失败;设得太长,资源又一直被占用。最终 BizBuddy 采用的是分层超时策略:
- 快速工具默认 3 秒超时,连续失败 3 次直接熔断 30 秒。
- 慢速工具默认 10 秒超时,连续失败 2 次直接熔断 60 秒。
- 断路器状态上抛给上下文,Agent 看到熔断结果后可以选择换一个工具或提前结束。
这个策略的好处是:既避免 Agent 在坏工具上反复横跳浪费时间,又保留了“工具临时不可用后自动恢复”的能力。注意熔断状态要按租户隔离,不能因为某个租户把工具调坏了,全平台都跟着不能用。
5.3 死循环与资源耗尽防护
Agent 是非确定性的,我说过它可能陷入同一个工具调用循环里出不来。BizBuddy 里做了三重防护:
- 步骤数上限:每个任务有一个
maxSteps,超过直接失败并清理资源。 - 重复调用检测:如果同一个工具连续调用超过 N 次且参数高度相似,直接判定为“疑似死循环”,中止任务。
- 全局信号量:每个租户同时运行的任务数有上限,申请不到信号量的任务进入队列等待。
有一次线上监控发现某个时段 CPU 飙升,查下来就是某个 Agent 在死循环里疯狂请求网关,网关再把请求打到数据库。重复调用检测一上线,这问题立刻解决。这种保护措施一开始可能用不上,但一定要提前埋好,否则等到事故来了,再设计就晚了一步。
5.4 调度稳定性:数据库轮询比内存定时器稳得多
第一版 BizBuddy 的调度器用的是进程内定时线程池,每 5 秒扫一次内存队列。任务量小的时候很流畅,后来一旦进程重启,内存里未执行的任务全部丢失,这在我这里属于不可接受的“安全事故”。后面果断改成了数据库轮询扫描:
- 每 2 秒扫描一次待调度表,把
PENDING状态且到了执行时间的任务取出来。 - 通过乐观锁
compare-and-set抢占任务,避免多个实例重复调度。 - 执行完立即更新状态,保证每个任务在同一时刻只有一个实例在跑。
这套方案虽然不是最高级的分布式调度,但胜在可靠。任何调度器宕机,重启后依然可以从数据库恢复任务。对于绝大多数企业内部 Agent 场景,任务量远没到需要引入专业分布式调度框架的程度,一个加索引的待调度表就够了。
写在最后的话
整个 BizBuddy 从立项到上线,花了大概四个多月。回过头看,我最大的体会是:Agent 平台的成败,不在于模型选得多强,而在于工程底座有没有把“不确定性”管理好。大模型的输出方向天生难预判,平台的价值就在于在这个不可控之上建立确定性——状态可控、上下文可控、资源可控、行为可观测。纯 Java 的选择不是因为它时髦,而是因为它能无缝融入现有的企业基础设施,让我们把注意力集中在 Agent 本身,而不是运维的泥潭。
如果你也在自研类似的 Agent 平台,我最后的建议是:第一版宁可少做功能,也要把生命周期状态机、工具网关、可观测性这三件事做扎实。这三件事做不好,后面每一个功能都是在沙地上盖楼。等这三个地基夯实了,Agent 平台才真正谈得上“企业级”三个字。