最近一直在折腾 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。
状态切换的代码可以用AtomicReference<HarnessState>实现,避免加一堆锁:
private final AtomicReference<HarnessState> 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-runtime:Harness 实现、内核实现、工具执行器等;agentscope-server:Spring Boot 入口,只负责把 HTTP 请求转成HarnessRequest。
模块依赖方向是单向的:server依赖runtime,runtime依赖api。这样底层模块可以单独测试,也不会出现“为了部署一个 Agent 把整个 Spring Boot 容器都带起来”的情况。
换一个场景也能体会到这个拆分的好处:如果要写命令行工具、批处理任务、或者消息队列消费者,那只需要依赖runtime,不需要引入 web 容器。模块边界就是复用边界。我在实际项目里还遇到过一个更现实的问题:同一个 Agent 内核,既要在定时任务里跑离线批量推理,又要在 Web 接口里跑实时对话。如果不拆模块,定时任务就会被迫启动整个 Web 容器,浪费资源不说,还容易因为端口冲突挂掉。
3.2 一个完整的会话闭环:请求到响应全链路
下面这条链路是我在生产环境实跑过的,也是 AgentScope Java 项目最常见的拓扑:
- HTTP 请求进入 Controller;
- Controller 解析出
sessionId和userMessage; - Harness 根据
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:class+load=info -jar app.jar看具体是哪个类加载失败。如果是依赖冲突,用dependency:tree把冲突的 jar 找出来排除掉。
一个更稳的实践是:插件包尽量做成纯接口实现,不引入第三方业务库。这样能避免一大半冲突问题。我自己踩过最深的坑是插件里引了一个旧版 HTTP 库,跟主应用的 Spring Boot 内嵌版本冲突,启动时提示方法和类都找不到。排查到最后,只能把插件里那个 HTTP 调用改成 JDK 原生HttpClient,问题才彻底消失。
4.2 模型超时导致线程池被打满
现象是:压测时 QPS 不高,但线程全部阻塞,新请求排队,最后超时雪崩。
原因十有八九是模型调用没有独立超时,也没有快速失败机制。比如 HTTP client 默认超时 60 秒,一旦模型响应慢,线程就被占住。一个模型超时,整个池的资源都被吃掉。
解决方式:给每次模型调用设置独立的超时和重试上限。
CompletableFuture<ModelResponse> 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 工程师最该发挥价值的地方。