
最近一直在折腾 AgentScope 的 Java 落地demo 阶段最爽内核一调通什么都能聊。可一到生产问题全变了并发一上来线程池先炸模型超时没人管工具调用没有审计重启一次会话状态全丢。折腾了一圈发现真正值得花心思的不是 Agent 内核而是它外面那层 Harness 工程层——把内核装进生产边界让它值得被信任。这篇文章是 AgentScope Java 实战系列第 02 篇专门讲 Harness 怎么设计、怎么拆、怎么落地。适合所有正在把 Java 智能体项目从 demo 推向线上的人。1. Harness 工程层到底解决什么问题1.1 从 Agent 内核到生产系统之间缺了什么先定义一下这两个词。Agent 内核我指的是 AgentScope 里真正负责“思考”的那部分读取消息、调用模型、根据中间结果决定下一步是继续聊还是调用工具。它不关心请求从哪儿来、线程池多大、日志往哪写。Harness 工程层是包在内核外面的那圈“生产边界”。请求进来先过 harness响应出去也先过 harness。它负责的状态包括会话生命周期、并发控制、超时重试、工具执行权限、上下文窗口裁剪、可观测性埋点、优雅停机。如果把 Agent 内核比作发动机harness 就是整车电子电气架构。发动机决定车能不能跑但决定这辆车能不能安全地跑在公共道路上的是电子电气架构。很多 Java 项目翻车不是内核不行是根本没有这一层。常见的表现包括一个 Agent 实例被多个请求共用上下文互相污染模型服务偶发超时线程全部卡在阻塞调用上工具方法直接暴露给模型没有任何权限校验服务重启时正在跑的会话被强行中断消息丢失出了问题只能看业务日志找不到一次完整请求的调用链。这些问题没有一个是“算法”问题但每一个都能让项目在线上死掉。Harness 工程层就是把这些工程问题统一收纳的地方。很多从 Python 迁移到 Java 的团队最初只把 AgentScope 当消息库用写完 prompt 调通模型就以为完事了。一直到压测才发现模型调用并发一高HTTP client 连接池先不够用会话一多内存里对象数量失控一次工具调用抛异常整个请求直接 500。这些事不会发生在 demo 里只会在生产环境排队等你。1.2 Harness 与 Agent 的边界谁负责思考谁负责承接“harness 和 agent 区别”是很多人刚接触这个概念时最容易懵的地方。我在代码评审时常用一个判断标准新的改动如果是为了让 Agent 更聪明放在 Agent 里如果是为了让系统更稳、更可控、更方便运维放在 Harness 里。具体点说Agent 内核里的代码应该只关注怎么把用户消息转成消息对象要不要调用某个工具、选择哪个工具如何把工具返回结果合并进上下文模型输出的结构化解析。Harness 工程层关注的是另一套东西请求进来时当前会话处于什么状态一个会话最多跑多少轮、消耗多少 token模型调用超时多久需要重试、重试几次工具执行前是否通过权限校验关键节点是否产生了 trace 日志JVM 停机时在途请求怎么处理。这两层之间通过 AgentScope Java 的接口解耦互相不碰内部实现。这样做的好处是内核可以独立做单元测试harness 也可以单独做压力测试。换模型、换工具、换部署环境都不需要动对方。边界一旦模糊就会出现“Agent 里写超时、Harness 里调模型”这种混乱到时候线上出了问题连该找谁背锅都说不清楚。2. AgentScope Java 里 Harness 的核心设计2.1 一个最小可落地的 Harness 接口在动手写业务之前先把最小接口定义清楚。我习惯从这三个维度切生命周期、执行入口、状态查询。Harness接口大致长这样public interface Harness extends AutoCloseable { void start(); HarnessResponse run(HarnessRequest request); HarnessState state(); void close(); }start()负责初始化内核、创建线程池、加载插件、连接模型通道run()是唯一入口所有外部请求都走这里state()返回当前状态close()负责优雅释放资源。为什么这里不把 Agent 内核直接暴露给 Controller因为一旦外部可以直接调agent.run(...)所有工程能力都没地方放了。Harness 作为一个门面外面的人只认识门面不认识背后的内核。这也是“生产边界”的第一个含义边界内外依赖方向是单向的。实现上可以把请求拆成几个阶段预处理、校验、调用内核、结果后处理。每个阶段都可以被替换比如加一个过滤器链。下面是一个比较稳的骨架public class DefaultHarness implements Harness { private final Agent agent; private final ExecutorService executor; private final HarnessMetrics metrics; private volatile HarnessState state HarnessState.CREATED; Override public HarnessResponse run(HarnessRequest request) { checkState(); return new HarnessPipeline(agent, executor, metrics) .pipe(new ValidateStage()) .pipe(new ContextStage()) .pipe(new AgentStage()) .pipe(new ToolStage()) .pipe(new OutputStage()) .execute(request); } }这里面每个 Stage 都很薄但组合起来就是一条可控的链路。后面对接 Spring Boot 时Controller 只需要拿到Harness接口不需要感知 Pipeline 内部发生了什么。2.2 生命周期状态机为什么不能直接 new 完就跑生产环境里一个 Harness 实例可能同时服务多个会话如果不管理生命周期会出现两个经典问题服务还没初始化完请求就打进来了服务已经开始关闭新请求还在往里塞。所以 Harness 内部应该维护一个状态机。我常用的状态有六个CREATED - STARTING - RUNNING - STOPPING - TERMINATED加上一个FAILED。start()方法里按顺序执行初始化全部成功后把状态置为RUNNING。任何一步抛异常进入FAILED并释放已经创建的资源。close()里先把状态改成STOPPING然后停止接收新请求等待在途请求完成最后销毁线程池置为TERMINATED。状态切换的代码可以用AtomicReferenceHarnessState实现避免加一堆锁private final AtomicReferenceHarnessState stateRef new AtomicReference(HarnessState.CREATED); private void checkState() { HarnessState s stateRef.get(); if (s ! HarnessState.RUNNING) { throw new IllegalStateException(Harness not running: s); } }有了状态机以后单元测试也好写在CREATED状态下调用run()必须抛异常在STOPPING状态下调用也必须抛异常。边界行为是可断言的这比“调用试一下看崩不崩”靠谱得多。实际线上出问题最多的就是“半初始化状态”。有人启动时加载配置失败Harness 却已经把端口暴露出去监控看到大量 500。有了状态机这类问题在调用入口就被挡掉了。2.3 把模型通道当成可替换的端口AgentScope Java 里内核不直接依赖某一个模型厂商的 SDK而是依赖一个抽象的ModelClient。Harness 在启动时负责把真实模型通道注入进去。比如接入 DeepSeek 的 chat 模型配置可以长这样示意写法具体 builder 按你用的版本调整ModelConfig config ModelConfig.builder() .model(deepseek-chat) .apiKey(System.getenv(DEEPSEEK_API_KEY)) .baseUrl(https://api.deepseek.com) .timeout(Duration.ofSeconds(30)) .maxRetries(2) .build(); ModelClient client new OpenAIChatClient(config);这样设计的好处有三个。第一切换模型不需要改内核代码改配置就行。第二可以给测试环境注入一个 mock client返回固定消息用来做回归。第三harness 可以在模型调用前后统一埋点、统计 token因为所有模型都走同一个端口。很多项目做到后面会同时接多个模型便宜的模型处理简单闲聊强一点的模型处理复杂任务。如果一开始就把模型 SDK 散落在业务代码里后面根本没法切。Harness 这一层把“模型”抽象成“端口”是降低未来运维成本最关键的一步。3. 实操把内核装进生产边界3.1 工程骨架不要让模块长成一坨Java 工程最怕把 Agent 内核、Harness、Web 控制器、工具函数全部塞在一个 module 里。我习惯拆成三个 Maven 模块agentscope-api只放接口和 DTO不依赖任何实现agentscope-runtimeHarness 实现、内核实现、工具执行器等agentscope-serverSpring Boot 入口只负责把 HTTP 请求转成HarnessRequest。模块依赖方向是单向的server依赖runtimeruntime依赖api。这样底层模块可以单独测试也不会出现“为了部署一个 Agent 把整个 Spring Boot 容器都带起来”的情况。换一个场景也能体会到这个拆分的好处如果要写命令行工具、批处理任务、或者消息队列消费者那只需要依赖runtime不需要引入 web 容器。模块边界就是复用边界。我在实际项目里还遇到过一个更现实的问题同一个 Agent 内核既要在定时任务里跑离线批量推理又要在 Web 接口里跑实时对话。如果不拆模块定时任务就会被迫启动整个 Web 容器浪费资源不说还容易因为端口冲突挂掉。3.2 一个完整的会话闭环请求到响应全链路下面这条链路是我在生产环境实跑过的也是 AgentScope Java 项目最常见的拓扑HTTP 请求进入 ControllerController 解析出sessionId和userMessageHarness 根据sessionId找到或创建会话上下文Harness 把上下文合并成消息列表交给 Agent 内核内核判断需要调用工具返回工具调用意图Harness 执行工具权限校验、超时、审计内核拿到工具结果继续生成最终回复Harness 记录指标返回响应。用代码描述核心步骤大概是public HarnessResponse execute(HarnessRequest req) { ChatSession session sessionManager.getOrCreate(req.sessionId()); MessageList messages session.appendUserMessage(req.userMessage()); AgentReply reply agent.run(messages, ctx - { // ctx 是工具调用回调由 Harness 提供实现 ToolResult result toolExecutor.execute(ctx.toolName(), ctx.args()); return result; }); session.appendAssistantMessage(reply.text()); return HarnessResponse.ok(reply.text()); }为什么工具调用不放在内核里因为工具执行涉及到权限、鉴权、审计、外部系统限流这些是典型的平台能力不是模型决策。让内核直接执行工具等于跳过了一整层安全边界。放在 harness 里每次工具调用都经过统一出口出了问题能追溯。这也是我在评审业务代码时反复强调的一点Agent 可以决定“要不要用工具”但绝不能决定“怎么执行工具”。3.3 上下文窗口不是无限的模型有上下文窗口Java 内存也不是无限的。如果不管理会话长度一个高频会话跑上一天消息列表可能膨胀到几万条。第一次调用模型直接超时。Harness 负责做滑动窗口裁剪。我常用的策略是总是保留系统提示词总是保留最近 N 轮对话如果总 token 估算超过上限优先丢弃最早的中间对话如果单条消息太长先做摘要再保留。一个简单的估算方法中文字符按 1 token / 0.6 字估英文按 1 token / 4 字符估。除非你要做精确计费否则不需要逐字调用 tokenizer估算够了。这个估算方法虽然粗略但在滑动窗口裁剪场景下足够稳定。真要精确等模型返回时再通过响应里的 usage 字段校准就行。public MessageList trimToFit(MessageList messages, int maxTokens) { if (messages.estimatedTokens() maxTokens) { return messages; } // 保留第一条丢弃最老的中间消息直到满足上限 }这个裁剪逻辑放在 Harness 里内核不用管上层窗口策略。生产环境里我给不同会话分配不同上限普通会话 8k复杂任务会话 16k超了就裁剪。有一段时间我发现内存持续增长排查到最后就是会话对象被全局 Map 持有裁剪逻辑只处理了消息列表没有处理会话本身的过期。后来给 session 加上了空闲淘汰机制问题才算根治。3.4 并发控制与线程池隔离AgentScope Java 内核本身不是线程安全的吗严格说无状态 Agent 可以并发跑但有会话状态的 Agent 必须隔离。更加稳妥的做法是每个会话一个轻量级执行切片线程池按业务域隔离。我实际用的线程池参数核心线程数CPU 核数最大线程数CPU 核数 * 2队列有界队列容量由压测决定拒绝策略CallerRunsPolicy至少不会无声丢请求模型调用单独用一个池避免工具调用把模型线程饿死。这个池不是越大越好。线程太多反而会因为上下文切换和 IO 等待把服务打垮。瓶颈通常在模型 API 的并发限制而不是本机 CPU。很多人以为把线程池调大就能提升吞吐结果就是下游模型限流本机线程大量阻塞在等待响应上CPU 空转线程切换开销却上去了。还可以用Semaphore控制并发模型请求数防止瞬间流量把模型通道打爆private final Semaphore modelPermits new Semaphore(32); public ModelResponse callModel(MessageList messages) { if (!modelPermits.tryAcquire()) { throw new TooManyRequestsException(model concurrency limit); } try { return modelClient.chat(messages); } finally { modelPermits.release(); } }信号量配合线程池一起用既能保护下游又能让本系统在过载时快速失败而不是无限排队。4. 常见问题与排查技巧实录4.1 “Harness failed to load plugins” 到底怎么回事这个错误我在网上看到很多人问自己也踩过。插件加载失败的常见原因有三类插件 JAR 在 classpath 里但不在插件目录里Harness 扫描目录时找不到插件依赖的第三方库和主应用版本冲突加载时NoClassDefFoundError插件类需要有 public 无参构造器但写成了私有构造器或带参构造器。排查思路不要上来就翻源码。先看启动日志里插件扫描路径打印的是什么然后对照路径去确认 JAR 是否存在。再用java -Xlog:classloadinfo -jar app.jar看具体是哪个类加载失败。如果是依赖冲突用dependency:tree把冲突的 jar 找出来排除掉。一个更稳的实践是插件包尽量做成纯接口实现不引入第三方业务库。这样能避免一大半冲突问题。我自己踩过最深的坑是插件里引了一个旧版 HTTP 库跟主应用的 Spring Boot 内嵌版本冲突启动时提示方法和类都找不到。排查到最后只能把插件里那个 HTTP 调用改成 JDK 原生HttpClient问题才彻底消失。4.2 模型超时导致线程池被打满现象是压测时 QPS 不高但线程全部阻塞新请求排队最后超时雪崩。原因十有八九是模型调用没有独立超时也没有快速失败机制。比如 HTTP client 默认超时 60 秒一旦模型响应慢线程就被占住。一个模型超时整个池的资源都被吃掉。解决方式给每次模型调用设置独立的超时和重试上限。CompletableFutureModelResponse future CompletableFuture.supplyAsync(() - modelClient.chat(messages), modelPool); return future.orTimeout(30, TimeUnit.SECONDS) .exceptionally(ex - buildFallback(ex));重试要带退避最简单的是固定 200ms 退避最多两次。不要做无限重试否则下游故障会把你这边打成重灾区。这个exceptionally里可以返回一个兜底文案比如“抱歉我暂时没法回答请稍后再试”。至少用户拿到的是一个可读的响应而不是连接重置。4.3 优雅停机时还在跑的消息怎么办Java 服务收到 SIGTERM 后Spring Boot 会开始关闭但如果你在线程池里跑着 Agent 会话默认情况下线程池会被强行中断。结果就是用户消息发出去了回复丢了。Harness 的close()要配合 JVM shutdown hook 一起用Runtime.getRuntime().addShutdownHook(new Thread(() - { harness.stopAcceptingNewRequests(); harness.awaitInFlightRequests(Duration.ofSeconds(30)); harness.close(); }));awaitInFlightRequests就是在STOPPING状态下等线程池里的任务跑完。如果 30 秒还没结束再强制 shutdownNow。这个时间窗口要根据模型最慢耗时来定别拍脑袋。我曾经把等待时间设成 5 秒结果模型还没返回线程就被中断了用户侧直接看到空响应。后来改成 30 秒配合超时控制才稳定下来。4.4 可观测性给一次请求一个 TraceId没有 TraceId 之前排查问题靠猜。后来我在 Harness 的run()入口生成 TraceId放到 MDC 里再在所有关键阶段打日志收到请求记录 sessionId 消息长度模型调用前记录模型名 输入 tokens 估算模型调用后记录耗时 tokens工具调用记录工具名 参数摘要 耗时返回响应记录总耗时。MDC.put(traceId, UUID.randomUUID().toString()); try { // 整个链路 } finally { MDC.remove(traceId); }日志框架配置好 pattern让 traceId 出现在每一行。这样线上任何一个报错都能按 traceId 串出完整调用链。成本很低价值极高。记得不要用System.out.println打日志一定要走日志框架不然 MDC 里的 traceId 是拿不到的。5. 实战避坑清单与后续扩展5.1 一张表看完最容易踩的坑坑表现处理方式Harness 没有状态机启动未完成就接流量初始化失败抛异常拒绝请求模型超时设置太长线程池被打满独立超时 快速失败会话上下文无限增长内存和 token 双爆炸滑动窗口裁剪 会话过期淘汰插件加载失败启动报 failed to load plugins检查扫描路径和依赖冲突工具调用没有审计出问题无法追溯在 Harness 统一收口停机强行中断用户消息丢失shutdown hook 等待在途请求日志没有 traceId排查问题全靠猜MDC 全链路埋点这张表基本就是我几次线上事故的浓缩。每次出问题最后定位到的根因都能在上面对应上一行。5.2 上线前的 Harness 自查清单除了上面的坑我每次上线前会再过一遍这几个检查项模型 API key 是否通过环境变量注入有没有硬编码所有外部调用是否都配置了超时和重试线程池队列是否有限拒绝策略是否明确会话存储有没有设置空闲过期时间插件目录路径是否正确JAR 是否可被扫描到日志里是否包含 traceId关键埋点是否齐了停机时会不会等待在途请求等待时间够不够。这些检查项不需要自动化工具一张纸就能写完。但每次上线前过一遍能挡掉大部分低级问题。5.3 后续扩展方向Harness 层稳定之后再往上加东西会非常顺手。我个人下一步打算做的三件事把 Harness 的指标接进 Prometheus按会话维度和模型维度统计 token 成本给工具调用加一层基于 RBAC 的权限过滤不同用户能调的工具不同把会话状态从内存搬到 Redis让 Harness 支持多实例水平扩展。这三个方向全部基于已有的 Harness 接口扩展不需要动 Agent 内核。这也验证了最开始的分层设计边界清晰扩展才不痛。5.4 个人体会踩过几次坑之后我越来越认同一个判断Agent 内核拼的是模型和 Prompt但 Agent 上线拼的是 Harness。把内核装进生产边界不是多写几个工具方法而是把生命周期、并发、可靠性、可观测性这些东西当成一等公民来设计。AgentScope Java 给了内核和消息协议剩下的工程层恰恰是我们 Java 工程师最该发挥价值的地方。