agentscope-ai / agentscope-ai/agentscope-java
[Bug]:SessionTree.flush + offloadToSessionTree 导致 session JSONL 文件内容重复膨胀
- 主要語言
- Java
- 星號
- 5.6k
- 分支
- 1.3k
- 平均合併
- 4 天 12 小時
- 30 天內合併 PR
- 77
描述
## 版本
- agentscope-harness: 2.0.0-RC3
- JDK: 21
## 问题描述
```java
@Slf4j
@Component
@RequiredArgsConstructor
public class DBAgentStateStore implements AgentStateStore {
// ...
}
@Slf4j
@Component
@RequiredArgsConstructor
public class DBFileSystemStore implements BaseStore {
// ...
}
HarnessAgent.Builder builder = HarnessAgent.builder()
.agentId(agentDef.getName())
.name(agentDef.getName())
.description(description)
.workspace(Paths.get(".agentscope/workspace"))
.sysPrompt(agentDef.getSystemPrompt())
.model(model)
.toolkit(toolkit)
.middlewares(List.of(
new TimestampMiddleware(),
new EnvironmentMiddleware()
))
.compaction(CompactionConfig.builder()
.triggerMessages(30)
.keepMessages(10)
.build())
.distributedStore(DistributedStore.builder()
.agentStateStore(dbAgentStateStore)
.baseStore(dbFileSystemStore)
.build())
.filesystem(new RemoteFilesystemSpec()
.isolationScope(IsolationScope.USER)
)
.maxIters(agentDef.getMaxIterations());
```
HarnessAgent创建代码如上。
`MemoryFlushManager.offloadToSessionTree()` 在每次请求结束时,将 **全部上下文消息**(包括已 offload 过的历史消息)转换为 `SessionEntry` 并通过 `SessionTree.flush()` → `appendToFile()` 追加到 session JSONL 文件中。
由于每次创建 `MessageEntry` 时 `id` 传入 `null`,`SessionEntry` 构造函数通过 `UUID.randomUUID()` 生成全新 ID,导致:
1. `SessionTree.append()` 无法通过 `entriesById` 识别重复条目
2. `syncFromRemote()` 的按 ID 去重机制完全失效
3. `flush()` 中 `appendToFile()` 将全量上下文(含重复的历史消息)追加到文件末尾
最终表现:session JSONL 文件(`contextFile` 和 `logFile`)及其 DB 镜像随请求次数线性膨胀。
## 复现步骤
1. 创建一个 HarnessAgent,配置 `DistributedStore`(含 `BaseStore` 实现)和 `RemoteFilesystemSpec`
2. 同一个 session 连续发送 2 次消息
3. 检查 workspace 目录下的 `agents//sessions/.jsonl`
## Debug 实证
以 2 次请求的真实 debug 数据为例。
### 第 1 次请求
用户发送"你叫什么?",Agent 回复。`flush()` 正常写入 2 条到本地和 DB:
```
contextFile (4001.jsonl) = 2 条
logFile (4001.log.jsonl) = 2 条
DB 镜像 = 2 条
```
### 第 2 次请求
用户发送"你有什么技能?"。在 `SessionTree.flush()` 断点处观察到以下状态:
**local(contextFile 已有内容,`syncFromRemote` 覆写后):**
```json
[
{"id":"7543564c-...", "role":"USER", "content":"你叫什么?", "timestamp":"2026-06-15T11:12:00.580109283Z"},
{"id":"216c9ac6-...", "role":"ASSISTANT", "content":"我是一个帮助用户做笔记的助手...", "timestamp":"2026-06-15T11:12:00.580943827Z"}
]
```
**remote(DB 镜像内容):** 与 local 完全一致。
**toWrite(pendingWrites,即将被 appendToFile 追加的内容):**
```json
[
{"id":"8e0f3672-...", "role":"USER", "content":"你叫什么?", "timestamp":"2026-06-15T11:16:39.254901611Z"},
{"id":"dcfbbd80-...", "role":"ASSISTANT", "content":"我是一个帮助用户做笔记的助手...", "timestamp":"2026-06-15T11:16:39.255001261Z"},
{"id":"a481916c-...", "role":"USER", "content":"你有什么技能?", "timestamp":"2026-06-15T11:16:39.255378798Z"},
{"id":"8762de4b-...", "role":"ASSISTANT", "content":"作为你的笔记助手...", "timestamp":"2026-06-15T11:16:39.255599853Z"}
]
```
可以看到:
- toWrite 前 2 条(`8e0f3672`、`dcfbbd80`)与 local 已有的 2 条(`7543564c`、`216c9ac6`)**内容完全相同**,但 **ID 和 timestamp 不同**
- toWrite 后 2 条(`a481916c`、`8762de4b`)才是本轮真正的新消息
**flush() 执行后的文件内容:**
```
$ cat 4001.jsonl
{"id":"7543564c-...", "content":"你叫什么?", ...} ← 原有(syncFromRemote 保留)
{"id":"216c9ac6-...", "content":"我是一个帮助...", ...} ← 原有
{"id":"8e0f3672-...", "content":"你叫什么?", ...} ← appendToFile 追加(重复!)
{"id":"dcfbbd80-...", "content":"我是一个帮助...", ...} ← appendToFile 追加(重复!)
{"id":"a481916c-...", "content":"你有什么技能?", ...} ← appendToFile 追加(新消息)
{"id":"8762de4b-...", "content":"作为你的笔记助手...", ...} ← appendToFile 追加(新消息)
```
6 条记录中,前 2 条和第 3-4 条内容完全重复,只是 ID 和 timestamp 不同。`logFile` 和 DB 镜像同样存在相同的重复。
### 持续膨胀
如果继续发送请求,每次 offload 都会追加全量上下文副本:
| 请求序号 | 上下文消息数 | 本次追加条目数 | 文件总条目数 |
|---------|-----------|-------------|------------|
| 1 | 2 | 2 | 2 |
| 2 | 4 | 4 | 6 |
| 3 | 6 | 6 | 12 |
| 4 | 8 | 8 | 20 |
| 5 | 10 | 10 | 30 |
## 根因分析
问题涉及 3 个关联点:
### 1. `MemoryFlushManager.offloadToSessionTree()` — 遍历全量上下文 + id 传 null
```java
private void offloadToSessionTree(RuntimeContext rc, List messages,
String agentId, String sessionId) {
SessionTree tree = new SessionTree(contextFile, workspace, filesystem, index, relativePath);
tree.setRuntimeContext(rc);
tree.load(); // 从本地(或 DB 恢复)加载已有条目到 entriesById
tree.syncFromRemote(); // 与 DB 合并去重,overwriteFile 覆写 contextFile
String parentId = null;
for (Msg msg : messages) { // ← 问题①:遍历 ALL 上下文消息,包括历史消息
if (msg.getRole() == null || isSessionContextMessage(msg)) continue;
String content = renderContentBlocks(msg);
if (content == null || content.isBlank()) continue;
MessageEntry entry = new MessageEntry(
null, // ← 问题②:id=null → UUID.randomUUID()
parentId,
null, // ← timestamp=null → Instant.now()
msg.getRole().name(), content, extractToolCallId(msg)
);
tree.append(entry); // ← 新 UUID 不在 entriesById 中,视为新条目
parentId = entry.getId();
}
tree.flush(); // 全量追加到文件 + 镜像到 DB
}
```
`SessionEntry` 构造函数中 null 的处理逻辑:
```java
protected SessionEntry(String id, String parentId, Instant timestamp) {
this.id = (id != null) ? id : UUID.randomUUID().toString(); // null → 随机 UUID
this.timestamp = (timestamp != null) ? timestamp : Instant.now(); // null → 当前时间
}
```
### 2. `SessionTree.append()` — 无去重检查
```java
public SessionEntry append(SessionEntry entry) {
entriesById.put(entry.getId(), entry); // 新 UUID,不和已有条目冲突
appendOrder.add(entry);
pendingWrites.add(entry); // ← 无条件加入,不检查是否已存在
// ... compaction 处理
return entry;
}
```
### 3. `SessionTree.flush()` — 使用 appendToFile 追加
```java
public void flush() {
if (pendingWrites.isEmpty()) return;
flushed = true;
List toWrite = new ArrayList<>(pendingWrites);
pendingWrites.clear();
appendToFile(contextFile, toWrite); // ← APPEND 模式追加到已有文件末尾
appendToFile(logFile, toWrite); // ← 同上
scheduleMirror(); // 异步镜像到 DB
}
```
`appendToFile` 使用 `StandardOpenOption.CREATE + APPEND`,在已有文件内容后面追加,不检查已有内容:
```java
private void appendToFile(Path file, List entries) {
try (BufferedWriter writer = Files.newBufferedWriter(file, UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
for (SessionEntry entry : entries) {
writer.write(JsonUtils.getJsonCodec().toJson(entry));
writer.newLine();
}
}
}
```
### 完整调用链
```
MemoryFlushMiddleware.doFlush()
→ MemoryFlushManager.offloadMessages()
→ offloadToSessionTree(rc, messages, agentId, sessionId)
→ SessionTree.load() // 加载已有条目到 entriesById
→ SessionTree.syncFromRemote() // 与 DB 合并,overwriteFile 覆写 contextFile
→ for each msg in ALL context messages:
new MessageEntry(null, ...) // id=null → 新 UUID
SessionTree.append(entry) // 新 UUID 不在 entriesById → 加入 pendingWrites
→ SessionTree.flush()
→ appendToFile(contextFile, pendingWrites) // 追加到已有文件末尾 → 重复
→ appendToFile(logFile, pendingWrites) // 同上
→ scheduleMirror() // 膨胀后的文件镜像到 DB
```
## 影响
- **存储膨胀**:本地 JSONL 文件和 DB 镜像随请求次数线性增长(O(N²),N 为请求次数)
- **load/sync 性能下降**:文件越大,后续 `SessionTree.load()` 和 `syncFromRemote()` 的解析和合并耗时越长
- **不影响对话功能**:LLM 的活跃上下文由 `AgentState`(通过 `AgentStateStore`)管理,不依赖 SessionTree 文件
## 建议修复方案
两处改动配合使用:
### 改动 1:`MemoryFlushManager.offloadToSessionTree()` — 使用消息原始 ID 和时间戳
```java
// 改前
MessageEntry entry = new MessageEntry(
null, // → UUID.randomUUID()
parentId,
null, // → Instant.now()
msg.getRole().name(), content, toolCallId
);
// 改后
MessageEntry entry = new MessageEntry(
msg.getId(), // 保持消息原始 ID,使 syncFromRemote 的去重生效
parentId,
msg.getTimestamp(), // 保持消息原始时间戳
msg.getRole().name(), content, toolCallId
);
```
### 改动 2:`SessionTree.append()` — 增加去重检查(兜底防御)
```java
// 改前
public SessionEntry append(SessionEntry entry) {
entriesById.put(entry.getId(), entry);
appendOrder.add(entry);
pendingWrites.add(entry);
// ...
return entry;
}
// 改后
public SessionEntry append(SessionEntry entry) {
if (entriesById.containsKey(entry.getId())) {
return entriesById.get(entry.getId()); // 已存在则跳过
}
entriesById.put(entry.getId(), entry);
appendOrder.add(entry);
pendingWrites.add(entry);
// ...
return entry;
}
```
改动 1 让同一条消息每次生成的 SessionEntry ID 保持一致,`syncFromRemote()` 的按 ID 去重机制恢复正常。改动 2 在 `append()` 层做兜底,即使上游传入重复 ID 也不会重复写入 `pendingWrites`。
### 改动 3(可选加固):`SessionTree.flush()` — contextFile 改为覆写模式
改动 1 + 2 已经能解决问题,但 `flush()` 对 contextFile 使用 `appendToFile`(APPEND 模式)本身存在脆弱性——一旦上游有任何遗漏导致重复条目进入 `pendingWrites`,追加就会产生累积膨胀。
建议将 contextFile 的写入方式从追加改为覆写,使其始终是当前完整状态的快照,从架构上杜绝累积膨胀的可能:
```java
// 改前
public void flush() {
if (pendingWrites.isEmpty()) return;
flushed = true;
List toWrite = new ArrayList<>(pendingWrites);
pendingWrites.clear();
appendToFile(contextFile, toWrite); // APPEND 追加
appendToFile(logFile, toWrite);
scheduleMirror();
}
// 改后
public void flush() {
if (pendingWrites.isEmpty()) return;
flushed = true;
List toWrite = new ArrayList<>(pendingWrites);
pendingWrites.clear();
overwriteFile(contextFile, new ArrayList<>(appendOrder)); // 覆写完整状态快照
appendToFile(logFile, toWrite); // logFile 保持追加(审计日志性质)
scheduleMirror();
}
```
**为什么 contextFile 适合覆写:**
- contextFile 的用途是持久化当前会话状态,`load()` 加载时会全量读取并重建 `entriesById` 和 `appendOrder`,是一个**状态快照**
- `overwriteFile` 使用 `TRUNCATE_EXISTING + WRITE`,每次写入完整的 `appendOrder`,天然不会跨请求累积
- 即使未来 `append()` 的去重逻辑被绕过,覆写模式也能保证文件内容等于当前内存状态,不会无限膨胀
**为什么 logFile 保持追加:**
- logFile 是审计日志性质(`*.log.jsonl`),设计意图就是 append-only 的事件流
- 追加模式对 logFile 是正确的语义
**注意:改动 3 不能替代改动 1 + 2。** 如果不修复 ID 生成和去重,`appendOrder` 在内存中仍然会包含重复条目,`overwriteFile` 写出来的文件一样有重复内容。三个改动的关系是:
| 改动 | 作用 | 能否独立解决问题 |
|---|---|---|
| 改动 1:`msg.getId()` | 让 SessionEntry ID 与消息 ID 对齐,使去重成为可能 | 不能,`append()` 仍无条件加入 `pendingWrites` |
| 改动 2:`append()` 去重 | 利用稳定 ID 跳过已加载的历史条目 | 不能,ID 随机则永远查不到 |
| 改动 3:`flush()` 覆写 | 防止文件跨请求累积膨胀 | 不能,`appendOrder` 内存中仍有重复 |
| **改动 1 + 2** | **完整修复** | **是** |
| **改动 1 + 2 + 3** | **完整修复 + 架构加固** | **是,且更健壮** |
貢獻指南
評估
這個 Issue 還沒有評估資料。