1. 为什么我要用纯 Java 造一个 Agent Harness
1.1 从一个真实的痛点说起
去年下半年,我所在的团队接了一个内部效率工具的需求:把公司里散落在各个业务系统中的重复性操作,用一个"智能代理"串起来。比如每天早上自动拉取前一天的订单异常数据、生成摘要、推送到值班群,再根据异常类型自动创建跟进任务。听起来不复杂,但真正动手的时候,问题就来了。
我们试过市面上几种主流的 Agent 框架,Python 生态的居多,功能确实强大,但落地到我们这套以 Java 为主的技术栈里,处处别扭。运维团队不愿意为一个内部工具单独维护一套 Python 运行时和依赖环境;安全团队对引入外部框架的供应链风险有顾虑;而业务侧的同事希望能像调用普通 Spring Service 一样调用这些 Agent 能力。折腾了两周,我做了个决定:干脆用纯 Java 自己搭一个 Agent Harness 平台,把"代理编排"这件事做成团队内部的基础设施。
这就是 BizBuddy 的起点。它不是又一个通用 Agent 框架,而是一个面向企业内网的、纯 Java 实现的 Agent 运行与编排底座。所谓 Harness,我理解就是"马具"——它不负责造马(模型能力),而是负责把马套好、缰绳理顺、让车能稳稳地跑起来。BizBuddy 要解决的核心问题就三个:怎么把大模型的调用封装成企业可管控的服务、怎么把多个 Agent 的协作流程编排清楚、怎么让业务代码零成本接入。
这篇文章我会把整个设计和取舍过程摊开讲,包括为什么坚持纯 Java、Harness 层到底该管什么、编排引擎怎么设计、状态和上下文怎么处理,以及我在实际踩坑中总结出来的一堆经验。适合正在做企业内部 AI 基础设施的工程师、架构师,也适合想了解 Agent 工程化落地细节的读者。哪怕你暂时不打算自己造轮子,这里面的取舍逻辑也能帮你在选型时少走弯路。
1.2 先明确边界:Harness 不是框架,是运行时
在动手之前,我花了很长时间想清楚一件事:BizBuddy 到底该做什么、不该做什么。市面上很多所谓的 Agent 框架,恨不得把提示词管理、工具注册、记忆存储、流程编排、模型路由全包了,结果就是又重又难改。我给自己划了条线——BizBuddy 只做运行时和编排,不做能力本身。
具体来说,它负责四件事:第一,把模型调用抽象成统一的ModelClient接口,屏蔽不同厂商 API 的差异;第二,提供 Agent 的生命周期管理,包括注册、发现、执行、销毁;第三,实现一套基于状态机的编排引擎,让多个 Agent 能按流程协作;第四,提供上下文管理和可观测性,让每一次 Agent 执行都可追溯、可回放。
它不负责的事也很明确:不内置具体的提示词模板(那是业务的事)、不绑定特定的模型厂商(通过 SPI 扩展)、不提供可视化编排界面(初期用代码配置就够)。这条边界非常重要,因为它决定了整个平台的复杂度上限。我见过太多内部工具因为想"什么都能干",最后变成谁都不敢动的巨石。
提示:做企业内部基础设施,克制比功能丰富更重要。每多一个功能,就多一份维护成本和被滥用的风险。
2. 核心架构设计与技术选型取舍
2.1 为什么坚持纯 Java,而不是混用多语言
这个决定在当时是有争议的。团队里有人建议核心编排用 Java,模型调用和提示词处理用 Python,通过进程间通信对接。听起来合理,但我否决了,理由有三。
第一是部署和运维的一致性。我们整个后端是 Spring Boot 体系,CI/CD、监控、日志、配置中心都是围绕 Java 建的。引入 Python 意味着要额外维护一套运行时、一套依赖管理、一套健康检查,运维成本直接翻倍。对于一个内部工具来说,这个代价不划算。
第二是类型安全和可维护性。Agent 编排涉及大量的状态流转、参数传递、错误处理,Java 的强类型和成熟的并发模型在这里是优势。Python 写起来快,但流程一复杂,动态类型带来的隐性 bug 会让人头疼。我们这套东西是要长期跑的,稳定性优先。
第三是团队技能栈。让一群写了多年 Java 的工程师去维护 Python 代码,短期能应付,长期一定出问题。技术选型要服务于团队的可持续维护能力,而不是追求技术上的"最优解"。
当然,纯 Java 也有代价。比如一些前沿的模型 SDK 只有 Python 版本,我们就得自己封装 HTTP 调用;再比如 Java 在流式处理上的写法比 Python 啰嗦。但这些代价是可控的,而多语言带来的运维复杂度是不可控的。
2.2 分层架构:把变化隔离在边界之外
BizBuddy 的整体架构我分成了四层,从下到上依次是:模型接入层、Agent 运行时层、编排引擎层、业务接入层。这个分层的核心思想是"把变化隔离在边界之外"——模型厂商会变、业务需求会变,但层与层之间的契约要尽量稳定。
模型接入层通过ModelClient接口定义统一契约,核心方法就两个:complete(request)返回完整结果,stream(request)返回流式结果。不同厂商的实现通过 SPI 注册,业务代码只依赖接口。这样换模型厂商时,只需要新增一个实现类,业务代码一行不用改。
Agent 运行时层负责 Agent 的注册、发现和执行。每个 Agent 是一个实现了Agent接口的 Bean,通过注解@AgentDefinition声明自己的名称、能力和所需工具。运行时负责在启动时扫描注册,执行时根据名称路由。
编排引擎层是重头戏,我用了基于状态机的方式来实现多 Agent 协作。每个编排流程定义为一个Workflow,由若干State和Transition组成。引擎负责驱动状态流转、处理分支和循环、管理超时和重试。
业务接入层就是给业务方用的门面,提供BizBuddyClient这样的统一入口,业务代码通过它提交任务、查询状态、获取结果。
| 层级 | 核心职责 | 关键接口 | 变化频率 |
|---|---|---|---|
| 模型接入层 | 屏蔽模型厂商差异 | ModelClient | 中 |
| Agent 运行时层 | Agent 生命周期管理 | Agent | 低 |
| 编排引擎层 | 多 Agent 流程编排 | Workflow | 中 |
| 业务接入层 | 统一对外门面 | BizBuddyClient | 高 |
2.3 编排引擎:状态机 vs 工作流引擎
编排引擎的设计我纠结了很久,主要是在"自己写状态机"和"集成现成工作流引擎"之间摇摆。现成的方案比如一些 BPMN 引擎,功能强大、可视化好,但引入成本高,而且它们的模型是为人工审批流程设计的,套到 Agent 编排上有点削足适履。
最后我选择自己实现一个轻量级状态机。核心类就三个:WorkflowDefinition定义流程结构,WorkflowInstance表示一次运行实例,StateMachineEngine负责驱动流转。状态之间的转换用Transition描述,支持条件表达式来决定走哪条分支。
这个选择的好处是完全可控。Agent 编排和传统工作流有个本质区别:Agent 的执行结果是不确定的,可能成功、可能失败、可能返回需要人工介入的中间态。自己写状态机,可以针对这些情况做精细处理,比如某个状态失败后自动重试三次,还失败就转人工审核状态。用现成引擎反而要绕很多弯。
代价是要自己处理并发、持久化、恢复这些问题。我的做法是:状态机的流转逻辑保持无状态,所有状态存在数据库里,每次流转都是一个事务。这样即使服务重启,也能从数据库恢复未完成的流程。
3. 核心模块的实操实现细节
3.1 ModelClient:统一模型调用的抽象
模型接入层是整个平台的地基,我把它设计得尽量薄。ModelClient接口定义如下:
public interface ModelClient { CompletionResult complete(CompletionRequest request); Stream<CompletionChunk> stream(CompletionRequest request); String provider(); }CompletionRequest里封装了模型名称、消息列表、温度、最大 token 等参数。这里有个关键取舍:我没有把厂商特有的参数放进通用请求对象,而是用一个Map<String, Object> extraParams来承载。这样通用逻辑保持干净,厂商特有的能力通过扩展参数传递,实现类自己解析。
具体实现上,我用了 Spring 的@ConditionalOnProperty来控制不同厂商实现的加载。配置文件里指定bizbuddy.model.provider=xxx,对应的实现类才会被注册。这样部署时切换模型厂商,改一行配置就行。
流式处理是另一个重点。Java 里做流式我用的是FlowAPI 或者 Reactor 的Flux,最终选了 Reactor,因为它在背压处理和错误传播上更成熟。stream方法返回Flux<CompletionChunk>,业务侧可以订阅处理。这里要注意的是流式响应的超时控制,我设置了两级超时:单个 chunk 之间的间隔超时(默认 30 秒)和整个流的总超时(默认 5 分钟),避免某个卡住的流把线程占死。
注意:模型调用一定要设置超时,而且要有熔断。我早期没做熔断,某次模型服务抖动,导致大量请求堆积,把整个应用拖垮了。后来引入了 Resilience4j,配置了基于失败率的熔断策略,才稳住。
3.2 Agent 运行时:注册、发现与执行
Agent 的抽象我设计得很简单,就是一个接口加一个注解:
public interface Agent { AgentResult execute(AgentContext context); } @AgentDefinition(name = "order-anomaly-detector", description = "检测订单异常", tools = {"orderQuery", "dataAnalysis"}) public class OrderAnomalyDetector implements Agent { ... }运行时在启动时扫描所有带@AgentDefinition的 Bean,把元信息注册到一个AgentRegistry里。执行时,编排引擎通过名称从注册表拿到 Agent 实例,构造AgentContext传进去。
AgentContext里包含三样东西:输入参数、共享上下文、工具访问器。输入参数是本次执行的入参;共享上下文是整个工作流级别的,多个 Agent 可以读写,用来传递中间结果;工具访问器让 Agent 能调用外部工具,比如查数据库、调 API。
这里有个设计细节值得说:Agent 的执行是同步的,但编排引擎的调度是异步的。也就是说,单个 Agent 的execute方法阻塞执行完返回结果,但引擎在调度时会把 Agent 执行提交到线程池,避免阻塞主流程。这样既保证了 Agent 实现的简单性(不用考虑异步),又保证了整体吞吐。
工具访问器的设计我用了"能力声明 + 运行时注入"的模式。Agent 在注解里声明自己需要哪些工具,运行时只注入声明过的工具。这样既安全(Agent 不能访问未授权的工具),又清晰(看注解就知道这个 Agent 依赖什么)。
3.3 编排引擎:状态流转的实现
编排引擎的核心是状态机。一个WorkflowDefinition由若干State组成,每个State可以是 Agent 执行状态、条件判断状态、等待状态或结束状态。状态之间通过Transition连接,Transition上可以挂条件表达式。
WorkflowDefinition workflow = WorkflowDefinition.builder() .name("daily-order-check") .state(State.agent("fetch", "order-fetcher")) .state(State.agent("detect", "order-anomaly-detector")) .state(State.condition("judge", ctx -> ctx.get("anomalyCount", 0) > 0)) .state(State.agent("notify", "alert-notifier")) .state(State.end("done")) .transition("fetch", "detect") .transition("detect", "judge") .transition("judge", "notify", ctx -> ctx.get("hasAnomaly", false)) .transition("judge", "done", ctx -> !ctx.get("hasAnomaly", false)) .transition("notify", "done") .build();引擎驱动流程时,每次从当前状态出发,评估所有出边的条件,选择第一条满足条件的边,流转到下一个状态。这个"评估条件选边"的逻辑是引擎的核心,我把它抽成了一个TransitionSelector,方便替换不同的策略(比如优先级策略、随机策略)。
状态持久化我用的是数据库表,每次流转前把当前状态和上下文序列化存进去,流转后更新。这样服务重启后,可以从数据库恢复所有未完成的流程实例。这里有个坑:上下文序列化要控制大小。早期我把整个上下文都存进去,结果有些流程上下文里塞了几百 KB 的数据,数据库压力很大。后来改成只存必要的状态数据,大对象走对象存储,上下文里只存引用。
3.4 上下文管理:共享数据的读写与隔离
上下文管理是容易被忽视但很关键的部分。多个 Agent 协作时,数据怎么传递、怎么隔离、怎么避免污染,都需要设计。
我的方案是分层上下文:每个 Agent 有自己的私有上下文(AgentContext),工作流有共享上下文(WorkflowContext)。Agent 执行时,私有上下文从共享上下文初始化,执行完把需要传递的结果写回共享上下文。这样 Agent 之间不直接耦合,只通过共享上下文通信。
共享上下文的读写我加了版本控制。每次写入会递增版本号,读取时可以指定版本。这样在并发场景下,能检测到冲突。虽然初期大部分流程是串行的,用不上这个,但为后续并行编排留了余地。
还有一个细节是上下文的生命周期。工作流结束后,共享上下文会被归档,保留一段时间供排查问题,然后清理。私有上下文在 Agent 执行完就销毁。这个清理策略要配好,否则上下文数据会无限增长。
4. 常见问题与排查技巧实录
4.1 模型调用超时和重试的坑
模型调用超时是最常见的问题。我踩过的坑是:重试策略没区分错误类型。早期我对所有失败都重试三次,结果遇到参数错误(比如 token 超限)也重试,白白浪费三次调用。后来改成只对网络超时和 5xx 错误重试,4xx 错误直接失败。
重试还有个坑是幂等性。如果 Agent 执行有副作用(比如创建了任务),重试可能导致重复创建。我的做法是在 Agent 层面做幂等控制,每次执行带一个唯一的executionId,副作用操作先检查这个 ID 是否已处理过。
| 错误类型 | 是否重试 | 重试次数 | 退避策略 |
|---|---|---|---|
| 网络超时 | 是 | 3 | 指数退避 |
| 5xx 服务错误 | 是 | 3 | 指数退避 |
| 4xx 参数错误 | 否 | - | - |
| 限流 429 | 是 | 5 | 固定间隔 |
4.2 状态机死循环和状态丢失
状态机最怕两件事:死循环和状态丢失。死循环通常是因为条件表达式写错,导致两个状态之间来回跳。我的防护措施是给每个流程实例设置最大流转次数(默认 100 次),超过就强制终止并告警。
状态丢失一般发生在持久化环节。有次线上出现流程卡住不动,排查发现是某次流转后数据库更新失败,但内存里的状态已经变了,导致内存和数据库不一致。后来我改成先持久化再更新内存,并且每次流转都加乐观锁版本号,冲突就重试。
提示:状态机的每一次流转都要当作一次事务来处理,宁可慢一点,也不能出现状态不一致。
4.3 Agent 执行结果不确定的处理
Agent 的执行结果天然不确定,可能返回成功、失败、或者需要人工介入的中间态。我定义了三种结果类型:SUCCESS、FAILURE、PENDING。PENDING表示需要外部介入,比如等待人工审核。引擎遇到PENDING会把流程挂起,等外部信号再恢复。
这个设计解决了很多实际问题。比如某个 Agent 检测到异常但不确定是否要告警,就返回PENDING,流程挂起等待人工确认,确认后再继续。这样既保留了自动化的效率,又保留了人工的判断权。
4.4 可观测性:日志、指标和追踪
Agent 编排的调试比普通服务难,因为一次执行涉及多个 Agent、多次模型调用,链路很长。我做了三件事来提升可观测性。
第一是结构化日志。每次 Agent 执行都打一条结构化日志,包含workflowId、executionId、agentName、duration、result。这样排查问题时可以按workflowId把所有相关日志串起来。
第二是关键指标。我埋了几个核心指标:Agent 执行成功率、平均耗时、模型调用 token 消耗、流程完成率。这些指标接入监控后,能快速发现异常。
第三是执行追踪。每次工作流执行生成一个 trace,记录每个状态的进入时间、退出时间、流转原因。这个 trace 存在数据库里,排查问题时可以完整回放整个执行过程。
5. 一些设计上的取舍与反思
5.1 为什么不做可视化编排
很多人问我为什么不做可视化编排界面。我的回答是:现阶段不值得。可视化编排的价值在于让非技术人员也能配置流程,但我们的用户是工程师,他们更习惯用代码定义流程。而且可视化编排的复杂度很高,要处理布局、连线、校验、版本管理,投入产出比不划算。
我的替代方案是提供一套流畅的 DSL。上面展示的WorkflowDefinition.builder()就是 DSL 的一部分,写起来接近自然语言,工程师上手很快。等未来真的有非技术用户的需求,再考虑可视化也不迟。
5.2 关于性能和资源控制
Agent 编排是 IO 密集型任务,大部分时间在等模型响应。所以线程模型上我用了异步 + 线程池隔离。不同优先级的流程用不同的线程池,避免低优先级任务把高优先级任务饿死。
资源控制上,我加了并发流程数限制和单流程 Agent 调用次数限制。前者防止系统被大量流程压垮,后者防止某个流程因为 bug 无限调用 Agent。这两个限制都是可配置的,根据实际负载调整。
5.3 后续可以扩展的方向
这套平台跑了大半年,基本稳定。后续我考虑扩展几个方向:一是支持并行编排,让没有依赖关系的 Agent 并行执行,提升效率;二是引入更智能的路由,根据历史执行数据自动选择最优的 Agent 组合;三是完善工具生态,把常用的企业内网工具(数据库、消息队列、内部 API)封装成标准工具,让 Agent 开发更简单。
不过这些都是后话。现阶段最重要的是把这套东西用稳、用好,让业务方真正感受到 Agent 编排带来的效率提升。基础设施的价值不在于功能多,而在于稳定可靠、让人愿意用。
我在实际维护这套平台的过程中最大的体会是:企业级 Agent 平台的难点从来不在模型调用,而在工程化。怎么让流程可控、怎么让问题可查、怎么让业务方用得放心,这些才是真正花时间的地方。纯 Java 的选择、状态机的设计、上下文的隔离,每一个决定背后都是对"可控性"的追求。如果你也在做类似的事情,希望这些经验能帮你少踩几个坑。