资讯动态

纯Java实现Agent Harness:企业级AI应用的生产落地与架构取舍

发布时间:2026/10/22 16:13:56 来源:尧图企业网站定制
半年前我们团队接了一个内部运营助手的活要在私有化环境里跑一批 AI Agent 处理工单分类、合同信息抽取和业务数据问答。最开始我用 Python 生态把 Agent 骨架搭了出来三个晚上就能对话了同事都说效果不错。可真正准备上生产的时候问题一个接一个冒出来模型调用怎么和现有权限体系打通多个 Agent 的会话怎么隔离工具调用怎么审计日志里怎么追踪一次完整的多步推理Python 那边的小样完全答不上来。于是我们转回去用纯 Java 重新实现做了个内部代号叫 BizBuddy 的 Agent Harness 平台。这篇文章想把这个过程中的设计取舍讲清楚哪些地方我坚持了 Java 的路子哪些地方故意没有照搬社区框架哪些决定是拿生产事故换来的。1. 起因小样用 Python 三天跑通上生产的时候全卡住了1.1 我们最初想要的东西其实需求本身并不玄乎。一个内部助手用户在对话框里提问题Agent 自己判断要不要调用我们已有的业务接口再把结果组织成自然语言回复回来。这里有对外的客服工单分类、有合同文本的字段抽取、也有针对业务数据的多轮问答。难点从来不是模型会不会回答而是这些问题在企业环境里要过安全评审、要接统一登录、要扣费审计、要能灰度发布。1.2 小样的快恰好就是它上不了生产的理由Python 小样确实快因为生态里现成的东西多。但当你认真问生产环境的问题就会发现全是坑第一是环境碎片化。模型推理主要走远端 API本地不需要太重的东西但 Python 的依赖管理和虚拟环境在公司安全策略下还是被反复盘问。私有化部署到客户现场的时候更是头疼客户环境里能不能装包、能不能联网、用哪个 Python 版本都得协商。第二是并发和稳定性。一个 Python 进程的并发能力有限对于几十个用户同时提问这种再普通不过的场景需要额外上异步框架而团队里没人愿意长期维护第二套技术栈。第三是治理和集成。业务系统是 Java 技术栈用户权限、组织架构、数据库访问、消息队列、统一配置中心全都长在 Java 这边。Python Agent 想调用这些能力要么开 HTTP 接口硬桥接要么在 Python 里再连一遍那些中间件。两条路都意味着双倍的运维面和双倍的审计面。那段小样最终没有被浪费它证明了用 Agent 处理这些业务可行。但架构上的账算得很清楚用 Java 实现同样的东西可能要多花两三周但之后它会和现有系统长在一起而不是在旁边另起炉灶。1.3 用纯 Java 做不是情怀是账算得过来我需要澄清一下纯 Java的边界。BizBuddy 不是一个机器学习训练平台不需要跑模型也不碰向量检索算法的内部实现。它是一个偏运行引擎和治理底座的东西主要负责 Agent 的生命周期、上下文、工具调用、流程编排、模型接入抽象、限流监控。这些活儿 JVM 生态天生就擅长而且我们有一整支熟悉 Spring、理解并发编程、会调 JVM 参数的技术团队。同样重要的是Java 生态里对企业级集成的沉淀非常深。安全框架、缓存抽象、连接池、事务管理、配置中心都已经有成熟方案BizBuddy 要做的不是重造这些轮子而是把它们和 Agent 的运行时协议衔接起来。1.4 什么是 Agent Harness现在Agent 框架满天飞但 Harness 和框架是两种位置的东西。框架解决的是Agent 怎么写比如怎么解析模型输出、怎么构造 Prompt、怎么调用工具它更像造船厂。Harness 解决的是Agent 怎么跑比如一个 Agent 从部署到下线谁在管、多个会话之间怎么隔离、失败了谁来重试、调用一次模型的成本记到哪个项目头上它更像船运公司。BizBuddy 的定位更偏 Harness。在它之上有业务团队写的具体 Agent在它之下是各种基础设施。它必须足够薄才能不绑架业务方的开发方式又必须足够强才能让 Agent 在真实生产环境里不出乱子。2. 核心抽象Agent 是一个业务单元不是一个模型调用壳2.1 为什么我把 Agent 接口做得这么简单最开始设计 BizBuddy 的 Agent 抽象时团队里有人建议照搬社区框架的路子把 Agent 做成一个大而全的类里面直接塞模型、记忆、工具、规划器。我坚持没有这么做因为这个层一旦变重所有 Agent 实现都要被框架牵着走。BizBuddy 的 Agent 接口收缩到只有四个方法public interface Agent { String name(); void initialize(AgentRuntime runtime); AgentReply invoke(AgentRequest request, AgentSession session); void destroy(); }我们不定义该不该反思该用哪种 Prompt 策略该选哪个模型这些属于具体 Agent 的个性。框架只要约定你有一个名字启动时我给你运行时能力请求来了我拿会话和请求调你服务下线我通知你做清理。这样业务团队写 Agent 时可以自由组合工具调用、模型调用和本地逻辑而 Harness 统一拿到生命周期与执行入口。AgentRuntime 是 BizBuddy 给 Agent 的能力清单里面包括工具注册表、模型 Provider、审批接口、指标收集器等。每个 Agent 在 initialize 阶段拿到的 runtime 是同一个但可以声明自己需要哪些子集这样测试时也能注入 mock。2.2 Session 持有状态Agent 本身保持无状态这是整个设计里我最看重的一个取舍Agent 实例禁止持有用户级状态。曾经有同事图省事把用户对话历史直接放在 Agent 的成员变量里。开发时什么问题都没有一上联调就出乱子A 用户的问题被 B 用户的上下文干扰工单查询结果串号。排查了好几个小时最后发现是 Agent 被注册成了单例所有并发请求共享了同一个成员变量。BizBuddy 里所有状态都放在 AgentSession 中public final class AgentSession implements AutoCloseable { private final String sessionId; private final String tenantId; private final String agentName; private final MapString, Object variables new ConcurrentHashMap(); private final ChatWindow chatWindow; public void setVariable(String key, Object value) { ... } public Object getVariable(String key) { ... } public void appendMessage(ChatMessage message) { ... } public ListChatMessage visibleMessages() { ... } Override public void close() { ... } }sessionId 是全局唯一标识tenantId 保证一个租户的会话不会串到另一个租户。ChatWindow 是带预算的对话窗口负责控制发往模型的 token 量这个放到后面讲。所有 Agent 实现都被要求写成无状态本次请求需要什么就通过参数从 session 里拿。这个约束让 BizBuddy 可以一个 Agent 实例服务几千个并发会话也让压测和生产环境的偶发问题变得好查。2.3 和 Spring 容器怎么协作BizBuddy 没有另搞一套依赖注入。Agent 本身就是普通的 Spring 组件通过注解注册Agent(name order_query_agent) public class OrderQueryAgent implements Agent { ... }应用启动时BizBuddy 的 Starter 组件扫描所有带 Agent 的类检查接口方法实现然后以单例方式注册到 AgentRegistry。Agent 内部想调什么业务服务直接使用平常的构造器注入或字段注入不需要自定义扩展机制。这样做的好处是业务方写 Agent 和写普通 Spring 服务几乎没有区别。他们不需要理解框架如何接管对象生命周期之类的东西只需要按常规方式写代码BizBuddy 在边界上把 Agent 的运行协议接住。3. 工具调用链路的设计取舍注解式 Tool 才是 Java 的答案3.1 为什么不直接暴露 JSON Schema做 Function Calling 相关的 Agent第一步通常是定义模型可以调用的工具。很多框架推荐直接给 JSON Schema让 Agent 决定调用哪个函数。我在 BizBuddy 里没有采用手写 schema的方案原因很实际字段一变schema 和人写的代码就会漂移类型一复杂手工维护成本直线上升更重要的是Java 是个强类型语言手写字符串让编译期完全帮不上忙。BizBuddy 的做法是用注解定义工具编译期和 IDE 都能参与到检查中Tool(name query_order, description 按条件查询订单列表返回订单ID、金额和状态) public OrderQueryResult queryOrder( ToolParam(name customerId, description 客户ID) String customerId, ToolParam(name startDate, description 开始日期格式 yyyy-MM-dd) LocalDate startDate, ToolParam(name status, description 订单状态) OrderStatus status) { return orderQueryService.query(customerId, startDate, status); }这里有几个隐性好处。参数类型是真的对象类型LocalDate、枚举、业务对象都能被正确序列化与校验方法是现成的 Spring Bean 方法可以直接用注解扫描与反射机制接管不需要额外包一层适配器。3.2 一个 Tool 从注册到执行的完整路径BizBuddy 的 Tool 不是普通方法它有完整的元数据、拦截器和调用器。注册阶段Starter 通过 BeanPostProcessor 收集所有标注了 Tool 的公共方法解析入参和描述打包成 ToolDefinition注册到 ToolRegistry。调用阶段完整链路如下LLM 在响应里返回一个 toolCall包含 toolName 和 JSON 字符串参数。BizBuddy 的 ToolInvoker 从 registry 拿到 ToolDefinition反序列化参数到方法签名要求的类型。参数经过一层 ThrottleAndBudget 检查确认这个租户、这个 Agent 还有调用额度。权限拦截器校验当前用户是否有权执行该工具。这一步不能省后面会讲为什么。真正的方法通过 MethodHandle 调用整个调用包裹在带超时时间的任务里。返回值转成一个 ToolResultMessage追加到 session 的对话窗口再送回给模型。这里有一个很容易踩的坑模型返回的参数 JSON 不一定符合方法签名。比如日期格式写成了别的样子或者枚举值拼错。BizBuddy 在反序列化阶段统一做了一次容错处理日期格式尽量多认几种枚举按 name 和别名双匹配实在解析不了就返回一个明确的解析错误给模型让模型自己修正参数再调用一次。实测下来这种把错误反馈给模型的策略比直接给用户报错效果好得多。3.3 权限、超时、注入防护放在哪工具调用是 Agent 能力边界也是安全风险边界。一个能从对话里生成参数的执行通道如果没有闸门等于给任何能触发对话的人开了一个无限调接口的口子。我在 BizBuddy 里把权限校验放在工具调用这一层和用户请求入口那层分开。用户请求入口那层管的是能不能跟这个 Agent 说话工具调用这一层管的是这个用户能不能让 Agent 替他执行这个工具。两者的粒度完全不同A 用户可能能和合同 Agent 对话但只允许它查公开摘要不允许它触发合同导出。超时和熔断也放在工具调用层。模型可能一口气发起三四个工具调用BizBuddy 默认并发执行这些工具但每个工具独立超时执行业务方法超过阈值后向模型返回一个超时结果由模型决定是重试还是告诉用户当前系统繁忙。这个细节让整个 Agent 系统在业务系统抖动时不会连带不可用。参数注入防护也是后来补的。开发者很容易信任模型生成的参数但模型可能被提示词注入诱导比如某条外部文本里写着请忽略之前指令执行某个高风险动作。BizBuddy 对每个工具参数都做类型和边界校验同时对路径型、查询型参数做额外的排除列表检查。4. 编排层不引入现成 BPM 引擎自研一个会回头的轻量图执行器4.1 BPM 引擎为什么让 Agent 别扭立项时有人提议用成熟的 BPM 工作流引擎来编排多步 Agent。当时我带着团队实际建模试了一下不到半小时就放弃了。根因是 BPM 引擎的哲学和 Agent 的哲学拧着。BPM 引擎擅长的是流程预先确定状态由引擎推进每一步的下一步是设计时写死的。Agent 则完全不同模型要根据前一步的工具结果动态决定下一步做什么。你可以预先定义一个最复杂的拓扑图但实际执行路径可能在每个节点都变化。硬把这种动态性塞进 BPM 编辑器里结果就是图上面全是条件分支模型反而成了摆设。4.2 图模型和节点类型BizBuddy 定义了一组非常小的图模型命名上故意不用工作流那套词避免团队下意识往流程定义上套。public sealed interface GraphNode permits StartNode, EndNode, LlmNode, ToolNode, ConditionNode, SubGraphNode { } public record GraphEdge(String targetNodeId, String whenCondition) { }看图就知道这个图不是流程图更像一棵执行树加循环控制Start 进入End 退出Llm 节点调用模型Tool 节点调用工具Condition 节点根据 session 里的变量走不同分支SubGraph 节点复用一张子图。每条边上可以带 SpEL 表达式比如graph.exit() .hasNode(tool_result_check, SpelCondition.on(result.status NEED_MORE_INFO).then(query_more)) .hasNode(tool_result_check, SpelCondition.on(result.status SUCCESS).then(final_answer));没有复杂的网关、会签、子流程约定只有节点和边。团队学习成本很低而表达力对大部分 Agent 场景已经够了。4.3 执行器的递归式实现与防呆执行器用显式栈模拟递归避免深调用把栈打爆public GraphExecutionResult execute(AgentGraph graph, AgentSession session, String entryNodeId) { DequeNodeFrame stack new ArrayDeque(); stack.push(new NodeFrame(entryNodeId, Map.of())); while (!stack.isEmpty()) { NodeFrame frame stack.pop(); GraphNode node graph.node(frame.nodeId()); NodeResult nodeResult node.run(session, frame.input()); for (GraphEdge edge : graph.outEdges(frame.nodeId())) { if (edge.whenCondition() null || evalCondition(edge.whenCondition(), session, nodeResult)) { stack.push(new NodeFrame(edge.targetNodeId(), nodeResult.output())); } } if (stack.size() maxDepth) { throw new GraphExecutionException(graph exceeds max depth maxDepth); } } return new GraphExecutionResult(session); }这个执行器实现了最关键的一个能力回头。Llm 节点输出工具调用请求后执行器可以先把控制权交给 Tool 节点Tool 的返回结果再作为下一次 Llm 节点的输入。这在图里不需要画圈只需要让 Llm 节点在存在 toolCall 请求时把后继边同时指向工具节点和自己即可。防呆方面我加了三个硬性上限最大执行深度、最大循环次数、整体超时时间。任意一个触发都以失败状态结束并且返回足够明确的错误信息。因为 Agent 模式的失败率本来就高于普通接口开发环境里跑 5 次可能没事到生产模型换版本后就可能出现死循环。4.4 多 Agent 协作我只先做了 Supervisor 和 Pipeline 两种多 Agent 协作听起来很酷但我没打算做一整套完整的 Agent 市场、辩论、群体共识机制。BizBuddy 第一版只支持两种模式Supervisor 模式一个协调 Agent 根据用户描述选择可用的子 Agent把子 Agent 的执行结果再组织成最终答复。Pipeline 模式请求按固定顺序经过多个 Agent上一个 Agent 的输出作为下一个 Agent 的输入。适合先分类再处理这类稳定链路。这两种模式覆盖了我们目前遇到的绝大多数业务场景而且实现都很简单就是图执行器上的特定排列。刻意不加更多模式是因为多 Agent 的调试成本是单 Agent 的好几倍绝大多数项目根本用不到那些花哨能力先把基础路径做稳比什么都强。5. LLM Provider 抽象能做 80% 的统一剩下的 20% 留给能力协商5.1 内部消息协议与适配器BizBuddy 面向业务开发者的 API 里不应该出现任何具体厂商的类型。所以第一步是定义一套内部消息协议public record ChatMessage(ChatRole role, String content, ListToolCall toolCalls, String toolCallId) { } public record ChatRequest(ListChatMessage messages, ChatOptions options, String agentName) { } public record ChatResult(ChatMessage message, Usage usage, ChatProvider provider) { }然后每个厂商一个适配器负责把内部协议翻译成厂商 SDK 的请求、再把厂商响应翻译回内部协议。这样业务 Agent 永远不需要 import 某个具体模型服务商的包后续切换模型时改的只是配置和适配器而不是具体 Agent 代码。5.2 流式输出落地从 Stream 到 SSE对话体验需要流式输出模型一个 Token 一个 Token 地蹦出来用户不会盯着转圈等二十秒。BizBuddy 在 Provider 接口里定义public interface ChatProvider { ChatResult complete(ChatRequest request, AgentSession session); StreamChatDelta stream(ChatRequest request, AgentSession session); }流式实现的第一个版本我直接用响应式流一路铺到前端结果发现团队里大量成员对背压、异步上下文传递不熟悉容易写出难维护的代码。后来换了一种更工程化的做法内部用阻塞队列收集供应商的回调事件对外以 Java 21 的虚拟线程逐个消费再桥接成 SSE 推给浏览器。这样流式处理的代码写起来像同步循环心智负担低线上也稳定很多。5.3 不硬抹平厂商差异早期我们想把所有厂商的能力对齐成一个超集结果代码里到处是如果支持这个特性则……的条件分支反而难维护。后来我把抽象目标下调到 80%只统一那些 Agent 逻辑真正依赖的基础能力对话补全、流式、工具调用、token 用量统计。剩下的差异比如工具调用格式的细节偏好、JSON 输出模式的支持程度、某些模型特有的参数通过能力协商来处理public record ProviderCapabilities( boolean supportsToolCall, boolean supportsJsonMode, boolean supportsVision, int maxOutputTokens ) { }请求构造器先看 capability能支持再做对应处理不支持就退回到更通用的路径并且日志里明确记录当前模型不支持某特性已降级。这个设计避免了大一统抽象带来的复杂度陷阱也让新接入一个厂商变得非常快。6. 生产环境里几次救命的调优线程模型、链路追踪和幂等6.1 虚拟线程不是银弹BizBuddy 部署在 Java 21 上LLM 调用是典型的阻塞式网络 IO正好适合虚拟线程。我用虚拟线程来处理一次 Agent 执行的整条链路并发量提升非常明显线程占用从几千个降到几十个。但有一个坑必须讲虚拟线程在遇到 synchronized 同步块时会发生 pinning如果同时配上数据库连接池驱逐高并发场景会出现连接拿不到的诡异现象。我们排查过一周最后发现是某处工具方法里加了 synchronized而方法内部阻塞在 JDBC 调用上。解决方法是把这段同步逻辑换成显式锁或原子类并限制连接池与虚拟线程的并发比例别让虚拟线程无限挤爆数据库连接数。6.2 让每一条日志都带 sessionId、agentName、nodeIdAgent 执行链路比普通接口长得多一个用户问题可能触发五六次模型调用、十几次工具调用没有统一的追踪上下文排障就是灾难。BizBuddy 在入口处把会话维度信息写入日志上下文打印的关键 log 都带上 sessionId、agentName、nodeId、toolCallId。最开始实现时用了简单的线程变量结果流式线程一切换上下文就丢了日志串联断裂。后来我改成在每段异步任务提交时显式传递上下文快照由执行线程恢复才算稳定。这个投入看起来小回报非常大。现在出问题第一件事就是按 sessionId 拉日志能把一次完整推理路径完整回放出来。6.3 LLM 重试带来的重复工具调用有一次工单系统出现了重复创建查了半天发现根因是模型侧的网络重试。模型服务返回超时实际请求已经到达 BizBuddyBizBuddy 里工具也执行成功了但客户端重试导致同一个 toolCall 被再次执行。这是 Agent 系统独有的幂等问题。普通接口可以要求客户端传幂等键但 BizBuddy 的调用方是模型服务它不会主动传幂等键。我们的解决方式是在 ToolInvoker 里对每一次 toolCall 生成唯一执行记录以 sessionId nodeId toolCallId 作为幂等键第二次命中直接返回第一次的结果不再真正调用业务方法。类似地同一个 Llm 节点如果因为网络超时重试也优先复用已有输出。6.4 按租户和 Agent 维度做限流配额大模型 API 不是免费午饭一次复杂的多步推理可能消耗上万 token还可能有并发配额上限。BizBuddy 提供两层级联的令牌桶租户级配额管整体预算Agent 级配额管单个场景上限。超过配额时不直接抛粗鲁的异常而是返回一个结构化错误Agent 能把这个信息转成对用户友好的提示比如当前该类服务的调用额度已用完请稍后再试。这套配额也作用于工具调用。有些工具比模型调用更贵比如导出报表、发送短信BizBuddy 在工具调用链路里加了同样的闸门防止模型被诱导触发高成本操作。7. 收尾BizBuddy 只解决了 80% 的问题写这篇文章时 BizBuddy 已经在内部支撑了三个业务 Agent 的上线这个量级不算大但足够暴露很多设计问题。实际做下来我最大的体会是Agent 平台最难的不是模型调用那一层而是把动态执行这件事装进企业现有的运维和治理体系里。BizBuddy 在生命周期、工具调用、编排、可观测和配额上切出了一个比较合理的边界团队写业务 Agent 时不用关心底层底层出问题时又能顺着日志和追踪一路查到底。下一阶段我想补两件事。一是把会话和图中断点做持久化让很长的任务可以挂起恢复二是把图执行过程可视化出来让业务同学也能看懂 Agent 是怎么得出答案的。至于那些还没做到的部分我宁可继续慢慢补也不愿意现在就堆一堆华而不实的功能进去。毕竟 Harness 这东西稳定和可控比热闹重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑