HarnessAgent 总览 详解
一句话概括
HarnessAgent 就是给"只有金鱼记忆的 AI 助手"装上一整套工程化装备------记忆系统、会话管理、上下文控制------让它能像一个靠谱的员工一样长期稳定工作。
你能学到什么
- 理解"裸 Agent"和"工程化 Agent"的本质区别
- 掌握 Hook 机制与工作区、双层记忆等核心概念
- 能跑通 Quickstart 示例并理解每一行代码
- 理解"三根支柱"设计思想:身份持续、上下文可控、状态可恢复
- 建立全局认知,知道后续每个文档对应哪个能力模块
前置知识
详见 README.md 前置知识部分。本篇额外需要:
- ReAct 模式(可选):了解 Agent 的"推理(Reason) -> 行动(Act) -> 观察(Observe)"循环即可
核心概念
裸 Agent vs 工程 Agent ------ 就像实习生 vs 正式员工
想象你招了两个员工:
- 实习生(裸 Agent / ReActAgent):你问他一个问题,他思考一下、查个资料、给你回答。但下班(对话结束)他就全忘了。第二天来上班,完全不知道昨天干了什么。
- 正式员工(HarnessAgent):有笔记本记东西(记忆系统),有工牌知道自己的身份(工作区),有档案柜存历史记录(会话持久化),遇到大任务会拆分给同事(子 Agent),工作量太大知道怎么精简(对话压缩)。
裸的 ReActAgent 只有"请求 -> 推理 -> 工具 -> 回复"这一轮循环。它回答不了这些问题:
| 问题 | 类比 |
|---|---|
| 下一轮怎么办? | 明天上班还记得昨天的事吗? |
| 下一天怎么办? | 一个月后还记得客户名字吗? |
| 上下文爆了怎么办? | 笔记本写满了怎么办? |
| 状态丢了怎么办? | 电脑重启后工作还在吗? |
| 任务太重怎么办? | 一个人干不完怎么办? |
HarnessAgent 不替换推理循环本身,而是在循环的关键时机"插入"各种能力(通过 Hook 机制),把这些问题的默认工程答案打包好。
===
裸 Agent(ReActAgent)的工作流:
用户提问 → Agent 思考 → 调用工具 → 回复用户
↑ ↓
└─── 一次结束,什么都不记得 ────────┘
HarnessAgent 的工作流:
用户提问 → [记忆注入] → Agent 思考 → 调用工具 → [记忆保存] → 回复用户
↑ ↓
└── 自动恢复上次状态 ←── 会话持久化 ──────────┘
Hook 机制 ------ 就像流水线上的检查站
生活类比:想象一条汽车生产流水线。汽车(Agent 的推理过程)经过不同的工位(Hook),每个工位负责一件事------有人装方向盘,有人喷漆,有人质检。每个工位有自己的优先级顺序,互不干扰,但都在同一条流水线上工作。
Hook 是在推理循环关键时机自动执行的模块。 每个 Hook 只做一件事,通过 priority(优先级数字)排好执行顺序,可以独立开关,互不影响。完整 Hook 列表和优先级见 02-architecture.md。
工作区(Workspace) ------ 就像员工的办公桌
生活类比:想象一个正式员工的办公桌。桌上有工牌(AGENTS.md,定义身份)、笔记本(MEMORY.md,记录长期记忆)、参考书(KNOWLEDGE.md,领域知识)、技能手册(skills/,可复用能力)。每天上班时,员工会把桌上重要的东西看一遍,这就是"工作区上下文注入"。
工作区就是一个目录结构,所有关于 Agent 的信息都以 Markdown 文件的形式存放在这里:
workspace/ ← 默认路径: .agentscope/workspace
├── AGENTS.md ← 工牌:人格 / 行为约定
├── MEMORY.md ← 笔记本:整理过的长期记忆
├── knowledge/
│ └── KNOWLEDGE.md ← 参考书:领域知识
├── memory/
│ └── YYYY-MM-DD.md ← 日记:每日记忆流水账
├── skills/<skill-name>/SKILL.md ← 技能手册:自定义技能
└── subagents/<id>.md ← 下属名单:子 Agent 声明
每次 Agent 开始推理前,WorkspaceContextHook 会自动读取这些文件,把它们"喂"给模型。这样 Agent 就知道自己是谁、知道什么、能做什么。
双层记忆 ------ 就像"日记本 + 整理后的笔记"
生活类比:你每天写日记(第一层),把当天发生的事流水账式地记下来。周末你花时间把日记整理成要点笔记(第二层),扔掉重复的、合并相似的。日常工作用日记查细节,用要点笔记快速回忆大局。
HarnessAgent 的记忆也分两层:
-
第一层:日流水账(
memory/YYYY-MM-DD.md)------每次对话结束后,LLM 自动从对话中提炼关键事实,追加到当天的流水账文件里。高频写入,质量一般。 -
第二层:长期记忆(
MEMORY.md) ------后台有个"整理员"(MemoryConsolidator),定期把流水账合并、去重、重写成高质量的长期记忆。低频更新,质量高。对话内容 → LLM 提炼事实 → 追加到当日流水账 → 后台整理 → 更新 MEMORY.md
(6小时一轮)
对话压缩 ------ 就像"给塞满的抽屉瘦身"
生活类比:你的抽屉里塞满了收据(对话历史)。有一天抽屉关不上了(上下文溢出)。你需要把旧的收据整理成一张摘要表,只保留最近几张重要的,这样抽屉就又有空间了。
当对话消息积累到一定数量(默认 30 条),CompactionHook 会自动触发压缩:
- 先把对话中的关键事实提取到日流水账(不丢失信息)
- 用 LLM 把旧对话生成一份摘要
- 用摘要替换掉旧消息,只保留最近几条
极端情况下,如果模型真的报了"context overflow"错误,HarnessAgent 会捕获这个错误,强制压缩一次,然后自动重试------这就是"溢出恢复"。
会话持久化 ------ 就像"员工下班打卡,明天打卡上班接着干"
生活类比:你每天下班时,把没做完的工作整理成一份交接单(状态快照)。第二天上班时,读一下交接单,就能接着昨天的进度继续。如果公司搬家了(进程重启),只要交接单还在,工作就不会断。
在 HarnessAgent 里,每次 call() 结束时:
SessionPersistenceHook把 Agent 的当前状态(Memory、上下文等)保存到文件- 下次用同一个
sessionId调用时,bindRuntimeContext自动从文件恢复状态
关键是:只要 sessionId 不变,即使你重启了 Java 进程,Agent 也能记得之前聊过什么。
大工具结果卸载 ------ 就像"快递太大放不进信箱,先放驿站"
生活类比:你收到一个巨大的快递包裹(工具返回的超大结果),但你的信箱(上下文窗口)装不下。快递员把包裹放在驿站(文件系统),在你信箱里留一张取件通知(占位符 + 预览)。你需要的时候再去驿站取。
ToolResultEvictionHook 在工具返回结果超过 80K 字符时自动触发:把完整结果保存到文件,上下文里只留一个简短的占位符。Agent 需要详细信息时,可以用 read_file 工具按需读取。
子 Agent 编排 ------ 就像"经理分配任务给下属"
生活类比:你是部门经理(主 Agent),手下有几个专员(子 Agent),各有所长。遇到复杂任务时,你把子任务分配给合适的专员去做。你可以等专员做完汇报(同步),也可以说"你先做着,我稍后来问"(后台异步)。
SubagentsHook 为主 Agent 注册 task 和 task_output 两个工具。主 Agent 可以:
- 同步委派:等子 Agent 做完,直接拿到结果
- 后台委派 :让子 Agent 在后台执行,后续通过
task_output查询进度和结果
RuntimeContext ------ 就像"当次通话的来电显示"
生活类比:每次客户打电话来,客服系统会显示来电号码(sessionId)和客户姓名(userId)。客服看到这些信息就知道该恢复哪份档案、以什么身份和客户沟通。电话挂了,这个"来电显示"信息就消失了------它不会被永久记录。
RuntimeContext 就是每次 call() 的身份载体:
sessionId:决定状态存放在哪里,日志归档到哪里userId:决定文件系统的命名空间(天然的多租户隔离)extra:可以携带额外的自定义数据
它不会被持久化,只在当次调用的 Hook 和工具之间共享。
关键代码解读
下面逐行解读 Quickstart 示例代码。这是你接触 HarnessAgent 的第一个程序,每个部分都很重要。
1. 引入依赖
xml
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-harness</artifactId>
<!-- 使用项目定义的版本变量 -->
<version>${agentscope.version}</version>
</dependency>
这一步只是告诉 Maven:"我要用 agentscope-harness 这个库"。类似于你在手机上安装一个 App。
2. 准备工作区
java
// 1. 准备工作区:第一次运行生成 AGENTS.md,后续运行复用
Path workspace = Paths.get(".agentscope/workspace");
initWorkspaceIfAbsent(workspace);
Paths.get(".agentscope/workspace"):指定工作区的路径。相对于当前运行目录。initWorkspaceIfAbsent(workspace):如果工作区目录不存在就创建,如果AGENTS.md不存在就写入默认内容。
java
private static void initWorkspaceIfAbsent(Path workspace) throws Exception {
// 创建工作区目录(如果已存在则不报错)
Files.createDirectories(workspace);
// 定位 AGENTS.md 文件
Path agentsMd = workspace.resolve("AGENTS.md");
// 如果文件已存在,直接返回------不覆盖用户的自定义内容
if (Files.exists(agentsMd)) return;
// 首次运行:写入默认的人格定义
Files.writeString(agentsMd, """
# 笔记助手
你是一个帮助用户整理笔记和知识的助手。
## 行为约定
- 主动记录用户提到的关键事实(姓名、计划、偏好等)
- 回答用简洁中文,必要时给出要点列表
- 对不确定的内容要主动说明,不要臆造
""");
}
这段代码的要点:只在首次运行时创建 。之后你再运行,它会检测到 AGENTS.md 已经存在,直接跳过。这意味着你可以手动修改 AGENTS.md 来定制 Agent 的人格,不用担心被覆盖。
3. 构建模型
java
// 2. 构建模型
Model model = DashScopeChatModel.builder()
// 从环境变量读取 API Key------永远不要把密钥硬编码在代码里
.apiKey(System.getenv("DASHSCOPE_API_KEY"))
// 指定使用哪个模型
.modelName("qwen-max")
// 启用流式输出(边生成边返回,不用等全部生成完)
.stream(true)
.build();
DashScopeChatModel:阿里云的通义千问模型客户端。你也可以换成其他模型。System.getenv("DASHSCOPE_API_KEY"):从环境变量读取密钥,安全做法。stream(true):流式模式,模型一边思考一边返回结果,用户体验更好。
4. 构建 HarnessAgent(核心!)
java
// 3. 构建 HarnessAgent
HarnessAgent agent = HarnessAgent.builder()
// 给 Agent 起个名字,方便在日志里识别
.name("quickstart-agent")
// 系统提示词:告诉 Agent 它的基本职责
.sysPrompt("你是一个帮助用户做笔记的助手。")
// 绑定上面创建的模型
.model(model)
// 指定工作区路径------Agent 的"办公桌"在哪里
.workspace(workspace)
// 配置对话压缩:何时压缩、保留多少、压缩前先提取事实
.compaction(CompactionConfig.builder()
// 消息达到 30 条时触发压缩
.triggerMessages(30)
// 压缩后保留最近 10 条消息
.keepMessages(10)
// 压缩前先把事实提取到日流水账,避免信息丢失
.flushBeforeCompact(true)
.build())
.build();
这段代码体现了 HarnessAgent 的"组装"思想:你通过 Builder 模式,把各个能力"装配"到 Agent 上。不配置 compaction 就没有压缩能力;配置了就自动拥有。
5. 创建 RuntimeContext
java
// 4. 同一个 RuntimeContext 发起两轮对话
// sessionId 相同 → 第二轮自动从 Session 恢复第一轮的状态
RuntimeContext ctx = RuntimeContext.builder()
// 会话 ID:决定了状态存储位置。同一个 ID = 同一个会话
.sessionId("demo-session")
// 用户 ID:决定了文件命名空间,天然隔离不同用户的数据
.userId("alice")
.build();
RuntimeContext 是"当次调用的身份证"。sessionId 是最关键的字段------它决定了 Agent 从哪里恢复状态、把状态保存到哪里。
6. 第一轮对话
java
// 第一轮:告诉 Agent 一些信息
Msg turn1 = agent.call(
// 构建用户消息
Msg.builder().role(MsgRole.USER)
.textContent("我叫天宇,今天准备一个关于 ReAct 的技术分享。")
.build(),
// 传入 RuntimeContext
ctx).block(); // .block() 同步等待结果;流式模式见 10-streaming.md
// 打印 Agent 的回复
System.out.println("[turn1] " + turn1.getTextContent());
Msg.builder().role(MsgRole.USER).textContent(...):构建一条用户角色的消息。agent.call(msg, ctx):发起调用,返回一个响应式对象。.block():同步阻塞,等待 Agent 完成推理后返回结果。
7. 第二轮对话(验证记忆恢复)
java
// 第二轮:问 Agent 之前告诉它的信息
Msg turn2 = agent.call(
Msg.builder().role(MsgRole.USER)
.textContent("我叫什么?我今天要干什么?")
.build(),
// 注意:用的是同一个 ctx,sessionId 相同
ctx).block();
System.out.println("[turn2] " + turn2.getTextContent());
关键是:同一个 sessionId 。因为用了同一个 ctx,第二轮 call() 会在开头自动从 Session 恢复第一轮的对话状态,所以 Agent 能回答"你叫天宇,今天要准备 ReAct 的技术分享"。
整体流程图
一次 agent.call(msg, ctx) 只需理解三个阶段:
- 绑定身份 ---
bindRuntimeContext(ctx):把 sessionId、userId 分发给所有 Hook,如果是已有会话则从文件恢复状态 - ReAct 循环 --- 委托给内部 ReActAgent 的推理循环,各 Hook 在推理前/后/工具执行时自动介入
- 收尾持久化 --- 提取对话事实到记忆、保存会话状态到文件,返回最终回复
如果 LLM 报 ContextOverflow 错误,HarnessAgent 会强制压缩一次并自动重试。
完整时序(含每个 Hook 的触发时机和详细步骤)见 02-architecture.md。
三根支柱:能力如何协同工作
HarnessAgent 的七大核心能力不是孤立的,它们分别支撑着"持续稳定运行"的三根支柱:
| 支柱 | 组成 | 效果 |
|---|---|---|
| 身份持续 | 工作区上下文注入 + 双层持久记忆 + 技能自动加载 | 每轮推理前,Agent 的人格和知识被重新"喂"给模型;对话事实沉淀回工作区。人格和知识不断累积,不随单次调用消失 |
| 上下文可控 | 对话压缩 + 工具结果卸载 + 溢出恢复 | 对话太长自动摘要,工具结果太大卸载到文件,溢出时强制压缩并重试。任意长度会话都不会压垮模型 |
| 状态可恢复 | 会话持久化 + RuntimeContext + 可插拔文件系统 | 每次调用结束保存状态到文件,下次调用自动恢复。进程重启、机器宕机,Agent 都能从断点继续 |
这三根支柱靠三个共享对象串起来:
| 共享对象 | 类比 | 职责 |
|---|---|---|
WorkspaceManager |
办公桌管理员 | 谁来读写工作区的文件 |
AbstractFilesystem |
文件柜/网盘 | 工作区的文件实际存在哪里 |
RuntimeContext |
来电显示 | 当次调用是谁在说话 |
每个 Hook 只做自己的事,通过这三个对象和其他 Hook 协作------这就是 HarnessAgent 把一组独立能力合成一个"持续稳定 Agent"的方式。
与其他模块的关系
本文是总览,建立了全局认知。以下是后续各文档与本文概念的对应关系:
| 下一篇文档 | 对应的核心能力 | 关键问题 |
|---|---|---|
| 02-architecture.md | 全部 | Hook 怎么驱动一切?一次 call() 经历了什么? |
| 03-workspace.md | 工作区上下文注入 | AGENTS.md / MEMORY.md 怎么注入到 prompt? |
| 04-session.md | 会话持久化 | 状态怎么跨进程保留? |
| 05-memory.md | 双层持久记忆 + 对话压缩 | 日流水账和 MEMORY.md 怎么协作? |
| 06-filesystem.md | 可插拔文件系统 | 文件存在本地还是远端还是沙箱? |
| 07-tool.md | 内置工具 | Agent 自带哪些工具? |
| 08-skill.md | 技能加载 | 怎么给 Agent 装"插件"? |
| 09-subagent.md | 子 Agent 编排 | 主 Agent 怎么分配任务给子 Agent? |
| 10-streaming.md | 流式输出 | 怎么实时看到 Agent 的"思考过程"? |
| 11-sandbox.md | 沙箱 | 怎么隔离执行环境? |
建议阅读顺序:按编号顺序读,每篇约 15-25 分钟,总计约 3.5 小时。
运行验证
如果你想亲手跑一下 Quickstart 示例,可以按以下步骤操作:
bash
# 1. 设置 API Key(替换成你自己的)
export DASHSCOPE_API_KEY=your_key_here
# 2. 首次运行需要安装依赖到本地 Maven 仓库
mvn -pl agentscope-examples/agents/harness-examples/harness-quickstart -am install \
-DskipTests -Dspotless.check.skip=true -Dmaven.javadoc.skip=true -q
# 3. 执行示例程序
mvn -pl agentscope-examples/agents/harness-examples/harness-quickstart exec:java \
-Dexec.mainClass=io.agentscope.harness.example.QuickstartExample \
-Dspotless.check.skip=true -q
运行后你应该观察到:
.agentscope/workspace/AGENTS.md被自动创建- 第二轮提问"我叫什么"能正确回答"天宇"
- 多聊几轮后(消息数 >= 30),
workspace/memory/目录下会出现日流水账文件 - 重启进程、用同一个
sessionId再调用,Agent 依然记得之前的内容
学习要点
- 能说清楚"裸 Agent"和"HarnessAgent"的区别------前者只有一轮循环,后者有七大工程化能力
- 理解 Hook 机制:每个 Hook 只做一件事,通过 priority 排序,可以独立开关
- 理解工作区 + 双层记忆:Agent 的"办公桌"与"日记本 + 笔记"的记忆模式
- 能跑通 Quickstart 示例,理解每一行代码的作用
- 理解三根支柱设计思想:身份持续、上下文可控、状态可恢复