1. 为什么企业需要一个 Agent Harness,而不是一堆 Agent 框架
1.1 从一次线上故障说起
前不久我们内部做了一个工单自动分诊的 Agent,上线后不到一个月就出现了一个很典型的事故:两个业务小组分别实现了两个 Agent,一个是销售助手,负责回答“最近跟单情况”,会调用 CRM 查询接口;另一个是客户画像补全,负责分析历史订单,也会调 CRM。两个 Agent 各写各的代码,各用各的 API Key,权限模型完全不一样。
某个下午 CRM 上游锁表,销售助手先超时了,然后它的重试逻辑不区分场景,硬重试了三次;客户画像那边没有降级,直接把上游数据库连接池打满。最后整个销售系统的接口都变慢,排查的人翻了两套日志才发现是两个互不相干的 Agent 在互相放大故障。
这个案例特别典型,它说明的其实不是“Agent 不够聪明”,而是缺少企业级的基础设施,把 Agent 的调用边界、超时策略、权限、观测都统一管理起来。这也是我做 BizBuddy 的直接动机:与其让每个业务线在 Agent 框架上各自发挥,不如做一个统一的运行时平台,也就是标题里说的 Agent Harness。
1.2 Harness 与 Agent 框架的本质区别
很多人第一次听到 Agent Harness,会以为它是又一个 LangChain 或者 AutoGen。这两个概念确实容易混淆,但它们解决的问题层次完全不同。
Agent 框架解决的是“模型怎么思考、怎么用工具”,核心是 Chain、Memory、Tool Calling 这些编排逻辑。而 Agent Harness 解决的是“这些 Agent 怎么被安全稳定地运行”,核心是生命周期、治理、可观测、故障恢复。
如果拿汽车来类比:框架是发动机,Harness 是底盘、座舱、刹车和仪表盘。你可能先造出了发动机,但没有刹车和仪表盘,你不敢让它上路。
我在实际设计 BizBuddy 时,刻意把这两个层次分开:框架层面的东西可以选型、可以替换,但 Harness 层面的东西必须自己做,因为它和企业的运维体系、权限体系、审计要求深度绑定。
| 维度 | Agent 框架(LangChain4j / Spring AI) | Agent Harness(BizBuddy) |
|---|---|---|
| 核心问题 | 让模型能推理、能调用工具 | 让 Agent 能被运维、被治理 |
| 关注层次 | 单个 Agent 的智能能力 | 多 Agent、多业务线的统一运行 |
| 生命周期 | 较弱,通常只存在于内存中 | 强,每个 Task 可恢复、可追踪 |
| 权限与审计 | 基本不负责,要自己去管 | 原生设计,每次调用都有记录 |
| 典型使用人 | 应用开发者 | 平台 / 基础设施团队 |
1.3 企业级四个关键词
我在企业里落 Agent 服务,最忌讳一个词:玩具化。所谓企业级,在我这里是由四个关键词撑起来的:稳定、可控、可观测、可审计。
稳定是指超时、重试、熔断、降级这些高可用手段一个都不能少,Agent 不是实验脚本,它是生产链路的一部分。
可控是指工具白名单、权限模型、模型路由都要有运维入口,不能让研发在代码里写死。
可观测是指日志、指标、链路追踪能跟现有业务监控打通,出了事能在同一个 Grafana 里看。
可审计是指每次模型调用、每次工具调用都要留痕,能追溯到人、时间和完整上下文。
BizBuddy 的所有设计,都是围着这四个关键词转的。后面讲的架构和实现细节,也都是从这四个词倒推出来的。
2. 纯 Java 选型的动机与代价:不跟风 Python 生态的底气
2.1 AI 功能只是订单系统里的一个环节
很多团队在讨论要不要用 Python 写 AI 服务时,忽略了一个事实:在企业系统里,Agent 并不是独立软件,它是订单、CRM、工单这些业务系统里的一个环节。
我们的核心服务都是 Java / Spring Boot 写的,Agent 需要直接调用这些系统的内部 API、读写数据库、发消息。如果用 Python 写 Agent,就意味着公司要在 Java 之外再维护一条 Python 服务链路:Python 的依赖管理、部署、日志体系、监控都要重新搞。
对大多数非 AI 公司来说,这个成本远比想象高。纯 Java 方案最大的动机不是技术有多先进,而是“少维护一条技术线”。这条理由,在技术选型评审会上往往比任何性能指标都管用。
2.2 为什么不用 LangChain4j 或 Spring AI
一开始我也考虑过直接用 Spring AI 或者 LangChain4j。Spring AI 起步快,和 Spring 生态集成方便,在构建单一 AI 应用的场景下很顺手。LangChain4j 是 Java 社区里比较有代表性的 LangChain 移植版,抽象比较全。
但真正评估下来,它们作为 Agent 框架是合格的,作为企业级 Agent Harness,还差一截。LangChain4j 的抽象偏“大而全”,Chain、Memory、RAG、OutputParser 一应俱全,团队不仅要学框架本身的思路,还要花力气砍掉不需要的部分。Spring AI 则偏应用开发,面向多 Agent 的应用编排、审计体系、工具权限控制这些 Harness 层面的能力,基本上要自己另外搭。
我们最终选择自研 BizBuddy,不是因为别人不行,而是想按自己的场景定义边界:核心只做 Harness 的事情,把模型调用、工具管理、生命周期这些抽象做到够用且可控,而不是把一个大而全的框架引进来再做减法。
| 维度 | Spring AI | LangChain4j | 自研 BizBuddy |
|---|---|---|---|
| 起步速度 | 很快 | 较快 | 慢,需要先做基础架构 |
| 治理能力 | 弱 | 弱 | 原生设计 |
| 工具管理 | 基础注解支持 | 较灵活 | 可定制权限、审计、幂等 |
| 多 Agent 编排 | 较弱 | 有但偏框架层 | 围绕 Task / Step 显式建模 |
| 学习成本 | 低 | 中 | 中等,但内部可控 |
2.3 纯 Java 的技术代价与补偿方案
客观说一句,微软 OpenAI SDK 的官方生态、LangChain 的精巧设计,大部分都是 Python 优先,这是事实。但 Java 生态也有自己的方法论来补偿:LLM 本质上就是 HTTP + SSE + JSON,Java 的 WebClient、虚拟线程、Jackson 这套组合,在并发和稳定性层面反而有优势。
工具调用本质上就是把模型的 JSON 输出映射到 Java 方法调用,这种强类型映射是 Java 的强项。我们要注意的不是 Java 能不能做,而是别把 Python 的 Agent 抽象直接搬过来。Python 那套 dict 加动态调用的风格,在 Java 里应该用 record、SPI、注解、反射去重新建模,才符合 Java 工程的习惯。
3. BizBuddy 的核心架构:把 Agent 当成组件来管理
3.1 模块划分:harness-core / runtime / adapter / console
BizBuddy 目前分成四个 Maven 模块,边界划分是经过几次重构后定下来的:
- harness-core:核心模型、抽象接口、工具注册、生命周期定义。不依赖任何 Web 框架,方便单元测试和复用。
- harness-runtime:运行引擎,负责 Task 执行、编排、状态机、故障恢复。内置可插拔的调度器。
- harness-adapter:接入不同模型、向量库、存储的适配层,比如 OpenAI 兼容协议、通义、DeepSeek、本地 Ollama。
- harness-console:基于 Spring Boot 的管理端,提供 Agent 注册、工具管理、任务监控、审计查询等 REST API。
之所以把 core 和 Web 框架解耦,是为了让核心逻辑可以脱离 Spring 独立测试,也能嵌入到非 Spring 的系统里。这个设计让我在后面接不同的业务线时省了很多事。
3.2 核心抽象:Agent、Task、Step、Context
在 BizBuddy 里,核心模型只有四个:Agent、Task、Step、Context。
Agent 是静态定义,描述一个 AI 助手具备什么能力、用什么模型、能调用哪些工具。Task 是运行单元,一次具体执行的完整记录。Step 是任务里的一个原子步骤,可能是调用一次模型、调用一次工具、做一个条件分支,也可能是等待人工审批。Context 是跨 Step 传递的状态,包含会话消息、中间变量、Token 用量。
我把 Step 设计成 sealed interface,这样编译器就能帮我们穷举所有步骤类型。
public sealed interface Step permits LlmCallStep, ToolCallStep, BranchStep, HumanApproveStep { StepId stepId(); }业务上,一个 Task 可以由多个 Step 组成。需要人工审批的时候,状态机进入 waiting 状态,审批通过后继续执行。这样 AI 任务就纳入了和普通流程一样的审批体系。
3.3 工具注册与 SPI 插件化机制
工具注册是整个 Harness 的核心。每个工具就是一组 Java 方法,通过 SPI 或注解自动加载。工具定义包括 name、description、parametersSchema、permissionScope 等元数据。
public interface ToolProvider { ToolDefinition definition(); Object invoke(ToolInvocation invocation); }系统启动时扫描 ToolProvider,为每个工具动态生成 JSON Schema,注册到模型接口。这里有一个很值得注意的点:模型给出的 tool_calls 是 JSON 结构,我们并不直接反射调用,而是在 ToolInvocation 参数里做一层校验和映射,防止恶意参数绕过权限检查。
这个设计借鉴了云平台 IAM 的思想:模型没有权限概念,它只负责生成输出,真正的权限校验一定在工具调用层强制做,不要相信模型“不会调用”某个工具。
3.4 会话与上下文状态管理:内存为主、数据库兜底
每个 Task 的 Context 里存放会话消息、变量、Token 用量。我们采用“内存为主、数据库兜底”的策略:Context 在 JVM 内存里有一份,方便快速访问;每执行完一个 Step,就把脏字段写入数据库,形成持久化快照。
如果 JVM 重启,可以从最近的快照恢复任务。这里有个细节值得留意:快照里带 version 乐观锁,避免并发恢复导致状态覆盖。持久化层我们用 MyBatis-Plus 处理,表结构按 agent_id、task_id、step_seq 建索引,查询和恢复都很快。
4. 落地 Java 时最难啃的五个技术点
4.1 LLM 调用的统一抽象与流式响应
第一步是定义 ChatClient 接口。两种调用方式必须都支持:同步一次性返回,和流式增量返回。Java 里流式用 Reactor 的 Flux 比较顺手。
public interface ChatClient { ChatResponse chat(ChatRequest request); Flux<ChatChunk> stream(ChatRequest request); }不同模型适配器实现同一个接口。内部都是 HTTP 调用,非流式用同步 HTTP,流式用 WebClient 的 SSE 接收,再转发给前端。前端对话、动态输出这些场景都需要流式,所以这个接口值得尽早设计。
4.2 工具调用(Function Calling)的协议转换
模型返回一个 AssistantMessage,它的 toolCalls 字段是结构化的 JSON,比如[{ "name": "queryOrder", "arguments": "{\"orderId\":\"123\"}" }]。我们需要做三件事:
第一,把 arguments JSON 解析成 Java 对象。第二,做参数校验,包括类型、长度、白名单。第三,调用注册的 Tool 方法,把结果包装成 ToolResult 回传给模型。
这里最容易出错的点是模型偶尔会生成不合法 JSON,比如字符串缺少引号。我们的做法是解析失败时把错误信息直接作为 tool 结果返回给模型,让模型自己修正,而不是抛出异常中断流程。
4.3 超时、重试与熔断策略
模型服务的超时和熔断策略必须按 endpoint 单独配置。我建议一组比较稳的初始值:连接超时 3 秒,读取超时 60 秒。生成式模型响应时间波动大,读取超时不能设太短,否则容易误杀;但连接超时要短,避免被网络问题拖死。
重试要区分动作类型:幂等的读操作重试 2 次,非幂等写操作不做自动重试,只能人工审批后重试。熔断用 Resilience4j,滑动窗口统计最近 30 秒的错误率,高于 40% 就熔断 15 秒,期间直接返回降级结果。
| 策略项 | 推荐配置 |
|---|---|
| 连接超时 | 3 秒 |
| 读取超时 | 60 秒 |
| 幂等读重试 | 2 次 |
| 非幂等写重试 | 不自动重试 |
| 熔断窗口 | 30 秒错误率 > 40% |
| 熔断恢复 | 15 秒后半开 |
4.4 上下文窗口的压缩与截断
Agent 长时间对话,Token 很容易超出窗口。Java 生态没有 Python 里 tiktoken 那么方便,但我们有两个办法:一是接入模型的 tokenizer 或官方估算表;二是定义统一的 MessageWindow 策略,当消息数超过阈值时,把旧消息做一个摘要替换进上下文。
还有一个容易忽略的点:工具返回的内容可能很大,我们会对内容长度做阈值控制,超限只保留摘要,同时在审计表里保存完整结果。这样既不撑爆上下文,又不丢证据。
4.5 线程模型与连接池
Java 21 的虚拟线程对 Agent 场景是很大的优势。一个 Task 对应一个虚拟线程,让阻塞的模型调用不再占据昂贵的平台线程。如果你的系统还在 Java 17,要么用响应式 WebClient,要么把线程池调大,但要注意阻塞线程比例不能过高。
连接池也要分类隔离:LLM 出口连接池、数据库连接池、Redis 连接池要分开配,因为一个拖垮了会影响另一个。实测下来,LLM 出口最好单独配一个连接池,不然模型响应慢的时候会反过来拖垮数据库连接。
5. 企业级硬性要求:权限、审计、可观测与安全过滤
5.1 谁可以调用哪个工具:权限模型与强制校验
工具注册时就带上 permissionScope,比如crm:read、order:write。Agent 定义时声明自己需要的 scope。Task 执行时由 PermissionEvaluator 判断当前用户是否有权限发起这个调用。
这个模型借鉴了 IAM 的思想。权限校验一定要在工具调用层强制做,不能依赖模型不调用某个工具。因为模型只是生成文本,它不理解也不用理解权限边界。
public class PermissionEvaluator { public boolean canInvoke(User user, ToolInvocation invocation) { Set<String> required = toolRegistry.requiredScopes(invocation.toolName()); return user.scopes().containsAll(required); } }5.2 每条 prompt 都留痕:审计日志怎么设计才可用
审计日志是 Harness 平台最容易被低估的部分。我设计的 audit_log 表包含这些字段:request_id、user_id、agent_id、task_id、step_id、model、prompt_hash、tool_name、invocation_args_hash、result_summary、latency_ms、token_usage、created_at。
为了避免把敏感信息全部落库,prompt 和参数只存哈希和脱敏后的摘要。审计日志通过异步队列写入,但要注意保持顺序,否则同一个 Task 的执行记录会乱掉,排查时很难受。
这里我强烈建议用 request_id 作为所有日志和追踪的关联键,从用户发起请求那刻起就生成,贯穿所有环节。这是排查线上问题的基础设施。
5.3 敏感信息脱敏:输入输出双向过滤
在模型输入出口和输出入口分别做过滤。用户资料里的手机号、邮箱,在进入 prompt 前就被替换成占位符;模型返回的内容也要过一遍检测,避免模型生成与隐私相关的输出。
Java 实现这一层不复杂,核心是定义统一的 SensitiveFilter SPI,允许各业务线自己注册脱敏规则。比如营销线要脱敏客户手机号,客服线要保留手机号后四位做核对,规则不同,但框架是同一套。
5.4 可观测性:指标、日志与链路追踪
Micrometer + Prometheus + Grafana 这条链路在企业里已经很成熟。我们会暴露几个核心指标:agent_run_total(按 agent 与结果标签)、tool_invoke_total(按工具与结果标签)、llm_latency_seconds(直方图)、token_usage_total。
链路追踪依赖 MDC 里的 requestId 贯穿所有日志,这样查问题的时候只要拿到一个 requestId,就能看到这个 Task 在所有服务里的完整轨迹。这个对于多 Agent 协作和跨服务调用尤为重要。
6. 生产环境实测踩坑记录
6.1 流式输出的断线重连与消息游标
第一个坑是前端页面刷新后,SSE 断掉,后端还在继续跑。后来我们改成消息表存完整输出,前端重连时通过游标把没有消费的消息重新拉取,像读 Kafka 一样。
这里的关键是每条消息都带一个自增 seq,避免重复展示。断线重连后,客户端只需要记住自己消费到哪个 seq,从那个位置继续拉就行。这个设计在对话场景里非常实用。
6.2 长任务的内存压力与快照恢复
有一个每天跑很多次的长任务 Agent,Context 里存了大量中间变量,在 JVM 里越积越多,Young GC 频繁,服务响应变差。后来我们把 Context 做快照,超过阈值就序列化到存储,内存里只留远程引用。
这个方案多一次反序列化开销,但整体稳定性提升很大。如果任务规模继续涨,可以考虑事件溯源,把所有状态变更都变成事件流,彻底消除内存状态。
6.3 重试导致重复副作用的幂等设计
模型调用成功但响应丢失时,如果直接重试,上游可能重复下单。我们给所有写操作工具增加幂等键:参数里带一个 requestId + stepId,写数据库时做唯一约束。
这个设计一定要在 Harness 层面做,不要依赖各业务线自觉。工具调用记录表加唯一索引,重复的 invocation 直接返回上次的结果。幂等键的生成规则是:agentId + taskId + stepId + toolName。
6.4 几个零碎的工程经验
最后分享几个零碎但在生产环境里很关键的细节。
模型输出的 JSON 容错必须做。解析失败时不要把异常抛出中断,而是把错误信息作为 tool 结果返回给模型,让它自己修正。这个策略在处理真实流量时效果很好,模型自己修正的成功率很高。
Token 计费以模型返回的 usage 字段为准,不要用本地估算值对账,否则月底账单对不上。
前端对话在超时时间内要有 loading 和取消按钮,否则用户会重复点击,产生重复任务。这个看起来是前端问题,但后端 Harness 也得有对应的重复任务检测机制,两段都要处理。
目前 BizBuddy 已经在内部支撑了三个业务线的 Agent 场景,包括工单分诊、销售助手、知识库问答。我个人最大的体会是:Harness 平台的价值不在于把 Agent 变得多智能,而在于让 Agent 像普通接口服务一样被管理、被恢复、被审计。很多团队自研的时候容易把精力花在 prompt 和 tool 设计上,反而忽略了生命周期和治理这些不那么出彩但至关重要的部分。如果你也打算在 Java 技术栈里搭类似的平台,我的建议是先做最小闭环:一个 Agent、两个工具、一套审计,跑通后再加编排和熔断,不要一上来就追求大而全。