做企业级 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 ThreadLocalMapString, Object variables; // 流程变量 private ListMessage messages; // 与模型交互的消息序列 private ListToolCallRecord 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. 执行逐个调用工具记录结果 ListToolResult 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/WebSocketAgent 执行是一个可能持续几分钟甚至几小时的过程前端需要实时看到任务进展、当前状态、工具调用结果和审批请求。如果用传统的 HTTP 请求响应模式客户端只能不断轮询既浪费资源又做不到真正的实时推送。BizBuddy 的对外接口采用反应式 Web 栈消息推送优先使用 SSEServer-Sent Events。SSE 胜在实现简单、基于标准 HTTP、天然兼容现有网关和负载均衡特别适合“服务端向客户端单向推送事件流”的场景。需要双向交互时比如用户在控制台里临时打断 Agent 并输入新指令再升级到 WebSocket。这个选择避免了一上来就背上全双工维护的复杂度。三种方案对比如下通道适用场景优点缺点HTTP 轮询低频同步调用实现最简单实时性差浪费资源SSEAgent 事件流推送标准协议自动重连仅服务端到客户端单向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.finishedCounter任务启动/完成数用于吞吐统计agent.run.roundsDistributionSummary每个任务实际循环轮数agent.tool.durationTimer工具调用耗时分布agent.tool.error.rateGauge工具调用失败比例agent.llm.token.usageCounter累计输入/输出 Token 数按模型维度打标签agent.approval.waiting.durationTimer审批等待时长判断流程是否卡住日志层面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 底座我的建议是先把状态机和审计做扎实再考虑怎么接入更厉害的模型。这顺序不能反反了后面大概率要返工。