做企业级 Agent 平台的这几年,我最大的感受是:难点根本不在“模型有多会推理”,而在你愿不愿意让它直接操纵业务系统。BizBuddy 这个项目,就是我用纯 Java 从零搭起的一个 Agent Harness 平台——把 Agent 的执行循环、工具调用、状态持久化、人工审批和审计日志全部纳入一个可控的底座。很多人看到“Agent Harness”第一反应是“又是个套壳聊天工具”,但实际它解决的是更痛的问题:业务系统里跑着的 Agent,凭什么能执行某个接口、调用某条数据、触发某个流程,出错了怎么回滚,用户审批到一半断线了怎么继续。这篇文章不做原理空谈,直接讲我在 BizBuddy 里的设计取舍、核心模型、踩坑记录和压测数据,适合正在设计企业级 Agent 底座的后端工程师、技术负责人,也适合想搞明白“Agent 平台到底在做什么”的业务同学。
1. 为什么我坚持用纯 Java 做 BizBuddy:五个硬理由
1.1 从“接模型脚本”到“Agent 产品”,中间隔着一条治理鸿沟
我见过太多团队做 Agent 的方式:写一个 Controller,循环调用模型接口,拿到结果后拼字符串返回。Demo 阶段这样做确实快,几个小时内就能看到模型“自己调用工具”的效果。但一旦要上生产,问题就全出来了:任务跑到一半进程挂了怎么办?工具调了两次导致重复扣款怎么处理?模型被某个恶意 prompt 引导去调用敏感接口谁负责?这些都不是“模型能力”问题,而是工程治理问题。
BizBuddy 定位就是一个“运行底座”,英文里叫 Harness 很形象——给 Agent 套上缰绳。它负责所有与模型智商无关的事情:任务状态管理、工具执行边界、审批流、重试与幂等、上下文裁剪、调用审计。模型的职责被压缩到“根据已有上下文决定下一步动作”这一件事上。这个拆分是整个平台设计的第一性原则。
1.2 纯 Java 的选型逻辑:稳定优先而不是热度优先
选纯 Java,不是因为我不会别的语言,而是基于四个非常具体的现实约束。
第一,团队存量系统几乎都在 JVM 上,Agent 要调的订单、库存、权限、审批接口,全部是 Java 服务。用纯 Java 做 Harness,可以最自然地复用内部 RPC 框架、序列化协议、配置中心和监控 SDK,不需要引入跨语言桥接层。第二,企业级平台对长时间运行的内存稳定性要求极高。JVM 经过这么多年的生产验证,在 GC 调优、线程调度、故障隔离方面有大量成熟的工程手段,这一点对常驻内存、需要维护大上下文状态的 Agent 服务来说非常关键。第三,纯 Java 的静态类型系统在“工具契约”这种强结构场景里优势明显。每个工具的参数、返回类型、错误码都是明确的数据结构,编译期就能暴露大量拼写和类型错误,省掉 Python 项目里那套庞大的运行时校验逻辑。第四,供应链维护更可控。Java 生态里核心依赖长期稳定,不会因为某个学术性质的小包升级导致框架行为漂移。
当然,我也承认这个选择有代价:写起来比脚本语言啰嗦,Agent 相关的算法原型迭代不如 Python 生态快。为了对冲这个短板,BizBuddy 把“可插拔”做得很彻底,模型调用层是 SPI 接口,任何语言写的模型网关都可以通过 HTTP 接入,平台本身并不把自己锁死在某个模型厂商上。核心判断是:企业底座拼的是确定性,不是炫技速度。
1.3 什么场景下不建议用纯 Java 实现 Agent 平台
写到这里必须泼一盆冷水。如果你只是做一周以内的概念验证,或者想快速验证“这个任务能不能用 Agent 自动化”,直接用现成的轻量编排脚本更合适。纯 Java Harness 的启动成本包括领域建模、状态机、权限模型、审批流程和可观测性,这些全部做完才能体现出优势,小项目根本等不到那一刻。我见过团队在 PoC 阶段就套了一个重量级平台,结果大部分时间在改平台配置而不是验证业务逻辑。
另外,如果你们的 Agent 任务主要是“文本转换”“资料总结”这类无副作用、无状态、无外部系统调用的场景,也不需要 Harness,一个带重试的异步任务就够。Harness 的复杂度只有在“Agent 的动作会产生业务副作用”时才值得。
2. 总体架构与状态机设计:先有骨架,再谈智能
2.1 六层分离:BizBuddy 的模块边界
BizBuddy 的整体架构没有追求微服务化,而是把边界划在模块层。模块划分的原则是“每一层都只依赖下层接口,不跨层调实现”。从上到下分别是接入层、编排层、工具层、记忆层、存储层和治理层。
接入层负责对外暴露 REST / SSE / WebSocket 接口,接收任务创建请求并把事件流推给前端控制台。编排层是核心引擎,包含规划器、执行器、观察器和守卫器四个组件,它们共同驱动一次 Agent 任务从开始到结束。工具层维护工具注册表、参数校验和调用执行;记忆层管理上下文窗口、摘要压缩和向量检索;存储层负责任务快照、事件日志和审计流水;治理层则横切在上面几层之上,提供限流、熔断、权限校验和指标采集。
这样分层的直接好处有两个。一是每个 Agent 任务可以在一个进程内跑完全流程,减少跨服务调度带来的延迟和失败点;二是出了故障能快速定位到底在哪一层。比如模型超时是编排层重试策略的问题,工具返回报错是工具层的异常归一化问题,事件丢失是存储层写入策略的问题,每一类问题都有清晰的归属。
2.2 领域模型:一个任务实例到底要记录哪些东西
Agent 任务的核心实体,我设计了四个:AgentDefinition 描述一个 Agent 的静态配置;AgentTaskRun 是一次具体执行;ToolCallRecord 记录每次工具调用的输入输出;ContextEntry 管理上下文条目。下面是一个简化版的任务实体的核心字段,实际项目中还加了更多扩展字段,但骨架不变:
public class AgentTaskRun { private String runId; // 全局唯一,业务幂等键 private String agentId; // 关联 Agent 定义 private String tenantId; // 租户隔离维度 private String initiatorId; // 发起人,审计用 private AgentTaskStatus status; // 当前状态 private int rounds; // 已经执行的循环轮次 private int maxRounds; // 上限,防止死循环 private ThreadLocalMap<String, Object> variables; // 流程变量 private List<Message> messages; // 与模型交互的消息序列 private List<ToolCallRecord> toolCalls; // 本轮所有工具调用 private Instant createdAt; private Instant updatedAt; private Instant completedAt; }设计时有一个很容易被忽略的点:messages不是只存“发给了模型的 prompt”,而是存一个不可变的消息序列,模型的回复、工具调用的结果、系统注入的提示,全部分开记录。这样做的目的是支持“重放”。生产环境里经常需要回答“刚才这个 Agent 为什么调了那个工具”,如果没有完整消息序列,事后根本没法复盘。即使存储成本高,也必须保留。
另外,任务实例的状态变化必须通过状态机统一流转,禁止业务代码里随手改状态。BizBuddy 的状态定义如下:
public enum AgentTaskStatus { PENDING, // 已创建,等待调度 PLANNING, // 正在生成行动计划 EXECUTING, // 正在执行工具调用 WAITING_FOR_APPROVAL, // 等待人工审批 RETRYING, // 失败后按策略重试 FINISHED, // 正常完成 FAILED, // 不可恢复失败 TERMINATED // 被人为停止 }状态机的引入让平台获得了两个关键能力:断点恢复和权限控制。某个任务在 WAITING_FOR_APPROVAL 状态下挂机两小时,管理员可以直接查看所有处于该状态的任务,按租户批量处理。如果只是用 if/else 加字符串 flag,这种平台级能力实现起来会非常痛苦。
2.3 上下文持久化与事件溯源
Agent 任务和传统接口调用最大的区别是“有记忆”。但记忆如果全放内存,一重启就没了;如果全存数据库,每轮都全量读,性能又扛不住。BizBuddy 的折中方案是:主存储使用事件溯源,所有状态变更都追加为不可变事件,同时周期性生成任务快照。恢复时从最近快照开始,重放快照之后的事件。
这里我踩过一个特别深的坑:最初把整个消息序列直接塞进关系型数据库的 JSON 字段里,结果任务轮次超过十轮后,每次加载都要反序列化几千行,耗时直线上升。后来改成事件表 + 快照表分离,事件按 runId 归档,快照每次覆盖写,读取性能才稳定下来。
上下文管理同样是记忆层的重要职责。模型输入有长度上限,但工具调用结果经常很长。BizBuddy 的策略是给每个工具结果加一个“保留窗口”:最近 N 条完整保留,更早的做摘要压缩。摘要不是直接把文本截断,而是调用一次轻量模型生成结构化摘要,并保留关键数字和结论。这块如果做得不好,Agent 会在长任务中途“失忆”,反复问用户同一个问题。
3. Agent 执行循环与工具调用落地
3.1 规划—执行—观察:主循环的工程化实现
BizBuddy 执行 Agent 任务的核心是一个命令循环,本质上就是经典的“规划—执行—观察”模式。循环的结构非常直接:
while (run.isActive() && run.getRounds() < run.getMaxRounds()) { // 1. 规划:让模型基于当前上下文生成下一步动作 Plan plan = planner.plan(run); // 2. 守卫:检查动作是否在权限范围内,是否触发审批 GuardDecision decision = guard.check(plan); if (decision == GuardDecision.NEED_APPROVAL) { run.waitForApproval(approvalRequestId); approvalEvent.await(); // 暂停,等待事件恢复 continue; } // 3. 执行:逐个调用工具,记录结果 List<ToolResult> results = executor.execute(plan, run); // 4. 观察:把结果放回上下文,进入下一轮 observer.observe(run, results); run.incrementRounds(); }这段代码看似简单,真正工程化后每一行都有讲究。比如plan不是模型返回的原始文本,而是经过结构化解析后的动作序列;guard.check不是一个布尔判断,而是包含白名单、角色权限、敏感操作标记的复杂规则链;approvalEvent.await()也不是线程 sleep,而是让出执行资源挂起任务,等审批结果通过事件总线再恢复。
关于并发模型,我最终选了 Java 虚拟线程。每个 AgentTaskRun 用一个虚拟线程承载主循环,好处是代码可以保持同步阻塞风格,可读性高,不用切成复杂的异步回调。但要特别注意:虚拟线程最好别用来执行 CPU 密集任务,也不要在里面直接阻塞共享的 IO 线程池。BizBuddy 的做法是虚拟线程负责编排逻辑,真正的工具调用放到一个独立的 IO 线程池,防止某个第三方工具响应慢拖垮整个平台的调度。
3.2 工具调用协议:契约、超时与幂等
工具是 Agent 接触业务系统的唯一通道。BizBuddy 对工具的定义严格遵循“先契约后实现”:每个工具必须提供名称、描述、JSON Schema 参数定义、权限标签、超时时间和是否幂等。
模型输出的工具调用参数经常是不完整甚至非法格式。平台必须在真正执行前做一次强制校验,而不是把脏数据直接传给业务接口。这个校验用 JSON Schema 实现,校验失败时返回一个结构化错误给模型,让模型自己修正参数。我见过不少 Agent 项目跳过这步,结果就是线上出现大量因为参数缺字段导致的调用异常。
超时是另一个必须显式设计的地方。模型可能会让 Agent 连续调用十个工具,如果每个工具默认 30 秒超时,一个循环最坏要五分钟。BizBuddy 的默认工具超时是 5 秒,核心交易的写操作单独配置,不允许与只读查询混在一个策略里。此外,每个工具调用都生成一个幂等键,由平台在发起调用前写入请求头。这个键的作用是防止网络重试导致业务重复执行。举个例子,Agent 调用“创建订单”接口,第一次调用超时了,重试时带上同一个幂等键,业务方就能识别出这是同一笔请求,直接返回原订单结果。
再强调一个很容易漏的点:工具结果要截断和脱敏。模型会把工具结果继续用于推理,一个返回几万行数据的查询接口,不可能全部塞进上下文。平台会在结果进入观察器之前做精简,只保留前若干条记录和必要统计值;涉及身份证号、手机号等敏感字段的内容,默认脱敏,除非该工具明确声明允许模型读取明文。
3.3 人工审批:企业级 Agent 和玩具 Agent 的分水岭
企业敢不敢让 Agent 真正去做事,很大程度上取决于有没有“人在环上”的刹车。BizBuddy 的审批机制并不复杂,但它是用得最多的功能。每个工具可以配置影响级别,低风险自动放行,高风险必须人工审批。
审批和普通任务调用的区别在于,审批期间任务必须能安全挂起。实现上,平台把任务从 EXECUTING 状态推进到 WAITING_FOR_APPROVAL,持久化当前快照,然后发布一个“等待审批”事件。审批人通过控制台查看模型打算执行的工具、参数和理由,选择通过、拒绝或者修改参数后通过。事件总线产生对应结果,任务状态机收到事件后恢复执行。
这里有一个设计细节非常关键:审批通过后,不应该把旧计划原封不动继续执行,而应该先让模型“观察”审批结果再重新规划。因为审批等待期间,业务状态可能已经变化,比如库存被扣减了,这时候继续执行旧计划会产生错误。BizBuddy 在恢复节点插入了一条“审批结果反馈”消息,模型看到反馈后自己决定下一步,比平台硬编码“直接执行剩余动作”安全得多。
4. 传输协议、事件流与集成设计
4.1 接入层为什么用 WebFlux + SSE/WebSocket
Agent 执行是一个可能持续几分钟甚至几小时的过程,前端需要实时看到任务进展、当前状态、工具调用结果和审批请求。如果用传统的 HTTP 请求响应模式,客户端只能不断轮询,既浪费资源,又做不到真正的实时推送。
BizBuddy 的对外接口采用反应式 Web 栈,消息推送优先使用 SSE(Server-Sent Events)。SSE 胜在实现简单、基于标准 HTTP、天然兼容现有网关和负载均衡,特别适合“服务端向客户端单向推送事件流”的场景。需要双向交互时,比如用户在控制台里临时打断 Agent 并输入新指令,再升级到 WebSocket。这个选择避免了一上来就背上全双工维护的复杂度。三种方案对比如下:
| 通道 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| HTTP 轮询 | 低频同步调用 | 实现最简单 | 实时性差,浪费资源 |
| SSE | Agent 事件流推送 | 标准协议,自动重连 | 仅服务端到客户端单向 |
| WebSocket | 控制台双向交互 | 全双工,低延迟 | 连接管理、心跳更复杂 |
4.2 事件协议设计:必须支持重放和断点续传
SSE 推送最怕的是连接断开后丢事件。BizBuddy 设计了一套带序号的事件协议,核心思想是事件不可丢、顺序不能乱。每个事件都包含以下结构:
{ "id": "evt_000123", "runId": "run_82913", "type": "tool.executed", "timestamp": "2025-01-15T10:30:12.000Z", "data": { "toolName": "order.create", "status": "success", "summary": "订单已创建,单号 20250115-001" } }事件 id 是严格递增的。客户端收到事件后会记录自己消费到的最大 id,重连协商时把 lastEventId 带给服务端,服务端从断点继续推送。服务端这边必须有一个事件存储组件,不能只依赖 SSE 连接的内存缓冲。一旦事件落库,重放、审计、排查都有了数据基础,这个投资是值得的。
事件类型我分成几类:生命周期事件(任务创建、状态变更)、执行过程事件(规划输出、工具调用开始/结束)、审批事件(等待审批、审批结果)、异常事件(重试计划、失败原因)。前端拿到这些事件渲染出任务时间线,后端排查问题时也能按时间轴复盘。
4.3 与业务系统的三集成模式
BizBuddy 不可能独立运行,它必然要和订单、CRM、消息、审批等系统打交道。工具层已经解决了“调用具体接口”的问题,但集成模式本身需要提前约定。我实践的三种主流模式是 REST 同步调用、消息队列异步投递、事件总线订阅。
同步写操作用 REST,平台充当普通调用方,必须处理好超时和重试;适合业务响应即时、结果需要回到模型上下文的场景。异步任务用消息队列,Agent 发指令后不等待结果,通过回调事件把结果收集回来,适合发通知、触发数据处理这类不需要即时返回的副作用。事件总线订阅则用于平台自己感知外部系统状态变化,比如订单状态变更后,Agent 自动调整后续计划。
多租户隔离是集成设计的暗线。每次调用业务接口时,平台都必须向下游传播租户身份和操作者身份。BizBuddy 在内部定义了一个身份上下文对象,从入口请求一路传递到工具层,由工具在执行前注入到目标系统的鉴权头。没有这一层,Agent 就会变成一个跨越租户边界的“万能账户”,这在企业环境里是绝对不能接受的。
5. 企业级治理:可观测性、安全与限流
5.1 可观测性:没有指标就谈不上治理
Agent 平台的可观测性比普通 Web 服务更需要细化,因为一个任务涉及多个决策点、多次模型调用和工具调用。BizBuddy 在启动时通过 Micrometer 暴露的核心指标如下:
| 指标 | 类型 | 说明 |
|---|---|---|
agent.run.started/agent.run.finished | Counter | 任务启动/完成数,用于吞吐统计 |
agent.run.rounds | DistributionSummary | 每个任务实际循环轮数 |
agent.tool.duration | Timer | 工具调用耗时分布 |
agent.tool.error.rate | Gauge | 工具调用失败比例 |
agent.llm.token.usage | Counter | 累计输入/输出 Token 数,按模型维度打标签 |
agent.approval.waiting.duration | Timer | 审批等待时长,判断流程是否卡住 |
日志层面,BizBuddy 强制输出结构化 JSON 日志,每个 Agent 任务都有一个全局 TraceId,从 HTTP 入口到工具调用全程透传。决策记录的日志尤其重要,必须包含“模型当时看到了什么信息”“模型决定调用什么工具”“工具最终返回了什么摘要”。这三个信息合在一起,才能回答用户最常见的投诉:“它怎么突然做了这件事?”
5.2 安全设计:Agent 的最小权限原则
Agent 本质上是“软件机器人”,它的权限应该比人类操作者更小,而不是更大。BizBuddy 采用三层权限模型:第一层是工具级白名单,Agent 定义时明确允许调用哪些工具,未授权的工具直接从规划阶段被守卫拦截;第二层是参数级约束,比如某个工具允许调用,但禁止传入金额超过一定阈值的参数;第三层是上下文敏感控制,某些工具只有在特定业务条件下才允许被 Agent 自动执行,其他情况一律转人工审批。
审计日志是安全设计的最后闭环。BizBuddy 为每一次工具调用生成一条不可篡改的审计记录,包括操作者身份、租户、Agent 版本、调用时间、入参摘要、出参摘要、审批判决结果。这些审计数据独立存储,不能写在业务库同一个事务里,避免业务回滚把审计记录也带走了。权限模型和审计必须从第一天就做,后期补会非常痛苦。
另外必须强调:密钥管理不能走捷径。生产环境中,模型可能在任何一轮收到包含密钥的系统提示,绝不能把数据库密码、第三方系统密钥放进上下文。BizBuddy 的密钥全部存放在独立的密钥管理模块,工具执行时由平台替 Agent 完成鉴权,模型只看到工具执行结果,永远看不到密钥明文。
5.3 限流、熔断与资源隔离:防止一个 Agent 拖垮全家
LLM 接口不是无限资源,不同服务商各有配额限制。BizBuddy 的限流分三层:全局令牌桶限制所有 Agent 对某个模型通道的总吞吐,租户级限流保证大租户不会挤占小租户,Agent 级限流防止某个失控任务的死循环烧光配额。配额用完后,平台不是直接报错,而是把任务塞回 PENDING 状态等待下一个令牌窗口,这样用户体验比看到 429 好很多。
针对模型服务商连续报错的情况,平台实现了熔断逻辑:连续 N 次 5xx 或超时后,自动打开熔断器,所有请求快速失败,避免故障扩散。熔断打开期间,正在执行的任务进入 RETRYING 状态,按指数退避等待恢复。
资源隔离方面,每个 Agent 类型默认使用独立的线程池和队列,任务量超过缓冲队列时直接拒绝新请求并返回明确错误。这样设计后,即使某个 Agent 的模型调用突然变慢,也不会把平台的公共线程资源全部拖死。
6. 测试策略与生产环境踩坑实录
6.1 三层测试体系:单元、集成与契约
给 Agent 平台写测试比普通服务难,核心原因是模型的输出不确定。BizBuddy 的解决方案是把模型调用抽象成一个接口,测试时注入一个可控的 LLM Stub,让它在特定输入下返回预设结果。这样状态机的每个分支都能用确定性测试覆盖。
集成测试重点验证工具调用的完整链路:从模型输出结构化动作,到参数校验、权限判断、工具执行、结果回写上下文。每个测试用例使用录制回放模式,把真实场景下模型返回的响应录制成 fixture,测试时重放,既保证了确定性,又保留了业务真实性。
契约测试用来保证工具 Schema 和下游接口的一致性。下游接口改了字段,契约测试会第一时间暴露参数不匹配问题。Agent 业务的价值链很长,没有契约测试,很容易出现“平台没坏、参数变了、调用全失败”的隐蔽故障。
6.2 生产环境四个经典故障案例
第一个教训是幂等键没有覆盖到全链路。早期版本只在平台内部生成了请求 ID,但下游业务系统没有把请求 ID 作为幂等键存储。后来一次网络抖动导致创建订单的工具被重试,产生了重复订单,虽然最后人工修正,但这件事让我把“每个写工具必须显式声明幂等策略”写进了工具注册规范。
第二个教训是上下文无限制增长。有个 Agent 每轮都会把一个长文本工具结果塞进消息序列,跑到第七轮时 Token 超限,任务直接失败。后来我们加了上下文压缩器和结果截断器,并且在指标里加了一个agent.run.context.overflow计数器,专门监控这种情况。
第三个教训是虚拟线程没有避开重量级同步调用。某个工具内部用了共享线程池做同步 IO,虚拟线程一旦阻塞在这个池上,实际是把有限的物理线程耗尽。排查了很久才定位到问题。最终的方案是工具调用统一走独立的异步客户端,虚拟线程只作为编排角色。
第四个教训是 SSE 断线重连丢事件。最初前端消费事件后没有记录 lastEventId,网络抖动一次,正在展示的审批弹窗就永远不出现了。补上事件序号消费进度的上报机制后,这类问题基本绝迹。
6.3 性能压测与调优参数参考
BizBuddy 在容器环境里做过一轮基准压测,单实例 8 核 16G,使用本地回环模拟模型响应时延约 1 秒。实测结果是:单个 Agent 任务实例并发 60 个左右时,P95 任务循环时延仍保持在 3 秒以内;当并发升到 200 时,任务排队严重,但平台本身没有出现 OOM。这个结果说明瓶颈通常在模型通道和下游业务接口,平台自身的编排开销占比很低。
基于这轮压测,我给出一组常用调优参考值,具体需要结合自己的下游性能二次调整:
| 参数 | 建议初始值 | 说明 |
|---|---|---|
| 单个 Agent 最大并发任务数 | 50 | 防止单一 Agent 独占公共资源 |
| 单任务最大循环轮数 | 30 | 超过即人工介入 |
| 工具调用默认超时 | 5 秒 | 写操作可单独调高 |
| 任务队列缓冲容量 | 2000 | 队列满时快速失败 |
| LLM 单通道 QPS 上限 | 按配额 80% 设置 | 预留令牌桶余量 |
最后说一个我的心路历程。刚做 BizBuddy 时,我总想着怎么把最新最强的模型塞进去,后来发现企业用户根本不关心模型有多聪明,他们只在意“它做的事是否符合预期、出问题能不能说清楚”。所以这个平台最花精力、回报也最大的部分,不是模型调用封装,而是状态机、审批流、审计日志和可观测性。如果你也准备做一个企业级 Agent 底座,我的建议是:先把状态机和审计做扎实,再考虑怎么接入更厉害的模型。这顺序不能反,反了后面大概率要返工。