前言
AgentScope Java 的 Harness 模块是一个面向生产环境的多智能体运行时框架。本文基于官方文档,从其设计理念到具体代码实践,完整梳理 Harness 的核心架构与使用方式。
一、Harness 是什么
agentscope-harness 在 agentscope-core 的 ReActAgent 之上,通过 Hook 和 Toolkit 两个扩展点,装配出一套面向长期稳定运行的工程化基础设施。它的入口只有一个类:HarnessAgent。
裸的 ReActAgent 只有"请求 — 推理 — 工具 — 回复"一轮循环。而 Harness 要回答的是一组更贴近生产的问题:
- 下一轮怎么办?
- 下一天怎么办?
- 上下文爆了怎么办?
- 状态丢了怎么办?
- 任务太重怎么办?
它不替换推理循环,而是在循环的关键时机插入 hook、为模型补上一组基础工具,把这些问题的默认工程答案打包好。
快速开始
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-harness</artifactId>
<version>${agentscope.version}</version>
</dependency>
public class QuickstartExample {
public static void main(String[] args) throws Exception {
// 1. 准备工作区
Path workspace = Paths.get(".agentscope/workspace");
initWorkspaceIfAbsent(workspace);
// 2. 构建模型
Model model = DashScopeChatModel.builder()
.apiKey(System.getenv("DASHSCOPE_API_KEY"))
.modelName("qwen-max")
.stream(true)
.build();
// 3. 构建 HarnessAgent
HarnessAgent agent = HarnessAgent.builder()
.name("quickstart-agent")
.sysPrompt("你是一个帮助用户做笔记的助手。")
.model(model)
.workspace(workspace)
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.flushBeforeCompact(true)
.build())
.build();
// 4. 两轮对话,同一 sessionId
RuntimeContext ctx = RuntimeContext.builder()
.sessionId("demo-session")
.userId("alice")
.build();
Msg turn1 = agent.call(
Msg.builder().role(MsgRole.USER)
.textContent("我叫天宇,今天准备做一个关于 ReAct 的技术分享。")
.build(),
ctx).block();
Msg turn2 = agent.call(
Msg.builder().role(MsgRole.USER)
.textContent("我叫什么?我今天要干什么?")
.build(),
ctx).block();
}
private static void initWorkspaceIfAbsent(Path workspace) throws Exception {
Files.createDirectories(workspace);
Path agentsMd = workspace.resolve("AGENTS.md");
if (Files.exists(agentsMd)) return;
Files.writeString(agentsMd, """
# 笔记助手
你是一个帮助用户整理笔记和知识的助手。
## 行为约定
- 主动记录用户提到的关键事实(姓名、计划、偏好等)
- 回答用简洁中文,必要时给出要点列表
- 对不确定的内容要主动说明,不要臆造
""");
}
}
上述代码展示了 Harness 的三个核心价值:工作区驱动的人格、会话持久化(同一 sessionId 的第二轮对话记得第一轮的内容)、显式启用对话压缩。
二、架构设计
2.1 设计理念(三个核心决策)
决策一:薄包装,不替换推理循环
HarnessAgent 不是一个新的推理引擎,它只是 ReActAgent 的薄包装,自身只做两件额外的事:
bindRuntimeContext(ctx)— 每次call()开头,把 sessionId/userId 分发给关心它的 hook,并按需从 Session 恢复 Memory 状态forceCompactAndRetry— 当模型返回 ContextOverflow 错误时,强制压缩并重试一次
其余所有能力——工作区注入、记忆管理、会话持久化、子 agent 编排——全部通过 ReActAgent 已有的 Hook 和 Toolkit 扩展点注入。ReActAgent 的能力完整保留,harness 只叠加,不替换。
决策二:Hook 驱动,能力正交
每个 hook 只做一件事,通过 priority 在同一个事件上排好执行顺序。比如在 PreReasoningEvent 上:
CompactionHook(10)— 先检查是否需要压缩SubagentsHook(80)— 注入子 agent 列表WorkspaceContextHook(900)— 最后叠加工作区文件
Hook 之间不持有彼此的引用,只通过三个共享对象通信。每项能力都能独立开关。
决策三:共享对象是唯一耦合点
| 对象 | 职责 | 生命周期 |
|---|---|---|
| RuntimeContext | 当次 call() 的身份:sessionId、userId、session 引用、extra 数据 | 每次 call() 重新注入,不持久化 |
| WorkspaceManager | 工作区无状态访问器:两层读(filesystem 优先 → 本地兜底)、写走 filesystem | 构建时创建,跨 call 复用 |
| AbstractFilesystem | 存储后端:本地磁盘 / 沙箱 / KV Store | 构建时创建,跨 call 复用 |
2.2 顶层架构
调用方 → HarnessAgent(薄包装层)→ ReActAgent(推理内核)→ 共享对象层
- 薄包装层:负责 per-call 身份绑定 + ContextOverflow 兜底
- 推理内核:Hook 事件驱动 + ReAct 循环(reason → act → observe)+ 工具执行
- 共享对象层:三个对象是所有 Hook 的协作底座
2.3 构建阶段
HarnessAgent agent = HarnessAgent.builder()
.name("agent")
.model(model)
.workspace(workspace)
.compaction(CompactionConfig.builder()
.triggerMessages(50)
.triggerTokens(80_000)
.flushBeforeCompact(true)
.build())
.toolResultEviction(ToolResultEvictionConfig.defaults())
.build();
Builder.build() 时一次性装配:
- 创建三个共享对象(WorkspaceManager、AbstractFilesystem、RuntimeContext ref)
- 按 priority 串好 Hook 链
- 追加内置工具到 Toolkit
- 从
workspace/skills/装配 SkillBox - 构造 ReActAgent delegate
- 启动后台 MemoryMaintenanceScheduler(守护线程,6h 周期)
三、Hook 事件管道
3.1 完整的事件矩阵
| 事件 | 触发时机 | 触发的 Hook(priority 升序) |
|---|---|---|
| PreCallEvent | 推理循环启动前 | AgentTraceHook(0) |
| PreReasoningEvent | 调用模型前 | Trace(0) → CompactionHook(10) → SubagentsHook(80) → WorkspaceContextHook(900) |
| PostReasoningEvent | 每次模型返回后 | Trace(0) |
| PreActingEvent | 每个工具调用前 | Trace(0) |
| PostActingEvent | 每个工具调用后 | EvictionHook(50) |
| PostCallEvent | 最终回复产出后 | Trace(0) → MemoryFlushHook(5) → MemoryMaintenanceHook(6) → SessionPersistenceHook(900) |
| ErrorEvent | 推理出现异常时 | SessionPersistenceHook(900) |
3.2 call() 生命周期时序
① bindRuntimeContext(ctx) → 分发 ctx、恢复 Memory
② delegate.call(msg) → ReAct 循环
├── PreCallEvent → Trace
├── PreReasoningEvent → Compact → Subagents → WorkspaceCtx
├── stream(model)
├── PostReasoningEvent → Trace
├── [loop] PreActingEvent → invoke(tool) → PostActingEvent
└── PostCallEvent → Trace → MemFlush → MemMaint → SessionPersistence
③ 失败路径: ErrorEvent → Session saveTo
④ ContextOverflow: forceCompactAndRetry → 重试
四、工作区(Workspace)
工作区是 HarnessAgent 的"地基":人格、长期记忆、领域知识、子 agent 声明、会话历史、技能定义统一以目录结构 + Markdown 的形式落地,不再散落在代码里。
4.1 触发时机
| 时机 | 动作 |
|---|---|
HarnessAgent.build() | WorkspaceManager.validate() 检查目录和 AGENTS.md 是否存在(缺失只 warn) |
每次 call() 推理前 | WorkspaceContextHook 读 MEMORY.md、knowledge/ 等注入 system prompt |
| 压缩 / 调用结束 | MemoryFlushHook、SessionPersistenceHook 等写回 memory/、agents/…/sessions/ |
4.2 目录结构
workspace/ ← 默认 .agentscope/workspace
├── AGENTS.md ← 人格 / 行为约定(每次注入全文)
├── MEMORY.md ← 整理过的长期记忆(每次注入,受 token 预算)
├── knowledge/
│ ├── KNOWLEDGE.md ← 领域知识入口
│ └── * ← 其他参考文件,按需 read_file 打开
├── memory/
│ ├── YYYY-MM-DD.md ← 每日记忆流水账(追加,由 MemoryFlushManager 写入)
│ └── .consolidation_state ← MemoryConsolidator 内部状态
├── skills/<skill-name>/SKILL.md ← 自定义技能
├── subagents/<id>.md ← 子 agent 声明(文件名=agent_id,自动发现)
└── agents/<agentId>/
├── workspace/ ← isolated 子 agent 的运行时根
└── sessions/
├── sessions.json ← 会话索引(id / summary / updatedAt)
├── <sessionId>.jsonl ← LLM 可见的压缩上下文
└── <sessionId>.log.jsonl ← 完整对话日志(追加)
4.3 两层读写机制
WorkspaceManager 是无状态访问器,所有读写遵循同一规约:
读路径:AbstractFilesystem 优先 → 本地磁盘兜底
让多租户场景对调用方透明
写路径:默认全部走 AbstractFilesystem
未配置时 fallback 本地磁盘
List 操作(listKnowledgeFiles / listMemoryFilePaths / listSessionLogFiles)
取两层并集去重,避免漏文件
4.4 System Prompt 注入内容
WorkspaceContextHook(priority 900)在 PreReasoningEvent 拼装一段固定结构的文本,合并到第一条 SYSTEM 消息:
| 段落 | 来源 | Token 预算 |
|---|---|---|
## Context | 模板生成(日期、OS、workspace 路径、sessionId) | 不限 |
<loaded_context> XML 块 | 内置模板 | — |
<agents_context> | AGENTS.md 全文 | — |
<memory_context> | MEMORY.md | maxContextTokens 限制 |
<domain_knowledge_context> | knowledge/KNOWLEDGE.md + listKnowledgeFiles() 路径目录 | 全文 + 路径目录 |
<{rel_path}> | 每个 additionalContextFile | 默认 8000 chars |
超出预算时按字符截断,并附尾注提示 agent 改走 memory_search。
4.5 关键 API
WorkspaceManager wm = new WorkspaceManager(workspace, abstractFilesystem);
wm.readAgentsMd(); // 两层读
wm.readMemoryMd();
wm.readKnowledgeMd(); // 读 knowledge/KNOWLEDGE.md
wm.readManagedWorkspaceFileUtf8(rel); // 任意工作区相对路径(path traversal 校验)
wm.listKnowledgeFiles(); // 两层并集
wm.listMemoryFilePaths();
wm.listSessionLogFiles();
wm.appendUtf8WorkspaceRelative(rel, content); // 走 AbstractFilesystem
wm.updateSessionIndex(agentId, sessionId, summary); // 维护 sessions.json
HarnessAgent agent = HarnessAgent.builder()
.name("MyAgent")
.model(model)
.workspace(Paths.get(".agentscope/workspace")) // 不传则用默认
.additionalContextFile("SOUL.md") // 任意工作区相对路径
.additionalContextFile("PREFERENCES.md")
.maxContextTokens(8000) // 控制 MEMORY 的注入上限
.build();
AGENTS.md 缺失时 agent 仍可工作,但会丢失 persona 段。
五、记忆系统(Memory)
记忆要解决两个问题:跨会话记住事实(今天说"我叫天宇",明天重启进程还能记得)+ 上下文不无限增长(聊 100 轮不能全塞给模型)。
5.1 双层记忆模型
对话 messages(InMemoryMemory)
│ 超阈值(50条/80K tokens)
▼
ConversationCompactor
├── offload → sessions/<id>.log.jsonl(原始对话存档)
└── flushMemories → MemoryFlushManager
└── append + FTS5 index → memory/YYYY-MM-DD.md(每日流水账)
↓ 后台二次加工
MemoryConsolidator
├── 读 MEMORY.md 去重
└── 重写 MEMORY.md(每次推理注入)
| 层次 | 文件 | 特点 |
|---|---|---|
| 第一层·流水账 | memory/YYYY-MM-DD.md | 只追加、不去重,LLM 每次提取新事实写入 |
| 第二层·策划后长期记忆 | MEMORY.md | MemoryConsolidator 后台整体重写,每次注入 system prompt |
第一层不直接入 system prompt(太原始),第二层才是每次推理喂给模型的"长期记忆"。
5.2 对话压缩流程
① 检查阈值(triggerMessages / triggerTokens)
② 找 cutoff 点(不切开 ASSISTANT/TOOL 对)
③ flushMemories(prefix) → LLM 提炼新事实 → 追加到今日流水账
④ offloadMessages → 原始消息追写到 sessions/<id>.log.jsonl
⑤ LLM distill summary → [summaryUserMsg] + tail 重装回 Memory
完整配置:
HarnessAgent agent = HarnessAgent.builder()
.name("MyAgent")
.model(model)
.workspace(workspace)
.compaction(CompactionConfig.builder()
.triggerMessages(50) // 按条数触发(0 = 关闭)
.triggerTokens(80_000) // 按 token 估算触发
.keepMessages(20) // 保留尾部条数
.keepTokens(0) // 非 0 时按 token 预算从后往前扫描
.flushBeforeCompact(true) // 压缩前提取记忆到今日流水账
.offloadBeforeCompact(true) // 压缩前将原始消息追写到 .log.jsonl
.summaryPrompt(...) // 四段式内置模板
.truncateArgs(TruncateArgsConfig.builder() // 轻量预处理(不走LLM)
.triggerMessages(25)
.triggerTokens(40_000)
.maxParamChars(2000) // write_file 等大入参截断
.build())
.build())
.toolResultEviction(ToolResultEvictionConfig.defaults()) // 工具结果卸载
.build();
5.3 记忆提取(flushMemories)
拿当前对话和今日流水账一起丢给 LLM,要求输出"仅新增的 bullet"。没有新东西则返回 NO_REPLY。写入位置固定是当天文件,不会动 MEMORY.md(防止二层被一层覆写)。
写完后立刻增量重建 FTS5 索引,并触发后台 consolidation 请求(30 分钟节流)。
5.4 二次合并(MemoryConsolidator)
后台线程读 mtime 超 watermark 的日流水账 + 当前 MEMORY.md,调 LLM 合并、去重、裁剪。默认输出限制 maxMemoryTokens=4000(约 16k 字符)。写后推进 watermark 存于 memory/.consolidation_state。永不阻塞推理循环。
5.5 上下文溢出自动恢复
模型返回 context_length_exceeded 时,HarnessAgent 自动捕获、强制压缩、清空 Memory 后重试。前提是配置了 compaction,否则直接抛错。
5.6 后台维护
MemoryMaintenanceScheduler 默认每 6h 运行一轮:
| 任务 | 行为 | 默认值 |
|---|---|---|
| expireDailyFiles | 超期日文件归档到 memory/archive/ | 90 天 |
| consolidateMemory | 合并去重到 MEMORY.md | — |
| pruneOldSessions | 删除超期会话文件 | 180 天 |
| reindex | 重建 FTS5 全文索引 | 6h |
5.7 memory_search 检索
MemoryIndex index = new MemoryIndex(workspaceDir);
index.open();
List<SearchHit> hits = index.search("数据库迁移");
// hit: { path, lineNumber, content, rank }
底层 SQLite FTS5,索引文件位于 <workspace_parent>/memory_index.db。
5.8 与会话的区别
| 会话(Session) | 记忆(Memory) | |
|---|---|---|
| 存什么 | Memory 快照 + 原始对话 JSONL | LLM 提炼的事实 bullet |
| 跨调用恢复 | 下一轮自动 loadIfExists | 通过 WorkspaceContextHook 注入 system prompt |
| 检索 | session_search 翻原始日志 | memory_search 走 FTS5 索引 |
| 生命周期 | 随会话过期清理(180天) | 持续积累到 MEMORY.md |
六、文件系统(Filesystem)
AbstractFilesystem 把 agent 对文件和命令的访问从"一定是本机磁盘"抽象成统一接口。三种声明式模式可选。
6.1 三种模式一览
| 模式 | 配置方法 | 典型产物 | 执行 Shell | 适用场景 |
|---|---|---|---|---|
| 1 — 复合+共享存储 | filesystem(RemoteFilesystemSpec) | CompositeFilesystem(本地根 + 按前缀路由到 KV) | 无(设计上不开放 shell) | 多副本共享记忆、会话落盘到 Redis/OSS,不执行命令 |
| 2 — 沙箱 | filesystem(SandboxFilesystemSpec) | SandboxBackedFilesystem | 有(在沙箱内执行) | 隔离执行、可恢复沙箱会话、快照 |
| 3 — 本机+shell | filesystem(LocalFilesystemSpec) / 默认 | LocalFilesystemWithShell | 有(直接在宿主执行) | 单进程测试、信任环境 |
6.2 三种模式详解
模式一:复合 + 共享存储(RemoteFilesystemSpec)
构造出来的是一个 CompositeFilesystem,由两个部分组成:
- 默认后端:纯
LocalFilesystem(工作区根目录,无 shell) - 前缀路由后端:
RemoteFilesystem(KV Store,如 Redis/OSS)
读写文件时按最长前缀匹配决定路由到哪个后端:
// 等价构造逻辑
CompositeFilesystem(
defaultBackend = LocalFilesystem(workspace/), // 未匹配的路径走本地
prefixBackends = {
"agents/<agentId>/sessions/" → RemoteFilesystem(Redis),
"memory/" → RemoteFilesystem(Redis),
// 还可 addSharedPrefix("knowledge/")
}
)
路由示例:
| 操作路径 | 匹配前缀 | 路由到 |
|---|---|---|
AGENTS.md | 无匹配(默认) | 本地 LocalFilesystem |
MEMORY.md | 无匹配(默认) | 本地 LocalFilesystem |
memory/2026-05-20.md | memory/ | RemoteFilesystem (Redis) |
agents/myBot/sessions/demo.jsonl | agents/... | RemoteFilesystem (Redis) |
为什么要这样设计:工作区根目录在本地磁盘,但记忆、会话等关键数据路由到共享 KV 存储。这样多副本部署时 MEMORY.md、memory/、sessions/ 在所有副本间一致,同时普通文件读写走本地不依赖网络。
HarnessAgent agent = HarnessAgent.builder()
.name("store-agent")
.model(model)
.filesystem(RemoteFilesystemSpec.builder()
.store(new RedisStore("redis-host", 6379))
.isolationScope(IsolationScope.USER) // user 作为 KV 命名空间前缀
.build())
.build();
模式一不提供 shell,因为默认后端是纯 LocalFilesystem(没有 execute 方法)。
模式二:沙箱(SandboxFilesystemSpec)
对外仍是 AbstractFilesystem,但真实 IO 和进程运行在 SandboxClient 管理的隔离环境中。SandboxLifecycleHook 在每次 call 周围执行 acquire / persist / release。
HarnessAgent agent = HarnessAgent.builder()
.name("sandbox-agent")
.model(model)
.filesystem(DockerFilesystemSpec.builder()
.image("ubuntu:24.04")
.snapshotSpec(OssSnapshotSpec.builder().bucket("my-bucket").build())
.isolationScope(IsolationScope.SESSION)
.build())
.build();
详见前文的沙箱章节。
模式三:本机 + shell(LocalFilesystemSpec / 默认)
根目录为工作区,命令通过 ProcessBuilder 在宿主机执行。不配任何 filesystem(...) 时默认就是此模式。
// 默认等价,仅用于调整超时等参数
HarnessAgent agent = HarnessAgent.builder()
.name("local-agent")
.model(model)
.filesystem(LocalFilesystemSpec.builder()
.executeTimeoutSeconds(120)
.build())
.build();
逃生口
不想用三种模式之一,可以自己实现一整套 AbstractFilesystem:
HarnessAgent agent = HarnessAgent.builder()
.name("custom-agent")
.model(model)
.abstractFilesystem(myCustomTree)
.build();
6.3 类层次
AbstractFilesystem(接口)
read / write / edit / grep / glob / upload / download
│
├── LocalFilesystem 仅本机文件,无执行(rootDir 防穿越)
│ └── LocalFilesystemWithShell 本机 + 宿主 shell(模式三的核心)
│
├── RemoteFilesystem KV 存储,无 shell
│
├── CompositeFilesystem 最长前缀匹配多后端(模式一的核心)
│
└── AbstractSandboxFilesystem 增加 execute() 方法
├── BaseSandboxFilesystem 对接远程 Unix,多数方法用 execute 拼命令
└── SandboxBackedFilesystem SandboxManager 代理(模式二的核心)
read(filePath, offset, limit) 中 limit=0 表示使用实现定义的"读默认行数"(本地与沙箱可能不同)。
6.4 多租户:NamespaceFactory
@FunctionalInterface
public interface NamespaceFactory {
List<String> getNamespace(RuntimeContext ctx);
}
每次文件操作会调用,返回当前请求的路径段。例如 userId=alice 返回 ["users", "alice"],同一份 RemoteFilesystem 实例在不同用户下自动落在不同的存储子树:
Redis 里键的分布:
users/alice/MEMORY.md
users/alice/memory/2026-05-20.md
users/bob/MEMORY.md
users/bob/memory/2026-05-20.md
在 RemoteFilesystemSpec 上设置 isolationScope(SESSION/USER/AGENT/GLOBAL)即可控制命名空间粒度,无需额外代码。
6.5 各 Filesystem 实现速查
| 实现 | 能力 | 说明 |
|---|---|---|
LocalFilesystem | 仅本机文件,无执行 | rootDir 防穿越 |
LocalFilesystemWithShell | 本机文件 + 宿主 shell | 模式三核心,ProcessBuilder |
RemoteFilesystem | 无 shell 的 KV 存储 | 配合 Redis/OSS |
CompositeFilesystem | 最长前缀匹配,组合多后端 | 模式一核心 |
BaseSandboxFilesystem | 对接远程 Unix | 多数方法用 execute 拼命令实现 |
SandboxBackedFilesystem | SandboxManager 代理 | 模式二核心 |
注意:CompositeFilesystem 只实现 AbstractFilesystem,不实现 AbstractSandboxFilesystem,所以本身无 shell。若需组合路由又要 shell,需用 abstractFilesystem 逃生口自行提供含 shell 的后端,或选用模式二/三。
七、沙箱(Sandbox)— 隔离执行
7.1 隔离维度
| 范围 | 持久化键来源 | 典型场景 |
|---|---|---|
| SESSION(默认) | sessionKey.toIdentifier() | 每个会话独立的沙箱/记忆 |
| USER | RuntimeContext.userId | 同用户跨会话共享工作区 |
| AGENT | agent 名称(构建时固定) | 单 agent 全局共享 |
| GLOBAL | __global__ | 全局共享 |
// SESSION 隔离:默认,每条对话独立沙箱
HarnessAgent agent = HarnessAgent.builder()
.name("code-agent")
.model(model)
.filesystem(DockerFilesystemSpec.builder()
.image("ubuntu:24.04")
.snapshotSpec(OssSnapshotSpec.builder().build())
.isolationScope(IsolationScope.SESSION) // 默认,可省略
.build())
.build();
// 不同 sessionId → 独立沙箱
agent.call(msg, RuntimeContext.builder()
.sessionId("session-1").userId("alice").build()).block();
agent.call(msg, RuntimeContext.builder()
.sessionId("session-2").userId("alice").build()).block(); // 不同沙箱
// USER 隔离:同一用户跨 Pod 共享快照(多副本推荐)
HarnessAgent agent = HarnessAgent.builder()
.name("assistant")
.model(model)
.filesystem(DockerFilesystemSpec.builder()
.image("ubuntu:24.04")
.snapshotSpec(OssSnapshotSpec.builder().build())
.isolationScope(IsolationScope.USER)
.build())
.sandboxDistributed(SandboxDistributedOptions.builder()
.redisSession(new RedisSession("redis-host", 6379))
.ossSnapshotSpec(OssSnapshotSpec.builder().build())
.build())
.build();
// 同一 userId → 从同一快照恢复
agent.call(msg, RuntimeContext.builder()
.sessionId("session-xyz").userId("alice").build()).block();
7.2 快照与 4-分支恢复
Sandbox.start() 通过 4 个分支决定如何初始化工作区:
- Branch A: workspaceRootReady=true & 容器内目录仍存在 → 只重新应用 ephemeral 条目(热启动)
- Branch B: workspaceRootReady=true & 容器内目录丢失 → 从快照还原 + 重新应用 ephemeral
- Branch C: workspaceRootReady=false & 快照可用 → 从快照还原 + 全部重新应用
- Branch D: workspaceRootReady=false & 无快照 → 全量初始化(冷启动)
7.3 并发控制
多副本下同一用户的沙箱状态需要保护:
RedisSandboxExecutionGuard guard = new RedisSandboxExecutionGuard(
new JedisPooled("redis-host", 6379),
Duration.ofMinutes(5), // TTL,须大于最坏情况 call 耗时
Duration.ofMillis(500), // 轮询间隔
"myapp:sandbox:lock:" // key 前缀
);
HarnessAgent agent = HarnessAgent.builder()
.name("shared-agent")
.model(model)
.filesystem(DockerFilesystemSpec.builder()
.image("ubuntu:24.04")
.snapshotSpec(OssSnapshotSpec.builder().build())
.isolationScope(IsolationScope.AGENT)
.sandboxExecutionGuard(guard)
.executionGuardRequired(true)
.build())
.build();
7.4 自定义沙箱实现
// 1. 实现 SandboxClient 和 SandboxFilesystemSpec
public class MySandboxFilesystemSpec extends SandboxFilesystemSpec {
@Override public SandboxClient<?> createClient() { return new MySandboxClient(); }
@Override public SandboxClientOptions clientOptions() { return new MySandboxClientOptions(); }
@Override public SnapshotSpec snapshotSpec() { return NoopSnapshotSpec.INSTANCE; }
@Override public WorkspaceSpec workspaceSpec() { return new WorkspaceSpec(); }
}
// 2. 启用
HarnessAgent agent = HarnessAgent.builder()
.name("my-agent")
.model(model)
.filesystem(new MySandboxFilesystemSpec())
.build();
7.5 工作区投影
沙箱模式下,宿主工作区里的特定目录在每次启动时自动同步进沙箱:
宿主机 workspace/ 沙箱内 /workspace/
├── AGENTS.md ──投影──→ ├── AGENTS.md
├── skills/ ──投影──→ ├── skills/
├── subagents/ ──投影──→ ├── subagents/
└── knowledge/ ──投影──→ └── knowledge/
宿主机器上文件更新后,下次沙箱启动时哈希变化,新版文件自动同步进去。Skill 脚本在沙箱内执行,pip install、rm -rf 等操作只影响沙箱,宿主机无感。
八、会话(Session)
会话让 agent 能在跨请求、跨进程、多用户场景下恢复状态。一次 call() 结束后自动落盘两路产出。
8.1 双轨存储布局
workspace/agents/<agentId>/
├── context/ ← WorkspaceSession 负责
│ └── <sessionId>/
│ ├── memory.json ← ReActAgent.memory 快照
│ └── *.json ← 其他 StateModule 序列化产物
└── sessions/ ← SessionTree + WorkspaceManager 负责
├── sessions.json ← 会话索引(sessionId / summary / updatedAt)
├── <sessionId>.jsonl ← LLM 可见的压缩上下文
└── <sessionId>.log.jsonl ← 完整对话日志(append-only,不被压缩)
两条路径分工明确:
| 路径 | 存储对象 | 写入时机 | 用途 |
|---|---|---|---|
| WorkspaceSession(context/) | StateModule 快照(memory.json) | PostCallEvent / ErrorEvent | 下一轮 call() 通过 loadIfExists 恢复 Memory |
| SessionTree(sessions/) | 对话 JSONL | 压缩/offload 时追写 | .jsonl 供 LLM 读取,.log.jsonl 供审计和 session_search 检索 |
8.2 生命周期
第一轮 call(msg, ctx{sessionId="sess-A"})
├── bindRuntimeContext → loadIfExists → Memory 为空(首次)
├── ReAct 循环
└── PostCallEvent → SessionPersistenceHook.saveTo → 写 context/sess-A/memory.json
第二轮 call(msg, ctx{sessionId="sess-A"})
├── bindRuntimeContext → loadIfExists → 读 context/sess-A/memory.json → 恢复第一轮
├── ReAct 循环(已知第一轮内容)
└── PostCallEvent → saveTo → 覆盖写盘
8.3 RuntimeContext 如何串联
RuntimeContext ctx = RuntimeContext.builder()
.sessionId("sess-001")
.userId("alice")
.build();
agent.call(msg, ctx).block();
bindRuntimeContext 做几件事:
- 补默认 Session — ctx.session 为空时使用构建时的
defaultSession(默认是 WorkspaceSession) - 补 sessionKey — 依次试
SimpleSessionKey.of(sessionId)→SimpleSessionKey.of(agentName) - 传递到 hooks — 所有关心的 hook 同步该 ctx
- userId 注入 — userId 作为 NamespaceFactory 路径前缀,多租户透明隔离
- 预加载状态 — 存在则 loadIfExists 覆盖 Memory;不存在不动
8.4 配置方式
// 1. 默认:什么都不传 → WorkspaceSession(workspace, agentId)
HarnessAgent.builder()
.name("MyAgent")
.model(model)
.workspace(workspace)
.build();
// 2. 使用任意指定路径的 JsonSession
HarnessAgent.builder()
.name("MyAgent")
.model(model)
.defaultSession(new JsonSession(Paths.get("/custom/sessions"), "MyAgent"))
.build();
// 3. 调用时临时覆盖
RuntimeContext.builder()
.session(new JsonSession(Paths.get("/tmp/sessions"), "MyAgent"))
.sessionKey(SimpleSessionKey.of("sess-002"))
.build();
8.5 多用户隔离
// 同一 agent 实例服务 alice / bob
agent.call(msg, RuntimeContext.builder()
.sessionId("alice-1").userId("alice").build()).block();
agent.call(msg, RuntimeContext.builder()
.sessionId("bob-1").userId("bob").build()).block();
- 会话层隔离 —
context/<sessionId>/、sessions/<sessionId>.jsonl彼此独立 - 文件层隔离 — NamespaceFactory + IsolationScope 决定路径前缀,互不干扰
十、内置工具
Harness 默认为 agent 提供一套"足够走一个闭环"的内置工具,HarnessAgent.build() 时自动注册,无需手动配置。
10.1 注册路径
HarnessAgent.build()
├── FilesystemTool 必装
├── MemorySearchTool 必装
├── MemoryGetTool 必装
├── SessionSearchTool 必装
├── ShellExecuteTool 条件:backend instanceof AbstractSandboxFilesystem
└── SubagentsHook.tools() 条件:非 leaf 且有 model
├── AgentSpawnTool
└── TaskTool
10.2 工具一览
文件系统工具
所有路径经过 AbstractFilesystem,后端可能为本地磁盘、沙箱或远端 KV。
| 工具 | 功能 | 参数 |
|---|---|---|
read_file | 读文件内容 | path, offset(0-indexed), limit(0=读全) |
write_file | 创建新文件(已存在报错) | path, content |
edit_file | 精确字符串替换 | path, old_string, new_string, replace_all |
grep_files | 搜索字符串(非正则) | pattern, glob |
glob_files | 按 glob 查文件 | pattern |
list_files | 列目录 | path |
记忆工具
| 工具 | 功能 | 参数 |
|---|---|---|
memory_search | FTS5 全文检索,最多 30 条 | query |
memory_get | 读记忆文件指定行范围,带行号 | path, startLine, endLine |
会话工具
| 工具 | 功能 | 参数 |
|---|---|---|
session_search | 在会话 JSONL 中扫关键词 | agentId(可选), maxResults(默认10) |
session_list | 列某个 agent 的会话 | agentId |
session_history | 返某个会话最近 N 条消息 | sessionId, lastN(默认20) |
子 Agent 工具
只在非 leaf 且有 model 时出现。session mode 下名称变为 sessions_spawn、sessions_send、sessions_list。
| 工具 | 功能 | 参数 |
|---|---|---|
agent_spawn | 创建临时子 agent | agent_id(必填), task(可选), label, timeout_seconds(默认30, 0=后台, 上限600) |
agent_send | 向已存在子 agent 发消息 | agent_id/agent_key, message |
agent_list | 列当前子 agent | — |
// 同步调用
agent_spawn(agent_id="research-analyst", task="调研主题 X")
// 后台异步(timeout_seconds=0)
agent_spawn(agent_id="auditor", task="全库安全审计", timeout_seconds=0)
// 后续轮询
task_output(task_id=..., block=false)
// 直接发送给已有子 agent
agent_send(agent_key=..., message="再查一下数据")
后台任务工具
| 工具 | 功能 | 参数 |
|---|---|---|
task_output | 拿后台任务结果 | task_id, block(默认true), timeout(默认30000ms,上限600000ms) |
task_cancel | 取消任务(终态不生效) | task_id |
task_list | 列任务 | status_filter: running/completed/failed/cancelled/all |
Shell 工具(条件性注册)
仅在 backend 是 AbstractSandboxFilesystem(含沙箱和本机+shell)时才注册。纯 LocalFilesystem 或 RemoteFilesystem 不会出现。
| 工具 | 功能 | 参数 |
|---|---|---|
execute | 执行 shell 命令,返 stdout + exit code | command, working_directory(可选), timeout(秒,默认30) |
execute("find . -name '*.java' | wc -l")
execute("mvn test", null, 300)
execute("git status", "app") // 拼为 cd app && git status
十一、子 Agent(Subagent)
子 Agent 让主 agent 把"可独立处理、上下文重、可并行"的任务委派出去,避免主线程膨胀。每个子 agent 都是一个临时 HarnessAgent 实例,拥有独立子会话,结果通过工具回传。
11.1 何时启用
满足以下条件时自动装载 SubagentsHook(priority=80):
- 不是 leaf subagent
- 没有
disableSubagents() - 配了
model
在每轮 PreReasoningEvent 向 SYSTEM 注入子 agent 使用规则 + 当前可用 agent_id + 异步任务摘要。
11.2 声明来源(四种)
// 1. 编程声明
HarnessAgent.builder()
.subagent(SubagentDeclaration.builder()
.name("reviewer")
.description("代码审查专家")
.workspace(Paths.get("./defs/reviewer"))
.workspaceMode(WorkspaceMode.ISOLATED)
.modelConfig("qwen3-max")
.maxIters(8)
.allowedTools(List.of("read_file", "grep_files"))
.build())
.subagent(SubagentDeclaration.builder()
.name("remote-researcher")
.description("远端调研子 agent")
.url("http://agent-task-server:8080")
.headers(Map.of("Authorization", "Bearer xxx"))
.build())
.build();
11.3 声明文件格式
---
description: 代码评审专家
workspace:
mode: isolated # isolated | shared,默认 isolated
path: ./defs/reviewer
model: openai:gpt-4o-mini
maxIters: 8 # 默认 10
tools: [read_file, grep_files]
---
你是一个专注代码评审的子 agent。
文件名(不含 .md)就是 agent_id。扫描 subagents/ 第一层文件(非递归)。
11.4 运行时工作区模式
| WorkspaceMode | sysPrompt base 来源 | runtime workspace |
|---|---|---|
| SHARED(默认) | workspace.path/AGENTS.md(无则空) | mainWorkspace(固定共享) |
| ISOLATED | workspace.path/AGENTS.md(无则空) | mainWorkspace/agents/<name>/workspace(自动创建) |
| INLINE | inlineAgentsBody(...) / markdown body | 同 SHARED |
11.5 Leaf Subagent(叶子节点)
一个 subagent 不能再 spawn 自己的 subagent——它是树上的叶子。代码里就是 asLeafSubagent(),不给它注册 AgentSpawnTool,手里没有 spawn 工具,自然生不出子节点。
同时还有双重保护:
- 第一层(主动):所有声明/内置生成的子 agent 强制设为 leaf → 不注册 spawn 工具
- 第二层(被动):
MAX_SPAWN_DEPTH是兜底——如果绕过第一层(比如自定义代码),最多嵌套到这个深度,超了就拒绝。正常 API 不会触及这个上限
提问:timeout_seconds=0 为何就是后台异步?
agent_spawn 的语义:
timeout_seconds=30→ 同步:父 agent 等 30 秒,超时返回。其间子 agent 事件实时转发到 Fluxtimeout_seconds=0→ 异步:立即返回task_id,不等待结果。父 agent 继续干自己的事,之后通过task_output(task_id, block=false)轮询
同步:父卡在那里等子出结果。异步:任务丢给 executor 去跑,主 agent 继续。
11.6 异步任务与存储分层
内存层:localTasks(ConcurrentHashMap)
→ 存 Future 句柄,快速查状态
→ 重启就丢
持久层:agents/<parentAgentId>/tasks/<sessionId>.json
→ 存任务状态真源
→ 重启还在
持久化存储了每个任务的 taskId、status、agentId、sessionId、result、时间戳等。跨节点部署时,Node A 发起的任务,Node B 通过读取同一个 workspace 下的 JSON 也能查到状态(但执行粘在创建节点),这就是"分布式语义"。
11.7 Remote Subagent
声明配 url(...) 时,工厂返回 RemoteSubagentStub(占位,不做本地推理),实际通过 HTTP 委派到远端服务。
十二、子 Agent 流式输出(Subagent Streaming)
12.1 要解决什么问题
call() 模式是"全部跑完给你一个回复";stream() 模式是"一边跑一边吐中间事件"。子 agent 也是 HarnessAgent,如果父用了 stream(),子 agent 产生的所有中间事件会被实时注入到父的 Flux<Event> 中,带着 EventSource 标识来源。无需任何额外配置。
12.2 工作原理
父调用方 stream()
└─ 父 HarnessAgent.stream()
├─ 创建 FluxSink<Event>
├─ 构造 SubagentEventBus (= sink::next)
└─ 通过 Reactor Context 注入 bus
└─ ReActAgent 循环中调用 agent_spawn
├─ 从 Context 读取 SubagentEventBus
├─ 调子 HarnessAgent.stream()
│ 每个子 Event → map(withSource) → 打 EventSource 标签
└─ doOnNext(bus::emit) → 实时推入父 FluxSink
12.3 什么是 Flux
Flux 是 Reactor 里的异步数据流,类比一根水管:
普通 call(): 调用方等 → 算完 → 拿到结果 [---- 阻塞等待 ----]
Flux stream():调用方 ← chunk1 ← chunk2 ← ... [--- 一边算一边吐 ---]
结合子 agent 的时间线:
父第1轮推理 → agent_spawn → 子推理 → 子结束 → 父第2轮推理
↑ ↑ ↑ ↑ ↑
REASONING TOOL_USE REASONING AGENT_RESULT REASONING
(parent) (child) (child) (parent)
←── 这些都是父等的时候实时推的 ──→
关键理解:父的 ReAct 循环是串行的——
loop:
① Reason(推理)→ 决定调用 agent_spawn
② Act(执行) → 调子 agent,同步等它返回
③ Observe(观察)→ 拿到结果拼进上下文
④ 回到 ①
父在第 ② 步是同步等待子 agent 全部跑完的。stream() 的作用不是让父不等,而是让调用方能实时看到"父等着的时候子里在干什么"。子 agent 的每个推理 chunk 都会实时从 Flux 推给调用方,不必等父整个 call() 结束。
12.4 EventSource 字段
| 字段 | 含义 | 示例 |
|---|---|---|
agentKey | 运行时实例句柄 | agent:researcher:550e8400-... |
agentId | 子 agent 类型 ID | researcher |
sessionId | 本次调用独立会话 ID | sub-a1b2c3d4-... |
parentSessionId | 父 agent 会话 ID | sess-main-001 |
depth | 嵌套深度(父直接子=1,孙=2) | 1 |
path | / 分隔的调用路径 | sess-main-001/researcher |
12.5 多级嵌套(孙 Agent)
孙 agent 的事件自动按深度叠加 path:
parent.stream()
└─ child.stream() depth=1, path="sess/planner"
└─ grandchild.stream() depth=2, path="sess/planner/executor"
12.6 常见消费模式
// 按事件类型 + 来源分流渲染
events.groupBy(e -> e.getSource() == null ? "parent" : e.getSource().getAgentId())
.flatMap(group -> group.doOnNext(event -> renderToPanel(group.key(), event)))
// 只等某子 agent 最终结果
String reply = events
.filter(e -> "researcher".equals(e.getSource().getAgentId()))
.filter(e -> e.getType() == EventType.AGENT_RESULT && e.isLast())
.map(e -> e.getMessage().getTextContent())
.blockFirst();
// SSE 转发给前端
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<?>> chat(@RequestParam String message) {
return agent.stream(msgs, ctx).map(event -> {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("type", event.getType().name());
payload.put("text", event.getMessage().getTextContent());
if (event.getSource() != null) {
payload.put("agentId", event.getSource().getAgentId());
payload.put("depth", event.getSource().getDepth());
}
return ServerSentEvent.builder(objectMapper.writeValueAsString(payload)).build();
});
}
12.7 行为边界
| 场景 | 子事件转发 |
|---|---|
| 同步本地子 agent(timeout>0) | ✅ 实时转发,带 EventSource |
call() 模式(非 stream) | ❌ 无事件转发,结果以字符串返回 |
timeout_seconds=0 后台异步 | ❌ 暂不支持流式转发 |
| 远程 subagent(Agent Protocol) | ❌ 同上 |
| 多级嵌套(孙 agent) | ✅ 自动按 depth/path 叠加 |
12.8 错误处理
子 agent 内部异常 → 框架捕获后写入 TOOL_RESULT 错误文本,不向父 onError,父流不受影响。父流自身致命错误(如模型调用失败)遵循标准 Reactor 语义。
十三、生产配置示例
将以上知识整合为一个生产级配置:
HarnessAgent agent = HarnessAgent.builder()
.name("production-agent")
.sysPrompt("你是专业助手。")
.model(model)
.workspace(Paths.get(".agentscope/workspace"))
.additionalContextFile("KNOWLEDGE.md")
.maxContextTokens(4000)
.compaction(CompactionConfig.builder()
.triggerMessages(50)
.triggerTokens(80_000)
.keepMessages(20)
.flushBeforeCompact(true)
.offloadBeforeCompact(true)
.build())
.toolResultEviction(ToolResultEvictionConfig.defaults())
.filesystem(DockerFilesystemSpec.builder()
.image("ubuntu:24.04")
.snapshotSpec(OssSnapshotSpec.builder().bucket("my-bucket").build())
.isolationScope(IsolationScope.USER)
.sandboxExecutionGuard(new RedisSandboxExecutionGuard(...))
.build())
.sandboxDistributed(SandboxDistributedOptions.builder()
.redisSession(new RedisSession("redis-host", 6379))
.build())
.build();
它同时具备:
- 身份持续 — 工作区注入 + 双层记忆 + Skill 自动加载
- 上下文可控 — 对话压缩(控制深度)+ 工具结果卸载(控制宽度)+ 溢出恢复(兜底)
- 状态可恢复 — 会话持久化 + 沙箱快照 + 分布式 Redis 锁
十四、总结
AgentScope Java Harness 的核心就是一个ReAct 循环。整个架构可以拆成三层:
HarnessAgent
│
├── 一层薄包装 ← bindRuntimeContext + forceCompactAndRetry
│
└── ReActAgent ← 核心:一个循环
│
├── Hook 链(一圈钩子) ← 在循环的关键节点插逻辑
│ PreReasoningEvent → 压缩记忆、注入工作区
│ PostActingEvent → 卸载大结果
│ PostCallEvent → 持久化会话、提取记忆
│
├── Toolkit(一批工具) ← 循环里调用的能力
│ read_file、memory_search、agent_spawn、execute 等
│
└── 三个共享对象 ← Hook 和 Tool 的协作底座
RuntimeContext、WorkspaceManager、AbstractFilesystem
循环本身就是:
loop:
reason(想下一步干什么)
act(调工具 / spawn 子 agent,同步等待返回)
observe(把结果放回上下文)
你之前问的所有功能——工作区注入、双层记忆、会话持久化、对话压缩、沙箱隔离——全部是在这个循环的某个事件节点上插进去的 hook。没有这个循环,这些功能就没有挂载点。
Harness 的设计哲学可以总结为三个"不":
- 不替换推理循环 — HarnessAgent 是 ReActAgent 的薄包装,通过 Hook 和 Toolkit 扩展点叠加能力
- 不耦合 Hook — 每个 Hook 只做一件事,通过 priority 排序,通过三个共享对象通信
- 不绑定文件系统 — AbstractFilesystem 抽象让存储后端可插拔,从本机到沙箱到远端 KV 一键切换
这种设计使得 Harness 能够将独立的能力(工作区、记忆、工具、沙箱、会话)合成为一个"持续稳定的 Agent",为生产环境的多智能体应用提供了扎实的基础设施。