我们团队花了大半年时间,把 AI Agent 的前期原型从 Python 搬到 Java,最后沉淀出了一个内部代号叫 BizBuddy 的 Agent Harness 平台。整个过程踩过的坑不少,但最核心的收获是:企业级 Agent 应用真正缺的不是“能调用大模型”的脚手架,而是一个能塞进现有后端体系、扛得住审计和业务隔离的“执行容器”。这篇文章就从头讲讲 BizBuddy 的设计思路和取舍过程,给准备在 Java 技术栈里落地 Agent 的团队一个参考。
1. Agent Harness 到底解决什么问题:一个被低估的中间层
1.1 别把 Agent Harness 当成又一个"调用 LLM 的工具包"
先明确一个概念。Agent Harness 是指用来承载、编排、管理 Agent 运行生命周期的框架层,它负责的不只是“把 prompt 发给大模型”,还包括工具调度、状态流转、上下文管理、鉴权审计、超时重试、并发隔离等一系列与业务强相关的事。在 Python 生态里,LangChain、LlamaIndex 这类东西做了不少 Harness 的工作,但一旦落到企业 Java 后端,问题就变了味。
举个具体场景。我们的电商业务需要一个智能客服 Agent,它要查订单、查库存、发起退款、给用户发消息。这些动作全部是 Java 现有系统里的接口,按照老办法,AI 侧只是“生成结果”,真正执行动作还得靠业务系统自己处理。但 Agent 应用恰恰相反,它需要自己根据用户意图主动调用业务动作,并且在一个任务里连续调多个。这种“自主执行 + 可追踪”的能力,是普通 SDK 给不了的,Harness 的核心就是把这套执行机制做扎实。
1.2 从"跑通 Demo"到"进生产链路",中间缺了什么
你能用 Python 脚本很轻松地写一个 Agent 循环:调模型、拿工具调用参数、执行函数、把结果塞回模型。但到了企业环境,问题一个接一个冒出来:
- 用户请求进系统后,Agent 是运行在 Servlet 线程里还是独立线程池?超时了怎么办?
- 每个工具调用都要有日志和审计,不能黑盒操作——谁来记录“Agent 替用户做了什么事”?
- Agent 要查库存,那权限怎么控制?不同用户看到的库存范围不一样。
- 工具返回的数据可能很大,上下文放不下,怎么压缩、怎么保留关键信息?
- 如果中间断线了,重试策略怎么设计?幂等性怎么保证?
这些问题不在“模型调用”这一层,而在“Agent 以什么身份、什么权限、按什么流程在系统里干活”这一层。这就是 Harness 平台的价值区。
1.3 为什么坚持纯 Java:不是研发情怀,是现实约束
选 Java 不是因为我们觉得 Java 比 Python 优雅,而是现实里 Java 后端的生态红利我们不想放弃。团队里大多数后端工程师的日常技术栈就是 Spring Boot + MyBatis + 各种中间件,把 Agent 编排部署在同一个进程里,意味着:
- 可以直接复用现有的用户鉴权、数据源、配置中心;
- 运维层面不需要额外维护一组 Python 服务,不用考虑两套日志、两套链路追踪、两套发布流程;
- 类型安全在复杂业务参数传递上有实打实的优势,编译期就能挡住不少低级错误;
- 业务团队如果后续需要扩展 Agent 能力,大概率会写 Java 而不是 Python。
所以与其说“纯 Java 是一个技术选型”,不如说它是一个团队和组织结构的决策。当然这也带来了不少麻烦,比如大模型调用生态没有 Python 丰富、JSON Schema 和类型映射需要自己打磨等,这些后文会展开。
2. BizBuddy 的顶层架构:面向 Java 后端习惯的 Agent 运行模型
2.1 三个核心抽象:会话、工具、执行引擎
BizBuddy 的设计没有刻意追求对称美,就是围绕三个核心抽象展开,它们分别对应 Agent 应用里“状态从哪里来”“能做什么”“怎么做”这三个问题。
第一个是AgentSession。它负责保存一次 Agent 任务从开始到结束的完整状态,包括用户消息、历史轮次、令牌计数、运行标志、上下文摘要等。Session 不一定长期驻留内存,可以是可序列化的状态对象,这样当任务超时或实例重启后,还能从存储里恢复会话继续执行。
第二个是ToolRegistry。它维护一个名称到工具的映射,工具元数据里包含描述、参数 JSON Schema、执行器、超时配置、权限标签等。BIzBuddy 用 Spring 来管理工具 Bean,这样工具可以注入业务 Service,天然复用事务管理能力。
第三个是ExecutionEngine。它是一个循环驱动器,负责按固定模式驱动 Session 和 ToolRegistry:调用模型、解析工具调用、分发到工具、回填观察结果、判断是否终结。Agent Harness 的骨架其实就是这个引擎循环。
2.2 一次 Agent 任务的完整生命周期
我建议从生命周期的角度去理解 BizBuddy 的架构,而不是从类图去理解。一个普通任务在 BizBuddy 里会经历这些阶段:
- 接收请求:
AgentController接收用户消息,同时从请求上下文解析 userId、角色、租户等身份信息。 - 创建会话:从
AgentSessionStore取会话或新建会话,绑定身份信息。 - 循环推理:执行引擎进入 ReAct 循环(Reason + Act)。大模型每轮要么输出最终答复,要么输出一个或多个工具调用指令,包含工具名称和参数。
- 工具执行与观察:引擎根据工具名称路由到对应执行器,带上从身份上下文提取的权限参数,执行结果作为下一轮的观察信息回填给模型。
- 循环结束:模型给出最终答案,或达到最大轮数/超时上限,引擎记录终止原因。
- 审计落库:整个执行轨迹以 JSON 字段写进审计表。
这里关键的一点是:工具的执行必须是真实业务代码,而不是模拟返回。Harness 的价值就是让“模型决定调用”和“业务真实执行”之间有一个安全的、可观测的桥。
2.3 和 Spring Boot 技术栈的集成方式
BizBuddy 的集成方式不是另起一个服务,而是做成一个 Maven 依赖,或者说是一个“框架”。业务系统引入依赖后,通过@EnableAgentHarness注解启动,扫包注册@AgentTool方法,同时把AgentConfig配置进现有配置文件。
这样做的好处是:
- 部署形态完全跟随现有微服务,不需要额外容器;
- 工具方法就是普通 Spring Bean 方法,事务、缓存、MQ 这些能力直接可用;
- 接入方不需要改架构迁移,只需要在业务方法上加一个注解。
这和很多团队用独立微服务跑 AI 的路径不同,BizBuddy 走的是“嵌入式 Harness”。独立服务的好处是隔离性更好,但代价是上下文传递、身份传递、数据链路都会变复杂。我们偏向前者,因为企业级落地第一步是让团队跑起来。
3. 核心实现:从零做一个可扩展的工具调用循环
3.1 用注解声明工具:绝大多数的 Agent 接入成本都在这
BizBuddy 对工具的定义走的是“声明式”路线。一个 Java 方法加上@AgentTool注解后,就会被扫描注册到 ToolRegistry。比如:
@Component public class OrderTools { @AgentTool( name = "query_order", description = "查询订单详情,参数 orderId 为订单号", permissionTags = "order:query" ) public OrderDetail queryOrder(@AgentToolParam(description = "订单号") String orderId) { return orderService.getDetail(orderId); } }这里的description非常重要,它是大模型决定“该不该用这个工具”的唯一依据。写描述的一个经验是:不要写实现细节,要写“这个工具能帮用户解决什么问题”。比如比较差的描述是“queryOrder(String orderId)”,模型根本不知道什么时候调用它;好一点的描述是“当用户询问某个订单的详细状态时使用,orderId 是订单号”。
@AgentToolParam注解定义的参数说明会被转换成 JSON Schema 传给模型。BizBuddy 在编译期没办法获得完整类型信息,所以我们用 Jackson 的ObjectMapper配合自定义 introspection 构建 Schema。需要注意的地方是:
- 基本类型、包装类型、String、List、嵌套 DTO 都要能反射;
required数组要基于非空参数标注生成;- 枚举类型要生成 enum 列表。
3.2 ReAct 循环的执行引擎,代码其实很短
ReAct 循环本身并不神秘,它的核心代码大约只有几十行。我们用一个ExecutionEngine来控制循环:
public AgentResult run(AgentSession session) { int step = 0; while (step < maxSteps) { LLMMessage response = llmService.chat(session.buildMessages()); if (response.hasFinalAnswer()) { session.appendAssistantMessage(response.getContent()); return AgentResult.success(session); } List<ToolCall> calls = response.getToolCalls(); if (calls.isEmpty()) { session.appendAssistantMessage("抱歉,我没能理解您的需求,请换一种方式描述"); break; } for (ToolCall call : calls) { ToolExecutionResult execResult = toolRegistry.execute( call, session.getIdentityContext()); session.appendToolMessage(call, execResult); } step++; } return AgentResult.stepLimitExceeded(session); }这段代码虽然短,但要支撑企业场景,还缺了不少东西:每个ToolCall都要有 traceId、执行耗时要采集、工具异常要记录并且给模型返回结构化错误信息、每次循环后要检查是否超过 Token 预算等等。真正难的是把这些“横切需求”揉进这个简短的循环里,同时不把代码搞成一团浆糊。
BizBuddy 的处理手法是把这些横切逻辑拆成ExecutionFilter,类似 Servlet Filter。比如:
TimeoutFilter:包装工具执行,超时则中断并返回错误消息;AuditFilter:记录 tool 入参和出参(可脱敏);TokenBudgetFilter:根据 token 计数决定是否截断上下文;RetryFilter:对 LLM 临时429/5xx做指数退避重试。
在执行引擎里,filterChain包裹了“调用模型→解析工具调用→执行工具→回填结果”的每一步,这让功能扩展不必改动主循环。
3.3 类型安全:Java 系 Agent 框架的护城河和紧箍咒
做纯 Java Agent 有个典型矛盾:一边是模型返回的 tool call 参数天然是 JSON 字符串,另一边是 Java 强类型方法希望接收强类型对象。BizBuddy 的解决方式是:每个工具执行器内部维护一个Function<JsonNode, Object>的反序列化器,该反序列化器基于方法签名生成,把JsonNode映射到参数对象。
比如上面的queryOrder(String orderId),它生成的映射逻辑就是把 JSON 里的orderId字段取出来,转成 String,再反射调用方法。参数如果是一个 DTO,则用 Jackson 反序列化成 DTO 实例。
反射调用本身成本不算高,但在高频场景下还是要做一层缓存。BizBuddy 在工具注册阶段就生成好一个MethodInvoker,缓存参数转换器和反射方法句柄(也就是java.lang.invoke.MethodHandle),这样运行时不用每次getDeclaredMethod。我们实测在吞吐较大的场景里,这个缓存能把工具分发的耗时减少到微秒级。
3.4 并发模型:Java 21 虚拟线程让阻塞 IO 不再蒙羞
企业级 Agent 的工具执行几乎全是阻塞调用数据库、HTTP、MQ。这些操作如果放在普通线程池里,浪涌的请求容易把线程池打满。BizBuddy 在 Java 21 上使用虚拟线程来处理 Agent 任务,思路是:“一个用户会话的任务对应一个虚拟线程”,它内部可以随意阻塞等待模型响应、等待工具执行,而底层调度会将这类阻塞挂起,释放物理线程给其他请求。
实现上并不复杂,关键是不要把虚拟线程扔到老式的synchronized阻塞或线程池嵌套里,否则会 pinned 到平台线程上,效果打折扣。我们的做法是:
- 接入层使用
ThreadPerTaskExecutor创建虚拟线程; - 数据库访问照用 MyBatis/Spring JDBC 的阻塞模型,没有做响应式改造;
- LLM 调用使用标准的 RestClient 阻塞写法;
- 并发限流仍靠信号量,而不是线程池。
这套模型下,单机同时挂几十个长时间运行的 Agent 会话是完全可行的,代价只是内存中保存了会话上下文。相比 Reactor/WebFlux 那种全链路异步方案,虚拟线程的最大优势是写起来像普通同步代码,这对 Java 后端团队太友好了。
4. 关键取舍:那些我踩过坑后不得不做的决定
4.1 大模型 SDK 选型,别神话"官方 SDK"
BizBuddy 最初接 OpenAI 兼容接口时,有同事建议直接用 Spring AI,理由是 API 简介、抽象完整。但实验下来,我们的体感是:Spring AI 的抽象层对于“对话直连”很好用,但一旦需要精细控制工具调用循环、自定义 System Prompt 策略,就会遇到很多需要绕过框架的地方。最终我们决定自己写一层很薄的 LLM 调用客户端,底层封装 OpenAI 兼容协议,上层暴露chat(messages, tools)这个方法。
这样做的代价是要自己解析 SSE 流式输出,自己处理 token 统计,但好处是对工具调用循环有绝对控制力。对企业级 Harness 来说,控制力比省代码重要。
4.2 工具调用协议:用原生 Function Calling 还是 MCP
MCP(Model Context Protocol)最近讨论热度很高,它本质上是定义了一套“Agent 与工具服务通信”的标准,理论上可以让不同 Agent 框架接入同一个 MCP 工具库。我们研究过把工具层改成 MCP Client,把所有工具以 MCP Server 的形式暴露,但最终没有作为第一版的主选。
原因是:BizBuddy 内嵌在业务系统里,工具本身就是本地 Spring Bean,再走一层 MCP 协议绕回本地,等于自己给自己跨进程,增加了序列化和网络开销;而且 MCP Server 的大多数 Java SDK 还不成熟,资源规范、日志规范都在演进,强迫团队用不稳定的库没有意义。
当然 MCP 也不是完全没用。我们把 MCP 模式做成了一个可选扩展:如果团队有独立部署的外部工具服务(比如一个第三方文档检索服务),可以通过 MCP Adapter 接入 BizBuddy。本地业务方法走原生 Function Calling,外部能力走 MCP,两套并存。这个决定让我们避免了把核心引擎绑死在某个协议上。
4.3 流式响应:为什么选择 SSE 而不是 WebSocket
Agent 任务经常要花费十几秒甚至更久,用户显然不能傻等着一个 HTTP 响应。BizBuddy 第一版用的是 WebSocket 全双工推送,直接把事件逐字推到前端。后来遇到一个问题:很多企业内网会对 WebSocket 做额外检测和代理配置,而不稳定性比预想高。而且对大部分场景,客户端根本不需要上行通道,只需要一个单向推送。所以我们把流式响应全部切成了 SSE(Server-Sent Events)。
切到 SSE 后,接入端变得异常简单,前端就是一个EventSource,后端就是SseEmitter。BizBuddy 在控制器里做这样的事:
@PostMapping("/agent/chat") public SseEmitter chat(@RequestParam String sessionId, @RequestParam String message) { SseEmitter emitter = new SseEmitter(120_000L); agentService.submitTask(sessionId, message, emitter); return emitter; }SseEmitter在虚拟线程模型下工作得很好,唯一要注意的是设置合理的超时时间,并且主动处理客户端断开事件,避免虚拟线程资源泄漏。这个决策让运维省心很多。
4.4 会话状态:不迷信"全无状态",但也不把所有东西放内存
纯函数式的无状态设计很理想,但 Agent 任务天生是有状态的:历史对话、临时推理结果、工具调用中间状态。如果严格无状态,每次请求都要回放所有工具调用历史让模型重新计算,Token 开销和时延都会爆炸。BizBuddy 最终采用了“内存优先 + 持久化兜底”的方案:
- 进行中的会话放在内存
ConcurrentHashMap里,key 是 sessionId; - 会话在每轮结束时把压缩后的历史快照写入数据库;
- 节点重启后可以根据 sessionId 恢复快照,保证用户感觉“对话没断”。
这个取舍对单节点部署很友好。如果你要横向扩容,就需要引入 Redis 做会话存储,把内存 Map 换成带 TTL 的缓存。我们预留了SessionStore接口,Redis 实现就是切换一个 Bean 的事。
5. 生产环境里真实的坑与处理思路
5.1 审计日志不是"记录对话"那么简单
如果 Agent 有能力替用户在系统里执行操作(比如改订单、发消息),那审计日志就是合规的生命线。BizBuddy 的审计设计不是记录“用户说了什么,模型回答了什么”,而是聚焦“Agent 以谁的授权、调了哪个工具、传了什么参数、拿到什么结果”。
每条审计记录我们定义为:
- traceId:一次任务的全局链路;
- sessionId:会话维度;
- userId/tenantId:身份维度;
- toolName:被调用的工具;
- argsJson:工具入参(可能脱敏);
- resultJson:工具返回结果(可能截断);
- 执行耗时、成功失败状态、错误摘要。
表格建了agent_audit_log,这个表的写入频率会是平时的几十倍,所以归档策略要提前规划。我们用分区表按月分区,同时把大文本字段(argsJson/resultJson)压缩后存储,保留 3 个月在线查询能力,更早的走冷备。
5.2 上下文窗口管理:不把什么都往模型里塞
工具返回结果是最容易让上下文失守的地方。一个订单详情对象序列化之后可能几千字,一套列表下来可能几万字,直接往对话历史里塞,第二轮就超 Token 预算了。
BizBuddy 的策略分三级:
- 工具返回值经过摘要器处理,截断到一个合理长度;
- 历史消息在较长会话里做滑动窗口,保留最近的 N 轮完整消息 + 全局摘要;
- 当预算仍超限时,使用“摘要 + 关键工具结果”代替完整历史。
这里的难点是摘要的生成本身也要调模型,如果你在每一轮都做摘要,成本反而上升。我们实际的生产参数是:最近 6 轮完整消息保留,更早历史生成一次性摘要并作为系统消息的一部分传入模型。Token 预算的检查点放在每次循环开头,提前拦截避免浪费整轮调用。
5.3 工具执行的时间边界问题
Agent 的工具调用不像数据库慢查询那样有明确上限,它受模型决策影响很大。我们遇到过一个故障:用户咨询一个复杂售后问题,Agent 连续调用了 8 个工具,每个工具平均 300ms,算上模型调用时间和等待时间,一次任务超过了一分钟。这本身不是灾难,问题是工具链里有一个查询接口的耗时会随数据量变长,在 Agent 场景下被放大了好几倍。
所以 BizBuddy 对工具的治理不是一刀切时间,而是给每个工具配置:
- 超时上限(默认 5 秒);
- 幂等键:如果是查询类工具无所谓,但变更类工具必须有幂等键,由引擎生成并传给执行器,防止重试导致重复下单;
- 并发控制:某些写库工具只允许同时执行 N 个,超过后排队或直接报错给模型。
幂等键这块,@AgentTool注解里加了一个idempotent = true的属性,声明后引擎在参数里自动注入一个X-BizBuddy-TraceId,真实业务方法里用这个字段做去重,成本很低收益却很明显。
5.4 虚拟线程 + 数据库连接池:一个警告
虚拟线程虽然便宜,但数据库连接不便宜。如果 50 个虚拟线程同时执行 Agent 任务,各自调用一个查询工具,你会瞬间占用 50 个连接。对于连接池上限 20 的数据库,后面的任务直接阻塞等待连接释放,表面看起来像是“数据库慢”,实际是连接池打满了。
我们的调整策略是:在工具执行层增加了一个“数据库密集工具”的信号量限流,把并发数限制在连接池大小的 60% 左右。也就是说,虚拟线程可以并发开很多个,但真正会占用数据库连接的工具执行需要排队。这个限流机制又反过来避免了 Agent 并发过高打垮底层数据库的问题。
6. 给我自己的经验,也分享给准备做同样事情的你
如果让我重做这个项目,恐怕还是会走纯 Java 这条路,但我一定会把工具注册的约定打磨得更早、更清晰。我们前期对工具描述写得很随意,导致模型的工具选择命中率忽高忽低,后来花了整整一轮精力专门重新编写所有工具的描述和参数说明,效果立竿见影。大模型时代,工具描述就是“文档即配置”,优先级应该和被调用的业务代码一样高。
另外,Agent Harness 的落地节奏建议是“从内向外”:先在运营后台做预算有限的内部工具(余额查询、订单查询、商品推荐),让 Agent 完全在可控制范围内跑,积累执行数据和审计日志;等团队对工具调用成功率、模型幻觉率、业务方接受度都有了体感,再考虑把写操作类 Agent 放给 C 端或合作方使用。写操作开放的早晚,往往是决定平台口碑的关键。