纯Java构建企业级Agent Harness:架构设计与工程实践

发布时间:2026/10/26 14:52:26
纯Java构建企业级Agent Harness:架构设计与工程实践

1. 为什么企业级 Agent 平台我选了纯 Java

1.1 Python 生态那么强,为什么还要用 Java 硬扛

先说结论:不是Python做不了Agent Harness,而是企业级落地时,Java在权限模型、事务一致性、审计合规、存量系统打通这几个方向上有压倒性优势。

2025年做AI应用,默认的技术栈几乎都是Python + LangChain/LangGraph,稍微有追求一点的用LlamaIndex做RAG。我一开始也这么干过,原型阶段确实爽,写个Agent编排脚本用不了半小时。但一旦开始思考“这个Agent要给我司一百个业务部门用”“每个部门的数据库都不一样”“数据权限要按组织架构隔离”“每一次工具调用都要能追溯到人”的时候,Python那套方案的短板就露出来了:ORM天然弱、行级权限没有现成体系、事务边界要自己拿捏、审计日志得从零搭。而这些恰恰是我在Java生态里闭着眼都能写出来的东西。

BizBuddy就是在这个背景下立项的。它不是一个聊天机器人框架,也不是一个低代码流程引擎,而是一个承载企业内多个Agent的运行时平台——一个真正意义上的 Harness。Agent只管“思考”,但它的生老病死、它能调什么工具、每次调用花了多少钱、结果有没有被正确审计,全部由平台收口。

我这个选择当时在组里被质疑过:纯Java + Spring Boot + MyBatis Plus这套组合,是不是太“传统”了?我的回答是:我们要做的是企业级基础设施,不是毕业设计。AI能力恰恰应该是这个平台里一个可替换的模块,而不是平台本身。业务部门的稳定性要求是99.95%,这个数字跟模型演进没关系,跟基础设施有关系。

1.2 BizBuddy 到底解决什么问题

先梳理一下场景。企业内部真正需要的不只是“一个能聊天的Agent”,而是:

  • 让Agent能安全地调用内部API,比如查订单、改工单、发审批;
  • 让Agent执行多步任务时状态可追踪,挂了能重试,跑到一半超时能终止;
  • 让不同业务线有自己的Agent配置,但共享同一套工具注册、权限体系和计费系统;
  • 让每一次Agent的工具调用都有审计流水,出问题了能追溯到具体是哪条指令触发的。

这些需求拼在一起,就是一个 Agent Harness 平台。Harness这个词我这一年越用越觉得贴切——它不是Agent本身,而是Agent的“行车记录仪+刹车系统+方向盘”。模型负责驾驶,平台负责路况感知、路线规划和最关键的“出事了能停下来”。

BizBuddy的定位明确:面向Java技术栈为主的企业开发团队,让他们不需要去学Python那套异步生态,不引入额外的服务端组件,就能把内部能力快速Agent化,并且复用已有的Spring Cloud体系做治理。

1.3 平台的边界在哪里

定义边界能避免项目失控。BizBuddy不做模型训练、不做知识库内容生产(只做检索接入)、不限制模型厂商。它做的是编排、执行、管控、审计四件事。

有意思的是,后来我把这个定位总结给老板听,他说这不像Agent平台,像ESB换了个马甲。我说对,就是把当年SOA/ESB时代沉淀的那堆企业集成经验,用LLM重新做了一遍。企业级Agent平台本质上就是“LLM驱动的服务编排总线”,想通这一点,架构就清晰了。

用表格做个对比:

能力维度说明典型实现
编排多步任务的状态机流转、条件分支、循环自研状态机(Spring StateMachine太重,下面细说)
执行调用模型、调用工具、处理流式输出线程池 + 异步事件总线
管控权限、限流、审计、敏感信息脱敏Shiro + MyBatis拦截器 + AOP审计
统计Token消耗、成本、成功率、时延埋点上报 + 定时聚合任务

2. 整体骨架:Harness 平台的分层设计与关键模块

2.1 五层结构:从接入层到存储层

BizBuddy的整体架构我按五层来组织,这样每一层都能独立演进,尤其是模型层和工具层。下面用文字加表格描述,不画图,免得大家反过来吐槽我画得丑。

第一层:接入层。负责接收不同来源的任务请求:OpenAPI、消息队列、定时任务。这一层做的事情很简单——把外部请求统一转换成平台内部的AgentTask对象,然后丢进调度队列。

第二层:编排执行层。这是Harness的核心,里面包含三个引擎:会话引擎(维护多轮对话上下文)、任务引擎(跑状态机)、工具调用引擎(管理Function Calling的注册和执行)。

第三层:模型适配层。统一封装各家LLM接口,包括OpenAI兼容协议、国内几家主流的国产模型服务商。对外只暴露一个ChatClient接口,里面定义chat()、chatStream()、embed()方法。

第四层:工具与连接层。工具在这里分两类:一类是平台内置工具(查询天气、查日历、跑SQL等),另一类是通过MCP协议接入的外部工具。所有工具都走统一的注册中心和参数Schema校验。

第五层:存储与基础设施层。MySQL存任务、会话、审计流水;Redis做分布式锁和热点缓存;本地磁盘或对象存储存大体积文件。

2.2 核心领域模型:Agent、任务、会话、工具

这四个领域对象的建模,决定了整个平台的表达力。我磨了三个版本才定下来。

Agent是一个持久化实体,代表一个业务Agent的配置模板,包含:绑定的模型Profile、系统提示词、可用工具列表、最大迭代轮次、超时时间、负责人。Agent本身不持有会话状态,它只是“配方”。

AgentTask是任务的运行时快照,包含当前状态、关联的会话ID、执行的工具调用历史、Token累计消耗、异常信息。任务表是平铺的,一次多步推理只记一行主记录,工具调用细节放子表。

AgentSession是会话,核心是上下文管理。这里有个取舍:上下文是存在数据库里还是Redis里?我的答案是Redis存热数据,MySQL做持久化归档。因为对话上下文要支持模型恢复,宕机之后不能丢。

Tool是工具的抽象,它不关心实现是什么,只暴露三件事:可以被模型感知的描述、参数JSON Schema、执行入口。Java里对应一个接口,实现类各自处理具体逻辑。

2.3 为什么把“工具注册中心”放在最中间

这一节说说架构上我最后悔和最后悔没做的两件事。

最后悔的是第一版把工具调用逻辑散落在各个Agent配置里,结果同一个工具在不同Agent里的行为不一致。后来我把所有工具统一收口到工具注册中心——一个基于内存的Map + 数据库配置的二级结构。工具注册中心提供的核心能力是:按Agent能访问的工具白名单过滤、执行前鉴权、执行后记录。

工具注册中心放在中间层还有一个好处:模型的Function Calling列表可以动态生成。每次请求模型前,根据当前Agent的工具白名单、加上权限过滤,动态拼出tools参数。这样模型永远只能“看到”它能用的工具,从源头上减少越权调用。

关于“最后悔没做的”:从第一天就应该把敏感工具(比如执行SQL)和普通工具做物理隔离,放到独立的进程池里执行。第二版我才加上这个机制,代价是重构了工具调度模块。这个后面踩坑部分再展开。

3. 核心引擎:Agent 生命周期与任务编排的 Java 实现

3.1 状态机模型:从 PENDING 到 TERMINATED

Agent的一次完整任务,状态流转是这样的:

  • PENDING(已入队,等待调度)
  • RUNNING(模型正在推理,或工具正在执行)
  • WAITING_INPUT(需要人工确认或补充信息)
  • TOOL_CALL(Agent请求调用某个工具)
  • RETRYING(上一次执行失败,准备重试)
  • SUCCEEDED(正常结束)
  • FAILED(最终失败)
  • TERMINATED(被管理员或超时机制强制终止)

我用了一张数据库表agent_task_state记录状态流转历史,字段就五个:task_id、from_state、to_state、operator、remark。查问题的时候特别有用,能还原整个任务的生命线。

状态机的Java实现,第一版我用了Spring StateMachine,后来拆了。原因是Spring StateMachine太重,我们需要的是“状态流转+动作回调”,不是完整的FSM框架。最终用枚举 + 状态转移表 + 责任链回调搞定,不到200行核心代码。

public enum AgentTaskState { PENDING, RUNNING, WAITING_INPUT, TOOL_CALL, RETRYING, SUCCEEDED, FAILED, TERMINATED; private static final Map<AgentTaskState, Set<AgentTaskState>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(PENDING, EnumSet.of(RUNNING, TERMINATED)); TRANSITIONS.put(RUNNING, EnumSet.of(WAITING_INPUT, TOOL_CALL, SUCCEEDED, FAILED, TERMINATED)); TRANSITIONS.put(WAITING_INPUT, EnumSet.of(RUNNING, TERMINATED)); TRANSITIONS.put(TOOL_CALL, EnumSet.of(RUNNING, RETRYING, FAILED, TERMINATED)); TRANSITIONS.put(RETRYING, EnumSet.of(RUNNING, FAILED, TERMINATED)); } public boolean canTransitionTo(AgentTaskState target) { return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target); } }

所有状态变更都走统一的transition(taskId, from, to, operator, remark)方法,方法内部先校验合法性,再落库,再发布Spring事件。发布事件的好处是:审计模块、通知模块、指标模块各自监听自己关心的事件,互不干扰。

3.2 并发模型:线程池 + 队列 + 限流

Agent任务是IO密集型任务,绝大部分时间在等模型响应和HTTP调用。线程模型没必要上虚拟线程那么激进,一个调好参数的ThreadPoolExecutor足够。

我自己总结的线程池配置策略分享给大家:

  • 核心线程数 = CPU核数 × 2,业务里基本跑不满;
  • 最大线程数 = 核心线程数 × 4,防止突发流量全部打满;
  • 队列用有界队列,容量500,拒绝策略用CallerRunsPolicy——这很关键,不能让任务静默丢失;
  • 任务提交后立即返回Future,由执行器内部完成状态推进。

限流这块我用了两层:第一层是Redis + Lua脚本实现的令牌桶,限制平台的全局QPS;第二层是按具体模型商家的限流,因为各家模型的Tier和并发上限不一样,不能用一个全局阈值去撞别人的墙。

一个容易被忽略的问题:会话内多轮任务之间的并发控制。同一会话下的任务必须串行执行,否则上下文会交错。我用会话ID做一致性哈希分片,让同一会话的任务永远落进同一个线程队列。这个方案简单有效,不用引入分布式锁。

3.3 持久化:MyBatis Plus 实体建模与自动建表

存储层我用了MyBatis Plus,主要原因:团队熟,CRUD零成本,分页好用。但有个问题——Agent平台里配置类实体很多,手写建表SQL效率低、容易漏字段。两个办法解决:

第一,MyBatis Plus没有官方自动建表能力,网上有个思路是写一个启动时检查表结构的工具类,用TableInfoHelper.getTableInfo()反射实体类,比对数据库字段差异,需要新增字段时自动执行ALTER TABLE。生产环境我建议只做“提醒”,不做自动变更,让DBA审批后执行。

第二,内部封了一个基于Java实体类生成建表SQL的小工具。实体上写@BizTable注解,标注表名、表注释、字段注释。生成器启动时扫描包路径,输出CREATE TABLE IF NOT EXISTS语句并自动执行。对于Agent这种需要快速迭代元数据的场景,非常实用。

@BizTable(name = "agent_tool_registry", comment = "工具注册中心表") public class ToolRegistryEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; @BizColumn(comment = "工具编码", length = 64, notNull = true) private String toolCode; @BizColumn(comment = "工具名称", length = 128) private String toolName; @BizColumn(comment = "参数JSON Schema", type = "TEXT") private String paramSchema; @BizColumn(comment = "所属分组", length = 64) private String groupCode; }

这套方案帮我迭代了二十多张表,从没手工写过一条Agent相关的建表SQL。对独立开发者或小团队来说,启动时自动建表可以接受;大团队建议改成输出SQL脚本走审核流程。

4. 函数调用层:Java 反射注册 Tool 的完整实践

4.1 先理解 LLM Function Calling 的原理

Function Calling(很多厂商叫Tool Calling)的原理很简单:调用模型时,把工具列表以JSON Schema格式传给模型,模型根据用户问题决定“要不要调用某个工具,参数是什么”,然后模型返回一个结构化指令,而不是直接回答。

举个具体例子:用户问“北京今天适合穿什么衣服”,如果平台注册了get_weather工具,模型可能会返回:

{ "tool_calls": [ { "id": "call_123", "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\",\"date\":\"today\"}" } } ] }

平台拿到这个结果后,执行get_weather方法,再把结果作为消息回传给模型,模型综合工具结果生成最终回复。这就是Agent“会调用工具”的核心循环。

理解这个原理之后,你就明白Java侧的关键工作了:把Java方法的签名翻译成JSON Schema,让模型“看得懂”;再把模型返回的arguments转化成Java参数,让方法“调得动”。前者是序列化问题,后者是反序列化问题,各自都有坑。

4.2 用一个注解把 Java 方法变成 Agent 工具

我想要的效果是:开发同事写一个普通Spring Bean方法,加一个@AgentTool注解,方法自动出现在Agent的工具列表里。核心实现分三步。

第一步,定义注解@AgentTool,参数包括toolName、description、是否需要人工确认等。

第二步,启动时扫描所有Spring Bean,找出带注解的方法,解析参数名和类型,生成JSON Schema。

第三步,运行时,根据模型返回的toolCode找到对应方法,反射调用。

@Component public class OrderToolService { @AgentTool(name = "query_order", description = "根据订单号查询订单详情", requireConfirm = false) public OrderInfo queryOrder(@ToolParam(description = "订单号") String orderId, @ToolParam(description = "查询人ID") Long userId) { return orderMapper.selectById(orderId); } }

解析Java参数名有一个经典坑:Spring默认不保留方法参数名,ByteBuddy、ASM这类字节码工具能读,但更省事的方案是加-parameters编译参数,这样反射拿到的参数名就是真实方法参数名,不用依赖注解一一对应。

4.3 JSON Schema 生成与参数校验的坑

Java方法签名和JSON Schema之间不是完全对得上的,这里我踩了一地坑:

  • 类型映射:LocalDate、BigDecimal、枚举这些类型,模型不认知,必须序列化成字符串;
  • 嵌套对象:模型对复杂嵌套对象的arguments生成成功率显著下降,能拍平就拍平;
  • 必填参数:对于缺少必填参数的场景,不要硬解析,返回错误让模型重新调整。强行用默认值填充会让模型形成“可以不遵守契约”的坏习惯。

我的成品Schema生成器,优先按Javadoc的@param注释解析参数描述,没有注释就退化成参数名描述。实测下来,带完整中文注释的方法,参数生成准确率比裸参数名高出一大截——这也算Java开发的独特优势:多年养成的注释习惯,直接成为Agent时代的模型提示词素材。

参数校验用jakarta.validation那一套就够了,反射调用前先validate(),不通过就直接进入重试逻辑,别让脏参数污染内部系统。

4.4 流式响应:WebFlux 还是传统 Servlet

Agent平台的流式输出(一边生成一边打字)有几个方案,我分别说结论:

传统Servlet + SSE(SseEmitter)可以撑住小规模场景,代码写起来跟同步逻辑几乎一样,团队上手快。问题是线程模型是“一个请求占一个线程”,一个Agent任务可能要持续40秒,线程一直挂着。对100个并发以内的内部平台,没什么问题;但线程池一旦被流式请求占满,普通HTTP接口也会跟着遭殃。

WebFlux是正统的流式方案,响应式链路天然适合长连接。但WebFlux和现有Servlet生态(比如Spring Security老版本、一些Filter)兼容性总有一些摩擦,团队学习曲线陡。

我的取舍:对外接口全是Servlet + SSE,足够稳定;内部模型调用统一走响应式Flux<String>,由适配层负责转换为SSE输出。这样既享受了响应式在底层IO上的红利,又没有把整个Web层拖进响应式改造。记住一个原则:边界隔离比技术统一更重要。

5. LLM 接入与 MCP 扩展:多厂商抽象和 REST 转 MCP

5.1 多模型接入层的设计:别把自己绑死在一家

模型接入层是所有Agent平台最容易变成“夹生饭”的地方。最简单的方式是直接调某家模型的SDK,但一个企业级平台绝对不能这样。

我在BizBuddy里抽象了一个ChatClient接口,所有模型供应商实现同一个接口:

public interface ChatClient { ChatResponse chat(ChatRequest request); Flux<ChatChunk> chatStream(ChatRequest request); String providerName(); boolean supportFunctionCalling(); }

ChatRequest里包含messages、tools、temperature、maxTokens等通用参数。各家模型有各家的特殊参数,我设计了一个extParams的Map扩展字段,不塞进公共字段。Between多轮对话时,上下文由平台维护,而不是把历史消息一股脑丢给模型——这一点对控制Token成本很关键。

模型选择策略存放在Agent的配置里:model.provider、model.name、model.temperature等。后续还可以做路由策略:简单问题走便宜的快模型,复杂推理走更强的模型。这个自动路由我们还在打磨,目前线上是手动指定为主。

5.2 把 Spring Controller 快速包成 MCP Tool

MCP(Model Context Protocol)是这两年Agent生态里最重要的协议,相当于“AI界的USB-C接口”。它定义了Agent通过标准协议访问外部工具和数据源的方式。对于Java团队来说,最痛的点是:系统里全是REST接口,怎么快速变成MCP可调用的工具,而不是用Python重写一遍?

我的落地方式:用MCP Java SDK写一个适配器,通过注解把Spring MVC的Controller方法暴露成MCP Tool。

第一步,引入io.modelcontextprotocol.sdk的Java包,实现一个ToolProvider。

第二步,注册一个/mcp端点,让MCP Client能通过HTTP发现工具。SDK自带HttpServletBridge或WebMvcSseServer,选WebMvcSseServer跟Spring WebMvc兼容。

第三步,扫描已有的RestController,把每个@RequestMapping方法解析成一个McpTool,入参是GET请求的query参数或POST请求的body,返回结果是JSON。

@RestController public class McpEndpointController { private final McpServer server; @GetMapping(value = "/mcp", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter mcp() { return server.handleHttpRequest(request, response); } }

这一步做完,企业内部所有REST接口瞬间变成Agent可发现的工具,不用给每个接口写胶水代码。老系统接入Agent平台的速度从“按周算”变成“按天算”。

5.3 MCP 对 Agent 平台的价值

MCP对Agent Harness的好处不仅仅是“少写胶水代码”。它更像一个标准化边界:平台不必知道每个工具的HTTP细节,只需要维护一份MCP工具清单。工具的确切状态、认证方式、重试策略,都由提供方负责。

BizBuddy把工具分成两类,一类走本地注册(上面说的@AgentTool注解方式),另一类通过MCP接入。本地注册适合响应延迟要求高、内部无跨系统需求的工具;MCP适合跨团队、跨系统共享的工具。MCP工具在平台的工具注册中心里也会有一条配置记录,但执行入口是一个通用的MCP调用适配器。

这样做的好处是:团队之间推送工具就像发布一个API文档。A系统把自己的订单工具打包成MCP Server,B系统的Agent平台配置一下工具源地址,马上就能用。

6. 企业级硬约束:数据一致性、行级权限和审计

6.1 分布式任务的数据一致性策略

Agent任务跨进程、跨系统,数据一致性该怎么保证?我的答案是:不强求强一致,核心链路用本地事务 + 事件最终一致。

任务推进方向上的关键操作有两类:一类是状态流转(必须准确),一类是副作用(工具调用效果)。如果状态入库成功但工具调用失败,整个任务可以重试;如果工具调用成功但状态入库失败,问题就比较麻烦——工具可能被重复调用。

我的方案是给工具调用记录加requestId,在业务层做幂等。所有可重复执行工具,入参必须包含requestId,调用方校验这个requestId是否已经成功过。对于非幂等操作(比如发起付款),我强制要求requireConfirm = true,必须人工确认后才能执行,天然规避重复执行问题。

数据库中用一张tool_invoke_record表记录每一次调用的请求唯一键、工具编码、入参摘要、返回值摘要、耗时、状态。线上排查问题时,这张表就是“黑匣子”。

6.2 行级权限的落地:MyBatis 拦截器

企业级平台绕不开数据权限:同一个订单查询工具,A部门的人只能看到A部门的数据,B部门的人只能看到B部门的。Agent调工具,一样要遵守这个规则。

行级权限我用了MyBatis的拦截器实现——按租户/部门过滤,自动拼SQL条件。原理不算复杂:实现InnerInterceptor的beforeQuery方法,解析SQL,识别主表别名,拼接AND dept_id = ?。

要注意的是,拼接条件不能影响原生SQL语义。比如原SQL里有子查询,只能改最外层的主表过滤,改错了就是数据泄漏事故。我的实践是:工具方法内部显式声明数据权限字段,由平台上下文注入当前用户所属部门,而不是让工具方法自己去读用户信息。

@Component public class DataScopeInterceptor implements InnerInterceptor { @Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { // 获取当前Agent任务上下文中的部门ID,拼接SQL } }

这套方案的边界要讲清楚:它解决的是“同一工具,不同身份看到不同数据”的问题,解决不了“不同工具之间的权限隔离”问题。后者由工具注册中心的访问控制负责。

6.3 审计日志与 Agent 行为管控

AI平台最怕的就是“模型抽风”。我见过不止一次,Agent在奇怪的时间以奇怪的理由调用了某个工具。

审计日志必须在最底层拦截,不能依赖业务代码自觉。我的实现方案是AOP + 事件异步落库。定义@Auditable注解,标注在工具调用入口上,AOP切面记录:谁发起的任务、用的哪个模型、模型完整的响应、工具入参和返回值、耗时、成功失败。入库操作异步执行,用单独的监听器消费,不阻塞主流程。

敏感信息脱敏在审计链路里必须做:手机号、身份证、银行卡、密钥这类字段入库前脱敏。不要相信模型不会把敏感数据写进arguments——实际上模型非常热衷于把用户提供的所有信息原封不动传给工具,这也是Agent安全问题的高发区。

7. 踩坑实录:那些文档里不会写的细节

7.1 大模型返回的 JSON 不稳定

做过Agent的人都有体会:模型返回的JSON偶尔会多出几个换行、注释、甚至中英文标点混用,反序列化直接炸。

不要指望模型厂商修复,必须在平台层兜底。我的策略是三层防护:

第一层,严格模式解析,失败直接返回给模型,提示它按规范重新生成。这一层解决80%问题。

第二层,容错清洗。写了一个JSON修复器,处理最常见的错误:去掉注释、转义多余引号、补齐缺失的尾括号。用正则加字符串扫描实现,不引入重量级依赖。

第三层,fuzzy匹配。当JSON完全不可用时,从原始arguments字符串里按参数名+正则去提取关键信息。作为最后的兜底。

7.2 Token 成本统计的粒度

成本统计看着简单,实际上容易漏算。BizBuddy的成本统计分成三层:按Agent维度汇总月成本、按工具维度汇总调用次数、按业务部门维度汇总预算消耗。

这里有个容易被忽略的地方:上下文会被压缩、重写,模型返回的usage里的token数和实际计费不一定一致。所以我们统计成本时,以模型返回的usage为基准,不做二次估算。同时记录模型响应里的prompt_tokens、completion_tokens、total_tokens,明细粒度保存到每次模型调用。

成本统计和工具调用记录是分开的表,通过taskId关联。排查“哪个Agent在烧钱”的时候,一条SQL就能拉出来。

7.3 流程引擎与事务的冲突

这是我第一版上线时最严重的事故:一次任务里编排了5个工具调用,每个工具调用里都有事务。中途一个工具失败,要求回滚之前4个工具的执行效果——这根本不可能做到。因为工具调用的异构系统(外部API、消息队列、第三方服务)根本不在同一个事务里。

后来我彻底放弃“全链路回滚”的念头,改成补偿模式:

  • 每个工具实现Compensable接口,声明自己的撤销操作;
  • 任务失败时,由平台依次执行已完成工具的补偿动作;
  • 补偿本身失败只记录告警,不阻塞主流程。

这套方案比强回滚务实得多,至少能把大部分副作用清理干净。

7.4 工具调用超时的处理

工具调用超时是最容易被忽略的坑。以查询订单为例,如果内部订单系统响应要30秒,模型早就等超时了。我的经验是:Agent平台给工具设置的超时时间一定要比模型侧的等待时间短。模型侧一般设置60秒等待响应,那我工具执行就设30秒,留出余量做重试。

超时的工具分两类处理:读操作(查询类)可以重试,写操作(修改类)不能直接重试,最多人工介入。重试要用指数退避,1s、2s、4s、8s四轮封顶,避免雪崩。

8. 实测表现与后续演进

8.1 一组压测数据

项目上线后,内部做过一轮压测,给几组参考数据(普通服务器配置,8核16G):

场景并发数P99响应成功率备注
单轮对话(无工具调用)502.1s99.9%模型耗时占大头
单轮对话(含1次工具调用)504.3s99.7%首字节输出较慢
多步任务(4步工具编排)2015.8s99.1%主要是模型串行调用
纯工具调用(不走模型)200180ms100%验证平台自身开销

平台自身开销(不含模型耗时)在单次工具调用链路里约占总时长的5%-10%,主要是参数校验、权限拦截、审计日志三个环节。压测时还发现,输出阶段SSE写日志是性能瓶颈,后来改成批量异步刷盘才解决。

8.2 后续想做的事

第一个方向是自动模型路由。按任务的复杂度动态选择模型,简单任务用便宜模型,复杂推理用强模型,把平均成本降下来。

第二个方向是Agent自动评测。每次Agent配置变更触发一个评测集回归,看任务成功率是否下降。没有这项能力,Agent配置改了之后有没有变笨,全靠运气。

第三个方向是轻量级沙箱,把不可信工具调用隔离到独立进程里执行,防止Agent执行了可疑代码拖垮平台主进程。

第四个方向是升级状态机,支持人工审批节点接入企业现有OA审批流,让Agent在关键操作面前学会“等人”。

做BizBuddy这一年最深的体会是:企业级AI应用,难的不是把模型接进来,而是把平台做成“即使模型出错了,系统也不会出大事”的样子。Agent Harness的“驾驭”两个字,本质上就是为不可预测的模型行为设计可控的轨道。这套以Java为中心的实践,在复杂企业环境里依然是最稳的底座之一。