AgentScope Java 实战做到第二个阶段,我敢说 Harness 是整条链路里最容易被误读的一层。第一次接触这个名字,我想这不就是个启动器吗?把 Agent 跑起来,然后把进程挂到后台,完事了。后来在生产环境被连续教育,才明白 Harness 根本不是启动器,而是把 Agent 内核装进生产边界的那道工程闸门。
这篇内容不教你写 Agent,也不分析提示词,而是讲怎么把一个已经能跑通的 AgentScope Java 多 Agent 内核,放进一个有边界、可管理、能优雅关停的 Java 工程里。适合已经跑通过 demo、准备把 Agent 接到真实业务系统的后端开发;如果你只是刚入门,也可以把它当成一篇工程化思路读物来读。
先说结论:没有 Harness 的 Agent 不是不能跑,而是不能保证生产环境下的可用性。下面我从分工、边界模型、代码实现、生产加固、排障经验五个部分,把我实际踩过的坑和沉淀下来的写法一次性讲清楚。
1. Harness 不是启动器:先把 Agent 和 Harness 的分工说透
1.1 名字里的线索
Harness 的英文原意是“马具”,是一套把马的力量导向车辆、同时约束方向的装置。工程界借用这个词,指的是“把动力引擎安全装配到使用场景的那套约束系统”。没有鞍辔的马不能拉车,没有 Harness 的 Agent 也不能直接暴露给业务流量。
我第一次看到 AgentScope Java 里的 harness 包时,以为它只是封装了 Agent 的启动过程,把start()和stop()暴露出来就行。实际用过之后发现,Agent 内核和 Harness 的关注点完全不同。
Agent 内部关心的是思考、规划、调用工具、生成回复,是一个状态机的世界。它可以有记忆、有策略、有内部工具调用。但 Agent 完全不关心自己跑在哪个线程上、请求从哪个通道进来、上下文超时之后要不要回收、进程被 kill 时未完成的会话怎么办。
Harness 关心的恰恰是这些问题:多少个会话可以并发、消息怎么路由到正确的 Agent、异常之后怎么隔离、重启之后如何恢复、线程池满了该拒绝还是排队。它就像发动机舱,负责把引擎的功率安全地传到传动系统,而不是替引擎做燃烧决策。
1.2 Harness 和 Agent 到底有没有清晰边界
我在团队里反复被问到一个问题:Harness 是不是就是 Agent 的容器?是,但不完整。容器只解决“装下来”的问题,Harness 还要解决“跑得稳、停得干净、坏了不炸”的问题。
我用一张表格说清楚它们的分工:
| 对比维度 | Agent | Harness |
|---|---|---|
| 核心任务 | 接收 Message、调用模型、规划步骤 | 接收请求、调度 Agent、管理会话生命周期 |
| 内部状态 | 会话内的上下文、记忆、工具调用栈 | 并发额度、资源池、运行状态、路由表 |
| 对外接口 | 面向 Agent 的消息接口 | 面向业务系统的 HTTP、MQ、定时任务接入 |
| 失败影响 | 单个 Agent 推理失败 | 承载多会话时,单点故障会被边界隔离 |
| 生命周期 | 一次会话一个生命周期 | 随应用启停,具备持久化与恢复能力 |
| 类比 | 发动机 | 发动机舱、点火系统、仪表盘 |
实际写代码时,最容易犯的错就是把 Agent 状态直接放到全局静态变量里,然后让 Harness 去“凑合”管理。方向反了。Agent 应该保持无状态或纯会话态,所有跨会话的东西都上交给 Harness。
1.3 为什么裸奔的 Agent 在 demo 里看起来很完美
很多项目死在从 demo 到生产的这一步,不是因为 Agent 推理能力不行,而是因为“裸奔的 Agent 在低流量下看不出问题”。
我总结过四个“没问题”假象:
- demo 没问题:一次只跑一个会话,入口线程和 Agent 线程天然就是一对一,根本暴露不出并发问题。
- 单测没问题:JUnit 帮你管理线程上下文,测试跑完 JVM 退出,资源泄漏看不见。
- 压测没问题:压测只盯吞吐量,没人盯线程池队列长度、堆内存里的会话对象是否越积越多。
- 上线小流量没问题:少量用户时,即使 Agent 状态串了,也不容易被发现;等流量大了,问题就像雪崩一样集中爆发。
所以我一直有个习惯:一个 Agent 工程如果不能在启动后列出“当前有多少会话在跑、每个会话停在哪个阶段、线程池队列剩多少”,它就没有达到生产标准。这也就是 Harness 层的核心价值。
2. 生产边界四层模型:把 Agent 内核约束在可控区间
2.1 边界不是限制,是给失控留止损点
有人觉得“边界”是给 Agent 戴镣铐,是限制它能力的发挥。我的理解完全相反:边界是给失控留止损点。汽车有刹车,不是为了不让车跑,而是为了让车能在紧急时刻停下来。
把 Agent 内核装进生产边界,本质上要做的是“四层约束”:接入层、调度层、会话层、资源层。每一层都有明确职责,层与层之间不越权。
这四层不是 AgentScope Java 官方规定的结构,而是我在项目中沉淀出来的思路。你可以照着它来组织你的 Harness 代码,也可以根据自己的业务场景增删。关键是:每一层都有独立的名字、独立的职责、独立的失败处理。
2.2 接入层:统一入口协议
接入层负责把外部请求变成 Harness 内部的标准化会话请求。无论是 HTTP 接口、MQ 消息、定时任务、命令行触发,进来之后都要统一转换成类似HarnessRequest(sessionId, agentKey, message)的结构。
这个层必须做的事情有三件。
第一,身份与来源标识。每个请求都要有 sessionId 和 traceId,后续日志、状态存储、问题排查全部依赖这两个 ID。
第二,限流与准入控制。接入层就要判断当前 Harness 是否还能接受新会话,而不是等请求进了线程池才说不行。
第三,超时语义标准化。HTTP 请求有自己的超时,MQ 消费有自己的重试,但进入 Harness 之后必须统一换算成 Harness 内部的会话超时时间。
我见过很多团队把这三个逻辑散落在各个 Controller 里,结果 Harness 根本没法从入口控制全局节奏,最后超时和重试规则互相打架。
2.3 调度层:搞清楚这个会话要去哪个内核
调度层解决的核心问题是路由和排队。同一个 Harness 里可能有多个 Agent,比如订单客服 Agent、售后 Agent、财务对账 Agent;也可能同一个业务 Agent 要服务多个会话。
调度层要维护一张路由表:agentKey -> Agent实例。请求进来后,调度层根据 agentKey 找到对应的 Agent,而不是让业务方直接持有 Agent 对象。
排队逻辑比路由更容易被忽略。Agent 推理是慢操作,一次模型调用可能要好几秒,如果业务请求量高于模型推理吞吐,调度层必须有一个有界队列。队列满了怎么办,要明确:直接拒绝并返回“当前系统繁忙”,而不是无限排队把内存打爆。
还要做会话并发隔离。某个 Agent 陷入异常循环,不应该把其他 Agent 的线程全部拖死。所以调度层建议按 Agent 维度拆分线程池或者至少拆分信号量。
2.4 会话层:让状态只属于当前会话
会话层是 Harness 里最容易写出隐蔽 Bug 的地方。Agent 往往有自己的上下文记忆,如果你把Map<sessionId, AgentState>放在内存里,就必须考虑并发访问和 Task 完成后的清理。
我推荐的做法是:Agent 内核对象保持无状态,所有会话相关数据都放到一个SessionContext对象里。SessionContext 由 Harness 创建、注入、销毁,Agent 只从 SessionContext 读取当前会话的信息。
会话层还必须处理一个问题:同一个 sessionId 的多次请求如何在 Agent 内部保持连续。第一次用户问“我的订单呢”,第二次问“那退了吧”,第二次必须能拿到第一次的上下文。这部分状态可以在内存里短期保存,生产环境则要考虑后面要讲的持久化。
会话结束之后清理动作很关键。释放 SessionContext、清空临时状态、关闭模型调用流,一个都不能漏。否则并发上来后,内存里的旧会话会像垃圾一样越堆越多。
2.5 资源层:线程、连接、凭据、调用次数的总额控制
资源层是边界模型的兜底层。AgentScope Java 内核运行时会消耗四类资源:线程、内存、模型服务连接、工具调用凭据。
线程层面,Harness 要有自己的线程池,不能直接使用业务 Web 容器的线程跑 Agent 推理。否则模型服务慢一次,整个 Web 服务的线程都被占住,其他普通 HTTP 接口全部跟着超时。
连接层面,大模型服务的连接池要有上限,客户端 HTTP 连接不能无限创建。凭据层面,API Key 的额度要能被 Harness 统计,接近配额时主动降级。调用次数层面,单次会话内的模型调用轮次必须设上限,防止两个 Agent 互相回复形成死循环。
资源层没有固定代码,但它是一组硬性约束:线程池大小、队列容量、最大并发会话数、单会话最大轮次。这些参数后面会详细讲。
3. AgentScope Java 实战:搭建一个最小可用的 Harness 工程
3.1 工程结构怎么摆
实际项目里,我不建议把 Harness 代码和业务代码全部混在 Controller 包下面。最好单独建一个harness包,让依赖关系保持单向:业务 Controller 依赖 harness,harness 依赖内核 Agent,内核不反向依赖 harness。
我常用的工程结构如下:
agent-service/ ├── src/main/java/com/example/agent/ │ ├── harness/ │ │ ├── HarnessRuntime.java │ │ ├── HarnessConfig.java │ │ ├── HarnessLifecycle.java │ │ └── SessionStore.java │ ├── kernel/ │ │ ├── CustomerAgent.java │ │ ├── OrderAgent.java │ │ └── ToolRegistry.java │ ├── controller/ │ │ └── ChatController.java │ └── Application.java └── pom.xml不要小看这个约束。一旦 Agent 直接去 Service 层拿数据库连接、直接调用外部 HTTP、直接在 Controller 里 new 出来,Harness 的边界就会失效。内核代码应该只通过 Harness 暴露的工具接口访问外部资源。
3.2 Maven 依赖与版本现状
AgentScope Java 当前迭代速度很快,Maven 坐标在不同版本上有差异。我在项目里的写法是先把版本变量统一管理,再通过mvn dependency:tree核验实际解析出来的 jar 包版本。
<dependency> <groupId>com.alibaba.agentscope</groupId> <artifactId>agentscope-java</artifactId> <version>${agentscope.version}</version> </dependency>建议你把${agentscope.version}放到父 POM 的 properties 里统一管理,团队内不要出现多个小版本混用的情况。AgentScope 这种框架类依赖,版本不一致很容易出现NoSuchMethodError,排查起来非常耗时间。
拿到依赖之后,先别急着写业务代码。打开 jar 包里的harness相关目录,看一眼实际的类名、包名、方法签名。下面示例代码我会按照当前较通用的写法给出,但你在 IDE 里看到的 API 可能因为版本不同略有调整,照着语义替换即可。
3.3 定义 Harness 运行时配置
我定义 Harness 运行时采用 Builder 模式,把所有关键参数集中在一个配置类里,避免散落各处。下面这段是我项目里简化后的写法:
@Configuration public class HarnessConfig { @Bean public HarnessRuntime harnessRuntime( AgentScopeClient agentscopeClient, SessionStore sessionStore) { return HarnessRuntime.builder() .agentScopeClient(agentscopeClient) .sessionStore(sessionStore) .maxConcurrentSessions(64) .sessionTimeout(Duration.ofMinutes(5)) .maxRoundsPerSession(10) .workerThreadPoolSize(16) .queueCapacity(2000) .build(); } }这段代码的核心思想是把“内核需要什么”和“内核能用到什么”分开。AgentScopeClient 是内核用来调用大模型服务的客户端;SessionStore 是 Harness 用来持久化会话状态的存储;后面的数字都是资源限制。
参数为什么这么给,不是拍脑袋,而是有计算逻辑。比如workerThreadPoolSize设为 16,对应一台 8 核机器上模型调用以 I/O 等待为主的情况;如果 Agent 内核里有大量 CPU 计算型工具,这个值就要往核数附近靠。后面参数调优章节会展开。
3.4 把 Agent 内核注册进 Harness
Agent 内核的接入不应该自动扫包,而是显式注册。自动扫描看起来很省事,但生产环境里一个 Agent 究竟有没有被注册、注册了几次,都会变成黑盒。
我建议在 HarnessRuntime 启动时手动完成注册:
@Configuration public class KernelRegistryConfig { @Bean public HarnessRuntime kernelRegistry( HarnessRuntime harnessRuntime, CustomerAgent customerAgent, OrderAgent orderAgent) { harnessRuntime.register("customer", customerAgent); harnessRuntime.register("order", orderAgent); return harnessRuntime; } }这里有个细节:CustomerAgent本身应该是无状态 Spring Bean,它的生命周期由 Spring 容器管理,而 Harness 内部只持有它的引用。每次会话进来,Harness 会创建 SessionContext,把这个上下文传给 Agent 内核处理,而不是复用上一次会话的上下文。
3.5 启动验证与性能冒烟
Harness 配置完成后,需要在Application里显式启动。通常我用一个ApplicationRunner来做启动时的检查:
@Component public class HarnessBootstrapRunner implements ApplicationRunner { private final HarnessRuntime harnessRuntime; public HarnessBootstrapRunner(HarnessRuntime harnessRuntime) { this.harnessRuntime = harnessRuntime; } @Override public void run(ApplicationArguments args) throws Exception { harnessRuntime.start(); log.info("harness started at {}, active sessions = {}", harnessRuntime.getStartedAt(), harnessRuntime.getActiveSessionCount()); } }启动之后不要急着接真实流量,先做一次性能冒烟。重点看两个指标:并发会话从 0 涨到满额时,线程池队列是否稳定;单个会话从提交到返回的 P99 延迟是否满足业务预期。
压测时我习惯用 wrk 或 ab 直接打 Harness 入口,同时后台开一个jstack采集线程快照。如果线程快照里出现大量BLOCKED状态的线程,说明锁竞争或者线程池容量不够,这时候调参比写业务代码更优先。
4. 生产加固三板斧:参数、持久化、优雅关闭
4.1 核心参数怎么给
生产环境和 demo 最大的区别是“会让参数失控”。我把 Harness 里最常见的参数整理成一张速查表,你可以直接参考:
| 参数 | 建议设置方式 | 说明 |
|---|---|---|
| maxConcurrentSessions | 根据模型服务吞吐估算 | 超过此值直接拒绝新会话,而不是无限等待 |
| workerThreadPoolSize | CPU 核数 x 2 起调 | Agent 任务以 I/O 等待为主时往上调,计算密集则往核数收敛 |
| queueCapacity | 有界队列,1000 到 5000 | 给短时流量峰值一点缓冲,但不允许无限积压 |
| sessionTimeout | 5 到 15 分钟 | 防止长尾会话占用线程池和内存 |
| maxRoundsPerSession | 5 到 10 | 防止多 Agent 互相回复形成死循环 |
| maxRetry | 1 到 2 | 只有幂等工具才允许重试,非幂等操作重试就是事故 |
| rpcTimeout | 模型调用 P99 的 1.5 倍左右 | 外层超时必须给到足够余量,但不能无限大 |
这里最容易被忽视的是sessionTimeout。Agent 的一次会话可能包含多轮模型调用,如果只给每轮调用设 30 秒超时,而整个会话没有总超时,那么 10 轮调用就可能拖 300 秒,用户早就走了,线程还占着。所以 Harness 必须从会话维度给一个总超时,而不是只依赖单步超时。
4.2 会话持久化与恢复
进程必然重启。Agent 会话如果在内存里,重启后用户继续提问时,上下文就断了,体验非常割裂。生产级 Harness 要做会话快照,把必要状态持久化到 Redis 或数据库中。
我的做法分三步:
第一步,定义会话快照的数据结构。里面包含 sessionId、agentKey、最近 N 条消息、当前工具调用栈、模型调用次数、最后更新时间。不需要把整个 Agent 对象序列化,只需要把 Agent 的逻辑状态落盘。
第二步,在 Harness 内核每次完成一轮推进后保存快照。注意不要每次 token 增量都存,那样 IO 会成为瓶颈。一个会话在一轮模型调用完成后保存一次,这个频次是可控的。
第三步,恢复机制。用户带着 sessionId 重新请求时,Harness 先从 SessionStore 读取快照,重建 SessionContext,再让 Agent 内核接着跑。
持久化还要注意过期问题。Redis 里要给会话快照设置 TTL,否则过期会话会一直占用存储。我一般设置为sessionTimeout的两倍,既保证恢复窗口,又不堆积无用数据。
4.3 优雅关闭:不是 kill -9 就能糊弄过去
生产上发布、扩容、缩容,进程随时会被停止。如果我们只停掉进程,正在进行的 Agent 会话会全部中断,用户端看到的就是“服务突然不可用”。所以 Harness 必须支持优雅关闭。
什么算优雅关闭?对外不再接收新会话,已经接收的会话在限定时间内跑完,跑不完的超时会话持久化到 SessionStore,然后释放线程池、关闭客户端连接。
Spring Boot 工程里可以直接监听ContextClosedEvent:
@Component public class HarnessShutdownHook implements ApplicationListener<ContextClosedEvent> { private final HarnessRuntime harnessRuntime; public HarnessShutdownHook(HarnessRuntime harnessRuntime) { this.harnessRuntime = harnessRuntime; } @Override public void onApplicationEvent(ContextClosedEvent event) { harnessRuntime.stop(Duration.ofSeconds(30)); } }这里有两个坑。第一个坑是stop的超时时间给太短,比如 5 秒,结果 Agent 还卡在一次模型调用里,然后被强制打断,会话快照都没存上。第二个坑是多个关闭钩子同时操作线程池,Spring 容器还没关干净,业务线程池已经关了,最后出现RejectedExecutionException。
所以我的建议是:关闭动作只在一处做,关闭顺序固定为“先停止接收新会话,再等待存量会话,最后持久化未完成会话”。
5. 排障记录:我在 Harness 层踩过的坑和排查思路
5.1 常见错误速查表
Harness 层的问题往往不是语法错误,而是运行时的资源和管理问题。我整理了一份高频错误速查表,方便你排查时快速对照:
| 异常或现象 | 常见原因 | 排查与处理 |
|---|---|---|
| 提交后长时间无响应 | 调度线程池队列满,未触发拒绝策略 | 查看队列容量和拒绝策略;短时流量用缓冲,长时过载直接拒绝 |
| 不同会话上下文串了 | Agent 内部使用了共享静态状态 | 检查 Agent 字段是否持有会话级数据,改为从 SessionContext 读取 |
| 重启后用户上下文丢失 | 会话快照没有持久化,或 TTL 太短 | 增加 SessionStore 持久化和快照恢复逻辑 |
| 模型调用线程把 Web 线程池打满 | 没有独立线程池,直接占用了业务线程 | Harness 单独创建线程池,并把容量收敛到固定范围 |
| shutdown 一直挂住 | Agent 内核不响应线程中断 | 用 Future 包装会话执行,stop 时先 cancel Future 再关闭线程池 |
| 两个 Agent 无限互相调用 | 缺少 maxRoundsPerSession 限制 | 给 Harness 增加会话轮次上限,并在达到上限后强制终止 |
5.2 故障复盘:一次消息风暴打爆 Harness
有个项目上线初期,客服 Agent 和对账 Agent 会互相调用。设计时觉得“一个 Agent 查订单,另一个 Agent 核算金额,最后汇总给用户”很合理,但没有给会话轮次设上限。
结果某次订单接口返回了异常结构,客服 Agent 无法解析,就把“再问一次对账 Agent”当成修正策略;对账 Agent 也没能理解,又回头找客服 Agent。两个 Agent 在消息循环里来回互抛,一轮模型调用接着一轮,Harness 的线程池被耗尽,整个服务进入拒绝状态。当时线上并没有高流量,纯粹是逻辑死循环把资源打没了。
排查过程不算复杂:先看线程快照,发现大量线程都卡在 Agent 消息处理链路;再看日志,发现同一个 sessionId 的消息往返轮次异常高。最后修复方式有两个:一是给 Harness 加maxRoundsPerSession限制,达到上限后强制结束会话;二是在 Agent 内部增加消息去重,同一个出错消息不允许原样反复重发。
5.3 故障复盘:静态状态导致上线后数据串台
另一次问题更隐蔽。Agent 内核为了图方便,在类里放了一个Map<String, String> currentUserContext的静态变量,用来记录“当前用户是谁”。单测和 demo 场景一个会话跑完后进程就结束了,静态变量没暴露问题。上线后两个用户同时发起会话,后一个用户进来,直接把前一个用户的上下文给覆盖了。
最后用户 A 查订单,看到了用户 B 的订单列表。这种数据串台属于生产环境最严重的级别。
根子在于 Agent 内核错误地使用了全局共享状态。修复方案是把 currentUserContext 彻底移除,所有用户维度数据都改成从 Harness 注入的 SessionContext 拿。SessionContext 由 Harness 按 sessionId 创建,会话结束即销毁,不跨会话共享。
从那之后我对团队加了一条硬性约束:Agent 类里禁止出现 static 可变字段,所有可变状态必须显式标注会话维度。
5.4 我给日常排障准备的三个小习惯
踩过足够多坑之后,我慢慢养成了一些习惯,这些不在官方文档里,但对排查效率帮助很大。
第一个习惯是给 Harness 的所有线程命名。线程池工厂统一加上harness-前缀,这样 jstack 打出来一眼就能分辨哪些线程是业务线程、哪些是 Agent 推理线程,不用猜。
第二个习惯是给每个会话的日志都带上 traceId。无论是 Agent 内部日志还是 Harness 调度日志,统一通过 MDC 注入 traceId。排查时只需要按 traceId 把日志拉出来,就能看清一条会话的完整时间线。
第三个习惯是保留 Harness 自带的可观测指标。运行中的会话数、队列长度、每轮调用耗时、拒绝次数,这些至少要输出到日志或者暴露成 Metrics。对 Agent 服务来说,这些指标就是安全气囊,平时不起眼,出事后才知道它们有多重要。
我把这轮实战里沉淀下来的 Harness 工程写法整理成了上面这些内容。核心思路并不复杂:Agent 负责聪明,Harness 负责稳健。两者边界越清晰,生产环境就越不容易出“看起来没问题、一上线就拉胯”的毛病。