
先说一个可能反直觉的结论企业级 Agent 应用难点从来不在模型也不在提示词而在那个把模型、工具、权限、审计、重试、上下文全部串起来的“壳子”——也就是 Harness。我在某制造企业的中间件团队里用一个纯 Java 实现的方式把这个壳子做成了 BizBuddy。技术栈不花哨Java 17、JDK HttpClient、Jackson、虚拟线程外加一套我们自己设计的工具注解。这篇文章把所有设计与取舍摊开写包括踩过的坑希望能对在 Java 技术栈里做 Agent 的团队有一点参考价值。1. 为什么是纯 Java 而不是 Python 生态1.1 Python 栈在企业落地的几道坎聊 Agent 的时候默认背景几乎都是 Python。这个生态确实繁荣模型 SDK 一抓一把社区样例多到看不完。可真准备在企业里跑一个 7x24 的 Agent 服务时Python 栈有几个绕不过去的问题第一是依赖环境。明明本地能跑上了服务器又是版本冲突、又是依赖缺失想把 Python runtime 打进镜像体积和基础库维护成本都在涨。我们团队原本就是 Java 一条链路为了 Agent 再养一套 Python 发布体系运维接受度很低。第二是并发模型。Agent 场景天然是“一个会话一个独立运行循环”这可能长时间阻塞等待模型响应。CPython 的 GIL 加上 worker 线程池想做到高并发在线会话要么引入协程要么堆很多进程。不是说做不到而是要付出额外的架构复杂度。第三也是最关键的企业里现存的业务能力大多是以 Java 接口、内部 RPC、数据库表、消息队列形式存在。如果 Harness 是 Python 写的每次调用一个企业工具都得跨进程走一遍 RPC 或者 HTTP链路越长故障点越多。反过来用 Java 做 Harness工具可以直接依赖内部 jar 包、本地调用权限和审计也都在一个进程里闭环。1.2 Java 真正的加分项不是“简单”而是整合与交付网上讨论 Java 做 AI经常被嘲笑“重”“啰嗦”。这话只对了一半。BizBuddy 选择纯 Java核心原因是三点一是类型安全。Agent 的工具调用本质上就是把大模型的自由文本输入变成一类强类型的 Java 参数调用。用注解定义工具入参、出参编译期就能挡住大多数低级错误。模型给的参数不合法也是运行时先过 JSON Schema 校验再进业务方法不会因为反射误用把整个服务打崩。二是企业中间件亲儿子。注册发现、配置中心、监控埋点、权限网关这些在 Java 体系内都有非常成熟的客户端库。BizBuddy 接入它们只需要写几十行配置换成 Python往往要翻文档研究怎么走协议甚至得请中间件团队单独支持。三是交付链路一致。我们团队的 CI/CD、灰度发布、日志采集、JVM 调优全是围绕 Java 服务建的。Harness 用纯 Java 实现就只是一条新业务线不改变运维模型。1.3 什么时候不建议上 Java Harness把 Java 说得这么好也得泼点冷水。如果你面对的是以下情况请老老实实选 Python团队里没有 Java 熟手现学成本极高。你的模型服务只在 Python SDK 里有完整的高级特性而 HTTP 接口能力残缺或者不稳定。定位是几天内跑通 Demo不是长期运维系统。你的 Agent 需要极高的科学计算、数据处理灵活性Java 那套类型系统会拖后腿。BizBuddy 的背景是“企业级”不是“实验室玩具”。真实生产线上的 Agent长期运行的稳定性、可审计性、故障恢复能力优先级高于一切“快速写脚本”。这个前提决定了技术选型的方向。2. BizBuddy 的核心能力和必要组件2.1 Harness 到底管什么很多人以为 Agent 就是“用户提问 - 调模型 - 拿结果”。实际一条正经的 Agent 调用链至少包括会话生命周期管理Agent 会话什么时候开始、什么时候挂起等待人工确认、工具调用太久怎么限制。模型接入与路由不同模型服务商、不同模型版本的统一接入。工具注册与调用业务能力如何暴露给模型参数 Schema 怎么生成权限如何校验。上下文管理消息历史存储、Token 窗口裁剪、长短期记忆分层。输出验证模型的结果是否合法是否满足既定的 JSON Schema。审计与监控每次工具调用、每次模型请求留痕可观测指标埋点。BizBuddy 把这些统称为 Agent Harness。它本身不提供模型不提供业务工具而是提供一个“让模型能安全地调用企业能力”的运行时。类比一下模型是车手工具是引擎Harness 是整车的电控系统负责点火、换挡、限速、记录数据。2.2 分层设计Scheduler / Session / Toolbox / DeciderBizBuddy 没有用复杂的画布编排而是简单分层Scheduler负责启动 Agent 实例管理并发资源处理超时和总量控制。Session一个会话对应一个独立 Agent 运行循环内部维护消息列表、剩余轮次、中间状态。Toolbox工具注册中心。工具以 Java Bean 形式注册进来BizBuddy 自动生成模型要用的 JSON Schema。Decider决策组件本质是对模型返回做解释是普通文本回复还是要调用某个工具或者是请求人工介入。这种分层为了一个明确目的让“运行循环”和“工具实现”完全解耦。业务团队加一个新工具只需要写一个带注解的类完全不碰 Agent 循环代码。后来接入第三个客户场景时这个解耦帮了大忙——Agent 内核一行没改。2.3 取舍清单哪些功能第一版就要硬砍企业项目最容易死的地方是试图第一版就做“大而全”。BizBuddy 第一版明确砍掉了下面这些可视化流程编排不做拖拽画布Agent 逻辑用代码表达等需求稳定后再考虑图形化。多 Agent 通信不做复杂的 Agent 间消息路由先做单 Agent 多工具。长期向量记忆不做自建向量库统一用关键词搜索加业务表存储。实时代码解释器不直接执行模型生成的代码避免 RCE 风险。砍掉这些之后核心架构收敛到非常小的一段循环调模型 - 解释结果 - 调工具 - 回填上下文 - 再调模型。所有企业级复杂度通过插件和后置模块补充。先跑通“最小的完整循环”是 Agent 平台第一版最重要的事。3. 核心实现用 Java 把 Agent 跑起来3.1 用注解替反射工具注册与 Schema 生成工具注册的第一版我们走了弯路用运行时反射遍历方法再用一堆手写 Map 构造 Schema。结果工具一多Schema 和 Java 参数对不上模型经常传错名字。第二版改为“注解优先”。每个工具方法打上 ToolDefinition参数打上 ToolParamToolDefinition( name inventory.query, description 按 SKU 查询实时库存, visibility ToolVisibility.INTERNAL ) public InventoryResult queryInventory( ToolParam(description 商品 SKU 编码, required true) String sku, ToolParam(description 仓库编码缺省查所有仓, required false) String warehouseCode ) { return inventoryService.query(sku, warehouseCode); }BizBuddy 在工具注册阶段扫描这些注解自动生成模型的 tool schema。关键点是 Schema 的字段顺序、描述长度都有讲究。description 是给模型看的要写清“什么时候该用”“参数格式是什么”。比如仓库编码如果不标注“缺省查所有仓”模型可能每次都猜测性传一个不存在的值。权限校验也挂在注解上。每个工具可以配置允许的角色标签调用前由 SecurityManager 统一校验工具方法本身完全不感知。这样达到的效果是业务方加工具只需要关注业务逻辑权限、审计、限流全是框架的事。3.2 模型调用层网络、重试、超时与流式Java 生态没有官方统一的大模型 SDKBizBuddy 选择直接封装 HTTP API。底层用 JDK 自带的 HttpClient不走第三方重量级客户端依赖最少同时用 Jackson 处理 JSON。public interface LanguageModelClient { ChatCompletionResponse complete(ChatCompletionRequest request); void stream(ChatCompletionRequest request, ConsumerChatChunk consumer); ModelPingStatus ping(); }模型调用层有几个必须处理的细节连接超时统一 3 秒。模型服务是外部依赖连接超时太久会拖垮线程。读取超时普通模式给 90 秒流式模式不设全局读取超时但每个 chunk 之间要设 30 秒空闲超时。模型如果“半路咽住”要能及时发现。重试策略只有连接失败和 HTTP 429/5xx 才重试业务参数错误400/401绝不重试否则就是把错误请求反复打给模型。退避算法采用指数退避加抖动基础倍率 2上限 30 秒。流式输出没有直接上 Reactor而是用一个更简单的回调接口模型层按 chunk 回调业务层业务层把增量内容推给上游 WebSocket 或 SSE。这样第一版依赖最少也不阻塞后续替换。3.3 Agent 主循环规划-调用-观察-再规划Agent 核心循环设计得朴素但可靠。每个 Session 独立运行一段循环代码大致如下public final class AgentSession { private final ListChatMessage messages; private final Toolbox toolbox; private int turn 0; public AgentResult run() { for (turn 0; turn maxTurns; turn) { ChatCompletionResponse response model.complete( ChatCompletionRequest.create(messages, toolbox.schema()) ); if (response.hasToolCall()) { ToolInvocation inv response.toolCall(); ToolResult result invokeToolSafely(inv); messages.add(result.toMessage()); if (!result.isSuccess()) { messages.add(ChatMessage.system( 工具调用失败原因: result.errorMsg() 请分析原因并调整参数后重试或告知用户当前操作不可行。 )); } } else { return AgentResult.completed(response.text()); } } return AgentResult.limitReached(达到最大执行轮次); } }这里最容易被忽略的是“工具调用失败后的反馈”。早期版本里ToolResult 失败时我们直接 throws 异常中断循环结果模型完全不知道发生了什么用户只能看到一句“系统错误”。正确做法是把失败原因作为一条 system 消息喂给模型让它自己调整参数或换策略。这样用户体验好了很多Agent 的“自我纠错”才真正成立。另一个细节是 maxTurns 的选取。BizBuddy 默认是 8 轮并在配置里强调每多一轮就多一次完整模型调用成本和时延都翻倍。曾经有个业务方把 maxTurns 调到 20结果出过一次工具死循环模型反复查同一个接口费用账单吓人。3.4 上下文与记忆窗口裁剪如何不伤技能Java 里处理 Token 窗口比 Python 麻烦因为现成的 tokenizer 库不多。BizBuddy 第一版直接用“字符数除以 3”估算 Token结果到了中文场景完全不准经常把上下文算多了导致请求失败。后来换成了统一按模型服务商公布的 tokenizer 规则引入一个纯 Java 实现的字节对编码库Token 估算误差控制在 5% 以内。上下文管理核心是“分优先级裁剪”系统提示词永久保留这是 Agent 技能的总纲。当前工具结果尽量保留模型下一步需要依靠它。最近几轮对话完整保留。历史摘要轮数太早的消息降级为摘要而不是整段删除。单条超长内容截断并标注已被截断。这套策略需要业务细节配合。比如工具返回一个超长的 Excel 查询结果我们会在工具包装层做“结果压缩”只返回前 20 行摘要加一个统计说明“共 500 行完整结果可调用 export 工具获取”。模型既能看到全局又不会炸掉 Token 窗口。4. 内存、并发与可观测性的细节4.1 别把 LLM 调用塞进数据库事务这是 BizBuddy 开发中踩得最疼的一个坑。最初做会话持久化时我们把工具调用和模型调用都放在一个 Spring 事务里想着“要么都成功要么都回滚”。结果模型响应超时 90 秒事务就悬空 90 秒工具调用是外部 HTTP 请求不参与数据库事务但连接池一直被占用。后来彻底推翻这个设计原则只有一条LLM 调用永远不允许出现在数据库事务里。事务只负责持久化“决策结果”和“审计记录”模型调用则放在事务外状态先标记为 running得到结果后再开一个短事务更新状态和记录日志。这样虽然可能看到“状态短暂不一致”但换来了数据库连接池和整体吞吐的稳定。面向用户的长时操作用乐观状态机管理状态流转比强行做分布式事务靠谱得多。4.2 虚拟线程和响应式流怎么选Java 21 的虚拟线程对 Agent 场景是重大利好。BizBuddy 早期在 Java 17 上跑每个 Agent 会话占一条平台线程模型调用阻塞 60 秒200 个会话就能耗光默认线程池。后来升级到虚拟线程一个会话一条虚拟线程阻塞时不占用系统级线程轻松支撑几千个并发会话。但虚拟线程不是万能的。第一是别在线程内部加 synchronized否则会导致虚拟线程被固定到平台线程上一旦大量阻塞照样崩。第二是流式响应处理用了响应式流在某些环节可以降内存但 BizBuddy 因为要兼容虚拟线程模型实际使用的是“每会话一个运行循环 阻塞式流读取”内存和代码心智负担更可控。选虚拟线程还是响应式流我的评价是如果你的团队已经熟 Reactor/响应式用响应式没问题如果像我们一样更在意循环逻辑可读性虚拟线程加阻塞式同步调用就是最佳选择。4.3 审计日志与可观测性企业要用它“交代”企业级 Agent 平台有一个隐性但核心的需求叫可交代性。出了事故、报表异常、审计检查时你要能回答“Agent 当时为什么调了这个工具、模型原始回答是什么、工具返回了什么”。BizBuddy 的事件日志采用结构化事件流设计每个 Agent 会话会连续输出这样的事件事件类型关键字段SESSION_START会话ID、用户身份、Agent类型、输入摘要TURN_START会话ID、轮次、消息数、Token估算MODEL_RESPONSE会话ID、轮次、模型名、是否调用工具TOOL_CALL会话ID、轮次、工具名、参数快照TOOL_RESULT会话ID、轮次、工具名、返回摘要、耗时SESSION_END会话ID、总轮次、总Token、结果状态敏感信息默认脱敏。所有工具参数和返回内容经过一个统一脱敏器身份证、手机号、密钥字段自动打码再决定是否写入日志。这个功能在第二个业务场景介入时立刻凸显价值——对方安全团队只提了两个问题一个是“日志存在哪”一个就是“日志里会不会有敏感信息”。可观测性指标也不只盯着模型调用耗时。BizBuddy 额外埋点这些指标工具成功率与耗时分布按工具名标签Agent 会话存活时长分布平均会话轮次Token 消耗速率用于预算告警有了这些才能回答一个业务方最喜欢问的问题“这个 Agent 一个月烧了多少 Token办成了多少事。”5. 常见问题与坑点实录5.1 结构化输出模型死活不给你合法 JSON在 Java 里处理模型结构化输出比 Python 更脆弱因为类型转换一旦失败就是运行时异常。早期我们靠提示词要求“只返回 JSON”试了无数次它总是夹带解释文字、Markdown 代码块或者 JSON 里多了尾逗号。后来武器库变成三层优先用 function calling 机制强制输出让模型告诉我们“它想调用一个叫 report_answer 的工具参数就是结构化结果”。工具调用参数本身就是模型强制产出的 JSON远比自然语言回答稳定。万一拿到的还是文本用宽容解析器先剥掉 Markdown 围栏再尝试按 JSON 解析失败时尝试 JSON5 特性比如单引号、尾逗号。最后一步上 JSON Schema 校验解析出来的 JsonNode用开源校验库按预期 Schema 验证不通过就让模型基于错误重新生成一次。这一套组合下来结构化输出的成功率从最初的 80% 提到 99%剩下的 1% 靠重试两轮基本也能兜住。5.2 Token 计数不一致导致欠切或过切Token 估算偏差是最隐蔽的问题。不同模型服务商、不同模型版本tokenizer 规则都不一样。早期我们用一个通用库估算结果某个中文场景下模型侧实际 Token 是估算值的 1.6 倍导致系统提示词被误裁掉Agent 突然“失忆”。解决思路很朴素让 tokenizer 跟随模型类型走。每个模型配置里带一个 tokenizer 实现类估算时按模型类型动态选择。同时留出安全缓冲请求前把消息总长控制在模型上限的 80%而不是顶着上限发。多出的 20% 空间用来承接模型回复里可能包含的额外内容。提示如果你用的模型服务商支持返回 usage 字段一定把它存下来。用它校准你的估算逻辑比拍脑袋设系数靠谱得多。5.3 工具循环跑飞了Agent 循环跑飞是“看起来最蠢但实际最常发生”的问题。表现就是模型反复调用同一个工具参数还一模一样。一次排查发现根源是我们把工具返回的“未找到数据”包装成了成功结果模型看到 statussuccess以为数据真的存在于是开心地反复查。后来做了两个改进每个工具返回对象强制区分“业务成功”和“业务失败”。查询无结果属于业务失败必须设置 okfalse并附带“建议模型尝试其他查询条件”的人类可读原因。连续三次调用同一工具且参数完全相同触发短路保护直接结束本轮 Agent 执行并提示用户“需要换一个别的工具或告知无法完成”。5.4 Java 生态模型 SDK 不完善怎么办很多人问纯 Java 缺官方 SDK是不是硬伤。实际BizBuddy 的做法是不依赖任何特定模型 SDK只依赖“模型服务商暴露的 HTTP 接口”。我们定义一套极简的 ChatCompletionRequest/ChatCompletionResponse 模型再针对不同模型服务商写适配器。每加一个模型只加一个 adapter核心 Harness 不感知。这套方式的额外好处是私有化部署模型时常常只有 HTTP 协议甚至完全不标准自定义适配器反而更灵活。想想纯 Java 团队如果围绕一个第三方 SDK 开发一旦 SDK 停更或跟不上模型升级整个 Harness 就要跟着返工。5.5 问题排查速查表症状可能原因优先排查方向模型返回频繁超时HttpClient 读取超时太短 / 网络代理查看模型服务端响应耗时、调整超时时间工具参数总是错误Schema description 不够明确 / 参数名与业务术语不一致打印模型原始 tool_call 参数对照 Schema 字段检查会话 Token 超限估算不准 / 裁剪策略失效查看 usage 记录、确认 tokenizer 跟随模型类型工具调用成功但 Agent 中断工具结果被当成异常抛出检查工具包装层是否吞掉异常并转成结构化反馈虚拟线程下服务卡死synchronized 锁导致 pin 住检查代码里是否在虚拟线程内使用同步块日志里有敏感数据脱敏器未覆盖新工具在工具注册阶段强制要求声明敏感字段标签6. 后续扩展与个人心得6.1 从 Harness 到 Agent 平台BizBuddy 现在还是 Harness 层级严格说不是完整 Agent 平台。下一阶段准备扩展三块人工审批中间态Agent 遇到需要确认的高危操作时挂起会话并推送待办至人工系统审批通过后继续执行。知识库接入标准化把 RAG 能力抽象成普通工具与业务工具同等对待统一走权限与审计。指标驱动的会话回放基于结构化事件日志重新回放一次 Agent 会话的执行轨迹作为排障和调优的主要手段。这三块的底层事件模型已经有了所以并不需要推翻现有设计。Harness 跑稳平台只是时间问题。6.2 心态与成本纯 Java 的边界在哪里做这个项目之前团队最担心的就是“纯 Java 会不会拖慢迭代速度”。真正做完后发现边界确实存在但不是想象中的模型能力边界而是“对模型变化的响应速度”。Java 的编译、启动、依赖检查都比较重如果模型服务商今天换一个 API 字段明天改一个返回结构Java 侧适配总要慢一点。解决办法是用“约定优先”把事情固化前端、模型适配器、业务工具之间全部以标准 JSON Schema 为契约。模型接口变化被隔离在 adapter 层业务代码几乎不动。这套思路坚持下来BizBuddy 后续接入多个模型都没有伤筋动骨。回到开头那句话Agent 的核心难点在 Harness而 Harness 的核心难点又在“克制”。纯 Java 的约束反而帮了忙——它逼着你把接口想清楚再动手逼着你在架构上做减法。如果当初用 Python 几条脚本就拼出一个 Demo可能到现在还在为线上稳定性救火。最后分享一个实操习惯每接入一个新工具第一件事不是写实现而是先写这个工具的失败路径测试。工具 90% 的故障都不是业务失败而是参数校验、权限不足、下游超时。先把这些失败路径的返回结构定义好Agent 循环才有底气长期跑下去。这也是 BizBuddy 从设计到上线最值得保留的经验。