面对群里几乎一边倒的“Agent 项目不选 Python 等于自找麻烦”的声音,我们组还是在一个面向多家企业客户的系统改造项目里,用纯 Java 搭了一个企业级 Agent Harness 平台,代号 BizBuddy。这篇文章想把立项时算的账、Harness 与 Agent 的边界划分、实现过程中的关键取舍,以及上线后踩过的几个大坑,原原本本讲清楚。如果你正在评估“企业级智能体底座到底要不要自研”“Java 能不能在 Agent 赛道里站住脚”,这篇内容应该能给你一个比较落地的参考。
1. 为什么绕了一圈,最后回来用纯 Java 搭这个 Agent Harness
1.1 起点:不是要造玩具,而是要交付给客户
项目起因并不复杂。我们当时接到一个需求:某类已有业务系统需要统一接入智能助手能力。所谓智能助手,不是简单接一个对话接口,而是让终端客户能用自然语言触发业务系统里的真实操作——查单据、跑审批、生成报表、更新主数据。每个系统都有自己的接口规范和权限模型,如果每次对接都单独开发一套 Agent 逻辑,那这个项目人力投入会完全失控。
所以我们最初想的不是“做一个 Agent”,而是“做一层能跑 Agent 的运行容器”,让上面的智能体逻辑可以被配置、被部署、被运维,同时还能对每一次模型调用和工具执行做审计。这一层东西就是 Agent Harness。放到实际业务语境里,它更像一个“智能体运行底座”,业务方只需要把各自的接口以工具方式注册进来,上层随便编排,底座负责模型会话、工具调度、上下文管理、权限校验、限流和监控。
一开始我们确实先做了 Python 原型。原型很简单:一个循环,模型输出工具调用,解析参数,执行本地函数,把结果塞回去,继续让模型推理。这个原型在单机 Demo 场景下跑得很顺,大概几天时间就能把“查库存—生成补货建议—发起审批”这样一条链路跑通。但当我们把这个原型往企业内部交付环境里放的时候,问题一个接一个冒了出来。
1.2 Python 方案的“行”与“不行”
不是说 Python 不行,而是我们面对的这个场景,Python 的优势发挥不出来,短板却被放得很大。
- 能力优势被稀释:我们不需要在 Harness 里做复杂的数值计算、机器学习训练、深度学习推理,核心逻辑是“调模型、调工具、管状态”。这些逻辑用 Java 写并不比 Python 慢多少,而且 Java 的类型体系和工程化能力更适合做大型平台。
- 运行时治理问题:企业内部大量存量中间件、监控告警体系、CI/CD 流水线都是围绕 Java 技术栈建立的。引入 Python 服务意味着要单独维护一套镜像、依赖锁、启动脚本,还要处理 GIL、多进程、依赖冲突这些运维问题。
- 人才梯队:组里最熟的是 Java 后端工程师。让每个人重新学 Python 生态不是不行,但同样的时间投入,放在梳理业务工具和稳定性上,回报明显更高。
- 安全合规与审计:客户场景要求每个工具调用都留痕,每次模型输入输出都要过审计,Python 灵活的动态特性反而让审计变得不好约束。Java 这边有成熟的切面、注解、字节码增强体系,做统一收口非常顺手。
当时我用一张表把两个方向的差异列给团队看,逻辑就非常清楚了:
| 维度 | Python 生态方案 | Java 企业级方案 |
|---|---|---|
| 原型开发速度 | 极快,适合验证算法 | 相对慢,适合沉淀工程 |
| 模型 SDK 生态 | 丰富,但碎片化 | 中等,可自封装 |
| 工具生态/运维体系 | 较独立,需要额外建设 | 与存量系统无缝集成 |
| 强类型与契约约束 | 弱,靠运行时校验 | 强,编译期拦截 |
| 审计/权限/多租户 | 需要自己从零搭 | 有大量成熟框架可组合 |
| 团队技能匹配 | 需要转型成本 | 几乎零成本 |
这张表解决了一个核心问题:Agent Harness 的本质不是模型能力,而是治理能力。治理能力恰好是 Java 技术栈的老本行。于是我们定下基调:用纯 Java 做 Harness,不引入任何非 JVM 语言,让整个平台保持统一的构建、测试、发布链路。
1.3 纯 Java 不代表什么都要自己写
这里要强调一下,“纯 Java”指的是技术栈统一在 JVM 上,不是说所有东西都要手写。我们仍然使用了成熟的框架,比如 Spring Boot 做应用骨架,虚拟线程做并发模型,OpenTelemetry 做链路追踪,JSON Schema 校验器做工具参数校验,数据库和消息队列用团队已有的基础设施。所谓“纯”,是指不混入 Python/Node 之类的第二种运行时,避免“平台主体是 Java,但工具脚本是 Python”这种常见的维护灾难。
这个决策在后来的排障里证明非常重要。有一次线上工具执行超时,我们直接对着 JFR(Java Flight Recorder)和线程堆栈定位到是某个外部 SDK 的同步调用阻塞,整个过程没有跨语言调试的痛苦。
2. Harness 和 Agent 的边界:BizBuddy 到底在管什么
2.1 一个经常被混淆的概念
聊 Agent 的人很多,但“Agent Harness”这个概念经常被混淆。有人把 Harness 理解成 Agent 框架,有人把 Harness 理解成编排引擎,还有人干脆认为 Harness 就是一套 Prompt 模板。我们在项目初期也被这个定义折磨过。
后来给它定下了一个清晰的分工:Agent 层负责“想什么”,Harness 层负责“怎么跑”。
- Agent 层包含模型选择、Prompt 模板、思维链策略、任务拆解逻辑,这些是可以被业务方频繁调整的部分。
- Harness 层包含会话生命周期、上下文窗口管理、工具注册与发现、参数校验、鉴权、限流、审计、重试、退避、超时熔断、指标上报,这些是无论如何都不能因为业务调整而改变的部分。
类比一下就懂了:Harness 之于 Agent,就像 Spring 容器之于一个普通 Java Bean,或者 Servlet 容器之于一个 Web 应用。Servlet 容器不管你的业务逻辑怎么写,但它负责 HTTP 协议解析、线程分配、请求生命周期、过滤器和安全。同样,BizBuddy 不管你的 Agent 具体怎么思考,但它负责模型会话怎么建、工具怎么被安全地调用、调用结果怎么记入审计。
2.2 核心抽象:只有五个
我在设计 BizBuddy 之初压了又压,最终只保留了五个核心抽象。加的东西越多,业务团队学习和接入的成本就越高。
- Runtime:负责一次 Agent 运行的完整生命周期。从接收用户请求开始,到模型对话、工具调用、结果返回结束,所有状态变化都由 Runtime 统一管理。
- Context:一次对话或一个任务共享的上下文。包含历史消息、当前会话变量、用户身份、权限范围、Token 可用额度。Context 是整个 Harness 最核心的“串联体”。
- Tool:业务系统暴露出来的能力单元。一个 Tool 就是一段结构化描述加一个执行入口。BizBuddy 不关心 Tool 内部实现,只关心它的签名、权限要求和副作用等级。
- Policy:策略层。这里放的是限流规则、超时时间、允许调用哪些模型、哪些 Tool 对哪些角色可见、敏感操作是否需要二次确认。
- Audit:审计通道。每一次模型调用、每一次工具执行、每一次策略拒绝都以结构化事件写入审计日志,供事后追溯。
这五个抽象下来,整个平台的架构就非常稳定了。业务方接入时,绝大多数时间只跟 Tool 和 Prompt 打交道,而平台团队只需要维护 Runtime、Context、Policy、Audit 这四块底盘。
2.3 工具调用必须是一等公民
Agent Harness 和普通对话机器人最大的区别,就是对工具调用的支持深度。普通对话机器人只需要“生成文本”,而 Harness 必须稳定地完成“生成意图—选择工具—参数校验—执行工具—处理结果—继续推理”这个闭环。
我们在设计时把工具调用当成平台的一等公民,意味着工具不只是模型可调用的一组函数,而是平台内部有完整的生命周期管理:
- 注册期:业务方上传工具描述,BizBuddy 解析成 JSON Schema,并登记权限标签。
- 发现期:模型触发工具调用意图,Runtime 在 ToolRegistry 里按名称匹配对应工具。
- 校验期:模型返回的参数必须通过 JSON Schema 校验,防止幻觉产生的非法参数被直接执行。
- 执行期:工具执行被纳入服务端的统一线程池,带超时和熔断。
- 审计期:成功失败都记录,同时记录“模型当时为什么会选这个工具”的原始上下文。
为了让这个模型更直白,我把核心接口设计得很薄:
public interface AgentRuntime { AgentSession startSession(SessionRequest request); AgentResponse execute(AgentSession session, String userMessage); void closeSession(String sessionId); } public interface Tool { String name(); String description(); JsonSchema inputSchema(); ToolResult execute(ToolContext ctx, JsonNode parameters); }所有复杂的策略都不放在 Tool 接口上,而是由 Harness 在调用前后通过拦截器统一处理。这样业务方写 Tool 时不需要关心限流和审计,底层的 PolicyEvaluator 会替它把关。
3. 纯 Java 实现里,我做的几组关键取舍
3.1 用虚拟线程而不是响应式栈
第一版 BizBuddy 的并发模型是我最先推翻的方案。当时组里有人提议直接用 WebFlux 全响应式,理由是 Agent 场景里大量 IO 等待,响应式不会浪费线程。这个理由在理论上成立,但放到真实工程里会带来一个巨大问题:业务方写 Tool 时,很难保证自己用的是非阻塞 API。
我们的工具大多要对接企业内部系统,有的是 HTTP 调用,有的是数据库查询,有的甚至调用了老旧的同步 SDK。在响应式框架下,只要有一个工具不小心用了阻塞调用,整个线程模型就退化,排查起来非常痛苦。而且响应式编程的学习成本对你团队里的业务开发来说,是不小的负担。
后来我们换成 Spring Boot 3 的虚拟线程,效果出乎意料地好。虚拟线程让代码保持同步、顺序、直觉的写法,同时 IO 等待时线程代价极低。在压测里,即使工具普遍耗时几百毫秒,单个实例也能扛住较高的并发而不需要手工调线程池大小。这对工具开发者非常友好:你只需要把工具逻辑当普通 Java 方法写,剩下的 Harness 兜底。
3.2 主流程事件化,但对外保持同步接口
刚开始我倾向于把所有步骤做成同步链路:接收请求 → 调模型 → 执行工具 → 再调模型 → 返回。这在单次对话里很自然,但后来发现企业场景里大量 Agent 运行是异步的:发起一个复杂任务后,用户希望先拿到“任务已受理”,然后等 Webhook 或轮询拿结果。
所以我们把 Runtime 内部改成事件驱动:一条 Agent 运行链路被切分成若干个事件(模型请求已发出、工具准备执行、工具执行完成、最终回复生成),每个事件被持久化到数据库。对外则同时暴露同步接口和异步任务接口,同步接口供低延迟对话场景使用,异步任务接口供耗时链路使用。
代价是内部复杂度明显上升,事件状态管理非常容易出错。但对企业客户来说,异步能力是刚需。没有这一步,BizBuddy 就只能算一个带工具的聊天服务,称不上 Harness 平台。
3.3 工具注册使用 JSON Schema,而不是 Java 强类型参数
这是整个项目里争议最大的一次取舍。按照工程师直觉,Tool 的参数应该用 Java 强类型定义,例如@ToolParam("orderId") String orderId,编译器就能校验。但模型返回的 tool_call 参数本质上是一段 JSON,它不可能知道你 Java 类型定义了没,它只会按模型训练时的方式输出。
如果我们用强类型入口,就必须做“JSON 到强类型”的映射,而这个映射在模型输出不稳定时会频繁暴雷。比如模型多传了一个字段、字段类型对不上、日期格式不对,强类型转换直接抛异常,工具还没执行就失败了。
我们最终选择让 Tool 统一接收JsonNode,然后用 JSON Schema 做前置校验。每个工具上传描述时,必须附带一份 inputSchema。Runtime 在模型返回工具调用后,先做校验,校验通过才把JsonNode传给工具方法。这样模型的“胡说八道”不会直接烧到业务代码,而是被 Harness 挡在门外。
{ "name": "create_purchase_order", "description": "创建采购订单,需要审批权限", "inputSchema": { "type": "object", "properties": { "vendorId": {"type": "string"}, "items": { "type": "array", "items": { "type": "object", "properties": { "sku": {"type": "string"}, "quantity": {"type": "integer", "minimum": 1} }, "required": ["sku", "quantity"] } } }, "required": ["vendorId", "items"] } }后来的事实证明:JSON Schema 校验是我们对“工具幻觉”最有效的一层防御。
3.4 上下文管理:不是所有历史都该喂给模型
Agent Harness 最常见的错误认知是:上下文就是把对话历史全部拼接起来发给模型。这在 demo 里没问题,但企业级场景中一个会话可能持续几天,期间还夹杂工具调用产生的大段结构化数据。如果全量拼接,Token 成本会失控,模型也会在处理无关信息时变笨。
BizBuddy 的 Context 设计了三层:
- 短期窗口:最近 N 轮用户消息和模型回复,完整保留。
- 压缩摘要:当短期窗口超出阈值,把更早的对话交给摘要模型压缩成结构化摘要。
- 外部记忆:用户基础信息、业务单据状态、审批结论等关键数据,显式写入会话变量,不依赖模型从历史里翻。
这套三层结构看起来简单,但非常有效。我们上线后 Token 成本比最初原型降低了约 60%,核心原因就是不再无脑拼接历史。
3.5 可观测性选型:Trace 优先,Log 其次
Agent 场景的排障非常特殊,问题往往跨多个环节:用户说了什么、模型返回了什么工具调用、工具执行报了什么错、上下文里被塞进了哪条过期数据。传统日志在这种场景下几乎不可用,因为日志是散的,根本没有一条贯穿链路。
我们从第一天就把 OpenTelemetry Trace 作为一等公民。每个会话都生成一个 root span,模型调用、工具执行、策略校验、审计写入都是子 span。这样在 Grafana 里可以直接展开一次完整 Agent 运行链路,看到每一步耗时和异常。
这套体系在后来排查故障时帮了大忙。有一次发现工具经常执行到一半就中断,我们直接看 trace 发现是某个下游接口在固定周期内超时,而不是模型层的问题,十分钟定位,半小时解决。
4. 三个事故复盘:Harness 的脆弱面
4.1 事故一:内部工具超时把核心线程池拖死
上线早期,我们低估了工具执行对线程池的冲击。某个工具内部调用下属系统的接口,正常响应 200ms,但下游故障时接口不再超时,而是无限等待。虚拟线程虽然能支撑高并发,但那一次调用量极大,大量虚拟线程卡在等待上,后续请求的上下文分配也跟着变慢,整个实例的 CPU 和内存迅速飙升。
排查过程很典型。先看监控,发现实例的活跃线程数异常高,再看 trace,发现大量工具 span 一直没有结束。把堆栈 dump 出来后,所有卡住的线程都停在同一个下游 SDK 的 socket 读取上。
修法不复杂:工具统一走服务端超时控制,下游接口最多等 10 秒,超时即熔断。同时给每个工具声明了超时等级,普通查询 5 秒,跨系统写操作 15 秒,超过阈值直接终结并返回错误给模型。
这个事故让我明白一件事:在 Agent Harness 里,工具调用不能用普通后端接口的标准来设计,所有工具的可靠性都要假设为“可能挂掉”,因为模型会天真地反复调用同一个失败工具,如果每次都让它完整等待,系统会被一次下游故障打穿。
4.2 事故二:审计日志的 CPU 代价
客户要求每次工具调用都必须有审计记录,这是硬性要求。我们在设计时图省事,直接在每个工具执行后同步写审计表,结果高峰期工具调用一多,数据库写压力陡增,主链路延迟直接翻倍。
排查起来也很有意思:从接口监控看,模型调用耗时正常,工具耗时正常,但整体请求时间多出了几十毫秒。最终 trace 里看到的是审计写入占了大头。
后来把审计写入改成异步批量落库,主链路只做事件发布,由单独的消费者线程批量写入。审计事件在内存中做缓冲,失败时保留本地文件并重试,保证不丢。那一版改造之后,主链路延迟基本恢复到改造前水平,而审计能力反而更完整了。
4.3 事故三:上下文过期数据导致模型反复犯错
一次线上反馈是:用户已经取消了某个审批单,但 Agent 还在反复建议用户“确认审批”。我们查 trace 发现,工具“查询审批状态”在第一次调用时返回了“待审批”,之后这个结果被完整保存在上下文中,即使后续工具调用已经返回“已取消”,模型仍优先参考了历史里更详细的旧数据。
这也是一个非常典型的企业级 Agent 问题:上下文里的信息有生命周期,不是所有历史结果都能一直有效。修法是在工具结果写入上下文时标记有效期,关键数据如审批状态、库存数量、价格信息,每次新结果都会覆盖旧结果,而不是简单追加。
经过这次之后,我把 Context 的“时效性”提到了和“容量”同等重要的位置。一个 Harness 如果连数据新鲜度都管不好,模型能力再强也白搭。
4.4 事故四:多租户策略的隔离漏洞
客户是多家企业共用部署环境,不同企业的工具权限模型完全不一样。我们第一版把策略写在上下文里,结果在并发场景下出现过上下文串用的情况:A 企业的用户在某个异常分支里看到了 B 企业的工具信息。原因是缓存 key 设计漏掉了租户维度。
这个问题让我们回炉了 Context 的隔离设计。现在每个会话从创建之初就绑定租户 ID、用户 ID、角色标签,所有缓存、线程变量、工具可见性查询都必须显式传递身份上下文,而不是靠隐式的 ThreadLocal。这条教训让我对“企业级”三个字有了更深的敬畏:功能可以少,隔离必须硬。
5. 压测数据与上线后的真实收益
5.1 我们的压测方式
BizBuddy 上线前做了两轮压测。第一轮是链路压测,模拟真实场景:用户发起查询→模型选择工具→工具返回→模型生成总结。第二轮是异常压测:随机让 30% 的工具调用超时、返回异常,观察 Harness 是否能按策略完成熔断和恢复。
压测环境是四台 8C16G 的实体机器,前面挂一个无状态的接入层,后面是 BizBuddy 主集群。数据库用现有的 PostgreSQL,审计写入走异步队列。
关键结果大概是这样:
| 指标 | 数据 |
|---|---|
| 单实例同步请求 QPS | 稳定在 60 左右 |
| 单次会话平均耗时 | 4~8 秒(取决于模型链路) |
| 工具超时熔断时间 | 10~15 秒 |
| 上下文压缩后 Token 节约 | 约 60% |
| 异常注入下成功率 | 99.2% |
这里说明一下,QPS 60 在企业内部系统场景已经非常够用。我们面对的不是 C 端千万级流量,而是几千名内部用户每天产生的几万个会话请求,瓶颈完全不在 Harness,而在外部模型接口的速率限制。
5.2 和 Python 方案的一次真实对比
我们把最初那个 Python 原型和老旧的方案在同一批数据上做了横向对比,结论很有意思:Python 原型在单次模型推理速度上几乎没差,因为大头耗在模型 API 上;但在高并发和工具治理场景下,Python 方案的线程模型和依赖管理带来的运维成本就明显偏高。
我还特意测试了一个反常识的场景:一次会话连续调用 8 个工具,中间产生大量临时数据。Java 侧的强类型模型上下文管理非常稳,而 Python 原型在对象引用和内存回收上会出现一些不可控的波动。当然这和原型代码粗糙有关,但也侧面说明“语言本身不是关键,工程化的强约束才是长尾服务的命门”。
5.3 什么时候我会承认纯 Java 不是万能答案
说句公道话,虽然 BizBuddy 选了纯 Java,但我不会说 Java 在所有 Agent 场景里都是最优解。
如果你的团队人少、项目周期短、核心价值在 Prompt 实验和模型能力快速验证,那 Python 生态确实更适合。在我看来,选 Java 的临界条件很清晰:这个 Agent 平台要长期存在、要多团队共建、要满足严格审计和安全要求、要和大量存量 Java 系统集成。只要符合其中两条以上,纯 Java 的收益就非常明显。
BizBuddy 现在还在持续演进。我们下一步的重点是把策略引擎和模型选择解耦得更彻底,让不同业务域能配置不同的模型路由规则,同时把工具血缘分析做起来——也就是“哪个工具被哪个 Agent 调用过、成功率和成本如何”,这些事情在 Python 快速原型里很难沉淀下来,但在 Java 平台里,它们正在慢慢长成真正的企业资产。
最后分享一个我现在仍然很依赖的排查技巧:当 Agent 行为异常时,不要急着怀疑模型,先拉 Trace 看完工具调用的完整链路,再打开上下文快照看模型到底“看”到了哪些历史数据。十次里起码有六次,问题不在模型不在代码,而在上下文给模型喂了不该喂的东西。这个习惯,让我在 BizBuddy 的排障效率上提高了不止一倍。