1. 为什么要在 Java 生态里造一个 Agent Harness
1.1 从“能跑”到“能管”的鸿沟
过去一年,我接触过不少团队在尝试把大模型能力接入内部系统。初期大家的目标都很朴素:写个脚本,调一下接口,把结果塞进业务流里,能跑通就行。但一旦这个“能跑”的脚本要进入生产环境,问题就全冒出来了。谁负责记录每一次模型调用的输入输出?多个业务线同时调用同一个模型服务,配额怎么分?某个流程需要模型先查数据库再调接口最后生成报告,这个编排逻辑写在哪?模型返回了不合规内容,拦截规则由谁维护?
这些问题,靠一个几百行的 Python 脚本是解决不了的。你需要一个中间层,它站在业务代码和模型服务之间,负责调度、管控、观测和治理。这个中间层,我把它叫做Agent Harness——智能体 harness,或者更直白一点,叫“模型能力的承载与管控框架”。
市面上有不少方案,但大多数要么绑定特定云厂商,要么以 Python 为主。对于大量以 Java 为核心技术栈的企业来说,引入一套 Python 服务意味着额外的运维成本、跨语言通信开销,以及团队学习曲线。所以,用纯 Java 构建一个企业级 Agent Harness,就成了一个很自然的需求。
1.2 BizBuddy 的定位:不做模型,做模型的“调度中枢”
BizBuddy 这个项目,从一开始就明确了几条边界。第一,它不训练模型,也不做模型推理优化,那是模型层的事。第二,它不替代业务系统,业务逻辑仍然由业务代码负责。第三,它的核心职责是:接收业务请求,编排模型调用链路,执行管控策略,记录全链路日志,并返回结构化结果。
你可以把它理解成一个“模型调用的总线”。业务方只需要告诉 BizBuddy:“我要完成一个什么任务,输入是什么,期望的输出格式是什么”,剩下的模型选择、参数配置、多步编排、异常重试、内容过滤、成本统计,都由 BizBuddy 来处理。
这个定位决定了它的设计取舍。比如,它必须足够轻量,不能引入过重的依赖;它必须对业务代码友好,提供简洁的 API;它必须可扩展,方便接入不同的模型服务;它还必须可观测,让运维和运营团队能看清每一次调用的来龙去脉。
1.3 适合哪些团队参考
如果你所在的团队正在做以下事情,BizBuddy 的设计思路应该能直接参考:第一,多个业务线需要调用大模型能力,但缺乏统一入口;第二,需要对模型调用做审计和成本控制;第三,业务流程中涉及多步模型交互,需要编排能力;第四,技术栈以 Java 为主,不希望引入额外的 Python 服务。
反过来,如果你的场景只是单次、低频的模型调用,或者团队已经有成熟的 Python 生态和运维体系,那自建 Java 版 Harness 的优先级可以往后放。工具的选择永远要看上下文,没有银弹。
2. 整体架构与核心模块拆解
2.1 分层设计:接入层、编排层、执行层、治理层
BizBuddy 的整体架构分成四层,每一层的职责非常清晰,层与层之间通过接口解耦。
接入层负责对外暴露 API。业务方可以通过 HTTP 接口、Java SDK 或者消息队列的方式提交任务。接入层不做任何业务逻辑,只负责参数校验、身份认证和请求转发。这样做的好处是,未来如果要支持 gRPC 或者 WebSocket,只需要在接入层增加适配器,不影响核心逻辑。
编排层是 BizBuddy 的大脑。它接收一个任务描述,解析出需要执行的步骤,然后按照预定义的流程依次或并行调用执行层。编排层支持条件分支、循环、并行执行和超时控制。比如一个典型的场景:先让模型从用户输入中提取关键信息,然后根据提取结果查询数据库,最后把查询结果和原始输入一起交给模型生成最终回复。这个流程就是在编排层定义的。
执行层负责实际调用模型服务。它封装了不同模型提供商的 API 差异,对外提供统一的调用接口。执行层还负责处理重试、降级、限流等运行时策略。每个模型调用都会被包装成一个“执行单元”,记录输入、输出、耗时和状态。
治理层是贯穿所有层的横切关注点。它包括日志记录、指标采集、内容过滤、成本核算和权限控制。治理层不直接参与任务执行,但会在关键节点插入钩子,收集数据并执行策略。比如每次模型调用前后,治理层都会检查内容是否合规,并记录 token 消耗。
2.2 核心数据结构:Task、Step、Execution
BizBuddy 内部用三个核心数据结构来描述一次完整的任务执行。
Task是最高层级的抽象,代表业务方提交的一个完整任务。它包含任务 ID、任务类型、输入参数、期望输出格式、优先级和超时时间。一个 Task 可以包含一个或多个 Step。
Step是任务中的一个步骤,代表一次具体的操作。Step 的类型可以是“模型调用”、“数据查询”、“条件判断”或“结果聚合”。每个 Step 有明确的输入和输出定义,以及执行顺序和依赖关系。
Execution是 Step 的一次实际执行记录。它包含执行 ID、关联的 Step ID、开始时间、结束时间、状态、输入快照、输出快照和错误信息。Execution 是治理层采集数据的基本单位。
这三个结构的设计参考了工作流引擎的常见模式,但做了大幅简化。我们没有引入复杂的 BPMN 标准,因为企业级 Agent 编排的场景相对聚焦,过度设计反而会增加使用成本。
2.3 为什么选择纯 Java 实现
这个决定背后有几个实际考量。第一,团队的技术栈以 Java 为主,使用 Java 可以复用现有的监控、日志、配置管理和部署体系。第二,Java 的强类型和成熟的并发库,对于构建稳定可靠的调度系统很有帮助。第三,Java 生态中有丰富的 HTTP 客户端、JSON 处理库和连接池实现,不需要重复造轮子。第四,纯 Java 意味着可以打包成一个独立的 JAR,部署简单,不需要额外的运行时环境。
当然,纯 Java 也有代价。比如在流式处理方面,Java 的响应式编程模型比 Python 的异步框架要复杂一些。但考虑到企业级场景中,稳定性和可维护性的优先级高于极致的开发效率,这个取舍是值得的。
3. 关键模块的实操细节与避坑经验
3.1 模型调用的统一抽象与适配器模式
执行层的核心是一个名为ModelInvoker的接口。它定义了三个方法:invoke用于同步调用,invokeAsync用于异步调用,supports用于判断当前适配器是否支持某个模型标识。
每个模型提供商的适配器都实现这个接口。比如针对某类通用大模型 API,适配器负责把统一的请求对象转换成该 API 要求的 JSON 格式,发送 HTTP 请求,再把响应解析成统一的结果对象。这样做的好处是,编排层不需要关心底层用的是哪家模型,只需要指定模型标识和参数即可。
在实际操作中,有几个细节需要特别注意。第一,超时设置。不同模型的响应时间差异很大,有的模型在高峰期可能需要几十秒。适配器必须支持可配置的超时时间,并且要和编排层的超时控制协调好。我的经验是,适配器超时时间设置为编排层超时时间的 80%,留出缓冲余地。
第二,重试策略。不是所有错误都适合重试。网络超时和 5xx 错误可以重试,但 4xx 错误通常意味着请求本身有问题,重试没有意义。我建议在适配器层面实现一个简单的重试逻辑,最多重试两次,并且使用指数退避。
第三,连接池管理。如果使用 HTTP 客户端调用模型服务,务必配置连接池。默认的连接池大小可能不够用,特别是在高并发场景下。我一般会把最大连接数设置为预期 QPS 的两倍,并且开启连接保活。
3.2 编排引擎的状态机设计与超时控制
编排层使用状态机来管理任务的执行流程。每个 Task 有一个状态,包括PENDING、RUNNING、COMPLETED、FAILED和TIMEOUT。每个 Step 也有类似的状态。状态之间的转换由编排引擎控制。
状态机的实现没有引入第三方框架,而是用 Java 的enum和switch语句手写。这样做虽然代码量稍大,但逻辑清晰,调试方便。核心是一个TaskExecutor类,它持有一个任务队列,不断从队列中取出待执行的 Step,调用执行层,然后根据结果更新状态。
超时控制是编排层的一个难点。我们采用了双层超时机制:Task 级别有一个总超时时间,Step 级别有一个单步超时时间。当 Task 超时时,所有未完成的 Step 都会被强制终止。当 Step 超时时,编排引擎会决定是重试、跳过还是终止整个任务。这个决策逻辑可以通过配置来定义。
注意:超时控制必须考虑线程中断的传播。如果使用
Future.get(timeout)来实现超时,记得在捕获TimeoutException后调用future.cancel(true),否则底层线程可能继续运行,造成资源泄漏。
3.3 内容过滤与合规检查的插入点
治理层的内容过滤模块需要在两个位置插入:模型调用的输入侧和输出侧。输入侧过滤主要防止敏感信息被发送到模型服务,输出侧过滤主要防止模型生成不合规内容。
实现上,我们定义了一个ContentFilter接口,包含filterInput和filterOutput两个方法。默认实现使用关键词匹配和正则表达式。企业可以根据自身需求,替换成更复杂的过滤逻辑,比如调用内部的风控服务。
这里有一个实操心得:过滤规则不要硬编码在代码里。我们最初把关键词写在 Java 类中,结果每次调整规则都要重新打包部署。后来改成从配置中心读取,支持热更新,运维效率大幅提升。配置格式用 JSON 数组,每个规则包含匹配模式和动作(拦截、替换或告警)。
另一个坑是性能问题。如果过滤规则很多,每次调用都遍历所有规则,延迟会明显增加。我们的优化方案是,把规则按类型分组,先用一个简单的布隆过滤器做快速判断,只有可能命中的请求才走完整匹配。这个优化把平均过滤耗时从 15 毫秒降到了 2 毫秒以内。
3.4 成本核算与配额管理的实现
成本核算是企业级 Harness 区别于个人项目的关键功能之一。BizBuddy 在每次模型调用后,会根据返回的 token 使用量,结合预设的单价,计算出本次调用的成本。成本数据会写入一个独立的成本表,按业务线、模型和日期维度聚合。
配额管理则是在成本核算的基础上,增加一个前置检查。每个业务线有一个月度预算,当累计成本接近预算时,系统会发出告警;当超过预算时,可以选择拒绝新请求或降级到更便宜的模型。这个策略通过配置来定义,不同业务线可以有不同的阈值。
实现配额管理时,我们遇到了并发问题。多个请求同时检查配额并扣减,可能导致超支。解决方案是使用数据库的行锁或者 Redis 的原子操作。我们最终选择了 Redis,因为它的性能更好,而且可以设置过期时间,自动清理历史数据。
提示:成本核算的单价配置一定要支持动态调整。模型提供商经常会调整价格,如果单价写死在代码里,每次调价都要发版。我们把单价放在配置中心,按模型标识和生效日期管理,支持历史价格回溯。
4. 实操过程:从零搭建一个最小可用版本
4.1 环境准备与依赖选型
搭建 BizBuddy 的最小可用版本,你需要的环境很简单:JDK 17 或更高版本,Maven 或 Gradle 作为构建工具,一个用于测试的模型服务 API Key。
依赖方面,我建议保持精简。核心依赖包括:一个 HTTP 客户端(我用的是 Java 11 自带的HttpClient,省去额外依赖),一个 JSON 处理库(Jackson 或 Gson 都可以),一个日志框架(SLF4J + Logback),以及一个配置管理库(如果不需要动态配置,用 Java 的Properties也够)。
如果你打算把 BizBuddy 部署成独立服务,还需要一个轻量级的 HTTP 服务器框架。我选择了 Javalin,因为它足够简单,启动快,不依赖 Servlet 容器。如果你更熟悉 Spring Boot,用 Spring Boot 也完全可以,只是启动时间和内存占用会高一些。
4.2 定义核心接口与数据模型
第一步是定义核心接口。我创建了以下几个关键接口:
public interface ModelInvoker { ModelResult invoke(ModelRequest request); CompletableFuture<ModelResult> invokeAsync(ModelRequest request); boolean supports(String modelId); }ModelRequest包含模型标识、输入文本、参数映射和超时时间。ModelResult包含输出文本、token 使用量、耗时和状态码。
然后是编排相关的接口:
public interface StepExecutor { StepResult execute(Step step, TaskContext context); }TaskContext是一个贯穿整个任务执行过程的上下文对象,用来传递中间结果和共享状态。
数据模型方面,Task、Step和Execution都用简单的 POJO 表示,字段尽量精简。我建议不要过早引入 ORM 框架,用 JDBC 直接操作数据库反而更可控。
4.3 实现一个最简单的模型适配器
以某类通用大模型 API 为例,适配器的核心逻辑是:把ModelRequest转换成 API 要求的 JSON 格式,发送 POST 请求,解析响应。
public class GenericModelAdapter implements ModelInvoker { private final HttpClient httpClient; private final String apiKey; private final String endpoint; @Override public ModelResult invoke(ModelRequest request) { String requestBody = buildRequestBody(request); HttpRequest httpRequest = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .timeout(Duration.ofMillis(request.getTimeoutMs())) .build(); // 发送请求并解析响应 // ... } }这里的关键点是错误处理。HTTP 状态码非 200 时,要区分是客户端错误还是服务端错误。客户端错误通常不需要重试,服务端错误可以重试。另外,响应体可能很大,解析时要注意内存占用,必要时使用流式解析。
4.4 编排一个两步任务:提取信息再生成回复
假设我们要实现一个任务:用户输入一段文本,先让模型提取其中的关键信息,再根据关键信息生成一个结构化的回复。
在 BizBuddy 中,这个任务定义为两个 Step。第一个 Step 的类型是MODEL_CALL,输入是用户文本,输出是提取的关键信息。第二个 Step 的类型也是MODEL_CALL,输入是第一个 Step 的输出,输出是最终回复。
编排引擎按顺序执行这两个 Step。第一个 Step 完成后,把结果放入TaskContext。第二个 Step 从上下文中读取第一个 Step 的结果,作为自己的输入。
这个过程中,上下文传递是关键。我建议在TaskContext中使用一个Map<String, Object>来存储中间结果,key 是 Step 的输出名称,value 是实际结果。这样不同的 Step 可以通过名称来引用彼此的输出,解耦效果好。
4.5 接入日志与监控
日志和监控是生产环境必备的。BizBuddy 在每个关键节点都会记录日志:任务开始、Step 开始、模型调用开始、模型调用结束、Step 结束、任务结束。日志中包含任务 ID、Step ID、执行 ID、耗时和状态。
监控方面,我建议暴露以下指标:任务总数、成功任务数、失败任务数、平均任务耗时、模型调用总数、模型调用平均耗时、token 总消耗量、成本总额。这些指标可以通过 Micrometer 采集,然后输出到 Prometheus 或类似的监控系统。
实操心得:日志中不要记录完整的模型输入输出,因为可能包含敏感信息。我一般只记录输入输出的哈希值和长度,需要排查问题时再通过执行 ID 去数据库中查询完整内容。
5. 常见问题与排查技巧实录
5.1 模型调用超时但业务侧未感知
这个问题通常是因为超时控制没有正确传播。比如编排层设置了 30 秒超时,但适配器内部的 HTTP 客户端没有设置超时,导致底层连接一直挂起。解决方案是确保每一层都有超时设置,并且适配器的超时时间要小于编排层的超时时间。
另一个可能的原因是线程池配置不当。如果编排引擎使用线程池来执行 Step,而线程池的队列满了,新的任务会排队等待,导致实际执行时间超过预期。我建议给线程池设置合理的队列大小和拒绝策略,并且监控队列深度。
5.2 并发场景下配额计算不准确
前面提到过,并发扣减配额需要使用原子操作。如果使用数据库,可以用UPDATE quota SET used = used + ? WHERE used + ? <= total这样的语句,通过影响行数来判断是否扣减成功。如果使用 Redis,可以用DECRBY命令,然后检查剩余值。
还有一个容易被忽略的点:预扣和实扣。模型调用前,我们不知道实际会消耗多少 token,所以只能预扣一个估算值。调用完成后,再根据实际消耗调整。如果预扣值过大,会导致配额利用率低;如果预扣值过小,可能导致超支。我的经验是,预扣值设置为历史平均消耗的 1.2 倍,调用完成后立即释放多余部分。
5.3 内容过滤误拦截正常请求
内容过滤的误拦截通常是因为规则太宽泛。比如一个关键词“密码”,如果直接匹配,可能会拦截“密码重置成功”这样的正常回复。解决方案是使用更精确的匹配模式,比如结合上下文或者使用正则表达式的边界匹配。
另一个原因是过滤规则没有区分输入和输出。有些词在输入中是敏感的,但在输出中可能是正常的。我建议为输入和输出分别配置规则集,并且定期审查拦截日志,调整规则。
5.4 任务状态不一致或丢失
任务状态不一致通常是因为状态更新没有事务保护。比如任务执行完成后,先更新了任务状态,然后写入执行记录,如果第二步失败了,就会出现状态不一致。解决方案是把状态更新和执行记录写入放在同一个事务中,或者使用最终一致性的方案,通过定时任务补偿。
任务丢失则可能是因为任务队列没有持久化。如果 BizBuddy 重启,内存中的任务队列就丢了。对于企业级场景,我建议把任务队列持久化到数据库或消息队列中,确保重启后能恢复。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型调用超时 | 适配器超时未设置 | 检查 HTTP 客户端配置 | 设置适配器超时小于编排层超时 |
| 配额计算不准 | 并发扣减未加锁 | 查看配额日志 | 使用原子操作或分布式锁 |
| 内容误拦截 | 规则太宽泛 | 查看拦截日志 | 细化规则,区分输入输出 |
| 任务状态丢失 | 队列未持久化 | 检查重启后任务数 | 持久化任务队列 |
| 成本核算偏差 | 单价配置过期 | 对比账单 | 动态更新单价配置 |
6. 一些关于扩展与演进的个人体会
BizBuddy 的最小可用版本跑通之后,我陆续做了一些扩展。比如增加了对流式输出的支持,让模型生成的内容可以逐字返回给前端;增加了对多模型并行调用的支持,让同一个任务可以同时调用多个模型,然后选择最优结果;还增加了对自定义插件的支持,允许业务方在 Step 执行前后插入自己的逻辑。
这些扩展都没有改动核心架构,只是在现有接口上增加实现。这让我更加确信,好的架构不是设计出来的,而是演进出来的。一开始不要追求大而全,先把核心链路跑通,然后根据实际需求逐步添加功能。
如果让我重新做一次,我会在早期就引入更完善的配置管理,把更多策略做成可配置的,而不是硬编码。另外,我会更早地考虑多租户隔离,因为企业级场景中,不同业务线的数据和配额需要严格隔离。
最后分享一个小技巧:在开发阶段,我建议实现一个“Mock 模型适配器”,它不调用真实的模型服务,而是返回预设的响应。这样可以快速测试编排逻辑,不消耗 token,也不依赖外部服务。这个 Mock 适配器在单元测试和本地开发中非常有用,强烈建议你一开始就加上。