1. 为什么要在 Java 生态里造一个 Agent Harness1.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、ExecutionBizBuddy 内部用三个核心数据结构来描述一次完整的任务执行。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); CompletableFutureModelResult 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中使用一个MapString, 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 适配器在单元测试和本地开发中非常有用强烈建议你一开始就加上。
阅读完成 · 觉得有帮助?