2024 年下半年我们团队开始认真琢磨怎么把大模型 Agent 能力接进手上那些 Java 存量业务系统。试了一圈当时流行的方案之后我发现一个尴尬的事实生态里做得顺手的 Agent 框架几乎都长在 Python 或 TypeScript 上而我们的生产环境、研发团队、运维体系全是纯 Java。于是就有了 BizBuddy——一个用纯 Java 自研的企业级 Agent Harness 平台。这篇文章不是手把手搭项目的教程而是一篇实打实的设计取舍复盘为什么坚持纯 JavaHarness 的模块边界到底怎么切一次工具调用的完整生命周期里藏着哪些坑企业级真正需要的审计、权限、可观测性又是怎么落地的如果你是 Java 后端工程师又正准备做 Agent 平台这一类基础设施这篇复盘应该能帮你省掉不少弯路。1. 为什么坚持纯 Java一次带着存量包袱的选型1.1 Python/TS 生态很香但接不进我们的运维体系先说背景。我们团队长期做 Java 后端线上跑着的服务、配置中心、日志系统、链路追踪、发布流程全部围绕 JVM 体系构建。2024 年下半年要接 Agent 能力时第一反应其实是要不要旁边挂一个 Python 服务毕竟当时最成熟的 Agent 编排生态都在那边。这个方案很快被否掉了原因很现实第一是部署与运维会割裂。挂一个 Python 服务意味着监控告警要新增一套体系日志采集要接新格式发布平台要支持新语言栈安全扫描要覆盖新的依赖库。这些成本并不体现在代码行数里而是散布在后续每一个上线日夜。第二是链路追踪会断裂。我们已有的全链路追踪系统只覆盖 Java 服务之间的调用如果模型调用和工具执行业务逻辑分布在两种语言里一次 Agent 请求的完整轨迹要跨两套系统拼排查一个超时问题得来回对时间戳。第三是团队技能匹配问题。让一个 Java 团队长期维护一个 Python 旁路服务等于人为制造了一个孤岛未来接手的同事学习成本也高。所以当时摆在我们面前的路线就只剩两条要么用 Java 生态里现成的编排框架要么自己做一个 Harness。1.2 现成 Java 框架的三个结构性问题Java 圈里其实已经有几个 LLM 编排框架我也翻了源码结论是它们解决让模型能调用函数这件事都做得不错但距离企业级 Agent 平台差得还很远。问题主要集中在三个方面。第一个是模型厂商绑定太重。这几个框架往往在设计上就和一个特定厂商的协议深度耦合切换模型要动业务代码。企业级的现实是不同模型在不同任务上各有优势有的擅长工具调用有的便宜适合摘要有的合规上只能部署在内网我们需要的是模型可替换而不是被某个厂商锁死。第二个是工具注册机制过于简陋。多数框架走的是注解 反射路线你写一个方法加个注解它就自动把方法暴露给模型。Demo 阶段很爽一旦工具复杂起来就露馅无法准确表达嵌套参数、枚举约束、字段间依赖连参数校验逻辑都没地方放。更要命的是权限声明、敏感字段标记、审计策略这些企业级元信息在注解模型里根本没有归属地。第三个是扩展点太少。Agent 循环是一个很敏感的控制面真正生产环境里我们必须在模型调用前后、工具执行前后插入一堆逻辑脱敏、限流、审计、成本核算、灰度开关。现成框架的钩子通常只暴露在几个固定位置想塞东西进去就得改源码或者绕路。所以我当时的判断是这些框架适合快速 Demo 验证但我们要做的是承载真实业务动作的 Harness必须把控制面握在自己手里。1.3 纯 Java 的真正代价选纯 Java不全是好处代价我也可以直接说。我们自己补了不少原本可以借生态现成能力的轮子主流厂商 API 协议差异需要统一层抹平流式输出要自己处理分片和工具调用事件解析JSON Schema 的生成和校验要自己维护Agent Loop 的并发回填、截断策略、超时重试都要从零写。但换个角度看这些能力恰恰是 Harness 平台的核心壁垒而不是边角料。把它们写成自有代码意味着后续可以按我们业务的需要无限调整。纯 Java的另一层好处是构建物统一。交付物就是一个标准 JVM 服务直接进已有的镜像构建和发布流水线底层基础设施、配置中心、权限网关都不用为它开新口子。换句话说这个选择不是为了技术情怀是在我们的存量技术体系里总拥有成本最低的一条路。2. Harness 的边界BizBuddy 从第一行代码就确定好的模块划分2.1 什么是 Agent Harness它和 Agent 框架差在哪Harness这个词在业内没有一个严格标准定义我的理解是模型本身是发动机而 Harness 是整辆车的仪表盘、油门刹车、油箱管路和故障告警系统。一个 Agent 框架通常只解决怎么把模型调用包装成可编程 API这件事而 Agent Harness 还要负责回答更麻烦的问题模型决定调用工具之后这个调用在真实系统里怎么安全落地调用失败怎么处理谁被授权调用这次调用花了多少钱出了事故怎么回放BizBuddy 的定位很明确面向工具型 Agent。它服务的场景是那些需要调用内部系统完成实际动作的任务比如查订单、改工单状态、发通知、跑统计报表。至于纯闲聊、开放域问答这类不需要工具副作用的场景不是它的舞台。2.2 七个核心模块以及它们之间的依赖关系代码还没写几行我们先把模块边界定了下来因为 Harness 平台最怕的就是模块职责模糊最后变成一个大泥球。最终拆成了七个模块模块职责是否需要外部依赖ModelGateway统一封装各类大模型接口负责流式/非流式、超时重试、模型路由是对接模型服务ToolRegistry工具注册、元信息管理、JSON Schema 校验否本地注册 配置AgentLoop核心编排循环控制 Reasoning-Action-Observation 的流转否MemoryManager对话历史管理、上下文窗口裁剪、摘要压缩否PolicyEngine权限校验、工具级 ACL、限流策略、token 预算是对接权限中心TraceStore全量轨迹存储支持日志检索和请求回放是对接日志存储EventBus事件发布订阅把 Agent 生命周期事件通知给旁路系统否内部实现这七个模块之间的依赖方向是单向的AgentLoop 依赖 ModelGateway、ToolRegistry、MemoryManager、PolicyEngine并往 TraceStore 写轨迹EventBus 只做解耦通知不让业务逻辑反向依赖它。2.3 一条对外 API 的定位边界BizBuddy 对外只提供两类入口一类是同步编排 API适合在线问答、简短任务另一类是异步任务回调适合需要跑多轮工具调用、耗时长、且结果通过回调通知业务方的场景。一开始我们想过去做可视化流程编排画布和RAG 文档处理后来都砍掉了。原因很简单边界外的东西越做越多核心控制面就会被稀释。RAG 有专门的文档服务和向量库团队负责BizBuddy 不应该关心文档切块和向量检索可视化编排是另一套产品逻辑也不是 Harness 该做的事。这条边界对后期维护带来的好处是巨大的。我们每次改动模块内部逻辑对外接口都很稳定业务方接入后几乎不需要跟着改代码。3. 一次工具调用的完整生命周期从大模型输出到真实副作用3.1 一个带工具的场景实例拿我们内部工单助手场景举例。业务方注册了三个工具查工单详情、更新工单状态、给处理人发通知。用户提问帮我查一下单号 T-20250318-007 为什么一直没人处理。接下来发生的事是这样的AgentLoop 把用户问题、系统提示词、三个工具的定义摘要一起发给模型。模型判断需要调用查工单详情工具返回一个结构化的工具调用请求BizBuddy 校验权限、执行工具、把查询结果以工具消息的形式回填给模型模型看到工单确实停留在一个待分配状态又决定调用更新工单状态把这个工单转派给某处理人接着再调用发通知告知对方最后组织一段话回复用户。这个过程看起来顺滑但每一环都有大量细节必须控制好。3.2 核心接口与 Agent Loop 的样子BizBuddy 里最核心的接口是工具执行器定义非常收敛public interface ToolExecutor { ToolSpec getSpec(); ToolResult execute(ToolInvocation invocation, AgentContext ctx); } public record ToolSpec( String name, String description, MapString, Object parameterSchema, SetString requiredAccess ) {} public record ToolInvocation( String callId, MapString, Object arguments, String agentSessionId ) {} public record ToolResult( boolean success, String message, MapString, Object data, long costTokens, long createdAt ) {}围绕这套接口AgentLoop 主循环写成下面这个样子public AgentResponse run(String userInput, AgentContext ctx) { ListMessage messages ctx.history().trimToWindow(); for (int i 0; i maxIterations; i) { ModelResponse resp modelGateway.complete(messages, toolRegistry.specForModel()); if (resp.hasToolCalls()) { ListToolResult results executeSafely(resp.toolCalls(), ctx); messages appendToolResults(messages, resp.toolCalls(), results); continue; } return AgentResponse.finish(resp.text()); } return AgentResponse.deadlineExceeded(); }两个细节要展开说。一个是maxIterations。我们取的经验值是 8 到 12太小任务跑不完太大会让模型陷入工具调用死循环浪费预算。另一个是trimToWindow。MemoryManager 必须在每一轮循环之前把历史裁剪到模型上下文窗口的安全水位通常保留最近几轮 Message再加一个较早历史的摘要防止上下文爆炸。3.3 工具入参校验、执行防护与结果回填模型给出的参数很多时候是不靠谱的这是必须接受的事实。它可能把枚举值写错可能缺少必填字段甚至可能在数字字段里给出一个字符串。BizBuddy 在真正执行工具之前会做三层检查第一层是 JSON Schema 校验。ToolRegistry 里存的是标准 JSON Schema模型返回的 arguments 必须通过校验才能进入下一步。校验失败的调用会直接作为错误结果回填给模型并提示参数不符合工具定义请修正后重新调用。第二层是权限校验。PolicyEngine 会检查当前用户、当前租户是否具备该工具声明的requiredAccess。注意权限校验发生在参数校验之后并不合理应该前置因为没必要为一个无权限的人做完整参数解析。第三层是敏感字段处理。工具入参里可能带手机号、身份证等敏感信息在执行前先做脱敏替换保证审计日志里不落明文。工具执行本身也要包一层防护包括超时控制、幂等键、异常兜底。实测下来很多工具是 HTTP 调用外部系统超时是最常见的坑。所以我们给每个工具配置了独立的超时时间并强制要求重试时必须携带同一callId避免重复扣减库存或者重复发通知。3.4 流式场景下工具调用块怎么从 token 流里捞出来这是开发过程中最折磨人的一部分。流式模式下模型是一块一块往外蹦 token 的一个完整的工具调用 JSON 会被切割到几十个 chunk 里甚至一个 JSON 字段名都会被劈成两半。一开始我们图省事把流式 chunk 全部拼成字符串等收到结束标志再整体解析。结果发现有概率永远等不到闭合括号——模型在输出工具调用之外还有自然语言拼字符串的方式根本区分不了工具调用 JSON 结束了和JSON 只是被切在中间。正确答案是做一个按事件边界组装的状态机维护一个 per-session 的缓冲结构流式响应中只要是工具调用事件就按事件类型增量填充字段名和字段值一旦检测到当前事件完整闭合立即把组装好的 ToolCall 交给执行器不等整个流结束。这块代码量不大但思路必须一开始就对否则后期流式工具调用一多就乱。4. 企业级不是形容词审计、权限、可观测性是怎么落地的4.1 双向审计模型看到的和工具实际做的不能只记一版Demo 阶段我们只记录模型最终回答和工具最终结果后来一次线上事故让我们重新设计了审计模型。产品侧反馈某个工单状态被 Agent 改错了我们回看日志发现模型确实调用了更新工单状态工具但日志里只记录了工具名称和返回结果完全看不出来当时传入的action和reason是什么也看不到模型是基于什么信息做出的这个决定。从那次之后BizBuddy 的审计改成双向记录第一向是模型看到了什么。也就是每一轮发给模型的完整系统提示词和工具返回内容但入参和出参里的敏感字段会先做掩码再落到 TraceStore。手机号存成138****1234身份证只存指纹这样既支持追溯又不违反数据安全要求。第二向是工具实际做了什么。每次工具调用完整的入参、出参、耗时、错误堆栈、幂等键、执行结果全部落库。语义上要能回答这个 Agent 在什么会话背景下、基于哪些上下文、调用了什么工具、传了什么参数、对业务数据造成了什么影响。这版审计模型上线后排障效率明显提升。业务方再反馈Agent 好像改错了数据时我们能从 requestId 出发在半分钟内还原整条链路而不是靠猜。4.2 权限模型工具级 ACL 加动态策略工具暴露给模型之后模型就成了一个可以调用工具的执行者但真正的权限主体还是背后的用户。BizBuddy 把权限分成两层第一层是静态声明。每个工具在注册时必须声明requiredAccess比如ticket:write、notify:send。工具定义里没有声明权限任何人和任何 Agent 都不能执行它。第二层是动态策略。PolicyEngine 启动时从外部权限中心拉取当前租户的策略配置支持用户维度允许/拒绝、角色维度允许/拒绝、临时放行、并发数限制。策略变更后无需重启服务下一次工具调用就会生效。实测中的教训是权限校验必须下沉到 Harness 内部而不是依赖业务系统自己在工具函数里做二次校验。原因很现实模型可能会在多个工具之间组合调用形成越权链路如果每个工具只校验自己的权限组合起来就可能绕过意图限制。举个简单例子一个工具允许读取工单另一个工具允许发送通知模型分别调用两个工具就能把敏感信息发出去。所以PolicyEngine 还需要支持链路上限的配置比如禁止模型在单次会话中同时调用涉及读取和发送的两类工具。4.3 成本与限流给每个请求一个 token 预算模型调用是真实成本企业里不可能让 Agent 无限跑下去。我们给 BizBuddy 做了三层成本控制第一层是单任务 token 预算。每个 Agent 会话在创建时指定一个预算上限MemoryManager 每轮循环都会累计已消耗 token超过预算直接终止循环并返回本次任务已超出预算上限的明确提示。这个数字可以根据场景调整简单问答任务预算低复杂流程任务预算高。第二层是单工具费用预估。每个工具执行完BizBuddy 会估算本次调用大约消耗了多少 token包括工具定义占用的 token 和回填结果占用的 token累计写入 TraceStore。我们内部一个统计服务每天按租户汇总费用业务方做预算规划时直接看报表就行。第三层是租户级 QPS 限流。既限制模型网关的调用频率也限制工具执行的整体频率。流量突发时先拒绝新会话而不是挤压已有会话避免把下游业务系统打垮。有一回同事在测试环境写了个递归工具调用模型连续调用了 30 多次工具如果没有单任务 token 预算兜底那一次的账单金额足够吃一整周午饭。这个机制不是可选项是必需品。4.4 可观测性设计一条链路数据覆盖到回放每个 Agent 请求从进入 BizBuddy 开始就会生成一条执行轨迹内部叫 Trace。Trace 里有多个 SpanSpan记录内容agentSession会话创建信息、用户、租户、初始化消息modelCall模型名、消息数量、补全方式、token 数、耗时toolCall工具名、入参、出参、错误信息、耗时policyCheck权限校验结果、策略命中情况actionResponse最终回复、结束原因正常/超预算/超迭代所有 Span 通过 requestId 串起来最终落到日志存储系统。排障时输入 requestId可以完整还原每个步骤包括模型每一轮看到了什么、调用了什么工具、工具返回了什么。这套机制真正发挥价值是在一次下游 API 故障排查中。工具调用本身成功但工具内部调用的外部服务返回了 5xx工具把错误消息原样带到了回复里。通过 TraceStore 我们直接看到失败发生在外部依赖上而不是 BizBuddy 的逻辑错误省掉了两三个小时的跨团队扯皮。5. 几个最值回票价的设计取舍与踩坑记录5.1 全量透传还是先压缩可审计性和上下文窗口的对立工具返回结果往往很长比如查订单可能返回几十条记录全量塞进对话历史两轮工具调用就能把上下文窗口挤爆。但如果不全量塞又担心模型漏掉关键信息。我们最初尝试的是先压缩再回填工具执行完用一个摘要模型把结果压缩成两三句话再把摘要回填给模型。这个方案省窗口确实明显但很快暴露了问题摘要模型可能漏掉关键字段比如某个订单的加急标志。模型看到摘要后判断无需处理而真实数据明明需要立即升级处理。后来的取舍是TraceStore 永远存全量工具出参这是审计底线回填给模型的内容则分两层——如果结果很小全量回填如果结果很大回填结构化摘要但摘要里必须保留关键业务字段金额、状态、时效节点、异常标记同时把原始数据的一个可查询索引也放进消息中提示模型如需要更多字段请调用 getOrderDetail 进行精确查询。这个方案既保住了窗口又给模型留了按需取数的路径。代价是需要在工具注册信息里额外声明哪些字段是关键字段这部分我们走了手写配置后面再说。5.2 手写 JSON Schema 而不是注解生成刚开始做 ToolRegistry 时我们用了一套注解反射机制想省事。直到连续三个工具出现同样的线上问题模型调用时给的参数结构总是不对比如把一个嵌套对象拍平传进来或者把枚举值传成同义词。追根究底问题出在注解模型表达力太弱。它无法准确表达当 action 为 escalate 时escalateReason 必填这种字段间依赖也没法表达复杂的一层嵌套结构。而模型真正需要的是清晰、完整的 JSON Schema。后来我们把工具定义全部改成手写 Schema 文件放在工具包 resources 目录下启动时做静态加载和校验。定义看起来是这样{ type: object, properties: { issueId: { type: string, description: 工单ID }, action: { type: string, enum: [assign, resolve, escalate], description: 要执行的动作 }, assigneeId: { type: string, description: 转派对象ID当 actionassign 时必填 }, escalateReason: { type: string, description: 升级原因当 actionescalate 时必填 } }, required: [issueId, action], allOf: [ { if: { properties: { action: { const: assign } } }, then: { required: [assigneeId] } }, { if: { properties: { action: { const: escalate } } }, then: { required: [escalateReason] } } ] }手写确实费一点功夫但换来的是模型理解的准确率和参数校验的严格程度。工具是我们与真实业务系统的边界值得把定义做得更精确。5.3 并行工具调用的乱序回填会让 Agent 失控现代模型喜欢一次返回多个工具调用尤其在查一下 A 和 B 再处理 C这类场景里。BizBuddy 也支持并行执行但中间踩了一个大坑并行工具的执行结束顺序和模型请求中的调用顺序经常不一致。举例模型一次请求了三个工具顺序是queryOrder、queryUser、sendNotify。我们并发执行结果queryUser一秒返回queryOrder三秒返回如果按完成时间回填消息模型看到的消息顺序就变成了queryUser、queryOrder、sendNotify但每个消息对应的 ToolCallId 还是原来的模型很容易错乱误以为某个结果对应另一个工具。修复方式简单但关键回填时强制按模型请求中的原始 index 排序而不是按完成时间。执行可以并行但消息列表必须按原始顺序组装。这是 AgentLoop 里必须守住的纪律。ListToolResult results parallelExecutor.execute(toolCalls); return toolCalls.stream() .sorted(comparingInt(ToolCall::index)) .map(call - new ToolResultMessage(call.callId(), results.byCallId(call.callId()))) .toList();5.4 工具报错时模型会自己圆场而不是说实话这可能是所有 Agent 系统里最容易翻车翻得不知不觉的坑。工具调用失败ToolResult 里明确了错误信息但模型在组织最终回答时倾向于把错误轻描淡写甚至编造一个看起来合理的成功结果。我们遇到过一次真实翻车库存查询工具因为上游超时返回失败模型却没有告诉用户查询失败而是直接回复库存充足可以正常发货。如果不是业务方后来抽查发现了问题这个错误会直接导致超卖。根因是太依赖模型自觉。大模型的训练目标里有强烈的助人性它不愿意让对话以失败收场所以会主动补全缺失信息哪怕信息是编的。修复有两步。第一步ToolResult 的 error 状态必须以结构化字段显式回填给模型并在系统提示词里写明如果工具返回 error你必须在最终回复中如实复述具体错误信息禁止替工具声称成功。第二步在模型回填后增加一个轻量校验层如果最终回复的文本里包含成功之类的正向词但对应工具调用记录里存在 error 结果就拦截回复并强制改写为工具执行失败的提示。这个拦截逻辑不完美会有误伤但相比让模型自由发挥牺牲一点回复流畅度是值得的。6. 实测效果与如果再让我做一遍6.1 内部工单助手场景的灰度表现BizBuddy 目前在我们内部一个工单助手场景里灰度运行。业务方原本需要人工处理的重复咨询现在 Agent 可以先做自动分类和初步处置拿不准的再转人工。灰度两周的数据大致是62% 的重复咨询由 Agent 直接闭环平均响应时间从十几分钟降到几十秒人工工单量下降明显。工具调用累计超过三万个整体成功率保持在 97.8% 左右失败主要集中在外部系统超时和模型参数校验收敛前的早期版本。这个成绩不算惊艳但至少证明纯 Java 自研 Harness 这条路在真实业务里是走得通的。我们并没有追求百分百自动化——无法优雅降级的场景还是直接抛给人工反而让业务方更愿意配合推广。6.2 如果重新开始我会调整的三个优先级现在回头看有两三个决定如果当时排了更高的优先级整个开发过程会顺利很多。第一先把 TraceStore 和回放能力做出来再写 AgentLoop。AgentLoop 的每一轮迭代都在制造复杂度没有回放能力时分析一个问题要打断点、加日志效率极低。有了完整 Trace开发调试的反馈闭环会快非常多。第二先用一个真实场景把全链路跑通再谈通用化。我们一开始花了大量时间设计什么都能接的接口结果发现很多抽象都是拍脑袋想的。绑定工单场景之后接口才一步步收敛成现在这套 ToolExecutor 的样子。第三工具注册先走静态白名单不要急着做动态加载。动态加载工具包很酷但会引入类加载器隔离、依赖冲突、热更新权限等一系列麻烦而这些对早期验证业务价值没有帮助。静态白名单足够支撑前三个场景等到工具数量真正过百再考虑动态化也不迟。如果让我重新选一次我还是会从纯 Java 起步。这个选择没有把任何生态红利让出去因为对我们来说最大的红利从来都是运维体系、人才梯队和审计合规的统一。