本篇是系列 上篇 :为什么用官方 Harness、六层架构、装配层、Redis、记忆与多用户隔离。
下篇:小深-AgentScope-Java-2.0-Harness架构博客-02.md(工具、MCP/REST、SSE、前端、踩坑笔记)
项目:
agentscope-assistant-java· 技术栈:Spring Boot 3.2.5 + AgentScope Java 2.0.1HarnessAgent+ MCP SDK 0.17.0
0. 写在前面
如果你做过 LLM Agent,大概经历过这些坑:
- 长任务中途「忘了自己在干什么」
- 调研过程把统筹上下文撑爆
- 自己写一套记忆抽取,和框架后台 Consolidation 抢
MEMORY.md - 多副本部署后,工作区文件和对话状态各活各的
- MCP SDK 和 json-schema-validator 版本一拧,启动直接
NoClassDefFoundError
AgentScope Java 2.0 的答案同样不是「再换一个更强的模型」,而是用官方 HarnessAgent :规划、文件系统、子 Agent、HITL、分层记忆、压缩、技能,都已经在框架里。应用层要做的是 接线,而不是再造一套中间件。
| 能力 | 作用 | 本项目怎么打开 |
|---|---|---|
| 规划(todos / Plan Mode) | 多步任务显式化;复杂事先写方案 | TodoTools + enablePlanMode() |
| 文件系统(带隔离) | 中间产物落盘;按用户分命名空间 | LocalFilesystemSpec.isolationScope(USER) |
| 子 Agent | 专科活隔离上下文 | 不 disable;workspace/agents/ + agent_spawn |
| 人工审批(HITL) | 写文件中断,等人批准后再续跑 | PermissionBehavior.ASK + /api/assistant/resume |
| 分层记忆 | 日流水 Flush + 长期 Consolidation | .memory(MemoryConfig),不要自己抽 JSON |
| 上下文压缩 | 超长摘要前缀,原文卸到 jsonl | .compaction(...) + session_search |
| 技能 | 目录渐进加载;可自进化 | enableSkillManageTool + enableSkillCurator |
| 会话恢复 | 进程重启可续跑 | JsonFileAgentStateStore 或 Redis |
| 多用户 | 记忆 / 工作区互不可见 | RuntimeContext.userId + IsolationScope.USER |
| 多副本 | 状态和工作区共享 | profile=distributed + Redis |
本项目是这套思想的 Spring 接线示例:统筹助手「小深」+ 联网调研工具 + 官方 research/general-purpose 子 Agent,前端通过 SSE 看工具轨迹并处理审批卡片。
和 deepagents-assistant-java 的差别一句话:那边用 LangGraph4j 自己实现 Harness;这边把同类产品能力接到 AgentScope 官方 API 上。
1. 系统架构
1.1 六层结构
┌─────────────────────────────────────────────────────────────────┐
│ L6 产品层 │
│ AssistantController(SSE /chat + /resume,全部带 userId) │
│ AssistantChatService(RuntimeContext + HITL pending map) │
│ AgentEventMapper(官方 AgentEvent → token/tool/interrupt) │
│ SessionStore(网页聊天 JSON,data/sessions/{userId}/) │
├─────────────────────────────────────────────────────────────────┤
│ L5 状态可插拔层 │
│ 默认:JsonFileAgentStateStore + LocalFilesystemSpec │
│ distributed:RedisAgentStateStore + RedisBaseStore │
│ + RemoteFilesystemSpec + DistributedStore │
├─────────────────────────────────────────────────────────────────┤
│ L4 统筹 HarnessAgent │
│ ModelRegistry.resolve("openai:"+model) 流式 │
│ maxIters=80 · PermissionMode.BYPASS + write_file ASK │
│ MemoryConfig(Flush / Consolidation) │
│ CompactionConfig + ToolResultEviction + Plan Mode + Skills │
├─────────────────────────────────────────────────────────────────┤
│ L3 工具 │
│ 官方:todo_write、filesystem、agent_spawn、memory_*、 │
│ session_search、skill_manage │
│ 本项目:webSearch/webRead、calculate、getCurrentDateTime、 │
│ search_conversation_history │
├─────────────────────────────────────────────────────────────────┤
│ L2 子 Agent(框架内置 + 工作区 agents/) │
│ research-agent / general-purpose · 提示词在 workspace/ │
├─────────────────────────────────────────────────────────────────┤
│ L1 外部世界 │
│ OpenAI 兼容大模型 · 智谱 MCP 0.17 · 可选本机 Redis │
└─────────────────────────────────────────────────────────────────┘
1.2 一次用户消息怎么走
智谱 MCP / REST MemoryFlushMiddleware HarnessAgent AssistantChatService AssistantController 浏览器 智谱 MCP / REST MemoryFlushMiddleware HarnessAgent AssistantChatService AssistantController 浏览器 #mermaid-svg-Cd5uIUxngYh9Yl0N{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Cd5uIUxngYh9Yl0N .error-icon{fill:#552222;}#mermaid-svg-Cd5uIUxngYh9Yl0N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Cd5uIUxngYh9Yl0N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Cd5uIUxngYh9Yl0N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .marker.cross{stroke:#333333;}#mermaid-svg-Cd5uIUxngYh9Yl0N svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Cd5uIUxngYh9Yl0N p{margin:0;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Cd5uIUxngYh9Yl0N .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .sequenceNumber{fill:white;}#mermaid-svg-Cd5uIUxngYh9Yl0N #sequencenumber{fill:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Cd5uIUxngYh9Yl0N .messageText{fill:#333;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N .labelText,#mermaid-svg-Cd5uIUxngYh9Yl0N .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .loopText,#mermaid-svg-Cd5uIUxngYh9Yl0N .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Cd5uIUxngYh9Yl0N .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Cd5uIUxngYh9Yl0N .noteText,#mermaid-svg-Cd5uIUxngYh9Yl0N .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actorPopupMenu{position:absolute;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Cd5uIUxngYh9Yl0N .actor-man circle,#mermaid-svg-Cd5uIUxngYh9Yl0N line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Cd5uIUxngYh9Yl0N :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 模型调 webSearch alt write_file 命中 ASK 流结束后后台 Flush → memory/YYYY-MM-DD.md POST /api/assistant/chat (SSE, userId) chat(userId, sessionId, message) SessionStore 落 user 消息 streamEvents(UserMessage, RuntimeContext) 注入 MEMORY.md / AGENTS.md / 技能 MCP tools/call 或 REST web_search 检索结果 RequireUserConfirmEvent SSE interrupt POST /api/assistant/resume resume(approved) ConfirmResult 续跑 TextBlockDelta / ToolCallStart ... SSE token / tool / plan / agent / done SessionStore 落 assistant
1.3 与官方 Harness / 原 Java 项目的对应
| 概念 | AgentScope 官方 | 本项目 | 原 deepagents-assistant-java |
|---|---|---|---|
| 工厂 | HarnessAgent.builder() |
AgentScopeConfig |
CreateDeepAgent.create |
| 运行时 | Harness ReAct 循环 | 同一个 HarnessAgent |
LangGraph4j AgentExecutorEx |
| 记忆 | Flush + Consolidation | .memory(MemoryConfig) |
自研 MemoryStore + update_memory |
| 文件 | FilesystemSpec |
Local 或 Remote + 隔离 USER | WorkspaceFileOperations |
| 子 Agent | agent_spawn |
不 disable | task / task_batch |
| HITL | Permission ASK | approval-tools: write_file,edit_file |
approvalOn(...) |
| 压缩 | CompactionConfig |
显式打开 | SummarizingConversationContextPolicy |
| 状态 | AgentStateStore |
JSON 文件或 Redis | FileSystemSaver checkpoint |
| 联网 | MCP Client | SDK 0.17.0 + REST 回退 | MCP 0.14.1 路径 |
1.4 角色分工
| 角色 | 职责 | 典型工具 |
|---|---|---|
| 统筹(小深) | 理解目标、规划、委派、对用户答复 | todos、files、agent_spawn、memory_*、时间/计算、网页检索 |
| research-agent | 多角度联网调研 | 工作区 subagents/research-agent.md;主工具仍是 webSearch/webRead |
| 网页用户 | 看自己的会话和档案 | /api/history/search、/api/memory,与官方 jsonl 不是同一份 |
两份「历史」不要混:
| 数据 | 路径 | 谁写 | 谁读 |
|---|---|---|---|
| 网页聊天 JSON | data/sessions/{userId}/*.json |
SessionStore |
侧栏、search_conversation_history |
| 压缩卸载日志 | workspace/.../sessions/*.log.jsonl |
官方 Compaction | 官方 session_search |
| 日流水 | memory/YYYY-MM-DD.md |
官方 Flush | 官方 memory_search、档案页 |
| 长期记忆 | MEMORY.md |
官方 Consolidator | 每轮注入 prompt、档案页 |
1.5 关键运行配置
- 端口:
8089 - 模型:OpenAI 兼容(
llm.*,示例qwen3.8-27b/ 百炼 MaaS) - 思考模式 :
llm.enable-thinking: false(Qwen 思考 + 流式时 tool_calls 容易不稳) - 联网:默认 MCP(
mcp.zhipu.enabled=true),SDK 必须 0.17.0+ 才能和 agentscope 的json-schema-validator:2.0.0共存 - 迭代:
agent.max-iters=80 - 审批:
write_file/edit_file - 单机状态:
data/agent-state/;工作区:workspace/(IsolationScope.USER后按用户分子目录) - 多副本:
--spring.profiles.active=distributed,Redis127.0.0.1:6379
${user.dir} 是 JVM 工作目录 (IDEA 跑该模块时一般是 agentscope-assistant-java/):
| 配置 | 目录 | 用途 |
|---|---|---|
agent.workspace |
workspace/ |
AGENTS.md、MEMORY.md、skills、agents、plans |
agent.sessions.dir |
data/sessions/ |
网页会话 JSON(按 userId 分子目录) |
agent.state-dir |
data/agent-state/ |
单机 AgentState |
| Redis(distributed) | as:state: / as:base: |
跨副本状态与工作区文件 |
1.6 多用户与 HITL(必读)
(1)隔离靠二元组,不是靠「别传错 sessionId」
所有寻址都是 (userId, sessionId):
- HTTP:POST body.
userId;GET/DELETE 用X-User-Id或?userId= - 空白 userId →
"local"(兼容原来的单用户) UserIds/SessionIds挡住../、/、空字节,因为它们会变成目录名- Harness:
RuntimeContext.userId+IsolationScope.USER,MEMORY.md 按用户分命名空间 - HITL:
pendingApprovals的 key 是userId/sessionId,避免两人审批单串单
(2)写文件会停,必须 resume
权限默认 BYPASS,只给 write_file / edit_file 加 ASK。框架抛 RequireUserConfirmEvent → SSE interrupt → 前端点批准/拒绝 → ConfirmResult 续跑同一条统筹图。
(3)不要自己再抽一轮记忆
流结束后 MemoryFlushMiddleware 已经在后台写日流水。本项目的 HarnessMemoryCatalog 只读 。若再调模型覆盖 MEMORY.md,会和 MemoryConsolidator 打架。
1.7 用本文源码复现项目
仓库里的 Java 源文件 + pom.xml + application.yml + index.html 下文均有对应内容;Java 已省略 import。
- 准备 JDK 17 与 Maven 3.8+。
- 建目录
agentscope-assistant-java/,把每个### \path`` 下的代码块存成该相对路径。 - 只改配置里的密钥与模型接入点(本文已脱敏,不能直接拿占位符去调模型 ):
llm.base-url/llm.modelexport LLM_API_KEY=...(或写在 yml)- 智谱搜索:
export MCP_API_KEY=...,保持 MCP 0.17.0 - 多副本:先
redis-server,再--spring.profiles.active=distributed
mvn spring-boot:run,打开 http://localhost:8089 ,侧栏填「用户」。
workspace/ 与 data/ 启动时会自动建目录。免费额度耗尽时百炼会 403 insufficient_quota,与框架无关。
2. 模块详解与源码
下面按「阅读顺序」展开每个模块:先说明职责,再贴核心源码 (Java 已省略 import,与仓库逻辑一致;配置中的密钥已脱敏)。
一、项目入口与总览
src/main/java/cn/deepassistant/DeepAssistantApplication.java
作用: Spring Boot 启动入口。官方 Harness 能力都在 AgentScopeConfig 打开
java
package cn.deepassistant;
/**
* Spring Boot 入口。所有 AgentScope 官方能力都在 {@link cn.deepassistant.config.AgentScopeConfig} 里打开。
*/
@SpringBootApplication
public class DeepAssistantApplication {
public static void main(String[] args) {
SpringApplication.run(DeepAssistantApplication.class, args);
}
}
二、Harness 装配层
src/main/java/cn/deepassistant/config/AgentScopeConfig.java
作用: 单机 HarnessAgent:模型、工作区、分层记忆、压缩、Plan Mode、技能、写文件 ASK
java
package cn.deepassistant.config;
/**
* 把 AgentScope Java 2.0 Harness 的官方能力接线到 Spring。
*
* <p>对照官方文档(<a href="https://java.agentscope.io/v2/zh/docs/harness/memory.html">记忆</a>),
* 不要再自己写一套「对话结束调模型抽 JSON」------框架已经有完整管线。
*
* <h2>两种部署形态(用 Spring profile 切换)</h2>
* <ul>
* <li><b>默认(单机)</b>:{@code JsonFileAgentStateStore} + {@code LocalFilesystemSpec}。
* AgentState 和工作区文件都落本地磁盘。见 {@link #agentStateStore()} / {@link #harnessAgent()}。</li>
* <li><b>分布式(profile=distributed)</b>:{@code RedisAgentStateStore} + {@code RedisBaseStore}
* + {@code RemoteFilesystemSpec} + {@code DistributedStore}。AgentState 和工作区文件都进 Redis,
* 多副本共享。见 {@link DistributedAgentScopeConfig}。</li>
* </ul>
*
* <h2>官方核心能力在本 Bean 里怎么打开</h2>
* <ol>
* <li><b>分层记忆</b>:{@link #memoryConfig()}。每次对话结束后后台 Flush 到
* {@code memory/YYYY-MM-DD.md},再周期性 Consolidation 进 {@code MEMORY.md}。</li>
* <li><b>上下文压缩</b>:{@code compaction(...)}。消息太多时摘要前缀;压缩前会再 flush 一次,
* 事实不会跟着摘要一起丢。</li>
* <li><b>大工具结果卸载</b>:{@code toolResultEviction}。单次工具输出太长时落盘,上下文只留预览。</li>
* <li><b>Plan Mode</b>:{@code enablePlanMode()}。只读规划,方案写到 {@code workspace/plans/}。</li>
* <li><b>技能仓库 / 自进化</b>:工作区 {@code skills/} 每轮按需加载;
* {@code enableSkillManageTool} 让模型能起草技能,{@code enableSkillCurator} 后台整理过期技能。</li>
* <li><b>子 Agent</b>:不 disable,工作区 {@code agents/} + {@code agent_spawn}。</li>
* <li><b>会话恢复</b>:状态存储按 userId + sessionId 落盘(单机 JSON / 分布式 Redis),进程重启可续跑。</li>
* <li><b>多用户隔离</b>:{@code IsolationScope.USER} 让框架把 memory/、sessions/ 等运行时数据
* 按用户分命名空间,不同用户的 MEMORY.md 互不可见。</li>
* <li><b>权限三态</b>:写文件 ASK,其余 BYPASS。官方是 Allow / Ask / Deny。</li>
* </ol>
*
* <p>刻意<b>不要</b>调用 {@code disableMemoryHooks()} / {@code disableMemoryTools()}。
* 关掉之后就没有自动抽取,也没有 {@code memory_search} / {@code memory_get} /
* {@code memory_save} / {@code session_search}。
*/
@Slf4j
@Configuration
public class AgentScopeConfig {
@Value("${llm.api-key}")
private String apiKey;
@Value("${llm.base-url}")
private String baseUrl;
@Value("${llm.model}")
private String modelName;
@Value("${llm.temperature:0.3}")
private double temperature;
@Value("${llm.enable-thinking:false}")
private boolean enableThinking;
@Value("${agent.workspace}")
private String workspace;
@Value("${agent.state-dir}")
private String stateDir;
@Value("${agent.max-iters:80}")
private int maxIters;
@Value("#{'${agent.approval-tools:write_file,edit_file}'.split(',')}")
private List<String> approvalTools;
@Value("${agent.memory.flush-throttle-minutes:0}")
private int flushThrottleMinutes;
@Value("${agent.memory.consolidation-min-gap-minutes:30}")
private int consolidationMinGapMinutes;
@Value("${agent.memory.daily-retention-days:90}")
private int dailyRetentionDays;
@Bean
public Model chatModel() {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("未配置 llm.api-key / LLM_API_KEY");
}
GenerateOptions options = GenerateOptions.builder()
.temperature(temperature)
.parallelToolCalls(false)
.additionalBodyParam("enable_thinking", enableThinking)
.build();
ModelCreationContext context = ModelCreationContext.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.stream(true)
.enableThinking(enableThinking)
.component(GenerateOptions.class, options)
.build();
return ModelRegistry.resolve("openai:" + modelName, context);
}
/**
* 单机状态存储:JSON 文件。仅默认 profile 用;
* distributed profile 用 {@link cn.deepassistant.redis.RedisAgentStateStore}(见 {@link DistributedAgentScopeConfig})。
*/
@Bean
@Profile("!distributed")
public AgentStateStore agentStateStore() throws Exception {
Path state = Path.of(stateDir).toAbsolutePath().normalize();
Files.createDirectories(state);
return new JsonFileAgentStateStore(state);
}
/**
* 官方两层记忆的开关。不调用 {@code .memory(...)} 时框架仍有历史默认行为;
* 这里显式配一遍,是为了:中文抽取规则、可调节流、让你在配置文件里能看见这些参数。
*
* <p>自定义 consolidation prompt 必须恰好两个 {@code %d}(token 上限、字符上限),
* 所以我们在官方默认模板后面追加中文说明,不去改那两个占位符。
*/
@Bean
public MemoryConfig memoryConfig() {
MemoryConfig.FlushTrigger trigger = flushThrottleMinutes <= 0
? MemoryConfig.FlushTrigger.always()
: MemoryConfig.FlushTrigger.throttled(Duration.ofMinutes(flushThrottleMinutes));
return MemoryConfig.builder()
.flushTrigger(trigger)
.flushPrompt(MemoryFlushManager.DEFAULT_FLUSH_PROMPT + """
Additional project rules:
- Write every extracted bullet in Chinese.
- Prefer durable user preferences, project facts, and constraints.
- Never record API keys, passwords, or .env contents.
""")
.consolidationPrompt(MemoryConsolidator.DEFAULT_CONSOLIDATION_PROMPT
+ "\nWrite the complete MEMORY.md in Chinese markdown.\n")
.consolidationMinGap(Duration.ofMinutes(Math.max(1, consolidationMinGapMinutes)))
.dailyFileRetentionDays(Math.max(7, dailyRetentionDays))
.sessionRetentionDays(180)
.build();
}
/**
* 单机 HarnessAgent:LocalFilesystem + JsonFile 状态。
* distributed profile 在 {@link DistributedAgentScopeConfig#harnessAgent} 里覆盖。
*/
@Bean(destroyMethod = "close")
@Profile("!distributed")
public HarnessAgent harnessAgent(Model chatModel,
AgentStateStore agentStateStore,
MemoryConfig memoryConfig,
CommonTools commonTools,
WebResearchTools webResearchTools,
HistoryMemoryTools historyMemoryTools) throws Exception {
Path ws = resolveAndInitWorkspace();
HarnessAgent.Builder builder = HarnessAgent.builder()
.name("xiao-shen")
.sysPrompt("你是私人智能助手「小深」。人格与行为细则见工作区 AGENTS.md。"
+ "跨会话事实以 MEMORY.md 为准;需要原文时用 memory_search / session_search / search_conversation_history。")
.model(chatModel)
.workspace(ws)
// 单机:本地文件系统 + 按用户隔离
.filesystem(new LocalFilesystemSpec().isolationScope(IsolationScope.USER))
.stateStore(agentStateStore);
return applyCommonAndBuild(builder, ws, memoryConfig, commonTools, webResearchTools, historyMemoryTools);
}
// ---- 两个 profile 共享的拼装逻辑 ----
/** 解析工作区根目录,提前建好约定子目录(新手一眼能看到结构)。 */
Path resolveAndInitWorkspace() throws Exception {
Path ws = Path.of(workspace).toAbsolutePath().normalize();
Files.createDirectories(ws);
Files.createDirectories(ws.resolve("memory"));
Files.createDirectories(ws.resolve("skills"));
Files.createDirectories(ws.resolve("agents"));
Files.createDirectories(ws.resolve("plans"));
Files.createDirectories(ws.resolve("knowledge"));
Files.createDirectories(ws.resolve("sessions"));
return ws;
}
/** 注册工具 + 权限 + 记忆 + 压缩 + Plan + Skills,最后 build。两个 profile 共用。 */
HarnessAgent applyCommonAndBuild(HarnessAgent.Builder builder,
Path ws,
MemoryConfig memoryConfig,
CommonTools commonTools,
WebResearchTools webResearchTools,
HistoryMemoryTools historyMemoryTools) {
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new TodoTools());
toolkit.registerTool(commonTools);
toolkit.registerTool(webResearchTools);
// 额外工具:检索「网页侧栏那份」聊天 JSON。官方 session_search 搜的是 workspace/sessions/*.log.jsonl。
toolkit.registerTool(historyMemoryTools);
PermissionContextState.Builder perm = PermissionContextState.builder()
.mode(PermissionMode.BYPASS);
for (String toolName : approvalTools == null ? List.<String>of() : approvalTools) {
if (toolName == null || toolName.isBlank()) {
continue;
}
String name = toolName.trim();
perm.addAskRule(name, new PermissionRule(name, null, PermissionBehavior.ASK, "policy"));
}
builder.toolkit(toolkit)
.additionalContextFile("PREFERENCES.md")
.permissionContext(perm.build())
.maxIters(Math.max(25, maxIters))
// ① 分层记忆:Flush + Consolidation + 自动注册 memory_* / session_search
.memory(memoryConfig)
// ② 上下文自动管理:超长则摘要;压缩前 flush + 把原文卸到 jsonl
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.flushBeforeCompact(true)
.offloadBeforeCompact(true)
.truncateArgs(CompactionConfig.TruncateArgsConfig.builder()
.maxArgLength(2000)
.truncationText("... [truncated] ...")
.build())
.build())
// ③ 单次工具结果过大时落盘,避免撑爆上下文
.toolResultEviction(ToolResultEvictionConfig.defaults())
// ④ 复杂任务先写方案文件,再动手
.enablePlanMode()
// ⑤ 技能自进化:模型可起草技能;后台 curator 归档过期技能
.enableSkillManageTool(SkillManageConfig.defaults())
.enableSkillCurator(SkillCuratorConfig.defaults())
// 本地个人助手先关掉无约束 Shell,避免模型乱执行系统命令
.disableShellTool();
HarnessAgent agent = builder.build();
log.info("[Harness] 已构建 workspace={} flushTrigger={} approvalTools={}",
ws,
memoryConfig.flushTrigger(),
approvalTools == null ? List.of() : new ArrayList<>(approvalTools));
return agent;
}
}
src/main/java/cn/deepassistant/config/DistributedAgentScopeConfig.java
作用: distributed profile:Redis 状态 + RemoteFilesystem + DistributedStore
java
package cn.deepassistant.config;
/**
* 分布式部署配置(Spring profile = {@code distributed})。
*
* <p>启动方式:{@code mvn spring-boot:run -Dspring-boot.run.profiles=distributed}
* 或 {@code java -jar app.jar --spring.profiles.active=distributed}
*
* <h2>和单机的区别</h2>
* <pre>
* 单机(默认) 分布式(profile=distributed)
* AgentState JsonFileAgentStateStore RedisAgentStateStore ← Redis
* 工作区文件 LocalFilesystemSpec(本地磁盘) RemoteFilesystemSpec ← Redis (RedisBaseStore)
* 状态后端 无 DistributedStore DistributedStore.builder() 组合上面两个
* </pre>
*
* <p>官方文档明确:用 {@code filesystem(RemoteFilesystemSpec)} 时框架会强制要求分布式状态存储,
* 否则 {@code HarnessAgent.build()} 直接抛 IllegalStateException。这里用
* {@link DistributedStore#builder()} 把 {@link RedisAgentStateStore} 和 {@link RedisBaseStore}
* 组装成 {@link DistributedStore} 交给 {@code .distributedStore(...)},框架在 build() 时会:
* <ol>
* <li>把 {@code distributedStore.agentStateStore()} 设为 ReActAgent 的状态后端</li>
* <li>把 {@code distributedStore.baseStore()} 注入 RemoteFilesystemSpec</li>
* </ol>
*
* <h2>Redis 连接</h2>
* 用 Lettuce 单连接(同步 API)。配置见 {@code application.yml} 的 {@code redis.*}:
* <pre>
* redis:
* host: 127.0.0.1
* port: 6379
* password: # 没密码留空
* database: 0
* </pre>
*
* <p>多副本时每个副本用同一份 Redis,任意副本都能用 (userId, sessionId) 续跑同一段对话,
* 也能读到同一份 MEMORY.md。
*/
@Slf4j
@Configuration
@Profile("distributed")
public class DistributedAgentScopeConfig {
@Value("${redis.host:127.0.0.1}")
private String redisHost;
@Value("${redis.port:6379}")
private int redisPort;
@Value("${redis.password:}")
private String redisPassword;
@Value("${redis.database:0}")
private int redisDatabase;
/**
* Lettuce 连接。{@code destroyMethod="close"} 保证 Spring 关闭时释放连接。
* 连接本身是线程安全的,{@link #redisCommands()} 从中拿同步接口。
*/
@Bean(destroyMethod = "close")
public StatefulRedisConnection<String, String> redisConnection() {
RedisURI.Builder uri = RedisURI.builder()
.withHost(redisHost)
.withPort(redisPort)
.withDatabase(redisDatabase);
if (redisPassword != null && !redisPassword.isBlank()) {
uri.withPassword(redisPassword.toCharArray());
}
RedisClient client = RedisClient.create(uri.build());
log.info("[Redis] 连接 {}:{} db={}", redisHost, redisPort, redisDatabase);
return client.connect();
}
/** 同步 Redis 命令接口,两个 Redis 存储共用。 */
@Bean
public RedisCommands<String, String> redisCommands(StatefulRedisConnection<String, String> conn) {
return conn.sync();
}
/** 分布式 AgentState 存储:状态进 Redis,多副本共享。 */
@Bean
public RedisAgentStateStore redisAgentStateStore(RedisCommands<String, String> redis) {
return new RedisAgentStateStore(redis);
}
/** 分布式工作区文件存储:MEMORY.md / memory/ / sessions/ 进 Redis。 */
@Bean
public RedisBaseStore redisBaseStore(RedisCommands<String, String> redis) {
return new RedisBaseStore(redis);
}
/**
* 把 RedisAgentStateStore + RedisBaseStore 组装成官方 {@link DistributedStore}。
* 交给 {@code .distributedStore(...)} 后,框架自动用前者做状态后端、把后者注入 RemoteFilesystem。
*/
@Bean
public DistributedStore distributedStore(RedisAgentStateStore stateStore,
RedisBaseStore baseStore) {
return DistributedStore.builder()
.agentStateStore(stateStore)
.baseStore(baseStore)
.build();
}
/**
* 分布式 HarnessAgent:RemoteFilesystem + DistributedStore。
*
* <p>注意:这里也声明了 {@code AgentStateStore} bean,覆盖单机的 JsonFile 版本,
* 这样 {@link cn.deepassistant.service.AssistantChatService} 注入到的就是 Redis 版。
*/
@Bean
public AgentStateStore agentStateStore(RedisAgentStateStore redisAgentStateStore) {
return redisAgentStateStore;
}
/**
* 分布式 HarnessAgent。和单机版共用 {@link AgentScopeConfig#applyCommonAndBuild},
* 区别只在前半段:RemoteFilesystem + distributedStore。
*/
@Bean(destroyMethod = "close")
public HarnessAgent harnessAgent(Model chatModel,
DistributedStore distributedStore,
MemoryConfig memoryConfig,
CommonTools commonTools,
WebResearchTools webResearchTools,
HistoryMemoryTools historyMemoryTools,
AgentScopeConfig base) throws Exception {
Path ws = base.resolveAndInitWorkspace();
HarnessAgent.Builder builder = HarnessAgent.builder()
.name("xiao-shen")
.sysPrompt("你是私人智能助手「小深」。人格与行为细则见工作区 AGENTS.md。"
+ "跨会话事实以 MEMORY.md 为准;需要原文时用 memory_search / session_search / search_conversation_history。")
.model(chatModel)
.workspace(ws)
// 分布式:工作区文件进 Redis(RemoteFilesystem + RedisBaseStore),按用户隔离
.filesystem(new RemoteFilesystemSpec().isolationScope(IsolationScope.USER))
// 状态 + 文件后端都来自 DistributedStore;build() 会自动接线
.distributedStore(distributedStore);
log.info("[Harness] 分布式模式:RemoteFilesystem + Redis ({}:{} db={})", redisHost, redisPort, redisDatabase);
return base.applyCommonAndBuild(builder, ws, memoryConfig, commonTools, webResearchTools, historyMemoryTools);
}
}
三、分布式状态(Redis)
src/main/java/cn/deepassistant/redis/package-info.java
作用: 为什么官方 RemoteFilesystem 必须配分布式 AgentState
java
/**
* Redis 版的分布式状态 / 文件存储。
*
* <p>多副本部署时启用(Spring profile = {@code distributed}):
* <ul>
* <li>{@link RedisAgentStateStore} ------ 把 AgentState(对话上下文、权限、计划等)存进 Redis,
* 任意副本都能用 (userId, sessionId) 续跑同一段对话</li>
* <li>{@link RedisBaseStore} ------ 把工作区文件(MEMORY.md、memory/日流水、sessions/*.log.jsonl)
* 存进 Redis,配合官方 {@code RemoteFilesystemSpec} 实现跨副本共享</li>
* <li>{@link RedisDistributedStoreFactory} ------ 用 {@code DistributedStore.builder()} 把上面两个
* 组装成 {@link io.agentscope.harness.agent.DistributedStore},交给
* {@code HarnessAgent.builder().distributedStore(...)},框架在 build() 时会自动把
* baseStore 注入 RemoteFilesystemSpec、把 agentStateStore 设为 ReActAgent 的状态后端</li>
* </ul>
*
* <p>对照官方文档「Context & AgentState」:用 {@code filesystem(RemoteFilesystemSpec)} 时
* 框架会强制要求分布式状态存储,否则 {@code build()} 直接抛 IllegalStateException。
* 这里两个 Redis 实现正好满足这个要求。
*
* <p>序列化复用官方 {@link io.agentscope.core.util.JsonUtils#getJsonCodec()},
* 和 {@link io.agentscope.core.state.JsonFileAgentStateStore} 完全一致,
* 所以单机 JSON 文件和 Redis 之间迁移不需要转格式。
*/
package cn.deepassistant.redis;
src/main/java/cn/deepassistant/redis/RedisAgentStateStore.java
作用: AgentState 存 Redis,按 (userId, sessionId) 隔离
java
package cn.deepassistant.redis;
/**
* Redis 版 {@link AgentStateStore}:把 AgentState 存进 Redis,多副本共享。
*
* <h3>Key 设计</h3>
* <pre>
* 单条状态: as:state:{userId}:{sessionId}:{slotName} → JSON 字符串
* 列表状态: as:list:{userId}:{sessionId}:{slotName} → Redis LIST(每行一个 JSON)
* </pre>
* userId / sessionId 只允许 {@code [a-zA-Z0-9_-]}(见 UserIds / SessionIds),
* 所以用 {@code :} 当分隔符不会撞。
*
* <p>序列化用 {@link JsonUtils#getJsonCodec()},和 {@link io.agentscope.core.state.JsonFileAgentStateStore}
* 完全一致------单机 JSON 文件迁到 Redis 不用转格式,反之亦然。
*
* <p>列表状态用 Redis LIST(RPUSH 一行一个 JSON)。{@link #save} 列表时做全量重写
* (DEL + RPUSH),比 JsonFile 的增量追加简单,量级不大时性能足够。
*/
@Slf4j
public class RedisAgentStateStore implements AgentStateStore {
private static final String STATE_PREFIX = "as:state:";
private static final String LIST_PREFIX = "as:list:";
private final RedisCommands<String, String> redis;
public RedisAgentStateStore(RedisCommands<String, String> redis) {
this.redis = redis;
}
private static String stateKey(String userId, String sessionId, String slotName) {
return STATE_PREFIX + userId + ":" + sessionId + ":" + slotName;
}
private static String listKey(String userId, String sessionId, String slotName) {
return LIST_PREFIX + userId + ":" + sessionId + ":" + slotName;
}
/** 单条状态:序列化成 JSON 存一个 String key。 */
@Override
public void save(String userId, String sessionId, String slotName, State state) {
String json = JsonUtils.getJsonCodec().toPrettyJson(state);
redis.set(stateKey(userId, sessionId, slotName), json);
}
/** 列表状态:全量重写(DEL + RPUSH 每行一个 JSON)。 */
@Override
public void save(String userId, String sessionId, String slotName,
List<? extends State> states) {
String key = listKey(userId, sessionId, slotName);
redis.del(key);
if (states == null || states.isEmpty()) {
return;
}
String[] jsons = new String[states.size()];
for (int i = 0; i < states.size(); i++) {
jsons[i] = JsonUtils.getJsonCodec().toPrettyJson(states.get(i));
}
redis.rpush(key, jsons);
}
@Override
public <T extends State> Optional<T> get(String userId, String sessionId, String slotName, Class<T> type) {
String json = redis.get(stateKey(userId, sessionId, slotName));
if (json == null || json.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(JsonUtils.getJsonCodec().fromJson(json, type));
} catch (Exception e) {
log.warn("[RedisState] 反序列化失败 {}/{}/{}: {}", userId, sessionId, slotName, e.getMessage());
return Optional.empty();
}
}
@Override
public <T extends State> List<T> getList(String userId, String sessionId, String slotName, Class<T> type) {
List<String> jsons = redis.lrange(listKey(userId, sessionId, slotName), 0, -1);
List<T> result = new ArrayList<>();
for (String json : jsons) {
if (json == null || json.isBlank()) {
continue;
}
try {
result.add(JsonUtils.getJsonCodec().fromJson(json, type));
} catch (Exception e) {
log.warn("[RedisState] 列表项反序列化失败 {}/{}/{}: {}", userId, sessionId, slotName, e.getMessage());
}
}
return result;
}
/** 该 (userId, sessionId) 是否存过任何状态。 */
@Override
public boolean exists(String userId, String sessionId) {
String stateMatch = STATE_PREFIX + userId + ":" + sessionId + ":*";
String listMatch = LIST_PREFIX + userId + ":" + sessionId + ":*";
return !scanKeys(stateMatch).isEmpty() || !scanKeys(listMatch).isEmpty();
}
/** 删整个会话:扫两种前缀全删。 */
@Override
public void delete(String userId, String sessionId) {
String stateMatch = STATE_PREFIX + userId + ":" + sessionId + ":*";
String listMatch = LIST_PREFIX + userId + ":" + sessionId + ":*";
delAll(scanKeys(stateMatch));
delAll(scanKeys(listMatch));
}
/** 删单个槽位:删单条 key + 列表 key。 */
@Override
public void delete(String userId, String sessionId, String slotName) {
redis.del(stateKey(userId, sessionId, slotName));
redis.del(listKey(userId, sessionId, slotName));
}
/** 列出某用户的所有 sessionId:扫 as:state:{userId}:* 和 as:list:{userId}:* 取第三段。 */
@Override
public Set<String> listSessionIds(String userId) {
Set<String> ids = new TreeSet<>();
String stateMatch = STATE_PREFIX + userId + ":*";
String listMatch = LIST_PREFIX + userId + ":*";
for (String key : scanKeys(stateMatch)) {
ids.add(extractSessionId(key, STATE_PREFIX));
}
for (String key : scanKeys(listMatch)) {
ids.add(extractSessionId(key, LIST_PREFIX));
}
return ids;
}
/** as:state:{userId}:{sessionId}:{slotName} → 取 sessionId 段。 */
private static String extractSessionId(String key, String prefix) {
String rest = key.substring(prefix.length());
int first = rest.indexOf(':');
int second = rest.indexOf(':', first + 1);
return second < 0 ? rest.substring(first + 1) : rest.substring(first + 1, second);
}
private Set<String> scanKeys(String pattern) {
Set<String> keys = new HashSet<>();
ScanCursor cursor = ScanCursor.INITIAL;
do {
var scan = redis.scan(cursor, ScanArgs.Builder.matches(pattern).limit(200));
keys.addAll(scan.getKeys());
cursor = scan;
} while (!cursor.isFinished());
return keys;
}
private void delAll(Set<String> keys) {
if (keys.isEmpty()) {
return;
}
redis.del(keys.toArray(new String[0]));
}
}
src/main/java/cn/deepassistant/redis/RedisBaseStore.java
作用: 工作区文件存 Redis Hash,Lua 做版本 CAS
java
package cn.deepassistant.redis;
/**
* Redis 版 {@link BaseStore}:给官方 {@code RemoteFilesystem} 当 KV 后端。
*
* <p>多副本时工作区文件(MEMORY.md、memory/日流水、sessions/*.log.jsonl)不再落本地磁盘,
* 而是存进 Redis,所有副本共享同一份。
*
* <h3>Key 设计</h3>
* <pre>
* as:base:{namespaceJoined}/{fileKey} → Redis Hash { value: <JSON>, version: <long> }
* </pre>
* namespace 是 {@link io.agentscope.harness.agent.filesystem.remote.store.NamespaceFactory#getNamespace} 的返回,
* {@code IsolationScope.USER} 下就是 {@code ["alice"]},join 成 {@code alice}。
* fileKey 是工作区相对路径,如 {@code MEMORY.md}、{@code memory/2024-01-01.md}。
* 所以最终 key 形如 {@code as:base:alice/MEMORY.md}。
*
* <h3>version / 乐观锁</h3>
* 用 Hash 的 {@code version} 字段做 {@link #putIfVersion} 乐观锁,靠 Lua 脚本保证原子。
* {@link #put} 每次写都 {@code version+1}。
*
* <p>value 是 {@link StoreItem#value()}({@code Map<String, Object>})的 JSON,
* 官方 {@code RemoteFilesystem.fileDataToStoreValue} 负责在 FileData ↔ Map 之间转换,
* 本类只管忠实存取 Map,不关心里面是什么。
*/
@Slf4j
public class RedisBaseStore implements BaseStore {
private static final String PREFIX = "as:base:";
private final RedisCommands<String, String> redis;
public RedisBaseStore(RedisCommands<String, String> redis) {
this.redis = redis;
}
/** namespace 段用 / 连接,再拼上 fileKey,加全局前缀。 */
private static String redisKey(List<String> namespace, String key) {
String ns = String.join("/", namespace);
return PREFIX + ns + "/" + key;
}
/** search 用的前缀:as:base:{namespace}/。 */
private static String scanPrefix(List<String> namespace) {
return PREFIX + String.join("/", namespace) + "/";
}
@Override
public StoreItem get(List<String> namespace, String key) {
String rk = redisKey(namespace, key);
Map<String, String> fields = redis.hgetall(rk);
if (fields == null || fields.isEmpty()) {
return null;
}
String valueJson = fields.getOrDefault("value", "");
long version = parseLong(fields.get("version"), 0L);
Map<String, Object> value = JsonCodecHolder.fromJsonToMap(valueJson);
return new StoreItem(key, value, version);
}
@Override
public void put(List<String> namespace, String key, Map<String, Object> value) {
String rk = redisKey(namespace, key);
String valueJson = JsonCodecHolder.toJson(value);
// Lua:version 不存在则置 1,存在则 +1;然后写 value。保证原子。
String script =
"local v = redis.call('HINCRBY', KEYS[1], 'version', 1) " +
"redis.call('HSET', KEYS[1], 'value', ARGV[1]) " +
"return v";
redis.eval(script, ScriptOutputType.INTEGER, new String[]{rk}, valueJson);
}
@Override
public boolean putIfVersion(List<String> namespace, String key,
Map<String, Object> value, long expectedVersion) {
String rk = redisKey(namespace, key);
String valueJson = JsonCodecHolder.toJson(value);
// Lua:读当前 version,匹配 expectedVersion 才 version+1 并写 value,返回 1;否则返回 0。
String script =
"local cur = tonumber(redis.call('HGET', KEYS[1], 'version')) or 0 " +
"if cur == tonumber(ARGV[1]) then " +
" redis.call('HINCRBY', KEYS[1], 'version', 1) " +
" redis.call('HSET', KEYS[1], 'value', ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long r = redis.eval(script, ScriptOutputType.INTEGER, new String[]{rk},
String.valueOf(expectedVersion), valueJson);
return r != null && r == 1L;
}
@Override
public List<StoreItem> search(List<String> namespace, int limit, int offset) {
String prefix = scanPrefix(namespace);
List<String> keys = new ArrayList<>(scanKeys(prefix + "*"));
keys.sort(Comparator.naturalOrder());
int from = Math.min(offset, keys.size());
int to = Math.min(from + limit, keys.size());
List<String> page = keys.subList(from, to);
List<StoreItem> items = new ArrayList<>();
for (String rk : page) {
Map<String, String> fields = redis.hgetall(rk);
if (fields == null || fields.isEmpty()) {
continue;
}
String valueJson = fields.getOrDefault("value", "");
long version = parseLong(fields.get("version"), 0L);
Map<String, Object> value = JsonCodecHolder.fromJsonToMap(valueJson);
// itemKey = 去掉前缀 + namespace/,剩下的是 fileKey
String itemKey = stripPrefix(rk, prefix);
items.add(new StoreItem(itemKey, value, version));
}
return items;
}
@Override
public void delete(List<String> namespace, String key) {
redis.del(redisKey(namespace, key));
}
// ---- 工具 ----
private static String stripPrefix(String rk, String prefix) {
return rk.startsWith(prefix) ? rk.substring(prefix.length()) : rk;
}
private static long parseLong(String s, long def) {
if (s == null || s.isBlank()) {
return def;
}
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return def;
}
}
private Set<String> scanKeys(String pattern) {
Set<String> keys = new HashSet<>();
ScanCursor cursor = ScanCursor.INITIAL;
do {
var scan = redis.scan(cursor, ScanArgs.Builder.matches(pattern).limit(200));
keys.addAll(scan.getKeys());
cursor = scan;
} while (!cursor.isFinished());
return keys;
}
/** 延迟拿官方 JsonCodec,避免类加载早期 init 问题。 */
private static final class JsonCodecHolder {
static String toJson(Object o) {
return io.agentscope.core.util.JsonUtils.getJsonCodec().toJson(o);
}
@SuppressWarnings("unchecked")
static Map<String, Object> fromJsonToMap(String json) {
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return io.agentscope.core.util.JsonUtils.getJsonCodec()
.fromJson(json, new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
log.warn("[RedisBaseStore] value 反序列化失败: {}", e.getMessage());
return Map.of();
}
}
}
}
四、记忆、会话与多用户隔离
src/main/java/cn/deepassistant/memory/package-info.java
作用: 三层记忆心智模型:不要再自己写抽取器去覆盖 MEMORY.md
java
/**
* 记忆分三层,对应 AgentScope Java 2.0 官方文档,而不是原 LangGraph 项目自己的 MemoryStore。
*
* <pre>
* ① 短期:当前会话 messages(AgentState)+ 本项目 SessionStore(给网页侧栏用)
* ② 中期日流水:workspace/memory/YYYY-MM-DD.md
* 由官方 MemoryFlushMiddleware 在每次 call 结束后后台抽取(只追加、不去重)
* ③ 长期策划:workspace/MEMORY.md
* 由官方 MemoryConsolidator 周期性合并日流水、去重后整体重写
* 每一轮推理都会注入 system prompt
*
* 压缩 Compaction:上下文太长时摘要前缀、保留尾部;压缩前会再 flush 一次
* 原文卸载:压缩掉的消息写入 sessions/*.log.jsonl,官方 session_search 能搜到
* </pre>
*
* 本包里的 Java 类只做两件事:<b>读</b>官方已经写好的文件,以及检索我们自己的聊天 JSON。
* 不要再另写一套抽取器去覆盖 MEMORY.md,否则会和 MemoryConsolidator 抢文件。
*/
package cn.deepassistant.memory;
src/main/java/cn/deepassistant/memory/HarnessMemoryCatalog.java
作用: 只读官方 WorkspaceManager;用 ObjectProvider 解开循环依赖
java
package cn.deepassistant.memory;
/**
* 只读目录:把官方 Harness 已经写到磁盘上的记忆文件读出来给前端 / 工具用。
*
* <h3>多用户隔离</h3>
* HarnessAgent 配了 {@code IsolationScope.USER},框架的 {@link WorkspaceManager} 会按
* {@code RuntimeContext.userId} 把 {@code memory/} 和 {@code MEMORY.md} 路由到
* 该用户的命名空间下。本类不自己拼路径,而是调
* {@link WorkspaceManager#readMemoryMd(RuntimeContext)} /
* {@link WorkspaceManager#listMemoryFilePaths(RuntimeContext)} /
* {@link WorkspaceManager#readManagedWorkspaceFileUtf8(RuntimeContext, String)},
* 保证和框架写入的路径完全一致------Local / Remote 文件系统都兼容。
*
* <h3>为什么用 {@link ObjectProvider} 而不是直接注入 {@link HarnessAgent}</h3>
* 存在循环依赖:{@code harnessAgent (bean) → historyMemoryTools → harnessMemoryCatalog → harnessAgent}。
* 直接构造注入会在 Spring 启动时死锁。{@code ObjectProvider<HarnessAgent>} 注入的是「延迟解析器」,
* 真正调 {@code getObject()} 时(HTTP 请求进来时)harnessAgent 早已建好,环就断了。
* 不能用 {@code @Lazy},因为 {@link HarnessAgent} 没有可见构造器,CGLIB 代理不了。
*
* <p>写文件的是框架,不是这个类:
* <ul>
* <li>{@code MemoryFlushManager} → {@code memory/YYYY-MM-DD.md}</li>
* <li>{@code MemoryConsolidator} → {@code MEMORY.md}</li>
* </ul>
*/
@Slf4j
@Component
public class HarnessMemoryCatalog {
private final ObjectProvider<HarnessAgent> harnessAgentProvider;
private volatile WorkspaceManager workspaceManager;
public HarnessMemoryCatalog(ObjectProvider<HarnessAgent> harnessAgentProvider) {
this.harnessAgentProvider = harnessAgentProvider;
}
/** 首次调用时从 harnessAgent 拿 WorkspaceManager,之后缓存。 */
private WorkspaceManager workspaceManager() {
WorkspaceManager wm = workspaceManager;
if (wm == null) {
synchronized (this) {
wm = workspaceManager;
if (wm == null) {
wm = harnessAgentProvider.getObject().getWorkspaceManager();
workspaceManager = wm;
log.info("[HarnessMemory] 拿到官方 WorkspaceManager workspace={}", wm.getWorkspace());
}
}
}
return wm;
}
/** 读指定用户的 MEMORY.md。 */
public String readMemoryMarkdown(String userId) {
String uid = UserIds.normalize(userId);
return workspaceManager().readMemoryMd(runtimeContext(uid));
}
/** 列指定用户的日流水文件。用官方 WorkspaceManager 列路径,再逐个读内容------Local/Remote 文件系统都兼容。 */
public List<DailyMemoryFile> listDailyLedgers(String userId) {
String uid = UserIds.normalize(userId);
RuntimeContext ctx = runtimeContext(uid);
List<String> relPaths = workspaceManager().listMemoryFilePaths(ctx);
List<DailyMemoryFile> files = new ArrayList<>();
for (String relPath : relPaths) {
// listMemoryFilePaths 会把 MEMORY.md 也列进来,只要 memory/ 下的日流水
if (relPath == null || relPath.isBlank()) {
continue;
}
String p = relPath.startsWith("/") ? relPath.substring(1) : relPath;
if (!p.startsWith("memory/") || !p.endsWith(".md")) {
continue;
}
String name = p.substring("memory/".length());
if (name.startsWith(".")) {
continue;
}
String content = workspaceManager().readManagedWorkspaceFileUtf8(ctx, p);
files.add(DailyMemoryFile.builder()
.path("memory/" + name)
.lastModified(Instant.now())
.content(content == null ? "" : content)
.build());
}
files.sort(Comparator.comparing(DailyMemoryFile::getPath, Comparator.reverseOrder()));
return files;
}
/**
* 把指定用户 MEMORY.md 里的 {@code - 条目} 拆成列表,给档案页用。
* 分类只能从当前小节标题猜,猜不到就叫 {@code fact}。
*/
public List<MemoryFact> parseFactsFromMemoryMd(String userId) {
return parseFacts(readMemoryMarkdown(userId));
}
/** 纯函数,方便单测:不碰磁盘。 */
static List<MemoryFact> parseFacts(String md) {
List<MemoryFact> facts = new ArrayList<>();
if (md == null || md.isBlank()) {
return facts;
}
String category = "fact";
for (String rawLine : md.split("\n")) {
String line = rawLine.trim();
if (line.startsWith("## ")) {
category = guessCategory(line.substring(3));
continue;
}
if (!line.startsWith("- ")) {
continue;
}
String content = line.substring(2).trim();
if (content.isBlank() || content.startsWith("(")) {
continue;
}
facts.add(MemoryFact.builder()
.id(UUID.randomUUID().toString())
.category(category)
.content(content)
.confidence(1.0)
.build());
}
return facts;
}
private static String guessCategory(String heading) {
String h = heading.toLowerCase(Locale.ROOT);
if (h.contains("偏好") || h.contains("preference")) {
return "preference";
}
if (h.contains("习惯") || h.contains("style")) {
return "working_style";
}
if (h.contains("用户") || h.contains("identity") || h.contains("关于")) {
return "identity";
}
if (h.contains("项目") || h.contains("project") || h.contains("技术")) {
return "project";
}
if (h.contains("约束") || h.contains("constraint")) {
return "constraint";
}
return "fact";
}
private static RuntimeContext runtimeContext(String userId) {
return RuntimeContext.builder()
.userId(userId)
.build();
}
}
src/main/java/cn/deepassistant/memory/SessionStore.java
作用: 网页侧栏那份聊天 JSON,按 userId 分目录
java
package cn.deepassistant.memory;
/**
* 会话层存储:每一次聊天的「全文档案」,按用户隔离。
*
* <h3>磁盘布局</h3>
* <pre>
* data/sessions/
* ├── alice/
* │ ├── sessions-index.json
* │ ├── {sessionId-A}.json
* │ └── {sessionId-B}.json
* └── bob/
* ├── sessions-index.json
* └── {sessionId-C}.json
* </pre>
*
* <p>每个用户一个子目录,互不可见。{@code userId} 会直接变成目录名,
* 所以走 {@link UserIds#requireValid} 挡住路径穿越。
*
* <p>长期偏好不放这里,见工作区 {@code MEMORY.md}(官方 Consolidation 产物,
* 也按用户隔离------见 {@link HarnessMemoryCatalog})。
*
* <p>本类还承担「历史检索」:把指定用户所有会话的消息扫一遍,找出包含关键词的句子。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionStore {
@Value("${agent.sessions.dir}")
private String sessionsDir;
private final ObjectMapper mapper;
private Path rootDir;
private final Map<String, SessionDetail> cache = new ConcurrentHashMap<>();
private final Map<String, Object> fileLocks = new ConcurrentHashMap<>();
/** cache / lock 的 key:userId + "/" + sessionId,避免不同用户撞车。 */
private static String key(String userId, String sessionId) {
return userId + "/" + sessionId;
}
/** 某个用户的会话根目录:data/sessions/{userId}/ */
private Path userDir(String userId) {
Path resolved = rootDir.resolve(userId).normalize();
if (!resolved.startsWith(rootDir)) {
throw new SecurityException("禁止访问 sessions 目录之外的路径: " + userId);
}
return resolved;
}
private Path sessionFile(String userId, String sessionId) {
return userDir(userId).resolve(sessionId + ".json");
}
private Path indexPath(String userId) {
return userDir(userId).resolve("sessions-index.json");
}
private Object lockFor(String userId, String sessionId) {
return fileLocks.computeIfAbsent(key(userId, sessionId), k -> new Object());
}
@PostConstruct
public void init() throws IOException {
rootDir = Path.of(sessionsDir).toAbsolutePath().normalize();
Files.createDirectories(rootDir);
log.info("[Session] 会话根目录: {}", rootDir);
}
/** 确保用户子目录存在,返回该目录。 */
private Path ensureUserDir(String userId) {
Path dir = userDir(userId);
try {
Files.createDirectories(dir);
} catch (IOException e) {
throw new RuntimeException("创建用户会话目录失败: " + userId, e);
}
return dir;
}
/**
* 列出某个用户的所有会话。别的用户的会话不会出现。
*/
public List<SessionSummary> listSessions(String userId) {
String uid = UserIds.normalize(userId);
Path index = indexPath(uid);
if (!Files.exists(index)) {
return List.of();
}
synchronized (indexLockFor(uid)) {
return readIndex(index).stream()
.sorted(Comparator.comparing(SessionSummary::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.collect(Collectors.toList());
}
}
public SessionDetail getOrCreate(String userId, String sessionId, String firstUserMessage) {
String uid = UserIds.normalize(userId);
String id = SessionIds.normalizeOrCreate(sessionId);
synchronized (lockFor(uid, id)) {
ensureUserDir(uid);
SessionDetail detail = load(uid, id);
if (detail == null) {
Instant now = Instant.now();
String title = buildTitle(firstUserMessage);
detail = SessionDetail.builder()
.id(id)
.title(title)
.createdAt(now)
.updatedAt(now)
.messages(new ArrayList<>())
.build();
cache.put(key(uid, id), detail);
persist(uid, detail);
upsertIndex(uid, SessionSummary.builder()
.id(id)
.title(title)
.preview(previewOf(firstUserMessage))
.updatedAt(now)
.messageCount(0)
.build());
}
return detail;
}
}
public SessionDetail get(String userId, String sessionId) {
String uid = UserIds.normalize(userId);
String id = SessionIds.requireValid(sessionId);
synchronized (lockFor(uid, id)) {
return load(uid, id);
}
}
public void appendMessage(String userId, String sessionId, ChatMessageRecord record) {
String uid = UserIds.normalize(userId);
String id = SessionIds.requireValid(sessionId);
synchronized (lockFor(uid, id)) {
SessionDetail detail = load(uid, id);
if (detail == null) {
detail = getOrCreate(uid, id, record.getContent());
}
detail.getMessages().add(record);
detail.setUpdatedAt(Instant.now());
if ("user".equals(record.getRole())
&& detail.getMessages().stream().filter(m -> "user".equals(m.getRole())).count() == 1) {
detail.setTitle(buildTitle(record.getContent()));
}
persist(uid, detail);
upsertIndex(uid, SessionSummary.builder()
.id(detail.getId())
.title(detail.getTitle())
.preview(previewOf(record.getContent()))
.updatedAt(detail.getUpdatedAt())
.messageCount(detail.getMessages().size())
.build());
}
}
/**
* 跨会话关键词检索,只搜指定用户的会话。
*/
public List<HistorySearchHit> search(String userId, String query, int limit) {
String uid = UserIds.normalize(userId);
String needle = query == null ? "" : query.trim();
if (needle.isBlank()) {
return List.of();
}
String lower = needle.toLowerCase();
int cap = Math.min(Math.max(limit, 1), 50);
List<HistorySearchHit> hits = new ArrayList<>();
for (SessionSummary summary : listSessions(uid)) {
SessionDetail detail = get(uid, summary.getId());
if (detail == null || detail.getMessages() == null) {
continue;
}
for (ChatMessageRecord message : detail.getMessages()) {
String content = message.getContent();
if (content == null) {
continue;
}
int matches = countMatches(content.toLowerCase(), lower);
if (matches == 0) {
continue;
}
hits.add(HistorySearchHit.builder()
.sessionId(detail.getId())
.sessionTitle(detail.getTitle())
.role(message.getRole())
.timestamp(message.getTimestamp())
.snippet(snippetAround(content, needle))
.matchCount(matches)
.build());
}
}
hits.sort(Comparator
.comparingInt(HistorySearchHit::getMatchCount).reversed()
.thenComparing(HistorySearchHit::getTimestamp, Comparator.nullsLast(Comparator.reverseOrder())));
if (hits.size() > cap) {
return new ArrayList<>(hits.subList(0, cap));
}
return hits;
}
/**
* 从会话索引 + 消息条数现算使用概况,只统计指定用户。
*/
public UsageStats usageStats(String userId) {
String uid = UserIds.normalize(userId);
List<SessionSummary> sessions = listSessions(uid);
int messages = 0;
int userMessages = 0;
Instant first = null;
Instant last = null;
for (SessionSummary summary : sessions) {
SessionDetail detail = get(uid, summary.getId());
if (detail == null) {
continue;
}
if (detail.getCreatedAt() != null && (first == null || detail.getCreatedAt().isBefore(first))) {
first = detail.getCreatedAt();
}
if (detail.getUpdatedAt() != null && (last == null || detail.getUpdatedAt().isAfter(last))) {
last = detail.getUpdatedAt();
}
if (detail.getMessages() == null) {
continue;
}
messages += detail.getMessages().size();
for (ChatMessageRecord message : detail.getMessages()) {
if ("user".equals(message.getRole())) {
userMessages++;
}
}
}
List<SessionSummary> recent = sessions.stream().limit(8).collect(Collectors.toList());
return UsageStats.builder()
.sessionCount(sessions.size())
.messageCount(messages)
.userMessageCount(userMessages)
.firstSeenAt(first)
.lastSeenAt(last)
.recentSessions(recent)
.build();
}
public boolean delete(String userId, String sessionId) {
String uid = UserIds.normalize(userId);
String id = SessionIds.requireValid(sessionId);
synchronized (lockFor(uid, id)) {
cache.remove(key(uid, id));
try {
Files.deleteIfExists(sessionFile(uid, id));
} catch (IOException e) {
log.warn("删除会话文件失败: {}", e.getMessage());
}
boolean removed;
synchronized (indexLockFor(uid)) {
List<SessionSummary> index = readIndex(indexPath(uid));
removed = index.removeIf(s -> Objects.equals(s.getId(), id));
writeIndex(indexPath(uid), index);
}
fileLocks.remove(key(uid, id));
return removed;
}
}
// ---- 内部方法 ----
private SessionDetail load(String userId, String sessionId) {
String cacheKey = key(userId, sessionId);
SessionDetail cached = cache.get(cacheKey);
if (cached != null) {
return cached;
}
Path file = sessionFile(userId, sessionId);
if (!Files.exists(file)) {
return null;
}
try {
SessionDetail detail = mapper.readValue(file.toFile(), SessionDetail.class);
cache.put(cacheKey, detail);
return detail;
} catch (IOException e) {
log.warn("读取会话失败 {}/{}: {}", userId, sessionId, e.getMessage());
return null;
}
}
private void persist(String userId, SessionDetail detail) {
try {
mapper.writerWithDefaultPrettyPrinter()
.writeValue(sessionFile(userId, detail.getId()).toFile(), detail);
} catch (IOException e) {
log.warn("写入会话失败: {}", e.getMessage());
}
}
private List<SessionSummary> readIndex(Path index) {
try {
return mapper.readValue(index.toFile(), new TypeReference<>() {
});
} catch (IOException e) {
return new ArrayList<>();
}
}
private void writeIndex(Path index, List<SessionSummary> data) {
try {
mapper.writerWithDefaultPrettyPrinter().writeValue(index.toFile(), data);
} catch (IOException e) {
log.warn("写入会话索引失败: {}", e.getMessage());
}
}
private void upsertIndex(String userId, SessionSummary summary) {
Path index = indexPath(userId);
ensureUserDir(userId);
synchronized (indexLockFor(userId)) {
List<SessionSummary> data = readIndex(index);
data.removeIf(s -> Objects.equals(s.getId(), summary.getId()));
data.add(summary);
writeIndex(index, data);
}
}
/** 每个用户一把索引锁,避免不同用户互相阻塞。 */
private final Map<String, Object> indexLocks = new ConcurrentHashMap<>();
private Object indexLockFor(String userId) {
return indexLocks.computeIfAbsent(userId, k -> new Object());
}
private static String buildTitle(String message) {
if (message == null || message.isBlank()) {
return "新对话";
}
String t = message.replace('\n', ' ').trim();
return t.length() > 24 ? t.substring(0, 24) + "..." : t;
}
private static String previewOf(String content) {
if (content == null) {
return "";
}
String t = content.replace('\n', ' ').trim();
return t.length() > 60 ? t.substring(0, 60) + "..." : t;
}
/** 统计 needle 在 haystack 里出现几次。haystack 调用方已转成小写。 */
static int countMatches(String haystackLower, String needleLower) {
if (haystackLower == null || needleLower == null || needleLower.isEmpty()) {
return 0;
}
int count = 0;
int from = 0;
while (true) {
int at = haystackLower.indexOf(needleLower, from);
if (at < 0) {
return count;
}
count++;
from = at + needleLower.length();
}
}
/** 截一段含关键词的上下文,避免把整段长回复塞进搜索结果。 */
static String snippetAround(String content, String needle) {
String flat = content.replace('\n', ' ').trim();
int at = flat.toLowerCase().indexOf(needle.toLowerCase());
if (at < 0) {
return flat.length() > 120 ? flat.substring(0, 120) + "..." : flat;
}
int start = Math.max(0, at - 40);
int end = Math.min(flat.length(), at + needle.length() + 80);
String snippet = flat.substring(start, end);
if (start > 0) {
snippet = "..." + snippet;
}
if (end < flat.length()) {
snippet = snippet + "...";
}
return snippet;
}
}
src/main/java/cn/deepassistant/service/UserMemoryQueryService.java
作用: 给前端档案页:usage + MEMORY.md + 日流水
java
package cn.deepassistant.service;
/**
* HTTP 门面:历史检索走 SessionStore,档案页读官方 MEMORY.md / 日流水。
* 所有方法都按 userId 隔离,不同用户互不可见。
*/
@Service
public class UserMemoryQueryService {
private final SessionStore sessionStore;
private final HarnessMemoryCatalog memoryCatalog;
public UserMemoryQueryService(SessionStore sessionStore, HarnessMemoryCatalog memoryCatalog) {
this.sessionStore = sessionStore;
this.memoryCatalog = memoryCatalog;
}
public HistorySearchResponse searchHistory(String userId, String query, int limit) {
String uid = UserIds.normalize(userId);
String q = query == null ? "" : query.trim();
List<HistorySearchHit> hits = sessionStore.search(uid, q, limit);
return HistorySearchResponse.builder()
.query(q)
.hits(hits)
.hitCount(hits.size())
.build();
}
public UserMemoryProfile profile(String userId) {
String uid = UserIds.normalize(userId);
return UserMemoryProfile.builder()
.usage(sessionStore.usageStats(uid))
.memoryMarkdown(memoryCatalog.readMemoryMarkdown(uid))
.dailyLedgers(memoryCatalog.listDailyLedgers(uid))
.facts(memoryCatalog.parseFactsFromMemoryMd(uid))
.build();
}
}
src/main/java/cn/deepassistant/util/UserIds.java
作用: userId 校验;空白回退 local;挡住路径穿越
java
package cn.deepassistant.util;
/**
* userId 校验,和 {@link SessionIds} 用同一套安全规则:
* 只允许 {@code [a-zA-Z0-9][a-zA-Z0-9_-]{0,127}},禁止路径穿越。
*
* <p>多用户隔离靠 (userId, sessionId) 二元组寻址,userId 会直接变成磁盘目录名,
* 所以必须挡住 {@code ..} / {@code /} / {@code \\} / {@code \0}。
*/
public final class UserIds {
private static final Pattern SAFE =
Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$");
/** 单机个人助手没传 userId 时的默认值,保持向后兼容。 */
public static final String DEFAULT_USER_ID = "local";
private UserIds() {
}
/**
* 校验 userId,空白时回退到 {@link #DEFAULT_USER_ID}。
* 这样前端不传 userId 也能跑,等价于原来的单用户模式。
*/
public static String normalize(String userId) {
if (userId == null || userId.isBlank()) {
return DEFAULT_USER_ID;
}
return requireValid(userId);
}
public static String requireValid(String userId) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId 为空");
}
String id = userId.trim();
if (id.contains("..") || id.contains("/") || id.contains("\\") || id.indexOf('\0') >= 0) {
throw new IllegalArgumentException("非法 userId");
}
if (!SAFE.matcher(id).matches()) {
throw new IllegalArgumentException("非法 userId 格式");
}
return id;
}
}
src/main/java/cn/deepassistant/util/SessionIds.java
作用: sessionId 校验 / 空白则新建 UUID
java
package cn.deepassistant.util;
public final class SessionIds {
private static final Pattern SAFE =
Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$");
private SessionIds() {
}
public static String newId() {
return UUID.randomUUID().toString();
}
public static String requireValid(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("sessionId 为空");
}
String id = sessionId.trim();
if (id.contains("..") || id.contains("/") || id.contains("\\") || id.indexOf('\0') >= 0) {
throw new IllegalArgumentException("非法 sessionId");
}
if (!SAFE.matcher(id).matches()) {
throw new IllegalArgumentException("非法 sessionId 格式");
}
return id;
}
public static String normalizeOrCreate(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
return newId();
}
return requireValid(sessionId);
}
}
下篇: 小深-AgentScope-Java-2.0-Harness架构博客-02.md --- 工具、MCP/REST 联网、对话 SSE、前端与踩坑笔记。