上一篇把事件流收了口:EventCodec 把拆散的框架细事件攒齐,SSE 主路径从 HTTP 请求一路通到六种对外事件。但收口的只是"消息怎么流",还有三件事悬在半空——会话状态活在内存里,进程重启即丢;多租户请求共用同一段上下文;错误与耗时只有零散的日志可看。最小内核解决了"Agent 能不能跑",这三件事决定"Agent 能不能长期跑在一个被多个用户同时访问的进程里"。
本篇进入 AgentScope 的 harness 层。harness 直译是"挽具"——套在马身上、让力气有处使的那套装备;框架语境里,它就是把 ReAct 内核套进工程边界的这一层。主角从 ReActAgent 换成 HarnessAgent,围绕它展开:三个共享对象、状态会话与多租户、Redis 分布式存储与 Compaction 记忆压缩、Middleware 横切层,最后用 dream-scope 的 ScopeChatAgent 装配实录收口,回答"主路径为什么是 HarnessAgent"。
前置知识:已读系列 01,理解 ReAct 循环、Msg、Model 与六模块边界;Java 21 与 Spring Boot 基础。
阅读目标:理解 HarnessAgent 与 ReActAgent 的分工,掌握 Workspace 目录约定、基于 RuntimeContext 的多租户状态会话,能独立完成 Redis 分布式存储与 Compaction 配置,并说清 Middleware 的钩子模型。
关于代码:文中 Java 片段均为从仓库摘出的关键片段,完整代码见各处标注的仓库路径;版本以 dream-scope 0.1.0-SNAPSHOT 与 AgentScope 2.0.3 源码为准。
一、HarnessAgent 与 ReActAgent:薄包装,厚工程
先把分工说清。ReActAgent 是 core 层的直接实现,职责单一:驱动"模型推理—工具行动—结果回填"的循环,直到最终答复或迭代上限。它不知道 HTTP、不知道持久化、也不知道一个请求背后是哪个用户。
HarnessAgent 是 ReActAgent 的一层薄包装。薄,指它不改写 ReAct 循环本身——推理还是那个推理,工具执行还是那个执行;厚,指它把长期运行所需的工程能力以 builder 挂点的形式叠加在循环外围:
- toolkit / generateOptions / fallbackModel:工具包、采样参数、备用模型;
- compaction / memory:记忆压缩与会话记忆配置;
- subagents:子 Agent 声明(细节留给后续篇目);
- middlewares:横切中间件,第五节展开;
- distributedStore / stateStore:分布式状态存储,第三、四节展开;
- workspace / skillRepository:工作区目录与技能仓库;
- enablePlanMode:计划模式开关。
这组挂点对应一条设计哲学:core 算法不动,Harness 只往里加东西。升级框架时,ReAct 循环的行为可预期;工程能力的增减只发生在 builder 链上,不渗透进业务代码。
更关键的架构认知是:Harness 的各项能力互不依赖。
compaction 不关心 workspace,middleware 不关心 stateStore。它们只通过三个共享对象通信:
- RuntimeContext:承载本次调用的身份;
- Workspace:约定文件读写;
- AgentStateStore / DistributedStore:负责跨调用的状态恢复。
三者的展开是第二节的主角,下文逐节拆开。这里先记住结论:理解了这三个对象,就理解了 HarnessAgent 装配的全部衔接面。
装配链路如下图,builder 挂点收进 HarnessAgent,HarnessAgent 内部持有 ReActAgent,能力之间靠共享对象衔接:
在 01 的 boot 对照路径上,官方 starter 装配的是 ReActAgent(内存 Memory、空 Toolkit);dream-scope 的 web 主路径装配的是 HarnessAgent。两条路径的差异不是"谁更强",而是工程边界的有无——01 第四节三个缺口中的"进程内状态",分水岭正在这条装配线上。
二、三个共享对象:身份、目录、状态
2.1 RuntimeContext:本次调用的身份
RuntimeContext 承载一次调用的运行时维度,最常用的是 userId 与 sessionId。两个要点:
**第一,它不持久化。**每次请求创建一个新实例,随调用结束而结束。持久化的状态交给第三节的 AgentStateStore,RuntimeContext 只负责"这一次是谁在调"。职责分离由此建立:同一个 Agent 实例被所有请求共享,用户维度全部由调用参数携带,Agent 内部不存在任何请求态字段。
**第二,它在整条链路上透传。**从 streamHandle 构建,到框架事件、中间件钩子(第五节签名里的 ctx 参数)、状态存储寻址,用的都是同一个对象。01 第四节的 EventCodec 按请求 new 一个实例,streamHandle 里构建的 RuntimeContext 同理:
Msg inbound = MessageCodec.toUserMessage(request.input(), request.imageUrls());
RuntimeContext context = RuntimeContext.builder()
.userId(blankToNull(request.userId()))
.sessionId(blankToNull(request.sessionId()))
.build();
context.put(AgentSpawnTool.CTX_FORCE_SYNC, Boolean.TRUE);
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
blankToNull 是私有工具方法:null 或空白字符串统一转成 null。这半行代码是会话语义的开关,第三节解释为什么必须它。
context.put 的那行则演示了 RuntimeContext 的另一个角色:调用侧附加参数的载体。子 Agent 强制同步执行这类只对本次调用生效的开关,不走全局配置,放进 context 随调用传递。
2.2 Workspace:把约定落成目录
Workspace 是 Harness 的工作区目录,把"Agent 需要哪些静态资源"变成文件系统约定。dream-scope 的目录结构如下:
| 路径 | 用途 |
|---|---|
| AGENTS.md | 工作区说明,供模型读取的行为约定 |
| MEMORY.md | 长期记忆文件 |
| knowledge/ | 检索资料目录 |
| skills/*/SKILL.md | 技能定义,Harness 自动扫描 |
| subagents/*.md | 子 Agent 的 Markdown 声明,Harness 自动扫描 |
| tools.json | 工具描述 |
目录约定替代了口头约定:想要新增一个技能,放一个 skills/<name>/SKILL.md 即可,不需要改装配代码;想加一个文件声明的子 Agent,往 subagents/ 放 Markdown。Harness 在构建时扫描这些目录,与 builder 上编程式注册的内容合并生效。
物理位置由配置决定。web 模块 application.yml 里 dream-scope.workspace-dir 默认 .agentscope/workspace,启动时把 classpath 内置的 workspace/ 目录拷贝过去(同名覆盖)。这样做的动机是部署友好:仓库里带着一套内置的工作区作为默认值,生产上用数据卷挂载真实路径即可替换,Agent 的代码与资源解耦。
需要注意边界:workspace 管的是"约定文件的读写",会话状态不走它——那是 AgentStateStore 的事。两套机制如果混用,会把"每用户一份"的状态写进"全局一份"的目录里。
2.3 AgentStateStore 与 DistributedStore:跨调用的状态
前两个对象解决"这次调用是谁"和"文件放哪里",第三个共享对象解决"上一轮聊到哪了"。Memory(对话记忆)、Plan 状态这些组件会被序列化为 State 对象,通过 AgentStateStore 接口落盘,寻址键是 (userId, sessionId):sessionId 必填且非空,userId 可选,null 表示匿名或单租户。
DistributedStore 是更上层的聚合接口,把所有需要分布式持久化的组件收进一处:
| 组件 | 职责 | Redis 实现 |
|---|---|---|
| AgentStateStore | Agent 状态持久化 | RedisAgentStateStore |
| BaseStore | 工作区文件系统的 KV 抽象 | RedisStore |
| SandboxSnapshotSpec | 沙箱快照 | RedisSnapshotSpec |
| SandboxExecutionGuard | 沙箱并发锁 | RedisSandboxExecutionGuard |
为什么偏偏是这四个?它们各自对应一类必须跨进程共享的资源:
- AgentStateStore 解决"会话状态放哪"——对话记忆、计划状态的读写,第三节的主角;
- BaseStore 解决"工作区文件放哪"——多副本部署时,workspace 里的知识库与技能文件不能只躺在单机磁盘上;
- SandboxSnapshotSpec 解决"沙箱环境如何复刻"——快照定义共享后,任意节点都能还原同一个沙箱环境;
- SandboxExecutionGuard 解决"沙箱能否并发执行"——用分布式锁防止多个节点同时抢占同一个沙箱。
一行配置即可把四个组件全部切到同一个分布式后端。dream-scope 选择了这条路径,但不是无脑全上——先看第三节的状态会话,再回来看它值不值。
三、状态会话与多租户:从内存裸奔到 Redis 槽位
3.1 五个实现,一条优先级规则
AgentScope 为 AgentStateStore 提供五种实现,按部署形态选择:
| 实现 | 适用场景 |
|---|---|
| InMemoryAgentStateStore | 单元测试 |
| JsonFileAgentStateStore | 单节点开发,HarnessAgent 默认 |
| RedisAgentStateStore | 多副本生产默认 |
| MysqlAgentStateStore | 已有关系型数据库基础设施 |
| OssAgentStateStore | 阿里云生态 |
从单机到多副本的过渡不需要换代码,只需要换实现。框架还定义了一条装配优先级规则,值得单独记住:
显式 builder 方法(如 .stateStore(...))> distributedStore 自动注入 > 本地默认。
意思是:builder 上显式指定的实现优先;只配了 distributedStore 时,其携带的各组件实现被自动注入;什么都没配,落回本地默认(JsonFile)。优先级从左到右逐级兜底,这也意味着 distributedStore 提供一站式默认之后,仍可对单个组件做精细覆盖。
一次会话请求的存储寻址决策如下图:
3.2 ScopeChatAgent 的二选一
dream-scope 在 ScopeChatAgent 构造器里把这条优先级规则用成了显式的二选一:
if (distributedStore != null) {
builder.distributedStore(distributedStore);
} else if (stateStore != null) {
builder.stateStore(stateStore);
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
生产路径走 distributedStore(Redis),单测路径注入 stateStore(文件会话),两者都空时框架用内存态,进程重启会话丢失——类注释把这三行语义写得很直白。这比"什么都配了再看谁生效"更可控:条件分支只出现在装配点一处,不散落在运行时。
3.3 会话槽位的准入规则
有了存储,还剩"什么样的请求算会话"。
ScopeChatAgent 的规则一句话:userId 与 sessionId 成对非空才写入 Redis 槽位,只传一个等同于无会话。这与框架层默认不同:2.3 节的规则是 sessionId 必填且非空、userId 可选,null 表示匿名或单租户——dream-scope 作为应用层,选择收严这道闸。
这就是 2.1 节 blankToNull 存在的原因。前端只传 sessionId 不传 userId 时,如果空白字符串原样进入 context,框架可能拿一个空串键值写出一半槽位;统一转成 null 后,"单边传值"与"没传"语义合并,会话干脆不建。
这个决策换来了清晰的租户边界:每个 (userId, sessionId) 对应一个独立状态槽位,多租户隔离是存储层的天然结果,不需要业务代码维护任何 Map。
01 第五节提到"续聊时在请求体中带上成对的 sessionId 与 userId,会话即写入 Redis 同一槽位",背后的机制就是本节内容。
顺手澄清一个容易混淆的点:多租户不等于多 Agent 实例。chat Agent 是单例,所有租户共享同一个 HarnessAgent;隔离发生在状态层,而不是进程里堆 N 个 Agent。这也是 RuntimeContext 不持久化的原因——它只是寻址凭证,不是存储本身。
四、Redis 分布式存储与 Compaction:会话活下来,上下文瘦下去
4.1 Redis:从 ping 开始的 fail fast
生产路径的 Redis 由 ChatRedis 打开:
static JedisPooled open(String uri) {
JedisPooled jedis = new JedisPooled(requireUri(uri));
try {
jedis.ping();
log.info("redis ping ok {}", safeHost(uri));
} catch (RuntimeException ex) {
jedis.close();
log.warn("redis ping failed {}", safeHost(uri));
throw new IllegalStateException("redis required but unreachable: " + uri, ex);
}
return jedis;
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ChatRedis.java
ping 失败直接抛异常,进程起不来。这是 01 提过的 fail fast(快速失败,带着失效连接进入服务只会把错误推迟到第一个请求):状态层是会话的命脉,起一个注定写不进会话的服务没有意义。
连接到手之后,DistributedStore 的获取只有一行:
return RedisDistributedStore.fromJedis(jedis, prefix);
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ChatRedis.java
fromJedis 一个静态工厂返回完整 DistributedStore,state、baseStore、snapshot、executionGuard 四组件全部指向同一个 Redis,key 前缀默认 dream-scope:(可通过配置调整)。四个组件单独各配一套存储当然也可行,但运维上是四份部署心智;聚到一个后端,出问题时只需要盯一个数据源。这是"一站式默认 + 单点排障"对"自由组合"的取舍,多副本部署场景下前者更划算。
4.2 Compaction:按条数压缩的记忆
多轮对话的另一面是上下文膨胀:消息只增不减,早晚触及模型窗口。AgentScope 的解法是 Compaction(上下文压缩,对话过长时把早期消息压缩成摘要,腾出窗口空间),通过 CompactionConfig 配置:
static CompactionConfig compactionConfig(int triggerMessages, int keepMessages) {
int trigger = triggerMessages > 0 ? triggerMessages : ChatHarnessOptions.DEFAULT_TRIGGER_MESSAGES;
int keep = keepMessages > 0 ? keepMessages : ChatHarnessOptions.DEFAULT_KEEP_MESSAGES;
return CompactionConfig.builder()
.triggerMessages(trigger)
.keepMessages(keep)
.keepTokens(0)
.build();
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
三个参数的语义:消息条数达到 triggerMessages 时触发压缩,压缩后保留最近 keepMessages 条原文,早期内容转为摘要。dream-scope 默认 30 / 10(ChatHarnessOptions.DEFAULT_TRIGGER_MESSAGES / DEFAULT_KEEP_MESSAGES,配置项 dream-scope.compaction.*),配错为 0 或负数时回落默认值,不静默接受非法配置。
keepTokens(0) 是一个值得停一下的决策:CompactionConfig 同时支持按 token 数与按消息条数两种触发阈值,把 token 阈值置 0 即显式关掉,只留条数阈值。两个阈值同时生效时,触发时机的归属会变得难以解释——"到底是条数到了还是 token 到了";只留一个,行为可预期。参数宁少勿杂,调试时同样适用。
压缩与第四节的 Redis 是配套关系:压缩发生在推理循环内,压缩结果随 State 一起持久化。进程重启后恢复的会话,是"压缩过的会话",而不是丢失的会话。
五、Middleware:推理链路上的横切层
5.1 接口而非抽象类:2.0.3 的一个变化
MiddlewareBase(中间件基类,框架在推理链路上预留的横切挂点,概念类似 Web 开发里的 AOP——面向切面编程,把日志、鉴权这类与业务无关的逻辑从业务代码里拆出来统一挂载)在 2.0.3 中是接口,不是抽象类。这是从 1.x 迁移时容易踩的坑:旧版本是抽象类,升级后 extends 会直接编译失败。
接口形态带来的是 default 方法的自由:一个中间件只实现它关心的钩子,其余钩子默认直通。dream-scope 自带的 LoggingMiddleware 就是典型,只实现 5 个钩子中的 3 个。
5.2 五个钩子,两种类型
五个钩子按行为分两类。四个 Onion(洋葱)类型钩子共用统一签名:
Flux<AgentEvent> onXxx(Agent agent, RuntimeContext ctx, XxxInput input,
Function<XxxInput, Flux<AgentEvent>> next)
(钩子签名形状示意:agent 为当前 Agent 实例,ctx 为本次调用的 RuntimeContext,四个钩子的输入类型各不相同;dream-scope 的 LoggingMiddleware 即按此形状实现 onAgent,见 5.3 节实际代码。)
- onAgent:包裹一整轮回复流程,覆盖所有 ReAct 轮次、工具执行与最终输出;
- onReasoning:包裹一次推理步骤(输入组装 → 模型调用 → 流式解码);
- onActing:包裹单次工具调用执行;
- onModelCall:包裹一次原始模型 API 调用,最贴近模型。
一个 Transformer(变换器)类型钩子:
- onSystemPrompt:系统提示词组装时触发,签名不同——输入
String current,返回Mono<String>,前一输出即下一输入,多个中间件按序串联。
嵌套关系:onAgent 在最外层,内部每轮推理进入 onReasoning,onReasoning 内先 onSystemPrompt 再 onModelCall;工具调用时进入 onActing。一次带工具调用的请求中,钩子像洋葱一样层层包裹:
洋葱模型的核心是 next 参数:调用 next.apply(input) 才会继续执行链上的下一环。不调用 next 就是短路——这是中间件拥有"拦截能力"的原因。设想一个权限中间件,在 onActing 里检查工具名,不满足条件直接返回拒绝事件的 Flux,工具执行根本不会发生。
5.3 LoggingMiddleware:观察者的写法
LoggingMiddleware 在三个钩子上打点。以 onAgent 为例,洋葱模型的标准写法:
@Override
public Flux<AgentEvent> onAgent(
Agent agent,
RuntimeContext ctx,
AgentInput input,
Function<AgentInput, Flux<AgentEvent>> next) {
String agentId = agentId(agent);
String sessionId = sessionId(ctx);
long start = System.nanoTime();
log.info("chat middleware onAgent start agentId={} sessionId={}", agentId, sessionId);
return next.apply(input)
.doOnComplete(() -> log.info(
"chat middleware onAgent complete agentId={} sessionId={} elapsedMs={}",
agentId, sessionId, elapsedMs(start)))
.doOnError(error -> log.warn(
"chat middleware onAgent error agentId={} sessionId={} elapsedMs={} message={}",
agentId, sessionId, elapsedMs(start),
error == null ? "" : error.getMessage()));
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/middleware/LoggingMiddleware.java
几个细节各有讲究:
- doOnComplete / doOnError 是旁路操作:挂回调、不改事件流,中间件因此是"透明"的;日志、埋点类中间件都该遵守这条边界。
- 错误日志走 warn:错误分类由上层 mapStreamError 收口成 AgentTimeoutException / AgentProviderException 两种领域异常,中间件只记录现象,不再用 error 级别与上层的错误处理抢话语权,避免同一个错误在日志系统里出现两份记录。
- onActing 只记工具名,不记参数与输出:工具参数可能包含 API Key、用户隐私,无差别写日志是审计需求越界变成泄露风险。字段取舍在写中间件时就要想清楚。
三个钩子的日志字段各有一个增量:onAgent 记 agentId 与 sessionId,onModelCall 多记 model(区分主模型与 fallback),onActing 多记 tools(逗号拼接的工具名)。当前未实现 onReasoning 与 onSystemPrompt 两个钩子——意味着无法区分"模型调用耗时"与"推理步骤总耗时",这是后续可以补的观察点。
5.4 ChatOtel:全局 Tracer 的双检锁懒初始化
可观测性的另一条路是 OpenTelemetry(开源的可观测性标准,产出结构化 trace 数据供 Jaeger 等 APM 后端消费)。框架内置的 OtelTracingMiddleware 读取全局 Tracer 产生 span(链路中的一个节点,如一次模型调用),而全局 Tracer 的注册由 dream-scope 自己的 ChatOtel 负责:
public static void install() {
if (installed || !endpointConfigured()) {
return;
}
synchronized (LOCK) {
if (installed || !endpointConfigured()) {
return;
}
if (blank(System.getProperty("otel.service.name"))
&& blank(System.getenv("OTEL_SERVICE_NAME"))) {
System.setProperty("otel.service.name", "dream-scope");
}
AutoConfiguredOpenTelemetrySdk.initialize();
installed = true;
log.info("OpenTelemetry SDK registered (OTLP {})", otlpEndpoint());
}
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/middleware/ChatOtel.java
三层防线:
- 双检锁:锁外先查一次,锁内再查一次,多线程并发 install 时 SDK 只初始化一次。installed 是普通 boolean——锁内写、锁内读自洽,但锁外快速路径读非 volatile 变量,严格按 Java 内存模型存在可见性风险;最坏情况只是错过快速路径、进锁再查一次,工程上可接受。要更稳妥可声明为 volatile 或 AtomicBoolean。
- 懒初始化:只有配置了
OTEL_EXPORTER_OTLP_ENDPOINT(环境变量或同名系统属性)才真正初始化,否则保持 no-op(空操作)。本地开发不配 endpoint,零开销;上生产配一个环境变量,链路数据自动导出。 - 幂等:installed 标志保证重复调用无害——生产装配与单测路径都可能触发 install,第二次调用直接返回。
service name 也有兜底:OTEL_SERVICE_NAME 与 otel.service.name 都没配时,写入 dream-scope,避免链路在 APM 后端里以 unknown service 出现。
5.5 权限取舍的真实决策
把 01 的安全设计与本节的中间件能力放在一起,能看到 dream-scope 在权限上的两层取舍:
- 装配期消灭:
disableFilesystemTools()与disableShellTool()在 builder 链上直接砍掉工作区文件工具与 shell,HTTP 进程从能力清单上就不存在这些危险项; - 运行期拦截:onActing 短路是框架预留的通用拦截路径,适合"按调用方身份动态判断"的场景。
dream-scope 主路径选择了前者。理由:chat 面向公网 HTTP,文件与 shell 的危险性与调用方无关,任何身份都不该碰——静态装配一刀切最简单,也不依赖运行时判断的正确性。动态拦截留给真正需要按身份区分权限的场景(多租户差异化授权)。两层不是替代关系:装配期收紧攻击面,运行期拦截做细粒度控制,纵深防御里各占一层。
六、收口:主路径为什么是 HarnessAgent——ScopeChatAgent 装配实录
6.1 16 个参数与两个入口
前面每节都引用了 ScopeChatAgent 的局部,现在看全貌。它的私有构造器有 16 个参数:
private ScopeChatAgent(
Model model,
Duration callTimeout,
AgentStateStore stateStore,
Path workspace,
int compactionTriggerMessages,
int compactionKeepMessages,
GenerateOptions generateOptions,
Model fallbackModel,
DistributedStore distributedStore,
AutoCloseable redisClient,
boolean planModeEnabled,
String planDirectory,
RetrievePort retrievePort,
List<McpClientWrapper> mcpClients,
String sysPromptOverride,
ChatNacosClient nacos) {
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
16 个参数不是平铺的,按职责分五组:
| 分组 | 参数 | 职责 |
|---|---|---|
| 模型/超时 | model, callTimeout | 主模型与调用超时 |
| 存储 | stateStore, distributedStore | 会话状态的两条持久化路径(第三节) |
| 工作区/压缩 | workspace, compactionTriggerMessages, compactionKeepMessages, planModeEnabled, planDirectory | 工作区目录、压缩阈值、计划模式落盘 |
| 可选扩展 | generateOptions, fallbackModel, retrievePort, mcpClients, sysPromptOverride | 采样覆盖、备用模型、RAG、MCP、部署方提示词增量 |
| 外部客户端 | redisClient, nacos | Redis 连接与 Nacos 客户端,由 create 工厂注入 |
第一观感是"该用 Builder 了"。实际的取舍是两个入口 + 一处汇聚:公开构造器最多 6 个参数(model、callTimeout、stateStore、workspace 与压缩两项),服务单测——临时 workspace、注入文件 store、不连 Redis;生产 create 工厂组装全部 16 个。所有依赖集中在一个私有构造器里,builder 模式反而要多出两百行转发代码。类是无 Spring 的普通类,web 的 PortsConfig 通过 create 注入一切——01 说的"框架类型的集中点",指的就是这里。
6.2 装配顺序的三条约束
私有构造器的开头几十行藏着三条顺序约束:
约束一:ChatOtel.install() 必须最先。
ChatOtel.install();
this.callTimeout = normalizeTimeout(callTimeout);
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
全局 Tracer 注册晚于任何 span 产生,那段链路就永久丢失。install 放在构造器第一行,后续装配过程中产生的任何 span 都有处可去。
约束二:提示词三层拼接,顺序固定。
String base = SYS_PROMPT;
if (sysPromptOverride != null && !sysPromptOverride.isBlank()) {
base = SYS_PROMPT + "\n" + sysPromptOverride.trim();
}
String prompt = planModeEnabled ? base + PLAN_PROMPT : base;
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
拼接顺序:基础系统提示 → 配置覆盖 → Plan Mode 附加。三层各有分工:
- SYS_PROMPT:仓库内置的基线,包含工具路由与子 Agent 调度规则;
- sysPromptOverride:部署方注入的增量;
- PLAN_PROMPT:只在计划模式开启时追加。
注意 Nacos 的动态提示词不走这条拼接——它作为 onSystemPrompt 中间件在每次推理时动态生效,与 build 时固化的 sysPrompt 是两条路径。动态提示词中间件的形态如下:
@Override
public Mono<String> onSystemPrompt(Agent agent, RuntimeContext ctx, String current) {
String base = current == null ? "" : current;
String loaded = nacos.sysPrompt(null);
if (loaded == null || loaded.isBlank()) {
return Mono.just(base);
}
return Mono.just(base + "\n" + loaded);
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/nacos/ChatNacosPromptMiddleware.java(节选,完整实现含防重与空值分支)
启动时静态拼接 + 运行时 Transformer 追加,两种提示词更新路径各自独立,互不干扰。
约束三:Nacos 技能仓库在 builder 链之后条件设置。
HarnessAgent.Builder builder = HarnessAgent.builder()
.name(AgentIds.CHAT)
.sysPrompt(prompt)
.model(Objects.requireNonNull(model, "model"))
.toolkit(chatToolkit(retrievePort, this.mcpClients))
.compaction(compactionConfig(compactionTriggerMessages, compactionKeepMessages))
.memory(MemoryConfig.defaults())
.subagents(chatSubagents())
.middlewares(chatMiddlewares(extras))
.disableFilesystemTools()
.disableShellTool();
if (nacosSkills != null) {
builder.skillRepository(nacosSkills);
}
if (generateOptions != null) {
builder.generateOptions(generateOptions);
}
if (fallbackModel != null) {
builder.fallbackModel(fallbackModel);
}
if (distributedStore != null) {
builder.distributedStore(distributedStore);
} else if (stateStore != null) {
builder.stateStore(stateStore);
}
boolean hasWorkspace = workspace != null && !workspace.toString().isBlank();
if (hasWorkspace) {
builder.workspace(workspace);
}
if (planModeEnabled && hasWorkspace) {
builder.enablePlanMode()
.planFileDirectory(planDirectory == null || planDirectory.isBlank() ? "plans" : planDirectory);
}
this.agent = builder.build();
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
主链只放必选项,条件项全部收在链后用 if 守卫:Nacos 技能仓库、采样参数、备用模型、存储、工作区、计划模式,每个挂点一个条件,一行对应一个工程决策。这个写法让 builder 链本身成为一张"必装清单",而条件分支成为"选装清单",装配审查时一眼可分。Plan Mode 依赖 workspace 落盘(计划文件写进 plans/ 目录),没有工作区就不开,这是挂点之间的隐式依赖被显式写出的例子。
6.3 双入口的双路径
ScopeChatAgent 对外实现 domain 的 StreamingAgentHandler,streamHandle 是唯一推理入口,按请求是否要求结构化输出分叉:普通路径订阅 agent.streamEvents(...),细事件经 EventCodec 转六种对外事件;结构化路径走 agent.call(messages, schema, context),因为 2.0.3 的 streamEvents 没有 schema 重载,完整输出后编成一条 Done 事件。01 第四节的 EventCodec 细节在此不重复,两路径最终汇入同一个 AgentStreamHandler 回调。
handle(同步入口)不是另一条裸 call,而是订阅 streamHandle、拼齐 Done 后返回,等待上限是 callTimeout 加 5 秒收尾余量。同步与流式共用一条推理链,这是 01 的结论,也是装配层的直接结果。
6.4 生命周期:申请与释放互为镜像
create 工厂里,资源申请顺序被严格定义:ChatRedis.open(ping 校验)→ ChatMcp.openAll(MCP,模型连接外部工具与数据的开放协议,建连)→ resolveModel → new ScopeChatAgent(触发 builder 装配)。任一步失败,已申请的资源按逆序关闭:
} catch (RuntimeException ex) {
ChatMcp.closeQuietly(mcpClients);
jedis.close();
throw ex;
}
片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeChatAgent.java
close 方法镜像了申请顺序:先 agent.close(),再 MCP 连接,最后 Jedis。build 失败不留半开连接,进程退出不留悬挂资源。
6.5 为什么主路径是 HarnessAgent
现在可以完整回答标题问题。三个理由:
- 能力与循环解耦:ReActAgent 提供循环,HarnessAgent 提供边界。业务对循环的依赖被限制在"调用"这个动作上,框架升级时推理行为可预期;
- 共享对象即协议:RuntimeContext、Workspace、AgentStateStore 三个对象是 Harness 各能力的全部衔接面。会话、多租户、记忆压缩不需要业务代码发明任何协议,交给框架的约定即可;
- 装配点集中:第六节的全部分支都发生在 ScopeChatAgent 一个类的构造器里,两入口对外,装配细节对内。web 与 domain 只看到 AgentInvokeRequest 进、AgentEvent 出,框架类型不出 adapter——六边形边界的承诺在装配层兑现。
如果主路径直接用 ReActAgent,上面三条都要业务代码自己补:自己设计会话结构、自己定义租户隔离、自己实现压缩触发。HarnessAgent 的价值不在多几个功能,而在这些工程命题已经有了框架级的形状。
七、本篇学到了什么与 03 预告
框架侧,本篇完成了 harness 层的四个认知:HarnessAgent 是 ReActAgent 的薄包装,工程能力全部以 builder 挂点按需开启,core 循环不改写;RuntimeContext、Workspace、AgentStateStore 三个共享对象构成能力的全部衔接面;状态会话以 (userId, sessionId) 寻址,五种存储实现与"显式 builder > distributedStore 自动注入 > 本地默认"的优先级规则覆盖从单测到多副本的部署谱系;MiddlewareBase 以接口形态提供四个洋葱钩子与一个提示词变换钩子,next 短路即拦截,ChatOtel 的双检锁懒初始化让可观测能力零配置缺席、一配置生效。
工程侧,ScopeChatAgent 把这些概念装配成一个真实可运行的主路径:16 参数构造器汇聚两个入口,ChatOtel.install 最先、提示词三层拼接、条件挂点守卫三条顺序约束,资源申请与释放互为镜像。权限取舍落在"装配期消灭危险工具、运行期拦截留给动态授权"的两层设计上。主路径选择 HarnessAgent,本质是选择让框架的工程约定替代业务代码里的自造协议。
下一篇进入知识与工具层:@Tool 注解驱动的工具系统——工具如何被扫描注册、签名约定如何影响模型调用,以及 RAG 检索链路——从 retrieve 工具到向量检索、查询改写与重排序的完整路径。工具是 Agent 的手,检索是 Agent 的书架,两者在本文 SYS_PROMPT 的工具路由里已经露过面,届时拆开看内部。
项目信息:
- dream-scope 开源地址:GitHub - logosssss/dream-scope: 基于 AgentScope Java 2.0 的模块化单体 Agent 运行时 · GitHub (觉得有帮助欢迎 star)
- Dream-SaaS 项目地址:dream-saas.com
有问题评论区见,欢迎交流~